Skip to main content

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:
FailureCauseResolution
Transaction revertsDestination is blocklistedCheck the blocklist before sending (Step 2)
Transaction pendingmaxFeePerGas too lowResubmit with maxFeePerGas >= 20 Gwei
Out of gasGas estimate too lowAdd a buffer (for example, multiply estimate by 1.2)
Insufficient balanceHot wallet underfundedTop 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