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).
Two attacks, two checks
Section titled “Two attacks, two checks”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.
Referring to Figure 1, we notice a few interesting things:
- 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.
- 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.
- 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).
Against the lineage
Section titled “Against the lineage”| 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).
Against the issuance
Section titled “Against the issuance”| 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 |
Against the fungible token
Section titled “Against the fungible token”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 |
Against the reset
Section titled “Against the reset”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 |
Against the modes
Section titled “Against the modes”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 |
Against succession
Section titled “Against succession”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 |
Why the earlier design fell
Section titled “Why the earlier design fell”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.

