> 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/developer-guides/integrate-solana-program.md).

# Integrate a Solana program

Integrate a Solana program with Rome EVM — who the Solana signer is, how value reaches your program and comes back, the one-track rule, atomic vs iterative, and account provisioning.

This guide is for a Solana program team that wants Rome EVM users to use their program — a privacy, lending or vault program that should hold value for EVM accounts. It builds on [Call Solana from EVM](/developer-guides/call-solana-from-evm.md) (the precompile mechanics) and answers the questions that come up once a real program is on the other side: *who* signs, *how* tokens get in and out, and *which* limits shape the design.

The flow used throughout: an EVM account moves USDC into your program ("shield" or "deposit"), your program holds it for that user, and later releases it back ("unshield" or "withdraw"). Replace the verbs with your own.

## 1. Every EVM address has one Solana identity

Rome derives a Solana account for every EVM address — users and contracts alike — called its **external authority**:

```
external_auth(addr) = find_program_address(["EXTERNAL_AUTHORITY", addr_h160], rome_evm_program_id)
```

It owns the address's associated token accounts (that is where `wUSDC.balanceOf(addr)` reads from) and it is the key that signs CPIs made on that address's behalf. Your program sees a 32-byte Solana pubkey; it never sees an H160.

**Who signs is decided by who calls the precompile:**

| The precompile is called by                                          | The Solana signer                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| A **contract**, with a normal `CALL`                                 | The **contract's own** external authority (`external_auth(contract)`), plus any salted sub-PDAs of the contract it asks for                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| A **user**, in a transaction sent straight to the precompile address | The **user's own** external authority                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| A contract via `DELEGATECALL`                                        | The bare authority is refused (`DelegatecallOwnerAuthority`). `invoke` without a signer, and `invoke_signed` with salted sub-PDAs, still run — but those sub-PDAs are namespaced by the running contract's address (`["EXTERNAL_AUTHORITY", caller, running_contract, salt]`), so `pda_with_salt` does not derive them. Of HelperProgram only the creation and funding selectors pass (`create_ata*`, `create_pda*`, `swap_gas_to_lamports`, `init_spl_mint`, `create_and_init_mint`); they are exempt because their target is bound to the caller or funded by the operator, not because they sign nothing |

The consequence is the ERC-20 rule you already know: **a contract cannot move a user's assets by itself.** It acts as its own identity, and the user grants it rights first. For tokens that have to move on the Solana side, that grant is an SPL delegation on the user's token account; the wrapper's ERC-20 `approve` is only an EVM-side allowance that the wrapper's own transfers honour. That is how Rome's own token wrappers work, and it is how your adapter will work (section 3).

Salted sub-PDAs: `invoke_signed` takes a list of `bytes32` salts, and Rome signs with `["EXTERNAL_AUTHORITY", caller_h160, salt]` for each (bump appended; the caller is the contract on a direct call). `HelperProgram.pda_with_salt(addr, salt)` returns the address from Solidity. Use one salt per user (for example the user's address) when your program wants a distinct owner account per position.

Funding: an external authority PDA does not have to exist to own an ATA (`create_ata` is idempotent and the transaction's Solana fee payer covers the rent). It must exist and hold lamports only when it funds accounts your instruction creates. `HelperProgram.create_pda(addr)` creates it as a plain System `CreateAccount`, so call it once; it is not idempotent. `swap_gas_to_lamports(n)` credits the address's PDA with `n` lamports from the transaction's Solana fee payer, and the caller pays for them in Rome gas: every legacy-track CPI's effect on the fee payer's balance is added to the transaction fee and charged to the user at the gas price, the same way rent for accounts a CPI creates is charged. The Solana lane's provisioning step is described in [Call EVM from Solana](/developer-guides/call-evm-from-solana.md); a contract provisions its own PDA in its constructor or first call.

## 2. Three ways to hold a user's position

**A. An adapter contract (recommended for a reusable adapter).** One Solidity contract fronts your program. The user grants the adapter's authority an SPL delegation on their token account; the adapter pulls the tokens into a token account it owns, calls your program as itself, and keys the user's position inside your program by a per-user sub-PDA or by an identifier it passes in the instruction data. On the way out it tells your program to release to the user's own token account. The adapter is the only Solana identity your program needs to know. The grant is an SPL delegation, not an ERC-20 `approve`; section 3 explains why and what it costs the user.

**B. The user as the Solana signer.** The front end sends a transaction from the user's EVM account **directly to the CpiProgram precompile** (`to = 0xff…08`, calldata = the ABI-encoded `invoke` or `invoke_signed`), so the user's own external authority signs and your program keys the position by it. No adapter contract and no delegation, but each action is an opaque calldata transaction in the wallet, and anything the flow needs besides the CPI (the ATA, a prior transfer) is a separate transaction. It is a fit for one-time grants (a user's `approve_spl` to a wrapper) and for simple programs, not for multi-step flows. [Cardo](https://github.com/rome-protocol/cardo) uses this shape: its front end sends the `invoke` straight to the precompile from the user's wallet.

**C. A custody-less router.** A contract that never holds the user's tokens. The user grants the router's authority an SPL delegation on their own token account; the router CPIs into your program with that authority as the transfer authority, so tokens move from the user's token account to your program's account (or back to the user's), and the router holds nothing between calls. The router derives every user-side account from `msg.sender`, so one user's grant cannot be pointed at another user's account, and it calls one fixed program with fixed instruction shapes rather than acting as a general `invoke` passthrough. The `RomeDexRouter` in [rome-dex](https://github.com/rome-protocol/rome-dex) is written this way; rome-dex's EVM lane also offers the raw direct `invoke` of shape B. It needs the same grant as A, with the same one-delegate cost (section 3), and no pull step.

The rest of this guide follows A; C uses the same grant and the same signing rule.

## 3. Tokens in, tokens out

USDC on Rome is one SPL mint seen from two sides: the `wUSDC` ERC-20 wrapper on the EVM side and the external authority's ATA on the Solana side. For a user's own address the two are the same balance: `wUSDC.balanceOf(user)` reads the user's ATA. There is no bridge or wrapping step in the middle.

**A contract's wrapper balance is not a token account the contract controls.** The factory deploys `SPL_ERC20_cached` for an ordinary mint, and Hadrian's `wUSDC` is one. That wrapper cannot sign for a contract, so when `wUSDC.transferFrom(user, adapter, amount)` or `transfer` sends tokens to a contract, the SPL tokens move into the **wrapper's own escrow token account** (`escrow_ata()`), and the wrapper records the amount per contract in its storage. `wUSDC.balanceOf(adapter)` returns that record, an escrow ledger, not the balance of a token account owned by `external_auth(adapter)`. Your adapter cannot hand those tokens to your program in a CPI; they can only leave through the wrapper again. So an adapter that funds itself with `transferFrom` has nothing it can spend on the Solana side.

The path that works is an SPL delegation:

* **Grant.** The user sends `approve_spl(adapter, amount, mint)` as a transaction **straight to the HelperProgram** (`to = 0xff…09`), where `mint` is the wrapper's `mint_id()`. The user's own authority signs, and the delegate it sets on the user's token account is the adapter's external authority.
* **In.** The adapter's `deposit(amount)` pulls with the delegate overload `HelperProgram.transfer_spl(srcAta, dstAta, amount, mint)`, with the user's ATA as source and the adapter's own ATA (`HelperProgram.ata(address(this), mint)`, created once with `create_ata`) as destination. The adapter's authority signs as the delegate. The tokens are now in a token account owned by `external_auth(adapter)`, which is the account your CPI names; the signer and the source are the adapter's own, so no further permission is needed. They do not show up in `wUSDC.balanceOf(adapter)`.
* **Out.** Your program's withdraw instruction transfers to the **user's** ATA, `HelperProgram.ata(user, mint)`. Receiving needs no signature from the user, and the balance is immediately visible as `wUSDC.balanceOf(user)`. If the ATA may not exist yet, create it first with `HelperProgram.create_ata(user, mint)` (idempotent).

**A token account has one delegate.** The grant above replaces the wrapper's own grant on that account. Until the user grants the wrapper again, every `wUSDC` `transfer` or `transferFrom` out of their address reverts with SPL error `0x4`. After any flow that uses this grant, call `wUSDC.isEnabled(user)`; when it returns `false`, ask the user to send `approve_spl(wrapper, 2^64-1, mint)` to the HelperProgram again, the same maximum grant the wrapper is built around (see [One-time: grant the wrapper](/developer-guides/dual-lane-app.md#one-time-grant-the-wrapper)). A capped grant would break the user's ordinary `wUSDC` transfers again once it runs low, and `isEnabled` stays `true` while any of it remains, so the app would not know to ask again.

To check which wrapper a mint has, read the factory's `wrapper_kind_by_mint(mint)` (the factory address is in the chain's `contracts.json` in the [registry](https://github.com/rome-protocol/rome-registry)). `1` is the cached wrapper this section describes. `2` is the direct-CPI wrapper the factory deploys for a Token-2022 mint with an armed transfer hook; this guide does not cover it. The registry's `tokens.json` lists the wrapper address and mint but not which kind it is.

## 4. One track per contract, and per transaction

Rome has two families of precompiles that change Solana state, and **a transaction uses exactly one of them** — the runtime refuses a transaction that mixes mutating calls from both, even across contracts.

| Track      | Precompiles                                                                                         | Effects land                                                                                          | Works in iterative execution                                                              |
| ---------- | --------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| **Legacy** | `CpiProgram` `0xff…08` (`invoke`, `invoke_signed`), `HelperProgram` `0xff…09`, `Withdraw` `0x42…16` | Immediately, as a real CPI inside the EVM step                                                        | **No** — a legacy CPI in an iterative transaction fails with `CpiProhibitedInIterativeTx` |
| **Cached** | `SplCached` `0xff…05`, `ASplCached` `0xff…06`, `SystemCached` `0xff…04`, `WithdrawCached` `0xff…0b` | Staged in an overlay and committed as CPIs at the end of the transaction; an EVM revert discards them | Yes                                                                                       |

What this means for a program integration:

* **Calling your own program's instructions is legacy-track only.** The cached track has no generic `invoke`; it covers SPL transfers, ATA creation, system-program operations and gas withdrawal.
* So the step that funds the adapter must be legacy-track too if it runs in the same transaction as the CPI. The cached wrapper's `transferFrom` is a cached-track call, so it cannot share a transaction with your CPI (and, as section 3 shows, it would only credit an escrow ledger). Pull with `HelperProgram.transfer_spl(userAta, adapterAta, amount, mint)` instead, after the user has made the adapter the SPL delegate (`approve_spl`, sent by the user directly to the HelperProgram). The pull and the CPI are both legacy-track, so they run in one transaction.
* **Keep the wrapper out of that transaction.** Do not call the wrapper's mutating functions (`wUSDC.transfer`, `transferFrom`) from the adapter in the same transaction as a legacy CPI. Check `isEnabled(user)` and balances from the front end with `eth_call`.
* **And the CPI transaction must be atomic.** That is the bound the design sits under.

Reads are outside the rule: `account_info`, `account_data_at`, `account_u64_at`, `account_lamports`, `pdas_batch_derive` and `HelperProgram.user_balance` / `allowance_of` are reads of pre-loaded accounts (no CPI), allowed on either track and in both execution modes. Put your pre-checks on them. One ordering rule: once a cached mutation has fired in a transaction, legacy-track reads are blocked for the rest of it — read first, then mutate.

## 5. Atomic is the budget

An atomic Rome transaction is one Solana transaction: about **1.4M compute units**, a **64-address** account list and a **4 KB** wire size (Solana v1 transactions). The Rome program and your CPI share that budget. A wrapper transfer runs about 170K CU on Hadrian and your instruction adds its own; measure with the Solana receipt's `computeUnitsConsumed`, not EVM `gasUsed`.

When emulation decides a transaction does not fit, Rome runs it **iteratively** across several Solana transactions, and a legacy CPI inside it then fails. Keep each of deposit and withdraw to one token movement plus one CPI into your program, and keep the Solidity around them small. Split anything larger into two user transactions.

CPI depth: Solana allows 4 levels and Rome uses one, so your instruction runs at level 2 with two levels below it (your program → SPL Token leaves one).

## 6. Account provisioning

Your program does not receive its accounts from Solidity at run time. The proxy **emulates** every transaction before sending it, records every Solana account the CPIs touched, and places them in the Solana transaction. Your adapter names the accounts in the `invoke_signed` account list; emulation proves they resolve. Three things follow:

* Every account your instruction touches must be derivable from inputs the adapter has (its own PDA and sub-PDAs, your program ID, mints, the user's address). `SystemProgram.find_program_address` and `CpiProgram.pdas_batch_derive` do the derivations in Solidity.
* An account that does not exist yet (a fresh position, a fresh ATA) still has to be named in the account list; its address is what reaches the Solana transaction.
* Everything the transaction touches — including Rome's own accounts for the user, the adapter and the wrapper — must stay under 64 addresses. A design that touches ten markets in one call will not fit.

For the Solana-wallet lane, `rome_emulateCallAccounts` exposes the same discovery to a client; the SDK uses it.

Two accounts a CPI may never name: the Solana fee payer that submits the transaction (except inside a plain System transfer instruction) and the Rome program itself. A CPI back into the Rome program is refused.

## 7. A deposit and a withdraw, in Solidity

A minimal sketch of an adapter on the legacy track. The user has already sent `approve_spl(adapter, amount, mint)` to the HelperProgram (section 3). `PRIVACY_PROGRAM` is your program ID; `encodeDeposit` / `encodeWithdraw` build your instruction data (Borsh, little-endian); the account metas follow your instruction's layout.

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

contract PrivacyAdapter {
    ICrossProgramInvocation constant Cpi    = ICrossProgramInvocation(0xFF00000000000000000000000000000000000008);
    IHelperProgram          constant Helper = IHelperProgram(0xFF00000000000000000000000000000000000009);
    bytes32 constant SPL_TOKEN = 0x06ddf6e1d765a193d9cbe146ceeb79ac1cb485ed5f5b37913a8cf5857eff00a9; // TokenkegQ...

    bytes32 immutable PRIVACY_PROGRAM;
    bytes32 immutable USDC_MINT;

    constructor(bytes32 program, bytes32 mint) {
        PRIVACY_PROGRAM = program; USDC_MINT = mint;
        Helper.create_pda(address(this));                 // the adapter's own Solana identity
        Helper.create_ata(address(this), USDC_MINT);      // and a USDC account it owns
    }

    function _salt(address user) internal pure returns (bytes32) { return bytes32(uint256(uint160(user))); }

    function deposit(uint64 amount) external {
        bytes32 self       = Helper.pda(address(this));                   // signer: the adapter's authority
        bytes32 adapterAta = Helper.ata(address(this), USDC_MINT);
        bytes32 position   = Helper.pda_with_salt(address(this), _salt(msg.sender)); // per-user sub-PDA, also signs

        // Pull from the user's token account. The adapter's authority signs as the SPL
        // delegate the user granted; this is a legacy-track call, like the CPI below.
        Helper.transfer_spl(Helper.ata(msg.sender, USDC_MINT), adapterAta, amount, USDC_MINT);

        ICrossProgramInvocation.AccountMeta[] memory accs = new ICrossProgramInvocation.AccountMeta[](5);
        accs[0] = ICrossProgramInvocation.AccountMeta(self,       true,  true);
        accs[1] = ICrossProgramInvocation.AccountMeta(position,   true,  true);
        accs[2] = ICrossProgramInvocation.AccountMeta(adapterAta, false, true);                          // source: owned by the adapter's authority
        accs[3] = ICrossProgramInvocation.AccountMeta(_vaultFor(position), false, true);                 // your program's vault
        accs[4] = ICrossProgramInvocation.AccountMeta(SPL_TOKEN, false, false);

        bytes32[] memory salts = new bytes32[](1);
        salts[0] = _salt(msg.sender);
        Cpi.invoke_signed(PRIVACY_PROGRAM, accs, encodeDeposit(amount), salts);
    }

    function withdraw(uint64 amount) external {
        bytes32 position = Helper.pda_with_salt(address(this), _salt(msg.sender));
        bytes32 userAta  = Helper.ata(msg.sender, USDC_MINT);            // destination: the user's own account
        // ... same shape: your program moves `amount` from the vault to userAta, signed by `position`
        Cpi.invoke_signed(PRIVACY_PROGRAM, accs, encodeWithdraw(amount), salts);
    }
}
```

The adapter never calls the `wUSDC` contract. The pull is a HelperProgram call that spends the user's delegation, and the tokens sit in a token account that `external_auth(adapter)` owns, which is why the CPI can spend them.

Notes on the signer. Rome signs for exactly two kinds of account: the calling contract's bare authority (`Helper.pda(address(this))`), which signs without being listed in `salts`, and one salted sub-PDA per entry in `salts`. Every salt you list must appear in the account list as a signer, or the call fails with `signer account calculated from the provided seed was not found`. Any other account marked as a signer, including the user's own authority, gets no seed from Rome, and the Solana runtime refuses the CPI. The user's rights over their tokens come from the delegation, never from the adapter signing as the user.

Delegation without a pull: if your program should spend straight from the user's token account, the user sends `HelperProgram.approve_spl_raw_delegate(userAta, yourProgramPda, amount, mint, decimals)` directly to the HelperProgram, naming your program's PDA as SPL delegate. The Rome bridge uses this for Wormhole. The one-delegate rule from section 3 applies here too: the grant displaces the wrapper's.

## 8. Testing

* Deploy against **Hadrian** (testnet, chain 200010) first; addresses and RPC are in [Contract Addresses](/reference/contract-addresses.md) and the [registry](https://github.com/rome-protocol/rome-registry).
* `eth_call` and `eth_estimateGas` run the same emulation the proxy uses to send, so a CPI that fails there fails on chain for the same reason, and the revert data carries the underlying Solana error (`non-evm call error: …` in the Solana program log).
* Read the Solana side of a landed EVM transaction with `rome_solanaTxForEvmTx(hash)`; the Solana receipt is where CU and your program's logs live.
* A CPI that works in `eth_call` but is refused when sent usually tripped a limit the emulation reports: the 64-address cap, the atomic CU budget (the transaction went iterative), or the track rule.

## 9. Reference

* [Precompile reference](/reference/precompiles.md) — every selector on every precompile, with its dispatch class and the delegatecall rule.
* [Constraints](/core-concepts/constraints.md) — the limits above in one table.
* [Token Interop](/core-concepts/token-interop.md) — wrappers, canonical mints, deposit and withdraw.
* Reference apps in the public repos: [cardo](https://github.com/rome-protocol/cardo) (the user calls the CPI precompile directly, shape B), [rome-dex](https://github.com/rome-protocol/rome-dex) (direct `invoke` plus a custody-less router, shape C), and [aerarium](https://github.com/rome-protocol/aerarium) (a Compound III lending app over one shared market; it does not call Solana programs from Solidity (no CPI), so it is not an example of an adapter).


---

# 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/developer-guides/integrate-solana-program.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.
