Skip to content

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)
final b = FnftBuilder.standard();

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);

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 Input
expect(tx.outputs, hasLength(1)); // the token is the only Output, at Output 0
expect(t1.value, lessThan(issuance.value)); // less the fee for this transfer

Do note that the token Input is signed over the token’s current value, not over 1 satoshi.

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 receives
expect(held.out, outpoint(t1.txid, 0)); // Output 0, not 1

Show 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.

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);

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 Issuer
final 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);

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.

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 spent

The 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.