> 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 **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:

```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** | 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 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.

`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:

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

const deps = { connection, proxyUrl, programId, chainId, payer: wallet.publicKey, signTransaction: wallet.signTransaction };
if (!(await isSyntheticProvisioned(connection, programId, synthetic))) {
  await provisionSynthetic(deps); // one create_pda; then submit writes with autoProvision: false
}
```

## 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 leg** — `buildFundLeg(...)` → `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. **Approve** — `submitRomeTxSolanaLane({ 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. **Deposit** — `submitRomeTxSolanaLane({ 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.**

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

const synthetic = syntheticAddress(wallet.publicKey);
const deps = { connection, proxyUrl, programId, chainId, payer: wallet.publicKey, signTransaction: wallet.signTransaction };

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

// 2) approve the vault (first lane call → synthetic auto-provisioned)
await submitRomeTxSolanaLane(deps, { to: wUSDC, data: encodeFunctionData({ abi: erc20Abi, functionName: "approve", args: [vault, depositAmount] }) });

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

## Value OUT — withdraw (step by step)

Now the user withdraws. Also Phantom-signed:

1. **Withdraw** — `submitRomeTxSolanaLane({ 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, 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.

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

// 1) withdraw — vault returns USDC to the synthetic's token account
await submitRomeTxSolanaLane(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 submitSolanaInstructions([sweep.ensureWalletAtaIx], { connection, feePayer: wallet.publicKey, signTransaction: wallet.signTransaction });
await submitRomeTxSolanaLane(deps, { to: sweep.helperTo, data: sweep.calldata, extraAccounts: sweep.extraAccounts });
```

## 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](/resources/faucets.md)).

## 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

* [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 dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.rome.builders/developer-guides/dual-lane-app.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

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.
