@cypher-zk/sdk 0.3.0 → 0.5.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/README.md CHANGED
@@ -2,268 +2,699 @@
2
2
 
3
3
  > **TypeScript SDK for the [Cypher](https://cyphers.live) privacy-preserving prediction market on Solana**, powered by [Arcium](https://arcium.com) MPC.
4
4
 
5
- [![Tests](https://img.shields.io/badge/tests-107%20passing-brightgreen)]()
6
- [![License: Source Available](https://img.shields.io/badge/license-Source%20Available-orange.svg)]()
5
+ [![tests](https://img.shields.io/badge/tests-150%20passing-brightgreen)]()
6
+ [![typecheck](https://img.shields.io/badge/typecheck-clean-brightgreen)]()
7
+ [![bundle](https://img.shields.io/badge/dist-ESM%20%2B%20.d.ts-blue)]()
8
+ [![version](https://img.shields.io/badge/version-0.2.0-blue)]()
9
+ [![license](https://img.shields.io/badge/license-Source%20Available-orange.svg)](./LICENSE)
10
+
11
+ A framework-agnostic core (Node, Bun, browser) with an optional React
12
+ hooks subpath and end-to-end progress callbacks so frontends can render
13
+ fine-grained loading state across every multi-step on-chain flow.
14
+
15
+ > **v0.2 — dispute window.** Reveal callbacks now land the market in
16
+ > `PendingResolution`, not `Resolved`. A configurable 24h–48h challenge
17
+ > window lets anyone flag a wrong outcome before claims open. See
18
+ > [§ Dispute window (v0.2)](#dispute-window-v02) for the full flow and
19
+ > three new instructions / actions / hooks.
7
20
 
8
21
  ---
9
22
 
10
- ## Features
23
+ ## Why this SDK exists
11
24
 
12
- - **Full instruction coverage** builders for all 26 Anchor instructions
13
- - **High-level action helpers** — encrypt → send → await MPC callback refetch, in one call
14
- - **Typed event parsing** — 7 on-chain events decoded and narrowed via discriminated unions
15
- - **Real-time & poll-based subscriptions** — WebSocket `onLogs` + HTTP fallback
16
- - **Framework-agnostic core** — works in any TS environment (Node, Deno, Bun, browser)
17
- - **React hooks layer** — optional `@cypher/sdk/react` with React Query integration
18
- - **Cluster-aware** — reads `GlobalState.accepted_mint` on-chain (devnet=CSDC, mainnet=USDC)
19
- - **Deterministic test suite** — 107 unit tests, 581 assertions
25
+ Cypher is a Solana program that wraps prediction-market state in
26
+ [Arcium MPC](https://docs.arcium.com) so user bets stay encrypted on
27
+ chain. Talking to it directly involves wiring:
20
28
 
21
- ## Installation
29
+ - A typed Anchor `Program` against the program's IDL
30
+ - Per-flow Arcium queue accounts (cluster offset, mempool, comp def, …)
31
+ - `x25519` keypair generation + Rescue cipher encryption for each bet
32
+ - Polling the computation account until the MPC nodes finalize the
33
+ callback
34
+ - Refetching the position/market after the callback updates state
35
+
36
+ This SDK gives you a single `client.actions.placeBet({...})` call that
37
+ does all of that, with progress events for the UI and discriminated-
38
+ union typed events for the indexer.
39
+
40
+ ```ts
41
+ const result = await client.actions.placeBet({
42
+ payer: wallet.publicKey,
43
+ user: wallet.publicKey,
44
+ marketId: 7n,
45
+ side: 1, // 1 = YES
46
+ amountUsdc: 5_000_000n, // $5 (6 decimals)
47
+ onProgress: (e) => updateLoaderUI(e.stage, e.message),
48
+ });
49
+ // Persist result.userKeypair.privateKey under the wallet's key — that's
50
+ // the only way to later decrypt this position to claim a payout.
51
+ ```
52
+
53
+ ## What's in the box
54
+
55
+ - **All 29 program instructions** with typed builders that return raw
56
+ `TransactionInstruction`s (compose, simulate, bundle freely) — 3 admin,
57
+ 8 init comp def, 4 market lifecycle, 2 bet, 2 resolve, 4 claim, plus
58
+ **3 dispute-window** instructions added in v0.2.
59
+ - **Ten high-level action helpers** (`createMarket`,
60
+ `createMarketMulti`, `placeBet`, `resolveMarket`, `claimPayout`,
61
+ `claimRefund`, `cancelMarket`, `withdrawCreatorFunds`, plus
62
+ **`flagResolution`, `finalizeResolution`, `adminOverrideResolution`**)
63
+ that hide the "encrypt → send → await MPC callback → refetch"
64
+ choreography.
65
+ - **Async progress events** on every multi-step action, so frontends can
66
+ render `Encrypting…` → `Submitting…` → `Awaiting MPC nodes…` instead
67
+ of one generic spinner.
68
+ - **Typed event surface** — **10 discriminated-union events** (7 core +
69
+ 3 dispute-window in v0.2) with `parseLogs`, `parseLogsFor`,
70
+ `subscribeAll`, `onXxx` helpers, and a WebSocket-less `pollEvents`
71
+ fallback. Decoded fields are camelCase `bigint`s, matching the typed
72
+ interfaces 1:1.
73
+ - **Account fetch + memcmp filters** for every program account, with
74
+ byte offsets drift-tested against the IDL.
75
+ - **React hooks** (`@cypher-zk/sdk/react`): `CypherProvider`,
76
+ `useGlobalState`, `useMarket`, `useMarkets`, `useUserPositions`,
77
+ `usePlaceBet`, `useResolveMarket`, `useClaimPayout`, `useClaimRefund`,
78
+ `useCreateMarket`, `useCancelMarket`, **`useFlagResolution`,
79
+ `useFinalizeResolution`, `useAdminOverrideResolution`** (v0.2),
80
+ `useMarketEvents` — all built on TanStack Query with sensible
81
+ cache-invalidation defaults.
82
+ - **Phase-aware UI gating** — `marketPhase(market)` returns one of nine
83
+ literal values including `pendingResolution`, `awaitingFinalize`, and
84
+ `disputed` so buttons only render when the corresponding ix is
85
+ actually clickable.
86
+ - **Cluster-agnostic at runtime** — reads `GlobalState.accepted_mint`
87
+ on-chain, so the same build works against any deployment (devnet CSDC,
88
+ mainnet USDC, localnet test mint).
89
+ - **150 unit tests** (712 assertions) covering PDA derivations, fee
90
+ math, deadline phases, IDL drift, Arcium offsets, encryption
91
+ round-trip, event parser round-trip with all 10 event types, action
92
+ input validation (including dispute-window phase gating), and React
93
+ hook wiring. Plus opt-in localnet integration and devnet smoke suites.
94
+
95
+ ## Install
22
96
 
23
97
  ```bash
24
- npm install @cypher-zk/sdk
25
- # or
26
98
  bun add @cypher-zk/sdk
99
+ # or
100
+ npm install @cypher-zk/sdk
27
101
  ```
28
102
 
29
103
  ### Peer dependencies
30
104
 
31
- | Package | Required for |
32
- | -------------------------- | --------------------------------- |
33
- | `react` ^18/^19 | `@cypher-zk/sdk/react` hooks only |
34
- | `@tanstack/react-query` ^5 | `@cypher-zk/sdk/react` hooks only |
35
- | `typescript` ^5 | Type checking |
105
+ | Package | Required for |
106
+ | --- | --- |
107
+ | `react` ^18 \|\| ^19 | `@cypher-zk/sdk/react` subpath only |
108
+ | `@tanstack/react-query` ^5 | `@cypher-zk/sdk/react` subpath only |
36
109
 
37
- ## Quick Start
110
+ Core SDK works in any TypeScript environment with no peer requirements.
38
111
 
39
- ### Core SDK
112
+ ---
113
+
114
+ ## Quickstart
115
+
116
+ ### 1. Construct a client
40
117
 
41
118
  ```ts
42
- import { CypherClient, keypairToWallet } from "@cypher-zk/sdk";
43
- import { Connection, Keypair } from "@solana/web3.js";
119
+ import { Connection } from "@solana/web3.js";
120
+ import { CypherClient } from "@cypher-zk/sdk";
44
121
 
45
- const connection = new Connection("https://api.devnet.solana.com");
46
- const wallet = keypairToWallet(Keypair.generate());
122
+ const connection = new Connection("https://api.devnet.solana.com", "confirmed");
47
123
 
124
+ // Wallet can be:
125
+ // - any @solana/wallet-adapter wallet (browser)
126
+ // - `keypairToWallet(Keypair)` (Node / scripts / tests)
48
127
  const client = new CypherClient({ connection, wallet, cluster: "devnet" });
128
+ ```
49
129
 
50
- // Fetch protocol state
130
+ ### 2. Read protocol state
131
+
132
+ ```ts
51
133
  const gs = await client.globalState.fetch();
52
- console.log("Market counter:", gs.marketCounter);
134
+ console.log("Protocol fee:", gs.protocolFeeRate, "bps");
53
135
 
54
- // Fetch a specific market
55
136
  const market = await client.markets.fetch(0n);
56
- console.log("Question:", market?.question);
57
-
58
- // Fetch all active markets
59
- const active = await client.markets.byState(0); // MarketState.Active
137
+ const active = await client.markets.byState(0); // MarketState.Active
138
+ const mine = await client.markets.byCreator(wallet.publicKey);
139
+ const myBets = await client.positions.byUser(wallet.publicKey);
60
140
  ```
61
141
 
62
- ### Place a Private Bet (end-to-end)
142
+ ### 3. Place a private bet — with live progress
63
143
 
64
144
  ```ts
65
- import { MarketType } from "@cypher-zk/sdk";
145
+ import { computeFees } from "@cypher-zk/sdk";
66
146
 
67
- const result = await client.actions.placeBet({
147
+ // Preview the fee split before showing a confirm modal:
148
+ const preview = computeFees(5_000_000n, {
149
+ protocolFeeRateBps: gs.protocolFeeRate,
150
+ lpFeeRateBps: gs.lpFeeRate,
151
+ });
152
+ console.log("Net stake:", preview.netAmount, "after fees:", preview.protocolFee + preview.lpFee);
153
+
154
+ // Fire the end-to-end flow:
155
+ const { signature, position, userKeypair } = await client.actions.placeBet({
68
156
  payer: wallet.publicKey,
69
157
  user: wallet.publicKey,
70
158
  marketId: 0n,
71
- marketType: MarketType.YesNo,
72
- side: 0, // 0=YES, 1=NO
73
- amountUsdc: 5_000_000n, // $5 USDC (6 decimals)
159
+ side: 1, // 0 = NO, 1 = YES
160
+ amountUsdc: 5_000_000n, // $5 (USDC has 6 decimals)
161
+ onProgress: ({ stage, message, signature }) => {
162
+ // stage ∈ "validating" | "fetching-state" | "encrypting" | "submitting"
163
+ // | "awaiting-callback" | "refetching" | "done"
164
+ console.log(stage, message ?? "", signature ?? "");
165
+ },
74
166
  });
75
167
 
76
- console.log("Tx:", result.signature);
77
- console.log("Entry odds:", result.position?.entryOdds);
168
+ // IMPORTANT: persist userKeypair.privateKey somewhere the user controls
169
+ // (e.g. localStorage encrypted under the wallet's signature) — without
170
+ // it, the user cannot decrypt this position later when claiming.
171
+ saveSecretForLater(position!.market, userKeypair.privateKey);
78
172
  ```
79
173
 
80
- ### Create a Market
174
+ The progress callback lets you drive multi-step UI:
175
+
176
+ ```
177
+ [ Validating … ] ◄ instant, client-side
178
+ [ Fetching protocol state … ]
179
+ [ Encrypting your bet … ]
180
+ [ Submitting transaction … ] ◄ tx signature available here
181
+ [ Awaiting MPC nodes (~10s) … ]
182
+ [ Updating position … ]
183
+ [ Done! ]
184
+ ```
185
+
186
+ ### 4. Create a market
81
187
 
82
188
  ```ts
83
- const result = await client.actions.createMarket({
189
+ const { marketId, marketPda, signature } = await client.actions.createMarket({
84
190
  creator: wallet.publicKey,
85
191
  question: "Will ETH hit $10k by end of 2026?",
86
192
  closeTime: BigInt(Math.floor(Date.now() / 1000) + 7 * 24 * 3600),
87
- category: 0, // Crypto
193
+ category: 0, // MarketCategory.Crypto
194
+ // v0.2+: optional. Defaults to MIN_CHALLENGE_PERIOD_SECS (24h).
195
+ // Must be in [MIN_CHALLENGE_PERIOD_SECS, MAX_CHALLENGE_PERIOD_SECS]
196
+ // (24h–48h). Pin shorter for prediction markets that settle fast.
197
+ challengePeriod: 24 * 3600,
198
+ onProgress: (e) => console.log(e.stage),
199
+ });
200
+ ```
201
+
202
+ Multi-outcome variant — `createMarketMulti` with `numOutcomes: 2 | 3 | 4`.
203
+
204
+ ### 5. Resolve, claim payout, claim refund
205
+
206
+ ```ts
207
+ // Resolver (oracle / DAO):
208
+ await client.actions.resolveMarket({
209
+ payer: wallet.publicKey,
210
+ resolver: wallet.publicKey,
211
+ marketId,
212
+ outcomeValue: 1,
213
+ onProgress: (e) => console.log(e.stage),
214
+ });
215
+
216
+ // Winning bettor:
217
+ await client.actions.claimPayout({
218
+ payer: wallet.publicKey,
219
+ user: wallet.publicKey,
220
+ marketId,
88
221
  });
89
222
 
90
- console.log("Market ID:", result.marketId);
91
- console.log("Market PDA:", result.marketPda.toBase58());
223
+ // Bettor on an unresolved market (past resolution_deadline):
224
+ await client.actions.claimRefund({
225
+ payer: wallet.publicKey,
226
+ user: wallet.publicKey,
227
+ marketId,
228
+ });
92
229
  ```
93
230
 
94
- ### Subscribe to Events
231
+ Each refuses pre-flight if the market isn't in the right phase
232
+ (`marketPhase` returns `"claimable"` for payout, `"refundable"` for
233
+ refund) so the user never burns gas on a guaranteed-to-fail tx.
234
+
235
+ > **v0.2 note:** `resolveMarket` no longer flips the market to
236
+ > `Resolved`. The reveal callback now sets `state = PendingResolution`
237
+ > and starts the challenge window. `claimPayout` opens only after
238
+ > `finalizeResolution` / `adminOverrideResolution` runs — see
239
+ > [§ Dispute window (v0.2)](#dispute-window-v02) below.
240
+
241
+ ### 6. Subscribe to events
95
242
 
96
243
  ```ts
97
- // Real-time (WebSocket)
244
+ // Real-time (WebSocket):
98
245
  const sub = client.events.onBetPlaced((data) => {
99
- console.log("Bet placed! Odds:", data.entryOdds);
246
+ // `data` is BetPlacedEvent — fully typed (camelCase fields, bigint amounts):
247
+ console.log(`Bet placed on market ${data.market.toBase58()} — odds ${data.entryOdds}`);
248
+ });
249
+
250
+ // Generic, typed by name:
251
+ const sub2 = client.events.subscribe("MarketResolvedEvent", (data) => {
252
+ console.log("Outcome:", data.outcome, "Payout ratio:", data.payoutRatio);
253
+ });
254
+
255
+ // v0.2: dispute-window events
256
+ const sub3 = client.events.onResolutionFlagged((data) => {
257
+ console.log("Market disputed by:", data.flaggedBy.toBase58());
258
+ });
259
+ const sub4 = client.events.onMarketFinalized((data) => {
260
+ console.log("Market finalized — claims now open. Outcome:", data.outcome);
261
+ });
262
+ const sub5 = client.events.onResolutionOverridden((data) => {
263
+ console.log(`Admin override: ${data.oldOutcome} → ${data.newOutcome}`);
100
264
  });
101
265
 
102
- // Later: sub.unsubscribe();
266
+ // Later
267
+ sub.unsubscribe();
268
+ sub2.unsubscribe();
103
269
 
104
- // Poll-based (no WS needed)
270
+ // Poll-based fallback (no WS required):
105
271
  const recent = await client.events.pollEvents({ limit: 20 });
106
- for (const { event } of recent) {
107
- console.log(event.name, event.data);
272
+ for (const { event, signature, slot } of recent) {
273
+ console.log(event.name, "in tx", signature, "at slot", slot);
274
+ }
275
+
276
+ // Parse events out of a known transaction:
277
+ import { parseLogs, parseLogsFor } from "@cypher-zk/sdk";
278
+ const tx = await connection.getTransaction(sig, { maxSupportedTransactionVersion: 0 });
279
+ const allEvents = parseLogs(tx?.meta?.logMessages ?? []);
280
+ const payoutsOnly = parseLogsFor(tx?.meta?.logMessages ?? [], "PayoutClaimedEvent");
281
+ ```
282
+
283
+ ### 7. Phase helpers
284
+
285
+ ```ts
286
+ import { marketPhase, projectDeadlines } from "@cypher-zk/sdk";
287
+
288
+ // Compute what action is currently available on a market:
289
+ switch (marketPhase(market)) {
290
+ case "betting": /* show "Bet" button */ break;
291
+ case "awaitingResolve": /* show "Pending resolution" */ break;
292
+ case "pendingResolution": /* v0.2: in challenge window — show countdown + "Flag" */ break;
293
+ case "awaitingFinalize": /* v0.2: window elapsed — show "Finalize" button */ break;
294
+ case "disputed": /* v0.2: flagged — admin override required */ break;
295
+ case "claimable": /* show "Claim payout" */ break;
296
+ case "refundable": /* show "Claim refund" */ break;
297
+ case "expired": /* show "Admin sweep eligible" */ break;
298
+ case "cancelled": /* show "Cancelled" */ break;
108
299
  }
300
+
301
+ // Preview deadlines for a draft market the user is filling in:
302
+ const projected = projectDeadlines(BigInt(closeTimeSec));
303
+ console.log("Resolution deadline:", new Date(Number(projected.resolutionDeadline) * 1000));
304
+ ```
305
+
306
+ ---
307
+
308
+ ## Dispute window (v0.2)
309
+
310
+ After the reveal callback lands, the market enters a configurable
311
+ **24h–48h** challenge window (`market.state === PendingResolution = 4`)
312
+ during which anyone can flag a wrong outcome. Only after the window
313
+ closes does the market move to `Resolved` and claims open.
314
+
315
+ ```
316
+ Resolver calls resolveMarket
317
+
318
+ ▼ reveal_market_outcome_* (Arcium MPC)
319
+ callback writes: outcome, revealedPool*, payoutRatio
320
+ state = PendingResolution
321
+ challengeDeadline = now + challenge_period
322
+
323
+ ┌─────────────────────────────────────┐
324
+ │ Challenge window (24h–48h) │
325
+ │ Anyone may flagResolution │
326
+ │ → market.disputed = true │
327
+ └─────────────────────────────────────┘
328
+ │ │
329
+ (undisputed) (disputed)
330
+ │ │
331
+ ▼ ▼
332
+ finalizeResolution adminOverrideResolution
333
+ (anyone, post-window) (admin only, recomputes payout_ratio)
334
+ │ │
335
+ └──────────┬───────────────┘
336
+
337
+ state = Resolved
338
+ claim_deadline + refund_deadline set
339
+ claimPayout opens
109
340
  ```
110
341
 
111
- ### Parse Transaction Logs
342
+ ### Three new actions
112
343
 
113
344
  ```ts
114
- import { parseLogs } from "@cypher-zk/sdk";
345
+ // 1. ANYONE during the challenge window — flag a wrong resolution
346
+ await client.actions.flagResolution({
347
+ flagger: wallet.publicKey,
348
+ marketId,
349
+ onProgress: (e) => console.log(e.stage),
350
+ });
351
+
352
+ // 2. ANYONE after the window closes undisputed — finalize → state = Resolved
353
+ await client.actions.finalizeResolution({
354
+ caller: wallet.publicKey,
355
+ marketId,
356
+ onProgress: (e) => console.log(e.stage),
357
+ });
358
+
359
+ // 3. ADMIN ONLY when market.disputed === true — re-resolve with corrected outcome
360
+ // Recomputes payout_ratio from the already-revealed plaintext pools.
361
+ await client.actions.adminOverrideResolution({
362
+ admin: wallet.publicKey,
363
+ marketId,
364
+ outcomeValue: 1,
365
+ onProgress: (e) => console.log(e.stage),
366
+ });
367
+ ```
368
+
369
+ Each emits `validating → submitting → refetching → done` progress
370
+ stages and refuses pre-flight if the market isn't in the right phase
371
+ (SDK throws clean errors pointing at the next valid step — e.g.
372
+ "market is in 'pendingResolution' — call finalizeResolution first").
115
373
 
116
- const tx = await connection.getTransaction(sig, { commitment: "confirmed" });
117
- const events = parseLogs(tx.meta?.logMessages ?? []);
374
+ ### Phase gating
118
375
 
119
- for (const evt of events) {
120
- if (evt.name === "MarketResolvedEvent") {
121
- console.log("Outcome:", evt.data.outcome);
376
+ `marketPhase(market)` returns the three new values whenever
377
+ `state === PendingResolution`:
378
+
379
+ | `marketPhase` | Meaning | Clickable |
380
+ | --- | --- | --- |
381
+ | `"pendingResolution"` | inside challenge window, not flagged | `flagResolution` (any user) |
382
+ | `"awaitingFinalize"` | window elapsed, not flagged | `finalizeResolution` (any user) |
383
+ | `"disputed"` | flagged during window | `adminOverrideResolution` (admin only) |
384
+ | `"claimable"` | finalized → window closed (state=Resolved) | `claimPayout` |
385
+
386
+ `claimPayoutAction` and `useClaimPayout` reject pre-flight in the
387
+ first three phases with a hint to call `finalizeResolution` first.
388
+
389
+ ### React example
390
+
391
+ ```tsx
392
+ import {
393
+ useMarket,
394
+ useFlagResolution,
395
+ useFinalizeResolution,
396
+ } from "@cypher-zk/sdk/react";
397
+ import { marketPhase } from "@cypher-zk/sdk";
398
+
399
+ function ChallengeWindowControls({ marketId }: { marketId: bigint }) {
400
+ const { data: market } = useMarket(marketId, { refetchInterval: 5_000 });
401
+ const flag = useFlagResolution();
402
+ const finalize = useFinalizeResolution();
403
+ if (!market) return null;
404
+
405
+ const phase = marketPhase(market);
406
+ if (phase === "pendingResolution") {
407
+ return (
408
+ <>
409
+ <p>Challenge closes at {new Date(Number(market.challengeDeadline) * 1000).toLocaleString()}</p>
410
+ <button onClick={() => flag.mutate({ flagger: wallet.publicKey!, marketId })}>
411
+ Flag this resolution
412
+ </button>
413
+ </>
414
+ );
122
415
  }
416
+ if (phase === "awaitingFinalize") {
417
+ return (
418
+ <button onClick={() => finalize.mutate({ caller: wallet.publicKey!, marketId })}>
419
+ Finalize resolution
420
+ </button>
421
+ );
422
+ }
423
+ if (phase === "disputed") {
424
+ return <p>Awaiting admin override.</p>;
425
+ }
426
+ return null;
123
427
  }
124
428
  ```
125
429
 
126
- ## React Hooks
430
+ ### Defaults & bounds
431
+
432
+ | Constant | Value | Notes |
433
+ | --- | --- | --- |
434
+ | `MIN_CHALLENGE_PERIOD_SECS` | `24 * 3600` (24h) | Action helpers default here |
435
+ | `MAX_CHALLENGE_PERIOD_SECS` | `48 * 3600` (48h) | Hard ceiling enforced by contract |
436
+
437
+ The high-level `client.actions.createMarket` makes `challengePeriod`
438
+ optional and defaults to `MIN_CHALLENGE_PERIOD_SECS`. The raw
439
+ `createMarketIx` builder requires it — out-of-range values throw
440
+ client-side before the tx is built.
441
+
442
+ ### Six new error codes
443
+
444
+ | Code | Name | When |
445
+ | --- | --- | --- |
446
+ | `6036` | `InvalidChallengePeriod` | `challengePeriod` outside 24h–48h |
447
+ | `6037` | `NotPendingResolution` | flag/finalize/override on wrong state |
448
+ | `6038` | `ChallengePeriodNotElapsed` | finalize called too early |
449
+ | `6039` | `ChallengePeriodElapsed` | flag called after window closed |
450
+ | `6040` | `MarketDisputed` | finalize on a flagged market |
451
+ | `6041` | `MarketNotDisputed` | admin override on a clean market |
452
+
453
+ Use `parseCypherError(err)` to extract a typed `CypherErrorCode` for
454
+ branching.
455
+
456
+ ### Three new events
457
+
458
+ `ResolutionFlaggedEvent`, `MarketFinalizedEvent`,
459
+ `ResolutionOverriddenEvent` are added to the discriminated `CypherEvent`
460
+ union and have matching `client.events.on*` helpers.
461
+
462
+ ---
463
+
464
+ ## React hooks
127
465
 
128
466
  ```tsx
129
- import { CypherProvider, useMarket, usePlaceBet } from "@cypher-zk/sdk/react";
467
+ import {
468
+ CypherProvider,
469
+ useGlobalState,
470
+ useMarket,
471
+ useMarkets,
472
+ usePlaceBet,
473
+ } from "@cypher-zk/sdk/react";
130
474
  import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
475
+ import { useState } from "react";
476
+ import { CypherClient, keypairToWallet, MarketState } from "@cypher-zk/sdk";
477
+ import type { ActionProgressEvent } from "@cypher-zk/sdk";
131
478
 
132
479
  const queryClient = new QueryClient();
480
+ const client = new CypherClient({ connection, wallet, cluster: "devnet" });
133
481
 
134
482
  function App() {
135
483
  return (
136
484
  <QueryClientProvider client={queryClient}>
137
485
  <CypherProvider client={client}>
138
- <MarketView />
486
+ <MarketView marketId={0n} />
139
487
  </CypherProvider>
140
488
  </QueryClientProvider>
141
489
  );
142
490
  }
143
491
 
144
- function MarketView() {
145
- const { data: market, isLoading } = useMarket(0n);
146
- const { mutateAsync: placeBet, isPending } = usePlaceBet();
492
+ function MarketView({ marketId }: { marketId: bigint }) {
493
+ const { data: market, isLoading } = useMarket(marketId);
494
+ const [stage, setStage] = useState<ActionProgressEvent | null>(null);
147
495
 
148
- if (isLoading) return <p>Loading…</p>;
496
+ const placeBet = usePlaceBet({
497
+ onSuccess: ({ userKeypair }) => persistUserSecret(userKeypair.privateKey),
498
+ });
499
+
500
+ if (isLoading) return <p>Loading market…</p>;
501
+ if (!market) return <p>Market not found.</p>;
149
502
 
150
503
  return (
151
504
  <div>
152
- <h1>{market?.question}</h1>
505
+ <h2>{market.question}</h2>
153
506
  <button
154
- disabled={isPending}
507
+ disabled={placeBet.isPending}
155
508
  onClick={() =>
156
- placeBet({
509
+ placeBet.mutate({
157
510
  payer: wallet.publicKey,
158
511
  user: wallet.publicKey,
159
- marketId: 0n,
160
- marketType: 0,
161
- side: 0,
162
- amountUsdc: 1_000_000n,
512
+ marketId,
513
+ side: 1,
514
+ amountUsdc: 5_000_000n,
515
+ onProgress: setStage,
163
516
  })
164
517
  }
165
518
  >
166
- Bet YES ($1)
519
+ {placeBet.isPending && stage ? `Bet ${stage.stage}` : "Bet $5 YES"}
167
520
  </button>
521
+ {placeBet.error && <p style={{ color: "crimson" }}>{placeBet.error.message}</p>}
168
522
  </div>
169
523
  );
170
524
  }
171
525
  ```
172
526
 
173
- ### Available Hooks
527
+ ### Available hooks
528
+
529
+ | Hook | Kind | Description |
530
+ | --- | --- | --- |
531
+ | `useGlobalState()` | Query | Protocol config (fees, mint, admin, counter) |
532
+ | `useMarket(id)` | Query | Single market by ID |
533
+ | `useMarkets(filter?)` | Query | All/filtered markets (creator, state) |
534
+ | `useUserPositions(user)` | Query | All bet positions for a user |
535
+ | `usePlaceBet()` | Mutation | End-to-end private bet |
536
+ | `useCreateMarket()` | Mutation | Create a new market |
537
+ | `useResolveMarket()` | Mutation | Submit outcome + await reveal |
538
+ | `useClaimPayout()` | Mutation | Claim winning payout |
539
+ | `useClaimRefund()` | Mutation | Claim refund on unresolved market |
540
+ | `useCancelMarket()` | Mutation | Cancel a zero-bet market |
541
+ | `useFlagResolution()` *(v0.2)* | Mutation | Flag a pending resolution during the challenge window |
542
+ | `useFinalizeResolution()` *(v0.2)* | Mutation | Finalize a pending resolution after the window elapses undisputed |
543
+ | `useAdminOverrideResolution()` *(v0.2)* | Mutation | Admin re-resolves a disputed market |
544
+ | `useMarketEvents()` | Subscription | Live event stream (component-scoped) |
545
+
546
+ Mutation hooks auto-invalidate the relevant query keys on success. Read
547
+ hooks expose their `queryKey` factories (`marketKeys.one(id)`,
548
+ `positionKeys.byUser(user)`, `globalStateKeys.all`) for manual
549
+ invalidation.
550
+
551
+ Live example under [`examples/react-vite/`](./examples/react-vite/) — a
552
+ small Vite app that renders the protocol state, lists active markets,
553
+ and drives `usePlaceBet` with live progress.
174
554
 
175
- | Hook | Type | Description |
176
- | ------------------------ | ------------ | ----------------------------------- |
177
- | `useGlobalState()` | Query | Protocol config (fees, mint, admin) |
178
- | `useMarket(id)` | Query | Single market by ID |
179
- | `useMarkets(filter?)` | Query | All/filtered markets |
180
- | `useUserPositions(user)` | Query | User's bet positions |
181
- | `usePlaceBet()` | Mutation | End-to-end private bet |
182
- | `useResolveMarket()` | Mutation | Resolve a market |
183
- | `useClaimPayout()` | Mutation | Claim winning payout |
184
- | `useClaimRefund()` | Mutation | Claim refund on unresolved market |
185
- | `useCreateMarket()` | Mutation | Create a new market |
186
- | `useCancelMarket()` | Mutation | Cancel a zero-bet market |
187
- | `useMarketEvents()` | Subscription | Live event stream |
555
+ ---
188
556
 
189
- ## Architecture
557
+ ## Layout
190
558
 
191
559
  ```
192
560
  cypher-sdk/
193
- ├── src/ # Framework-agnostic core
194
- │ ├── config.ts # Program ID, cluster presets, on-chain constants
195
- │ ├── pda.ts # PDA derivation helpers
196
- │ ├── wallet.ts # Wallet adapter interface
197
- │ ├── fees.ts # Fee calculation (mirrors on-chain math)
198
- │ ├── deadlines.ts # Market lifecycle phase computation
199
- │ ├── errors.ts # Typed error codes + parsing
200
- │ ├── client.ts # CypherClient — unified entry point
201
- │ ├── accounts/ # Account fetchers (GlobalState, Market, Position, LP)
202
- │ ├── arcium/ # MPC glue (cipher, encoding, queue accounts, offsets)
203
- │ ├── instructions/ # Raw instruction builders (26 instructions)
204
- │ ├── actions/ # High-level flows (encrypt send → callback → refetch)
205
- │ ├── events/ # Event parser + WebSocket/poll subscriptions
206
- │ ├── idl/ # Synced Anchor IDL (source of truth)
207
- │ └── node/ # Node-only admin helpers
208
- ├── react/src/ # Optional React hooks layer
209
- ├── CypherProvider.tsx # Context + provider
210
- ├── useGlobalState.ts # Read hook
211
- │ ├── useMarket.ts # Read hooks (single + filtered)
212
- ├── useUserPositions.ts # Read hook
213
- │ ├── mutations.ts # Write hooks (all actions)
214
- │ └── useMarketEvents.ts # Live event subscription hook
561
+ ├── src/ # framework-agnostic core (ESM)
562
+ │ ├── config.ts # program ID, cluster presets, constants
563
+ │ ├── pda.ts # PDA derivations
564
+ │ ├── wallet.ts # Wallet interface + Keypair adapter
565
+ │ ├── fees.ts # mirrors on-chain fee math
566
+ │ ├── deadlines.ts # market phase computation
567
+ │ ├── errors.ts # CypherErrorCode + Anchor error walker
568
+ │ ├── client.ts # CypherClient — single import surface
569
+ │ ├── accounts/ # fetch + memcmp filter helpers per account
570
+ │ ├── arcium/ # MPC glue (cipher, queue accounts, offsets)
571
+ │ ├── instructions/ # raw TransactionInstruction builders (29 ix, v0.2)
572
+ │ ├── actions/ # high-level flows + progress events
573
+ │ ├── events/ # typed event parser + WS/poll subscriptions
574
+ │ ├── idl/ # synced Anchor IDL (source of truth)
575
+ │ └── node/ # node-only admin helpers
576
+ ├── react/src/ # @cypher-zk/sdk/react hooks subpath
577
+ ├── examples/react-vite/ # minimal Vite app smoke
578
+ ├── docs/
579
+ │ ├── architecture.md # three-surface model + module map
580
+ └── flows.md # one diagram per user flow
215
581
  └── tests/
216
- ├── unit/ # 107 tests, 581 assertions
217
- ├── integration/ # Localnet lifecycle (INTEGRATION=1)
218
- └── devnet/ # Devnet smoke test (DEVNET=1)
582
+ ├── unit/ # 150 tests, 712 assertions — pure, no chain
583
+ ├── integration/ # INTEGRATION=1 — Arcium localnet lifecycle
584
+ └── devnet/ # DEVNET=1 devnet read-only + opt-in writes
219
585
  ```
220
586
 
221
- ## Cluster Configuration
587
+ See [`docs/architecture.md`](./docs/architecture.md) for the three-surface
588
+ model (circuit / program / client), the account topology, and the full
589
+ runtime flow of a private bet. See [`docs/flows.md`](./docs/flows.md) for
590
+ per-flow ASCII diagrams.
591
+
592
+ ---
222
593
 
223
- The SDK is **cluster-agnostic** — it reads `GlobalState.accepted_mint` on-chain at runtime:
594
+ ## Cluster strategy
224
595
 
225
- | Cluster | Accepted Mint | Arcium Cluster Offset |
226
- | ---------- | --------------------- | --------------------- |
227
- | `devnet` | CSDC (`8AF9BABN…`) | `456` |
228
- | `mainnet` | USDC (`EPjFWdd5…`) | TBD |
229
- | `localnet` | CSDC (same as devnet) | `1116522022` |
596
+ The SDK is **cluster-agnostic at runtime**: it reads the accepted SPL mint
597
+ from `GlobalState.accepted_mint` on every flow rather than hard-coding
598
+ CSDC vs USDC. The same build works against any Cypher deployment.
230
599
 
231
- Override cluster detection:
600
+ | Cluster | RPC default | Accepted mint | Arcium offset |
601
+ | --- | --- | --- | --- |
602
+ | `devnet` | `api.devnet.solana.com` | CSDC (`8AF9BABN…`) | `456` |
603
+ | `mainnet` | `api.mainnet-beta.solana.com` | USDC (`EPjFWdd5…`) | (set at deploy) |
604
+ | `localnet` | `localhost:8899` | CSDC (test build) | `1116522022` |
232
605
 
233
606
  ```ts
234
- // Explicit preset
607
+ // Explicit preset:
235
608
  const client = new CypherClient({ connection, wallet, cluster: "devnet" });
236
609
 
237
- // Fully custom
610
+ // Custom config — override RPC and/or Arcium cluster offset:
238
611
  const client = new CypherClient({
239
612
  connection,
240
613
  wallet,
241
614
  cluster: {
242
615
  name: "devnet",
243
- rpc: "https://my-helius-rpc.com",
616
+ rpc: "https://my-helius-endpoint.example",
244
617
  arciumClusterOffset: 456,
245
- expectedMint: new PublicKey("8AF9BABNWwEhipRxtXPYoWSZW24SKjUn6YqbKd9ZqhwB"),
618
+ expectedMint: KNOWN_MINTS.devnetCSDC,
246
619
  },
247
620
  });
248
621
  ```
249
622
 
623
+ ---
624
+
250
625
  ## Scripts
251
626
 
252
- ```bash
253
- bun run build # Build dist/ with tsup
254
- bun run typecheck # Type-check without emitting
255
- bun run test # All unit tests
256
- bun run test:unit # Unit tests only
257
- bun run test:integration # Integration (requires localnet)
258
- bun run test:devnet # Devnet smoke (requires DEVNET_KEYPAIR)
259
- bun run sync:idl # Copy IDL from ../cypher-main
260
- bun run clean # Remove dist/
261
- ```
627
+ | Command | Purpose |
628
+ | --- | --- |
629
+ | `bun install` | Install deps |
630
+ | `bun test` | All unit suites (gates skipped) |
631
+ | `bun run test:unit` | Unit-only |
632
+ | `bun run test:integration` | `INTEGRATION=1` Arcium localnet lifecycle |
633
+ | `bun run test:devnet` | `DEVNET=1` devnet read-only + opt-in writes |
634
+ | `bun run typecheck` | `tsc --noEmit` (strict) |
635
+ | `bun run build` | ESM + `.d.ts` via tsup → `dist/` |
636
+ | `bun run sync:idl` | Re-copy IDL + types from `../cypher-main` |
637
+ | `bun run prepublishOnly` | sync IDL → typecheck → unit tests → build |
638
+
639
+ ---
262
640
 
263
641
  ## Security
264
642
 
265
- The Cypher protocol has undergone a comprehensive security audit (June 2026). All 9 findings — including 3 critical, 2 high, and 4 medium/low — have been fully remediated and verified in a second-round audit. See `cypher-main/audit_report.md` for the full report.
643
+ The Cypher protocol underwent a two-round security audit. All 9
644
+ findings — 3 critical, 2 high, 4 medium/low — have been remediated and
645
+ verified. See `cypher-main/audit_report.md` for the full report.
646
+
647
+ The SDK adds defense-in-depth client-side:
648
+
649
+ - **`computeFees` mirrors the on-chain math exactly** — `placeBet`
650
+ pre-asserts the encrypted `net_amount` will match what the contract
651
+ computes from the gross. The contract's circuit also verifies this
652
+ on-chain (audit fix H-1) — the SDK guard surfaces the failure as a
653
+ clean rejection instead of a wasted MPC computation.
654
+ - **`marketPhase` blocks known-bad claims** — `claimPayout` /
655
+ `claimRefund` refuse to send if the market isn't in the right phase,
656
+ saving users from on-chain `MarketStillOpen` / `ClaimPeriodExpired`
657
+ rejections.
658
+ - **Position double-claim guard** — `claimPayout` reads `position.claimed`
659
+ before submitting (the contract enforces it again as audit fix H-2).
660
+ - **Strict ciphertext-length enforcement** — every builder that takes a
661
+ `Uint8Array` ciphertext asserts `length === 32` before assembling the
662
+ ix, catching the "combined ciphertext arrays" mistake from the Arcium
663
+ skill before it hits the program.
664
+ - **v0.2 dispute-window phase gating** — `claimPayout` rejects pre-flight
665
+ if `market.state === PendingResolution` with a typed error pointing
666
+ the caller at `finalizeResolution`, so users don't pay MPC compute
667
+ fees on a guaranteed-to-fail tx. `flagResolution` /
668
+ `finalizeResolution` / `adminOverrideResolution` validate against
669
+ `market.disputed` + `challengeDeadline` client-side.
670
+
671
+ ## Changelog
672
+
673
+ ### 0.2.0 — dispute window
674
+
675
+ - **NEW**: 3 instructions (`flagResolution`, `finalizeResolution`,
676
+ `adminOverrideResolution`) + 3 actions + 3 React hooks + 3 events
677
+ + 6 error codes (6036–6041).
678
+ - **NEW**: `MarketState.PendingResolution = 4` and three new
679
+ `marketPhase` values: `pendingResolution`, `awaitingFinalize`,
680
+ `disputed`.
681
+ - **NEW**: `Market` gains `challengePeriod`, `challengeDeadline`,
682
+ `disputed` fields; layout offsets shifted.
683
+ - **NEW**: `MIN_CHALLENGE_PERIOD_SECS` / `MAX_CHALLENGE_PERIOD_SECS`
684
+ constants (24h / 48h).
685
+ - **BREAKING**: `createMarketIx` / `createMarketMultiIx` raw builders
686
+ require a new `challengePeriod` arg. The high-level
687
+ `client.actions.createMarket` makes it optional (defaults to 24h).
688
+ - **BREAKING**: `resolveMarket` now leaves the market in
689
+ `PendingResolution`; `claimPayout` opens only after
690
+ `finalizeResolution` or `adminOverrideResolution` runs.
691
+ - **DX**: `claimPayoutAction` pre-flight error now explicitly names
692
+ `finalizeResolution` as the next step when state is
693
+ `PendingResolution`.
694
+
695
+ ---
266
696
 
267
697
  ## License
268
698
 
269
- Source Available — use is permitted, redistribution is not. See [LICENSE](./LICENSE) for full terms.
699
+ Source Available — use is permitted, redistribution is not. See
700
+ [LICENSE](./LICENSE) for full terms.