@cypher-zk/sdk
v0.7.7
Published
TypeScript SDK for the Cypher prediction market (Arcium + Anchor on Solana)
Maintainers
Readme
Cypher SDK
TypeScript SDK for the Cypher privacy-preserving prediction market on Solana, powered by Arcium MPC.
A framework-agnostic core (Node, Bun, browser) with an optional React hooks subpath and end-to-end progress callbacks so frontends can render fine-grained loading state across every multi-step on-chain flow.
v0.2 — dispute window. Reveal callbacks now land the market in
PendingResolution, notResolved. A configurable 24h–48h challenge window lets anyone flag a wrong outcome before claims open. See § Dispute window (v0.2) for the full flow and three new instructions / actions / hooks.
Why this SDK exists
Cypher is a Solana program that wraps prediction-market state in Arcium MPC so user bets stay encrypted on chain. Talking to it directly involves wiring:
- A typed Anchor
Programagainst the program's IDL - Per-flow Arcium queue accounts (cluster offset, mempool, comp def, …)
x25519keypair generation + Rescue cipher encryption for each bet- Polling the computation account until the MPC nodes finalize the callback
- Refetching the position/market after the callback updates state
This SDK gives you a single client.actions.placeBet({...}) call that
does all of that, with progress events for the UI and discriminated-
union typed events for the indexer.
const result = await client.actions.placeBet({
payer: wallet.publicKey,
user: wallet.publicKey,
marketId: 7n,
side: 1, // 1 = YES
amountUsdc: 5_000_000n, // $5 (6 decimals)
onProgress: (e) => updateLoaderUI(e.stage, e.message),
});
// Persist result.userKeypair.privateKey under the wallet's key — that's
// the only way to later decrypt this position to claim a payout.What's in the box
- All 29 program instructions with typed builders that return raw
TransactionInstructions (compose, simulate, bundle freely) — 3 admin, 8 init comp def, 4 market lifecycle, 2 bet, 2 resolve, 4 claim, plus 3 dispute-window instructions added in v0.2. - Ten high-level action helpers (
createMarket,createMarketMulti,placeBet,resolveMarket,claimPayout,claimRefund,cancelMarket,withdrawCreatorFunds, plusflagResolution,finalizeResolution,adminOverrideResolution) that hide the "encrypt → send → await MPC callback → refetch" choreography. - Async progress events on every multi-step action, so frontends can
render
Encrypting…→Submitting…→Awaiting MPC nodes…instead of one generic spinner. - Typed event surface — 10 discriminated-union events (7 core +
3 dispute-window in v0.2) with
parseLogs,parseLogsFor,subscribeAll,onXxxhelpers, and a WebSocket-lesspollEventsfallback. Decoded fields are camelCasebigints, matching the typed interfaces 1:1. - Account fetch + memcmp filters for every program account, with byte offsets drift-tested against the IDL.
- React hooks (
@cypher-zk/sdk/react):CypherProvider,useGlobalState,useMarket,useMarkets,useUserPositions,usePlaceBet,useResolveMarket,useClaimPayout,useClaimRefund,useCreateMarket,useCancelMarket,useFlagResolution,useFinalizeResolution,useAdminOverrideResolution(v0.2),useMarketEvents— all built on TanStack Query with sensible cache-invalidation defaults. - Phase-aware UI gating —
marketPhase(market)returns one of nine literal values includingpendingResolution,awaitingFinalize, anddisputedso buttons only render when the corresponding ix is actually clickable. - Cluster-agnostic at runtime — reads
GlobalState.accepted_minton-chain, so the same build works against any deployment (devnet CSDC, mainnet USDC, localnet test mint). - 150 unit tests (712 assertions) covering PDA derivations, fee math, deadline phases, IDL drift, Arcium offsets, encryption round-trip, event parser round-trip with all 10 event types, action input validation (including dispute-window phase gating), and React hook wiring. Plus opt-in localnet integration and devnet smoke suites.
Install
bun add @cypher-zk/sdk
# or
npm install @cypher-zk/sdkPeer dependencies
| Package | Required for |
| -------------------------- | ----------------------------------- |
| react ^18 || ^19 | @cypher-zk/sdk/react subpath only |
| @tanstack/react-query ^5 | @cypher-zk/sdk/react subpath only |
Core SDK works in any TypeScript environment with no peer requirements.
Quickstart
1. Construct a client
import { Connection } from "@solana/web3.js";
import { CypherClient } from "@cypher-zk/sdk";
const connection = new Connection("https://api.devnet.solana.com", "confirmed");
// Wallet can be:
// - any @solana/wallet-adapter wallet (browser)
// - `keypairToWallet(Keypair)` (Node / scripts / tests)
const client = new CypherClient({ connection, wallet, cluster: "devnet" });2. Read protocol state
const gs = await client.globalState.fetch();
console.log("Protocol fee:", gs.protocolFeeRate, "bps");
const market = await client.markets.fetch(0n);
const active = await client.markets.byState(0); // MarketState.Active
const mine = await client.markets.byCreator(wallet.publicKey);
const myBets = await client.positions.byUser(wallet.publicKey);3. Place a private bet — with live progress
import { computeFees } from "@cypher-zk/sdk";
// Preview the fee split before showing a confirm modal:
const preview = computeFees(5_000_000n, {
protocolFeeRateBps: gs.protocolFeeRate,
lpFeeRateBps: gs.lpFeeRate,
});
console.log(
"Net stake:",
preview.netAmount,
"after fees:",
preview.protocolFee + preview.lpFee,
);
// Fire the end-to-end flow:
const { signature, position, userKeypair } = await client.actions.placeBet({
payer: wallet.publicKey,
user: wallet.publicKey,
marketId: 0n,
side: 1, // 0 = NO, 1 = YES
amountUsdc: 5_000_000n, // $5 (USDC has 6 decimals)
onProgress: ({ stage, message, signature }) => {
// stage ∈ "validating" | "fetching-state" | "encrypting" | "submitting"
// | "awaiting-callback" | "refetching" | "done"
console.log(stage, message ?? "", signature ?? "");
},
});
// IMPORTANT: persist userKeypair.privateKey somewhere the user controls
// (e.g. localStorage encrypted under the wallet's signature) — without
// it, the user cannot decrypt this position later when claiming.
saveSecretForLater(position!.market, userKeypair.privateKey);The progress callback lets you drive multi-step UI:
[ Validating … ] ◄ instant, client-side
[ Fetching protocol state … ]
[ Encrypting your bet … ]
[ Submitting transaction … ] ◄ tx signature available here
[ Awaiting MPC nodes (~10s) … ]
[ Updating position … ]
[ Done! ]4. Create a market
const { marketId, marketPda, signature } = await client.actions.createMarket({
creator: wallet.publicKey,
question: "Will ETH hit $10k by end of 2026?",
closeTime: BigInt(Math.floor(Date.now() / 1000) + 7 * 24 * 3600),
category: 0, // MarketCategory.Crypto
// v0.2+: optional. Defaults to MIN_CHALLENGE_PERIOD_SECS (24h).
// Must be in [MIN_CHALLENGE_PERIOD_SECS, MAX_CHALLENGE_PERIOD_SECS]
// (24h–48h). Pin shorter for prediction markets that settle fast.
challengePeriod: 24 * 3600,
onProgress: (e) => console.log(e.stage),
});Multi-outcome variant — createMarketMulti with numOutcomes: 2 | 3 | 4.
5. Resolve, claim payout, claim refund
// Resolver (oracle / DAO):
await client.actions.resolveMarket({
payer: wallet.publicKey,
resolver: wallet.publicKey,
marketId,
outcomeValue: 1,
onProgress: (e) => console.log(e.stage),
});
// Winning bettor:
await client.actions.claimPayout({
payer: wallet.publicKey,
user: wallet.publicKey,
marketId,
});
// Bettor on an unresolved market (past resolution_deadline):
await client.actions.claimRefund({
payer: wallet.publicKey,
user: wallet.publicKey,
marketId,
});Each refuses pre-flight if the market isn't in the right phase
(marketPhase returns "claimable" for payout, "refundable" for
refund) so the user never burns gas on a guaranteed-to-fail tx.
v0.2 note:
resolveMarketno longer flips the market toResolved. The reveal callback now setsstate = PendingResolutionand starts the challenge window.claimPayoutopens only afterfinalizeResolution/adminOverrideResolutionruns — see § Dispute window (v0.2) below.
6. Subscribe to events
// Real-time (WebSocket):
const sub = client.events.onBetPlaced((data) => {
// `data` is BetPlacedEvent — fully typed (camelCase fields, bigint amounts):
console.log(
`Bet placed on market ${data.market.toBase58()} — odds ${data.entryOdds}`,
);
});
// Generic, typed by name:
const sub2 = client.events.subscribe("MarketResolvedEvent", (data) => {
console.log("Outcome:", data.outcome, "Payout ratio:", data.payoutRatio);
});
// v0.2: dispute-window events
const sub3 = client.events.onResolutionFlagged((data) => {
console.log("Market disputed by:", data.flaggedBy.toBase58());
});
const sub4 = client.events.onMarketFinalized((data) => {
console.log("Market finalized — claims now open. Outcome:", data.outcome);
});
const sub5 = client.events.onResolutionOverridden((data) => {
console.log(`Admin override: ${data.oldOutcome} → ${data.newOutcome}`);
});
// Later
sub.unsubscribe();
sub2.unsubscribe();
// Poll-based fallback (no WS required):
const recent = await client.events.pollEvents({ limit: 20 });
for (const { event, signature, slot } of recent) {
console.log(event.name, "in tx", signature, "at slot", slot);
}
// Parse events out of a known transaction:
import { parseLogs, parseLogsFor } from "@cypher-zk/sdk";
const tx = await connection.getTransaction(sig, {
maxSupportedTransactionVersion: 0,
});
const allEvents = parseLogs(tx?.meta?.logMessages ?? []);
const payoutsOnly = parseLogsFor(
tx?.meta?.logMessages ?? [],
"PayoutClaimedEvent",
);7. Phase helpers
import { marketPhase, projectDeadlines } from "@cypher-zk/sdk";
// Compute what action is currently available on a market:
switch (marketPhase(market)) {
case "betting":
/* show "Bet" button */ break;
case "awaitingResolve":
/* show "Pending resolution" */ break;
case "pendingResolution":
/* v0.2: in challenge window — show countdown + "Flag" */ break;
case "awaitingFinalize":
/* v0.2: window elapsed — show "Finalize" button */ break;
case "disputed":
/* v0.2: flagged — admin override required */ break;
case "claimable":
/* show "Claim payout" */ break;
case "refundable":
/* show "Claim refund" */ break;
case "expired":
/* show "Admin sweep eligible" */ break;
case "cancelled":
/* show "Cancelled" */ break;
}
// Preview deadlines for a draft market the user is filling in:
const projected = projectDeadlines(BigInt(closeTimeSec));
console.log(
"Resolution deadline:",
new Date(Number(projected.resolutionDeadline) * 1000),
);Dispute window (v0.2)
After the reveal callback lands, the market enters a configurable
24h–48h challenge window (market.state === PendingResolution = 4)
during which anyone can flag a wrong outcome. Only after the window
closes does the market move to Resolved and claims open.
Resolver calls resolveMarket
│
▼ reveal_market_outcome_* (Arcium MPC)
callback writes: outcome, revealedPool*, payoutRatio
state = PendingResolution
challengeDeadline = now + challenge_period
┌─────────────────────────────────────┐
│ Challenge window (24h–48h) │
│ Anyone may flagResolution │
│ → market.disputed = true │
└─────────────────────────────────────┘
│ │
(undisputed) (disputed)
│ │
▼ ▼
finalizeResolution adminOverrideResolution
(anyone, post-window) (admin only, recomputes payout_ratio)
│ │
└──────────┬───────────────┘
▼
state = Resolved
claim_deadline + refund_deadline set
claimPayout opensThree new actions
// 1. ANYONE during the challenge window — flag a wrong resolution
await client.actions.flagResolution({
flagger: wallet.publicKey,
marketId,
onProgress: (e) => console.log(e.stage),
});
// 2. ANYONE after the window closes undisputed — finalize → state = Resolved
await client.actions.finalizeResolution({
caller: wallet.publicKey,
marketId,
onProgress: (e) => console.log(e.stage),
});
// 3. ADMIN ONLY when market.disputed === true — re-resolve with corrected outcome
// Recomputes payout_ratio from the already-revealed plaintext pools.
await client.actions.adminOverrideResolution({
admin: wallet.publicKey,
marketId,
outcomeValue: 1,
onProgress: (e) => console.log(e.stage),
});Each emits validating → submitting → refetching → done progress
stages and refuses pre-flight if the market isn't in the right phase
(SDK throws clean errors pointing at the next valid step — e.g.
"market is in 'pendingResolution' — call finalizeResolution first").
Phase gating
marketPhase(market) returns the three new values whenever
state === PendingResolution:
| marketPhase | Meaning | Clickable |
| --------------------- | ------------------------------------------ | -------------------------------------- |
| "pendingResolution" | inside challenge window, not flagged | flagResolution (any user) |
| "awaitingFinalize" | window elapsed, not flagged | finalizeResolution (any user) |
| "disputed" | flagged during window | adminOverrideResolution (admin only) |
| "claimable" | finalized → window closed (state=Resolved) | claimPayout |
claimPayoutAction and useClaimPayout reject pre-flight in the
first three phases with a hint to call finalizeResolution first.
React example
import {
useMarket,
useFlagResolution,
useFinalizeResolution,
} from "@cypher-zk/sdk/react";
import { marketPhase } from "@cypher-zk/sdk";
function ChallengeWindowControls({ marketId }: { marketId: bigint }) {
const { data: market } = useMarket(marketId, { refetchInterval: 5_000 });
const flag = useFlagResolution();
const finalize = useFinalizeResolution();
if (!market) return null;
const phase = marketPhase(market);
if (phase === "pendingResolution") {
return (
<>
<p>
Challenge closes at{" "}
{new Date(Number(market.challengeDeadline) * 1000).toLocaleString()}
</p>
<button
onClick={() => flag.mutate({ flagger: wallet.publicKey!, marketId })}
>
Flag this resolution
</button>
</>
);
}
if (phase === "awaitingFinalize") {
return (
<button
onClick={() => finalize.mutate({ caller: wallet.publicKey!, marketId })}
>
Finalize resolution
</button>
);
}
if (phase === "disputed") {
return <p>Awaiting admin override.</p>;
}
return null;
}Defaults & bounds
| Constant | Value | Notes |
| --------------------------- | ----------------- | --------------------------------- |
| MIN_CHALLENGE_PERIOD_SECS | 24 * 3600 (24h) | Action helpers default here |
| MAX_CHALLENGE_PERIOD_SECS | 48 * 3600 (48h) | Hard ceiling enforced by contract |
The high-level client.actions.createMarket makes challengePeriod
optional and defaults to MIN_CHALLENGE_PERIOD_SECS. The raw
createMarketIx builder requires it — out-of-range values throw
client-side before the tx is built.
Six new error codes
| Code | Name | When |
| ------ | --------------------------- | ------------------------------------- |
| 6036 | InvalidChallengePeriod | challengePeriod outside 24h–48h |
| 6037 | NotPendingResolution | flag/finalize/override on wrong state |
| 6038 | ChallengePeriodNotElapsed | finalize called too early |
| 6039 | ChallengePeriodElapsed | flag called after window closed |
| 6040 | MarketDisputed | finalize on a flagged market |
| 6041 | MarketNotDisputed | admin override on a clean market |
Use parseCypherError(err) to extract a typed CypherErrorCode for
branching.
Three new events
ResolutionFlaggedEvent, MarketFinalizedEvent,
ResolutionOverriddenEvent are added to the discriminated CypherEvent
union and have matching client.events.on* helpers.
React hooks
import {
CypherProvider,
useGlobalState,
useMarket,
useMarkets,
usePlaceBet,
} from "@cypher-zk/sdk/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import { CypherClient, keypairToWallet, MarketState } from "@cypher-zk/sdk";
import type { ActionProgressEvent } from "@cypher-zk/sdk";
const queryClient = new QueryClient();
const client = new CypherClient({ connection, wallet, cluster: "devnet" });
function App() {
return (
<QueryClientProvider client={queryClient}>
<CypherProvider client={client}>
<MarketView marketId={0n} />
</CypherProvider>
</QueryClientProvider>
);
}
function MarketView({ marketId }: { marketId: bigint }) {
const { data: market, isLoading } = useMarket(marketId);
const [stage, setStage] = useState<ActionProgressEvent | null>(null);
const placeBet = usePlaceBet({
onSuccess: ({ userKeypair }) => persistUserSecret(userKeypair.privateKey),
});
if (isLoading) return <p>Loading market…</p>;
if (!market) return <p>Market not found.</p>;
return (
<div>
<h2>{market.question}</h2>
<button
disabled={placeBet.isPending}
onClick={() =>
placeBet.mutate({
payer: wallet.publicKey,
user: wallet.publicKey,
marketId,
side: 1,
amountUsdc: 5_000_000n,
onProgress: setStage,
})
}
>
{placeBet.isPending && stage ? `Bet → ${stage.stage}` : "Bet $5 YES"}
</button>
{placeBet.error && (
<p style={{ color: "crimson" }}>{placeBet.error.message}</p>
)}
</div>
);
}Available hooks
| Hook | Kind | Description |
| --------------------------------------- | ------------ | ----------------------------------------------------------------- |
| useGlobalState() | Query | Protocol config (fees, mint, admin, counter) |
| useMarket(id) | Query | Single market by ID |
| useMarkets(filter?) | Query | All/filtered markets (creator, state) |
| useUserPositions(user) | Query | All bet positions for a user |
| usePlaceBet() | Mutation | End-to-end private bet |
| useCreateMarket() | Mutation | Create a new market |
| useResolveMarket() | Mutation | Submit outcome + await reveal |
| useClaimPayout() | Mutation | Claim winning payout |
| useClaimRefund() | Mutation | Claim refund on unresolved market |
| useCancelMarket() | Mutation | Cancel a zero-bet market |
| useFlagResolution() (v0.2) | Mutation | Flag a pending resolution during the challenge window |
| useFinalizeResolution() (v0.2) | Mutation | Finalize a pending resolution after the window elapses undisputed |
| useAdminOverrideResolution() (v0.2) | Mutation | Admin re-resolves a disputed market |
| useMarketEvents() | Subscription | Live event stream (component-scoped) |
Mutation hooks auto-invalidate the relevant query keys on success. Read
hooks expose their queryKey factories (marketKeys.one(id),
positionKeys.byUser(user), globalStateKeys.all) for manual
invalidation.
Live example under examples/react-vite/ — a
small Vite app that renders the protocol state, lists active markets,
and drives usePlaceBet with live progress.
Layout
cypher-sdk/
├── src/ # framework-agnostic core (ESM)
│ ├── config.ts # program ID, cluster presets, constants
│ ├── pda.ts # PDA derivations
│ ├── wallet.ts # Wallet interface + Keypair adapter
│ ├── fees.ts # mirrors on-chain fee math
│ ├── deadlines.ts # market phase computation
│ ├── errors.ts # CypherErrorCode + Anchor error walker
│ ├── client.ts # CypherClient — single import surface
│ ├── accounts/ # fetch + memcmp filter helpers per account
│ ├── arcium/ # MPC glue (cipher, queue accounts, offsets)
│ ├── instructions/ # raw TransactionInstruction builders (29 ix, v0.2)
│ ├── actions/ # high-level flows + progress events
│ ├── events/ # typed event parser + WS/poll subscriptions
│ ├── idl/ # synced Anchor IDL (source of truth)
│ └── node/ # node-only admin helpers
├── react/src/ # @cypher-zk/sdk/react hooks subpath
├── examples/react-vite/ # minimal Vite app smoke
├── docs/
│ ├── architecture.md # three-surface model + module map
│ └── flows.md # one diagram per user flow
└── tests/
├── unit/ # 150 tests, 712 assertions — pure, no chain
├── integration/ # INTEGRATION=1 — Arcium localnet lifecycle
└── devnet/ # DEVNET=1 — devnet read-only + opt-in writesSee docs/architecture.md for the three-surface
model (circuit / program / client), the account topology, and the full
runtime flow of a private bet. See docs/flows.md for
per-flow ASCII diagrams.
Cluster strategy
The SDK is cluster-agnostic at runtime: it reads the accepted SPL mint
from GlobalState.accepted_mint on every flow rather than hard-coding
CSDC vs USDC. The same build works against any Cypher deployment.
| Cluster | RPC default | Accepted mint | Arcium offset |
| ---------- | ----------------------------- | ------------------ | --------------- |
| devnet | api.devnet.solana.com | CSDC (8AF9BABN…) | 456 |
| mainnet | api.mainnet-beta.solana.com | USDC (EPjFWdd5…) | (set at deploy) |
| localnet | localhost:8899 | CSDC (test build) | 1116522022 |
// Explicit preset:
const client = new CypherClient({ connection, wallet, cluster: "devnet" });
// Custom config — override RPC and/or Arcium cluster offset:
const client = new CypherClient({
connection,
wallet,
cluster: {
name: "devnet",
rpc: "https://my-helius-endpoint.example",
arciumClusterOffset: 456,
expectedMint: KNOWN_MINTS.devnetCSDC,
},
});Scripts
| Command | Purpose |
| -------------------------- | --------------------------------------------- |
| bun install | Install deps |
| bun test | All unit suites (gates skipped) |
| bun run test:unit | Unit-only |
| bun run test:integration | INTEGRATION=1 — Arcium localnet lifecycle |
| bun run test:devnet | DEVNET=1 — devnet read-only + opt-in writes |
| bun run typecheck | tsc --noEmit (strict) |
| bun run build | ESM + .d.ts via tsup → dist/ |
| bun run sync:idl | Re-copy IDL + types from ../cypher-main |
| bun run prepublishOnly | sync IDL → typecheck → unit tests → build |
Security
The Cypher protocol underwent a two-round security audit. All 9
findings — 3 critical, 2 high, 4 medium/low — have been remediated and
verified. See cypher-main/audit_report.md for the full report.
The SDK adds defense-in-depth client-side:
computeFeesmirrors the on-chain math exactly —placeBetpre-asserts the encryptednet_amountwill match what the contract computes from the gross. The contract's circuit also verifies this on-chain (audit fix H-1) — the SDK guard surfaces the failure as a clean rejection instead of a wasted MPC computation.marketPhaseblocks known-bad claims —claimPayout/claimRefundrefuse to send if the market isn't in the right phase, saving users from on-chainMarketStillOpen/ClaimPeriodExpiredrejections.- Position double-claim guard —
claimPayoutreadsposition.claimedbefore submitting (the contract enforces it again as audit fix H-2). - Strict ciphertext-length enforcement — every builder that takes a
Uint8Arrayciphertext assertslength === 32before assembling the ix, catching the "combined ciphertext arrays" mistake from the Arcium skill before it hits the program. - v0.2 dispute-window phase gating —
claimPayoutrejects pre-flight ifmarket.state === PendingResolutionwith a typed error pointing the caller atfinalizeResolution, so users don't pay MPC compute fees on a guaranteed-to-fail tx.flagResolution/finalizeResolution/adminOverrideResolutionvalidate againstmarket.disputed+challengeDeadlineclient-side.
Changelog
0.2.0 — dispute window
- NEW: 3 instructions (
flagResolution,finalizeResolution,adminOverrideResolution) + 3 actions + 3 React hooks + 3 events- 6 error codes (6036–6041).
- NEW:
MarketState.PendingResolution = 4and three newmarketPhasevalues:pendingResolution,awaitingFinalize,disputed. - NEW:
MarketgainschallengePeriod,challengeDeadline,disputedfields; layout offsets shifted. - NEW:
MIN_CHALLENGE_PERIOD_SECS/MAX_CHALLENGE_PERIOD_SECSconstants (24h / 48h). - BREAKING:
createMarketIx/createMarketMultiIxraw builders require a newchallengePeriodarg. The high-levelclient.actions.createMarketmakes it optional (defaults to 24h). - BREAKING:
resolveMarketnow leaves the market inPendingResolution;claimPayoutopens only afterfinalizeResolutionoradminOverrideResolutionruns. - DX:
claimPayoutActionpre-flight error now explicitly namesfinalizeResolutionas the next step when state isPendingResolution.
License
Source Available — use is permitted, redistribution is not. See LICENSE for full terms.
