@cloakely/sdk 0.8.5 → 0.9.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.
@@ -54,6 +54,8 @@ export interface CloakDeployment {
54
54
  sharedRouter?: Address;
55
55
  /** SharedBatcher: batch rounds for the shared vault's tokens. */
56
56
  sharedBatcher?: Address;
57
+ /** CloakReceive: private receive addresses. */
58
+ receive?: Address;
57
59
  /** CloakDropFactory: sealed NFT drops. */
58
60
  dropFactory?: Address;
59
61
  /** Block the drop factory was deployed in: scan drop events from here. */
@@ -26,6 +26,7 @@ export const deployments = {
26
26
  sharedBlock: 21426599n,
27
27
  sharedRouter: "0xe2a0769a5acbd987c1b6725247c56187a112dc7e",
28
28
  sharedBatcher: "0x11598b35fabc90fdd45d8a77ac41e2057c32ea14",
29
+ receive: "0x852675b2e127fcce3b5efd3ef817623db7aa44f3",
29
30
  // sealed NFT drops (Nov 3): GTD, then a sealed auction with the creator's price as the floor and a WL bid boost
30
31
  dropFactory: "0x2afb7d9a5baa02a2d72a7c761521fc66dce9d783",
31
32
  dropFactoryBlock: 22329730n,
@@ -115,6 +116,7 @@ export async function liveDeployment(publicClient, base) {
115
116
  sharedBlock: reg.sharedBlock ?? base.sharedBlock,
116
117
  sharedRouter: reg.sharedRouter ?? base.sharedRouter,
117
118
  sharedBatcher: reg.sharedBatcher ?? base.sharedBatcher,
119
+ receive: reg.receive ?? base.receive,
118
120
  hypePool,
119
121
  whype,
120
122
  previous: reg.previous.length
package/dist/drops.d.ts CHANGED
@@ -179,7 +179,7 @@ export interface CreateDropOptions {
179
179
  };
180
180
  biddingSecs: number;
181
181
  /** Longest time after bidding closes for the keeper to open the bids (default 30 minutes). The drop settles as soon
182
- * as every bid is open, so a long window only matters if the keeper is down; a bid still sealed at its end loses 10%. */
182
+ * as every bid is open, so a long window only matters if the keeper is down; a bid still sealed at its end loses 25% (10% on drops made before Nov 7). */
183
183
  revealSecs?: number;
184
184
  image?: string;
185
185
  /** A wide banner for the drop page (an https:, ipfs: or small data: image). */
@@ -1294,6 +1294,16 @@ export declare class CloakDrops {
1294
1294
  readonly internalType: "uint256";
1295
1295
  }];
1296
1296
  readonly stateMutability: "view";
1297
+ }, {
1298
+ readonly type: "function";
1299
+ readonly name: "seedBlock";
1300
+ readonly inputs: readonly [];
1301
+ readonly outputs: readonly [{
1302
+ readonly name: "";
1303
+ readonly type: "uint64";
1304
+ readonly internalType: "uint64";
1305
+ }];
1306
+ readonly stateMutability: "view";
1297
1307
  }, {
1298
1308
  readonly type: "function";
1299
1309
  readonly name: "setApprovalForAll";
@@ -2,6 +2,9 @@ import { type Address, type Hex, type PublicClient } from "viem";
2
2
  import { type Wallet } from "../cloak.js";
3
3
  import { CloakKeys } from "./crypto.js";
4
4
  import { MerkleTree } from "./tree.js";
5
+ import { type PublicLeaves } from "./syncState.js";
6
+ import { type FundsProof } from "./funds.js";
7
+ import type { FundsProver } from "./prover.js";
5
8
  import type { ProofArgs, Prover } from "./prover.js";
6
9
  import { type ExtData, type OwnedNote } from "./tx.js";
7
10
  /** A shielded call the relayer submits so the trader's wallet never appears on-chain. */
@@ -45,6 +48,30 @@ export declare function relayerSubmitter(url: string): Submitter;
45
48
  /** Submit from a wallet directly — works, but that wallet pays gas and appears as the sender. Use for tests/relayers. */
46
49
  export declare function walletSubmitter(publicClient: PublicClient, wallet: Wallet, router: Address, batcher?: Address, migrator?: Address): Submitter;
47
50
  export type Status = (msg: string) => void;
51
+ /** One piece of a split withdrawal: a proved relay call, and the earliest time a relayer may send it (unix seconds). */
52
+ export interface ScheduledPiece {
53
+ call: RelayCall;
54
+ notBefore: number;
55
+ /** what this piece withdraws (before the vault's withdrawal fee) */
56
+ amount: bigint;
57
+ to: Address;
58
+ }
59
+ /**
60
+ * `amount` in `k` pieces of different, ordinary-looking sizes: random shares rounded to 2 significant digits, the
61
+ * last one taking the remainder. Every piece is above zero.
62
+ */
63
+ export declare function splitAmount(amount: bigint, k: number): bigint[];
64
+ /** Hand split-withdrawal pieces to a Cloak relayer (`POST /schedule`). Returns an id per piece, for `scheduleStatus`. */
65
+ export declare function scheduleRelay(url: string, pieces: ScheduledPiece[]): Promise<string[]>;
66
+ export interface ScheduledStatus {
67
+ id: string;
68
+ state: "waiting" | "sent" | "dropped" | "unknown";
69
+ notBefore?: number;
70
+ txHash?: Hex;
71
+ error?: string;
72
+ }
73
+ /** Where a relayer's scheduled pieces stand (`GET /schedule?ids=`). "unknown": it doesn't have it (re-send it). */
74
+ export declare function scheduleStatus(url: string, ids: string[]): Promise<ScheduledStatus[]>;
48
75
  /**
49
76
  * Private trading for Cloak launch tokens on Elysium.
50
77
  *
@@ -102,8 +129,23 @@ export declare class CloakPrivate {
102
129
  /** Register a shielded pool that isn't a launch market, such as USDC (`deploymentFor(chainId).assets`). */
103
130
  addAsset(a: PrivateAsset): PrivateMarket;
104
131
  private state;
105
- /** Pull new leaves from the pool's events, trial-decrypt them, and refresh spent flags. */
132
+ private inflight;
133
+ /** Pull new leaves from the pool's events, trial-decrypt them, and refresh spent flags. One sync per pool at a time
134
+ * (callers at the same moment share it), so a new note is never counted twice. */
106
135
  sync(pool: Address): Promise<OwnedNote[]>;
136
+ private syncOnce;
137
+ /**
138
+ * Start `pool` from public leaves (a keeper's GET /leaves) instead of scanning its history. Used only if nothing is
139
+ * synced yet and the leaves hash to a root the vault knows; returns whether they were used.
140
+ */
141
+ seedLeaves(pool: Address, src: PublicLeaves): Promise<boolean>;
142
+ /**
143
+ * What sync has learned so far, to keep on this device (encrypted: it lists your notes) and hand back to
144
+ * `importSync` next time, so only new blocks are scanned. Spending keys are left out and derived again on import.
145
+ */
146
+ exportSync(): string;
147
+ /** Load a state from `exportSync` (this account's only: notes that don't match its keys are dropped). */
148
+ importSync(data: string): Promise<boolean>;
107
149
  /** Every note this account has received in `pool`, spent ones included (each with `spent` and its nullifier). */
108
150
  history(pool: Address): Promise<OwnedNote[]>;
109
151
  balance(pool: Address): Promise<bigint>;
@@ -248,6 +290,38 @@ export declare class CloakPrivate {
248
290
  * `token` = a launch token, an asset such as USDC, or the WHYPE address / hypeMarket.token for HYPE.
249
291
  */
250
292
  send(token: Address, to: string, amount: bigint, onStatus?: Status): Promise<Hex>;
293
+ /**
294
+ * A withdrawal sent later in 2–4 pieces of different sizes, so the amount coming out doesn't match anything that went
295
+ * in. Done now, on this device:
296
+ * 1. private self-sends split the balance into one exact note per piece (pieces − 1 relayed sends),
297
+ * 2. each piece's withdrawal is proved, its recipient fixed by the proof.
298
+ * Hand the pieces to a relayer with `scheduleRelay`: it can only send each one (after `notBefore`) or refuse it,
299
+ * never change where it goes. Spending a piece's note before it's sent cancels that piece.
300
+ * `token` is a launch token or asset, or the WHYPE address / hypeMarket.token for HYPE.
301
+ */
302
+ planSplitWithdraw(token: Address, to: Address, amount: bigint, opts?: {
303
+ pieces?: number;
304
+ windowSecs?: number;
305
+ onStatus?: Status;
306
+ }): Promise<ScheduledPiece[]>;
307
+ /**
308
+ * Prove "I hold at least `threshold` of `token` in its vault" without revealing the balance, the notes or who you
309
+ * are (see funds.ts). Uses 1–3 of your notes; if the balance is spread over more, they're combined first (one
310
+ * relayed private send). `label` says who it's for and `expiresAt` (unix seconds) when it stops counting.
311
+ */
312
+ proveFunds(token: Address, threshold: bigint, o: {
313
+ label: string;
314
+ expiresAt: number;
315
+ prover: FundsProver;
316
+ symbol?: string;
317
+ decimals?: number;
318
+ onStatus?: Status;
319
+ }): Promise<FundsProof>;
320
+ /**
321
+ * Move notes into a fresh note of your own (a relayed private send to yourself), so whoever holds a proof of funds
322
+ * that named their nullifiers can't tell when you later spend that money.
323
+ */
324
+ refreshNotes(pool: Address, notes: OwnedNote[], onStatus?: Status): Promise<Hex>;
251
325
  /** Private tokens (a launch token, or an asset such as USDC) → any address. */
252
326
  unshieldToken(token: Address, to: Address, amount: bigint, onStatus?: Status): Promise<Hex>;
253
327
  /**
@@ -4,6 +4,8 @@ import { cloakAuctionAbi, cloakConfigAbi, cloakMigratorAbi, cloakPairAbi, privat
4
4
  import { Cloak } from "../cloak.js";
5
5
  import { ACCOUNT_MESSAGE, ACCOUNT_TYPED_DATA, ROUTER_ACCOUNT_TYPED_DATA, CloakKeys, MAIN_KEY, noteCommitment, encryptFullMemo, encryptRouterMemo, formatCloakAddress, nullifierOf, parseCloakAddress, randomField, randomKeyIndex, readMemo, } from "./crypto.js";
6
6
  import { MerkleTree } from "./tree.js";
7
+ import { fromJson, leavesRoot, toJson } from "./syncState.js";
8
+ import { FUNDS_NOTES, fundsContext } from "./funds.js";
7
9
  import { MOVE_DATA_ABI, ORDER_DATA_ABI, SWAP_DATA_ABI, buildTransaction, selectNotes, splitStandard } from "./tx.js";
8
10
  /** Leaf positions per tree epoch (a pool starts a new tree after 2^20 leaves). */
9
11
  const EPOCH_BITS = 20;
@@ -71,6 +73,46 @@ export function walletSubmitter(publicClient, wallet, router, batcher, migrator)
71
73
  return hash;
72
74
  };
73
75
  }
76
+ /**
77
+ * `amount` in `k` pieces of different, ordinary-looking sizes: random shares rounded to 2 significant digits, the
78
+ * last one taking the remainder. Every piece is above zero.
79
+ */
80
+ export function splitAmount(amount, k) {
81
+ if (amount < BigInt(k) * 100n)
82
+ return [amount];
83
+ const w = Array.from({ length: k }, () => 0.6 + Math.random());
84
+ const sum = w.reduce((a, b) => a + b, 0);
85
+ const round2 = (x) => {
86
+ const d = x.toString().length;
87
+ const unit = d > 2 ? 10n ** BigInt(d - 2) : 1n;
88
+ return (x / unit) * unit;
89
+ };
90
+ const out = [];
91
+ let left = amount;
92
+ for (let i = 0; i < k - 1; i++) {
93
+ const x = round2((amount * BigInt(Math.round((w[i] / sum) * 1e6))) / 1000000n);
94
+ out.push(x);
95
+ left -= x;
96
+ }
97
+ out.push(left);
98
+ return out.every((x) => x > 0n) ? out : [amount];
99
+ }
100
+ /** Hand split-withdrawal pieces to a Cloak relayer (`POST /schedule`). Returns an id per piece, for `scheduleStatus`. */
101
+ export async function scheduleRelay(url, pieces) {
102
+ const res = await fetch(`${url.replace(/\/$/, "")}/schedule`, { method: "POST", headers: { "content-type": "application/json" }, body: json({ pieces: pieces.map((p) => ({ call: p.call, notBefore: p.notBefore })) }), signal: AbortSignal.timeout(120_000) });
103
+ const out = await res.json().catch(() => ({}));
104
+ if (!res.ok)
105
+ throw new Error(out.error ?? `scheduling failed (${res.status})`);
106
+ return out.ids;
107
+ }
108
+ /** Where a relayer's scheduled pieces stand (`GET /schedule?ids=`). "unknown": it doesn't have it (re-send it). */
109
+ export async function scheduleStatus(url, ids) {
110
+ const res = await fetch(`${url.replace(/\/$/, "")}/schedule?ids=${ids.join(",")}`, { signal: AbortSignal.timeout(30_000) });
111
+ const out = await res.json().catch(() => ({}));
112
+ if (!res.ok)
113
+ throw new Error(out.error ?? `status failed (${res.status})`);
114
+ return out.pieces;
115
+ }
74
116
  /**
75
117
  * Private trading for Cloak launch tokens on Elysium.
76
118
  *
@@ -207,8 +249,19 @@ export class CloakPrivate {
207
249
  }
208
250
  return s;
209
251
  }
210
- /** Pull new leaves from the pool's events, trial-decrypt them, and refresh spent flags. */
252
+ inflight = new Map();
253
+ /** Pull new leaves from the pool's events, trial-decrypt them, and refresh spent flags. One sync per pool at a time
254
+ * (callers at the same moment share it), so a new note is never counted twice. */
211
255
  async sync(pool) {
256
+ const k = pool.toLowerCase();
257
+ let p = this.inflight.get(k);
258
+ if (!p) {
259
+ p = this.syncOnce(pool).finally(() => this.inflight.delete(k));
260
+ this.inflight.set(k, p);
261
+ }
262
+ return p;
263
+ }
264
+ async syncOnce(pool) {
212
265
  const s = this.state(pool);
213
266
  const latest = await this.publicClient.getBlockNumber({ cacheTime: 0 });
214
267
  if (latest >= s.fromBlock) {
@@ -233,6 +286,11 @@ export class CloakPrivate {
233
286
  let n = 0;
234
287
  while (n < s.leaves.length && s.leaves[n].index === n)
235
288
  n++;
289
+ if (n < s.leaves.length && s.seeded) {
290
+ // the cache stopped short of where the chain picked up: forget it and scan this vault from the start
291
+ this.pools.delete(pool.toLowerCase());
292
+ return this.syncOnce(pool);
293
+ }
236
294
  for (const l of s.leaves.slice(s.scannedUpTo, n)) {
237
295
  const m = readMemo(this.keys.encPriv, l.memo);
238
296
  if (!m || m.amount === 0n)
@@ -249,6 +307,60 @@ export class CloakPrivate {
249
307
  s.notes.forEach((x, i) => (x.spent = Boolean(flags[i])));
250
308
  return s.notes.filter((x) => !x.spent);
251
309
  }
310
+ /**
311
+ * Start `pool` from public leaves (a keeper's GET /leaves) instead of scanning its history. Used only if nothing is
312
+ * synced yet and the leaves hash to a root the vault knows; returns whether they were used.
313
+ */
314
+ async seedLeaves(pool, src) {
315
+ const s = this.state(pool);
316
+ if (s.leaves.length || s.scannedUpTo)
317
+ return false;
318
+ const root = await leavesRoot(src.leaves);
319
+ if (root === null)
320
+ return false;
321
+ const known = await this.publicClient.readContract({ address: pool, abi: shieldedPoolAbi, functionName: "isKnownRoot", args: [root] }).catch(() => false);
322
+ if (!known)
323
+ return false;
324
+ s.leaves = src.leaves.map((l) => ({ ...l }));
325
+ s.fromBlock = src.block + 1n;
326
+ s.seeded = true;
327
+ return true;
328
+ }
329
+ /**
330
+ * What sync has learned so far, to keep on this device (encrypted: it lists your notes) and hand back to
331
+ * `importSync` next time, so only new blocks are scanned. Spending keys are left out and derived again on import.
332
+ */
333
+ exportSync() {
334
+ // scanned leaves keep only their commitment (the tree needs it; the memo was already read)
335
+ const pools = [...this.pools].map(([k, s]) => [k, { leaves: s.leaves.map((l, i) => (i < s.scannedUpTo ? { ...l, memo: "0x" } : l)), scannedUpTo: s.scannedUpTo, fromBlock: s.fromBlock, notes: s.notes.map(({ privKey: _, ...n }) => n) }]);
336
+ return toJson({ v: 1, pools });
337
+ }
338
+ /** Load a state from `exportSync` (this account's only: notes that don't match its keys are dropped). */
339
+ async importSync(data) {
340
+ try {
341
+ const o = fromJson(data);
342
+ if (o.v !== 1)
343
+ return false;
344
+ for (const [k, st] of o.pools) {
345
+ if (this.pools.has(k))
346
+ continue;
347
+ const notes = [];
348
+ let ok = true;
349
+ for (const n of st.notes) {
350
+ const key = await this.keys.key(n.keyIndex);
351
+ if (key.pubKey !== n.pubKey)
352
+ ok = false;
353
+ notes.push({ ...n, privKey: key.privKey });
354
+ }
355
+ if (ok)
356
+ this.pools.set(k, { leaves: st.leaves, scannedUpTo: st.scannedUpTo, fromBlock: st.fromBlock, notes, seeded: true });
357
+ }
358
+ return true;
359
+ }
360
+ catch {
361
+ return false;
362
+ }
363
+ }
252
364
  /** Every note this account has received in `pool`, spent ones included (each with `spent` and its nullifier). */
253
365
  async history(pool) {
254
366
  await this.sync(pool);
@@ -742,6 +854,148 @@ export class CloakPrivate {
742
854
  onStatus("Relaying private transfer…");
743
855
  return this.submit({ kind: "transact", pool, proof, extData });
744
856
  }
857
+ /**
858
+ * A withdrawal sent later in 2–4 pieces of different sizes, so the amount coming out doesn't match anything that went
859
+ * in. Done now, on this device:
860
+ * 1. private self-sends split the balance into one exact note per piece (pieces − 1 relayed sends),
861
+ * 2. each piece's withdrawal is proved, its recipient fixed by the proof.
862
+ * Hand the pieces to a relayer with `scheduleRelay`: it can only send each one (after `notBefore`) or refuse it,
863
+ * never change where it goes. Spending a piece's note before it's sent cancels that piece.
864
+ * `token` is a launch token or asset, or the WHYPE address / hypeMarket.token for HYPE.
865
+ */
866
+ async planSplitWithdraw(token, to, amount, opts = {}) {
867
+ const onStatus = opts.onStatus ?? (() => { });
868
+ const k = Math.min(4, Math.max(2, opts.pieces ?? 3));
869
+ const windowSecs = Math.max(600, opts.windowSecs ?? 3600);
870
+ const isHype = token.toLowerCase() === this.hypeMarket.token.toLowerCase();
871
+ const pool = isHype ? this.hypeMarket.pool : this.market(token).pool;
872
+ const kind = isHype ? "unshieldHype" : "transact";
873
+ const amounts = splitAmount(amount, k);
874
+ // each piece carries twice today's relayer fee, so a gas rise before it goes out doesn't get it refused
875
+ const q = await this.relayFee(kind, pool);
876
+ const pieceFee = q.fee * 2n;
877
+ const notes = [];
878
+ let rest = null;
879
+ for (let i = 0; i < k - 1; i++) {
880
+ onStatus(`Splitting your balance privately (${i + 1} of ${k - 1})…`);
881
+ const sq = await this.relayFee("transact", pool);
882
+ const remaining = amounts.slice(i).reduce((a, x) => a + x + pieceFee, 0n);
883
+ const inputs = rest ?? (await this.inputsFor(pool, remaining + sq.fee * BigInt(2 * (k - 1)), onStatus));
884
+ const total = inputs.reduce((a, x) => a + x.amount, 0n);
885
+ const first = amounts[i] + pieceFee;
886
+ const outputs = [await this.changeOutput(first), await this.changeOutput(total - sq.fee - first)];
887
+ onStatus("Generating zero-knowledge proof on this device…");
888
+ const { proof, extData } = await buildTransaction({ tree: await this.treeFor(pool, inputs), inputs, outputs, fee: sq.fee, relayer: sq.relayer, ...(await this.dummy()), prover: await this.prover() });
889
+ const h = await this.submit({ kind: "transact", pool, proof, extData });
890
+ await this.publicClient.waitForTransactionReceipt({ hash: h });
891
+ const [c0, c1] = proof.outputCommitments.map(BigInt);
892
+ let mine = [];
893
+ for (let t = 0; t < 10 && mine.length < 2; t++) {
894
+ mine = (await this.sync(pool)).filter((n) => n.commitment === c0 || n.commitment === c1);
895
+ if (mine.length < 2)
896
+ await new Promise((r) => setTimeout(r, 1500));
897
+ }
898
+ const piece = mine.find((n) => n.commitment === c0);
899
+ const left = mine.find((n) => n.commitment === c1);
900
+ if (!piece || !left)
901
+ throw new Error("The split landed, but the new notes haven't shown up yet. Try again in a minute: nothing was lost.");
902
+ notes.push(piece);
903
+ rest = [left];
904
+ }
905
+ // the last piece's note is what the splits left: its amount plus anything spare, which comes back as change
906
+ notes.push(rest[0]);
907
+ const now = Math.floor(Date.now() / 1000);
908
+ const times = Array.from({ length: k }, () => now + 60 + Math.floor(Math.random() * (windowSecs - 60))).sort((a, b) => a - b);
909
+ const out = [];
910
+ for (let i = 0; i < k; i++) {
911
+ onStatus(`Proving withdrawal ${i + 1} of ${k}…`);
912
+ const n = notes[i];
913
+ const pieceAmount = amounts[i];
914
+ const change = n.amount - pieceAmount - pieceFee;
915
+ if (change < 0n)
916
+ throw new Error("The split came out short (the relayer's fee rose meanwhile). Your balance is intact: try again.");
917
+ const { proof, extData } = await buildTransaction({
918
+ tree: await this.treeFor(pool, [n]),
919
+ inputs: [n],
920
+ outputs: change > 0n ? [await this.changeOutput(change)] : [],
921
+ extAmount: -pieceAmount,
922
+ fee: pieceFee,
923
+ relayer: q.relayer,
924
+ recipient: isHype ? this.router : to,
925
+ ...(isHype ? { data: encodeAbiParameters([{ type: "address" }], [to]) } : {}),
926
+ ...(await this.dummy()),
927
+ prover: await this.prover(),
928
+ });
929
+ out.push({ call: { kind, pool, proof, extData }, notBefore: times[i], amount: pieceAmount, to });
930
+ }
931
+ return out;
932
+ }
933
+ /**
934
+ * Prove "I hold at least `threshold` of `token` in its vault" without revealing the balance, the notes or who you
935
+ * are (see funds.ts). Uses 1–3 of your notes; if the balance is spread over more, they're combined first (one
936
+ * relayed private send). `label` says who it's for and `expiresAt` (unix seconds) when it stops counting.
937
+ */
938
+ async proveFunds(token, threshold, o) {
939
+ const onStatus = o.onStatus ?? (() => { });
940
+ if (threshold <= 0n)
941
+ throw new Error("Prove an amount above zero.");
942
+ if (!o.label.trim() || o.label.length > 80)
943
+ throw new Error("Say who the proof is for, in up to 80 characters.");
944
+ const isHype = token.toLowerCase() === this.hypeMarket.token.toLowerCase();
945
+ const pool = isHype ? this.hypeMarket.pool : this.market(token).pool;
946
+ const notes = await this.inputsFor(pool, threshold, onStatus);
947
+ if (notes.length > FUNDS_NOTES)
948
+ throw new Error("Your balance is in too many pieces: combine it first.");
949
+ const tree = await this.treeFor(pool, notes);
950
+ const root = await tree.root();
951
+ const paths = await Promise.all(notes.map((n) => tree.path(n.leafIndex)));
952
+ const block = await this.publicClient.getBlockNumber({ cacheTime: 0 });
953
+ const chainId = this.publicClient.chain?.id ?? (await this.publicClient.getChainId());
954
+ const context = fundsContext(chainId, pool, o.label, o.expiresAt);
955
+ const slot = (i, f, empty) => (i < notes.length ? f(notes[i]) : empty);
956
+ const idx = [0, 1, 2];
957
+ onStatus("Generating the proof on this device…");
958
+ const { proof, publicSignals } = await o.prover({
959
+ root,
960
+ threshold,
961
+ context,
962
+ nullifier: idx.map((i) => slot(i, (n) => n.nullifier, 0n)),
963
+ used: idx.map((i) => (i < notes.length ? 1n : 0n)),
964
+ amount: idx.map((i) => slot(i, (n) => n.amount, 0n)),
965
+ privKey: idx.map((i) => slot(i, (n) => n.privKey, 1n)),
966
+ blinding: idx.map((i) => slot(i, (n) => n.blinding, 0n)),
967
+ pathIndices: idx.map((i) => slot(i, (n) => BigInt(n.leafIndex), 0n)),
968
+ pathElements: idx.map((i) => (i < notes.length ? paths[i].pathElements : new Array(paths[0].pathElements.length).fill(0n))),
969
+ });
970
+ return {
971
+ v: 1,
972
+ chainId,
973
+ pool,
974
+ token: isHype ? this.hypeMarket.token : token,
975
+ symbol: o.symbol ?? (isHype ? "HYPE" : this.market(token).symbol ?? "tokens"),
976
+ decimals: o.decimals ?? (isHype ? 18 : this.market(token).decimals ?? 18),
977
+ threshold: threshold.toString(),
978
+ label: o.label,
979
+ expiresAt: o.expiresAt,
980
+ block: block.toString(),
981
+ root: publicSignals[0],
982
+ context: publicSignals[2],
983
+ nullifiers: publicSignals.slice(3, 3 + FUNDS_NOTES),
984
+ proof,
985
+ };
986
+ }
987
+ /**
988
+ * Move notes into a fresh note of your own (a relayed private send to yourself), so whoever holds a proof of funds
989
+ * that named their nullifiers can't tell when you later spend that money.
990
+ */
991
+ async refreshNotes(pool, notes, onStatus = () => { }) {
992
+ const q = await this.relayFee("transact", pool);
993
+ const total = notes.reduce((a, x) => a + x.amount, 0n) - q.fee;
994
+ onStatus("Generating zero-knowledge proof on this device…");
995
+ const { proof, extData } = await buildTransaction({ tree: await this.treeFor(pool, notes), inputs: notes, outputs: [await this.changeOutput(total)], fee: q.fee, relayer: q.relayer, ...(await this.dummy()), prover: await this.prover() });
996
+ onStatus("Relaying…");
997
+ return this.submit({ kind: "transact", pool, proof, extData });
998
+ }
745
999
  /** Private tokens (a launch token, or an asset such as USDC) → any address. */
746
1000
  async unshieldToken(token, to, amount, onStatus = () => { }) {
747
1001
  const pool = this.market(token).pool;
@@ -4,6 +4,9 @@ export declare const LEVELS = 20;
4
4
  export declare const MAX_NOTE_AMOUNT: bigint;
5
5
  /** Key index of the main spending key; any other index is a one-time key used for a single public entry. */
6
6
  export declare const MAIN_KEY = 4294967295;
7
+ /** One-time keys of private receive addresses: RECEIVE_KEY_BASE + n (random one-time keys stay below it in practice). */
8
+ export declare const RECEIVE_KEY_BASE = 3221225472;
9
+ export declare const RECEIVE_KEY_COUNT = 1073741823;
7
10
  export declare const randomField: () => bigint;
8
11
  export declare const randomKeyIndex: () => number;
9
12
  export declare function poseidon(inputs: bigint[]): Promise<bigint>;
@@ -75,6 +78,18 @@ export declare class CloakKeys {
75
78
  * bid can't be linked to the account's wallet. Derived from this account's seed, so the same signature always finds
76
79
  * it again (on any device).
77
80
  */
81
+ /**
82
+ * Everything private receive address `n` needs, derived from the seed alone, so any device that unlocks the account
83
+ * finds the same address again: a one-time key (index RECEIVE_KEY_BASE + n), a blinding, the memo encrypted to this
84
+ * account's view key (deterministic, so the address is too) and the key that may sign a rescue for it.
85
+ */
86
+ receive(n: number): Promise<{
87
+ keyIndex: number;
88
+ pubKey: bigint;
89
+ blinding: bigint;
90
+ memo: Hex;
91
+ rescueKey: Hex;
92
+ }>;
78
93
  bidWalletKey(auction: Hex, n: number): Hex;
79
94
  }
80
95
  export interface Note {
@@ -10,9 +10,12 @@ export const LEVELS = 20;
10
10
  export const MAX_NOTE_AMOUNT = 2n ** 128n;
11
11
  /** Key index of the main spending key; any other index is a one-time key used for a single public entry. */
12
12
  export const MAIN_KEY = 0xffffffff;
13
+ /** One-time keys of private receive addresses: RECEIVE_KEY_BASE + n (random one-time keys stay below it in practice). */
14
+ export const RECEIVE_KEY_BASE = 0xc0000000;
15
+ export const RECEIVE_KEY_COUNT = 0x3fffffff;
13
16
  const fieldMod = (x) => ((x % FIELD_SIZE) + FIELD_SIZE) % FIELD_SIZE;
14
17
  export const randomField = () => BigInt(toHex(randomBytes(31)));
15
- export const randomKeyIndex = () => Number(BigInt(toHex(randomBytes(4))) % 0xfffffffen);
18
+ export const randomKeyIndex = () => Number(BigInt(toHex(randomBytes(4))) % 0xc0000000n); // below RECEIVE_KEY_BASE
16
19
  let _poseidon;
17
20
  export async function poseidon(inputs) {
18
21
  _poseidon ??= await (await import("circomlibjs")).buildPoseidon(); // loaded on first use
@@ -82,6 +85,21 @@ export class CloakKeys {
82
85
  * bid can't be linked to the account's wallet. Derived from this account's seed, so the same signature always finds
83
86
  * it again (on any device).
84
87
  */
88
+ /**
89
+ * Everything private receive address `n` needs, derived from the seed alone, so any device that unlocks the account
90
+ * finds the same address again: a one-time key (index RECEIVE_KEY_BASE + n), a blinding, the memo encrypted to this
91
+ * account's view key (deterministic, so the address is too) and the key that may sign a rescue for it.
92
+ */
93
+ async receive(n) {
94
+ if (!Number.isInteger(n) || n < 0 || n >= RECEIVE_KEY_COUNT)
95
+ throw new Error("receive address number out of range");
96
+ const tag = (t) => toBytes(keccak256(concat([toBytes(t), this.seed, toBytes(toHex(n, { size: 4 }))])));
97
+ const keyIndex = RECEIVE_KEY_BASE + n;
98
+ const k = await this.key(keyIndex);
99
+ const blinding = fieldMod(BigInt(toHex(tag("cloak-receive-blinding-v1"))));
100
+ const memo = toHex(seal(this.encPub, concat([toBytes(toHex(blinding, { size: 32 })), u32(keyIndex)]), tag("cloak-receive-eph-v1"), tag("cloak-receive-nonce-v1").slice(0, 24)));
101
+ return { keyIndex, pubKey: k.pubKey, blinding, memo, rescueKey: toHex(tag("cloak-receive-rescue-v1")) };
102
+ }
85
103
  bidWalletKey(auction, n) {
86
104
  return keccak256(concat([toBytes("cloak-bidwallet-v1"), this.seed, toBytes(auction.toLowerCase()), toBytes(toHex(n, { size: 4 }))]));
87
105
  }
@@ -97,10 +115,8 @@ export async function nullifierOf(n, leafIndex, privKey) {
97
115
  // 0x02 | amount(16) | eph(32) | nonce(24) | enc(blinding32 ‖ keyIndex4) + tag(16) — notes created by the router
98
116
  // (the router prepends the public amount; the client only encrypts blinding + key index)
99
117
  const INFO = toBytes("cloak-note-v1");
100
- function seal(encPub, pt) {
101
- const eph = randomBytes(32);
118
+ function seal(encPub, pt, eph = randomBytes(32), nonce = randomBytes(24)) {
102
119
  const key = hkdf(sha256, x25519.getSharedSecret(eph, encPub), undefined, INFO, 32);
103
- const nonce = randomBytes(24);
104
120
  return concat([x25519.getPublicKey(eph), nonce, xchacha20poly1305(key, nonce).encrypt(pt)]);
105
121
  }
106
122
  function open(encPriv, b) {
@@ -0,0 +1,58 @@
1
+ import { type Address, type PublicClient } from "viem";
2
+ import type { RawGroth16 } from "./prover.js";
3
+ import { FUNDS_VERIFYING_KEY } from "./fundsVk.js";
4
+ export { FUNDS_VERIFYING_KEY };
5
+ /**
6
+ * Proof of funds: "this vault holds at least `threshold` for me, as of block `block`", from up to 3 of the prover's
7
+ * notes, without the balance, the notes or the owner. The proof reveals those notes' nullifiers, so the verifier can
8
+ * check none was spent; the flip side is that the verifier could notice when they're spent later, which is why the
9
+ * app offers to refresh them (a private send to yourself) after proving.
10
+ *
11
+ * `context` binds a proof to one purpose: the chain, the vault, a free-text label (who it's for) and an expiry. A proof
12
+ * shown with a different label or after its expiry fails.
13
+ */
14
+ export declare const FUNDS_NOTES = 3;
15
+ export interface FundsProof {
16
+ v: 1;
17
+ chainId: number;
18
+ pool: Address;
19
+ /** the vault's asset, for display (WHYPE for HYPE) */
20
+ token: Address;
21
+ symbol: string;
22
+ decimals: number;
23
+ threshold: string;
24
+ label: string;
25
+ /** unix seconds */
26
+ expiresAt: number;
27
+ /** the block the notes were checked unspent at, when proving */
28
+ block: string;
29
+ root: string;
30
+ context: string;
31
+ nullifiers: string[];
32
+ proof: RawGroth16;
33
+ }
34
+ export declare function fundsContext(chainId: number, pool: Address, label: string, expiresAt: number): bigint;
35
+ /** Public signals in the circuit's order: root, threshold, context, nullifier[3]. */
36
+ export declare const fundsSignals: (p: FundsProof) => string[];
37
+ /** A proof as a short shareable string (base64url JSON), for a link like `cloak.boblabs.tech/proof#<this>`. */
38
+ export declare function encodeFundsProof(p: FundsProof): string;
39
+ export declare function decodeFundsProof(s: string): FundsProof;
40
+ export interface FundsCheck {
41
+ ok: boolean;
42
+ checks: {
43
+ name: string;
44
+ ok: boolean;
45
+ detail: string;
46
+ }[];
47
+ /** a backing note was spent after the proof's block (the claim held when made, not now) */
48
+ spentSince: boolean;
49
+ }
50
+ /**
51
+ * Check a proof of funds: the zero-knowledge proof itself (against `vk`, by default the key built into the SDK), that it's bound
52
+ * to this chain, vault, label and expiry, that the vault is a Cloak vault and knows the root, and that no backing note
53
+ * was spent as of the proof's block. A note spent since is reported, not failed: the claim was true when made.
54
+ */
55
+ export declare function verifyFundsProof(publicClient: PublicClient, p: FundsProof, vk?: unknown, opts?: {
56
+ vaults?: Address;
57
+ now?: number;
58
+ }): Promise<FundsCheck>;
@@ -0,0 +1,65 @@
1
+ import { encodeAbiParameters, keccak256 } from "viem";
2
+ import { cloakVaultsAbi, shieldedPoolAbi } from "../abis.js";
3
+ import { FIELD_SIZE } from "./crypto.js";
4
+ import { FUNDS_VERIFYING_KEY } from "./fundsVk.js";
5
+ export { FUNDS_VERIFYING_KEY };
6
+ /**
7
+ * Proof of funds: "this vault holds at least `threshold` for me, as of block `block`", from up to 3 of the prover's
8
+ * notes, without the balance, the notes or the owner. The proof reveals those notes' nullifiers, so the verifier can
9
+ * check none was spent; the flip side is that the verifier could notice when they're spent later, which is why the
10
+ * app offers to refresh them (a private send to yourself) after proving.
11
+ *
12
+ * `context` binds a proof to one purpose: the chain, the vault, a free-text label (who it's for) and an expiry. A proof
13
+ * shown with a different label or after its expiry fails.
14
+ */
15
+ export const FUNDS_NOTES = 3;
16
+ export function fundsContext(chainId, pool, label, expiresAt) {
17
+ return BigInt(keccak256(encodeAbiParameters([{ type: "string" }, { type: "uint256" }, { type: "address" }, { type: "string" }, { type: "uint64" }], ["cloak-funds-v1", BigInt(chainId), pool, label, BigInt(expiresAt)]))) % FIELD_SIZE;
18
+ }
19
+ /** Public signals in the circuit's order: root, threshold, context, nullifier[3]. */
20
+ export const fundsSignals = (p) => [p.root, p.threshold, p.context, ...p.nullifiers];
21
+ /** A proof as a short shareable string (base64url JSON), for a link like `cloak.boblabs.tech/proof#<this>`. */
22
+ export function encodeFundsProof(p) {
23
+ const b = new TextEncoder().encode(JSON.stringify(p));
24
+ return btoa(Array.from(b, (c) => String.fromCharCode(c)).join("")).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
25
+ }
26
+ export function decodeFundsProof(s) {
27
+ const b = Uint8Array.from(atob(s.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0));
28
+ const p = JSON.parse(new TextDecoder().decode(b));
29
+ if (p?.v !== 1 || !Array.isArray(p.nullifiers) || p.nullifiers.length !== FUNDS_NOTES)
30
+ throw new Error("not a Cloak proof of funds");
31
+ return p;
32
+ }
33
+ /**
34
+ * Check a proof of funds: the zero-knowledge proof itself (against `vk`, by default the key built into the SDK), that it's bound
35
+ * to this chain, vault, label and expiry, that the vault is a Cloak vault and knows the root, and that no backing note
36
+ * was spent as of the proof's block. A note spent since is reported, not failed: the claim was true when made.
37
+ */
38
+ export async function verifyFundsProof(publicClient, p, vk = FUNDS_VERIFYING_KEY, opts = {}) {
39
+ const checks = [];
40
+ const add = (name, ok, detail) => checks.push({ name, ok, detail });
41
+ const now = opts.now ?? Math.floor(Date.now() / 1000);
42
+ const chainId = publicClient.chain?.id ?? (await publicClient.getChainId());
43
+ add("Right network", chainId === p.chainId, chainId === p.chainId ? "The proof is for this chain." : `The proof is for chain ${p.chainId}.`);
44
+ const ctx = fundsContext(p.chainId, p.pool, p.label, p.expiresAt).toString();
45
+ add("Made for this purpose", ctx === p.context, ctx === p.context ? `Label "${p.label}".` : "The label, vault or expiry was changed after proving.");
46
+ add("Not expired", p.expiresAt > now, p.expiresAt > now ? `Valid until ${new Date(p.expiresAt * 1000).toISOString().slice(0, 16).replace("T", " ")} UTC.` : "This proof has expired.");
47
+ const snarkjs = await import("snarkjs");
48
+ const valid = await snarkjs.groth16.verify(vk, fundsSignals(p), p.proof).catch(() => false);
49
+ add("Valid zero-knowledge proof", valid, valid ? "The math checks out: the notes exist and add up." : "The proof doesn't verify.");
50
+ if (opts.vaults) {
51
+ const isPool = await publicClient.readContract({ address: opts.vaults, abi: cloakVaultsAbi, functionName: "isPool", args: [p.pool] }).catch(() => false);
52
+ add("A Cloak vault", isPool, isPool ? "The notes are in one of Cloak's permanent vaults." : "That address isn't a Cloak vault.");
53
+ }
54
+ const rootOk = await publicClient.readContract({ address: p.pool, abi: shieldedPoolAbi, functionName: "isKnownRoot", args: [BigInt(p.root)] }).catch(() => false);
55
+ add("Real vault state", rootOk, rootOk ? "The vault knows the tree the notes are in." : "The vault doesn't know this tree.");
56
+ const live = p.nullifiers.filter((n) => n !== "0");
57
+ const block = BigInt(p.block);
58
+ const spentAt = (n, blockNumber) => publicClient.readContract({ address: p.pool, abi: shieldedPoolAbi, functionName: "isSpent", args: [BigInt(n)], ...(blockNumber ? { blockNumber } : {}) });
59
+ const atBlock = await Promise.all(live.map((n) => spentAt(n, block).catch(() => spentAt(n))));
60
+ const unspentThen = atBlock.every((x) => !x);
61
+ add("Unspent when proved", unspentThen && live.length > 0, unspentThen ? `None of the ${live.length} backing note${live.length > 1 ? "s" : ""} had been spent at block ${block}.` : "A backing note had already been spent when the proof was made.");
62
+ const nowSpent = await Promise.all(live.map((n) => spentAt(n).catch(() => false)));
63
+ const spentSince = nowSpent.some(Boolean);
64
+ return { ok: checks.every((c) => c.ok), checks, spentSince };
65
+ }