@pulsepairs/sdk 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,52 @@
1
+ # Changelog
2
+
3
+ ## 0.10.0 — 2026-09-30
4
+
5
+ ### Added
6
+
7
+ - **Collateral unit helpers**, so an integrator no longer hand-rolls them. The
8
+ venue's decimals are `usdtDecimals` on `GET /config`: **18 on dev (USDR), 6 on
9
+ prod (USDT0)**. Every amount the API sends or accepts is in base units at that
10
+ scale.
11
+ - `client.ensureUsdtDecimals(): Promise<number>` — reads `usdtDecimals` from
12
+ `/config` once per client and caches it; concurrent callers share one
13
+ request. An invalid or missing value (anything but an integer 0–36) rejects
14
+ and is not cached, so the caller can retry.
15
+ - `client.getUsdtDecimals(): number` — the cached value, synchronously. Before
16
+ it is loaded it returns 6 and warns once (a caller bug: await
17
+ `ensureUsdtDecimals()` first).
18
+ - `client.setUsdtDecimals(d)` — stores `d` if valid, e.g. from a `getConfig()`
19
+ response the caller already has.
20
+ - `usdToAtomic(usd, decimals?)` — whole tokens → base units, through a decimal
21
+ string (`parseUnits`), never multiplication, which is inexact at 18 decimals.
22
+ - `atomicToUsd(atomic, decimals?)` — base units → whole tokens via
23
+ `formatUnits`; `null`/`""` → 0; never throws.
24
+ - `roundTripToleranceAtomic(decimals?)` — `10n ** max(0, decimals − 6)`, for
25
+ snapping a MAX sell onto the exact owned balance.
26
+ - `isValidUsdtDecimals(d)` — the validity check the above use.
27
+
28
+ The three standalone helpers default to the decimals the client loaded.
29
+ - **Client-bound forms** — `client.usdToAtomic`, `client.atomicToUsd`,
30
+ `client.roundTripToleranceAtomic` — at that client's own decimals, for a
31
+ process that talks to more than one venue (e.g. dev at 18 and prod at 6).
32
+ Once two clients load different decimals the standalone helpers' shared
33
+ default is ambiguous: `usdToAtomic` and `roundTripToleranceAtomic` then throw
34
+ rather than sign at a guessed scale, and `atomicToUsd` keeps its never-throw
35
+ contract (warns once, returns 0). Explicit `decimals` always works.
36
+ A client only ever uses its OWN decimals: before it has loaded, it answers
37
+ 6 and warns once per client, never another client's value. At worst an
38
+ unloaded client under-signs (the venue's $1 minimum rejects it); it can never
39
+ over-sign at another venue's scale.
40
+
41
+ ### Fixed
42
+
43
+ - The README order example and the five example scripts (`simple-taker`,
44
+ `simple-maker`, `rain-taker`, `account-kit-taker`, `full-dmm-bot`) signed
45
+ amounts with `parseStake`, which is 6-decimal only, so on the 18-decimal dev
46
+ venue they signed 10^12 too little. They now load `usdtDecimals` from the
47
+ config they already fetch and use `client.usdToAtomic`, and check the $1
48
+ minimum against `client.usdToAtomic(1)` instead of `assertStakeBounds`, whose
49
+ minimum is 6-decimal. `parseStake` and `assertStakeBounds` themselves are
50
+ unchanged and documented as 6-decimal only.
51
+
52
+ No existing method changed.
package/DOCUMENTATION.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # `@pulsepairs/sdk` — Reference Documentation
2
2
 
3
- **Version:** 0.7.0 · **License:** UNLICENSED · **Runtime:** Node ≥ 18 or a modern browser
3
+ **Version:** 0.10.0 · **License:** UNLICENSED · **Runtime:** Node ≥ 18 or a modern browser
4
4
 
5
5
  Complete reference for the UpDown TypeScript SDK: every export, its
6
6
  units, its failure modes, and the protocol it speaks.
@@ -43,10 +43,14 @@ returns, what unit a number is in, or why a signature is being rejected.
43
43
  ## 1. What this SDK is
44
44
 
45
45
  UpDown is a binary prediction market: *will BTC-USD (or ETH-USD) be UP or DOWN at
46
- the end of this 5-minute / 15-minute / 1-hour cycle?* Trading is an **off-chain
46
+ the end of this 5-minute or 15-minute cycle?* Trading is an **off-chain
47
47
  central limit order book** (the "matcher"); settlement is **on-chain** on
48
48
  Arbitrum.
49
49
 
50
+ 60-minute cycles were retired on 2026-08-18 and no new ones are created. The
51
+ `3600` timeframe is still accepted and still returns the historical rounds — see
52
+ `getMarkets` under [§5 `UpDownHttpClient`](#5-updownhttpclient--rest-client).
53
+
50
54
  The SDK gives you three layers, usable independently:
51
55
 
52
56
  | Layer | Modules | What it does |
@@ -725,7 +729,10 @@ fresh nonce. Single-writer bots may prefer a monotonic counter — seed it from
725
729
  centsToBps(cents: number): number // 55 → 5500; 49.5 → 4950. Throws outside (0,100)
726
730
  bpsToCents(bps: number): number // 5500 → 55. Throws outside (0,10000)
727
731
  parseStake(usd: string | number): bigint // "5.50" → 5_500_000n. Rejects negative/non-finite
732
+ // 6-DECIMAL ONLY (prod USDT0). For the venue's scale use usdToAtomic.
728
733
  assertStakeBounds(amountAtomic: bigint, side?: "BUY" | "SELL"): void
734
+ // 6-DECIMAL ONLY: its $1 minimum is 1_000_000. On other
735
+ // scales compare against client.usdToAtomic(1).
729
736
  feeAtomic(notionalAtomic, priceBps, cfg): bigint
730
737
 
731
738
  MIN_STAKE_ATOMIC = 1_000_000n // $1 of share FACE value (BUY only)
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @pulsepairs/sdk
2
2
 
3
3
  Standalone SDK for the **UpDown** up/down prediction markets —
4
- BTC-USD / ETH-USD, UP or DOWN, on 5-minute / 15-minute / 1-hour cycles.
4
+ BTC-USD / ETH-USD, UP or DOWN, on 5-minute and 15-minute cycles.
5
5
 
6
6
  > **On the name:** the product is **UpDown**. "PulsePairs" is a retired name that
7
7
  > survives in two places for compatibility reasons only — this npm package's name
@@ -83,7 +83,7 @@ import { privateKeyToAccount } from "viem/accounts";
83
83
  import { arbitrum } from "viem/chains";
84
84
  import {
85
85
  UpDownHttpClient, buildOrderTypedData, ensureSettlementAllowance, freshNonce,
86
- parseCompositeMarketKey, parseStake, OrderType, OrderSide, Option,
86
+ parseCompositeMarketKey, OrderType, OrderSide, Option,
87
87
  } from "@pulsepairs/sdk";
88
88
 
89
89
  // Point the bot at a matcher YOU chose. Don't hardcode ours into a process
@@ -96,7 +96,10 @@ const [live] = (await api.getMarkets({ pair: "BTC-USD", timeframe: 300 }))
96
96
  .filter((m) => m.status === "ACTIVE");
97
97
  const { settlementAddress, marketId } = parseCompositeMarketKey(live.address)!;
98
98
 
99
- const amount = parseStake("5");
99
+ // Amounts are base units at the venue's decimals (18 on dev, 6 on prod) — see
100
+ // "Collateral units" below. `parseStake` is 6-decimal only (prod USDT0).
101
+ api.setUsdtDecimals(cfg.usdtDecimals); // or: await api.ensureUsdtDecimals()
102
+ const amount = api.usdToAtomic(5); // 5 × 10^decimals
100
103
 
101
104
  // BOUNDED approve. `settlementAddress` is server-supplied (it came from the
102
105
  // matcher's market list), so cap its reach at what you actually intend to
@@ -395,7 +398,7 @@ EIP-712 ORDER_TYPES, CANCEL_TYPES, WS_AUTH_TYPES,
395
398
  buildOrderTypedData, buildCancelTypedData, buildWsAuthTypedData,
396
399
  freshSessionId, freshNonce, domainForSettlement,
397
400
  findPairBySettlement, parseCompositeMarketKey
398
- Trade-math centsToBps, bpsToCents, parseStake, assertStakeBounds, feeAtomic,
401
+ Trade-math centsToBps, bpsToCents, parseStake (6-decimal only), assertStakeBounds (6-decimal only), feeAtomic,
399
402
  MIN_STAKE_ATOMIC, MAX_STAKE_ATOMIC
400
403
  PnL (v0.5.0) UpDownHttpClient.getPnl, positionPnl, portfolioPnl,
401
404
  computePortfolioPnl, markValueAtomic, midBps, markForOption,
@@ -413,6 +416,32 @@ Enums/types OrderType, OrderSide, Option, ApiConfig, PairConfig, PostOrderB
413
416
 
414
417
  ---
415
418
 
419
+ ## Collateral units (v0.10.0)
420
+
421
+ Amounts are base units at the venue's collateral decimals — `usdtDecimals` on
422
+ `GET /config`: **18 on dev (USDR), 6 on prod (USDT0)**. Load them once, then
423
+ convert with the helpers; never hardcode 6.
424
+
425
+ ```ts
426
+ import { UpDownHttpClient, usdToAtomic, atomicToUsd } from "@pulsepairs/sdk";
427
+
428
+ const client = new UpDownHttpClient("https://dev-api-updown.rain.trade");
429
+ await client.ensureUsdtDecimals(); // 18 on dev; one request, cached
430
+
431
+ usdToAtomic(50); // 50000000000000000000n — sign this as `amount`
432
+ atomicToUsd("101010101010101010101"); // 101.01… — render this
433
+ ```
434
+
435
+ `usdToAtomic` goes through a decimal string (`parseUnits`), never
436
+ `usd * 10 ** decimals`, which is inexact at 18 decimals. Reading the decimals
437
+ before `ensureUsdtDecimals()` resolves falls back to 6 with a one-time warning.
438
+
439
+ **Talking to more than one venue** (e.g. dev and prod in one process)? Use the
440
+ client-bound forms — `client.usdToAtomic`, `client.atomicToUsd`,
441
+ `client.roundTripToleranceAtomic` — which use that client's own decimals. The
442
+ standalone helpers share one default; once two clients load different decimals
443
+ it is ambiguous, and `usdToAtomic` throws rather than sign at a guessed scale.
444
+
416
445
  ## Cancel-and-replace, and bulk writes (v0.9.0)
417
446
 
418
447
  **`amendOrder` replaces a resting order in one request.** Doing it as
@@ -456,6 +485,32 @@ for (const f of bulkFailures(res)) {
456
485
 
457
486
  `index` is the position in the array you sent — results are positional.
458
487
 
488
+ **On success the order is NESTED, and the single routes spread it.** This is
489
+ the one asymmetry that bites, because nothing about the call site hints at it:
490
+
491
+ ```ts
492
+ const single = await api.amendOrder({ cancelOrderId, order });
493
+ single.id; // flat — the replacement's id
494
+
495
+ const bulk = await api.amendOrdersBulk({ amends });
496
+ for (const r of bulk.results) {
497
+ if (!r.ok) continue; // narrow first; failures have no `order`
498
+ r.order.id; // NESTED — `r.id` is undefined
499
+ r.replaced; // the id that was retired
500
+ r.priorityKept; // false when queue position was forfeited
501
+ r.remaining; // atomic USDT carried to the replacement
502
+ }
503
+ ```
504
+
505
+ `postOrdersBulk` nests the same way: `results[i].order`, never `results[i].id`.
506
+
507
+ Reading `results[i].id` yields `undefined` rather than throwing, so the mistake
508
+ survives to wherever that id is used — usually a later cancel that silently
509
+ matches nothing. SDK **0.9.0 typed the bulk result flat** and shipped that
510
+ mistake into the types themselves; 0.9.1 corrected them. The wire shape never
511
+ changed, so no server behaviour depends on which version you are on — but on
512
+ 0.9.0 the compiler will agree with you while you read the wrong field.
513
+
459
514
  Two behavioural differences worth knowing, because they change how latency
460
515
  scales:
461
516
 
package/dist/http.d.ts CHANGED
@@ -2,7 +2,40 @@ import type { AmendOrderBody, AmendOrderResult, ApiConfig, Balance, BulkAmendBod
2
2
  import { type PortfolioPnl } from "./pnl.js";
3
3
  export declare class UpDownHttpClient {
4
4
  private readonly baseUrl;
5
+ /** `usdtDecimals` from /config, once loaded. See `ensureUsdtDecimals`. */
6
+ private usdtDecimals;
7
+ /** The one /config request concurrent `ensureUsdtDecimals` callers share. */
8
+ private usdtDecimalsInflight;
9
+ /** getUsdtDecimals() warns once per client when read before loading. */
10
+ private warnedUnloaded;
5
11
  constructor(baseUrl: string);
12
+ /**
13
+ * The venue's collateral decimals (`usdtDecimals` on `GET /config`: 18 on dev,
14
+ * 6 on prod), fetched once per client and cached. Concurrent callers share one
15
+ * request. An invalid or missing value REJECTS rather than being guessed at, and
16
+ * a rejection is not cached, so the caller can retry. Also becomes the default
17
+ * for `usdToAtomic` / `atomicToUsd` / `roundTripToleranceAtomic`.
18
+ */
19
+ ensureUsdtDecimals(): Promise<number>;
20
+ /**
21
+ * This client's decimals, synchronously. Called before `ensureUsdtDecimals`
22
+ * has resolved, it answers 6 and warns once — that is a bug in the caller. It
23
+ * never reads another client's value.
24
+ */
25
+ getUsdtDecimals(): number;
26
+ /**
27
+ * `usdToAtomic` / `atomicToUsd` / `roundTripToleranceAtomic` at THIS client's
28
+ * decimals. Use these when a process talks to more than one venue (dev at 18,
29
+ * prod at 6): the standalone helpers share one default, which is ambiguous
30
+ * then. Before this client's `ensureUsdtDecimals` resolves they use 6 and
31
+ * warn once, like `getUsdtDecimals` — never another client's decimals.
32
+ */
33
+ usdToAtomic(usd: number): bigint;
34
+ atomicToUsd(atomic: bigint | string | number | null | undefined): number;
35
+ roundTripToleranceAtomic(): bigint;
36
+ /** Cache `d` if it is a valid decimal count (integer 0–36); otherwise ignored.
37
+ * Lets a `getConfig()` response the caller already has populate the cache. */
38
+ setUsdtDecimals(d: unknown): void;
6
39
  getVersion(): Promise<Version>;
7
40
  getHealth(): Promise<{
8
41
  status: string;
package/dist/http.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { computePortfolioPnl, feesPaidByWallet } from "./pnl.js";
2
+ import { atomicToUsd, isValidUsdtDecimals, publishUsdtDecimals, roundTripToleranceAtomic, usdToAtomic, } from "./units.js";
2
3
  function buildUrl(base, path, query) {
3
4
  const b = base.replace(/\/$/, "");
4
5
  const p = path.startsWith("/") ? path : `/${path}`;
@@ -31,9 +32,85 @@ async function parseJson(res) {
31
32
  }
32
33
  export class UpDownHttpClient {
33
34
  baseUrl;
35
+ /** `usdtDecimals` from /config, once loaded. See `ensureUsdtDecimals`. */
36
+ usdtDecimals;
37
+ /** The one /config request concurrent `ensureUsdtDecimals` callers share. */
38
+ usdtDecimalsInflight;
39
+ /** getUsdtDecimals() warns once per client when read before loading. */
40
+ warnedUnloaded = false;
34
41
  constructor(baseUrl) {
35
42
  this.baseUrl = baseUrl;
36
43
  }
44
+ /**
45
+ * The venue's collateral decimals (`usdtDecimals` on `GET /config`: 18 on dev,
46
+ * 6 on prod), fetched once per client and cached. Concurrent callers share one
47
+ * request. An invalid or missing value REJECTS rather than being guessed at, and
48
+ * a rejection is not cached, so the caller can retry. Also becomes the default
49
+ * for `usdToAtomic` / `atomicToUsd` / `roundTripToleranceAtomic`.
50
+ */
51
+ async ensureUsdtDecimals() {
52
+ if (this.usdtDecimals !== undefined)
53
+ return this.usdtDecimals;
54
+ if (!this.usdtDecimalsInflight) {
55
+ this.usdtDecimalsInflight = this.getConfig()
56
+ .then((cfg) => {
57
+ const d = cfg.usdtDecimals;
58
+ if (!isValidUsdtDecimals(d)) {
59
+ throw new Error(`[@pulsepairs/sdk] /config returned an invalid usdtDecimals: ${String(d)}`);
60
+ }
61
+ this.setUsdtDecimals(d);
62
+ return d;
63
+ })
64
+ .finally(() => {
65
+ this.usdtDecimalsInflight = undefined;
66
+ });
67
+ }
68
+ return this.usdtDecimalsInflight;
69
+ }
70
+ /**
71
+ * This client's decimals, synchronously. Called before `ensureUsdtDecimals`
72
+ * has resolved, it answers 6 and warns once — that is a bug in the caller. It
73
+ * never reads another client's value.
74
+ */
75
+ getUsdtDecimals() {
76
+ if (this.usdtDecimals !== undefined)
77
+ return this.usdtDecimals;
78
+ // THIS client's value only — never another client's shared default. A prod
79
+ // client that has not loaded would otherwise inherit a dev client's 18 and
80
+ // sign a $5 order 10^12 too large (PR #437 re-review). Unloaded, it answers
81
+ // the documented 6 and warns once per client: at worst it under-signs, which
82
+ // the venue's $1 minimum rejects, and it can never over-sign.
83
+ if (!this.warnedUnloaded) {
84
+ this.warnedUnloaded = true;
85
+ console.warn("[@pulsepairs/sdk] this client's usdtDecimals is not loaded — using 6. " +
86
+ "Await ensureUsdtDecimals() first (the venue may use 18).");
87
+ }
88
+ return 6;
89
+ }
90
+ /**
91
+ * `usdToAtomic` / `atomicToUsd` / `roundTripToleranceAtomic` at THIS client's
92
+ * decimals. Use these when a process talks to more than one venue (dev at 18,
93
+ * prod at 6): the standalone helpers share one default, which is ambiguous
94
+ * then. Before this client's `ensureUsdtDecimals` resolves they use 6 and
95
+ * warn once, like `getUsdtDecimals` — never another client's decimals.
96
+ */
97
+ usdToAtomic(usd) {
98
+ return usdToAtomic(usd, this.getUsdtDecimals());
99
+ }
100
+ atomicToUsd(atomic) {
101
+ return atomicToUsd(atomic, this.getUsdtDecimals());
102
+ }
103
+ roundTripToleranceAtomic() {
104
+ return roundTripToleranceAtomic(this.getUsdtDecimals());
105
+ }
106
+ /** Cache `d` if it is a valid decimal count (integer 0–36); otherwise ignored.
107
+ * Lets a `getConfig()` response the caller already has populate the cache. */
108
+ setUsdtDecimals(d) {
109
+ if (!isValidUsdtDecimals(d))
110
+ return;
111
+ this.usdtDecimals = d;
112
+ publishUsdtDecimals(d);
113
+ }
37
114
  async getVersion() {
38
115
  const res = await fetch(buildUrl(this.baseUrl, "/version"));
39
116
  return parseJson(res);
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { UpDownHttpClient, wsUrlFromHttpBase, bulkFailures } from "./http.js";
2
+ export { usdToAtomic, atomicToUsd, roundTripToleranceAtomic, isValidUsdtDecimals } from "./units.js";
2
3
  export { UpDownWsClient, type UpDownWsMessage, type SubscribePayload, type WsAuthCredentials, type ConnectAuthedOptions, } from "./ws.js";
3
4
  export { ORDER_TYPES, CANCEL_TYPES, WS_AUTH_TYPES, buildOrderTypedData, buildCancelTypedData, buildWsAuthTypedData, freshSessionId, freshNonce, domainForSettlement, findPairBySettlement, parseCompositeMarketKey, centsToBps, bpsToCents, parseStake, assertStakeBounds, feeAtomic, MIN_STAKE_ATOMIC, type OrderSignMessage, type CancelSignMessage, type WsAuthMessage, type ParsedComposite, } from "./eip712.js";
4
5
  export { CLOB_AUTH_TYPES, CLOB_AUTH_MESSAGE, buildClobAuthTypedData, buildHmacSignature, HMAC_HEADERS, type ClobAuthDomain, type ClobAuthMessage, } from "./auth.js";
package/dist/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  // HTTP + WS clients
2
2
  export { UpDownHttpClient, wsUrlFromHttpBase, bulkFailures } from "./http.js";
3
+ // Collateral units (usdtDecimals from /config: 18 on dev USDR, 6 on prod USDT0)
4
+ export { usdToAtomic, atomicToUsd, roundTripToleranceAtomic, isValidUsdtDecimals } from "./units.js";
3
5
  export { UpDownWsClient, } from "./ws.js";
4
6
  // EIP-712 helpers
5
7
  export { ORDER_TYPES, CANCEL_TYPES, WS_AUTH_TYPES, buildOrderTypedData, buildCancelTypedData, buildWsAuthTypedData, freshSessionId, freshNonce, domainForSettlement, findPairBySettlement, parseCompositeMarketKey, centsToBps, bpsToCents, parseStake, assertStakeBounds, feeAtomic,
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Collateral unit helpers — whole-token ("USD") amounts ↔ on-chain base units.
3
+ *
4
+ * The venue's collateral decimals are published as `usdtDecimals` on
5
+ * `GET /config`: 18 on dev (USDR), 6 on prod (USDT0). Every amount the API
6
+ * sends or accepts is in base units at that scale, so a client must read it
7
+ * rather than assume 6 — a 6-decimal assumption against an 18-decimal venue is
8
+ * off by 10^12 on every order and every displayed amount.
9
+ *
10
+ * `UpDownHttpClient.ensureUsdtDecimals()` fetches the value and caches it here;
11
+ * the helpers below default to that cached value.
12
+ */
13
+ /** Integer 0–36: the range a real ERC-20 uses. Anything else is not guessed at. */
14
+ export declare function isValidUsdtDecimals(d: unknown): d is number;
15
+ /** @internal Store a validated value as the default for the helpers below. */
16
+ export declare function publishUsdtDecimals(d: number): void;
17
+ /**
18
+ * @internal The decimals the helpers default to. Before the value is loaded it
19
+ * answers 6 and warns ONCE: reading it early is a bug in the caller (it should
20
+ * have awaited `ensureUsdtDecimals()`), and 6 is wrong on an 18-decimal venue.
21
+ */
22
+ export declare function defaultUsdtDecimals(): number;
23
+ /** @internal tests only. */
24
+ export declare function _resetUsdtDecimalsForTests(): void;
25
+ /**
26
+ * Whole tokens → base units, e.g. `usdToAtomic(0.1, 18) === 100000000000000000n`.
27
+ *
28
+ * Goes through a decimal STRING (`parseUnits(usd.toFixed(min(decimals, 12)))`),
29
+ * never `usd * 10 ** decimals`: at 18 decimals that product is past 2^53 and
30
+ * float error lands in the integer (1.1 * 1e18 is 1100000000000000128).
31
+ * `toFixed` is capped at 12 places, well beyond any display precision.
32
+ * Non-finite input → 0n.
33
+ *
34
+ * Without `decimals`, THROWS if clients in this process loaded different
35
+ * decimals: signing at a guessed scale is the failure this refuses. Use
36
+ * `client.usdToAtomic` in a multi-venue process.
37
+ */
38
+ export declare function usdToAtomic(usd: number, decimals?: number): bigint;
39
+ /**
40
+ * Base units → whole tokens, e.g. `atomicToUsd("5000000", 6) === 5`.
41
+ *
42
+ * Integer input (bigint, integer string or integer number) goes through
43
+ * `formatUnits`, so 18-decimal amounts convert without float loss. A
44
+ * non-integer number falls back to `atomic / 10 ** decimals`. `null`,
45
+ * `undefined` and `""` → 0. Never throws; a non-finite result → 0.
46
+ */
47
+ export declare function atomicToUsd(atomic: bigint | string | number | null | undefined, decimals?: number): number;
48
+ /**
49
+ * The slack, in base units, for snapping a MAX sell onto the exact owned
50
+ * balance after a round trip through a 6-decimal display value:
51
+ * `10n ** BigInt(max(0, decimals - 6))`. 1n at 6 decimals, 10^12 at 18.
52
+ */
53
+ export declare function roundTripToleranceAtomic(decimals?: number): bigint;
package/dist/units.js ADDED
@@ -0,0 +1,137 @@
1
+ import { formatUnits, parseUnits } from "viem";
2
+ /**
3
+ * Collateral unit helpers — whole-token ("USD") amounts ↔ on-chain base units.
4
+ *
5
+ * The venue's collateral decimals are published as `usdtDecimals` on
6
+ * `GET /config`: 18 on dev (USDR), 6 on prod (USDT0). Every amount the API
7
+ * sends or accepts is in base units at that scale, so a client must read it
8
+ * rather than assume 6 — a 6-decimal assumption against an 18-decimal venue is
9
+ * off by 10^12 on every order and every displayed amount.
10
+ *
11
+ * `UpDownHttpClient.ensureUsdtDecimals()` fetches the value and caches it here;
12
+ * the helpers below default to that cached value.
13
+ */
14
+ /** Integer 0–36: the range a real ERC-20 uses. Anything else is not guessed at. */
15
+ export function isValidUsdtDecimals(d) {
16
+ return typeof d === "number" && Number.isInteger(d) && d >= 0 && d <= 36;
17
+ }
18
+ /** The fallback before the value is loaded — prod's USDT0. */
19
+ const FALLBACK_DECIMALS = 6;
20
+ let cachedDecimals;
21
+ let warnedUnloaded = false;
22
+ /**
23
+ * Set once two clients have loaded DIFFERENT decimals (a process talking to dev
24
+ * at 18 and prod at 6). The shared default is then ambiguous: it would be
25
+ * whichever loaded last, and a signed amount at the wrong scale is 10^12 off.
26
+ * From then on the standalone helpers refuse to guess — see defaultUsdtDecimals.
27
+ * Per-client conversion stays exact: `client.usdToAtomic` etc.
28
+ */
29
+ let conflictingDecimals = false;
30
+ /** @internal Store a validated value as the default for the helpers below. */
31
+ export function publishUsdtDecimals(d) {
32
+ if (cachedDecimals !== undefined && cachedDecimals !== d)
33
+ conflictingDecimals = true;
34
+ cachedDecimals = d;
35
+ }
36
+ /**
37
+ * @internal The decimals the helpers default to. Before the value is loaded it
38
+ * answers 6 and warns ONCE: reading it early is a bug in the caller (it should
39
+ * have awaited `ensureUsdtDecimals()`), and 6 is wrong on an 18-decimal venue.
40
+ */
41
+ export function defaultUsdtDecimals() {
42
+ if (conflictingDecimals) {
43
+ throw new Error("[@pulsepairs/sdk] clients in this process loaded different usdtDecimals " +
44
+ "(e.g. dev 18 and prod 6), so there is no single default. Pass `decimals` " +
45
+ "explicitly, or use client.usdToAtomic / client.atomicToUsd.");
46
+ }
47
+ if (cachedDecimals !== undefined)
48
+ return cachedDecimals;
49
+ if (!warnedUnloaded) {
50
+ warnedUnloaded = true;
51
+ console.warn("[@pulsepairs/sdk] usdtDecimals read before it was loaded — using 6. " +
52
+ "Await client.ensureUsdtDecimals() first (the venue may use 18).");
53
+ }
54
+ return FALLBACK_DECIMALS;
55
+ }
56
+ /** @internal tests only. */
57
+ export function _resetUsdtDecimalsForTests() {
58
+ cachedDecimals = undefined;
59
+ warnedUnloaded = false;
60
+ conflictingDecimals = false;
61
+ warnedAmbiguous = false;
62
+ }
63
+ /**
64
+ * Whole tokens → base units, e.g. `usdToAtomic(0.1, 18) === 100000000000000000n`.
65
+ *
66
+ * Goes through a decimal STRING (`parseUnits(usd.toFixed(min(decimals, 12)))`),
67
+ * never `usd * 10 ** decimals`: at 18 decimals that product is past 2^53 and
68
+ * float error lands in the integer (1.1 * 1e18 is 1100000000000000128).
69
+ * `toFixed` is capped at 12 places, well beyond any display precision.
70
+ * Non-finite input → 0n.
71
+ *
72
+ * Without `decimals`, THROWS if clients in this process loaded different
73
+ * decimals: signing at a guessed scale is the failure this refuses. Use
74
+ * `client.usdToAtomic` in a multi-venue process.
75
+ */
76
+ export function usdToAtomic(usd, decimals = defaultUsdtDecimals()) {
77
+ if (!Number.isFinite(usd))
78
+ return 0n;
79
+ return parseUnits(usd.toFixed(Math.min(decimals, 12)), decimals);
80
+ }
81
+ /**
82
+ * Base units → whole tokens, e.g. `atomicToUsd("5000000", 6) === 5`.
83
+ *
84
+ * Integer input (bigint, integer string or integer number) goes through
85
+ * `formatUnits`, so 18-decimal amounts convert without float loss. A
86
+ * non-integer number falls back to `atomic / 10 ** decimals`. `null`,
87
+ * `undefined` and `""` → 0. Never throws; a non-finite result → 0.
88
+ */
89
+ export function atomicToUsd(atomic, decimals) {
90
+ try {
91
+ if (atomic === null || atomic === undefined || atomic === "")
92
+ return 0;
93
+ // Resolved inside the try: an ambiguous default (clients disagree) must not
94
+ // make this throw — it warns once and answers 0 instead.
95
+ if (decimals === undefined) {
96
+ try {
97
+ decimals = defaultUsdtDecimals();
98
+ }
99
+ catch (e) {
100
+ warnAmbiguousOnce(e);
101
+ return 0;
102
+ }
103
+ }
104
+ let out;
105
+ if (typeof atomic === "bigint") {
106
+ out = Number(formatUnits(atomic, decimals));
107
+ }
108
+ else if (typeof atomic === "string") {
109
+ const s = atomic.trim();
110
+ out = /^-?\d+$/.test(s) ? Number(formatUnits(BigInt(s), decimals)) : Number(s) / 10 ** decimals;
111
+ }
112
+ else {
113
+ out = Number.isInteger(atomic)
114
+ ? Number(formatUnits(BigInt(atomic), decimals))
115
+ : atomic / 10 ** decimals;
116
+ }
117
+ return Number.isFinite(out) ? out : 0;
118
+ }
119
+ catch {
120
+ return 0;
121
+ }
122
+ }
123
+ let warnedAmbiguous = false;
124
+ function warnAmbiguousOnce(e) {
125
+ if (warnedAmbiguous)
126
+ return;
127
+ warnedAmbiguous = true;
128
+ console.warn(String(e instanceof Error ? e.message : e));
129
+ }
130
+ /**
131
+ * The slack, in base units, for snapping a MAX sell onto the exact owned
132
+ * balance after a round trip through a 6-decimal display value:
133
+ * `10n ** BigInt(max(0, decimals - 6))`. 1n at 6 decimals, 10^12 at 18.
134
+ */
135
+ export function roundTripToleranceAtomic(decimals = defaultUsdtDecimals()) {
136
+ return 10n ** BigInt(Math.max(0, decimals - 6));
137
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pulsepairs/sdk",
3
- "version": "0.9.1",
4
- "description": "Standalone SDK for the UpDown (PulsePairs) up/down prediction markets \u2014 matcher REST/WS client, EIP-712 order signing, trade-math, and Alchemy Account Kit (smart-account) order signing for rain.trade integration.",
3
+ "version": "0.10.0",
4
+ "description": "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.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
@@ -28,13 +28,14 @@
28
28
  "files": [
29
29
  "dist",
30
30
  "README.md",
31
- "DOCUMENTATION.md"
31
+ "DOCUMENTATION.md",
32
+ "CHANGELOG.md"
32
33
  ],
33
34
  "sideEffects": false,
34
35
  "scripts": {
35
36
  "clean": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\"",
36
37
  "build": "npm run clean && tsc",
37
- "test": "node scripts/eip712-golden.test.mjs && node scripts/rawtx-tier.test.mjs && node scripts/hot-key-safety.test.mjs && node scripts/reconcile.test.mjs && node scripts/pnl.test.mjs && node scripts/ws-subscribe.test.mjs && node scripts/ws-onerror.test.mjs && node scripts/amend-wrappers.test.mjs && node scripts/amend-integration.test.mjs",
38
+ "test": "node scripts/eip712-golden.test.mjs && node scripts/rawtx-tier.test.mjs && node scripts/hot-key-safety.test.mjs && node scripts/reconcile.test.mjs && node scripts/pnl.test.mjs && node scripts/ws-subscribe.test.mjs && node scripts/ws-onerror.test.mjs && node scripts/amend-wrappers.test.mjs && node scripts/amend-integration.test.mjs && node scripts/usdt-units.test.mjs",
38
39
  "test:integration": "UPND_INTEGRATION=1 node scripts/amend-integration.test.mjs",
39
40
  "prepublishOnly": "npm run build && npm test",
40
41
  "example:taker": "npx tsx examples/simple-taker.ts",