FAsset Auto-Redemption
Overview
In this guide, you will learn how to bridge FAssets (specifically FXRP) between Flare Testnet Coston2 and Hyperliquid 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 four key functionalities:
- Bridging FXRP from Flare Testnet Coston2 to Hyperliquid EVM (
bridgeToHyperEVM.ts) - Transfer wrapped XRP tokens to Hyperliquid EVM for DeFi use. - Bridging FXRP from Flare Testnet Coston2 to HyperCore (
bridgeToHyperCore.ts) - Transfer wrapped XRP tokens directly to HyperCore for spot trading. - Auto-Redeem from Hyperliquid EVM to Underlying Asset (
autoRedeemFromHyperEVM.ts) - Send FXRP from Hyperliquid EVM back to Flare Testnet Coston2 and automatically redeem it for native XRP. - Auto-Redeem from HyperCore to Underlying Asset (
autoRedeemFromHyperCore.ts) - Transfer FXRP from HyperCore spot wallet to HyperEVM, then automatically bridge and redeem to native XRP in one script.
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.
- Hyperliquid spotSend API for transferring tokens between HyperCore and HyperEVM.
A shared FAssetRedeemComposer contract is pre-deployed on Coston2, so users do not need to deploy their own. This guide includes four TypeScript scripts (bridgeToHyperEVM.ts, bridgeToHyperCore.ts, autoRedeemFromHyperEVM.ts, and autoRedeemFromHyperCore.ts) that demonstrate the complete workflows.
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 autoRedeemFromHyperEVM.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/autoRedeemFromHyperEVM.ts --network hyperliquidTestnet
FAssetRedeemComposer Contract
What It Is
The FAssetRedeemComposer is a shared LayerZero Composer contract pre-deployed on Coston2 that automatically redeems FAssets to their underlying assets when tokens arrive via LayerZero's compose message feature.
How It Works
The composer implements the ILayerZeroComposer interface and creates per-user FAssetRedeemerAccount contracts automatically:
- Receives Compose Message: LayerZero endpoint calls lzCompose() with the incoming OFT transfer and compose data.
- Decodes
RedeemComposeData: Extracts the redeemer's EVM address and their underlying XRP address from the compose message. - Creates Redeemer Account: Deploys (or reuses) a deterministic per-user
FAssetRedeemerAccountcontract via CREATE2. - Deducts Composer Fee: Takes a percentage fee (configurable per source chain, currently 1%) from the received FXRP.
- Transfers Tokens: Sends the remaining FXRP to the redeemer account.
- Executes Redemption: The redeemer account calls
assetManager.redeem()to burn FAssets and release underlying XRP.
Compose Message Format
The auto-redeem scripts encode the compose message using the RedeemComposeData struct:
struct RedeemComposeData {
/// @notice EVM address that owns the per-redeemer account.
address redeemer;
/// @notice Underlying-chain redemption destination passed to the asset manager.
string redeemerUnderlyingAddress;
}
In TypeScript, this is encoded as:
const composeMsg = web3.eth.abi.encodeParameters(
["address", "string"],
[redeemer, xrpAddress],
);
Executor Fee
The lzCompose call must include native value to cover the executor fee on Coston2. This is set via addExecutorLzComposeOption:
const options = Options.newOptions()
.addExecutorLzReceiveOption(EXECUTOR_GAS, 0)
.addExecutorComposeOption(0, COMPOSE_GAS, COMPOSE_VALUE);
The current executor fee can be queried via getExecutorData() on the composer contract.
Contract Source
View FAssetRedeemComposer.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.27;
import {IAssetManager} from "flare-periphery/src/flare/IAssetManager.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {ILayerZeroComposer} from "@layerzerolabs/lz-evm-protocol-v2/contracts/interfaces/ILayerZeroComposer.sol";
import {OFTComposeMsgCodec} from "@layerzerolabs/oft-evm/contracts/libs/OFTComposeMsgCodec.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import {ReentrancyGuardTransient} from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol";
import {Create2} from "@openzeppelin/contracts/utils/Create2.sol";
import {IIFAssetRedeemerAccount} from "../interface/IIFAssetRedeemerAccount.sol";
import {FAssetRedeemerAccountProxy} from "../proxy/FAssetRedeemerAccountProxy.sol";
import {IFAssetRedeemComposer} from "../../userInterfaces/IFAssetRedeemComposer.sol";
import {IBeacon} from "@openzeppelin/contracts/proxy/beacon/IBeacon.sol";
import {OwnableWithTimelock} from "../../utils/implementation/OwnableWithTimelock.sol";
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
/**
* @title FAssetRedeemComposer
* @notice LayerZero compose handler that orchestrates deterministic redeemer accounts and f-asset redemption.
*/
contract FAssetRedeemComposer is
IFAssetRedeemComposer,
OwnableWithTimelock,
UUPSUpgradeable,
ReentrancyGuardTransient
{
using SafeERC20 for IERC20;
uint256 private constant PPM_DENOMINATOR = 1_000_000;
/// @notice Mapping of redeemer to deterministic redeemer account address.
mapping(address redeemer => address redeemerAccount)
private redeemerToRedeemerAccount;
/// @notice Trusted endpoint allowed to invoke `lzCompose`.
address public endpoint;
/// @notice Asset manager used for f-asset redemption.
IAssetManager public assetManager;
/// @notice FAsset token.
IERC20 public fAsset;
/// @notice Stable coin token - returned in case of a redemption failure.
IERC20 public stableCoin;
/// @notice Wrapped native token - returned in case of a redemption failure if stable coin balance is insufficient.
IERC20 public wNat;
/// @notice Trusted source OApp address (FAssetOFTAdapter).
address public trustedSourceOApp;
/// @notice Current beacon implementation for redeemer account proxies.
address public redeemerAccountImplementation;
/// @notice Recipient of composer fee collected in fAsset.
address public composerFeeRecipient;
/// @notice Default composer fee in PPM.
uint256 public defaultComposerFeePPM;
/// @notice Optional srcEid-specific composer fee in PPM, stored as fee + 1 to distinguish unset values.
mapping(uint32 srcEid => uint256 feePPM) private composerFeesPPM;
/// @notice The redeem executor.
address payable private executor;
/// @notice The native fee expected by the executor for redeem execution.
uint256 private executorFee;
/**
* @notice Disables initializers on implementation contract.
*/
constructor() {
_disableInitializers();
}
/**
* @notice Initializes composer proxy state.
* @param _initialOwner Owner address for administrative operations.
* @param _endpoint Trusted endpoint allowed to invoke `lzCompose`.
* @param _trustedSourceOApp Trusted source OApp address.
* @param _assetManager Asset manager used for redemption.
* @param _stableCoin Stable coin token - returned in case of a redemption failure.
* @param _wNat Wrapped native token - returned in case of a redemption failure
* if stable coin balance is insufficient.
* @param _composerFeeRecipient Recipient of composer fee collected in fAsset.
* @param _defaultComposerFeePPM Default composer fee in PPM.
* @param _redeemerAccountImplementation Beacon implementation for redeemer accounts.
*/
function initialize(
address _initialOwner,
address _endpoint,
address _trustedSourceOApp,
IAssetManager _assetManager,
IERC20 _stableCoin,
IERC20 _wNat,
address _composerFeeRecipient,
uint256 _defaultComposerFeePPM,
address _redeemerAccountImplementation
) external initializer {
require(_initialOwner != address(0), InvalidAddress());
require(_endpoint != address(0), InvalidAddress());
require(_trustedSourceOApp != address(0), InvalidAddress());
require(address(_assetManager).code.length > 0, InvalidAddress());
require(address(_stableCoin).code.length > 0, InvalidAddress());
require(address(_wNat).code.length > 0, InvalidAddress());
require(_composerFeeRecipient != address(0), InvalidAddress());
require(
_defaultComposerFeePPM < PPM_DENOMINATOR,
InvalidComposerFeePPM()
);
require(
_redeemerAccountImplementation.code.length > 0,
InvalidRedeemerAccountImplementation()
);
__Ownable_init(_initialOwner);
endpoint = _endpoint;
trustedSourceOApp = _trustedSourceOApp;
assetManager = _assetManager;
fAsset = _assetManager.fAsset();
require(address(fAsset).code.length > 0, InvalidAddress());
stableCoin = _stableCoin;
wNat = _wNat;
composerFeeRecipient = _composerFeeRecipient;
defaultComposerFeePPM = _defaultComposerFeePPM;
redeemerAccountImplementation = _redeemerAccountImplementation;
emit ComposerFeeRecipientSet(_composerFeeRecipient);
emit DefaultComposerFeeSet(_defaultComposerFeePPM);
emit RedeemerAccountImplementationSet(redeemerAccountImplementation);
}
/**
* @notice Updates default composer fee in PPM.
* @param _defaultComposerFeePPM New default composer fee in PPM.
*/
function setDefaultComposerFee(
uint256 _defaultComposerFeePPM
) external onlyOwnerWithTimelock {
require(
_defaultComposerFeePPM < PPM_DENOMINATOR,
InvalidComposerFeePPM()
);
defaultComposerFeePPM = _defaultComposerFeePPM;
emit DefaultComposerFeeSet(_defaultComposerFeePPM);
}
/**
* @notice Sets srcEid-specific composer fees in PPM.
* @dev Uses fee+1 storage to distinguish unset (0) from an explicit zero fee.
* @param _srcEids List of OFT source endpoint IDs.
* @param _composerFeesPPM Composer fee values in PPM for corresponding srcEids.
*/
function setComposerFees(
uint32[] calldata _srcEids,
uint256[] calldata _composerFeesPPM
) external onlyOwnerWithTimelock {
require(_srcEids.length == _composerFeesPPM.length, LengthMismatch());
for (uint256 i = 0; i < _srcEids.length; i++) {
uint32 srcEid = _srcEids[i];
uint256 feePPM = _composerFeesPPM[i];
require(feePPM < PPM_DENOMINATOR, InvalidComposerFeePPM());
composerFeesPPM[srcEid] = feePPM + 1;
emit ComposerFeeSet(srcEid, feePPM);
}
}
/**
* @notice Removes srcEid-specific composer fee overrides.
* @param _srcEids List of OFT source endpoint IDs.
*/
function removeComposerFees(
uint32[] calldata _srcEids
) external onlyOwnerWithTimelock {
for (uint256 i = 0; i < _srcEids.length; i++) {
uint32 srcEid = _srcEids[i];
require(composerFeesPPM[srcEid] != 0, ComposerFeeNotSet(srcEid));
delete composerFeesPPM[srcEid];
emit ComposerFeeRemoved(srcEid);
}
}
/**
* @notice Updates recipient for collected composer fee.
* @param _composerFeeRecipient New recipient address.
*/
function setComposerFeeRecipient(
address _composerFeeRecipient
) external onlyOwnerWithTimelock {
require(
_composerFeeRecipient != address(0),
InvalidComposerFeeRecipient()
);
composerFeeRecipient = _composerFeeRecipient;
emit ComposerFeeRecipientSet(_composerFeeRecipient);
}
/**
* @notice Updates beacon implementation used by redeemer accounts.
* @param _implementation New implementation address.
*/
function setRedeemerAccountImplementation(
address _implementation
) external onlyOwnerWithTimelock {
require(
_implementation.code.length > 0,
InvalidRedeemerAccountImplementation()
);
redeemerAccountImplementation = _implementation;
emit RedeemerAccountImplementationSet(_implementation);
}
/**
* @notice Updates executor data used for redemption execution.
* @param _executor New executor address.
* @param _executorFee New expected fee for executor.
*/
function setExecutorData(
address payable _executor,
uint256 _executorFee
) external onlyOwnerWithTimelock {
require(
_executor != address(0) || _executorFee == 0,
InvalidExecutorData()
);
executor = _executor;
executorFee = _executorFee;
emit ExecutorDataSet(_executor, _executorFee);
}
/**
* @notice Transfers f-assets held by composer to a target address.
* @dev Recovery function for funds stuck on composer when compose flow fails or is not invoked.
* @param _to Recipient address.
* @param _amount Amount of f-asset to transfer.
*/
function transferFAsset(
address _to,
uint256 _amount
) external onlyOwnerWithTimelock {
require(_to != address(0), InvalidAddress());
fAsset.safeTransfer(_to, _amount);
emit FAssetTransferred(_to, _amount);
}
/**
* @notice Transfers native tokens held by composer to a target address.
* @dev Recovery function for funds stuck on composer when compose flow fails.
* @param _to Recipient address.
* @param _amount Amount of native tokens to transfer.
*/
function transferNative(
address _to,
uint256 _amount
) external onlyOwnerWithTimelock {
require(_to != address(0), InvalidAddress());
(bool success, ) = _to.call{value: _amount}("");
require(success, NativeTransferFailed());
emit NativeTransferred(_to, _amount);
}
/// @inheritdoc ILayerZeroComposer
function lzCompose(
address _from,
bytes32 _guid,
bytes calldata _message,
address /* _executor */,
bytes calldata /* _extraData */
) external payable nonReentrant {
require(msg.sender == endpoint, OnlyEndpoint());
require(_from == trustedSourceOApp, InvalidSourceOApp(_from));
uint32 srcEid = OFTComposeMsgCodec.srcEid(_message);
uint256 amountLD = OFTComposeMsgCodec.amountLD(_message);
uint256 composerFeePPM = _getComposerFeePPM(srcEid);
uint256 composerFee = Math.mulDiv(
amountLD,
composerFeePPM,
PPM_DENOMINATOR
);
uint256 amountToRedeemAfterFee = amountLD - composerFee;
RedeemComposeData memory data = abi.decode(
OFTComposeMsgCodec.composeMsg(_message),
(RedeemComposeData)
);
require(data.redeemer != address(0), InvalidAddress());
if (composerFee > 0) {
fAsset.safeTransfer(composerFeeRecipient, composerFee);
emit ComposerFeeCollected(
_guid,
srcEid,
composerFeeRecipient,
composerFee
);
}
address redeemerAccount = _getOrCreateRedeemerAccount(data.redeemer);
fAsset.safeTransfer(redeemerAccount, amountToRedeemAfterFee);
emit FAssetTransferred(redeemerAccount, amountToRedeemAfterFee);
try
IIFAssetRedeemerAccount(redeemerAccount).redeemFAsset{
value: msg.value
}(
assetManager,
amountToRedeemAfterFee,
data.redeemerUnderlyingAddress,
executor,
executorFee
)
returns (uint256 _redeemedAmountUBA) {
emit FAssetRedeemed(
_guid,
srcEid,
data.redeemer,
redeemerAccount,
amountToRedeemAfterFee,
data.redeemerUnderlyingAddress,
executor,
executorFee,
_redeemedAmountUBA
);
} catch {
emit FAssetRedeemFailed(
_guid,
srcEid,
data.redeemer,
redeemerAccount,
amountToRedeemAfterFee
);
}
}
/// @inheritdoc IFAssetRedeemComposer
function getComposerFeePPM(
uint32 _srcEid
) external view returns (uint256 _composerFeePPM) {
_composerFeePPM = _getComposerFeePPM(_srcEid);
}
/// @inheritdoc IBeacon
function implementation() external view returns (address) {
return redeemerAccountImplementation;
}
/// @inheritdoc IFAssetRedeemComposer
function getExecutorData()
external
view
returns (address payable _executor, uint256 _executorFee)
{
_executor = executor;
_executorFee = executorFee;
}
/// @inheritdoc IFAssetRedeemComposer
function getRedeemerAccountAddress(
address _redeemer
) external view returns (address _redeemerAccount) {
_redeemerAccount = redeemerToRedeemerAccount[_redeemer];
if (_redeemerAccount == address(0)) {
bytes memory bytecode = _generateRedeemerAccountBytecode(_redeemer);
_redeemerAccount = Create2.computeAddress(
bytes32(0),
keccak256(bytecode)
);
}
}
/**
* @inheritdoc UUPSUpgradeable
* @dev Only owner can call this method.
*/
function upgradeToAndCall(
address _newImplementation,
bytes memory _data
) public payable override onlyOwnerWithTimelock {
super.upgradeToAndCall(_newImplementation, _data);
}
/**
* Unused. Present just to satisfy UUPSUpgradeable requirement as call is timelocked.
* The real check is in onlyOwnerWithTimelock modifier on upgradeToAndCall.
*/
function _authorizeUpgrade(address _newImplementation) internal override {}
/**
* @notice Gets existing redeemer account or creates a deterministic one.
* @param _redeemer Redeemer account owner address.
* @return _redeemerAccount Redeemer account address.
*/
function _getOrCreateRedeemerAccount(
address _redeemer
) internal returns (address _redeemerAccount) {
_redeemerAccount = redeemerToRedeemerAccount[_redeemer];
if (_redeemerAccount != address(0)) {
return _redeemerAccount;
}
// redeemer account does not exist, create it
bytes memory bytecode = _generateRedeemerAccountBytecode(_redeemer);
_redeemerAccount = Create2.deploy(0, bytes32(0), bytecode); // reverts on failure
redeemerToRedeemerAccount[_redeemer] = _redeemerAccount;
emit RedeemerAccountCreated(_redeemer, _redeemerAccount);
// set unlimited allowances for fAsset, stable coin and wNat
// to enable redeemer to transfer funds to redeemer address in case of redemption failure
IIFAssetRedeemerAccount(_redeemerAccount).setMaxAllowances(
fAsset,
stableCoin,
wNat
);
}
/**
* @notice Builds CREATE2 deployment bytecode for redeemer account proxy.
* @param _redeemer Redeemer account owner address.
* @return Bytecode used for deterministic deployment.
*/
function _generateRedeemerAccountBytecode(
address _redeemer
) internal view returns (bytes memory) {
return
abi.encodePacked(
type(FAssetRedeemerAccountProxy).creationCode,
abi.encode(address(this), _redeemer)
);
}
/**
* @notice Retrieves composer fee in PPM for a given srcEid, falling back to default if not set.
* @param _srcEid OFT source endpoint ID.
* @return _composerFeePPM Composer fee in PPM.
*/
function _getComposerFeePPM(
uint32 _srcEid
) internal view returns (uint256 _composerFeePPM) {
uint256 srcEidFeePlusOne = composerFeesPPM[_srcEid];
if (srcEidFeePlusOne > 0) {
return srcEidFeePlusOne - 1;
}
return defaultComposerFeePPM;
}
}
The full verified source is available on the Coston2 explorer.
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 autoRedeemFromHyperEVM.ts script.
How It Works
Step-by-Step Process
- Get Asset Manager Info: Retrieves fAsset address and lot size dynamically from the AssetManager contract using the
getAssetManagerFXRPutility. - Balance Check: Verifies user has sufficient FTestXRP tokens.
- Token Approval:
- Approves OFT Adapter to spend FTestXRP.
- 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 for safety).
- 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=0x80c5ebBD65b4857CA2f917EAB1e9F83bcC947c52 -
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 {
IERC20MetadataInstance,
FAssetOFTAdapterInstance,
} from "../../typechain-types";
import { getAssetManagerFXRP } from "../utils/getters";
const IERC20Metadata = artifacts.require("IERC20Metadata");
const FAssetOFTAdapter = artifacts.require("FAssetOFTAdapter");
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",
} as const;
type BridgeParams = {
amountToBridge: bigint;
recipientAddress: string;
signerAddress: string;
fAssetAddress: string;
};
type SendParams = {
dstEid: EndpointId;
to: string;
amountLD: string;
minAmountLD: string;
extraOptions: string;
composeMsg: string;
oftCmd: string;
};
async function getAssetManagerInfo(lots: bigint) {
const assetManager = await getAssetManagerFXRP();
const fAssetAddress = await assetManager.fAsset();
const lotSizeBN = await assetManager.lotSize();
const lotSize = BigInt(lotSizeBN.toString());
const amountToBridge = lotSize * lots;
return {
fAssetAddress,
amountToBridge: (amountToBridge * 11n) / 10n, // 10% buffer
};
}
/**
* Gets the signer and displays account information
*/
async function getSigner(fAssetAddress: string) {
const accounts = await web3.eth.getAccounts();
const signerAddress = accounts[0];
console.log("Using account:", signerAddress);
console.log("Token address:", fAssetAddress);
return signerAddress;
}
/**
* Prepares bridge parameters
*/
function prepareBridgeParams(
signerAddress: string,
fAssetAddress: string,
amountToBridge: bigint,
decimals: number,
): BridgeParams {
const recipientAddress = signerAddress;
console.log("\n📋 Bridge Details:");
console.log("From: Coston2");
console.log("To: Hyperliquid EVM Testnet");
console.log(
"Amount:",
formatUnits(amountToBridge.toString(), decimals),
"FXRP",
);
console.log("Recipient:", recipientAddress);
return { amountToBridge, recipientAddress, signerAddress, fAssetAddress };
}
/**
* Checks if user has sufficient balance to bridge
*/
async function checkBalance(
params: BridgeParams,
decimals: number,
): Promise<IERC20MetadataInstance> {
const fAsset: IERC20MetadataInstance = await IERC20Metadata.at(
params.fAssetAddress,
);
const balance = await fAsset.balanceOf(params.signerAddress);
console.log(
"\nYour FTestXRP balance:",
formatUnits(balance.toString(), decimals),
);
if (BigInt(balance.toString()) < params.amountToBridge) {
console.error("\n❌ Insufficient FTestXRP balance!");
console.log(" Token address: " + params.fAssetAddress);
throw new Error("Insufficient balance");
}
return fAsset;
}
/**
* Approves OFT Adapter AND Composer to spend FTestXRP
*/
async function approveTokens(
fAsset: IERC20MetadataInstance,
amountToBridge: bigint,
signerAddress: string,
fAssetAddress: string,
decimals: number,
): Promise<FAssetOFTAdapterInstance> {
const oftAdapter: FAssetOFTAdapterInstance = await FAssetOFTAdapter.at(
CONFIG.COSTON2_OFT_ADAPTER,
);
console.log("\n1️⃣ Checking OFT Adapter token address...");
const underlyingToken = await oftAdapter.token();
console.log(" OFT Adapter's underlying token:", underlyingToken);
console.log(" Expected token:", fAssetAddress);
console.log(
" Match:",
underlyingToken.toLowerCase() === fAssetAddress.toLowerCase(),
);
console.log("\n Approving FTestXRP for OFT Adapter...");
console.log(" OFT Adapter address:", oftAdapter.address);
console.log(
" Amount:",
formatUnits(amountToBridge.toString(), decimals),
"FXRP",
);
const amount = amountToBridge;
await fAsset.approve(oftAdapter.address, amount.toString());
console.log("✅ OFT Adapter approved");
// Verify the allowance
const allowance1 = await fAsset.allowance(signerAddress, oftAdapter.address);
console.log(
" Verified allowance:",
formatUnits(allowance1.toString(), decimals),
"FXRP",
);
console.log("\n2️⃣ Approving FTestXRP for Composer...");
console.log(" Composer address:", CONFIG.COSTON2_COMPOSER);
await fAsset.approve(CONFIG.COSTON2_COMPOSER, amountToBridge.toString());
console.log("✅ Composer approved");
// Verify the allowance
const allowance2 = await fAsset.allowance(
signerAddress,
CONFIG.COSTON2_COMPOSER,
);
console.log(
" Verified allowance:",
formatUnits(allowance2.toString(), decimals),
"FXRP",
);
return oftAdapter;
}
/**
* Builds LayerZero send parameters
*/
function buildSendParams(params: BridgeParams): SendParams {
const options = Options.newOptions().addExecutorLzReceiveOption(
CONFIG.EXECUTOR_GAS,
0,
);
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,
) {
const result = await oftAdapter.quoteSend(sendParam, false);
const nativeFee = BigInt(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: bigint,
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 fAsset address and amount from AssetManager
const { fAssetAddress, amountToBridge } = await getAssetManagerInfo(
BigInt(CONFIG.BRIDGE_LOTS),
);
// 2. Get signer and display account info
const signerAddress = await getSigner(fAssetAddress);
// 3. Get token decimals
const fAssetToken: IERC20MetadataInstance =
await IERC20Metadata.at(fAssetAddress);
const decimals = Number(await fAssetToken.decimals());
console.log("Token decimals:", decimals);
// 4. Prepare bridge parameters
const params = prepareBridgeParams(
signerAddress,
fAssetAddress,
amountToBridge,
decimals,
);
// 5. Check balance and get token contract
const fAsset = await checkBalance(params, decimals);
// 6. Approve tokens and get OFT adapter
const oftAdapter = await approveTokens(
fAsset,
params.amountToBridge,
signerAddress,
fAssetAddress,
decimals,
);
// 7. Build send parameters
const sendParam = buildSendParams(params);
// 8. Quote the fee
const nativeFee = await quoteFee(oftAdapter, sendParam);
// 9. 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.
Bridge FXRP to HyperCore
What It Is
This script bridges FXRP tokens from Flare Testnet Coston2 directly to Hyperliquid HyperCore (the spot trading layer) using LayerZero's OFT Adapter with a compose message.
Unlike bridgeToHyperEVM.ts which deposits tokens on HyperEVM, this script triggers automatic transfer to HyperCore where you can trade FXRP on Hyperliquid's spot market.
- bridgeToHyperEVM.ts: Deposits FXRP on HyperEVM (EVM layer) for DeFi use.
- bridgeToHyperCore.ts: Deposits FXRP on HyperCore (trading layer) for spot trading.
Choose based on whether you want to use FXRP in smart contracts (HyperEVM) or trade it (HyperCore).
How It Works
Flow Diagram
Step-by-Step Process
- Validate Config: Checks that
HYPERLIQUID_COMPOSERis configured. - Get Asset Manager Info: Retrieves fAsset address and lot size dynamically from the AssetManager contract.
- Balance Check: Verifies user has sufficient FTestXRP tokens.
- Token Approval: Approves OFT Adapter to spend FTestXRP.
- Encode Compose Message: Encodes
(uint256 amount, address recipient)for HyperCore transfer. - Build LayerZero Options:
- Executor gas for
lzReceive(): 200,000 - Compose gas for
lzCompose(): 300,000
- Executor gas for
- Build Send Parameters:
- Destination: HyperEVM (EndpointID for Hyperliquid Testnet:
40294). - Recipient: HyperliquidComposer contract (not user address).
- Amount: Calculated from configured lots (default 1 lot, with 10% buffer).
- Destination: HyperEVM (EndpointID for Hyperliquid Testnet:
- Quote Fee: Calculates the LayerZero cross-chain messaging fee.
- Execute Bridge: Sends tokens via
oftAdapter.send()with compose. - HyperCore Credit: Upon arrival, composer transfers tokens to HyperCore.
Prerequisites
- Balance Requirements:
- FTestXRP tokens on Coston2 (amount you want to bridge).
- C2FLR tokens (for gas fees + LayerZero fees). You can get some from the Flare Testnet faucet.
- Deployed Contracts:
- HyperliquidComposer must be deployed on HyperEVM Testnet.
- Set
COSTON2_COMPOSER=0x80c5ebBD65b4857CA2f917EAB1e9F83bcC947c52in.env.
Configuration
Edit the CONFIG object in the script:
const CONFIG = {
COSTON2_OFT_ADAPTER: "0xCd3d2127935Ae82Af54Fc31cCD9D3440dbF46639",
HYPERLIQUID_COMPOSER: process.env.HYPERLIQUID_COMPOSER || "",
HYPERLIQUID_EID: EndpointId.HYPERLIQUID_V2_TESTNET,
EXECUTOR_GAS: 200_000, // Gas for receiving on HyperEVM
COMPOSE_GAS: 300_000, // Gas for compose execution
BRIDGE_LOTS: "1", // Change this to your desired number of lots
};
How to Run
-
Deploy HyperliquidComposer (first time only):
yarn hardhat run scripts/fassets/deployHyperliquidComposer.ts --network hyperliquidTestnet -
Configure Environment:
# .env file
COSTON2_RPC_URL=https://coston2-api.flare.network/ext/C/rpc
DEPLOYER_PRIVATE_KEY=your_private_key_here
HYPERLIQUID_COMPOSER=0x... # Required! Set this after deploying the composer -
Run the Script on Flare Testnet Coston2:
yarn hardhat run scripts/fassets/bridgeToHyperCore.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 on HyperCore via Hyperliquid's UI or API.
Expected Output
✓ HyperliquidComposer configured: 0x123...
Using account: 0x742d35Cc6634C0532925a3b844Bc454e4438f44e
Token address: 0x8b4abA9C4BD7DD961659b02129beE20c6286e17F
Token decimals: 6
📋 Bridge Details:
From: Coston2
To: Hyperliquid HyperCore (via HyperEVM)
Amount: 11.0 FXRP
HyperCore Recipient: 0x742d35Cc6634C0532925a3b844Bc454e4438f44e
Your FTestXRP balance: 100.0
1️⃣ Checking OFT Adapter token address...
OFT Adapter's underlying 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: 11.0 FXRP
2️⃣ Compose message encoded for HyperCore transfer
3️⃣ LayerZero Fee: 0.001234 C2FLR
4️⃣ Sending FXRP to Hyperliquid HyperCore...
Via HyperliquidComposer: 0x123...
Transaction sent: 0xabc123...
✅ Confirmed in block: 12345678
🎉 Success! Your FXRP is on the way to Hyperliquid HyperCore!
Track your transaction:
https://testnet.layerzeroscan.com/tx/0xabc123...
⏳ The tokens will be automatically transferred to HyperCore once they arrive on HyperEVM.
You can then trade them on the Hyperliquid DEX.
View bridgeToHyperCore.ts source code
/**
* Bridge FXRP from Coston2 to Hyperliquid HyperCore
*
* This script bridges FXRP from Coston2 to HyperEVM with a compose message
* that triggers automatic transfer to HyperCore (the trading layer).
*
* Flow:
* 1. Send FXRP via LayerZero from Coston2 to HyperEVM
* 2. LayerZero delivers to HyperliquidComposer on HyperEVM
* 3. Composer transfers tokens to system address, crediting them on HyperCore
*
* Prerequisites:
* - FTestXRP tokens on Coston2
* - CFLR on Coston2 for gas
* - HyperliquidComposer deployed on HyperEVM
*
* Usage:
* yarn hardhat run scripts/fassets/bridgeToHyperCore.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 {
IERC20MetadataInstance,
FAssetOFTAdapterInstance,
} from "../../typechain-types";
import { getAssetManagerFXRP } from "../utils/getters";
const IERC20Metadata = artifacts.require("IERC20Metadata");
const FAssetOFTAdapter = artifacts.require("FAssetOFTAdapter");
const CONFIG = {
COSTON2_OFT_ADAPTER: "0xCd3d2127935Ae82Af54Fc31cCD9D3440dbF46639",
HYPERLIQUID_COMPOSER: process.env.HYPERLIQUID_COMPOSER || "",
HYPERLIQUID_EID: EndpointId.HYPERLIQUID_V2_TESTNET,
EXECUTOR_GAS: 200_000,
COMPOSE_GAS: 300_000,
BRIDGE_LOTS: "1",
} as const;
type BridgeParams = {
amountToBridge: bigint;
recipientAddress: string;
signerAddress: string;
fAssetAddress: string;
};
type SendParams = {
dstEid: EndpointId;
to: string;
amountLD: string;
minAmountLD: string;
extraOptions: string;
composeMsg: string;
oftCmd: string;
};
/**
* Validates that required configuration is set
*/
function validateConfig() {
if (!CONFIG.HYPERLIQUID_COMPOSER) {
throw new Error(
"HYPERLIQUID_COMPOSER not set in .env!\n" +
" Deploy HyperliquidComposer first on HyperEVM:\n" +
" yarn hardhat run scripts/fassets/deployHyperliquidComposer.ts --network hyperliquidTestnet",
);
}
console.log("✓ HyperliquidComposer configured:", CONFIG.HYPERLIQUID_COMPOSER);
}
/**
* Gets fAsset address and calculates amount from AssetManager
*/
async function getAssetManagerInfo(lots: bigint) {
const assetManager = await getAssetManagerFXRP();
const fAssetAddress = await assetManager.fAsset();
const lotSizeBN = await assetManager.lotSize();
const lotSize = BigInt(lotSizeBN.toString());
const amountToBridge = lotSize * lots;
return {
fAssetAddress,
amountToBridge: (amountToBridge * 11n) / 10n, // 10% buffer
};
}
/**
* Gets the signer and displays account information
*/
async function getSigner(fAssetAddress: string) {
const accounts = await web3.eth.getAccounts();
const signerAddress = accounts[0];
console.log("Using account:", signerAddress);
console.log("Token address:", fAssetAddress);
return signerAddress;
}
/**
* Prepares bridge parameters
*/
function prepareBridgeParams(
signerAddress: string,
fAssetAddress: string,
amountToBridge: bigint,
decimals: number,
): BridgeParams {
const recipientAddress = signerAddress;
console.log("\n📋 Bridge Details:");
console.log("From: Coston2");
console.log("To: Hyperliquid HyperCore (via HyperEVM)");
console.log(
"Amount:",
formatUnits(amountToBridge.toString(), decimals),
"FXRP",
);
console.log("HyperCore Recipient:", recipientAddress);
return { amountToBridge, recipientAddress, signerAddress, fAssetAddress };
}
/**
* Checks if user has sufficient balance to bridge
*/
async function checkBalance(
params: BridgeParams,
decimals: number,
): Promise<IERC20MetadataInstance> {
const fAsset: IERC20MetadataInstance = await IERC20Metadata.at(
params.fAssetAddress,
);
const balance = await fAsset.balanceOf(params.signerAddress);
console.log(
"\nYour FTestXRP balance:",
formatUnits(balance.toString(), decimals),
);
if (BigInt(balance.toString()) < params.amountToBridge) {
console.error("\n❌ Insufficient FTestXRP balance!");
console.log(" Token address: " + params.fAssetAddress);
throw new Error("Insufficient balance");
}
return fAsset;
}
/**
* Approves OFT Adapter to spend FTestXRP
*/
async function approveTokens(
fAsset: IERC20MetadataInstance,
amountToBridge: bigint,
signerAddress: string,
fAssetAddress: string,
decimals: number,
): Promise<FAssetOFTAdapterInstance> {
const oftAdapter: FAssetOFTAdapterInstance = await FAssetOFTAdapter.at(
CONFIG.COSTON2_OFT_ADAPTER,
);
console.log("\n1️⃣ Checking OFT Adapter token address...");
const underlyingToken = await oftAdapter.token();
console.log(" OFT Adapter's underlying token:", underlyingToken);
console.log(" Expected token:", fAssetAddress);
console.log(
" Match:",
underlyingToken.toLowerCase() === fAssetAddress.toLowerCase(),
);
console.log("\n Approving FTestXRP for OFT Adapter...");
console.log(" OFT Adapter address:", oftAdapter.address);
console.log(
" Amount:",
formatUnits(amountToBridge.toString(), decimals),
"FXRP",
);
await fAsset.approve(oftAdapter.address, amountToBridge.toString());
console.log("✅ OFT Adapter approved");
// Verify the allowance
const allowance = await fAsset.allowance(signerAddress, oftAdapter.address);
console.log(
" Verified allowance:",
formatUnits(allowance.toString(), decimals),
"FXRP",
);
return oftAdapter;
}
/**
* Encodes the compose message for HyperCore transfer
* Format: (amount, recipientAddress)
*/
function encodeComposeMessage(params: BridgeParams): string {
// Encode: (amount, recipient) - recipient will receive tokens on HyperCore
const composeMsg = web3.eth.abi.encodeParameters(
["uint256", "address"],
[params.amountToBridge.toString(), params.recipientAddress],
);
console.log("\n2️⃣ Compose message encoded for HyperCore transfer");
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 LayerZero send parameters with compose message
*/
function buildSendParams(
params: BridgeParams,
composeMsg: string,
options: string,
): SendParams {
return {
dstEid: CONFIG.HYPERLIQUID_EID as EndpointId,
to: web3.utils.padLeft(CONFIG.HYPERLIQUID_COMPOSER, 64), // Send to composer, not recipient
amountLD: params.amountToBridge.toString(),
minAmountLD: params.amountToBridge.toString(),
extraOptions: options,
composeMsg: composeMsg,
oftCmd: "0x",
};
}
/**
* Quotes the LayerZero fee for the bridge transaction
*/
async function quoteFee(
oftAdapter: FAssetOFTAdapterInstance,
sendParam: SendParams,
) {
const result = await oftAdapter.quoteSend(sendParam, false);
const nativeFee = BigInt(result.nativeFee.toString());
console.log(
"\n3️⃣ LayerZero Fee:",
formatUnits(nativeFee.toString(), 18),
"C2FLR",
);
return nativeFee;
}
/**
* Executes the bridge transaction with compose
*/
async function executeBridge(
oftAdapter: FAssetOFTAdapterInstance,
sendParam: SendParams,
nativeFee: bigint,
signerAddress: string,
): Promise<void> {
console.log("\n4️⃣ Sending FXRP to Hyperliquid HyperCore...");
console.log(" Via HyperliquidComposer:", CONFIG.HYPERLIQUID_COMPOSER);
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 HyperCore!",
);
console.log("\nTrack your transaction:");
console.log(`https://testnet.layerzeroscan.com/tx/${tx.tx}`);
console.log(
"\n⏳ The tokens will be automatically transferred to HyperCore once they arrive on HyperEVM.",
);
console.log("You can then trade them on the Hyperliquid DEX.");
}
async function main() {
// 0. Validate configuration
validateConfig();
// 1. Get fAsset address and amount from AssetManager
const { fAssetAddress, amountToBridge } = await getAssetManagerInfo(
BigInt(CONFIG.BRIDGE_LOTS),
);
// 2. Get signer and display account info
const signerAddress = await getSigner(fAssetAddress);
// 3. Get token decimals
const fAssetToken: IERC20MetadataInstance =
await IERC20Metadata.at(fAssetAddress);
const decimals = Number(await fAssetToken.decimals());
console.log("Token decimals:", decimals);
// 4. Prepare bridge parameters
const params = prepareBridgeParams(
signerAddress,
fAssetAddress,
amountToBridge,
decimals,
);
// 5. Check balance and get token contract
const fAsset = await checkBalance(params, decimals);
// 6. Approve tokens and get OFT adapter
const oftAdapter = await approveTokens(
fAsset,
params.amountToBridge,
signerAddress,
fAssetAddress,
decimals,
);
// 7. Encode compose message
const composeMsg = encodeComposeMessage(params);
// 8. Build LayerZero options with compose
const options = buildComposeOptions();
// 9. Build send parameters
const sendParam = buildSendParams(params, composeMsg, options);
// 10. Quote the fee
const nativeFee = await quoteFee(oftAdapter, sendParam);
// 11. Execute the bridge transaction
await executeBridge(oftAdapter, sendParam, nativeFee, signerAddress);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Troubleshooting
Error: HYPERLIQUID_COMPOSER not set
- Solution: Deploy the HyperliquidComposer contract on HyperEVM Testnet first.
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: Tokens arrived on HyperEVM but not on HyperCore
- Check that the HyperliquidComposer is correctly configured.
- Verify the compose message was properly encoded.
- Check LayerZero Scan for compose execution status.
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 shared FAssetRedeemComposer is pre-deployed on Coston2 at
0x80c5ebBD65b4857CA2f917EAB1e9F83bcC947c52.
How It Works
Flow Diagram
Step-by-Step Process
- Validate Setup:
- Checks that
COSTON2_COMPOSERis configured. - Gets the signer account.
- Checks that
- Connect to FXRP OFT: Gets the OFT contract on Hyperliquid using the
FXRPOFTartifact. - Prepare Redemption Parameters:
- Number of lots to send (default: 1 lot).
- XRP address to receive native XRP.
- Redeemer address (EVM address).
- Encode Compose Message:
- Encodes
(address redeemer, string xrpAddress)matching theRedeemComposeDatastruct. - This tells the composer what to do when tokens arrive.
- Encodes
- Build LayerZero Options:
- Executor gas for
lzReceive(): 1,000,000 - Compose gas for
lzCompose(): 1,000,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:
- The shared FAssetRedeemComposer is pre-deployed on Coston2 at
0x80c5ebBD65b4857CA2f917EAB1e9F83bcC947c52. - Set
COSTON2_COMPOSER=0x80c5ebBD65b4857CA2f917EAB1e9F83bcC947c52in.env.
- The shared FAssetRedeemComposer is pre-deployed on Coston2 at
Configuration
Edit the CONFIG object in the script:
const CONFIG = {
HYPERLIQUID_FXRP_OFT:
process.env.HYPERLIQUID_FXRP_OFT ||
"0x14bfb521e318fc3d5e92A8462C65079BC7d4284c",
// Shared FAssetRedeemComposer (pre-deployed on Coston2)
COSTON2_COMPOSER:
process.env.COSTON2_COMPOSER ||
"0x80c5ebBD65b4857CA2f917EAB1e9F83bcC947c52",
COSTON2_EID: EndpointId.FLARE_V2_TESTNET,
EXECUTOR_GAS: 1_000_000, // Gas for receiving on Coston2
COMPOSE_GAS: 1_000_000, // Gas for compose execution
// Native value forwarded to cover executor fee on Coston2
COMPOSE_VALUE: BigInt("1000000000000000"), // 0.001 ETH
SEND_LOTS: "1", // Number of lots to redeem
XRP_ADDRESS: "rpHuw4bKSjonKRrKKVYYVedg1jyPrmp", // 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:
2. **Configure Environment**:
```bash
# .env file
HYPERLIQUID_TESTNET_RPC_URL=https://api.hyperliquid-testnet.xyz/evm
DEPLOYER_PRIVATE_KEY=your_private_key_here
COSTON2_COMPOSER=0x80c5ebBD65b4857CA2f917EAB1e9F83bcC947c52
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/autoRedeemFromHyperEVM.ts --network hyperliquidTestnet
Expected Output
Using account: 0x742d35Cc6634C0532925a3b844Bc454e4438f44e
✓ Composer configured: 0x123...
📋 Redemption Parameters:
Amount: 10.0 FXRP
XRP Address: rpHuw4bKSjonKRrKKVYYVedg1jyPrmp
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: rpHuw4bKSjonKRrKKVYYVedg1jyPrmp
✓ 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: rpHuw4bKSjonKRrKKVYYVedg1jyPrmp
View autoRedeemFromHyperEVM.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/autoRedeemFromHyperEVM.ts --network hyperliquidTestnet
*/
import { web3 } from "hardhat";
import { formatUnits } from "ethers";
import { Options } from "@layerzerolabs/lz-v2-utilities";
import { EndpointId } from "@layerzerolabs/lz-definitions";
import type { FXRPOFTInstance } from "../../typechain-types";
import { calculateAmountToSend } from "../utils/fassets";
const FXRPOFT = artifacts.require("FXRPOFT");
// Configuration - using existing deployed contracts
const CONFIG = {
HYPERLIQUID_FXRP_OFT:
process.env.HYPERLIQUID_FXRP_OFT ||
"0x14bfb521e318fc3d5e92A8462C65079BC7d4284c",
COSTON2_COMPOSER:
process.env.COSTON2_COMPOSER ||
"0x80c5ebBD65b4857CA2f917EAB1e9F83bcC947c52",
COSTON2_EID: EndpointId.FLARE_V2_TESTNET,
EXECUTOR_GAS: 1_000_000,
COMPOSE_GAS: 1_000_000,
// Native value forwarded to FAssetRedeemerAccount to cover executor fee on Coston2
COMPOSE_VALUE: BigInt("1000000000000000"), // 0.001 ETH
SEND_LOTS: "1",
XRP_ADDRESS: "rpHuw4bKSjonKRrKKVYUZYYVedg1jyPrmp",
} as const;
type RedemptionParams = {
amountToSend: bigint;
underlyingAddress: string;
redeemer: string;
signerAddress: string;
executor: string;
};
type SendParams = {
dstEid: EndpointId;
to: string;
amountLD: string;
minAmountLD: string;
extraOptions: string;
composeMsg: string;
oftCmd: string;
};
/**
* Gets the signer and validates composer is deployed
*/
async function validateSetup() {
const accounts = await web3.eth.getAccounts();
const signerAddress = accounts[0];
console.log("Using account:", signerAddress);
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 signerAddress;
}
/**
* Prepares redemption parameters
*/
function prepareRedemptionParams(
signerAddress: string,
decimals: number,
): RedemptionParams {
const amountToSend = calculateAmountToSend(BigInt(CONFIG.SEND_LOTS));
const underlyingAddress = CONFIG.XRP_ADDRESS;
const redeemer = signerAddress;
console.log("\n📋 Redemption Parameters:");
console.log(
"Amount:",
formatUnits(amountToSend.toString(), decimals),
"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(): Promise<FXRPOFTInstance> {
console.log(
"Connecting to FXRP OFT on Hyperliquid EVM:",
CONFIG.HYPERLIQUID_FXRP_OFT,
);
const oft = await FXRPOFT.at(CONFIG.HYPERLIQUID_FXRP_OFT);
console.log("\n✓ Connected to FXRP OFT:", CONFIG.HYPERLIQUID_FXRP_OFT);
console.log("OFT address:", oft.address);
return oft;
}
/**
* Encodes the compose message with redemption details
* Format: (address redeemer, string redeemerUnderlyingAddress)
* Matches the RedeemComposeData struct on the shared FAssetRedeemComposer
*/
function encodeComposeMessage(params: RedemptionParams): string {
const composeMsg = web3.eth.abi.encodeParameters(
["address", "string"],
[params.redeemer, params.underlyingAddress],
);
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,
CONFIG.COMPOSE_VALUE.toString(),
);
return options.toHex();
}
/**
* Builds the send parameters for LayerZero
*/
function buildSendParams(
params: RedemptionParams,
composeMsg: string,
options: string,
): SendParams {
return {
dstEid: CONFIG.COSTON2_EID,
to: web3.utils.padLeft(CONFIG.COSTON2_COMPOSER, 64),
amountLD: params.amountToSend.toString(),
minAmountLD: params.amountToSend.toString(),
extraOptions: options,
composeMsg: composeMsg,
oftCmd: "0x",
};
}
/**
* Checks if user has sufficient FXRP balance
*/
async function checkBalance(
oft: FXRPOFTInstance,
signerAddress: string,
amountToSend: bigint,
decimals: number,
): Promise<void> {
const balance = await oft.balanceOf(signerAddress);
console.log(
"\n💰 Current FXRP balance:",
formatUnits(balance.toString(), decimals),
);
if (BigInt(balance.toString()) < amountToSend) {
console.error("\n❌ Insufficient FXRP balance!");
console.log(
" Required:",
formatUnits(amountToSend.toString(), decimals),
"FXRP",
);
console.log(
" Available:",
formatUnits(balance.toString(), decimals),
"FXRP",
);
throw new Error("Insufficient FXRP balance");
}
console.log("Sufficient balance");
}
/**
* Quotes the LayerZero fee for the send transaction
*/
async function quoteFee(oft: FXRPOFTInstance, sendParam: SendParams) {
const result = await oft.quoteSend(sendParam, false);
const nativeFee = BigInt(result.nativeFee.toString());
const lzTokenFee = BigInt(result.lzTokenFee.toString());
console.log(
"\n💵 LayerZero Fee:",
formatUnits(nativeFee.toString(), 18),
"HYPE",
);
return { nativeFee, lzTokenFee };
}
/**
* Executes the send with auto-redeem
*/
async function executeSendAndRedeem(
oft: FXRPOFTInstance,
sendParam: SendParams,
nativeFee: bigint,
lzTokenFee: bigint,
params: RedemptionParams,
decimals: number,
): Promise<void> {
console.log(
"\n🚀 Sending",
formatUnits(params.amountToSend.toString(), decimals),
"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: nativeFee.toString(), lzTokenFee: lzTokenFee.toString() },
params.signerAddress,
{ value: nativeFee.toString() },
);
console.log("\n✓ Transaction sent:", tx.tx);
console.log("✅ Confirmed in block:", tx.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.tx}`);
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 signerAddress = await validateSetup();
// 2. Connect to OFT contract
const oft = await connectToOFT();
// 3. Get token decimals
const decimals = Number(await oft.decimals());
console.log("Token decimals:", decimals);
// 4. Prepare redemption parameters
const params = prepareRedemptionParams(signerAddress, decimals);
// 5. Encode compose message
const composeMsg = encodeComposeMessage(params);
// 6. Build LayerZero options
const options = buildComposeOptions();
// 7. Build send parameters
const sendParam = buildSendParams(params, composeMsg, options);
// 8. Check balance
await checkBalance(oft, params.signerAddress, params.amountToSend, decimals);
// 9. Quote fee
const { nativeFee, lzTokenFee } = await quoteFee(oft, sendParam);
// 10. Execute send with auto-redeem
await executeSendAndRedeem(
oft,
sendParam,
nativeFee,
lzTokenFee,
params,
decimals,
);
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
Auto-Redeem from HyperCore
What It Is
This script provides a complete auto-redemption flow starting from HyperCore (Hyperliquid's spot trading layer) to native XRP on the XRP Ledger. Unlike the HyperEVM flow which assumes you already have FXRP on HyperEVM, this script handles the full journey: HyperCore → HyperEVM → Coston2 → XRP Ledger.
HyperCore is Hyperliquid's high-performance spot trading layer where tokens are held in spot wallets. HyperEVM is Hyperliquid's EVM-compatible execution environment where tokens exist as ERC-20s. Tokens must be transferred from HyperCore to HyperEVM before they can be bridged via LayerZero.
Prerequisites:
- You must have FXRP tokens in your HyperCore spot wallet.
- You need HYPE tokens on HyperEVM for LayerZero fees.
- The shared FAssetRedeemComposer is pre-deployed on Coston2 at
0x80c5ebBD65b4857CA2f917EAB1e9F83bcC947c52.
How It Works
Flow Diagram
Step-by-Step Process
- Validate Setup:
- Checks that
COSTON2_COMPOSERis configured. - Gets the signer account.
- Checks that
- Check HyperCore Balance:
- Queries HyperCore spot balance via the
/infoAPI endpoint. - Verifies sufficient FXRP is available.
- Queries HyperCore spot balance via the
- Transfer from HyperCore to HyperEVM:
- Prepares an EIP-712 signed
spotSendmessage. - Sends to the FXRP system address (
0x20000000000000000000000000000000000005a3). - Waits for the transfer to settle on HyperEVM.
- Prepares an EIP-712 signed
- Connect to FXRP OFT: Gets the OFT contract on HyperEVM.
- Prepare Redemption Parameters:
- Number of lots to send (default: 1 lot).
- XRP address to receive native XRP.
- Redeemer address (EVM address).
- Check HyperEVM Balance: Verifies tokens arrived from HyperCore.
- Encode Compose Message:
- Encodes
(address redeemer, string xrpAddress)matching theRedeemComposeDatastruct. - This tells the composer what to do when tokens arrive.
- Encodes
- Build LayerZero Options:
- Executor gas for
lzReceive(): 1,000,000 - Compose gas for
lzCompose(): 1,000,000
- Executor gas for
- Quote Fee: Calculates LayerZero messaging fee.
- Execute Send: Sends FXRP with compose message to Coston2.
- Auto-Redemption: On arrival, composer automatically redeems to XRP.
Understanding HyperCore Transfers
The script uses Hyperliquid's spotSend API to transfer tokens from HyperCore to HyperEVM.
This requires EIP-712 signing with specific parameters.
EIP-712 Domain
const EIP712_DOMAIN = {
name: "HyperliquidSignTransaction",
version: "1",
chainId: 42161, // Arbitrum chainId - required by Hyperliquid API
verifyingContract: "0x0000000000000000000000000000000000000000",
};
Hyperliquid's API requires Arbitrum's chainId (42161) for all EIP-712 signatures, regardless of the actual chain. This is a legacy requirement from when Hyperliquid settled on Arbitrum.
System Address
To transfer tokens from HyperCore to HyperEVM, you send them to a special system address. Each token has a unique system address based on its token index.
// System address for FXRP on testnet (token index 1443 = 0x5A3)
FXRP_SYSTEM_ADDRESS: "0x20000000000000000000000000000000000005a3";
Prerequisites
- Network: Must run on Hyperliquid EVM Testnet.
- Balance Requirements:
- FXRP tokens in your HyperCore spot wallet (amount you want to redeem).
- HYPE tokens on HyperEVM (for gas fees + LayerZero fees).
- Deployed Contracts:
- The shared FAssetRedeemComposer is pre-deployed on Coston2 at
0x80c5ebBD65b4857CA2f917EAB1e9F83bcC947c52. - Set
COSTON2_COMPOSER=0x80c5ebBD65b4857CA2f917EAB1e9F83bcC947c52in.env.
- The shared FAssetRedeemComposer is pre-deployed on Coston2 at
Configuration
Edit the CONFIG object in the script:
const CONFIG = {
// Testnet config
HYPERLIQUID_API:
process.env.HYPERLIQUID_API || "https://api.hyperliquid-testnet.xyz",
HYPERLIQUID_FXRP_OFT:
process.env.HYPERLIQUID_FXRP_OFT ||
"0x14bfb521e318fc3d5e92A8462C65079BC7d4284c",
// System address for FXRP on testnet (token index 1443 = 0x5A3)
FXRP_SYSTEM_ADDRESS: "0x20000000000000000000000000000000000005a3",
FXRP_TOKEN_ID: "FXRP:0x2af78df5b575b45eea8a6a1175026dd6",
// Shared FAssetRedeemComposer (pre-deployed on Coston2)
COSTON2_COMPOSER:
process.env.COSTON2_COMPOSER ||
"0x80c5ebBD65b4857CA2f917EAB1e9F83bcC947c52",
COSTON2_EID: EndpointId.FLARE_V2_TESTNET,
EXECUTOR_GAS: 1_000_000, // Gas for receiving on Coston2
COMPOSE_GAS: 1_000_000, // Gas for compose execution
// Native value forwarded to cover executor fee on Coston2
COMPOSE_VALUE: BigInt("1000000000000000"), // 0.001 HYPE
SEND_LOTS: "1", // Number of lots to redeem
XRP_ADDRESS: process.env.XRP_ADDRESS || "rpHuw4bKSjonKRrKKVYYVedg1jyPrmp",
HYPERLIQUID_CHAIN: "Testnet",
};
How to Run
PREREQUISITE: You must have FXRP tokens in your HyperCore spot wallet before running this script.
2. **Configure Environment**:
```bash
# .env file
HYPERLIQUID_TESTNET_RPC_URL=https://api.hyperliquid-testnet.xyz/evm
DEPLOYER_PRIVATE_KEY=your_private_key_here
COSTON2_COMPOSER=0x80c5ebBD65b4857CA2f917EAB1e9F83bcC947c52
HYPERLIQUID_FXRP_OFT=0x14bfb521e318fc3d5e92A8462C65079BC7d4284c
XRP_ADDRESS=rYourXRPAddressHere # Your XRP ledger address
-
Ensure you have FXRP on HyperCore:
- Bridge FXRP to HyperCore using
bridgeToHyperEVM.tsfollowed by a transfer to HyperCore. - Or acquire FXRP on Hyperliquid's spot market.
- Bridge FXRP to HyperCore using
-
Run the Script on Hyperliquid Testnet:
yarn hardhat run scripts/fassets/autoRedeemFromHyperCore.ts --network hyperliquidTestnet
Expected Output
============================================================
FXRP Auto-Redemption from HyperCore
HyperCore → HyperEVM → Coston2 → XRP Ledger
============================================================
Using account: 0x742d35Cc6634C0532925a3b844Bc454e4438f44e
✓ Coston2 Composer configured: 0x80c5ebBD65b4857CA2f917EAB1e9F83bcC947c52
📊 Checking HyperCore spot balance...
HyperCore FXRP balance: 25.0
📤 Step 1: Transferring FXRP from HyperCore to HyperEVM...
Amount: 10 FXRP
Destination (system address): 0x20000000000000000000000000000000000005a3
✅ HyperCore → HyperEVM transfer initiated
Response: {"status":"ok","response":{"type":"default"}}
Waiting for transfer to settle on HyperEVM...
Connecting to FXRP OFT on HyperEVM: 0x14bfb521e318fc3d5e92A8462C65079BC7d4284c
✓ Connected to FXRP OFT
Token decimals: 6
📋 Redemption Parameters:
Amount: 10.0 FXRP
XRP Address: rpHuw4bKSjonKRrKKVYYVedg1jyPrmp
Redeemer: 0x742d35Cc6634C0532925a3b844Bc454e4438f44e
💰 HyperEVM FXRP balance: 10.0
✓ Sufficient balance on HyperEVM
Compose message encoded for auto-redemption
💵 LayerZero Fee: 0.003456 HYPE
📤 Step 2: Sending FXRP from HyperEVM to Coston2 with auto-redeem...
Amount: 10.0 FXRP
Target composer: 0x80c5ebBD65b4857CA2f917EAB1e9F83bcC947c52
XRP destination: rpHuw4bKSjonKRrKKVYYVedg1jyPrmp
✓ Transaction sent: 0xabc123...
✅ Confirmed in block: 9876543
🎉 Success! Auto-redemption initiated!
📊 Track your cross-chain transaction:
https://testnet.layerzeroscan.com/tx/0xabc123...
⏳ The auto-redeem will execute once the message arrives on Coston2.
XRP will be sent to: rpHuw4bKSjonKRrKKVYYVedg1jyPrmp
View autoRedeemFromHyperCore.ts source code
/**
* Auto-redeem FXRP from Hyperliquid HyperCore to native XRP
*
* This script demonstrates the complete flow:
* 1. Transfer FXRP from HyperCore spot to HyperEVM (via Hyperliquid spotSend API)
* 2. Send FXRP from HyperEVM to Coston2 via LayerZero with compose
* 3. FAssetRedeemComposer on Coston2 automatically redeems to native XRP
*
* Prerequisites:
* - FXRP tokens on HyperCore spot wallet
* - HYPE on HyperEVM for LayerZero fees
* - FAssetRedeemComposer deployed on Coston2
*
* Usage:
* yarn hardhat run scripts/fassets/autoRedeemFromHyperCore.ts --network hyperliquidTestnet
*/
import { web3 } from "hardhat";
import { formatUnits } from "ethers";
import { Options } from "@layerzerolabs/lz-v2-utilities";
import { EndpointId } from "@layerzerolabs/lz-definitions";
import type { FXRPOFTInstance } from "../../typechain-types";
import { calculateAmountToSend } from "../utils/fassets";
const FXRPOFT = artifacts.require("FXRPOFT");
// Configuration
const CONFIG = {
// Testnet config
HYPERLIQUID_API:
process.env.HYPERLIQUID_API || "https://api.hyperliquid-testnet.xyz",
HYPERLIQUID_FXRP_OFT:
process.env.HYPERLIQUID_FXRP_OFT ||
"0x14bfb521e318fc3d5e92A8462C65079BC7d4284c",
// System address for FXRP on testnet (token index 1443 = 0x5A3)
FXRP_SYSTEM_ADDRESS: "0x20000000000000000000000000000000000005a3",
FXRP_TOKEN_ID: "FXRP:0x2af78df5b575b45eea8a6a1175026dd6",
COSTON2_COMPOSER:
process.env.COSTON2_COMPOSER ||
"0x80c5ebBD65b4857CA2f917EAB1e9F83bcC947c52",
COSTON2_EID: EndpointId.FLARE_V2_TESTNET,
EXECUTOR_GAS: 1_000_000,
COMPOSE_GAS: 1_000_000,
// Native value forwarded to FAssetRedeemerAccount to cover executor fee on Coston2
COMPOSE_VALUE: BigInt("1000000000000000"), // 0.001 HYPE
SEND_LOTS: "1",
XRP_ADDRESS: process.env.XRP_ADDRESS || "rpHuw4bKSjonKRrKKVYUZYYVedg1jyPrmp",
HYPERLIQUID_CHAIN: "Testnet",
} as const;
type RedemptionParams = {
amountToSend: bigint;
underlyingAddress: string;
redeemer: string;
signerAddress: string;
};
type SendParams = {
dstEid: EndpointId;
to: string;
amountLD: string;
minAmountLD: string;
extraOptions: string;
composeMsg: string;
oftCmd: string;
};
/**
* EIP-712 domain for Hyperliquid signing.
* Note: Hyperliquid's API requires Arbitrum's chainId (42161) for all EIP-712 signatures,
* regardless of the actual chain. This is a legacy requirement from when Hyperliquid settled on Arbitrum.
*/
const EIP712_DOMAIN = {
name: "HyperliquidSignTransaction",
version: "1",
chainId: 42161,
verifyingContract: "0x0000000000000000000000000000000000000000",
};
/**
* EIP-712 types for spotSend action
*/
const SPOT_SEND_TYPES = {
"HyperliquidTransaction:SpotSend": [
{ name: "hyperliquidChain", type: "string" },
{ name: "destination", type: "string" },
{ name: "token", type: "string" },
{ name: "amount", type: "string" },
{ name: "time", type: "uint64" },
],
};
/**
* Validates the setup
*/
async function validateSetup() {
const accounts = await web3.eth.getAccounts();
const signerAddress = accounts[0];
console.log("Using account:", signerAddress);
if (!CONFIG.COSTON2_COMPOSER) {
throw new Error(
"COSTON2_COMPOSER not set in .env!\n" +
" Deploy FAssetRedeemComposer first on Coston2:\n" +
" yarn hardhat run scripts/fassets/deployFassetRedeemComposer.ts --network coston2",
);
}
console.log("✓ Coston2 Composer configured:", CONFIG.COSTON2_COMPOSER);
return signerAddress;
}
/**
* Queries HyperCore spot balance for FXRP
*/
async function getHyperCoreBalance(address: string): Promise<string> {
const response = await fetch(CONFIG.HYPERLIQUID_API + "/info", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "spotClearinghouseState",
user: address,
}),
});
const data = await response.json();
const balances = data.balances || [];
// Find FXRP balance
const fxrpBalance = balances.find(
(b: { coin: string; hold: string }) => b.coin === "FXRP",
);
return fxrpBalance ? fxrpBalance.hold : "0";
}
/**
* Transfers FXRP from HyperCore to HyperEVM using spotSend API
*/
async function transferFromHyperCoreToHyperEVM(
signerAddress: string,
amount: string,
): Promise<void> {
console.log("\n📤 Step 1: Transferring FXRP from HyperCore to HyperEVM...");
console.log(" Amount:", amount, "FXRP");
console.log(" Destination (system address):", CONFIG.FXRP_SYSTEM_ADDRESS);
const timestamp = Date.now();
// Prepare the message for EIP-712 signing
const message = {
hyperliquidChain: CONFIG.HYPERLIQUID_CHAIN,
destination: CONFIG.FXRP_SYSTEM_ADDRESS,
token: CONFIG.FXRP_TOKEN_ID,
amount: amount,
time: timestamp,
};
// Sign with EIP-712 using web3
const typedData = {
types: {
EIP712Domain: [
{ name: "name", type: "string" },
{ name: "version", type: "string" },
{ name: "chainId", type: "uint256" },
{ name: "verifyingContract", type: "address" },
],
...SPOT_SEND_TYPES,
},
primaryType: "HyperliquidTransaction:SpotSend" as const,
domain: EIP712_DOMAIN,
message: message,
};
const signature = await web3.eth.signTypedData(signerAddress, typedData);
// Prepare the action payload
const action = {
type: "spotSend",
hyperliquidChain: CONFIG.HYPERLIQUID_CHAIN,
signatureChainId: "0xa4b1", // Arbitrum chainId (42161) in hex - required by Hyperliquid API
destination: CONFIG.FXRP_SYSTEM_ADDRESS.toLowerCase(),
token: CONFIG.FXRP_TOKEN_ID,
amount: amount,
time: timestamp,
};
// Send to Hyperliquid exchange API
const response = await fetch(CONFIG.HYPERLIQUID_API + "/exchange", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: action,
nonce: timestamp,
signature: signature,
}),
});
const result = await response.json();
if (result.status === "err") {
throw new Error(`HyperCore transfer failed: ${result.response}`);
}
console.log("✅ HyperCore → HyperEVM transfer initiated");
console.log(" Response:", JSON.stringify(result));
// Wait for transfer to complete (typically ~2 seconds for HyperEVM block)
console.log(" Waiting for transfer to settle on HyperEVM...");
await new Promise((resolve) => setTimeout(resolve, 5000));
}
/**
* Prepares redemption parameters
*/
function prepareRedemptionParams(
signerAddress: string,
decimals: number,
): RedemptionParams {
const amountToSend = calculateAmountToSend(BigInt(CONFIG.SEND_LOTS));
const underlyingAddress = CONFIG.XRP_ADDRESS;
const redeemer = signerAddress;
console.log("\n📋 Redemption Parameters:");
console.log(
"Amount:",
formatUnits(amountToSend.toString(), decimals),
"FXRP",
);
console.log("XRP Address:", underlyingAddress);
console.log("Redeemer:", redeemer);
return { amountToSend, underlyingAddress, redeemer, signerAddress };
}
/**
* Connects to the OFT contract on HyperEVM
*/
async function connectToOFT(): Promise<FXRPOFTInstance> {
console.log(
"\nConnecting to FXRP OFT on HyperEVM:",
CONFIG.HYPERLIQUID_FXRP_OFT,
);
const oft = await FXRPOFT.at(CONFIG.HYPERLIQUID_FXRP_OFT);
console.log("✓ Connected to FXRP OFT");
return oft;
}
/**
* Encodes the compose message for auto-redemption
*/
function encodeComposeMessage(params: RedemptionParams): string {
// Matches the RedeemComposeData struct on the shared FAssetRedeemComposer
const composeMsg = web3.eth.abi.encodeParameters(
["address", "string"],
[params.redeemer, params.underlyingAddress],
);
console.log("Compose message encoded for auto-redemption");
return composeMsg;
}
/**
* Builds LayerZero options with compose
*/
function buildComposeOptions(): string {
const options = Options.newOptions()
.addExecutorLzReceiveOption(CONFIG.EXECUTOR_GAS, 0)
.addExecutorComposeOption(
0,
CONFIG.COMPOSE_GAS,
CONFIG.COMPOSE_VALUE.toString(),
);
return options.toHex();
}
/**
* Builds send parameters for LayerZero
*/
function buildSendParams(
params: RedemptionParams,
composeMsg: string,
options: string,
): SendParams {
return {
dstEid: CONFIG.COSTON2_EID,
to: web3.utils.padLeft(CONFIG.COSTON2_COMPOSER, 64),
amountLD: params.amountToSend.toString(),
minAmountLD: params.amountToSend.toString(),
extraOptions: options,
composeMsg: composeMsg,
oftCmd: "0x",
};
}
/**
* Checks HyperEVM balance
*/
async function checkHyperEVMBalance(
oft: FXRPOFTInstance,
signerAddress: string,
amountToSend: bigint,
decimals: number,
): Promise<void> {
const balance = await oft.balanceOf(signerAddress);
console.log(
"\n💰 HyperEVM FXRP balance:",
formatUnits(balance.toString(), decimals),
);
if (BigInt(balance.toString()) < amountToSend) {
console.error("\n❌ Insufficient FXRP balance on HyperEVM!");
console.log(
" Required:",
formatUnits(amountToSend.toString(), decimals),
"FXRP",
);
console.log(
" Available:",
formatUnits(balance.toString(), decimals),
"FXRP",
);
throw new Error(
"Insufficient FXRP balance on HyperEVM. Transfer from HyperCore may still be pending.",
);
}
console.log("✓ Sufficient balance on HyperEVM");
}
/**
* Quotes LayerZero fee
*/
async function quoteFee(oft: FXRPOFTInstance, sendParam: SendParams) {
const result = await oft.quoteSend(sendParam, false);
const nativeFee = BigInt(result.nativeFee.toString());
const lzTokenFee = BigInt(result.lzTokenFee.toString());
console.log(
"\n💵 LayerZero Fee:",
formatUnits(nativeFee.toString(), 18),
"HYPE",
);
return { nativeFee, lzTokenFee };
}
/**
* Executes LayerZero send with auto-redeem
*/
async function executeSendAndRedeem(
oft: FXRPOFTInstance,
sendParam: SendParams,
nativeFee: bigint,
lzTokenFee: bigint,
params: RedemptionParams,
decimals: number,
): Promise<void> {
console.log(
"\n📤 Step 2: Sending FXRP from HyperEVM to Coston2 with auto-redeem...",
);
console.log(
" Amount:",
formatUnits(params.amountToSend.toString(), decimals),
"FXRP",
);
console.log(" Target composer:", CONFIG.COSTON2_COMPOSER);
console.log(" XRP destination:", params.underlyingAddress);
const tx = await oft.send(
sendParam,
{ nativeFee: nativeFee.toString(), lzTokenFee: lzTokenFee.toString() },
params.signerAddress,
{ value: nativeFee.toString() },
);
console.log("\n✓ Transaction sent:", tx.tx);
console.log("✅ Confirmed in block:", tx.receipt.blockNumber);
console.log("\n🎉 Success! Auto-redemption initiated!");
console.log("\n📊 Track your cross-chain transaction:");
console.log(`https://testnet.layerzeroscan.com/tx/${tx.tx}`);
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() {
console.log("=".repeat(60));
console.log("FXRP Auto-Redemption from HyperCore");
console.log("HyperCore → HyperEVM → Coston2 → XRP Ledger");
console.log("=".repeat(60));
// 1. Validate setup
const signerAddress = await validateSetup();
// 2. Check HyperCore balance
console.log("\n📊 Checking HyperCore spot balance...");
const hyperCoreBalance = await getHyperCoreBalance(signerAddress);
console.log(" HyperCore FXRP balance:", hyperCoreBalance);
const lotSize = 10; // 1 lot = 10 FXRP
const requiredAmount = parseInt(CONFIG.SEND_LOTS) * lotSize;
if (parseFloat(hyperCoreBalance) < requiredAmount) {
throw new Error(
`Insufficient FXRP on HyperCore. Have: ${hyperCoreBalance}, Need: ${requiredAmount}\n` +
"Bridge FXRP to HyperCore first using bridgeToHyperCore.ts",
);
}
// 3. Transfer from HyperCore to HyperEVM
await transferFromHyperCoreToHyperEVM(
signerAddress,
requiredAmount.toString(),
);
// 4. Connect to OFT on HyperEVM
const oft = await connectToOFT();
// 5. Get token decimals
const decimals = Number(await oft.decimals());
console.log("Token decimals:", decimals);
// 6. Prepare redemption parameters
const params = prepareRedemptionParams(signerAddress, decimals);
// 7. Check HyperEVM balance (should have tokens now)
await checkHyperEVMBalance(
oft,
params.signerAddress,
params.amountToSend,
decimals,
);
// 8. Encode compose message
const composeMsg = encodeComposeMessage(params);
// 9. Build LayerZero options
const options = buildComposeOptions();
// 10. Build send parameters
const sendParam = buildSendParams(params, composeMsg, options);
// 11. Quote fee
const { nativeFee, lzTokenFee } = await quoteFee(oft, sendParam);
// 12. Execute send with auto-redeem
await executeSendAndRedeem(
oft,
sendParam,
nativeFee,
lzTokenFee,
params,
decimals,
);
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
Troubleshooting
Error: Insufficient FXRP on HyperCore
- Solution: Bridge FXRP to HyperCore first or acquire FXRP on Hyperliquid's spot market.
Error: HyperCore transfer failed
- Check that your wallet is properly configured with Hyperliquid.
- Verify the EIP-712 signature is being generated correctly.
- Ensure the FXRP_TOKEN_ID matches the correct token on the network.
Error: Insufficient FXRP balance on HyperEVM
- The transfer from HyperCore may still be pending.
- Wait a few more seconds and try again.
- Check your HyperEVM balance directly.
Error: Insufficient HYPE for gas
- Get HYPE tokens from Hyperliquid testnet faucet.
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.
Q: What's the difference between HyperCore and HyperEVM? A: HyperCore is Hyperliquid's high-performance spot trading layer where tokens are held in spot wallets. HyperEVM is Hyperliquid's EVM-compatible execution environment where tokens exist as ERC-20s. You need to transfer tokens from HyperCore to HyperEVM before bridging via LayerZero.
Q: Why does the HyperCore transfer use Arbitrum's chainId? A: Hyperliquid's API requires Arbitrum's chainId (42161) for all EIP-712 signatures. This is a legacy requirement from when Hyperliquid settled on Arbitrum.
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