FAsset Auto-Redemption
Overview
In this guide, you will learn how to bridge FAssets (specifically FXRP) between Flare Testnet Coston2 and Hyperliquid EVM Testnet using LayerZero's cross-chain messaging protocol, with support for automatic redemption - converting FXRP back to native XRP on the XRP Ledger in a single transaction.
This guide covers two key functionalities:
- Bridging FXRP from Flare Testnet Coston2 to Hyperliquid EVM (
bridgeToHyperEVM.ts) - Transfer wrapped XRP tokens to Hyperliquid for trading or DeFi use. - Auto-Redeem from Hyperliquid to Underlying Asset (
autoRedeemHyperEVM.ts) - Send FXRP from Hyperliquid back to Flare Testnet Coston2 and automatically redeem it for native XRP.
Key technologies:
- LayerZero OFT (Omnichain Fungible Token) for cross-chain token transfers. OFT works by burning tokens on the source chain and minting equivalent tokens on the destination chain, enabling seamless movement of assets across different blockchains.
- Flare's FAsset system for tokenizing non-smart contract assets.
- LayerZero Composer pattern for executing custom logic (like FAsset redemption) automatically when tokens arrive on the destination chain.
This guide includes three main components: a Solidity smart contract (FAssetRedeemComposer.sol) and two TypeScript scripts (bridgeToHyperEVM.ts and autoRedeemHyperEVM.ts) that demonstrate the complete workflow.
Clone the Flare Hardhat Starter to follow along.
Getting Started: The Two-Step Process
Step 1: Bridge FXRP to Hyperliquid EVM (Required First)
Run bridgeToHyperEVM.ts on Flare Testnet Coston2 network to transfer your FXRP tokens from Coston2 to Hyperliquid EVM Testnet.
This script does NOT involve auto-redemption - it simply moves your tokens to Hyperliquid where you can use them to prepare for the auto-redemption process.
You need FXRP tokens on Hyperliquid EVM before you can test the auto-redeem functionality. The auto-redeem script will fail if you don't have tokens there.
# Step 1: Bridge tokens TO Hyperliquid
yarn hardhat run scripts/fassets/bridgeToHyperEVM.ts --network coston2
Step 2: Auto-Redeem to Native XRP (Requires Step 1)
Once you have FXRP tokens on Hyperliquid (from Step 1), run autoRedeemHyperEVM.ts on Hyperliquid Testnet network to send them back to Flare Testnet Coston2 with automatic redemption to native XRP.
This is the auto-redemption feature that converts FXRP back to native XRP on the XRP Ledger in a single transaction.
# Step 2: Auto-redeem back to native XRP
yarn hardhat run scripts/fassets/autoRedeemHyperEVM.ts --network hyperliquidTestnet
FAssetRedeemComposer Contract
What It Is
FAssetRedeemComposer.sol is a LayerZero Composer contract that automatically redeems FAssets to their underlying assets when tokens arrive on Flare Testnet Coston2 via LayerZero's compose message feature.
How It Works
The composer implements the IOAppComposer interface and processes LayerZero compose messages:
- Receives Compose Message: LayerZero endpoint calls lzCompose() with the incoming OFT transfer and compose data.
- Extracts Parameters: Decodes the compose message to get:
- Amount to redeem (in lots).
- Underlying XRP address on the XRP Ledger.
- Redeemer EVM address on Flare Chain.
- Gets Asset Manager: Retrieves the FAsset AssetManager from Flare's ContractRegistry.
- Calculates Lots: Determines how many lots can be redeemed based on received balance.
- Approves Tokens: Grants AssetManager permission to spend the FAsset tokens (which is in the ERC-20 standard).
- Executes Redemption: Calls
assetManager.redeem()to burn FAssets and release underlying XRP.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;
import {IOAppComposer} from "@layerzerolabs/oapp-evm/contracts/oapp/interfaces/IOAppComposer.sol";
import {OFTComposeMsgCodec} from "@layerzerolabs/oft-evm/contracts/libs/OFTComposeMsgCodec.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {IAssetManager} from "@flarenetwork/flare-periphery-contracts/coston2/IAssetManager.sol";
import {ContractRegistry} from "@flarenetwork/flare-periphery-contracts/coston2/ContractRegistry.sol";
contract FAssetRedeemComposer is IOAppComposer, Ownable, ReentrancyGuard {
using SafeERC20 for IERC20;
address public immutable endpoint;
event RedemptionTriggered(
address indexed redeemer,
string underlyingAddress,
uint256 indexed amountRedeemed,
uint256 indexed lots
);
error OnlyEndpoint();
error InsufficientBalance();
error AmountTooSmall();
constructor(address _endpoint) Ownable(msg.sender) {
endpoint = _endpoint;
}
receive() external payable {}
function lzCompose(
address /* _from */,
bytes32 /* _guid */,
bytes calldata _message,
address /* _executor */,
bytes calldata /* _extraData */
) external payable override nonReentrant {
if (msg.sender != endpoint) revert OnlyEndpoint();
bytes memory composeMsg = OFTComposeMsgCodec.composeMsg(_message);
_processRedemption(composeMsg);
}
function _processRedemption(bytes memory composeMsg) internal {
// 1. Decode message
(, string memory underlyingAddress, address redeemer) = abi.decode(
composeMsg,
(uint256, string, address)
);
// 2. Get Asset Manager & fXRP Token from Registry
IAssetManager assetManager = ContractRegistry.getAssetManagerFXRP();
IERC20 fAssetToken = IERC20(address(assetManager.fAsset()));
// 3. Check Actual Balance received from LayerZero
uint256 currentBalance = fAssetToken.balanceOf(address(this));
if (currentBalance == 0) revert InsufficientBalance();
// 4. Calculate Lots
uint256 lotSizeUBA = assetManager.getSettings().lotSizeAMG;
uint256 lots = currentBalance / lotSizeUBA;
if (lots == 0) revert AmountTooSmall();
// 5. Calculate amount to burn
uint256 amountToRedeem = lots * lotSizeUBA;
// 6. Approve AssetManager to spend the tokens
fAssetToken.forceApprove(address(assetManager), amountToRedeem);
// 7. Redeem
uint256 redeemedAmount = assetManager.redeem(
lots,
underlyingAddress,
payable(address(0))
);
emit RedemptionTriggered(
redeemer,
underlyingAddress,
redeemedAmount,
lots
);
}
function recoverTokens(
address token,
address to,
uint256 amount
) external onlyOwner {
IERC20(token).safeTransfer(to, amount);
}
function recoverNative() external onlyOwner {
payable(owner()).transfer(address(this).balance);
}
}
Code Breakdown
The _processRedemption function contains the core redemption logic with numbered steps in the comments:
- Decode Message: Extracts the underlying XRP address and redeemer EVM address from the compose message.
- Get Asset Manager & fXRP Token: Retrieves the FAsset AssetManager.
- Check Balance: Verifies that tokens were received from LayerZero. Reverts with
InsufficientBalance()if balance is zero. - Calculate Lots: Determines how many complete lots can be redeemed based on the received balance and the lot size from AssetManager settings.
- Calculate Amount: Computes the exact amount to redeem (must be a multiple of lot size). Reverts with
AmountTooSmall()if less than 1 lot. - Approve AssetManager: Grants the AssetManager permission to spend the FAsset tokens using the ERC-20
approvepattern. - Redeem: Calls
assetManager.redeem()to burn FAssets and trigger the release of underlying XRP to the specified address.
The contract also includes recoverTokens() and recoverNative() helper functions that allow the owner to recover any stuck tokens or native currency if needed.
Bridge FXRP to Hyperliquid EVM (Step 1)
What It Is
This is Step 1 of the two-step process and is a prerequisite for testing the auto-redemption feature.
This script bridges FXRP tokens from Flare Testnet Coston2 to Hyperliquid EVM Testnet using LayerZero's OFT Adapter pattern. It wraps existing ERC20 FAsset tokens into LayerZero OFT format for cross-chain transfer.
This script does NOT perform auto-redemption.
Its purpose is to get your FXRP tokens onto Hyperliquid EVM Testnet so that:
- You can use them for trading or DeFi on Hyperliquid, OR
- You can run the auto-redeem script (Step 2) to convert them back to native XRP
You must successfully complete this step before running the autoRedeemHyperEVM.ts script.
How It Works
Step-by-Step Process
- Balance Check: Verifies user has sufficient FTestXRP tokens.
- Token Approval:
- Approves OFT Adapter to spend FTestXRP (2x amount for safety).
- Approves Composer (if needed for future operations).
- Build Send Parameters:
- Destination: Hyperliquid EVM Testnet (EndpointID for Hyperliquid Testnet:
40294). - Recipient: Same address on destination chain.
- Amount: Calculated from configured lots (default 1 lot, with 10% buffer).
- LayerZero options: Executor gas limit set to 200,000.
- Destination: Hyperliquid EVM Testnet (EndpointID for Hyperliquid Testnet:
- Quote Fee: Calculates the LayerZero cross-chain messaging fee.
- Execute Bridge: Sends tokens via
oftAdapter.send(). - Confirmation: Waits for transaction confirmation and provides tracking link to the LayerZero Explorer page.
Prerequisites
- Balance Requirements:
- FTestXRP tokens (amount you want to bridge).
- C2FLR tokens (for gas fees + LayerZero fees). You can get some from the Flare Testnet faucet.
- Environment Setup:
- Private key configured in Hardhat for Flare Testnet Coston2 and Hyperliquid Testnet.
Configuration
Edit the CONFIG object in the script:
const CONFIG = {
COSTON2_OFT_ADAPTER: "0xCd3d2127935Ae82Af54Fc31cCD9D3440dbF46639",
COSTON2_COMPOSER: process.env.COSTON2_COMPOSER || "",
HYPERLIQUID_EID: EndpointId.HYPERLIQUID_V2_TESTNET,
EXECUTOR_GAS: 200_000,
BRIDGE_LOTS: "1", // Change this to your desired number of lots
};
How to Run
Run this script FIRST before attempting auto-redemption. This gets your FXRP tokens onto Hyperliquid EVM.
-
Install Dependencies:
yarn install -
Configure Environment:
# .env file
COSTON2_RPC_URL=https://coston2-api.flare.network/ext/C/rpc
DEPLOYER_PRIVATE_KEY=your_private_key_here
COSTON2_COMPOSER=0x... # Optional for this step, required for Step 2 -
Run the Script on Flare Testnet Coston2:
yarn hardhat run scripts/fassets/bridgeToHyperEVM.ts --network coston2 -
Wait for Completion:
- Monitor the transaction on LayerZero Scan (link provided in output)
- Allow 2-5 minutes for cross-chain delivery
- Verify FXRP balance increased on Hyperliquid EVM before proceeding to Step 2
Expected Output
Using account: 0x742d35Cc6634C0532925a3b844Bc454e4438f44e
📋 Bridge Details:
From: Coston2
To: Hyperliquid EVM Testnet
Amount: 11.0 FXRP
Recipient: 0x742d35Cc6634C0532925a3b844Bc454e4438f44e
Your FTestXRP balance: 100.0
1️⃣ Checking OFT Adapter token address...
OFT Adapter's inner token: 0x8b4abA9C4BD7DD961659b02129beE20c6286e17F
Expected token: 0x8b4abA9C4BD7DD961659b02129beE20c6286e17F
Match: true
Approving FTestXRP for OFT Adapter...
OFT Adapter address: 0xCd3d2127935Ae82Af54Fc31cCD9D3440dbF46639
Amount: 11.0 FXRP
✅ OFT Adapter approved
Verified allowance: 22.0 FXRP
3️⃣ LayerZero Fee: 0.001234 C2FLR
4️⃣ Sending FXRP to Hyperliquid EVM Testnet...
Transaction sent: 0xabc123...
✅ Confirmed in block: 12345678
🎉 Success! Your FXRP is on the way to Hyperliquid EVM Testnet!
Track your transaction:
https://testnet.layerzeroscan.com/tx/0xabc123...
It may take a few minutes to arrive on Hyperliquid EVM Testnet.
View bridgeToHyperEVM.ts source code
/**
* Bridge FXRP from Coston2 to Hyperliquid EVM Testnet
*
* This script helps you get FXRP on Hyperliquid EVM Testnet by bridging from Coston2
*
* Prerequisites:
* - FTestXRP tokens on Coston2
* - CFLR on Coston2 for gas
*
* Usage:
* yarn hardhat run scripts/fassets/bridgeToHyperEVM.ts --network coston2
*/
import { web3 } from "hardhat";
import { formatUnits } from "ethers";
import { EndpointId } from "@layerzerolabs/lz-definitions";
import { Options } from "@layerzerolabs/lz-v2-utilities";
import type {
IERC20Instance,
FAssetOFTAdapterInstance,
} from "../../typechain-types";
import { getAssetManagerFXRP } from "../utils/getters";
// Get the contracts
const IERC20 = artifacts.require("IERC20");
const FAssetOFTAdapter = artifacts.require("FAssetOFTAdapter");
const CONFIG = {
COSTON2_FTESTXRP: "0x8b4abA9C4BD7DD961659b02129beE20c6286e17F",
COSTON2_OFT_ADAPTER: "0xCd3d2127935Ae82Af54Fc31cCD9D3440dbF46639",
COSTON2_COMPOSER: process.env.COSTON2_COMPOSER || "",
HYPERLIQUID_EID: EndpointId.HYPERLIQUID_V2_TESTNET, // Hyperliquid testnet EID
EXECUTOR_GAS: 200_000,
BRIDGE_LOTS: "1",
} as const;
type BridgeParams = {
amountToBridge: bigint;
recipientAddress: string;
signerAddress: string;
};
type SendParams = {
dstEid: EndpointId;
to: string;
amountLD: string;
minAmountLD: string;
extraOptions: string;
composeMsg: string;
oftCmd: string;
};
async function calculateAmountToBridge(lots: bigint) {
const assetManager = await getAssetManagerFXRP();
const lotSize = await assetManager.lotSize();
const amountToBridge = lotSize * lots;
return amountToBridge * BigInt(1.1); // 10% buffer
}
/**
* Gets the signer and displays account information
*/
async function getSigner() {
const accounts = await web3.eth.getAccounts();
const signerAddress = accounts[0];
console.log("Using account:", signerAddress);
console.log("Token address:", CONFIG.COSTON2_FTESTXRP);
return signerAddress;
}
/**
* Prepares bridge parameters
*/
async function prepareBridgeParams(
signerAddress: string,
): Promise<BridgeParams> {
const amountToBridge = await calculateAmountToBridge(
BigInt(CONFIG.BRIDGE_LOTS),
);
const recipientAddress = signerAddress;
console.log("\n📋 Bridge Details:");
console.log("From: Coston2");
console.log("To: Hyperliquid EVM Testnet");
console.log("Amount:", formatUnits(amountToBridge.toString(), 6), "FXRP");
console.log("Recipient:", recipientAddress);
return { amountToBridge, recipientAddress, signerAddress };
}
/**
* Checks if user has sufficient balance to bridge
*/
async function checkBalance(params: BridgeParams): Promise<IERC20Instance> {
const fTestXRP: IERC20Instance = await IERC20.at(CONFIG.COSTON2_FTESTXRP);
const balance = await fTestXRP.balanceOf(params.signerAddress);
console.log("\nYour FTestXRP balance:", formatUnits(balance.toString(), 6));
if (BigInt(balance.toString()) > params.amountToBridge) {
console.error("\n❌ Insufficient FTestXRP balance!");
console.log(" Token address: " + CONFIG.COSTON2_FTESTXRP);
throw new Error("Insufficient balance");
}
return fTestXRP;
}
/**
* Approves OFT Adapter AND Composer to spend FTestXRP
*/
async function approveTokens(
fTestXRP: IERC20Instance,
amountToBridge: bigint,
signerAddress: string,
): Promise<FAssetOFTAdapterInstance> {
const oftAdapter: FAssetOFTAdapterInstance = await FAssetOFTAdapter.at(
CONFIG.COSTON2_OFT_ADAPTER,
);
console.log("\n1️⃣ Checking OFT Adapter token address...");
const innerToken = await oftAdapter.token();
console.log(" OFT Adapter's inner token:", innerToken);
console.log(" Expected token:", CONFIG.COSTON2_FTESTXRP);
console.log(
" Match:",
innerToken.toLowerCase() === CONFIG.COSTON2_FTESTXRP.toLowerCase(),
);
console.log("\n Approving FTestXRP for OFT Adapter...");
console.log(" OFT Adapter address:", oftAdapter.address);
console.log(" Amount:", formatUnits(amountToBridge.toString(), 6), "FXRP");
// Approve a much larger amount to account for any potential fees
const largeAmount = amountToBridge * BigInt(2);
await fTestXRP.approve(oftAdapter.address, largeAmount.toString());
console.log("✅ OFT Adapter approved");
// Verify the allowance
const oftAdapterAllowance = await fTestXRP.allowance(
signerAddress,
oftAdapter.address,
);
console.log(
" Verified allowance:",
formatUnits(oftAdapterAllowance.toString(), 6),
"FXRP",
);
console.log("\n2️⃣ Approving FTestXRP for Composer...");
console.log(" Composer address:", CONFIG.COSTON2_COMPOSER);
await fTestXRP.approve(CONFIG.COSTON2_COMPOSER, amountToBridge.toString());
console.log("✅ Composer approved");
// Verify the allowance
const composerAllowance = await fTestXRP.allowance(
signerAddress,
CONFIG.COSTON2_COMPOSER,
);
console.log(
" Verified allowance:",
formatUnits(composerAllowance.toString(), 6),
"FXRP",
);
return oftAdapter;
}
/**
* Builds LayerZero send parameters
*/
function buildSendParams(params: BridgeParams): SendParams {
// See https://docs.layerzero.network/v2/tools/sdks/options#generating-options
const options = Options.newOptions().addExecutorLzReceiveOption(
CONFIG.EXECUTOR_GAS,
0,
);
// Review send parameters here: https://docs.layerzero.network/v2/developers/evm/oft/oft-patterns-extensions#:~:text=Sending%20Token%E2%80%8B,composeMsg%20in%20bytes.
return {
dstEid: CONFIG.HYPERLIQUID_EID as EndpointId,
to: web3.utils.padLeft(params.recipientAddress, 64), // 32 bytes = 64 hex chars
amountLD: params.amountToBridge.toString(),
minAmountLD: params.amountToBridge.toString(),
extraOptions: options.toHex(),
composeMsg: "0x",
oftCmd: "0x",
};
}
/**
* Quotes the LayerZero fee for the bridge transaction
*/
async function quoteFee(
oftAdapter: FAssetOFTAdapterInstance,
sendParam: SendParams,
): Promise<BN> {
const result = await oftAdapter.quoteSend(sendParam, false);
const nativeFee = web3.utils.toBN(result.nativeFee.toString());
console.log(
"\n3️⃣ LayerZero Fee:",
formatUnits(nativeFee.toString(), 18),
"C2FLR",
);
return nativeFee;
}
/**
* Executes the bridge transaction
*/
async function executeBridge(
oftAdapter: FAssetOFTAdapterInstance,
sendParam: SendParams,
nativeFee: BN,
signerAddress: string,
): Promise<void> {
console.log("\n4️⃣ Sending FXRP to Hyperliquid EVM Testnet...");
const tx = await oftAdapter.send(
sendParam,
{ nativeFee: nativeFee.toString(), lzTokenFee: "0" },
signerAddress,
{
value: nativeFee.toString(),
},
);
console.log("Transaction sent:", tx.tx);
console.log("✅ Confirmed in block:", tx.receipt.blockNumber);
console.log(
"\n🎉 Success! Your FXRP is on the way to Hyperliquid EVM Testnet!",
);
console.log("\nTrack your transaction:");
console.log(`https://testnet.layerzeroscan.com/tx/${tx.tx}`);
console.log(
"\nIt may take a few minutes to arrive on Hyperliquid EVM Testnet.",
);
}
async function main() {
// 1. Get signer and display account info
const signerAddress = await getSigner();
// 2. Prepare bridge parameters
const params = await prepareBridgeParams(signerAddress);
// 3. Check balance and get token contract
const fTestXRP = await checkBalance(params);
// 4. Approve tokens and get OFT adapter
const oftAdapter = await approveTokens(
fTestXRP,
params.amountToBridge,
signerAddress,
);
// 5. Build send parameters
const sendParam = buildSendParams(params);
// 6. Quote the fee
const nativeFee = await quoteFee(oftAdapter, sendParam);
// 7. Execute the bridge transaction
await executeBridge(oftAdapter, sendParam, nativeFee, signerAddress);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Troubleshooting
Error: Insufficient FTestXRP balance
- Solution: Acquire FTestXRP tokens on Flare Testnet Coston2 faucet or follow our Fasset minting guide.
Error: Insufficient C2FLR for gas
- Solution: Get C2FLR from Flare Testnet Coston2 faucet.
Error: Transaction reverted
- Check that the OFT Adapter address matches the FTestXRP token.
- Verify LayerZero endpoint is operational.
- Ensure gas limits are sufficient.
Auto-Redeem from Hyperliquid EVM (Step 2)
What It Is
This is Step 2 of the two-step process and requires you to have FXRP tokens on Hyperliquid EVM first (from Step 1).
This script sends FXRP from Hyperliquid EVM Testnet back to Flare Testnet Coston2 with automatic redemption to native XRP on the XRP Ledger.
It uses LayerZero's compose feature to trigger the FAssetRedeemComposer contract upon arrival.
Prerequisites:
- You must have FXRP OFT tokens on Hyperliquid EVM Testnet.
- If you don't have FXRP on Hyperliquid, run
bridgeToHyperEVM.tsfirst (Step 1). - The FAssetRedeemComposer contract must be deployed on Flare Testnet Coston2.
How It Works
Flow Diagram
┌──────────────────┐
│ Developer │
│ (Hyperliquid EVM)│
└────────┬─────────┘
│
│ 1. Has FXRP OFT
▼
┌─────────────────────────┐
│ FXRP OFT (Hyperliquid) │
│ Balance: 10 FXRP │
└────────┬────────────────┘
│
│ 2. Send with Compose Message
│ - Destination: Coston2 Composer
│ - Compose Data: (amount, xrpAddress, redeemer)
▼
┌─────────────────────────┐
│ LayerZero Endpoint │
│ - lzSend() │
│ - Compose enabled │
└────────┬────────────────┘
│
│ 3. Cross-chain message
▼
┌─────────────────────────┐
│ Coston2 Endpoint │
│ - lzReceive() │
│ - lzCompose() │
└────────┬────────────────┘
│
│ 4. Calls Composer
▼
┌─────────────────────────┐
│ FAssetRedeemComposer │
│ - Receives FXRP │
│ - Calculates lots │
│ - Calls AssetManager │
└────────┬────────────────┘
│
│ 5. Redemption
▼
┌─────────────────────────┐
│ FAsset AssetManager │
│ - Burns FXRP │
│ - Releases XRP │
└────────┬────────────────┘
│
│ 6. XRP sent to address
▼
┌─────────────────────────┐
│ XRP Ledger Address │
│ rpHuw4b... │
└─────────────────────────┘
Step-by-Step Process
- Validate Setup:
- Checks that
COSTON2_COMPOSERis configured. - Gets the signer account.
- Checks that
- Prepare Redemption Parameters:
- Number of lots to send (default: 1 lot, amount calculated from lot size).
- XRP address to receive native XRP.
- Redeemer address (EVM address).
- Connect to FXRP OFT: Gets the OFT contract on Hyperliquid.
- Encode Compose Message:
- Encodes
(uint256 amount, string xrpAddress, address redeemer). - This tells the composer what to do when tokens arrive.
- Encodes
- Build LayerZero Options:
- Executor gas for
lzReceive(): 400,000 - Compose gas for
lzCompose(): 700,000
- Executor gas for
- Build Send Parameters:
- Destination: Coston2 (Endpoint ID:
40296). - Recipient: FAssetRedeemComposer contract.
- Amount: Calculated from configured lots (default 1 lot).
- Compose message included
- Destination: Coston2 (Endpoint ID:
- Check Balance: Verifies user has sufficient FXRP OFT.
- Quote Fee: Calculates LayerZero messaging fee.
- Execute Send: Sends FXRP with compose message.
- Auto-Redemption: On arrival, composer automatically redeems to XRP.
Prerequisites
- Network: Must run on Hyperliquid EVM Testnet.
- Balance Requirements:
- FXRP OFT tokens on Hyperliquid (amount you want to redeem).
- HYPE tokens (for gas fees + LayerZero fees).
- Deployed Contracts:
- FAssetRedeemComposer must be deployed on Flare Testnet Coston2.
- Composer address must be set in
.env.
Configuration
Edit the CONFIG object in the script:
const CONFIG = {
HYPERLIQUID_FXRP_OFT:
process.env.HYPERLIQUID_FXRP_OFT ||
"0x14bfb521e318fc3d5e92A8462C65079BC7d4284c",
COSTON2_COMPOSER: process.env.COSTON2_COMPOSER || "",
COSTON2_EID: EndpointId.FLARE_V2_TESTNET,
EXECUTOR_GAS: 400_000, // Gas for receiving on Coston2
COMPOSE_GAS: 700_000, // Gas for compose execution
SEND_LOTS: "1", // Number of lots to redeem
XRP_ADDRESS: "rpHuw4bKSjonKRrKKVYUZYYVedg1jyPrmp", // Your XRP address
};
How to Run
PREREQUISITE: You must have FXRP tokens on Hyperliquid EVM Testnet before running this script.
If you don't have FXRP on Hyperliquid yet:
- Run
bridgeToHyperEVM.tsfirst (Step 1) - Wait for the bridge transaction to complete (2-5 minutes)
- Verify your FXRP balance on Hyperliquid EVM before proceeding
Once you have FXRP on Hyperliquid:
-
Deploy FAssetRedeemComposer (first time only):
npx hardhat deploy --network coston2 --tags FAssetRedeemComposer -
Configure Environment:
# .env file
HYPERLIQUID_TESTNET_RPC_URL=https://api.hyperliquid-testnet.xyz/evm
DEPLOYER_PRIVATE_KEY=your_private_key_here
COSTON2_COMPOSER=0x... # Required! Set this after deploying the composer
HYPERLIQUID_FXRP_OFT=0x14bfb521e318fc3d5e92A8462C65079BC7d4284c -
Update XRP Address:
- Edit
CONFIG.XRP_ADDRESSin the script to your XRP ledger address - This is where you'll receive the native XRP
- Must be a valid XRP address format (starts with 'r')
- Edit
-
Run the Script on Hyperliquid Testnet:
yarn hardhat run scripts/fassets/autoRedeemHyperEVM.ts --network hyperliquidTestnet
Expected Output
Using account: 0x742d35Cc6634C0532925a3b844Bc454e4438f44e
✓ Composer configured: 0x123...
📋 Redemption Parameters:
Amount: 10.0 FXRP
XRP Address: rpHuw4bKSjonKRrKKVYUZYYVedg1jyPrmp
Redeemer: 0x742d35Cc6634C0532925a3b844Bc454e4438f44e
Connecting to FXRP OFT on Hyperliquid EVM: 0x14bfb521e318fc3d5e92A8462C65079BC7d4284c
✓ Connected to FXRP OFT: 0x14bfb521e318fc3d5e92A8462C65079BC7d4284c
OFT address: 0x14bfb521e318fc3d5e92A8462C65079BC7d4284c
Compose message encoded
💰 Current FXRP balance: 25.0
Sufficient balance
💵 LayerZero Fee: 0.002456 HYPE
🚀 Sending 10.0 FXRP to Coston2 with auto-redeem...
Target composer: 0x123...
Underlying address: rpHuw4bKSjonKRrKKVYUZYYVedg1jyPrmp
✓ Transaction sent: 0xdef456...
Waiting for confirmation...
✅ Confirmed in block: 9876543
🎉 Success! Your FXRP is on the way to Coston2!
📊 Track your cross-chain transaction:
https://testnet.layerzeroscan.com/tx/0xdef456...
⏳ The auto-redeem will execute once the message arrives on Coston2.
XRP will be sent to: rpHuw4bKSjonKRrKKVYUZYYVedg1jyPrmp
View autoRedeemHyperEVM.ts source code
/**
* Example script to send FXRP from Hyperliquid EVM Testnet to Coston2 with automatic redemption
*
* This demonstrates how to:
* 1. Send OFT tokens from Hyperliquid EVM Testnet (where FXRP is an OFT)
* 2. Use LayerZero compose to trigger automatic redemption on Hyperliquid
* 3. Redeem the underlying asset (XRP) to a specified address
*
* Usage:
* yarn hardhat run scripts/fassets/autoRedeemHyperEVM.ts --network hyperliquidTestnet
*/
import { ethers } from "hardhat";
import type { Contract } from "ethers";
import { formatUnits, zeroPadValue, AbiCoder } from "ethers";
import { Options } from "@layerzerolabs/lz-v2-utilities";
import { EndpointId } from "@layerzerolabs/lz-definitions";
import { getAssetManagerFXRP } from "../utils/getters";
// Configuration - using existing deployed contracts
const CONFIG = {
HYPERLIQUID_FXRP_OFT:
process.env.HYPERLIQUID_FXRP_OFT ||
"0x14bfb521e318fc3d5e92A8462C65079BC7d4284c",
COSTON2_COMPOSER: process.env.COSTON2_COMPOSER || "",
COSTON2_EID: EndpointId.FLARE_V2_TESTNET, // Coston2 EID (destination)
EXECUTOR_GAS: 400_000,
COMPOSE_GAS: 700_000,
SEND_LOTS: "1",
XRP_ADDRESS: "rpHuw4bKSjonKRrKKVYUZYYVedg1jyPrmp", // Change this to the XRP address you are auto-redeeming to
} as const;
type RedemptionParams = {
amountToSend: bigint;
underlyingAddress: string;
redeemer: string;
signerAddress: string;
executor: string;
};
type SendParams = {
dstEid: EndpointId;
to: string;
amountLD: bigint;
minAmountLD: bigint;
extraOptions: string;
composeMsg: string;
oftCmd: string;
};
async function calculateAmountToSend(lots: bigint) {
const assetManager = await getAssetManagerFXRP();
const lotSize = BigInt(await assetManager.lotSize());
return lotSize * lots;
}
/**
* Gets the signer and validates composer is deployed
*/
async function validateSetup() {
const [signer] = await ethers.getSigners();
console.log("Using account:", signer.address);
if (!CONFIG.COSTON2_COMPOSER) {
throw new Error(
"❌ HYPERLIQUID_COMPOSER not set in .env!\n" +
" Deploy FAssetRedeemComposer first on Hyperliquid:\n" +
" npx hardhat deploy --network hyperliquid --tags FAssetRedeemComposer",
);
}
console.log("✓ Composer configured:", CONFIG.COSTON2_COMPOSER);
return signer;
}
/**
* Prepares redemption parameters
*/
async function prepareRedemptionParams(
signerAddress: string,
): Promise<RedemptionParams> {
const amountToSend = await calculateAmountToSend(BigInt(CONFIG.SEND_LOTS));
const underlyingAddress = CONFIG.XRP_ADDRESS;
const redeemer = signerAddress;
console.log("\n📋 Redemption Parameters:");
console.log("Amount:", formatUnits(amountToSend.toString(), 6), "FXRP");
console.log("XRP Address:", underlyingAddress);
console.log("Redeemer:", redeemer);
const executor = "0x0000000000000000000000000000000000000000";
return { amountToSend, underlyingAddress, redeemer, signerAddress, executor };
}
/**
* Connects to the OFT contract on Hyperliquid EVM
*/
async function connectToOFT() {
console.log(
"Connecting to FXRP OFT on Hyperliquid EVM:",
CONFIG.HYPERLIQUID_FXRP_OFT,
);
const oft = await ethers.getContractAt(
"FXRPOFT",
CONFIG.HYPERLIQUID_FXRP_OFT,
);
console.log("\n✓ Connected to FXRP OFT:", CONFIG.HYPERLIQUID_FXRP_OFT);
console.log("OFT address:", oft.target);
return oft;
}
/**
* Encodes the compose message with redemption details
* Format: (amountToRedeem, underlyingAddress, redeemer)
*/
function encodeComposeMessage(params: RedemptionParams): string {
const abiCoder = AbiCoder.defaultAbiCoder();
// redeem(uint256 _lots, string memory _redeemerUnderlyingAddressString, executor address)
const composeMsg = abiCoder.encode(
["uint256", "string", "address"],
[params.amountToSend, params.underlyingAddress, params.redeemer],
);
console.log("Compose message encoded");
return composeMsg;
}
/**
* Builds LayerZero options with compose support
*/
function buildComposeOptions(): string {
const options = Options.newOptions()
.addExecutorLzReceiveOption(CONFIG.EXECUTOR_GAS, 0)
.addExecutorComposeOption(0, CONFIG.COMPOSE_GAS, 0);
return options.toHex();
}
/**
* Builds the send parameters for LayerZero
*/
function buildSendParams(
params: RedemptionParams,
composeMsg: string,
options: string,
): SendParams {
return {
dstEid: CONFIG.COSTON2_EID,
to: zeroPadValue(CONFIG.COSTON2_COMPOSER, 32),
amountLD: params.amountToSend,
minAmountLD: params.amountToSend,
extraOptions: options,
composeMsg: composeMsg,
oftCmd: "0x",
};
}
/**
* Checks if user has sufficient FXRP balance
*/
async function checkBalance(
oft: Contract,
signerAddress: string,
amountToSend: bigint,
): Promise<void> {
console.log("signer address", signerAddress);
console.log("oft.address", oft.address);
const balance = await oft.balanceOf(signerAddress);
console.log("signer address", signerAddress);
console.log("\n💰 Current FXRP balance:", formatUnits(balance, 6));
if (balance < amountToSend) {
console.error("\n❌ Insufficient FXRP balance!");
console.log(" Required:", formatUnits(amountToSend, 6), "FXRP");
console.log(" Available:", formatUnits(balance, 6), "FXRP");
throw new Error("Insufficient FXRP balance");
}
console.log("Sufficient balance");
}
/**
* Quotes the LayerZero fee for the send transaction
*/
async function quoteFee(
oft: Contract,
sendParam: SendParams,
): Promise<{ nativeFee: bigint; lzTokenFee: bigint }> {
const result = await oft.quoteSend(sendParam, false);
const nativeFee = result.nativeFee;
const lzTokenFee = result.lzTokenFee;
console.log("\n💵 LayerZero Fee:", formatUnits(nativeFee, 18), "HYPE");
return { nativeFee, lzTokenFee };
}
/**
* Executes the send with auto-redeem
*/
async function executeSendAndRedeem(
oft: Contract,
sendParam: SendParams,
nativeFee: bigint,
lzTokenFee: bigint,
params: RedemptionParams,
): Promise<void> {
console.log(
"\n🚀 Sending",
formatUnits(params.amountToSend, 6),
"FXRP to Coston2 with auto-redeem...",
);
console.log("Target composer:", CONFIG.COSTON2_COMPOSER);
console.log("Underlying address:", params.underlyingAddress);
const tx = await oft.send(
sendParam,
{ nativeFee, lzTokenFee },
params.signerAddress,
{ value: nativeFee },
);
console.log("\n✓ Transaction sent:", tx.hash);
console.log("Waiting for confirmation...");
const receipt = await tx.wait();
console.log("✅ Confirmed in block:", receipt?.blockNumber);
console.log("\n🎉 Success! Your FXRP is on the way to Coston2!");
console.log("\n📊 Track your cross-chain transaction:");
console.log(`https://testnet.layerzeroscan.com/tx/${tx.hash}`);
console.log(
"\n⏳ The auto-redeem will execute once the message arrives on Coston2.",
);
console.log("XRP will be sent to:", params.underlyingAddress);
}
async function main() {
// 1. Validate setup and get signer
const signer = await validateSetup();
// 2. Prepare redemption parameters
const params = await prepareRedemptionParams(signer.address);
// 3. Connect to OFT contract
const oft = await connectToOFT();
console.log("3. oft.address", oft.address);
// 4. Encode compose message
const composeMsg = encodeComposeMessage(params);
// 5. Build LayerZero options
const options = buildComposeOptions();
// 6. Build send parameters
const sendParam = buildSendParams(params, composeMsg, options);
console.log("7. oft.address", oft.address);
// 7. Check balance
await checkBalance(oft, params.signerAddress, params.amountToSend);
// 8. Quote fee
const { nativeFee, lzTokenFee } = await quoteFee(oft, sendParam);
// 9. Execute send with auto-redeem
await executeSendAndRedeem(oft, sendParam, nativeFee, lzTokenFee, params);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
FAQ
Q: How long does bridging take? A: Typically 2-5 minutes for LayerZero message delivery + execution time.
Q: What's the minimum amount I can bridge? A: Any amount for bridging to Hyperliquid. For auto-redeem, minimum is 1 lot (10 FXRP for XRP).
Q: Can I bridge to a different address?
A: Yes, edit the recipientAddress parameter in the scripts.
Q: What happens if compose execution fails?
A: Tokens will be stuck in the composer. Owner can recover using recoverTokens().
Q: Can I use this on mainnet? A: This is designed for testnet. For mainnet, update contract addresses, thoroughly test, and audit all code.
Q: How do I get FTestXRP on Flare Testnet Coston2? A: Use Flare's FAsset minting process via the AssetManager contract.
Q: What if I don't have HYPE tokens? A: Get them from Hyperliquid testnet faucet or DEX.
Discovering Available Bridge Routes
The getOftPeers.ts utility script discovers all configured LayerZero peers for the FXRP OFT Adapter on Flare Testnet Coston2.
It scans all LayerZero V2 testnet endpoints to find which EVM chains have been configured as valid bridge destinations.
Before bridging FXRP to another chain, you need to know which chains are supported. This script:
- Automatically discovers all configured peer addresses
- Shows which EVM chains you can bridge FXRP to/from
- Provides the peer contract addresses for each chain
- Outputs results in both human-readable and JSON formats
How It Works
- Loads V2 Testnet Endpoints: Dynamically retrieves all LayerZero V2 testnet endpoint IDs from the
@layerzerolabs/lz-definitionspackage. - Queries Peers: For each endpoint, calls the
peers()function on the OFT Adapter contract. - Filters Results: Only shows endpoints that have a non-zero peer address configured.
- Formats Output: Displays results in a table format and as JSON for programmatic use.
How to Run
yarn hardhat run scripts/layerzero/getOFTPeers.ts --network coston2
Expected Output
=== FXRP OFT Adapter Peers Discovery ===
OFT Adapter: 0xCd3d2127935Ae82Af54Fc31cCD9D3440dbF46639
Network: Coston2 (Flare Testnet)
Scanning 221 LayerZero V2 Testnet endpoints...
✅ Bsc (EID: 40102): 0xac7c4a07670589cf83b134a843bfe86c45a4bf4e
✅ Sepolia (EID: 40161): 0x81672c5d42f3573ad95a0bdfbe824faac547d4e6
✅ Hyperliquid (EID: 40362): 0x14bfb521e318fc3d5e92a8462c65079bc7d4284c
============================================================
SUMMARY: Configured Peers
============================================================
Found 3 configured peer(s):
| Chain | EID | Peer Address |
|-------|-----|--------------|
| Bsc | 40102 | 0xac7c4a07670589cf83b134a843bfe86c45a4bf4e |
| Sepolia | 40161 | 0x81672c5d42f3573ad95a0bdfbe824faac547d4e6 |
| Hyperliquid | 40362 | 0x14bfb521e318fc3d5e92a8462c65079bc7d4284c |
--- Available Routes ---
You can bridge FXRP to/from the following chains:
• Bsc
• Sepolia
• Hyperliquid
Once you've identified available peers, you can:
- Bridge to any discovered chain: Update the destination EID in
bridgeToHyperEVM.tsto target a different chain - Verify peer addresses: Use the peer addresses to interact with OFT contracts on other chains
- Build integrations: Use the JSON output to programmatically determine available routes
View getOftPeers.ts source code
/**
* Get all LayerZero peers for the FXRP OFT Adapter on Coston2
*
* Usage:
* yarn hardhat run scripts/layerzero/getOFTPeers.ts --network coston2
*/
import { web3 } from "hardhat";
import { EndpointId } from "@layerzerolabs/lz-definitions";
// FXRP OFT Adapter on Coston2
const OFT_ADAPTER_ADDRESS = "0xCd3d2127935Ae82Af54Fc31cCD9D3440dbF46639";
// Minimal OApp ABI for peers function
const OAPP_ABI = [
{
inputs: [{ internalType: "uint32", name: "eid", type: "uint32" }],
name: "peers",
outputs: [{ internalType: "bytes32", name: "", type: "bytes32" }],
stateMutability: "view",
type: "function",
},
];
// Get ALL V2 Testnet endpoints dynamically from the EndpointId enum
function getAllV2TestnetEndpoints(): { name: string; eid: number }[] {
const endpoints: { name: string; eid: number }[] = [];
for (const [key, value] of Object.entries(EndpointId)) {
// Only include V2 testnet endpoints (they end with _V2_TESTNET and have numeric values)
if (key.endsWith("_V2_TESTNET") && typeof value === "number") {
// Convert key like "SEPOLIA_V2_TESTNET" to "Sepolia"
const name = key
.replace("_V2_TESTNET", "")
.split("_")
.map((word) => word.charAt(0) + word.slice(1).toLowerCase())
.join(" ");
endpoints.push({ name, eid: value });
}
}
// Sort by EID for consistent output
return endpoints.sort((a, b) => a.eid - b.eid);
}
const V2_TESTNET_ENDPOINTS = getAllV2TestnetEndpoints();
const ZERO_BYTES32 =
"0x0000000000000000000000000000000000000000000000000000000000000000";
async function main() {
console.log("=== FXRP OFT Adapter Peers Discovery ===\n");
console.log(`OFT Adapter: ${OFT_ADAPTER_ADDRESS}`);
console.log(`Network: Coston2 (Flare Testnet)\n`);
const oftAdapter = new web3.eth.Contract(OAPP_ABI, OFT_ADAPTER_ADDRESS);
const configuredPeers: { name: string; eid: number; peer: string }[] = [];
const errors: { name: string; eid: number; error: string }[] = [];
console.log(
`Scanning ${V2_TESTNET_ENDPOINTS.length} LayerZero V2 Testnet endpoints...\n`,
);
for (const endpoint of V2_TESTNET_ENDPOINTS) {
try {
const peer = await oftAdapter.methods.peers(endpoint.eid).call();
if (peer && peer !== ZERO_BYTES32) {
// Convert bytes32 to address (last 20 bytes)
const peerAddress = "0x" + peer.slice(-40);
configuredPeers.push({
name: endpoint.name,
eid: endpoint.eid,
peer: peerAddress,
});
console.log(
`✅ ${endpoint.name} (EID: ${endpoint.eid}): ${peerAddress}`,
);
}
} catch (error: unknown) {
// Some endpoints might not exist or the contract might revert
const errorMessage =
error instanceof Error ? error.message.slice(0, 50) : "Unknown error";
errors.push({
name: endpoint.name,
eid: endpoint.eid,
error: errorMessage,
});
}
}
console.log("\n" + "=".repeat(60));
console.log("SUMMARY: Configured Peers");
console.log("=".repeat(60) + "\n");
if (configuredPeers.length === 0) {
console.log("No peers configured for the FXRP OFT Adapter.\n");
} else {
console.log(`Found ${configuredPeers.length} configured peer(s):\n`);
console.log("| Chain | EID | Peer Address |");
console.log("|-------|-----|--------------|");
for (const peer of configuredPeers) {
console.log(`| ${peer.name} | ${peer.eid} | ${peer.peer} |`);
}
console.log("\n--- Available Routes ---");
console.log("You can bridge FXRP to/from the following chains:\n");
for (const peer of configuredPeers) {
console.log(` • ${peer.name}`);
}
}
if (errors.length > 0) {
console.log(
`\n(${errors.length} endpoints had errors or are not available)`,
);
}
// Export as JSON for programmatic use
console.log("\n--- JSON Output ---");
console.log(JSON.stringify(configuredPeers, null, 2));
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
To continue your FAssets development journey, you can:
- Learn how to mint FXRP
- Understand how to redeem FXRP
- Explore FAssets system settings