@pulsepairs/sdk
v0.6.0
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.
Readme
@pulsepairs/sdk
Standalone SDK for the UpDown (PulsePairs) up/down prediction markets — BTC-USD / ETH-USD, UP or DOWN, on 5-minute / 15-minute / 1-hour cycles.
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 integration environment: a public demo of the full stack (markets cycling 24/7 on Arbitrum One, mock oracle, mintable test USDT) runs at https://demo-pulsepairs.rainwins.com with the matcher API/WS at
https://api.demo-pulsepairs.rainwins.com(wss://…/stream). Every example below targets it by default. 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).
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
const typedData = buildOrderTypedData({
cfg, settlementAddress,
message: { maker: account.address, market: BigInt(marketId), option: BigInt(Option.UP),
side: OrderSide.BUY, type: OrderType.MARKET, price: 0n, 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: 0, 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://api.demo-pulsepairs.rainwins.com");
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
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, …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.
