Skip to main content

Overview

This guide shows how to use Magic’s Express Server Wallet to supply USDC to AAVE V3 on Base using the @aave/client SDK. The SDK generates the transaction plan, and your server signs it via the TEE and broadcasts — the private key never leaves the TEE.

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. The AAVE SDK generates the supply or withdraw transaction plan
  4. If approval is required, the approval transaction is signed via the TEE and broadcast
  5. The main transaction is signed via the TEE and broadcast
  6. The user receives aUSDC, which accrues interest automatically

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.
TypeScript
import {
  createPublicClient,
  http,
  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) {
  const serialized = serializeTransaction(tx);
  const hash = keccak256(serialized);

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

  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 });
}

Setting Up the AAVE Client

Install dependencies and initialize the AAVE client.
npm install @aave/client viem
TypeScript
import { AaveClient, evmAddress } from "@aave/client";
import { supply, withdraw } from "@aave/client/actions";

const aaveClient = AaveClient.create();

// AAVE V3 Pool on Base (market address)
const AAVE_POOL = "0xA238Dd80C259a72e81d7e4664a9801593F98d1c5";

// USDC on Base (native, issued by Circle)
const USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
const USDC_DECIMALS = 6;

Executing an AAVE Plan via TEE

The AAVE SDK returns an execution plan that may include an approval step. This helper signs and broadcasts each step through the TEE.
TypeScript
import type { Address } from "viem";

async function executePlan(jwt: string, eoaAddress: Address, plan: any) {
  const nonce = await publicClient.getTransactionCount({ address: eoaAddress });
  const fees = await publicClient.estimateFeesPerGas();

  const txBase = {
    chainId: 8453,
    maxFeePerGas: fees.maxFeePerGas,
    maxPriorityFeePerGas: fees.maxPriorityFeePerGas,
  };

  let currentNonce = nonce;

  if (plan.__typename === "ApprovalRequired") {
    // Sign and send approval
    const approveGas = await publicClient.estimateGas({
      account: eoaAddress,
      to: plan.approval.to as Address,
      data: plan.approval.data as Hex,
      value: BigInt(plan.approval.value),
    });

    await signAndSend(jwt, {
      ...txBase,
      to: plan.approval.to as Address,
      data: plan.approval.data as Hex,
      value: BigInt(plan.approval.value),
      gas: approveGas * 2n,
      nonce: currentNonce,
    });
    currentNonce++;

    // Sign and send the main transaction
    const mainGas = await publicClient.estimateGas({
      account: eoaAddress,
      to: plan.originalTransaction.to as Address,
      data: plan.originalTransaction.data as Hex,
      value: BigInt(plan.originalTransaction.value),
    });

    return await signAndSend(jwt, {
      ...txBase,
      to: plan.originalTransaction.to as Address,
      data: plan.originalTransaction.data as Hex,
      value: BigInt(plan.originalTransaction.value),
      gas: mainGas * 2n,
      nonce: currentNonce,
    });
  }

  if (plan.__typename === "TransactionRequest") {
    const gas = await publicClient.estimateGas({
      account: eoaAddress,
      to: plan.to as Address,
      data: plan.data as Hex,
      value: BigInt(plan.value),
    });

    return await signAndSend(jwt, {
      ...txBase,
      to: plan.to as Address,
      data: plan.data as Hex,
      value: BigInt(plan.value),
      gas: gas * 2n,
      nonce: currentNonce,
    });
  }

  if (plan.__typename === "InsufficientBalanceError") {
    throw new Error(`Insufficient balance: ${plan.required.value} required`);
  }

  throw new Error(`Unhandled plan type: ${plan.__typename}`);
}

Supplying USDC

Use the AAVE SDK to generate a supply plan, then execute it via the TEE.
TypeScript
async function supplyUSDC(jwt: string, eoaAddress: Address, amount: string) {
  const result = await supply(aaveClient, {
    market: AAVE_POOL,
    amount: {
      erc20: {
        currency: USDC_ADDRESS,
        value: amount,
      },
    },
    sender: evmAddress(eoaAddress),
    chainId: 8453,
  });

  if (result.isErr()) {
    throw new Error(`Supply failed: ${result.error}`);
  }

  return await executePlan(jwt, eoaAddress, result.value);
}

// Supply 10 USDC
await supplyUSDC(jwt, eoaAddress, "10");

Withdrawing USDC

Use the withdraw action to pull USDC back from the lending pool, including any earned yield.
TypeScript
async function withdrawUSDC(jwt: string, eoaAddress: Address, amount: string) {
  const result = await withdraw(aaveClient, {
    market: AAVE_POOL,
    amount: {
      erc20: {
        currency: USDC_ADDRESS,
        value: { exact: amount },
      },
    },
    sender: evmAddress(eoaAddress),
    chainId: 8453,
  });

  if (result.isErr()) {
    throw new Error(`Withdraw failed: ${result.error}`);
  }

  return await executePlan(jwt, eoaAddress, result.value);
}

// Withdraw 5 USDC
await withdrawUSDC(jwt, eoaAddress, "5");

Checking Position

Read the user’s aUSDC balance to see their current position including accrued yield.
TypeScript
import { formatUnits } from "viem";

const poolAbi = [
  {
    name: "getReserveData",
    type: "function",
    inputs: [{ name: "asset", type: "address" }],
    outputs: [
      {
        type: "tuple",
        components: [
          { name: "configuration", type: "uint256" },
          { name: "liquidityIndex", type: "uint128" },
          { name: "currentLiquidityRate", type: "uint128" },
          { name: "variableBorrowIndex", type: "uint128" },
          { name: "currentVariableBorrowRate", type: "uint128" },
          { name: "currentStableBorrowRate", type: "uint128" },
          { name: "lastUpdateTimestamp", type: "uint40" },
          { name: "id", type: "uint16" },
          { name: "aTokenAddress", type: "address" },
          { name: "stableDebtTokenAddress", type: "address" },
          { name: "variableDebtTokenAddress", type: "address" },
          { name: "interestRateStrategyAddress", type: "address" },
          { name: "accruedToTreasury", type: "uint128" },
          { name: "unbacked", type: "uint128" },
          { name: "isolationModeTotalDebt", type: "uint128" },
        ],
      },
    ],
    stateMutability: "view",
  },
] as const;

const erc20BalanceAbi = [
  {
    name: "balanceOf",
    type: "function",
    inputs: [{ name: "account", type: "address" }],
    outputs: [{ type: "uint256" }],
    stateMutability: "view",
  },
] as const;

async function getPosition(eoaAddress: Address) {
  const reserveData = await publicClient.readContract({
    address: AAVE_POOL as Address,
    abi: poolAbi,
    functionName: "getReserveData",
    args: [USDC_ADDRESS as Address],
  });

  const aTokenBalance = await publicClient.readContract({
    address: reserveData.aTokenAddress,
    abi: erc20BalanceAbi,
    functionName: "balanceOf",
    args: [eoaAddress],
  });

  const usdcBalance = await publicClient.readContract({
    address: USDC_ADDRESS as Address,
    abi: erc20BalanceAbi,
    functionName: "balanceOf",
    args: [eoaAddress],
  });

  return {
    supplied: formatUnits(aTokenBalance, USDC_DECIMALS),
    wallet: formatUnits(usdcBalance, USDC_DECIMALS),
  };
}
The aUSDC balance increases over time as interest accrues. Unlike vault-based protocols, AAVE’s aTokens are rebasing — the balance itself grows, so 1 aUSDC always equals 1 USDC of underlying value.

Key Dependencies

PackagePurpose
viemEthereum client for transaction serialization and broadcasting
@aave/clientAAVE V3 SDK for building supply and withdraw transactions

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 supply transaction fails or reverts on-chain.Solutions:
  • Check that the USDC approval was confirmed before the supply transaction
  • Verify the wallet has sufficient USDC balance on Base
  • Ensure the wallet has ETH on Base for gas fees
  • Confirm the USDC address is correct (native USDC, not bridged USDbC)
Symptoms: The supply action returns a plan with __typename: "InsufficientBalanceError".Solutions:
  • The wallet doesn’t have enough USDC to supply the requested amount
  • Check the plan.required.value field for the amount needed
  • Verify the token balance on Base before calling supply
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
  • The executePlan helper increments the nonce after each step — ensure it’s not called concurrently

Resources

Express API Docs

Learn about Magic’s Express Server Wallet API

AAVE V3 Documentation

Official AAVE protocol documentation

EVM Data Preparation

Guide for preparing EVM transaction data for signing

AAVE Base Markets

View current AAVE supply rates on Base