Fuelled NFT
The Fuelled NFT (TSL1_LT_FNFT) is a covenant NFT whose token Output holds satoshis, which we call its fuel. Each transfer pays its fee out of the fuel. A transfer therefore has one Input and one Output, and the holder needs no coins of their own. Anyone may top the fuel up, and the owner may burn the token and take the fuel back as an ordinary coin.
We use it when our holders may not have any satoshis: a ticket sent to a guest’s new wallet, a badge in an app that holds no money. It is also the base of the Voucher NFT and the Note NFT.
Since we have already met the covenant NFT, let’s compare the two:
| Covenant NFT | Fuelled NFT | |
|---|---|---|
| Token output | output 1, 1 sat | output 0, the fuel |
| Who pays a transfer’s fee | the sender, from a coin | the token |
| A transfer | token + funding in, change + token out | token in, token out (a top-up adds a coin in) |
| End (burn) | needs a funding coin | needs none; returns the fuel |
| Growth per transfer | 386 B | 152 B (a top-up adds 143 B once) |
The builder
Section titled “The builder”final b = FnftBuilder.standard();Issuing the token
Section titled “Issuing the token”The first coin (and an optional second, fund:), less the fee, becomes the fuel. How much to put in is the Issuer’s choice. Each transfer takes a few satoshis at standard fee rates (7 to 9 sats at 1 sat/kB on regtest).
// The Issuer's coin, less the fee, becomes the token's fuel. The token// is at Output 0, and holds those satoshis.final issuance = b.issue( first: coinOf(issuerKey, 20300), issuerPkh: issuer.lt.pkh, owner: pkhOf(issuerKey), fee: 300,);issuance.x = issuer.lt.attest(issuance.txid);expect(issuance.value, 20000);Transferring it
Section titled “Transferring it”There is no funding coin. The fee comes out of the token’s value:
// There is no funding coin. The fee comes out of the token's value.final t1 = b.transfer(from: issuance, ownerKey: issuerKey.lt, to: pkhOf(alice), satsPerKb: 100);spends(t1.raw, 0, issuance.lock(b.code), issuance.value);
final tx = Transaction.fromHex(hex.encode(t1.raw));expect(tx.inputs, hasLength(1)); // the token is the only Inputexpect(tx.outputs, hasLength(1)); // the token is the only Output, at Output 0expect(t1.value, lessThan(issuance.value)); // less the fee for this transferDo note that the token Input is signed over the token’s current value, not over 1 satoshi.
Receiving it
Section titled “Receiving it”FnftReceive.check runs the same checks as the NFT’s, and returns an FnftHolding with the fuel:
final FnftHolding held = await FnftReceive.check( b: b, raw: t1.raw, ownerPkh: pkhOf(alice), anchorTx: issuer.anchor,);expect(held.tokenId, iss.txid);expect(held.value, 20000 - 700); // the fuel alice receivesexpect(held.out, outpoint(t1.txid, 0)); // Output 0, not 1Show the fuel together with the token, in satoshis, before a person accepts or pays for one. A payee gets whatever fuel the earlier transfers left behind. The fuel is not spendable balance.
Topping up, running dry, and burning
Section titled “Topping up, running dry, and burning”When the fuel cannot cover a transfer’s fee, the builder refuses to build it:
// The builder refuses a transfer whose fee the fuel cannot cover.expect(() => b.transfer(from: t1, ownerKey: alice.lt, to: pkhOf(bob), fee: 900), throwsArgumentError);Anyone holding a coin can add it with fund:. The whole coin goes into the token, and there is no change Output. Its outpoint and Signature stay in every later record, which costs 143 bytes, once:
// Anyone holding a coin can add it. The whole coin is swept into the// token, with no change output.final coin = coinOf(funder, 5000);final t2 = b.transfer(from: t1, ownerKey: alice.lt, to: pkhOf(bob), fund: coin, fee: 900);spends(t2.raw, 0, t1.lock(b.code), t1.value);spends(t2.raw, 1, p2pkhOf(funder), 5000);expect(t2.value, 800 + 5000 - 900);The owner can always end the token and take the fuel. The burn needs no coin and accepts any value, so the satoshis are never stranded. Only the token, as a token, can be stranded if nobody refuels it.
// The owner ends the token, and takes what it holds, less the fee, as// an ordinary coin. No funding coin is needed.final burn = b.end(from: t1, ownerKey: alice.lt, payTo: pkhOf(alice), fee: 300);spends(burn, 0, t1.lock(b.code), t1.value);final out = Transaction.fromHex(hex.encode(burn)).outputs.single;expect(out.satoshis.toInt(), 800 - 300);Reissuing
Section titled “Reissuing”The end pays the fuel to Output 0, and the successor is issued on that Output, so the old fuel goes on to fuel the successor. A reissue refuels the token without the top-up’s permanent 143 bytes. Prefer it when the Issuer is available.
final g = b.transfer(from: t1, ownerKey: alice.lt, to: pkhOf(issuerKey), fee: 700); // back to the Issuerfinal e = b.end(from: g, ownerKey: issuerKey.lt, payTo: pkhOf(issuerKey), fee: 200);final r = b.reissue(ended: e, endKey: issuerKey.lt, issuerPkh: issuer.lt.pkh, fee: 300);r.x = issuer.lt.attest(r.txid);expect(r.value, g.value - 200 - 300); // the old fuel, less two fees
final t2 = b.transfer(from: r, ownerKey: issuerKey.lt, to: pkhOf(bob), fee: 700);final held = await FnftReceive.check( b: b, raw: t2.raw, ownerPkh: pkhOf(bob), anchorTx: issuer.anchor, links: [LtLink(e, g.rec)]);expect(held.originId, iss.txid);Payload and sale
Section titled “Payload and sale”A Fuelled NFT carries a payload in the same way as the NFT does. We issue with the payload Transaction’s Output 1 as fund:, and FnftReceive.check(..., payloadTx:) returns it.
It cannot be sold by a swap (it has no change Output, and a funding coin would be swept into the fuel), nor listed for a stranger to buy. Its sale version, FnftBuilder.saleable(), can be sold. See Sales and swaps.
In a wallet
Section titled “In a wallet”LtFnftPlugin has issue, transfer, topup, end and reissue. Transfers, ends and reissues take no wallet coin. We send them with amount: 0, and libspiffy selects and reserves nothing. See Plugin reference.
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 spentThe code is 2,471 bytes, and the locking script 2,627 (header 153). Hop 20 is 8,943 bytes (9,096 with one top-up). A burn is 191 bytes.

