Skip to content

Voucher NFT

The Voucher NFT (TSL1_LT_VNFT) is a Fuelled NFT with three additional rules, which are fixed at issuance:

  • it can be transferred at most maxHops times (1 to 255), after which it can only be redeemed;
  • the holder can redeem it at any hop, but only to the Issuer, who receives the fuel;
  • after an expiry block height, the Issuer can force its redemption from whoever holds it.

We use it for things that go back to the Issuer: gift cards, coupons, event passes, memberships, licences. Do note that the fuel belongs to the Issuer (a deposit the Issuer puts into the voucher), not to the holder. The holder cannot burn a voucher and take the fuel.

VoucherTerms are written into the header at issuance, and every transfer must carry them unchanged:

final b = FnftBuilder.voucher();
final terms = VoucherTerms(
maxHops: 2, // transfers allowed, the Issuer's distribution included
expiry: 900000, // block height from which the Issuer may take it back
redeemTo: pkhOf(issuerKey), // redemptions pay this key, which also signs a forced redemption
feeCap: 1000, // the most a holder's redemption may spend on its fee
);

redeemTo must be a key that the Issuer’s wallet holds. If the Issuer loses it, the Issuer can no longer force redemptions, and holders’ redemptions pay a key that nobody has. The plugin defaults it to the issuing wallet’s own key.

The Issuer’s distribution counts as the first of the maxHops transfers:

final issuance = b.issue(
first: coinOf(issuerKey, 30300),
issuerPkh: issuer.lt.pkh,
owner: pkhOf(issuerKey),
terms: terms,
fee: 300,
);
issuance.x = issuer.lt.attest(issuance.txid);
final t1 = b.transfer(from: issuance, ownerKey: issuerKey.lt, to: pkhOf(alice), fee: 700);
spends(t1.raw, 0, issuance.lock(b.code), issuance.value);

The holding tells the payee what to show before a person accepts the voucher: its fuel, the transfers it has left, and when the Issuer may take it back.

final held = await FnftReceive.check(b: b, raw: t1.raw, ownerPkh: pkhOf(alice), anchorTx: issuer.anchor);
// What to show before a person accepts a voucher:
expect(held.value, 29300); // its fuel, which belongs to the Issuer
expect(held.tx.hopsLeft, 1); // transfers left before it must be redeemed
expect(held.tx.terms.expiry, 900000); // when the Issuer may take it back
expect(held.tx.terms.blocksToExpiry(899000), 1000); // VoucherMath

Each transfer counts as one hop. The builder refuses a transfer past the limit (and so does the Script):

final t2 = b.transfer(from: t1, ownerKey: alice.lt, to: pkhOf(bob), fee: 700);
expect(t2.hopsLeft, 0);
// A third transfer is refused. The voucher can only be redeemed now.
expect(() => b.transfer(from: t2, ownerKey: bob.lt, to: pkhOf(alice), fee: 700), throwsArgumentError);

The holder’s redemption pays a single Output to redeemTo: the fuel less the fee, where the fee may not exceed feeCap. A higher fee needs a coin of the holder’s, and in that case the Issuer gets the fuel whole:

// One Output, to redeemTo. The fee comes from the fuel, up to feeCap.
final r = b.redeem(from: t1, ownerKey: alice.lt, fee: 400);
spends(r, 0, t1.lock(b.code), t1.value);
final paid = Transaction.fromHex(hex.encode(r)).outputs.single;
expect(paid.satoshis.toInt(), terms.redeemed(value: t1.value, fee: 400));
expect(hex.encode(paid.script.buffer), hex.encode(p2pkhOf(issuerKey)));
// A fee above the cap needs a coin of the holder's to pay it.
expect(() => b.redeem(from: t1, ownerKey: alice.lt, fee: 1500), throwsArgumentError);
final funded = b.redeem(from: t1, ownerKey: alice.lt, fund: coinOf(funder, 3000), fee: 1500);
spends(funded, 0, t1.lock(b.code), t1.value);

The Issuer needs both the redemption and the redeemed Transaction to reissue the voucher, so have the holder’s wallet send both.

The owner cannot burn a voucher:

expect(() => b.end(from: t1, ownerKey: alice.lt, payTo: pkhOf(alice)), throwsArgumentError);

From the expiry height onwards, the Issuer takes the voucher back with forceRedeem, signed by the redeemTo key. The Transaction’s nLockTime is the expiry, and its Input’s sequence is not final, so the network will not mine it before that height:

// Signed by the redeemTo key. nLockTime is the expiry, and the Input's
// sequence is not final, so no node mines it before that height.
final forced = b.forceRedeem(from: t1, issuerKey: issuerKey.lt, fee: 300);
spends(forced, 0, t1.lock(b.code), t1.value);
final tx = Transaction.fromHex(hex.encode(forced));
expect(tx.nLockTime, 900000);
expect(tx.inputs.single.sequenceNumber, FnftBuilder.nonFinal);
// The Script refuses an nLockTime before the expiry.
final early = b.forceRedeem(from: t1, issuerKey: issuerKey.lt, lockHeight: 899999, fee: 300);
expect(() => spends(early, 0, t1.lock(b.code), t1.value), refusedByScript);

The Issuer’s wallet does not follow the voucher around, since holders do not send it their transfers. To force a redemption, it needs the voucher’s current Transaction. One way to get our hands on it is to follow the spends from the issuance, with one “spent by” query per hop, which comes to at most maxHops queries.

A redemption or forced redemption pays the Issuer at Output 0, and reissue issues the successor on it, with new terms if the Issuer wants them. A voucher’s record does not carry its terms, so a link to a voucher’s predecessor has to carry them instead: LtLink(ended, recG, terms.bytes). The plugin’s link builds it for us.

Preset Terms What it is
Badge maxHops: 1 The issuer’s distribution is the only transfer. The holder cannot pass it on, only redeem it. A membership, a licence.
Take-back at will expiry at or below the current height The issuer can force the redemption at any time. Show it as one the issuer can take back now.

A voucher cannot be swapped. If holders should be able to sell it, issue the sale version (FnftBuilder.saleableVoucher()). A sale counts as a hop, and the buyer gets the same terms. See Sales and swaps.

LtVnftPlugin has issue, transfer, topup, redeem, redeemFunded, forceRedeem and reissue, and no end. See Plugin reference.

The code is 2,726 bytes, and the header 187. A redemption is about 3,090 bytes.