> 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/zh/kai-fa-zhe-zhi-nan/dual-lane-app.md).

# 构建双通道应用

一个双通道应用的逐步讲解——同一个 Solidity 合约同时供 MetaMask 用户和 Solana 钱包用户使用，并精确说明每一步会发生什么。

一个 **双通道应用** 是一个 Solidity 合约，供一个 **MetaMask（EVM）** 用户以及一个 **Solana 钱包** 用户直接使用——同一个合约、同一份状态，每个人都使用自己已有的钱包。本页将逐步讲解 *准确地* 每一步、双方各自会发生什么。

这个示例是一个很小的 **金库**：你 `deposit` USDC，之后再 `withdraw` 它。“质押/解押”、“存入/赎回”、“打赏/领取”都是同一种模式。

## 你要编写的内容——一个标准的 ERC-20 金库

在 Rome 上，Solana 用户的 USDC 在 EVM 侧表现为一个普通的 **ERC-20 代币** ——该铸币对应的 SPL 包装代币（例如 `wUSDC`）。因此，你的合约就是一个普通的代币金库：它通过 `transferFrom` 拉取代币，并通过 `transfer`归还代币。没有任何 Rome 特定逻辑：

```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;                 // wUSDC 包装代币
    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));
    }
}
```

> **为什么是 ERC-20，而不是 `payable` / `msg.value`?** Solana 用户可支配的余额是他们的 **钱包的 SPL 代币账户**，在这里一一映射为这个 ERC-20 包装代币—— *而不是* EVM 原生余额。因此，Solana 用户总是可以为 `transferFrom`提供资金，但 `deposit() payable` 则需要他们在静态状态下并不持有的原生资产。围绕代币来设计，两条通道就能以同样方式工作。

使用 Foundry 或 Hardhat 部署它，并将构造函数指向你的代币对应的包装地址（来自 [registry](https://github.com/rome-protocol/rome-registry)）。在 Rome 上，gas 代币是 USDC，因此你需要少量 USDC gas 余额才能部署。

## 两条通道

| 通道         | 钱包                                      | 应用如何调用                                           |
| ---------- | --------------------------------------- | ------------------------------------------------ |
| **EVM**    | MetaMask（一个 EVM 密钥）                     | `submitRomeTx` ——标准的 Rome 写入方式                   |
| **Solana** | 例如 Solflare 这样的 Solana 钱包（一个 Solana 密钥） | `submitRomeTxV1` ——每次调用一笔 Solana v1 交易，无需 EVM 密钥 |

EVM 通道很普通。本页其余部分讲的是 **Solana 通道** ——更有意思的另一半。

## 核心思想：synthetic 是一个中转通道

Solana 用户的 EVM 身份是他们的 **合成地址** — `keccak256(solana_pubkey)[12:]`。它是他们的 `msg.sender` 在合约中的 **，但在静态状态下不持有任何资产。** 用户的资金存在于他们的 **Solana 钱包** （作为 SPL USDC）中，而在 EVM 侧，同一余额就是 `wUSDC.balanceOf(synthetic)` 读取到的值。价值流经 *通过* synthetic：

* **流入** （存款）：钱包代币账户 → synthetic 代币账户 → 合约（通过 `transferFrom`).
* **流出** （取款）：合约 → synthetic 代币账户 → 钱包代币账户。

每次往返后，synthetic 的净余额归零。

## 一次性：激活（为 synthetic 预置账户）

全新的 synthetic 链上账户在你创建之前并不存在。Solana 用户首次操作时，他们的 synthetic 会被 **预置** 并通过一个 `create_pda` 调用完成——之后，涉及价值移动的调用（ERC-20 `transferFrom`，以及 sweep）就可以由它签名。

`submitRomeTxV1` 不会替你完成这一步，因此请在第一次涉及价值移动的调用之前显式预置一次。一个合适的位置是“激活”步骤，或者在第一次 approve 之前：

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

const synthetic = syntheticAddress(wallet.publicKey);
const deps = { connection, proxyUrl, chainId, programId, signer }; // signer：脚本中的 Keypair，或钱包应用中的 LaneSigner
if (!(await isSyntheticProvisioned(connection, programId, synthetic))) {
  await submitRomeTxV1(deps, buildCreatePdaCall(synthetic)); // 一次 create_pda
}
```

## 一次性：授予包装器

wUSDC 包装代币以用户的 **SPL 委托人** 代币账户的 `transferFrom` 从钱包中转出都会失败，存款会因 SPL 错误而回滚 `0x4` （所有者不匹配）。

使用包装代币的视图函数检查 `isEnabled(user)`。如果返回 `false`，则将 `approve_spl(wrapper, 2^64-1, mint)` 直接发送到位于 `0xff00000000000000000000000000000000000009`的 Helper 预编译合约；铸币为 `mint_id()` ，位于包装代币上。在 EVM 通道中这是一个 `submitRomeTx` 调用，在 Solana 通道中是一个 `submitRomeTxV1` 调用：

```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) }); // 连接到该链 RPC 的 viem 客户端

// who：MetaMask 地址，或者 Solana 用户的 syntheticAddress(wallet.publicKey)
let grant = null; // 当 isEnabled 为 true 时保持为 null
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 通道：如果有 grant，就执行 await submitRomeTx(provider, { from: who, ...grant });
// Solana 通道：如果有 grant，就执行 await submitRomeTxV1(deps, grant);
```

## 各方需要什么

|                      | 需求                                                  | 原因                                                                         |
| -------------------- | --------------------------------------------------- | -------------------------------------------------------------------------- |
| **Solana 用户**        | **SOL** （少量）                                        | 支付每笔通道交易的 Solana 交易费用                                                      |
|                      | **以 SPL 代币形式存在的 USDC** 在他们的钱包中                      | 他们存入的价值（在 EVM 侧显示为 `wUSDC`)                                                |
| **EVM 用户（MetaMask）** | **USDC** 作为他们的 Rome gas 余额                          | gas，以及在包装后他们存入的价值；他们通过 **跨链桥接 USDC** 到 Rome 中（没有水龙头）                       |
|                      | 一个 **包装** 将部分 gas 包装为 **wUSDC** (`withdraw_to_ata`) | 金库接受的是 wUSDC，即 ERC-20 包装代币，而不是原生 gas。MetaMask 中的 gas USDC 余额在包装之前并不是 wUSDC |
|                      | 一次性的 **包装代币授权** (`approve_spl`)                     | 允许 wUSDC 包装代币移动他们的代币；参见 [一次性：授予包装器](#one-time-grant-the-wrapper)           |
| **你（构建者）**           | 一个带有 **USDC** Rome 上 gas 的钱包                        | 用于部署合约                                                                     |

## 资金流入——存款（逐步说明）

Solana 用户的钱包中有 SOL 和 USDC。每一步都是一笔 Solana v1 交易， **由该钱包签名**，并由应用发送到 Solana RPC。资金腿在应用中构建，钱包通过一次提示完成签名；每次合约调用都由代理组装，钱包在阅读授权消息后签名：

1. **资金腿** — `buildFundLeg(...)` → `submitSolanaInstructionsV1(...)`。创建 synthetic 的 USDC 代币账户（如有需要），并执行 **`ActivateAta`**，将 `amount` 的 USDC 从钱包的代币账户 **转入 synthetic 的**构建。现在 `wUSDC.balanceOf(synthetic)` 显示该余额。
2. **授权包装代币** （当 `isEnabled` 为 false）—— `submitRomeTxV1(deps, { to: HELPER, data: approve_spl(wUSDC, 2^64-1, mint) })`。参见 [一次性：授予包装器](#one-time-grant-the-wrapper). *（如果这是用户首次通道调用，请在此之前预置 synthetic。）*
3. **授权** — `submitRomeTxV1(deps, { to: wUSDC, data: approve(vault, amount) })`。允许金库拉取代币。
4. **存入** — `submitRomeTxV1(deps, { to: vault, data: deposit(amount) })`。金库执行 `transferFrom(synthetic, vault, amount)` ——USDC 从 synthetic 转入金库，并记入 synthetic 地址。

**净效果：** USDC 流向了 **Solana 钱包 →（synthetic）→ 金库。**

```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：LaneSigner（payer、signAuthHash、signV1Wire）或 Keypair

// 1) 资金腿——钱包 USDC → synthetic 代币账户（由钱包签名）
await submitSolanaInstructionsV1(
  buildFundLeg({ programId, chainId, mint: usdcMint, amount: depositAmount, wallet: wallet.publicKey, synthetic }),
  { connection, signer },
);

// 2) 仅授权一次包装代币（synthetic 已经预置）；`grant` 是在“ 一次性：授权包装代币”下构建的调用，若 isEnabled 为 true 则为 null
if (grant) await submitRomeTxV1(deps, grant);

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

// 4) 存款——金库通过 transferFrom 拉取
await submitRomeTxV1(deps, { to: vault, data: encodeFunctionData({ abi, functionName: "deposit", args: [depositAmount] }) });
```

## 资金流出——取款（逐步说明）

现在用户取款。同样由钱包签名，每次调用两次提示：

1. **提取** — `submitRomeTxV1(deps, { to: vault, data: withdraw(amount) })`. `vault.withdraw` 执行 `transfer(synthetic, amount)` ——USDC 从金库返回到 **synthetic 的** 代币账户。
2. **回扫腿** — `buildSweepLeg(...)` 为你提供 `HelperProgram.transfer_spl` 调用 + 账户；执行它（如有需要，先通过 `submitSolanaInstructionsV1`创建钱包的代币账户，然后通过 `submitRomeTxV1`；代理会找到代币账户）以将 USDC 从 synthetic **再转回用户自己的 Solana 钱包**。synthetic 的净额归零。

**净效果：** USDC 流向了 **金库 →（synthetic）→ 用户的 Solana 钱包。** 没有任何资金滞留。

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

// 1) 取款——金库将 USDC 返还到 synthetic 的代币账户
await submitRomeTxV1(deps, { to: vault, data: encodeFunctionData({ abi, functionName: "withdraw", args: [amount] }) });

// 2) 回扫腿——synthetic 代币账户 → 用户自己的钱包代币账户
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 });
```

## 在 Hadrian 上测得

以下每个 Solana 通道步骤都作为一笔 version-1 交易在 Hadrian 上运行：

| 步骤                   | 计算单元    | 字节    | 账户 |
| -------------------- | ------- | ----- | -- |
| 预置（`create_pda`)     | 39,008  | 703   | 9  |
| 包装器授权（`approve_spl`) | 50,687  | 899   | 13 |
| 授权                   | 79,335  | 769   | 10 |
| 金库存款                 | 242,880 | 1,070 | 20 |
| 金库取款                 | 280,903 | 1,037 | 19 |
| 回扫（`transfer_spl`)   | 46,810  | 899   | 13 |

## 注意事项

这些是手工构建的 Solana 通道交易容易出错的地方。SDK 和代理的 composer 会处理其中大部分：

* **预置。** 全新的 synthetic 账户必须先创建（`create_pda`）然后才能进行任何涉及价值移动的调用，否则它无法签名转账。 `submitRomeTxV1` 不会替你完成：请检查 `isSyntheticProvisioned` 并提交 `buildCreatePdaCall(synthetic)` 一次。
* **仅授权一次包装代币。** 包装代币会作为用户的 SPL 委托方移动代币。检查 `isEnabled` 并发送 `approve_spl` 在首次存款之前发送到 Helper 预编译合约，否则存款会因 SPL 错误而回滚 `0x4`.
* **花费包装代币，而不是 `msg.value`.** Solana 用户的余额就是他们的 SPL 代币账户，在此表现为 ERC-20 包装代币——请用 `transfer` / `transferFrom`进行转移，绝不要用原生资产。
* **为你组装完成。** ComputeBudget、treasure wallet 和账户发现都由代理的 composer 处理，因此它们不会出现在你的代码中。在钱包签名之前，SDK 会检查已组装的交易，只允许用户授权的调用。
* **发送到哪里。** 钱包签署 v1 交易，而 **应用将其发送到 Solana RPC** ——不是发送给代理，代理只负责组装它（下面有一个例外：中继调用；参见 [代发调用和长调用](/zh/kai-fa-zhe-zhi-nan/call-evm-from-solana.md#relayed-calls-and-long-calls)）。在链上，程序会从 `msg.sender` Solana 签名者派生出
* **每次调用两次提示。** 钱包先签署一条可读的授权消息（`Rome authorization` 以及 EVM 交易哈希），然后再签署交易，两者都使用同一把密钥。
* **检查你的钱包是否支持 v1 签名。** `signV1Wire` 必须返回针对 v1 消息的 64 字节签名；通过 Wallet Standard，这对应于 `solana:signTransaction` 在原始字节流中的 `signedTransaction.slice(-64)`。并非每个钱包都已支持解析 v1 交易，因此在基于它开发前请先测试你的钱包。当前在 devnet 上，Solflare 支持签名；Phantom 仍不支持。
* **大型调用会走中继。** 无法装入一笔交易的调用会由代理使用同一授权签名进行中继，其结果具有 `route: "relay"`。中继调用以该链的原生 gas 支付，因此请用 `fundSyntheticGas(deps, amountWei)`。参见 [代发调用和长调用](/zh/kai-fa-zhe-zhi-nan/call-evm-from-solana.md#relayed-calls-and-long-calls).
* **Gas 是 USDC。** 没有水龙头——请桥入 USDC（参见 [获得资金](/zh/zi-yuan/faucets.md)).

前面的 `submitRomeTxSolanaLane` 仍然保留在 SDK 中：它会在客户端构建一笔传统的 Solana 交易（一次钱包提示，ComputeBudget 和 treasure wallet 由 SDK 处理）。

## 来自 MetaMask 的同一应用

EVM 用户使用 `submitRomeTx` ——标准 EVM 工具，gas 以 USDC 支付。与 Solana 用户不同的一点是：MetaMask 用户持有 **原生 gas USDC**，而不是 wUSDC。金库接受的是 wUSDC 包装代币，因此用户先将部分 gas 包装起来，然后只需授权一次包装代币，再像往常一样执行 ERC-20 步骤。由于包装后的代币已经存在于他们的 EVM 地址中，所以没有资金腿或回扫腿。

1. **将 gas 包装为 wUSDC** ——发送 `withdraw_to_ata(wei)` 到位于 `0x4200000000000000000000000000000000000016`。它会销毁等量的原生 gas，并将等值金额记入用户在 gas 代币中的代币 `wUSDC.balanceOf(user)` 便会显示出来。 `wei` 以 18 位小数的 gas 单位计价，且必须是 10^12 的倍数（即一个 6 位小数的 wUSDC 单位），否则调用会回滚。如果用户已经持有 wUSDC，则跳过此步骤。
2. **授权包装代币** （当 `isEnabled` 为 false）——同样的 `approve_spl` 调用如上，发送时使用 `submitRomeTx`。参见 [一次性：授予包装器](#one-time-grant-the-wrapper).
3. **授权** — `approve(vault, amount)` 于 wUSDC 上。
4. **存入** — `deposit(amount)` 于金库上。取款是 `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) 将部分 gas USDC 包装成 wUSDC（weiAmount 以 18 位小数的 gas 单位计）
await submitRomeTx(provider, { from: who, to: WITHDRAW, data: encodeFunctionData({ abi: withdrawAbi, functionName: "withdraw_to_ata", args: [weiAmount] }) });

// 2) 仅授权一次包装代币：`grant` 是在“ 一次性：授权包装代币”下构建的调用，或为 null
if (grant) await submitRomeTx(provider, { from: who, ...grant });

// 3) 授权并 4) 存款，与任何 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] }) });
```

两位用户共享同一个 `balanceOf` 金库中的状态。

## 下一步

* [从 Solana 调用 EVM](/zh/kai-fa-zhe-zhi-nan/call-evm-from-solana.md) ——Solana 通道机制详解
* [从 EVM 调用 Solana](/zh/kai-fa-zhe-zhi-nan/call-solana-from-evm.md) ——另一个方向（CPI）
* [获得资金](/zh/zi-yuan/faucets.md) ——USDC 作为 gas 代币；先桥入


---

# 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/zh/kai-fa-zhe-zhi-nan/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.
