Quick Start: a Frontend
This walkthrough builds a working trading page with no framework: it connects the user’s wallet, lists Bazaar’s markets with their prices and status, shows the user’s position on one, deposits collateral, and places a limit order, simulating every transaction before the wallet is asked to sign. At the end the page follows the account live. It is one TypeScript file and one HTML file, about 200 lines in all.
You need a browser wallet (MetaMask, Rabby, …) on Arbitrum Sepolia with a little ETH for gas and some of the deployment’s USDC, the deployment’s factory address (Bazaar’s deployments page), and a way for your page to get Pyth Pro prices without holding the API key: an endpoint of your backend that mints short-lived tokens from it (see Connecting).
1. Create the Page
npm create vite@latest bazaar-demo -- --template vanilla-ts
cd bazaar-demo
npm install @liberta-labs/bazaar-sdk viem
Until the SDK is on npm, install it from the repository instead, as in Installation.
Replace index.html with a page that has a few named elements:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Bazaar demo</title>
</head>
<body>
<button id="connect">Connect wallet</button>
<select id="market"></select>
<pre id="position"></pre>
<form id="deposit">
<input name="amount" placeholder="USDC to deposit" />
<button>Deposit</button>
</form>
<form id="order">
<select name="side"><option value="buy">Buy</option><option value="sell">Sell</option></select>
<input name="size" placeholder="Size" />
<input name="price" placeholder="Limit price" />
<button>Place limit order</button>
</form>
<p id="status"></p>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
Everything below goes into src/main.ts, in the order shown (delete what Vite put there). The
complete file is docs/examples/getting-started/frontend.ts in the SDK’s repository.
2. Configure
const FACTORY: Address = "0x0000000000000000000000000000000000000000"; // from Bazaar's deployments page
const CHAIN_ID = 421614; // Arbitrum Sepolia
const TOKEN_URL = "/api/pyth-token"; // your backend mints short-lived Pyth Pro tokens: never a Pyth key in a bundle
const pythToken = cachedToken(() => fetch(TOKEN_URL).then((r) => r.json())); // answers { token, expiresAt }
The SDK reads everything else (the lens, the terminator, the oracle, USDC, the markets and their feeds) from the factory. The price endpoint serves the signed Pyth updates that order calls carry and the feed symbols the market list labels markets with; never put a Pyth API key in code a browser downloads.
3. Two Helpers
const el = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
const status = (text: string) => {
el("status").textContent = text; // textContent, never innerHTML: chain and subgraph strings are untrusted
};
/** Run a click handler, showing any failure as one sentence for the user. */
function run(task: () => Promise<void>): void {
task().catch((e: unknown) => {
console.error(e);
status(e instanceof UnitError ? "Enter amounts as plain numbers, like 12.5." : errorMessage(e));
});
}
let bz: Bazaar;
let user: Address;
let market: markets.MarketInfo;
errorMessage(e) turns whatever a step throws (a decoded contract revert, a wallet rejection, an
input the SDK refused) into one sentence for the user. Inputs are parsed with parseUnits18, which
throws a UnitError for anything that is not a plain decimal number; the helper words that one
itself. Text from the chain or the subgraph (market descriptions, for one) is untrusted: the page only
ever sets textContent.
4. Connect
async function connect(): Promise<void> {
const accounts = (await window.ethereum.request({ method: "eth_requestAccounts" })) as string[];
user = getAddress(accounts[0]!);
// Ask the wallet for the right network first: connect() refuses any other chain.
await window.ethereum.request({ method: "wallet_switchEthereumChain", params: [{ chainId: toHex(CHAIN_ID) }] });
bz = await Bazaar.connect({ transport: window.ethereum, factory: FACTORY, chainId: CHAIN_ID, prices: { token: pythToken } });
status(`Connected as ${user}`);
}
eth_requestAccounts asks the wallet for the user’s address. wallet_switchEthereumChain moves the
wallet to the right network (it fails with error 4902 when the wallet does not know the chain; add it
first with wallet_addEthereumChain). Bazaar.connect then reads the deployment’s wiring from the
factory in one batched call and checks the chain: with chainId set, it throws a ConnectError on
any other. The result, bz, carries every area of the SDK with this connection bound
(bz.markets, bz.account, bz.trade, …). All options are on Connecting.
5. List the Markets
async function showMarkets(): Promise<void> {
const list = await bz.markets.listMarkets(); // every market the factory deployed, labelled; cached
const snaps = await bz.markets.marketSnapshots(list); // their live state, read in one batch
const select = el<HTMLSelectElement>("market");
select.replaceChildren(
...list.map((m) => {
const s = snaps.get(m.address);
const price = s ? formatUnits18(s.price.spot, 2) : "?";
return new Option(`${m.label} ${price} ${s?.status ?? ""} (${m.hours})`, m.address);
}),
);
market = list[0]!;
}
listMarkets() returns every market the factory has deployed, in the factory’s order, with its
static facts: label (“ETH/USD”, from the feed’s Pyth symbol), address, hours (“24/7” or
“trading-hours”), its feeds. It is cached on the connection, so later calls cost nothing.
marketSnapshots(list) reads every market’s live state at one block in two batched calls; here the
page shows the pair’s stored price and its status (live, adl, limbo, settling,
terminated). Every field is on Markets.
6. Show the Position
async function showPosition(): Promise<void> {
render(await bz.account.accountSnapshot(market, user)); // one market, one account, two batched reads
}
function render(me: account.AccountSnapshot): void {
const p = me.position;
const lines = [
`${market.label}: ${me.status}`,
`Collateral: ${formatUnits18(p.collateral, 2)} USDC (withdrawable now: ${formatUnits18(me.maxWithdrawable, 2)})`,
];
if (me.hasPosition && me.health) {
lines.push(
`Position: ${p.isLong ? "long" : "short"} ${formatUnits18(p.size)} at ${formatUnits18(p.entryPrice, 2)}`,
`PnL: ${formatUnits18(me.health.totalPnl, 2)} at ${formatUnits18(me.health.price, 2)}, margin ratio ${me.health.marginRatio.toFixed(2)}`,
);
} else {
lines.push("No position");
}
for (const o of me.orders) {
lines.push(`Order #${o.orderId}: ${o.typeName} ${o.isLong ? "buy" : "sell"} ${formatUnits18(o.remaining)} at ${formatUnits18(o.limitPrice, 2)}`);
}
if (me.withdrawalBlocked) lines.push(me.withdrawalBlocked);
el("position").textContent = lines.join("\n");
}
accountSnapshot(market, user) reads the user’s position on one market and values it at the live
price: the position bucket (size, entryPrice, collateral), health (PnL, equity, the margin
ratio, below 1 of which anyone may liquidate), the live orders, maxWithdrawable, and
withdrawalBlocked, the reason a withdrawal would be refused right now. Amounts are 18-decimal
bigints; formatUnits18(x, 2) prints one with two decimals. Every field is on
Accounts & Portfolios.
7. Simulate, Then Send
Every write the SDK builds is a PreparedCall: a target, calldata, an ETH value, the account it acts
for and a one-line summary. The page runs each one through the same function:
/** Simulate as the user, then let the wallet sign and send, then wait for the receipt. */
async function simulateAndSend(call: PreparedCall): Promise<void> {
const sim = await bz.simulate(call);
if (!sim.ok) throw new CallError(sim.error!.message, sim.error); // errorMessage() shows its sentence
status(`Confirm in your wallet: ${call.summary}`);
const hash = await bz.send(window.ethereum, call);
status(`Waiting for ${hash}...`);
await bz.waitForReceipt(hash); // throws CallError if it reverted
status(`Done: ${call.summary}`);
}
bz.simulate(call) runs the call as the user with eth_call and estimates its gas. If it would
revert, the revert comes back decoded (sim.error.name is the contract’s custom error, and
sim.error.message a sentence for the user), and the wallet is never asked. Otherwise bz.send
hands the call to the wallet (eth_sendTransaction), and bz.waitForReceipt polls for the receipt,
throwing a CallError if the transaction reverted after all. See
Sending Transactions.
8. Deposit Collateral
Collateral is per market: each market (each pair contract) holds its own, and pulls the USDC from the wallet itself, so the wallet must allow that pair to take it.
async function deposit(amountText: string): Promise<void> {
const amount = parseUnits18(amountText); // dollars, 18 decimals
const funding = await bz.allowance.usdcFunding(user, market.address, amount);
if (!funding.enoughBalance) {
const has = allowance.formatUsdcUnits(funding.balance); // USDC's own units: 6 decimals
return status(`This wallet holds ${has} USDC; the deposit needs ${allowance.formatUsdcUnits(funding.needUsdc)}.`);
}
// Each market pulls its own collateral: approve exactly what this deposit needs, if the allowance is short.
if (funding.approveUsdc > 0n) await simulateAndSend(await bz.allowance.approveUsdc(user, market.address, funding.approveUsdc));
await simulateAndSend(await bz.trade.depositCollateral(market.address, user, amount));
}
usdcFunding reads the wallet’s USDC balance and its allowance to the pair, and says how much to
approve (approveUsdc, in USDC’s own 6-decimal units). approveUsdc builds an approval for exactly
that amount; depositCollateral then builds the deposit (at least 5 USDC). A deposit can instead
carry a USDC permit, a signature in place of the approval transaction: see
Collateral, Approvals & Permits.
9. Place a Limit Order
async function placeLimitOrder(side: string, sizeText: string, priceText: string): Promise<void> {
const order = {
orderType: OrderType.Limit,
isLong: side === "buy",
size: parseUnits18(sizeText),
limitPrice: parseUnits18(priceText),
expirationBlock: (await bz.ctx.chain.blockNumber()) + orders.blocksIn(24 * 3600), // a day of L2 blocks
};
// createOrder checks the order as the pair will, and attaches a Pyth print the pair accepts for
// 3 s: build it right before the wallet prompt, and rebuild it if simulating took too long.
let call = await bz.trade.createOrder(market.address, user, order);
const sim = await bz.simulate(call);
if (!sim.ok) throw new CallError(sim.error!.message, sim.error);
if (Date.now() / 1000 - (call.priceTime ?? 0) > 2) call = await bz.trade.createOrder(market.address, user, order);
status(`Confirm in your wallet: ${call.summary}`);
await bz.waitForReceipt(await bz.send(window.ethereum, call));
status(`Placed: ${call.summary}`);
}
createOrder checks the order exactly as the pair will (a size and price, a limit within 50% of the
market price, at least $5 of notional, an expiry at least about a minute away, the per-user order
caps) and throws an InputError naming the field otherwise. It then finds the order’s place in the
book and attaches a fresh signed Pyth price, which every order call carries.
That price is the catch: the pair accepts a print at most 3 seconds from the block’s time. The page
builds the call right before the wallet prompt and rebuilds it if simulating took long enough that the
print is more than 2 seconds old (call.priceTime is its publish time). A user who takes longer than
that to approve in the wallet can still see the transaction revert on a quiet market; on an active
one, another trade has usually refreshed the pair’s price within the window. Production apps either
offer a retry with a freshly built call or trade gasless, where the relayer attaches the price when it
submits (Gasless Trading & Relayers). The details are in
The 3-Second Price Window.
expirationBlock counts Arbitrum blocks, about four a second: orders.blocksIn(seconds) converts.
Every option of every order type is on Placing Orders.
10. Wire the Buttons
const form = (id: string) => new FormData(el<HTMLFormElement>(id));
el("connect").addEventListener("click", () =>
run(async () => {
await connect();
await showMarkets();
await showPosition();
follow();
}),
);
el<HTMLSelectElement>("market").addEventListener("change", (e) =>
run(async () => {
market = await bz.markets.getMarket((e.target as HTMLSelectElement).value as Address);
await showPosition();
follow();
}),
);
el<HTMLFormElement>("deposit").addEventListener("submit", (e) => {
e.preventDefault();
run(async () => {
await deposit(String(form("deposit").get("amount")));
await showPosition();
});
});
el<HTMLFormElement>("order").addEventListener("submit", (e) => {
e.preventDefault();
const f = form("order");
run(async () => {
await placeLimitOrder(String(f.get("side")), String(f.get("size")), String(f.get("price")));
await showPosition();
});
});
11. Keep It Current
// Keep the position current. Events for this account (fills, orders, collateral) and a
// timer refresh both re-read the snapshot; call stop() when the view goes away.
let watch: { stop(): void } | null = null;
function follow(): void {
watch?.stop();
watch = bz.live.watchAccount(user, [market], (u) => {
if (u.type === "account") render(u.snapshot);
if (u.type === "error") console.warn("live updates:", u.error);
});
}
watchAccount follows the chain (on new-head notifications where the wallet’s provider pushes them,
otherwise polling the head about once a second) and delivers the
account’s own events and a refreshed AccountSnapshot for each market an event touched, plus one
every 15 seconds as funding accrues and clocks run. The page re-renders from the snapshot it is handed,
with no read of its own. See Live Updates.
12. Run It
npm run dev
Open the page, connect, deposit 20 USDC, and place a buy a little under the market price. The order appears in the position view as soon as the transaction lands.
What Can Go Wrong
| What the user sees | Why |
|---|---|
| “You rejected the request in your wallet.” | The user declined a prompt (EIP-1193 error 4001). |
| “This network has not been added to your wallet yet. Add it and try again.” | wallet_switchEthereumChain failed with 4902. |
| “Something went wrong. Please try again.” on connect | A ConnectError: the wallet is on another chain, or FACTORY is not a Bazaar factory on this chain. Log e.message for which. |
| “Something went wrong…” on the first market list | The price endpoint or the RPC could not be reached (labels fall back to short addresses when only the feed metadata fails). |
| A sentence naming a field, such as “limitPrice: A limit price must be within 50% of the market price: …” | An InputError: the SDK refused the order before building it. |
| “This order needs $… of collateral …” | The simulation reverted: not enough collateral for the order’s margin. Deposit first. |
| The order’s transaction reverts on chain | The attached price was more than 3 seconds old when it landed (a slow wallet confirmation on a quiet market). Build and send it again. |
Next
- Core Concepts: what is behind each step above.
- Live Prices, to show a streaming price next to each market.
- Previews, to show the fees, margin and liquidation price of an order before it is placed.
- Managing Orders, to amend and cancel.