Skip to content

Fungible token

The Fungible Token (TSL1_LT_FT) carries an amount in its header: points, credits, shares, a stable unit. A transfer can keep the amount whole, split it into two Outputs, or merge two Outputs of the same token into one. Whichever it does, the Script checks that no transfer creates amount.

Instead of redeem-and-reissue, a Fungible Token’s record can be reset in place. Three attesters are named at issuance, and any two of them can sign for an Output so that its record restarts there.

The attesters are Rabin Keys, like the Issuer’s. Choose them as you would choose who may issue, since two of them together can mint (see below).

final b = LtBuilder(LtLayout.ft, LtScriptGen.ft.code().buffer);
// Three attesters are fixed at issuance. Any two of them can reset the record.
final attesters = [for (final k in attesterRabins) LtAttester(k)];

The issuance holds the whole supply, in the token’s smallest unit. The header commits to the attesters by the hash of their three keys:

final issuance = b.issue(
first: coinOf(issuerKey),
fund: coinOf(issuerKey),
issuerPkh: issuer.lt.pkh,
attesters: attestersOf(attesters), // HASH160 of the three keys
owner: pkhOf(issuerKey),
amount: 1000000, // the whole supply, in the token's smallest unit
);
issuance.x = issuer.lt.attest(issuance.txid);
final t1 = b.transfer(
from: issuance,
k: 1,
ownerKey: issuerKey.lt,
fund: coinOf(issuerKey),
outs: [LtOut(pkhOf(alice), 1000000)], // the amount must be kept whole
);
spends(t1.raw, 0, issuance.lock(1, b.code), 1);
expect(t1.amount(1), 1000000);

If we give a transfer two Outputs, their amounts must add up to the Input’s. The token Outputs are 1 and 2:

// Alice pays Bob 300 and keeps 700, giving two token Outputs, 1 and 2.
final split = b.transfer(
from: t1,
k: 1,
ownerKey: alice.lt,
fund: coinOf(alice),
outs: [LtOut(pkhOf(bob), 300), LtOut(pkhOf(alice), 700)],
);
spends(split.raw, 0, t1.lock(1, b.code), 1);
expect([split.amount(1), split.amount(2)], [300, 700]);

A merge spends two token Outputs. The driver sits at Input 0 (on the normal path), and the passenger at Input 1, signed by its owner. The driver’s Script also rebuilds the passenger’s Transaction and its parent, while the passenger’s own Script only checks its owner’s Signature. The Output holds the sum:

// Later Bob is paid Alice's 700 too, at Output 2 of the split, and
// merges the two into one Output of 1000. The driver (Input 0) is Bob's
// 300, and the passenger (Input 1) is the 700, signed by its owner.
final toBob = b.transfer(from: split, k: 2, ownerKey: alice.lt, fund: coinOf(alice), outs: [LtOut(pkhOf(bob), 700)]);
final merged = b.transfer(
from: split,
k: 1,
ownerKey: bob.lt,
passenger: LtPassenger(toBob, 1, bob.lt),
fund: coinOf(bob),
outs: [LtOut(pkhOf(carol), 1000)],
);
spends(merged.raw, 0, split.lock(1, b.code), 1); // the driver
spends(merged.raw, 1, toBob.lock(1, b.code), 1); // the passenger
expect(merged.amount(1), 1000);

A merge’s record holds both parents’ records, so merging tokens with long histories makes for a large Transaction. Reset them first.

It should be noted that the builder does not check that amounts add up. It writes whatever we give it. It is the Script that refuses a transfer whose Outputs hold more than its Input:

// The builder writes whatever amounts it is given. It is the Script that
// refuses Outputs that add up to more than the Input.
final inflated =
b.transfer(from: iss, k: 1, ownerKey: issuerKey.lt, fund: coinOf(issuerKey), outs: [LtOut(pkhOf(alice), 1001)]);
expect(() => spends(inflated.raw, 0, iss.lock(1, b.code), 1), refusedByScript);

So run a transfer you built through the interpreter before you broadcast it.

Each hop adds 455 bytes of record. A reset replaces the parent’s record with evidence: the issuance’s record (which names the attesters), and two attesters’ Rabin Signatures over the Token ID and the outpoint being spent. The record then starts over from there:

// Two of the three attesters sign for the outpoint being spent.
final at = outpoint(t.txid, 1);
final evidence = resetEvidence(
issuance, // the issuance names the attesters
attesters,
attesters[0].attest(t.tokenId, at),
attesters[2].attest(t.tokenId, at),
);
// The reset hop carries that evidence in place of its parent's record.
final r = b.transfer(
from: t,
k: 1,
ownerKey: holder.lt,
fund: coinOf(holder),
outs: [LtOut(pkhOf(alice), 1000)],
reset: evidence,
);
spends(r.raw, 0, t.lock(1, b.code), 1);
// The next hop's record starts over from the reset.
final next = b.transfer(from: r, k: 1, ownerKey: alice.lt, fund: coinOf(alice), outs: [LtOut(pkhOf(bob), 1000)]);
spends(next.raw, 0, r.lock(1, b.code), 1);
expect(next.rec.length, lessThan(grown));
bytes
code after the separator 5,343
locking script (header 241) 5,587
issuance 5,938
hop 1 / hop 10 / hop 20 12,470 / 16,564 / 21,114
growth per ancestor 455
a reset hop (a normal hop at the same depth: 17,019) 12,728