Skip to content

Attacks

In this section we go through every attack that libtokensv 0.2.0’s tests try against the Lineage Tokens. For each one we say what the attacker does, which check stops it, and which test shows it. The test links point at the exact line in the v0.2.0 tag.

Unless we say otherwise, a test asserts the refusal on the values the Script compared, as described in the Threat model. Where a test also removes the check and shows the attack succeeding, we mark it (check removed).

Almost every attack on the lineage comes down to one of two moves, and each is caught by one of the two rebuilds. Figure 1 shows both.

Left: the attacker copies hop 2’s token Output byte for byte into a Transaction Q of their own. Spending it, step A rebuilds hop 2 from the header and the record, and the txid t2 does not equal q, so the spend is refused. Right: the attacker writes a Transaction F whose record names hop 2 as its parent, but whose Input 0 spends the attacker’s own coin c. Step A passes, but step B rebuilds hop 2, and t2 does not equal c, so the spend is refused.A copied Output: step AA fabricated parent: step Bhop 2 · txid t₂Q, the attacker’s · txid qhop 2 · txid t₂F, the attacker’s · txid fh₂codeh₂codeh₂codeh_fcodeout 1out 1out 1in 0: coin ccopied byte for byteits record names hop 2Spending q:1, step A rebuilds theTransaction that h₂ describes: hop 2.t₂ ≠ q, so the spend is refusedSpending f:1, step A passes. Step Brebuilds the parent the record names.t₂ ≠ c, so the spend is refused
Figure 1: A copied Output fails step A; a fabricated parent fails step B

Referring to Figure 1, we notice a few interesting things:

  1. A copy is perfect, and it doesn’t help. The attacker’s Q holds exactly the same bytes in its Output 1 as hop 2. But the header describes the Inputs of hop 2, not of Q, so the Transaction that step A rebuilds is hop 2, and its txid is not the one in the outpoint being spent.
  2. A fabricated parent passes step A. The attacker writes F honestly, with a header that describes F’s own Inputs, so step A rebuilds F and passes. The lie is in the record, which claims that F’s parent is hop 2. Step B rebuilds hop 2 from the record, and compares its txid with the outpoint F’s Input 0 actually spent, which is the attacker’s coin.
  3. Step B is what makes the induction work. The rebuilt parent holds the same code in its token Output, so a passing step B means that the same Script ran, and passed, when F was mined. Without step B, the test shows the fabricated parent accepted (check removed).
Attack Stopped by Test
Copy a token Output, byte for byte, into a new Transaction, and spend it. Step A: the rebuilt Transaction’s txid against the copy’s. lt_nft_test.dart:202
Spend an Output whose Input 0 spent the attacker’s own coin, with a record that names a genuine parent. Step B: the parent’s txid against the outpoint actually spent. (check removed) lt_nft_test.dart:174, lt_ft_test.dart:476
Relabel a token with another Token ID. The Token ID in the child header against the parent’s. lt_nft_test.dart:214, lt_fnft_test.dart:168
Transfer a token without the owner’s key, on the normal path or the cheap path. The pushed key’s hash against the header’s owner. lt_nft_test.dart:227, lt_fnft_test.dart:162
Carry on as a token from an Output made by a cheap-path spend. Step A: a cheap-path spend has no record, so its rebuild can’t match. The Output is dead as a token. lt_nft_test.dart:242
Make a transfer with a Transaction version other than 1, where later versions relax the encoding rules. The version read from the Sighash preimage. lt_nft_test.dart:253
As a relayer, change the child header of a signed transfer in flight, e.g. its change Address. Every Input signs with SIGHASH_ALL, so every Signature fails. lt_nft_test.dart:266
Change a byte in any level of the record. The rebuild no longer matches the txid. lt_fnft_test.dart:174
Smuggle a stray funding outpoint, or unknown flag bits, into a child header. The forward covenant on the child header. lt_fnft_test.dart:185

Do note that the cheap path is a real way to spend a token, and it belongs to the owner. It ends the token. A wallet that signs a token Output as an ordinary coin destroys the token, which is why the libspiffy plugins keep token Outputs out of ordinary payments (S05).

Attack Stopped by Test
Issue a token under someone else’s name, with an Attestation from a key that isn’t the Issuer’s. The Rabin key’s hash against the header’s Issuer. (check removed) lt_nft_test.dart:281, :300, lt_fnft_test.dart:195
Replay a genuine Attestation onto a second, counterfeit issuance. The Attestation signs SHA-256(identity anchor txid ‖ identity key ‖ Token ID), and the Token ID is the issuance’s own txid. lt_nft_test.dart:289
Make an issuance with a shape later hops can’t rebuild (an extra Output), stranding the first holder. The issuance’s first spend rebuilds it, and refuses it. The Issuer’s own first transfer fails, never a customer’s. lt_nft_test.dart:309, lt_swap_test.dart:269
Present an identity anchor for another key, an Attestation naming another anchor, or a tampered anchor. The payee’s check of the anchor (wallet side). lt_nft_test.dart:676

The fungible token adds amounts, a split into two Outputs, and a merge, in which the token at Input 0 (the driver) absorbs a second token Input (the passenger). The passenger’s own Script runs only the cheap path. The driver checks it, by rebuilding the passenger’s Transaction and its parent.

Attack Stopped by Test
Merge in a fabricated passenger claiming any amount. Passenger step B, run by the driver. (check removed: the supply inflates to 1,001,000.) lt_ft_test.dart:331, :338
Merge in a passenger created by a cheap-path spend, as passenger or as driver. A Transaction’s token Outputs are genuine only if its Input 0 was a normal-path spend; the rebuild can’t match. lt_ft_test.dart:349
Merge in a passenger of another token. The passenger’s Token ID against the driver’s. lt_ft_test.dart:383
Merge a passenger into the issuance’s own Output. The issuance’s first spend accepts no passenger. lt_ft_test.dart:390
Absorb someone else’s passenger without their key. The passenger’s cheap path needs its owner’s Signature. lt_ft_test.dart:400
Inflate an amount, in a transfer or a split. The child amounts must sum to the parent’s plus the passenger’s. lt_ft_test.dart:420
Make an amount negative. Every child amount must be positive. lt_ft_test.dart:442
Make a split whose two headers describe different Transactions. The two headers must agree everywhere but the owner and the amount. lt_ft_test.dart:457
Write an amount of eight ff bytes, hoping it reads as a large number. Script reads it as a negative number, so the check above refuses it. The test shows the reading that check relies on. lt_payload_test.dart:165

A fungible token can be reset: two of the three attesters fixed at issuance sign for an outpoint, and the record restarts there. This keeps the token’s size down, at the cost of trusting the attesters.

Attack Stopped by Test
Reset with one attester’s signature used twice. The two signers must be distinct members of the list. lt_ft_test.dart:719
Reset with a signer outside the list. Each signer must be in the list. lt_ft_test.dart:725
Reset with a list other than the one the header commits to. The list’s hash against the header’s attesters. lt_ft_test.dart:732
Reset with a corrupted signature, or one made for another outpoint. The Rabin check, over the Token ID and the outpoint being reset. lt_ft_test.dart:740
Set the reset flag over an ordinary record. The record must then be reset evidence. lt_ft_test.dart:748
Reset the issuance’s own Output. Refused. lt_ft_test.dart:754
Mint with three attester keys of one’s own, written into a fabricated header. The attesters are taken from the rebuilt issuance, not from the header being spent. lt_ft_test.dart:778
Build reset evidence on another issuance, even one with the same attesters. The rebuilt issuance’s txid against the Token ID. lt_ft_test.dart:805
Offer a corrupt reset as a passenger. The driver checks the passenger’s reset evidence again. lt_ft_test.dart:693

Each mode adds rules of its own, which the Script enforces as part of the forward covenant.

Mode Attack Test
Listed NFT Pay one satoshi under the price, pay someone other than the owner, buy an unlisted token, or leave it listed after buying. lt_lnft_test.dart:96
Listed NFT Buy a leftover listing from the previous buyer, if the unlisted-child rule is removed. (check removed) lt_lnft_test.dart:110
Listed NFT Sell a counterfeit listing written by a stranger. Stopped by step B. lt_lnft_test.dart:120
Voucher NFT Transfer past the hop limit, skip a count, or repeat one. lt_vnft_test.dart:113
Voucher NFT Change the terms in a transfer. lt_vnft_test.dart:123
Voucher NFT Redeem with a fee beyond the cap, to anyone but the Issuer, as a non-owner, or burn it. lt_vnft_test.dart:155
Voucher NFT Force a redemption before the expiry, with a time instead of a height, a final sequence, another key, or a wrong owner. lt_vnft_test.dart:183
Note NFT Take one satoshi of the Issuer’s share on an exit, or pay the Issuer one satoshi short. lt_note_test.dart:143
Note NFT Exit at maturity with an early nLockTime, a final sequence, or a timestamp. lt_note_test.dart:168
Note NFT Call it early, under the floor, with another key, or naming another owner. lt_note_test.dart:186
Note NFT Change a term in a transfer, or take the cheap path. lt_note_test.dart:201
Sale versions Change the price after the seller has signed. lt_fnft_sale_test.dart:110
Sale versions Hide a pay field without the paid flag, or set a stray flag bit. (check removed: the token is stranded at the next hop.) lt_fnft_sale_test.dart:118, :128
Swap The seller changes the payee, the price or the new owner after the buyer has signed. lt_swap_test.dart:145
Swap Replay the buyer’s Signature on another token, or misstate the buyer’s coin. lt_swap_test.dart:162, :171
Payload Change a byte of the payload, point at another Transaction, or use an Output that isn’t data. lt_payload_test.dart:93

When a token is redeemed and reissued, the successor gets a new Token ID, and the holder passes on links back to the token it succeeds. These are checked by the payee’s wallet, not by the Script.

Attack Test
Issue a successor of a forged copy (the cheap path takes the copy, but its record can’t be rebuilt). lt_nft_test.dart:442
Issue a successor of a copy whose Input 0 spent something other than a token Output. lt_nft_test.dart:457
Issue a successor of a Transaction that continued the token instead of ending it. lt_nft_test.dart:471
Issue a successor on another Output of the end. lt_nft_test.dart:482
Claim a successor under another Issuer, another predecessor, or with a tampered record. lt_nft_test.dart:528, lt_fnft_test.dart:311

The Lineage Tokens replace libtokensv’s earlier token archetypes (TSL1), which proved each hop with a separate witness Transaction. The next token Transaction checked that the witness had happened by running a partial SHA-256 over the witness’s last 128 bytes, and comparing the result with the witness’s txid.

It turns out that no check of a Transaction’s tail can tell its structure from its data. An attacker can build a fake witness of exactly the same length, carrying the genuine tail as data at its end, and the partial hash accepts it. The SHA-256 midstate hides where the tail sits in the Transaction’s parse, and every byte of the tail is the attacker’s to choose.

A Transaction can prove only its own spends. That is the lesson the Lineage Tokens are built on: steps A and B don’t trust a summary of a Transaction. They rebuild the whole Transaction from byte 0, and compare its txid.