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/cpi/UserPda.sol b/contracts/cpi/UserPda.sol index 3afc0d0..0ce4635 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,18 +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. - 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 @@ -80,24 +69,37 @@ library UserPda { ); } - /// Batch-derive N ATAs for one EVM user across N classic SPL Token mints. - /// - /// 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]` + /// @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. /// - /// 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`. + /// @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. /// - /// 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(...)`. + /// 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 + view + returns (bytes32) + { + (bytes32 tokenProgram, , , ,) = HelperProgram.mint_info(mint); + return AssociatedSplToken.get_associated_token_address_with_program_id( + ownerKey, + mint, + tokenProgram, + SolanaConstants.ASSOCIATED_TOKEN_PROGRAM + ); + } + + /// @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 b94a51e..5e6b052 100644 --- a/contracts/cpi/test/UserPdaWrapper.sol +++ b/contracts/cpi/test/UserPdaWrapper.sol @@ -16,7 +16,7 @@ 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); } diff --git a/contracts/erc20spl/erc20spl.sol b/contracts/erc20spl/erc20spl.sol index 424814e..cf4e54e 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_; @@ -261,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 @@ -323,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; } @@ -460,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 @@ -555,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/erc20spl/erc20spl_cached.sol b/contracts/erc20spl/erc20spl_cached.sol index ce87f04..27bdd6f 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_; @@ -109,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 { @@ -199,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 ? 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 @@ -238,7 +253,24 @@ 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_ = balanceOf(to); + delivered = to == from ? value - (before - now_) : now_ - before; + } + emit Transfer(from, to, delivered); return true; } diff --git a/contracts/erc20spl/erc20spl_factory.sol b/contracts/erc20spl/erc20spl_factory.sol index 005615e..cd2683a 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 @@ -82,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/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/contracts/erc20spl/test/MintInfoMock.sol b/contracts/erc20spl/test/MintInfoMock.sol new file mode 100644 index 0000000..8c6c03e --- /dev/null +++ b/contracts/erc20spl/test/MintInfoMock.sol @@ -0,0 +1,118 @@ +// 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. +interface ISeed { + struct Seed { + bytes item; + } +} + +contract MintInfoMock { + bytes32 public constant TOKEN_2022 = + 0x06ddf6e1ee758fde18425dbce46ccddab61afc4d83b90d27febdf928d8a18bfc; + 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. + 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 + 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 + // 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/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) 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/contracts/spl_token/spl_token.sol b/contracts/spl_token/spl_token.sol index 6574bba..a73d8e0 100644 --- a/contracts/spl_token/spl_token.sol +++ b/contracts/spl_token/spl_token.sol @@ -3,17 +3,146 @@ 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); 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; @@ -22,13 +151,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); } 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/admission.test.ts b/tests/token2022/admission.test.ts new file mode 100644 index 0000000..2ec61f8 --- /dev/null +++ b/tests/token2022/admission.test.ts @@ -0,0 +1,272 @@ +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. +// +// 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"); +} +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`, + ); + } + }); +}); + +// 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"); + // 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] }); + } + + /// 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/, + ); + }); + + // 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 new file mode 100644 index 0000000..d5ebe4b --- /dev/null +++ b/tests/token2022/ata-derivation.test.ts @@ -0,0 +1,127 @@ +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 +// 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("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"]) { + assert.match(src(f), /UserPda\.ataForKey\(/, `${f} derives via UserPda`); + } + }); + + 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", () => { + // 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/); + }); +}); + +// 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); + }); +}); 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`); + } + }); +}); diff --git a/tests/token2022/mint-identity.test.ts b/tests/token2022/mint-identity.test.ts new file mode 100644 index 0000000..d261c33 --- /dev/null +++ b/tests/token2022/mint-identity.test.ts @@ -0,0 +1,158 @@ +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])); + }); +}); + +// 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`); + } + }); +}); 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"); + }); + } +});