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:
ScriptPluginworks one output at a time: recognise a script, read metadata out of it, and build a locking script for an output.TransactionBuilderPluginextends 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.
ScriptPlugin
Section titled “ScriptPlugin”| 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.
// 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;}What libspiffy does with it
Section titled “What libspiffy does with it”- Recognising outputs. When a script matches none of dartsv’s templates,
ScriptTypeRegistryasks each registered plugin in turn. A claimed script’s type readspluginId:scriptType(mytoken:token_v1). - Crediting outputs. An incoming or outgoing output a plugin claims belongs to the wallet when the
metadata’s
ownerAddressis one of the wallet’s addresses. WithoutownerAddress, the output is recognised but not credited. - Keeping tokens out of the balance. A UTXO whose
pluginMetadatanames apluginIdis not counted as spendable balance and is not used to fund ordinary payments. Query them withlibspiffy.walletStorage.getUTXOsByPlugin(walletId, 'mytoken', metadataFilter: {'scriptType': 'token_v1'}). - Building outputs. A payment whose
outputshold aPluginOutputSpecgets that output’s locking script fromcreateLockBuilder. 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.
Register a plugin
Section titled “Register a plugin”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.
TransactionBuilderPlugin
Section titled “TransactionBuilderPlugin”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');The request
Section titled “The request”PluginTransactionRequest gives the plugin:
fundingUtxosandpublicKeys: the coins selected for it, and their public keys;fundingInputs: the same coins asPluginFundingInputs, each with its reallockingScript, itsoutpointand anewUnlocker()factory for the unlocking script;signer: a dartsvTransactionSignerwhose signatures come from the wallet. The private key never leaves the wallet aggregate;params: thePluginOutputSpec.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 aPluginKeywith its ownsignerandpublicKey. Use it for a covenant whose signature covers only the code after anOP_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.@overrideFuture<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);}Signing runs in passes
Section titled “Signing runs in passes”dartsv signs synchronously inside TransactionBuilder.build(), but the wallet aggregate answers
asynchronously. So libspiffy runs buildTransaction more than once (up to 8 passes):
- A pass signs with the signatures it already has, and uses a placeholder signature for every other input, recording what still needs signing.
- The wallet signs those inputs, and the build runs again.
- 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:
buildTransactionmust 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.
Related
Section titled “Related”- Multi-output invoices:
PluginOutputSpecin an invoice or payment - API reference