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

Sending Transactions

Every write the SDK builds, on any Bazaar contract, comes back as one shape: a PreparedCall. The SDK never holds a user’s key and never sends anything by itself. You inspect the call, simulate it as the user, show its summary, and hand it to whatever signs: a browser wallet, a viem or wagmi client, or a bot’s local key.

This page covers that path end to end. The builders themselves (createOrder, depositCollateral, …) are on their own pages: Collateral, Placing Orders, Managing Orders. The gasless path, where the user signs and a relayer sends, is in Gasless Trading & Relayers.

The samples assume bz connected as in Connecting, user the wallet’s address, and eth a market from Markets.

The Flow

  1. Build the call with a builder. It checks its inputs against what it can read (the account’s orders, the book, the live price) and throws an InputError naming the field for anything it would refuse.
  2. Simulate it as the user (bz.simulate). A revert comes back decoded, with a sentence for the user, before anything is signed.
  3. Show call.summary (and, for a call that takes USDC, call.pullsUsdc) on the confirmation screen.
  4. Send it with the user’s wallet (bz.send, toTransactionRequest for viem and wagmi, or a local key in a bot).
  5. Wait for the receipt (bz.waitForReceipt).

A call that carries a Pyth price update has to land within 3 seconds of that update’s publish time, so for those calls steps 1 to 4 must happen in quick succession. See The 3-Second Price Window.

PreparedCall

const block = await bz.ctx.chain.blockNumber();
const call: PreparedCall = await bz.trade.createOrder(eth.address, user, {
  orderType: OrderType.Limit,
  isLong: true,
  size: parseUnits18("0.5"),
  limitPrice: parseUnits18("3000"),
  expirationBlock: block + orders.blocksIn(24 * 60 * 60), // about a day
});

call.action; // "createOrder"
call.to; // the pair
call.data; // the calldata
call.value; // the Pyth update fee in wei
call.from; // user
call.summary; // "Buy 0.5 at 3000 (limit, GTC)"
call.priceTime; // the update's publish time (unix seconds)
call.pullsUsdc; // 0n: a limit order pulls nothing from the wallet
FieldTypeMeaning
actionstringThe contract function: "createOrder", "depositCollateral", "approve", "execute", "proposeTermination", …
toAddressThe contract to call: the pair, USDC for an approval, the terminator or factory for governance calls.
dataHexThe calldata.
valuebigintETH to send, in wei: the Pyth update fee when the call carries a price update, otherwise 0n.
fromAddress | nullThe account the call acts for: the sender of a direct call. null when the builder does not know it (a relayed call encoded without relayer).
summarystringOne line for a confirmation screen, such as Deposit 100 USDC as collateral or Buy 0.5 at 3000 (limit, GTC). Amounts print exactly.
priceTimenumber | undefinedPresent when the call carries a Pyth update: the update’s publish time in unix seconds (the oldest leg’s, for a composite market).
noFreshPricestring | undefinedPresent when the call takes a Pyth update but carries none because the price source had none the oracle would accept: why. The pair then judges the call on its stored price.
pullsUsdcbigint | undefinedUSDC the call takes from the wallet, in BAZAAR precision (18 decimals): a deposit’s amount, a trigger order’s keeper bounty, 0n for calls that pull nothing. Check the wallet against it with usdcFunding.

The builders that read a price fetch the update from the connection’s prices source; a connection without one fails with ConnectError (“this call needs a fresh Pyth price”) on those builders. They attach it only when the oracle would accept it (priceUpdateFor, updateProblem): an update that fails verification reverts the whole call, while a call with none runs on the pair’s stored price. So when the source’s price is one Pyth carries forward from a closed venue, wider than the 2% confidence cap, or not a usable live price, a market with trading hours gets no update (the call’s noFreshPrice says why), and a continuously traded market, which has no stale fallback, fails with NoFreshPriceError instead of building a call that must revert.

BuilderCarries a price update
createOrder, amendOrders, executeTriggeredOrdersWhenever the oracle would accept one (above).
withdrawCollateralWhen a price source is connected and has one the oracle would accept (the pair refunds the fee when it does not read the price), or withPrice: true. A flat account’s withdrawal goes without one when there is none.
depositCollateral, cancelOrders, claimFees, approveUsdcNever.

The insurance and termination builders that read a price are described on Insurance and Termination & Settlement.

Simulating

const sim = await bz.simulate(call); // as call.from; pass { from } to simulate as someone else
if (!sim.ok) {
  show(sim.error!.message); // "A limit price must be within 50% of the market price..."
} else {
  console.log(`estimated gas: ${sim.gas}`);
}

bz.simulate(call, opts?) runs the call with eth_call as opts.from, else call.from, sending call.value, and nothing is signed or broadcast. It is simulate(bz.ctx, call, opts) with the context bound.

OptionDefaultMeaning
fromcall.fromThe account to simulate as.
estimateGastrueAlso run eth_estimateGas. Skipped when there is no from; a failed estimate leaves gas null rather than failing the simulation.

The result is a Simulation:

FieldTypeMeaning
okbooleanThe call would succeed against the current state.
returnDataHexThe call’s return data, or the revert data when ok is false ("0x" when the node returned none).
errorDecodedError | nullWhy it would revert: the custom error’s name, its decoded args, and message, a sentence for the user. Null when ok. See Errors & Messages.
gasbigint | nullThe eth_estimateGas answer, when it was asked for and succeeded.

A JSON-RPC error answer counts as a revert (ok: false). A transport failure (the RPC is down, an HTTP error, a timeout) is not a revert: simulate throws it.

A simulation is against the state at the latest block. The transaction lands later, after other transactions, so a successful simulation is a strong hint, not a guarantee. It also stores no price: the simulated call reads the update it carries exactly as the real one will.

Sending Through an EIP-1193 Wallet

try {
  const hash = await bz.send(window.ethereum, call); // eth_sendTransaction; resolves once the wallet accepts
  const receipt = await bz.waitForReceipt(hash); // throws CallError if it reverted
  console.log(`order placed in block ${receipt.blockNumber}`);
} catch (e) {
  show(errorMessage(e)); // "You rejected the request in your wallet.", a decoded revert, ...
}

bz.send(wallet, call, opts?) (the standalone sendCall) asks any EIP-1193 provider, such as window.ethereum, to eth_sendTransaction the call, and returns the transaction hash once the wallet has accepted it. The wallet estimates gas and fees itself.

OptionDefaultMeaning
fromcall.fromThe sending account. A call with neither fails with CallError("sending needs the user's address (from)").
gasnoneA gas limit to send instead of the wallet’s own estimate.

Every failure is thrown as a CallError: the user rejecting the prompt, the wallet refusing, or the wallet’s gas estimate reverting (in which case decoded holds the reason).

Sending With viem or wagmi

toTransactionRequest(call) returns { to, data, value, account } (without account when call.from is null), the shape viem’s sendTransaction takes:

const walletClient = createWalletClient({ chain: arbitrum, transport: custom(window.ethereum) });
const request = toTransactionRequest(call); // { to, data, value, account }
const viaViem = await walletClient.sendTransaction({ ...request, account: request.account ?? user });
await bz.waitForReceipt(viaViem);

wagmi’s sendTransaction action and useSendTransaction hook take the same fields, so sendTransaction(config, toTransactionRequest(call)) works there unchanged. Wait for the receipt with bz.waitForReceipt(hash) or with your library’s own receipt helper.

Sending From a Bot With a Local Key

A bot or a service signs with its own key. Connect bz with an RPC URL for reads and send with a viem wallet client that holds a local account:

const account = privateKeyToAccount(process.env.BOT_PRIVATE_KEY as Hex, { nonceManager });
const wallet = createWalletClient({ account, chain: arbitrum, transport: http(RPC_URL) });

const bz = await Bazaar.connect({
  transport: RPC_URL, // reads go through the SDK's own JSON-RPC client
  factory: FACTORY,
  chainId: arbitrum.id,
  prices: { token: process.env.PYTH_PRO_API_KEY }, // a server may hold the Pyth Pro key itself
});
const eth = await bz.markets.getMarket("ETH/USD");

const call = await bz.trade.createOrder(eth.address, account.address, {
  orderType: OrderType.Market,
  isLong: false,
  size: parseUnits18("0.1"),
  maxSlippageBp: 30n,
});

const sim = await bz.simulate(call);
if (!sim.ok) throw new Error(`would revert: ${sim.error!.message}`);

try {
  // Not toTransactionRequest(call): its `account` is the bare address, which would make viem ask the
  // node to sign (eth_sendTransaction). Leave `account` to the client's local account.
  const hash = await wallet.sendTransaction({ to: call.to, data: call.data, value: call.value });
  const receipt = await bz.waitForReceipt(hash);
  console.log(`${call.summary}: ${receipt.status} in block ${receipt.blockNumber}`);
} catch (e) {
  console.error(errorMessage(e));
}

Two details matter here:

  • Do not spread toTransactionRequest(call) into a local-account client: its account is the bare address, and viem then asks the node to sign with eth_sendTransaction, which a public RPC refuses. Pass to, data and value and let the client’s account sign.
  • nonceManager (from viem) hands out nonces locally, so two sends in quick succession do not reuse one. A bot that sends one transaction at a time and waits for each receipt does not need it, but it costs nothing.

Building a Bot puts this together with live prices, a quoting loop and error handling.

The 3-Second Price Window

The pair accepts a Pyth price only when it was generated at most 3 seconds from the block’s timestamp (MAX_PRICE_STALENESS). A call that carries an update is judged on it: the pair has it verified, and an update whose price is older than that reverts the call. A call that carries none runs on the pair’s own stored price while that is at most 3 seconds old, which it is only when another call stored one that recently. Pyth Pro keeps no price on chain, so nothing else stands in. See Oracle for the full rule, including the stale-price regime of markets with trading hours.

The update is fetched when the call is built, so the clock starts then: the time to simulate, to show a confirmation screen, for the user to approve in the wallet, and for the transaction to be included all count against those 3 seconds. In practice:

  • Build as late as possible. buildAndSend(wallet, build, opts?) takes a function that builds the call, calls it, and immediately sends the result through the wallet, returning { call, hash }. Simulate on the same build (as in Rebuilding) or accept that the wallet’s own gas estimate is the only check.
// The price update is fetched inside build(), right before the wallet prompt.
const placeOrder = () =>
  bz.trade.createOrder(eth.address, user, {
    orderType: OrderType.Market,
    isLong: true,
    size: parseUnits18("0.5"),
    maxSlippageBp: 50n, // 0.5%
  });

try {
  const { call: sent, hash } = await buildAndSend(window.ethereum, placeOrder);
  console.log(`${sent.summary}: ${hash}`);
  await bz.waitForReceipt(hash);
} catch (e) {
  show(errorMessage(e));
}
  • Rebuild a call whose priceTime is more than a second or two old before it reaches the wallet. The keeper does the same: its price service sends a print only while it is at least 1 second younger than the limit (sendMargin).

  • Expect direct sends from a slow wallet to revert. A browser wallet’s prompt can take longer than 3 seconds to approve, and the update was fixed when the call was built: once its price is older than the window, the call reverts on chain (BazaarOracle__StalePrice), whatever other traders did meanwhile. The wallet’s estimate was made when the prompt opened, so it does not catch this. A reverted receipt makes waitForReceipt throw; offer to try again with a freshly built call.

  • Gasless calls do not have the problem. The price update is outside the signed request, so the relayer attaches a fresh one when it submits. See Gasless Trading & Relayers.

  • Bots sign locally and send in milliseconds, so build, simulate and send in one go and they land well inside the window.

Rebuilding a Call

/** Whether a call's price update is still young enough to send (the pair allows 3 s at landing). */
function priceStillFresh(c: PreparedCall, maxAgeSeconds = 2): boolean {
  return c.priceTime === undefined || Date.now() / 1000 - c.priceTime <= maxAgeSeconds;
}

// Simulate first, then rebuild if the simulation took long enough for the print to age.
let order = await placeOrder();
const check = await bz.simulate(order);
if (check.ok && !priceStillFresh(order)) order = await placeOrder();

A stale print fails inside Pyth’s contract with Pyth’s own StalePrice error (selector 0x19abf40e). The SDK’s ABIs do not declare it, so it decodes as Unknown(0x19abf40e); when you see that name on a price-carrying call, rebuild it. Errors & Messages shows how to give it a message.

Waiting for the Receipt

bz.waitForReceipt(hash, opts?) polls eth_getTransactionReceipt until the transaction is mined.

OptionDefaultMeaning
timeoutMs120000How long to wait before giving up.
pollMs500Time between polls.

It returns a Receipt (transactionHash, blockNumber, status, gasUsed, effectiveGasPrice, from, to, logs), and throws CallError when the transaction reverted (“transaction 0x… reverted”) or has no receipt by the timeout (“… has no receipt after 120 s”). A revert seen in a receipt carries no reason: the node does not return it. To learn why, simulate the same call again, or read the events with Live Updates. A timeout does not mean the transaction failed: it may still be mined, so look it up by hash before sending it again.

CallError

try {
  const hash = await bz.send(window.ethereum, order);
  await bz.waitForReceipt(hash, { timeoutMs: 60_000, pollMs: 1_000 });
} catch (e) {
  if (e instanceof CallError && e.decoded) {
    // The wallet's own gas estimate reverted: the decoded custom error, with its arguments.
    console.log(e.decoded.name, e.decoded.args);
  }
  show(errorMessage(e));
}

CallError is what sendCall, buildAndSend, waitForReceipt and the batching senders throw:

FieldMeaning
messageThe decoded revert’s user message when there is one, else the wallet’s or node’s own message.
decodedThe DecodedError when the wallet’s error carried revert data (its gas estimate reverted), else null.
causeThe original error from the wallet or library, for logging.

Show errorMessage(e) to the user: it handles a CallError, a wallet rejection, a decoded revert and an InputError alike. Errors & Messages has the details, and the cases it does not cover.