Send USDC withdrawals from an exchange hot wallet on Arc by validating
addresses, estimating gas, building EIP-1559 transactions, and confirming with
deterministic finality. Send withdrawals as native USDC transfers—the
recommended path for exchanges: cheaper gas (~21,000 vs ~65,000 units) and
receivable by any address. 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 for the native USDC
send. The value field is denominated in 18-decimal native wei.
// 1 USDC in 18-decimal native wei. If you track 6-decimal balances,
// convert with: amount = amount6 * 10n ** 12n
const amount = 1_000_000_000_000_000_000n;
const gasEstimate = await client.estimateGas({
account: hotWalletAddress,
to: destination as `0x${string}`,
value: amount,
});
console.log(`Estimated gas units: ${gasEstimate}`);
// Typical native USDC send: ~21,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, a native USDC send using 21,000 gas at 20 Gwei:
21,000 * 20,000,000,000 = 420,000,000,000,000 wei = 0.00042 USDC
Step 5. Build the EIP-1559 transaction
Construct a type-2 (EIP-1559) transaction with maxFeePerGas set to at least 20
Gwei. The fee fields and the value field are all 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: destination as `0x${string}`,
value: amount, // 18-decimal native USDC
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.
Send withdrawals as native USDC (a plain value transfer). It is cheaper than
an ERC-20 transfer() and any address can receive it. Native sends still emit
a Transfer log from the system emitter (0xffff…fffe), so indexers and
block explorers still capture them. Reach for the ERC-20 transfer() only
when you need 6-decimal exactness or ERC-20 call semantics.
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`);
}
// amount is 18-decimal native USDC wei
const gas = await client.estimateGas({
account: hotWalletAddress,
to: validAddress as `0x${string}`,
value: amount,
});
const txHash = await walletClient.sendTransaction({
to: validAddress as `0x${string}`,
value: amount,
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