Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
66 changes: 34 additions & 32 deletions contracts/cpi/UserPda.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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-
Expand All @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion contracts/cpi/test/UserPdaWrapper.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
54 changes: 44 additions & 10 deletions contracts/erc20spl/erc20spl.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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_;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`
Expand Down
42 changes: 37 additions & 5 deletions contracts/erc20spl/erc20spl_cached.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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_;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

Expand Down
61 changes: 55 additions & 6 deletions contracts/erc20spl/erc20spl_factory.sol
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
}

/**
Expand Down
Loading