Skip to content

Wallet plugins

Each kind of token has its own libspiffy TransactionBuilderPlugin. The work is divided up as follows:

  • libspiffy selects and reserves the funding coins, and asks the plugin to build the Transaction. It then signs every Input (the token’s Input with the key the plugin names by its hash), records the Transaction, makes its BEEF, broadcasts it and follows it.
  • The plugin reads the token from the wallet’s own copy of its Transaction, and builds the token Transaction with libtokensv’s builders. Before it hands the Transaction back, it runs every token spend through the token’s Script. It never receives a Private Key.
// In the isolate the wallet runs in, before libspiffy loads or receives
// anything: a token received while its plugin is not registered is not
// recorded as the wallet's.
final nft = LtNftPlugin(network: dartsv.NetworkType.TEST);
final fnft = LtFnftPlugin(network: dartsv.NetworkType.TEST);
setUp(() {
PluginRegistry().register(nft);
PluginRegistry().register(fnft);
});
tearDown(() {
PluginRegistry().unregister(LtNftPlugin.id);
PluginRegistry().unregister(LtFnftPlugin.id);
});

We register in the isolate the wallet runs in, before the wallet loads or receives anything, and we give each plugin the wallet’s network (Getting started has the list, and the caveat about the network).

  1. Pay a PluginOutputSpec. This holds the plugin’s id and scriptType, params with an action and its arguments, and an amount that tells libspiffy how many coins to select.

    // A plugin payment is an invoice whose Output is the plugin's spec. Its
    // amount makes libspiffy select coins. Above one 60,000-sat coin, it takes two.
    final issued = await h.ask<BEEFPaymentResponse>(
    h.spiffy.paymentCoordinator,
    PayInvoiceMessage(
    walletId: h.wallet,
    invoiceId: 'issue-1',
    addresses: const [],
    amount: BigInt.from(70000),
    outputs: [
    PluginOutputSpec(
    pluginId: LtNftPlugin.id,
    pluginScriptType: LtNftPlugin.scriptType,
    params: {'action': 'issue', 'issuer': hex.encode(issuer.lt.pkh)},
    amount: BigInt.from(70000),
    ),
    ],
    ),
    );
    expect(issued.success, isTrue, reason: issued.error);
  2. Check the response. On failure, error holds the plugin’s or libspiffy’s reason.

  3. Settle it with SettleBEEFCommand, and wait for ARC to report it. A result with a paired Transaction (a reissue’s successor, an issuance on a payload) has witnessTxid and witnessBeefBytes. Settle that one too, after the primary.

  4. Hand it over, for a transfer to another wallet. That means the BEEF, and with it the Issuer’s anchor (unless the payee already has it), the reissue links if there are any, and the payload Transaction if the token has one.

Params use hex everywhere: key hashes (20 bytes), txids in display order (as Transaction.id prints them), Attestations, and raw Transactions.

It should be noted that the amount is not paid to anyone. Its only job is to make libspiffy select coins. Each action needs a fixed number of coins (plugin.requiredFundingUtxoCount(action)):

  • An action that takes no coin (every fuelled-token transfer, end, redemption and reissue; every swap action; a purchase): send amount: 0, and libspiffy selects and reserves nothing. A wallet with no coins can therefore still move a Fuelled NFT.
  • An action that takes one coin (an NFT transfer or end, a top-up, a listing): an amount that one coin covers.
  • An action that takes two (an NFT issue or reissue): an amount above one coin’s value, so that the selection takes two. The examples’ coins hold 60,000 satoshis, so they pay 70,000.
final sent = await h.pay(
LtFnftPlugin.id,
LtFnftPlugin.scriptType,
{'action': 'transfer', 'token': iss.id, 'to': second, 'attestation': attest(iss)},
amount: 0, // the token pays its own fee
);
expect(sent.success, isTrue, reason: sent.error);
expect(sent.spentUtxoKeys, ['${iss.id}:0']); // only the token was spent

The plugin’s extractMetadata names each token Output’s owner Address, and libspiffy records the Output under the plugin when that Address is the wallet’s. Token Outputs never count in the balance, and never fund an ordinary payment:

// The wallet tracks the token Output under the plugin, and never offers
// it as a coin for an ordinary payment.
final tokens = await h.spiffy.walletStorage.getUTXOsByPlugin(h.wallet, LtNftPlugin.id);
final coins = await h.spiffy.walletStorage.getPaymentUTXOs(h.wallet);
expect(tokens.map((u) => u.key), contains('${t1.id}:1'));
expect(coins.where((c) => tokens.any((t) => t.key == c.key)), isEmpty);

libspiffy refuses a second payment that spends a token Output already held by an earlier one:

// A second payment of a token already sent is refused.
final again = await h.pay(LtNftPlugin.id, LtNftPlugin.scriptType, {'action': 'transfer', 'token': iss.id, 'to': second});
expect(again.success, isFalse);

That check uses libspiffy’s read model, so two payments started at the same moment could both pass it. Run one operation at a time per token, i.e. take a lock per token in your app.

A payee’s wallet gets a transfer as BEEF. Each plugin has a receive check that takes the BEEF, the Address the payee gave the sender, and the Issuer’s anchor: LtNftReceive, LtFnftReceive, LtVnftReceive and LtNoteReceive. They read the BEEF’s subject, and run the token check from Concepts.

The examples on this page and in the plugin reference call buildTransaction directly, with a stand-in for libspiffy’s request, so that the Issuer’s wallet and the holder’s can be two different wallets. The stand-in lives in test/support/standin_wallet.dart.

// Alice's wallet gets the transfer as BEEF, and checks it for the Address
// it gave the sender. The anchor's txid is named in the transfer.
expect(plugin.anchorTxid(hex.decode(t1.serialize())), issuer.anchor.id);
final held = await LtNftReceive.check(
plugin: plugin,
beef: beefOf(t1),
ownerAddress: addressOf(alice),
anchorTx: issuer.anchor,
);
expect(hex.encode(held.tokenId.reversed.toList()), iss.id);

plugin.anchorTxid(transfer) names the anchor that the transfer’s Attestation points to, so that a payee knows which anchor to fetch. Fetch it with its merkle proof, and check that proof against your headers.

Putting it all together, a payee does the following:

  1. Validate the BEEF with libspiffy (proofs against the wallet’s headers).
  2. Run the plugin’s receive check. On LtRefused, refuse the token and show the reason.
  3. Store the transfer’s raw bytes, its links and its origin, and show the token as pending.
  4. Count it as held when ARC reports SEEN_ON_NETWORK or MINED.

After a reissue, the Issuer’s wallet makes a link with plugin.link(endRaw, redeemedRaw). It takes the end (the primary Transaction of the reissue result), and the Transaction whose token it ended. Holders pass the links on, newest first, encoded with LtLinks.encode, and the payee passes them to the receive check as links:. The holding’s originId is then the first token of the lineage.

Some tokens can leave a wallet without the wallet signing anything: a voucher the Issuer takes back after its expiry, a note the Issuer calls, a Listed NFT a stranger buys. libspiffy’s CheckForeignSpendsCommand asks the chain about each plugin Output the wallet holds, one at a time, and returns a proven spender’s raw Transaction so that the app can import it. Run it before you show a voucher past its expiry, a note past its call height, or a listing, as held. See libspiffy.dev.

Every plugin action, with its params and its errors, is in the Plugin reference. The rules that every host app must follow are in Host security rules.