@cloak.dev/sdk 0.1.7 → 0.2.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
@@ -1,53 +1,81 @@
1
1
  # @cloak.dev/sdk
2
2
 
3
- TypeScript SDK for the Cloak Protocol - Private transactions on Solana using zero-knowledge proofs.
3
+ TypeScript SDK for Cloak: shielded transactions on Solana. Shield SOL, USDC or USDT into a
4
+ per-mint pool, send privately inside the pool, withdraw to any address, or swap shielded SOL to
5
+ USDC/USDT with the output landing in a public token account. Groth16 proofs are generated
6
+ client-side (snarkjs) and verified on-chain by the Cloak program.
4
7
 
5
- ## Features
6
-
7
- - 🔒 **Private Transfers**: Shield, send, and reclaim SOL or SPL tokens via the UTXO model and zero-knowledge proofs
8
- - 🔁 **Shield-to-shield**: Move funds privately between UTXOs (`transfer`) without ever de-shielding
9
- - 💱 **Token Swaps**: Swap shielded SOL for SPL tokens via Jupiter (`swapUtxo`) — SOL → USDC, BRZ, etc.
10
- - 🔐 **Type-Safe**: Full TypeScript types; the `getConfig()` snapshot is structurally narrower than the constructor input so secrets cannot be re-exposed by accident
11
- - 🌐 **Cross-Platform**: Browser (wallet adapter) and Node.js (`Keypair`) modes
12
- - ⚡ **Functional API**: `transact` / `transfer` / `partialWithdraw` / `fullWithdraw` / `swapUtxo` — pass connection + program + relay + signer per call
8
+ Version 0.2.0 targets the mainnet program deployed on 2026-08-24 and the ceremony-frozen
9
+ `cloak-transaction-0.2.0` circuit bundle. See [Version and compatibility](#version-and-compatibility).
13
10
 
14
11
  ## Installation
15
12
 
16
13
  ```bash
17
14
  npm install @cloak.dev/sdk @solana/web3.js
18
- # or
19
- yarn add @cloak.dev/sdk @solana/web3.js
20
- # or
21
- pnpm add @cloak.dev/sdk @solana/web3.js
15
+ # swaps and SPL pools also need:
16
+ npm install @solana/spl-token
22
17
  ```
23
18
 
24
- **Note**: For swap functionality, you'll also need `@solana/spl-token`:
25
- ```bash
26
- npm install @solana/spl-token
19
+ Node >= 18. Ships ESM and CJS builds with type declarations. Runs in Node and in the browser
20
+ (wallet-adapter signing); the on-chain Merkle-tree rebuild fallback is not available in browsers.
21
+
22
+ ## Networks
23
+
24
+ ### Mainnet (defaults)
25
+
26
+ | Setting | Value |
27
+ | --- | --- |
28
+ | Program | `zh1eLd6rSphLejbFfJEneUwzHRfMKxgzrgkfwA6qRkW` (`CLOAK_PROGRAM_ID`) |
29
+ | Relay | `https://api.cloak.ag`, passed as `relayUrl` or via `CLOAK_RELAY_URL` (see below) |
30
+ | Circuits | `https://storage.googleapis.com/cloak-circuits/circuits/0.2.0` (the SDK default) |
31
+ | Pools | WSOL (`NATIVE_SOL_MINT`), USDC `EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v`, USDT `Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB` |
32
+ | RPC | any mainnet RPC; use `new Connection(url, "confirmed")` |
33
+
34
+ The relay URL is never defaulted. `transact` resolves it from the `relayUrl` option, then from
35
+ `CLOAK_RELAY_URL`, and with neither set it throws before anything is signed or sent. This is
36
+ deliberate: a script that forgets the option must not ship its proof to production. Name production
37
+ explicitly:
38
+
39
+ ```ts
40
+ relayUrl: "https://api.cloak.ag"
27
41
  ```
28
42
 
29
- ## Quick Start
43
+ Every flow needs it, deposits included: the SDK registers the wallet's viewing key through the relay
44
+ before its first transaction (see [Recovery and discovery](#recovery-and-discovery-viewing-key)),
45
+ deposits fetch the signed risk quote the program requires from `<relayUrl>/range-quote`, and sends,
46
+ withdrawals and swaps are submitted through it. `enforceViewingKeyRegistration: false` skips only the
47
+ registration step (the shipped examples set it on a local fork); it is not a no-relay mode.
48
+
49
+ Do not expose `programId`, the relay URL or the circuits base as end-user input. Wallet adapters are
50
+ the in-app signer; keypair files belong to scripts only.
51
+
52
+ ### Devnet and local
53
+
54
+ The same SDK runs against a devnet or local deployment by pointing the three coordinates at it:
30
55
 
31
- ### SDK Defaults (Recommended)
56
+ ```bash
57
+ SOLANA_RPC_URL=https://api.devnet.solana.com # or http://127.0.0.1:8899 for a local fork
58
+ CLOAK_RELAY_URL=<relay serving that deployment>
59
+ CLOAK_PROGRAM_ID=<program id of that deployment> # examples read this; pass it as programId in your code
60
+ ```
32
61
 
33
- - Standard integrations should use SDK defaults for program, relay, and circuits.
34
- - Do not expose protocol-level config (`programId`, relay URL, circuits URL) as end-user input.
35
- - `transact`, `partialWithdraw`, and `fullWithdraw` already include stale-root retry handling.
36
- - For simple CLI sends, require only `SOLANA_RPC_URL` and `KEYPAIR_PATH`.
62
+ The shipped examples default to a local fork (`http://127.0.0.1:8899`) with a local relay
63
+ (`http://127.0.0.1:5500`); see [Examples](#examples).
37
64
 
38
- ### Minimal Private SOL Send (Single File, Keypair)
65
+ ## Quick start: private SOL send (single file, keypair)
39
66
 
40
- Use this contract for one-shot scripts and AI-generated snippets.
67
+ Shield from the signer, then unshield to the recipient. Use this shape for one-shot scripts.
41
68
 
42
69
  ```ts
43
70
  import { readFileSync } from "fs";
44
71
  import {
45
72
  CLOAK_PROGRAM_ID,
46
73
  NATIVE_SOL_MINT,
47
- createUtxo,
74
+ createRecoverableDepositUtxo,
48
75
  createZeroUtxo,
49
76
  fullWithdraw,
50
77
  generateUtxoKeypair,
78
+ getNkFromUtxoPrivateKey,
51
79
  transact,
52
80
  } from "@cloak.dev/sdk";
53
81
  import { Connection, Keypair, PublicKey } from "@solana/web3.js";
@@ -59,45 +87,54 @@ async function main() {
59
87
  }
60
88
 
61
89
  const rpcUrl = process.env.SOLANA_RPC_URL;
90
+ const relayUrl = process.env.CLOAK_RELAY_URL;
62
91
  const keypairPath = process.env.KEYPAIR_PATH;
63
- if (!rpcUrl || !keypairPath) {
64
- throw new Error("Set SOLANA_RPC_URL and KEYPAIR_PATH");
92
+ if (!rpcUrl || !relayUrl || !keypairPath) {
93
+ throw new Error("Set SOLANA_RPC_URL, CLOAK_RELAY_URL and KEYPAIR_PATH");
65
94
  }
66
95
 
67
96
  const connection = new Connection(rpcUrl, "confirmed");
68
97
  const signer = Keypair.fromSecretKey(
69
98
  Uint8Array.from(JSON.parse(readFileSync(keypairPath, "utf8"))),
70
99
  );
71
-
72
100
  const recipient = new PublicKey(recipientArg);
73
- const amountLamports = BigInt(lamportsArg);
101
+ const amount = BigInt(lamportsArg);
74
102
 
103
+ // The wallet's viewing base (nk). A real wallet derives it from its seed; a throwaway
104
+ // keypair keeps this script self-contained.
75
105
  const owner = await generateUtxoKeypair();
76
- const output = await createUtxo(amountLamports, owner, NATIVE_SOL_MINT);
106
+ const nk = getNkFromUtxoPrivateKey(owner.privateKey);
77
107
 
108
+ // Shield. The note's keypair and blinding are derived from (nk, noteSalt) and the salt is
109
+ // published inside the nk-encrypted chain note, so a scan holding only nk can rebuild it.
110
+ const { utxo, noteSalt } = await createRecoverableDepositUtxo(amount, nk, NATIVE_SOL_MINT);
78
111
  const deposited = await transact(
79
112
  {
80
113
  inputUtxos: [await createZeroUtxo(NATIVE_SOL_MINT)],
81
- outputUtxos: [output],
82
- externalAmount: amountLamports,
114
+ outputUtxos: [utxo],
115
+ externalAmount: amount,
83
116
  depositor: signer.publicKey,
84
117
  },
85
118
  {
86
119
  connection,
87
120
  programId: CLOAK_PROGRAM_ID,
121
+ relayUrl,
88
122
  depositorKeypair: signer,
89
- walletPublicKey: signer.publicKey,
90
- enforceViewingKeyRegistration: false,
123
+ chainNoteViewingKeyNk: nk,
124
+ chainNoteSalt: noteSalt,
91
125
  },
92
126
  );
93
127
 
94
- const withdrawn = await fullWithdraw(deposited.outputUtxos, recipient, {
128
+ // Unshield to the recipient. outputUtxos[0] is the deposited note ([1] is the zero pad).
129
+ // The program takes its fee here (0.005 SOL + 0.3%).
130
+ const withdrawn = await fullWithdraw([deposited.outputUtxos[0]], recipient, {
95
131
  connection,
96
132
  programId: CLOAK_PROGRAM_ID,
133
+ relayUrl,
97
134
  depositorKeypair: signer,
98
135
  walletPublicKey: signer.publicKey,
136
+ chainNoteViewingKeyNk: nk,
99
137
  cachedMerkleTree: deposited.merkleTree,
100
- enforceViewingKeyRegistration: false,
101
138
  });
102
139
 
103
140
  console.log(withdrawn.signature);
@@ -112,327 +149,433 @@ main().catch((e) => {
112
149
  Run:
113
150
 
114
151
  ```bash
115
- SOLANA_RPC_URL="https://api.mainnet-beta.solana.com" \
152
+ SOLANA_RPC_URL="https://<your mainnet rpc>" \
153
+ CLOAK_RELAY_URL="https://api.cloak.ag" \
116
154
  KEYPAIR_PATH="/absolute/path/to/id.json" \
117
155
  npx tsx send-sol-private.ts <recipientPubkey> <lamports>
118
156
  ```
119
157
 
120
- Hard rules for minimal scripts:
158
+ Rules for minimal scripts:
121
159
 
122
- - Use lamports from CLI (`<lamports>`) and keep transaction math in `bigint`.
123
- - Use `KEYPAIR_PATH`; do not ask for raw private key env vars.
124
- - Do not parse SOL decimals with float math (`parseFloat`, `AMOUNT_SOL`).
125
- - Keep `programId` fixed to `CLOAK_PROGRAM_ID` (no end-user override).
160
+ - Take lamports from the CLI and keep all amount math in `bigint`; never parse SOL with float math.
161
+ - `<lamports>` must be at least `MIN_DEPOSIT_LAMPORTS` (0.01 SOL) and the withdrawal must exceed
162
+ its fee (see [Fees and limits](#fees-and-limits)).
163
+ - Use `KEYPAIR_PATH`; do not read raw private keys from the environment.
164
+ - Keep `programId` fixed to `CLOAK_PROGRAM_ID`.
165
+ - `transact`, `partialWithdraw` and `fullWithdraw` already retry on a stale Merkle root; do not
166
+ wrap them in your own retry loop.
126
167
 
127
- ### Maintained Examples
168
+ ### Browser (wallet adapter)
128
169
 
129
- ```bash
130
- npm run example:fast-send
131
- npm run example:fast-usdc-send
132
- npm run example:usdc-pool-transfer
133
- npm run example:swap
134
- npm run example:swap-recovery
135
- npm run example:swap-usdc
136
- npm run example:swap-brz
137
- npm run example:transfer
138
- npm run test:examples
170
+ Replace `depositorKeypair` with the adapter's signers:
171
+
172
+ ```ts
173
+ await transact(params, {
174
+ connection,
175
+ programId: CLOAK_PROGRAM_ID,
176
+ relayUrl,
177
+ signTransaction: (tx) => wallet.signTransaction(tx),
178
+ signMessage: (message) => wallet.signMessage(message), // signs the viewing-key registration challenge
179
+ depositorPublicKey: wallet.publicKey,
180
+ walletPublicKey: wallet.publicKey,
181
+ chainNoteViewingKeyNk: nk,
182
+ chainNoteSalt: noteSalt,
183
+ });
139
184
  ```
140
185
 
141
- `example:transfer` runs deposit -> shield-to-shield transfer (`public_amount=0`) -> recipient withdraw verification and prints stable `FULL_SIG|transfer|deposit|...`, `FULL_SIG|transfer|tx|...`, and `COMMITMENT_INDICES|transfer|[...]` markers.
186
+ `signTransaction` must accept both a legacy `Transaction` and a `VersionedTransaction` (v0 with
187
+ address lookup tables); deposits use either depending on the path taken. `signMessage` is required
188
+ unless `enforceViewingKeyRegistration: false` is set; without it the call fails with
189
+ "Viewing key registration is mandatory: signMessage (wallet) or depositorKeypair is required."
142
190
 
143
- `example:fast-usdc-send` is the one-shot private send path for USDC recipients (deposit SOL, swap privately to USDC, deliver to recipient ATA).
191
+ ## The UTXO model
144
192
 
145
- `example:usdc-pool-transfer` is the mint-scoped USDC pool transfer path (User A deposits USDC into Cloak USDC pool, sends shielded USDC to User B, and User B spends the received shielded note).
193
+ - A shielded balance is a set of notes (`Utxo`): `{ amount, keypair, blinding, mintAddress, index?, commitment? }`.
194
+ Amounts are `bigint` in base units (lamports, or 10^-6 for USDC/USDT).
195
+ - Every transaction is 2-in / 2-out. Pad unused slots with `createZeroUtxo(mint)`.
196
+ - `externalAmount > 0` is a deposit (funds enter from `depositor`), `< 0` is a withdrawal to
197
+ `recipient`, `0` is a private send inside the pool.
198
+ - Pools are per mint. All inputs and outputs of one transaction live in the same pool;
199
+ `NATIVE_SOL_MINT` is the SOL pool.
200
+ - `TransactResult.outputUtxos` are the notes you can spend next (leaf `index` set). Persist them
201
+ with `serializeUtxo` / `deserializeUtxo`. Pass `result.merkleTree` as `cachedMerkleTree` to the
202
+ next call to skip re-fetching commitments.
203
+ - The Merkle tree is read at `confirmed`, so a note is spendable as soon as its deposit is confirmed.
146
204
 
147
- `example:swap-usdc` is the canonical Nora swap evidence path (SOL -> USDC) and prints quote/route details, `FULL_SIG|swap-usdc|transact_swap|...`, `FULL_SIG|swap-usdc|swap_completed|...`, and `RECIPIENT_USDC_BALANCE|...` markers.
205
+ Entry points: `transact` (general), `transfer`, `partialWithdraw`, `fullWithdraw`,
206
+ `swapUtxo` / `swapWithChange`.
148
207
 
149
- `example:swap-recovery` focuses on pending/timeout behavior. It submits a swap, tracks `swap_phase` + `slots_remaining` from relay `/status`, and can optionally call `close_timed_out` (`AUTO_CLOSE_TIMED_OUT=1`) once `can_recover=true`.
208
+ ### Private send (shield-to-shield)
150
209
 
151
- `example:swap-brz` first attempts BRZ (`FtgGSFADXBtroxq8VCausXRr2of47QBf5AS1NtZCu4GD`) and automatically falls back to USDC if BRZ routing is unavailable. It always emits `QUOTE_ROUTE|...`, `SWAP_OUTPUT_MINT|...`, and (when fallback happens) `BRZ_FALLBACK_TO|...`.
210
+ ```ts
211
+ import { deriveViewingKeyFromNk, transfer } from "@cloak.dev/sdk";
152
212
 
153
- `test:examples` runs all maintained examples in `CLOAK_EXAMPLE_DRY_RUN=1` mode so CI/local checks validate script wiring without requiring funded wallets or live RPC execution.
213
+ const result = await transfer(
214
+ [myNote], // inputs to spend (same pool)
215
+ recipientUtxoPublicKey, // bigint: the recipient's UTXO public key
216
+ amount,
217
+ {
218
+ connection,
219
+ programId: CLOAK_PROGRAM_ID,
220
+ relayUrl,
221
+ depositorKeypair: signer,
222
+ walletPublicKey: signer.publicKey,
223
+ chainNoteViewingKeyNk: myNk,
224
+ // The recipient's X25519 viewing public key. With it the recipient can discover the note
225
+ // from chain with their own key (scanRecipientDeliveryNotes). Without it the note is still
226
+ // valid but must be handed over out of band.
227
+ recipientViewingPublicKey: deriveViewingKeyFromNk(recipientNk).publicKey,
228
+ cachedMerkleTree: previous.merkleTree,
229
+ },
230
+ );
231
+ ```
154
232
 
155
- ### Node.js (with Keypair)
233
+ `transfer` builds the recipient note as output 0 and returns the change (owned by the input's
234
+ keypair) as output 1. Calling `transact` directly with `externalAmount: 0n` does the same; keep the
235
+ recipient note at `outputUtxos[0]`, which is the one the delivery carrier is built for.
236
+ The recipient shares two values with the sender: their UTXO public key (`UtxoKeypair.publicKey`)
237
+ and their viewing public key (`deriveViewingKeyFromNk(nk).publicKey`).
156
238
 
157
- ```typescript
158
- import {
159
- CLOAK_PROGRAM_ID,
160
- NATIVE_SOL_MINT,
161
- createUtxo,
162
- createZeroUtxo,
163
- fullWithdraw,
164
- generateUtxoKeypair,
165
- transact,
166
- } from "@cloak.dev/sdk";
167
- import { Connection, Keypair, PublicKey } from "@solana/web3.js";
239
+ ### Withdraw (unshield)
240
+
241
+ ```ts
242
+ await fullWithdraw([note], recipientWallet, options); // whole note
243
+ await partialWithdraw([note], recipientWallet, amount, options); // amount out, change stays shielded
244
+ ```
168
245
 
169
- const connection = new Connection("https://api.mainnet-beta.solana.com");
170
- const signer = Keypair.fromSecretKey(/* your secret key */);
246
+ For SPL pools the recipient receives tokens in their associated token account. The program charges
247
+ its fee on the withdrawn amount ([Fees and limits](#fees-and-limits)).
171
248
 
172
- // Generate a UTXO keypair (owner of the shielded balance)
173
- const owner = await generateUtxoKeypair();
249
+ ### SPL pools (USDC, USDT)
174
250
 
175
- // Build a 0.01 SOL output UTXO and shield it
176
- const amountLamports = 10_000_000n;
177
- const output = await createUtxo(amountLamports, owner, NATIVE_SOL_MINT);
251
+ Same API with the pool mint on every note:
178
252
 
179
- const deposited = await transact(
180
- {
181
- inputUtxos: [await createZeroUtxo(NATIVE_SOL_MINT), await createZeroUtxo(NATIVE_SOL_MINT)],
182
- outputUtxos: [output, await createZeroUtxo(NATIVE_SOL_MINT)],
183
- externalAmount: amountLamports, // positive = deposit
184
- depositor: signer.publicKey,
185
- },
253
+ ```ts
254
+ const usdc = new PublicKey("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v");
255
+ const { utxo, noteSalt } = await createRecoverableDepositUtxo(2_000_000n, nk, usdc); // 2.00 USDC
256
+ await transact(
257
+ { inputUtxos: [await createZeroUtxo(usdc)], outputUtxos: [utxo], externalAmount: 2_000_000n, depositor: signer.publicKey },
258
+ { connection, programId: CLOAK_PROGRAM_ID, relayUrl, depositorKeypair: signer, chainNoteViewingKeyNk: nk, chainNoteSalt: noteSalt },
259
+ );
260
+ ```
261
+
262
+ Minimum SPL deposit is 1.00 token. SOL deposits fit in a single v0 packet with the production
263
+ lookup table; SPL deposits currently still create a small supplemental lookup table
264
+ (about 0.0056 SOL rent, reclaimable by the depositor). Details: [docs/DEPOSIT-SIZE-NOTES.md](docs/DEPOSIT-SIZE-NOTES.md).
265
+
266
+ ### Shielded swap (SOL to USDC/USDT)
267
+
268
+ Spends shielded SOL and delivers the output token to a public associated token account, routed
269
+ through Jupiter. Swap input is SOL only (the pool is wSOL-locked).
270
+
271
+ ```ts
272
+ import { swapWithChange } from "@cloak.dev/sdk";
273
+ import { getAssociatedTokenAddressSync } from "@solana/spl-token";
274
+
275
+ const recipientAta = getAssociatedTokenAddressSync(usdc, recipientWallet);
276
+
277
+ const swap = await swapWithChange(
278
+ [solNote],
279
+ swapAmount, // lamports leaving the pool; the program's fee is taken from this
280
+ usdc,
281
+ recipientAta,
282
+ minOutputAmount, // proof-bound floor; quote the fee-adjusted amount and use the quote's threshold
186
283
  {
187
284
  connection,
188
285
  programId: CLOAK_PROGRAM_ID,
189
- relayUrl: "https://api.cloak.ag",
286
+ relayUrl,
190
287
  depositorKeypair: signer,
288
+ walletPublicKey: signer.publicKey,
289
+ chainNoteViewingKeyNk: nk,
290
+ swapSlippageBps: 100,
191
291
  },
292
+ recipientWallet, // required on mainnet: the wallet the swap's risk quote is issued for
192
293
  );
193
- console.log("Deposit signature:", deposited.signature);
194
294
 
195
- // Reclaim the full balance to a public address
196
- const recipient = new PublicKey("RECIPIENT_ADDRESS");
197
- const withdrawn = await fullWithdraw(deposited.outputUtxos, recipient, {
198
- connection,
199
- programId: CLOAK_PROGRAM_ID,
200
- relayUrl: "https://api.cloak.ag",
201
- depositorKeypair: signer,
202
- cachedMerkleTree: deposited.merkleTree,
203
- });
204
- console.log("Withdraw signature:", withdrawn.signature);
295
+ swap.signature; // the executed swap; swapWithChange resolves only once execution has completed
296
+ swap.refund; // persist: the refund-fallback secret (see below)
205
297
  ```
206
298
 
207
- ### React/Next.js (with Wallet Adapter)
299
+ `swapUtxo` is the lower-level form taking `UtxoSwapParams` and an explicit change note. Quote the
300
+ amount after the protocol fee (`swapAmount - calculateFeeBigint(swapAmount)` for SOL) so
301
+ `minOutputAmount` is achievable. `outputMint`, `recipientAta` and `minOutputAmount` are bound into
302
+ the proof and cannot be changed after it is generated.
208
303
 
209
- ```typescript
210
- import {
211
- CLOAK_PROGRAM_ID,
212
- NATIVE_SOL_MINT,
213
- createUtxo,
214
- createZeroUtxo,
215
- generateUtxoKeypair,
216
- transact,
217
- } from "@cloak.dev/sdk";
218
- import { useConnection, useWallet } from "@solana/wallet-adapter-react";
219
- import { useCallback } from "react";
220
-
221
- export function DepositButton() {
222
- const { connection } = useConnection();
223
- const wallet = useWallet();
224
-
225
- const onClick = useCallback(async () => {
226
- if (!wallet.publicKey || !wallet.signTransaction) return;
227
-
228
- const owner = await generateUtxoKeypair();
229
- const amount = 10_000_000n; // 0.01 SOL — SDK-enforced minimum
230
- const output = await createUtxo(amount, owner, NATIVE_SOL_MINT);
231
-
232
- const result = await transact(
233
- {
234
- inputUtxos: [await createZeroUtxo(NATIVE_SOL_MINT), await createZeroUtxo(NATIVE_SOL_MINT)],
235
- outputUtxos: [output, await createZeroUtxo(NATIVE_SOL_MINT)],
236
- externalAmount: amount,
237
- depositor: wallet.publicKey,
238
- },
239
- {
240
- connection,
241
- programId: CLOAK_PROGRAM_ID,
242
- relayUrl: "https://api.cloak.ag",
243
- wallet: {
244
- publicKey: wallet.publicKey,
245
- signTransaction: wallet.signTransaction,
246
- },
247
- },
248
- );
249
- console.log("Deposited:", result.signature);
250
- }, [connection, wallet]);
251
-
252
- return <button onClick={onClick}>Deposit 0.01 SOL</button>;
304
+ #### Timed-out swaps
305
+
306
+ `swapWithChange` polls the swap status and throws if the swap is refunded, cancelled or fails, or if
307
+ polling runs out (`swapStatusMaxAttempts` x `swapStatusDelayMs`, default 60 x 2 s); the result above
308
+ exists only for a completed swap. On a timeout the program returns the principal, net of the swap
309
+ fee, to the SOL pool as a refund note, which is spent through the normal withdraw path. When the
310
+ swap was submitted with `chainNoteViewingKeyNk` (`swap.refund.derivedFromNk === true`) that note is
311
+ discoverable from the key alone:
312
+
313
+ ```ts
314
+ import { discoverSwapRefunds, fullWithdraw, NATIVE_SOL_MINT, type Utxo } from "@cloak.dev/sdk";
315
+
316
+ const refunds = await discoverSwapRefunds(connection, CLOAK_PROGRAM_ID, nk); // DiscoveredSwapRefund[]
317
+ for (const refund of refunds) {
318
+ const note: Utxo = {
319
+ amount: refund.amount, // principal net of the swap fee
320
+ keypair: refund.keypair,
321
+ blinding: refund.blinding,
322
+ mintAddress: NATIVE_SOL_MINT,
323
+ commitment: refund.commitment,
324
+ index: Number(refund.leafIndex),
325
+ };
326
+ await fullWithdraw([note], recipientWallet, options);
253
327
  }
254
328
  ```
255
329
 
256
- The `CloakSDK` class is kept as a thin config + read-only chain helper
257
- (`getPublicKey`, `getCurrentRoot`, `getMerkleProof`, `getTransactionStatus`,
258
- `importWalletKeys`, `exportWalletKeys`, `getConfig`). All transaction-emitting
259
- calls use the standalone helpers above.
330
+ Swaps that executed leave no refund note, and an empty result only says that none of the scanned
331
+ leaves derive from this `nk`. A swap submitted without `chainNoteViewingKeyNk` has `swap.refund`
332
+ (hex `privateKey`, `publicKey`, `blinding`) as the only copy of its secret; rebuild the note from it
333
+ with the on-chain `SwapState.sol_amount` as the amount and
334
+ `computeSwapRefundCommitment(amount, publicKey, blinding)` as the commitment, then look up the leaf
335
+ index as in [Recovery and discovery](#recovery-and-discovery-viewing-key).
260
336
 
261
- ## Core Functions
337
+ ## Recovery and discovery (viewing key)
262
338
 
263
- ### Deposit (`transact` with positive `externalAmount`)
264
-
265
- ```typescript
266
- const result = await transact(
267
- {
268
- inputUtxos: [await createZeroUtxo(), await createZeroUtxo()],
269
- outputUtxos: [outputUtxo, await createZeroUtxo()],
270
- externalAmount: 10_000_000n, // positive = deposit
271
- depositor: signer.publicKey,
272
- },
273
- { connection, programId: CLOAK_PROGRAM_ID, relayUrl: "https://api.cloak.ag", depositorKeypair: signer },
274
- );
275
- console.log("Leaf index:", result.commitmentIndices[0]);
276
- console.log("Output UTXO:", result.outputUtxos[0]);
277
- ```
339
+ The viewing base `nk` is derived from the wallet's spend key (`getNkFromUtxoPrivateKey(privateKey)`
340
+ or `expandSpendKey(skSpend).nsk`). It decrypts the chain notes the SDK attaches to transactions and
341
+ opens notes delivered by other people's private sends. Recoverable deposit notes and swap refund
342
+ notes also derive their secrets from it, which is what makes key-only recovery possible, so store
343
+ it with the same care as the spend key.
278
344
 
279
- ### Withdraw
345
+ Before a wallet's first transaction the SDK registers `nk` with the relay for compliance scanning:
346
+ it requests a challenge from `<relayUrl>/viewing-key/challenge`, signs it with `signMessage` (wallet)
347
+ or `depositorKeypair`, and posts `nk` with the signature to `<relayUrl>/viewing-key/register`.
348
+ Nothing viewing-key related is written on-chain. The step is on by default;
349
+ `enforceViewingKeyRegistration: false` disables it.
280
350
 
281
- ```typescript
282
- import { fullWithdraw, partialWithdraw } from "@cloak.dev/sdk";
351
+ ```ts
352
+ import { scanTransactions, fetchCommitments, type Utxo } from "@cloak.dev/sdk";
283
353
 
284
- // Reclaim everything in one or more input UTXOs to a public address.
285
- const result = await fullWithdraw(myUtxos, recipientPublicKey, {
354
+ const scan = await scanTransactions({
286
355
  connection,
287
356
  programId: CLOAK_PROGRAM_ID,
288
- relayUrl: "https://api.cloak.ag",
289
- depositorKeypair: signer,
357
+ viewingKeyNk: nk,
358
+ ownerUtxoPublicKey: owner.publicKey, // authenticates delivered notes' commitments
359
+ deliveryMints: [NATIVE_SOL_MINT],
360
+ untilSignature: lastScan?.lastSignature, // incremental scans
290
361
  });
291
362
 
292
- // Or withdraw a portion and keep the change shielded.
293
- const partial = await partialWithdraw(myUtxos, recipientPublicKey, 5_000_000n, {
294
- connection,
295
- programId: CLOAK_PROGRAM_ID,
296
- relayUrl: "https://api.cloak.ag",
297
- depositorKeypair: signer,
298
- });
363
+ scan.recoveredDepositNotes; // own deposits built with createRecoverableDepositUtxo, in spendable form
364
+ scan.deliveredNotes; // notes sent to this wallet (amount + blinding; the keypair is your own)
365
+ scan.transactions; // history rows for compliance reporting
299
366
  ```
300
367
 
301
- ### Shield-to-shield Transfer
368
+ `scanRecipientDeliveryNotes` returns only the delivered notes. Deposit recovery applies to deposits
369
+ built with `createRecoverableDepositUtxo` (the default in the examples); a deposit built with
370
+ `createUtxo` has a random blinding that is written nowhere and appears as history only.
302
371
 
303
- ```typescript
304
- import { transfer } from "@cloak.dev/sdk";
372
+ A recovered or delivered note needs its leaf index before it can be spent. Look it up by commitment.
373
+ The two record types differ: `RecoveredDepositNoteRecord` carries the note's own `keypair`, a
374
+ `bigint` `commitment` and a `PublicKey` `mintAddress`; `DeliveredNote` carries a hex `commitment`,
375
+ a base58 `mint` (set only when the scan was given `ownerUtxoPublicKey`) and no keypair, because the
376
+ keypair is yours.
305
377
 
306
- const result = await transfer(myUtxos, recipientUtxoPublicKey, amountLamports, {
307
- connection,
308
- programId: CLOAK_PROGRAM_ID,
309
- relayUrl: "https://api.cloak.ag",
310
- depositorKeypair: signer,
311
- });
378
+ ```ts
379
+ import { PublicKey } from "@solana/web3.js";
380
+
381
+ const entries = await fetchCommitments(relayUrl, { mint: NATIVE_SOL_MINT });
382
+ const indexOf = (commitment: bigint) =>
383
+ entries.find((e) => BigInt("0x" + e.commitment.replace(/^0x/, "")) === commitment)?.index;
384
+
385
+ // Own deposit recovered from nk
386
+ const recovered = scan.recoveredDepositNotes[0];
387
+ const ownNote: Utxo = {
388
+ amount: recovered.amount,
389
+ keypair: recovered.keypair,
390
+ blinding: recovered.blinding,
391
+ mintAddress: recovered.mintAddress,
392
+ commitment: recovered.commitment,
393
+ index: indexOf(recovered.commitment),
394
+ };
395
+
396
+ // Note delivered by someone else's private send
397
+ const delivered = scan.deliveredNotes[0];
398
+ if (!delivered.commitmentVerified || !delivered.mint) {
399
+ throw new Error("scan with ownerUtxoPublicKey to authenticate delivered notes");
400
+ }
401
+ const deliveredCommitment = BigInt("0x" + delivered.commitment);
402
+ const deliveredNote: Utxo = {
403
+ amount: delivered.amount,
404
+ keypair: owner, // the UtxoKeypair whose publicKey the sender used
405
+ blinding: delivered.blinding,
406
+ mintAddress: new PublicKey(delivered.mint),
407
+ commitment: deliveredCommitment,
408
+ index: indexOf(deliveredCommitment),
409
+ };
312
410
  ```
313
411
 
314
- ### Swap (SOL SPL token)
412
+ `discoverSwapRefunds(connection, programId, nk)` returns `DiscoveredSwapRefund` records (`keypair`,
413
+ `blinding`, `amount`, `commitment`, `leafIndex: bigint`); build the `Utxo` from one as shown under
414
+ [Timed-out swaps](#timed-out-swaps).
315
415
 
316
- ```typescript
317
- import { swapUtxo } from "@cloak.dev/sdk";
416
+ `toComplianceReport(scan)` and `formatComplianceCsv(report)` turn a scan into a report.
318
417
 
319
- const result = await swapUtxo(
320
- {
321
- inputUtxos: myUtxos,
322
- swapAmount: 10_000_000n,
323
- outputMint: new PublicKey("TOKEN_MINT_ADDRESS"),
324
- recipientAta: recipientTokenAccount,
325
- minOutputAmount: 1_000_000n,
326
- },
327
- {
328
- connection,
329
- programId: CLOAK_PROGRAM_ID,
330
- relayUrl: "https://api.cloak.ag",
331
- depositorKeypair: signer,
332
- },
333
- );
418
+ ## Nullifiers and spent checks
419
+
420
+ Two exports share a name and are not interchangeable:
421
+
422
+ - `computeUtxoNullifier(utxo)` is the circuit's scheme: `Poseidon(commitment, index, signature)`.
423
+ This is the value the program records when a note is spent. Use it for spent checks.
424
+ - `computeNullifier(skSpend, leafIndex)` is `Poseidon(sk0, sk1, leafIndex)` from the legacy
425
+ `withdraw_regular` circuit. It does not match anything the 0.2.0 program stores.
426
+
427
+ Prefer the helpers, which use the UTXO scheme:
428
+
429
+ ```ts
430
+ import { verifyUtxos, preflightNullifiers } from "@cloak.dev/sdk";
431
+
432
+ const { spent } = await verifyUtxos(notes, connection, CLOAK_PROGRAM_ID);
433
+ await preflightNullifiers(notes, connection, CLOAK_PROGRAM_ID); // throws UtxoAlreadySpentError
334
434
  ```
335
435
 
336
- ## Fee Structure
436
+ `transact` runs the preflight itself before generating a proof.
337
437
 
338
- - **Fixed Fee**: 0.005 SOL (5,000,000 lamports) on withdraw
339
- - **Variable Fee**: 0.3% of withdrawn amount
438
+ ## Fees and limits
340
439
 
341
- Use `getDistributableAmount()` to calculate the amount a recipient receives after fees:
440
+ Fees are collected on-chain by the program from each pool's `PoolConfig` account
441
+ (`["pool_config", mint]`). They apply to withdrawals and swaps, on the amount leaving the pool.
442
+ Deposits and private sends carry no protocol fee.
342
443
 
343
- ```typescript
344
- import { getDistributableAmount } from "@cloak.dev/sdk";
444
+ | Pool | Fixed fee | Rate | Minimum deposit |
445
+ | --- | --- | --- | --- |
446
+ | SOL | 0.005 SOL | 0.3% | 0.01 SOL |
447
+ | USDC | 0.45 USDC | 0.3% | 1.00 USDC |
448
+ | USDT | 0.45 USDT | 0.3% | 1.00 USDT |
345
449
 
346
- const withdrawing = 100_000_000; // 0.1 SOL
347
- const recipientReceives = getDistributableAmount(withdrawing); // ~94,700,000 lamports
450
+ A withdrawal or swap must exceed its fee or the program rejects it; deposits below the minimum are
451
+ rejected with `DepositTooSmall`.
452
+
453
+ `utils/fees.ts` mirrors the SOL pool for estimates in the UI:
454
+
455
+ ```ts
456
+ import { calculateFeeBigint, getDistributableAmount, isWithdrawAmountSufficient } from "@cloak.dev/sdk";
457
+
458
+ calculateFeeBigint(1_000_000_000n); // 8_000_000n (0.005 SOL + 0.3% of 1 SOL)
459
+ getDistributableAmount(100_000_000); // 94_700_000 (0.1 SOL withdrawal, net to recipient)
460
+ isWithdrawAmountSufficient(6_000_000n); // true: 0.006 SOL covers the 0.005018 SOL fee
348
461
  ```
349
462
 
350
- ## UTXOs
463
+ For USDC/USDT compute `450_000n + amount * 3n / 1000n`. Treat these as estimates: the deployed
464
+ `PoolConfig` is the source of truth. Solana transaction fees and, for SPL deposits, the supplemental
465
+ lookup-table rent are separate.
351
466
 
352
- A **UTXO** is a cryptographic commitment over `(amount, ownerPubkey, blinding, mintAddress)`. Each `transact` call consumes up to 2 input UTXOs and produces up to 2 outputs:
467
+ ## Circuit artifacts
353
468
 
354
- ```typescript
355
- interface Utxo {
356
- amount: bigint; // lamports for SOL, token units for SPL
357
- keypair: UtxoKeypair; // { privateKey, publicKey } — bigints
358
- blinding: bigint; // randomness
359
- mintAddress: PublicKey; // NATIVE_SOL_MINT for SOL
360
- index?: number; // leaf index in the Merkle tree (set after the UTXO is in-tree)
361
- commitment?: bigint;
362
- nullifier?: bigint;
363
- }
469
+ Proving uses the `transaction` circuit from bundle `cloak-transaction-0.2.0` (multi-party ceremony,
470
+ 6 contributors plus a public final beacon; 42,672 constraints, 9 public inputs). The verifying key is
471
+ embedded in the program.
472
+
473
+ | Artifact | SHA-256 |
474
+ | --- | --- |
475
+ | `transaction_js/transaction.wasm` | `02ec02e954ae3932827ad9de51afa597ca95569aa97fec8410879c937a58aa2b` |
476
+ | `transaction_final.zkey` | `9da7db8cb1370fc497d36a0365f1f107ab0b0c13ca66fa9f0287e5f96ee68d25` |
477
+ | `transaction.vkey.json` | `deb40e7b94eae17db2975d23dcf26c26db2a36a4f02d14a25830dee3e88fb93c` |
478
+
479
+ The SDK downloads the artifacts once per process from
480
+ `DEFAULT_TRANSACTION_CIRCUITS_URL` (`https://storage.googleapis.com/cloak-circuits/circuits/0.2.0`),
481
+ hashes them, and refuses to prove if any digest differs from the pinned values. No configuration is
482
+ needed for mainnet.
483
+
484
+ To serve the artifacts yourself (a mirror or a local directory containing
485
+ `transaction_js/transaction.wasm` and `transaction_final.zkey` with the same digests):
486
+
487
+ ```ts
488
+ import { resolveCircuitsBase, setCircuitsPath } from "@cloak.dev/sdk";
489
+
490
+ // explicit argument, else CLOAK_CIRCUITS_PATH / CLOAK_CIRCUITS, else the pinned default
491
+ setCircuitsPath(resolveCircuitsBase());
364
492
  ```
365
493
 
366
- **⚠️ Important**: persist the spend `privateKey`, `blinding`, `amount`, `index`, and `mintAddress` for every output UTXO — those are the only inputs you need to spend it later. The SDK ships `LocalStorageAdapter` for browser wallet keys; UTXO persistence is the consumer's responsibility (see `lib/utxo-manager.ts` patterns in cloak-ag/web for a reference implementation).
367
-
368
- ## Compliance Chain Scanning
369
-
370
- Cloak supports a viewing-key commitment flow for compliance and self-discovery:
371
-
372
- - The viewing key itself is never written on-chain.
373
- - Only `viewing_key_commitment = SHA256(viewing_key_public)` is stored on-chain.
374
- - A scanner recomputes this commitment from `viewing_key_public` and matches program transactions.
375
-
376
- ```mermaid
377
- sequenceDiagram
378
- autonumber
379
- participant U as User Wallet + SDK
380
- participant R as Relay
381
- participant C as Cloak Program (Solana)
382
- participant S as Scanner
383
-
384
- U->>U: Generate (vk_priv, vk_pub)
385
- U->>U: vkc = SHA256(vk_pub)
386
- U->>R: Register vk_priv (signed)
387
- U->>R: POST /transact + metadata_bundle + viewing_key_commitment=vkc
388
- R->>C: Submit instruction [proof|public_inputs|vkc]
389
- C-->>R: Confirm tx signature
390
- R-->>U: Return signature
391
-
392
- S->>S: Compute target_vkc from vk_pub
393
- S->>C: Fetch recent program txs
394
- S->>S: Decode ix data, extract vkc bytes, filter target_vkc
395
- alt Relay enrichment enabled
396
- S->>R: POST /admin/compliance/decrypt (admin signed)
397
- R-->>S: Decrypted metadata rows (amount/recipient/type)
398
- S->>S: Join on commitment + public_amount hints
399
- end
400
- S-->>U: Matching transactions
494
+ Never write the artifact URL out by hand: the version segment and the digests are declared together
495
+ in `src/config/circuit-release.ts` so they cannot drift.
496
+
497
+ ## Version and compatibility
498
+
499
+ - SDK 0.2.0 pairs with the mainnet program at `zh1eLd6rSphLejbFfJEneUwzHRfMKxgzrgkfwA6qRkW`
500
+ (deployed 2026-08-24) and the 0.2.0 circuit bundle. All flows in this README were exercised on
501
+ mainnet on 2026-08-25: deposit, private send and withdraw on SOL, USDC and USDT; swaps SOL to
502
+ USDC and USDT; viewing-key discovery followed by a spend; keypair-only deposit recovery.
503
+ - Proofs generated with the 0.1.x circuits are rejected by the mainnet program. SDK 0.1.x builds
504
+ (which ship the 0.1.0 bundle) are not usable against it; upgrade to 0.2.0.
505
+ - The note-based `CloakSDK` class (`deposit` / `withdraw` / `send` / `swap` on `CloakNote`) targets
506
+ the pre-0.2.0 instruction layout and the unpublished 0.1.0 `withdraw_*` circuits. It is still
507
+ exported but does not work against the current program. Use the UTXO API above.
508
+
509
+ ## Examples
510
+
511
+ ```bash
512
+ npm run example:fast-send # deposit SOL, withdraw to a recipient
513
+ npm run example:transfer # deposit, private send, recipient withdraw
514
+ npm run example:fast-usdc-send # deposit SOL, swap to USDC, deposit USDC, withdraw USDC
515
+ npm run example:usdc-pool-transfer # USDC pool: A deposits, sends to B, B spends
516
+ npm run example:swap # SOL -> USDC swap with interactive route retry
517
+ npm run example:swap-usdc # same, with the canonical swap markers
518
+ npm run example:swap-brz # BRZ with automatic USDC fallback
519
+ npm run example:swap-recovery # pending / timed-out swap handling
520
+ npm run example:history-scan # every flow, then full and incremental viewing-key scans
521
+ npm run test:examples # dry-run all of the above (no wallet, no RPC)
401
522
  ```
402
523
 
403
- ## Error Handling
524
+ Environment read by the examples:
404
525
 
405
- ```typescript
526
+ | Variable | Default |
527
+ | --- | --- |
528
+ | `SOLANA_RPC_URL` | `http://127.0.0.1:8899` (local fork) |
529
+ | `CLOAK_RELAY_URL` | `http://127.0.0.1:5500` (local relay) |
530
+ | `CLOAK_PROGRAM_ID` | `CLOAK_PROGRAM_ID` |
531
+ | `CLOAK_CIRCUITS_PATH` | the pinned 0.2.0 bundle |
532
+ | `CLOAK_ALT_ADDRESSES` | unset; the production lookup tables are resolved automatically |
533
+
534
+ The examples fund throwaway keypairs from `~/.config/solana/id.json` and print stable
535
+ `FULL_SIG|<example>|<step>|<signature>` markers. They import `@cloak.ag/sdk`, which
536
+ `tsconfig.json` maps to `src/index.ts`; outside this repo import `@cloak.dev/sdk`.
537
+
538
+ ## Error handling
539
+
540
+ ```ts
406
541
  import {
407
542
  CloakError,
408
543
  UtxoAlreadySpentError,
409
- RootNotFoundError,
410
- classifyRelayError,
411
- fullWithdraw,
412
- CLOAK_PROGRAM_ID,
544
+ SanctionsQuoteError,
545
+ RelayInternalError,
546
+ SettlementVerificationError,
413
547
  } from "@cloak.dev/sdk";
414
548
 
415
549
  try {
416
- await fullWithdraw(myUtxos, recipient, {
417
- connection,
418
- programId: CLOAK_PROGRAM_ID,
419
- relayUrl: "https://api.cloak.ag",
420
- depositorKeypair: signer,
421
- });
550
+ await fullWithdraw([note], recipient, options);
422
551
  } catch (error) {
423
552
  if (error instanceof UtxoAlreadySpentError) {
424
- // Drop the spent UTXO from local storage; the structured error
425
- // carries which nullifiers collided.
426
- console.log("Spent nullifiers:", error.nullifiers);
427
- } else if (error instanceof RootNotFoundError) {
428
- // Relay/chain root mismatch; retry against a fresh tree.
553
+ // the note was spent elsewhere; rescan
554
+ } else if (error instanceof SettlementVerificationError) {
555
+ // error.outcome tells whether it is safe to retry; error.signature is what to look up
429
556
  } else if (error instanceof CloakError) {
430
- console.log("Category:", error.category); // 'wallet' | 'network' | 'prover' | 'relay' | 'validation' | 'environment' | 'service' | 'indexer'
431
- console.log("Retryable:", error.retryable);
557
+ console.log(error.category, error.retryable);
432
558
  }
433
559
  }
434
560
  ```
435
561
 
562
+ Catch the typed errors instead of matching message strings. `confirmTransactSettlement` re-checks a
563
+ signature from chain state when an outcome was reported as unknown.
564
+
565
+ ## Development
566
+
567
+ ```bash
568
+ npm install
569
+ npm run lint # tsc --noEmit
570
+ npm test # jest
571
+ npm run build # tsup -> dist/
572
+ npm run verify:dist # dist is byte-identical to a fresh build
573
+ npm run test:examples # dry-run the examples
574
+ ```
575
+
576
+ Notes: [docs/DEPOSIT-SIZE-NOTES.md](docs/DEPOSIT-SIZE-NOTES.md) (deposit transaction size,
577
+ confirmed-tree reads, supplemental lookup tables).
578
+
436
579
  ## Links
437
580
 
438
581
  - Website: [https://cloak.ag](https://cloak.ag)