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:
- 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).
- Bridge FXRP cross-chain — Transfer the minted FXRP to another EVM chain (Sepolia) using LayerZero, triggered by a Smart Accounts custom instruction.
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:
- Resolve the Core Vault XRPL address from
directMintingPaymentAddress(). - Build a 32-byte
DIRECT_MINTINGmemo that encodes the personal account as recipient. - Send an XRPL
Paymentto the Core Vault for the net mint amount plus fees. - 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:
- Approve: Grant the OFT Adapter permission to spend FXRP tokens.
- 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
};
| Parameter | Description |
|---|---|
MASTER_ACCOUNT_CONTROLLER | Address of the MasterAccountController contract on Coston2 |
COSTON2_OFT_ADAPTER | Address of the FXRP OFT Adapter for LayerZero bridging |
XRPL_RPC | WebSocket URL for XRPL Testnet |
SEPOLIA_EID | LayerZero Endpoint ID for the destination chain |
EXECUTOR_GAS | Gas limit for the LayerZero executor on the destination chain |
BRIDGE_LOTS | Number of FXRP lots to bridge (1 lot = 10 FXRP) |
AUTO_MINT_IF_NEEDED | Whether to automatically mint FXRP if the personal account has insufficient balance |
MINT_LOTS | Number of lots to mint if auto-minting is triggered |
How to Run
-
Install Dependencies:
yarn install -
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 -
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:
- FXRP balance - Enough tokens to bridge
- 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:
- Core Vault payment: Send XRP to the Core Vault with a
DIRECT_MINTINGmemo encoding the personal account. - Fee coverage: Payment amount includes the net mint plus minting and executor fees (see fees).
- 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:
- The
MasterAccountControllerlooks up the registered instruction by its hash. - The personal account executes the atomic batch:
- Approves the OFT Adapter to spend FXRP
- Calls the OFT Adapter's
send()function
- 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
/**
* 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:
- Encodes the ERC20
approve()call for the OFT Adapter. - Builds LayerZero send parameters with the destination chain and recipient.
- Quotes the LayerZero fee using
oftAdapter.quoteSend(). - Encodes the OFT
send()call with the fee. - 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:
- Reads
directMintingPaymentAddress()and fee settings from the AssetManager. - Builds a 32-byte
DIRECT_MINTINGmemo for the personal account recipient. - Sends XRP to the Core Vault covering net mint plus fees.
- Waits for the
DirectMintingExecutedevent.
Understanding the Instruction Encoding
FAssets minting memo
The minting payment uses the 32-byte PaymentReference layout:
| Bytes | Content |
|---|---|
| 0-7 | 4642505266410018 (DIRECT_MINTING prefix) |
| 8-11 | Zero padding |
| 12-31 | Personal 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
.envfile.
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_NEEDEDistrue, 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.
To continue your FAssets development journey, you can:
- Follow the preferred Cross-Chain Mint TypeScript + Viem guide
- Learn how to mint FXRP
- Understand how to redeem FXRP
- Explore auto-redemption from Hyperliquid
- Read more about Flare Smart Accounts