Cross-Chain Mint
In this guide, you will learn how to mint FXRP from native XRP on the XRPL and bridge it to Sepolia in a single end-to-end flow, using the Custom Instruction (0xFE).
The script combines two operations into one user journey, driven by a single XRPL payment:
- mint FXRP on Flare from a payment made on the XRPL by the user's personal account.
- bridge the minted FXRP to Sepolia using a LayerZero OFT, by calling the OFT Adapter directly from the personal account.
- wait for the
OFTReceivedevent on Sepolia to confirm that the FXRP has arrived.
The 0xFE memo commits only a keccak256 hash of the call batch and needs an off-chain executor to deliver the batch bytes and finalize the mint - see Custom Instruction for the full protocol.
Because the memo is a fixed 42 bytes regardless of batch size, the approve and bridge calls fit in a single XRPL payment, with no shim contract needed to keep calldata small.
The alternative - the Memo Field Custom Instruction (0xFF) - ships the whole call batch inline in the memo instead, trading the off-chain executor for a lower XRPL memo-size ceiling; see the Custom Instruction Comparison for when to pick which.
A prerequisite for the script to work is a funded personal account on Flare (the C2FLR is used to pay the LayerZero native fee) and a funded XRPL testnet wallet that will originate the XRPL payment. The State Lookup guide explains how to obtain the personal account address from the XRPL address. Once known, the personal account can be funded using the Flare faucet.
The full code showcased in this guide, cross-chain-mint.ts, is available in the flare-viem-starter repository under src/layer-zero/cross-chain-mint.ts.
The code in this guide is set up for the Coston2 testnet and the Sepolia testnet. Despite that, we will refer to the Flare-side network as Flare and its currency as FLR instead of Coston2 and C2FLR. Likewise, we will refer to the Sepolia testnet's currency as ETH instead of SETH.
Setup
The script uses three clients: a Flare public client, a Sepolia public client, and an XRPL client. The Flare client is the same one introduced in the State Lookup guide; a second Viem public client is added for Sepolia, plus a wallet client and a signing account for write operations.
import { createPublicClient, createWalletClient, http } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { flareTestnet, sepolia } from "viem/chains";
export const publicClient = createPublicClient({
chain: flareTestnet,
transport: http(),
});
export const account = privateKeyToAccount(
process.env.PRIVATE_KEY as `0x${string}`,
);
export const sepoliaPublicClient = createPublicClient({
chain: sepolia,
transport: http(process.env.SEPOLIA_RPC_URL),
});
The XRPL Client and Wallet are imported from the xrpl library and initialised from environment variables.
The recipient of the bridged FXRP on Sepolia is the main externally owned account (EOA) loaded from the wallet client, not the personal account on Flare - the personal account is only the intermediary that performs the mint and the bridge call.
const xrplClient = new Client(process.env.XRPL_TESTNET_RPC_URL!);
const xrplWallet = Wallet.fromSeed(process.env.XRPL_SEED!);
const recipient = account.address;
Reading state and fees
Before sending anything, the script reads the personal account address, the FXRP token address and decimals, and the gross XRP payment amount (net mint plus fees) in parallel:
const [personalAccount, fxrpAddress, fxrpDecimals, paymentAmountXrp] =
await Promise.all([
getPersonalAccountAddress(xrplWallet.address),
getFxrpAddress(),
getFxrpDecimals(),
computeDirectMintingPaymentAmountXrp({
netMintAmountXrp: fxrpMintAmountXrp,
}),
]);
The getPersonalAccountAddress function is covered in the State Lookup guide.
The script bridges through the FXRP OFT Adapter directly rather than a shim contract, since the 0xFE memo has no calldata-size pressure to work around.
It builds the LayerZero SendParam and quotes the native fee before encoding the call batch:
const amountToBridge = BigInt(xrpToDrops(fxrpMintAmountXrp));
const extraOptions = Options.newOptions()
.addExecutorLzReceiveOption(CONFIG.EXECUTOR_GAS, 0)
.toHex() as `0x${string}`;
const sendParam: SendParam = {
dstEid: CONFIG.SEPOLIA_EID,
to: pad(recipient, { size: 32 }),
amountLD: amountToBridge,
minAmountLD: amountToBridge,
extraOptions,
composeMsg: "0x",
oftCmd: "0x",
};
const messagingFee = await publicClient.readContract({
address: CONFIG.COSTON2_OFT_ADAPTER,
abi: fxrpOftAbi,
functionName: "quoteSend",
args: [sendParam, false],
});
const nativeFee = messagingFee.nativeFee;
Encoding the approve and bridge calls
The personal account will receive the freshly minted FXRP, so the batch approves the OFT Adapter for that amount and then calls send directly:
const customInstruction: Call[] = [
{
target: fxrpAddress,
value: 0n,
data: encodeFunctionData({
abi: erc20Abi,
functionName: "approve",
args: [CONFIG.COSTON2_OFT_ADAPTER, amountToBridge],
}),
},
{
target: CONFIG.COSTON2_OFT_ADAPTER,
value: nativeFee,
data: encodeFunctionData({
abi: fxrpOftAbi,
functionName: "send",
args: [sendParam, { nativeFee, lzTokenFee: 0n }, personalAccount],
}),
},
];
The sendParam.to value is the EOA recipient on Sepolia; the third argument to send is the refund address for any unused native fee.
That is set to personalAccount, not recipient - the caller of send (via executeUserOp) is the personal account, so a leftover refund lands back where it came from.
Because the 0xFE memo is a fixed 42 bytes regardless of batch size, both calls fit in a single XRPL payment - see Memo Layout.
Sending the XRPL payment
The rest of the flow is the 0xFE three-step protocol described in full in the Custom Instruction guide:
sendHashInstruction(user side) commits the batch's hash to a 42-byte memo and sends the XRPL payment that doubles as the direct-minting payment.executeDirectMintingWithData(executor side) fetches the FDC proof and finalizes the mint and the batch atomically.findUserOperationExecuted(confirmation) reads the result straight off the executor's transaction receipt.
// Sample the Sepolia block height before the bridge runs so we don't miss
// the OFTReceived event if the LayerZero delivery is unusually fast.
const startSepoliaBlock = await sepoliaPublicClient.getBlockNumber();
// --- 1. USER SIDE ---------------------------------------------------------
const userSide = await sendHashInstruction({
label: "mint-approve-and-bridge",
customInstruction,
amountXrp: paymentAmountXrp,
personalAccount,
xrplClient,
xrplWallet,
});
// --- 2. EXECUTOR SIDE ------------------------------------------------------
const { hash: executorTxHash, receipt } = await executeDirectMintingWithData({
xrplTransactionHash: userSide.xrplTransactionHash,
data: userSide.data,
value: userSide.totalCallValue,
xrplClient,
label: "mint-approve-and-bridge",
});
// --- 3. CONFIRMATION --------------------------------------------------------
const event = findUserOperationExecuted(
receipt,
personalAccount,
userSide.nonce,
);
The value parameter on executeDirectMintingWithData is the sum of call.value across the batch (here, just the LayerZero native fee) - it is forwarded AssetManagerFXRP -> MasterAccountController -> PersonalAccount.executeUserOp, so the inner send call can attach it.
The LayerZero send call runs inside that same Flare transaction, so executorTxHash doubles as the LayerZero scan link.
Waiting for arrival on Sepolia
Once the executor's transaction confirms and LayerZero has accepted the message, the script tracks the LayerZero scanner link and then polls Sepolia for the OFTReceived event on the FXRP OFT contract.
console.log(`https://testnet.layerzeroscan.com/tx/${executorTxHash}`);
const arrivalEvent = await waitForOftReceivedOnSepolia({
oftAddress: sepoliaOft,
toAddress: recipient,
fromBlock: startSepoliaBlock,
});
The waitForOftReceivedOnSepolia helper is defined in the same file as the main flow; it queries sepoliaPublicClient.getContractEvents every 10 seconds for up to 10 minutes, filtering on the recipient address.
Full Script
The repository with the above example is available on GitHub.
In the example repository, certain helpers are isolated into separate files in the src/utils directory.
pnpm run script src/layer-zero/cross-chain-mint.ts
import {
encodeFunctionData,
erc20Abi,
formatUnits,
pad,
type Address,
} from "viem";
import { EndpointId } from "@layerzerolabs/lz-definitions";
import { Client, Wallet, xrpToDrops } from "xrpl";
import { Options } from "@layerzerolabs/lz-v2-utilities";
import { account, publicClient, sepoliaPublicClient } from "./utils/client";
import {
executeDirectMintingWithData,
findUserOperationExecuted,
getPersonalAccountAddress,
sendHashInstruction,
type Call,
} from "./utils/smart-accounts";
import {
computeDirectMintingPaymentAmountXrp,
getFxrpDecimals,
} from "./utils/fassets";
import { getFxrpAddress } from "./utils/flare-contract-registry";
import { abi as fxrpOftAbi } from "./abis/FXRPOFT";
import type { SendParam } from "./types";
const CONFIG = {
COSTON2_OFT_ADAPTER: "0xCd3d2127935Ae82Af54Fc31cCD9D3440dbF46639" as Address,
SEPOLIA_FXRP_OFT: process.env.SEPOLIA_FXRP_OFT as Address | undefined,
SEPOLIA_EID: EndpointId.SEPOLIA_V2_TESTNET,
EXECUTOR_GAS: 200_000,
} as const;
const SEPOLIA_ARRIVAL_TIMEOUT_MS = 10 * 60 * 1000;
const SEPOLIA_ARRIVAL_POLL_INTERVAL_MS = 10_000;
async function waitForOftReceivedOnSepolia({
oftAddress,
toAddress,
fromBlock,
}: {
oftAddress: Address;
toAddress: Address;
fromBlock: bigint;
}) {
const deadline = Date.now() + SEPOLIA_ARRIVAL_TIMEOUT_MS;
while (Date.now() < deadline) {
const logs = await sepoliaPublicClient.getContractEvents({
address: oftAddress,
abi: fxrpOftAbi,
eventName: "OFTReceived",
args: { toAddress },
fromBlock,
strict: true,
});
if (logs.length > 0) {
return logs[0]!;
}
await new Promise((resolve) =>
setTimeout(resolve, SEPOLIA_ARRIVAL_POLL_INTERVAL_MS),
);
}
throw new Error(
`OFTReceived event not observed on Sepolia within ${SEPOLIA_ARRIVAL_TIMEOUT_MS}ms`,
);
}
// NOTE: For this example to work, you first need to faucet C2FLR to your
// personal account address.
// 0xFE is a three-step protocol; this script runs all three steps inline.
//
// The personal account drives the OFT Adapter directly - 0xFE's 42-byte memo
// removes the calldata-size constraint that the memo-field flow needs a shim
// to satisfy.
//
// The total call.value (the LayerZero nativeFee) is forwarded as msg.value in
// step 2, so it flows AssetManager -> MasterAccountController -> PersonalAccount
// -> OFT Adapter. Unused native fee is refunded by the adapter to the personal
// account (the refund address we pass to `send`).
async function main() {
const fxrpMintAmountXrp = 10;
if (!CONFIG.SEPOLIA_FXRP_OFT) {
throw new Error(
"SEPOLIA_FXRP_OFT env var is required (address of the FXRP OFT on Sepolia)",
);
}
const sepoliaOft = CONFIG.SEPOLIA_FXRP_OFT;
const xrplClient = new Client(process.env.XRPL_TESTNET_RPC_URL!);
const xrplWallet = Wallet.fromSeed(process.env.XRPL_SEED!);
const recipient = account.address;
const [personalAccount, fxrpAddress, fxrpDecimals, paymentAmountXrp] =
await Promise.all([
getPersonalAccountAddress(xrplWallet.address),
getFxrpAddress(),
getFxrpDecimals(),
computeDirectMintingPaymentAmountXrp({
netMintAmountXrp: fxrpMintAmountXrp,
}),
]);
const amountToBridge = BigInt(xrpToDrops(fxrpMintAmountXrp));
const extraOptions = Options.newOptions()
.addExecutorLzReceiveOption(CONFIG.EXECUTOR_GAS, 0)
.toHex() as `0x${string}`;
const sendParam: SendParam = {
dstEid: CONFIG.SEPOLIA_EID,
to: pad(recipient, { size: 32 }),
amountLD: amountToBridge,
minAmountLD: amountToBridge,
extraOptions,
composeMsg: "0x",
oftCmd: "0x",
};
const messagingFee = await publicClient.readContract({
address: CONFIG.COSTON2_OFT_ADAPTER,
abi: fxrpOftAbi,
functionName: "quoteSend",
args: [sendParam, false],
});
const nativeFee = messagingFee.nativeFee;
console.log("Personal account:", personalAccount);
console.log("FXRP token:", fxrpAddress);
console.log("OFT Adapter (Coston2):", CONFIG.COSTON2_OFT_ADAPTER);
console.log("\nCross-chain mint details:");
console.log("From (XRPL):", xrplWallet.address);
console.log("Via (Coston2 personal account):", personalAccount);
console.log("To (Sepolia):", recipient);
console.log(
"Net FXRP to mint & bridge:",
formatUnits(amountToBridge, fxrpDecimals),
"FXRP",
);
console.log("XRPL payment amount (mint + fees):", paymentAmountXrp, "XRP");
console.log("LayerZero native fee:", formatUnits(nativeFee, 18), "C2FLR");
const customInstruction: Call[] = [
{
target: fxrpAddress,
value: 0n,
data: encodeFunctionData({
abi: erc20Abi,
functionName: "approve",
args: [CONFIG.COSTON2_OFT_ADAPTER, amountToBridge],
}),
},
{
target: CONFIG.COSTON2_OFT_ADAPTER,
value: nativeFee,
data: encodeFunctionData({
abi: fxrpOftAbi,
functionName: "send",
args: [sendParam, { nativeFee, lzTokenFee: 0n }, personalAccount],
}),
},
];
// Sample the Sepolia block height before the bridge runs so we don't miss
// the OFTReceived event if the LayerZero delivery is unusually fast.
const startSepoliaBlock = await sepoliaPublicClient.getBlockNumber();
// --- 1. USER SIDE ---------------------------------------------------------
const userSide = await sendHashInstruction({
label: "mint-approve-and-bridge",
customInstruction,
amountXrp: paymentAmountXrp,
personalAccount,
xrplClient,
xrplWallet,
});
// --- 2. EXECUTOR SIDE ------------------------------------------------------
const { hash: executorTxHash, receipt } = await executeDirectMintingWithData({
xrplTransactionHash: userSide.xrplTransactionHash,
data: userSide.data,
value: userSide.totalCallValue,
xrplClient,
label: "mint-approve-and-bridge",
});
// --- 3. CONFIRMATION --------------------------------------------------------
const event = findUserOperationExecuted(
receipt,
personalAccount,
userSide.nonce,
);
console.log("UserOperationExecuted:", event, "\n");
console.log("\nTrack your cross-chain transaction:");
console.log(`https://testnet.layerzeroscan.com/tx/${executorTxHash}`);
console.log(
"\nWaiting for FXRP to arrive on Sepolia (this can take a few minutes)...",
);
const arrivalEvent = await waitForOftReceivedOnSepolia({
oftAddress: sepoliaOft,
toAddress: recipient,
fromBlock: startSepoliaBlock,
});
console.log("\nFXRP arrived on Sepolia:");
console.log(" Tx hash:", arrivalEvent.transactionHash);
console.log(
" Amount received:",
formatUnits(arrivalEvent.args.amountReceivedLD, fxrpDecimals),
"FXRP",
);
console.log(" Recipient:", arrivalEvent.args.toAddress);
}
void main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
Expected output
Personal account: 0xFd2f0eb6b9fA4FE5bb1F7B26fEE3c647ed103d9F
FXRP token: 0x0b6A3645c240605887a5532109323A3E12273dc7
OFT Adapter (Coston2): 0xCd3d2127935Ae82Af54Fc31cCD9D3440dbF46639
Cross-chain mint details:
From (XRPL): rPdLcCkSJzLvURM2vV3bCWwXBgT7FyJojU
Via (Coston2 personal account): 0xFd2f0eb6b9fA4FE5bb1F7B26fEE3c647ed103d9F
To (Sepolia): 0xF5488132432118596fa13800B68df4C0fF25131d
Net FXRP to mint & bridge: 10 FXRP
XRPL payment amount (mint + fees): 10.2 XRP
LayerZero native fee: 22.950824887834713257 C2FLR
[mint-approve-and-bridge] customInstruction: [
{
target: '0x0b6A3645c240605887a5532109323A3E12273dc7',
value: 0n,
data: '0x095ea7b3000000000000000000000000cd3d2127935ae82af54fc31ccd9d3440dbf466390000000000000000000000000000000000000000000000000000000000989680'
},
{
target: '0xCd3d2127935Ae82Af54Fc31cCD9D3440dbF46639',
value: 22950824887834713257n,
data: '0xc7c7f5b300000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000013e81b5a305951ca90000000000000000000000000000000000000000000000000000000000000000000000000000000000000000fd2f0eb6b9fa4fe5bb1f7b26fee3c647ed103d9f0000000000000000000000000000000000000000000000000000000000009ce1000000000000000000000000F5488132432118596fa13800B68df4C0fF25131d0000000000000000000000000000000000000000000000000000000000989680000000000000000000000000000000000000000000000000000000000098968000000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000000000000001600030100110100000000000000000000000000030d400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000'
}
]
[mint-approve-and-bridge] current nonce: 194n
[mint-approve-and-bridge] userOpHash: 0xd5e696a799759b79bca768fa41a6e568424766fde45f2de62b72fecdd5b80a07
[mint-approve-and-bridge] _data (1472 bytes): 0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000fd2f0eb6b9fa4fe5bb1f7b26fee3c647ed103d9f00000000000000000000000000000000000000000000000000000000000000c20000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005600000000000000000000000000000000000000000000000000000000000000580000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003e42b2ee78300000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000b6a3645c240605887a5532109323a3e12273dc7000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000044095ea7b3000000000000000000000000cd3d2127935ae82af54fc31ccd9d3440dbf46639000000000000000000000000000000000000000000000000000000000098968000000000000000000000000000000000000000000000000000000000000000000000000000000000cd3d2127935ae82af54fc31ccd9d3440dbf466390000000000000000000000000000000000000000000000013e81b5a305951ca9000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000001e4c7c7f5b300000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000013e81b5a305951ca90000000000000000000000000000000000000000000000000000000000000000000000000000000000000000fd2f0eb6b9fa4fe5bb1f7b26fee3c647ed103d9f0000000000000000000000000000000000000000000000000000000000009ce1000000000000000000000000F5488132432118596fa13800B68df4C0fF25131d0000000000000000000000000000000000000000000000000000000000989680000000000000000000000000000000000000000000000000000000000098968000000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000000000000001600030100110100000000000000000000000000030d40000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
[mint-approve-and-bridge] total call.value (native value to attach on executor tx): 22950824887834713257n
[mint-approve-and-bridge] XRPL transaction hash: 5EF35694B74846271B21BF697BAF658F3FBC1FB6E9401CBAE4650B1C8D134C3E
[mint-approve-and-bridge] Waiting for XRPL transaction to reach 3 confirmations
[mint-approve-and-bridge] XRPL finality reached: 3 confirmations (txLedger=20096460, validated=20096462)
[mint-approve-and-bridge] Preparing FDC XRPPayment attestation for txid 0x5ef35694b74846271b21bf697baf658f3fbc1fb6e9401cbae4650b1c8d134c3e (proofOwner=0xF5488132432118596fa13800B68df4C0fF25131d)
Url: https://fdc-verifiers-testnet.flare.network/verifier/xrp/XRPPayment/prepareRequest
Prepared request:
{
attestationType: '0x5852505061796d656e7400000000000000000000000000000000000000000000',
sourceId: '0x7465737458525000000000000000000000000000000000000000000000000000',
requestBody: {
transactionId: '0x5ef35694b74846271b21bf697baf658f3fbc1fb6e9401cbae4650b1c8d134c3e',
proofOwner: '0xF5488132432118596fa13800B68df4C0fF25131d'
}
}
Response status is OK
FDC attestation submitted. Round id: 1432039
View round progress in explorer: https://coston2-systems-explorer.flare.network/voting-round/1432039?tab=fdc
Waiting for FDC round to finalize...
Round finalized.
[mint-approve-and-bridge] FDC proof obtained (votingRound=1432039)
[mint-approve-and-bridge] Calling executeDirectMintingWithData on 0xc1Ca88b937d0b528842F95d5731ffB586f4fbDFA (value=22950824887834713257)
[mint-approve-and-bridge] executeDirectMintingWithData tx: 0x660816d1387a716824c9ba0ffeaf98d2f55ac43028754bff80e9c71f9e4cabbe
UserOperationExecuted: {
eventName: 'UserOperationExecuted',
args: {
personalAccount: '0xFd2f0eb6b9fA4FE5bb1F7B26fEE3c647ed103d9F',
nonce: 194n
},
address: '0x434936d47503353f06750db1a444dbdc5f0ad37c',
topics: [
'0xf1fb8f9b365735a54cdafe3a27ffbad0a0cf1f35454f0c4c0c4dc68591d484fe',
'0x000000000000000000000000fd2f0eb6b9fa4fe5bb1f7b26fee3c647ed103d9f'
],
data: '0x00000000000000000000000000000000000000000000000000000000000000c2',
blockNumber: 34318342n,
transactionHash: '0x660816d1387a716824c9ba0ffeaf98d2f55ac43028754bff80e9c71f9e4cabbe',
transactionIndex: 1,
blockHash: '0x808445358b047d509c1986b1a5a254d184d46bafedc754cb0eded38ca87df464',
logIndex: 19,
removed: false,
blockTimestamp: undefined
}
Track your cross-chain transaction:
https://testnet.layerzeroscan.com/tx/0x660816d1387a716824c9ba0ffeaf98d2f55ac43028754bff80e9c71f9e4cabbe
Waiting for FXRP to arrive on Sepolia (this can take a few minutes)...
FXRP arrived on Sepolia:
Tx hash: 0xe5a614e4518ede743b1db69f10f618a4d593edc140a90fa687de724fbdf3f705
Amount received: 10 FXRP
Recipient: 0xF5488132432118596fa13800B68df4C0fF25131d
What's next
- Auto Minting and Bridging FXRP - the FXRP-focused overview of this same flow.
- Cross-Chain Redeem guide
- Cross-Chain Redeem to Tag guide
- Minting overview