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
maxHopstimes (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.
Issuing and receiving
Section titled “Issuing and receiving”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 Issuerexpect(held.tx.hopsLeft, 1); // transfers left before it must be redeemedexpect(held.tx.terms.expiry, 900000); // when the Issuer may take it backexpect(held.tx.terms.blocksToExpiry(899000), 1000); // VoucherMathThe hop limit
Section titled “The hop limit”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);Redeeming
Section titled “Redeeming”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);Forced redemption
Section titled “Forced redemption”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.
Reissuing
Section titled “Reissuing”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.
Presets
Section titled “Presets”| 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. |
Selling
Section titled “Selling”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.
In a wallet
Section titled “In a wallet”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.

