· 17 min read

Smart Batching SDK: Building on ERC-8211 Without Touching Solidity

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:

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:

  1. Runtime parameter injection — fetchers (RAW_BYTES, STATIC_CALL, BALANCE) that resolve values when the call actually runs.
  2. Inline pre/post-assertions — predicates (EQ, GTE, LTE, IN, the signed variants SIGNED_GTE/SIGNED_LTE, and logical OR) that gate execution. Fail one and the whole batch reverts. (The signed predicates and OR land in this week's release — see examples 16 and 17.)
  3. 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:

The SDK takes your declarative description of a flow and emits two outputs:

  1. batch.toCalls() — a ComposableCall[] 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.
  2. batch.toCalldata() — the full batch encoded as executeComposable(...) calldata, ready to drop into a UserOperation.callData or 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:

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:

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:

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:

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:


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 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:

  1. npm install smart-batching viem.
  2. Make sure the smart account (or EOA + 7702 delegation) you're targeting has an ERC-8211 executeComposable implementation reachable.
  3. Replace your static calls[] array with a createComposableBatch(...).add([...]).toCalldata() (or .toCalls()) pipeline.
  4. Reach for runtimeBalance(), runtimeAllowance(), runtimeValue(), and check() 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:

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

If you ship something interesting with it, we want to see it.