For the complete documentation index, see llms.txt. This page is also available as Markdown.

Build a dual-lane app

A step-by-step walkthrough of a dual-lane app — one Solidity contract used by both a MetaMask user and a Phantom (Solana) user, with exactly what happens at each step.

A dual-lane app is one Solidity contract that both a MetaMask (EVM) user and a Phantom (Solana) user use directly — same contract, same state, each with the wallet they already have. This page walks through exactly what happens at each step, on each side.

The example is a tiny vault: you deposit USDC and later withdraw it. "Stake / unstake", "supply / redeem", "tip / claim" are the same shape.

What you write — a standard ERC-20 vault

On Rome, a Solana user's USDC appears on the EVM side as an ordinary ERC-20 token — the SPL wrapper for that mint (e.g. wUSDC). So your contract is a normal token vault: it pulls tokens with transferFrom and returns them with transfer. Nothing Rome-specific:

interface IERC20 {
    function transfer(address to, uint256 amount) external returns (bool);
    function transferFrom(address from, address to, uint256 amount) external returns (bool);
}

contract Vault {
    IERC20 public immutable token;                 // the wUSDC wrapper
    mapping(address => uint256) public balanceOf;
    constructor(IERC20 _token) { token = _token; }

    function deposit(uint256 amount) external {
        require(token.transferFrom(msg.sender, address(this), amount));
        balanceOf[msg.sender] += amount;
    }
    function withdraw(uint256 amount) external {
        balanceOf[msg.sender] -= amount;
        require(token.transfer(msg.sender, amount));
    }
}

Why ERC-20, not payable / msg.value? A Solana user's spendable balance is their wallet's SPL token account, surfaced 1:1 as this ERC-20 wrapper — not the EVM native balance. So a Solana user can always fund a transferFrom, but a deposit() payable would need native value they don't hold at rest. Build around the token and both lanes work the same way.

Deploy it with Foundry or Hardhat, pointing the constructor at the wrapper address for your token (from the registry). On Rome the gas token is USDC, so you need a little USDC gas balance to deploy.

The two lanes

Lane
Wallet
How the app calls it

EVM

MetaMask (an EVM key)

submitRomeTx — the standard Rome write

Solana

Phantom (a Solana key)

submitRomeTxSolanaLane — no EVM key needed

The EVM lane is ordinary. The rest of this page is the Solana lane — the interesting half.

The key idea: the synthetic is a pass-through

A Solana user's EVM identity is their synthetic addresskeccak256(solana_pubkey)[12:]. It's their msg.sender in the contract, but it holds nothing at rest. The user's money lives in their Solana wallet (as SPL USDC), and on the EVM side that same balance is what wUSDC.balanceOf(synthetic) reads. Value flows through the synthetic:

  • In (deposit): wallet token account → synthetic token account → contract (via transferFrom).

  • Out (withdraw): contract → synthetic token account → wallet token account.

The synthetic nets back to nothing after each round-trip.

One-time: Activate (provision the synthetic)

A brand-new synthetic's on-chain account doesn't exist until you create it. The first time a Solana user acts, their synthetic is provisioned with a create_pda call — after that, value-moving calls (the ERC-20 transferFrom, the sweep) can be signed by it.

submitRomeTxSolanaLane does this automatically on first use (autoProvision defaults on). If you'd rather show an explicit "Activate" screen (a one-time account setup), do it yourself:

What each side needs

Needs
Why

Solana user (Phantom)

SOL (a little)

pays the Solana transaction fee on each lane tx

USDC as an SPL token in their wallet

the value they deposit (seen on the EVM side as wUSDC)

EVM user (MetaMask)

USDC as their Rome gas balance

gas + value; they top it up by bridging USDC into Rome (there is no faucet)

You (the builder)

a wallet with USDC gas on Rome

to deploy the contract

Value IN — deposit (step by step)

The Solana user has SOL + USDC in their Phantom wallet. Every lane transaction is Phantom-signed — the wallet signs it and sends it to the Solana RPC (the proxy is only used to discover accounts):

  1. Fund legbuildFundLeg(...)submitSolanaInstructions(...). Creates the synthetic's USDC token account (if needed) and runs ActivateAta, moving amount of USDC from the wallet's token account into the synthetic's. Now wUSDC.balanceOf(synthetic) shows that balance.

  2. ApprovesubmitRomeTxSolanaLane({ to: wUSDC, data: approve(vault, amount) }). Lets the vault pull the tokens. (This is usually the first lane call, so the synthetic is auto-provisioned here.)

  3. DepositsubmitRomeTxSolanaLane({ to: vault, data: deposit(amount) }). The vault runs transferFrom(synthetic, vault, amount) — the USDC moves from the synthetic into the vault, credited to the synthetic's address.

Net effect: USDC went Phantom wallet → (synthetic) → the vault.

Value OUT — withdraw (step by step)

Now the user withdraws. Also Phantom-signed:

  1. WithdrawsubmitRomeTxSolanaLane({ to: vault, data: withdraw(amount) }). vault.withdraw runs transfer(synthetic, amount) — USDC moves from the vault back into the synthetic's token account.

  2. Sweep legbuildSweepLeg(...) gives you the HelperProgram.transfer_spl call + accounts; run it (create the wallet's token account if needed, then a DoTxUnsigned to the Helper precompile) to move the USDC from the synthetic back to the user's own Solana wallet. The synthetic nets to nothing.

Net effect: USDC went the vault → (synthetic) → the user's Phantom wallet. Nothing is stranded.

The gotchas — all handled by the SDK

These are the things a hand-built Solana-lane transaction gets wrong; submitRomeTxSolanaLane does them for you:

  • Provisioning. A fresh synthetic's account must be created (create_pda) before any value-moving call, or it can't sign the transfer. Auto on first use; opt out with autoProvision: false + provisionSynthetic.

  • Spend the wrapper, not msg.value. A Solana user's balance is their SPL token account, surfaced as the ERC-20 wrapper — move it with transfer / transferFrom, never native value.

  • ComputeBudget. Rome's EVM needs a raised CU limit (~1.35M) and a large heap frame (~250 KB). Solana's 200K-CU / 32-KB defaults fault.

  • Treasure wallet. The execution pays a per-chain treasure account a small fee; account discovery omits it, so the SDK appends it.

  • Where it's sent. The wallet signs the Solana transaction and sends it to the Solana RPC — not to the proxy. The proxy is used only for account discovery. On-chain, the program derives msg.sender from the Solana signer.

  • Gas is USDC. No faucet — bridge USDC in (see Getting funded).

The same app from MetaMask

An EVM user calls the identical contract with submitRomeTx — standard EVM tooling, gas in USDC. They still approve then deposit (ERC-20 as usual), with no fund/sweep legs (their tokens already live at their EVM address). Both users share the same balanceOf state.

What's next

Last updated

Was this helpful?