@waterx/sdk 4.3.2 → 4.3.3

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.
@@ -0,0 +1,219 @@
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 and oracleSource options, 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 two required options
37
+
38
+ ```ts
39
+ import { WaterXClient } from "@waterx/sdk";
40
+ import { parseOracleSourceList } from "@waterx/sdk/oracle";
41
+
42
+ const client = await WaterXClient.create({
43
+ network: "TESTNET",
44
+ waterxConfigUrl: process.env.WATERX_CONFIG_URL, // REQUIRED — no default, no env fallback
45
+ oracleSource: parseOracleSourceList(process.env.ORACLE_SOURCE), // REQUIRED — no default
46
+ pythApiKey: process.env.PYTH_API_KEY, // required iff the set includes 'pyth_lazer_rule'
47
+ });
48
+ ```
49
+
50
+ **`waterxConfigUrl`** points at the canonical
51
+ [`waterx-config`](https://github.com/WaterXProtocol/waterx-config) JSON. It is fetched
52
+ as-is — the SDK appends no `<network>.json` and no git ref. Your app reads the env var;
53
+ the SDK never does. Look up ids through the client (`client.perp.getMarket(ticker)`,
54
+ `client.perp.creditType()`, `client.perp.wlpType()`) rather than hardcoding them.
55
+
56
+ **`oracleSource`** is one source or a **list — the fed set**. Every listed source's data
57
+ is fetched and fed in one PTB, and the chain's per-ticker weight tables arbitrate.
58
+
59
+ | Source | Notes |
60
+ | ----------------- | ------------------------------------------------------------------------ |
61
+ | `pyth_rule` | Pyth Core; per-feed update fees; no credential |
62
+ | `pyth_lazer_rule` | one signed verify per PTB, no per-feed fees; **requires `pythApiKey`** |
63
+ | `waterx_rule` | first-party TEE quote-center; no credential; browser needs a CORS-allowed origin |
64
+
65
+ The rule that decides the value: **the fed set must be a superset of every ticker's
66
+ on-chain weighted rules.** Starving a weighted rule aborts `EMissingPriceSource`; feeding
67
+ an unweighted one is silently dropped. That asymmetry is what makes a weight migration an
68
+ env edit rather than an SDK release.
69
+
70
+ Never guess the set — read it off chain:
71
+
72
+ ```bash
73
+ pnpm oracle:aggregates:testnet # per-ticker aggregator sources + weights
74
+ ```
75
+
76
+ Parse env with `parseOracleSourceList` (it trims, drops empties, validates, dedupes, and
77
+ throws actionably), never a bare `split(",")`.
78
+
79
+ ## Step 3 — Ensure a wxa account
80
+
81
+ Every trading call needs a `waterx_account` account id. One account serves both lines.
82
+ It is **not** a Sui address — it is an object id returned by `create_account` and emitted
83
+ in the `AccountCreated` event.
84
+
85
+ ```ts
86
+ import { Transaction } from "@mysten/sui/transactions";
87
+ import { AccountCreated } from "@waterx/sdk/generated/waterx_account/events";
88
+
89
+ const tx = new Transaction();
90
+ client.account.createAccount(tx, { alias: "alice" });
91
+ const exec = await client.perp.signAndExecuteTransaction({ transaction: tx, signer });
92
+ const digest = exec.Transaction?.digest ?? "";
93
+
94
+ // The id is NOT a builder return value — read it back off the digest. Decode the
95
+ // event's `bcs`, never its `json`: only the BCS layout is the Move struct itself.
96
+ // (`as const` is load-bearing — it is what types `events` as present.)
97
+ const res = await client.perp.grpcClient.getTransaction({
98
+ digest,
99
+ include: { events: true } as const,
100
+ });
101
+ const ev = res.Transaction?.events?.find((e) => e.eventType.endsWith("::events::AccountCreated"));
102
+ const accountId = ev ? AccountCreated.parse(ev.bcs).account_object_address : undefined; // persist
103
+ ```
104
+
105
+ A **simulate emits the same `AccountCreated` event but creates nothing** — the address
106
+ in a dry run's events is not on chain and reusing it aborts `EAccountNotFound`. Only read
107
+ an id back after a real execute. Runnable version:
108
+ `accountIdFromDigest` in `examples/_shared.ts`.
109
+
110
+ ## Step 4 — Fund it
111
+
112
+ Collateral must sit **inside** the account. Depositing is two calls in one PTB:
113
+ `requestDeposit(coin)` returns a `DepositRequest` hot potato, then
114
+ `direct_rule::consume_deposit_direct(req)` finalizes it. Leaving out the second call is a
115
+ build error, not a silent no-op.
116
+
117
+ Cross-chain CREDIT (`credit.ts`) and the native PSM (`custody.ts`) are the other funding
118
+ routes.
119
+
120
+ ## Step 5 — Build → simulate → execute
121
+
122
+ Builders are **build-only**: they return or mutate a `Transaction`. Signing stays with
123
+ you — a keypair, or a browser wallet.
124
+
125
+ ```ts
126
+ import { rawPrice } from "@waterx/sdk/perp";
127
+
128
+ const tx = await client.perp.buildPlaceOrderTx({
129
+ ticker: "BTCUSD",
130
+ collateralType: client.perp.creditType(),
131
+ accountId,
132
+ main: {
133
+ isLong: true,
134
+ isStopOrder: false,
135
+ reduceOnly: false,
136
+ size: rawPrice(0.001),
137
+ triggerPrice: undefined, // omit ⇒ market order
138
+ acceptablePrice: rawPrice(120_000), // slippage cap
139
+ collateralAmount: 5_000_000n,
140
+ },
141
+ preOrders: [], // optional reduce-only TP/SL legs
142
+ });
143
+
144
+ tx.setSender(address);
145
+ await client.perp.simulate(tx); // ALWAYS. Free, and catches every step-2 mistake.
146
+ await client.perp.signAndExecuteTransaction({ transaction: tx, signer });
147
+ ```
148
+
149
+ `build*Tx` helpers are **async** because they prepend the oracle refresh legs. The
150
+ low-level `*Request` builders are sync and do not refresh — pair them with
151
+ `executeTrading` in the same PTB if you compose by hand.
152
+
153
+ There is no `open_position_request`. A market order is a limit order with
154
+ `triggerPrice: undefined` and a non-zero `acceptablePrice`; a keeper fills it.
155
+
156
+ ## Step 6 — Read state
157
+
158
+ Reads are `simulateTransaction` + BCS decode: no signer, no gas, zero-address sender.
159
+
160
+ ```ts
161
+ const positions = await client.perp.getAccountPositions({
162
+ ticker: "BTCUSD",
163
+ accountObjectAddress: accountId,
164
+ basePriceUsd: 0n,
165
+ });
166
+ ```
167
+
168
+ Returned structs keep their **snake_case** Move field names
169
+ (`account_object_address`, `create_timestamp`) — use them as-is.
170
+
171
+ ## Red flags
172
+
173
+ Stop if you catch yourself doing any of these:
174
+
175
+ - **Hardcoding an object id.** It belongs in the config JSON, read via the client.
176
+ - **Writing `BTC/USD` or `BTC`.** Tickers are concatenated: `BTCUSD`, `ETHUSD`, `SUIUSD`.
177
+ (Collateral *tokens* keep a plain symbol — `USDC` — and are a different thing.)
178
+ - **Passing a plain number as a price or size.** Wrap in `rawPrice()`. The exception:
179
+ view `basePriceUsd` arguments take a whole-dollar u64 — `parseWholeDollarU64`.
180
+ - **Skipping simulate.** Every failure in the table below is free to find at simulate.
181
+ - **Reusing one `waterx_rule` envelope across concurrent builds for the same symbol.**
182
+ A signed timestamp is single-use per symbol and the second one aborts
183
+ `EReplayedSignature` — weight-independent (audit F-014). Fetch per build.
184
+ - **Expecting a fallback between oracle sources.** There is none. Sources are
185
+ self-contained; an absent feed fails at tx-build.
186
+ - **Reaching for `process.env` inside SDK calls.** Read env at your app's boundary and
187
+ pass values in.
188
+ - **Assuming SemVer.** This package may ship a breaking change in a patch. Pin exact and
189
+ read the CHANGELOG before upgrading.
190
+
191
+ ## Aborts and errors
192
+
193
+ Which step a failure sends you back to. The **full messages, causes, and fixes live in
194
+ one place** — `README.md`'s Troubleshooting table — so that they stay accurate; do not
195
+ re-derive them from here.
196
+
197
+ | Error | Go back to |
198
+ | ----- | ---------- |
199
+ | `loadConfig: no config URL …` | Step 2 — `waterxConfigUrl` |
200
+ | `oracleSource is REQUIRED …` / `… has no feed configured for ticker(s)` | Step 2 — `oracleSource` |
201
+ | `EMissingPriceSource` | Step 2 — the fed set is too narrow for that ticker |
202
+ | `LazerApiKeyMissing …` | Step 2 — `pythApiKey` |
203
+ | `EAccountNotFound` | Step 3 — the account id is not on this network |
204
+ | `EReplayedSignature` | Step 5 — an envelope was reused across builds |
205
+
206
+ ## Verifying an integration
207
+
208
+ 1. `client.perp.simulate(tx)` returns without `FailedTransaction` for one order build.
209
+ 2. A read path returns real rows for a funded account.
210
+ 3. The fed set covers every ticker you trade — cross-check `pnpm oracle:aggregates`.
211
+ 4. Only then execute, and confirm the digest.
212
+
213
+ ## Reference
214
+
215
+ - Runnable walkthrough: `examples/quickstart.ts`
216
+ - Every perp recipe, one file per entry point: `examples/`
217
+ - Prediction reference flows: `test/prediction/e2e/`
218
+ - Authoritative export lists: `src/perp/index.ts`, `src/prediction/index.ts`
219
+ - Architecture and contract surface: `CLAUDE.md`
package/README.md CHANGED
@@ -17,7 +17,8 @@ const client = await WaterXClient.create({
17
17
  network: "TESTNET",
18
18
  waterxConfigUrl:
19
19
  "https://raw.githubusercontent.com/WaterXProtocol/waterx-config/main/testnet.json",
20
- oracleSource: "pyth_rule",
20
+ oracleSource: ["pyth_rule", "pyth_lazer_rule"],
21
+ pythApiKey: process.env.PYTH_API_KEY, // required iff 'pyth_lazer_rule' is listed
21
22
  });
22
23
  client.account.createAccount(tx, { alias }); // shared waterx_account + funding (credit/custody)
23
24
  client.perp.buildPlaceOrderTx(params); // perpetuals
@@ -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,20 @@ 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
+ // REQUIRED — single source or a list (the fed set). The set must COVER each
72
+ // ticker's on-chain weighted rules; testnet majors need both of these today.
73
+ // Verify with `pnpm oracle:aggregates:testnet`. See "Oracle sources".
74
+ oracleSource: ["pyth_rule", "pyth_lazer_rule"],
75
+ pythApiKey: process.env.PYTH_API_KEY, // required iff 'pyth_lazer_rule' is listed
62
76
  });
63
77
  const signer = /* your Ed25519Keypair or wallet Signer */;
78
+ const accountId = "0x..."; // wxa account object id — see "First integration"
64
79
 
65
80
  // --- Perp: place a market order ---
66
81
  const tx = await client.perp.buildPlaceOrderTx({
67
82
  ticker: "BTCUSD",
68
83
  collateralType: client.perp.creditType(),
69
- accountId: "0x...", // UserAccount object id (hex)
84
+ accountId,
70
85
  main: {
71
86
  isLong: true,
72
87
  isStopOrder: false,
@@ -80,13 +95,116 @@ const tx = await client.perp.buildPlaceOrderTx({
80
95
  await client.perp.signAndExecuteTransaction({ transaction: tx, signer });
81
96
 
82
97
  // --- Prediction: same pattern under client.predict ---
98
+ // Preconditions: `accountId` is a wxa account registered with the prediction
99
+ // protocol and holding settlement collateral; `marketId` is an OPEN market.
100
+ // Object ids (globalConfig / marketRegistry / accountRegistry / settlement coin
101
+ // type) are resolved from config — pass them only to override.
83
102
  const ptx = new Transaction();
84
- client.predict.placeOrder(ptx, params);
103
+ client.predict.placeOrder(ptx, {
104
+ accountId, // payer; `receiverAccountId` defaults to this
105
+ marketId: "0x...", // market id bytes or 0x-hex
106
+ selection: "YES", // "YES" | "NO"
107
+ maxSpend: 1_000_000n, // cap in settlement-coin base units
108
+ minShares: 1n, // fill floor — chain asserts filled_shares >= this; 0 accepts any fill
109
+ priceCapBps: 5_000n, // max price per share, bps of the 1-unit payout; MUST be <= 10_000
110
+ expiryTs: BigInt(Date.now() + 60_000), // ms epoch
111
+ });
85
112
  await client.predict.signAndExecuteTransaction({ transaction: ptx, signer });
86
113
  ```
87
114
 
88
115
  > 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
116
 
117
+ ## First integration
118
+
119
+ The quickstart above starts from an `accountId` you already have. If you have none yet,
120
+ this is the whole arc. **[`examples/quickstart.ts`](./examples/quickstart.ts) is this
121
+ walkthrough as one runnable file** — being real code, it is covered by `pnpm lint` and
122
+ `pnpm typecheck`, so the API it exercises cannot go stale unnoticed:
123
+
124
+ ```bash
125
+ export WATERX_CONFIG_URL=https://raw.githubusercontent.com/WaterXProtocol/waterx-config/main/testnet.json
126
+ export ORACLE_SOURCE=pyth_rule,pyth_lazer_rule # step 2 — must cover the ticker's weighted rules
127
+ export PYTH_API_KEY=... # required whenever pyth_lazer_rule is listed
128
+ pnpm exec tsx examples/quickstart.ts # simulate-only; WATERX_EXECUTE=1 to sign + send
129
+ ```
130
+
131
+ **1 — Get a config URL.** Every chain-specific id comes from the canonical
132
+ [`waterx-config`](https://github.com/WaterXProtocol/waterx-config) JSON. There is no
133
+ built-in default and the SDK never reads `process.env`: your app reads the URL and passes
134
+ it in. Hardcoding object ids instead is the single most common integration mistake.
135
+
136
+ **2 — Pick your oracle source(s).** `oracleSource` is required, and the fed set must
137
+ **cover every ticker's on-chain weighted rules** — starving a weighted rule aborts
138
+ `EMissingPriceSource` at simulate. Check what a network actually weights before you wire
139
+ it up:
140
+
141
+ ```bash
142
+ pnpm oracle:aggregates:testnet # per-ticker aggregator sources + weights
143
+ ```
144
+
145
+ Weights are on-chain state that changes without an SDK release, so read them rather than
146
+ trusting any list written here. [Oracle sources](#oracle-sources) has the full model.
147
+
148
+ **3 — Create a wxa account.** One account serves both product lines; every trading call
149
+ needs one. The id is **not** a builder return value — it lands in the `AccountCreated`
150
+ event, so read it back off the digest, then treat it as the user's durable handle. (A
151
+ simulate emits the same event but creates nothing; only read an id back after a real
152
+ execute.)
153
+
154
+ ```ts
155
+ import { Transaction } from "@mysten/sui/transactions";
156
+ import { AccountCreated } from "@waterx/sdk/generated/waterx_account/events";
157
+
158
+ const tx = new Transaction();
159
+ client.account.createAccount(tx, { alias: "alice" });
160
+ const exec = await client.perp.signAndExecuteTransaction({ transaction: tx, signer });
161
+ const digest = exec.Transaction?.digest ?? "";
162
+
163
+ // Decode the event's `bcs`, never its `json` — only the BCS layout is the Move
164
+ // struct. (`as const` is load-bearing: it is what types `events` as present.)
165
+ const res = await client.perp.grpcClient.getTransaction({
166
+ digest,
167
+ include: { events: true } as const,
168
+ });
169
+ const ev = res.Transaction?.events?.find((e) => e.eventType.endsWith("::events::AccountCreated"));
170
+ const accountId = ev ? AccountCreated.parse(ev.bcs).account_object_address : undefined;
171
+ ```
172
+
173
+ → [`examples/actions/action-create-account.ts`](./examples/actions/action-create-account.ts)
174
+
175
+ **4 — Fund it.** Collateral must sit *inside* the account before an order will fill.
176
+ Deposit is two calls in one PTB — `requestDeposit(coin)` then
177
+ `direct_rule::consume_deposit_direct(req)`.
178
+
179
+ → [`examples/actions/action-request-deposit.ts`](./examples/actions/action-request-deposit.ts)
180
+ · cross-chain CREDIT and the native PSM are in
181
+ [`src/account/funding/credit.ts`](./src/account/funding/credit.ts) and
182
+ [`src/account/funding/custody.ts`](./src/account/funding/custody.ts)
183
+
184
+ **5 — Build, simulate, then execute.** Builders are **build-only**: they return or mutate
185
+ a `Transaction` and never sign. Always simulate first — that is where a bad fed set, an
186
+ unfunded account, or a stale id surfaces, for free.
187
+
188
+ ```ts
189
+ const tx = await client.perp.buildPlaceOrderTx({ ... }); // async: prepends oracle legs
190
+ tx.setSender(address);
191
+ const result = await client.perp.simulate(tx); // no signer, no gas
192
+ await client.perp.signAndExecuteTransaction({ transaction: tx, signer });
193
+ ```
194
+
195
+ **6 — Read state back.** Reads are `simulateTransaction` + BCS decode — no signer, no gas,
196
+ zero-address sender.
197
+
198
+ ```ts
199
+ const positions = await client.perp.getAccountPositions({
200
+ ticker: "BTCUSD",
201
+ accountObjectAddress: accountId,
202
+ basePriceUsd: 0n, // WHOLE-DOLLAR u64 (not rawPrice); 0n zero-bases the PnL fields
203
+ });
204
+ ```
205
+
206
+ → [`examples/views/`](./examples/views) for every read path
207
+
90
208
  ## Per-line clients
91
209
 
92
210
  If you only need one line, construct it directly (both factories are **async** — they fetch deployment config; `waterxConfigUrl` is **required**):
@@ -97,7 +215,12 @@ import { PredictClient } from "@waterx/sdk/prediction";
97
215
 
98
216
  const waterxConfigUrl =
99
217
  "https://raw.githubusercontent.com/WaterXProtocol/waterx-config/main/testnet.json";
100
- const perp = await PerpClient.create("TESTNET", { waterxConfigUrl, oracleSource: "pyth_rule" }); // or PerpClient.testnet({ ... })
218
+ // oracleSource must cover each ticker's weighted rules see "Oracle sources".
219
+ const perp = await PerpClient.create("TESTNET", {
220
+ waterxConfigUrl,
221
+ oracleSource: ["pyth_rule", "pyth_lazer_rule"],
222
+ pythApiKey: process.env.PYTH_API_KEY, // required iff 'pyth_lazer_rule' is listed
223
+ }); // or PerpClient.testnet({ ... })
101
224
  const predict = await PredictClient.create("TESTNET", { waterxConfigUrl }); // predict line needs no oracle source
102
225
  ```
103
226
 
@@ -143,7 +266,9 @@ Every source plugs in the same way — routing is driven **only** by the client'
143
266
  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
267
  5. **Consumers flip `oracleSource`** per environment — no consumer code change, no SDK re-release.
145
268
 
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.
269
+ 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.
270
+
271
+ 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
272
 
148
273
  > **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
274
  >
@@ -152,7 +277,7 @@ The in-house `waterx_rule` (ed25519 enclave-signed CEX prices, `src/oracle/rules
152
277
  > waterxConfigUrl,
153
278
  > oracleSource: "waterx_rule",
154
279
  > // absolute URL on your own origin; its base path is PRESERVED, so this
155
- > // fetches https://app.example/api/quote-center/v1/quotes/update
280
+ > // fetches https://app.example/api/quote-center/v1/quotes/leaves
156
281
  > waterxEndpoint: "https://app.example/api/quote-center",
157
282
  > waterxFetch: { fetchImpl: myFetch, timeoutMs: 8_000 }, // optional custom transport / policy
158
283
  > });
@@ -164,22 +289,64 @@ The in-house `waterx_rule` (ed25519 enclave-signed CEX prices, `src/oracle/rules
164
289
 
165
290
  To avoid doc drift, per-action usage lives in maintained, lint-checked code rather than this README:
166
291
 
292
+ - **Start here:** [`examples/quickstart.ts`](./examples/quickstart.ts) — the [First integration](#first-integration) walkthrough as one runnable file.
167
293
  - **Perp recipes:** [`examples/`](./examples) — ~30 runnable scripts (place orders, WLP mint/redeem, account/delegates, reads). Each uses `buildClient()` + a builder + `simThenMaybeExecute`.
168
294
  - **Prediction recipes:** [`test/prediction/e2e/`](./test/prediction/e2e) — the live reference for `client.predict.*` flows.
169
295
  - **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
296
 
171
297
  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).
172
298
 
299
+ ## Troubleshooting
300
+
301
+ Every row below is a message the SDK or the chain actually emits. Simulate first — all of
302
+ these surface at simulate, before you spend gas.
303
+
304
+ | Message | What it means, and what to do |
305
+ | ------- | ------------------------------ |
306
+ | `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()`. |
307
+ | `oracleSource is REQUIRED and must name at least one of …` | Missing, empty, or a retired value (`'core'`, `'pyth'`). Parse env with `parseOracleSourceList`, not a bare `split`. |
308
+ | `oracleSource [...] has no feed configured for ticker(s): …` | No listed source serves that ticker in this deployment. Raised at **tx-build**, not at client creation. Add the feed under a listed source, or list a source that serves it. Constant-only tickers are exempt. |
309
+ | `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` and widen `oracleSource` to a superset. |
310
+ | `LazerApiKeyMissing: pyth_lazer_rule requires a Pyth Lazer access token` | `'pyth_lazer_rule'` is listed but `pythApiKey` was not passed. The SDK never reads `process.env` for it — pass it at client creation. |
311
+ | `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`. |
312
+ | `EReplayedSignature` | One `waterx_rule` envelope was fed twice for the same symbol; a signed timestamp is single-use per symbol (audit F-014). Fetch per build — never share one across concurrent builds. |
313
+ | 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. |
314
+ | Ticker lookups return nothing | Wrong format. Tickers are concatenated — `BTCUSD`, never `BTC/USD` or `BTC`. Canonical list: the config JSON's `markets` keys. |
315
+ | 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`. |
316
+ | `… has no feed configured for ticker(s)` on `MAINNET` with a fed set copied from testnet | The two networks configure **different sources**. Today's `mainnet.json` carries `pyth_rule` (plus `constant_rule` for `USDCUSD`) and **no** `pyth_lazer_rule` / `waterx_rule` block, so an `ORACLE_SOURCE` naming only those serves nothing there. Listing an unconfigured source *alongside* `pyth_rule` is harmless — it contributes no tickers. Confirm per network with `pnpm oracle:aggregates:mainnet`. |
317
+
318
+ ## Documentation map
319
+
320
+ | Document | What it answers |
321
+ | ------------------------------------------------------------ | -------------------------------------------------------------------------- |
322
+ | [`SKILLS.md`](./SKILLS.md) | The fixed integration flow, for an agent or a developer |
323
+ | [`examples/README.md`](./examples/README.md) | Every runnable perp recipe, one file per entry point |
324
+ | [`CHANGELOG.md`](./CHANGELOG.md) | What changed per release — **read before upgrading** (see versioning note) |
325
+ | [`PACKAGES.md`](./PACKAGES.md) | The Move packages behind the SDK |
326
+ | [`CLAUDE.md`](./CLAUDE.md) | Architecture and contract surface, for people hacking on the SDK |
327
+ | [`test/perp/README.md`](./test/perp/README.md) | Perp test tiers, fixtures, and known skips |
328
+ | [`test/prediction/README.md`](./test/prediction/README.md) | Prediction test tiers and the live `client.predict.*` reference |
329
+ | [`waterx-config`](https://github.com/WaterXProtocol/waterx-config) | The canonical deployment JSON schema |
330
+
173
331
  ## Development
174
332
 
333
+ Working on the SDK itself (rather than consuming it):
334
+
335
+ ```bash
336
+ pnpm install
337
+ pnpm build
338
+ ```
339
+
175
340
  | Command | Use |
176
341
  | ------------------------------ | ---------------------------------------------------------- |
177
342
  | `pnpm typecheck` | Typecheck the whole tree |
343
+ | `pnpm docs:check` | Resolve every relative link in the docs |
178
344
  | `pnpm test` / `pnpm test:unit` | Unit tests (perp + prediction) |
179
345
  | `pnpm test:e2e` | Testnet simulate e2e (perp + prediction) |
180
346
  | `pnpm test:integration` | On-chain integration (needs `SUI_PRIVATE_KEY`; local-only) |
181
347
  | `pnpm lint` / `pnpm format` | ESLint + Prettier |
182
348
  | `pnpm codegen` | Regenerate `src/generated` from Move |
349
+ | `pnpm oracle:aggregates:testnet` | Per-ticker aggregator sources + weights (diagnose `EMissingPriceSource`) |
183
350
  | `pnpm seed:testnet` | Seed prediction testnet fixtures (needs `SUI_PRIVATE_KEY`) |
184
351
 
185
352
  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.
package/SKILLS.md ADDED
@@ -0,0 +1,34 @@
1
+ # Skills
2
+
3
+ Agent Skills shipped with `@waterx/sdk`. A skill is a written procedure an AI coding agent
4
+ loads on demand — the same flow a developer would follow, in a form an agent can execute
5
+ without being re-taught it each session.
6
+
7
+ [**`waterx-sdk-integration`**](./.claude/skills/waterx-sdk-integration/SKILL.md) is the one
8
+ skill here: it covers integrating the SDK into an app, keeper, or bot. Its frontmatter
9
+ `description` states exactly when it triggers.
10
+
11
+ ## Using it in this repo
12
+
13
+ Nothing to do. Claude Code discovers `.claude/skills/` automatically; ask for the skill by
14
+ name, or just describe an integration task and it loads.
15
+
16
+ ## Using it in your own repo
17
+
18
+ The skill ships inside the published package, so copy it out of `node_modules`:
19
+
20
+ ```bash
21
+ mkdir -p .claude/skills
22
+ cp -r node_modules/@waterx/sdk/.claude/skills/waterx-sdk-integration .claude/skills/
23
+ ```
24
+
25
+ Agents other than Claude Code can read the file directly — it is plain Markdown with a
26
+ YAML header, and nothing in it is Claude-specific.
27
+
28
+ Re-copy after upgrading the SDK; the skill tracks the API surface and changes with it.
29
+
30
+ ## For humans
31
+
32
+ The skill is readable on its own and doubles as an integration checklist. If you are
33
+ reading rather than delegating, [`README.md`](./README.md) covers the same ground with
34
+ more prose — start with [First integration](./README.md#first-integration).