Agent documentation index: llms.txt. Markdown versions of documentation pages are available by appending .md to the page URL.
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 on Flare via FAssets minting (payment to the Core Vault with a memo that credits the personal account).
  2. Bridge FXRP cross-chain — Transfer the minted FXRP to another EVM chain (Sepolia) using LayerZero, triggered by a Smart Accounts custom instruction.
Preferred TypeScript path

For the modern mint-and-bridge flow with Viem (mint via Core Vault + memo-field custom instructions), follow the Cross-Chain Mint guide. The Hardhat script below is an alternate illustration that registers a proof-based custom instruction for the bridge leg.

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.
  • FAssets minting for tokenizing XRP to FXRP.
  • LayerZero OFT for cross-chain token transfers.

Clone the Flare Hardhat Starter to follow along with the script below.

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 uses two complementary pieces:

1. FAssets minting to the personal account

When the personal account needs FXRP, the script mints via FAssets minting:

  1. Resolve the Core Vault XRPL address from directMintingPaymentAddress().
  2. Build a 32-byte DIRECT_MINTING memo that encodes the personal account as recipient.
  3. Send an XRPL Payment to the Core Vault for the net mint amount plus fees.
  4. An executor calls executeDirectMinting; FXRP is credited to the personal account.

See the Mint FXRP developer guide for the full memo layout and fee helpers.

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 | |
| (Core Vault + | | (Has balance) | |
| DIRECT_MINTING) | | | |
+--------+----------+ +--------+----------+ |
| | |
+-----------+-------------+ |
| |
v |
+-------------------+ |
| 4. Execute |<---------------------------------+
| Bridge |
| (XRPL Payment) |
+--------+----------+
|
v
CROSS-CHAIN FLOW
------------------------------------------------------------------------

+-------------------+ +-------------------+ +-------------------+
| XRP Ledger | | Flare Coston2 | | Sepolia |
| | | | | |
| Payment to Core |--------->| AssetManager | | |
| Vault + memo | FDC | executeDirect- | | |
| | Proof | Minting | | |
| | | | | | |
| Bridge trigger |--------->| MasterAccount | | |
| (custom instr.) | 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 mints via FAssets minting:

const DIRECT_MINTING_PREFIX = "4642505266410018";

function buildDirectMintingMemo(recipientAddress: string): string {
return (
DIRECT_MINTING_PREFIX + "00000000" + recipientAddress.slice(2).toLowerCase()
);
}

const coreVaultXrplAddress = await assetManager.directMintingPaymentAddress();
const paymentAmountXrp = await computeDirectMintingPaymentAmountXrp(
assetManager,
netMintAmountXrp,
);
const memoHex = buildDirectMintingMemo(personalAccountAddr);

await sendXrplMemoPayment(
xrplWallet,
coreVaultXrplAddress,
paymentAmountXrp.toString(),
memoHex,
);

The minting process involves:

  1. Core Vault payment: Send XRP to the Core Vault with a DIRECT_MINTING memo encoding the personal account.
  2. Fee coverage: Payment amount includes the net mint plus minting and executor fees (see fees).
  3. Finalization: An executor calls executeDirectMinting; FXRP is credited to the personal account.

For a mint that also runs an on-chain action in the same flow, prefer memo-field custom instructions as in the Cross-Chain Mint guide.

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 { 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");

// 32-byte DIRECT_MINTING PaymentReference prefix
const DIRECT_MINTING_PREFIX = "4642505266410018";

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;
};

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,
lotSize,
};
}

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,
);
}

/**
* Build the 32-byte DIRECT_MINTING memo:
* [8-byte prefix][4-byte zero padding][20-byte recipient address]
*/
function buildDirectMintingMemo(recipientAddress: string): string {
return (
DIRECT_MINTING_PREFIX + "00000000" + recipientAddress.slice(2).toLowerCase()
);
}

/**
* Gross XRP payment = net mint + minting fee + executor fee.
* @see https://dev.flare.network/fassets/minting#fees
*/
async function computeDirectMintingPaymentAmountXrp(
assetManager: IAssetManagerInstance,
netMintAmountXrp: number,
): Promise<number> {
const [executorFeeUBA, feeBIPS, minimumFeeUBA] = await Promise.all([
assetManager.getDirectMintingExecutorFeeUBA(),
assetManager.getDirectMintingFeeBIPS(),
assetManager.getDirectMintingMinimumFeeUBA(),
]);

const netMintUBA = BigInt(xrpToDrops(netMintAmountXrp));
const proportionalFeeUBA =
(netMintUBA * BigInt(feeBIPS.toString())) / 10_000n;
const minFee = BigInt(minimumFeeUBA.toString());
const mintingFeeUBA =
proportionalFeeUBA > minFee ? proportionalFeeUBA : minFee;
const totalUBA =
netMintUBA + mintingFeeUBA + BigInt(executorFeeUBA.toString());

return Number(totalUBA) / 1_000_000;
}

/**
* 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 waitForDirectMintingExecuted(
assetManager: IAssetManagerInstance,
targetAddress: string,
startBlock: number,
) {
console.log("⏳ Waiting for DirectMintingExecuted...");
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("DirectMintingExecuted", {
fromBlock: currentFrom,
toBlock: currentTo,
filter: { targetAddress },
});

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

/**
* Mint FXRP via FAssets minting: pay the Core Vault with a DIRECT_MINTING memo
* that credits the personal account.
* @see https://dev.flare.network/fassets/developer-guides/fassets-mint
*/
async function mintFXRP(
xrplWallet: XrplWallet,
lots: number,
lotSize: bigint,
personalAccountAddr: string,
) {
console.log(`\n=== Starting Mint for ${lots} Lot(s) ===`);
const assetManager = await getAssetManagerFXRP();

const coreVaultXrplAddress = await assetManager.directMintingPaymentAddress();
const netMintAmountXrp = Number(lotSize * BigInt(lots)) / 1_000_000;
const paymentAmountXrp = await computeDirectMintingPaymentAmountXrp(
assetManager,
netMintAmountXrp,
);
const memoHex = buildDirectMintingMemo(personalAccountAddr);

console.log(`Core Vault XRPL address: ${coreVaultXrplAddress}`);
console.log(`Personal account recipient: ${personalAccountAddr}`);
console.log(`Net mint: ${netMintAmountXrp} XRP`);
console.log(`Payment amount (net + fees): ${paymentAmountXrp} XRP`);
console.log(`DIRECT_MINTING memo: ${memoHex}`);

const currentBlock = await web3.eth.getBlockNumber();
console.log("Sending XRPL payment to Core Vault...");
await sendXrplMemoPayment(
xrplWallet,
coreVaultXrplAddress,
paymentAmountXrp.toString(),
memoHex,
);

await waitForDirectMintingExecuted(
assetManager,
personalAccountAddr,
currentBlock,
);
console.log("✅ FXRP minted to personal account.");
}

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, lotSize } = 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 via FAssets minting if balance is insufficient
if (status.needsMint) {
if (!CONFIG.AUTO_MINT_IF_NEEDED) throw new Error("Insufficient Funds");
const personalAccountAddr = await getMasterController()
.methods.getPersonalAccount(xrplWallet.address)
.call();
await mintFXRP(xrplWallet, CONFIG.MINT_LOTS, lotSize, personalAccountAddr);
} 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, lotSize, personalAccountAddr)

Executes FAssets minting to the personal account:

  1. Reads directMintingPaymentAddress() and fee settings from the AssetManager.
  2. Builds a 32-byte DIRECT_MINTING memo for the personal account recipient.
  3. Sends XRP to the Core Vault covering net mint plus fees.
  4. Waits for the DirectMintingExecuted event.

Understanding the Instruction Encoding

FAssets minting memo

The minting payment uses the 32-byte PaymentReference layout:

BytesContent
0-74642505266410018 (DIRECT_MINTING prefix)
8-11Zero padding
12-31Personal account recipient (20-byte EVM address)

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: After the XRPL payment to the Core Vault confirms, an executor finalizes with executeDirectMinting. Rate limits may emit DirectMintingDelayed before DirectMintingExecuted — see delayed minting.

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: For the bridge custom instruction, the operator monitors XRPL payments to a designated address, obtains FDC proofs, and relays transactions to the MasterAccountController on Flare. Mint finalization is performed by an FAssets executor calling executeDirectMinting.

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: Timeout waiting for DirectMintingExecuted

  • Solution: Confirm the XRPL payment to the Core Vault succeeded and that an executor has finalized the mint. Check for rate-limit delays.

Error: Insufficient balance for bridging

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

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: