@cloak.dev/sdk 0.1.8 → 0.2.1

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,104 @@
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
- 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
14
+ npm install @cloak.dev/sdk@^0.2.0 @solana/web3.js
15
+ # swaps and SPL pools also need:
16
+ npm install @solana/spl-token
17
+ ```
18
+
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
+ | Cloak endpoint | `https://api.cloak.ag` — pinned into the build, not configurable (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 endpoint is fixed when the SDK is built and cannot be changed by a consumer. A published build
35
+ carries `RELAY_ORIGIN_ALLOWLIST` as a compiled-in constant, every request is checked against it
36
+ before it leaves the process, and there is no option, environment variable, or bundler define that
37
+ moves it. Name it by importing the constant rather than typing a host:
38
+
39
+ ```ts
40
+ import { CLOAK_PRODUCTION_RELAY_URL } from "@cloak.dev/sdk";
41
+
42
+ relayUrl: CLOAK_PRODUCTION_RELAY_URL
22
43
  ```
23
44
 
24
- **Note**: For swap functionality, you'll also need `@solana/spl-token`:
45
+ It is still never defaulted: omitting the option throws before anything is signed or sent, so a
46
+ script that forgets it cannot ship its proof anywhere. `relayUrl: ""` means the opposite — no
47
+ endpoint at all, so a deposit is signed and submitted by the caller.
48
+
49
+ A build pinned somewhere else is made by editing source and rebuilding, which is also what makes a
50
+ published build unrepointable:
51
+
25
52
  ```bash
26
- npm install @solana/spl-token
53
+ # in a checkout of this repo
54
+ $ $EDITOR src/config/relay.ts # replace the RELAY_ORIGIN_ALLOWLIST entry, do not append
55
+ $ npm run build # only needed by consumers that import dist/
27
56
  ```
28
57
 
29
- ## Quick Start
58
+ The same edit unlocks the second half of the lock: a build pinned to a non-local origin refuses to
59
+ run against an RPC served by the local machine, and a build pinned to a local origin does not. One
60
+ constant, both behaviours.
30
61
 
31
- ### SDK Defaults (Recommended)
62
+ Every flow needs it, deposits included: the SDK registers the wallet's viewing key through the relay
63
+ before its first transaction (see [Recovery and discovery](#recovery-and-discovery-viewing-key)),
64
+ deposits fetch the signed risk quote the program requires from `<relayUrl>/range-quote`, and sends,
65
+ withdrawals and swaps are submitted through it. `enforceViewingKeyRegistration: false` skips only the
66
+ registration step (some of the shipped examples set it on a local fork); it is not a no-relay mode.
32
67
 
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`.
68
+ Do not expose `programId`, the relay URL or the circuits base as end-user input. Wallet adapters are
69
+ the in-app signer; keypair files belong to scripts only.
37
70
 
38
- ### Minimal Private SOL Send (Single File, Keypair)
71
+ ### Devnet and local
39
72
 
40
- Use this contract for one-shot scripts and AI-generated snippets.
73
+ The same SDK runs against a devnet or local deployment by pointing the three coordinates at it:
74
+
75
+ ```bash
76
+ SOLANA_RPC_URL=https://api.devnet.solana.com # or http://127.0.0.1:8899 for a local fork
77
+ CLOAK_RELAY_URL=<endpoint serving that deployment> # read by examples/ and scripts/, NOT by the SDK
78
+ CLOAK_PROGRAM_ID=<program id of that deployment> # examples read this; pass it as programId in your code
79
+ ```
80
+
81
+ `CLOAK_RELAY_URL` configures the shipped examples and scripts, which pass its value as `relayUrl`.
82
+ The SDK itself does not read it: the endpoint a build may talk to comes from `src/config/relay.ts`,
83
+ so pointing at a devnet or local deployment means editing that file in a checkout and building it.
84
+
85
+ The shipped examples default to a local fork (`http://127.0.0.1:8899`) with a local relay
86
+ (`http://127.0.0.1:5500`); see [Examples](#examples).
87
+
88
+ ## Quick start: private SOL send (single file, keypair)
89
+
90
+ Shield from the signer, then unshield to the recipient. Use this shape for one-shot scripts.
41
91
 
42
92
  ```ts
43
93
  import { readFileSync } from "fs";
44
94
  import {
45
95
  CLOAK_PROGRAM_ID,
46
96
  NATIVE_SOL_MINT,
47
- createUtxo,
97
+ createRecoverableDepositUtxo,
48
98
  createZeroUtxo,
49
99
  fullWithdraw,
50
100
  generateUtxoKeypair,
101
+ getNkFromUtxoPrivateKey,
51
102
  transact,
52
103
  } from "@cloak.dev/sdk";
53
104
  import { Connection, Keypair, PublicKey } from "@solana/web3.js";
@@ -59,45 +110,54 @@ async function main() {
59
110
  }
60
111
 
61
112
  const rpcUrl = process.env.SOLANA_RPC_URL;
113
+ const relayUrl = process.env.CLOAK_RELAY_URL;
62
114
  const keypairPath = process.env.KEYPAIR_PATH;
63
- if (!rpcUrl || !keypairPath) {
64
- throw new Error("Set SOLANA_RPC_URL and KEYPAIR_PATH");
115
+ if (!rpcUrl || !relayUrl || !keypairPath) {
116
+ throw new Error("Set SOLANA_RPC_URL, CLOAK_RELAY_URL and KEYPAIR_PATH");
65
117
  }
66
118
 
67
119
  const connection = new Connection(rpcUrl, "confirmed");
68
120
  const signer = Keypair.fromSecretKey(
69
121
  Uint8Array.from(JSON.parse(readFileSync(keypairPath, "utf8"))),
70
122
  );
71
-
72
123
  const recipient = new PublicKey(recipientArg);
73
- const amountLamports = BigInt(lamportsArg);
124
+ const amount = BigInt(lamportsArg);
74
125
 
126
+ // The wallet's viewing base (nk). A real wallet derives it from its seed; a throwaway
127
+ // keypair keeps this script self-contained.
75
128
  const owner = await generateUtxoKeypair();
76
- const output = await createUtxo(amountLamports, owner, NATIVE_SOL_MINT);
129
+ const nk = getNkFromUtxoPrivateKey(owner.privateKey);
77
130
 
131
+ // Shield. The note's keypair and blinding are derived from (nk, noteSalt) and the salt is
132
+ // published inside the nk-encrypted chain note, so a scan holding only nk can rebuild it.
133
+ const { utxo, noteSalt } = await createRecoverableDepositUtxo(amount, nk, NATIVE_SOL_MINT);
78
134
  const deposited = await transact(
79
135
  {
80
136
  inputUtxos: [await createZeroUtxo(NATIVE_SOL_MINT)],
81
- outputUtxos: [output],
82
- externalAmount: amountLamports,
137
+ outputUtxos: [utxo],
138
+ externalAmount: amount,
83
139
  depositor: signer.publicKey,
84
140
  },
85
141
  {
86
142
  connection,
87
143
  programId: CLOAK_PROGRAM_ID,
144
+ relayUrl,
88
145
  depositorKeypair: signer,
89
- walletPublicKey: signer.publicKey,
90
- enforceViewingKeyRegistration: false,
146
+ chainNoteViewingKeyNk: nk,
147
+ chainNoteSalt: noteSalt,
91
148
  },
92
149
  );
93
150
 
94
- const withdrawn = await fullWithdraw(deposited.outputUtxos, recipient, {
151
+ // Unshield to the recipient. outputUtxos[0] is the deposited note ([1] is the zero pad).
152
+ // The program takes its fee here (0.005 SOL + 0.3%).
153
+ const withdrawn = await fullWithdraw([deposited.outputUtxos[0]], recipient, {
95
154
  connection,
96
155
  programId: CLOAK_PROGRAM_ID,
156
+ relayUrl,
97
157
  depositorKeypair: signer,
98
158
  walletPublicKey: signer.publicKey,
159
+ chainNoteViewingKeyNk: nk,
99
160
  cachedMerkleTree: deposited.merkleTree,
100
- enforceViewingKeyRegistration: false,
101
161
  });
102
162
 
103
163
  console.log(withdrawn.signature);
@@ -112,245 +172,553 @@ main().catch((e) => {
112
172
  Run:
113
173
 
114
174
  ```bash
115
- SOLANA_RPC_URL="https://api.mainnet-beta.solana.com" \
175
+ SOLANA_RPC_URL="https://<your mainnet rpc>" \
176
+ CLOAK_RELAY_URL="https://api.cloak.ag" \
116
177
  KEYPAIR_PATH="/absolute/path/to/id.json" \
117
178
  npx tsx send-sol-private.ts <recipientPubkey> <lamports>
118
179
  ```
119
180
 
120
- Hard rules for minimal scripts:
181
+ Rules for minimal scripts:
121
182
 
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).
183
+ - Take lamports from the CLI and keep all amount math in `bigint`; never parse SOL with float math.
184
+ - `<lamports>` must be at least `MIN_DEPOSIT_LAMPORTS` (0.01 SOL) and the withdrawal must exceed
185
+ its fee (see [Fees and limits](#fees-and-limits)).
186
+ - Use `KEYPAIR_PATH`; do not read raw private keys from the environment.
187
+ - Keep `programId` fixed to `CLOAK_PROGRAM_ID`.
188
+ - `transact`, `partialWithdraw` and `fullWithdraw` already retry on a stale Merkle root; do not
189
+ wrap them in your own retry loop.
126
190
 
127
- ### Maintained Examples
191
+ ### Browser (wallet adapter)
128
192
 
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
193
+ Replace `depositorKeypair` with the adapter's signers:
194
+
195
+ ```ts
196
+ await transact(params, {
197
+ connection,
198
+ programId: CLOAK_PROGRAM_ID,
199
+ relayUrl,
200
+ signTransaction: (tx) => wallet.signTransaction(tx),
201
+ signMessage: (message) => wallet.signMessage(message), // signs the viewing-key registration challenge
202
+ depositorPublicKey: wallet.publicKey,
203
+ walletPublicKey: wallet.publicKey,
204
+ chainNoteViewingKeyNk: nk,
205
+ chainNoteSalt: noteSalt,
206
+ });
139
207
  ```
140
208
 
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.
209
+ `signTransaction` must accept both a legacy `Transaction` and a `VersionedTransaction` (v0 with
210
+ address lookup tables); deposits use either depending on the path taken. `signMessage` is required
211
+ unless `enforceViewingKeyRegistration: false` is set; without it the call fails with
212
+ "Viewing key registration is mandatory: signMessage (wallet) or depositorKeypair is required."
142
213
 
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).
214
+ A deposit is signed by the wallet and goes straight to chain. A private send, a withdrawal and a
215
+ swap are submitted for you, and each one carries an authenticated sender — so those three need
216
+ `signMessage` and `walletPublicKey` even when `enforceViewingKeyRegistration` is off:
144
217
 
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).
218
+ ```ts
219
+ // Private send (externalAmount 0) from a wallet adapter. Same for fullWithdraw / partialWithdraw
220
+ // (externalAmount < 0) and swapUtxo / swapWithChange.
221
+ await transact(
222
+ { inputUtxos, outputUtxos, externalAmount: BigInt(0) },
223
+ {
224
+ connection,
225
+ programId: CLOAK_PROGRAM_ID,
226
+ relayUrl,
227
+ signMessage: (message) => wallet.signMessage(message),
228
+ walletPublicKey: wallet.publicKey, // becomes the authenticated sender
229
+ chainNoteViewingKeyNk: nk,
230
+ },
231
+ );
232
+ ```
146
233
 
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.
234
+ `signMessage` must return the raw 64-byte ed25519 detached signature over the bytes it is given;
235
+ adapters that re-encode or wrap the result are rejected by name. `walletPublicKey` must be the end
236
+ user's own wallet — it is the identity the transaction is authenticated as. Passing neither a
237
+ `depositorKeypair` nor both of these fails immediately, before any proof is generated.
148
238
 
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`.
239
+ `depositorKeypair`, `walletPublicKey` and `depositorPublicKey` are three names for the SAME end
240
+ user. That one key is the request's authenticated sender, the key screened for sanctions, and the
241
+ wallet a viewing key is registered against, so setting two of the names to two different keys would
242
+ screen one person and authenticate another. Passing two different wallets is refused at the call.
150
243
 
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|...`.
244
+ ### The approval window
152
245
 
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.
246
+ The request's timestamp is stamped when the bytes are built, because it is part of what the wallet
247
+ signs, and the relay accepts a first-use request for 300 seconds from that moment. An approval left
248
+ sitting in a hardware wallet past that window cannot be rescued by re-stamping it, so the SDK stops
249
+ there and says so instead of shipping a request that can only come back as a 401. Retry the
250
+ operation and approve the prompt when it appears.
154
251
 
155
- ### Node.js (with Keypair)
252
+ `auth_issued_at` comes from the machine's own clock, and the relay rejects anything more than 30
253
+ seconds ahead of its own. A user whose clock is fast cannot authenticate at all until it is
254
+ corrected; `explainRelayAuthRejection` turns that rejection, and the rest of the relay's 401s, into
255
+ a sentence that names the cause. The SDK applies it to its own relay errors already.
156
256
 
157
- ```typescript
158
- import { CloakSDK } from "@cloak.dev/sdk";
159
- import { Connection, Keypair, PublicKey } from "@solana/web3.js";
257
+ A private send or withdrawal signs exactly ONCE: every network retry re-POSTs the same signed
258
+ bytes, so the user sees one prompt. A swap re-proves on every retry, so each retry needs its own
259
+ approval; `maxWalletApprovals` (default 5) bounds how many times one swap may interrupt the user,
260
+ independently of `maxRootRetries`. A `depositorKeypair` signs silently and is unaffected.
160
261
 
161
- // Initialize connection and keypair
162
- const connection = new Connection("https://api.devnet.solana.com");
163
- const keypair = Keypair.fromSecretKey(/* your secret key */);
262
+ ### Authenticating a call you build yourself
164
263
 
165
- // Initialize SDK
166
- const sdk = new CloakSDK({
167
- keypairBytes: keypair.secretKey,
168
- network: "devnet",
169
- });
264
+ To authenticate a call you build yourself (a swap retry that posts only `retry_request_id`, for
265
+ example), use the exported primitives rather than re-deriving the scheme:
170
266
 
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);
267
+ ```ts
268
+ import { buildRelayAuthPreimage, TRANSACT_SWAP_AUTH_FIELDS } from "@cloak.dev/sdk";
174
269
 
175
- // Withdraw to a recipient
176
- const withdrawResult = await sdk.withdraw(
177
- connection,
178
- depositResult.note,
179
- new PublicKey("RECIPIENT_ADDRESS"),
180
- { withdrawAll: true }
270
+ const body = { retry_request_id: requestId, slippage_bps: 500 };
271
+ const preimage = buildRelayAuthPreimage(
272
+ "/transact_swap",
273
+ CLOAK_PROGRAM_ID,
274
+ body,
275
+ wallet.publicKey,
276
+ undefined,
277
+ TRANSACT_SWAP_AUTH_FIELDS,
181
278
  );
182
- console.log("Withdrawn! TX:", withdrawResult.signature);
279
+ const signature = await wallet.signMessage(preimage.message);
280
+
281
+ const payload = {
282
+ ...body,
283
+ sender: preimage.sender,
284
+ auth_issued_at: preimage.auth_issued_at,
285
+ auth_nonce: preimage.auth_nonce,
286
+ auth_signature: Buffer.from(signature).toString("base64"),
287
+ };
183
288
  ```
184
289
 
185
- ### React/Next.js (with Wallet Adapter)
290
+ Reuse one `preimage` for every retry of the same logical request; rebuilding it produces a new
291
+ nonce and a new request.
292
+
293
+ `slippage_bps` in that snippet is not decoration. Every other field in both lists is optional on
294
+ the relay side, so omitting one and signing it as `null` matches what the relay signs. `slippage_bps`
295
+ is the exception: it is not optional, and a request that omits it is read by the relay as **500**.
296
+ Omit it and you sign `null` while the relay signs `500`, the digests differ, and the answer is a 401
297
+ that points at nothing. Set it explicitly, to the same value the body carries. The SDK's own swap
298
+ path always does; a hand-built body that leaves it out is refused before it is signed.
299
+
300
+ `canonicalJson` is exported for the same reason, and it is the SDK's half of a byte-for-byte
301
+ agreement with one Rust function over one fixed schema: ASCII keys over strings, small unsigned
302
+ integers, booleans, nulls, arrays and plain objects. Amounts travel as decimal strings, not numbers,
303
+ because JavaScript and `serde_json` do not spell every number the same way. Anything outside that
304
+ schema that could serialize differently on the two sides is refused rather than silently signed.
305
+
306
+ ## The UTXO model
307
+
308
+ - A shielded balance is a set of notes (`Utxo`): `{ amount, keypair, blinding, mintAddress, index?, commitment? }`.
309
+ Amounts are `bigint` in base units (lamports, or 10^-6 for USDC/USDT).
310
+ - Every transaction is 2-in / 2-out; `transact` pads unused slots with zero notes for you.
311
+ `createZeroUtxo(mint)` is exported if you want to pass them explicitly.
312
+ - `externalAmount > 0` is a deposit (funds enter from `depositor`), `< 0` is a withdrawal to
313
+ `recipient`, `0` is a private send inside the pool.
314
+ - Pools are per mint. All inputs and outputs of one transaction live in the same pool;
315
+ `NATIVE_SOL_MINT` is the SOL pool.
316
+ - `TransactResult.outputUtxos` are the notes you can spend next (leaf `index` set). Persist them
317
+ with `serializeUtxo` / `deserializeUtxo`. Pass `result.merkleTree` as `cachedMerkleTree` to the
318
+ next call to skip re-fetching commitments.
319
+ - The Merkle tree is read at `confirmed`, so a note is spendable as soon as its deposit is confirmed.
320
+
321
+ Entry points: `transact` (general), `transfer`, `partialWithdraw`, `fullWithdraw`,
322
+ `swapUtxo` / `swapWithChange`.
323
+
324
+ ### Private send (shield-to-shield)
186
325
 
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";
326
+ ```ts
327
+ import { deriveViewingKeyFromNk, transfer } from "@cloak.dev/sdk";
191
328
 
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
- };
329
+ const result = await transfer(
330
+ [myNote], // inputs to spend (same pool)
331
+ recipientUtxoPublicKey, // bigint: the recipient's UTXO public key
332
+ amount,
333
+ {
334
+ connection,
335
+ programId: CLOAK_PROGRAM_ID,
336
+ relayUrl,
337
+ depositorKeypair: signer,
338
+ walletPublicKey: signer.publicKey,
339
+ chainNoteViewingKeyNk: myNk,
340
+ // The recipient's X25519 viewing public key. With it the recipient can discover the note
341
+ // from chain with their own key (scanRecipientDeliveryNotes). Without it the note is still
342
+ // valid but must be handed over out of band.
343
+ recipientViewingPublicKey: deriveViewingKeyFromNk(recipientNk).publicKey,
344
+ cachedMerkleTree: previous.merkleTree,
345
+ },
346
+ );
347
+ ```
213
348
 
214
- return <button onClick={handleDeposit}>Deposit 0.1 SOL</button>;
215
- }
349
+ `transfer` builds the recipient note as output 0 and returns the change (owned by the input's
350
+ keypair) as output 1. Calling `transact` directly with `externalAmount: 0n` does the same; keep the
351
+ recipient note at `outputUtxos[0]`, which is the one the delivery carrier is built for.
352
+ The recipient shares two values with the sender: their UTXO public key (`UtxoKeypair.publicKey`)
353
+ and their viewing public key (`deriveViewingKeyFromNk(nk).publicKey`).
354
+
355
+ ### Withdraw (unshield)
356
+
357
+ ```ts
358
+ await fullWithdraw([note], recipientWallet, options); // whole note
359
+ await partialWithdraw([note], recipientWallet, amount, options); // amount out, change stays shielded
216
360
  ```
217
361
 
218
- ## Core Methods
362
+ For SPL pools the recipient receives tokens in their associated token account. The program charges
363
+ its fee on the withdrawn amount ([Fees and limits](#fees-and-limits)).
219
364
 
220
- ### Deposit
365
+ ### SPL pools (USDC, USDT)
221
366
 
222
- Deposit SOL into the privacy pool:
367
+ Same API with the pool mint on every note:
223
368
 
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);
369
+ ```ts
370
+ const usdc = new PublicKey("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v");
371
+ const { utxo, noteSalt } = await createRecoverableDepositUtxo(2_000_000n, nk, usdc); // 2.00 USDC
372
+ await transact(
373
+ { inputUtxos: [await createZeroUtxo(usdc)], outputUtxos: [utxo], externalAmount: 2_000_000n, depositor: signer.publicKey },
374
+ { connection, programId: CLOAK_PROGRAM_ID, relayUrl, depositorKeypair: signer, chainNoteViewingKeyNk: nk, chainNoteSalt: noteSalt },
375
+ );
228
376
  ```
229
377
 
230
- ### Withdraw
378
+ Minimum SPL deposit is 1.00 token. SOL deposits fit in a single v0 packet with the production
379
+ lookup table; SPL deposits currently still create a small supplemental lookup table
380
+ (about 0.0056 SOL rent, reclaimable by the depositor).
231
381
 
232
- Withdraw to a single recipient:
382
+ ### Shielded swap (SOL to USDC/USDT)
233
383
 
234
- ```typescript
235
- const result = await sdk.withdraw(
236
- connection,
237
- note,
238
- recipientPublicKey,
239
- { withdrawAll: true }
384
+ Spends shielded SOL and delivers the output token to a public associated token account, routed
385
+ through Jupiter. Swap input is SOL only (the pool is wSOL-locked).
386
+
387
+ ```ts
388
+ import { swapWithChange } from "@cloak.dev/sdk";
389
+ import { getAssociatedTokenAddressSync } from "@solana/spl-token";
390
+
391
+ const recipientAta = getAssociatedTokenAddressSync(usdc, recipientWallet);
392
+
393
+ const swap = await swapWithChange(
394
+ [solNote],
395
+ swapAmount, // lamports leaving the pool; the program's fee is taken from this
396
+ usdc,
397
+ recipientAta,
398
+ minOutputAmount, // proof-bound floor; quote the fee-adjusted amount and use the quote's threshold
399
+ {
400
+ connection,
401
+ programId: CLOAK_PROGRAM_ID,
402
+ relayUrl,
403
+ depositorKeypair: signer,
404
+ walletPublicKey: signer.publicKey,
405
+ chainNoteViewingKeyNk: nk,
406
+ swapSlippageBps: 100,
407
+ },
408
+ recipientWallet, // required on mainnet: the wallet the swap's risk quote is issued for
240
409
  );
410
+
411
+ swap.signature; // the executed swap; swapWithChange resolves only once execution has completed
412
+ swap.refund; // persist: the refund-fallback secret (see below)
241
413
  ```
242
414
 
243
- ### Send to Multiple Recipients
415
+ `swapUtxo` is the lower-level form taking `UtxoSwapParams` and an explicit change note. Quote the
416
+ amount after the protocol fee (`swapAmount - calculateFeeBigint(swapAmount)` for SOL) so
417
+ `minOutputAmount` is achievable. `outputMint`, `recipientAta` and `minOutputAmount` are bound into
418
+ the proof and cannot be changed after it is generated.
419
+
420
+ #### Timed-out swaps
244
421
 
245
- Send to up to 5 recipients:
422
+ `swapWithChange` polls the swap status and throws if the swap is refunded, cancelled or fails, or if
423
+ polling runs out (`swapStatusMaxAttempts` x `swapStatusDelayMs`, default 60 x 2 s); the result above
424
+ exists only for a completed swap. On a timeout the program returns the principal, net of the swap
425
+ fee, to the SOL pool as a refund note, which is spent through the normal withdraw path. When the
426
+ swap was submitted with `chainNoteViewingKeyNk` (`swap.refund.derivedFromNk === true`) that note is
427
+ discoverable from the key alone:
246
428
 
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
- ]);
429
+ ```ts
430
+ import { discoverSwapRefunds, fullWithdraw, NATIVE_SOL_MINT, type Utxo } from "@cloak.dev/sdk";
431
+
432
+ const refunds = await discoverSwapRefunds(connection, CLOAK_PROGRAM_ID, nk); // DiscoveredSwapRefund[]
433
+ for (const refund of refunds) {
434
+ const note: Utxo = {
435
+ amount: refund.amount, // principal net of the swap fee
436
+ keypair: refund.keypair,
437
+ blinding: refund.blinding,
438
+ mintAddress: NATIVE_SOL_MINT,
439
+ commitment: refund.commitment,
440
+ index: Number(refund.leafIndex),
441
+ };
442
+ await fullWithdraw([note], recipientWallet, options);
443
+ }
252
444
  ```
253
445
 
254
- ### Swap
446
+ Swaps that executed leave no refund note, and an empty result only says that none of the scanned
447
+ leaves derive from this `nk`. A swap submitted without `chainNoteViewingKeyNk` has `swap.refund`
448
+ (hex `privateKey`, `publicKey`, `blinding`) as the only copy of its secret; rebuild the note from it
449
+ with the on-chain `SwapState.sol_amount` as the amount and
450
+ `computeSwapRefundCommitment(amount, publicKey, blinding)` as the commitment, then look up the leaf
451
+ index as in [Recovery and discovery](#recovery-and-discovery-viewing-key).
452
+
453
+ ## Recovery and discovery (viewing key)
454
+
455
+ The viewing base `nk` is derived from the wallet's spend key (`getNkFromUtxoPrivateKey(privateKey)`
456
+ or `expandSpendKey(skSpend).nsk`). It decrypts the chain notes the SDK attaches to transactions and
457
+ opens notes delivered by other people's private sends. Recoverable deposit notes and swap refund
458
+ notes also derive their secrets from it, which is what makes key-only recovery possible, so store
459
+ it with the same care as the spend key.
255
460
 
256
- Swap SOL for SPL tokens:
461
+ Before a wallet's first transaction the SDK registers `nk` with the relay for compliance scanning:
462
+ it signs a one-time challenge with `signMessage` (wallet) or `depositorKeypair` and submits `nk`
463
+ with that signature. Nothing viewing-key related is written on-chain. The step is on by default;
464
+ `enforceViewingKeyRegistration: false` disables it. `registerViewingKey(relayUrl, userPubkey, nk,
465
+ signMessage)` performs the same registration on its own.
257
466
 
258
- ```typescript
259
- const result = await sdk.swap(connection, note, recipientPublicKey, {
260
- outputMint: "TOKEN_MINT_ADDRESS",
261
- minOutputAmount: 1000000,
467
+ ```ts
468
+ import { scanTransactions, fetchCommitments, type Utxo } from "@cloak.dev/sdk";
469
+
470
+ const scan = await scanTransactions({
471
+ connection,
472
+ programId: CLOAK_PROGRAM_ID,
473
+ viewingKeyNk: nk,
474
+ ownerUtxoPublicKey: owner.publicKey, // authenticates delivered notes' commitments
475
+ deliveryMints: [NATIVE_SOL_MINT],
476
+ untilSignature: lastScan?.lastSignature, // incremental scans
262
477
  });
478
+
479
+ scan.recoveredDepositNotes; // own deposits built with createRecoverableDepositUtxo, in spendable form
480
+ scan.deliveredNotes; // notes sent to this wallet (amount + blinding; the keypair is your own)
481
+ scan.transactions; // history rows for compliance reporting
482
+ ```
483
+
484
+ `scanRecipientDeliveryNotes` does the delivery sweep alone, returning `{ notes, rpcCalls }`
485
+ instead of a full scan result. Deposit recovery applies to deposits built with
486
+ `createRecoverableDepositUtxo` (the default in the examples); a deposit built with
487
+ `createUtxo` has a random blinding that is written nowhere and appears as history only.
488
+
489
+ A recovered or delivered note needs its leaf index before it can be spent. Look it up by commitment.
490
+ The two record types differ: `RecoveredDepositNoteRecord` carries the note's own `keypair`, a
491
+ `bigint` `commitment` and a `PublicKey` `mintAddress`; `DeliveredNote` carries a hex `commitment`,
492
+ a base58 `mint` (set only when the scan was given `ownerUtxoPublicKey`) and no keypair, because the
493
+ keypair is yours.
494
+
495
+ ```ts
496
+ import { PublicKey } from "@solana/web3.js";
497
+
498
+ const entries = await fetchCommitments(relayUrl, { mint: NATIVE_SOL_MINT });
499
+ const indexOf = (commitment: bigint) =>
500
+ entries.find((e) => BigInt("0x" + e.commitment.replace(/^0x/, "")) === commitment)?.index;
501
+
502
+ // Own deposit recovered from nk
503
+ const recovered = scan.recoveredDepositNotes[0];
504
+ const ownNote: Utxo = {
505
+ amount: recovered.amount,
506
+ keypair: recovered.keypair,
507
+ blinding: recovered.blinding,
508
+ mintAddress: recovered.mintAddress,
509
+ commitment: recovered.commitment,
510
+ index: indexOf(recovered.commitment),
511
+ };
512
+
513
+ // Note delivered by someone else's private send
514
+ const delivered = scan.deliveredNotes[0];
515
+ if (!delivered.commitmentVerified || !delivered.mint) {
516
+ throw new Error("scan with ownerUtxoPublicKey to authenticate delivered notes");
517
+ }
518
+ const deliveredCommitment = BigInt("0x" + delivered.commitment);
519
+ const deliveredNote: Utxo = {
520
+ amount: delivered.amount,
521
+ keypair: owner, // the UtxoKeypair whose publicKey the sender used
522
+ blinding: delivered.blinding,
523
+ mintAddress: new PublicKey(delivered.mint),
524
+ commitment: deliveredCommitment,
525
+ index: indexOf(deliveredCommitment),
526
+ };
263
527
  ```
264
528
 
265
- ## Fee Structure
529
+ `discoverSwapRefunds(connection, programId, nk)` returns `DiscoveredSwapRefund` records (`keypair`,
530
+ `blinding`, `amount`, `commitment`, `leafIndex: bigint`); build the `Utxo` from one as shown under
531
+ [Timed-out swaps](#timed-out-swaps).
266
532
 
267
- - **Fixed Fee**: 0.005 SOL (5,000,000 lamports)
268
- - **Variable Fee**: 0.3% of deposit amount
533
+ `toComplianceReport(scan)` and `formatComplianceCsv(report)` turn a scan into a report.
269
534
 
270
- Use `getDistributableAmount()` to calculate the amount after fees:
535
+ ## Nullifiers and spent checks
271
536
 
272
- ```typescript
273
- import { getDistributableAmount } from "@cloak.dev/sdk";
537
+ The nullifier export is `computeUtxoNullifier(utxo)`, the circuit's scheme:
538
+ `Poseidon(commitment, index, signature)`. This is the value the program records when a note is
539
+ spent, so it is what a spent check compares against. It is async and requires `utxo.index` to be
540
+ set.
274
541
 
275
- const deposited = 100_000_000; // 0.1 SOL
276
- const afterFees = getDistributableAmount(deposited); // ~94,700,000 lamports
542
+ Prefer the helpers, which derive the nullifier and check the on-chain PDA for you:
543
+
544
+ ```ts
545
+ import { verifyUtxos, preflightNullifiers } from "@cloak.dev/sdk";
546
+
547
+ const { spent } = await verifyUtxos(notes, connection, CLOAK_PROGRAM_ID);
548
+ await preflightNullifiers(notes, connection, CLOAK_PROGRAM_ID); // throws UtxoAlreadySpentError
277
549
  ```
278
550
 
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
- }
551
+ `transact` runs the preflight itself before generating a proof.
552
+
553
+ ## Fees and limits
554
+
555
+ Fees are collected on-chain by the program from each pool's `PoolConfig` account
556
+ (`["pool_config", mint]`). They apply to withdrawals and swaps, on the amount leaving the pool.
557
+ Deposits and private sends carry no protocol fee.
558
+
559
+ | Pool | Fixed fee | Rate | Minimum deposit |
560
+ | --- | --- | --- | --- |
561
+ | SOL | 0.005 SOL | 0.3% | 0.01 SOL |
562
+ | USDC | 0.45 USDC | 0.3% | 1.00 USDC |
563
+ | USDT | 0.45 USDT | 0.3% | 1.00 USDT |
564
+
565
+ A withdrawal or swap must exceed its fee or the program rejects it; deposits below the minimum are
566
+ rejected with `DepositTooSmall`.
567
+
568
+ `shared/fees.ts` mirrors the SOL pool for estimates in the UI:
569
+
570
+ ```ts
571
+ import { calculateFeeBigint, getDistributableAmount, isWithdrawAmountSufficient } from "@cloak.dev/sdk";
572
+
573
+ calculateFeeBigint(1_000_000_000n); // 8_000_000n (0.005 SOL + 0.3% of 1 SOL)
574
+ getDistributableAmount(100_000_000); // 94_700_000 (0.1 SOL withdrawal, net to recipient)
575
+ isWithdrawAmountSufficient(6_000_000n); // true: 0.006 SOL covers the 0.005018 SOL fee
295
576
  ```
296
577
 
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
578
+ For USDC/USDT compute `450_000n + amount * 3n / 1000n`. Treat these as estimates: the deployed
579
+ `PoolConfig` is the source of truth. Solana transaction fees and, for SPL deposits, the supplemental
580
+ lookup-table rent are separate.
581
+
582
+ ## Circuit artifacts
583
+
584
+ Proving uses the `transaction` circuit from bundle `cloak-transaction-0.2.0` (multi-party ceremony,
585
+ 6 contributors plus a public final beacon; 42,672 constraints, 9 public inputs). The verifying key is
586
+ embedded in the program, so the SDK never fetches it; for the record, the exported
587
+ `transaction.vkey.json` from the same ceremony is SHA-256
588
+ `deb40e7b94eae17db2975d23dcf26c26db2a36a4f02d14a25830dee3e88fb93c`.
589
+
590
+ | Artifact | SHA-256 |
591
+ | --- | --- |
592
+ | `transaction_js/transaction.wasm` | `02ec02e954ae3932827ad9de51afa597ca95569aa97fec8410879c937a58aa2b` |
593
+ | `transaction_final.zkey` | `9da7db8cb1370fc497d36a0365f1f107ab0b0c13ca66fa9f0287e5f96ee68d25` |
594
+
595
+ The SDK downloads both artifacts once per process from
596
+ `DEFAULT_TRANSACTION_CIRCUITS_URL` (`https://storage.googleapis.com/cloak-circuits/circuits/0.2.0`),
597
+ hashes them, and refuses to prove if any digest differs from the pinned values. No configuration is
598
+ needed for mainnet.
599
+
600
+ To load the artifacts from a local directory instead (an air-gapped machine, a React Native bundle,
601
+ or the ceremony output — the directory must contain `transaction_js/transaction.wasm` and
602
+ `transaction_final.zkey` with the same digests):
603
+
604
+ ```ts
605
+ import { resolveCircuitsBase, setCircuitsPath } from "@cloak.dev/sdk";
606
+
607
+ // explicit argument, else the pinned default; no environment variable is consulted
608
+ setCircuitsPath(resolveCircuitsBase());
609
+ setCircuitsPath("/opt/cloak/ceremony-0.2.0"); // or a local directory you control
610
+ ```
611
+
612
+ Never write the artifact URL out by hand: the version segment and the digests are declared together
613
+ in `src/proving/circuits.ts` so they cannot drift.
614
+
615
+ Where the artifacts may be read from is fixed when the SDK is built. `transaction.wasm` is the
616
+ witness generator, so it is handed your spend key, your note secrets, the amounts and the recipient
617
+ — a mirror on another host is therefore not accepted, and the SHA-256 check over the bytes cannot be
618
+ switched off by any option or environment variable. A local directory is accepted (nothing leaves
619
+ the machine, and the digest check still decides whether the bytes are usable); an `http(s)` base
620
+ must be the bundle base this build pins. To point a build somewhere else, edit
621
+ `RELAY_ORIGIN_ALLOWLIST` in `src/config/relay.ts` and rebuild — the one edit that also unlocks the
622
+ relay endpoint and the localhost-RPC check.
623
+
624
+ ## Version and compatibility
625
+
626
+ - SDK 0.2.0 pairs with the mainnet program at `zh1eLd6rSphLejbFfJEneUwzHRfMKxgzrgkfwA6qRkW`
627
+ (deployed 2026-08-24) and the 0.2.0 circuit bundle. All flows in this README were exercised on
628
+ mainnet on 2026-08-25: deposit, private send and withdraw on SOL, USDC and USDT; swaps SOL to
629
+ USDC and USDT; viewing-key discovery followed by a spend; keypair-only deposit recovery.
630
+ - Proofs generated with the 0.1.x circuits are rejected by the mainnet program. SDK 0.1.x builds
631
+ (which ship the 0.1.0 bundle) are not usable against it; upgrade to 0.2.0.
632
+ - The note-based `CloakSDK` class is removed in 0.2.0. It targeted the pre-0.2.0 instruction layout
633
+ and the unpublished 0.1.0 `withdraw_*` circuits, so it could not work against the current program.
634
+ Use the UTXO API above: `transact`, `transfer`, `partialWithdraw`, `fullWithdraw`, `swapUtxo`.
635
+
636
+ ## Examples
637
+
638
+ ```bash
639
+ # SOL pool
640
+ npm run example:deposit # move lamports into the shielded SOL pool
641
+ npm run example:private-send # shield-to-shield send the recipient can discover
642
+ npm run example:withdraw # partial withdraw, then full withdraw of the change
643
+ npm run example:swap # SOL to USDC through Jupiter
644
+
645
+ # SPL pools (USDC, USDT). There is no SPL swap: swap input is wSOL-locked.
646
+ npm run example:spl-deposit
647
+ npm run example:spl-private-send
648
+ npm run example:spl-withdraw
649
+
650
+ # Not pool specific
651
+ npm run example:viewing-keys # derive a viewing key, register it, read your history
652
+
653
+ # Swap variants
654
+ npm run example:swap-usdc # same swap, with the canonical swap markers
655
+ npm run example:swap-brz # BRZ with automatic USDC fallback
656
+
657
+ npm run test:examples # dry-run all eight (no wallet, no RPC)
332
658
  ```
333
659
 
334
- ## Error Handling
660
+ Examples log by default. Set `CLOAK_DEBUG=0` to silence them.
661
+
662
+ Environment read by the examples:
663
+
664
+ | Variable | Default |
665
+ | --- | --- |
666
+ | `SOLANA_RPC_URL` | `http://127.0.0.1:8899` (local fork) |
667
+ | `CLOAK_RELAY_URL` | `http://127.0.0.1:5500` (local endpoint) — read by the examples, not by the SDK; it must name an origin the SDK build is pinned to |
668
+ | `CLOAK_PROGRAM_ID` | `CLOAK_PROGRAM_ID` |
669
+ | `CLOAK_ALT_ADDRESSES` | unset; the production lookup tables are resolved automatically |
335
670
 
336
- ```typescript
337
- import { CloakError } from "@cloak.dev/sdk";
671
+ The examples fund throwaway keypairs from `~/.config/solana/id.json` and print stable
672
+ `FULL_SIG|<example>|<step>|<signature>` markers. They import `@cloak.dev/sdk`, the published
673
+ package name; inside this repo `tsconfig.json` maps that specifier to `src/index.ts` so the
674
+ examples run against source.
675
+
676
+ ## Error handling
677
+
678
+ ```ts
679
+ import {
680
+ CloakError,
681
+ UtxoAlreadySpentError,
682
+ SanctionsQuoteError,
683
+ RelayInternalError,
684
+ SettlementVerificationError,
685
+ } from "@cloak.dev/sdk";
338
686
 
339
687
  try {
340
- await sdk.withdraw(connection, note, recipient);
688
+ await fullWithdraw([note], recipient, options);
341
689
  } catch (error) {
342
- if (error instanceof CloakError) {
343
- console.log("Category:", error.category); // 'wallet', 'network', 'prover', etc.
344
- console.log("Retryable:", error.retryable);
690
+ if (error instanceof UtxoAlreadySpentError) {
691
+ // the note was spent elsewhere; rescan
692
+ } else if (error instanceof SettlementVerificationError) {
693
+ // error.outcome tells whether it is safe to retry; error.signature is what to look up
694
+ } else if (error instanceof CloakError) {
695
+ console.log(error.category, error.retryable);
345
696
  }
346
697
  }
347
698
  ```
348
699
 
700
+ Catch the typed errors instead of matching message strings. `confirmTransactSettlement` re-checks a
701
+ signature from chain state when an outcome was reported as unknown.
702
+
703
+ ## Development
704
+
705
+ ```bash
706
+ npm install
707
+ npm run lint # tsc --noEmit
708
+ npm test # jest
709
+ npm run build # tsup -> dist/
710
+ npm run verify:dist # dist is byte-identical to a fresh build
711
+ npm run test:examples # dry-run the examples
712
+ ```
713
+
714
+ Notes: `docs/DEPOSIT-SIZE-NOTES.md` (deposit transaction size, confirmed-tree reads,
715
+ supplemental lookup tables), `docs/transact-split.md` (how `flows/transact.ts` is split),
716
+ `docs/cleanup-plan.md` (the root/scripts/examples cleanup this repo is executing).
717
+
349
718
  ## Links
350
719
 
351
720
  - Website: [https://cloak.ag](https://cloak.ag)
352
721
  - Documentation: [https://docs.cloak.ag](https://docs.cloak.ag)
353
- - GitHub: [https://github.com/cloak-ag/sdk](https://github.com/cloak-ag/sdk)
354
722
 
355
723
  ## License
356
724