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.90deposit: ~120k gas → $18.00redeem: ~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,convertToSharesare 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
convertToAssetsat redeem time to compute exact yield. - Use
permit+depositWithPermitwhere 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.