Skip to content

Covenant NFT

The Covenant NFT (TSL1_LT_NFT) is a single indivisible unit: a ticket, a certificate, a collectible, a title to something. Its Output holds 1 satoshi, and the sender pays each transfer’s fee from an ordinary coin. It is the simplest of the Lineage Tokens, and the base of the Listed NFT, which makes it a good place to start. If you have not done so yet, read Concepts first.

A transfer has two Inputs (the token, and a funding coin) and two Outputs (the change at Output 0, and the token at Output 1).

There is one LtBuilder per archetype, made from its layout and code:

final b = LtBuilder(LtLayout.nft, LtScriptGen.nft.code().buffer);

The issuance is an ordinary Transaction, made from two of the Issuer’s coins. Its Output 1 is the token, owned by the Issuer’s own key. We set its x to the Attestation before anything spends it:

// The issuance: Input 0 and Input 1 are ordinary coins of the Issuer,
// and Output 1 is the token, owned by the Issuer's own key.
final issuance = b.issue(
first: coinOf(issuerKey),
fund: coinOf(issuerKey),
issuerPkh: issuer.lt.pkh, // HASH160 of the Issuer's Rabin Key
owner: pkhOf(issuerKey),
);
// The Issuer signs the issuance's txid with its Rabin Key. The first
// transfer carries this attestation, and the script checks it there.
issuance.x = issuer.lt.attest(issuance.txid);

The fee defaults to 1,000 satoshis. Pass fee: for a fixed fee, or satsPerKb: to price the Transaction by its size. The builder refuses coins that do not cover both the token’s satoshi and the fee.

The Issuer’s first transfer carries the Attestation, and that is where the Script checks it:

// Distribution is an ordinary transfer. The Issuer's key signs the
// token Input, and a coin of the Issuer's pays the fee.
final t1 = b.transfer(
from: issuance,
k: 1, // the token is Output 1
ownerKey: issuerKey.lt,
fund: coinOf(issuerKey),
outs: [LtOut(pkhOf(alice))],
satsPerKb: 100,
);
// A node runs the token's Script on Input 0, and accepts the spend.
spends(t1.raw, 0, issuance.lock(1, b.code), 1);
expect(t1.tokenId, issuance.txid);

In the preceding example, k is the index of the token Output being spent, which for the NFT is always 1. outs holds exactly one LtOut, carrying the new owner’s key hash.

Now let’s look at the other side. The payee checks the raw transfer against the key hash it gave the sender, and against the Issuer’s anchor:

// Alice was handed t1's raw bytes (from the BEEF), and the Issuer's
// identity anchor. The check rebuilds t1 and its parent from the record
// t1 carries, runs the token's Script, and verifies the Issuer.
final LtHolding held = await LtReceive.check(
b: b,
raw: t1.raw,
ownerPkh: pkhOf(alice),
anchorTx: issuer.anchor,
);
expect(held.tokenId, iss.txid);
expect(held.originId, iss.txid); // never reissued
expect(held.out, outpoint(t1.txid, 1)); // what alice's wallet holds

The check throws LtRefused, with the reason, at the first check that fails:

// Paid to someone else, so it is refused, with the reason.
await expectLater(
LtReceive.check(b: b, raw: t1.raw, ownerPkh: pkhOf(bob), anchorTx: issuer.anchor),
throwsA(isA<LtRefused>().having((e) => e.reason, 'reason', 'the token is not paid to this key')),
);

The Script run takes tens of milliseconds at a few hops, and grows with the record (about 150 ms at hop 20 on a laptop). runScript: false skips it. The network will still refuse a bad spend later, but you would not know that before you show the token.

Before the network has the transfer, the check proves that the bytes are a genuine token for you. It does not prove that you hold it. See What the payee checks.

The wallet stores the raw transfer. To send the token on, we read it back with fromRaw and transfer it. The record it carries is exactly what the next transfer needs:

// Later, Alice's wallet reads the token back from the raw bytes it
// stored, and pays it on to Bob. Alice pays the fee from her own coin.
final stored = b.fromRaw(t1.raw);
expect(stored.rec, held.tx.rec);
final t2 = b.transfer(from: stored, k: 1, ownerKey: alice.lt, fund: coinOf(alice), outs: [LtOut(pkhOf(bob))]);
spends(t2.raw, 0, stored.lock(1, b.code), 1);
spends(t2.raw, 1, p2pkhOf(alice), 100000); // the funding Input

Only the owner’s key moves the token. A transfer signed by any other key is refused by the Script:

final stolen = b.transfer(from: t1, k: 1, ownerKey: bob.lt, fund: coinOf(bob), outs: [LtOut(pkhOf(bob))]);
expect(() => spends(stolen.raw, 0, t1.lock(1, b.code), 1), refusedByScript);

end spends the token on the cheap path, i.e. the owner’s Signature only, to plain P2PKH Outputs. This stops the token for good. Output 0 is the change, and it is the coin that a successor’s issuance spends.

To shrink a token whose Transactions have grown, the Issuer takes it back, ends it, and reissues it. The holder then receives the successor with a link that shows where it came from:

// 1. The holder sends the token back to the Issuer's key.
final g = b.transfer(from: t1, k: 1, ownerKey: alice.lt, fund: coinOf(alice), outs: [LtOut(pkhOf(issuerKey))]);
// 2. The Issuer ends it. Output 0 of the end pays the Issuer's key.
final e = b.end(from: g, k: 1, ownerKey: issuerKey.lt, fund: coinOf(issuerKey), changePkh: pkhOf(issuerKey));
spends(e, 0, g.lock(1, b.code), 1);
// 3. The successor is issued on the end's output 0, attested, and
// distributed like any new token.
final r = b.reissue(ended: e, endKey: issuerKey.lt, fund: coinOf(issuerKey), issuerPkh: issuer.lt.pkh, owner: pkhOf(issuerKey));
r.x = issuer.lt.attest(r.txid);
final t2 = b.transfer(from: r, k: 1, ownerKey: issuerKey.lt, fund: coinOf(issuerKey), outs: [LtOut(pkhOf(bob))]);
// The link (the end, and the record of the Transaction it ended)
// travels with every later transfer, newest first.
final links = [LtLink(e, g.rec)];
final wire = LtLinks.encode(links);
final held = await LtReceive.check(
b: b, raw: t2.raw, ownerPkh: pkhOf(bob), anchorTx: issuer.anchor, links: LtLinks.decode(wire));
expect(held.tokenId, r.txid); // the successor's own id
expect(held.originId, iss.txid); // the first token of the lineage

LtSuccession.verify checks a single link on its own, should you need that outside the payee check.

A token can carry content (metadata, a hash of an image, terms) at no cost per hop. The content goes in a payload Transaction, whose Output 1 funds the issuance’s Input 1, and the Token ID commits to it. The payee is given the payload Transaction along with the transfer, and the check returns the content:

final content = utf8.encode('{"name":"Ticket 42","seat":"F17"}');
// The payload Transaction: Output 0 is OP_FALSE OP_RETURN <content>,
// and Output 1 is the coin that funds the issuance.
final (payloadTx, payloadCoin) = LtPayload.build(coin: coinOf(issuerKey), payload: content, fundSats: 5000);
final iss = b.issue(first: coinOf(issuerKey), fund: payloadCoin, issuerPkh: issuer.lt.pkh, owner: pkhOf(issuerKey));
iss.x = issuer.lt.attest(iss.txid);
final t1 = b.transfer(from: iss, k: 1, ownerKey: issuerKey.lt, fund: coinOf(issuerKey), outs: [LtOut(pkhOf(alice))]);
// The payee is given the payload Transaction along with the transfer.
final held = await LtReceive.check(b: b, raw: t1.raw, ownerPkh: pkhOf(alice), anchorTx: issuer.anchor, payloadTx: payloadTx);
expect(utf8.decode(held.payload!), '{"name":"Ticket 42","seat":"F17"}');

Without the payload Transaction the token is still valid, and payload is null. The payload needs no proof of its own, since it is an ancestor of the issuance. Treat it as data, never as instructions, and bound its size before you show it (LtPayload.maxPayload is 100,000 bytes).

LtNftPlugin has the actions issue (with an optional payload), transfer, end, reissue and the swap’s four. See Plugin reference.

bytes
code after the separator 2,231
locking script (header 172) 2,406
issuance 2,757
hop 1 / hop 10 / hop 20 6,039 / 9,513 / 13,372
growth per hop 386

At 100 sat/kB, a hop-20 transfer costs about 1,340 satoshis.