Skip to main content

Overview

This guide shows how to use Magic’s Express Server Wallet to send USDC on Base. The private key never leaves the TEE — your server builds the ERC-20 transfer transaction, signs it via the Express API, and broadcasts the result.

Prerequisites

Before starting, ensure you have:
  1. A Magic Secret Key — from your Magic Dashboard
  2. An OIDC Provider ID — configured for your auth provider (setup guide)
  3. A Base RPC endpoint (e.g., from Alchemy or QuickNode)
  4. A user JWT from your authentication provider

How It Works

  1. Your server authenticates the user and obtains a JWT
  2. JWT is sent to Magic’s TEE to get (or create) the user’s EOA
  3. Your server builds the ERC-20 transfer transaction
  4. Transaction hash is signed via the TEE’s /v1/wallet/sign/data endpoint
  5. Signed transaction is broadcast to Base via your RPC provider

TEE Request Helper

All TEE calls use the same authentication headers. Create a reusable helper for your server-side code.
TypeScript
const TEE_BASE = "https://tee.express.magiclabs.com";

async function teeRequest<T>(path: string, jwt: string, body: object): Promise<T> {
  const res = await fetch(`${TEE_BASE}${path}`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${jwt}`,
      "X-Magic-Secret-Key": "YOUR_SECRET_KEY",
      "X-OIDC-Provider-ID": "YOUR_OIDC_PROVIDER_ID",
      "X-Magic-Chain": "ETH",
    },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`TEE error: ${res.status}`);
  return res.json();
}

Get or Create a Wallet

Fetch the user’s wallet address. If one doesn’t exist, it will be created automatically.
TypeScript
const { public_address: eoaAddress } = await teeRequest<{ public_address: string }>(
  "/v1/wallet", jwt, {}
);

Sign and Broadcast Transactions

Build the transaction locally, compute its unsigned hash, sign via the TEE, and broadcast. This example uses viem but you can use any EVM library that can serialize unsigned transactions and compute their keccak256 hash.
TypeScript
import {
  createPublicClient,
  http,
  encodeFunctionData,
  serializeTransaction,
  keccak256,
  type TransactionSerializable,
  type Hex,
} from "viem";
import { base } from "viem/chains";

const publicClient = createPublicClient({
  chain: base,
  transport: http("YOUR_BASE_RPC_URL"),
});

async function signAndSend(jwt: string, tx: TransactionSerializable) {
  // Serialize the unsigned transaction and hash it
  const serialized = serializeTransaction(tx);
  const hash = keccak256(serialized);

  // Sign the hash via TEE
  const { r, s, v } = await teeRequest<{ r: string; s: string; v: string }>(
    "/v1/wallet/sign/data", jwt, { raw_data_hash: hash, chain: "ETH" }
  );

  // Serialize with signature and broadcast
  const yParity = Number(v) >= 27 ? Number(v) - 27 : Number(v);
  const signed = serializeTransaction(tx, {
    r: r as Hex,
    s: s as Hex,
    yParity,
  });
  return await publicClient.sendRawTransaction({ serializedTransaction: signed });
}

Sending USDC

Build the ERC-20 transfer transaction with the recipient and amount, estimate gas, and send.
TypeScript
import { parseUnits, formatUnits, type Address } from "viem";

const USDC_ADDRESS: Address = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
const USDC_DECIMALS = 6;

const erc20Abi = [
  {
    name: "transfer",
    type: "function",
    inputs: [
      { name: "to", type: "address" },
      { name: "value", type: "uint256" },
    ],
    outputs: [{ type: "bool" }],
    stateMutability: "nonpayable",
  },
  {
    name: "balanceOf",
    type: "function",
    inputs: [{ name: "account", type: "address" }],
    outputs: [{ type: "uint256" }],
    stateMutability: "view",
  },
] as const;

async function sendUSDC(jwt: string, eoaAddress: Address, to: Address, amount: string) {
  const value = parseUnits(amount, USDC_DECIMALS);
  const nonce = await publicClient.getTransactionCount({ address: eoaAddress });
  const fees = await publicClient.estimateFeesPerGas();

  const data = encodeFunctionData({
    abi: erc20Abi,
    functionName: "transfer",
    args: [to, value],
  });

  const gas = await publicClient.estimateGas({
    account: eoaAddress,
    to: USDC_ADDRESS,
    data,
  });

  const txHash = await signAndSend(jwt, {
    chainId: 8453,
    to: USDC_ADDRESS,
    nonce,
    gas,
    maxFeePerGas: fees.maxFeePerGas,
    maxPriorityFeePerGas: fees.maxPriorityFeePerGas,
    data,
  });

  return txHash;
}

// Send 10 USDC
await sendUSDC(jwt, eoaAddress, "0xRecipientAddress", "10");

Checking Balance

Read the user’s USDC balance before or after a transfer.
TypeScript
async function getUSDCBalance(address: Address) {
  const balance = await publicClient.readContract({
    address: USDC_ADDRESS,
    abi: erc20Abi,
    functionName: "balanceOf",
    args: [address],
  });

  return formatUnits(balance, USDC_DECIMALS);
}

const balance = await getUSDCBalance(eoaAddress);
console.log(`Balance: ${balance} USDC`);

Contract Addresses

Key addresses on Base (chain ID 8453):
TypeScript
// USDC on Base (native, issued by Circle)
const USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
Base has both native USDC (0x8335...) and bridged USDbC (0xd9aA...). This guide uses native USDC, which is the standard token on Base.

TEE Endpoints Used

EndpointPurpose
POST /v1/walletGet or create an EOA wallet
POST /v1/wallet/sign/dataSign a raw transaction hash

Troubleshooting

Symptoms: Authentication errors when calling TEE endpoints.Solutions:
  • Verify the JWT token is valid and not expired
  • Check that X-Magic-Secret-Key matches your dashboard credentials
  • Ensure the OIDC Provider ID is correct
  • Confirm your domain is allowlisted in the Magic Dashboard
Symptoms: The transfer transaction fails or reverts on-chain.Solutions:
  • Check that the wallet has sufficient USDC balance on Base
  • Verify the recipient address is valid (not the zero address)
  • Ensure the wallet has ETH on Base for gas fees
Symptoms: Tokens were sent but don’t appear as USDC in the recipient’s wallet.Solutions:
  • Confirm you’re using the native USDC address (0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913), not bridged USDbC
  • Check the transaction on Basescan to verify the token contract
Symptoms: Transaction fails with a nonce-related error.Solutions:
  • Ensure no concurrent transactions are being sent for the same wallet
  • If a previous transaction is pending, wait for it to confirm
  • Fetch the nonce with "pending" to account for in-flight transactions

Resources

Express API Docs

Learn about Magic’s Express Server Wallet API

EVM Data Preparation

Guide for preparing EVM transaction data for signing

Base Documentation

Official Base network documentation

USDC on Base

Learn about Circle’s USDC stablecoin