> 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/vi/huong-dan-cho-nha-phat-trien/dual-lane-app.md).

# Xây dựng một ứng dụng hai làn

Hướng dẫn từng bước cho một ứng dụng hai làn — một hợp đồng Solidity được cả người dùng MetaMask và người dùng ví Solana sử dụng, với chính xác những gì xảy ra ở mỗi bước.

Một ứng dụng **ứng dụng hai làn** là một hợp đồng Solidity duy nhất mà cả một **MetaMask (EVM)** người dùng và một **ví Solana** đều dùng trực tiếp — cùng hợp đồng, cùng trạng thái, mỗi bên với ví họ đã có sẵn. Trang này sẽ đi qua *chính xác* điều gì xảy ra ở từng bước, ở mỗi phía.

Ví dụ này là một **két**: bạn `deposit` USDC và sau đó `rút` nó. "Stake / unstake", "supply / redeem", "tip / claim" đều có cùng dạng.

## Thứ bạn viết — một két ERC-20 tiêu chuẩn

Trên Rome, USDC của một người dùng Solana xuất hiện ở phía EVM như một **token ERC-20** — wrapper SPL cho mint đó (ví dụ `wUSDC`). Vì vậy hợp đồng của bạn là một két token bình thường: nó kéo token bằng `transferFrom` và trả chúng lại bằng `chuyển`. Không có gì đặc thù của 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;                 // wrapper 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));
    }
}
```

> **Vì sao là ERC-20, không phải `có thể nhận thanh toán` / `msg.value`?** Số dư có thể chi tiêu của một người dùng Solana là **tài khoản token SPL của ví**, được hiển thị 1:1 dưới dạng wrapper ERC-20 này — *không phải* số dư native trên EVM. Vì vậy người dùng Solana luôn có thể nạp cho một `transferFrom`, nhưng một `deposit() payable` sẽ cần giá trị native mà họ không nắm giữ ở trạng thái nghỉ. Hãy xây quanh token và cả hai làn sẽ hoạt động như nhau.

Triển khai nó bằng Foundry hoặc Hardhat, trỏ constructor tới địa chỉ wrapper của token của bạn (từ [registry](https://github.com/rome-protocol/rome-registry)). Trên Rome, token gas là USDC, nên bạn cần một ít số dư gas USDC để triển khai.

## Hai làn

| Làn        | Ví                                           | Ứng dụng gọi nó như thế nào                                                   |
| ---------- | -------------------------------------------- | ----------------------------------------------------------------------------- |
| **EVM**    | MetaMask (một khóa EVM)                      | `submitRomeTx` — cách ghi Rome tiêu chuẩn                                     |
| **Solana** | một ví Solana như Solflare (một khóa Solana) | `submitRomeTxV1` — mỗi lần gọi là một giao dịch Solana v1, không cần khóa EVM |

Làn EVM là bình thường. Phần còn lại của trang này là **Solana** — nửa thú vị.

## Ý tưởng chính: synthetic chỉ là lớp chuyển tiếp

Danh tính EVM của một người dùng Solana là **địa chỉ tổng hợp** — `keccak256(solana_pubkey)[12:]`. Nó là `msg.sender` trong hợp đồng, nhưng **nó không giữ gì ở trạng thái nghỉ.** Tiền của người dùng nằm trong **ví Solana** (dưới dạng SPL USDC), và ở phía EVM chính số dư đó là thứ mà `wUSDC.balanceOf(synthetic)` đọc được. Giá trị chảy *qua* qua synthetic:

* **Vào** (nạp): tài khoản token của ví → tài khoản token của synthetic → hợp đồng (qua `transferFrom`).
* **Ra** (rút): hợp đồng → tài khoản token của synthetic → tài khoản token của ví.

Synthetic quay về 0 sau mỗi lượt đi-về.

## Một lần: Kích hoạt (cấp phát synthetic)

Tài khoản on-chain của một synthetic mới hoàn toàn không tồn tại cho đến khi bạn tạo nó. Lần đầu tiên người dùng Solana hành động, synthetic của họ được **cấp phát** bằng một `create_pda` lệnh gọi — sau đó, các lệnh gọi dịch chuyển giá trị (ERC-20 `transferFrom`, lệnh sweep) có thể được nó ký.

`submitRomeTxV1` không tự làm việc này cho bạn, vì vậy hãy cấp phát rõ ràng, một lần, trước lệnh gọi dịch chuyển giá trị đầu tiên. Một chỗ hợp lý là bước "Activate", hoặc ngay trước lần approve đầu tiên:

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

const synthetic = syntheticAddress(wallet.publicKey);
const deps = { connection, proxyUrl, chainId, programId, signer }; // signer: một Keypair trong script, hoặc một LaneSigner trong ứng dụng ví
if (!(await isSyntheticProvisioned(connection, programId, synthetic))) {
  await submitRomeTxV1(deps, buildCreatePdaCall(synthetic)); // một create_pda
}
```

## Một lần: cấp cho wrapper

Wrapper wUSDC di chuyển token của người dùng với tư cách là **SPL delegate** của tài khoản token của họ, vì vậy mỗi người dùng phải cấp cho nó quyền đó một lần cho mỗi wrapper, trước lần nạp đầu tiên. Điều này áp dụng như nhau cho người dùng MetaMask và địa chỉ synthetic của người dùng Solana. Nếu không cấp quyền, mọi `transferFrom` lệnh chuyển ra khỏi ví đều thất bại và lệnh nạp hoàn nguyên với lỗi SPL `0x4` (không khớp chủ sở hữu).

Kiểm tra bằng hàm view của wrapper `isEnabled(user)`. Nếu nó trả về `false`, hãy gửi `approve_spl(wrapper, 2^64-1, mint)` thẳng tới precompile Helper tại `0xff00000000000000000000000000000000000009`; mint là `mint_id()` trên wrapper. Ở làn EVM đó là một `submitRomeTx` lệnh gọi, còn ở làn Solana là một `submitRomeTxV1` lệnh gọi:

```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) }); // một viem client trên RPC của chain

// ai: địa chỉ MetaMask, hoặc syntheticAddress(wallet.publicKey) cho người dùng Solana
let grant = null; // giữ nguyên null khi isEnabled là 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] }) };
}
// Làn EVM:    nếu có grant thì await submitRomeTx(provider, { from: who, ...grant });
// Làn Solana: nếu có grant thì await submitRomeTxV1(deps, grant);
```

## Mỗi bên cần gì

|                               | Cần                                                             | Vì sao                                                                                                                          |
| ----------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| **Người dùng Solana**         | **SOL** (một ít)                                                | trả phí giao dịch Solana cho mỗi giao dịch trên làn này                                                                         |
|                               | **USDC dưới dạng token SPL** trong ví của họ                    | giá trị họ nạp (trên phía EVM thấy như `wUSDC`)                                                                                 |
| **Người dùng EVM (MetaMask)** | **USDC** là số dư gas Rome của họ                               | gas, và giá trị họ nạp sau khi nó được bọc; họ nạp thêm bằng cách **bridge USDC** vào Rome (không có faucet)                    |
|                               | một **bọc** một phần gas đó thành **wUSDC** (`withdraw_to_ata`) | két nhận wUSDC, wrapper ERC-20, chứ không phải gas native. Số dư gas USDC của MetaMask không phải wUSDC cho đến khi nó được bọc |
|                               | việc cấp một lần **quyền cấp cho wrapper** (`approve_spl`)      | cho phép wrapper wUSDC di chuyển token của họ; xem [Một lần: cấp cho wrapper](#one-time-grant-the-wrapper)                      |
| **Bạn (người xây dựng)**      | một ví có **USDC** gas trên Rome                                | để triển khai hợp đồng                                                                                                          |

## Giá trị VÀO — nạp (từng bước)

Người dùng Solana có SOL + USDC trong ví Solana của họ. Mỗi bước là một giao dịch Solana v1, **được ví đó ký**, và ứng dụng gửi nó tới Solana RPC. Nhánh fund được dựng trong ứng dụng và ví ký nó bằng một lời nhắc; mỗi lệnh gọi hợp đồng do proxy tạo thành, và ví ký nó sau một thông điệp ủy quyền dễ đọc:

1. **Nhánh fund** — `buildFundLeg(...)` → `submitSolanaInstructionsV1(...)`. Tạo tài khoản token USDC của synthetic (nếu cần) và chạy **`ActivateAta`**, chuyển `amount` USDC từ tài khoản token của ví **vào**. Giờ thì `wUSDC.balanceOf(synthetic)` hiển thị số dư đó.
2. **Cấp quyền cho wrapper** (khi `vẫn` là false) — `submitRomeTxV1(deps, { to: HELPER, data: approve_spl(wUSDC, 2^64-1, mint) })`. Xem [Một lần: cấp cho wrapper](#one-time-grant-the-wrapper). *(Hãy cấp phát synthetic trước bước này nếu đây là lệnh gọi đầu tiên của người dùng trên làn này.)*
3. **Phê duyệt** — `submitRomeTxV1(deps, { to: wUSDC, data: approve(vault, amount) })`. Cho phép két kéo token.
4. **Nạp** — `submitRomeTxV1(deps, { to: vault, data: deposit(amount) })`. Két chạy `transferFrom(synthetic, vault, amount)` — USDC di chuyển từ synthetic vào két, được ghi có vào địa chỉ của synthetic.

**Hiệu ứng ròng:** USDC đi **ví Solana → (synthetic) → két.**

```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: một LaneSigner (payer, signAuthHash, signV1Wire) hoặc một Keypair

// 1) nhánh fund — USDC của ví → tài khoản token của synthetic (ví ký)
await submitSolanaInstructionsV1(
  buildFundLeg({ programId, chainId, mint: usdcMint, amount: depositAmount, wallet: wallet.publicKey, synthetic }),
  { connection, signer },
);

// 2) cấp quyền cho wrapper, một lần; `grant` là lệnh được dựng dưới mục "Một lần: cấp quyền cho wrapper", hoặc null khi isEnabled là true
if (grant) await submitRomeTxV1(deps, grant);

// 3) phê duyệt két
await submitRomeTxV1(deps, { to: wUSDC, data: encodeFunctionData({ abi: erc20Abi, functionName: "approve", args: [vault, depositAmount] }) });

// 4) nạp — két kéo qua transferFrom
await submitRomeTxV1(deps, { to: vault, data: encodeFunctionData({ abi, functionName: "deposit", args: [depositAmount] }) });
```

## Giá trị RA — rút (từng bước)

Bây giờ người dùng rút. Cũng do ví ký, hai lời nhắc cho mỗi lần gọi:

1. **Rút** — `submitRomeTxV1(deps, { to: vault, data: withdraw(amount) })`. `vault.withdraw` chạy `transfer(synthetic, amount)` — USDC di chuyển từ két trở lại **của synthetic** tài khoản token.
2. **Nhánh sweep** — `buildSweepLeg(...)` cho bạn `HelperProgram.transfer_spl` lệnh gọi + các tài khoản; chạy nó (tạo tài khoản token của ví nếu cần bằng `submitSolanaInstructionsV1`, rồi gửi lệnh gọi Helper qua `submitRomeTxV1`; proxy sẽ tìm các tài khoản token) để chuyển USDC từ synthetic **trở lại ví Solana của chính người dùng**. Synthetic về 0.

**Hiệu ứng ròng:** USDC đi **két → (synthetic) → ví Solana của người dùng.** Không có gì bị kẹt lại.

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

// 1) rút — két trả USDC về tài khoản token của synthetic
await submitRomeTxV1(deps, { to: vault, data: encodeFunctionData({ abi, functionName: "withdraw", args: [amount] }) });

// 2) nhánh sweep — tài khoản token của synthetic → tài khoản token của chính ví người dùng
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 });
```

## Đo trên Hadrian

Mỗi bước trên làn Solana dưới đây đã chạy như một giao dịch version-1 trên Hadrian:

| Bước                          | Đơn vị tính toán | Byte  | Tài khoản |
| ----------------------------- | ---------------- | ----- | --------- |
| Cấp phát (`create_pda`)       | 39,008           | 703   | 9         |
| Cấp quyền bọc (`approve_spl`) | 50,687           | 899   | 13        |
| Phê duyệt                     | 79,335           | 769   | 10        |
| Nạp vào két                   | 242,880          | 1,070 | 20        |
| Rút từ két                    | 280,903          | 1,037 | 19        |
| Sweep (`transfer_spl`)        | 46,810           | 899   | 13        |

## Những điểm dễ vướng

Đây là những thứ một giao dịch Solana-lane tự dựng thường làm sai. SDK và bộ composer của proxy xử lý hầu hết chúng:

* **Cấp phát.** Tài khoản của synthetic mới phải được tạo (`create_pda`) trước bất kỳ lệnh gọi chuyển giá trị nào, nếu không nó không thể ký chuyển khoản. `submitRomeTxV1` không làm việc này thay bạn: hãy kiểm tra `isSyntheticProvisioned` và gửi `buildCreatePdaCall(synthetic)` một lần.
* **Cấp quyền cho wrapper một lần.** Wrapper di chuyển token với tư cách là delegate SPL của người dùng. Kiểm tra `vẫn` và gửi `approve_spl` tới precompile Helper trước lần nạp đầu tiên, nếu không lệnh nạp sẽ hoàn nguyên với lỗi SPL `0x4`.
* **Chi tiêu wrapper, không phải `msg.value`.** Số dư của người dùng Solana là tài khoản token SPL của họ, được hiển thị như wrapper ERC-20 — hãy chuyển nó bằng `chuyển` / `transferFrom`, không bao giờ bằng giá trị native.
* **Được compose sẵn cho bạn.** ComputeBudget, ví kho bạc và việc dò tìm tài khoản đều do composer của proxy xử lý, nên không thứ nào xuất hiện trong code của bạn. Trước khi ví ký, SDK kiểm tra giao dịch đã compose và từ chối mọi thứ ngoài lệnh gọi mà người dùng đã ủy quyền.
* **Nó được gửi đến đâu.** Ví ký giao dịch v1 và **ứng dụng gửi nó tới Solana RPC** — không phải tới proxy, vốn là bên compose nó (một lệnh gọi relay là ngoại lệ duy nhất, bên dưới; xem [Các lệnh gọi được chuyển tiếp và lệnh gọi dài](/vi/huong-dan-cho-nha-phat-trien/call-evm-from-solana.md#relayed-calls-and-long-calls)). Trên chain, chương trình suy ra `msg.sender` từ người ký Solana.
* **Hai lời nhắc cho mỗi lần gọi.** Ví ký một thông điệp ủy quyền dễ đọc (`Rome authorization` và hash giao dịch EVM), rồi ký giao dịch, cả hai bằng cùng một khóa.
* **Hãy kiểm tra ví của bạn có ký v1.** `signV1Wire` phải trả về chữ ký 64 byte trên thông điệp v1; thông qua Wallet Standard thì đó là `solana:signTransaction` trên wire thô và `signedTransaction.slice(-64)`. Không phải ví nào cũng giải mã giao dịch v1, vì vậy hãy thử ví của bạn trước khi xây dựng trên đó. Trên devnet hiện nay, Solflare ký chúng; Phantom thì chưa.
* **Các lệnh gọi lớn sẽ relay.** Một lệnh gọi không vừa trong một giao dịch sẽ được proxy relay với cùng chữ ký ủy quyền, và kết quả có `route: "relay"`. Một lệnh gọi được relay sẽ được thanh toán bằng gas native của chain, vì vậy hãy nạp thêm cho synthetic bằng `fundSyntheticGas(deps, amountWei)`. Xem [Các lệnh gọi được chuyển tiếp và lệnh gọi dài](/vi/huong-dan-cho-nha-phat-trien/call-evm-from-solana.md#relayed-calls-and-long-calls).
* **Gas là USDC.** Không có faucet — bridge USDC vào (xem [Được cấp vốn](/vi/tai-nguyen/faucets.md)).

Phần trước `submitRomeTxSolanaLane` vẫn còn trong SDK: nó dựng một giao dịch Solana kiểu legacy ở phía client (một lời nhắc ví, với ComputeBudget và ví kho bạc do SDK xử lý).

## Cùng ứng dụng từ MetaMask

Một người dùng EVM gọi cùng hợp đồng đó bằng `submitRomeTx` — bộ công cụ EVM tiêu chuẩn, gas bằng USDC. Một điểm khác so với người dùng Solana: người dùng MetaMask nắm giữ **gas USDC native**, không phải wUSDC. Két nhận wrapper wUSDC, nên người dùng trước tiên bọc một phần gas của họ, rồi cấp quyền cho wrapper một lần, rồi thực hiện các bước ERC-20 như bình thường. Không có nhánh fund hay sweep, vì các token đã bọc đã nằm sẵn ở địa chỉ EVM của họ.

1. **Bọc gas thành wUSDC** — gửi `withdraw_to_ata(wei)` tới precompile Withdraw tại `0x4200000000000000000000000000000000000016`. Nó đốt lượng gas native đó và ghi có lượng tương đương vào tài khoản token của người dùng bằng gas token, mà `wUSDC.balanceOf(user)` sau đó hiển thị. `wei` nằm trong đơn vị gas 18 chữ số thập phân và phải là bội số của 10^12 (một đơn vị wUSDC 6 chữ số thập phân), nếu không lệnh gọi sẽ hoàn nguyên. Bỏ qua bước này nếu người dùng đã có wUSDC.
2. **Cấp quyền cho wrapper** (khi `vẫn` là false) — cùng một `approve_spl` lệnh gọi như trên, được gửi với `submitRomeTx`. Xem [Một lần: cấp cho wrapper](#one-time-grant-the-wrapper).
3. **Phê duyệt** — `approve(vault, amount)` trên wUSDC.
4. **Nạp** — `deposit(amount)` trên két. Withdraw là `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) bọc một ít gas USDC thành wUSDC (weiAmount là đơn vị gas 18 chữ số thập phân)
await submitRomeTx(provider, { from: who, to: WITHDRAW, data: encodeFunctionData({ abi: withdrawAbi, functionName: "withdraw_to_ata", args: [weiAmount] }) });

// 2) cấp quyền cho wrapper, một lần: `grant` là lệnh được dựng dưới mục "Một lần: cấp quyền cho wrapper", hoặc null
if (grant) await submitRomeTx(provider, { from: who, ...grant });

// 3) phê duyệt và 4) nạp, như với bất kỳ ERC-20 nào
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] }) });
```

Cả hai người dùng dùng chung cùng một `balanceOf` trạng thái trong két.

## Tiếp theo là gì

* [Gọi EVM từ Solana](/vi/huong-dan-cho-nha-phat-trien/call-evm-from-solana.md) — cơ chế của làn Solana chi tiết
* [Gọi Solana từ EVM](/vi/huong-dan-cho-nha-phat-trien/call-solana-from-evm.md) — chiều ngược lại (CPI)
* [Được cấp vốn](/vi/tai-nguyen/faucets.md) — USDC là token gas; bridge nó vào


---

# 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/vi/huong-dan-cho-nha-phat-trien/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.
