Bridge FXRP to Ethereum
Overview
In this guide, you will learn how to move FXRP you already hold on Flare Testnet Coston2 to Ethereum Sepolia using Viem and LayerZero's OFT (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 for how the standard works and the full list of deployments.
Key technologies:
- FAssets — FXRP is the FAssets-wrapped representation of native XRP.
- LayerZero OFT for cross-chain token transfers.
- Viem 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 repository under src/layer-zero/bridge-fxrp.ts.
Clone it to follow along:
git clone https://github.com/flare-foundation/flare-viem-starter
cd flare-viem-starter
pnpm install
How Bridging Works
Moving FXRP from Flare to Ethereum is a two-step on-chain flow:
- Approve: Grant the OFT Adapter permission to spend the FXRP you want to bridge.
- Send: Call the OFT Adapter's
send()function with the destination chain, recipient, and amount. The adapter pulls your FXRP viatransferFrom(this is the lock) and instructs LayerZero to mint the equivalent amount to the recipient on Ethereum.
LayerZero's DVNs verify the message before it is delivered, so the transfer is asynchronous — it typically takes a few minutes to arrive.
Prerequisites
- Flare Testnet Account: An EVM wallet with:
- FTestXRP (FXRP) on Coston2 to bridge.
- C2FLR for gas and the LayerZero messaging fee.
- Environment Setup: A
.envfile withPRIVATE_KEYset to your Flare account's private key (see.env.examplein the starter repository).
You can get both FXRP and C2FLR from the Coston2 Faucet. To mint FXRP yourself, follow the Mint FXRP guide.
Setup
The script reads and writes to Flare through a Viem public client and wallet client, both scoped to flareTestnet:
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:
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
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:
const bridgeAmount = process.env.BRIDGE_AMOUNT ?? "1";
const [fAssetAddress, decimals] = await Promise.all([
getFxrpAddress(),
getFxrpDecimals(),
]);
const amountToBridge = parseUnits(bridgeAmount, decimals);
Step 2: Check Balance
Before bridging, the script confirms the signer holds enough FXRP:
const balance = await getFxrpBalance(signerAddress);
if (balance < amountToBridge) {
throw new Error("Insufficient FTestXRP balance");
}
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:
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 flow if you switch destinations later.
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:
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
The quoteSend() function returns the native fee (in C2FLR) required to pay for message delivery:
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
Finally, the script simulates and sends the send() transaction with the quoted fee attached as value:
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
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
Using account: 0x742d35Cc6634C0532925a3b844Bc454e4438f44e
Token address: 0x...
Token decimals: 6
Bridge Details:
From: Coston2
To: Sepolia
Amount: 1.0 FXRP
Recipient: 0x742d35Cc6634C0532925a3b844Bc454e4438f44e
Your FTestXRP balance: 15.0
OFT Adapter underlying token: 0x...
Expected token: 0x...
Match: true
Approving FTestXRP for OFT Adapter: 0xCd3d2127935Ae82Af54Fc31cCD9D3440dbF46639
Amount: 1.0 FXRP
OFT Adapter approved
Approving FTestXRP for Composer: 0xa10569DFb38FE7Be211aCe4E4A566Cea387023b0
Composer approved
LayerZero Fee: 0.001234 C2FLR
Sending FXRP to Sepolia...
Transaction sent: 0xabc123...
Confirmed in block: 12345678
Track your transaction:
https://testnet.layerzeroscan.com/tx/0xabc123...
It may take a few minutes to arrive on Sepolia.
Full Script
The repository with the above example is available on GitHub.
In the example repository, helpers such as getFxrpAddress and getFxrpBalance are isolated into separate files in the src/utils directory.
View bridge-fxrp.ts source code
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);
});
To continue your FAssets development journey, you can:
- Learn how to mint FXRP
- Understand how to redeem FXRP
- Explore auto-redemption from Hyperliquid
- Explore auto-minting and bridging via Smart Accounts for XRPL-triggered workflows