Biconomy just shipped the Smart Batching SDK — a TypeScript library that turns the ERC-8211 "Smart Batching" standard into something you can actually use today. Here's why that matters, what becomes possible the moment you npm install it, and a tour of patterns you can ship this week.
The static-batch problem
Every batched transaction model we have today — ERC-4337 user ops, EIP-5792 wallet send-calls, and most account-abstraction "executor" patterns — shares one quiet limitation:
Every parameter is static. Frozen at signing time. Blind to whatever the chain looks like at execution.
That's fine for trivial flows. It falls apart the moment your batch has any data dependency between steps.
A few examples that any DeFi team has hit:
- Swap → supply. You swap USDC → WETH, then supply the WETH to Aave. The swap returns
0.04951WETH but the next call hardcodes0.05. Revert. - Vault withdraw → redeploy. Share-rate ticks one block before execution. Hardcoded amount no longer matches. Revert.
- Bridge → use. Bridge fees are non-deterministic. You don't know what arrives on the other side. Revert, or leave dust.
- Borrow loops. Each iteration depends on the last. You can't express that as a static batch.
- Sweep operations. "Send everything" is impossible when "everything" depends on gas costs you don't know yet.
- MEV. No way to assert between calls that the price you got is the price you wanted.
Workarounds today are all bad: deploy a custom Solidity router, run an off-chain simulator and re-sign, or accept reverts and refunds as part of the UX. None of these scale, and all of them push complexity onto product teams who shouldn't be writing single-purpose Solidity.
ERC-8211 in one paragraph
ERC-8211 — Smart Batching — is a Standards Track draft (created 2026-02-11) that introduces a programmable batch encoding. Instead of freezing parameter values at signing time, each parameter declares how it should be resolved on-chain at execution and what constraints it must satisfy. The batch becomes a small, deterministic program that reads chain state, validates it, and routes resolved values into target calls.
Three primitives carry the whole standard:
- Runtime parameter injection — fetchers (
RAW_BYTES,STATIC_CALL,BALANCE) that resolve values when the call actually runs. - Inline pre/post-assertions — predicates (
EQ,GTE,LTE,IN, the signed variantsSIGNED_GTE/SIGNED_LTE, and logicalOR) that gate execution. Fail one and the whole batch reverts. (The signed predicates andORland in this week's release — see examples 16 and 17.) - Shared storage context — a Storage contract that lets the output of step N become the input of step N+1, even across user operations.
If you want the full spec, the canonical reference is erc8211.com.
Enter the Smart Batching SDK
The standard is elegant. The encoding — fetcher tags, predicate selectors, calldata routing instructions — is not what you want to write by hand.
The Smart Batching SDK is the developer surface. It's a TypeScript library, built on Viem, that compiles intent-style code into the ComposableExecution[] payload that any ERC-8211-compatible executeComposable function expects. You write what should happen; the SDK emits the encoded program.
The SDK is just an encoder. That's the whole point.
This is the most important architectural fact about the SDK, and it's worth saying plainly:
The Smart Batching SDK is a calldata encoder. It produces the bytes that target an executeComposable function selector. Where that function lives is irrelevant to the SDK.The SDK has no opinion on:
- Whether
executeComposableis implemented on a smart account directly. - Whether it's installed as an ERC-7579 executor module.
- Whether it's an ERC-6900 plugin execution function.
- Whether it's delegated to from an EOA via ERC-7702.
- Whether it lives on a singleton contract that any account can call.
- Whether it's invoked by a bundler, a relayer, an MEE, a multisig, a session key, or a human signing in MetaMask.
The SDK takes your declarative description of a flow and emits two outputs:
batch.toCalls()— aComposableCall[]array. Use this when your execution client (e.g. an MEE / Modular Execution Environment, or any abstraction layer that accepts a calls array directly) handles the encoding for you.batch.toCalldata()— the full batch encoded asexecuteComposable(...)calldata, ready to drop into aUserOperation.callDataor any low-level call. Use this when you control the transaction directly via a bundler such as ZeroDev, Alchemy, Pimlico, or any ERC-4337 bundler.
That's it. That's the whole surface. The SDK does not deploy contracts, sign anything, send anything, or know anything about your account. It encodes. You ship the bytes.
This is why the SDK works equally well for a smart-account dApp, an EOA using 7702 delegation, a relayer service, or even a contract that wants to construct a composable batch on-the-fly and forward it. The only requirement is that somewhere downstream there's a function that implements the ERC-8211 executeComposable interface to receive the calldata.
The core API surface
A minimal mental model for the SDK is four modules:
| Module | What it gives you |
|---|---|
| Batch | createComposableBatch(publicClient, accountAddress) — the fluent builder, plus add(), toCalls(), toCalldata() |
| Token | ERC-20 helpers — runtimeBalance(), runtimeAllowance(), transfer, approve, check |
| Contract | Generic contract reads/writes — runtimeValue(), captures, arbitrary calls |
| Storage | Namespaced inter-call storage — capture a return value and inject it later |
The vocabulary you'll actually reach for:
runtimeBalance()— "use whatever this account holds at execution."runtimeAllowance()— "use whatever's been approved at execution."runtimeValue()— "execute this read at execution and use the result."check({ constraints })— "before continuing, assert this is true." Constraint operators includeeq,gte,lte,in, the signed variantssignedGte/signedLte, and a logicalorfor branching predicates.capture— "remember this return value for a later step."
Everything else composes from those.
Installation
npm install smart-batching viem
or with Bun (the SDK uses Bun internally):
bun add smart-batching viem
A guided tour, by use case
The rest of this article is examples. Each one is a real pattern that's painful or impossible with static batches and trivial with the SDK.
1. Safe ERC-20 transfer with pre/post guards
The "hello world" of composable batches: assert balance before, transfer, assert receipt after.
import { createComposableBatch } from 'smart-batching';
import { parseUnits } from 'viem';
const batch = createComposableBatch(publicClient, scaAddress);
const usdc = batch.erc20Token('0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48');
const amount = parseUnits('10', 6);
batch.add([
usdc.check({
functionName: 'balanceOf',
args: [scaAddress],
constraints: [{ gte: amount }],
}),
usdc.write({
functionName: 'transfer',
args: [recipient, amount],
}),
usdc.check({
functionName: 'balanceOf',
args: [recipient],
constraints: [{ gte: amount }],
}),
]);
const calldata = await batch.toCalldata();
// drop into UserOperation.callData
The constraint isn't checked off-chain. It's part of the encoded batch. If anything between signing and execution makes the pre-condition false, the whole thing reverts atomically — before the transfer fires.
2. Swap → supply with runtimeBalance()
This is the canonical example, and the one that pays the SDK's whole rent.
const usdc = batch.erc20Token(USDC);
const weth = batch.erc20Token(WETH);
const router = batch.contract(SWAP_ROUTER, swapRouterAbi);
const aave = batch.contract(AAVE_POOL, aavePoolAbi);
batch.add([
usdc.write({
functionName: 'approve',
args: [SWAP_ROUTER, parseUnits('1000', 6)],
}),
router.write({
functionName: 'swapExactTokensForTokens',
args: [parseUnits('1000', 6), 0n, [USDC, WETH], scaAddress, deadline],
}),
// Slippage guard: assert we got at least 0.49 WETH back
weth.check({
functionName: 'balanceOf',
args: [scaAddress],
constraints: [{ gte: parseUnits('0.49', 18) }],
}),
// Approve Aave for the *exact* amount we now hold
weth.write({
functionName: 'approve',
args: [AAVE_POOL, weth.runtimeBalance()],
}),
// Supply the *exact* amount, resolved on-chain
aave.write({
functionName: 'supply',
args: [WETH, weth.runtimeBalance(), scaAddress, 0],
}),
]);
const calldata = await batch.toCalldata();
No off-chain simulator. No "swap, wait, re-sign, supply" two-step UX. No revert on a 0.0001 WETH discrepancy. One signature, one atomic execution, with a slippage guard built into the batch itself.
3. Dustless full-balance sweep
"Send everything I have" is genuinely hard with static batches because you can't know what "everything" means at signing time — gas costs may have moved, a dust airdrop may have arrived, an interest-bearing token may have rebased. With runtimeBalance(), it's one call:
const usdc = batch.erc20Token(USDC);
batch.add([
usdc.write({
functionName: 'transfer',
args: [coldWallet, usdc.runtimeBalance()],
}),
]);
The amount transferred is whatever the account holds at execution. Zero dust. No simulator. Want to sweep a list of tokens?
const tokens = [USDC, USDT, DAI, WETH].map(addr => batch.erc20Token(addr));
batch.add(
tokens.map(t =>
t.write({
functionName: 'transfer',
args: [coldWallet, t.runtimeBalance()],
}),
),
);
One signed batch, full treasury sweep, no leftover dust on any token.
4. Native ETH sweep with gas reserve
Sending native ETH is trickier because you need gas to send the tx, so you want to transfer "balance minus a reserve." The SDK has no composable math primitive — you cannot subtract values inside the encoding. Any on-chain arithmetic has to come from somewhere that can do the math at execution time:
- A small custom Reader contract that returns the computed value, resolved via
runtimeValue(). This is the right route here, because the input (the live balance) isn't known until execution. - A value you compute yourself in TypeScript before signing — only viable when every input is known ahead of time, which a runtime balance is not.
So for a gas-reserved sweep, push the balance − reserve arithmetic into a Reader contract:
const eth = batch.nativeToken();
const reader = batch.contract(GAS_RESERVE_READER, gasReserveReaderAbi);
const reserveForGas = parseEther('0.001');
batch.add([
// Bail early if we don't even hold the reserve
eth.check({
constraints: [{ gte: reserveForGas }],
}),
eth.transfer({
to: coldWallet,
// No math primitive in the SDK — the "balance - reserve" computation lives
// in a tiny custom Reader contract and is resolved at execution.
value: reader.runtimeValue({
functionName: 'balanceMinusReserve',
args: [scaAddress, reserveForGas],
}),
}),
]);
The Reader contract is a handful of lines of Solidity (return account.balance - reserve;) and is reusable across every gas-reserved sweep you encode. The SDK stays a pure encoder; the arithmetic stays on-chain.
5. Approve-with-cleanup
Setting an allowance, using it, then resetting it to zero in one batch — a common pattern for protocols that want minimum allowance lifetime:
const usdc = batch.erc20Token(USDC);
const router = batch.contract(SWAP_ROUTER, swapRouterAbi);
const swapAmount = parseUnits('500', 6);
batch.add([
usdc.write({
functionName: 'approve',
args: [SWAP_ROUTER, swapAmount],
}),
router.write({
functionName: 'swapExactTokensForTokens',
args: [swapAmount, minOut, [USDC, WETH], scaAddress, deadline],
}),
// Defensive: assert allowance was fully consumed before resetting
usdc.check({
functionName: 'allowance',
args: [scaAddress, SWAP_ROUTER],
constraints: [{ lte: 0n }],
}),
usdc.write({
functionName: 'approve',
args: [SWAP_ROUTER, 0n],
}),
]);
Without composable batches, you'd need either a custom Solidity helper or accept that the allowance lingers between signed transactions — a real attack surface.
6. Vault deposit / redeploy across share-rate drift
ERC-4626 vaults convert shares to assets at a rate that can change every block. You sign at block N; the user op may execute at block N+3. With static amounts, you either over-redeem (revert) or under-redeem (leave funds behind).
const vault = batch.contract(VAULT, erc4626Abi);
const newVault = batch.contract(NEW_VAULT, erc4626Abi);
const asset = batch.erc20Token(USDC);
batch.add([
// Redeem all our shares, whatever they convert to
vault.write({
functionName: 'redeem',
args: [vault.runtimeBalance({ from: scaAddress }), scaAddress, scaAddress],
}),
// Make sure we got at least the expected minimum out
asset.check({
functionName: 'balanceOf',
args: [scaAddress],
constraints: [{ gte: minExpectedAssets }],
}),
// Approve and deposit *exactly* what we received
asset.write({
functionName: 'approve',
args: [NEW_VAULT, asset.runtimeBalance()],
}),
newVault.write({
functionName: 'deposit',
args: [asset.runtimeBalance(), scaAddress],
}),
]);
Vault migrations, yield rotations, and treasury rebalances stop being multi-tx jobs.
7. Cross-chain execution, predicate-gated
ERC-8211's killer feature for cross-chain UX: a destination batch can wait on a predicate. The relayer simulates via eth_call and submits the moment the condition passes.
// === Source chain (Base): bridge USDC to Ethereum ===
const baseBatch = createComposableBatch(basePublicClient, scaAddress);
const usdcBase = baseBatch.erc20Token(USDC_BASE);
const bridge = baseBatch.contract(BRIDGE_ROUTER, bridgeAbi);
baseBatch.add([
usdcBase.write({
functionName: 'approve',
args: [BRIDGE_ROUTER, usdcBase.runtimeBalance()],
}),
bridge.write({
functionName: 'bridge',
args: [USDC_BASE, ETHEREUM_CHAIN_ID, scaAddress, usdcBase.runtimeBalance()],
}),
]);
// === Destination chain (Ethereum): wait for funds, then supply to Aave ===
const ethBatch = createComposableBatch(ethPublicClient, scaAddress);
const usdcEth = ethBatch.erc20Token(USDC_ETH);
const aave = ethBatch.contract(AAVE_POOL, aavePoolAbi);
ethBatch.add([
// The relayer polls this predicate. Tx submits only when balance ≥ threshold.
usdcEth.check({
functionName: 'balanceOf',
args: [scaAddress],
constraints: [{ gte: minBridgedAmount }],
}),
usdcEth.write({
functionName: 'approve',
args: [AAVE_POOL, usdcEth.runtimeBalance()],
}),
aave.write({
functionName: 'supply',
args: [USDC_ETH, usdcEth.runtimeBalance(), scaAddress, 0],
}),
]);
const baseCalldata = await baseBatch.toCalldata();
const ethCalldata = await ethBatch.toCalldata();
// Both signed under one Merkle root by the user's account.
The destination batch is bridge-agnostic. Native bridge, Across, ERC-7683, LayerZero — the predicate just waits for the balance to land. The user sees one signature and "your funds will arrive shortly."
8. Leverage loop
Recursive borrow / swap / supply, where each iteration's amount is derived from the last. Static batches can't express this without unrolling at signing time, which means simulating the entire path off-chain — fragile.
const aave = batch.contract(AAVE_POOL, aavePoolAbi);
const usdc = batch.erc20Token(USDC);
const weth = batch.erc20Token(WETH);
const router = batch.contract(SWAP_ROUTER, swapRouterAbi);
const initialDeposit = parseUnits('1000', 6);
batch.add([
usdc.write({ functionName: 'approve', args: [AAVE_POOL, initialDeposit] }),
aave.write({ functionName: 'supply', args: [USDC, initialDeposit, scaAddress, 0] }),
// Borrow 70% LTV worth of WETH against the deposit
aave.write({
functionName: 'borrow',
args: [WETH, borrowAmountForCycle1, 2, 0, scaAddress],
}),
// Swap borrowed WETH back to USDC, using *runtime* WETH balance
weth.write({ functionName: 'approve', args: [SWAP_ROUTER, weth.runtimeBalance()] }),
router.write({
functionName: 'swapExactTokensForTokens',
args: [weth.runtimeBalance(), 0n, [WETH, USDC], scaAddress, deadline],
}),
// Re-supply whatever USDC we now have
usdc.write({ functionName: 'approve', args: [AAVE_POOL, usdc.runtimeBalance()] }),
aave.write({
functionName: 'supply',
args: [USDC, usdc.runtimeBalance(), scaAddress, 0],
}),
// Health-factor guard: assert we didn't accidentally over-leverage
aave.check({
functionName: 'getUserAccountData',
args: [scaAddress],
// Pseudocode — real shape depends on tuple constraint support
constraints: [{ healthFactor: { gte: parseUnits('1.5', 18) } }],
}),
]);
Each iteration's amount resolves at execution. The health-factor check at the end is the safety net.
9. Conditional flow with runtimeValue()
"Pick the higher-yield protocol at execution time" — the kind of decision that today requires an off-chain keeper.
const aaveOracle = batch.contract(AAVE_RATE_ORACLE, oracleAbi);
const compoundOracle = batch.contract(COMPOUND_RATE_ORACLE, oracleAbi);
const usdc = batch.erc20Token(USDC);
const aave = batch.contract(AAVE_POOL, aavePoolAbi);
// Pre-condition: only proceed if Aave's rate is ≥ Compound's at execution
batch.add([
aaveOracle.check({
functionName: 'getSupplyRate',
args: [USDC],
constraints: [{
gte: compoundOracle.runtimeValue({ functionName: 'getSupplyRate', args: [USDC] }),
}],
}),
usdc.write({ functionName: 'approve', args: [AAVE_POOL, usdc.runtimeBalance()] }),
aave.write({
functionName: 'supply',
args: [USDC, usdc.runtimeBalance(), scaAddress, 0],
}),
]);
If Aave's rate is below Compound's at execution, the batch reverts. Pair two such batches behind a multiplexer and you've got automated protocol routing without any off-chain infrastructure.
10. Storage: capturing a return value across calls
Sometimes you need step N's return value (not just a balance change) in step N+1. Use capture. Storage keys are unique bigints — typically a unix timestamp — and getStorageKey() mints a fresh one for you:
const storage = batch.storage();
const swapKey = storage.getStorageKey();
batch.add([
router.write({
functionName: 'swapExactTokensForTokens',
args: [parseUnits('1000', 6), 0n, [USDC, WETH], scaAddress, deadline],
// The function returns uint256[] of amounts; capture the last (output amount).
capture: { type: 'execResult', storageKey: swapKey, path: 'amounts[1]' },
}),
// Use the captured value in a later call
aave.write({
functionName: 'supply',
args: [WETH, storage.runtimeValue({ storageKey: swapKey }), scaAddress, 0],
}),
]);
This matters when the value isn't reflected as a token balance — for example, an NFT ID returned from a mint, a position ID from a perps protocol, or any opaque handle.
11. Storage: explicit write for cross-batch context
Storage is namespaced per-account and persists across user operations. That means a batch you sign today can read state written by a batch you signed yesterday. Because both batches must agree on the same slot, use a stable custom bigint key here rather than minting a fresh one with getStorageKey():
const storage = batch.storage();
const limitKey = 1n; // any app-chosen constant bigint, shared by both batches
// One-time setup batch
await storage.write({ storageKey: limitKey, value: parseUnits('500', 6) });
// Every subsequent payment batch
batch.add([
usdc.check({
functionName: 'balanceOf',
args: [scaAddress],
// Compare runtime balance change to the persisted limit
constraints: [{ lte: storage.runtimeValue({ storageKey: limitKey }) }],
}),
usdc.write({
functionName: 'transfer',
args: [recipient, paymentAmount],
}),
]);
Persistent on-chain context, no custom contract.
12. Subscription / scheduled payment
Pay exactly the current invoice amount (read on-chain), capped by a user-signed ceiling:
const invoice = batch.contract(INVOICE_CONTRACT, invoiceAbi);
const usdc = batch.erc20Token(USDC);
const userCap = parseUnits('100', 6);
batch.add([
// The amount-due read happens at execution; gated by the user's cap.
usdc.write({
functionName: 'transfer',
args: [
MERCHANT,
invoice.runtimeValue({
functionName: 'amountDue',
args: [scaAddress],
constraints: [{ lte: userCap }],
}),
],
}),
]);
If the invoice exceeds the cap, the batch reverts — no surprise charges. If it's under the cap, exact payment, no overpayment.
13. MEV-protected swap with bounded slippage
Instead of just minAmountOut at the swap level, assert price bounds between calls — the kind of thing sandwich bots arbitrage:
const usdc = batch.erc20Token(USDC);
const weth = batch.erc20Token(WETH);
const router = batch.contract(SWAP_ROUTER, swapRouterAbi);
const oracle = batch.contract(CHAINLINK_ETH_USD, oracleAbi);
batch.add([
// Assert the on-chain oracle price is within an expected band
oracle.check({
functionName: 'latestAnswer',
args: [],
constraints: [
{ gte: minOraclePrice },
{ lte: maxOraclePrice },
],
}),
usdc.write({ functionName: 'approve', args: [SWAP_ROUTER, parseUnits('1000', 6)] }),
router.write({
functionName: 'swapExactTokensForTokens',
args: [parseUnits('1000', 6), minOutFromSwap, [USDC, WETH], scaAddress, deadline],
}),
// Post-condition: assert we received within the expected range
weth.check({
functionName: 'balanceOf',
args: [scaAddress],
constraints: [
{ gte: expectedMinWeth },
{ lte: expectedMaxWeth },
],
}),
]);
Sandwich attacks rely on the ability to push the user's transaction to extreme outputs. The lte post-condition makes them unprofitable: they can't extract value without tripping the assertion and reverting the whole batch.
14. Multi-token treasury rebalance to target weights
Sell a basket of tokens, buy a basket of others, with each leg's amount derived at execution:
const sellTokens = [USDT, DAI];
const buyToken = batch.erc20Token(USDC);
const calls = sellTokens.flatMap(addr => {
const t = batch.erc20Token(addr);
return [
t.write({ functionName: 'approve', args: [SWAP_ROUTER, t.runtimeBalance()] }),
router.write({
functionName: 'swapExactTokensForTokens',
args: [t.runtimeBalance(), 0n, [addr, USDC], scaAddress, deadline],
}),
];
});
batch.add([
...calls,
buyToken.check({
functionName: 'balanceOf',
args: [scaAddress],
constraints: [{ gte: minTotalUsdcAfter }],
}),
]);
The total USDC floor is asserted at the end — partial fills don't matter as long as the final position satisfies the constraint.
15. Emergency exit / stop-loss
Sign now, executes only when the price condition triggers:
const oracle = batch.contract(CHAINLINK_ETH_USD, oracleAbi);
const weth = batch.erc20Token(WETH);
const router = batch.contract(SWAP_ROUTER, swapRouterAbi);
batch.add([
// Predicate: only execute if ETH/USD < stopLossPrice
oracle.check({
functionName: 'latestAnswer',
args: [],
constraints: [{ lte: stopLossPrice }],
}),
weth.write({ functionName: 'approve', args: [SWAP_ROUTER, weth.runtimeBalance()] }),
router.write({
functionName: 'swapExactTokensForTokens',
args: [weth.runtimeBalance(), minOut, [WETH, USDC], scaAddress, deadline],
}),
]);
A relayer simulates this via eth_call every block. The transaction submits the moment the predicate passes. No keeper contract, no custom Solidity, no deposit-locked architecture.
16. Signed comparisons: PnL-gated position close
New in this week's release. Perps and margin protocols expose values like unrealized PnL as int256 — they can go negative. The unsigned gte/lte operators compare raw words, so a negative int256 (which is an enormous number in two's-complement form) would satisfy an unsigned gte by accident. signedGte / signedLte interpret the value as a signed integer, so the comparison is correct across the zero boundary.
const perps = batch.contract(PERPS_CLEARINGHOUSE, perpsAbi);
batch.add([
// PnL is int256 and may be negative. Take profit only if PnL ≥ +500 USDC.
perps.check({
functionName: 'getUnrealizedPnl',
args: [scaAddress, positionId],
constraints: [{ signedGte: parseUnits('500', 6) }],
}),
perps.write({
functionName: 'closePosition',
args: [positionId],
}),
]);
The same primitive flips for a signed stop-loss — { signedLte: parseUnits('-200', 6) } closes once the loss is at least 200 USDC. Anything that reads or asserts on a signed quantity (PnL, funding rate deltas, net position size) should reach for the signed operators rather than the unsigned ones.
17. OR flow: combined stop-loss / take-profit in one predicate
New in this week's release. Multiple constraints on a single check are ANDed by default — every branch must hold. or lets a predicate pass when any branch holds. The canonical case is a single signed batch that exits a position on either a stop-loss or a take-profit, whichever the market hits first:
const oracle = batch.contract(CHAINLINK_ETH_USD, oracleAbi);
const weth = batch.erc20Token(WETH);
const router = batch.contract(SWAP_ROUTER, swapRouterAbi);
batch.add([
// Trigger if price ≤ stopLoss OR price ≥ takeProfit
oracle.check({
functionName: 'latestAnswer',
args: [],
constraints: [{
or: [
{ lte: stopLossPrice },
{ gte: takeProfitPrice },
],
}],
}),
weth.write({ functionName: 'approve', args: [SWAP_ROUTER, weth.runtimeBalance()] }),
router.write({
functionName: 'swapExactTokensForTokens',
args: [weth.runtimeBalance(), minOut, [WETH, USDC], scaAddress, deadline],
}),
]);
One signature, one relayer polling the predicate, and the exit fires at whichever band the price reaches first — no need to sign and manage two separate orders. or composes with the signed operators too, so a PnL-based bracket ({ or: [{ signedLte: stopPnl }, { signedGte: targetPnl }] }) is a one-liner.
Two output modes — and why both exist
This is worth a section because picking the wrong one is the most common integration mistake.
batch.toCalls(): Promise<ComposableCall[]>
Returns an array of ComposableCall objects — the structured representation of each entry in the batch. Use this when your execution client accepts a calls array directly:
- Modular Execution Environments (MEEs) that handle batching/encoding internally.
- High-level abstractions (Biconomy's own SDK clients, similar wrapper layers) that take "an array of things to do" and figure out the rest.
- Your own scripts where you want to inspect the structured batch before encoding.
const calls = await batch.toCalls();
await mee.execute({ calls });
batch.toCalldata(): Promise<Hex>
Returns the full batch encoded as executeComposable(...) calldata — a hex string ready to drop into any low-level call. Use this when you control the transaction directly:
- ERC-4337 bundlers (ZeroDev, Alchemy, Pimlico, Stackup, Voltaire) where you build your own
UserOperation. - ERC-7702 delegations where you're building an EOA-originated transaction.
- Direct contract calls — a multisig executing via
Safe.execTransaction(...), a relayer's wrapper contract, anywhere you can put bytes.
const callData = await batch.toCalldata();
// ERC-4337 path
const userOp = {
sender: scaAddress,
callData,
// ...nonce, gas, signature
};
await bundlerClient.sendUserOperation({ userOp });
// Or any contract-level call
await walletClient.sendTransaction({
to: scaAddress,
data: callData,
});
// Or a Safe transaction
await safe.execTransaction({
to: scaAddress,
data: callData,
// ...
});
The same encoded calldata works in all three cases, because it's targeting the executeComposable selector — the SDK doesn't care where that function lives.
Where the executeComposable function actually lives
Since the SDK is selector-agnostic, here's the menu of places that selector can be implemented, and the integration story for each:
ERC-7579 executor module. The most common path. Install Biconomy's (or any) ERC-8211 executor module on a 7579 account. The module exposes executeComposable via the standard executor interface. Your toCalldata() output is the data field; the account routes to the module via its standard execute flow.
ERC-6900 plugin. Same idea, different account standard. The plugin exposes executeComposable as a plugin execution function. The account dispatches to it.
ERC-7702 delegation. EOAs delegate to an implementation contract via 7702. If that implementation includes executeComposable, the SDK's calldata works directly — no smart account deployment needed, just an EOA with a 7702 authorization.
Native account inheritance. If you ship a smart account that inherits IComposableExecution directly, the SDK's calldata is your account's callData. Done.
Singleton router. Deploy a single executeComposable contract that any caller can invoke — useful for relayer architectures where the relayer holds funds in escrow and runs composable batches on behalf of users. The SDK's calldata becomes the call payload.
Custom executor contracts. Building something exotic? As long as it implements the executeComposable selector with the ERC-8211 ABI, the SDK's output works.
The SDK itself never asks which of these you're using. It just emits calldata.
Use cases the SDK unlocks (the cheat sheet)
The examples above span most of these. Quick index:
- Swap cascades — exact-output usage after dynamic swaps.
- Vault migrations / yield rotation — share-rate-tolerant.
- Cross-chain rebalancing — bridge-agnostic, one-signature.
- Leverage loops — recursive position building with health-factor guards.
- Dustless transfers — full-balance sweeps without overestimation.
- MEV protection — inline price-band assertions between calls.
- Conditional defi flows — protocol routing based on on-chain reads.
- Subscriptions — pay current invoice, capped by signed limit.
- Stop-loss / triggered exits — predicate-gated execution, relayer-driven.
- Signed-value gating — PnL-aware exits and any
int256assertion viasignedGte/signedLte. - OR conditions — stop-loss/take-profit brackets in a single predicate.
- Multi-token rebalances — basket sells/buys with end-state constraints.
- Allowance hygiene — set, use, reset in one atomic batch.
- Persistent on-chain context — Storage-backed limits, counters, IDs.
Who should be paying attention
Smart account / wallet teams. ERC-8211 is the next composability primitive. Installing the module gives every one of your users access to all of the above, and the Smart Batching SDK is what your dApp partners will integrate against.
DeFi protocol teams. Stop shipping bespoke Solidity routers for every multi-step product flow. Express the flow in TypeScript, let the user's account execute it. New product, new batch — no new audit.
Aggregators and intent solvers. ERC-8211 is the natural execution layer for solver outputs. The solver decides routing; the composable batch enforces the bounds.
dApp developers. If your UX has ever been "approve, wait, then click again," or "we'll refund you if the price moves," this is the primitive that fixes it. One signature, one atomic flow, slippage-protected.
Power users / treasury / DAO ops. Multi-step treasury operations — sweeping, rebalancing, vesting — become signable atomic operations instead of multi-tx sagas.
AI agent developers. An agent signing a composable batch with bounds and assertions is meaningfully safer than one signing a static batch with hope. Constraints become the agent's safety rails.
What the SDK does not do
Worth being explicit, because this confuses people on first read:
- It's not a smart account. You bring your own account (or EOA + 7702) that has access to an
executeComposableimplementation. - It's not a bundler / paymaster. Output is
ComposableCall[]orexecuteComposablecalldata — feed it into your existing 4337 stack. - It's not a solver. It encodes the flow you describe; it doesn't decide routes for you. Compose it with Bungee, LiFi, 1inch, or your own solver.
- It doesn't sign. Signing happens at the layer above — wallet, session key, agent, multisig.
- It doesn't deploy contracts. New flows don't require new contracts.
- It doesn't do composable math. There's no arithmetic primitive — push any on-chain math into a small custom Reader contract (resolved via
runtimeValue()), or precompute in TypeScript when the inputs are known ahead of signing.
It's the encoding and developer-ergonomics layer. Deliberately narrow, deliberately composable.
Getting started
The SDK is on GitHub: bcnmy/smart-batching-sdk. It's TypeScript, Viem-based, Bun-managed, Vitest-tested. Idiomatic for anyone who's already shipping with Viem.
The shape of an integration:
npm install smart-batching viem.- Make sure the smart account (or EOA + 7702 delegation) you're targeting has an ERC-8211
executeComposableimplementation reachable. - Replace your static
calls[]array with acreateComposableBatch(...).add([...]).toCalldata()(or.toCalls()) pipeline. - Reach for
runtimeBalance(),runtimeAllowance(),runtimeValue(), andcheck()everywhere you currently overestimate-and-pray.
You'll know it's working when one of your "this flow needs two transactions and a backend job" features collapses into a single signed batch.
Why this matters beyond DeFi
Static batches put a ceiling on what an account can express in one signature. Lifting that ceiling changes a lot of categories:
- Wallet UX. "Sign once, do five things" stops being a marketing line and starts being literal.
- Cross-chain apps. A user signs one root; predicates handle the choreography. The bridge becomes an implementation detail.
- AI agents. Bounded, assertion-gated execution is the difference between safe and unsafe autonomy.
- Game economies. "Buy item, equip, transfer" with on-chain state checks between each step.
- Subscriptions and recurring payments. Pay exactly what's owed, capped at a signed ceiling.
- Treasury automation. Multisig-friendly composable batches replace bespoke ops scripts.
ERC-8211 is — quietly — one of the more important ergonomics-shaped standards to land on Ethereum since 4337. The Smart Batching SDK is what makes it real for product teams today, without writing a single line of Solidity.
Resources
- SDK source: https://github.com/bcnmy/smart-batching-sdk
- NPM package: https://www.npmjs.com/package/@biconomy/smart-batching
- ERC-8211 reference: erc8211.com
- Spec discussion: Ethereum Magicians — open feedback on constraint mechanisms, Storage contract design, and integration patterns
If you ship something interesting with it, we want to see it.