> 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/dual-lane-app.md).

# 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 Solana wallet user, with exactly what happens at each step.

A **dual-lane app** is one Solidity contract that both a **MetaMask (EVM)** user and a **Solana wallet** 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:

```solidity
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](https://github.com/rome-protocol/rome-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** | a Solana wallet such as Solflare (a Solana key) | `submitRomeTxV1` — one Solana v1 transaction per call, 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 address** — `keccak256(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.

`submitRomeTxV1` does not do this for you, so provision explicitly, once, before the first value-moving call. A good place is an "Activate" step, or just before the first approve:

```javascript
import { submitRomeTxV1, isSyntheticProvisioned, buildCreatePdaCall, syntheticAddress } from "@rome-protocol/sdk";

const synthetic = syntheticAddress(wallet.publicKey);
const deps = { connection, proxyUrl, chainId, programId, signer }; // signer: a Keypair in scripts, a LaneSigner in a wallet app
if (!(await isSyntheticProvisioned(connection, programId, synthetic))) {
  await submitRomeTxV1(deps, buildCreatePdaCall(synthetic)); // one create_pda
}
```

## One-time: grant the wrapper

The wUSDC wrapper moves a user's tokens as the **SPL delegate** of their token account, so each user has to grant it that right once per wrapper, before the first deposit. This applies to a MetaMask user and to a Solana user's synthetic address alike. Without the grant, every `transferFrom` out of the wallet fails and the deposit reverts with SPL error `0x4` (owner mismatch).

Check with the wrapper's view `isEnabled(user)`. If it returns `false`, send `approve_spl(wrapper, 2^64-1, mint)` straight to the Helper precompile at `0xff00000000000000000000000000000000000009`; the mint is `mint_id()` on the wrapper. On the EVM lane that is a `submitRomeTx` call, on the Solana lane a `submitRomeTxV1` call:

```javascript
import { submitRomeTx, submitRomeTxV1, syntheticAddress } from "@rome-protocol/sdk";
import { createPublicClient, encodeFunctionData, http, parseAbi } from "viem";

const HELPER = "0xff00000000000000000000000000000000000009";
const wrapperAbi = parseAbi(["function isEnabled(address user) view returns (bool)", "function mint_id() view returns (bytes32)"]);
const grantAbi = parseAbi(["function approve_spl(address spender, uint64 amount, bytes32 mint)"]);

const publicClient = createPublicClient({ transport: http(rpcUrl) }); // a viem client on the chain's RPC

// who: the MetaMask address, or syntheticAddress(wallet.publicKey) for a Solana user
let grant = null; // stays null when isEnabled is true
if (!(await publicClient.readContract({ address: wUSDC, abi: wrapperAbi, functionName: "isEnabled", args: [who] }))) {
  const mint = await publicClient.readContract({ address: wUSDC, abi: wrapperAbi, functionName: "mint_id" });
  grant = { to: HELPER, data: encodeFunctionData({ abi: grantAbi, functionName: "approve_spl", args: [wUSDC, 2n ** 64n - 1n, mint] }) };
}
// EVM lane:    if (grant) await submitRomeTx(provider, { from: who, ...grant });
// Solana lane: if (grant) await submitRomeTxV1(deps, grant);
```

## What each side needs

|                         | Needs                                                             | Why                                                                                                                        |
| ----------------------- | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| **Solana user**         | **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, and the value they deposit once it is wrapped; they top it up by **bridging USDC** into Rome (there is no faucet)     |
|                         | a **wrap** of some of that gas into **wUSDC** (`withdraw_to_ata`) | the vault takes wUSDC, the ERC-20 wrapper, not native gas. A MetaMask balance of gas USDC is not wUSDC until it is wrapped |
|                         | the one-time **wrapper grant** (`approve_spl`)                    | lets the wUSDC wrapper move their tokens; see [One-time: grant the wrapper](#one-time-grant-the-wrapper)                   |
| **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 Solana wallet. Every step is a Solana v1 transaction, **signed by that wallet**, and the app sends it to the Solana RPC. The fund leg is built in the app and the wallet signs it with one prompt; each contract call is composed by the proxy, and the wallet signs it after a readable authorization message:

1. **Fund leg** — `buildFundLeg(...)` → `submitSolanaInstructionsV1(...)`. 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. **Grant the wrapper** (when `isEnabled` is false) — `submitRomeTxV1(deps, { to: HELPER, data: approve_spl(wUSDC, 2^64-1, mint) })`. See [One-time: grant the wrapper](#one-time-grant-the-wrapper). *(Provision the synthetic before this if it is the user's first lane call.)*
3. **Approve** — `submitRomeTxV1(deps, { to: wUSDC, data: approve(vault, amount) })`. Lets the vault pull the tokens.
4. **Deposit** — `submitRomeTxV1(deps, { 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 **Solana wallet → (synthetic) → the vault.**

```javascript
import { syntheticAddress, buildFundLeg, submitSolanaInstructionsV1, submitRomeTxV1 } from "@rome-protocol/sdk";
import { encodeFunctionData, erc20Abi } from "viem";

const synthetic = syntheticAddress(wallet.publicKey);
const deps = { connection, proxyUrl, chainId, programId, signer }; // signer: a LaneSigner (payer, signAuthHash, signV1Wire) or a Keypair

// 1) fund leg — wallet USDC → synthetic token account (the wallet signs)
await submitSolanaInstructionsV1(
  buildFundLeg({ programId, chainId, mint: usdcMint, amount: depositAmount, wallet: wallet.publicKey, synthetic }),
  { connection, signer },
);

// 2) grant the wrapper, once (the synthetic is already provisioned); `grant` is the call built under "One-time: grant the wrapper", or null when isEnabled is true
if (grant) await submitRomeTxV1(deps, grant);

// 3) approve the vault
await submitRomeTxV1(deps, { to: wUSDC, data: encodeFunctionData({ abi: erc20Abi, functionName: "approve", args: [vault, depositAmount] }) });

// 4) deposit — the vault pulls via transferFrom
await submitRomeTxV1(deps, { to: vault, data: encodeFunctionData({ abi, functionName: "deposit", args: [depositAmount] }) });
```

## Value OUT — withdraw (step by step)

Now the user withdraws. Also wallet-signed, two prompts per call:

1. **Withdraw** — `submitRomeTxV1(deps, { 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 leg** — `buildSweepLeg(...)` gives you the `HelperProgram.transfer_spl` call + accounts; run it (create the wallet's token account if needed with `submitSolanaInstructionsV1`, then send the Helper call through `submitRomeTxV1`; the proxy finds the token accounts) 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 Solana wallet.** Nothing is stranded.

```javascript
import { buildSweepLeg } from "@rome-protocol/sdk";

// 1) withdraw — vault returns USDC to the synthetic's token account
await submitRomeTxV1(deps, { to: vault, data: encodeFunctionData({ abi, functionName: "withdraw", args: [amount] }) });

// 2) sweep leg — synthetic token account → the user's own wallet token account
const sweep = buildSweepLeg({ programId, mint: usdcMint, amount, wallet: wallet.publicKey, synthetic });
await submitSolanaInstructionsV1([sweep.ensureWalletAtaIx], { connection, signer });
await submitRomeTxV1(deps, { to: sweep.helperTo, data: sweep.calldata });
```

## Measured on Hadrian

Each Solana-lane step below ran as one version-1 transaction on Hadrian:

| Step                          | Compute units | Bytes | Accounts |
| ----------------------------- | ------------- | ----- | -------- |
| Provision (`create_pda`)      | 39,008        | 703   | 9        |
| Wrapper grant (`approve_spl`) | 50,687        | 899   | 13       |
| Approve                       | 79,335        | 769   | 10       |
| Vault deposit                 | 242,880       | 1,070 | 20       |
| Vault withdraw                | 280,903       | 1,037 | 19       |
| Sweep (`transfer_spl`)        | 46,810        | 899   | 13       |

## The gotchas

These are the things a hand-built Solana-lane transaction gets wrong. The SDK and the proxy's composer handle most of them:

* **Provisioning.** A fresh synthetic's account must be created (`create_pda`) before any value-moving call, or it can't sign the transfer. `submitRomeTxV1` does not do it for you: check `isSyntheticProvisioned` and submit `buildCreatePdaCall(synthetic)` once.
* **Grant the wrapper once.** The wrapper moves tokens as the user's SPL delegate. Check `isEnabled` and send `approve_spl` to the Helper precompile before the first deposit, or the deposit reverts with SPL error `0x4`.
* **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.
* **Composed for you.** ComputeBudget, the treasure wallet and account discovery are done by the proxy's composer, so none of them appear in your code. Before the wallet signs, the SDK checks the composed transaction and refuses anything but the call the user authorized.
* **Where it's sent.** The wallet signs the v1 transaction and the **app sends it to the Solana RPC** — not to the proxy, which composes it (a relayed call is the one exception, below; see [Relayed calls and long calls](/developer-guides/call-evm-from-solana.md#relayed-calls-and-long-calls)). On-chain, the program derives `msg.sender` from the Solana signer.
* **Two prompts per call.** The wallet signs a readable authorization message (`Rome authorization` and the EVM transaction hash), then the transaction, both with the same key.
* **Check your wallet signs v1.** `signV1Wire` must return the 64-byte signature over the v1 message; through Wallet Standard that is `solana:signTransaction` on the raw wire and `signedTransaction.slice(-64)`. Not every wallet decodes v1 transactions yet, so test yours before you build on it. On devnet today, Solflare signs them; Phantom does not yet.
* **Big calls relay.** A call that does not fit one transaction is relayed by the proxy with the same authorization signature, and the result has `route: "relay"`. A relayed call is paid in the chain's native gas, so top up the synthetic with `fundSyntheticGas(deps, amountWei)`. See [Relayed calls and long calls](/developer-guides/call-evm-from-solana.md#relayed-calls-and-long-calls).
* **Gas is USDC.** No faucet — bridge USDC in (see [Getting funded](/resources/faucets.md)).

The earlier `submitRomeTxSolanaLane` is still in the SDK: it builds a legacy Solana transaction in the client (one wallet prompt, with ComputeBudget and the treasure wallet handled by the SDK).

## The same app from MetaMask

An EVM user calls the identical contract with `submitRomeTx` — standard EVM tooling, gas in USDC. One difference from the Solana user: a MetaMask user holds **native gas USDC**, not wUSDC. The vault takes the wUSDC wrapper, so the user wraps some of their gas first, then grants the wrapper once, then does the ERC-20 steps as usual. There are no fund or sweep legs, because the wrapped tokens already live at their EVM address.

1. **Wrap gas into wUSDC** — send `withdraw_to_ata(wei)` to the Withdraw precompile at `0x4200000000000000000000000000000000000016`. It burns that much native gas and credits the equivalent amount to the user's token account in the gas token, which `wUSDC.balanceOf(user)` then shows. `wei` is in the 18-decimal gas unit and must be a multiple of 10^12 (one 6-decimal wUSDC unit), or the call reverts. Skip this step if the user already holds wUSDC.
2. **Grant the wrapper** (when `isEnabled` is false) — the same `approve_spl` call as above, sent with `submitRomeTx`. See [One-time: grant the wrapper](#one-time-grant-the-wrapper).
3. **Approve** — `approve(vault, amount)` on wUSDC.
4. **Deposit** — `deposit(amount)` on the vault. Withdraw is `withdraw(amount)`.

```javascript
import { submitRomeTx } from "@rome-protocol/sdk";
import { encodeFunctionData, erc20Abi, parseAbi } from "viem";

const WITHDRAW = "0x4200000000000000000000000000000000000016";
const withdrawAbi = parseAbi(["function withdraw_to_ata(uint256 wei_)"]);

// 1) wrap some gas USDC into wUSDC (weiAmount is in 18-decimal gas units)
await submitRomeTx(provider, { from: who, to: WITHDRAW, data: encodeFunctionData({ abi: withdrawAbi, functionName: "withdraw_to_ata", args: [weiAmount] }) });

// 2) grant the wrapper, once: `grant` is the call built under "One-time: grant the wrapper", or null
if (grant) await submitRomeTx(provider, { from: who, ...grant });

// 3) approve and 4) deposit, as for any ERC-20
await submitRomeTx(provider, { from: who, to: wUSDC, data: encodeFunctionData({ abi: erc20Abi, functionName: "approve", args: [vault, depositAmount] }) });
await submitRomeTx(provider, { from: who, to: vault, data: encodeFunctionData({ abi, functionName: "deposit", args: [depositAmount] }) });
```

Both users share the same `balanceOf` state in the vault.

## What's next

* [Call EVM from Solana](/developer-guides/call-evm-from-solana.md) — the Solana-lane mechanics in detail
* [Call Solana from EVM](/developer-guides/call-solana-from-evm.md) — the other direction (CPI)
* [Getting funded](/resources/faucets.md) — USDC as the gas token; bridge it in


---

# 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/dual-lane-app.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.
