Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

Smart Contracts

Integrate the Surge earn market with direct contract calls on Base.

This is the first integration path. A TypeScript SDK and other methods will follow, but the direct contract calls below are stable and enough to ship a full deposit and withdraw flow today.

It covers the variable market only, and is fully non-custodial: you build the transactions, the user signs them in their own wallet.

You can see this market live, with its current Supply APY and TVL, at earn.surge.credit.

Scope:

  • One market: the variable market, marketId = 0.
  • One asset: USDC (6 decimals).
  • Two writes: deposit and withdraw. The rest are reads.
  • Networks: Base Sepolia (testnet) and Base (mainnet).

Networks and addresses

Key your config by chainId so switching networks switches addresses with no code branch. The pool address and USDC address are all you hardcode. The market provider is read on-chain from markets(0), so you never hardcode it.

Base Sepolia (testnet)Base (mainnet)
chainId845328453
LiquidityPool0xed9613914c004Db819C8f0994a7388770E932Ef00xEE755F1BbcbF6e3260469D0f473522d71d3bdDda
USDC0x036CbD53842c5426634e7929541eC2318f3dCF7e0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913

Verify the contracts on the explorer before integrating:

Public RPC endpoints are https://sepolia.base.org and https://mainnet.base.org. For testnet USDC, use the Circle faucet at faucet.circle.com (select Base Sepolia).

TypeScript
export const SURGE_EARN = {
  84532: {
    name: "base-sepolia",
    pool: "0xed9613914c004Db819C8f0994a7388770E932Ef0",
    usdc: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
  },
  8453: {
    name: "base",
    pool: "0xEE755F1BbcbF6e3260469D0f473522d71d3bdDda",
    usdc: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
  },
} as const;
 
export const VARIABLE_MARKET_ID = 0;
export const USDC_DECIMALS = 6;

Minimal ABI

These fragments are the complete set an earn integration calls. The subset is stable across both networks, so copy it as-is. The examples load the Pool ABI below as poolAbi (TypeScript) or POOL_ABI (Python).

Pool ABI (the earn function subset)
[
  { "type": "function", "name": "deposit", "stateMutability": "nonpayable",
    "inputs": [{ "name": "amount", "type": "uint256" }], "outputs": [] },
  { "type": "function", "name": "withdraw", "stateMutability": "nonpayable",
    "inputs": [{ "name": "amount", "type": "uint256" }], "outputs": [] },
  { "type": "function", "name": "getUserSupplyAmount", "stateMutability": "view",
    "inputs": [{ "name": "user", "type": "address" }, { "name": "marketId", "type": "uint256" }],
    "outputs": [{ "type": "uint256" }] },
  { "type": "function", "name": "userPositions", "stateMutability": "view",
    "inputs": [{ "name": "user", "type": "address" }, { "name": "marketId", "type": "uint256" }],
    "outputs": [{ "name": "supplyShares", "type": "uint256" }, { "name": "borrowShares", "type": "uint256" }] },
  { "type": "function", "name": "getMarketBorrowRate", "stateMutability": "view",
    "inputs": [{ "name": "marketId", "type": "uint256" }], "outputs": [{ "type": "uint256" }] },
  { "type": "function", "name": "getUtilization", "stateMutability": "view",
    "inputs": [{ "name": "marketId", "type": "uint256" }], "outputs": [{ "type": "uint256" }] },
  { "type": "function", "name": "getAvailableLiquidity", "stateMutability": "view",
    "inputs": [{ "name": "marketId", "type": "uint256" }], "outputs": [{ "type": "uint256" }] },
  { "type": "function", "name": "markets", "stateMutability": "view",
    "inputs": [{ "name": "marketId", "type": "uint256" }],
    "outputs": [
      { "name": "provider", "type": "address" },
      { "name": "token", "type": "address" },
      { "name": "active", "type": "bool" },
      { "name": "totalSupplyShares", "type": "uint256" },
      { "name": "totalSupplyAssets", "type": "uint256" },
      { "name": "totalPhysicalSupply", "type": "uint256" },
      { "name": "totalBorrowShares", "type": "uint256" },
      { "name": "totalBorrowAssets", "type": "uint256" },
      { "name": "totalPhysicalBorrow", "type": "uint256" },
      { "name": "supplyExchangeRate", "type": "uint256" },
      { "name": "borrowExchangeRate", "type": "uint256" },
      { "name": "protocolEarnings", "type": "uint256" },
      { "name": "protocolEarningsAvailable", "type": "uint256" },
      { "name": "originationFeeBps", "type": "uint256" },
      { "name": "reserveRateBps", "type": "uint256" },
      { "name": "maxLtvBps", "type": "uint256" },
      { "name": "liquidationThresholdBps", "type": "uint256" },
      { "name": "lastAccrueTime", "type": "uint256" },
      { "name": "protocolSupplyShares", "type": "uint256" }
    ] },
  { "type": "event", "name": "Deposit", "inputs": [
      { "name": "user", "type": "address", "indexed": true },
      { "name": "marketId", "type": "uint256", "indexed": true },
      { "name": "amount", "type": "uint256", "indexed": false },
      { "name": "shares", "type": "uint256", "indexed": false } ] },
  { "type": "event", "name": "Withdraw", "inputs": [
      { "name": "user", "type": "address", "indexed": true },
      { "name": "marketId", "type": "uint256", "indexed": true },
      { "name": "amount", "type": "uint256", "indexed": false },
      { "name": "shares", "type": "uint256", "indexed": false } ] }
]

deposit and withdraw take an amount only. The market is fixed to 0 inside the contract, so there is no market argument.

For USDC, use any standard ERC-20 ABI (approve, allowance, balanceOf); it is built into viem as erc20Abi. The minimal subset the examples call:

USDC ERC-20 ABI (approve, allowance, balanceOf)
[
  { "type": "function", "name": "approve", "stateMutability": "nonpayable",
    "inputs": [{ "name": "spender", "type": "address" }, { "name": "amount", "type": "uint256" }],
    "outputs": [{ "type": "bool" }] },
  { "type": "function", "name": "allowance", "stateMutability": "view",
    "inputs": [{ "name": "owner", "type": "address" }, { "name": "spender", "type": "address" }],
    "outputs": [{ "type": "uint256" }] },
  { "type": "function", "name": "balanceOf", "stateMutability": "view",
    "inputs": [{ "name": "account", "type": "address" }],
    "outputs": [{ "type": "uint256" }] }
]

The market provider is a separate contract from the pool. Read its address from markets(0).provider, never hardcode it. The examples use it only to read getSupplyRate; load this fragment as providerAbi (TypeScript) or PROVIDER_ABI (Python):

Market provider ABI (getSupplyRate)
[
  { "type": "function", "name": "getSupplyRate", "stateMutability": "view",
    "inputs": [{ "name": "borrowed", "type": "uint256" }, { "name": "liquidity", "type": "uint256" }],
    "outputs": [{ "type": "uint256" }] }
]

Deposit

Approve USDC to the pool, then deposit. Amounts are in base units, so 100 USDC is 100_000000.

  • Spender for the approval is the pool itself. There is no router.
  • USDC is a standard ERC-20, no value is sent (value = 0).
  • The deposit credits the calling address. Funds come from msg.sender.
  • Approve the exact amount you are about to deposit. See Security notes.
  • Read the current allowance first and skip the approve if it already covers the amount, so you do not send a redundant transaction:
if usdc.functions.allowance(user, cfg["pool"]).call() >= amount:
    pass  # already approved, go straight to deposit
TypeScript
import { parseUnits } from "viem";
 
const cfg = SURGE_EARN[chainId];
const amount = parseUnits("100", 6); // 100 USDC
 
// 1) approve exact amount, spender = pool
await wallet.writeContract({
  address: cfg.usdc,
  abi: erc20Abi,
  functionName: "approve",
  args: [cfg.pool, amount],
});
 
// 2) deposit
await wallet.writeContract({
  address: cfg.pool,
  abi: poolAbi,
  functionName: "deposit",
  args: [amount],
});

The deposit emits Deposit(user, 0, amount, shares).

Withdraw

withdraw(amount) takes the amount in USDC, not in shares. This matters: read the user position in USDC and pass a USDC amount back.

Withdrawals are bounded by available liquidity, which can be less than the user balance when utilization is high. Always clamp the request to getAvailableLiquidity(0). See Available to withdraw.

TypeScript
const amount = parseUnits("50", 6); // withdraw 50 USDC
await wallet.writeContract({
  address: cfg.pool,
  abi: poolAbi,
  functionName: "withdraw",
  args: [amount],
});

Withdraw needs no approval (it burns the user's internal shares). It emits Withdraw(user, 0, amount, shares).

Read: user position

getUserSupplyAmount(user, 0) returns the user's current claimable value in USDC base units (6 decimals). This already includes accrued interest, so it is principal plus yield combined.

TypeScript
const value6 = await client.readContract({
  address: cfg.pool, abi: poolAbi,
  functionName: "getUserSupplyAmount", args: [user, 0n],
}); // USDC, 6 decimals

The contract does not store a separate principal vs earned split. If your UI shows earned yield, track the user's net deposits yourself (sum of Deposit.amount minus Withdraw.amount from events) and compute earned = max(0, currentValue - netDeposited). Clamp at zero, since rounding can make a fresh position read slightly below the deposited amount.

Available to withdraw

getAvailableLiquidity(0) returns the idle USDC available to withdraw right now, which is totalPhysicalSupply - totalPhysicalBorrow. A user's withdrawable amount is the smaller of their balance and this number.

For a full exit, read the balance and available liquidity right before you build the transaction and pass the smaller of the two. A freshly read value closes the position cleanly. The only catch is the rare case where a liquidation lowers the rate between your read and your send: the withdraw reverts with InvalidAmount, so read again and resubmit.

balance6 = pool.functions.getUserSupplyAmount(user, 0).call()
avail6   = pool.functions.getAvailableLiquidity(0).call()
amount   = min(balance6, avail6)   # withdrawable now; for a full exit this is the whole position
if amount > 0:
    tx = pool.functions.withdraw(amount).build_transaction({"from": user, "nonce": ...})
    # reverts with InvalidAmount only if the rate dropped (a liquidation) since the read; re-read and retry

Read: Supply APY

Read the current Supply APY from the market provider's getSupplyRate, which returns basis points (divide by 100 for a percentage). This is the net lender rate, already after the protocol's reserve cut. Read the provider address from markets(0).provider rather than hardcoding it, since the protocol can change it.

TypeScript
const market = await client.readContract({
  address: cfg.pool, abi: poolAbi, functionName: "markets", args: [0n],
});
const supplyApyBps = await client.readContract({
  address: market[0], abi: providerAbi,                        // market[0] = provider
  functionName: "getSupplyRate", args: [market[7], market[4]], // borrowed, liquidity
});
const supplyApyPct = Number(supplyApyBps) / 100;

Borrow APR and utilization are direct reads on the pool, also in basis points:

borrow_apr_pct  = pool.functions.getMarketBorrowRate(0).call() / 100
utilization_pct = pool.functions.getUtilization(0).call() / 100

TVL is markets(0).totalSupplyAssets and total borrowed is markets(0).totalBorrowAssets, both USDC with 6 decimals. There is no minimum deposit or lockup on the variable market, so deposits and withdrawals are available at any time, subject to liquidity.

Field mapping

If you render an earn opportunity and a user position, here is where each value comes from.

FieldSource
Supply APYprovider getSupplyRate(borrowed, liquidity), basis points / 100
Borrow APRgetMarketBorrowRate(0) / 100
UtilizationgetUtilization(0) / 100
TVLmarkets(0).totalSupplyAssets, 6 decimals
TokenUSDC, address from config, 6 decimals
Minimum depositnone (0)
Lock / cooldownnone
Position value (principal + earned)getUserSupplyAmount(user, 0), 6 decimals
Earned onlymax(0, value - netDeposited) (track deposits/withdrawals from events)
Withdrawable nowmin(getUserSupplyAmount(user, 0), getAvailableLiquidity(0))
Wallet USDC balancestandard ERC-20 balanceOf on the USDC address

Attribution

If you want the liquidity your app brings to be attributed to you, append a short tag to the deposit transaction. We read it when indexing and attribute those deposits to your account. Two ways to set it up:

  • We assign you a partner id that you append to the deposit calldata.
  • Or, if you already append an ERC-8021 builder code on Base, share it and we map that instead.

Errors

The pool reverts with typed custom errors. Decode these to give users a clear reason instead of a generic failure.

ErrorWhen
InvalidAmount()Deposit or withdraw amount is 0, or a withdraw is larger than the user's claimable balance
InsufficientLiquidity()Withdraw is larger than getAvailableLiquidity(0)
MarketNotActive()Deposit while the market is deactivated. Withdraw still works
EnforcedPause()Deposit while the pool is paused. Withdraw still works
SafeERC20FailedOperation(address)The USDC approve or transferFrom failed, usually missing allowance or balance

Security and operational notes

These are the things to handle correctly before this carries real volume.

  • Approvals. Approve the exact deposit amount with the pool as spender. Avoid unlimited approvals so there is no standing allowance left to draw on later.
  • Withdrawals are liquidity bound. A withdraw is capped at min(getUserSupplyAmount(user, 0), getAvailableLiquidity(0)); when utilization is high this sits below the user's balance, so clamp to it and surface the withdrawable amount separately. Exceeding it reverts with InsufficientLiquidity. Read both values right before sending (a full exit is just withdrawing up to this cap), and if a liquidation lowers the rate in between, the withdraw reverts with InvalidAmount, so re-read and resubmit.
  • A position can go down, not only up. It usually grows as interest accrues, but if a borrower defaults and their collateral does not cover their credit, every lender's value takes a small hit, so getUserSupplyAmount can read lower than before. That is why the earned figure is clamped at zero.

v0.0.1