@waterx/sdk 4.3.2 → 5.0.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.
Files changed (150) hide show
  1. package/.claude/skills/waterx-sdk-integration/SKILL.md +225 -0
  2. package/README.md +216 -45
  3. package/SKILLS.md +34 -0
  4. package/dist/cjs/src/account/config.d.ts +3 -5
  5. package/dist/cjs/src/account/funding/wormhole.d.ts +1 -2
  6. package/dist/cjs/src/account/funding/wormhole.js +1 -2
  7. package/dist/cjs/src/generated/waterx_rule/waterx_rule.d.ts +140 -7
  8. package/dist/cjs/src/generated/waterx_rule/waterx_rule.js +163 -8
  9. package/dist/cjs/src/oracle/aggregate.d.ts +49 -62
  10. package/dist/cjs/src/oracle/aggregate.js +208 -164
  11. package/dist/cjs/src/oracle/config.d.ts +38 -51
  12. package/dist/cjs/src/oracle/config.js +1 -1
  13. package/dist/cjs/src/oracle/host.d.ts +14 -19
  14. package/dist/cjs/src/oracle/host.js +3 -3
  15. package/dist/cjs/src/oracle/index.d.ts +25 -16
  16. package/dist/cjs/src/oracle/index.js +87 -69
  17. package/dist/cjs/src/oracle/price-update-rule.d.ts +104 -66
  18. package/dist/cjs/src/oracle/price-update-rule.js +24 -16
  19. package/dist/cjs/src/oracle/pyth-pro-history.d.ts +36 -0
  20. package/dist/cjs/src/oracle/pyth-pro-history.js +41 -0
  21. package/dist/cjs/src/oracle/read-plane.d.ts +35 -53
  22. package/dist/cjs/src/oracle/read-plane.js +26 -48
  23. package/dist/cjs/src/oracle/read-prices.d.ts +105 -0
  24. package/dist/cjs/src/oracle/read-prices.js +162 -0
  25. package/dist/cjs/src/oracle/rule-registry.d.ts +12 -12
  26. package/dist/cjs/src/oracle/rule-registry.js +12 -14
  27. package/dist/cjs/src/oracle/rules/pyth-lazer-rule.d.ts +24 -3
  28. package/dist/cjs/src/oracle/rules/pyth-lazer-rule.js +40 -20
  29. package/dist/cjs/src/oracle/rules/waterx-rule.d.ts +271 -57
  30. package/dist/cjs/src/oracle/rules/waterx-rule.js +743 -144
  31. package/dist/cjs/src/oracle/schedule.d.ts +109 -0
  32. package/dist/cjs/src/oracle/schedule.js +991 -0
  33. package/dist/cjs/src/oracle/source-list.d.ts +31 -27
  34. package/dist/cjs/src/oracle/source-list.js +43 -45
  35. package/dist/cjs/src/oracle/symbol-catalog.d.ts +54 -0
  36. package/dist/cjs/src/oracle/symbol-catalog.js +69 -0
  37. package/dist/cjs/src/oracle/update-fetch.d.ts +11 -15
  38. package/dist/cjs/src/oracle/update-fetch.js +12 -19
  39. package/dist/cjs/src/oracle/validate.d.ts +105 -0
  40. package/dist/cjs/src/oracle/validate.js +185 -0
  41. package/dist/cjs/src/oracle/weight-coverage.d.ts +79 -0
  42. package/dist/cjs/src/oracle/weight-coverage.js +173 -0
  43. package/dist/cjs/src/perp/client.d.ts +40 -55
  44. package/dist/cjs/src/perp/client.js +44 -17
  45. package/dist/cjs/src/perp/config-view.d.ts +5 -14
  46. package/dist/cjs/src/perp/config-view.js +5 -16
  47. package/dist/cjs/src/perp/config.d.ts +3 -3
  48. package/dist/cjs/src/perp/config.js +11 -13
  49. package/dist/cjs/src/perp/index.d.ts +4 -6
  50. package/dist/cjs/src/perp/index.js +43 -18
  51. package/dist/cjs/src/perp/tx-builders/common.d.ts +86 -48
  52. package/dist/cjs/src/perp/tx-builders/common.js +115 -47
  53. package/dist/cjs/src/perp/tx-builders/wlp.d.ts +3 -14
  54. package/dist/cjs/src/perp/tx-builders/wlp.js +6 -35
  55. package/dist/cjs/src/perp/tx-builders.d.ts +6 -5
  56. package/dist/cjs/src/perp/tx-builders.js +16 -10
  57. package/dist/cjs/src/unified-client.d.ts +9 -35
  58. package/dist/cjs/src/unified-client.js +3 -4
  59. package/dist/cjs/src/utils/config.d.ts +0 -8
  60. package/dist/cjs/src/utils/config.js +5 -13
  61. package/dist/src/account/config.d.ts +3 -5
  62. package/dist/src/account/funding/wormhole.d.ts +1 -2
  63. package/dist/src/account/funding/wormhole.js +1 -2
  64. package/dist/src/generated/waterx_rule/waterx_rule.d.ts +140 -7
  65. package/dist/src/generated/waterx_rule/waterx_rule.js +151 -7
  66. package/dist/src/oracle/aggregate.d.ts +49 -62
  67. package/dist/src/oracle/aggregate.js +209 -164
  68. package/dist/src/oracle/config.d.ts +38 -51
  69. package/dist/src/oracle/config.js +1 -1
  70. package/dist/src/oracle/host.d.ts +14 -19
  71. package/dist/src/oracle/host.js +3 -3
  72. package/dist/src/oracle/index.d.ts +25 -16
  73. package/dist/src/oracle/index.js +61 -54
  74. package/dist/src/oracle/price-update-rule.d.ts +104 -66
  75. package/dist/src/oracle/price-update-rule.js +23 -16
  76. package/dist/src/oracle/pyth-pro-history.d.ts +36 -0
  77. package/dist/src/oracle/pyth-pro-history.js +38 -0
  78. package/dist/src/oracle/read-plane.d.ts +35 -53
  79. package/dist/src/oracle/read-plane.js +25 -47
  80. package/dist/src/oracle/read-prices.d.ts +105 -0
  81. package/dist/src/oracle/read-prices.js +156 -0
  82. package/dist/src/oracle/rule-registry.d.ts +12 -12
  83. package/dist/src/oracle/rule-registry.js +12 -14
  84. package/dist/src/oracle/rules/pyth-lazer-rule.d.ts +24 -3
  85. package/dist/src/oracle/rules/pyth-lazer-rule.js +39 -20
  86. package/dist/src/oracle/rules/waterx-rule.d.ts +271 -57
  87. package/dist/src/oracle/rules/waterx-rule.js +735 -144
  88. package/dist/src/oracle/schedule.d.ts +109 -0
  89. package/dist/src/oracle/schedule.js +985 -0
  90. package/dist/src/oracle/source-list.d.ts +31 -27
  91. package/dist/src/oracle/source-list.js +42 -43
  92. package/dist/src/oracle/symbol-catalog.d.ts +54 -0
  93. package/dist/src/oracle/symbol-catalog.js +65 -0
  94. package/dist/src/oracle/update-fetch.d.ts +11 -15
  95. package/dist/src/oracle/update-fetch.js +12 -18
  96. package/dist/src/oracle/validate.d.ts +105 -0
  97. package/dist/src/oracle/validate.js +177 -0
  98. package/dist/src/oracle/weight-coverage.d.ts +79 -0
  99. package/dist/src/oracle/weight-coverage.js +166 -0
  100. package/dist/src/perp/client.d.ts +40 -55
  101. package/dist/src/perp/client.js +45 -18
  102. package/dist/src/perp/config-view.d.ts +5 -14
  103. package/dist/src/perp/config-view.js +5 -16
  104. package/dist/src/perp/config.d.ts +3 -3
  105. package/dist/src/perp/config.js +11 -13
  106. package/dist/src/perp/index.d.ts +4 -6
  107. package/dist/src/perp/index.js +17 -5
  108. package/dist/src/perp/tx-builders/common.d.ts +86 -48
  109. package/dist/src/perp/tx-builders/common.js +114 -48
  110. package/dist/src/perp/tx-builders/wlp.d.ts +3 -14
  111. package/dist/src/perp/tx-builders/wlp.js +6 -35
  112. package/dist/src/perp/tx-builders.d.ts +6 -5
  113. package/dist/src/perp/tx-builders.js +15 -6
  114. package/dist/src/unified-client.d.ts +9 -35
  115. package/dist/src/unified-client.js +3 -4
  116. package/dist/src/utils/config.d.ts +0 -8
  117. package/dist/src/utils/config.js +5 -12
  118. package/package.json +8 -2
  119. package/dist/cjs/src/generated/pyth_sponsor_rule/deps/sui/balance.d.ts +0 -12
  120. package/dist/cjs/src/generated/pyth_sponsor_rule/deps/sui/balance.js +0 -17
  121. package/dist/cjs/src/generated/pyth_sponsor_rule/pyth_sponsor_rule.d.ts +0 -68
  122. package/dist/cjs/src/generated/pyth_sponsor_rule/pyth_sponsor_rule.js +0 -113
  123. package/dist/cjs/src/generated/waterx_pyth_rule/deps/sui/vec_map.d.ts +0 -36
  124. package/dist/cjs/src/generated/waterx_pyth_rule/deps/sui/vec_map.js +0 -31
  125. package/dist/cjs/src/generated/waterx_pyth_rule/pyth_rule.d.ts +0 -192
  126. package/dist/cjs/src/generated/waterx_pyth_rule/pyth_rule.js +0 -214
  127. package/dist/cjs/src/oracle/pyth.d.ts +0 -227
  128. package/dist/cjs/src/oracle/pyth.js +0 -652
  129. package/dist/cjs/src/oracle/rules/pyth-core-rule.d.ts +0 -15
  130. package/dist/cjs/src/oracle/rules/pyth-core-rule.js +0 -100
  131. package/dist/cjs/src/oracle/rules/pyth-rule.d.ts +0 -11
  132. package/dist/cjs/src/oracle/rules/pyth-rule.js +0 -29
  133. package/dist/cjs/src/oracle/rules/sponsor.d.ts +0 -32
  134. package/dist/cjs/src/oracle/rules/sponsor.js +0 -56
  135. package/dist/src/generated/pyth_sponsor_rule/deps/sui/balance.d.ts +0 -12
  136. package/dist/src/generated/pyth_sponsor_rule/deps/sui/balance.js +0 -14
  137. package/dist/src/generated/pyth_sponsor_rule/pyth_sponsor_rule.d.ts +0 -68
  138. package/dist/src/generated/pyth_sponsor_rule/pyth_sponsor_rule.js +0 -73
  139. package/dist/src/generated/waterx_pyth_rule/deps/sui/vec_map.d.ts +0 -36
  140. package/dist/src/generated/waterx_pyth_rule/deps/sui/vec_map.js +0 -27
  141. package/dist/src/generated/waterx_pyth_rule/pyth_rule.d.ts +0 -192
  142. package/dist/src/generated/waterx_pyth_rule/pyth_rule.js +0 -170
  143. package/dist/src/oracle/pyth.d.ts +0 -227
  144. package/dist/src/oracle/pyth.js +0 -638
  145. package/dist/src/oracle/rules/pyth-core-rule.d.ts +0 -15
  146. package/dist/src/oracle/rules/pyth-core-rule.js +0 -97
  147. package/dist/src/oracle/rules/pyth-rule.d.ts +0 -11
  148. package/dist/src/oracle/rules/pyth-rule.js +0 -26
  149. package/dist/src/oracle/rules/sponsor.d.ts +0 -32
  150. package/dist/src/oracle/rules/sponsor.js +0 -52
@@ -0,0 +1,225 @@
1
+ ---
2
+ name: waterx-sdk-integration
3
+ description: Use when integrating @waterx/sdk into an app, keeper, or bot — wiring a WaterX client, creating and funding a wxa account, building perp or prediction transactions, or debugging a WaterX build/simulate failure. Covers the required waterxConfigUrl option, the config-derived oracle fed set, the build→simulate→execute discipline, and the aborts integrators hit first.
4
+ ---
5
+
6
+ # Integrating `@waterx/sdk`
7
+
8
+ WaterX is a perpetual futures DEX and prediction market on Sui. The SDK **builds
9
+ transactions**; it never signs on your behalf and never reads `process.env`. Every
10
+ chain-specific value comes from a config JSON _you_ supply.
11
+
12
+ Work the steps in order. Each one has a decision you must make explicitly — the SDK has
13
+ no defaults for the first two on purpose, so that every environment runs the same build
14
+ and differs only by configuration.
15
+
16
+ ## Step 1 — Choose the entry point
17
+
18
+ | You need | Import | Client |
19
+ | ----------------------- | ------------------------ | ------------------------ |
20
+ | Both product lines | `@waterx/sdk` | `WaterXClient.create()` |
21
+ | Perpetuals only | `@waterx/sdk/perp` | `PerpClient.create()` |
22
+ | Prediction markets only | `@waterx/sdk/prediction` | `PredictClient.create()` |
23
+
24
+ The umbrella exposes three namespaces: `client.account` (shared wxa account + funding),
25
+ `client.perp`, `client.predict`. The two lines have colliding builder names
26
+ (`placeOrder`, `deposit`), which is why they are namespaced rather than flat.
27
+
28
+ **All factories are async** — they fetch deployment config.
29
+
30
+ ```bash
31
+ pnpm add @waterx/sdk @mysten/sui @mysten/bcs # the two Mysten packages are peers
32
+ ```
33
+
34
+ Node ≥ 22. ESM and CJS both resolve.
35
+
36
+ ## Step 2 — Supply the config URL
37
+
38
+ ```ts
39
+ import { WaterXClient } from "@waterx/sdk";
40
+
41
+ const client = await WaterXClient.create({
42
+ network: "TESTNET",
43
+ waterxConfigUrl: process.env.WATERX_CONFIG_URL, // REQUIRED — no default, no env fallback
44
+ pythApiKey: process.env.PYTH_API_KEY, // required iff the config wires pyth_lazer_rule
45
+ });
46
+ ```
47
+
48
+ **`waterxConfigUrl`** points at the canonical
49
+ [`waterx-config`](https://github.com/WaterXProtocol/waterx-config) JSON. It is fetched
50
+ as-is — the SDK appends no `<network>.json` and no git ref. Your app reads the env var;
51
+ the SDK never does. Look up ids through the client (`client.perp.getMarket(ticker)`,
52
+ `client.perp.creditType()`, `client.perp.wlpType()`) rather than hardcoding them.
53
+
54
+ **The oracle fed set is DERIVED from that config** — there is no `oracleSource` option
55
+ and no `ORACLE_SOURCE` env var. A source is fed when its block is published, carries at
56
+ least one feed, and is not explicitly `enabled: false`. Every derived source's data is
57
+ fetched and fed in one PTB, and the chain's per-ticker weight tables arbitrate. Read the
58
+ answer for a live deployment with `client.perp.oracleSources`, or before a client exists
59
+ with `deriveOracleSources(config)`.
60
+
61
+ | Source | Notes |
62
+ | ----------------- | -------------------------------------------------------------------------------- |
63
+ | `pyth_lazer_rule` | one signed verify per PTB, no per-feed fees; **requires `pythApiKey`** |
64
+ | `waterx_rule` | first-party TEE quote-center; no credential; browser needs a CORS-allowed origin |
65
+
66
+ (`pyth_rule` — Pyth Core / Hermes — was retired in 5.0.0. Its block is still published
67
+ in the live configs and is inert: it is not a derivable source, so nothing feeds it.)
68
+
69
+ Why derived rather than declared: **the fed set must be a superset of every ticker's
70
+ on-chain weighted rules.** Starving a weighted rule aborts `EMissingPriceSource`; feeding
71
+ an unweighted one is silently dropped. Because the failure is one-sided, taking every
72
+ source the config wires is the fail-safe answer — and a hand-typed list could only err
73
+ in the fatal direction (the classic being one copied between networks). A weight
74
+ migration is then a config change, never an env edit and never an SDK release.
75
+
76
+ Inspect what a network actually weights when you are debugging:
77
+
78
+ ```bash
79
+ pnpm oracle:aggregates:testnet # per-ticker aggregator sources + weights
80
+ ```
81
+
82
+ Want to fail at BOOT rather than at the first trade that needs a missing feed? Pass the
83
+ tickers you care about to `assertOracleWriteCoverage(client.perp, tickers)`.
84
+
85
+ ## Step 3 — Ensure a wxa account
86
+
87
+ Every trading call needs a `waterx_account` account id. One account serves both lines.
88
+ It is **not** a Sui address — it is an object id returned by `create_account` and emitted
89
+ in the `AccountCreated` event.
90
+
91
+ ```ts
92
+ import { Transaction } from "@mysten/sui/transactions";
93
+ import { AccountCreated } from "@waterx/sdk/generated/waterx_account/events";
94
+
95
+ const tx = new Transaction();
96
+ client.account.createAccount(tx, { alias: "alice" });
97
+ const exec = await client.perp.signAndExecuteTransaction({ transaction: tx, signer });
98
+ const digest = exec.Transaction?.digest ?? "";
99
+
100
+ // The id is NOT a builder return value — read it back off the digest. Decode the
101
+ // event's `bcs`, never its `json`: only the BCS layout is the Move struct itself.
102
+ // (`as const` is load-bearing — it is what types `events` as present.)
103
+ const res = await client.perp.grpcClient.getTransaction({
104
+ digest,
105
+ include: { events: true } as const,
106
+ });
107
+ const ev = res.Transaction?.events?.find((e) => e.eventType.endsWith("::events::AccountCreated"));
108
+ const accountId = ev ? AccountCreated.parse(ev.bcs).account_object_address : undefined; // persist
109
+ ```
110
+
111
+ A **simulate emits the same `AccountCreated` event but creates nothing** — the address
112
+ in a dry run's events is not on chain and reusing it aborts `EAccountNotFound`. Only read
113
+ an id back after a real execute. Runnable version:
114
+ `accountIdFromDigest` in `examples/_shared.ts`.
115
+
116
+ ## Step 4 — Fund it
117
+
118
+ Collateral must sit **inside** the account. Depositing is two calls in one PTB:
119
+ `requestDeposit(coin)` returns a `DepositRequest` hot potato, then
120
+ `direct_rule::consume_deposit_direct(req)` finalizes it. Leaving out the second call is a
121
+ build error, not a silent no-op.
122
+
123
+ Cross-chain CREDIT (`credit.ts`) and the native PSM (`custody.ts`) are the other funding
124
+ routes.
125
+
126
+ ## Step 5 — Build → simulate → execute
127
+
128
+ Builders are **build-only**: they return or mutate a `Transaction`. Signing stays with
129
+ you — a keypair, or a browser wallet.
130
+
131
+ ```ts
132
+ import { rawPrice } from "@waterx/sdk/perp";
133
+
134
+ const tx = await client.perp.buildPlaceOrderTx({
135
+ ticker: "BTCUSD",
136
+ collateralType: client.perp.creditType(),
137
+ accountId,
138
+ main: {
139
+ isLong: true,
140
+ isStopOrder: false,
141
+ reduceOnly: false,
142
+ size: rawPrice(0.001),
143
+ triggerPrice: undefined, // omit ⇒ market order
144
+ acceptablePrice: rawPrice(120_000), // slippage cap
145
+ collateralAmount: 5_000_000n,
146
+ },
147
+ preOrders: [], // optional reduce-only TP/SL legs
148
+ });
149
+
150
+ tx.setSender(address);
151
+ await client.perp.simulate(tx); // ALWAYS. Free, and catches every step-2 mistake.
152
+ await client.perp.signAndExecuteTransaction({ transaction: tx, signer });
153
+ ```
154
+
155
+ `build*Tx` helpers are **async** because they prepend the oracle refresh legs. The
156
+ low-level `*Request` builders are sync and do not refresh — pair them with
157
+ `executeTrading` in the same PTB if you compose by hand.
158
+
159
+ There is no `open_position_request`. A market order is a limit order with
160
+ `triggerPrice: undefined` and a non-zero `acceptablePrice`; a keeper fills it.
161
+
162
+ ## Step 6 — Read state
163
+
164
+ Reads are `simulateTransaction` + BCS decode: no signer, no gas, zero-address sender.
165
+
166
+ ```ts
167
+ const positions = await client.perp.getAccountPositions({
168
+ ticker: "BTCUSD",
169
+ accountObjectAddress: accountId,
170
+ basePriceUsd: 0n,
171
+ });
172
+ ```
173
+
174
+ Returned structs keep their **snake_case** Move field names
175
+ (`account_object_address`, `create_timestamp`) — use them as-is.
176
+
177
+ ## Red flags
178
+
179
+ Stop if you catch yourself doing any of these:
180
+
181
+ - **Hardcoding an object id.** It belongs in the config JSON, read via the client.
182
+ - **Writing `BTC/USD` or `BTC`.** Tickers are concatenated: `BTCUSD`, `ETHUSD`, `SUIUSD`.
183
+ (Collateral _tokens_ keep a plain symbol — `USDC` — and are a different thing.)
184
+ - **Passing a plain number as a price or size.** Wrap in `rawPrice()`. The exception:
185
+ view `basePriceUsd` arguments take a whole-dollar u64 — `parseWholeDollarU64`.
186
+ - **Skipping simulate.** Every failure in the table below is free to find at simulate.
187
+ - **Reusing one `waterx_rule` envelope across concurrent builds for the same symbol.**
188
+ A signed timestamp is single-use per symbol and the second one aborts
189
+ `EReplayedSignature` — weight-independent (audit F-014). Fetch per build.
190
+ - **Expecting a fallback between oracle sources.** There is none. Sources are
191
+ self-contained; an absent feed fails at tx-build.
192
+ - **Reaching for `process.env` inside SDK calls.** Read env at your app's boundary and
193
+ pass values in.
194
+ - **Assuming SemVer.** This package may ship a breaking change in a patch. Pin exact and
195
+ read the CHANGELOG before upgrading.
196
+
197
+ ## Aborts and errors
198
+
199
+ Which step a failure sends you back to. The **full messages, causes, and fixes live in
200
+ one place** — `README.md`'s Troubleshooting table — so that they stay accurate; do not
201
+ re-derive them from here.
202
+
203
+ | Error | Go back to |
204
+ | --------------------------------------------------------------------- | -------------------------------------------------- |
205
+ | `loadConfig: no config URL …` | Step 2 — `waterxConfigUrl` |
206
+ | `fed set […] has no feed for ticker(s)` (`OracleTickerUnservedError`) | Step 2 — the config's feeds |
207
+ | `EMissingPriceSource` | Step 2 — the fed set is too narrow for that ticker |
208
+ | `LazerApiKeyMissing …` | Step 2 — `pythApiKey` |
209
+ | `EAccountNotFound` | Step 3 — the account id is not on this network |
210
+ | `EReplayedSignature` | Step 5 — an envelope was reused across builds |
211
+
212
+ ## Verifying an integration
213
+
214
+ 1. `client.perp.simulate(tx)` returns without `FailedTransaction` for one order build.
215
+ 2. A read path returns real rows for a funded account.
216
+ 3. The fed set covers every ticker you trade — cross-check `pnpm oracle:aggregates`.
217
+ 4. Only then execute, and confirm the digest.
218
+
219
+ ## Reference
220
+
221
+ - Runnable walkthrough: `examples/quickstart.ts`
222
+ - Every perp recipe, one file per entry point: `examples/`
223
+ - Prediction reference flows: `test/prediction/e2e/`
224
+ - Authoritative export lists: `src/perp/index.ts`, `src/prediction/index.ts`
225
+ - Architecture and contract surface: `CLAUDE.md`
package/README.md CHANGED
@@ -11,21 +11,22 @@ The perp and prediction lines expose builder functions with **colliding names**
11
11
  ```ts
12
12
  import { WaterXClient } from "@waterx/sdk";
13
13
 
14
- // waterxConfigUrl and oracleSource are REQUIRED — the SDK has no built-in
15
- // defaults and never reads env. oracleSource: see "Oracle sources" below.
14
+ // waterxConfigUrl is REQUIRED — the SDK has no built-in default and never
15
+ // reads env. The oracle fed set is DERIVED from that config; there is nothing
16
+ // to pick. See "Oracle sources" below.
16
17
  const client = await WaterXClient.create({
17
18
  network: "TESTNET",
18
19
  waterxConfigUrl:
19
20
  "https://raw.githubusercontent.com/WaterXProtocol/waterx-config/main/testnet.json",
20
- oracleSource: "pyth_rule",
21
+ pythApiKey: process.env.PYTH_API_KEY, // required iff the config wires pyth_lazer_rule
21
22
  });
22
23
  client.account.createAccount(tx, { alias }); // shared waterx_account + funding (credit/custody)
23
24
  client.perp.buildPlaceOrderTx(params); // perpetuals
24
25
  client.predict.placeOrder(tx, params); // prediction markets
25
26
  // client.perp / client.predict ARE the line clients — sign/execute on them directly:
26
27
  // await client.perp.signAndExecuteTransaction({ transaction: tx, signer })
27
- // each line can target a different network + URL (oracleSource stays top-level):
28
- // WaterXClient.create({ oracleSource: "pyth_rule", perp: { network: "MAINNET", waterxConfigUrl: mainnetUrl }, predict: { network: "TESTNET", waterxConfigUrl: testnetUrl } })
28
+ // each line can target a different network + URL (each derives its own fed set):
29
+ // WaterXClient.create({ perp: { network: "MAINNET", waterxConfigUrl: mainnetUrl }, predict: { network: "TESTNET", waterxConfigUrl: testnetUrl } })
29
30
  ```
30
31
 
31
32
  > `WaterXClient` is the umbrella entry point. `Client` is kept as a **deprecated alias** for one major cycle.
@@ -41,11 +42,20 @@ Import surfaces:
41
42
  ## Install
42
43
 
43
44
  ```bash
44
- pnpm install
45
- pnpm build
45
+ pnpm add @waterx/sdk @mysten/sui @mysten/bcs
46
46
  ```
47
47
 
48
- Consumers: `pnpm add @waterx/sdk @mysten/sui`
48
+ `@mysten/sui` (`^2.9.0`) and `@mysten/bcs` (`^1.9.0`) are **peer** dependencies — the SDK
49
+ does not bundle them, so your app and the SDK share one Sui client and one BCS registry.
50
+
51
+ - **Node ≥ 22** (declared in `engines`; CI builds and tests on 24).
52
+ - **ESM and CJS** both resolve, including on every subpath export.
53
+ - **One runtime dependency** (`@noble/hashes`), plus the two peers above.
54
+ - **Browser supported** — see the CORS note under [Oracle sources](#oracle-sources) if you
55
+ use `waterx_rule`.
56
+
57
+ Contributor setup (building this repo rather than consuming it) is under
58
+ [Development](#development).
49
59
 
50
60
  ## Quickstart (unified client)
51
61
 
@@ -58,15 +68,18 @@ import { Transaction } from "@mysten/sui/transactions";
58
68
  const client = await WaterXClient.create({
59
69
  network: "TESTNET",
60
70
  waterxConfigUrl: "https://raw.githubusercontent.com/WaterXProtocol/waterx-config/main/testnet.json",
61
- oracleSource: "pyth_rule", // REQUIRED single source or a list (the fed set); see "Oracle sources"
71
+ // The fed set is derived from this config nothing to declare. Inspect it
72
+ // with `pnpm oracle:aggregates:testnet`. See "Oracle sources".
73
+ pythApiKey: process.env.PYTH_API_KEY, // required iff the config wires pyth_lazer_rule
62
74
  });
63
75
  const signer = /* your Ed25519Keypair or wallet Signer */;
76
+ const accountId = "0x..."; // wxa account object id — see "First integration"
64
77
 
65
78
  // --- Perp: place a market order ---
66
79
  const tx = await client.perp.buildPlaceOrderTx({
67
80
  ticker: "BTCUSD",
68
81
  collateralType: client.perp.creditType(),
69
- accountId: "0x...", // UserAccount object id (hex)
82
+ accountId,
70
83
  main: {
71
84
  isLong: true,
72
85
  isStopOrder: false,
@@ -80,13 +93,117 @@ const tx = await client.perp.buildPlaceOrderTx({
80
93
  await client.perp.signAndExecuteTransaction({ transaction: tx, signer });
81
94
 
82
95
  // --- Prediction: same pattern under client.predict ---
96
+ // Preconditions: `accountId` is a wxa account registered with the prediction
97
+ // protocol and holding settlement collateral; `marketId` is an OPEN market.
98
+ // Object ids (globalConfig / marketRegistry / accountRegistry / settlement coin
99
+ // type) are resolved from config — pass them only to override.
83
100
  const ptx = new Transaction();
84
- client.predict.placeOrder(ptx, params);
101
+ client.predict.placeOrder(ptx, {
102
+ accountId, // payer; `receiverAccountId` defaults to this
103
+ marketId: "0x...", // market id bytes or 0x-hex
104
+ selection: "YES", // "YES" | "NO"
105
+ maxSpend: 1_000_000n, // cap in settlement-coin base units
106
+ minShares: 1n, // fill floor — chain asserts filled_shares >= this; 0 accepts any fill
107
+ priceCapBps: 5_000n, // max price per share, bps of the 1-unit payout; MUST be <= 10_000
108
+ expiryTs: BigInt(Date.now() + 60_000), // ms epoch
109
+ });
85
110
  await client.predict.signAndExecuteTransaction({ transaction: ptx, signer });
86
111
  ```
87
112
 
88
113
  > Account creation is shared: `client.account.*` builds accounts via the one on-chain `waterx_account` system (perp-backed), so an account created through `client.account.createAccount` is usable by both `client.perp.*` and `client.predict.*`. (On split-network setups `client.account` follows the perp line — reach the predict line's generic account builders via the `prediction` namespace.)
89
114
 
115
+ ## First integration
116
+
117
+ The quickstart above starts from an `accountId` you already have. If you have none yet,
118
+ this is the whole arc. **[`examples/quickstart.ts`](./examples/quickstart.ts) is this
119
+ walkthrough as one runnable file** — being real code, it is covered by `pnpm lint` and
120
+ `pnpm typecheck`, so the API it exercises cannot go stale unnoticed:
121
+
122
+ ```bash
123
+ export WATERX_CONFIG_URL=https://raw.githubusercontent.com/WaterXProtocol/waterx-config/main/testnet.json
124
+ export PYTH_API_KEY=... # required iff the config wires pyth_lazer_rule
125
+ pnpm exec tsx examples/quickstart.ts # simulate-only; WATERX_EXECUTE=1 to sign + send
126
+ ```
127
+
128
+ **1 — Get a config URL.** Every chain-specific id comes from the canonical
129
+ [`waterx-config`](https://github.com/WaterXProtocol/waterx-config) JSON. There is no
130
+ built-in default and the SDK never reads `process.env`: your app reads the URL and passes
131
+ it in. Hardcoding object ids instead is the single most common integration mistake.
132
+
133
+ **2 — Nothing to pick: the fed set is derived.** Every source the config wires (a
134
+ published package with a non-empty feeds map) is fed. That is what keeps the fed set a
135
+ **superset of every ticker's on-chain weighted rules**, which is the property that
136
+ matters — starving a weighted rule aborts `EMissingPriceSource` at simulate, while
137
+ feeding an unweighted one is dropped harmlessly on chain. Inspect what a network
138
+ weights when you are debugging:
139
+
140
+ ```bash
141
+ pnpm oracle:aggregates:testnet # per-ticker aggregator sources + weights
142
+ ```
143
+
144
+ Weights are on-chain state that changes without an SDK release, so read them rather than
145
+ trusting any list written here. [Oracle sources](#oracle-sources) has the full model.
146
+
147
+ **3 — Create a wxa account.** One account serves both product lines; every trading call
148
+ needs one. The id is **not** a builder return value — it lands in the `AccountCreated`
149
+ event, so read it back off the digest, then treat it as the user's durable handle. (A
150
+ simulate emits the same event but creates nothing; only read an id back after a real
151
+ execute.)
152
+
153
+ ```ts
154
+ import { Transaction } from "@mysten/sui/transactions";
155
+ import { AccountCreated } from "@waterx/sdk/generated/waterx_account/events";
156
+
157
+ const tx = new Transaction();
158
+ client.account.createAccount(tx, { alias: "alice" });
159
+ const exec = await client.perp.signAndExecuteTransaction({ transaction: tx, signer });
160
+ const digest = exec.Transaction?.digest ?? "";
161
+
162
+ // Decode the event's `bcs`, never its `json` — only the BCS layout is the Move
163
+ // struct. (`as const` is load-bearing: it is what types `events` as present.)
164
+ const res = await client.perp.grpcClient.getTransaction({
165
+ digest,
166
+ include: { events: true } as const,
167
+ });
168
+ const ev = res.Transaction?.events?.find((e) => e.eventType.endsWith("::events::AccountCreated"));
169
+ const accountId = ev ? AccountCreated.parse(ev.bcs).account_object_address : undefined;
170
+ ```
171
+
172
+ → [`examples/actions/action-create-account.ts`](./examples/actions/action-create-account.ts)
173
+
174
+ **4 — Fund it.** Collateral must sit _inside_ the account before an order will fill.
175
+ Deposit is two calls in one PTB — `requestDeposit(coin)` then
176
+ `direct_rule::consume_deposit_direct(req)`.
177
+
178
+ → [`examples/actions/action-request-deposit.ts`](./examples/actions/action-request-deposit.ts)
179
+ · cross-chain CREDIT and the native PSM are in
180
+ [`src/account/funding/credit.ts`](./src/account/funding/credit.ts) and
181
+ [`src/account/funding/custody.ts`](./src/account/funding/custody.ts)
182
+
183
+ **5 — Build, simulate, then execute.** Builders are **build-only**: they return or mutate
184
+ a `Transaction` and never sign. Always simulate first — that is where a bad fed set, an
185
+ unfunded account, or a stale id surfaces, for free.
186
+
187
+ ```ts
188
+ const tx = await client.perp.buildPlaceOrderTx({ ... }); // async: prepends oracle legs
189
+ tx.setSender(address);
190
+ const result = await client.perp.simulate(tx); // no signer, no gas
191
+ await client.perp.signAndExecuteTransaction({ transaction: tx, signer });
192
+ ```
193
+
194
+ **6 — Read state back.** Reads are `simulateTransaction` + BCS decode — no signer, no gas,
195
+ zero-address sender.
196
+
197
+ ```ts
198
+ const positions = await client.perp.getAccountPositions({
199
+ ticker: "BTCUSD",
200
+ accountObjectAddress: accountId,
201
+ basePriceUsd: 0n, // WHOLE-DOLLAR u64 (not rawPrice); 0n zero-bases the PnL fields
202
+ });
203
+ ```
204
+
205
+ → [`examples/views/`](./examples/views) for every read path
206
+
90
207
  ## Per-line clients
91
208
 
92
209
  If you only need one line, construct it directly (both factories are **async** — they fetch deployment config; `waterxConfigUrl` is **required**):
@@ -97,89 +214,143 @@ import { PredictClient } from "@waterx/sdk/prediction";
97
214
 
98
215
  const waterxConfigUrl =
99
216
  "https://raw.githubusercontent.com/WaterXProtocol/waterx-config/main/testnet.json";
100
- const perp = await PerpClient.create("TESTNET", { waterxConfigUrl, oracleSource: "pyth_rule" }); // or PerpClient.testnet({ ... })
101
- const predict = await PredictClient.create("TESTNET", { waterxConfigUrl }); // predict line needs no oracle source
217
+ // The fed set comes from the config see "Oracle sources".
218
+ const perp = await PerpClient.create("TESTNET", {
219
+ waterxConfigUrl,
220
+ pythApiKey: process.env.PYTH_API_KEY, // required iff the config wires pyth_lazer_rule
221
+ }); // or PerpClient.testnet({ ... })
222
+ const predict = await PredictClient.create("TESTNET", { waterxConfigUrl }); // predict line has no oracle plane
102
223
  ```
103
224
 
104
225
  Read-only queries use gRPC `simulateTransaction` (no signer) — the `getX` view helpers, e.g. `await perp.simulate(tx)` or `getMarketData(perp, …)`.
105
226
 
106
227
  ## Oracle sources
107
228
 
108
- ONE **required** client create option, `oracleSource`, names the price-update source(s)a single value or a **list (the fed set)**. Each source is **self-contained** it owns its own infra + config and does **not** back-stop any other source. There is **no default source**: a client that has not named its sources fails at creation. The name is source-neutral on purpose: a future source need not be Pyth. The SDK **never reads `process.env`** each consumer wires it from its own env var, so every environment runs the **same SDK version** and differs only by env:
229
+ The fed set is **derived from the deployment config**there is no `oracleSource` create option and no `ORACLE_SOURCE` env var. A source is fed when its block is published AND carries at least one feed, so mainnet derives `[pyth_lazer_rule, waterx_rule]` and testnet `[waterx_rule]` with no per-environment wiring at all. Each source remains **self-contained** — it owns its own infra + config and does **not** back-stop any other source.
230
+
231
+ Why derived rather than declared: the chain arbitrates. Per-ticker weights decide which contributions count, feeding an **unweighted** rule is dropped on-chain, and starving a **weighted** one aborts `EMissingPriceSource`. The failure is one-sided, so a hand-typed list can only err in the fatal direction — the classic being one copied between networks, naming a source that deployment does not carry. The config cannot, because it _is_ what wires the rules. Retired blocks are inert: `pyth_rule` and `pyth_sponsor_rule` still sit in the live configs, and neither is an `ORACLE_SOURCES` member, so neither can ever be derived.
232
+
233
+ | Source | Fed when | What it is |
234
+ | ----------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
235
+ | `pyth_lazer_rule` | `packages.pyth_lazer_rule` is published with feeds | Pyth Lazer signed updates — ONE `leEcdsa` verify per PTB, no per-feed fees. Auth-first, so it needs a `pythApiKey`. |
236
+ | `waterx_rule` | `packages.waterx_rule` is published with feeds | The first-party WaterX quote-center (Nautilus-TEE, ed25519-signed CEX prices): one signed Merkle leaf per ticker, batch-envelope fallback. No API key, no per-update fee. |
109
237
 
110
- | Option | Values | What it selects |
111
- | -------------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
112
- | `oracleSource` | `OracleSource \| OracleSource[]` of `'pyth_rule'` \| `'pyth_lazer_rule'` \| `'waterx_rule'` — REQUIRED, no default | The price-update source(s). `'pyth_rule'` = Pyth Core updates (Hermes VAA + per-feed update fees, keyless `hermes.pyth.network`). `'pyth_lazer_rule'` = Pyth Lazer signed updates (ONE `leEcdsa` verify per PTB, no per-feed fees); needs `packages.pyth_lazer_rule` feeds + a `pythApiKey`. `'waterx_rule'` = the first-party WaterX quote-center (Nautilus-TEE, ed25519-signed CEX prices): one signed batch envelope per build, no API key and no per-update fee; needs `packages.waterx_rule` feeds. |
238
+ `deriveOracleSources(config)` is exported if you need the answer before a client exists (e.g. to pair with `missingOracleCredentials` in a boot assert); `client.oracleSources` is the same value on a live client. (`pyth_rule` — Pyth Core / Hermes — was RETIRED in 5.0.0.)
113
239
 
114
- **Multi-source fed sets.** With a list, ONE build fetches and feeds EVERY listed source's data in the same PTB; the chain's per-ticker aggregator **weight tables** decide which contributions count — feeding an unweighted rule's price is dropped on-chain, while starving a weighted rule aborts. That asymmetry is what makes weight migrations (Core→Pro, Pyth↔waterx coexistence) safe: keep the list a **superset** of every ticker's weighted rule set and flip weights per ticker at any time — an env edit, never an SDK release. (One caveat: waterx's feed call burns a per-symbol signed-timestamp high-water mark regardless of weights — see the replay note below.)
240
+ **Multi-source fed sets.** When the config wires more than one, ONE build fetches and feeds EVERY derived source's data in the same PTB; the chain's per-ticker aggregator **weight tables** decide which contributions count. That asymmetry is what makes weight migrations (Lazer↔waterx coexistence) safe: the derived set is a superset of every ticker's weighted rule set by construction, so weights flip per ticker at any time — a config edit, never an SDK release and never an env edit. (One caveat: waterx's feed call burns a per-symbol signed-timestamp high-water mark regardless of weights — see the replay note below.)
115
241
 
116
- **No cross-source fallback, no feeds guard at init.** Construction rejects an empty/nullish `oracleSource` **and any value outside `ORACLE_SOURCES`** (a legacy `'core'` / `'pyth'` string fails at `create`), but a listed source whose feed for a requested ticker is absent is **not** an error at client creation — the build fails at **tx-build** only when **no** listed source serves the ticker (constant-only tickers, which need no price update, are exempt). A present-but-wrong feed id is not validated by the SDK; it aborts on-chain at dry-run.
242
+ **No cross-source fallback.** Construction fails only when the config wires **no** price-update source at all (nothing could ever be priced). A derived source whose feed for a particular ticker is absent is not an error: `refreshOraclePrices` **skips** that ticker and reports it in `OracleRefreshSummary.skipped`, so a sweep over 30 markets does not lose 29 because the 30th is unconfigured. The `build*Tx` composers then fail closed with `OracleTickerUnservedError` on the tickers their specific action depends on — the traded market plus collateral, or **every pool asset** for WLP unless you pass `allowUnrefreshedPrices: true`. Constant-only tickers need no price update and are exempt. A present-but-wrong feed id is not validated by the SDK; it aborts on-chain at dry-run.
117
243
 
118
- Every source's external infra is a **rule-owned per-network table**, never deployment-overridable and never in the config JSON: `PYTH_CORE_INFRA` (`src/oracle/pyth.ts` — Pyth state ids + the keyless Core Hermes endpoint, read-plane accessor `pythCoreHermesEndpoint(network)`), `LAZER_INFRA` (`src/oracle/rules/pyth-lazer-rule.ts`), `WATERX_INFRA` (`src/oracle/rules/waterx-rule.ts` — testnet `quote-center-staging.waterx.app` / mainnet `quote-center.waterx.app`, accessor `waterxQuoteCenterEndpoint(network)`). For **price READS** under a fed set without `pyth_rule`, the documented Pyth Pro base (`pythProHermesEndpoint()` identical for every subscriber, auth via the Bearer key) applies: resolve the read endpoint with `resolveHermesReadEndpoint(network, sources, override?)` instead of branching by hand, and pair it with `resolveOracleReadPlan` for the per-source served-sets/ids. `client.pyth` is the access-only `PythAccessConfig` — just the caller-supplied `pythApiKey` / `pythFetch` create options (a secret has no place in a public CDN JSON); `client.waterx` is likewise `WaterxAccessConfig` (`waterxEndpoint` / `waterxFetch` overrides only; fetch policy resolves **`waterxFetch` → built-in defaults** — deliberately no `pythFetch` fallback, sources never share config). See the browser/CORS note below.
244
+ Every source's external infra is a **rule-owned per-network table**, never deployment-overridable and never in the config JSON: `LAZER_INFRA` (`src/oracle/rules/pyth-lazer-rule.ts` — Lazer HTTP endpoint, verifier package, per-network channel), `WATERX_INFRA` (`src/oracle/rules/waterx-rule.ts` — testnet `quote-center-staging.waterx.app` / mainnet `quote-center.waterx.app`, accessor `waterxQuoteCenterEndpoint(network)`). For **price READS**, every source reads through its OWN feeds namespace (write set == read set by construction): resolve the per-source served-set/ids with `resolveOracleReadPlan` (`lazer` = integer Lazer ids, `quote_center` = tickers; `readPlanTickers` flattens either) and execute the plan with `readLazerPrices` / `readQuoteCenterPrices` (`src/oracle/read-prices.ts`). `client.pyth` is the access-only `PythAccessConfig` — just the caller-supplied `pythApiKey` / `pythFetch` create options (a secret has no place in a public CDN JSON); `client.waterx` is likewise `WaterxAccessConfig` (`waterxEndpoint` / `waterxFetch` overrides only; fetch policy resolves **`waterxFetch` → built-in defaults** — deliberately no `pythFetch` fallback, sources never share config). See the browser/CORS note below.
119
245
 
120
246
  ```ts
121
- // Per-environment wiring the consumer owns the env var, not the SDK.
122
- // parseOracleSourceList is THE canonical parser (trim, drop empty entries,
123
- // validate every value, dedupe, throw operator-actionably) — never a bare
124
- // split-and-cast, which would hand the strict constructor untrimmed junk.
125
- import { parseOracleSourceList } from "@waterx/sdk/oracle";
247
+ // Need the fed set before a client exists (boot asserts, health checks)?
248
+ import { deriveOracleSources, missingOracleCredentials } from "@waterx/sdk/oracle";
126
249
 
250
+ // Per-environment wiring is just the config URL: point an environment at a
251
+ // different config and its fed set follows.
127
252
  const perp = await PerpClient.create(network, {
128
253
  waterxConfigUrl,
129
- oracleSource: parseOracleSourceList(process.env.ORACLE_SOURCE), // REQUIRED; comma list = the fed set
130
- pythApiKey: process.env.PYTH_API_KEY, // required iff 'pyth_lazer_rule' is listed (Lazer is auth-first)
254
+ pythApiKey: process.env.PYTH_API_KEY, // required iff that config wires pyth_lazer_rule (auth-first)
255
+ });
256
+
257
+ const missing = missingOracleCredentials(deriveOracleSources(config), {
258
+ pythApiKey: process.env.PYTH_API_KEY,
131
259
  });
132
260
  ```
133
261
 
134
- This is the coexistence rollout pattern: staging lists every source under migration (`ORACLE_SOURCE=pyth_rule,pyth_lazer_rule,waterx_rule` + `PYTH_API_KEY`) while production stays single-value (`ORACLE_SOURCE=pyth_rule`) until its weight tables move — flipping an environment is an env-var change, never an SDK release.
262
+ This is the coexistence rollout pattern: staging's config wires every source under migration while production's trails until its weight tables move — flipping an environment is a **config** change, never an env edit and never an SDK release.
135
263
 
136
264
  ### Adding an oracle source (runbook)
137
265
 
138
- Every source plugs in the same way — routing is driven **only** by the client's `oracleSource` option (never a config `enabled` flag, never `process.env`):
266
+ Every source plugs in the same way — routing is driven **only** by what the deployment config wires (never a config `enabled` flag, never `process.env`):
139
267
 
140
- 1. **Implement `PriceUpdateRule`** in `src/oracle/rules/<name>-rule.ts` — all port fields (`src/oracle/price-update-rule.ts`): `kind`, `requiresFeeSource` (`true` iff the on-chain verify draws a per-update feegates the fail-fast fee-source check), `supportedTickers`, `fetchUpdateData`, `narrowUpdateData` (subset a cached whole-universe payload to one build's tickers — a divisible payload returns a per-feed subset, an indivisible one returns itself whole iff fully covered; uncovered ticker → `null` miss), `buildUpdateCalls`.
268
+ 1. **Implement `PriceUpdateRule`** in `src/oracle/rules/<name>-rule.ts` — all port fields (`src/oracle/price-update-rule.ts`): `kind`, `credential` (set iff the off-chain fetch needs a caller credentialone object carrying the credential KIND and the rule's OWN error, which the fail-fast pre-check throws and `missingOracleCredentials` reports), `supportedTickers`, `fetchUpdateData`, `narrowUpdateData` (subset a cached whole-universe payload to one build's tickers — a divisible payload returns a per-feed subset, an indivisible one returns itself whole iff fully covered; uncovered ticker → `null` miss), `updateIdentityBySymbol` (iff the on-chain verify is replay-guarded per symbol), `buildUpdateCalls`.
141
269
  2. **Register it** in `src/oracle/rule-registry.ts` (`DEFAULT_RULES`) under a new `OracleSource` value — added to `ORACLE_SOURCES` in `price-update-rule.ts` (the union derives from that list; a registry test pins every listed value to a registered rule).
142
270
  3. **Publish the on-chain rule package** — its config entry (package ids, per-ticker `feeds`) arrives via the normal `waterx-config` deploy pipeline; type it in `OraclePackages` (`src/oracle/config.ts`).
143
271
  4. **Add SDK infra constants** if the source needs external infra that is not part of the config JSON (API endpoints, verifier packages, state objects) — a **rule-owned** per-network table inside the rule's own file, mirroring `LAZER_INFRA` / `WATERX_INFRA` (never on the shared client, never in `oracle/config.ts`). Wire its read-plane served-set/ids into `resolveOracleReadPlan` (`src/oracle/read-plane.ts`).
144
- 5. **Consumers flip `oracleSource`** per environmentno consumer code change, no SDK re-release.
272
+ 5. **Publish the block in the config** for the deployments that should feed it every client on that config picks it up. No consumer code change, no env edit, no SDK re-release.
273
+
274
+ The in-house `waterx_rule` (ed25519 enclave-signed CEX prices, `src/oracle/rules/waterx-rule.ts`) took exactly this path: it pulls one signed Merkle **leaf** per requested ticker from the quote-center (`GET /v1/quotes/leaves?symbols=…`, public read — no auth), then verifies **and** feeds in a single `waterx_rule::collect_single_with_proof` call per collector, so it emits no shared verify step. Each leaf carries its own membership proof and the enclave's signature over the snapshot root, so a PTB rebuilds exactly ONE price item however wide the snapshot was. Against a quote-center with no leaf route (404) it falls back to the older shape — one signature over a whole batch (`GET /v1/quotes/update`) fed through `collect_batch_latest`, which is indivisible and therefore has to rebuild _every_ item in the batch in-PTB just to use one symbol's price.
145
275
 
146
- The in-house `waterx_rule` (ed25519 enclave-signed CEX prices, `src/oracle/rules/waterx-rule.ts`) took exactly this path: it pulls one enclave-signed batch envelope covering the requested tickers from the quote-center (`GET /v1/quotes/update?symbols=…`, public read — no auth), then verifies **and** feeds in a single `waterx_rule::collect_batch_latest` call per collector, so it emits no shared verify step. On-chain a **freshness** miss abstains (the other weighted rules cover); a config mismatch or bad signature aborts and so does a **replayed** signed timestamp (`EReplayedSignature`, audit F-014: a signed tuple is single-use per symbol, weight-independent). Consequence: two PTBs carrying the same envelope for the same symbol cannot both land never share one fetched envelope across concurrent builds for the same symbol.
276
+ On-chain, both entries dispose of failures identically: a **freshness** miss abstains (the other weighted rules cover), and so does a **replayed** signed timestamp (the per-symbol high-water mark of audit F-014 already recorded means the chain already holds a price at least this fresh, so concurrent builds sharing one snapshot no longer kill each other; only the single-rule `feed_*` entries abort on a replay). A config mismatch, a bad signature, or a signed timestamp **ahead of the on-chain `Clock`** aborts.
147
277
 
148
278
  > **Browser consumers:** this source fetches the quote-center directly from the page, so the quote-center deployment must return `Access-Control-Allow-Origin` for the app's origin. For an origin that is not on that allowlist, point the SDK at your own proxy instead of the default host — the endpoint and the transport are both overridable at client init:
149
279
  >
150
280
  > ```ts
151
281
  > const perp = await PerpClient.create(network, {
152
282
  > waterxConfigUrl,
153
- > oracleSource: "waterx_rule",
154
283
  > // absolute URL on your own origin; its base path is PRESERVED, so this
155
- > // fetches https://app.example/api/quote-center/v1/quotes/update
284
+ > // fetches https://app.example/api/quote-center/v1/quotes/leaves
156
285
  > waterxEndpoint: "https://app.example/api/quote-center",
157
286
  > waterxFetch: { fetchImpl: myFetch, timeoutMs: 8_000 }, // optional custom transport / policy
158
287
  > });
159
288
  > ```
160
289
  >
161
- > Unset, `waterxEndpoint` falls back to the rule-owned `WATERX_INFRA[network]` and `waterxFetch` to the built-in policy (15s timeout, 2 retries) — there is deliberately no `pythFetch` fallback. Both are inert under the Pyth sources. They are also top-level options on the umbrella `WaterXClient.create({ oracleSource, waterxEndpoint, waterxFetch, … })`, which forwards them to the perp line. Node/keeper consumers are unaffected by CORS either way.
290
+ > Unset, `waterxEndpoint` falls back to the rule-owned `WATERX_INFRA[network]` and `waterxFetch` to the built-in policy (15s timeout, 2 retries) — there is deliberately no `pythFetch` fallback. Both are inert under the Pyth sources. They are also top-level options on the umbrella `WaterXClient.create({ waterxEndpoint, waterxFetch, … })`, which forwards them to the perp line. Node/keeper consumers are unaffected by CORS either way.
162
291
 
163
292
  ## Recipes & full surface
164
293
 
165
294
  To avoid doc drift, per-action usage lives in maintained, lint-checked code rather than this README:
166
295
 
296
+ - **Start here:** [`examples/quickstart.ts`](./examples/quickstart.ts) — the [First integration](#first-integration) walkthrough as one runnable file.
167
297
  - **Perp recipes:** [`examples/`](./examples) — ~30 runnable scripts (place orders, WLP mint/redeem, account/delegates, reads). Each uses `buildClient()` + a builder + `simThenMaybeExecute`.
168
298
  - **Prediction recipes:** [`test/prediction/e2e/`](./test/prediction/e2e) — the live reference for `client.predict.*` flows.
169
299
  - **Authoritative export list:** [`src/perp/index.ts`](./src/perp/index.ts) (perp) and [`src/prediction/index.ts`](./src/prediction/index.ts) — clients, builders, view helpers, BCS types, and `*Calls` generated namespaces. The package root (`.`) is [`src/sdk.ts`](./src/sdk.ts) (umbrella + flat-perp re-export); the shared base is published at `@waterx/sdk/account` and `@waterx/sdk/oracle`.
170
300
 
171
- Perp `build*Tx` helpers are oracle-backed (`async`; they refresh prices before the call) — through whichever source `oracleSource` selects, not Pyth specifically. The oracle layer (sources, rules, refresh) lives in [`src/oracle/`](./src/oracle).
301
+ Perp `build*Tx` helpers are oracle-backed (`async`; they refresh prices before the call) — through whichever sources the deployment config wires, not Pyth specifically. The oracle layer (sources, rules, refresh) lives in [`src/oracle/`](./src/oracle).
302
+
303
+ ## Troubleshooting
304
+
305
+ Every row below is a message the SDK or the chain actually emits. Simulate first — all of
306
+ these surface at simulate, before you spend gas.
307
+
308
+ | Message | What it means, and what to do |
309
+ | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
310
+ | `loadConfig: no config URL — pass opts.waterxConfigUrl` | `waterxConfigUrl` is unset; there is no default and no env fallback. Read the URL in your app and pass it to `create()`. |
311
+ | `this deployment's config wires no price-update source …` | The loaded config publishes no `pyth_lazer_rule` / `waterx_rule` block with feeds, so nothing could ever be priced. Raised at client creation. Check you loaded the config you meant to. |
312
+ | `fed set [...] has no feed for ticker(s): …` (`OracleTickerUnservedError`) | No derived source serves a ticker this build depends on. `refreshOraclePrices` itself SKIPS such tickers; the `build*Tx` composers raise this for the ones their action needs (traded ticker + collateral, or every pool asset for WLP). Add the feed, or pass `allowUnrefreshedPrices: true` to build anyway. Constant-only tickers are exempt. |
313
+ | `EMissingPriceSource` (Move abort in `aggregator::remove_outliers`) | The fed set does not cover that ticker's on-chain weighted rules — starving a weighted rule aborts, feeding an unweighted one is a no-op. Run `pnpm oracle:aggregates:testnet`, then publish the missing source's feeds in the config so it is derived. |
314
+ | `LazerApiKeyMissing: pyth_lazer_rule requires a Pyth Lazer access token` | The config wires `pyth_lazer_rule` but `pythApiKey` was not passed. The SDK never reads `process.env` for it — pass it at client creation. |
315
+ | `EAccountNotFound` (Move abort in `account::borrow_account`) | The `accountId` does not exist on this network — usually a fixture from another deployment, or a Sui address used where a wxa account id belongs. Create one with `client.account.createAccount`. |
316
+ | `EReplayedSignature` | A `waterx_rule` signed timestamp was replayed on a **single-rule `feed_*`** entry, where it aborts. The `build*Tx` composers do NOT use those — they feed through `collect_*`, where a replay ABSTAINS (audit F-014's high-water mark means the chain already holds a price at least that fresh). If you see this, a custom PTB is calling a `feed_*` leg directly. |
317
+ | CORS failure fetching the quote-center (browser only) | `waterx_rule` fetches from the page and your origin is not on the allowlist. Point `waterxEndpoint` at a same-origin proxy; its base path is preserved. Node and keeper consumers are unaffected. |
318
+ | Ticker lookups return nothing | Wrong format. Tickers are concatenated — `BTCUSD`, never `BTC/USD` or `BTC`. Canonical list: the config JSON's `markets` keys. |
319
+ | Prices off by 10⁹, or an order fills far from the intended level | A human-readable number was passed where a raw 1e9-scaled `u64` belongs. Wrap in `rawPrice()`. Exception: view `basePriceUsd` args take a **whole-dollar** u64 — use `parseWholeDollarU64`. |
320
+ | A ticker prices on one network but not another | The two networks wire **different sources**, and the fed set follows the config — mainnet derives `[pyth_lazer_rule, waterx_rule]`, testnet `[waterx_rule]`. This is the drift a hand-declared list used to cause and derivation removes. Confirm per network with `pnpm oracle:aggregates:mainnet`. |
321
+
322
+ ## Documentation map
323
+
324
+ | Document | What it answers |
325
+ | ------------------------------------------------------------------ | -------------------------------------------------------------------------- |
326
+ | [`SKILLS.md`](./SKILLS.md) | The fixed integration flow, for an agent or a developer |
327
+ | [`examples/README.md`](./examples/README.md) | Every runnable perp recipe, one file per entry point |
328
+ | [`CHANGELOG.md`](./CHANGELOG.md) | What changed per release — **read before upgrading** (see versioning note) |
329
+ | [`PACKAGES.md`](./PACKAGES.md) | The Move packages behind the SDK |
330
+ | [`CLAUDE.md`](./CLAUDE.md) | Architecture and contract surface, for people hacking on the SDK |
331
+ | [`test/perp/README.md`](./test/perp/README.md) | Perp test tiers, fixtures, and known skips |
332
+ | [`test/prediction/README.md`](./test/prediction/README.md) | Prediction test tiers and the live `client.predict.*` reference |
333
+ | [`waterx-config`](https://github.com/WaterXProtocol/waterx-config) | The canonical deployment JSON schema |
172
334
 
173
335
  ## Development
174
336
 
175
- | Command | Use |
176
- | ------------------------------ | ---------------------------------------------------------- |
177
- | `pnpm typecheck` | Typecheck the whole tree |
178
- | `pnpm test` / `pnpm test:unit` | Unit tests (perp + prediction) |
179
- | `pnpm test:e2e` | Testnet simulate e2e (perp + prediction) |
180
- | `pnpm test:integration` | On-chain integration (needs `SUI_PRIVATE_KEY`; local-only) |
181
- | `pnpm lint` / `pnpm format` | ESLint + Prettier |
182
- | `pnpm codegen` | Regenerate `src/generated` from Move |
183
- | `pnpm seed:testnet` | Seed prediction testnet fixtures (needs `SUI_PRIVATE_KEY`) |
337
+ Working on the SDK itself (rather than consuming it):
338
+
339
+ ```bash
340
+ pnpm install
341
+ pnpm build
342
+ ```
343
+
344
+ | Command | Use |
345
+ | -------------------------------- | ------------------------------------------------------------------------ |
346
+ | `pnpm typecheck` | Typecheck the whole tree |
347
+ | `pnpm docs:check` | Resolve every relative link in the docs |
348
+ | `pnpm test` / `pnpm test:unit` | Unit tests (perp + prediction) |
349
+ | `pnpm test:e2e` | Testnet simulate e2e (perp + prediction) |
350
+ | `pnpm test:integration` | On-chain integration (needs `SUI_PRIVATE_KEY`; local-only) |
351
+ | `pnpm lint` / `pnpm format` | ESLint + Prettier |
352
+ | `pnpm codegen` | Regenerate `src/generated` from Move |
353
+ | `pnpm oracle:aggregates:testnet` | Per-ticker aggregator sources + weights (diagnose `EMissingPriceSource`) |
354
+ | `pnpm seed:testnet` | Seed prediction testnet fixtures (needs `SUI_PRIVATE_KEY`) |
184
355
 
185
356
  Tests are split per line under `test/perp/` and `test/prediction/`, each with `unit` / `e2e` / `integration` tiers. See the per-line `README.md` in each.