> For the complete documentation index, see [llms.txt](https://docs.rome.builders/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.rome.builders/core-concepts/token-interop.md).

# Token Interop

How ERC-20 and SPL tokens are the same underlying account on Rome.

Rome represents an ERC-20 token and its underlying SPL token as one shared account. This page explains how tokens work across EVM and Solana.

## The shared-state model

For an ordinary wallet, an ERC-20 token on Rome is a **transparent wrapper** over an SPL token account on Solana. The wallet's ERC-20 balance is its SPL balance. A program-derived address for the EVM address owns the token account. This is its **external-authority PDA**.

<figure><img src="https://2654533989-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F671PKS8ZQSNqtoC8PGvz%2Fuploads%2Fgit-blob-552c639322c2d0e90b312def5db39d06c1871d18%2Ftoken-interop.svg?alt=media" alt="An EVM address and its Solana token account, owned by its external-authority PDA. Multiple wrappers read the same token account. The wrapper escrow account and Solana pool vaults hold tokens of the same Solana mint."><figcaption></figcaption></figure>

* No bridging delay: the wallet's ERC-20 balance is its SPL balance.
* Ordinary wallets, including MetaMask wallets and synthetic addresses, have no liquidity fragmentation. Every wrapper over a mint shows the same wallet balance. The `balanceOf` method reads that balance from the wallet's token account. Tokens held inside EVM contracts are held per wrapper (see [The wrapper contract](#the-wrapper-contract)).
* For a wallet's tokens, nothing is locked on one side and minted on the other. Contract-held tokens sit in the wrapper's own escrow token account on Solana. Each wrapper tracks those tokens separately.

**Why a second wrapper does not split the balance**

Two wrappers over the same mint show the same wallet balance. Wrappers read `totalSupply` from the Solana mint. On Hadrian (testnet), three wUSDC wrappers returned matching wallet balances. All three wUSDC wrappers (9.0.0, 8.0.0 and May 2026) returned the Solana mint's supply.

Each factory allows one wrapper per mint. A new factory version can deploy another wrapper for that mint. The registry names the canonical wrapper per chain.

A Solana wallet holding USDC directly and an EVM address hold tokens of the same mint. Solana pools use the same vaults and reserves for both.

The holder table in the [Via explorer](/apps-on-rome/via.md) is built from each wrapper's `Transfer` events. It can differ from the token-account balance because direct SPL transfers, `ActivateAta`, and transfers through another wrapper do not appear in that wrapper's events.

> Import paths below use `@rome-protocol/rome-solidity`. The package is not on npm; install it from the public [`rome-solidity`](https://github.com/rome-protocol/rome-solidity) repo with `npm install github:rome-protocol/rome-solidity`, or `forge install rome-protocol/rome-solidity` with a remapping; the wrapper and factory contracts also need OpenZeppelin under Foundry (see [Deploy Solidity Contracts](/developer-guides/deploy-solidity.md#using-the-rome-solidity-sdk)). Precompile interfaces live in [`contracts/interface.sol`](https://github.com/rome-protocol/rome-solidity/blob/master/contracts/interface.sol).

## The wrapper contract

`SPL_ERC20` (and its cached-track variant `SPL_ERC20_cached`, which the factory deploys for every mint except one with an armed transfer hook; see [Token-2022 mints](#token-2022-mints)) provide a full ERC-20 interface over an SPL mint:

* `balanceOf()` — reads the user's ATA balance from Solana (for a contract address it reads the wrapper's own ledger, see below)
* `transfer()` / `transferFrom()` — move tokens on Solana
* `approve()` / `allowance()` — per-spender ERC-20 allowances kept in EVM storage (an SPL token account has no EVM-style allowances). They are separate from the one-time grant below: to move a user's tokens at all, the wrapper acts as that user's SPL delegate, and the user gives it that right once per wallet
* `totalSupply()` — reads the SPL mint supply

A contract cannot send the one-time grant, so a contract's tokens are held in the wrapper's own token account and tracked in the wrapper's ledger. `balanceOf(contract)` reads that ledger.

## The factory

`ERC20SPLFactory` deploys a wrapper for any SPL mint:

```solidity
import {ERC20SPLFactory} from "@rome-protocol/rome-solidity/contracts/erc20spl/erc20spl_factory.sol";

// Deploy a wrapper, loading name/symbol from the mint's Token-2022 metadata or Metaplex
address wrapper = factory.add_spl_token_with_metadata(splMint);

// Or specify name/symbol manually
address wrapper = factory.add_spl_token_no_metadata(splMint, "USD Coin", "USDC");
```

Live factory address, from the `live` entries of the registry (v0.2.0): Hadrian `0x37c13e4ff21fb914eb24014dcbd0f8dbeaa31078`. Earlier factories are still on-chain but are marked `deprecated` or `retired` in the [registry](https://github.com/rome-protocol/rome-registry/tree/main/chains); do not use them. The registry is the source of truth, so read the live address from it (see [Reading the contracts list](/reference/contract-addresses.md#reading-the-contracts-list)) instead of copying one into your code.

## Canonical mints

There is no on-chain token-registry contract. Canonical wrappers and gas/bridge tokens are curated in the off-chain [`rome-protocol/registry`](https://github.com/rome-protocol/rome-registry); permissionless wrappers created via `add_spl_token_no_metadata` are discovered from the on-chain `TokenCreated` event. The registry names the canonical wrapper for each mint on each chain. Wallet balances are shared across wrappers. Each wrapper separately tracks the balances held by EVM contracts.

## SPL operations from Solidity

For user-PDA-signed SPL primitives, use the **HelperProgram** precompile (`0xFF…09`) — ATA creation, SPL transfers, and gas↔lamports conversion:

```solidity
import {IHelperProgram} from "@rome-protocol/rome-solidity/contracts/interface.sol";

IHelperProgram helper = IHelperProgram(0xFF00000000000000000000000000000000000009);

helper.create_ata(user, mint);              // create the user's ATA for a mint
helper.transfer_spl(to, tokens, mint);      // transfer SPL from the caller's PDA
```

`transfer_spl` has several overloads (including a delegate variant for `transferFrom` flows); see `interface.sol` for exact signatures. On the cached track, the equivalent operations live on `ISplCached` (`0xFF…05`) and `IAssociatedSplCached` (`0xFF…06`). A contract uses one track consistently.

## One-time grant per wallet

The wrapper moves a user's tokens as the **SPL delegate** of that user's token account. A wallet therefore has to grant it that right once per wrapper, before the wrapper can move anything for it. The grant is one call to the Helper precompile (`0xff00000000000000000000000000000000000009`):

```solidity
helper.approve_spl(wrapper, type(uint64).max, mint);   // approve_spl(wrapper, 2^64-1, mint)
```

`approve_spl` is signed by the user's own account, so the user sends it, not the wrapper or your contract. The mint is `mint_id()` on the wrapper.

* **Who needs it.** Every EVM wallet, and every Solana user's synthetic address, for each wrapper whose tokens it wants to move. A contract that pulls tokens with `transferFrom` works only for users who have granted the wrapper.
* **How to check.** Call `isEnabled(user)` on the wrapper. It returns `true` once the grant is in place.
* **What it looks like when it is missing.** The transfer reverts with SPL error `0x4` (owner mismatch).
* **One delegate per token account.** An SPL token account has a single delegate. Giving another program a grant on the same token account, such as a bridge or a router, replaces the wrapper's grant, and the wrapper stops working for that user until they grant it again. Check `isEnabled` before a transfer instead of assuming an earlier grant is still there.

When a new factory version replaces a wrapper, each wallet grants the new wrapper permission with `approve_spl`. The old wrapper can move that wallet's tokens only while it remains the token account's delegate. Granting the new wrapper replaces that delegate.

The [dual-lane app guide](/developer-guides/dual-lane-app.md#one-time-grant-the-wrapper) has the full code for both lanes.

## Deposit and withdraw

* **Into EVM** — the SPL side credits the user's PDA-owned ATA; the ERC-20 wrapper immediately reflects the balance. Cross-chain inbound transfers settle trustlessly via a user-signed authorization on the bridge.
* **Wrap gas into the gas token** — call the `Withdraw` precompile (`0x42…16`). `withdraw_to_ata(wei)` burns native gas and credits the equivalent amount, in the gas token's own decimals, to the caller's token account, which the gas token's wrapper (for example wUSDC) then shows as a balance. `withdraw_to_pda(wei)` pays lamports to the caller's PDA and applies only to a chain whose gas is SOL; it reverts on Hadrian and Rubicon, which use USDC as gas, so use `withdraw_to_ata` there. The amount is in 18-decimal gas units and must be a whole number of gas-token units: on a USDC-gas chain a multiple of 10^12 (one 6-decimal unit), otherwise the call reverts. `HelperProgram.deposit_from_ata(wei)` goes the other way and turns the gas token in the caller's token account back into native gas, with the same multiple-of rule. These are gas-token conversions, not an exit for other tokens.
* **Out to Solana, any other token** — the wrapper's own balance is already the SPL balance, so moving it out is an SPL transfer:
  * `HelperProgram.transfer_spl_to_signer(amount, mint)` sends the caller's tokens to the token account of the Solana wallet that signed the transaction. This return leg reaches the user's own wallet only on the wallet-signed Solana lane. Called from MetaMask or through the relay, the signer is Rome's proxy payer, so the call fails or the tokens go to Rome. Use `transfer_spl` or `RomeBridgeWithdraw.bridgeOutToSolana` there instead.
  * `RomeBridgeWithdraw.bridgeOutToSolana(solanaRecipient, amount, mint)` sends them to any Solana wallet. It needs the user's grant for the bridge contract (`approve_spl(bridge, …)`, see the one-delegate note above), and the recipient's token account must exist; `ensureRecipientAta(solanaRecipient, mint)` creates it.
  * `RomeBridgeWithdraw` also burns USDC to another chain over CCTP (`burnUSDC`) and sends other assets over Wormhole (`burnETH`, `burnToWormhole`).

Take the live `RomeBridgeWithdraw` address from the registry.

## Gas token

Each chain has its own gas token: SOL or an SPL token. The Rome Proxy's price source can be a Hermes API feed, a Meteora DAMM v1 pool, a Meteora DAMM v2 pool, or a fixed price. Hadrian (testnet) and Rubicon (mainnet) use USDC. Hadrian uses test USDC. Rubicon uses real USDC. There is no universal default gas token.

## Token-2022 mints

The factory also wraps Token-2022 mints. It asks the Helper precompile what the mint is, with `mint_info(mint)` (selector `0xe24bf5d4`), and picks the wrapper from the answer. `mint_info` returns five values:

| Value          | Meaning                                                                                       |
| -------------- | --------------------------------------------------------------------------------------------- |
| `tokenProgram` | the token program that owns the mint (the classic SPL Token program or Token-2022)            |
| `decimals`     | the mint's decimals                                                                           |
| `hookProgram`  | the transfer-hook program, or zero when there is no hook or the hook is present but not armed |
| `feeBps`       | the transfer fee in basis points, or zero when there is none or it is set to zero             |
| `extensions`   | a bitmap of the Token-2022 extensions the mint carries, bit N for extension type N            |

What a mint gets:

| Mint                                                                                         | Wrapper                                                                                                      | `wrapper_kind_by_mint` |
| -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ---------------------- |
| An ordinary mint, a Token-2022 mint with no hook, or one whose hook is present but not armed | the cached wrapper (`SPL_ERC20_cached`)                                                                      | `1`                    |
| A Token-2022 mint with an armed transfer hook                                                | the hooked wrapper (`SPL_ERC20_Token2022Hooked`), deployed through the factory's `hooked_wrapper_deployer()` | `2`                    |

A mint with a transfer fee gets the cached wrapper, and its `Transfer` event reports the amount that arrived after the fee, not the amount sent. You can read the kind a mint got from the factory with `wrapper_kind_by_mint(mint)`; on the live Hadrian factory the USDC, ETH and SOL wrappers all return `1`.

What is refused:

* A mint with an armed transfer hook cannot get one of the ordinary wrappers. Deploying `SPL_ERC20` or `SPL_ERC20_cached` on it directly reverts with `ArmedTransferHookUnsupported(mint, hookProgram)`. Through the factory this case goes to the hooked wrapper instead.
* An address that is not an SPL mint makes `mint_info` revert with `Invalid SPL mint account: <address>`.
* A mint that already has a wrapper reverts with `Token exists`, and a symbol that is already in use reverts with `Token with symbol exists`.
* `add_spl_token_with_metadata` reads the name and symbol from the mint's own Token-2022 metadata first and from Metaplex second. If the mint has neither it reverts with `Mint asserts no name: use add_spl_token_no_metadata`.

How the hooked wrapper behaves:

* Token-2022 runs the hook on every transfer, and the hook needs accounts that depend on the transfer. The wrapper cannot work those out, so the caller resolves the full account list from the mint's extra-account-metas list and passes it in. The wrapper checks the fixed part of that list (the hook program and its validation account) and forwards it unchanged.
* The plain `transfer`, `transferFrom` and `bridgeOutToSolana` revert with `HookAccountPlanRequired`. Use `transferWithHookAccounts`, `transferFromWithHookAccounts` and `bridgeOutToSolanaWithHookAccounts`, which take the account list as an extra argument.
* If the mint's hook program changes after the wrapper was deployed, transfers revert with `HookConfigurationChanged`.
* It moves tokens by direct cross-program call, the legacy track, and does not use the cached track. A contract uses one track consistently, and calls on the legacy track run only in atomic execution, so a call that uses the hooked wrapper has to fit one atomic transaction.
* It keeps no ledger for contract balances. Tokens sent to a contract land in the contract's own token account, and the contract can send them on only after it has made the `approve_spl` grant to the wrapper itself.
* It needs the same [one-time grant](#one-time-grant-per-wallet) as any other wrapper.

## Constraints

* SPL token amounts are `uint64` (max 18,446,744,073,709,551,615)
* ERC-20 allowances (`approve` / `allowance`) are EVM storage. The wrapper moves tokens as the user's SPL delegate, which the user grants once per wrapper (see [One-time grant per wallet](#one-time-grant-per-wallet))
* ERC-20 wrapper symbols must be unique per factory

## What's Next

* [Contract Addresses](/reference/contract-addresses.md) — precompiles and per-chain addresses
* [Call Solana from EVM](/developer-guides/call-solana-from-evm.md) — CPI and SPL operations from Solidity
* [How Solana and the EVM meet on Rome](/core-concepts/how-solana-and-evm-meet.md)
* [Integrate a Solana program](/developer-guides/integrate-solana-program.md)


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://docs.rome.builders/core-concepts/token-interop.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
