Control USDT0
This guide shows how to control USDT0 held by a Flare personal account through an XRPL wallet.
We will read the account's USDT0 balance, transfer USDT0 to another EVM address, and swap USDT0 for FXRP on SparkDEX — all through fee-only 0xFE custom instructions.
Unlike the minting demos in the Custom Instruction TypeScript guide, these flows set netMintAmountXrp: 0.
The XRPL payment covers only direct minting and executor fees; no FXRP is minted.
The personal account already holds USDT0 (and native FLR for any call values), and the user operation moves that ERC-20 balance.
Read the Custom Instruction TypeScript guide first if you are not familiar with the 0xFE hash-commitment flow, sendHashInstruction, or executeDirectMintingWithData.
The full code is available on GitHub.
The code in this guide is set up for the Coston2 testnet. Despite that, we refer to the network as Flare and its currency as FLR, rather than Coston2 and C2FLR.
Prerequisites
Before running transfer.ts or swap-usdt0-to-fxrp.ts:
- Configure
.envin flare-viem-starter withXRPL_TESTNET_RPC_URL,XRPL_SEED, andPRIVATE_KEY(executor wallet for the Flare submission step). - Derive your personal account address with the State Lookup guide (or the read-only
balance.tsscript below). - Fund that EVM address with FLR and USDT0 from the Coston2 faucet.
The XRPL wallet must hold enough XRP to cover the fee-only payment amount returned by computeDirectMintingPaymentAmountXrp({ netMintAmountXrp: 0 }).
Addresses and Config
Constants live in the src/usdt0/config.ts file.
On Coston2, the scripts use:
| Constant | Value | Notes |
|---|---|---|
USDT0_ADDRESS | 0xC1A5B41512496B80903D1f32d6dEa3a73212E71F | Coston2 USDT0. Mainnet is 0xe7cd86e13AC4309349F30B3435a9d337750fC82D. |
SWAP_ROUTER_ADDRESS | 0x8a1E35F5c98C4E85B36B7B253222eE17773b2781 | SparkDEX Uniswap V3 SwapRouter |
POOL_FEE | 500 | 0.05% fee tier for the USDT0/FXRP pool |
DEFAULT_AMOUNT_IN_UNITS | 1 | Transfer / swap size in whole USDT0 units |
DEFAULT_AMOUNT_OUT_MINIMUM_UNITS | "0.3" | Demo slippage floor in whole FXRP units — tune against the live pool before larger amounts |
FXRP is resolved at runtime with getFxrpAddress() from the Flare Contract Registry, not hardcoded.
The EOA-oriented SparkDEX pattern (without smart accounts) is documented in Swap USDT0 to FXRP.
src/usdt0/config.ts
import type { Address } from "viem";
/** USDT0 on Coston2. Mainnet: 0xe7cd86e13AC4309349F30B3435a9d337750fC82D */
export const USDT0_ADDRESS =
"0xC1A5B41512496B80903D1f32d6dEa3a73212E71F" as Address;
/** SparkDEX Uniswap V3 SwapRouter. See https://dev.flare.network/fxrp/token-interactions/usdt0-fxrp-swap */
export const SWAP_ROUTER_ADDRESS =
"0x8a1E35F5c98C4E85B36B7B253222eE17773b2781" as Address;
/** USDT0/FXRP pool fee tier (0.05%). */
export const POOL_FEE = 500;
/** Default swap / transfer size in whole USDT0 units (scaled by on-chain decimals). */
export const DEFAULT_AMOUNT_IN_UNITS = 1;
/**
* Minimum FXRP out in whole FXRP units. Conservative floor for demo swaps —
* tune against current pool price before running on mainnet-sized amounts.
*/
export const DEFAULT_AMOUNT_OUT_MINIMUM_UNITS = "0.3";
/** Swap deadline offset from now (seconds). */
export const SWAP_DEADLINE_SECONDS = 20 * 60;
/** Hardcoded EVM recipient for the transfer demo. */
export const DEFAULT_TRANSFER_RECIPIENT =
"0x1cdacde0c68e0a508ae85279375070a88554871b" as Address;
Flow Overview
Both transfer.ts and swap-usdt0-to-fxrp.ts follow the same three-step 0xFE protocol used elsewhere in the starter:
- User side — encode the
Call[], commitkeccak256(PackedUserOperation)in a 42-byte XRPL memo, send a fee-only Payment. - Executor side — fetch an FDC Payment proof and call
executeDirectMintingWithDatawith the UserOp bytes. - Confirmation — read
UserOperationExecutedfrom the receipt and compare balances.
Check Balance
The balance.ts script is read-only.
It derives the personal account from the XRPL_SEED environment variable, then prints USDT0 decimals, balanceOf, and allowance to the SparkDEX SwapRouter.
const personalAccount = await getPersonalAccountAddress(xrplWallet.address);
const decimals = await readUsdt0Decimals();
const [balance, allowance] = await Promise.all([
readUsdt0Balance(personalAccount),
readUsdt0Allowance(personalAccount, SWAP_ROUTER_ADDRESS),
]);
Helpers in src/usdt0/utils.ts wrap Viem readContract calls against the USDT0 ERC-20 ABI.
Run:
pnpm run script src/usdt0/balance.ts
If the balance is zero, faucet USDT0 to the printed personal account address before continuing.
Transfer USDT0
The transfer.ts script builds a single-call UserOp that invokes USDT0.transfer(recipient, amount) from the personal account.
The payment amount is fee-only:
const [personalAccount, memoOnlyAmountXrp, decimals] = await Promise.all([
getPersonalAccountAddress(xrplWallet.address),
computeDirectMintingPaymentAmountXrp({ netMintAmountXrp: 0 }),
readUsdt0Decimals(),
]);
const amount = toTokenAmount(DEFAULT_AMOUNT_IN_UNITS, decimals);
The call batch is one ERC-20 transfer:
const customInstruction: Call[] = [
{
target: USDT0_ADDRESS,
value: 0n,
data: encodeFunctionData({
abi: ERC20Abi,
functionName: "transfer",
args: [recipient, amount],
}),
},
];
Then the script runs the three 0xFE steps inline:
const userSide = await sendHashInstruction({
label: "transfer-usdt0",
customInstruction,
amountXrp: memoOnlyAmountXrp,
personalAccount,
xrplClient,
xrplWallet,
});
const { receipt } = await executeDirectMintingWithData({
xrplTransactionHash: userSide.xrplTransactionHash,
data: userSide.data,
value: userSide.totalCallValue,
xrplClient,
label: "transfer-usdt0",
});
const event = findUserOperationExecuted(
receipt,
personalAccount,
userSide.nonce,
);
The DEFAULT_TRANSFER_RECIPIENT constant in config.ts is a hardcoded demo address — change it before sending real funds.
XRPL payments targeting smart accounts must not use a destination tag.
Run:
pnpm run script src/usdt0/transfer.ts
Swap USDT0 to FXRP
The swap-usdt0-to-fxrp.ts script swaps USDT0 for FXRP on SparkDEX from the personal account.
The XRPL payment is again fee-only; the FXRP received comes from the DEX pool, not from an FAssets mint.
The UserOp batches two calls atomically:
USDT0.approve(SwapRouter, amountIn)SwapRouter.exactInputSinglewithtokenIn = USDT0,tokenOut = FXRP,recipient = personalAccount
const customInstruction: Call[] = [
{
target: USDT0_ADDRESS,
value: 0n,
data: encodeFunctionData({
abi: ERC20Abi,
functionName: "approve",
args: [SWAP_ROUTER_ADDRESS, amountIn],
}),
},
{
target: SWAP_ROUTER_ADDRESS,
value: 0n,
data: encodeFunctionData({
abi: swapRouterAbi,
functionName: "exactInputSingle",
args: [
{
tokenIn: USDT0_ADDRESS,
tokenOut: fxrpAddress,
fee: POOL_FEE,
recipient: personalAccount,
deadline,
amountIn,
amountOutMinimum,
sqrtPriceLimitX96: 0n,
},
],
}),
},
];
The amountOutMinimum value is derived from DEFAULT_AMOUNT_OUT_MINIMUM_UNITS (0.3 FXRP in the demo).
Treat it as a conservative floor for testnet demos; adjust it against the current pool price for larger amounts.
After execution, the script logs USDT0 spent and FXRP received by comparing balances before and after.
Run:
pnpm run script src/usdt0/swap-usdt0-to-fxrp.ts
Full Scripts
The repository with the examples is available on GitHub.
Shared helpers (sendHashInstruction, executeDirectMintingWithData, FDC proof helpers) live under src/utils.
src/usdt0/balance.ts
import { formatUnits } from "viem";
import { Wallet } from "xrpl";
import { getPersonalAccountAddress } from "../utils/smart-accounts";
import { SWAP_ROUTER_ADDRESS, USDT0_ADDRESS } from "./config";
import {
readUsdt0Allowance,
readUsdt0Balance,
readUsdt0Decimals,
} from "./utils";
// Read-only: prints the personal account's USDT0 balance and allowance to the
// SparkDEX SwapRouter. Faucet USDT0 to the personal account EVM address via
// https://faucet.flare.network/coston2 if the balance is zero.
async function main() {
const xrplWallet = Wallet.fromSeed(process.env.XRPL_SEED!);
const personalAccount = await getPersonalAccountAddress(xrplWallet.address);
const decimals = await readUsdt0Decimals();
console.log("Personal account address:", personalAccount, "\n");
console.log("USDT0 address:", USDT0_ADDRESS, "\n");
console.log("USDT0 decimals:", decimals, "\n");
console.log("Spender (SwapRouter):", SWAP_ROUTER_ADDRESS, "\n");
const [balance, allowance] = await Promise.all([
readUsdt0Balance(personalAccount),
readUsdt0Allowance(personalAccount, SWAP_ROUTER_ADDRESS),
]);
console.log("USDT0 balance:", formatUnits(balance, decimals), "\n");
console.log(
"USDT0 allowance (→ SwapRouter):",
formatUnits(allowance, decimals),
"\n",
);
}
void main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
src/usdt0/transfer.ts
import { encodeFunctionData, formatUnits } from "viem";
import { Client, Wallet } from "xrpl";
import { abi as ERC20Abi } from "../abis/ERC20";
import { computeDirectMintingPaymentAmountXrp } from "../utils/fassets";
import {
executeDirectMintingWithData,
findUserOperationExecuted,
getPersonalAccountAddress,
sendHashInstruction,
type Call,
} from "../utils/smart-accounts";
import {
DEFAULT_AMOUNT_IN_UNITS,
DEFAULT_TRANSFER_RECIPIENT,
USDT0_ADDRESS,
} from "./config";
import { readUsdt0Balance, readUsdt0Decimals, toTokenAmount } from "./utils";
// NOTE: For this example to work, faucet C2FLR and USDT0 to your personal
// account address (https://faucet.flare.network/coston2). Use balance.ts or
// state-lookup.ts to print the personal account EVM address.
//
// Transfers USDT0 from the personal account via a fee-only 0xFE UserOp
// (no FXRP mint). 0xFE is a three-step protocol; this script runs all three
// steps inline.
async function main() {
const recipient = DEFAULT_TRANSFER_RECIPIENT;
const xrplClient = new Client(process.env.XRPL_TESTNET_RPC_URL!);
const xrplWallet = Wallet.fromSeed(process.env.XRPL_SEED!);
const [personalAccount, memoOnlyAmountXrp, decimals] = await Promise.all([
getPersonalAccountAddress(xrplWallet.address),
computeDirectMintingPaymentAmountXrp({ netMintAmountXrp: 0 }),
readUsdt0Decimals(),
]);
const amount = toTokenAmount(DEFAULT_AMOUNT_IN_UNITS, decimals);
console.log("Personal account address:", personalAccount, "\n");
console.log("USDT0 address:", USDT0_ADDRESS, "\n");
console.log("USDT0 decimals:", decimals, "\n");
console.log("Recipient:", recipient, "\n");
console.log("Memo-only amount (XRP, fees only):", memoOnlyAmountXrp, "\n");
console.log("Transfer amount:", formatUnits(amount, decimals), "USDT0\n");
const [usdt0Before, recipientBefore] = await Promise.all([
readUsdt0Balance(personalAccount),
readUsdt0Balance(recipient),
]);
console.log(
"Personal USDT0 before:",
formatUnits(usdt0Before, decimals),
"\n",
);
console.log(
"Recipient USDT0 before:",
formatUnits(recipientBefore, decimals),
"\n",
);
if (usdt0Before < amount) {
throw new Error(
`Insufficient USDT0 on ${personalAccount}: have ${formatUnits(usdt0Before, decimals)}, need ${formatUnits(amount, decimals)}`,
);
}
const customInstruction: Call[] = [
{
target: USDT0_ADDRESS,
value: 0n,
data: encodeFunctionData({
abi: ERC20Abi,
functionName: "transfer",
args: [recipient, amount],
}),
},
];
// --- 1. USER SIDE -------------------------------------------------------
const userSide = await sendHashInstruction({
label: "transfer-usdt0",
customInstruction,
amountXrp: memoOnlyAmountXrp,
personalAccount,
xrplClient,
xrplWallet,
});
// --- 2. EXECUTOR SIDE ---------------------------------------------------
const { receipt } = await executeDirectMintingWithData({
xrplTransactionHash: userSide.xrplTransactionHash,
data: userSide.data,
value: userSide.totalCallValue,
xrplClient,
label: "transfer-usdt0",
});
// --- 3. CONFIRMATION ----------------------------------------------------
const event = findUserOperationExecuted(
receipt,
personalAccount,
userSide.nonce,
);
console.log("UserOperationExecuted:", event, "\n");
const [usdt0After, recipientAfter] = await Promise.all([
readUsdt0Balance(personalAccount),
readUsdt0Balance(recipient),
]);
console.log("Personal USDT0 after:", formatUnits(usdt0After, decimals), "\n");
console.log(
"Recipient USDT0 after:",
formatUnits(recipientAfter, decimals),
"\n",
);
console.log(
"USDT0 sent:",
formatUnits(usdt0Before - usdt0After, decimals),
"\n",
);
}
void main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
src/usdt0/swap-usdt0-to-fxrp.ts
import { encodeFunctionData, formatUnits } from "viem";
import { Client, Wallet } from "xrpl";
import { abi as ERC20Abi } from "../abis/ERC20";
import { abi as swapRouterAbi } from "../abis/ISwapRouter";
import {
computeDirectMintingPaymentAmountXrp,
getFxrpBalance,
getFxrpDecimals,
} from "../utils/fassets";
import { getFxrpAddress } from "../utils/flare-contract-registry";
import {
executeDirectMintingWithData,
findUserOperationExecuted,
getPersonalAccountAddress,
sendHashInstruction,
type Call,
} from "../utils/smart-accounts";
import {
DEFAULT_AMOUNT_IN_UNITS,
DEFAULT_AMOUNT_OUT_MINIMUM_UNITS,
POOL_FEE,
SWAP_DEADLINE_SECONDS,
SWAP_ROUTER_ADDRESS,
USDT0_ADDRESS,
} from "./config";
import { readUsdt0Balance, readUsdt0Decimals, toTokenAmount } from "./utils";
// NOTE: For this example to work, faucet C2FLR and USDT0 to your personal
// account address (https://faucet.flare.network/coston2). Use balance.ts or
// state-lookup.ts to print the personal account EVM address.
//
// Swaps USDT0 to FXRP via SparkDEX's Uniswap V3 router. The XRPL payment
// carries only direct-minting fees (no FXRP mint); the swap itself is a
// batched 0xFE UserOp: USDT0.approve(router) then router.exactInputSingle.
// Both calls run atomically from the personal account.
//
// 0xFE is a three-step protocol; this script runs all three steps inline.
async function main() {
const deadline = BigInt(
Math.floor(Date.now() / 1000) + SWAP_DEADLINE_SECONDS,
);
const xrplClient = new Client(process.env.XRPL_TESTNET_RPC_URL!);
const xrplWallet = Wallet.fromSeed(process.env.XRPL_SEED!);
const [
personalAccount,
fxrpAddress,
memoOnlyAmountXrp,
usdt0Decimals,
fxrpDecimals,
] = await Promise.all([
getPersonalAccountAddress(xrplWallet.address),
getFxrpAddress(),
computeDirectMintingPaymentAmountXrp({ netMintAmountXrp: 0 }),
readUsdt0Decimals(),
getFxrpDecimals(),
]);
const amountIn = toTokenAmount(DEFAULT_AMOUNT_IN_UNITS, usdt0Decimals);
const amountOutMinimum = toTokenAmount(
DEFAULT_AMOUNT_OUT_MINIMUM_UNITS,
fxrpDecimals,
);
console.log("Personal account address:", personalAccount, "\n");
console.log("FXRP address:", fxrpAddress, "\n");
console.log("USDT0 address:", USDT0_ADDRESS, "\n");
console.log("USDT0 decimals:", usdt0Decimals, "\n");
console.log("FXRP decimals:", fxrpDecimals, "\n");
console.log("Swap router:", SWAP_ROUTER_ADDRESS, "\n");
console.log("Memo-only amount (XRP, fees only):", memoOnlyAmountXrp, "\n");
console.log("Amount in:", formatUnits(amountIn, usdt0Decimals), "USDT0");
console.log(
"Amount out minimum:",
formatUnits(amountOutMinimum, fxrpDecimals),
"FXRP\n",
);
const [usdt0Before, fxrpBefore] = await Promise.all([
readUsdt0Balance(personalAccount),
getFxrpBalance(personalAccount),
]);
console.log("USDT0 before:", formatUnits(usdt0Before, usdt0Decimals), "\n");
console.log("FXRP before:", formatUnits(fxrpBefore, fxrpDecimals), "\n");
if (usdt0Before < amountIn) {
throw new Error(
`Insufficient USDT0 on ${personalAccount}: have ${formatUnits(usdt0Before, usdt0Decimals)}, need ${formatUnits(amountIn, usdt0Decimals)}`,
);
}
const customInstruction: Call[] = [
{
target: USDT0_ADDRESS,
value: 0n,
data: encodeFunctionData({
abi: ERC20Abi,
functionName: "approve",
args: [SWAP_ROUTER_ADDRESS, amountIn],
}),
},
{
target: SWAP_ROUTER_ADDRESS,
value: 0n,
data: encodeFunctionData({
abi: swapRouterAbi,
functionName: "exactInputSingle",
args: [
{
tokenIn: USDT0_ADDRESS,
tokenOut: fxrpAddress,
fee: POOL_FEE,
recipient: personalAccount,
deadline,
amountIn,
amountOutMinimum,
sqrtPriceLimitX96: 0n,
},
],
}),
},
];
// --- 1. USER SIDE -------------------------------------------------------
const userSide = await sendHashInstruction({
label: "swap-usdt0-to-fxrp",
customInstruction,
amountXrp: memoOnlyAmountXrp,
personalAccount,
xrplClient,
xrplWallet,
});
// --- 2. EXECUTOR SIDE ---------------------------------------------------
const { receipt } = await executeDirectMintingWithData({
xrplTransactionHash: userSide.xrplTransactionHash,
data: userSide.data,
value: userSide.totalCallValue,
xrplClient,
label: "swap-usdt0-to-fxrp",
});
// --- 3. CONFIRMATION ----------------------------------------------------
const event = findUserOperationExecuted(
receipt,
personalAccount,
userSide.nonce,
);
console.log("UserOperationExecuted:", event, "\n");
const [usdt0After, fxrpAfter] = await Promise.all([
readUsdt0Balance(personalAccount),
getFxrpBalance(personalAccount),
]);
console.log("USDT0 after:", formatUnits(usdt0After, usdt0Decimals), "\n");
console.log("FXRP after:", formatUnits(fxrpAfter, fxrpDecimals), "\n");
console.log(
"USDT0 spent:",
formatUnits(usdt0Before - usdt0After, usdt0Decimals),
"\n",
);
console.log(
"FXRP received:",
formatUnits(fxrpAfter - fxrpBefore, fxrpDecimals),
"\n",
);
}
void main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
Expected output
Values will differ slightly, but the output should follow the same format.
Exact fee amounts depend on live AssetManagerFXRP direct-minting parameters.
balance.ts
> pnpm run script src/usdt0/balance.ts
Personal account address: 0xFd2f0eb6b9fA4FE5bb1F7B26fEE3c647ed103d9F
USDT0 address: 0xC1A5B41512496B80903D1f32d6dEa3a73212E71F
USDT0 decimals: 6
Spender (SwapRouter): 0x8a1E35F5c98C4E85B36B7B253222eE17773b2781
USDT0 balance: 10
USDT0 allowance (→ SwapRouter): 0
transfer.ts
> pnpm run script src/usdt0/transfer.ts
Personal account address: 0xFd2f0eb6b9fA4FE5bb1F7B26fEE3c647ed103d9F
USDT0 address: 0xC1A5B41512496B80903D1f32d6dEa3a73212E71F
USDT0 decimals: 6
Recipient: 0x1cdacde0c68e0a508ae85279375070a88554871b
Memo-only amount (XRP, fees only): 0.2
Transfer amount: 1 USDT0
Personal USDT0 before: 10
Recipient USDT0 before: 0
UserOperationExecuted: { ... }
Personal USDT0 after: 9
Recipient USDT0 after: 1
USDT0 sent: 1
swap-usdt0-to-fxrp.ts
> pnpm run script src/usdt0/swap-usdt0-to-fxrp.ts
Personal account address: 0xFd2f0eb6b9fA4FE5bb1F7B26fEE3c647ed103d9F
FXRP address: 0x0b6A3645c240605887a5532109323A3E12273dc7
USDT0 address: 0xC1A5B41512496B80903D1f32d6dEa3a73212E71F
USDT0 decimals: 6
FXRP decimals: 6
Swap router: 0x8a1E35F5c98C4E85B36B7B253222eE17773b2781
Memo-only amount (XRP, fees only): 0.2
Amount in: 1 USDT0
Amount out minimum: 0.3 FXRP
USDT0 before: 9
FXRP before: 0
UserOperationExecuted: { ... }
USDT0 after: 8
FXRP after: 0.48
USDT0 spent: 1
FXRP received: 0.48
- Re-read the Custom Instruction protocol details and failure handling.
- Swap as an EOA (without smart accounts) in Swap USDT0 to FXRP.
- Compare with gasless USD₮0 transfers if you need EIP-3009 meta-transactions instead of XRPL memo authorization.