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

Low-Level Building Blocks

The Bazaar facade is built from smaller pieces, and the package exports all of them: the chain client and its JSON-RPC transport, ABI codecs, revert decoding, the batched market reader, the subgraph client, the Pyth Pro client, update codec and price service, ports of the contract formulas, and the typed ABIs. The keeper runs on the same code.

Reach for them when the facade does too much or too little: a bot that reads every market’s state in one request per block, simulates its own calls through the probe, signs with its own key and submits exactly once; a service that indexes logs; a tool that needs a contract call the SDK has no builder for. A frontend rarely needs this page. bz.ctx.chain is the Chain a facade connection uses, so you can mix both.

Every piece here is browser-safe unless it says otherwise; the samples run in Node and read their URLs and keys from the environment.

Chain

Chain wraps any EIP-1193 transport (request({ method, params })): a browser wallet’s provider, a viem client, or the SDK’s own JsonRpc. Chain.fromUrl(url, options) builds one over HTTP, with an optional second endpoint for submitting transactions (a sequencer, a private relay):

// A server-side Chain over HTTP. Keep the URL (which often holds the provider's key) in the environment.
const chain = Chain.fromUrl(process.env.RPC_HTTP_URL!, {
  perSecond: 10, // this client's own rate limit (default 25)
  timeoutMs: 15_000, // per request (default 30 s)
  retries: 2, // transient failures (default 3); never a send
  sendUrl: process.env.SEQUENCER_URL ?? null, // optional: submit transactions elsewhere
});

console.log(await chain.chainId(), await chain.blockNumber());
const head = await chain.block("latest"); // number, timestamp, hash, parentHash, baseFee
console.log(head.timestamp);

Chain.fromUrl takes the JsonRpc options and sendUrl (default: send through the read URL). new Chain(transport, { send }) does the same with transports you build:

// The same Chain over any EIP-1193 transport: a browser wallet, a viem client, or a JsonRpc.
const rpc = new JsonRpc(process.env.RPC_HTTP_URL!, { headers: { "x-api-key": process.env.RPC_KEY ?? "" } });
const viaRpc = new Chain(rpc, { send: new JsonRpc(process.env.SEQUENCER_URL ?? process.env.RPC_HTTP_URL!) });
console.log(rpc.displayUrl, redactUrl(process.env.RPC_HTTP_URL!)); // both safe to log
const inBrowser = new Chain(window.ethereum);
console.log(viaRpc.rpc === rpc, inBrowser.sendRpc === inBrowser.rpc);

Methods

MethodReturnsNotes
request(method, params?, via?)the JSON-RPC resultAny method. A JSON-RPC error is rethrown as RpcError however deep the transport nested it. via defaults to the read transport.
chainId(), blockNumber()number
block(at = "latest")Blocknumber, timestamp, hash, parentHash, baseFee (bigint, 0 when absent). at is a number or "latest", "pending", "safe", "finalized", "earliest".
code(address, at = "latest")HexAn earlier block needs a node that keeps historical state.
balance(address)bigintWei, at the head.
nonce(address, at = "pending")number
gasPrice()bigint
call(to, data, { from, value, block, overrides })HexOne eth_call; overrides is an eth_call state override. A revert throws RpcError with revertData.
batch(reads, block = "latest", chunk = 200)[ok, data][]Many reads in as few calls as the node allows (below).
supportsOverrides()booleanWhether eth_call honours state overrides here; asked once and cached in overridesSupported.
probe(sender, target, data, value, lens, pair)ProbeResultSimulate one call from the probe and report what it earns on pair (below).
estimateGas(from, to, data, value = 0n)bigint
logs(fromBlock, toBlock, addresses)RawLog[]eth_getLogs for the addresses over the inclusive range, with no topic filter; removed (reorged) logs are dropped. Keep ranges within your provider’s limit.
sendRaw(raw)HexSubmit a signed transaction exactly once (below).
receipt(hash)Receipt | nullnull until mined. status is "success" or "reverted"; logs are RawLogs.

Batched Reads

batch takes { to, data } reads and returns one [ok, returnData] per read, in order. A failing read does not fail the batch: it comes back as [false, revertData]. It uses, in order of preference:

  1. the probe’s aggregate, placed at PROBE_ADDRESS by an eth_call state override, chunk reads per call, when the node honours overrides;
  2. Multicall3 (0xcA11bde05977b3631167028862bE2a173976CA11) aggregate3, when it is deployed;
  3. parallel eth_calls.

If a node that honoured overrides stops doing so (a load balancer moved you to another node), batch notices the empty answer, records overridesSupported = false and retries with the next strategy.

// Many reads in as few eth_calls as the node allows. A failing read reports [false, revertData].
const L = lens();
const reads = [
  { to: PAIR, data: P.encode("lastPairPrice") },
  { to: PAIR, data: P.encode("marginRequirements") },
  { to: LENS, data: L.encode("triggerBounty", PAIR, 42n) },
];
const results = await chain.batch(reads);
const [ok, data] = results[2]!;
if (ok) console.log("bounty", formatUnits18(L.decode<bigint>("triggerBounty", data)));
else console.log("read failed:", decodeError(data).toString());
console.log("state overrides:", await chain.supportsOverrides());

The Probe

The probe (contracts/KeeperProbe.sol) is never deployed: its runtime code is placed at PROBE_ADDRESS (0x0000000000000000000000000000000000bea7e5) with PROBE_BALANCE (100 ETH, for Pyth fees) through an eth_call state override (stateOverride()). probe(sender, target, data, value, lens, pair) runs target.call{value}(data) from it and returns:

FieldMeaning
ok, retWhether the call succeeded, and its return or revert data (decode it with decodeError).
claimBefore, claimAfterThe probe’s claimable balance on pair (through the lens) before and after.
rewardclaimAfter - claimBefore, floored at 0: exactly what the call would earn, in BAZAAR precision.
gasUsedGas the call used.
adlPendingAfter, settlementFixedAfterThe pair’s ADL flag and settlement-fixed time after the call.

Pass pair = null to skip the claimable reads. A node that ignores the override answers "0x", and probe throws (“the node ignored the eth_call state override, so the probe cannot simulate here; use an RPC that supports state overrides”).

// Simulate a call from the probe (placed by state override) and read what it would earn.
const update: Hex[] = []; // a signed Pyth update for the pair's legs, from a PriceService
const pythFee = 0n; // Reader.updateFee(update): the oracle's fee in wei
const refresh = P.encode("refreshPrice", update);
const sim = await chain.probe(null, PAIR, refresh, pythFee, LENS, PAIR);
if (!sim.ok) console.log("would revert:", decodeError(sim.ret).toString());
else console.log("reward $" + formatUnits18(sim.reward), "gas", sim.gasUsed);

This is how the keeper measures every reward before it sends: see What the Keeper Does.

Sending a Signed Transaction

sendRaw(raw) submits a signed transaction (its canonical envelope, as viem’s signTransaction returns it) through the send transport, exactly once. JsonRpc never retries eth_sendRawTransaction; a transport of your own must not either. The hash is computed locally as keccak256(raw), so an unknown outcome can always be settled by looking the hash up.

OutcomeResult
The node answered with the same hashreturns the hash
The node already holds it (“already known”, “known transaction”, “transaction already imported”, “AlreadyKnown”)returns the hash
“nonce too low” (or “nonce has already been used”) and eth_getTransactionByHash finds the hash, on the read transport or then the send transportreturns the hash
raw is not 0x-prefixed hex bytes, or is an EIP-4844 blob transaction (type 3)TypeError; nothing was sent
Any other JSON-RPC error (underpriced, insufficient funds, …), or “nonce too low” with no transaction foundRpcError, the node’s own error; the transaction is not in that node’s pool
The transport failed (connection error, timeout, non-2xx HTTP, oversized or unreadable answer), the node’s error reports a timeout, the node answered with another hash or with something that is not a hash, or the lookup after “nonce too low” failedSendError with hash and cause: the outcome is unknown and the transaction may still be mined

After a SendError, look up e.hash (receipt, eth_getTransactionByHash). Submitting the same signed bytes again is safe: it cannot run twice. Signing a new transaction for the same work is not, until the first is settled.

// Sign with your own key and submit exactly once; settle an unknown outcome by the hash.
const account = privateKeyToAccount(process.env.KEEPER_PRIVATE_KEY as Hex);
const gas = await chain.estimateGas(account.address, PAIR, refresh, pythFee);
const signed = await account.signTransaction({
  chainId: await chain.chainId(),
  type: "eip1559",
  to: PAIR,
  data: refresh,
  value: pythFee,
  nonce: await chain.nonce(account.address),
  gas: (gas * 12n) / 10n,
  maxFeePerGas: head.baseFee * 2n,
  maxPriorityFeePerGas: 0n,
});
try {
  const hash = await chain.sendRaw(signed);
  const receipt = await chain.receipt(hash); // null until mined: poll it
  console.log(hash, receipt?.status);
} catch (e) {
  if (e instanceof SendError) {
    // Unknown outcome: it may still be mined. Look up e.hash; resending `signed` is safe, signing anew is not.
    console.warn(e.message, e.hash);
  } else if (e instanceof RpcError) {
    console.error("refused:", e.rpcMessage); // underpriced, insufficient funds, ...: not in the pool
  } else throw e; // TypeError: not signed hex, or a blob transaction
}

A frontend sends through the user’s wallet instead: see Sending Transactions.

The Deployment Block

No Bazaar contract records the block it was deployed in. findDeploymentBlock(chain, address, { head, from }) finds it by binary search on eth_getCode(address, block) (code appears in exactly one block and never leaves, since Bazaar’s contracts cannot self-destruct): about 30 reads on Arbitrum One. It searches [from, head], by default [0, the chain's head]. It needs historical state (hosted providers such as Alchemy, Infura and QuickNode keep it); it throws DeploymentBlockError when there is no code at head, or when a read fails (a pruned node), rather than guess. The keeper uses it when its configuration leaves out startBlock.

// The block the factory was deployed in, by binary search on eth_getCode (needs historical state).
try {
  const startBlock = await findDeploymentBlock(chain, FACTORY);
  console.log("index from block", startBlock);
} catch (e) {
  if (e instanceof DeploymentBlockError) console.error(e.message); // pruned node, or no code there
  else throw e;
}

JsonRpc

JsonRpc(url, options) is a small JSON-RPC client over HTTP(S) that implements Transport. It rate limits itself, retries transient failures (never a send), caps response sizes, keeps revert data, and never puts its URL or a header’s value into an error message.

OptionDefaultMeaning
perSecond25Requests per second this client allows itself; concurrent callers queue in call order.
timeoutMs30000Per-request timeout.
retries3Retries after a failed request (HTTP error, connection error, timeout, unreadable answer), with delays doubling from 500 ms. Never for the methods in NEVER_RETRIED.
headersnoneSent with every request. A name or value HTTP cannot carry (a line break, NUL, …) throws TypeError at construction, naming the header, never its value.
fetchglobalThis.fetch
maxResponseBytes64 MiBThe most bytes one response may hold, counted after decompression; a larger one throws ResponseTooLargeError and is not retried.

request(args, { retries }) overrides the retry count for one request. displayUrl is the URL with any key masked (see URLs).

NEVER_RETRIED holds eth_sendRawTransaction, eth_sendTransaction, eth_sendRawTransactionConditional, eth_sendRawTransactionSync, eth_sendPrivateTransaction and eth_sendBundle: a lost answer to one of them does not mean it was not done.

ErrorWhen
RpcErrorThe node answered with a JSON-RPC error: code, rpcMessage, data, and revertData (the revert payload of a failed eth_call or eth_estimateGas, whatever shape the node uses; "0x" for a bare “execution reverted”; null when there is none). Not retried.
HttpErrorA non-2xx answer, after the retries: status; the message carries the redacted URL, and 401/403 add “check the key in the URL”.
ResponseTooLargeErrorThe body was over maxResponseBytes: limit.
ErrorAnything else that failed (connection, timeout, not JSON-RPC), after the retries, with its text scrubbed of the URL and header values.

toRpcError(e) turns whatever a transport threw into an RpcError when it carries a JSON-RPC error (EIP-1193 providers and viem nest it at different depths), or null. readTextCapped(response, maxBytes, what) is the capped body reader JsonRpc and SubgraphClient use; sleep(ms) is a promise timer.

Codecs

One ContractCodec per contract, built once and cached: factory(), pair(), lens(), terminator(), oracle(), or codec(name) with a ContractName. new ContractCodec(abi) builds one for any other ABI.

MemberMeaning
encode(name, ...args)Calldata for name(args). Structs may be objects of their fields or arrays. A wrong argument count throws TypeError.
decode<T>(name, data)A return value, named as the ABI names it: a struct decodes to an object of its fields, several named outputs to an object of the output names, a single output to the bare value.
decodeLog<A>(log)A RawLog decoded to { name, address, args, blockNumber, blockHash, txHash, logIndex }, or null when its topic0 is not one of the contract’s events. A dynamic indexed value (string, bytes, array, struct) is recoverable only as its topic hash.
topic(event), selector(fn), fn(name)topic0, a selector, a FunctionSpec. An unknown name throws.
functions, events, eventByName, errorsThe ABI’s specs, by name, topic0 and selector.

viem decodes integers of 48 bits or fewer as number and wider ones as bigint; both encode from either. allErrors() maps every custom error the five contracts declare by selector, and canonicalType(param) and signature(name, inputs) build the canonical signature strings selectors are hashed from.

// Encode a call, run it, decode the answer with the ABI's own names.
const P = pair();
const raw = await chain.call(PAIR, P.encode("lastPairPrice"));
const price = P.decode<{ spotPrice: bigint; updateTs: bigint }>("lastPairPrice", raw);
console.log("stored price", formatUnits18(price.spotPrice), "at", Number(price.updateTs));

// A struct output decodes to an object of its fields.
const bucket = P.decode<{ isLong: boolean; size: bigint; collateral: bigint }>(
  "positionBuckets",
  await chain.call(PAIR, P.encode("positionBuckets", user)),
);
console.log(bucket.isLong ? "long" : "short", formatUnits18(bucket.size));

The pair’s codec decodes the events of the libraries it links, which run in the pair’s context and log from its address:

// Raw logs for a block range, decoded by the pair's codec (which knows its libraries' events too).
const from = head.number - 1_000;
for (const log of await chain.logs(from, head.number, [PAIR])) {
  const ev = P.decodeLog<{ maker: Address; taker: Address; fillSize: bigint; price: bigint }>(log);
  if (ev?.name === "Trade") console.log(ev.blockNumber, ev.args.maker, formatUnits18(ev.args.fillSize), "@", formatUnits18(ev.args.price));
}
// topic0 for a filter of your own:
console.log(P.topic("Trade"));

decodeError

decodeError(data) turns revert data into a DecodedError:

Revert datanameargs
Shorter than a selector ("0x", out of gas)Reverted{}
Error(string)Error{ message }
Panic(uint256)Panic{ code }
A custom error any of the five contracts declaresits name, e.g. BazaarPair__TradingHaltedits arguments by name (arg0, … when unnamed)
Anything elseUnknown(0x12345678){}
MemberMeaning
messageThe text for an end user: the registered user message (from Errors & Messages), else the operator hint, else the name.
hintThe operator-facing hint from KEEPER_HINTS, or "".
opensAt()For a “too early” error, the time the call opens: the first of its deadline, effectiveTs, expiryTs, opensAt or scheduledTs arguments, or null.
toString()name(arg=value, ...): hint, for logs.
dataThe revert data, lowercase.
try {
  await chain.call(PAIR, P.encode("claimFees"), { from: user });
} catch (e) {
  if (e instanceof RpcError && e.revertData) {
    const err = decodeError(e.revertData);
    console.log(err.name, err.args); // e.g. CollateralLib__NothingToClaim {}
    console.log(err.message); // the user-facing sentence, or the operator hint, or the name
    console.log(err.toString()); // name(args): hint, for logs
    const opens = err.opensAt(); // for a "too early" error, when the call opens (unix seconds)
    if (opens !== null) console.log("retry after", new Date(opens * 1000).toISOString());
  } else throw e;
}

KEEPER_HINTS holds the sentences written for someone running the keeper. User messages are registered by installUserMessages() (which connect calls); registerUserMessages(map) adds your own. For any error a call can throw, use errorMessage(e).

Reader

Reader makes the keeper’s batched market reads through any object with call and batch (a Chain). Reads for many markets go out as one chain.batch, never one request per market. Call deployment(factory) first: every other read needs it (they throw “Reader.deployment() has not been called”).

MethodReturns
deployment(factory)Deployment: factory, lens, terminator, oracle, usdc. Throws when the address does not answer as a Bazaar factory.
wiringProblems()string[]: a lens or terminator that does not report a factory, or belongs to another. Empty when consistent.
pairAddresses()Every pair the factory deployed.
describe(addresses)Market[]: pairId, baseFeedId, legs (one raw Pyth feed, or two for a composite), invertQuote, continuous (traded 24/7). label and description are empty: fill them yourself. Throws when one of a pair’s reads fails.
marketStates(markets, block, timestamp)Map<Address, MarketState> at block: one batched read of 12 values per market. A market whose price, margin or vault read fails is left out.
buckets(pair, accounts)Map<Address, PositionBucket>: isLong, size, entryValue, collateral, entryFundingIndex, takeProfitOrderId, stopLossOrderId.
orders(pair, ids)Map<bigint, OrderRecord>: the stored order with its trigger bounty (0 when that read failed). A slot that never held an order has a zero creator; expiryBlock is 2^64 - 1 for an order that never expires.
windowDeposits(pair, accounts)Each account’s ADL score deposit (0 where the read fails).
predecessor(pair, isLong, limitPrice)The book-insertion hint lens.findPredecessor gives, followed through up to 8 pages of 64.
claimable(wallet)Map<Address, bigint>: what the wallet can claim, by pair; pairs owing nothing are left out.
updateFee(updates)The oracle’s fee in wei for having these updates verified: Pyth’s fee per update times their number (0 for none). The same fee applies to live calls and to settlement sets.
usdcBalance(account)USDC balance, in USDC units.
one(to, codec, fn, ...args)One call, decoded.
const reader = new Reader(chain);
const dep = await reader.deployment(FACTORY); // factory, lens, terminator, oracle, usdc
const problems = await reader.wiringProblems();
if (problems.length) throw new Error(`not a consistent Bazaar deployment: ${problems.join("; ")}`);

const markets = await reader.describe(await reader.pairAddresses()); // pairId, feed, legs, calendar
const head = await chain.block("latest");
const states = await reader.marketStates(markets, head.number, head.timestamp); // one batched read

for (const m of markets) {
  const s = states.get(m.address);
  if (!s) continue; // its price, margin or vault read failed
  const status = marketStatus(s, head.timestamp);
  console.log(m.address, status, "stored price", formatUnits18(s.spot), "free insurance", formatUnits18(freeCapital(s)));
  if (status === Status.Adl) console.log("  ADL window open since", s.adlAuctionStart);
}
console.log("oracle", dep.oracle);

Models

MarketState is one read of a market: block, timestamp, the stored price spot and its updateTs, imrBp, mmrBp, laggedMmrBp, the stored fundingIndex and the lens’s projectedFundingIndex (accrued to now), adlPending, adlAuctionStart, adlTerminationTs, settlementFixedTs, scheduledTs, terminated, open interest (longOi, shortOi), insurance, deficit, and the vault’s inventory on each side (longInv, shortInv: size, entryNotional, bankruptcyNotional, seized, entryFundingIndex, seizedTs). Times are seconds, zero when unset.

marketStatus(state, now) gives the market’s Status, checked in this order:

StatusWhen
terminatedThe pair is terminated.
settlingIts settlement price is fixed, not yet finalized.
limboA cutoff is scheduled and now + LIMBO_LEAD (6 s) has reached it: trading has halted, the price is not yet fixed.
adlAn ADL window is open: matching halts, liquidation and ADL run.
liveNone of the above.

freeCapital(state) is the insurance balance less the seizure earmarked against inventory the vault holds, and inventorySize(state) the inventory on both sides. Market adds mutable fields the keeper uses (state, dirty, lastRead); marketStatic, marketFromStatic, Position, Order, orderRemaining and emptyInventory are the keeper’s store types.

Formulas

Ports of the contract formulas, each with the Solidity’s integer rounding, so the SDK’s view of a position matches the contract’s whenever the inputs match. The chain still decides: use them to preview and to pick what to simulate.

FunctionPort ofReturns
bucketState(isLong, size, entryValue, collateral, entryFundingIndex, price, fundingIndex, mmrBp)BucketLib.calculateStateBucketState: unrealizedPnl, fundingPnl, totalPnl, currentNotional, minRequiredCollateral, availableEquity, equity, isSolvent. mmrBp must already be the effective rate.
effectiveMmrBp(mmrBp, laggedMmrBp)BucketLib.effectiveMmrBpThe lower of the current rate and a non-zero lagged one.
marginRatio(state)Equity over the maintenance requirement as a number: below 1 is liquidatable.
triggerReached(orderType, isLong, triggerPrice, price)MatchingEngineLib._triggerReachedWhether a trigger order fires at price (inclusive).
adlThreshold(auctionStartTs, now)AdlLib._getAdlScoreThresholdThe ADL score threshold, decaying quadratically from 25e18 to 1 over 10 minutes.
adlScore(totalPnl, collateral, windowDeposit)AdlLib._scoreCollateralA winner’s ADL score.
refreshBountyGap, refreshBountyDue, refreshBountyDueAtRiskParamsLib.payRefreshBountyWhen refreshPrice pays its bounty.
convertExponent(value, fromExpo, toExpo = -18)BazaarMathLib.convertExponentA Pyth price rescaled; RangeError past MAX_EXPONENT_SHIFT (77).
pairPrice(legs, invertQuote)BazaarOraclePairPrice (spot, low, high, publishTime, confidenceOk) composed from one or two legs, or null for a non-positive leg.
legConfOk(price, conf)Whether one leg’s confidence is within the 2% cap.
mulDiv, signedMulDivOpenZeppelin Math.mulDiv, BazaarMathLib.signedMulDivFloor, and rounding toward zero.

An account’s health with the market’s own rates, as the keeper’s liquidation job computes it:

// One account's health on each market, at the stored price, with the contract's own formula.
for (const m of markets) {
  const s = states.get(m.address);
  const b = (await reader.buckets(m.address, [user])).get(user);
  if (!s || !b || b.size === 0n) continue;
  const mmr = effectiveMmrBp(s.mmrBp, s.laggedMmrBp);
  const st = bucketState(b.isLong, b.size, b.entryValue, b.collateral, b.entryFundingIndex, s.spot, s.projectedFundingIndex, mmr);
  console.log(m.address, "equity", formatUnits18(st.equity), "needs", formatUnits18(st.minRequiredCollateral), "ratio", marginRatio(st).toFixed(2));
  if (!st.isSolvent) console.log("  below maintenance margin: liquidatable");
}

bz.account.accountSnapshot does this at the live price for you: see Accounts & Portfolios. Orders read straight from the pair:

const [first] = markets;
if (first) {
  const recs = await reader.orders(first.address, [1n, 2n, 3n]);
  for (const [id, o] of recs) {
    if (o.creator === "0x0000000000000000000000000000000000000000") continue; // never held an order
    console.log(id, o.orderType, formatUnits18(o.size - o.filledSize), "left, bounty", formatUnits18(o.bounty));
  }
  const owed = await reader.claimable(user); // pair -> claimable balance
  for (const [p, amount] of owed) console.log(p, "claimable", formatUnits18(amount));
}

SubgraphClient

SubgraphClient(url, apiKey?, options) is the keeper’s GraphQL client: raw queries, the indexer’s head, and paging pinned to one block. For typed, paginated history use History instead.

OptionDefaultMeaning
timeoutMs30000Per request.
fetchglobalThis.fetch
maxResponseBytes16 MiBThe most bytes one answer may hold.
maxPages1000The most pages pages reads for one entity (a million rows).
MethodReturns
query<T>(query, variables?)The answer’s data.
meta()[block, hash, hasIndexingErrors]: the indexer’s head.
pages<T>(entity, fields, where, block, bytesId = false)Every row of entity matching where (GraphQL filter text, may be empty) as of block, in id order, SUBGRAPH_PAGE (1000) rows a request. bytesId for entities whose id is Bytes.
descriptions(block)Each pair’s listing description, by address.
proposals()Open termination, insurer, listing and identifier-upgrade proposals, raw.
close()Nothing to release; there so callers can close every client alike.

apiKey is sent as Authorization: Bearer <key>. Every failure (unreachable, HTTP 400 or above, GraphQL errors, an unreadable or oversized answer, no data) throws this module’s SubgraphError, with the URL redacted and the key masked. pages also throws when the subgraph returns rows out of id order or without a valid id, or runs past maxPages, rather than page forever. The subgraph lags the chain and cannot see state that changes without an event: treat what it returns as discovery and re-read from the chain before acting.

const url = process.env.SUBGRAPH_URL!;
const sg = new SubgraphClient(url, process.env.SUBGRAPH_API_KEY ?? null, { timeoutMs: 20_000 });

try {
  const [block, , hasIndexingErrors] = await sg.meta();
  if (hasIndexingErrors) console.warn("the subgraph hit an indexing error");

  // Every open position at that block, 1000 rows a request, every page pinned to the same block.
  const rows = await sg.pages<{ id: string; account: { id: string }; size: string }>(
    "positions",
    "account { id } size",
    "isOpen: true",
    block,
  );
  for (const r of rows) console.log(getAddress(r.account.id), BigInt(r.size));

  // Any query of your own: its `data`.
  const d = await sg.query<{ pairs: { id: string; description: string }[] }>("{ pairs(first: 5) { id description } }");
  for (const p of d.pairs) console.log(p.id, p.description); // render descriptions as text, never HTML
} catch (e) {
  if (e instanceof SubgraphError) console.error(e.message); // the URL redacted, the key masked
  else throw e;
}

This SubgraphError (exported at the top level) is a different class from history.SubgraphError, which the history functions throw; test for the one that matches the client you use.

Prices

ProClient

new ProClient(options) reads Pyth Pro: its request API (the latest signed update for a set of feeds, and the one-second channel’s update for any past second), its WebSocket stream, and its symbols registry. The options are those of Connecting. A token or header that an HTTP header cannot carry, a base URL with a query or a fragment, and a credential bound for a plain http:// or ws:// endpoint on another machine without allowInsecure (the token, headers, or a user name, password or query in the endpoint’s URL; only exactly localhost, 127.0.0.0/8 and [::1] count as this machine) throw ProError at construction, naming the setting and quoting no token, header value or URL.

MethodReturns
latest(ids)Print[]: the latest print for each feed with a usable live price, all from one signed update.
latestUpdate(ids)That update as a ProUpdate (blob, signer, timestampUs, channel, feeds, prints), or null. More than 255 feeds (lazer.MAX_FEEDS_PER_UPDATE, all one update carries) throws.
atTick(ids, second)The fixed_rate@1000ms update stamped exactly second as a ProUpdate, or null when there is none (settlement proofs). Throws when the endpoint answers with another update.
symbols({ query?, assetType? })ProSymbol[] from the registry: id, feedId, symbol, assetType, minChannel, state, schedule, …, every text field printable and at most 256 characters. No token is sent. An answer that is not a list throws ProError.
stream(feeds, signal?, { idleTimeout, onNotice })An async generator of Print[] batches. feeds is one set or a list of sets; each distinct set is one subscription, so its updates carry exactly its feeds. It connects to every stream endpoint, yields each update once, and reconnects a dropped endpoint with a growing delay: one that drops, sends an update it cannot read, one stamped more than maxUpdateAhead seconds ahead of clock, one from an untrusted signer, or MAX_STRAY_MESSAGES (64) messages in a row that are not price updates. With a signer check, each set keeps only its newest update waiting and the sets take turns, so slow checks never starve a set; nothing counts as taken before its check passes. No new update for idleTimeout seconds (default STREAM_IDLE_TIMEOUT, 10; null or 0 waits forever) throws ProError.
close()Ends any open streams.
displayUrlThe request API’s base URL with any key masked.

Feed IDs are bytes32 everywhere in the SDK, as the contracts hold them; only this client converts them to Pyth’s numbers. feedIdHex(1) is BTC/USD’s 0x00…01, proFeedId(id) the number a raw ID holds, and isRawFeedId(id) the oracle’s own test (a non-zero 32-bit integer in a bytes32).

A Print has feedId, price, conf, expo, publishTime (the second the price was generated in), generatedUs, timestampUs, channel, signer, blob (the signed update to attach to a call) and received (seconds on a monotonic clock). Every field but received and signer is read from the update’s signed payload, never from the JSON beside it. A feed without a usable live price (no price, no confidence, a price at or below zero, a property the request left out, a price or confidence that does not scale to the oracle’s 18 decimals) yields no print; whyNotLive(entry) says why. An endpoint’s text that a message quotes (a body, a close reason) is put on one line and bounded. Responses over MAX_RESPONSE_BYTES (16 MiB) and stream messages over MAX_STREAM_MESSAGE (1 MiB) are refused. Errors are ProError: unreachable, refused (401/403: “check the token”), 404, another HTTP error, not JSON, an update that cannot be read, or one from a signer that is not trusted.

Signers. With isTrustedSigner set, the signer of each update the client takes is recovered and an update it does not accept is never used. verifierSignerCheck(chain, oracle) is the check connect installs: it asks the deployment’s Pyth verifier (oracle.pythLazer(), then isValidSigner) and keeps each answer for a minute (ttl); a “trusted” answer is then refreshed in the background and used meanwhile for up to maxAge (10 minutes). A failed check’s error is reduced to a short summary with every URL redacted, since the transport behind it may quote its own.

Tokens. mintProToken(apiKey) exchanges an API key for a short-lived token (server side): over https only, unless allowInsecure, and never following a redirect. cachedToken(mint) turns any such call into a token provider that mints only when the held token is about to expire; an expiry already past or nearer than the margin is treated as unknown (the token is reused for 5 minutes) rather than minting a token for every request.

// One client for Pyth Pro's request API, its stream endpoints and its symbols registry. The token is
// sent as a Bearer header and masked in every error. Node 22 and later have the WebSocket the stream
// runs over; on Node 20 pass one (the `ws` package's) as the WebSocket option.
const pro = new ProClient({
  token: process.env.PYTH_PRO_API_KEY, // a server may hold the API key itself
  timeout: 5,
  // Use only updates signed by a signer the deployment's Pyth verifier trusts.
  isTrustedSigner: verifierSignerCheck(chain, deployment.oracle),
});

try {
  // One request, one signed update carrying every feed asked for.
  const prints = await pro.latest(markets.flatMap((m) => m.legs));
  for (const p of prints) console.log(proFeedId(p.feedId), p.price, "e", p.expo, "generated in second", p.publishTime);
} catch (e) {
  if (e instanceof ProError) console.error(e.message); // unreachable, refused (401/403), an untrusted signer, not JSON
  else throw e;
}

// Labels from Pyth Pro's symbols registry: "ETH/USD", or "XAU/USD" for a composite.
const symbols = await fetchSymbols(pro, markets.flatMap((m) => m.legs));
for (const m of markets) m.label = marketLabel(symbols.get(m.legs[0]!), m.legs[1] ? symbols.get(m.legs[1]) : null);

fetchSymbols(client, feeds) maps feed ids to their Pyth symbols and fetchFeedInfo(client, feeds) to their whole registry entries (both empty when the registry cannot be read); feedLabel("Crypto.BTC/USD") is "BTC/USD", and marketLabel(base, quote?) labels a composite (one with a quote leg) in USD.

PriceService

PriceService(source, options) keeps the newest print of every watched feed and the newest update of every watched set of feeds, from a ProClient or any PriceSource (latest and stream), and builds per-market prices and updates from them. It is what the keeper runs; frontends use bz.livePrices() instead.

OptionDefaultMeaning
streamtrueFollow the stream, falling back to polling when it fails.
streamIdleTimeout10Seconds without a new update before the stream is dropped and reconnected; null or 0 waits forever.
pollInterval1Seconds between polls when not streaming.
clockthe system clockThe time freshness is judged at, in Unix seconds.
sendMargin1A price must be this many seconds younger than the 3 s limit to count as fresh.
maxFutureSkew5Seconds a print may be dated ahead of clock before it is ignored (with one warning per feed).
ethUsdMaxAge60Seconds after which ethUsd() stops trusting the ETH/USD print.
ethUsdBounds[100, 1000000]The ETH/USD prices ethUsd() accepts.
loggerconsoleWhere endpoint failures are reported; null for silence.
MemberMeaning
watchMarkets(markets), watchSets(sets), watch(feeds)Watch each market’s legs (or each set) as one subscription, or each feed on its own. ETH/USD (ETH_USD_FEED, Pyth Pro feed 2) is always watched, for gas. A new set restarts the stream.
run()Follow the stream (or poll) until stop(), through any failure: three in a row mark the service unhealthy and switch a streaming service to polling for a minute. Never rejects.
stop()End run at once and release nextPrints.
fetch(feeds?)One request for the latest prints, outside the loop.
ingest(prints)Take in prints from a source of your own.
onPrints(listener)Call listener(feeds) after each batch that brings a newly generated price; returns the function that removes it.
nextPrints()Wait for newly generated prices and return their feeds; for one consumer.
price(market)The composed PairPrice, or null while a leg has no print.
fresh(market, now?, margin?)Every leg’s price generated within 3 - sendMargin seconds of now and the price inside the 2% confidence cap.
closed(market, now?, after = 15)No price generated for after seconds while the endpoint is otherwise delivering: a closed session.
legs(market)The prints the market prices from, or null.
updateFor(market)The signed update a call on the market attaches: the one update that carries exactly its legs (one per leg only while its own subscription has delivered none or lags).
printsFor(feeds)The prints a call reading these feeds together would attach.
ethUsd(now?)USD per ETH for pricing gas, or null when missing, too old or out of bounds.
missing()Watched feeds without a print yet.
latest, healthy, lastError, lastUpdateThe prints by feed, and the endpoint’s health.

A print is newer when its price was generated later (generatedUs), several times a second on a fast channel. An update that only carries the same price forward under a later timestamp, as Pyth sends for a quiet feed, changes nothing, and its price stays as old as when it was generated.

const prices = new PriceService(pro, { sendMargin: 1, logger: console });
prices.watchMarkets(markets); // one subscription per market's legs (ETH/USD is always watched, for gas)
void prices.run(); // follows the stream, falls back to polling, never rejects; prices.stop() ends it

const off = prices.onPrints((feeds) => {
  for (const m of markets) {
    if (!m.legs.some((leg) => feeds.has(leg))) continue;
    const pp = prices.price(m); // the oracle's composition: spot, low, high, confidenceOk
    if (!pp) continue;
    const sendable = prices.fresh(m); // every leg generated within 3 s less sendMargin, and inside the 2% cap
    console.log(m.label, formatUnits18(pp.spot), sendable ? "fresh" : "stale");
  }
});

A call that needs a price attaches updateFor(market) and sends Reader.updateFee(update) wei with it:

// The signed update a call on one market attaches: the one update that carries exactly its legs.
const [m] = markets;
if (m && prices.fresh(m)) {
  const update = prices.updateFor(m);
  const fee = await reader.updateFee(update); // wei to send with the call: Pyth's fee per update
  console.log(update.length, "update(s), Pyth fee", fee);
}

Signed Updates

A Pyth Pro update is an envelope (a magic number, a signature, the payload’s size) around a signed payload: the update’s timestamp in microseconds, its channel, and per feed the properties the requester asked for. The lazer namespace reads and builds them: parseUpdate(blob) (the payload and the recovered signer), readUpdate(blob) (the payload alone), parsePayload, splitUpdate, recoverSigner, and for tests and local chains encodePayload, signPayload and buildUpdate. The parser is as strict as the oracle’s own (LazerPayloadLib): the same bytes are malformed, and each feed’s included bits tell a property that was left out of the request from one that is present and zero. Malformed input throws LazerError.

// Work with a signed update directly: read its payload, and recover who signed it.
const print = m ? prices.legs(m)?.[0] : undefined;
if (print) {
  const u = await lazer.parseUpdate(print.blob); // { signer, timestampUs, channel, feeds, payload }
  console.log(u.signer, lazer.channelName(u.channel), u.timestampUs);
  for (const f of u.feeds) console.log(f.id, f.price, f.exponent, f.confidence, f.feedUpdateTimestamp);
}
console.log(feedIdHex(1)); // Pyth Pro feed 1 (BTC/USD) as the bytes32 the contracts hold: 0x00…01
// The price a set of prints composes to, without a service:
if (print) console.log(pairPrice([print], false)?.spot);

URLs

Hosted RPCs, indexers and price services often put the API key in the URL: Alchemy’s /v2/<key>, Infura’s /v3/<id>, The Graph gateway’s /api/<key>/, a ?key= query, user:password@. redactUrl(url, mask = "***") keeps only the parts of a URL known not to be secret and masks the rest, whatever the key looks like:

  • a user name and password: always masked;
  • the host: a label under 16 characters is kept, a longer one only when it is lowercase words joined by -;
  • path segments: kept when empty, a word in PATH_WORDS (rpc, api, subgraphs, stream, arbitrum, …), a version (v2), a number of up to 8 digits, one or two characters, or lowercase words joined by - or _; every other segment is masked;
  • query values: masked, except plain values of the parameters in PUBLIC_PARAMS (network, chain, chainId, asset_type);
  • the fragment: masked.

A value that is not an http(s) or ws(s) URL is returned unchanged (isUrl tells). urlCredential(url) says where a URL may carry a credential, as a phrase, or null; it is non-null exactly when redactUrl would mask something. Every SDK error that mentions a URL passes it through redactUrl, and the keeper’s config loader refuses a URL with a credential written into its file.

// Before printing a URL that may hold a key:
console.log(redactUrl("https://arb-mainnet.g.alchemy.com/v2/abcdef0123456789")); // https://arb-mainnet.g.alchemy.com/v2/***
console.log(urlCredential("https://example.com/rpc?key=abc")); // "a key in its key parameter"
console.log(urlCredential("https://arb1.arbitrum.io/rpc")); // null

The Typed ABIs

The five contracts’ ABIs are vendored as as const modules from the Bazaar repository’s abi/ directory (MIT): bazaarFactoryAbi, bazaarOracleAbi, bazaarPairAbi, bazaarPairLensAbi, bazaarPairTerminatorAbi, and ABIS by ContractName (CONTRACT_NAMES lists them). bazaarPairAbi also carries the events and errors of the libraries the pair links: they run by DELEGATECALL in the pair’s context, so their logs carry the pair’s address and their reverts surface from its calls. Use them with viem, wagmi or any typed client:

const client = createPublicClient({ chain: arbitrum, transport: http(process.env.RPC_HTTP_URL) });

// `as const` ABIs: viem infers argument and return types from them.
const [spotPrice, , updateTs] = await client.readContract({ address: PAIR, abi: bazaarPairAbi, functionName: "lastPairPrice" });
const owed = await client.readContract({ address: LENS, abi: bazaarPairLensAbi, functionName: "getClaimableFeesAll", args: [user] });
console.log(spotPrice, updateTs, owed);

// The pair's ABI includes its linked libraries' events, which carry the pair's address.
const trades = await client.getContractEvents({ address: PAIR, abi: bazaarPairAbi, eventName: "Trade", fromBlock: (await client.getBlockNumber()) - 1_000n });
for (const t of trades) console.log(t.args.maker, t.args.fillSize, t.args.price);

console.log(CONTRACT_NAMES, Object.keys(ABIS)); // BazaarFactory, BazaarOracle, BazaarPair, BazaarPairLens, BazaarPairTerminator
console.log(`ABIs from Bazaar commit ${ABI_SOURCE.commit} (${ABI_SOURCE.date})`);

ABI_SOURCE records the Bazaar commit the ABIs were copied from (commit, date, dirty). npm run sync-abis refreshes them: see Development.