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), withbatching.sendCalls; - the example BazaarTradingAccount contract from Bazaar’s repository
(
examples/BazaarTradingAccount.sol), withbatching.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:
withdrawCollateralandclaimFeespay 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 }] }
| Option | Default | Meaning |
|---|---|---|
atomicRequired | true | Require the wallet to run the calls atomically: a Bazaar batch is meant as one. |
capabilities | none | Wallet 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:
| Field | Value |
|---|---|
action | "execute" |
to | The account. |
from | The owner, who sends it. |
value | The 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. |
summary | The inner call’s summary for one call, else "3 calls: ...; ...; ...". |
priceTime | The earliest of the inner calls’ price times. |
pullsUsdc | The 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 see | It means |
|---|---|
CallError from sendCalls | The 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 execute | Most 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__InsufficientMarginAfterOrder | The account’s collateral, after the batch’s earlier deposits, does not cover the order. |
MatchingEngineLib__FillOrKillUnfilled | A fill-or-kill order in the batch could not fill, and the whole batch reverted. |
Related Pages
- Building a Bot: a quoting loop from an EOA.
- Placing Orders and Collateral: the calls in a batch.
- Sending Transactions: one call at a time.