Skip to content

Token-2022: wrappers read mint_info, refuse armed hooks, report delivered amounts - #301

Merged
anil-rome merged 12 commits into
masterfrom
feat-token2022
Jul 25, 2026
Merged

Token-2022: wrappers read mint_info, refuse armed hooks, report delivered amounts#301
anil-rome merged 12 commits into
masterfrom
feat-token2022

Conversation

@anil-rome

@anil-rome anil-rome commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Wrapper-side Token-2022 support. Companion to rome-evm-private #464, which must merge first — the mint_info selector these contracts call does not exist on master.

One gate blocked every extension-bearing mint

parseMint required exactly 82 bytes. A Token-2022 mint with extensions is longer — the base layout is byte-identical and the extensions trail it — so every such mint was rejected before anything else was tried. Extension-free 2022 mints already worked.

Solidity no longer parses mint bytes

Both wrappers take decimals from mint_info instead of unpacking the mint themselves. That removes the second interpretation of the same bytes, which is what drifted from the program's in the first place.

Change Why
mint_info(bytes32) on IHelperProgram and ISplCached verify_call refuses a legacy cross-state read once a cached invoke has fired, so a cached wrapper needs its own home. One interpreter behind both.
Three admission paths refuse an armed hook factory, and each wrapper constructor — the second reachable directly by deploy scripts, which the factory gate never sees
Delivered amount measured, not computed SPL caps the fee at maximum_fee, so bps arithmetic is wrong above the cap. feeBps is a predicate, not an operand.
ataForKey resolves the program from the mint the program is part of an ATA's seeds, so assuming it named an account create_ata_for_key never created — bridge-out was broken for every 2022 mint
parseMint accepts >= 82, load_mint checks the owner orphaned by the wrapper change but still a latent copy of the same bug

Registration reads the mint's own identity

add_spl_token_with_metadata exists to take a wrapper's name from the mint rather than from whoever registers it — but it looked only in the Metaplex account. A Token-2022 mint carries its identity inside the mint, under its own metadata authority, so for those mints it reverted "Metadata does not exist" about a mint that plainly had metadata, in a place it did not look.

Native is read first, Metaplex stays the fallback. Strictly more inputs succeed; nothing that worked stops working.

add_spl_token_no_metadata is untouched — choosing your own label stays allowed. Anyone can deploy their own factory and their own wrapper on any chain, and curation answers squatting: Rome's registry already declines to carry permissionless registrations. A gate here would be a restriction neither Solana nor Ethereum has. Symbol uniqueness is untouched too — that path already derived symbols from Metaplex, so this adds no new class of collision.

The TLV walk lives in SplTokenLib beside the mint parsing already there. It bounds every length against the extension's own end and treats absence as a value rather than an error.

Not read, deliberately

A MetadataPointer aimed at a separate account falls through to the Metaplex attempt — today's behaviour. The self-referential shape is what real mints use and is the one verified against actual bytes. Nothing forces the two wrappers over one mint to agree on a name, because nothing should.

Verification

hardhat compile clean · 263 passing (241 at the branch point).

The admission and derivation tests execute rather than reading source. A stateless mint_info mock installed with hardhat_setCode runs the real constructors; presence is a separate byte from arming in its layout, so a wrapper keying on presence fails. Installing the mock at one precompile at a time turns "each wrapper reads its own track" into behaviour rather than a grep.

Mutation-verified, each failing a test: presence-instead-of-armed (2), cached-reads-legacy (2), hardcoded program — the original ataForKey bug (1), wrong extension type (1), wrong payload offset (1), skipping the native read (1).

Red team on the TLV walk, whose input is data someone else controls: an entry claiming more bytes than the account holds, a zero-length entry of the type sought, a name length larger than its own entry, a buffer with no terminator, and truncated or empty accounts. The no-terminator case is the one worth having — an unbounded loop over attacker data is the failure mode this parser most needs to not have.

Parsing is asserted against the real ai16z mint bytes, committed as a CI fixture, following tests/oracle/PythPullParser.test.ts.

Residual

Wrapper name()/symbol() are asserted once at registration and immutable. Neon live-reads Metaplex on every call, which cannot be squatted but makes the ERC-20 name mutable by the metadata authority and forbids both custom labels and metadata-less mints. Ours is the better trade; a UI wanting to show "the mint now says X" reads Solana, not a new field here.

anil-rome added 12 commits July 25, 2026 07:11
Declares mint_info(bytes32) on IHelperProgram and ISplCached — the same
selector twice, because a contract must read from the track it is already on
and verify_call refuses a legacy cross-state read once a cached invoke has
fired in the transaction.

The return is deliberately shaped around the armed state rather than presence:
hookProgram and feeBps read zero when the extension is absent or present and
inert, so `hookProgram != 0` is the question a caller should ask. `extensions`
carries presence separately, one bit per ExtensionType discriminant, for a
caller that wants to refuse a family outright.

The test asserts parity in both directions — the signature hashes to the const
the program dispatches on, both interfaces compile to that same selector, and
the five outputs are in the program's encoding order. Signature drift between
Rust and Solidity is the documented top cause of silent dispatch failures, so
it fails here rather than at a dispatcher that no longer recognises the call.

Wires tests/token2022 into the CI test list; a test CI does not run is not a
gate. 216 passing, up from 209.
Layer 3 of the four-layer propagation, which slice 1 skipped. Three call sites in
the canonical example contract: the legacy read, the cached read a cached-track
contract must use instead once a cached invoke has fired, and the fee predicate --
showing that feeBps gates whether to measure the delivered amount rather than
being multiplied, since the real fee is capped by maximum_fee which mint_info
deliberately does not carry.

Compiles; the solidity suite is 223 passing.
…use an armed hook

Slice 2 items 1 and 2. They land together because the guard uses the value the
constructor now already holds.

Both wrapper constructors take decimals from mint_info instead of parsing the mint
with load_mint. decimals is the only mint fact either needs, so the Solidity mint
parser leaves the wrapper path entirely: no length bound to guess, no TLV read in
Solidity, and structure validated where it belongs, by SPL's unpack inside the
program. Each reads on its own track -- legacy via HelperProgram, cached via
SplCached -- because verify_call refuses a legacy cross-state read once a cached
invoke has fired, so a cached contract reading the legacy home would fail
mid-transaction.

Three paths can bring a wrapper into existence, and all three now refuse an armed
hook: the factory, and each constructor. The second constructor matters because
the deploy scripts call it directly and the factory gate never sees them. The
factory checks before deploying rather than relying on a failing CREATE, which
would burn the caller's gas and revert without saying why.

A present-but-unarmed hook is accepted, and the tests pin it. Its program_id is
zero, no CPI is reached, and the mint transfers perfectly well -- refusing on
presence would reject most real Token-2022 mints.

The error names both the mint and the hook so a refusal is diagnosable. Tests
assert the wiring that can silently regress: each path keys on the armed hook,
each reads its own track, and neither wrapper parses mint bytes any more. Suite is
232 passing, up from 223.
…omputed

Slice 2 item 5, and the last of it. Both wrappers emitted the caller-requested
value verbatim, which is wrong the moment a transfer fee is armed: SPL credits the
destination less than was asked for, so every downstream holder of that event saw
a number that never arrived.

Measured rather than computed, for a reason that is not stylistic. SPL caps the fee
at maximum_fee, so ceil(amount * bps / 10_000) is wrong above the cap -- and
mint_info deliberately does not carry maximum_fee, because adding it would only
move SPL's arithmetic into Solidity. feeBps is therefore a predicate: it decides
whether the extra balance reads are worth paying for, and legacy and zero-fee
transfers pay nothing. Each wrapper reads on its own track.

The self-transfer case flagged during the design pass turned out to be a real bug
in the first cut of this change, not a hypothetical. Sending to yourself with an
armed fee debits value and credits value minus fee, so the account nets MINUS the
fee -- after minus before underflows, and Solidity 0.8 reverts. ERC-20
self-transfer must not revert. Measuring the loss instead gives the delivered
amount in both directions, and it is one conditional.

Tested through the repo's pure-logic mirror convention: both directions, the
uncapped and capped fee showing why a bps computation would be wrong, and a guard
that neither wrapper carries SPL's arithmetic. Suite is 237 passing, up from 232.
Slice 4. The token program is part of the ATA seeds, so an ATA for a Token-2022
mint sits at a different address than the same mint under legacy SPL Token.
UserPda.ataForKey hardcoded the legacy program, so all three raw-pubkey call sites
derived one address while HelperProgram.create_ata_for_key created another, and the
transfer then targeted an account that never existed. That broke bridge-out for
every Token-2022 mint, extension-free ones included -- a bug independent of any
extension.

Adds ataForKeyWithProgram beside ataForKey, mirroring how ataWithProgram already
sits beside ata, and migrates all three callers to resolve the program from
mint_info: bridgeOutToSolana, ensureRecipientAta, and RomeBridgeWithdraw. Today's
bridged assets are legacy SPL, so this is behaviour-neutral now and correct when
that changes.

No program-aware batch variant. atas has no caller outside its own test wrapper, so
building one would be surface with no consumer; it is documented as legacy-only
instead and gains a variant when something needs it. This deviates from the plan,
which said to fix both sites, and the reason is that only one of them is actually
broken.

UserPda.ata needed no change and its comment claimed otherwise -- it delegates to
HelperProgram.ata, which resolves the program from the mint's owner in Rust. The
comment is corrected and a test pins it, because the next reader would otherwise
"fix" a working path.

Byte-identity of the derivation is not asserted here: it runs through Rome's
find_program_address precompile, which hardhat does not provide, for the same
reason tests/cpi/UserPda.test.ts gates its integration cases on a live stack. That
is funded-suite work. The wiring is asserted, which is what can silently regress.
Suite is 240 passing, up from 237.
The previous commit added ataForKeyWithProgram and made three callers pass a
program they had to look up themselves. That was the wrong precedent to copy:
ataWithProgram exists as an escape hatch for callers that already know the
program, is unused, and is documented as reserved. The working primary is
ata(address,bytes32), which resolves the program from the mint's owner inside
HelperProgram and asks nothing of its callers.

So ataForKey now does the same. One function, correct for legacy SPL Token and
Token-2022 alike, and callers just call it. That removes the two problems the
sibling created: a legacy-only entry point left reachable in a shared library
where a doc comment is the only thing discouraging its use, and three call sites
each having to remember to resolve and thread a program.

It becomes view rather than pure, which is why it was not written this way
originally. That costs nothing here -- all three callers already mutate state, and
the mint read moved from the caller into the helper rather than being added.

ataWithProgram is untouched and stays as the explicit-program escape hatch; an
over-broad edit in the previous commit had removed it, restored here. atas is
still legacy-only with no caller outside its test wrapper, documented rather than
given a per-mint resolve it would pay for N times.

241 passing.
_balance_of copied balanceOf's body verbatim, because balanceOf was external and
so not callable internally. The legacy wrapper's is public, which is why that one
just called it. Made the cached one public too and deleted the duplicate.

Same class of mistake as the ataForKeyWithProgram sibling: a second entry point
for something that already existed, instead of adjusting the existing one.
…ected

Cleanup pass over the edits. parseMint was left orphaned by the wrapper change --
its only remaining caller is a bench probe -- but it still rejected any mint longer
than 82 bytes, which is a latent copy of the bug this work exists to fix, waiting
for its next caller. Fixed in place rather than left: the base layout it parses is
byte-identical under Token-2022, with the account-type byte and TLV region trailing
it, so the guard only has to reject SHORT data. load_mint now also checks the
account is owned by a token program at all, which it previously read and discarded.

The wrappers do not use either any more -- they take decimals from mint_info -- and
the doc says so, so nobody reintroduces mint parsing on that path.

Two comments in erc20spl.sol still described ataForKey as a "deterministic two-step"
derivation with a hardcoded program. It now resolves the token program from the
mint, so both are corrected to say why: the program is part of the ATA seeds, so
assuming it derives an address create_ata_for_key never creates.

Also simplified a guard I had written as `!= LEN && <= LEN`, which is just `< LEN`.

241 passing.
The admission tests asserted on source text. That catches a deleted check, but it
would pass just as happily if the check were unreachable, or if a wrapper refused
a present-but-*unarmed* hook — the failure that would reject most real Token-2022
mints.

mint_info is the only precompile either constructor calls, so a stateless mock
installed at the two precompile addresses with hardhat_setCode is enough to run
them for real. It derives its answer from the mint id, which keeps it stateless
under setCode (code is copied, storage is not).

Presence is a separate byte from arming in that layout, deliberately. The first
version derived the extension bitmap from the armed state and so could not
express a present-but-inert hook — which meant a wrapper keying on presence
passed every test. With them separated, that mutation fails two.

The header comment claimed behaviour was the funded matrix's job because no
armed-hook mint existed to point a run at. Both halves are now wrong: it is a
committed fixture the CI validator loads at genesis, and the refusal against it
is asserted on a live stack in the tests repo.
…eir source

Three source-text assertions were carrying weight they could not hold.

Track: "each wrapper reads mint_info on its own track" was a grep. Installing the
mock at one precompile address at a time makes it behaviour — the wrapper whose
home is absent must fail to construct. It matters because verify_call refuses a
legacy cross-state read once a cached invoke has fired, so a cached wrapper
reading the legacy home fails mid-transaction, on a path no local test reaches.

Derivation: ata-derivation asserted source and deferred byte-identity to a funded
run because hardhat has no find_program_address. Byte-identity against Solana does
need a chain, but the property that actually broke bridge-out does not — an ATA's
seeds include the token program, so two mints differing in nothing but the program
they report must derive to different addresses. Mocking find_program_address as a
hash of its inputs is enough to observe that. Hardcoding the program back, which is
the original bug, fails the test.

Factory: its gate was source-only. The add-existing-mint path needs no mint
creation, so mocking mint_info runs it end to end — an armed hook reverts before
any CREATE, an inert one registers a wrapper.

Mutations verified for each: presence-instead-of-armed fails 2, cached-reads-legacy
fails 2, hardcoded-program fails 1.
…using it

add_spl_token_with_metadata exists to take a wrapper's name and symbol from the
mint rather than from whoever registers it, but it looked only in the Metaplex
account. A Token-2022 mint carries its identity inside the mint itself, under its
own metadata authority — MetadataPointer alongside TokenMetadata — so for those
mints the function reverted "Metadata does not exist" about a mint that plainly
had metadata, in a place it did not look.

Native is read first, because it is the source closest to the mint; Metaplex stays
the fallback and is what legacy SPL mints use. Strictly more inputs now succeed,
and the message when none is found says which condition failed.

add_spl_token_no_metadata is untouched. Choosing your own label stays allowed:
anyone can deploy their own factory and their own wrapper on any chain, and
curation is what answers that — Rome's registry already declines to carry
permissionless registrations. A gate here would be a restriction neither Solana
nor Ethereum has. Symbol uniqueness is likewise untouched: that path already
derived symbols from Metaplex, so this introduces no new class of collision.

The TLV walk lives in SplTokenLib beside the mint parsing already there, bounds
every length against the extension's own end, and treats absence as a value rather
than an error. Asserted against the real ai16z mint bytes committed as a CI
fixture, following tests/oracle/PythPullParser.test.ts.

Not read: a MetadataPointer aimed at a separate account. Those fall through to the
Metaplex attempt — today's behaviour — because the self-referential shape is what
real mints use and is the one verified against actual bytes.

Mutations: wrong extension type, wrong payload offset, and skipping the native read
each fail a test.
Its input is a mint account — data someone else controls — and every length in it
is attacker-chosen, so the parser has to survive a hostile one rather than trust it.

Five cases: an entry claiming more bytes than the account holds, a zero-length
entry of the type being looked for, a name length larger than its own entry, a
buffer with no terminator, and truncated or empty accounts.

The no-terminator case is the one worth having. Every entry advances the cursor by
at least its 4-byte header, so a buffer of zero-length entries cannot loop forever
— but that is a property of the code rather than of the format, and an unbounded
loop over attacker data is the failure mode this parser most needs to not have.
@anil-rome
anil-rome marked this pull request as ready for review July 25, 2026 13:45
@anil-rome
anil-rome merged commit 62949e1 into master Jul 25, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant