# Bridge FXRP to Ethereum

> Move FXRP cross-chain from Flare to Ethereum using LayerZero's OFT standard and Viem

> For the complete documentation index, see [llms.txt](/llms.txt). Markdown versions of documentation pages are available by appending `.md` to the page URL.

Source: https://dev.flare.network/fxrp/oft/fxrp-bridge-ethereum

## Overview[​](#overview "Direct link to Overview")

In this guide, you will learn how to move FXRP you already hold on Flare Testnet Coston2 to Ethereum Sepolia using [Viem](https://viem.sh) and [LayerZero's OFT](https://docs.layerzero.network/v2/developers/evm/oft/quickstart) (Omnichain Fungible Token) standard.

FXRP is deployed as an OFT: on Flare, an **OFT Adapter** locks tokens when bridging out; on Ethereum, a native **OFT** contract mints the equivalent amount to the recipient. See the [OFT overview](/fxrp/oft) for how the standard works and the full list of deployments.

**Key technologies:**

-   [FAssets](/fassets/overview) — FXRP is the FAssets-wrapped representation of native XRP.
-   [LayerZero OFT](https://docs.layerzero.network/v2/developers/evm/oft/quickstart) for cross-chain token transfers.
-   [Viem](https://viem.sh) for reading from and writing to Flare and Ethereum.

The full code showcased in this guide, `bridge-fxrp.ts`, is available in the [`flare-viem-starter`](https://github.com/flare-foundation/flare-viem-starter/blob/main/src/layer-zero/bridge-fxrp.ts) repository under `src/layer-zero/bridge-fxrp.ts`. Clone it to follow along:

```
git clone https://github.com/flare-foundation/flare-viem-startercd flare-viem-starterpnpm install
```

## How Bridging Works[​](#how-bridging-works "Direct link to How Bridging Works")

Moving FXRP from Flare to Ethereum is a two-step on-chain flow:

1.  **Approve**: Grant the OFT Adapter permission to spend the FXRP you want to bridge.
2.  **Send**: Call the OFT Adapter's `send()` function with the destination chain, recipient, and amount. The adapter pulls your FXRP via `transferFrom` (this is the lock) and instructs LayerZero to mint the equivalent amount to the recipient on Ethereum.

LayerZero's [DVNs](/fxrp/oft#dvn-security-stack) verify the message before it is delivered, so the transfer is asynchronous — it typically takes a few minutes to arrive.

## Prerequisites[​](#prerequisites "Direct link to Prerequisites")

-   **Flare Testnet Account**: An EVM wallet with:
    -   [FTestXRP (FXRP)](https://coston2-explorer.flare.network/address/0x0b6A3645c240605887a5532109323A3E12273dc7) on Coston2 to bridge.
    -   C2FLR for gas and the LayerZero messaging fee.
-   **Environment Setup**: A `.env` file with `PRIVATE_KEY` set to your Flare account's private key (see `.env.example` in the starter repository).

Need testnet tokens?

You can get both FXRP and C2FLR from the [Coston2 Faucet](https://faucet.flare.network/coston2). To mint FXRP yourself, follow the [Mint FXRP](/fassets/developer-guides/fassets-mint) guide.

## Setup[​](#setup "Direct link to Setup")

The script reads and writes to Flare through a Viem public client and wallet client, both scoped to `flareTestnet`:

src/utils/client.ts

```
import { createPublicClient, createWalletClient, http } from "viem";import { privateKeyToAccount } from "viem/accounts";import { flareTestnet } from "viem/chains";export const publicClient = createPublicClient({  chain: flareTestnet,  transport: http(),});export const walletClient = createWalletClient({  chain: flareTestnet,  transport: http(),});export const account = privateKeyToAccount(  process.env.PRIVATE_KEY as `0x${string}`,);
```

The bridge parameters — the OFT Adapter address, the FAsset redeem composer, the destination LayerZero Endpoint ID, and the executor gas limit — are grouped in a `CONFIG` object at the top of the script:

src/layer-zero/bridge-fxrp.ts

```
const CONFIG = {  COSTON2_OFT_ADAPTER: "0xCd3d2127935Ae82Af54Fc31cCD9D3440dbF46639" as Address,  COSTON2_COMPOSER: (process.env.COSTON2_COMPOSER ??    "0xa10569DFb38FE7Be211aCe4E4A566Cea387023b0") as Address,  SEPOLIA_EID: EndpointId.SEPOLIA_V2_TESTNET,  EXECUTOR_GAS: 200_000,} as const;
```

Parameter

Description

`COSTON2_OFT_ADAPTER`

Address of the FXRP OFT Adapter on Coston2

`COSTON2_COMPOSER`

Address of the FAsset redeem composer on Coston2

`SEPOLIA_EID`

LayerZero Endpoint ID for the destination chain (Ethereum Sepolia)

`EXECUTOR_GAS`

Gas limit for the LayerZero executor on the destination chain

## Script Walkthrough[​](#script-walkthrough "Direct link to Script Walkthrough")

### Step 1: Resolve the Bridge Amount[​](#step-1-resolve-the-bridge-amount "Direct link to Step 1: Resolve the Bridge Amount")

The script reads the FXRP token address and decimals, then converts the human-readable `BRIDGE_AMOUNT` (defaulting to `"1"` FXRP) into token units with `parseUnits`:

src/layer-zero/bridge-fxrp.ts

```
const bridgeAmount = process.env.BRIDGE_AMOUNT ?? "1";const [fAssetAddress, decimals] = await Promise.all([  getFxrpAddress(),  getFxrpDecimals(),]);const amountToBridge = parseUnits(bridgeAmount, decimals);
```

### Step 2: Check Balance[​](#step-2-check-balance "Direct link to Step 2: Check Balance")

Before bridging, the script confirms the signer holds enough FXRP:

src/layer-zero/bridge-fxrp.ts

```
const balance = await getFxrpBalance(signerAddress);if (balance < amountToBridge) {  throw new Error("Insufficient FTestXRP balance");}
```

### Step 3: Approve the OFT Adapter[​](#step-3-approve-the-oft-adapter "Direct link to Step 3: Approve the OFT Adapter")

The OFT Adapter needs an ERC-20 approval before it can lock your FXRP. The script first confirms the adapter's underlying token matches the FXRP address it resolved, then approves the adapter and, if configured, the FAsset redeem composer:

src/layer-zero/bridge-fxrp.ts

```
async function approveSpender(  fAssetAddress: Address,  spender: Address,  amount: bigint,) {  const { request } = await publicClient.simulateContract({    account,    address: fAssetAddress,    abi: erc20Abi,    functionName: "approve",    args: [spender, amount],  });  const txHash = await walletClient.writeContract(request);  await publicClient.waitForTransactionReceipt({ hash: txHash });}async function approveTokens(  fAssetAddress: Address,  amountToBridge: bigint,  decimals: number,) {  const underlyingToken = await publicClient.readContract({    address: CONFIG.COSTON2_OFT_ADAPTER,    abi: fxrpOftAbi,    functionName: "token",  });  await approveSpender(    fAssetAddress,    CONFIG.COSTON2_OFT_ADAPTER,    amountToBridge,  );  if (CONFIG.COSTON2_COMPOSER) {    await approveSpender(      fAssetAddress,      CONFIG.COSTON2_COMPOSER,      amountToBridge,    );  }}
```

Composer approval is not needed for a plain transfer to your own address on Sepolia, but the script always grants it so the same approvals also cover the [auto-redeem to Hyperliquid](/fxrp/oft/fxrp-autoredeem) flow if you switch destinations later.

### Step 4: Build the Send Parameters[​](#step-4-build-the-send-parameters "Direct link to Step 4: Build the Send Parameters")

The recipient address is padded to 32 bytes, and `extraOptions` sets the gas the LayerZero executor should forward to `lzReceive` on the destination chain:

src/layer-zero/bridge-fxrp.ts

```
function buildSendParam(recipient: Address, amountToBridge: bigint): SendParam {  const options = Options.newOptions().addExecutorLzReceiveOption(    CONFIG.EXECUTOR_GAS,    0,  );  return {    dstEid: CONFIG.SEPOLIA_EID,    to: pad(recipient, { size: 32 }),    amountLD: amountToBridge,    minAmountLD: amountToBridge,    extraOptions: options.toHex() as `0x${string}`,    composeMsg: "0x",    oftCmd: "0x",  };}
```

### Step 5: Quote the LayerZero Fee[​](#step-5-quote-the-layerzero-fee "Direct link to Step 5: Quote the LayerZero Fee")

The `quoteSend()` function returns the native fee (in C2FLR) required to pay for message delivery:

src/layer-zero/bridge-fxrp.ts

```
async function quoteFee(sendParam: SendParam) {  const { nativeFee } = await publicClient.readContract({    address: CONFIG.COSTON2_OFT_ADAPTER,    abi: fxrpOftAbi,    functionName: "quoteSend",    args: [sendParam, false],  });  return nativeFee;}
```

### Step 6: Send[​](#step-6-send "Direct link to Step 6: Send")

Finally, the script simulates and sends the `send()` transaction with the quoted fee attached as `value`:

src/layer-zero/bridge-fxrp.ts

```
async function executeBridge(  sendParam: SendParam,  nativeFee: bigint,  signerAddress: Address,) {  const { request } = await publicClient.simulateContract({    account,    address: CONFIG.COSTON2_OFT_ADAPTER,    abi: fxrpOftAbi,    functionName: "send",    args: [sendParam, { nativeFee, lzTokenFee: 0n }, signerAddress],    value: nativeFee,  });  const txHash = await walletClient.writeContract(request);  await publicClient.waitForTransactionReceipt({ hash: txHash });}
```

The adapter locks the FXRP on Coston2, and LayerZero delivers the equivalent amount to the recipient on Sepolia once the configured DVNs verify the message.

## How to Run[​](#how-to-run "Direct link to How to Run")

```
pnpm run script src/layer-zero/bridge-fxrp.ts
```

Set `BRIDGE_AMOUNT` in your environment to bridge a different amount of FXRP (e.g. `BRIDGE_AMOUNT=25`); it defaults to `1`.

## Expected Output[​](#expected-output "Direct link to Expected Output")

```
Using account: 0x742d35Cc6634C0532925a3b844Bc454e4438f44eToken address: 0x...Token decimals: 6Bridge Details:From: Coston2To: SepoliaAmount: 1.0 FXRPRecipient: 0x742d35Cc6634C0532925a3b844Bc454e4438f44eYour FTestXRP balance: 15.0OFT Adapter underlying token: 0x...Expected token: 0x...Match: trueApproving FTestXRP for OFT Adapter: 0xCd3d2127935Ae82Af54Fc31cCD9D3440dbF46639Amount: 1.0 FXRPOFT Adapter approvedApproving FTestXRP for Composer: 0xa10569DFb38FE7Be211aCe4E4A566Cea387023b0Composer approvedLayerZero Fee: 0.001234 C2FLRSending FXRP to Sepolia...Transaction sent: 0xabc123...Confirmed in block: 12345678Track your transaction:https://testnet.layerzeroscan.com/tx/0xabc123...It may take a few minutes to arrive on Sepolia.
```

## Full Script[​](#full-script "Direct link to Full Script")

The repository with the above example is available on [GitHub](https://github.com/flare-foundation/flare-viem-starter). In the example repository, helpers such as `getFxrpAddress` and `getFxrpBalance` are isolated into separate files in the `src/utils` directory.

<details>
<summary>View bridge-fxrp.ts source code</summary>

View `bridge-fxrp.ts` source code

src/layer-zero/bridge-fxrp.ts

```
import { erc20Abi, formatUnits, pad, parseUnits, type Address } from "viem";import { EndpointId } from "@layerzerolabs/lz-definitions";import { Options } from "@layerzerolabs/lz-v2-utilities";import { account, publicClient, walletClient } from "./utils/client";import { abi as fxrpOftAbi } from "./abis/FXRPOFT";import { getFxrpBalance, getFxrpDecimals } from "./utils/fassets";import { getFxrpAddress } from "./utils/flare-contract-registry";import type { SendParam } from "./types";const CONFIG = {  COSTON2_OFT_ADAPTER: "0xCd3d2127935Ae82Af54Fc31cCD9D3440dbF46639" as Address,  COSTON2_COMPOSER: (process.env.COSTON2_COMPOSER ??    "0xa10569DFb38FE7Be211aCe4E4A566Cea387023b0") as Address,  SEPOLIA_EID: EndpointId.SEPOLIA_V2_TESTNET,  EXECUTOR_GAS: 200_000,} as const;async function approveSpender(  fAssetAddress: Address,  spender: Address,  amount: bigint,) {  const { request } = await publicClient.simulateContract({    account,    address: fAssetAddress,    abi: erc20Abi,    functionName: "approve",    args: [spender, amount],  });  const txHash = await walletClient.writeContract(request);  await publicClient.waitForTransactionReceipt({ hash: txHash });}async function approveTokens(  fAssetAddress: Address,  amountToBridge: bigint,  decimals: number,) {  const underlyingToken = await publicClient.readContract({    address: CONFIG.COSTON2_OFT_ADAPTER,    abi: fxrpOftAbi,    functionName: "token",  });  console.log("\nOFT Adapter underlying token:", underlyingToken);  console.log("Expected token:", fAssetAddress);  console.log(    "Match:",    underlyingToken.toLowerCase() === fAssetAddress.toLowerCase(),  );  console.log(    "\nApproving FTestXRP for OFT Adapter:",    CONFIG.COSTON2_OFT_ADAPTER,  );  console.log("Amount:", formatUnits(amountToBridge, decimals), "FXRP");  await approveSpender(    fAssetAddress,    CONFIG.COSTON2_OFT_ADAPTER,    amountToBridge,  );  console.log("OFT Adapter approved");  if (CONFIG.COSTON2_COMPOSER) {    console.log("\nApproving FTestXRP for Composer:", CONFIG.COSTON2_COMPOSER);    await approveSpender(      fAssetAddress,      CONFIG.COSTON2_COMPOSER,      amountToBridge,    );    console.log("Composer approved");  }}function buildSendParam(recipient: Address, amountToBridge: bigint): SendParam {  const options = Options.newOptions().addExecutorLzReceiveOption(    CONFIG.EXECUTOR_GAS,    0,  );  return {    dstEid: CONFIG.SEPOLIA_EID,    to: pad(recipient, { size: 32 }),    amountLD: amountToBridge,    minAmountLD: amountToBridge,    extraOptions: options.toHex() as `0x${string}`,    composeMsg: "0x",    oftCmd: "0x",  };}async function quoteFee(sendParam: SendParam) {  const { nativeFee } = await publicClient.readContract({    address: CONFIG.COSTON2_OFT_ADAPTER,    abi: fxrpOftAbi,    functionName: "quoteSend",    args: [sendParam, false],  });  console.log("LayerZero Fee:", formatUnits(nativeFee, 18), "C2FLR");  return nativeFee;}async function executeBridge(  sendParam: SendParam,  nativeFee: bigint,  signerAddress: Address,) {  console.log("\nSending FXRP to Sepolia...");  const { request } = await publicClient.simulateContract({    account,    address: CONFIG.COSTON2_OFT_ADAPTER,    abi: fxrpOftAbi,    functionName: "send",    args: [sendParam, { nativeFee, lzTokenFee: 0n }, signerAddress],    value: nativeFee,  });  const txHash = await walletClient.writeContract(request);  const receipt = await publicClient.waitForTransactionReceipt({    hash: txHash,  });  console.log("Transaction sent:", txHash);  console.log("Confirmed in block:", receipt.blockNumber);  console.log("\nTrack your transaction:");  console.log(`https://testnet.layerzeroscan.com/tx/${txHash}`);  console.log("\nIt may take a few minutes to arrive on Sepolia.");}async function main() {  const bridgeAmount = process.env.BRIDGE_AMOUNT ?? "1";  const signerAddress = account.address;  const [fAssetAddress, decimals] = await Promise.all([    getFxrpAddress(),    getFxrpDecimals(),  ]);  const amountToBridge = parseUnits(bridgeAmount, decimals);  console.log("Using account:", signerAddress);  console.log("Token address:", fAssetAddress);  console.log("Token decimals:", decimals);  console.log("\nBridge Details:");  console.log("From: Coston2");  console.log("To: Sepolia");  console.log("Amount:", formatUnits(amountToBridge, decimals), "FXRP");  console.log("Recipient:", signerAddress);  const balance = await getFxrpBalance(signerAddress);  console.log("\nYour FTestXRP balance:", formatUnits(balance, decimals));  if (balance < amountToBridge) {    throw new Error("Insufficient FTestXRP balance");  }  await approveTokens(fAssetAddress, amountToBridge, decimals);  const sendParam = buildSendParam(signerAddress, amountToBridge);  const nativeFee = await quoteFee(sendParam);  await executeBridge(sendParam, nativeFee, signerAddress);}void main()  .then(() => process.exit(0))  .catch((error) => {    console.error(error);    process.exit(1);  });
```

</details>

Next Steps

To continue your FAssets development journey, you can:

-   Learn how to [mint FXRP](/fassets/developer-guides/fassets-mint)
-   Understand how to [redeem FXRP](/fassets/developer-guides/fassets-redeem)
-   Explore [auto-redemption from Hyperliquid](/fxrp/oft/fxrp-autoredeem)
-   Explore [auto-minting and bridging via Smart Accounts](/fxrp/oft/fxrp-automint) for XRPL-triggered workflows
