Transfer frxUSD from EVM chains
This guide demonstrates how to use the viem framework in a simple script that enables a user to transfer frxUSD from any EVM-compatible chains listed on the FraxZero RemoteHop addresses page using the RemoteHop method.
In this example, we will be transferring frxUSD from Ethereum mainnet to Arbitrum mainnet and vice versa.
Note: This guide does not support frxUSD transfers with Fraxtal and Solana as a source chain or Fraxtal as a destination chain. To support frxUSD transfers from Solana, see our Transfer frxUSD from Solana guide. To support frxUSD transfers from or to Fraxtal, see our Transfer frxUSD with Fraxtal guide.
Prerequisites
Before you start building the sample app to perform a frxUSD transfer, ensure you have met the following prerequisites:
-
Install Node.js and npm
- Download and install Node.js directly or use a version manager like nvm.
- npm is included with Node.js.
-
Set up a non-custodial wallet (for example, MetaMask)
- You can download, install, and create a MetaMask wallet from its official website.
- During setup, create a wallet on Ethereum mainnet.
- Retrieve the private key for your wallet, as it will be required in the script below.
-
Fund your wallet with the gas token on the source chains
- For this guide, we will be transferring frxUSD from Ethereum mainnet and Arbitrum mainnet and vice versa. Therefore, you will need to fund your wallet with ETH on both networks.
-
Fund your wallet with frxUSD on the source chain
- For this guide, we will be first transferring frxUSD from Ethereum mainnet to Arbitrum mainnet and vice versa. Therefore, you will need to fund your wallet (opens in a new tab) with frxUSD on Ethereum mainnet.
Project setup
To build the script, first set up your project environment and install the required dependencies.
1. Set up a new project
Create a new directory and initialize a new Node.js
project with default settings:
mkdir frxusd-cross-chain-transfers
cd frxusd-cross-chain-transfers
npm init -y
This also creates a default package.json
file.
2. Install dependencies
In your project directory, install the required dependencies, including viem
:
npm install dotenv@^16.4.7 viem@^2.23.4
This sets up your development environment with the necessary libraries for building the script. It also updates the package.json
file with the dependencies.
3. Add module type
Add "type": "module"
to the package.json
file:
{
"name": "frax-cross-chain-transfer",
"version": "1.0.0",
"type": "module",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node transfer.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"dotenv": "^16.4.7",
"viem": "^2.23.4"
}
}
4. Configure environment variables
Create a .env
file in your project directory and add your wallet private key:
echo "PRIVATE_KEY=your-private-key-here" > .env
Warning: This is strictly for testing purposes. Never share your private key.
Script setup
This section covers the necessary setup for the transfer.js script, including defining keys and addresses, and configuring the wallet client for interacting with the source and destination chains.
1. Replace with your private key and wallet address
Ensure that this section of the file includes your private key for PRIVATE_KEY
and associated wallet address for DESTINATION_ADDRESS
.
The script also predefines the RemoteHop contract addresses, the frxUSD contract addresses, the lockbox contract address (only for transfers from Ethereum using the RemoteHop method), the destination EIDs, and the transfer amount. These definitions are critical for successfully transferring frxUSD between the intended wallets.
// ============ Configuration Constants ============
// Authentication
const PRIVATE_KEY = process.env.PRIVATE_KEY;
const account = privateKeyToAccount(`0x${PRIVATE_KEY}`, { nonceManager });
// FILL IN THE FOLLOWING VARIABLE
const DESTINATION_ADDRESS = 'enter-your-recipient-wallet-address-here'; // Recipient address
const DESTINATION_ADDRESS_BYTES32 = `0x000000000000000000000000${DESTINATION_ADDRESS.slice(2)}`; // Convert to bytes32 format
// RemoteHop Contract Addresses
const ETHEREUM_MAINNET_REMOTE_HOP = '0x3ad4dC2319394bB4BE99A0e4aE2AbF7bCEbD648E'; // RemoteHop contract address on source chain
const ARBITRUM_MAINNET_REMOTE_HOP = '0x29F5DBD0FE72d8f11271FCBE79Cb87E18a83C70A'; // RemoteHop contract address on destination chain
// frxUSD Contract Addresses
const ETHEREUM_MAINNET_FRXUSD_ADDRESS = '0xCAcd6fd266aF91b8AeD52aCCc382b4e165586E29'; // frxUSD contract address on source chain
const ARBITRUM_MAINNET_FRXUSD_ADDRESS = '0x80Eede496655FB9047dd39d9f418d5483ED600df'; // frxUSD contract address on destination chain
// Lockbox Contract Address
const ETHEREUM_MAINNET_LOCKBOX_ADDRESS = '0x566a6442a5a6e9895b9dca97cc7879d632c6e4b0'; // When transferring from Ethereum to any other EVM chain (excluding Fraxtal) using the RemoteHop method, the Ethereum Lockbox address must be used for `quote` and `send` methods.
// EIDs for destination chains
const ARBITRUM_MAINNET_EID = 30110; // EID of destination chain for first frxUSD transfer (Arbitrum mainnet)
const ETHEREUM_MAINNET_EID = 30101; // EID of destination chain for second frxUSD transfer (Ethereum mainnet)
// Transfer Parameters for both frxUSD transfers
const TRANSFER_AMOUNT = 1_000_000_000_000_000_000n; // 1 frxUSD (18 decimals)
2. Set up wallet and public clients
The wallet client and public client configure the appropriate network settings using viem
. In this example, the script will be transferring frxUSD from Ethereum mainnet to Arbitrum mainnet and vice versa.
// Set up wallet clients
const ethereumClient = createWalletClient({
chain: mainnet,
transport: http(),
account,
});
const arbitrumClient = createWalletClient({
chain: arbitrum,
transport: http(),
account,
});
// Set up public clients
const ethereumPublicClient = createPublicClient({
chain: mainnet,
transport: http(),
});
const arbitrumPublicClient = createPublicClient({
chain: arbitrum,
transport: http(),
});
frxUSD cross-chain transfer process
The following sections outline the relevant transfer logic of the sample script. In this example, we are first transferring 1 frxUSD from Ethereum mainnet to Arbitrum mainnet. Then we are transferring 1 frxUSD back to Ethereum mainnet from Arbitrum mainnet. Follow the steps below to perform the transfer:
1. Approve frxUSD
For the first transfer from Ethereum mainnet to Arbitrum mainnet, grant approval for the FraxZero RemoteHop contract deployed on Ethereum mainnet.
For the second transfer from Arbitrum mainnet to Ethereum mainnet, grant approval for the FraxZero RemoteHop contract deployed on Arbitrum mainnet.
This allows the contracts to withdraw frxUSD from the specified wallet address on the source chains. For other EVM chains as a source chain, use the appropriate RemoteHop contract address from the frxUSD RemoteHop addresses page.
async function approveFrxUSD(sourceChainClient, frxUsdTokenAddress, remoteHopAddress) {
console.log('Approving frxUSD transfer...');
const approveTx = await sourceChainClient.sendTransaction({
to: frxUsdTokenAddress,
data: encodeFunctionData({
abi: [
{
type: 'function',
name: 'approve',
stateMutability: 'nonpayable',
inputs: [
{ name: 'spender', type: 'address' },
{ name: 'amount', type: 'uint256' },
],
outputs: [{ name: '', type: 'bool' }],
},
],
functionName: 'approve',
args: [remoteHopAddress, TRANSFER_AMOUNT], // Set max allowance in wei (change as needed)
}),
});
console.log(`frxUSD Approval Tx: ${approveTx}`);
}
async function approveFrxUSDForEthereumToArbitrumTransfer() {
await approveFrxUSD(
ethereumClient,
ETHEREUM_MAINNET_FRXUSD_ADDRESS,
ETHEREUM_MAINNET_REMOTE_HOP,
);
}
async function approveFrxUSDForArbitrumToEthereumTransfer() {
await approveFrxUSD(
arbitrumClient,
ARBITRUM_MAINNET_FRXUSD_ADDRESS,
ARBITRUM_MAINNET_REMOTE_HOP,
);
}
2. Retrieve FraxZero Quote
In this step, you call the quote
function on the RemoteHop contracts deployed on Ethereum mainnet and Arbitrum mainnet to get the native fee amounts required for each respective transfer. You specify the following parameters:
- OFT: The contract address of the frxUSD token being transferred from the source chain. For transfers from Ethereum to Arbitrum or any other EVM chain (excluding Fraxtal), use the Ethereum Lockbox address instead of the frxUSD token address for this parameter.
- Destination EID: The EID of the destination chain (EIDs can be found here)
- Destination address: The wallet address that will receive the transferred frxUSD (in bytes32 format)
- Amount: The amount of frxUSD to transfer (in wei)
The quote function returns the native fee amount required for the transfer.
async function retrieveQuote(
sourceChainClient,
remoteHopAddress,
frxUsdTokenAddressOrEthereumLockboxAddress,
destinationEid,
) {
console.log('Retrieving quote...');
const quote = await sourceChainClient.readContract({
address: remoteHopAddress,
abi: [
{
inputs: [
{ internalType: 'address', name: '_oft', type: 'address' },
{ internalType: 'uint32', name: '_dstEid', type: 'uint32' },
{ internalType: 'bytes32', name: '_to', type: 'bytes32' },
{ internalType: 'uint256', name: '_amountLD', type: 'uint256' },
],
name: 'quote',
outputs: [
{
components: [
{ internalType: 'uint256', name: 'nativeFee', type: 'uint256' },
{
internalType: 'uint256',
name: 'lzTokenFee',
type: 'uint256',
},
],
internalType: 'struct MessagingFee',
name: 'fee',
type: 'tuple',
},
],
stateMutability: 'view',
type: 'function',
},
],
functionName: 'quote',
args: [
frxUsdTokenAddressOrEthereumLockboxAddress,
destinationEid,
DESTINATION_ADDRESS_BYTES32, // Use bytes32 format
TRANSFER_AMOUNT,
],
});
const nativeFee = quote.nativeFee;
return nativeFee;
}
3. Transfer frxUSD
After retrieving the native fee amounts, you can call the sendOFT
function on the RemoteHop contracts deployed on Ethereum mainnet and Arbitrum mainnet to transfer frxUSD from Ethereum mainnet to Arbitrum mainnet and back to Ethereum mainnet. This function takes the following parameters:
- OFT: The contract address of the frxUSD token being transferred from the source chain. For transfers from Ethereum to Arbitrum or any other EVM chain (excluding Fraxtal), use the Ethereum Lockbox address instead of the frxUSD token address for this parameter.
- Destination EID: The EID of the destination chain
- Destination address: The wallet address that will receive the transferred frxUSD (in bytes32 format)
- Amount: The amount of frxUSD to transfer (in wei)
async function transferFrxUSD(
sourceWalletClient,
sourcePublicClient,
remoteHopAddress,
frxUsdTokenAddressOrEthereumLockboxAddress,
destinationEid,
) {
console.log('Transferring frxUSD...');
const nativeFee = await retrieveQuote(
sourcePublicClient,
remoteHopAddress,
frxUsdTokenAddressOrEthereumLockboxAddress,
destinationEid,
);
const transferTx = await sourceWalletClient.sendTransaction({
to: remoteHopAddress,
value: nativeFee,
data: encodeFunctionData({
abi: [
{
inputs: [
{ internalType: 'address', name: '_oft', type: 'address' },
{ internalType: 'uint32', name: '_dstEid', type: 'uint32' },
{ internalType: 'bytes32', name: '_to', type: 'bytes32' },
{ internalType: 'uint256', name: '_amountLD', type: 'uint256' },
],
name: 'sendOFT',
outputs: [],
stateMutability: 'payable',
type: 'function',
},
],
functionName: 'sendOFT',
args: [
frxUsdTokenAddressOrEthereumLockboxAddress,
destinationEid,
DESTINATION_ADDRESS_BYTES32, // Use bytes32 format
TRANSFER_AMOUNT,
],
}),
});
console.log();
console.log(`Transfer Tx: ${transferTx}`);
console.log(`View on LayerZero explorer: https://layerzeroscan.com/tx/${transferTx}`);
}
// Here, because Ethereum mainnet is the source chain for this transfer using the RemoteHop method, we use the Ethereum Lockbox address instead of the frxUSD token address.
async function transferFrxUSDForEthereumToArbitrum() {
await transferFrxUSD(
ethereumWalletClient,
ethereumPublicClient,
ETHEREUM_MAINNET_REMOTE_HOP,
ETHEREUM_MAINNET_LOCKBOX_ADDRESS, // Use lockbox address only when transferring from Ethereum to any other EVM chain (excluding Fraxtal)
ARBITRUM_MAINNET_EID,
);
}
// Here, because Ethereum mainnet is not the source chain for this transfer using the RemoteHop method, we use the frxUSD token address instead of the Ethereum Lockbox address.
async function transferFrxUSDForArbitrumToEthereum() {
await transferFrxUSD(
arbitrumWalletClient,
arbitrumPublicClient,
ARBITRUM_MAINNET_REMOTE_HOP,
ARBITRUM_MAINNET_FRXUSD_ADDRESS,
ETHEREUM_MAINNET_EID,
);
}
Build the script
Now that you understand the core steps for programmatically transferring frxUSD from Ethereum mainnet to Arbitrum mainnet using Frax's cross-chain infrastructure, create a transfer.js
in your project directory and populate it with the sample code below.
Note: The source wallet must contain native mainnet ETH (to cover gas fees) and mainnet frxUSD to complete the transfer.
transfer.js
// Import environment variables
import 'dotenv/config';
import {
createWalletClient,
createPublicClient,
http,
encodeFunctionData,
nonceManager,
} from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { mainnet, arbitrum } from 'viem/chains';
// ============ Configuration Constants ============
// Authentication
const PRIVATE_KEY = process.env.PRIVATE_KEY;
const account = privateKeyToAccount(`0x${PRIVATE_KEY}`, { nonceManager });
// Recipient address
const DESTINATION_ADDRESS = 'enter-your-recipient-wallet-address-here'; // Enter your recipient wallet address here
const DESTINATION_ADDRESS_BYTES32 = `0x000000000000000000000000${DESTINATION_ADDRESS.slice(
2,
)}`; // Convert to bytes32 format
// RemoteHop Contract Addresses
const ETHEREUM_MAINNET_REMOTE_HOP = '0x3ad4dC2319394bB4BE99A0e4aE2AbF7bCEbD648E'; // RemoteHop contract address on source chain
const ARBITRUM_MAINNET_REMOTE_HOP = '0x29F5DBD0FE72d8f11271FCBE79Cb87E18a83C70A'; // RemoteHop contract address on destination chain
// frxUSD Contract Addresses
const ETHEREUM_MAINNET_FRXUSD_ADDRESS = '0xCAcd6fd266aF91b8AeD52aCCc382b4e165586E29'; // frxUSD contract address on source chain
const ARBITRUM_MAINNET_FRXUSD_ADDRESS = '0x80Eede496655FB9047dd39d9f418d5483ED600df'; // frxUSD contract address on destination chain
// Lockbox Contract Address
const ETHEREUM_MAINNET_LOCKBOX_ADDRESS = '0x566a6442a5a6e9895b9dca97cc7879d632c6e4b0'; // When transferring from Ethereum to any other EVM chain (excluding Fraxtal) using the RemoteHop method, the Ethereum Lockbox address must be used for `quote` and `send` methods.
// EIDs for destination chains
const ARBITRUM_MAINNET_EID = 30110; // EID of destination chain for first frxUSD transfer (Arbitrum mainnet)
const ETHEREUM_MAINNET_EID = 30101; // EID of destination chain for second frxUSD transfer (Ethereum mainnet)
// Transfer Parameters for both frxUSD transfers
const TRANSFER_AMOUNT = 1_000_000_000_000_000_000n; // 1 frxUSD (18 decimals)
// Set up wallet clients
const ethereumWalletClient = createWalletClient({
chain: mainnet,
transport: http(),
account,
});
const arbitrumWalletClient = createWalletClient({
chain: arbitrum,
transport: http(),
account,
});
// Set up public clients
const ethereumPublicClient = createPublicClient({
chain: mainnet,
transport: http(),
});
const arbitrumPublicClient = createPublicClient({
chain: arbitrum,
transport: http(),
});
async function approveFrxUSD(sourceChainClient, frxUsdTokenAddress, remoteHopAddress) {
console.log('Approving frxUSD transfer...');
const approveTx = await sourceChainClient.sendTransaction({
to: frxUsdTokenAddress,
data: encodeFunctionData({
abi: [
{
type: 'function',
name: 'approve',
stateMutability: 'nonpayable',
inputs: [
{ name: 'spender', type: 'address' },
{ name: 'amount', type: 'uint256' },
],
outputs: [{ name: '', type: 'bool' }],
},
],
functionName: 'approve',
args: [remoteHopAddress, TRANSFER_AMOUNT], // Set max allowance in wei (change as needed)
}),
});
console.log(`frxUSD Approval Tx: ${approveTx}`);
}
async function approveFrxUSDForEthereumToArbitrumTransfer() {
await approveFrxUSD(
ethereumWalletClient,
ETHEREUM_MAINNET_FRXUSD_ADDRESS,
ETHEREUM_MAINNET_REMOTE_HOP,
);
}
async function approveFrxUSDForArbitrumToEthereumTransfer() {
await approveFrxUSD(
arbitrumWalletClient,
ARBITRUM_MAINNET_FRXUSD_ADDRESS,
ARBITRUM_MAINNET_REMOTE_HOP,
);
}
async function retrieveQuote(
sourceChainClient,
remoteHopAddress,
frxUsdTokenAddressOrEthereumLockboxAddress,
destinationEid,
) {
console.log('Retrieving quote...');
const quote = await sourceChainClient.readContract({
address: remoteHopAddress,
abi: [
{
inputs: [
{ internalType: 'address', name: '_oft', type: 'address' },
{ internalType: 'uint32', name: '_dstEid', type: 'uint32' },
{ internalType: 'bytes32', name: '_to', type: 'bytes32' },
{ internalType: 'uint256', name: '_amountLD', type: 'uint256' },
],
name: 'quote',
outputs: [
{
components: [
{ internalType: 'uint256', name: 'nativeFee', type: 'uint256' },
{
internalType: 'uint256',
name: 'lzTokenFee',
type: 'uint256',
},
],
internalType: 'struct MessagingFee',
name: 'fee',
type: 'tuple',
},
],
stateMutability: 'view',
type: 'function',
},
],
functionName: 'quote',
args: [
frxUsdTokenAddressOrEthereumLockboxAddress,
destinationEid,
DESTINATION_ADDRESS_BYTES32, // Use bytes32 format
TRANSFER_AMOUNT,
],
});
const nativeFee = quote.nativeFee;
return nativeFee;
}
async function transferFrxUSD(
sourceWalletClient,
sourcePublicClient,
remoteHopAddress,
frxUsdTokenAddressOrEthereumLockboxAddress,
destinationEid,
) {
console.log('Transferring frxUSD...');
const nativeFee = await retrieveQuote(
sourcePublicClient,
remoteHopAddress,
frxUsdTokenAddressOrEthereumLockboxAddress,
destinationEid,
);
const transferTx = await sourceWalletClient.sendTransaction({
to: remoteHopAddress,
value: nativeFee,
data: encodeFunctionData({
abi: [
{
inputs: [
{ internalType: 'address', name: '_oft', type: 'address' },
{ internalType: 'uint32', name: '_dstEid', type: 'uint32' },
{ internalType: 'bytes32', name: '_to', type: 'bytes32' },
{ internalType: 'uint256', name: '_amountLD', type: 'uint256' },
],
name: 'sendOFT',
outputs: [],
stateMutability: 'payable',
type: 'function',
},
],
functionName: 'sendOFT',
args: [
frxUsdTokenAddressOrEthereumLockboxAddress,
destinationEid,
DESTINATION_ADDRESS_BYTES32, // Use bytes32 format
TRANSFER_AMOUNT,
],
}),
});
console.log();
console.log(`Transfer Tx: ${transferTx}`);
console.log(`View on LayerZero explorer: https://layerzeroscan.com/tx/${transferTx}`);
}
// Here, because Ethereum mainnet is the source chain for this transfer using the RemoteHop method, we use the Ethereum Lockbox address instead of the frxUSD token address.
async function transferFrxUSDForEthereumToArbitrum() {
await transferFrxUSD(
ethereumWalletClient,
ethereumPublicClient,
ETHEREUM_MAINNET_REMOTE_HOP,
ETHEREUM_MAINNET_LOCKBOX_ADDRESS, // Use lockbox address only when transferring from Ethereum to any other EVM chain (excluding Fraxtal)
ARBITRUM_MAINNET_EID,
);
}
// Here, because Ethereum mainnet is not the source chain for this transfer using the RemoteHop method, we use the frxUSD token address instead of the Ethereum Lockbox address.
async function transferFrxUSDForArbitrumToEthereum() {
await transferFrxUSD(
arbitrumWalletClient,
arbitrumPublicClient,
ARBITRUM_MAINNET_REMOTE_HOP,
ARBITRUM_MAINNET_FRXUSD_ADDRESS,
ETHEREUM_MAINNET_EID,
);
}
async function main() {
if (DESTINATION_ADDRESS === 'enter-your-recipient-wallet-address-here') {
console.error(
'Destination address is not set. Please set the DESTINATION_ADDRESS variable in the transfer.js file.',
);
process.exit(1);
}
const command = process.argv[2]; // Get command from CLI
switch (command) {
case 'ethereum-to-arbitrum':
console.log('Running Ethereum → Arbitrum transfer...');
await approveFrxUSDForEthereumToArbitrumTransfer();
await transferFrxUSDForEthereumToArbitrum();
break;
case 'arbitrum-to-ethereum':
console.log('Running Arbitrum → Ethereum transfer...');
await approveFrxUSDForArbitrumToEthereumTransfer();
await transferFrxUSDForArbitrumToEthereum();
break;
default:
console.error(
'Invalid command. Please use "ethereum-to-arbitrum" or "arbitrum-to-ethereum".',
);
process.exit(1);
}
console.log('Transfer completed!');
}
main().catch(console.error);
The transfer.js
script provides a complete end-to-end solution for transferring frxUSD using Frax's cross-chain infrastructure with a non-custodial wallet. In the next section, you can test the script.
Test the script
To test the Ethereum to Arbitrum transfer, run the following command:
node transfer.js ethereum-to-arbitrum
To test the Arbitrum to Ethereum transfer, run the following command:
node transfer.js arbitrum-to-ethereum
Once each script runs and the transfer is finalized, a confirmation receipt and link on LayerZero scan (opens in a new tab) is logged in the console for each respective transfer. Each transfer takes approximately 4-5 minutes to complete.