Documentation Index
Fetch the complete documentation index at: https://docs.arc.io/llms.txt
Use this file to discover all available pages before exploring further.
Send USDC withdrawals from an exchange hot wallet on Arc by validating
addresses, estimating gas, building EIP-1559 transactions, and confirming with
deterministic finality. The Memo contract lets you attach compliance metadata to
the same transaction.
Prerequisites
Before you begin, ensure you have:
- An Arc Testnet RPC endpoint (
https://rpc.testnet.arc.network)
- A funded hot wallet with USDC for both transfer amounts and gas fees
- The USDC ERC-20 contract address:
0x3600000000000000000000000000000000000000
- viem installed (
npm install viem)
Steps
Step 1. Validate the destination address
Verify that the withdrawal address is a valid EIP-55 checksummed Ethereum
address before building the transaction. This prevents sending funds to
malformed addresses.
import { getAddress, isAddress } from "viem";
function validateDestination(address: string): string {
if (!isAddress(address)) {
throw new Error(`Invalid address: ${address}`);
}
// Return EIP-55 checksum-validated address
return getAddress(address);
}
const destination = validateDestination(
"0x742d35Cc6634C0532925a3b844Bc9e7595f2bD28",
);
The address must be:
- 20 bytes (40 hex characters) with a
0x prefix
- Valid per EIP-55 checksum rules
Step 2. Check the blocklist
Arc enforces a blocklist at the protocol level. If the destination address is
blocklisted, the transaction reverts at runtime. Check before sending to avoid
wasted gas.
import { createPublicClient, http, parseAbi } from "viem";
const client = createPublicClient({
transport: http("https://rpc.testnet.arc.network"),
});
const USDC_ADDRESS = "0x3600000000000000000000000000000000000000" as const;
async function isBlocklisted(address: string): Promise<boolean> {
const blocked = await client.readContract({
address: USDC_ADDRESS,
abi: parseAbi(["function isBlacklisted(address) view returns (bool)"]),
functionName: "isBlacklisted",
args: [address as `0x${string}`],
});
return blocked;
}
const blocked = await isBlocklisted(destination);
if (blocked) {
throw new Error(`Destination ${destination} is blocklisted`);
}
Step 3. Estimate gas units
Call eth_estimateGas to determine the gas units required.
import { encodeFunctionData, parseAbi } from "viem";
const transferData = encodeFunctionData({
abi: parseAbi([
"function transfer(address to, uint256 amount) returns (bool)",
]),
functionName: "transfer",
args: [destination as `0x${string}`, 1000000n], // 1 USDC (6 decimals via ERC-20)
});
const gasEstimate = await client.estimateGas({
account: hotWalletAddress,
to: USDC_ADDRESS,
data: transferData,
});
console.log(`Estimated gas units: ${gasEstimate}`);
// Typical ERC-20 transfer: ~65,000 gas units
eth_estimateGas returns gas units, not a cost in USDC. To calculate the
cost, multiply by the effective gas price. A native USDC send uses
approximately 21,000 gas units, while an ERC-20 transfer() call uses
approximately 65,000.
Step 4. Calculate gas cost
Use eth_gasPrice to get the current suggested gas price, then compute the
total fee.
const gasPrice = await client.getGasPrice();
// Gas cost formula: gas_units * gas_price = cost in USDC wei (18 decimals)
const estimatedCost = gasEstimate * gasPrice;
// Convert to human-readable USDC (18 decimals for native gas accounting)
const costInUsdc = Number(estimatedCost) / 1e18;
console.log(`Estimated fee: ${costInUsdc} USDC`);
Gas cost formula:
cost_usdc_wei = gas_used * effective_gas_price
cost_usdc = cost_usdc_wei / 10^18
For example, an ERC-20 transfer using 65,000 gas at 20 Gwei:
65,000 * 20,000,000,000 = 1,300,000,000,000,000 wei = 0.0013 USDC
Step 5. Build the EIP-1559 transaction
Construct a type-2 (EIP-1559) transaction with maxFeePerGas set to at least 20
Gwei. Both fee fields are denominated in USDC wei (18 decimals).
import { createWalletClient, http, parseGwei } from "viem";
const walletClient = createWalletClient({
account: hotWalletAccount, // Your signing account
transport: http("https://rpc.testnet.arc.network"),
});
const txHash = await walletClient.sendTransaction({
to: USDC_ADDRESS,
data: transferData,
gas: gasEstimate,
maxFeePerGas: parseGwei("25"), // Must be >= 20 Gwei
maxPriorityFeePerGas: parseGwei("1"),
chain: {
id: 5042002,
name: "Arc Testnet",
nativeCurrency: { name: "USDC", symbol: "USDC", decimals: 18 },
rpcUrls: { default: { http: ["https://rpc.testnet.arc.network"] } },
},
});
console.log(`Transaction hash: ${txHash}`);
Transactions with maxFeePerGas below 20 Gwei may remain pending or fail to
execute.
Use the ERC-20 transfer() method instead of a native USDC send. ERC-20
transfers emit Transfer events that are indexed by block explorers, making
withdrawals easier to trace and audit.
Step 6. Confirm inclusion
Arc provides deterministic finality. Once a transaction is included in a block,
it is final—no reorgs, no need to wait for additional confirmations.
const receipt = await client.waitForTransactionReceipt({ hash: txHash });
if (receipt.status === "success") {
console.log(`Withdrawal confirmed in block ${receipt.blockNumber}`);
// Credit the withdrawal as complete—no further checks needed
} else {
console.error("Transaction reverted");
// Handle failure (see Step 7)
}
Unlike other blockchains, you do not need to wait for multiple block
confirmations. A single block inclusion is final on Arc.
Step 7. Handle failures
Common failure scenarios and how to address them:
| Failure | Cause | Resolution |
|---|
| Transaction reverts | Destination is blocklisted | Check the blocklist before sending (Step 2) |
| Transaction pending | maxFeePerGas too low | Resubmit with maxFeePerGas >= 20 Gwei |
| Out of gas | Gas estimate too low | Add a buffer (for example, multiply estimate by 1.2) |
| Insufficient balance | Hot wallet underfunded | Top up the hot wallet—USDC covers both the transfer and gas |
async function processWithdrawal(to: string, amount: bigint): Promise<string> {
const validAddress = validateDestination(to);
if (await isBlocklisted(validAddress)) {
throw new Error(`Address ${validAddress} is blocklisted`);
}
const data = encodeFunctionData({
abi: parseAbi([
"function transfer(address to, uint256 amount) returns (bool)",
]),
functionName: "transfer",
args: [validAddress as `0x${string}`, amount],
});
const gas = await client.estimateGas({
account: hotWalletAddress,
to: USDC_ADDRESS,
data,
});
const txHash = await walletClient.sendTransaction({
to: USDC_ADDRESS,
data,
gas: (gas * 120n) / 100n, // 20% buffer
maxFeePerGas: parseGwei("25"),
maxPriorityFeePerGas: parseGwei("1"),
});
const receipt = await client.waitForTransactionReceipt({ hash: txHash });
if (receipt.status !== "success") {
throw new Error(`Withdrawal failed: tx ${txHash} reverted`);
}
return txHash;
}
Attach memos for compliance
Use the Memo contract to attach metadata (such as internal withdrawal IDs or
compliance references) to transfers. The sendWithMemo function transfers USDC
and records an arbitrary bytes memo in the same transaction.
Memo contract address: 0x9702466268ccF55eAB64cdf484d272Ac08d3b75b
import { encodeFunctionData, parseAbi, toHex } from "viem";
const MEMO_CONTRACT = "0x9702466268ccF55eAB64cdf484d272Ac08d3b75b" as const;
const memo = toHex("withdrawal:WD-2026-00142"); // Your internal reference
const memoTxData = encodeFunctionData({
abi: parseAbi([
"function sendWithMemo(address to, uint256 amount, bytes memo)",
]),
functionName: "sendWithMemo",
args: [
destination as `0x${string}`,
1000000n, // 1 USDC (6 decimals via ERC-20 interface)
memo as `0x${string}`,
],
});
const memoTxHash = await walletClient.sendTransaction({
to: MEMO_CONTRACT,
data: memoTxData,
value: 1000000000000000000n, // 1 USDC in native 18-decimal wei
maxFeePerGas: parseGwei("25"),
maxPriorityFeePerGas: parseGwei("1"),
});
console.log(`Withdrawal with memo sent: ${memoTxHash}`);
The memo is stored onchain and can be read back from transaction input data or
contract events. Use it to link onchain transfers to internal records for
auditing and reconciliation.
See also