Cross-Chain Redeem to Tag
This guide is a variant of the Cross-Chain Redeem guide that redeems FXRP to an XRPL address with a destination tag. Read the redeem guide first — only the differences are covered here.
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.
What's different
Three things change relative to the tagless redeem:
- the compose message is a 6-field struct instead of a 3-tuple — it carries the destination tag and reserves space for an optional executor and executor fee.
- a new environment variable
REDEMPTION_DESTINATION_TAGcontrols the XRPL destination tag (defaulting to72). - the script waits for a
FAssetRedeemedevent on the composer instead of aRedemptionRequestedevent on theAssetManager.
Both scripts target the same composer contract (default 0xa10569DFb38FE7Be211aCe4E4A566Cea387023b0).
The rest of the flow — quoting the LayerZero fee, building the SendParam, checking the balance, sending the transaction, paying for the cross-chain message in ETH — is identical to the Cross-Chain Redeem guide.
The compose message
The destination composer expects a single ABI-encoded struct that mirrors the on-chain IFAssetRedeemComposer.RedeemComposeMessage type.
const redeemComposeMessageAbi = [
{
type: "tuple",
components: [
{ name: "redeemer", type: "address" },
{ name: "redeemerUnderlyingAddress", type: "string" },
{ name: "redeemWithTag", type: "bool" },
{ name: "destinationTag", type: "uint256" },
{ name: "executor", type: "address" },
{ name: "executorFee", type: "uint256" },
],
},
] as const;
The two executor-related fields support paying a third party to advance the redemption on the user's behalf.
In this script they are set to the zero address and zero amount, so the user themselves drives the flow.
function encodeComposeMessage(
redeemer: Address,
underlyingAddress: string,
destinationTag: bigint,
): `0x${string}` {
return encodeAbiParameters(redeemComposeMessageAbi, [
{
redeemer,
redeemerUnderlyingAddress: underlyingAddress,
redeemWithTag: true,
destinationTag,
executor: zeroAddress,
executorFee: 0n,
},
]);
}
The redeemWithTag argument is hardcoded to true — that is the whole point of this guide.
For tagless redemption against the same composer, see the Cross-Chain Redeem guide.
Before the script can use REDEMPTION_DESTINATION_TAG, the tag has to be reserved on the IMintingTagManager (via reserve()) and bound to the redeemer with setMintingRecipient(tag, recipient).
Without that registration the composer will refuse to process the redemption.
See the Redeem with Tag section of the Redemption guide for the registration flow.
Waiting for FAssetRedeemed
Because the composer is the contract that pulls the trigger on the redemption, it emits its own event that fires after the underlying AssetManager.redeem call succeeds.
The polling loop is the same shape as waitForRedemptionOnCoston2 in the tagless redeem (25-block chunks, 5-minute timeout) but reads from the composer's ABI and filters on the user as the redeemer.
async function waitForFAssetRedeemedOnCoston2(
redeemer: Address,
fromBlock: bigint,
) {
const deadline = Date.now() + REDEMPTION_TIMEOUT_MS;
let cursor = fromBlock;
while (Date.now() < deadline) {
const latest = await publicClient.getBlockNumber();
while (cursor <= latest) {
const chunkEnd = cursor + MAX_BLOCK_RANGE - 1n;
const toBlock = chunkEnd > latest ? latest : chunkEnd;
const logs = await publicClient.getContractEvents({
address: CONFIG.COSTON2_COMPOSER,
abi: coston2.ifAssetRedeemComposerAbi,
eventName: "FAssetRedeemed",
args: { redeemer },
fromBlock: cursor,
toBlock,
});
if (logs.length > 0) {
return logs[0]!;
}
cursor = toBlock + 1n;
}
await new Promise((resolve) =>
setTimeout(resolve, REDEMPTION_POLL_INTERVAL_MS),
);
}
throw new Error(
`FAssetRedeemed event not observed within ${REDEMPTION_TIMEOUT_MS}ms`,
);
}
The redeemer argument here is the Sepolia signer address — i.e. the user — which makes the event easy to filter even if multiple unrelated redemptions are in flight against the same composer.
All the other helpers used by this script (Sepolia client setup, calculateAmountToSend, the SendParam builder, the balance check, fee quote, and send path) are unchanged from the Cross-Chain Redeem guide.
Full script
The repository with the above example is available on GitHub.
import {
encodeAbiParameters,
formatUnits,
pad,
zeroAddress,
type Address,
} from "viem";
import { Options } from "@layerzerolabs/lz-v2-utilities";
import { EndpointId } from "@layerzerolabs/lz-definitions";
import { coston2 } from "@flarenetwork/flare-wagmi-periphery-package";
import {
account,
sepoliaPublicClient,
sepoliaWalletClient,
publicClient,
} from "./utils/client";
import { abi as fxrpOftAbi } from "./abis/FXRPOFT";
import { calculateAmountToSend } from "./utils/fassets";
import type { SendParam } from "./types";
const CONFIG = {
SEPOLIA_FXRP_OFT: process.env.SEPOLIA_FXRP_OFT as Address | undefined,
COSTON2_COMPOSER: (process.env.COSTON2_COMPOSER ??
"0xa10569DFb38FE7Be211aCe4E4A566Cea387023b0") as Address,
COSTON2_EID: EndpointId.FLARE_V2_TESTNET,
EXECUTOR_GAS: 1_000_000,
COMPOSE_GAS: 1_000_000,
SEND_LOTS: process.env.SEND_LOTS ?? "1",
XRP_ADDRESS: process.env.XRP_ADDRESS ?? "rpHuw4bKSjonKRrKKVYUZYYVedg1jyPrmp",
// XRPL destination tag pre-registered on the MintingTagManager and bound to a recipient.
// The tag must first be reserved (via `MintingTagManager.reserve()`) and tied to the
// redeemer using `setMintingRecipient(tag, recipient)` before this script can use it.
// See the "Redeem with Tag" section of the Redemption guide for the registration flow:
// https://dev.flare.network/fassets/redemption#redeem-with-tag
REDEMPTION_DESTINATION_TAG: BigInt(
process.env.REDEMPTION_DESTINATION_TAG ?? "72",
),
} as const;
const REDEMPTION_TIMEOUT_MS = 5 * 60 * 1000;
const REDEMPTION_POLL_INTERVAL_MS = 10_000;
// Coston2 RPC caps eth_getLogs to a 30-block range; chunk lookups to stay under it.
const MAX_BLOCK_RANGE = 25n;
// Mirrors `IFAssetRedeemComposer.RedeemComposeMessage`.
const redeemComposeMessageAbi = [
{
type: "tuple",
components: [
{ name: "redeemer", type: "address" },
{ name: "redeemerUnderlyingAddress", type: "string" },
{ name: "redeemWithTag", type: "bool" },
{ name: "destinationTag", type: "uint256" },
{ name: "executor", type: "address" },
{ name: "executorFee", type: "uint256" },
],
},
] as const;
function encodeComposeMessage(
redeemer: Address,
underlyingAddress: string,
destinationTag: bigint,
): `0x${string}` {
return encodeAbiParameters(redeemComposeMessageAbi, [
{
redeemer,
redeemerUnderlyingAddress: underlyingAddress,
redeemWithTag: true,
destinationTag,
executor: zeroAddress,
executorFee: 0n,
},
]);
}
function buildComposeOptions(): `0x${string}` {
return Options.newOptions()
.addExecutorLzReceiveOption(CONFIG.EXECUTOR_GAS, 0)
.addExecutorComposeOption(0, CONFIG.COMPOSE_GAS, 0)
.toHex() as `0x${string}`;
}
function buildSendParam(
amountToSend: bigint,
composeMsg: `0x${string}`,
extraOptions: `0x${string}`,
): SendParam {
return {
dstEid: CONFIG.COSTON2_EID,
to: pad(CONFIG.COSTON2_COMPOSER, { size: 32 }),
amountLD: amountToSend,
minAmountLD: amountToSend,
extraOptions,
composeMsg,
oftCmd: "0x",
};
}
async function checkBalance(
oftAddress: Address,
signerAddress: Address,
amountToSend: bigint,
decimals: number,
) {
const balance = await sepoliaPublicClient.readContract({
address: oftAddress,
abi: fxrpOftAbi,
functionName: "balanceOf",
args: [signerAddress],
});
console.log("\nCurrent FXRP balance:", formatUnits(balance, decimals));
if (balance < amountToSend) {
console.error("\nInsufficient FXRP balance!");
console.log(" Required:", formatUnits(amountToSend, decimals), "FXRP");
console.log(" Available:", formatUnits(balance, decimals), "FXRP");
throw new Error("Insufficient FXRP balance");
}
}
async function quoteFee(oftAddress: Address, sendParam: SendParam) {
const { nativeFee, lzTokenFee } = await sepoliaPublicClient.readContract({
address: oftAddress,
abi: fxrpOftAbi,
functionName: "quoteSend",
args: [sendParam, false],
});
console.log("\nLayerZero Fee:", formatUnits(nativeFee, 18), "ETH");
return { nativeFee, lzTokenFee };
}
async function executeSendAndRedeem(
oftAddress: Address,
sendParam: SendParam,
nativeFee: bigint,
lzTokenFee: bigint,
signerAddress: Address,
underlyingAddress: string,
destinationTag: bigint,
) {
const { request } = await sepoliaPublicClient.simulateContract({
account,
address: oftAddress,
abi: fxrpOftAbi,
functionName: "send",
args: [sendParam, { nativeFee, lzTokenFee }, signerAddress],
value: nativeFee,
});
const txHash = await sepoliaWalletClient.writeContract(request);
const receipt = await sepoliaPublicClient.waitForTransactionReceipt({
hash: txHash,
});
console.log("\nTransaction sent:", txHash);
console.log("Confirmed in block:", receipt.blockNumber);
console.log("\nTrack your cross-chain transaction:");
console.log(`https://testnet.layerzeroscan.com/tx/${txHash}`);
console.log(
"\nThe auto-redeem-with-tag will execute once the message arrives on Coston2.",
);
console.log("XRP will be sent to:", underlyingAddress);
console.log("Destination tag:", destinationTag.toString());
return txHash;
}
async function waitForFAssetRedeemedOnCoston2(
redeemer: Address,
fromBlock: bigint,
) {
const deadline = Date.now() + REDEMPTION_TIMEOUT_MS;
console.log("\nWaiting for FAssetRedeemed event on Coston2 composer...");
let cursor = fromBlock;
while (Date.now() < deadline) {
const latest = await publicClient.getBlockNumber();
while (cursor <= latest) {
const chunkEnd = cursor + MAX_BLOCK_RANGE - 1n;
const toBlock = chunkEnd > latest ? latest : chunkEnd;
const logs = await publicClient.getContractEvents({
address: CONFIG.COSTON2_COMPOSER,
abi: coston2.ifAssetRedeemComposerAbi,
eventName: "FAssetRedeemed",
args: { redeemer },
fromBlock: cursor,
toBlock,
});
if (logs.length > 0) {
return logs[0]!;
}
cursor = toBlock + 1n;
}
await new Promise((resolve) =>
setTimeout(resolve, REDEMPTION_POLL_INTERVAL_MS),
);
}
throw new Error(
`FAssetRedeemed event not observed within ${REDEMPTION_TIMEOUT_MS}ms`,
);
}
async function main() {
if (!CONFIG.SEPOLIA_FXRP_OFT) {
throw new Error(
"SEPOLIA_FXRP_OFT env var is required (address of the FXRP OFT on Sepolia)",
);
}
const oftAddress = CONFIG.SEPOLIA_FXRP_OFT;
const signerAddress = account.address;
console.log("Using account:", signerAddress);
console.log("Composer configured:", CONFIG.COSTON2_COMPOSER);
console.log("Connecting to FXRP OFT on Sepolia:", oftAddress);
const [decimals, amountToSend, startBlock] = await Promise.all([
sepoliaPublicClient.readContract({
address: oftAddress,
abi: fxrpOftAbi,
functionName: "decimals",
}),
calculateAmountToSend(BigInt(CONFIG.SEND_LOTS)),
publicClient.getBlockNumber(),
]);
console.log("Token decimals:", decimals);
console.log("\nRedemption Parameters:");
console.log("Amount:", amountToSend.toString(), "FXRP base units");
console.log("XRP Address:", CONFIG.XRP_ADDRESS);
console.log("Redeemer:", signerAddress);
console.log("Destination tag:", CONFIG.REDEMPTION_DESTINATION_TAG.toString());
const composeMsg = encodeComposeMessage(
signerAddress,
CONFIG.XRP_ADDRESS,
CONFIG.REDEMPTION_DESTINATION_TAG,
);
const extraOptions = buildComposeOptions();
const sendParam = buildSendParam(amountToSend, composeMsg, extraOptions);
await checkBalance(oftAddress, signerAddress, amountToSend, decimals);
const { nativeFee, lzTokenFee } = await quoteFee(oftAddress, sendParam);
console.log(
"\nSending",
formatUnits(amountToSend, decimals),
"FXRP to Coston2 with auto-redeem-with-tag...",
);
console.log("Target composer:", CONFIG.COSTON2_COMPOSER);
await executeSendAndRedeem(
oftAddress,
sendParam,
nativeFee,
lzTokenFee,
signerAddress,
CONFIG.XRP_ADDRESS,
CONFIG.REDEMPTION_DESTINATION_TAG,
);
const redemptionEvent = await waitForFAssetRedeemedOnCoston2(
signerAddress,
startBlock,
);
console.log("\nFAssetRedeemed event observed on Coston2:");
console.log(redemptionEvent);
}
void main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
Expected output
Using account: 0xF5488132432118596fa13800B68df4C0fF25131d
Composer configured: 0xa10569DFb38FE7Be211aCe4E4A566Cea387023b0
Connecting to FXRP OFT on Sepolia: 0x81672c5d42F3573aD95A0bdfBE824FaaC547d4E6
Token decimals: 6
Redemption Parameters:
Amount: 10000000 FXRP base units
XRP Address: rpHuw4bKSjonKRrKKVYUZYYVedg1jyPrmp
Redeemer: 0xF5488132432118596fa13800B68df4C0fF25131d
Destination tag: 72
Current FXRP balance: 70
LayerZero Fee: 0.000101716112596574 ETH
Sending 10 FXRP to Coston2 with auto-redeem-with-tag...
Target composer: 0xa10569DFb38FE7Be211aCe4E4A566Cea387023b0
Transaction sent: 0xca0e2558b4397beef13c8b7b0acf6e9ecf42b22ea93aeec7e837656d7f23674c
Confirmed in block: 10838169n
Track your cross-chain transaction:
https://testnet.layerzeroscan.com/tx/0xca0e2558b4397beef13c8b7b0acf6e9ecf42b22ea93aeec7e837656d7f23674c
The auto-redeem-with-tag will execute once the message arrives on Coston2.
XRP will be sent to: rpHuw4bKSjonKRrKKVYUZYYVedg1jyPrmp
Destination tag: 72
Waiting for FAssetRedeemed event on Coston2 composer...
FAssetRedeemed event observed on Coston2:
{
eventName: 'FAssetRedeemed',
args: {
guid: '0xaebbeadcb6b78b8ab0c541cefec4a5b9d037d4f069a7f333fb8b16f9b6439cb3',
srcEid: 40161,
redeemer: '0xF5488132432118596fa13800B68df4C0fF25131d',
redeemerAccount: '0xEB947e97BD6b02C4121D2AdBAa1e07A4cA3D8D1E',
amountToRedeemUBA: 9990000n,
redeemerUnderlyingAddress: 'rpHuw4bKSjonKRrKKVYUZYYVedg1jyPrmp',
redeemWithTag: true,
destinationTag: 72n,
executor: '0x103b384064ae85577127097A7cCadfd6fb13f437',
executorFee: 0n,
redeemedAmountUBA: 9990000n,
wrappedAmount: 0n
},
address: '0xa10569dfb38fe7be211ace4e4a566cea387023b0',
topics: [
'0x9e8ed1306ec6ca165a2c23ae451b161741d3da7ab41fe134775ebd94b2351f04',
'0xaebbeadcb6b78b8ab0c541cefec4a5b9d037d4f069a7f333fb8b16f9b6439cb3',
'0x0000000000000000000000000000000000000000000000000000000000009ce1',
'0x000000000000000000000000f5488132432118596fa13800b68df4c0ff25131d'
],
data: '0x000000000000000000000000eb947e97bd6b02c4121d2adbaa1e07a4ca3d8d1e0000000000000000000000000000000000000000000000000000000000986f70000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000048000000000000000000000000103b384064ae85577127097a7ccadfd6fb13f43700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000986f7000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000022727048757734624b536a6f6e4b52724b4b5659555a595956656467316a7950726d70000000000000000000000000000000000000000000000000000000000000',
blockNumber: 30438770n,
transactionHash: '0xc1d39bc7383e856a92f9a3a60f1b33f89926a21c68910f29a3566c1a0f557679',
transactionIndex: 9,
blockHash: '0x2071fdbb5d07266f380a40c819818fff507f7142340961681af5a8ec796e6b09',
logIndex: 18,
removed: false,
blockTimestamp: undefined
}