@pulsepairs/sdk
v0.9.2
Published
Standalone SDK for the UpDown (PulsePairs) up/down prediction markets — matcher REST/WS client, EIP-712 order signing, trade-math, and Alchemy Account Kit (smart-account) order signing for rain.trade integration.
Keywords
Readme
@pulsepairs/sdk
Standalone SDK for the UpDown up/down prediction markets — BTC-USD / ETH-USD, UP or DOWN, on 5-minute and 15-minute cycles.
On the name: the product is UpDown. "PulsePairs" is a retired name that survives in two places for compatibility reasons only — this npm package's name (
@pulsepairs/sdk, renaming it would break every consumer) and two EIP-712 domain strings that are hashed into signatures (PulsePairs WebSocket Auth,PulsePairsAuthDomain). Neither can be changed without breaking deployed clients.
This is the package rain.trade integrates to surface
UpDown markets alongside Rain's own markets ("satellite" integration, Option A).
It is deliberately segregated from rain-sdk-v2: bump this package's version
independently, no release coupling.
It gives you three things:
- Matcher client — typed REST + WebSocket client for the off-chain
order book (
UpDownHttpClient,UpDownWsClient). - EIP-712 + trade-math —
buildOrderTypedData/buildCancelTypedData/buildWsAuthTypedData, plus fee/stake/price helpers that mirror the backend and contract exactly. - Account Kit signing —
UpDownAccountKitSigner: sign orders with an Alchemy smart-contract account (the SAME wallet rain.trade uses), plus gasless onboarding / approve / withdraw.
This README is the quickstart. For the exhaustive reference — every export, its units, its failure modes, the WS protocol, the full type surface and a troubleshooting table — see
DOCUMENTATION.md.
Live environments — there are two, both on Arbitrum One:
| | REST | WebSocket | |---|---|---| | Dev — mock oracle, mock USDT, faucet on |
https://dev-api-updown.rain.trade|wss://dev-api-updown.rain.trade/stream| | Prod — real Chainlink DON, real USDT0 |https://prod-api-updown.rain.trade|wss://prod-api-updown.rain.trade/stream|Examples below target dev by default — markets cycle 24/7 there and a mistake costs play money. Test funds:
POST /test/devmint(10k cap, 1 mint per address per 5 min — mint to the smart-account address, not the owner EOA; it also seeds a little gas ETH). The faucet 404s on prod.The older
demo-pulsepairs.rainwins.com/testnet-pulsepairs.rainwins.comhosts are retired; there is no Arbitrum Sepolia deployment.
Install
npm install @pulsepairs/sdk viem
# For Account Kit (smart-account) signing, also install the peer deps rain.trade
# already ships (skip if you only use the EOA / private-key path):
npm install @account-kit/wallet-client@^4.88 @account-kit/infra@^4.88 \
@account-kit/smart-contracts@^4.88 @aa-sdk/core@^4.88viem is a required peer. The four @account-kit/*/@aa-sdk/* packages are
optional peers — they are lazy-imported and only needed if you construct
UpDownAccountKitSigner (smart-contracts specifically backs the popup-less
order-session signing below). A pure-EOA bot never pulls any of them.
Two signing paths
The matcher and contract are signer-agnostic (on-chain verification is OZ
SignatureChecker, which accepts EOAs and ERC-1271 smart accounts). Pick:
| Path | order.maker | Sign with | Use for |
|---|---|---|---|
| EOA (examples/simple-*) | your EOA | viem account.signTypedData | bots, scripts, testing |
| Account Kit (examples/account-kit-taker.ts) | your SCA | UpDownAccountKitSigner | rain.trade / any smart-wallet UX |
Quickstart — EOA (bots)
import { createPublicClient, createWalletClient, http } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { arbitrum } from "viem/chains";
import {
UpDownHttpClient, buildOrderTypedData, ensureSettlementAllowance, freshNonce,
parseCompositeMarketKey, parseStake, OrderType, OrderSide, Option,
} from "@pulsepairs/sdk";
// Point the bot at a matcher YOU chose. Don't hardcode ours into a process
// that holds a funded key — and assert the chain matches your RPC's chain
// before you approve anything (see "Handling a funded key" below).
const api = new UpDownHttpClient(process.env.UPND_API!);
const cfg = await api.getConfig(); // never hardcode addresses
const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`);
const [live] = (await api.getMarkets({ pair: "BTC-USD", timeframe: 300 }))
.filter((m) => m.status === "ACTIVE");
const { settlementAddress, marketId } = parseCompositeMarketKey(live.address)!;
const amount = parseStake("5");
// BOUNDED approve. `settlementAddress` is server-supplied (it came from the
// matcher's market list), so cap its reach at what you actually intend to
// spend. `amount` = how much to approve; `threshold` = when to top back up.
// Omitting `amount` still approves MAX_UINT256 — that default is legacy.
await ensureSettlementAllowance({
publicClient, walletClient, usdt: cfg.usdtAddress, settlement: settlementAddress,
amount: amount * 20n, threshold: amount,
});
const maxFee = (amount * BigInt(cfg.platformFeeBps + cfg.makerFeeBps)) / 10000n; // peak fee
const nonce = freshNonce(); // CSPRNG; never Math.random/Date.now
// A MARKET order's signed `price` is its SLIPPAGE BOUND — the worst price it
// will accept — not a fill price. `price: 0` is REJECTED at submit ("Orders must
// specify a non-zero price"), so there is no unbounded market order to sign.
// Derive the bound from the current quote and pad it in CENTS: a percentage pad
// is worth almost nothing at long-shot prices, where these markets spend their
// final minutes (5% of an 8c option is 0.4c, against a 7.39c spread). 100 bps =
// 1 cent exactly. On a BUY the bound is reference + pad (the most you will pay);
// on a SELL it is reference − pad, because there the price is a FLOOR.
const reference = live.upPrice; // bps; use downPrice for DOWN
if (reference === null) throw new Error("no quote to bound against — refusing to guess");
const price = BigInt(Math.min(9999, Math.max(1, reference + 5 * 100))); // +5c
const typedData = buildOrderTypedData({
cfg, settlementAddress,
message: { maker: account.address, market: BigInt(marketId), option: BigInt(Option.UP),
side: OrderSide.BUY, type: OrderType.MARKET, price, amount, maxFee, nonce,
expiry: BigInt(live.endTime) },
});
const signature = await account.signTypedData(typedData);
await api.postOrder({ maker: account.address, market: live.address, option: Option.UP,
side: OrderSide.BUY, type: OrderType.MARKET, price: Number(price), amount: amount.toString(),
maxFee: maxFee.toString(), nonce: Number(nonce), expiry: live.endTime, signature });Full runnable scripts live in the repo under examples/ — simple-taker.ts
(MARKET), simple-maker.ts (LIMIT + authed WS), full-dmm-bot.ts (two-sided
quoting). They are not in the npm tarball (files: ["dist","README.md"]), so
if you installed from npm without repo access, this README is your reference —
the safe patterns are inlined here on purpose.
Handling a funded key
This SDK is normally driven by a hot key that holds real USDT. Three rules, each of which exists because the failure is silent:
1. Never default your endpoints. A bot signs orders for whatever matcher you
point it at, and then grants that matcher's server-supplied settlement address
a USDT allowance. A defaulted UPND_API plus a defaulted mainnet RPC means a
real funded key trading against a box you never chose. Require both explicitly:
const API = process.env.UPND_API;
if (!API) throw new Error("Set UPND_API — refusing to default a funded key to a third-party endpoint");
const RPC = process.env.ARBITRUM_RPC_URL;
if (!RPC) throw new Error("Set ARBITRUM_RPC_URL");2. Assert the matcher and the RPC agree on the chain, before the approve. This one check catches the whole class — a testnet/demo matcher paired with a mainnet key can't survive it:
const cfg = await api.getConfig();
const rpcChainId = await publicClient.getChainId();
if (cfg.chainId !== rpcChainId) {
throw new Error(`chain mismatch: matcher says ${cfg.chainId}, RPC says ${rpcChainId}`);
}3. Bound the allowance. Pass amount to ensureSettlementAllowance (EOA) /
onboard/approve (Account Kit) / buildApproveSettlementTx (raw-tx tier).
All four default to MAX_UINT256 for backwards compatibility; that default is
an unbounded claim on your balance by an address the server named. A bounded
allowance is consumed by fills, so re-run the helper on a timer — if it runs dry
mid-session your fills revert with insufficient allowance.
Use freshNonce() for order/cancel nonces. It draws from crypto.getRandomValues
and stays under 2^53 so Number(nonce) on the wire matches the value you signed.
Math.random()/Date.now() nonces are guessable, which lets anyone pre-burn your
next nonce against the replay store and block your order flow.
Quickstart — Account Kit (rain.trade / smart wallets)
UpDownAccountKitSigner builds its own Alchemy smart-wallet client using the
same config rain.trade's RainAA uses, so the same owner EOA resolves to
the same SCA address → one wallet across both products.
import { arbitrum } from "viem/chains";
import { UpDownAccountKitSigner, buildOrderTypedData, UpDownHttpClient } from "@pulsepairs/sdk";
const ak = new UpDownAccountKitSigner({
walletClient: window.ethereum, // owner EOA provider (or connector.getProvider())
alchemyApiKey: import.meta.env.VITE_ALCHEMY_API_KEY, // SAME as rain.trade
paymasterPolicyId: import.meta.env.VITE_PAYMASTER_POLICY_ID, // SAME as rain.trade
chain: arbitrum, // SAME as rain.trade (42161)
});
const sca = await ak.connect(); // deterministic; == rain.trade's SCA
// Deploy-before-fill: ONE UserOp deploys the SCA + approves USDT + installs
// the popup-less order-session key (see "Popup-less order signing" below).
await ak.onboard({ usdt: cfg.usdtAddress, settlement });
// Owner-key ERC-1271 order signature (6492 wrapper stripped automatically).
const signature = await ak.signTypedDataBare(buildOrderTypedData({ cfg, settlementAddress, message }));
// WS auth for orders:/balance: channels (keyed by the SCA):
ws.connectAuthed({ channels: [`orders:${sca.toLowerCase()}`], signAuth: () => ak.signWsAuth(cfg.chainId) });See examples/account-kit-taker.ts for the end-to-end flow.
Already have rain.trade's smart-wallet client?
If the host app owns an Alchemy client (e.g. rain.trade's shared session), skip
UpDownAccountKitSigner and just make its output UpDown-correct:
import { bareErc1271Signer } from "@pulsepairs/sdk";
const signOrder = bareErc1271Signer((td) => rainSmartWalletClient.signTypedData(td));
const signature = await signOrder(buildOrderTypedData({ cfg, settlementAddress, message }));Low-level "raw tx" tier (v0.3.0) — send with your own AA session
If you run your own Account-Abstraction send path (like rain.trade's sendTxs
through their root session), use the transport-free primitives instead of the
signer class. They return rain's exact RawTransaction shape ({ to, data, value? })
for the on-chain setup, and sign orders locally with the popup-less
order-session key — the part your send-only session structurally cannot do
(a root session packs the owner entity → isValidSignature returns 0xffffffff).
The one mental-model flip: placing an order is not a transaction. It is an
off-chain EIP-712 signature POSTed to the matcher; the relayer transacts the fill.
So there is no buildPlaceOrderTx — you signWithOrderSession(...) and postOrder.
import {
buildApproveSettlementTx, buildInstallOrderSessionTx, signWithOrderSession,
buildOrderTypedData,
} from "@pulsepairs/sdk";
import { arbitrum } from "viem/chains";
// Onboard once — batch both raw txs into ONE userOp through YOUR session:
const approveTx = buildApproveSettlementTx({ usdt: cfg.usdtAddress, settlement });
const { tx: installTx, session } = await buildInstallOrderSessionTx({ sca, chain: arbitrum });
await sendTxs([approveTx, installTx]); // your batched, gasless send
persist(session); // { v, privateKey, entityId, expirySec }
// Every order after that — no chain, no gas, no popup:
const typedData = buildOrderTypedData({ cfg, settlementAddress, message /* maker: sca, maxFee, … */ });
const signature = await signWithOrderSession({ session, sca, chain: arbitrum, alchemyApiKey, typedData });
await api.postOrder({ maker: sca, /* … */, signature });Two tiers, adopt in order: MVP = approve (1 raw tx) + bareErc1271Signer
owner-path (1 popup/order, zero new code) → Production = approve + install
(batched) + signWithOrderSession (popup-less). Full walkthrough in
examples/rain-taker.ts; rationale in
UPDOWN_RAIN_RAWTX_SDK_DESIGN.md. The on-chain acceptance of these bytes on
rain's exact MA-v2 impl is proven by updown-contracts/test/OrderSessionForkTest.t.sol.
Account Kit: the two rules you cannot break
Both come from the on-chain verifier (OZ SignatureChecker v5.6.1, no EIP-6492):
- Deploy the SCA before its first fill. A counterfactual (undeployed)
account cannot be verified via ERC-1271 → the fill reverts.
onboard()does this (deploy + approve in one UserOp);isDeployed(publicClient)checks it. - Bare ERC-1271, not 6492.
signTypedDataon an undeployed account can return a 6492-wrapped blob the settlement can't parse. Every signature this SDK produces is run throughstripErc6492Wrapper(...), so a wrapped blob never reaches the matcher. (Exposed standalone too, for the callback path.)
Confirmed live on Arbitrum One (2026-07-05/06). A real SCA was deployed,
isValidSignatureover the full Order typed-data returned the0x1626ba7emagic on-chain, a realenterPositionfill settled withmaker = SCA, and the negative control (undeployed SCA) reverted as expected. The MA-v2 / ERC-7739 question is resolved: sign the FULL typed-data through the account client (which this SDK does); a bare-bytes32flow does not validate.
Popup-less order signing (order sessions) — ON by default
UpDownAccountKitSigner distinguishes two different session-key mechanisms
— do not conflate them:
- Send session (
grantSession(), AlchemygrantPermissionsroot) — authorizes gasless UserOp execution (onboard/approve/withdraw). Its signatures pack the OWNER validation entity, so it is verified incapable of ERC-1271 order signing. - Order session (default ON) — a locally-generated key installed on the
MA-v2 SCA as a signature-validation-ONLY entity
(
isSignatureValidation: true,isUserOpValidation: false). It can only answerisValidSignaturefor orders / cancels / WS-auth — it can never move funds or execute a UserOp.onboard()batches the install into the same single UserOp as deploy + approve (still exactly one owner signature), after which orders sign with zero wallet popups.
Behavior and knobs:
signTypedDataBareprefers the order-session key when one is active and falls back to the owner key automatically on any failure — enabling this feature can never break signing.ensureOrderSession()installs a session for already-onboarded accounts (one owner-signed UserOp);revokeOrderSession()drops the local key;hasOrderSessioninspects state.- Opt out entirely with
orderSessions: falsein the constructor — every order then prompts the owner wallet (the pre-session behavior). - Storage: browser
localStorage(updown:oskey:<sca>) by default, an in-memory map in Node, or bring your own viaorderSessionStorage. - Known limitations (fine for integration/demo, review before launch):
expiry is client-side only (24 h — not enforced on-chain), and
revokeOrderSession()does not uninstall the validation entity on-chain.
Validated in 4 live PoC stages before shipping (on-chain 0x1626ba7e +
negative controls, live matcher order 201 + cancel 200, batched-install flow).
maxFee (F-2026-17731) — REQUIRED on every order
The signed Order includes maxFee (between amount and nonce). The
contract's ORDER_TYPEHASH includes it, so omitting it produces a signature
the on-chain SignatureChecker rejects — the fill reverts. Compute the peak
fee and pass the same value into both the typed-data and the POST body:
const maxFee = (amount * BigInt(cfg.platformFeeBps + cfg.makerFeeBps)) / 10000n;Signing the peak means legitimate (probability-weighted) fills never revert with
FeeExceedsTakerCap; the contract enforces the actual cumulative fee under
this cap across partial fills.
PnL — positions marked to market (v0.5.0)
The matcher hands you the ingredients for PnL (shares / avgPrice /
costBasis on positions, winner + order book on markets) but never a PnL
number. getPnl assembles one, marking each position:
- resolved market → won shares at full face (10000 bps), lost shares at 0;
- live market → the order-book mid for that option;
- no book signal → the position's own
avgPrice(an honest 0-PnL mark, flaggedmarkSource: "cost"so you can tell it apart).
const client = new UpDownHttpClient("https://dev-api-updown.rain.trade");
const pnl = await client.getPnl(wallet, { includeFees: true });
console.log(pnl.unrealizedPnlUsdt, pnl.roiPct); // e.g. -3, -30
for (const p of pnl.positions) {
console.log(p.optionLabel, p.markSource, p.unrealizedPnlUsdt);
}All money math is BigInt-exact on atomic units and returned as decimal
strings (costBasis, markValue, unrealizedPnl — signed); the *Usdt /
roiPct floats are display-only. Cost basis is fee-exclusive (matching the
backend's fold), so fees are a separate fees line, reported not netted.
Already holding the data? The pure helpers are transport-free and unit-tested:
import { positionPnl, computePortfolioPnl, midBps, feesPaidByWallet } from "@pulsepairs/sdk";
positionPnl(position, { markBps: 7000 }); // one position, explicit mark
positionPnl(position, { winner: 1 }); // settled: full-face / zero
computePortfolioPnl(positions, marketsByKey); // roll-up from market detailsUnits: shares atomic (= USDT face at settlement), price/avgPrice/markBps
in bps (10000 = full face), fees atomic. Option is 1 = UP, 2 = DOWN.
API surface
Clients UpDownHttpClient, UpDownWsClient, wsUrlFromHttpBase
Maker writes UpDownHttpClient.postOrder, cancelOrder, amendOrder,
(v0.9.0) amendOrdersBulk, postOrdersBulk, bulkFailures
EIP-712 ORDER_TYPES, CANCEL_TYPES, WS_AUTH_TYPES,
buildOrderTypedData, buildCancelTypedData, buildWsAuthTypedData,
freshSessionId, freshNonce, domainForSettlement,
findPairBySettlement, parseCompositeMarketKey
Trade-math centsToBps, bpsToCents, parseStake, assertStakeBounds, feeAtomic,
MIN_STAKE_ATOMIC, MAX_STAKE_ATOMIC
PnL (v0.5.0) UpDownHttpClient.getPnl, positionPnl, portfolioPnl,
computePortfolioPnl, markValueAtomic, midBps, markForOption,
feesPaidByWallet, atomicToUsdt, isResolvedWinner
Approve (EOA) ensureSettlementAllowance, MAX_UINT256
Account Kit UpDownAccountKitSigner (connect, onboard, approve, withdraw,
signTypedDataBare, signWsAuth, isDeployed, grantSession,
ensureOrderSession, revokeOrderSession, hasOrderSession),
bareErc1271Signer, stripErc6492Wrapper, isErc6492Signature
Raw-tx tier buildApproveSettlementTx, buildInstallOrderSessionTx,
(v0.3.0) signWithOrderSession, RawTransaction, OrderSessionRecord
L2 HMAC auth CLOB_AUTH_TYPES, buildClobAuthTypedData, buildHmacSignature, HMAC_HEADERS
Enums/types OrderType, OrderSide, Option, ApiConfig, PairConfig, PostOrderBody, …Cancel-and-replace, and bulk writes (v0.9.0)
amendOrder replaces a resting order in one request. Doing it as
cancel-then-place leaves the book without your quote for a round trip, and a
fill can land on the original inside that window — so the two-step is not just
slower, it is a different outcome. The server does both under one lock, and a
rejected amend leaves the original resting, never nothing.
const res = await api.amendOrder({
cancelOrderId: resting.id,
order: { maker, market, option: Option.UP, side: 0, type: 0,
price: 5100, amount: "10000000", maxFee, nonce: freshNonce(),
expiry, signature },
});
res.replaced; // the order id that was retired
res.priorityKept; // false when the amend forfeited queue position
res.remaining; // atomic USDT carried over to the replacementorder is a freshly signed order, exactly as postOrder takes — it carries
its own nonce and expiry. An amend is a new order, not an edit.
Pass expectedRemaining to make it conditional: the amend is refused with
REMAINING_MISMATCH unless the resting order still has exactly that much
unfilled. Omit it to amend regardless of intervening fills.
Bulk: read results[], never the status code
amendOrdersBulk and postOrdersBulk answer HTTP 200 with each order's
outcome in the body. A rejection — bad signature, stale expiry, rate-limit
shed — never reaches a status code, so a caller that treats 200 as "all placed"
silently counts every shed order as a success. Use bulkFailures:
const res = await api.postOrdersBulk({ orders: ladder });
for (const f of bulkFailures(res)) {
console.warn(`orders[${f.index}] rejected: ${f.error}`, f.scope ?? "");
}index is the position in the array you sent — results are positional.
On success the order is NESTED, and the single routes spread it. This is the one asymmetry that bites, because nothing about the call site hints at it:
const single = await api.amendOrder({ cancelOrderId, order });
single.id; // flat — the replacement's id
const bulk = await api.amendOrdersBulk({ amends });
for (const r of bulk.results) {
if (!r.ok) continue; // narrow first; failures have no `order`
r.order.id; // NESTED — `r.id` is undefined
r.replaced; // the id that was retired
r.priorityKept; // false when queue position was forfeited
r.remaining; // atomic USDT carried to the replacement
}postOrdersBulk nests the same way: results[i].order, never results[i].id.
Reading results[i].id yields undefined rather than throwing, so the mistake
survives to wherever that id is used — usually a later cancel that silently
matches nothing. SDK 0.9.0 typed the bulk result flat and shipped that
mistake into the types themselves; 0.9.1 corrected them. The wire shape never
changed, so no server behaviour depends on which version you are on — but on
0.9.0 the compiler will agree with you while you read the wrong field.
Two behavioural differences worth knowing, because they change how latency scales:
| | fan-out | latency vs batch size |
|---|---|---|
| postOrdersBulk | concurrent | roughly flat |
| amendOrdersBulk | serial | roughly linear |
Amends run serially on purpose: two amends racing on neighbouring price levels would otherwise see each other's half-applied state. Both cap at 20 per request.
Verifying against a live matcher
npm test is network-free. To round-trip all three against the dev stack:
UPND_INTEGRATION=1 PRIVATE_KEY=0x… npm run test:integrationIt grants the collateral allowance, rests an order, amends it, bulk-places two,
bulk-amends those, and cancels everything in a finally — including on failure.
It defaults to dev and refuses to run against prod without UPND_ALLOW_PROD=1,
because it places real orders and only dev's USDT is play money. Needs a live 5m
market with >120s left, so the keeper has to be cycling.
The key needs mock USDT and a little gas ETH. On dev the public faucet gives both in one call:
curl -X POST https://dev-api-updown.rain.trade/test/devmint \
-H 'Content-Type: application/json' \
-d '{"address":"0x…","amount":"100000000"}'The allowance is granted by the test itself, not by hand — a freshly deployed Settlement has no approvals from anyone, and requiring a manual approve made "run the acceptance test" a two-step nobody had written down. Use a throwaway key: the faucet is public, so nothing valuable should sit on it.
Field-name gotchas (live with them)
OrderRow.nonce/OrderRow.expiryare JSON strings (uint256 precision).- WS
order_updateusesorderType; RESTOrderRowusestype. Same value. Optionis1 = UP,2 = DOWN— not0/1.- Market addresses are composite
<settlementAddress>-<marketId>. The signedOrder.marketis the bare uint256 marketId; the composite goes in the POST body'smarketfield for routing only. - EIP-712 domain name is
"UpDown Exchange",verifyingContract= the settlement contract, price in bps (not 1e18). WS-auth uses a distinct domain ("PulsePairs WebSocket Auth",verifyingContract= zero) so an order signature can never replay as a WS-auth signature.
Reference
- Wire shapes / REST / WS:
docs/api.mdin theupdown-backendrepo (branchaudit/hacken-remediation-v2) — auth model, WS handshake, all endpoints, error strings, rate limits. - On-chain spec:
src/UpDownSettlement.solin theupdown-contractsrepo (same branch) —ORDER_TYPEHASH,enterPosition, fee cap enforcement. - Schema drift guard:
npm testruns an EIP-712 golden-vector test whose expected digest/signature were derived independently from the backend and contract definitions.
