Skip to main content

Auto Minting and Bridging FXRP

Overview

In this guide, you will learn how to mint FXRP and bridge it cross-chain from Flare Testnet Coston2 to Sepolia using Flare Smart Accounts and LayerZero's OFT (Omnichain Fungible Token) protocol.

This guide demonstrates an end-to-end workflow that allows XRPL users to:

  1. Mint FXRP - Convert native XRP from the XRP Ledger into FXRP tokens on Flare, controlled entirely via XRPL transactions.
  2. Bridge FXRP cross-chain - Transfer the minted FXRP to another EVM chain (Sepolia) using LayerZero, triggered by a single XRPL payment.

Key technologies:

  • Flare Smart Accounts - Account abstraction enabling XRPL users to execute actions on Flare without holding FLR tokens.
  • Custom Instructions - Register arbitrary contract calls that can be triggered via XRPL payments.
  • FAsset System for tokenizing non-smart contract assets (XRP to FXRP).
  • LayerZero OFT for cross-chain token transfers.
  • @flarenetwork/smart-accounts-encoder library for encoding FAsset instructions.

Clone the Flare Hardhat Starter to follow along.

How Smart Accounts Enable This Workflow

Flare Smart Accounts allow XRPL users to perform actions on the Flare chain without owning any FLR tokens. Each XRPL address is assigned a unique personal account (smart contract wallet) on Flare, which only that XRPL address can control through Payment transactions on the XRP Ledger.

This script leverages two types of Smart Account instructions:

1. FAsset Collateral Reservation Instruction

The FXRPCollateralReservationInstruction is a predefined instruction type that initiates the FAsset minting process. When sent via an XRPL payment memo:

  1. The operator monitors the XRPL payment and relays it to Flare.
  2. The MasterAccountController contract reserves collateral from the selected agent.
  3. After the user sends XRP to the agent's underlying address, FXRP is minted to their personal account.

2. Custom Instruction for Atomic Bridging

Custom instructions allow registering arbitrary contract calls that execute atomically. This script registers an atomic batch containing:

  1. Approve: Grant the OFT Adapter permission to spend FXRP tokens.
  2. Send: Execute the LayerZero cross-chain transfer.

Both actions execute in a single transaction when triggered by the XRPL payment, ensuring the bridge cannot fail due to missing approval.

Flow Diagram

                          XRPL USER WORKFLOW
------------------------------------------------------------------------
|
----------------------------|----------------------------
| | |
v | |
+-------------------+ | |
| 1. Register | | |
| Custom Bridge | | |
| Instruction | | |
| (Flare EOA) | | |
+--------+----------+ | |
| | |
| Returns instruction hash | |
v | |
+-------------------+ | |
| 2. Check Smart | | |
| Account | | |
| Balance | | |
+--------+----------+ | |
| | |
| Needs FXRP? | |
v v |
+-------------------+ +-------------------+ |
| 3a. Mint FXRP | | 3b. Skip Mint | |
| (If needed) | | (Has balance) | |
+--------+----------+ +--------+----------+ |
| | |
+-----------+-------------+ |
| |
v |
+-------------------+ |
| 4. Execute |<---------------------------------+
| Bridge |
| (XRPL Payment) |
+--------+----------+
|
v
CROSS-CHAIN FLOW
------------------------------------------------------------------------

+-------------------+ +-------------------+ +-------------------+
| XRP Ledger | | Flare Coston2 | | Sepolia |
| | | | | |
| XRPL Payment |--------->| MasterAccount | | |
| with Memo | FDC | Controller | | |
| | Proof | | | | |
| | | v | | |
| | | Personal Account | | |
| | | | | | |
| | | v | | |
| | | Atomic Batch: | | |
| | | +-------------+ | | |
| | | | 1. Approve | | | |
| | | | 2. LZ Send |--|--------->| FXRP OFT |
| | | +-------------+ |LayerZero | Received |
| | | | | |
+-------------------+ +-------------------+ +-------------------+

Prerequisites

  • XRPL Testnet Account: An XRP Ledger testnet wallet with XRP for payments. Get testnet XRP from the XRP Testnet Faucet.
  • Flare Testnet Account: An EVM wallet with C2FLR for gas fees. Get C2FLR from the Flare Testnet Faucet.
  • Environment Setup: Private keys configured in Hardhat.

Configuration

Edit the CONFIG object in the script to customize the bridge parameters:

const CONFIG = {
MASTER_ACCOUNT_CONTROLLER: "0xa7bc2aC84DB618fde9fa4892D1166fFf75D36FA6",
COSTON2_OFT_ADAPTER: "0xCd3d2127935Ae82Af54Fc31cCD9D3440dbF46639",
XRPL_RPC: "wss://s.altnet.rippletest.net:51233",
SEPOLIA_EID: EndpointId.SEPOLIA_V2_TESTNET,
EXECUTOR_GAS: 400_000,
BRIDGE_LOTS: 1, // Number of lots to bridge
AUTO_MINT_IF_NEEDED: true, // Automatically mint if insufficient balance
MINT_LOTS: 1, // Number of lots to mint if needed
};
ParameterDescription
MASTER_ACCOUNT_CONTROLLERAddress of the MasterAccountController contract on Coston2
COSTON2_OFT_ADAPTERAddress of the FXRP OFT Adapter for LayerZero bridging
XRPL_RPCWebSocket URL for XRPL Testnet
SEPOLIA_EIDLayerZero Endpoint ID for the destination chain
EXECUTOR_GASGas limit for the LayerZero executor on the destination chain
BRIDGE_LOTSNumber of FXRP lots to bridge (1 lot = 10 FXRP)
AUTO_MINT_IF_NEEDEDWhether to automatically mint FXRP if the personal account has insufficient balance
MINT_LOTSNumber of lots to mint if auto-minting is triggered

How to Run

  1. Install Dependencies:

    yarn install
  2. Configure Environment:

    # .env file
    COSTON2_RPC_URL=https://coston2-api.flare.network/ext/C/rpc
    DEPLOYER_PRIVATE_KEY=your_flare_private_key_here
    XRPL_SECRET=your_xrpl_wallet_secret_here
  3. Run the Script:

    yarn hardhat run scripts/smartAccounts/bridgeViaSmartAccount.ts --network coston2

Script Walkthrough

Step 1: Register the Bridge Instruction

The script first registers a custom instruction with the MasterAccountController. This instruction bundles two contract calls into an atomic batch:

// 1. Prepare APPROVE Call
const instructionApprove: CustomInstruction = {
targetContract: fxrpAddress,
value: 0n,
data: approveCallData, // ERC20 approve(spender, amount)
};

// 2. Prepare SEND Call
const instructionBridge: CustomInstruction = {
targetContract: CONFIG.COSTON2_OFT_ADAPTER,
value: nativeFee, // LayerZero fee in native tokens
data: sendCallData, // OFT send() with LayerZero options
};

// 3. Register the atomic batch
const atomicInstruction = [instructionApprove, instructionBridge];
await masterController.methods
.registerCustomInstruction(atomicInstruction)
.send({ from: accounts[0] });

The registration returns an instruction hash that will be used as the XRPL payment memo to trigger execution. The memo format for custom instructions is:

  • First byte: 99 (custom instruction identifier)
  • Remaining 31 bytes: instruction hash (padded)

Step 2: Check Smart Account Balance

Before bridging, the script checks if the user's personal account has sufficient:

  1. FXRP balance - Enough tokens to bridge
  2. Native balance (C2FLR) - Enough gas to pay for the LayerZero fee
const personalAccountAddr = await masterController.methods
.getPersonalAccount(xrplAddress)
.call();

const fxrpBalance = await ftestxrp.balanceOf(personalAccountAddr);
const nativeBalance = await web3.eth.getBalance(personalAccountAddr);

If the personal account doesn't exist yet, it will be created automatically when the first instruction is executed.

Step 3: Fund Gas (If Needed)

If the personal account lacks sufficient native tokens for the LayerZero fee, the script funds it from the Flare EOA:

if (status.needsGas && status.hasAccount) {
await web3.eth.sendTransaction({
from: accounts[0],
to: status.personalAccountAddr,
value: (requiredGas - status.currentNative + BigInt(1e17)).toString(),
});
}

Step 4: Mint FXRP (If Needed)

If the personal account has insufficient FXRP, the script initiates the FAsset minting process using the Smart Accounts system:

import { FXRPCollateralReservationInstruction } from "@flarenetwork/smart-accounts-encoder";

// Encode the collateral reservation instruction
const reservationInstruction = new FXRPCollateralReservationInstruction({
walletId: 0,
value: lots, // Number of lots to mint
agentVaultId: agentIndex, // Selected agent's index
});

// Send XRPL payment with the instruction memo
const instructionMemo = reservationInstruction.encode().slice(2);
await sendXrplMemoPayment(xrplWallet, operatorAddress, "1", instructionMemo);

The minting process involves:

  1. Reservation Trigger: Send an XRPL payment to the operator with the encoded instruction.
  2. Wait for Reservation: The operator processes the payment and reserves collateral on Flare.
  3. Send Underlying Collateral: Send XRP to the agent's underlying address with the payment reference.
  4. Mint Execution: The operator executes the mint, depositing FXRP to the personal account.

Step 5: Execute the Bridge

Finally, trigger the bridge by sending an XRPL payment with the custom instruction memo:

await sendXrplMemoPayment(xrplWallet, operatorAddress, "0.1", bridgeMemo);

When the operator relays this payment to Flare:

  1. The MasterAccountController looks up the registered instruction by its hash.
  2. The personal account executes the atomic batch:
    • Approves the OFT Adapter to spend FXRP
    • Calls the OFT Adapter's send() function
  3. LayerZero delivers the tokens to Sepolia.

Expected Output

Flare EOA: 0x742d35Cc6634C0532925a3b844Bc454e4438f44e
XRPL Wallet: rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh

Bridging 1 lot(s) = 10.0 FXRP

=== Step 1: Registering Atomic Bridge Instruction ===
LayerZero Fee: 0.001234 C2FLR required in personal account
Submitting registration tx...
Instruction Registered.
Final XRPL Memo: 99000000...abc123

=== Checking Smart Account Balance ===
Personal Account: 0x123...
FXRP Balance: 15.0
C2FLR Balance: 0.5
Sufficient FXRP balance found. Skipping mint.

=== Bridging to Sepolia via Custom Instruction ===
Sending Bridge Trigger on XRPL...
Sending 0.1 XRP to rOperator... with Memo 99000000...abc123
Tx Hash: ABC123...

Bridge Request Sent! (Asynchronous execution on Flare will follow)
View bridgeViaSmartAccount.ts source code
scripts/smartAccounts/bridgeViaSmartAccount.ts
/**
* Usage:
* yarn hardhat run scripts/smartAccounts/bridgeViaSmartAccount.ts --network coston2
*/

import { web3, artifacts } from "hardhat";
import { formatUnits } from "ethers";
import { Options } from "@layerzerolabs/lz-v2-utilities";
import { EndpointId } from "@layerzerolabs/lz-definitions";
import { Client, Wallet as XrplWallet, xrpToDrops } from "xrpl";
import type { Payment } from "xrpl";
import { FXRPCollateralReservationInstruction } from "@flarenetwork/smart-accounts-encoder";
import { getAssetManagerFXRP } from "../utils/getters";
import { sleep } from "../utils/core";
import type {
IAssetManagerInstance,
IERC20Instance,
} from "../../typechain-types";
import * as fs from "fs";
import * as path from "path";

const IERC20 = artifacts.require("IERC20");

// ABIs
const MASTER_ACCOUNT_CONTROLLER_ABI = JSON.parse(
fs.readFileSync(
path.join(__dirname, "../abi/MasterAccountController.json"),
"utf-8",
),
).abi;

const FASSET_OFT_ADAPTER_ABI = JSON.parse(
fs.readFileSync(
path.join(__dirname, "../abi/FAssetOFTAdapter.json"),
"utf-8",
),
).abi;

type CustomInstruction = {
targetContract: string;
value: bigint;
data: string;
};

// Configuration
const CONFIG = {
MASTER_ACCOUNT_CONTROLLER: "0xa7bc2aC84DB618fde9fa4892D1166fFf75D36FA6",
COSTON2_OFT_ADAPTER: "0xCd3d2127935Ae82Af54Fc31cCD9D3440dbF46639",
XRPL_RPC: "wss://s.altnet.rippletest.net:51233",
SEPOLIA_EID: EndpointId.SEPOLIA_V2_TESTNET,
EXECUTOR_GAS: 400_000,
BRIDGE_LOTS: 1, // Number of lots to bridge
AUTO_MINT_IF_NEEDED: true,
MINT_LOTS: 1,
} as const;

/**
* Get the FXRP token address and calculate bridge amount from lots
* @see https://dev.flare.network/fassets/developer-guides/fassets-fxrp-address
*/
async function getAssetManagerInfo(lots: number) {
const assetManager = await getAssetManagerFXRP();
const fxrpAddress = await assetManager.fAsset();
const lotSizeBN = await assetManager.lotSize();
const lotSize = BigInt(lotSizeBN.toString());
const amountToBridge = lotSize * BigInt(lots);

return {
fxrpAddress,
amountToBridge,
};
}

async function getWallets() {
const accounts = await web3.eth.getAccounts();
const signerAddress = accounts[0];
const xrplSecret = process.env.XRPL_SECRET;
if (!xrplSecret) throw new Error("XRPL_SECRET not set in .env");
const xrplWallet = XrplWallet.fromSeed(xrplSecret);

console.log(`Flare EOA: ${signerAddress}`);
console.log(`XRPL Wallet: ${xrplWallet.address}`);
return { signerAddress, xrplWallet };
}

function getMasterController() {
return new web3.eth.Contract(
MASTER_ACCOUNT_CONTROLLER_ABI,
CONFIG.MASTER_ACCOUNT_CONTROLLER,
);
}

/**
* Step 1: Register the Bridge Instruction on Flare
* Creates an ATOMIC BATCH: [Approve Token, Send Token]
*/
async function registerBridgeInstruction(
recipientAddress: string,
amountToBridge: bigint,
fxrpAddress: string,
) {
console.log("\n=== Step 1: Registering Atomic Bridge Instruction ===");

const oftAdapter = new web3.eth.Contract(
FASSET_OFT_ADAPTER_ABI,
CONFIG.COSTON2_OFT_ADAPTER,
);
const ftestxrp = new web3.eth.Contract(IERC20.abi, fxrpAddress);

// 1. Prepare APPROVE Call (Personal Account -> OFT Adapter)
const approveCallData = ftestxrp.methods
.approve(CONFIG.COSTON2_OFT_ADAPTER, amountToBridge.toString())
.encodeABI();

const instructionApprove: CustomInstruction = {
targetContract: fxrpAddress,
value: 0n,
data: approveCallData,
};

// 2. Prepare SEND Call (Personal Account -> LayerZero)
const options = Options.newOptions().addExecutorLzReceiveOption(
CONFIG.EXECUTOR_GAS,
0,
);
const sendParam = {
dstEid: CONFIG.SEPOLIA_EID,
to: web3.utils.padLeft(recipientAddress, 64),
amountLD: amountToBridge.toString(),
minAmountLD: amountToBridge.toString(),
extraOptions: options.toHex(),
composeMsg: "0x",
oftCmd: "0x",
};

const quoteResult = await oftAdapter.methods
.quoteSend(sendParam, false)
.call();
const nativeFee = BigInt(quoteResult.nativeFee);
console.log(
`LayerZero Fee: ${formatUnits(nativeFee, 18)} C2FLR required in personal account`,
);

const feeStruct = { nativeFee: nativeFee.toString(), lzTokenFee: "0" };
const sendCallData = oftAdapter.methods
.send(sendParam, feeStruct, recipientAddress)
.encodeABI();

const instructionBridge: CustomInstruction = {
targetContract: CONFIG.COSTON2_OFT_ADAPTER,
value: nativeFee, // Gas needed for this specific step
data: sendCallData,
};

// 3. Bundle & Register
const atomicInstruction: CustomInstruction[] = [
instructionApprove,
instructionBridge,
];
const masterController = getMasterController();
const accounts = await web3.eth.getAccounts();

console.log("Submitting registration tx...");
// Note: In a real app, check if this exact hash is already registered to save gas
await masterController.methods
.registerCustomInstruction(atomicInstruction)
.send({ from: accounts[0] });

const encodedInstructionBN = await masterController.methods
.encodeCustomInstruction(atomicInstruction)
.call();
let instructionHash = BigInt(encodedInstructionBN).toString(16);
if (instructionHash.length % 2 !== 0) instructionHash = "0" + instructionHash;

console.log("✅ Instruction Registered.");

const finalMemo = "99" + instructionHash.padStart(60, "0");
console.log("Final XRPL Memo:", finalMemo);

return { memo: finalMemo, requiredGas: nativeFee };
}

async function sendXrplMemoPayment(
xrplWallet: XrplWallet,
destination: string,
amountXrp: string,
memoHex: string,
) {
const client = new Client(CONFIG.XRPL_RPC);
await client.connect();
try {
const payment: Payment = {
TransactionType: "Payment",
Account: xrplWallet.address,
Destination: destination,
Amount: xrpToDrops(amountXrp),
Memos: [{ Memo: { MemoData: memoHex.toUpperCase() } }],
};
console.log(
`Sending ${amountXrp} XRP to ${destination} with Memo ${memoHex}...`,
);
const prepared = await client.autofill(payment);
const signed = xrplWallet.sign(prepared);
const result = await client.submitAndWait(signed.tx_blob);
if (
result.result.meta &&
typeof result.result.meta === "object" &&
result.result.meta.TransactionResult !== "tesSUCCESS"
) {
throw new Error(
`XRPL Payment Failed: ${result.result.meta.TransactionResult}`,
);
}
console.log(`Tx Hash: ${result.result.hash}`);
} finally {
await client.disconnect();
}
}

async function checkPersonalAccount(
xrplAddress: string,
requiredAmountFXRP: bigint,
requiredGas: bigint,
fxrpAddress: string,
) {
console.log("\n=== Checking Smart Account Balance ===");
const masterController = getMasterController();

const personalAccountAddr = await masterController.methods
.getPersonalAccount(xrplAddress)
.call();

const hasAccount =
personalAccountAddr !== "0x0000000000000000000000000000000000000000";

let fxrpBalance = 0n;
let nativeBalance = 0n;

if (hasAccount) {
const ftestxrp: IERC20Instance = await IERC20.at(fxrpAddress);
fxrpBalance = BigInt(await ftestxrp.balanceOf(personalAccountAddr));
nativeBalance = BigInt(await web3.eth.getBalance(personalAccountAddr));

console.log(`Personal Account: ${personalAccountAddr}`);
console.log(`FXRP Balance: ${formatUnits(fxrpBalance, 18)}`);
console.log(`C2FLR Balance: ${formatUnits(nativeBalance, 18)}`);
} else {
console.log("Personal Account: Not created yet");
}

return {
personalAccountAddr,
hasAccount,
needsMint: fxrpBalance < requiredAmountFXRP,
needsGas: nativeBalance < requiredGas,
currentNative: nativeBalance,
};
}

async function waitForReservationEvent(
assetManager: IAssetManagerInstance,
agentVault: string,
startBlock: number,
) {
console.log("⏳ Waiting for Operator to Execute Reservation...");
let currentFrom = startBlock;
const MAX_BLOCK_RANGE = 25;
const MAX_DURATION = 15 * 60 * 1000;
const startTime = Date.now();

while (Date.now() - startTime < MAX_DURATION) {
const latest = await web3.eth.getBlockNumber();
while (currentFrom <= latest) {
const currentTo = Math.min(currentFrom + MAX_BLOCK_RANGE, latest);
const events = await assetManager.getPastEvents("CollateralReserved", {
fromBlock: currentFrom,
toBlock: currentTo,
filter: { agentVault: agentVault },
});

if (events.length > 0) {
const evt = events[events.length - 1];
console.log("\n✅ Event Detected in block", evt.blockNumber);
return {
valueUBA: BigInt(evt.returnValues.valueUBA),
paymentReference: evt.returnValues.paymentReference,
};
}
currentFrom = currentTo + 1;
}
process.stdout.write(".");
await sleep(5000);
}
throw new Error("Timeout waiting for reservation event.");
}

async function mintFXRP(xrplWallet: XrplWallet, lots: number) {
console.log(`\n=== Starting Mint for ${lots} Lot(s) ===`);
const assetManager = await getAssetManagerFXRP();
const masterController = getMasterController();
const operatorAddress = await masterController.methods
.xrplProviderWallet()
.call();

const agents = await assetManager.getAvailableAgentsDetailedList(0, 20);
// Note: This is a proof of concept. In production, you can select your own agent.
const agentIndex = agents._agents.findIndex(
(a) => BigInt(a.freeCollateralLots) >= BigInt(lots),
);
if (agentIndex === -1) throw new Error("No agents available");
const agent = agents._agents[agentIndex];
console.log(`Selected Agent: ${agent.agentVault} (index: ${agentIndex})`);

const agentInfo = await assetManager.getAgentInfo(agent.agentVault);
const agentXrplAddress = agentInfo.underlyingAddressString;

// Encode mint instruction using smart-accounts-encoder library
const reservationInstruction = new FXRPCollateralReservationInstruction({
walletId: 0,
value: lots,
agentVaultId: agentIndex,
});
const instructionMemo = reservationInstruction.encode().slice(2); // Remove '0x' prefix for XRPL memo

const currentBlock = await web3.eth.getBlockNumber();
console.log(`1. Sending Reservation Trigger...`);
await sendXrplMemoPayment(xrplWallet, operatorAddress, "1", instructionMemo);

const { valueUBA, paymentReference } = await waitForReservationEvent(
assetManager,
agent.agentVault,
currentBlock,
);
const xrpAmount = Number(valueUBA) / 1_000_000;

console.log(`\n✅ Reservation Confirmed.`);
console.log(`2. Sending Underlying Collateral to Agent...`);
const refClean = paymentReference.replace("0x", "");
await sendXrplMemoPayment(
xrplWallet,
agentXrplAddress,
xrpAmount.toString(),
refClean,
);

console.log("⏳ Waiting for FXRP Mint Execution (60s)...");
await sleep(60000);
}

async function executeBridge(xrplWallet: XrplWallet, bridgeMemo: string) {
console.log("\n=== Bridging to Sepolia via Custom Instruction ===");
const masterController = getMasterController();
const operatorAddress = await masterController.methods
.xrplProviderWallet()
.call();

console.log("Sending Bridge Trigger on XRPL...");
await sendXrplMemoPayment(xrplWallet, operatorAddress, "0.1", bridgeMemo);
console.log(
"\n✅ Bridge Request Sent! (Asynchronous execution on Flare will follow)",
);
}

/**
* Main Flow
*/
async function main() {
const { signerAddress, xrplWallet } = await getWallets();

// Get FXRP address and calculate bridge amount from lots
const { fxrpAddress, amountToBridge } = await getAssetManagerInfo(
CONFIG.BRIDGE_LOTS,
);
console.log(
`\nBridging ${CONFIG.BRIDGE_LOTS} lot(s) = ${formatUnits(amountToBridge, 6)} FXRP`,
);

// 1. Register custom instruction
const { memo: bridgeMemo, requiredGas } = await registerBridgeInstruction(
signerAddress,
amountToBridge,
fxrpAddress,
);

// 2. Check State
const status = await checkPersonalAccount(
xrplWallet.address,
amountToBridge,
requiredGas,
fxrpAddress,
);

// 3. Fund Gas
if (status.needsGas && status.hasAccount) {
console.log(`\n⚠️ Personal Account needs Native Gas! Sending C2FLR...`);
const accounts = await web3.eth.getAccounts();
await web3.eth.sendTransaction({
from: accounts[0],
to: status.personalAccountAddr,
value: (requiredGas - status.currentNative + BigInt(1e17)).toString(),
});
console.log("Gas funded.");
}

// 4. Mint (Skipped if balance exists!)
if (status.needsMint) {
if (!CONFIG.AUTO_MINT_IF_NEEDED) throw new Error("Insufficient Funds");
await mintFXRP(xrplWallet, CONFIG.MINT_LOTS);
} else {
console.log("✅ Sufficient FXRP balance found. Skipping mint.");
}

// 5. Execute Bridge
await executeBridge(xrplWallet, bridgeMemo);
}

main().catch((error) => {
console.error(error);
process.exit(1);
});

Code Breakdown

Key Functions

getAssetManagerInfo(lots)

Retrieves the FXRP token address and calculates the exact amount to bridge based on the lot size:

const assetManager = await getAssetManagerFXRP();
const fxrpAddress = await assetManager.fAsset();
const lotSize = BigInt(await assetManager.lotSize());
const amountToBridge = lotSize * BigInt(lots);

registerBridgeInstruction(recipientAddress, amountToBridge, fxrpAddress)

Creates and registers the atomic bridge instruction:

  1. Encodes the ERC20 approve() call for the OFT Adapter.
  2. Builds LayerZero send parameters with the destination chain and recipient.
  3. Quotes the LayerZero fee using oftAdapter.quoteSend().
  4. Encodes the OFT send() call with the fee.
  5. Registers both calls as a single atomic instruction.

sendXrplMemoPayment(xrplWallet, destination, amountXrp, memoHex)

Sends an XRP Ledger payment with an encoded memo:

const payment: Payment = {
TransactionType: "Payment",
Account: xrplWallet.address,
Destination: destination,
Amount: xrpToDrops(amountXrp),
Memos: [{ Memo: { MemoData: memoHex.toUpperCase() } }],
};

mintFXRP(xrplWallet, lots)

Executes the full FAsset minting flow:

  1. Selects an available agent with sufficient free collateral.
  2. Encodes and sends the collateral reservation instruction.
  3. Waits for the CollateralReserved event.
  4. Sends XRP collateral to the agent's underlying address.
  5. Waits for the mint to complete.

Understanding the Instruction Encoding

FAsset Instructions

The @flarenetwork/smart-accounts-encoder library provides typed instruction builders. The collateral reservation instruction encodes to a 32-byte value:

BytesContent
0Instruction ID (00 for FXRP type, 00 for collateral reservation)
1Wallet ID (typically 0)
2-11Value (number of lots)
12-13Agent Vault ID
14-31Reserved

Custom Instructions

Custom instructions are identified by the first byte being 99. The remaining 31 bytes contain the keccak256 hash of the encoded instruction array (right-shifted by 8 bits):

uint256(keccak256(abi.encode(_customInstruction))) >> 8;

FAQ

Q: What's the minimum amount I can bridge? A: Minimum is 1 lot (10 FXRP for XRP).

Q: How long does the minting process take? A: Minting typically takes 1-2 minutes for the reservation, plus time for the underlying XRP payment to be confirmed and processed by the operator.

Q: What if the bridge execution fails? A: If the atomic instruction fails (e.g., insufficient balance), the entire transaction reverts. The FXRP remains in the personal account and can be retrieved or retried.

Q: Can I bridge to chains other than Sepolia? A: Yes, update the SEPOLIA_EID configuration to any LayerZero-supported destination. Use the getOftPeers script to discover available routes.

Q: Do I need FLR tokens to use this? A: You need C2FLR (testnet FLR) to:

  • Register custom instructions (one-time gas cost)
  • Fund the personal account with native tokens for LayerZero fees

The actual bridging is triggered via XRPL payments and executed by the operator.

Q: What is the operator's role? A: The operator monitors XRPL payments to a designated address, obtains FDC proofs, and relays transactions to the MasterAccountController on Flare. This service enables XRPL users to trigger Flare actions without holding FLR.

Q: Can I use this on mainnet? A: This guide is for testnet. For mainnet deployment, update contract addresses, thoroughly test, and audit all code.

Troubleshooting

Error: XRPL_SECRET not set in .env

  • Solution: Add your XRPL testnet wallet secret to the .env file.

Error: No agents available

  • Solution: No FAsset agents have sufficient free collateral. Try with fewer lots or wait for agents to free up capacity.

Error: Timeout waiting for reservation event

  • Solution: The operator may be delayed. Check that the XRPL payment was successful and retry.

Error: Insufficient balance for bridging

  • Solution: Ensure AUTO_MINT_IF_NEEDED is true, or manually mint FXRP to the personal account first.

Error: LayerZero fee insufficient

  • Solution: Fund the personal account with more C2FLR for the LayerZero cross-chain fee.
Next Steps

To continue your FAssets development journey, you can: