Skip to content

Script plugins

A script plugin lets your app teach libspiffy about locking scripts it does not know, such as a token protocol’s, without libspiffy depending on that protocol’s library. Once registered, the wallet recognises those outputs, tags them with your metadata, and can build payments that create or spend them.

There are two interfaces, both exported from package:libspiffy/libspiffy.dart:

  • ScriptPlugin works one output at a time: recognise a script, read metadata out of it, and build a locking script for an output.
  • TransactionBuilderPlugin extends it for protocols whose transactions have a fixed, multi-output shape (a token transfer with interdependent outputs, say). The plugin builds the whole transaction; libspiffy selects and reserves the coins, signs, and packages the result as a BEEF.
Member Purpose
String get pluginId A unique id, used as a namespace ('mytoken')
String get displayName A name for your UI
List<String> get scriptTypes The script types the plugin handles (['token_v1'])
String? identifyScript(SVScript script) The script type, or null if the script is not yours
Map<String, dynamic>? extractMetadata(SVScript script) Data read out of a script, stored on the UTXO as BitcoinUtxo.pluginMetadata
LockingScriptBuilder? createLockBuilder(PluginOutputSpec spec) The locking script for a PluginOutputSpec output, or null
UnlockingScriptBuilder? createUnlockBuilder(PluginUnlockSpec spec) An unlocking script for one of your outputs, or null

SVScript, LockingScriptBuilder and UnlockingScriptBuilder are dartsv types.

lib/my_token_plugin.dart
// A sketch: MyTokenLockBuilder and MyTokenUnlockBuilder stand for your protocol's own dartsv builders.
import 'package:dartsv/dartsv.dart';
import 'package:libspiffy/libspiffy.dart';
class MyTokenPlugin extends ScriptPlugin {
@override
String get pluginId => 'mytoken';
@override
String get displayName => 'My Token';
@override
List<String> get scriptTypes => ['token_v1'];
@override
String? identifyScript(SVScript script) {
try {
MyTokenLockBuilder.fromScript(script);
return 'token_v1';
} catch (_) {
return null;
}
}
@override
Map<String, dynamic>? extractMetadata(SVScript script) {
final lock = MyTokenLockBuilder.fromScript(script);
return {
'pluginId': pluginId,
'scriptType': 'token_v1',
'tokenId': lock.tokenId,
// The address that owns the output. The wallet credits the output to itself only through this key.
'ownerAddress': lock.ownerAddress,
};
}
@override
LockingScriptBuilder? createLockBuilder(PluginOutputSpec spec) =>
spec.pluginScriptType == 'token_v1'
? MyTokenLockBuilder(tokenId: spec.params['tokenId'], ownerPKH: spec.params['ownerPKH'])
: null;
@override
UnlockingScriptBuilder? createUnlockBuilder(PluginUnlockSpec spec) => null;
}
  • Recognising outputs. When a script matches none of dartsv’s templates, ScriptTypeRegistry asks each registered plugin in turn. A claimed script’s type reads pluginId:scriptType (mytoken:token_v1).
  • Crediting outputs. An incoming or outgoing output a plugin claims belongs to the wallet when the metadata’s ownerAddress is one of the wallet’s addresses. Without ownerAddress, the output is recognised but not credited.
  • Keeping tokens out of the balance. A UTXO whose pluginMetadata names a pluginId is not counted as spendable balance and is not used to fund ordinary payments. Query them with libspiffy.walletStorage.getUTXOsByPlugin(walletId, 'mytoken', metadataFilter: {'scriptType': 'token_v1'}).
  • Building outputs. A payment whose outputs hold a PluginOutputSpec gets that output’s locking script from createLockBuilder. See Multi-output invoices.

libspiffy 5.0.0 itself never calls createUnlockBuilder; PluginRegistry().createUnlockBuilder(spec) forwards to it for your own code. To spend your outputs through the wallet, use a TransactionBuilderPlugin.

PluginRegistry is a singleton. Register your plugins once, at startup:

PluginRegistry().register(MyTokenPlugin());

register throws a StateError if the pluginId is taken; unregister(pluginId) removes one. You can also call getPlugin, isRegistered, allPlugins, hasPlugins, and identifyScript(script), which returns a ({String pluginId, String scriptType}) record or null. clear() is for tests.

Every call into a plugin is guarded: a plugin that throws is logged against its pluginId. While recognising a script, the registry moves on to the next plugin; a plugin call made while building a payment fails that payment, naming the plugin.

A TransactionBuilderPlugin builds the whole transaction when a payment’s outputs contain a PluginOutputSpec for it whose params['action'] is one of its supportedActions.

Member Purpose
List<String> get supportedActions The actions it builds (['issuance', 'transfer', 'burn'])
Future<TransactionBuilderResult> buildTransaction(PluginTransactionRequest request) Build and sign the transaction
bool validateTransactionStructure(Transaction tx, String action) Check the result; false fails the payment
int requiredFundingUtxoCount(String action) Funding coins needed; default 1. With 0 no coin is selected
bool get spendsAnyWalletOutput Default false. true if you spend funding through request.fundingInputs, so bare multisig and P2PK coins can fund you too
Future<List<ProvisionedTransaction>> provisionFunding(PluginTransactionRequest request) Optional; builds a funding tree for ProvisionFundingCommand. Throws UnsupportedError by default. ProvisionedTransaction lives in lib/src/plugin/provisioned_transaction.dart, which 5.0.0 does not export

When the wallet has fewer separate coins than requiredFundingUtxoCount asks for, the payment coordinator splits one first (auto-provisioning).

To build a funding tree yourself, ask the coordinator with ProvisionFundingCommand. Its reply is ProvisioningCompleteEvent (transactionCount, earmarkCount); a provisioning that fails throws CoordinatorFailure.

// A sketch: the params are whatever your plugin's provisionFunding reads.
final provisioned = await libspiffy.coordinator.ask(ProvisionFundingCommand(
walletId: 'my-wallet',
pluginId: 'mytoken',
pluginParams: {'action': 'issuance'},
));
print('${provisioned.transactionCount} transactions, ${provisioned.earmarkCount} earmarked coins');

PluginTransactionRequest gives the plugin:

  • fundingUtxos and publicKeys: the coins selected for it, and their public keys;
  • fundingInputs: the same coins as PluginFundingInputs, each with its real lockingScript, its outpoint and a newUnlocker() factory for the unlocking script;
  • signer: a dartsv TransactionSigner whose signatures come from the wallet. The private key never leaves the wallet aggregate;
  • params: the PluginOutputSpec.params;
  • feeRate: ARC’s policy rate. feeRate.feeFor(sizeBytes) gives the fee for a size;
  • transactionLookup: an optional callback that returns a stored transaction’s raw hex by txid;
  • keyFor(pubkeyHash): the wallet key with that HASH160 (40 hex characters), as a PluginKey with its own signer and publicKey. Use it for a covenant whose signature covers only the code after an OP_CODESEPARATOR, so the signed script names no owner. It fails when the wallet holds no such key.

Return a TransactionBuilderResult(primaryTx:, primaryFeeSats:), or TransactionBuilderResult.paired(...) with a witnessTx and witnessFeeSats for actions that produce two transactions. The witness reaches the payer as PaymentReadyEvent.witnessTxid and witnessBeefBytes, on the reply to its PayInvoiceCommand.

// A sketch: one P2PKH input per funding coin, all of it paid to params['to'] less the fee.
@override
Future<TransactionBuilderResult> buildTransaction(PluginTransactionRequest request) async {
final builder = TransactionBuilder();
var total = BigInt.zero;
for (final input in request.fundingInputs) {
total += input.utxo.satoshis;
builder.spendFromOutpointWithSigner(
request.signer, input.outpoint, TransactionInput.MAX_SEQ_NUMBER, input.newUnlocker());
}
final fee = request.feeRate.feeFor(estimatedSizeBytes); // your own size estimate
builder.spendToLockBuilder(
P2PKHLockBuilder.fromAddress(Address.fromBase58(request.params['to'] as String)),
total - fee,
);
return TransactionBuilderResult(primaryTx: builder.build(false), primaryFeeSats: fee);
}

dartsv signs synchronously inside TransactionBuilder.build(), but the wallet aggregate answers asynchronously. So libspiffy runs buildTransaction more than once (up to 8 passes):

  1. A pass signs with the signatures it already has, and uses a placeholder signature for every other input, recording what still needs signing.
  2. The wallet signs those inputs, and the build runs again.
  3. Only the final pass, in which every input is already signed, is returned.

ECDSA signatures here are deterministic, so each pass rebuilds the same transaction. Two rules follow:

  • buildTransaction must have no side effects.
  • If your plugin checks the spend it built (a token script, say), it may refuse a pass signed with placeholders. That is expected: since 4.7.0 such a refusal is not logged as a plugin failure, and the next pass signs for real. Only a failure with nothing left to sign fails the payment.

Each input is signed with the wallet key its spent script names (a public key hash or public key push that matches a wallet address); keyFor covers the case where it names none.