@cloak.dev/sdk 0.1.8 → 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**: Send SOL privately using zero-knowledge proofs
8
- - 👥 **Multi-Recipient**: Support for 1-5 recipients in a single transaction
9
- - 💱 **Token Swaps**: Swap SOL for SPL tokens privately (SOL → USDC, etc.)
10
- - 🔐 **Type-Safe**: Full TypeScript support with comprehensive types
11
- - 🌐 **Cross-Platform**: Works in browser (React, Next.js) and Node.js
12
- - ⚡ **Simple API**: Easy-to-use high-level client with wallet adapter support
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
30
53
 
31
- ### SDK Defaults (Recommended)
54
+ The same SDK runs against a devnet or local deployment by pointing the three coordinates at it:
32
55
 
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`.
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
+ ```
61
+
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,240 +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";
212
+
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
+ ```
152
232
 
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.
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`).
154
238
 
155
- ### Node.js (with Keypair)
239
+ ### Withdraw (unshield)
156
240
 
157
- ```typescript
158
- import { CloakSDK } from "@cloak.dev/sdk";
159
- import { Connection, Keypair, PublicKey } from "@solana/web3.js";
241
+ ```ts
242
+ await fullWithdraw([note], recipientWallet, options); // whole note
243
+ await partialWithdraw([note], recipientWallet, amount, options); // amount out, change stays shielded
244
+ ```
160
245
 
161
- // Initialize connection and keypair
162
- const connection = new Connection("https://api.devnet.solana.com");
163
- const keypair = 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)).
164
248
 
165
- // Initialize SDK
166
- const sdk = new CloakSDK({
167
- keypairBytes: keypair.secretKey,
168
- network: "devnet",
169
- });
249
+ ### SPL pools (USDC, USDT)
170
250
 
171
- // Deposit SOL into the privacy pool
172
- const depositResult = await sdk.deposit(connection, 100_000_000); // 0.1 SOL
173
- console.log("Deposited! Leaf index:", depositResult.leafIndex);
251
+ Same API with the pool mint on every note:
174
252
 
175
- // Withdraw to a recipient
176
- const withdrawResult = await sdk.withdraw(
177
- connection,
178
- depositResult.note,
179
- new PublicKey("RECIPIENT_ADDRESS"),
180
- { withdrawAll: true }
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 },
181
259
  );
182
- console.log("Withdrawn! TX:", withdrawResult.signature);
183
260
  ```
184
261
 
185
- ### React/Next.js (with Wallet Adapter)
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).
186
265
 
187
- ```typescript
188
- import { CloakSDK } from "@cloak.dev/sdk";
189
- import { useWallet, useConnection } from "@solana/wallet-adapter-react";
190
- import { PublicKey } from "@solana/web3.js";
266
+ ### Shielded swap (SOL to USDC/USDT)
191
267
 
192
- function PrivateTransfer() {
193
- const { publicKey, signTransaction, sendTransaction } = useWallet();
194
- const { connection } = useConnection();
195
-
196
- // Initialize SDK with wallet adapter
197
- const sdk = useMemo(() => {
198
- if (!publicKey) return null;
199
- return new CloakSDK({
200
- network: "devnet",
201
- wallet: {
202
- publicKey,
203
- signTransaction: (tx) => signTransaction!(tx),
204
- sendTransaction: (tx, conn, opts) => sendTransaction(tx, conn, opts),
205
- },
206
- });
207
- }, [publicKey, signTransaction, sendTransaction]);
208
-
209
- const handleDeposit = async () => {
210
- const result = await sdk.deposit(connection, 100_000_000);
211
- console.log("Deposited!", result.signature);
212
- };
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).
213
270
 
214
- return <button onClick={handleDeposit}>Deposit 0.1 SOL</button>;
215
- }
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
283
+ {
284
+ connection,
285
+ programId: CLOAK_PROGRAM_ID,
286
+ relayUrl,
287
+ depositorKeypair: signer,
288
+ walletPublicKey: signer.publicKey,
289
+ chainNoteViewingKeyNk: nk,
290
+ swapSlippageBps: 100,
291
+ },
292
+ recipientWallet, // required on mainnet: the wallet the swap's risk quote is issued for
293
+ );
294
+
295
+ swap.signature; // the executed swap; swapWithChange resolves only once execution has completed
296
+ swap.refund; // persist: the refund-fallback secret (see below)
216
297
  ```
217
298
 
218
- ## Core Methods
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.
219
303
 
220
- ### Deposit
304
+ #### Timed-out swaps
221
305
 
222
- Deposit SOL into the privacy pool:
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:
223
312
 
224
- ```typescript
225
- const result = await sdk.deposit(connection, 100_000_000); // 0.1 SOL
226
- // Save the note securely - you need it to withdraw!
227
- console.log(result.note);
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);
327
+ }
228
328
  ```
229
329
 
230
- ### Withdraw
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).
231
336
 
232
- Withdraw to a single recipient:
337
+ ## Recovery and discovery (viewing key)
233
338
 
234
- ```typescript
235
- const result = await sdk.withdraw(
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.
344
+
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.
350
+
351
+ ```ts
352
+ import { scanTransactions, fetchCommitments, type Utxo } from "@cloak.dev/sdk";
353
+
354
+ const scan = await scanTransactions({
236
355
  connection,
237
- note,
238
- recipientPublicKey,
239
- { withdrawAll: true }
240
- );
356
+ programId: CLOAK_PROGRAM_ID,
357
+ viewingKeyNk: nk,
358
+ ownerUtxoPublicKey: owner.publicKey, // authenticates delivered notes' commitments
359
+ deliveryMints: [NATIVE_SOL_MINT],
360
+ untilSignature: lastScan?.lastSignature, // incremental scans
361
+ });
362
+
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
241
366
  ```
242
367
 
243
- ### Send to Multiple Recipients
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.
371
+
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.
244
377
 
245
- Send to up to 5 recipients:
378
+ ```ts
379
+ import { PublicKey } from "@solana/web3.js";
246
380
 
247
- ```typescript
248
- const result = await sdk.send(connection, note, [
249
- { recipient: addr1, amount: 50_000_000 },
250
- { recipient: addr2, amount: 47_000_000 },
251
- ]);
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
+ };
252
410
  ```
253
411
 
254
- ### Swap
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).
255
415
 
256
- Swap SOL for SPL tokens:
416
+ `toComplianceReport(scan)` and `formatComplianceCsv(report)` turn a scan into a report.
257
417
 
258
- ```typescript
259
- const result = await sdk.swap(connection, note, recipientPublicKey, {
260
- outputMint: "TOKEN_MINT_ADDRESS",
261
- minOutputAmount: 1000000,
262
- });
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
263
434
  ```
264
435
 
265
- ## Fee Structure
436
+ `transact` runs the preflight itself before generating a proof.
266
437
 
267
- - **Fixed Fee**: 0.005 SOL (5,000,000 lamports)
268
- - **Variable Fee**: 0.3% of deposit amount
438
+ ## Fees and limits
269
439
 
270
- Use `getDistributableAmount()` to calculate the amount 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.
271
443
 
272
- ```typescript
273
- 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 |
274
449
 
275
- const deposited = 100_000_000; // 0.1 SOL
276
- const afterFees = getDistributableAmount(deposited); // ~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
277
461
  ```
278
462
 
279
- ## Notes
280
-
281
- A **Cloak Note** is a cryptographic commitment representing a private amount of SOL:
282
-
283
- ```typescript
284
- interface CloakNote {
285
- version: string;
286
- amount: number; // Amount in lamports
287
- commitment: string; // Commitment hash
288
- sk_spend: string; // Spending key (keep secret!)
289
- r: string; // Randomness
290
- timestamp: number;
291
- network: Network;
292
- leafIndex?: number; // Set after deposit
293
- depositSignature?: string;
294
- }
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.
466
+
467
+ ## Circuit artifacts
468
+
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());
295
492
  ```
296
493
 
297
- **⚠️ Important**: Save your notes securely! Without the note, you cannot withdraw your funds.
298
-
299
- ## Compliance Chain Scanning
300
-
301
- Cloak supports a viewing-key commitment flow for compliance and self-discovery:
302
-
303
- - The viewing key itself is never written on-chain.
304
- - Only `viewing_key_commitment = SHA256(viewing_key_public)` is stored on-chain.
305
- - A scanner recomputes this commitment from `viewing_key_public` and matches program transactions.
306
-
307
- ```mermaid
308
- sequenceDiagram
309
- autonumber
310
- participant U as User Wallet + SDK
311
- participant R as Relay
312
- participant C as Cloak Program (Solana)
313
- participant S as Scanner
314
-
315
- U->>U: Generate (vk_priv, vk_pub)
316
- U->>U: vkc = SHA256(vk_pub)
317
- U->>R: Register vk_priv (signed)
318
- U->>R: POST /transact + metadata_bundle + viewing_key_commitment=vkc
319
- R->>C: Submit instruction [proof|public_inputs|vkc]
320
- C-->>R: Confirm tx signature
321
- R-->>U: Return signature
322
-
323
- S->>S: Compute target_vkc from vk_pub
324
- S->>C: Fetch recent program txs
325
- S->>S: Decode ix data, extract vkc bytes, filter target_vkc
326
- alt Relay enrichment enabled
327
- S->>R: POST /admin/compliance/decrypt (admin signed)
328
- R-->>S: Decrypted metadata rows (amount/recipient/type)
329
- S->>S: Join on commitment + public_amount hints
330
- end
331
- 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)
332
522
  ```
333
523
 
334
- ## Error Handling
524
+ Environment read by the examples:
335
525
 
336
- ```typescript
337
- import { CloakError } from "@cloak.dev/sdk";
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
541
+ import {
542
+ CloakError,
543
+ UtxoAlreadySpentError,
544
+ SanctionsQuoteError,
545
+ RelayInternalError,
546
+ SettlementVerificationError,
547
+ } from "@cloak.dev/sdk";
338
548
 
339
549
  try {
340
- await sdk.withdraw(connection, note, recipient);
550
+ await fullWithdraw([note], recipient, options);
341
551
  } catch (error) {
342
- if (error instanceof CloakError) {
343
- console.log("Category:", error.category); // 'wallet', 'network', 'prover', etc.
344
- console.log("Retryable:", error.retryable);
552
+ if (error instanceof UtxoAlreadySpentError) {
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
556
+ } else if (error instanceof CloakError) {
557
+ console.log(error.category, error.retryable);
345
558
  }
346
559
  }
347
560
  ```
348
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
+
349
579
  ## Links
350
580
 
351
581
  - Website: [https://cloak.ag](https://cloak.ag)