BchainPay logoBchainPay
EngineeringTreasuryDeFiStablecoinsEthereum

ERC-4626 yield vaults for idle merchant stablecoin reserves

Put idle USDC to work between payment receipt and treasury sweep: ERC-4626 vault integration, share-price accounting, and risk controls for crypto merchants.

By Cipher · Founding engineer, BchainPay9 min read
Illustration for ERC-4626 yield vaults for idle merchant stablecoin reserves

Every crypto payment gateway has a gap. Funds land at a deposit address, pass confirmation, and wait in a hot wallet until the nightly treasury sweep consolidates them. On a quiet day that gap is a few hours. On a high-volume day — say, a flash sale that fires 4,000 payments before the sweep job runs — it can be $2M sitting idle in a hot wallet earning exactly nothing.

ERC-4626 standardises the interface for tokenized yield vaults. Any vault that implements the standard (Aave v3's wrapped aTokens, Morpho's MetaMorpho vaults, Compound v3's cUSDC) exposes the same deposit, redeem, and convertToAssets calls. This post covers how to plug those four hours of float into a vault, account for shares correctly, and get the money back out cleanly before the sweep runs — without taking on speculative risk or locking up liquidity.

Why the gap exists and what it costs#

The sweep problem is well known (we covered the mechanics in Treasury auto-sweep patterns). What is less discussed is the yield opportunity that gap represents.

Annualised rates on Aave v3 USDC on Ethereum have ranged from 4% to 12% over 2025. At an average 6% APY and an average float of $500,000 (realistic for a mid-sized gateway doing $10M/month), the daily yield is roughly:

$500,000 × 0.06 / 365 ≈ $82 per day
$82 × 365 ≈ $30,000 per year

That is not a rounding error. At $50M/month it scales to $300,000 a year. The operational overhead of integrating one ERC-4626 vault is a weekend project. The return-on-engineering is unusually high.

The ERC-4626 interface in 60 seconds#

EIP-4626 defines a minimal vault interface on top of ERC-20. The vault wraps an underlying asset (USDC, USDT, DAI) and issues shares that represent a proportional claim on the growing pool.

The four calls you care about:

interface IERC4626 {
  // Deposit `assets` of underlying, receive `shares` in return
  function deposit(uint256 assets, address receiver)
    external returns (uint256 shares);
 
  // Burn `shares`, receive `assets` of underlying in return
  function redeem(uint256 shares, address receiver, address owner)
    external returns (uint256 assets);
 
  // How many underlying assets is `shares` worth right now?
  function convertToAssets(uint256 shares)
    external view returns (uint256 assets);
 
  // How many shares does `assets` of underlying buy right now?
  function convertToShares(uint256 assets)
    external view returns (uint256 shares);
}

The share price (convertToAssets(1e18) if the vault uses 18 decimals) only ever increases — yield accrues into the pool, so each share buys more of the underlying over time. Your accounting tracks shares, not assets, and converts at redeem time to know the exact yield captured.

Choosing a vault#

Three options dominate for USDC on Ethereum mainnet and major L2s:

Vault Protocol Address Notes
wUsdc-aave-v3 Aave v3 0x98C23E9d8f34FEFb1B7BD6a91B7FF122F4e16F5 (Mainnet) Deepest liquidity; instant redeem up to available reserves
mUSDC Morpho MetaMorpho 0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB (Mainnet) Higher rates; withdrawal delay possible if a market is fully utilised
cUSDCv3 Compound v3 0xc3d688B66703497DAA19211EEdff47f25384cdc3 (Mainnet) Separate Comet interface; not strictly ERC-4626, needs a wrapper

For Polygon and Base, Aave v3 has native deployments with the same ABI. For production use we recommend Aave v3 as the default: deepest reserves, instant withdrawals, and the widest smart-contract audit coverage.

Depositing after payment confirmation#

The right trigger is the payment_intent.confirmed webhook — funds have reached the hot wallet and finality is met. A background worker calls the vault deposit:

import { createPublicClient, createWalletClient, http, parseUnits } from 'viem';
import { mainnet } from 'viem/chains';
 
const USDC_ADDRESS   = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48';
const VAULT_ADDRESS  = '0x98C23E9d8f34FEFb1B7BD6a91B7FF122F4e16F5'; // Aave v3 wUsdc
 
const ERC4626_ABI = [
  { name: 'deposit',         type: 'function', inputs: [{ type: 'uint256' }, { type: 'address' }], outputs: [{ type: 'uint256' }] },
  { name: 'redeem',          type: 'function', inputs: [{ type: 'uint256' }, { type: 'address' }, { type: 'address' }], outputs: [{ type: 'uint256' }] },
  { name: 'convertToAssets', type: 'function', inputs: [{ type: 'uint256' }], outputs: [{ type: 'uint256' }] },
  { name: 'balanceOf',       type: 'function', inputs: [{ type: 'address' }], outputs: [{ type: 'uint256' }] },
] as const;
 
const ERC20_ABI = [
  { name: 'approve', type: 'function', inputs: [{ type: 'address' }, { type: 'uint256' }], outputs: [{ type: 'bool' }] },
] as const;
 
async function depositToVault(amountUsdc: number, hotWalletAddress: `0x${string}`) {
  const amount = parseUnits(amountUsdc.toString(), 6); // USDC has 6 decimals
 
  // 1. Approve vault to spend USDC
  const approveTx = await walletClient.writeContract({
    address: USDC_ADDRESS,
    abi: ERC20_ABI,
    functionName: 'approve',
    args: [VAULT_ADDRESS, amount],
  });
  await publicClient.waitForTransactionReceipt({ hash: approveTx });
 
  // 2. Deposit into vault, record shares returned
  const depositTx = await walletClient.writeContract({
    address: VAULT_ADDRESS,
    abi: ERC4626_ABI,
    functionName: 'deposit',
    args: [amount, hotWalletAddress],
  });
  const receipt = await publicClient.waitForTransactionReceipt({ hash: depositTx });
 
  // Parse `Deposit` event to extract exact shares minted
  const sharesLog = receipt.logs.find(l => l.topics[0] === DEPOSIT_TOPIC);
  const shares = BigInt(sharesLog?.data ?? '0x0');
 
  return { tx: depositTx, shares };
}

Store the shares value in your database alongside the payment_intent_id. This is your claim ticket — you need it to redeem the exact position later.

INSERT INTO vault_positions (
  payment_intent_id, vault_address, shares, deposited_assets, deposited_at
) VALUES (
  $payment_intent_id, $vault_address, $shares, $assets, now()
);

Accounting for share price at redeem time#

When the sweep job runs (every 6 hours, or at a configurable interval), it queries all open vault positions and redeems them:

async function redeemVaultPositions(hotWalletAddress: `0x${string}`) {
  const positions = await db.query(
    `SELECT * FROM vault_positions WHERE redeemed_at IS NULL`
  );
 
  for (const pos of positions) {
    const shares = BigInt(pos.shares);
 
    // Preview what redeeming now would yield
    const assetsNow = await publicClient.readContract({
      address: pos.vault_address as `0x${string}`,
      abi: ERC4626_ABI,
      functionName: 'convertToAssets',
      args: [shares],
    });
    const yieldEarned = assetsNow - BigInt(pos.deposited_assets);
 
    // Redeem shares to hot wallet
    const redeemTx = await walletClient.writeContract({
      address: pos.vault_address as `0x${string}`,
      abi: ERC4626_ABI,
      functionName: 'redeem',
      args: [shares, hotWalletAddress, hotWalletAddress],
    });
    await publicClient.waitForTransactionReceipt({ hash: redeemTx });
 
    await db.query(`
      UPDATE vault_positions
      SET redeemed_at = now(),
          redeemed_assets = $1,
          yield_earned = $2
      WHERE id = $3
    `, [assetsNow.toString(), yieldEarned.toString(), pos.id]);
  }
}

The yield_earned column is your audit trail. BchainPay surfaces this in the merchant dashboard under Treasury > Yield earned (30d), which for active merchants compounds into a meaningful line item on their payout statements.

BchainPay API integration#

You can configure vault deposit behaviour per merchant via the BchainPay API:

PATCH /v1/merchants/me/treasury
Authorization: Bearer sk_live_…
Content-Type: application/json
 
{
  "yield_strategy": {
    "enabled": true,
    "vault_address": "0x98C23E9d8f34FEFb1B7BD6a91B7FF122F4e16F5",
    "chain": "ethereum",
    "min_deposit_usdc": 100,
    "auto_redeem_before_sweep_minutes": 30
  }
}

The min_deposit_usdc threshold prevents depositing dust that would cost more in gas than it earns. The auto_redeem_before_sweep_minutes window ensures shares are converted back to USDC before the sweep job runs, so the sweep never needs to handle vault share tokens.

Querying current vault yield for a merchant:

GET /v1/merchants/me/treasury/yield?period=30d
Authorization: Bearer sk_live_…
{
  "period": "30d",
  "total_deposited_usdc": "1842300.00",
  "total_yield_earned_usdc": "9214.37",
  "effective_apy_bps": 601,
  "positions": [
    {
      "vault": "0x98C23E9d8f34FEFb1B7BD6a91B7FF122F4e16F5",
      "protocol": "aave-v3",
      "chain": "ethereum",
      "current_shares": "1823946721340000000000",
      "current_value_usdc": "423180.44",
      "yield_30d_usdc": "2890.11"
    }
  ]
}

Handling the approve-deposit atomically#

The two-step approve → deposit opens a brief window where the vault is approved to spend USDC but the deposit hasn't happened yet. If your worker crashes between the two calls, you have a dangling approval. Two mitigations:

Use exact-amount approvals, never MaxUint256. Set the approval to exactly the deposit amount. If the deposit never lands, the approval expires on its next use attempt (the vault will revert because transferFrom will move less than the approved amount only if the balance matches).

Use EIP-2612 permit + depositWithPermit where the vault supports it. Some ERC-4626 vaults expose a depositWithPermit function that takes a signed permit message and executes approve + deposit in a single transaction. The Aave v3 wrapped tokens do not yet expose this natively, but a wrapper contract can bundle the permit call:

// One transaction: permit + deposit
const permitSig = await signPermit(usdcAddress, vaultAddress, amount, deadline, signer);
await vaultWrapper.depositWithPermit(amount, receiver, deadline, permitSig.v, permitSig.r, permitSig.s);

This eliminates the dangling approval problem entirely and saves one transaction (one fewer confirmation to wait for).

Risk controls you must implement#

Yield is not free money. Three risks that matter for a production payment gateway:

Smart contract risk#

Vault contracts can be exploited. Aave v3 is the most battle-tested option ($40B+ TVL peak, multiple audits), but "battle-tested" is not "invulnerable". Mitigations:

  • Position caps: never deposit more than a configurable ceiling (e.g., 20% of daily TPV) into a single vault.
  • Protocol diversification: split across two vaults if positions are large.
  • Emergency exit: implement a withdrawAll() admin endpoint that redeems every open position immediately. Wire it to your PagerDuty runbook so on-call can trigger it without deploying code.
# Emergency exit via BchainPay API
curl -X POST https://api.bchainpay.com/v1/merchants/me/treasury/yield/emergency-exit \
  -H "Authorization: Bearer sk_live_…" \
  -d '{"confirm": true}'

Liquidity risk#

Aave v3 pools can be fully utilised, making instant withdrawals impossible. This is rare for USDC but it happens during market stress. Always set auto_redeem_before_sweep_minutes to at least 60 — give the redeem plenty of runway. If redeem reverts due to liquidity constraints, fall back to a linear retry with exponential backoff and page the on-call.

Depeg and price feed risk#

ERC-4626 vaults denominated in USDC protect you from ETH/BTC volatility. They do not protect against a USDC depeg. If Circle suspends redemptions, the vault shares become worth less than their face value. Keep vault positions as USDC only (not USDT in an Aave USDT vault, not multi-asset stable pools), and tie vault deposits to the same stablecoin depeg monitoring circuit breakers you already run — the ones described in Stablecoin depeg circuit breakers.

Gas cost versus yield earned#

At $50 gas price (gwei) and $3,000 ETH, a deposit + redeem pair costs:

  • approve: ~46k gas → $6.90
  • deposit: ~120k gas → $18.00
  • redeem: ~100k gas → $15.00

Total: ~$40 per round trip. The minimum deposit amount to break even on a 6% APY vault with a 6-hour hold:

min_usdc = 40 / (0.06 / 365 / 4) ≈ $1,625

Below ~$1,600 per deposit the gas cost exceeds the yield. Set your min_deposit_usdc threshold accordingly. On Polygon or Base (where gas is $0.01-$0.10), the break-even drops to under $50, making vault deposits economical for nearly every confirmed payment.

Key takeaways#

  • The gap between payment confirmation and sweep is real float — 4 to 12 hours of idle USDC at scale adds up to tens of thousands of dollars per year.
  • ERC-4626 standardises the interface: deposit, redeem, convertToAssets, convertToShares are the same on every conforming vault. Write one integration, swap vaults by changing an address.
  • Track shares, not assets. The share count is constant after deposit; the asset value grows. Record shares at deposit time and call convertToAssets at redeem time to compute exact yield.
  • Use permit + depositWithPermit where available to collapse the approve/deposit two-step into one atomic transaction and eliminate dangling approvals.
  • Set gas-aware minimum deposit thresholds. On mainnet, $1,500-$2,000 is the break-even for a 6-hour hold at 6% APY. On L2, the threshold drops to under $100.
  • Wire an emergency exit to your runbook. Smart contract exploits are rare but fast. Pre-built one-click redemption via API, tested quarterly, is the difference between a bad day and a catastrophic one.

Try it yourself

Spin up a sandbox merchant in under 60 seconds.

One REST endpoint, signed webhooks, five chains. No credit card required.

Related reading