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

# 듀얼 레인 앱 빌드

듀얼 레인 앱의 단계별 안내 — MetaMask 사용자와 Solana 지갑 사용자가 모두 사용하는 하나의 Solidity 계약, 그리고 각 단계에서 정확히 무슨 일이 일어나는지.

하나의 **이중 레인 앱** 은(는) 하나의 Solidity 계약으로, 둘 다 **MetaMask(EVM)** 사용자와 **Solana 지갑** 사용자가 직접 사용합니다 — 동일한 계약, 동일한 상태이며, 각각 이미 가진 지갑을 사용합니다. 이 페이지에서는 *정확히* 각 단계에서, 양쪽에서 무슨 일이 일어나는지 설명합니다.

예시는 작은 **볼트**: 당신은 `deposit` USDC를 넣고 나중에 `withdraw` 그것을 꺼냅니다. "스테이킹 / 언스테이킹", "공급 / 상환", "팁 / 청구"는 같은 형태입니다.

## 작성하는 것 — 표준 ERC-20 볼트

Rome에서는 Solana 사용자의 USDC가 EVM 측에서 일반적인 **ERC-20 토큰** — 해당 민트의 SPL 래퍼(예: `wUSDC`). 따라서 당신의 계약은 일반적인 토큰 볼트입니다: 토큰을 `transferFrom` 로 가져오고, `전송에서는 후크가 작동하지 않습니다`로 돌려보냅니다. 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 래퍼로 1:1로 드러납니다 — *가 아닌* EVM 네이티브 잔액입니다. 따라서 Solana 사용자는 항상 `transferFrom`를 자금 조달할 수 있지만, `지불 가능한 deposit()` 는 휴면 상태에서 보유하지 않는 네이티브 값을 필요로 합니다. 토큰을 중심으로 설계하면 두 레인 모두 같은 방식으로 동작합니다.

Foundry 또는 Hardhat으로 배포하고, 생성자는 당신의 토큰에 대한 래퍼 주소를 가리키게 하세요( [레지스트리](https://github.com/rome-protocol/rome-registry)). Rome에서는 가스 토큰이 USDC이므로, 배포하려면 약간의 USDC 가스 잔액이 필요합니다.

## 두 레인

| 레인      | 지갑                              | 앱이 이를 호출하는 방식                                       |
| ------- | ------------------------------- | --------------------------------------------------- |
| **EVM** | MetaMask(EVM 키)                 | `submitRomeTx` — 표준 Rome 쓰기                         |
| **솔라나** | Solflare 같은 Solana 지갑(Solana 키) | `submitRomeTxV1` — 호출당 Solana v1 트랜잭션 1개, EVM 키 불필요 |

EVM 레인은 일반적입니다. 이 페이지의 나머지는 **솔라나 경로** — 더 흥미로운 부분입니다.

## 핵심 아이디어: synthetic은 패스스루입니다

Solana 사용자의 EVM 신원은 그들의 **합성 주소** — `keccak256(solana_pubkey)[12:]`. 그것은 그들의 `msg.sender` 계약상에서는 **휴면 상태에서는 아무것도 보유하지 않습니다.** 사용자의 자금은 그들의 **Solana 지갑** (SPL USDC로), 그리고 EVM 측에서는 그 동일한 잔액이 `wUSDC.balanceOf(synthetic)` 가 읽는 값입니다. 가치는 *을 통해* synthetic을 통해 흐릅니다:

* **들어갈 때** (deposit): 지갑 토큰 계정 → synthetic 토큰 계정 → 계약(을 통해 `transferFrom`).
* **나갈 때** (withdraw): 계약 → synthetic 토큰 계정 → 지갑 토큰 계정.

synthetic은 왕복할 때마다 최종적으로 0이 됩니다.

## 한 번만: 활성화(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 1회
}
```

## 한 번만: 래퍼에 허가 부여

wUSDC 래퍼는 사용자의 토큰을 **SPL delegate** 그들의 토큰 계정의 대리인으로 이동시키므로, 각 사용자는 첫 입금 전에 래퍼마다 한 번씩 그 권한을 부여해야 합니다. 이는 MetaMask 사용자와 Solana 사용자의 synthetic 주소 모두에 적용됩니다. 이 권한 부여가 없으면 모든 `transferFrom` 지갑에서의 이동은 실패하고 입금은 SPL 오류로 되돌아갑니다 `0x4` (owner mismatch)로 되돌아갑니다.

래퍼의 view로 확인하세요 `isEnabled(user)`. 반환값이 `false`, `approve_spl(wrapper, 2^64-1, mint)` 바로 Helper precompile로 보냅니다: `0xff00000000000000000000000000000000000009`; 민트는 `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) }); // chain의 RPC 위에 있는 viem 클라이언트

// who: MetaMask 주소, 또는 Solana 사용자의 경우 syntheticAddress(wallet.publicKey)
// 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 레인:    if (grant) await submitRomeTx(provider, { from: who, ...grant });
// Solana 레인: if (grant) await submitRomeTxV1(deps, grant);
```

## 각 측에 필요한 것

|                       | 필요한 것                                              | 이유                                                                                      |
| --------------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------- |
| **Solana 사용자**        | **SOL** (약간)                                       | 각 레인 tx의 Solana 거래 수수료를 지불합니다                                                           |
|                       | **SPL 토큰으로서의 USDC** 지갑에                            | 그들이 입금하는 가치(EVM 측에서는 `wUSDC`)                                                           |
| **EVM 사용자(MetaMask)** | **USDC** Rome 가스 잔액으로                              | 가스와, 래핑된 후 입금하는 가치입니다; 다음으로 충전합니다 **USDC를 브리지하여** Rome으로(펀싯은 없습니다)                      |
|                       | 하나의 **래핑** 그 가스의 일부를 **wUSDC** (`withdraw_to_ata`) | 볼트는 네이티브 가스가 아니라 ERC-20 래퍼인 wUSDC를 받습니다. MetaMask의 가스 USDC 잔액은 래핑되기 전까지는 wUSDC가 아닙니다    |
|                       | 한 번만 하는 **래퍼 승인** (`approve_spl`)                  | 를 통해 wUSDC 래퍼가 그들의 토큰을 이동할 수 있게 합니다; 참조: [한 번만: 래퍼에 허가 부여](#one-time-grant-the-wrapper) |
| **당신(빌더)**            | 지갑과 **USDC** Rome의 가스                              | 를 계약 배포에 사용                                                                             |

## 가치 유입 — 입금(단계별)

Solana 사용자는 자신의 Solana 지갑에 SOL + USDC를 보유하고 있습니다. 각 단계는 Solana v1 트랜잭션이며, **해당 지갑이**, 앱은 이를 Solana RPC로 전송합니다. fund leg는 앱에서 구성되고 지갑은 한 번의 프롬프트로 서명합니다; 각 계약 호출은 프록시가 조립하며, 지갑은 사람이 읽을 수 있는 승인 메시지 뒤에 서명합니다:

1. **자금 이동 레그** — `buildFundLeg(...)` → `submitSolanaInstructionsV1(...)`. synthetic의 USDC 토큰 계정을(필요한 경우) 생성하고 **`ActivateAta`**, 이동시켜 `수량` 지갑의 토큰 계정에서 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);
// signer: LaneSigner(payer, signAuthHash, signV1Wire) 또는 Keypair

// 1) fund leg — 지갑 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. **sweep 레그** — `buildSweepLeg(...)` 는 다음을 제공합니다 `HelperProgram.transfer_spl` 호출 + 계정; 실행하세요(필요하면 지갑의 토큰 계정을 생성하고 `submitSolanaInstructionsV1`, 그런 다음 Helper 호출을 `submitRomeTxV1`; 프록시가 토큰 계정을 찾아줍니다) synthetic에서 USDC를 이동시킵니다 **다시 사용자의 Solana 지갑으로**. synthetic은 최종적으로 0이 됩니다.

**순효과:** 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) sweep 레그 — 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 레인 단계는 Hadrian에서 버전 1 트랜잭션 하나로 실행되었습니다:

| 단계                      | Compute 유닛 | 바이트   | 계정 |
| ----------------------- | ---------- | ----- | -- |
| 프로비저닝(`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 |
| sweep(`transfer_spl`)   | 46,810     | 899   | 13 |

## 주의할 점

수동으로 만든 Solana 레인 트랜잭션이 틀리기 쉬운 부분들입니다. SDK와 프록시의 composer가 대부분을 처리합니다:

* **프로비저닝.** 새 synthetic의 계정은 생성되어야 합니다(`create_pda`) `submitRomeTxV1` 가 대신 해주지 않습니다: 확인하세요 `isSyntheticProvisioned` 를 확인하고 `buildCreatePdaCall(synthetic)` 한 번.
* **래퍼를 한 번 승인하세요.** 래퍼는 사용자의 SPL 대리인으로서 토큰을 이동합니다. 확인하세요 `isEnabled` 그리고 보내세요 `approve_spl` 첫 입금 전에 Helper precompile로 보내세요. 그렇지 않으면 입금이 SPL 오류로 되돌아갑니다 `0x4`.
* **사용하는 것은 래퍼이지,  `msg.value`.** Solana 사용자의 잔액은 그들의 SPL 토큰 계정이며, ERC-20 래퍼로 드러납니다 — 이를 이동하려면 `전송에서는 후크가 작동하지 않습니다` / `transferFrom`, 네이티브 값은 절대 아닙니다.
* **당신을 위해 조립됩니다.** ComputeBudget, treasure wallet 및 계정 탐색은 프록시의 composer가 수행하므로 코드에 나타나지 않습니다. 지갑이 서명하기 전에 SDK는 조립된 트랜잭션을 검사하고 사용자가 승인한 호출 외에는 거부합니다.
* **어디로 전송되는지.** 지갑은 v1 트랜잭션과 **앱이 이를 Solana RPC로 보냅니다** — 프록시로 보내는 것이 아닙니다. 프록시는 이를 조립합니다(아래의 relayed call이 유일한 예외입니다; 참조: [릴레이된 호출 및 장시간 호출](/ko/developer-guides/call-evm-from-solana.md#relayed-calls-and-long-calls)). 온체인에서는 프로그램이 다음을 파생합니다 `msg.sender` 를 Solana 서명자로부터.
* **호출당 두 번의 프롬프트.** 지갑은 사람이 읽을 수 있는 승인 메시지(`Rome 승인` 와 EVM 트랜잭션 해시)를 서명한 뒤, 같은 키로 트랜잭션도 서명합니다.
* **지갑이 v1에 서명하는지 확인하세요.** `signV1Wire` 는 v1 메시지에 대한 64바이트 서명을 반환해야 합니다; Wallet Standard를 통해서는 `solana:signTransaction` 원시 와이어에서는 `signedTransaction.slice(-64)`. 아직 모든 지갑이 v1 트랜잭션을 디코드하는 것은 아니므로, 이를 기반으로 빌드하기 전에 본인 지갑을 테스트하세요. 현재 devnet에서는 Solflare가 서명하고 Phantom은 아직 그렇지 않습니다.
* **큰 호출은 relayed 됩니다.** 하나의 트랜잭션에 들어가지 않는 호출은 동일한 승인 서명으로 프록시가 relayed하며, 결과에는 `route: "relay"`. relayed call은 체인의 네이티브 가스로 지불되므로, synthetic에 `fundSyntheticGas(deps, amountWei)`. 참조: [릴레이된 호출 및 장시간 호출](/ko/developer-guides/call-evm-from-solana.md#relayed-calls-and-long-calls).
* **가스는 USDC입니다.** 펀싯 없음 — USDC를 브리지해 넣으세요(참조: [자금 받기](/ko/resources/faucets.md)).

이전의 `submitRomeTxSolanaLane` 는 여전히 SDK에 있습니다: 클라이언트에서 레거시 Solana 트랜잭션을 구성합니다(지갑 프롬프트 1회, ComputeBudget와 treasure wallet은 SDK가 처리).

## MetaMask에서의 동일한 앱

EVM 사용자는 동일한 계약을 다음으로 호출합니다 `submitRomeTx` — 표준 EVM 도구, 가스는 USDC입니다. Solana 사용자와의 한 가지 차이점: MetaMask 사용자는 **네이티브 가스 USDC**, wUSDC가 아닙니다. 볼트는 wUSDC 래퍼를 받으므로, 사용자는 먼저 가스의 일부를 래핑한 다음 래퍼를 한 번 승인하고, 이후에는 평소처럼 ERC-20 단계를 진행합니다. 래핑된 토큰은 이미 EVM 주소에 존재하므로 fund 또는 sweep 레그는 없습니다.

1. **가스를 wUSDC로 래핑** — 전송 `withdraw_to_ata(wei)` 를 Withdraw precompile로 `0x4200000000000000000000000000000000000016`. 그만큼의 네이티브 가스를 소각하고, 해당하는 양을 사용자의 가스 토큰 계정에 적립합니다. 그러면 `wUSDC.balanceOf(user)` 그 값이 표시됩니다. `wei` 는 18자리 소수의 가스 단위이며 10^12의 배수(6자리 소수 wUSDC 1단위)여야 합니다. 그렇지 않으면 호출이 되돌아갑니다. 사용자가 이미 wUSDC를 보유한다면 이 단계를 건너뛰세요.
2. **래퍼 승인** (이때 `isEnabled` 가 false일 때) — 동일한 `approve_spl` 위의 호출이며, 다음과 함께 전송됩니다 `submitRomeTx`. 참조: [한 번만: 래퍼에 허가 부여](#one-time-grant-the-wrapper).
3. **승인** — `approve(vault, amount)` 를 wUSDC에 대해
4. **예치** — `deposit(amount)` 를 볼트에 대해. Withdraw는 `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) 일부 가스 USDC를 wUSDC로 래핑 (weiAmount는 18자리 소수 가스 단위)
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 호출](/ko/developer-guides/call-evm-from-solana.md) — Solana 레인 메커니즘 자세히
* [EVM에서 Solana 호출](/ko/developer-guides/call-solana-from-evm.md) — 반대 방향(CPI)
* [자금 받기](/ko/resources/faucets.md) — 가스 토큰으로서의 USDC; 브리지해 넣기


---

# 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/ko/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.
