Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Smart Accounts & Batching

Bazaar has no batch form of createOrder: each order is one call. (Canceling and amending are batched already: cancelOrders takes up to 200 ids and amendOrders up to 32 amendments.) To place several orders at once, or to approve, deposit and trade in one step, trade from a contract account that calls the pair several times in one transaction. That gets most of what a batch entry point would: one transaction’s overhead, warm storage for the later calls, and one price update for all of them.

The SDK supports two ways to submit such a batch:

  • a wallet that batches natively through EIP-5792 wallet_sendCalls (many smart wallets, ERC-4337 accounts and Safes speak it), with batching.sendCalls;
  • the example BazaarTradingAccount contract from Bazaar’s repository (examples/BazaarTradingAccount.sol), with batching.tradingAccountExecute.

Read Batching Orders in Bazaar’s docs for the pattern and its costs. The samples assume bz connected as in Connecting and eth a market.

The Account Is the Trader

The pair sees ordinary calls from the account’s address, so the account, not its owner, holds everything: collateral, positions, resting orders and claimable fees. That means:

  • Build every call with from = the account. With a smart wallet, the address the wallet connects as is the account.
  • Approve USDC to the pair from the account (approveUsdc(account, pair, ...)), in the same batch or before. A deposit or a bounty pulls from the account’s USDC.
  • Claim through the account, and move withdrawn USDC on to the owner inside the batch: withdrawCollateral and claimFees pay the caller.
  • The account is one trader: one net position per market, 32 active Limit + StopLimit orders, one TakeProfit and one StopLoss, shared by everything it does.
  • Its own orders never fill each other. A later order that crosses an earlier one is a self-match and is canceled. Quote both sides at prices that do not cross.
  • An auto-cancel does not stop the batch (margin, self-match, post-only): the call returns normally and the batch goes on. A fill-or-kill that cannot fill, a bad hint, or a margin failure at creation reverts that call, and with it the whole batch.
  • Gas per order is unchanged. A crossing order can walk 32 makers at roughly 90k gas each, so keep batches of taking orders short: about nine full walks reach Arbitrum’s 32M gas per-transaction limit.
  • Hints are computed per order, against the live book, when each call is built.
  • No gasless path. The relayed path checks a plain ECDSA signature, which a smart account cannot produce; it sends its calls directly (Gasless Trading & Relayers).

Building the Batch

// With a smart wallet, the connected address IS the account that trades: build every call from it.
const [account] = (await window.ethereum.request({ method: "eth_requestAccounts" })) as Address[];
const expiry = (await bz.ctx.chain.blockNumber()) + blocksIn(24 * 60 * 60);
const quote = (isLong: boolean, price: string) =>
  bz.trade.createOrder(eth.address, account, {
    orderType: OrderType.Limit,
    isLong,
    size: parseUnits18("1"),
    limitPrice: parseUnits18(price),
    isPostOnly: true,
    expirationBlock: expiry,
  });

const calls: PreparedCall[] = [
  await bz.allowance.approveUsdc(account, eth.address, toUsdcUnits(parseUnits18("2000"))),
  await bz.trade.depositCollateral(eth.address, account, parseUnits18("2000")),
  await quote(true, "2980"), // the first order carries the Pyth update...
  await quote(true, "2970"),
  await quote(false, "3020"), // ...the later ones are re-encoded without it
];
const batch = batching.shareOnePriceUpdate(calls);

batching.toBatchCalls(calls) turns prepared calls into the generic { to, data, value } shape smart wallets take, in order; batching.batchValue(calls) sums their ETH.

shareOnePriceUpdate

Every price-carrying builder attaches its own Pyth update and pays its own fee. In one transaction only the first is needed: the pair reads Pyth’s on-chain price first and parses an update only when that price is more than 3 seconds old, so once one call has stored a fresh price, the later calls find it.

batching.shareOnePriceUpdate(calls) keeps the update and fee on the first call to each pair that always stores a price (createOrder, amendOrders, executeTriggeredOrders, refreshPrice), and re-encodes the later calls to the same pair with an empty priceUpdate, value 0 and no priceTime. Calls to other contracts (an approval, say) and calls with no price update are left as they are. The result must run in one transaction, in this order.

EIP-5792 Wallets

try {
  const id = await batching.sendCalls(window.ethereum, bz.ctx, account, batch); // atomic by default
  for (;;) {
    const status = (await batching.getCallsStatus(window.ethereum, id)) as { status?: number; receipts?: { transactionHash: Hex }[] };
    if (status.status === 200) {
      show(`batch landed: ${status.receipts?.[0]?.transactionHash}`);
      break;
    }
    if (status.status !== undefined && status.status >= 400) {
      show("the batch failed; nothing in it was applied");
      break;
    }
    await new Promise((r) => setTimeout(r, 1000));
  }
} catch (e) {
  show(errorMessage(e)); // a CallError: the wallet refused, or does not support wallet_sendCalls
}

batching.sendCalls(wallet, ctx, from, calls, opts?) sends wallet_sendCalls and returns the wallet’s batch id. Track it with batching.getCallsStatus(wallet, id), which returns the wallet’s wallet_getCallsStatus answer as it gave it (in EIP-5792 2.0, status 100 is pending, 200 confirmed, 400 and above failed). Every failure, including a wallet that does not support the method, is thrown as a CallError. Build price-carrying calls right before sending.

bz.batching.walletSendCallsRequest(from, calls, opts?) returns the request itself, for a library that sends it (wagmi’s sendCalls, say):

const request = bz.batching.walletSendCallsRequest(account, batch, {
  atomicRequired: true, // the default
  capabilities: { paymasterService: { url: "https://paymaster.example.com" } }, // passed through as given
});
// { method: "wallet_sendCalls", params: [{ version: "2.0.0", chainId: "0xa4b1", from, atomicRequired, calls, capabilities }] }
OptionDefaultMeaning
atomicRequiredtrueRequire the wallet to run the calls atomically: a Bazaar batch is meant as one.
capabilitiesnoneWallet capabilities (a paymaster service and the like), passed through as given.
version"2.0.0"The EIP-5792 request version.

A batch from a smart wallet cannot be simulated with bz.simulate, which runs one call. Simulate each call that does not depend on an earlier one in the batch, or rely on the wallet’s own simulation.

BazaarTradingAccount

The example account is the smallest contract that does the job: an owner, and one function, execute(Call[] calls), which runs the calls in order as the account and returns each call’s return data. The first call that fails reverts the whole batch with that call’s own revert data, so a failure decodes like a direct call’s. It is yours to deploy, one per trader; it is not part of the protocol and has not been audited with it. Only its owner may call execute.

// The example BazaarTradingAccount: the owner's EOA sends one execute() carrying every call.
const tradingAccount: Address = "0x0000000000000000000000000000000000000002";
const [owner] = (await window.ethereum.request({ method: "eth_accounts" })) as Address[];
if (!isAddressEqual(await bz.batching.readTradingAccountOwner(tradingAccount), owner)) throw new Error("only the account's owner may call execute");

const inner = batching.shareOnePriceUpdate([
  await bz.allowance.approveUsdc(tradingAccount, eth.address, toUsdcUnits(parseUnits18("2000"))),
  await bz.trade.depositCollateral(eth.address, tradingAccount, parseUnits18("2000")),
  await bz.trade.createOrder(eth.address, tradingAccount, { orderType: OrderType.Market, isLong: true, size: parseUnits18("0.5"), maxSlippageBp: 50n }),
]);
const exec = batching.tradingAccountExecute(tradingAccount, owner, inner);
exec.value; // the inner calls' Pyth fees, summed
exec.pullsUsdc; // from the trading account's USDC, not the owner's

const sim = await bz.simulate(exec);
if (!sim.ok) {
  show(sim.error!.message); // the first failing inner call's own revert, decoded
} else {
  const results = batching.decodeTradingAccountResults(sim.returnData); // one return value per inner call
  show(`${results.length} calls would succeed`);
  await bz.waitForReceipt(await bz.send(window.ethereum, exec));
}

batching.tradingAccountExecute(account, owner, calls) returns one PreparedCall to the account:

FieldValue
action"execute"
toThe account.
fromThe owner, who sends it.
valueThe sum of the inner calls’ values (their Pyth fees). The account forwards each call’s own; the pair refunds any unspent fee to the account.
summaryThe inner call’s summary for one call, else "3 calls: ...; ...; ...".
priceTimeThe earliest of the inner calls’ price times.
pullsUsdcThe sum of what the inner calls pull, which comes from the account’s USDC and its allowance to each pair, not the owner’s wallet.

Simulate it as the owner (bz.simulate(exec) does, since from is the owner) and batching.decodeTradingAccountResults(sim.returnData) gives one Hex return value per inner call. bz.batching.readTradingAccountOwner(account) reads the owner. The lower-level pieces are batching.TRADING_ACCOUNT_ABI and batching.encodeTradingAccountExecute(batchCalls).

Getting Money Out

withdrawCollateral pays the account. To reach the owner, add a USDC transfer to the same batch. The SDK has no builder for a plain transfer, so encode it with viem:

// withdrawCollateral pays the account, so move the USDC on to the owner in the same batch. The SDK has
// no transfer builder; encode USDC's transfer with viem.
const amount = parseUnits18("500");
const transfer: PreparedCall = {
  action: "transfer",
  to: bz.ctx.usdc,
  data: encodeFunctionData({ abi: erc20Abi, functionName: "transfer", args: [owner, toUsdcUnits(amount)] }),
  value: 0n,
  from: tradingAccount,
  summary: `Send ${toUsdcUnits(amount) / 1_000_000n} USDC to ${owner}`,
};
const out = batching.tradingAccountExecute(tradingAccount, owner, [await bz.trade.withdrawCollateral(eth.address, tradingAccount, amount), transfer]);
if ((await bz.simulate(out)).ok) await bz.send(window.ethereum, out);

What Can Go Wrong

You seeIt means
CallError from sendCallsThe wallet refused, or does not support wallet_sendCalls (fall back to sending the calls one by one).
InputError on calls: “Nothing to send.”An empty batch.
Unknown(0x...) from executeMost often BazaarTradingAccount__NotOwner: the sender is not the account’s owner. The SDK’s decoder does not know the example contract’s errors.
Error: “ERC20: transfer amount exceeds allowance”The account (not the owner) lacks USDC or an allowance to the pair.
OrderManagementLib__InsufficientMarginAfterOrderThe account’s collateral, after the batch’s earlier deposits, does not cover the order.
MatchingEngineLib__FillOrKillUnfilledA fill-or-kill order in the batch could not fill, and the whole batch reverted.