|
| 1 | +/** |
| 2 | + * Example: ERC20 — atomic approve + Aave deposit in one batch |
| 3 | + * |
| 4 | + * Scenario: A user wants to move their entire USDC balance into Aave to earn |
| 5 | + * yield. Aave requires an ERC-20 approval before it can pull funds, which |
| 6 | + * normally means two separate transactions: approve, then supply. |
| 7 | + * |
| 8 | + * With ERC-8211, both happen atomically in a single batch. If either step |
| 9 | + * fails, neither executes — you can never end up in a state where the |
| 10 | + * approval was granted but the deposit didn't happen. |
| 11 | + * |
| 12 | + * The deposit amount is also unknown at construction time — it depends on |
| 13 | + * whatever balance the SCA holds at execution time. `runtimeBalance()` resolves |
| 14 | + * this: the same live balance value is used for both the approval amount and |
| 15 | + * the deposit amount, so they are guaranteed to match exactly. |
| 16 | + * |
| 17 | + * Flow: |
| 18 | + * 1. Pre-condition: assert USDC balance is worth depositing (not dust) |
| 19 | + * 2. Approve the Aave pool for exactly the runtime balance |
| 20 | + * 3. Supply that same runtime balance to Aave |
| 21 | + * 4. Post-condition: assert USDC balance is now (near) zero |
| 22 | + */ |
| 23 | + |
| 24 | +import { createComposableBatch } from '@biconomy/smart-batching'; |
| 25 | +import { createPublicClient, http, parseUnits } from 'viem'; |
| 26 | +import { base } from 'viem/chains'; |
| 27 | + |
| 28 | +// ─── Addresses (Base mainnet) ──────────────────────────────────────────────── |
| 29 | + |
| 30 | +const USDC = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'; |
| 31 | +const AAVE_V3_POOL = '0xA238Dd80C259a72e81d7e4664a9801593F98d1c5'; |
| 32 | + |
| 33 | +// ─── Minimal Aave V3 Pool ABI ──────────────────────────────────────────────── |
| 34 | + |
| 35 | +const AAVE_V3_POOL_ABI = [ |
| 36 | + { |
| 37 | + name: 'supply', |
| 38 | + type: 'function', |
| 39 | + stateMutability: 'nonpayable', |
| 40 | + inputs: [ |
| 41 | + { name: 'asset', type: 'address' }, |
| 42 | + { name: 'amount', type: 'uint256' }, |
| 43 | + { name: 'onBehalfOf', type: 'address' }, |
| 44 | + { name: 'referralCode', type: 'uint16' }, |
| 45 | + ], |
| 46 | + outputs: [], |
| 47 | + }, |
| 48 | +] as const; |
| 49 | + |
| 50 | +// ─── Setup ─────────────────────────────────────────────────────────────────── |
| 51 | + |
| 52 | +const publicClient = createPublicClient({ chain: base, transport: http() }); |
| 53 | + |
| 54 | +// Your ERC-4337 smart account address |
| 55 | +const scaAddress = '0xYourSmartAccountAddress' as `0x${string}`; |
| 56 | + |
| 57 | +// ─── Deposit floor ──────────────────────────────────────────────────────────── |
| 58 | + |
| 59 | +const MIN_DEPOSIT = parseUnits('10', 6); // Don't bother depositing less than 10 USDC |
| 60 | + |
| 61 | +// ─── Build the batch ───────────────────────────────────────────────────────── |
| 62 | + |
| 63 | +const batch = createComposableBatch(publicClient, scaAddress); |
| 64 | + |
| 65 | +const usdc = batch.erc20Token(USDC); |
| 66 | +const aavePool = batch.contract(AAVE_V3_POOL, AAVE_V3_POOL_ABI); |
| 67 | + |
| 68 | +batch.add([ |
| 69 | + // ── Step 1: pre-condition — assert balance is worth depositing ───────────── |
| 70 | + // `check()` acts as an inline guard. If the SCA holds less than MIN_DEPOSIT |
| 71 | + // the whole batch reverts before anything moves. |
| 72 | + usdc.check({ |
| 73 | + functionName: 'balanceOf', |
| 74 | + args: [scaAddress], |
| 75 | + constraint: { gte: MIN_DEPOSIT }, |
| 76 | + }), |
| 77 | + |
| 78 | + // ── Step 2: approve Aave for exactly the runtime balance ────────────────── |
| 79 | + // The approval amount is resolved on-chain at execution time — not hardcoded. |
| 80 | + // This means the approval is tight (no excess allowance left over) and always |
| 81 | + // matches the deposit amount in step 3 regardless of when the batch executes. |
| 82 | + usdc.write({ |
| 83 | + functionName: 'approve', |
| 84 | + args: [AAVE_V3_POOL, usdc.runtimeBalance()], |
| 85 | + }), |
| 86 | + |
| 87 | + // ── Step 3: supply the runtime balance to Aave ──────────────────────────── |
| 88 | + // Uses the same live balance as step 2. Both resolve via independent |
| 89 | + // staticcalls at execution time, but since no balance change happens between |
| 90 | + // them in this batch, they are guaranteed to return the same value. |
| 91 | + aavePool.write({ |
| 92 | + functionName: 'supply', |
| 93 | + args: [USDC, usdc.runtimeBalance(), scaAddress, 0], |
| 94 | + }), |
| 95 | + |
| 96 | + // ── Step 4: post-condition — assert the full balance was deposited ───────── |
| 97 | + // After a full-balance deposit the SCA should hold no USDC. If Aave only |
| 98 | + // partially accepted the deposit for any reason, this check catches it and |
| 99 | + // reverts the entire batch — including the approval and the supply. |
| 100 | + usdc.check({ |
| 101 | + functionName: 'balanceOf', |
| 102 | + args: [scaAddress], |
| 103 | + constraint: { eq: 0n }, |
| 104 | + }), |
| 105 | +]); |
| 106 | + |
| 107 | +// ─── Generate calldata ──────────────────────────────────────────────────────── |
| 108 | + |
| 109 | +const calls = await batch.toCalls(); |
| 110 | +// biome-ignore lint/suspicious/noConsole: intentional in examples |
| 111 | +console.log('Composable calls:', JSON.stringify(calls, null, 2)); |
0 commit comments