From cb74725d7fe35d3c03db5c71ed904249715f9e04 Mon Sep 17 00:00:00 2001 From: Anil Kumar <150643132+anil-rome@users.noreply.github.com> Date: Sat, 25 Jul 2026 07:11:28 +0300 Subject: [PATCH 01/12] feat(token2022): declare mint_info on both precompile interfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/ci.yml | 3 +- contracts/interface.sol | 26 +++++++++ tests/token2022/mint-info.selectors.test.ts | 65 +++++++++++++++++++++ 3 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 tests/token2022/mint-info.selectors.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60c2578..13ced6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,7 +51,8 @@ jobs: tests/cpi/CpiError.test.ts \ tests/cpi/PdaDeriver.test.ts \ tests/cpi/SolanaConstants.test.ts \ - tests/cpi/UserPda.test.ts + tests/cpi/UserPda.test.ts \ + tests/token2022/*.test.ts tx-origin-ban: # Strict gate per cardo-foundation section 9 + section 7 Task 14. diff --git a/contracts/interface.sol b/contracts/interface.sol index 54b1160..4874207 100644 --- a/contracts/interface.sol +++ b/contracts/interface.sol @@ -117,6 +117,19 @@ interface ISplCached { // 0xf9827227 — derives ATA(external_auth(user), mint) internally for an // arbitrary mint; mirror of account(address) with an explicit mint. function account(address user, bytes32 mint) external view returns(Account memory); + + // 0xe24bf5d4 — mint facts, ARMED not merely present. + // hookProgram and feeBps are zero when the extension is absent OR present + // and inert (a zero hook program_id fires no CPI; zero bps skips the fee + // path), so `hookProgram != 0` is the question worth asking. `extensions` + // reports presence, bit N per ExtensionType discriminant N. + function mint_info(bytes32 mint) external view returns ( + bytes32 tokenProgram, + uint8 decimals, + bytes32 hookProgram, + uint16 feeBps, + uint32 extensions + ); } interface IAssociatedSplCached { @@ -283,6 +296,19 @@ interface IHelperProgram { // actual delegate is another, then transferFrom reverts. Collapses // 5 v1 dispatches into one. function allowance_of(address owner, address spender, bytes32 mint) external view returns (uint64); + + // 0xe24bf5d4 — mint facts, ARMED not merely present. + // hookProgram and feeBps are zero when the extension is absent OR present + // and inert (a zero hook program_id fires no CPI; zero bps skips the fee + // path), so `hookProgram != 0` is the question worth asking. `extensions` + // reports presence, bit N per ExtensionType discriminant N. + function mint_info(bytes32 mint) external view returns ( + bytes32 tokenProgram, + uint8 decimals, + bytes32 hookProgram, + uint16 feeBps, + uint32 extensions + ); // deposit gas-token from ata function deposit_from_ata(uint256 wei_) external; } diff --git a/tests/token2022/mint-info.selectors.test.ts b/tests/token2022/mint-info.selectors.test.ts new file mode 100644 index 0000000..112014a --- /dev/null +++ b/tests/token2022/mint-info.selectors.test.ts @@ -0,0 +1,65 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { keccak256, stringToBytes, toFunctionSelector } from "viem"; +import { readFileSync } from "node:fs"; + +// mint_info selector lock, both directions. +// +// The signature is declared twice in interface.sol — once on the legacy +// HelperProgram and once on the cached SplCached — because a contract must read +// from the track it is already on. Both must dispatch on the SAME selector so +// callers write one call either way, and both must match the const the Rome EVM +// program dispatches on. A signature edit on either side fails here rather than +// silently reaching a dispatcher that no longer recognises it. + +const MINT_INFO = "0xe24bf5d4"; +const SIG = "mint_info(bytes32)"; + +function selectorOf(signature: string): `0x${string}` { + return keccak256(stringToBytes(signature)).slice(0, 10) as `0x${string}`; +} + +function abiOf(iface: string) { + const p = `artifacts/contracts/interface.sol/${iface}.json`; + return JSON.parse(readFileSync(p, "utf8")).abi as any[]; +} + +describe("mint_info — selector parity with the Rome EVM program", () => { + it(`${SIG} = ${MINT_INFO}`, () => { + assert.equal(selectorOf(SIG), MINT_INFO); + }); + + for (const iface of ["IHelperProgram", "ISplCached"]) { + it(`${iface} declares it, and the compiled selector matches`, () => { + const entry = abiOf(iface).find( + (e) => e.type === "function" && e.name === "mint_info", + ); + assert.ok(entry, `${iface} must declare mint_info`); + assert.equal(toFunctionSelector(entry), MINT_INFO); + }); + + it(`${iface} returns the five fields the program encodes`, () => { + const entry = abiOf(iface).find( + (e) => e.type === "function" && e.name === "mint_info", + )!; + assert.deepEqual( + entry.outputs.map((o: any) => `${o.type} ${o.name}`), + [ + "bytes32 tokenProgram", + "uint8 decimals", + "bytes32 hookProgram", + "uint16 feeBps", + "uint32 extensions", + ], + "field order is the program's ABI encoding order", + ); + }); + + it(`${iface}.mint_info is a view`, () => { + const entry = abiOf(iface).find( + (e) => e.type === "function" && e.name === "mint_info", + )!; + assert.equal(entry.stateMutability, "view"); + }); + } +}); From fff7afdcf4971d2fe967c36f1f27c9ffdfd8b99d Mon Sep 17 00:00:00 2001 From: Anil Kumar <150643132+anil-rome@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:00:43 +0300 Subject: [PATCH 02/12] docs(token2022): example call sites for mint_info 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. --- contracts/examples/helper.sol | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/contracts/examples/helper.sol b/contracts/examples/helper.sol index a5d3aa8..729eb96 100644 --- a/contracts/examples/helper.sol +++ b/contracts/examples/helper.sol @@ -87,6 +87,29 @@ contract helper_example { bytes32 pda_ = HelperProgram.pda(msg.sender); return pda_; } + + // Ask what a mint actually is before acting on it. hookProgram and feeBps + // report the ARMED state — both are zero when the extension is absent OR + // present and inert — so `hookProgram != 0` is the question worth asking; + // checking presence instead would reject most real Token-2022 mints. + function mint_info() external view returns (bytes32, uint8, bytes32, uint16, uint32) { + return HelperProgram.mint_info(SystemProgram.mint_id()); + } + + // A cached-track contract must read from its own track: once a cached + // invoke has fired in the transaction, verify_call refuses a legacy + // cross-state read. Same selector, same answer. + function mint_info_cached(bytes32 mint) external view returns (bytes32, uint8, bytes32, uint16, uint32) { + return SplCached.mint_info(mint); + } + + // Only spend the extra read when a fee is actually armed: measure the + // delivered amount instead of computing it, since the real fee is capped by + // maximum_fee, which mint_info deliberately does not carry. + function is_fee_armed(bytes32 mint) external view returns (bool) { + (, , , uint16 feeBps, ) = HelperProgram.mint_info(mint); + return feeBps > 0; + } function deposit_from_ata() external { (bool success, ) = address(HelperProgram).delegatecall( abi.encodeWithSignature("deposit_from_ata(uint256)", 1 ether) From 2fe3ed5938615c30f7b051bfbcd98d8ff3210533 Mon Sep 17 00:00:00 2001 From: Anil Kumar <150643132+anil-rome@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:46:02 +0300 Subject: [PATCH 03/12] feat(token2022): constructors read mint_info, and all three paths refuse 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. --- contracts/erc20spl/erc20spl.sol | 18 +++++- contracts/erc20spl/erc20spl_cached.sol | 14 ++++- contracts/erc20spl/erc20spl_factory.sol | 13 ++++- tests/token2022/admission.test.ts | 75 +++++++++++++++++++++++++ 4 files changed, 114 insertions(+), 6 deletions(-) create mode 100644 tests/token2022/admission.test.ts diff --git a/contracts/erc20spl/erc20spl.sol b/contracts/erc20spl/erc20spl.sol index 424814e..414228c 100644 --- a/contracts/erc20spl/erc20spl.sol +++ b/contracts/erc20spl/erc20spl.sol @@ -65,6 +65,11 @@ contract ERC20Users { } } +/// A mint whose transfer hook is armed cannot be wrapped: the hook requires +/// extra accounts that no transfer path in this wrapper supplies. Unarmed hooks +/// are inert and are accepted. +error ArmedTransferHookUnsupported(bytes32 mint, bytes32 hookProgram); + contract SPL_ERC20 is IERC20, IERC20Metadata { // SystemProgram bytes32 public constant system_program_id = 0x0000000000000000000000000000000000000000000000000000000000000000; @@ -91,11 +96,20 @@ contract SPL_ERC20 is IERC20, IERC20Metadata { string memory symbol_, ERC20Users users_ ) { - SplTokenLib.SplMint memory mint = SplTokenLib.load_mint(_mint_id, _cpi_program); + // decimals is the only mint fact this wrapper needs, and mint_info + // supplies it without parsing mint bytes in Solidity. It also tells us + // whether a transfer hook is ARMED, which this wrapper cannot honour — + // an armed hook needs extra accounts no transfer path here supplies, so + // the wrapper refuses to exist rather than reverting on every transfer. + // A present-but-unarmed hook is inert and must pass. + (, uint8 mint_decimals, bytes32 hook_program, ,) = HelperProgram.mint_info(_mint_id); + if (hook_program != bytes32(0)) { + revert ArmedTransferHookUnsupported(_mint_id, hook_program); + } cpi_program = _cpi_program; mint_id = _mint_id; - decimals = mint.decimals; + decimals = mint_decimals; _name = name_; _symbol = symbol_; _users = users_; diff --git a/contracts/erc20spl/erc20spl_cached.sol b/contracts/erc20spl/erc20spl_cached.sol index ce87f04..74cfafa 100644 --- a/contracts/erc20spl/erc20spl_cached.sol +++ b/contracts/erc20spl/erc20spl_cached.sol @@ -12,7 +12,7 @@ import { } from "../interface.sol"; import {AccountReader} from "../cpi/AccountReader.sol"; import {Convert} from "../convert.sol"; -import {ERC20Users} from "./erc20spl.sol"; +import {ERC20Users, ArmedTransferHookUnsupported} from "./erc20spl.sol"; /// @title SPL_ERC20_cached /// @notice Cache-based ERC20 wrapper around an SPL mint. Replaces the @@ -55,10 +55,18 @@ contract SPL_ERC20_cached is IERC20, IERC20Metadata { string memory symbol_, ERC20Users users_ ) { - SplTokenLib.SplMint memory mint = SplTokenLib.load_mint(_mint_id, _cpi_program); + // Read on this contract's own track: once a cached invoke has fired in a + // transaction, verify_call refuses a legacy cross-state read. Same + // selector, same answer. An armed hook is refused here too — the cached + // track additionally cannot stage one at all, since the processor it runs + // in-process would reach a real CPI. + (, uint8 mint_decimals, bytes32 hook_program, ,) = SplCached.mint_info(_mint_id); + if (hook_program != bytes32(0)) { + revert ArmedTransferHookUnsupported(_mint_id, hook_program); + } cpi_program = _cpi_program; mint_id = _mint_id; - decimals = mint.decimals; + decimals = mint_decimals; _name = name_; _symbol = symbol_; _users = users_; diff --git a/contracts/erc20spl/erc20spl_factory.sol b/contracts/erc20spl/erc20spl_factory.sol index 005615e..978c991 100644 --- a/contracts/erc20spl/erc20spl_factory.sol +++ b/contracts/erc20spl/erc20spl_factory.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; -import {ERC20Users} from "./erc20spl.sol"; +import {ERC20Users, ArmedTransferHookUnsupported} from "./erc20spl.sol"; import {SPL_ERC20_cached} from "./erc20spl_cached.sol"; import {MplTokenMetadataLib} from "../mpl_token_metadata/lib.sol"; import {SplTokenLib} from "../spl_token/spl_token.sol"; @@ -51,6 +51,17 @@ contract ERC20SPLFactory { bytes32 symbolHash = keccak256(bytes(symbol)); _check_symbol_hash_exists(symbolHash); + // Refuse an armed transfer hook before deploying, not after. The wrapper + // constructor refuses it too, but a factory that only found out via a + // failing CREATE would burn the caller's gas and leave a confusing + // revert; this check names the reason. A present-but-unarmed hook is + // inert and is accepted — refusing on presence would reject most real + // Token-2022 mints. + (, , bytes32 hook_program, ,) = HelperProgram.mint_info(mint); + if (hook_program != bytes32(0)) { + revert ArmedTransferHookUnsupported(mint, hook_program); + } + // Deploy the cache-track wrapper. `SPL_ERC20_cached` exposes the // identical IERC20 + IERC20Metadata surface as the prior // `SPL_ERC20`, but dispatches every mutating SPL operation diff --git a/tests/token2022/admission.test.ts b/tests/token2022/admission.test.ts new file mode 100644 index 0000000..a4179c0 --- /dev/null +++ b/tests/token2022/admission.test.ts @@ -0,0 +1,75 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +// Admission is keyed on the ARMED hook, at every path that can bring a wrapper +// into existence. Three such paths exist: the factory, and each wrapper's own +// constructor — the second reachable directly by the deploy scripts, which the +// factory gate never sees. +// +// These assert the wiring, which is what can silently regress: that each path +// declares the error and reads mint_info on its own track. Behaviour against a +// real armed-hook mint is the funded matrix's job; no armed-hook 2022 mint exists +// on devnet to point a local run at yet. + +function src(p: string): string { + return readFileSync(`contracts/${p}`, "utf8"); +} +function abi(iface: string) { + return JSON.parse( + readFileSync(`artifacts/contracts/interface.sol/${iface}.json`, "utf8"), + ).abi as any[]; +} + +const PATHS: Array<[string, string, string]> = [ + ["factory", "erc20spl/erc20spl_factory.sol", "HelperProgram"], + ["legacy wrapper ctor", "erc20spl/erc20spl.sol", "HelperProgram"], + ["cached wrapper ctor", "erc20spl/erc20spl_cached.sol", "SplCached"], +]; + +describe("armed-hook admission", () => { + for (const [name, file, track] of PATHS) { + it(`${name} refuses an armed hook`, () => { + const s = src(file); + assert.match( + s, + /hook_program != bytes32\(0\)/, + "must key on the ARMED hook — a zero program_id is inert and must pass", + ); + assert.match(s, /revert ArmedTransferHookUnsupported\(/); + }); + + it(`${name} reads mint_info on its own track (${track})`, () => { + // 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. + assert.match(src(file), new RegExp(`${track}\\.mint_info\\(`)); + }); + } + + it("neither wrapper parses mint bytes in Solidity any more", () => { + for (const f of ["erc20spl/erc20spl.sol", "erc20spl/erc20spl_cached.sol"]) { + assert.doesNotMatch( + src(f), + /load_mint\(/, + `${f} must take decimals from mint_info, not by parsing the mint (I2)`, + ); + } + }); + + it("the error names the mint and the hook, so a failure is diagnosable", () => { + assert.match( + src("erc20spl/erc20spl.sol"), + /error ArmedTransferHookUnsupported\(bytes32 mint, bytes32 hookProgram\)/, + ); + }); + + it("mint_info is declared on both tracks with the same selector", () => { + for (const iface of ["IHelperProgram", "ISplCached"]) { + assert.ok( + abi(iface).some((e) => e.type === "function" && e.name === "mint_info"), + `${iface} must declare mint_info`, + ); + } + }); +}); From 2cd2897d258bd2db12626b8f4e108b4587916a5b Mon Sep 17 00:00:00 2001 From: Anil Kumar <150643132+anil-rome@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:55:18 +0300 Subject: [PATCH 04/12] feat(token2022): wrappers report the delivered amount, measured not computed 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. --- contracts/erc20spl/erc20spl.sol | 22 ++++++++- contracts/erc20spl/erc20spl_cached.sol | 35 ++++++++++++++- .../erc20spl/test/DeliveredAmountHelper.sol | 34 ++++++++++++++ .../test/DeliveredAmountHelperHarness.sol | 14 ++++++ tests/token2022/delivered-amount.test.ts | 45 +++++++++++++++++++ 5 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 contracts/erc20spl/test/DeliveredAmountHelper.sol create mode 100644 contracts/erc20spl/test/DeliveredAmountHelperHarness.sol create mode 100644 tests/token2022/delivered-amount.test.ts diff --git a/contracts/erc20spl/erc20spl.sol b/contracts/erc20spl/erc20spl.sol index 414228c..268813b 100644 --- a/contracts/erc20spl/erc20spl.sol +++ b/contracts/erc20spl/erc20spl.sol @@ -275,6 +275,16 @@ contract SPL_ERC20 is IERC20, IERC20Metadata { // the precompile derives `external_auth(msg.sender)` itself. // Callers still compute `user` for the `ensure_user` mapping-side-effect. user; + + // A transfer-fee mint credits the destination less than was requested, + // and the fee is capped by maximum_fee — which mint_info deliberately + // does not carry, because computing the fee here would duplicate SPL's + // arithmetic and be wrong at the cap. So the delta is measured, and the + // extra reads are paid for only when a fee is actually armed: feeBps is + // a predicate, not an operand. Read on this contract's own track. + (, , , uint16 feeBps, ) = HelperProgram.mint_info(mint_id); + bool fee_armed = feeBps > 0; + uint256 before = fee_armed ? balanceOf(to) : 0; // Auto-create the recipient's PDA-owned ATA on first transfer. // Without this, sending an SPL_ERC20 wrapper to a fresh address // reverts with "Token account does not exist" because the @@ -337,7 +347,17 @@ contract SPL_ERC20 is IERC20, IERC20Metadata { } require (success, string(Convert.revert_msg(result))); - emit Transfer(from, to, value); + // Self-transfer needs the other direction. Sending to yourself with an + // armed fee debits `value` and credits `value - fee`, so the account nets + // MINUS fee — `after - before` would underflow and revert, and ERC-20 + // self-transfer must not revert. Measuring the loss gives the delivered + // amount in both directions. + uint256 delivered = value; + if (fee_armed) { + uint256 now_ = balanceOf(to); + delivered = to == from ? value - (before - now_) : now_ - before; + } + emit Transfer(from, to, delivered); return true; } diff --git a/contracts/erc20spl/erc20spl_cached.sol b/contracts/erc20spl/erc20spl_cached.sol index 74cfafa..b1d1c72 100644 --- a/contracts/erc20spl/erc20spl_cached.sol +++ b/contracts/erc20spl/erc20spl_cached.sol @@ -207,6 +207,13 @@ contract SPL_ERC20_cached is IERC20, IERC20Metadata { function _transfer(address from, address to, uint256 value) internal returns (bool) { require(value <= type(uint64).max, "Transfer amount exceeds uint64"); _users.ensure_user(msg.sender); + + // Read the destination before the transfer only when a fee is armed; + // an unarmed or absent fee credits exactly `value`, so the common path + // pays nothing for this. + (, , , uint16 feeBps, ) = SplCached.mint_info(mint_id); + bool fee_armed = feeBps > 0; + uint256 before = fee_armed ? _balance_of(to) : 0; // Gate recipient ATA-create on existence (mirror approve #216): on the // common transfer-to-existing-holder path, skip the idempotent // AssociatedSplCached.create_ata round-trip. Overlay-aware via @@ -246,10 +253,36 @@ contract SPL_ERC20_cached is IERC20, IERC20Metadata { ); } require(ok, string(Convert.revert_msg(result))); - emit Transfer(from, to, value); + + // A transfer-fee mint credits the destination less than was requested, + // and the fee is capped by maximum_fee — which mint_info deliberately + // does not carry, because computing the fee here would duplicate SPL's + // arithmetic and be wrong at the cap. So measure the delta instead of + // computing it, and only pay for the extra reads when a fee is actually + // armed: feeBps is a predicate, not an operand. + // Self-transfer needs the other direction. Sending to yourself with an + // armed fee debits `value` and credits `value - fee`, so the account nets + // MINUS fee — `after - before` would underflow and revert, and ERC-20 + // self-transfer must not revert. Measuring the loss gives the delivered + // amount in both directions. + uint256 delivered = value; + if (fee_armed) { + uint256 now_ = _balance_of(to); + delivered = to == from ? value - (before - now_) : now_ - before; + } + emit Transfer(from, to, delivered); return true; } + /// Destination balance, overlay-aware, 0 when the ATA does not exist yet. + function _balance_of(address account) internal view returns (uint256) { + try SplCached.account(account, mint_id) returns (ISplCached.Account memory acc) { + return uint256(acc.amount); + } catch { + return 0; + } + } + /// @notice ERC-20: approve succeeds for any caller regardless of balance. /// SPL approve_checked writes the delegate fields on the owner ATA, /// so the ATA must exist. Auto-create if missing; the existence diff --git a/contracts/erc20spl/test/DeliveredAmountHelper.sol b/contracts/erc20spl/test/DeliveredAmountHelper.sol new file mode 100644 index 0000000..cf740db --- /dev/null +++ b/contracts/erc20spl/test/DeliveredAmountHelper.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// Pure mirror of the delivered-amount arithmetic in both wrappers, so the two +/// directions and the fee cap can be asserted without a chain. +/// +/// The wrappers MEASURE the delivered amount rather than computing it from +/// feeBps, because SPL caps the fee at maximum_fee — a bps computation is wrong +/// above the cap. `spl_fee` below exists only to demonstrate that, and is +/// deliberately not what the wrappers do. +library DeliveredAmountHelper { + /// What the wrappers compute, given balances observed around the transfer. + function delivered( + bool feeArmed, + bool selfTransfer, + uint256 value, + uint256 before, + uint256 afterBal + ) internal pure returns (uint256) { + if (!feeArmed) { + return value; + } + // Self-transfer nets minus the fee, so measure the loss instead; the + // other direction would underflow. + return selfTransfer ? value - (before - afterBal) : afterBal - before; + } + + /// SPL's own rule: ceil(amount * bps / 10_000), capped at maximumFee. + /// Present to show why the wrappers do not compute the fee themselves. + function spl_fee(uint256 amount, uint16 bps, uint64 maximumFee) internal pure returns (uint256) { + uint256 raw = (amount * bps + 9_999) / 10_000; + return raw > maximumFee ? maximumFee : raw; + } +} diff --git a/contracts/erc20spl/test/DeliveredAmountHelperHarness.sol b/contracts/erc20spl/test/DeliveredAmountHelperHarness.sol new file mode 100644 index 0000000..4c85f6d --- /dev/null +++ b/contracts/erc20spl/test/DeliveredAmountHelperHarness.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {DeliveredAmountHelper} from "./DeliveredAmountHelper.sol"; + +/// Test-only external surface over the pure library. +contract DeliveredAmountHelperHarness { + function delivered(bool a, bool b, uint256 c, uint256 d, uint256 e) external pure returns (uint256) { + return DeliveredAmountHelper.delivered(a, b, c, d, e); + } + function spl_fee(uint256 amount, uint16 bps, uint64 maximumFee) external pure returns (uint256) { + return DeliveredAmountHelper.spl_fee(amount, bps, maximumFee); + } +} diff --git a/tests/token2022/delivered-amount.test.ts b/tests/token2022/delivered-amount.test.ts new file mode 100644 index 0000000..da24cf4 --- /dev/null +++ b/tests/token2022/delivered-amount.test.ts @@ -0,0 +1,45 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { network } from "hardhat"; + +// The wrappers report what actually arrived. Two things make that necessary +// rather than stylistic: SPL caps the fee at maximum_fee, so a bps computation +// is wrong above the cap; and a self-transfer nets MINUS the fee, so the naive +// direction underflows and would revert. + +describe("delivered amount", async () => { + const { viem } = await network.connect(); + const h = await viem.deployContract("DeliveredAmountHelperHarness"); + + it("no armed fee: delivered is exactly what was requested", async () => { + assert.equal(await h.read.delivered([false, false, 1000n, 0n, 0n]), 1000n); + }); + + it("armed fee: delivered is the measured credit, not the request", async () => { + // 100 bps on 1000 → 990 credited. + assert.equal(await h.read.delivered([true, false, 1000n, 500n, 1490n]), 990n); + }); + + it("self-transfer measures the loss, because the balance nets minus the fee", async () => { + // Holder had 5000, sends 1000 to itself at 100 bps → nets 4990. + // The naive direction (after - before) would underflow and revert. + assert.equal(await h.read.delivered([true, true, 1000n, 5000n, 4990n]), 990n); + }); + + it("the fee cap is why the wrappers measure instead of computing", async () => { + // Uncapped, bps and measurement agree. + assert.equal(await h.read.spl_fee([1000n, 100, 1_000_000n]), 10n); + // Capped, a bps computation over-reports the fee — so it would + // under-report the delivered amount. Measurement is unaffected. + assert.equal(await h.read.spl_fee([1_000_000n, 100, 50n]), 50n); + }); + + it("neither wrapper computes a fee in Solidity", () => { + for (const f of ["erc20spl.sol", "erc20spl_cached.sol"]) { + const s = readFileSync(`contracts/erc20spl/${f}`, "utf8"); + assert.doesNotMatch(s, /10_?000/, `${f} must not carry SPL's bps arithmetic`); + assert.match(s, /feeBps > 0/, `${f} must use feeBps as a predicate`); + } + }); +}); From 8aac3ab1335547f39ef27e300641fc7fe8bf9064 Mon Sep 17 00:00:00 2001 From: Anil Kumar <150643132+anil-rome@users.noreply.github.com> Date: Sat, 25 Jul 2026 09:01:50 +0300 Subject: [PATCH 05/12] fix(token2022): derive raw-pubkey ATAs with the mint's own token program 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. --- contracts/bridge/RomeBridgeWithdraw.sol | 6 ++- contracts/cpi/UserPda.sol | 33 ++++++++++++-- contracts/cpi/test/UserPdaWrapper.sol | 5 +++ contracts/erc20spl/erc20spl.sol | 13 +++++- tests/token2022/ata-derivation.test.ts | 58 +++++++++++++++++++++++++ 5 files changed, 108 insertions(+), 7 deletions(-) create mode 100644 tests/token2022/ata-derivation.test.ts diff --git a/contracts/bridge/RomeBridgeWithdraw.sol b/contracts/bridge/RomeBridgeWithdraw.sol index a6da25e..ac835a4 100644 --- a/contracts/bridge/RomeBridgeWithdraw.sol +++ b/contracts/bridge/RomeBridgeWithdraw.sol @@ -932,7 +932,11 @@ contract RomeBridgeWithdraw is ERC2771Context, RomeBridgeEvents { // Recipient ATA = getATA(recipientWallet, mint) — derived read (EthCall, // track-neutral, never locks the tx track). - bytes32 toAta = UserPda.ataForKey(solanaRecipient, mint); + // The token program is part of the ATA seeds, so resolve it from the + // mint rather than assuming legacy. Today's bridged assets are legacy + // SPL, so this is behaviour-neutral now and correct if that changes. + (bytes32 tokenProgram, , , ,) = HelperProgram.mint_info(mint); + bytes32 toAta = UserPda.ataForKeyWithProgram(solanaRecipient, mint, tokenProgram); // Single legacy CPI: SPL transfer_checked from caller's PDA-owned ATA // to the recipient ATA, signed as external_auth(caller). diff --git a/contracts/cpi/UserPda.sol b/contracts/cpi/UserPda.sol index 3afc0d0..981ca27 100644 --- a/contracts/cpi/UserPda.sol +++ b/contracts/cpi/UserPda.sol @@ -26,10 +26,11 @@ library UserPda { return RomeEVMAccount.pda(user); } - /// User's Associated Token Account for the given mint, assuming the - /// classic SPL Token program (Tokenkeg...). Token-2022 ATAs use a - /// different derivation; adapters that support Token-2022 call - /// `ataWithProgram` directly. + /// User's Associated Token Account for the given mint. The token program is + /// resolved from the mint's own owner inside `HelperProgram.ata`, so this is + /// correct for Token-2022 as well. An earlier revision of this comment said + /// the opposite and sent readers to `ataWithProgram`; that is not needed + /// here, and "fixing" this path would break it. /// /// Delegates to `HelperProgram.ata(address, bytes32)` — selector /// `0xfeb1c647` on `0xff…09` (HelperProgram precompile; rome-evm- @@ -50,6 +51,9 @@ library UserPda { /// Derive an ATA for a raw Solana pubkey (pool-side, fee receiver, etc.). /// Used when the "wallet" isn't a Rome EVM user — e.g. Meteora pool's /// protocol token fee accumulator. + /// @dev Legacy SPL Token only — the token program is baked into the ATA + /// seeds, so this returns the WRONG address for a Token-2022 mint. + /// Callers that may see either program use `ataForKeyWithProgram`. function ataForKey(bytes32 ownerKey, bytes32 mint) internal pure @@ -98,6 +102,27 @@ library UserPda { /// bulker supply/borrow lists, multi-token swap UIs probing balances, /// portfolio screens enumerating per-mint balances). For single-mint or /// arbitrary-owner cases, prefer `ata(user, mint)` / `ataForKey(...)`. + /// @notice ATA for a raw Solana pubkey owner under an explicit token + /// program. The program is part of the ATA seeds, so a Token-2022 + /// mint derives a different address than the same mint would under + /// legacy SPL Token — pass the program the mint is actually owned + /// by, e.g. from `mint_info`. + function ataForKeyWithProgram(bytes32 ownerKey, bytes32 mint, bytes32 tokenProgram) + internal + pure + returns (bytes32) + { + return AssociatedSplToken.get_associated_token_address_with_program_id( + ownerKey, + mint, + tokenProgram, + SolanaConstants.ASSOCIATED_TOKEN_PROGRAM + ); + } + + /// @dev Legacy SPL Token only, same reason as `ataForKey`. No program-aware + /// batch exists because nothing calls this with a Token-2022 mint yet; + /// one lands when a consumer needs it rather than in advance. function atas(address user, bytes32[] memory mints) internal view diff --git a/contracts/cpi/test/UserPdaWrapper.sol b/contracts/cpi/test/UserPdaWrapper.sol index b94a51e..1c66015 100644 --- a/contracts/cpi/test/UserPdaWrapper.sol +++ b/contracts/cpi/test/UserPdaWrapper.sol @@ -19,6 +19,11 @@ contract UserPdaWrapper { function ataForKey(bytes32 ownerKey, bytes32 mint) external pure returns (bytes32) { return UserPda.ataForKey(ownerKey, mint); } + function ataForKeyWithProgram(bytes32 ownerKey, bytes32 mint, bytes32 tokenProgram) + external pure returns (bytes32) + { + return UserPda.ataForKeyWithProgram(ownerKey, mint, tokenProgram); + } function ataWithProgram(address user, bytes32 mint, bytes32 tokenProgram) external diff --git a/contracts/erc20spl/erc20spl.sol b/contracts/erc20spl/erc20spl.sol index 268813b..249b1da 100644 --- a/contracts/erc20spl/erc20spl.sol +++ b/contracts/erc20spl/erc20spl.sol @@ -496,7 +496,12 @@ contract SPL_ERC20 is IERC20, IERC20Metadata { // `HelperProgram.ata(address, bytes32)` overload doesn't apply // — `UserPda.ataForKey` keeps the deterministic two-step Solana // find_program_address derivation for an arbitrary pubkey. - bytes32 to_ata = UserPda.ataForKey(solana_recipient, mint_id); + // The token program is part of the ATA seeds, so it must come from the + // mint rather than being assumed: with a hardcoded legacy program this + // derived one address while HelperProgram.create_ata_for_key created + // another, and the transfer then targeted an account that never existed. + (bytes32 token_program, , , ,) = HelperProgram.mint_info(mint_id); + bytes32 to_ata = UserPda.ataForKeyWithProgram(solana_recipient, mint_id, token_program); // CPI 1 — `AssociatedToken.CreateIdempotent` for the recipient // ATA via `HelperProgram.create_ata_for_key(wallet, mint)` @@ -594,7 +599,11 @@ contract SPL_ERC20 is IERC20, IERC20Metadata { // doesn't return a value, but the ATA address is deterministic // from (wallet, mint, spl_program). Same single `find_program_address` // syscall as the prior derivation inside AssociatedSplToken. - bytes32 to_ata = UserPda.ataForKey(solana_recipient, mint_id); + // Program-aware for the same reason as bridgeOutToSolana: the token + // program is part of the ATA seeds, so assuming legacy derives an + // address create_ata_for_key never creates. + (bytes32 token_program, , , ,) = HelperProgram.mint_info(mint_id); + bytes32 to_ata = UserPda.ataForKeyWithProgram(solana_recipient, mint_id, token_program); // Idempotent ATA-create via `HelperProgram.create_ata_for_key` // (selector `0xd258a69d`, shipped in a Rome EVM program upgrade). diff --git a/tests/token2022/ata-derivation.test.ts b/tests/token2022/ata-derivation.test.ts new file mode 100644 index 0000000..6eed010 --- /dev/null +++ b/tests/token2022/ata-derivation.test.ts @@ -0,0 +1,58 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +// The token program is part of the ATA seeds, so an ATA for a Token-2022 mint is +// at a DIFFERENT address than the same mint under legacy SPL Token. Deriving with +// a hardcoded legacy program — which is what ataForKey does — produced one address +// while HelperProgram.create_ata_for_key created another, so bridge-out targeted +// an account that never existed, for every Token-2022 mint including +// extension-free ones. +// +// Byte-identity of the derivation itself is NOT asserted here: it runs through +// Rome's find_program_address precompile, which hardhat does not provide (the same +// reason tests/cpi/UserPda.test.ts gates its integration cases on a live stack). +// That belongs to the funded suite. What is asserted here is the wiring, which is +// what can silently regress. + +function src(p: string): string { + return readFileSync(`contracts/${p}`, "utf8"); +} + +describe("ATA derivation is program-aware", () => { + it("both raw-pubkey callers pass a program resolved from the mint", () => { + for (const f of ["erc20spl/erc20spl.sol", "bridge/RomeBridgeWithdraw.sol"]) { + const s = src(f); + assert.match( + s, + /ataForKeyWithProgram\(/, + `${f} must derive with an explicit token program`, + ); + assert.match( + s, + /mint_info\(/, + `${f} must take that program from the mint, not assume it`, + ); + assert.doesNotMatch( + s, + /UserPda\.ataForKey\(/, + `${f} must not use the legacy-only derivation`, + ); + } + }); + + it("the legacy-only helpers say so, so they are not reached for by mistake", () => { + const s = src("cpi/UserPda.sol"); + // ataForKey and atas both bake in SPL_TOKEN_PROGRAM. + assert.match(s, /Legacy SPL Token only — the token program is baked/); + assert.match(s, /Legacy SPL Token only, same reason as `ataForKey`/); + }); + + it("ata() is documented as already program-aware, so nobody 'fixes' it", () => { + // It delegates to HelperProgram.ata, which resolves the program from the + // mint's owner in Rust. An earlier comment claimed the opposite. + const s = src("cpi/UserPda.sol"); + assert.match(s, /correct for Token-2022 as well/); + assert.doesNotMatch(s, /assuming the\s+\/\/\/ classic SPL Token program/); + }); +}); From 948d6b847e9d321891c64fba52d2b8b0cd578194 Mon Sep 17 00:00:00 2001 From: Anil Kumar <150643132+anil-rome@users.noreply.github.com> Date: Sat, 25 Jul 2026 09:09:28 +0300 Subject: [PATCH 06/12] refactor(token2022): fix ataForKey in place instead of adding a sibling 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. --- contracts/bridge/RomeBridgeWithdraw.sol | 6 +-- contracts/cpi/UserPda.sol | 59 ++++++++----------------- contracts/cpi/test/UserPdaWrapper.sol | 7 +-- contracts/erc20spl/erc20spl.sol | 13 +----- tests/token2022/ata-derivation.test.ts | 46 ++++++++++--------- 5 files changed, 46 insertions(+), 85 deletions(-) diff --git a/contracts/bridge/RomeBridgeWithdraw.sol b/contracts/bridge/RomeBridgeWithdraw.sol index ac835a4..a6da25e 100644 --- a/contracts/bridge/RomeBridgeWithdraw.sol +++ b/contracts/bridge/RomeBridgeWithdraw.sol @@ -932,11 +932,7 @@ contract RomeBridgeWithdraw is ERC2771Context, RomeBridgeEvents { // Recipient ATA = getATA(recipientWallet, mint) — derived read (EthCall, // track-neutral, never locks the tx track). - // The token program is part of the ATA seeds, so resolve it from the - // mint rather than assuming legacy. Today's bridged assets are legacy - // SPL, so this is behaviour-neutral now and correct if that changes. - (bytes32 tokenProgram, , , ,) = HelperProgram.mint_info(mint); - bytes32 toAta = UserPda.ataForKeyWithProgram(solanaRecipient, mint, tokenProgram); + bytes32 toAta = UserPda.ataForKey(solanaRecipient, mint); // Single legacy CPI: SPL transfer_checked from caller's PDA-owned ATA // to the recipient ATA, signed as external_auth(caller). diff --git a/contracts/cpi/UserPda.sol b/contracts/cpi/UserPda.sol index 981ca27..0ce4635 100644 --- a/contracts/cpi/UserPda.sol +++ b/contracts/cpi/UserPda.sol @@ -51,21 +51,6 @@ library UserPda { /// Derive an ATA for a raw Solana pubkey (pool-side, fee receiver, etc.). /// Used when the "wallet" isn't a Rome EVM user — e.g. Meteora pool's /// protocol token fee accumulator. - /// @dev Legacy SPL Token only — the token program is baked into the ATA - /// seeds, so this returns the WRONG address for a Token-2022 mint. - /// Callers that may see either program use `ataForKeyWithProgram`. - function ataForKey(bytes32 ownerKey, bytes32 mint) - internal - pure - returns (bytes32) - { - return AssociatedSplToken.get_associated_token_address_with_program_id( - ownerKey, - mint, - SolanaConstants.SPL_TOKEN_PROGRAM, - SolanaConstants.ASSOCIATED_TOKEN_PROGRAM - ); - } /// ATA with caller-supplied token program. Reserved for future Token-2022 /// support — no current adapter uses it. Kept internal so Slither won't @@ -84,34 +69,25 @@ library UserPda { ); } - /// Batch-derive N ATAs for one EVM user across N classic SPL Token mints. + /// @notice ATA for a raw Solana pubkey owner (not an EVM address), with the + /// token program resolved from the mint's own owner — so this is + /// correct for legacy SPL Token and Token-2022 alike. /// - /// Composes two primitives: - /// 1. `RomeEVMAccount.pda(user)` — single dispatch, returns AUTHORITY_PDA - /// 2. `PdasBatch.derive(seedGroups, ASSOCIATED_TOKEN_PROGRAM)` — N PDAs - /// in one syscall, each `[authority_pda, SPL_TOKEN_PROGRAM, mint_i]` + /// @dev The program is part of the ATA seeds, so assuming one derives an + /// address that `create_ata_for_key` never creates. Resolving here + /// rather than asking callers to pass a program keeps that impossible + /// to get wrong, and mirrors `ata(address,bytes32)`, which resolves the + /// same way inside HelperProgram. /// - /// CU vs N×`ata(user, mint)`: the per-mint `HelperProgram.ata` shortcut - /// is ~129K CU each (Hadrian 2026-05-14 baseline). This path is one - /// `find_program_address` for the AUTHORITY_PDA plus one - /// `pdas_batch_derive` for the N ATAs — pays off at N ≥ 2 mints, with the - /// per-PDA saving growing with N. Measurement post-deploy lands in - /// `the Rome design specs`. - /// - /// Use this when one user owns ATAs across multiple mints (Compound - /// bulker supply/borrow lists, multi-token swap UIs probing balances, - /// portfolio screens enumerating per-mint balances). For single-mint or - /// arbitrary-owner cases, prefer `ata(user, mint)` / `ataForKey(...)`. - /// @notice ATA for a raw Solana pubkey owner under an explicit token - /// program. The program is part of the ATA seeds, so a Token-2022 - /// mint derives a different address than the same mint would under - /// legacy SPL Token — pass the program the mint is actually owned - /// by, e.g. from `mint_info`. - function ataForKeyWithProgram(bytes32 ownerKey, bytes32 mint, bytes32 tokenProgram) + /// Reads via HelperProgram, so a cached-track contract must not call + /// this after staging a cached invoke — `verify_call` refuses a legacy + /// cross-state read at that point. No cached-track caller exists today. + function ataForKey(bytes32 ownerKey, bytes32 mint) internal - pure + view returns (bytes32) { + (bytes32 tokenProgram, , , ,) = HelperProgram.mint_info(mint); return AssociatedSplToken.get_associated_token_address_with_program_id( ownerKey, mint, @@ -120,9 +96,10 @@ library UserPda { ); } - /// @dev Legacy SPL Token only, same reason as `ataForKey`. No program-aware - /// batch exists because nothing calls this with a Token-2022 mint yet; - /// one lands when a consumer needs it rather than in advance. + /// @dev Legacy SPL Token only — unlike `ataForKey`, this bakes in + /// SPL_TOKEN_PROGRAM. It has no caller outside its test wrapper, so it + /// is left as-is rather than given a per-mint resolve it would pay for + /// N times; it gains one when a consumer needs it. function atas(address user, bytes32[] memory mints) internal view diff --git a/contracts/cpi/test/UserPdaWrapper.sol b/contracts/cpi/test/UserPdaWrapper.sol index 1c66015..5e6b052 100644 --- a/contracts/cpi/test/UserPdaWrapper.sol +++ b/contracts/cpi/test/UserPdaWrapper.sol @@ -16,14 +16,9 @@ contract UserPdaWrapper { return UserPda.ata(user, mint); } - function ataForKey(bytes32 ownerKey, bytes32 mint) external pure returns (bytes32) { + function ataForKey(bytes32 ownerKey, bytes32 mint) external view returns (bytes32) { return UserPda.ataForKey(ownerKey, mint); } - function ataForKeyWithProgram(bytes32 ownerKey, bytes32 mint, bytes32 tokenProgram) - external pure returns (bytes32) - { - return UserPda.ataForKeyWithProgram(ownerKey, mint, tokenProgram); - } function ataWithProgram(address user, bytes32 mint, bytes32 tokenProgram) external diff --git a/contracts/erc20spl/erc20spl.sol b/contracts/erc20spl/erc20spl.sol index 249b1da..268813b 100644 --- a/contracts/erc20spl/erc20spl.sol +++ b/contracts/erc20spl/erc20spl.sol @@ -496,12 +496,7 @@ contract SPL_ERC20 is IERC20, IERC20Metadata { // `HelperProgram.ata(address, bytes32)` overload doesn't apply // — `UserPda.ataForKey` keeps the deterministic two-step Solana // find_program_address derivation for an arbitrary pubkey. - // The token program is part of the ATA seeds, so it must come from the - // mint rather than being assumed: with a hardcoded legacy program this - // derived one address while HelperProgram.create_ata_for_key created - // another, and the transfer then targeted an account that never existed. - (bytes32 token_program, , , ,) = HelperProgram.mint_info(mint_id); - bytes32 to_ata = UserPda.ataForKeyWithProgram(solana_recipient, mint_id, token_program); + bytes32 to_ata = UserPda.ataForKey(solana_recipient, mint_id); // CPI 1 — `AssociatedToken.CreateIdempotent` for the recipient // ATA via `HelperProgram.create_ata_for_key(wallet, mint)` @@ -599,11 +594,7 @@ contract SPL_ERC20 is IERC20, IERC20Metadata { // doesn't return a value, but the ATA address is deterministic // from (wallet, mint, spl_program). Same single `find_program_address` // syscall as the prior derivation inside AssociatedSplToken. - // Program-aware for the same reason as bridgeOutToSolana: the token - // program is part of the ATA seeds, so assuming legacy derives an - // address create_ata_for_key never creates. - (bytes32 token_program, , , ,) = HelperProgram.mint_info(mint_id); - bytes32 to_ata = UserPda.ataForKeyWithProgram(solana_recipient, mint_id, token_program); + bytes32 to_ata = UserPda.ataForKey(solana_recipient, mint_id); // Idempotent ATA-create via `HelperProgram.create_ata_for_key` // (selector `0xd258a69d`, shipped in a Rome EVM program upgrade). diff --git a/tests/token2022/ata-derivation.test.ts b/tests/token2022/ata-derivation.test.ts index 6eed010..566f0e4 100644 --- a/tests/token2022/ata-derivation.test.ts +++ b/tests/token2022/ata-derivation.test.ts @@ -20,32 +20,34 @@ function src(p: string): string { } describe("ATA derivation is program-aware", () => { - it("both raw-pubkey callers pass a program resolved from the mint", () => { + it("ataForKey resolves the token program itself — callers cannot get it wrong", () => { + const s = src("cpi/UserPda.sol"); + // Fixed in place rather than given a sibling: a second entry point would + // leave the wrong one reachable, and callers would have to remember which. + assert.match( + s, + /function ataForKey\(bytes32 ownerKey, bytes32 mint\)\s+internal\s+view/, + "ataForKey must be view — it reads the mint to resolve the program", + ); + assert.match(s, /HelperProgram\.mint_info\(mint\)/); + assert.doesNotMatch( + s, + /ataForKeyWithProgram/, + "no explicit-program sibling: the one function is correct for both", + ); + }); + + it("callers just call it, and pass no program", () => { for (const f of ["erc20spl/erc20spl.sol", "bridge/RomeBridgeWithdraw.sol"]) { - const s = src(f); - assert.match( - s, - /ataForKeyWithProgram\(/, - `${f} must derive with an explicit token program`, - ); - assert.match( - s, - /mint_info\(/, - `${f} must take that program from the mint, not assume it`, - ); - assert.doesNotMatch( - s, - /UserPda\.ataForKey\(/, - `${f} must not use the legacy-only derivation`, - ); + assert.match(src(f), /UserPda\.ataForKey\(/, `${f} derives via UserPda`); } }); - it("the legacy-only helpers say so, so they are not reached for by mistake", () => { - const s = src("cpi/UserPda.sol"); - // ataForKey and atas both bake in SPL_TOKEN_PROGRAM. - assert.match(s, /Legacy SPL Token only — the token program is baked/); - assert.match(s, /Legacy SPL Token only, same reason as `ataForKey`/); + it("the batch variant is still legacy-only, and says so", () => { + // atas bakes in SPL_TOKEN_PROGRAM. It has no caller outside its test + // wrapper, so it is documented rather than given a per-mint resolve it + // would pay for N times. + assert.match(src("cpi/UserPda.sol"), /Legacy SPL Token only — unlike `ataForKey`/); }); it("ata() is documented as already program-aware, so nobody 'fixes' it", () => { From 3689cb00115bb4dd318c9ca24ae75d7089807f8e Mon Sep 17 00:00:00 2001 From: Anil Kumar <150643132+anil-rome@users.noreply.github.com> Date: Sat, 25 Jul 2026 09:16:23 +0300 Subject: [PATCH 07/12] refactor(token2022): call balanceOf instead of duplicating it _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. --- contracts/erc20spl/erc20spl_cached.sol | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/contracts/erc20spl/erc20spl_cached.sol b/contracts/erc20spl/erc20spl_cached.sol index b1d1c72..27bdd6f 100644 --- a/contracts/erc20spl/erc20spl_cached.sol +++ b/contracts/erc20spl/erc20spl_cached.sol @@ -117,7 +117,7 @@ contract SPL_ERC20_cached is IERC20, IERC20Metadata { /// Discovered 2026-05-25 during Hadrian V3 create-pool /// smoke (the Rome app). Cross-ref: /// rome-uniswap-v3/contracts/UniswapV3Pool.sol:486-490. - function balanceOf(address account) external view returns (uint256) { + function balanceOf(address account) public view returns (uint256) { try SplCached.account(account, mint_id) returns (ISplCached.Account memory acc) { return uint256(acc.amount); } catch { @@ -213,7 +213,7 @@ contract SPL_ERC20_cached is IERC20, IERC20Metadata { // pays nothing for this. (, , , uint16 feeBps, ) = SplCached.mint_info(mint_id); bool fee_armed = feeBps > 0; - uint256 before = fee_armed ? _balance_of(to) : 0; + uint256 before = fee_armed ? balanceOf(to) : 0; // Gate recipient ATA-create on existence (mirror approve #216): on the // common transfer-to-existing-holder path, skip the idempotent // AssociatedSplCached.create_ata round-trip. Overlay-aware via @@ -267,22 +267,13 @@ contract SPL_ERC20_cached is IERC20, IERC20Metadata { // amount in both directions. uint256 delivered = value; if (fee_armed) { - uint256 now_ = _balance_of(to); + uint256 now_ = balanceOf(to); delivered = to == from ? value - (before - now_) : now_ - before; } emit Transfer(from, to, delivered); return true; } - /// Destination balance, overlay-aware, 0 when the ATA does not exist yet. - function _balance_of(address account) internal view returns (uint256) { - try SplCached.account(account, mint_id) returns (ISplCached.Account memory acc) { - return uint256(acc.amount); - } catch { - return 0; - } - } - /// @notice ERC-20: approve succeeds for any caller regardless of balance. /// SPL approve_checked writes the delegate fields on the owner ATA, /// so the ATA must exist. Auto-create if missing; the existence From 10fdd6040c2950bee91dce592a9305bac4f9b8b6 Mon Sep 17 00:00:00 2001 From: Anil Kumar <150643132+anil-rome@users.noreply.github.com> Date: Sat, 25 Jul 2026 09:38:09 +0300 Subject: [PATCH 08/12] fix(token2022): parseMint accepts Token-2022, and stale comments corrected 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. --- contracts/erc20spl/erc20spl.sol | 14 +++++++------- contracts/spl_token/spl_token.sol | 19 +++++++++++++++++-- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/contracts/erc20spl/erc20spl.sol b/contracts/erc20spl/erc20spl.sol index 268813b..cf4e54e 100644 --- a/contracts/erc20spl/erc20spl.sol +++ b/contracts/erc20spl/erc20spl.sol @@ -494,8 +494,9 @@ contract SPL_ERC20 is IERC20, IERC20Metadata { // Recipient ATA pubkey — same canonical ATA derivation but for // the raw Solana recipient pubkey (not an EVM address), so the // `HelperProgram.ata(address, bytes32)` overload doesn't apply - // — `UserPda.ataForKey` keeps the deterministic two-step Solana - // find_program_address derivation for an arbitrary pubkey. + // — `UserPda.ataForKey` derives it for an arbitrary pubkey, resolving + // the token program from the mint so the address matches whatever + // `create_ata_for_key` will actually create. bytes32 to_ata = UserPda.ataForKey(solana_recipient, mint_id); // CPI 1 — `AssociatedToken.CreateIdempotent` for the recipient @@ -589,11 +590,10 @@ contract SPL_ERC20 is IERC20, IERC20Metadata { // expect the caller to be registered. _users.ensure_user(msg.sender); - // Derive the recipient ATA pubkey client-side via SystemProgram - // find_program_address — `create_ata_for_key` is an Invoke that - // doesn't return a value, but the ATA address is deterministic - // from (wallet, mint, spl_program). Same single `find_program_address` - // syscall as the prior derivation inside AssociatedSplToken. + // Derive the recipient ATA client-side — `create_ata_for_key` is an + // Invoke and returns nothing, but the address is deterministic from + // (wallet, mint, spl_program). The token program comes from the mint + // rather than being assumed, since it is part of the seeds. bytes32 to_ata = UserPda.ataForKey(solana_recipient, mint_id); // Idempotent ATA-create via `HelperProgram.create_ata_for_key` diff --git a/contracts/spl_token/spl_token.sol b/contracts/spl_token/spl_token.sol index 6574bba..1e16342 100644 --- a/contracts/spl_token/spl_token.sol +++ b/contracts/spl_token/spl_token.sol @@ -3,12 +3,16 @@ pragma solidity ^0.8.20; import "../interface.sol"; import {Convert} from "../convert.sol"; +import {SolanaConstants} from "../cpi/SolanaConstants.sol"; library SplTokenLib { bytes32 public constant SPL_TOKEN_PROGRAM = 0x06ddf6e1d765a193d9cbe146ceeb79ac1cb485ed5f5b37913a8cf5857eff00a9; // Tokenkeg.. uint256 internal constant SPL_MINT_LEN = 82; + + /// The account is not owned by a token program, so its bytes are not a mint. + error NotATokenMint(bytes32 token, bytes32 owner); uint256 internal constant SPL_TOKEN_ACCOUNT_MIN_LEN = 72; error InvalidTokenAccountDataLength(uint256 actual, uint256 expected); @@ -22,13 +26,24 @@ library SplTokenLib { Convert.COptionBytes32 freeze_authority; } + /// @dev The wrappers no longer use this — they take `decimals` from + /// `mint_info`, so no Solidity code parses mint bytes on their path. + /// It remains for general callers and is correct for Token-2022, whose + /// base layout is byte-identical and whose extensions trail it. function load_mint(bytes32 token, address cpi_program) internal view returns (SplMint memory mint) { - (,,,,, bytes memory data) = ICrossProgramInvocation(cpi_program).account_info(token); + (, bytes32 owner,,,, bytes memory data) = ICrossProgramInvocation(cpi_program).account_info(token); + if (owner != SolanaConstants.SPL_TOKEN_PROGRAM && owner != SolanaConstants.TOKEN_2022_PROGRAM) { + revert NotATokenMint(token, owner); + } return parseMint(data, token); } function parseMint(bytes memory data, bytes32 token) internal pure returns (SplMint memory mint) { - if (data.length != SPL_MINT_LEN) { + // A Token-2022 mint with extensions is >= SPL_MINT_LEN + 1: the base + // layout parsed below is byte-identical, and the account-type byte plus + // the TLV region trail it. An exact-length check here was the single + // thing that rejected every extension-bearing mint. + if (data.length < SPL_MINT_LEN) { revert InvalidMintDataLength(data.length, SPL_MINT_LEN, token); } From 67a34c485680971521b9404170d7f2ae7fcd6b1f Mon Sep 17 00:00:00 2001 From: Anil Kumar <150643132+anil-rome@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:55:52 +0300 Subject: [PATCH 09/12] test(token2022): execute the admission path instead of reading it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- contracts/erc20spl/test/MintInfoMock.sol | 61 ++++++++++++ tests/token2022/admission.test.ts | 114 ++++++++++++++++++++++- 2 files changed, 171 insertions(+), 4 deletions(-) create mode 100644 contracts/erc20spl/test/MintInfoMock.sol diff --git a/contracts/erc20spl/test/MintInfoMock.sol b/contracts/erc20spl/test/MintInfoMock.sol new file mode 100644 index 0000000..702cbe3 --- /dev/null +++ b/contracts/erc20spl/test/MintInfoMock.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +/// @title MintInfoMock +/// @notice Stands in for the `mint_info` precompile so wrapper constructors can +/// be *executed* in a local test, not merely read as source. +/// +/// @dev Installed at the precompile address with `hardhat_setCode`, which copies +/// code and not storage, so this must be stateless: the answer is derived +/// from the mint id itself. That also means one instance serves every case. +/// +/// Layout of the mint id, most-significant byte first: +/// +/// byte 0 decimals +/// byte 1 non-zero arms the transfer hook +/// bytes 2-3 transfer fee, basis points, big-endian +/// byte 4 non-zero marks the mint legacy rather than Token-2022 +/// byte 5 non-zero marks the hook PRESENT in the extension bitmap +/// +/// The remaining bytes are free, so a test can keep mint ids distinct while +/// asking for the same facts. +/// +/// Presence is deliberately separate from arming. A mint can carry the +/// TransferHook extension with a zero program_id — present, inert — and that +/// is the case the wrappers must accept. A mock that derived the bitmap from +/// the armed state could not express it, and so could not catch a wrapper +/// that refused on presence. Arming implies presence; the reverse does not +/// hold. +/// +/// Only the shape the wrappers consume is modelled. It is deliberately not +/// a Token-2022 implementation: `mint_info` is the whole surface either +/// constructor touches, which is why this is one function. +contract MintInfoMock { + bytes32 public constant TOKEN_2022 = + 0x06ddf6e1ee758fde18425dbce46ccddab61afc4d83b90d27febdf928d8a18bfc; + bytes32 public constant TOKEN_LEGACY = + 0x06ddf6e1d765a193d9cbe146ceeb79ac1cb485ed5f5b37913a8cf5857eff00a9; + + function mint_info(bytes32 mint) + external + pure + returns (bytes32 tokenProgram, uint8 decimals, bytes32 hookProgram, uint16 feeBps, uint32 extensions) + { + decimals = uint8(mint[0]); + + // Unarmed reads as zero, exactly as the program encodes it — a hook can + // be present and inert, and the wrappers must not refuse that. + hookProgram = mint[1] == 0 + ? bytes32(0) + : keccak256(abi.encodePacked("mint_info.mock.hook", mint)); + + feeBps = (uint16(uint8(mint[2])) << 8) | uint16(uint8(mint[3])); + tokenProgram = mint[4] == 0 ? TOKEN_2022 : TOKEN_LEGACY; + + // Presence bitmap. Independent of arming, because that is the whole + // point: bit 14 set with a zero hookProgram is a present-but-inert hook. + extensions = 0; + if (hookProgram != bytes32(0) || mint[5] != 0) extensions |= uint32(1) << 14; // TransferHook + if (feeBps != 0) extensions |= uint32(1) << 1; // TransferFeeConfig + } +} diff --git a/tests/token2022/admission.test.ts b/tests/token2022/admission.test.ts index a4179c0..3951868 100644 --- a/tests/token2022/admission.test.ts +++ b/tests/token2022/admission.test.ts @@ -1,16 +1,24 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; +import { network } from "hardhat"; // Admission is keyed on the ARMED hook, at every path that can bring a wrapper // into existence. Three such paths exist: the factory, and each wrapper's own // constructor — the second reachable directly by the deploy scripts, which the // factory gate never sees. // -// These assert the wiring, which is what can silently regress: that each path -// declares the error and reads mint_info on its own track. Behaviour against a -// real armed-hook mint is the funded matrix's job; no armed-hook 2022 mint exists -// on devnet to point a local run at yet. +// The first block asserts the wiring, which is what can silently regress: that +// each path declares the error and reads mint_info on its own track. The second +// executes the constructors against a mocked mint_info, so the refusal is +// observed rather than inferred from source. +// +// An earlier version of this comment said behaviour was "the funded matrix's job; +// no armed-hook 2022 mint exists on devnet to point a local run at yet." Both +// halves are now wrong: an armed-hook mint is a committed fixture +// (rome-evm-private ci/dump, built by ci/gen-t22-fixtures.sh) that the CI +// validator loads at genesis, and the refusal against it is asserted on a live +// stack in the tests repo. Nothing here waits on a funded chain. function src(p: string): string { return readFileSync(`contracts/${p}`, "utf8"); @@ -73,3 +81,101 @@ describe("armed-hook admission", () => { } }); }); + +// The block above reads source. That catches a deleted check, but it would pass +// just as happily if the check were unreachable, or if the wrapper refused an +// *unarmed* hook too — the failure that would reject most real Token-2022 mints. +// +// So run the constructors. `mint_info` is the only precompile either one calls, +// so putting MintInfoMock at the two precompile addresses is enough to execute +// them for real. The mock derives its answer from the mint id (byte 0 decimals, +// byte 1 arms the hook, bytes 2-3 fee bps), so no per-case setup is needed. +describe("armed-hook admission, executed", async () => { + const HELPER = "0xff00000000000000000000000000000000000009"; + const SPL_CACHED = "0xff00000000000000000000000000000000000005"; + + const conn = await network.connect(); + const { viem } = conn; + + const mock = await viem.deployContract("MintInfoMock"); + const client = await viem.getPublicClient(); + const code = await client.getCode({ address: mock.address }); + assert.ok(code && code !== "0x", "mock must have deployed code to install"); + for (const addr of [HELPER, SPL_CACHED]) { + await conn.provider.request({ method: "hardhat_setCode", params: [addr, code] }); + } + + /// byte 0 decimals · byte 1 arms the hook · bytes 2-3 fee bps · + /// byte 5 marks the hook present in the bitmap without arming it · rest distinct. + /// `hookPresent` defaults to whatever `hookArmed` is, since arming implies + /// presence; pass it explicitly to build the present-but-inert case. + function mintId( + decimals: number, + hookArmed: boolean, + feeBps: number, + tag: number, + hookPresent = hookArmed, + ): `0x${string}` { + const b = new Uint8Array(32); + b[0] = decimals; + b[1] = hookArmed ? 1 : 0; + b[2] = (feeBps >> 8) & 0xff; + b[3] = feeBps & 0xff; + b[5] = hookPresent ? 1 : 0; + b[31] = tag; + return `0x${Buffer.from(b).toString("hex")}` as `0x${string}`; + } + + const WRAPPERS = ["SPL_ERC20", "SPL_ERC20_cached"] as const; + + async function deployWrapper(name: string, mint: `0x${string}`) { + const users = await viem.deployContract("ERC20Users"); + return viem.deployContract(name, [ + mint, + "0xff00000000000000000000000000000000000008", + "Wrapped", + "WRAP", + users.address, + ]); + } + + for (const name of WRAPPERS) { + it(`${name} refuses an armed hook, and says which`, async () => { + await assert.rejects( + deployWrapper(name, mintId(6, true, 0, 1)), + (e: Error) => { + assert.match(e.message, /ArmedTransferHookUnsupported/); + return true; + }, + "an armed hook must be refused at construction, not at first transfer", + ); + }); + + it(`${name} accepts a present-but-unarmed hook`, async () => { + // The mint carries the extension — bit 14 is set in the bitmap — + // and its program_id is zero, so the processor's get_program_id() + // returns None and no CPI ever fires. Refusing this would refuse + // most real Token-2022 mints, so a wrapper keying on presence must + // fail this test. + const w = await deployWrapper(name, mintId(6, false, 0, 2, true)); + assert.equal(await w.read.decimals(), 6); + }); + + it(`${name} accepts an armed fee, which it handles by measuring`, async () => { + const w = await deployWrapper(name, mintId(9, false, 50, 3)); + assert.equal(await w.read.decimals(), 9, "decimals come from mint_info"); + }); + + it(`${name} takes decimals from mint_info rather than a constructor argument`, async () => { + const w = await deployWrapper(name, mintId(2, false, 0, 4)); + assert.equal(await w.read.decimals(), 2); + }); + } + + it("a fee and an armed hook together are still refused for the hook", async () => { + await assert.rejects( + deployWrapper("SPL_ERC20_cached", mintId(6, true, 50, 5)), + /ArmedTransferHookUnsupported/, + ); + }); +}); From d0a98e4b169373e19d7a19db90eaf577e667464f Mon Sep 17 00:00:00 2001 From: Anil Kumar <150643132+anil-rome@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:03:16 +0300 Subject: [PATCH 10/12] test(token2022): execute the track and derivation claims, not just their source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- contracts/erc20spl/test/MintInfoMock.sol | 30 ++++++++ tests/token2022/admission.test.ts | 93 +++++++++++++++++++++++- tests/token2022/ata-derivation.test.ts | 67 +++++++++++++++++ 3 files changed, 189 insertions(+), 1 deletion(-) diff --git a/contracts/erc20spl/test/MintInfoMock.sol b/contracts/erc20spl/test/MintInfoMock.sol index 702cbe3..b1a2573 100644 --- a/contracts/erc20spl/test/MintInfoMock.sol +++ b/contracts/erc20spl/test/MintInfoMock.sol @@ -30,12 +30,42 @@ pragma solidity 0.8.28; /// Only the shape the wrappers consume is modelled. It is deliberately not /// a Token-2022 implementation: `mint_info` is the whole surface either /// constructor touches, which is why this is one function. +interface ISeed { + struct Seed { + bytes item; + } +} + contract MintInfoMock { bytes32 public constant TOKEN_2022 = 0x06ddf6e1ee758fde18425dbce46ccddab61afc4d83b90d27febdf928d8a18bfc; bytes32 public constant TOKEN_LEGACY = 0x06ddf6e1d765a193d9cbe146ceeb79ac1cb485ed5f5b37913a8cf5857eff00a9; + /// The factory's constructor converts a program name before any mint is in + /// play, so installing this at the System precompile too is what makes the + /// factory deployable here. The value is irrelevant to the gate under test. + function base58_to_bytes32(bytes calldata) external pure returns (bytes32) { + return keccak256("mint_info.mock.base58"); + } + + /// Enough of `find_program_address` to make ATA derivation observable: the + /// answer is a hash of everything that went in, so two derivations differ + /// exactly when their seeds do. That is the property under test — an ATA's + /// seeds include the token program, so resolving it from the mint rather than + /// hardcoding it has to change the address. + function find_program_address(bytes32 program, ISeed.Seed[] calldata seeds) + external + pure + returns (bytes32, uint8) + { + bytes memory acc; + for (uint256 i = 0; i < seeds.length; ++i) { + acc = abi.encodePacked(acc, seeds[i].item); + } + return (keccak256(abi.encodePacked(program, acc)), uint8(255)); + } + function mint_info(bytes32 mint) external pure diff --git a/tests/token2022/admission.test.ts b/tests/token2022/admission.test.ts index 3951868..2ec61f8 100644 --- a/tests/token2022/admission.test.ts +++ b/tests/token2022/admission.test.ts @@ -101,7 +101,10 @@ describe("armed-hook admission, executed", async () => { const client = await viem.getPublicClient(); const code = await client.getCode({ address: mock.address }); assert.ok(code && code !== "0x", "mock must have deployed code to install"); - for (const addr of [HELPER, SPL_CACHED]) { + // Also the System precompile: the factory's constructor converts a program + // name through it before any mint is involved. + const SYSTEM = "0xff00000000000000000000000000000000000007"; + for (const addr of [HELPER, SPL_CACHED, SYSTEM]) { await conn.provider.request({ method: "hardhat_setCode", params: [addr, code] }); } @@ -178,4 +181,92 @@ describe("armed-hook admission, executed", async () => { /ArmedTransferHookUnsupported/, ); }); + + // The third admission path. It refuses before deploying rather than letting + // the wrapper's own constructor fail, so the caller does not pay for a CREATE + // that was always going to revert. The gate is the first thing the register + // path does after the symbol check, which is why it is reachable here without + // mocking the mint-creation machinery behind it. + it("the factory refuses an armed hook before it deploys anything", async () => { + const factory = await viem.deployContract("ERC20SPLFactory", [ + "0xff00000000000000000000000000000000000008", + ]); + await assert.rejects( + factory.write.add_spl_token_no_metadata([mintId(6, true, 0, 6), "Wrapped", "WRAP"]), + /ArmedTransferHookUnsupported/, + ); + }); + + // This path registers a wrapper over a mint that already exists, so it needs + // no mint-creation machinery: mocking mint_info is enough for it to run to + // completion. A present-but-unarmed hook therefore has to produce a wrapper, + // not a revert — and the wrapper it deploys reads the same mint_info on the + // cached track, so this covers both gates in one call. + it("the factory registers a wrapper for a present-but-unarmed hook", async () => { + const factory = await viem.deployContract("ERC20SPLFactory", [ + "0xff00000000000000000000000000000000000008", + ]); + const mint = mintId(6, false, 0, 7, true); + await factory.write.add_spl_token_no_metadata([mint, "Wrapped2", "WRP2"]); + + const wrapper = await factory.read.token_by_mint([mint]); + assert.notEqual( + wrapper, + "0x0000000000000000000000000000000000000000", + "an inert hook must not stop the factory from registering", + ); + }); +}); + +// The block above installs the mock at both homes, so it cannot tell whether each +// wrapper reads its OWN track — only that each reads something. The distinction +// matters: verify_call refuses a legacy cross-state read once a cached invoke has +// fired, so a cached wrapper reading the legacy home would fail mid-transaction, +// on a path no local test would exercise. +// +// Install one home at a time. The wrapper whose track is absent must fail to +// deploy, which is the source assertion above turned into behaviour. +describe("each wrapper reads its own track, executed", async () => { + const HELPER = "0xff00000000000000000000000000000000000009"; + const SPL_CACHED = "0xff00000000000000000000000000000000000005"; + + const conn = await network.connect(); + const { viem } = conn; + const mock = await viem.deployContract("MintInfoMock"); + const code = await (await viem.getPublicClient()).getCode({ address: mock.address }); + + async function installOnly(present: string, absent: string) { + await conn.provider.request({ method: "hardhat_setCode", params: [present, code] }); + await conn.provider.request({ method: "hardhat_setCode", params: [absent, "0x"] }); + } + + async function deploy(name: string) { + const users = await viem.deployContract("ERC20Users"); + const mint = `0x06${"00".repeat(31)}` as `0x${string}`; // 6 decimals, nothing armed + return viem.deployContract(name, [ + mint, + "0xff00000000000000000000000000000000000008", + "Wrapped", + "WRAP", + users.address, + ]); + } + + it("the cached wrapper needs SplCached, not HelperProgram", async () => { + await installOnly(SPL_CACHED, HELPER); + await deploy("SPL_ERC20_cached"); // resolves + await assert.rejects( + deploy("SPL_ERC20"), + "the legacy wrapper must be reading HelperProgram, which is absent here", + ); + }); + + it("the legacy wrapper needs HelperProgram, not SplCached", async () => { + await installOnly(HELPER, SPL_CACHED); + await deploy("SPL_ERC20"); // resolves + await assert.rejects( + deploy("SPL_ERC20_cached"), + "the cached wrapper must be reading SplCached, which is absent here", + ); + }); }); diff --git a/tests/token2022/ata-derivation.test.ts b/tests/token2022/ata-derivation.test.ts index 566f0e4..d5ebe4b 100644 --- a/tests/token2022/ata-derivation.test.ts +++ b/tests/token2022/ata-derivation.test.ts @@ -1,6 +1,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; +import { network } from "hardhat"; // The token program is part of the ATA seeds, so an ATA for a Token-2022 mint is // at a DIFFERENT address than the same mint under legacy SPL Token. Deriving with @@ -58,3 +59,69 @@ describe("ATA derivation is program-aware", () => { assert.doesNotMatch(s, /assuming the\s+\/\/\/ classic SPL Token program/); }); }); + +// The block above reads source, and its comment said byte-identity "belongs to the +// funded suite" because hardhat has no find_program_address. That is now only half +// true: byte-identity against Solana still needs a real chain (asserted on a live +// stack in the tests repo), but the property that actually broke bridge-out is +// observable here. +// +// That property is program-dependence: an ATA's seeds include the token program, +// so resolving it from the mint has to produce a different address than resolving +// it to a hardcoded legacy program. Two mints that differ in nothing but which +// program they report must therefore derive to different addresses — and an +// implementation with the program hardcoded returns the same one for both. +describe("ATA derivation is program-aware, executed", async () => { + const SYSTEM = "0xff00000000000000000000000000000000000007"; + const HELPER = "0xff00000000000000000000000000000000000009"; + + const conn = await network.connect(); + const { viem } = conn; + const mock = await viem.deployContract("MintInfoMock"); + const code = await (await viem.getPublicClient()).getCode({ address: mock.address }); + for (const a of [SYSTEM, HELPER]) { + await conn.provider.request({ method: "hardhat_setCode", params: [a, code] }); + } + const wrapper = await viem.deployContract("UserPdaWrapper"); + + /// Identical mints but for byte 4, which is what the mocked mint_info reads to + /// decide whether the mint is Token-2022 or legacy. + function mint(legacy: boolean): `0x${string}` { + const b = new Uint8Array(32); + b[0] = 6; + b[4] = legacy ? 1 : 0; + b[31] = 0xa7; + return `0x${Buffer.from(b).toString("hex")}` as `0x${string}`; + } + + const owner = `0x${"11".repeat(32)}` as `0x${string}`; + + it("the same owner and mint derive different ATAs under different token programs", async () => { + const as2022 = await wrapper.read.ataForKey([owner, mint(false)]); + const asLegacy = await wrapper.read.ataForKey([owner, mint(true)]); + assert.notEqual( + as2022, + asLegacy, + "ataForKey must feed the mint's own program into the seeds — a hardcoded " + + "program derives one address for both, which is the bug that made " + + "bridge-out target an account create_ata_for_key never created", + ); + }); + + it("it is the token program that moves the address, not the mint bytes", async () => { + // Same program, different mint: also different, so the previous assertion + // is not just observing that the mint changed. + const a = await wrapper.read.ataForKey([owner, mint(false)]); + const b = new Uint8Array(32); + b[0] = 6; + b[31] = 0xa8; + const other = await wrapper.read.ataForKey([owner, `0x${Buffer.from(b).toString("hex")}` as `0x${string}`]); + assert.notEqual(a, other, "the mint is in the seeds too"); + }); + + it("deriving twice is stable", async () => { + const a = await wrapper.read.ataForKey([owner, mint(false)]); + const b = await wrapper.read.ataForKey([owner, mint(false)]); + assert.equal(a, b); + }); +}); From 3658573e30f535c181465cca2179a678347e1a6c Mon Sep 17 00:00:00 2001 From: Anil Kumar <150643132+anil-rome@users.noreply.github.com> Date: Sat, 25 Jul 2026 12:53:03 +0300 Subject: [PATCH 11/12] fix(token2022): read a mint's own metadata, so registration stops refusing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- contracts/erc20spl/erc20spl_factory.sol | 48 ++++++- contracts/erc20spl/test/MintInfoMock.sol | 27 ++++ contracts/spl_token/spl_token.sol | 125 ++++++++++++++++++ .../spl_token/test/SplTokenLibHarness.sol | 22 +++ tests/token2022/mint-identity.test.ts | 103 +++++++++++++++ 5 files changed, 320 insertions(+), 5 deletions(-) create mode 100644 contracts/spl_token/test/SplTokenLibHarness.sol create mode 100644 tests/token2022/mint-identity.test.ts diff --git a/contracts/erc20spl/erc20spl_factory.sol b/contracts/erc20spl/erc20spl_factory.sol index 978c991..cd2683a 100644 --- a/contracts/erc20spl/erc20spl_factory.sol +++ b/contracts/erc20spl/erc20spl_factory.sol @@ -93,16 +93,54 @@ contract ERC20SPLFactory { * this function will revert. Symbol must be unique across all tokens created through this factory. * @param mint SPL token mint address */ + /// The identity the mint itself asserts, if it asserts one. + /// + /// A Token-2022 mint can carry its name and symbol inside the mint account, + /// under the mint's own metadata authority — MetadataPointer alongside + /// TokenMetadata. That is where 2022 tokens put it, and it is checked first + /// because it is the source closest to the mint. Metaplex stays the fallback, + /// and is what legacy SPL mints use. + /// + /// Deliberately not read: a MetadataPointer aimed at a SEPARATE account. Such a + /// mint falls through to the Metaplex attempt, which is exactly today's + /// behaviour — the self-referential shape is what real mints use, and it is the + /// one verified against actual mint bytes. + function _mint_identity(bytes32 mint) + internal + view + returns (bool, string memory, string memory) { + (uint64 lamports, , , , , bytes memory data) = + ICrossProgramInvocation(cpi_program).account_info(mint); + if (lamports != 0) { + (bool has_pointer, bytes32 where) = SplTokenLib.metadata_pointer(data); + if (has_pointer && where == mint) { + (bool ok, string memory n, string memory sym) = SplTokenLib.token_metadata(data); + if (ok) { + return (true, n, sym); + } + } + } + + (bool metadata_exists, MplTokenMetadataLib.Metadata memory metadata) = MplTokenMetadataLib.load_metadata( + mint, mpl_token_metadata_program, cpi_program + ); + if (metadata_exists) { + return (true, metadata.name, metadata.symbol); + } + return (false, "", ""); + } + function add_spl_token_with_metadata(bytes32 mint) public returns (address) { require(token_by_mint[mint] == address(0), "Token exists"); - (bool metadata_exists, MplTokenMetadataLib.Metadata memory metadata) = MplTokenMetadataLib.load_metadata( - mint, mpl_token_metadata_program, cpi_program - ); - require(metadata_exists, "Metadata does not exist"); - return _register_contract(mint, metadata.name, metadata.symbol); + (bool found, string memory name, string memory symbol) = _mint_identity(mint); + // Says which condition failed. The old message claimed no metadata existed, + // which was untrue of any Token-2022 mint carrying it natively — the mint + // had it, in a place this function did not look. + require(found, "Mint asserts no name: use add_spl_token_no_metadata"); + return _register_contract(mint, name, symbol); } /** diff --git a/contracts/erc20spl/test/MintInfoMock.sol b/contracts/erc20spl/test/MintInfoMock.sol index b1a2573..8c6c03e 100644 --- a/contracts/erc20spl/test/MintInfoMock.sol +++ b/contracts/erc20spl/test/MintInfoMock.sol @@ -42,6 +42,27 @@ contract MintInfoMock { bytes32 public constant TOKEN_LEGACY = 0x06ddf6e1d765a193d9cbe146ceeb79ac1cb485ed5f5b37913a8cf5857eff00a9; + /// The one real mint this mock knows: ai16z, dumped from mainnet and committed + /// as a CI fixture. Its identity lives in the mint itself — MetadataPointer + /// self-referential, TokenMetadata carrying name and symbol "ai16z" — which is + /// the shape the factory has to read and today does not. + bytes32 public constant FIXTURE_MINT = + 0xf74be1d76ab9a6c2be4999663fc6a0e19974000e836ef30c5b6286f42c020f87; + bytes public constant FIXTURE_MINT_DATA = hex"010000008e266e49fd037319a0710185b369e0be018bbb2b1baa2b240e6b84f1837b01efb6c4322896b0430f0901000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001120040000000000000000000000000000000000000000000000000000000000000000000f74be1d76ab9a6c2be4999663fc6a0e19974000e836ef30c5b6286f42c020f871300aa008e266e49fd037319a0710185b369e0be018bbb2b1baa2b240e6b84f1837b01eff74be1d76ab9a6c2be4999663fc6a0e19974000e836ef30c5b6286f42c020f8705000000616931367a05000000616931367a5000000068747470733a2f2f697066732e696f2f697066732f6261666b726569676166346d6d69626b6d6a6d7a346d6e346f7073767a62637037346b3265646c6475693268787465636f666c616c746f6737783400000000"; + + /// Only the fixture mint exists; everything else reads as absent, so the + /// Metaplex fallback finds nothing and the native path is what is under test. + function account_info(bytes32 pubkey) + external + pure + returns (uint64, bytes32, bool, bool, bool, bytes memory) + { + if (pubkey == FIXTURE_MINT) { + return (1, TOKEN_2022, false, false, false, FIXTURE_MINT_DATA); + } + return (0, bytes32(0), false, false, false, ""); + } + /// The factory's constructor converts a program name before any mint is in /// play, so installing this at the System precompile too is what makes the /// factory deployable here. The value is irrelevant to the gate under test. @@ -71,6 +92,12 @@ contract MintInfoMock { pure returns (bytes32 tokenProgram, uint8 decimals, bytes32 hookProgram, uint16 feeBps, uint32 extensions) { + // The fixture is a real mint, so report what it actually is rather than + // deriving from its pubkey — byte 1 of a real pubkey is arbitrary and would + // otherwise trip the armed-hook gate. + if (mint == FIXTURE_MINT) { + return (TOKEN_2022, 9, bytes32(0), 0, (uint32(1) << 18) | (uint32(1) << 19)); + } decimals = uint8(mint[0]); // Unarmed reads as zero, exactly as the program encodes it — a hook can diff --git a/contracts/spl_token/spl_token.sol b/contracts/spl_token/spl_token.sol index 1e16342..a73d8e0 100644 --- a/contracts/spl_token/spl_token.sol +++ b/contracts/spl_token/spl_token.sol @@ -18,6 +18,131 @@ library SplTokenLib { error InvalidTokenAccountDataLength(uint256 actual, uint256 expected); error InvalidMintDataLength(uint256 actual, uint256 expected, bytes32 spl_token); + // ── Token-2022 extension TLV ──────────────────────────────────────────── + // + // A 2022 mint pads its base layout to Account::LEN before the TLV region, so + // the region can never be mistaken for a legacy token account. Hence 165 + 1 + // account-type byte: entries begin at 166, each `type:u16 | len:u16 | value`, + // all little-endian, terminated by a zero type or by running out of buffer. + uint256 internal constant TLV_START = 166; + uint16 internal constant EXT_METADATA_POINTER = 18; + uint16 internal constant EXT_TOKEN_METADATA = 19; + + /// Little-endian u16 at `off`. Reverts if it would read past the end, because + /// these lengths come from an account someone else controls. + function _le16(bytes memory data, uint256 off) private pure returns (uint16) { + require(off + 2 <= data.length, "tlv: u16 out of bounds"); + return uint16(uint8(data[off])) | (uint16(uint8(data[off + 1])) << 8); + } + + function _le32(bytes memory data, uint256 off) private pure returns (uint32) { + require(off + 4 <= data.length, "tlv: u32 out of bounds"); + return uint32(uint8(data[off])) + | (uint32(uint8(data[off + 1])) << 8) + | (uint32(uint8(data[off + 2])) << 16) + | (uint32(uint8(data[off + 3])) << 24); + } + + /// Offset of an extension's payload, or `(false, 0)` when absent. Absence is a + /// value here rather than an error: most mints carry neither of these. + function _find_extension(bytes memory data, uint16 want) + private + pure + returns (bool, uint256, uint256) + { + if (data.length < TLV_START + 4) { + return (false, 0, 0); + } + uint256 off = TLV_START; + while (off + 4 <= data.length) { + uint16 t = _le16(data, off); + uint16 len = _le16(data, off + 2); + off += 4; + if (t == 0 && len == 0) { + break; // terminator + } + if (off + len > data.length) { + break; // truncated entry — refuse to read past the end + } + if (t == want) { + return (true, off, len); + } + off += len; + } + return (false, 0, 0); + } + + /// Where the mint says its metadata lives. Self-referential — pointing at the + /// mint itself — is the common shape, and means the TokenMetadata extension + /// below carries the identity; a different address means it lives beside the + /// mint, and the caller must read that account instead. + function metadata_pointer(bytes memory data) internal pure returns (bool, bytes32) { + (bool found, uint256 off, uint256 len) = _find_extension(data, EXT_METADATA_POINTER); + if (!found || len < 64) { + return (false, bytes32(0)); + } + // authority (32), then metadata address (32). + bytes32 addr; + for (uint256 i = 0; i < 32; ++i) { + addr |= bytes32(uint256(uint8(data[off + 32 + i])) << (8 * (31 - i))); + } + return (true, addr); + } + + /// The name and symbol the mint itself asserts, from the TokenMetadata + /// extension. Layout: update_authority (32), mint (32), then name, symbol and + /// uri as u32-length-prefixed UTF-8. + /// + /// This is the identity a wrapper must present. The factory is permissionless + /// and a wrapper's name is immutable once constructed, so a deployer-supplied + /// label lets whoever registers a mint first name it permanently — including + /// somebody else's token. Where the mint asserts an identity, that identity + /// wins. + function token_metadata(bytes memory data) + internal + pure + returns (bool, string memory, string memory) + { + (bool found, uint256 off, uint256 len) = _find_extension(data, EXT_TOKEN_METADATA); + if (!found || len < 72) { + return (false, "", ""); + } + uint256 end = off + len; + uint256 p = off + 64; // past update_authority and mint + + (bool ok_name, string memory name, uint256 after_name) = _read_string(data, p, end); + if (!ok_name) { + return (false, "", ""); + } + (bool ok_sym, string memory symbol, ) = _read_string(data, after_name, end); + if (!ok_sym) { + return (false, "", ""); + } + return (true, name, symbol); + } + + /// A u32-length-prefixed string, bounded by the extension's own end so a + /// hostile length cannot walk into the next entry. + function _read_string(bytes memory data, uint256 off, uint256 end) + private + pure + returns (bool, string memory, uint256) + { + if (off + 4 > end) { + return (false, "", off); + } + uint256 n = uint256(_le32(data, off)); + off += 4; + if (off + n > end) { + return (false, "", off); + } + bytes memory out = new bytes(n); + for (uint256 i = 0; i < n; ++i) { + out[i] = data[off + i]; + } + return (true, string(out), off + n); + } + struct SplMint { Convert.COptionBytes32 mint_authority; uint64 supply; diff --git a/contracts/spl_token/test/SplTokenLibHarness.sol b/contracts/spl_token/test/SplTokenLibHarness.sol new file mode 100644 index 0000000..d8af861 --- /dev/null +++ b/contracts/spl_token/test/SplTokenLibHarness.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +import {SplTokenLib} from "../spl_token.sol"; + +/// @notice Test-only surface for the pure parsers in SplTokenLib. +/// @dev Mirrors the wrapper convention in contracts/cpi/test — internal library +/// functions need an external entry point to be called from a test, and the +/// library itself stays free of one. +contract SplTokenLibHarness { + function metadataPointer(bytes calldata data) external pure returns (bool, bytes32) { + return SplTokenLib.metadata_pointer(data); + } + + function tokenMetadata(bytes calldata data) + external + pure + returns (bool, string memory, string memory) + { + return SplTokenLib.token_metadata(data); + } +} diff --git a/tests/token2022/mint-identity.test.ts b/tests/token2022/mint-identity.test.ts new file mode 100644 index 0000000..d3c6eed --- /dev/null +++ b/tests/token2022/mint-identity.test.ts @@ -0,0 +1,103 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { network } from "hardhat"; + +// A Token-2022 mint can carry its own identity, in the mint account itself, under +// the mint's metadata authority. That identity is what a wrapper must present: +// the factory is permissionless and its name/symbol are immutable once set, so a +// deployer-supplied label lets whoever registers first name somebody else's token +// permanently, and every EVM wallet then shows that label. +// +// Bytes below are the real ai16z mint, dumped from mainnet and committed as a CI +// fixture — MetadataPointer self-referential, TokenMetadata in the mint, name and +// symbol both "ai16z". Parsed here as a pure function against real data rather +// than a construction of our own, following tests/oracle/PythPullParser.test.ts. +const AI16Z_MINT = + "0x010000008e266e49fd037319a0710185b369e0be018bbb2b1baa2b240e6b84f1837b01efb6c4322896b0430f0901000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001120040000000000000000000000000000000000000000000000000000000000000000000f74be1d76ab9a6c2be4999663fc6a0e19974000e836ef30c5b6286f42c020f871300aa008e266e49fd037319a0710185b369e0be018bbb2b1baa2b240e6b84f1837b01eff74be1d76ab9a6c2be4999663fc6a0e19974000e836ef30c5b6286f42c020f8705000000616931367a05000000616931367a5000000068747470733a2f2f697066732e696f2f697066732f6261666b726569676166346d6d69626b6d6a6d7a346d6e346f7073767a62637037346b3265646c6475693268787465636f666c616c746f6737783400000000"; + +describe("mint identity is read from the mint", async () => { + const { viem } = await network.connect(); + const lib = await viem.deployContract("SplTokenLibHarness"); + + /// ai16z's own pubkey. The pointer is self-referential, which is the common + /// shape: the metadata lives in the mint account rather than beside it. + const AI16Z_PUBKEY = + "0xf74be1d76ab9a6c2be4999663fc6a0e19974000e836ef30c5b6286f42c020f87"; + + it("finds the metadata pointer, and it points at the mint itself", async () => { + const [found, addr] = await lib.read.metadataPointer([AI16Z_MINT]); + assert.equal(found, true, "ai16z carries MetadataPointer"); + assert.equal( + (addr as string).toLowerCase(), + AI16Z_PUBKEY, + "self-referential: the metadata is in the mint, not beside it", + ); + }); + + it("reads the name and symbol the mint asserts", async () => { + const [found, name, symbol] = await lib.read.tokenMetadata([AI16Z_MINT]); + assert.equal(found, true, "ai16z carries TokenMetadata in the mint"); + assert.equal(name, "ai16z"); + assert.equal(symbol, "ai16z"); + }); + + it("a mint with no metadata reports none, rather than reverting", async () => { + // 82 bytes of base layout and nothing else: the extension-free case. + const bare = "0x" + "00".repeat(82); + const [found] = await lib.read.tokenMetadata([bare]); + assert.equal(found, false, "absence is a value, not an error"); + }); + + it("truncated TLV is refused, not read past the end", async () => { + const truncated = AI16Z_MINT.slice(0, 2 + 2 * 300); + const [found] = await lib.read.tokenMetadata([truncated]); + assert.equal(found, false); + }); +}); + +// The factory path. `add_spl_token_with_metadata` exists to take the name and +// symbol from the mint rather than from whoever registers it, but it looks only in +// the Metaplex account — so for a Token-2022 mint carrying native TokenMetadata it +// reverts "Metadata does not exist" about a mint that plainly has metadata. +// +// `add_spl_token_no_metadata` is deliberately untouched by this: choosing your own +// label stays allowed. Anyone can deploy their own factory and their own wrapper, +// on any chain; curation is what answers that, not a gate here. +describe("the factory reads identity from the mint", async () => { + const CPI = "0xff00000000000000000000000000000000000008"; + const SYSTEM = "0xff00000000000000000000000000000000000007"; + const HELPER = "0xff00000000000000000000000000000000000009"; + // The wrapper the factory deploys reads mint_info on its own track, so the + // cached home has to answer too — otherwise its constructor decodes five words + // from an empty return and reverts with no reason at all. + const SPL_CACHED = "0xff00000000000000000000000000000000000005"; + + const conn = await network.connect(); + const { viem } = conn; + const mock = await viem.deployContract("MintInfoMock"); + const code = await (await viem.getPublicClient()).getCode({ address: mock.address }); + for (const a of [CPI, SYSTEM, HELPER, SPL_CACHED]) { + await conn.provider.request({ method: "hardhat_setCode", params: [a, code] }); + } + + const AI16Z = "0xf74be1d76ab9a6c2be4999663fc6a0e19974000e836ef30c5b6286f42c020f87"; + + it("registers a Token-2022 mint whose metadata is native, and takes its name", async () => { + const factory = await viem.deployContract("ERC20SPLFactory", [CPI]); + await factory.write.add_spl_token_with_metadata([AI16Z]); + + const wrapper = await factory.read.token_by_mint([AI16Z]); + assert.notEqual(wrapper, "0x0000000000000000000000000000000000000000"); + + const w = await viem.getContractAt("SPL_ERC20_cached", wrapper as `0x${string}`); + assert.equal(await w.read.name(), "ai16z", "the name comes from the mint"); + assert.equal(await w.read.symbol(), "ai16z"); + assert.equal(await w.read.decimals(), 9, "and decimals from mint_info"); + }); + + it("a mint that asserts no identity still reverts, and says so", async () => { + const factory = await viem.deployContract("ERC20SPLFactory", [CPI]); + const bare = `0x06${"11".repeat(31)}` as `0x${string}`; + await assert.rejects(factory.write.add_spl_token_with_metadata([bare])); + }); +}); From c0a999d419ee5ce64e90938af815b38a40a497e0 Mon Sep 17 00:00:00 2001 From: Anil Kumar <150643132+anil-rome@users.noreply.github.com> Date: Sat, 25 Jul 2026 14:32:14 +0300 Subject: [PATCH 12/12] test(token2022): red-team the TLV walk against hostile mint data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tests/token2022/mint-identity.test.ts | 55 +++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/token2022/mint-identity.test.ts b/tests/token2022/mint-identity.test.ts index d3c6eed..d261c33 100644 --- a/tests/token2022/mint-identity.test.ts +++ b/tests/token2022/mint-identity.test.ts @@ -101,3 +101,58 @@ describe("the factory reads identity from the mint", async () => { await assert.rejects(factory.write.add_spl_token_with_metadata([bare])); }); }); + +// Red team the TLV walk. 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. +describe("the TLV walk survives hostile mint data", async () => { + const { viem } = await network.connect(); + const lib = await viem.deployContract("SplTokenLibHarness"); + + const mutate = (at: number, bytes: string) => { + const b = Buffer.from(AI16Z_MINT.slice(2), "hex"); + Buffer.from(bytes, "hex").copy(b, at); + return `0x${b.toString("hex")}` as `0x${string}`; + }; + + it("an entry claiming more bytes than the account has is refused", async () => { + // First TLV entry sits at 166: type u16 then len u16, little-endian. + const [found] = await lib.read.tokenMetadata([mutate(168, "ffff")]); + assert.equal(found, false, "must not read past the account"); + }); + + it("a zero-length entry of the right type is refused, not parsed as empty", async () => { + // type 19 (TokenMetadata) with len 0 — shorter than the fixed prefix. + const [found] = await lib.read.tokenMetadata([mutate(166, "13000000")]); + assert.equal(found, false); + }); + + it("a name length larger than the entry is refused", async () => { + // TokenMetadata payload: 32 authority + 32 mint, then u32 name length. + const [, off] = [0, 166 + 4 + 64]; + const [found] = await lib.read.tokenMetadata([mutate(off, "ffffffff")]); + assert.equal(found, false, "the string must stay inside its own entry"); + }); + + it("entries with no terminator terminate anyway", async () => { + // Every entry advances the cursor by at least the 4-byte header, so a + // buffer of zero-length non-zero-type entries cannot loop forever. If this + // ever times out rather than returning, the walk can be stalled. + const b = Buffer.alloc(400); + for (let o = 166; o + 4 <= 400; o += 4) { + b[o] = 0x63; // type 99, absent + b[o + 1] = 0x00; + b[o + 2] = 0x00; // len 0 + b[o + 3] = 0x00; + } + const [found] = await lib.read.tokenMetadata([`0x${b.toString("hex")}`]); + assert.equal(found, false); + }); + + it("a truncated account, and an empty one, are refused", async () => { + for (const d of ["0x", "0x00", `0x${"00".repeat(165)}`]) { + const [found] = await lib.read.tokenMetadata([d as `0x${string}`]); + assert.equal(found, false, `len ${(d.length - 2) / 2} must be refused`); + } + }); +});