@utxopia/sdk 0.1.0-alpha.2 → 0.1.0-alpha.3

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.
Files changed (142) hide show
  1. package/README.md +214 -108
  2. package/{packages/sdk/dist → dist}/client.d.ts +25 -1
  3. package/{packages/sdk/dist → dist}/client.js +36 -1
  4. package/{packages/sdk/dist → dist}/crypto-ed25519.d.ts +14 -0
  5. package/{packages/sdk/dist → dist}/crypto-ed25519.js +14 -0
  6. package/{packages/sdk/dist → dist}/index.d.ts +3 -3
  7. package/{packages/sdk/dist → dist}/index.js +3 -3
  8. package/{packages/sdk/dist → dist}/instructions.d.ts +28 -11
  9. package/{packages/sdk/dist → dist}/instructions.js +45 -15
  10. package/{packages/sdk/dist → dist}/stealth.d.ts +104 -9
  11. package/{packages/sdk/dist → dist}/stealth.js +146 -14
  12. package/{packages/sdk/dist → dist}/taproot.d.ts +39 -2
  13. package/{packages/sdk/dist → dist}/taproot.js +54 -2
  14. package/package.json +86 -63
  15. package/src/announcement-client.ts +457 -0
  16. package/src/auditor-ciphertext.ts +181 -0
  17. package/src/auditor.ts +409 -0
  18. package/src/bitcoin/ika.ts +103 -0
  19. package/src/bitcoin/index.ts +5 -0
  20. package/src/bound-params.ts +322 -0
  21. package/src/chadbuffer.ts +603 -0
  22. package/src/circomlibjs.d.ts +51 -0
  23. package/src/claim-link.ts +53 -0
  24. package/src/client.ts +638 -0
  25. package/src/commitment-tree.ts +736 -0
  26. package/src/config.ts +772 -0
  27. package/src/core/esplora.ts +332 -0
  28. package/src/core/mempool.ts +159 -0
  29. package/src/crypto-babyjub.ts +385 -0
  30. package/src/crypto-ed25519.ts +297 -0
  31. package/src/crypto.ts +199 -0
  32. package/src/event-client.ts +231 -0
  33. package/src/events.ts +384 -0
  34. package/src/explorer.ts +300 -0
  35. package/src/index.ts +902 -0
  36. package/src/instructions.ts +2820 -0
  37. package/src/keys.ts +1228 -0
  38. package/src/logger.ts +41 -0
  39. package/src/magicblock.ts +278 -0
  40. package/src/merkle.ts +197 -0
  41. package/src/note.ts +754 -0
  42. package/src/pda.ts +516 -0
  43. package/src/pool-state.ts +176 -0
  44. package/src/poseidon.ts +175 -0
  45. package/src/prover/index.ts +19 -0
  46. package/src/prover/mobile.ts +303 -0
  47. package/src/prover/web.ts +771 -0
  48. package/src/psbt.ts +333 -0
  49. package/src/selective-disclosure.ts +284 -0
  50. package/src/sender-memo.ts +343 -0
  51. package/src/snarkjs.d.ts +19 -0
  52. package/src/sns-resolver.ts +333 -0
  53. package/src/solana/connection.ts +189 -0
  54. package/src/solana/priority-fee.ts +201 -0
  55. package/src/spend-doc.ts +163 -0
  56. package/src/stealth.ts +1477 -0
  57. package/src/taproot.ts +707 -0
  58. package/src/token-registry.ts +207 -0
  59. package/src/utils/encoding.ts +33 -0
  60. package/src/vk-registry.ts +295 -0
  61. package/LICENSE +0 -21
  62. package/packages/btc-client/src/esplora-client.ts +0 -153
  63. package/packages/btc-client/src/index.ts +0 -3
  64. package/packages/btc-client/src/op-return.ts +0 -93
  65. package/packages/btc-client/src/types.ts +0 -112
  66. package/packages/sdk/README.md +0 -277
  67. /package/{packages/sdk/dist → dist}/announcement-client.d.ts +0 -0
  68. /package/{packages/sdk/dist → dist}/announcement-client.js +0 -0
  69. /package/{packages/sdk/dist → dist}/auditor-ciphertext.d.ts +0 -0
  70. /package/{packages/sdk/dist → dist}/auditor-ciphertext.js +0 -0
  71. /package/{packages/sdk/dist → dist}/auditor.d.ts +0 -0
  72. /package/{packages/sdk/dist → dist}/auditor.js +0 -0
  73. /package/{packages/sdk/dist → dist}/bitcoin/ika.d.ts +0 -0
  74. /package/{packages/sdk/dist → dist}/bitcoin/ika.js +0 -0
  75. /package/{packages/sdk/dist → dist}/bitcoin/index.d.ts +0 -0
  76. /package/{packages/sdk/dist → dist}/bitcoin/index.js +0 -0
  77. /package/{packages/sdk/dist → dist}/bound-params.d.ts +0 -0
  78. /package/{packages/sdk/dist → dist}/bound-params.js +0 -0
  79. /package/{packages/sdk/dist → dist}/chadbuffer.d.ts +0 -0
  80. /package/{packages/sdk/dist → dist}/chadbuffer.js +0 -0
  81. /package/{packages/sdk/dist → dist}/claim-link.d.ts +0 -0
  82. /package/{packages/sdk/dist → dist}/claim-link.js +0 -0
  83. /package/{packages/sdk/dist → dist}/commitment-tree.d.ts +0 -0
  84. /package/{packages/sdk/dist → dist}/commitment-tree.js +0 -0
  85. /package/{packages/sdk/dist → dist}/config.d.ts +0 -0
  86. /package/{packages/sdk/dist → dist}/config.js +0 -0
  87. /package/{packages/sdk/dist → dist}/core/esplora.d.ts +0 -0
  88. /package/{packages/sdk/dist → dist}/core/esplora.js +0 -0
  89. /package/{packages/sdk/dist → dist}/core/mempool.d.ts +0 -0
  90. /package/{packages/sdk/dist → dist}/core/mempool.js +0 -0
  91. /package/{packages/sdk/dist → dist}/crypto-babyjub.d.ts +0 -0
  92. /package/{packages/sdk/dist → dist}/crypto-babyjub.js +0 -0
  93. /package/{packages/sdk/dist → dist}/crypto.d.ts +0 -0
  94. /package/{packages/sdk/dist → dist}/crypto.js +0 -0
  95. /package/{packages/sdk/dist → dist}/event-client.d.ts +0 -0
  96. /package/{packages/sdk/dist → dist}/event-client.js +0 -0
  97. /package/{packages/sdk/dist → dist}/events.d.ts +0 -0
  98. /package/{packages/sdk/dist → dist}/events.js +0 -0
  99. /package/{packages/sdk/dist → dist}/explorer.d.ts +0 -0
  100. /package/{packages/sdk/dist → dist}/explorer.js +0 -0
  101. /package/{packages/sdk/dist → dist}/keys.d.ts +0 -0
  102. /package/{packages/sdk/dist → dist}/keys.js +0 -0
  103. /package/{packages/sdk/dist → dist}/logger.d.ts +0 -0
  104. /package/{packages/sdk/dist → dist}/logger.js +0 -0
  105. /package/{packages/sdk/dist → dist}/magicblock.d.ts +0 -0
  106. /package/{packages/sdk/dist → dist}/magicblock.js +0 -0
  107. /package/{packages/sdk/dist → dist}/merkle.d.ts +0 -0
  108. /package/{packages/sdk/dist → dist}/merkle.js +0 -0
  109. /package/{packages/sdk/dist → dist}/note.d.ts +0 -0
  110. /package/{packages/sdk/dist → dist}/note.js +0 -0
  111. /package/{packages/sdk/dist → dist}/pda.d.ts +0 -0
  112. /package/{packages/sdk/dist → dist}/pda.js +0 -0
  113. /package/{packages/sdk/dist → dist}/pool-state.d.ts +0 -0
  114. /package/{packages/sdk/dist → dist}/pool-state.js +0 -0
  115. /package/{packages/sdk/dist → dist}/poseidon.d.ts +0 -0
  116. /package/{packages/sdk/dist → dist}/poseidon.js +0 -0
  117. /package/{packages/sdk/dist → dist}/prover/index.d.ts +0 -0
  118. /package/{packages/sdk/dist → dist}/prover/index.js +0 -0
  119. /package/{packages/sdk/dist → dist}/prover/mobile.d.ts +0 -0
  120. /package/{packages/sdk/dist → dist}/prover/mobile.js +0 -0
  121. /package/{packages/sdk/dist → dist}/prover/web.d.ts +0 -0
  122. /package/{packages/sdk/dist → dist}/prover/web.js +0 -0
  123. /package/{packages/sdk/dist → dist}/psbt.d.ts +0 -0
  124. /package/{packages/sdk/dist → dist}/psbt.js +0 -0
  125. /package/{packages/sdk/dist → dist}/selective-disclosure.d.ts +0 -0
  126. /package/{packages/sdk/dist → dist}/selective-disclosure.js +0 -0
  127. /package/{packages/sdk/dist → dist}/sender-memo.d.ts +0 -0
  128. /package/{packages/sdk/dist → dist}/sender-memo.js +0 -0
  129. /package/{packages/sdk/dist → dist}/sns-resolver.d.ts +0 -0
  130. /package/{packages/sdk/dist → dist}/sns-resolver.js +0 -0
  131. /package/{packages/sdk/dist → dist}/solana/connection.d.ts +0 -0
  132. /package/{packages/sdk/dist → dist}/solana/connection.js +0 -0
  133. /package/{packages/sdk/dist → dist}/solana/priority-fee.d.ts +0 -0
  134. /package/{packages/sdk/dist → dist}/solana/priority-fee.js +0 -0
  135. /package/{packages/sdk/dist → dist}/spend-doc.d.ts +0 -0
  136. /package/{packages/sdk/dist → dist}/spend-doc.js +0 -0
  137. /package/{packages/sdk/dist → dist}/token-registry.d.ts +0 -0
  138. /package/{packages/sdk/dist → dist}/token-registry.js +0 -0
  139. /package/{packages/sdk/dist → dist}/utils/encoding.d.ts +0 -0
  140. /package/{packages/sdk/dist → dist}/utils/encoding.js +0 -0
  141. /package/{packages/sdk/dist → dist}/vk-registry.d.ts +0 -0
  142. /package/{packages/sdk/dist → dist}/vk-registry.js +0 -0
package/src/psbt.ts ADDED
@@ -0,0 +1,333 @@
1
+ /**
2
+ * PSBT builder for UTXOpia non-interactive deposits.
3
+ *
4
+ * Creates a Partially-Signed Bitcoin Transaction with:
5
+ * - Input(s): user's UTXOs (P2TR or P2WPKH)
6
+ * - Output 1: P2TR deposit (commitment-bound Taproot address)
7
+ * - Output 2: OP_RETURN (73 bytes: header || poolTag || ephemeralPub || npk)
8
+ * - Output 3: change back to user (if needed)
9
+ *
10
+ * Uses @scure/btc-signer for PSBT construction.
11
+ */
12
+
13
+ import * as btc from "@scure/btc-signer";
14
+ import { hex } from "@scure/base";
15
+ import { DEPOSIT_OP_RETURN_SIZE, createOpReturnScriptFromPayload } from "./taproot";
16
+
17
+ // =============================================================================
18
+ // Types
19
+ // =============================================================================
20
+
21
+ /** UTXO descriptor for PSBT inputs */
22
+ export interface UtxoDescriptor {
23
+ /** Transaction ID (hex, 64 chars) */
24
+ txid: string;
25
+ /** Output index */
26
+ vout: number;
27
+ /** Value in satoshis */
28
+ value: number;
29
+ /** Raw scriptPubkey (hex) */
30
+ scriptPubkeyHex: string;
31
+ /** Witness UTXO script type (inferred from scriptPubkey if omitted) */
32
+ type?: "p2tr" | "p2wpkh";
33
+ }
34
+
35
+ /** Parameters for building a deposit PSBT */
36
+ export interface BuildDepositPsbtParams {
37
+ /** Sender's UTXOs to spend */
38
+ senderUtxos: UtxoDescriptor[];
39
+ /** Taproot deposit address (bc1p... or tb1p...) */
40
+ depositAddress: string;
41
+ /** Deposit amount in satoshis */
42
+ depositAmountSats: number;
43
+ /** Compact deposit OP_RETURN payload (from buildDepositOpReturn) */
44
+ opReturnPayload: Uint8Array;
45
+ /** Change address (same type as sender) */
46
+ changeAddress: string;
47
+ /** Fee rate in sats/vbyte */
48
+ feeRate: number;
49
+ /** Bitcoin network */
50
+ network?: "mainnet" | "testnet" | "signet";
51
+ }
52
+
53
+ /** Result of PSBT construction */
54
+ export interface BuildDepositPsbtResult {
55
+ /** PSBT encoded as base64 */
56
+ psbtBase64: string;
57
+ /** PSBT encoded as hex */
58
+ psbtHex: string;
59
+ /** Estimated transaction fee in satoshis */
60
+ estimatedFee: number;
61
+ /** Total input value in satoshis */
62
+ totalInput: number;
63
+ /** Change amount in satoshis (0 if no change) */
64
+ changeAmount: number;
65
+ }
66
+
67
+ // =============================================================================
68
+ // Constants
69
+ // =============================================================================
70
+
71
+ /** Dust limit for Bitcoin outputs (satoshis) */
72
+ const DUST_LIMIT = 546;
73
+
74
+ /** Estimated vbytes per P2TR key-path input */
75
+ const P2TR_INPUT_VBYTES = 58;
76
+
77
+ /** Estimated vbytes per P2WPKH input */
78
+ const P2WPKH_INPUT_VBYTES = 68;
79
+
80
+ /** Estimated vbytes for P2TR output */
81
+ const P2TR_OUTPUT_VBYTES = 43;
82
+
83
+ /** Estimated vbytes for OP_RETURN output (with compact deposit payload) */
84
+ const OP_RETURN_OUTPUT_VBYTES = 84; // 8 value + 1 script_len + 1 OP_RETURN + 1 push + 73 payload
85
+
86
+ /** Transaction overhead (version + locktime + segwit marker + input/output count) */
87
+ const TX_OVERHEAD_VBYTES = 11;
88
+
89
+ // =============================================================================
90
+ // Fee Estimation
91
+ // =============================================================================
92
+
93
+ /**
94
+ * Estimate the transaction fee for a deposit PSBT.
95
+ */
96
+ export function estimateDepositFee(
97
+ numInputs: number,
98
+ feeRate: number,
99
+ inputType: "p2tr" | "p2wpkh" = "p2tr",
100
+ hasChange: boolean = true,
101
+ ): number {
102
+ const inputVbytes = inputType === "p2tr" ? P2TR_INPUT_VBYTES : P2WPKH_INPUT_VBYTES;
103
+ const outputCount = hasChange ? 3 : 2; // deposit + OP_RETURN + optional change
104
+
105
+ const vsize =
106
+ TX_OVERHEAD_VBYTES +
107
+ numInputs * inputVbytes +
108
+ P2TR_OUTPUT_VBYTES + // deposit output
109
+ OP_RETURN_OUTPUT_VBYTES + // OP_RETURN output
110
+ (hasChange ? P2TR_OUTPUT_VBYTES : 0); // change output
111
+
112
+ return Math.ceil(vsize * feeRate);
113
+ }
114
+
115
+ // =============================================================================
116
+ // PSBT Builder
117
+ // =============================================================================
118
+
119
+ /**
120
+ * Build a deposit PSBT with OP_RETURN for non-interactive stealth deposits.
121
+ *
122
+ * The PSBT is unsigned — it must be signed by the user's wallet (e.g. via sats-connect).
123
+ */
124
+ export function buildDepositPsbt(params: BuildDepositPsbtParams): BuildDepositPsbtResult {
125
+ const {
126
+ senderUtxos,
127
+ depositAddress,
128
+ depositAmountSats,
129
+ opReturnPayload,
130
+ changeAddress,
131
+ feeRate,
132
+ network = "testnet",
133
+ } = params;
134
+
135
+ if (senderUtxos.length === 0) {
136
+ throw new Error("No UTXOs provided");
137
+ }
138
+ if (depositAmountSats < DUST_LIMIT) {
139
+ throw new Error(`Deposit amount ${depositAmountSats} is below dust limit ${DUST_LIMIT}`);
140
+ }
141
+ if (opReturnPayload.length !== DEPOSIT_OP_RETURN_SIZE) {
142
+ throw new Error(`OP_RETURN payload must be ${DEPOSIT_OP_RETURN_SIZE} bytes, got ${opReturnPayload.length}`);
143
+ }
144
+
145
+ const btcNetwork = network === "mainnet" ? btc.NETWORK : btc.TEST_NETWORK;
146
+
147
+ // Calculate total input value
148
+ const totalInput = senderUtxos.reduce((sum, u) => sum + u.value, 0);
149
+
150
+ // Detect input type from first UTXO
151
+ const firstScript = hex.decode(senderUtxos[0].scriptPubkeyHex);
152
+ const inputType = firstScript[0] === 0x51 ? "p2tr" : "p2wpkh";
153
+
154
+ // Estimate fee with change
155
+ const feeWithChange = estimateDepositFee(senderUtxos.length, feeRate, inputType, true);
156
+ const changeAmount = totalInput - depositAmountSats - feeWithChange;
157
+
158
+ // Check if we have enough funds
159
+ const feeWithoutChange = estimateDepositFee(senderUtxos.length, feeRate, inputType, false);
160
+ if (totalInput < depositAmountSats + feeWithoutChange) {
161
+ throw new Error(
162
+ `Insufficient funds: have ${totalInput} sats, need ${depositAmountSats + feeWithoutChange} sats (including fee)`,
163
+ );
164
+ }
165
+
166
+ const hasChange = changeAmount > DUST_LIMIT;
167
+ const actualFee = hasChange ? feeWithChange : totalInput - depositAmountSats;
168
+
169
+ // Build the transaction using @scure/btc-signer
170
+ // allowUnknownOutputs is required for the OP_RETURN output script
171
+ const tx = new btc.Transaction({ allowUnknownOutputs: true });
172
+
173
+ // Add inputs
174
+ for (const utxo of senderUtxos) {
175
+ const scriptPubkey = hex.decode(utxo.scriptPubkeyHex);
176
+
177
+ if (scriptPubkey[0] === 0x51 && scriptPubkey.length === 34) {
178
+ // P2TR input
179
+ tx.addInput({
180
+ txid: utxo.txid,
181
+ index: utxo.vout,
182
+ witnessUtxo: {
183
+ script: scriptPubkey,
184
+ amount: BigInt(utxo.value),
185
+ },
186
+ tapInternalKey: scriptPubkey.slice(2), // x-only pubkey from OP_1 <32 bytes>
187
+ });
188
+ } else if (scriptPubkey[0] === 0x00 && scriptPubkey.length === 22) {
189
+ // P2WPKH input
190
+ tx.addInput({
191
+ txid: utxo.txid,
192
+ index: utxo.vout,
193
+ witnessUtxo: {
194
+ script: scriptPubkey,
195
+ amount: BigInt(utxo.value),
196
+ },
197
+ });
198
+ } else {
199
+ throw new Error(`Unsupported input script type for UTXO ${utxo.txid}:${utxo.vout}`);
200
+ }
201
+ }
202
+
203
+ // Output 1: P2TR deposit
204
+ tx.addOutputAddress(depositAddress, BigInt(depositAmountSats), btcNetwork);
205
+
206
+ // Output 2: OP_RETURN with compact deposit payload.
207
+ const opReturnScript = createOpReturnScriptFromPayload(opReturnPayload);
208
+ tx.addOutput({
209
+ script: opReturnScript,
210
+ amount: 0n,
211
+ });
212
+
213
+ // Output 3: Change (if above dust)
214
+ if (hasChange) {
215
+ tx.addOutputAddress(changeAddress, BigInt(changeAmount), btcNetwork);
216
+ }
217
+
218
+ // Extract PSBT
219
+ const psbtBytes = tx.toPSBT();
220
+ const psbtHex = hex.encode(psbtBytes);
221
+ const psbtBase64 = btoa(String.fromCharCode(...psbtBytes));
222
+
223
+ return {
224
+ psbtBase64,
225
+ psbtHex,
226
+ estimatedFee: actualFee,
227
+ totalInput,
228
+ changeAmount: hasChange ? changeAmount : 0,
229
+ };
230
+ }
231
+
232
+ // =============================================================================
233
+ // UTXO Fetching (mempool.space API)
234
+ // =============================================================================
235
+
236
+ /** UTXO as returned by mempool.space API */
237
+ interface MempoolUtxo {
238
+ txid: string;
239
+ vout: number;
240
+ status: {
241
+ confirmed: boolean;
242
+ block_height?: number;
243
+ };
244
+ value: number;
245
+ }
246
+
247
+ /**
248
+ * Fetch UTXOs for an address from mempool.space API.
249
+ */
250
+ export async function fetchUtxos(
251
+ address: string,
252
+ network: "mainnet" | "testnet" | "signet" = "testnet",
253
+ ): Promise<UtxoDescriptor[]> {
254
+ const baseUrl =
255
+ network === "mainnet"
256
+ ? "https://mempool.space/api"
257
+ : network === "signet"
258
+ ? "https://mempool.space/signet/api"
259
+ : "https://mempool.space/testnet/api";
260
+
261
+ // Fetch UTXOs
262
+ const utxoRes = await fetch(`${baseUrl}/address/${address}/utxo`);
263
+ if (!utxoRes.ok) {
264
+ throw new Error(`Failed to fetch UTXOs: ${utxoRes.status} ${utxoRes.statusText}`);
265
+ }
266
+ const utxos: MempoolUtxo[] = await utxoRes.json();
267
+
268
+ // We need scriptPubkey for each UTXO. Fetch from the tx details.
269
+ // For efficiency, batch unique txids.
270
+ const txidSet = new Set(utxos.map((u) => u.txid));
271
+ const txCache = new Map<string, any>();
272
+
273
+ await Promise.all(
274
+ [...txidSet].map(async (txid) => {
275
+ const txRes = await fetch(`${baseUrl}/tx/${txid}`);
276
+ if (txRes.ok) {
277
+ txCache.set(txid, await txRes.json());
278
+ }
279
+ }),
280
+ );
281
+
282
+ return utxos
283
+ .filter((u) => u.status.confirmed) // Only confirmed UTXOs
284
+ .map((u) => {
285
+ const tx = txCache.get(u.txid);
286
+ const output = tx?.vout?.[u.vout];
287
+ const scriptPubkeyHex = output?.scriptpubkey ?? "";
288
+
289
+ return {
290
+ txid: u.txid,
291
+ vout: u.vout,
292
+ value: u.value,
293
+ scriptPubkeyHex,
294
+ };
295
+ })
296
+ .filter((u) => u.scriptPubkeyHex.length > 0);
297
+ }
298
+
299
+ /**
300
+ * Select UTXOs to cover the target amount + estimated fee.
301
+ * Simple greedy algorithm: sort descending by value, take until covered.
302
+ */
303
+ export function selectUtxos(
304
+ utxos: UtxoDescriptor[],
305
+ targetSats: number,
306
+ feeRate: number,
307
+ ): UtxoDescriptor[] {
308
+ // Sort descending by value
309
+ const sorted = [...utxos].sort((a, b) => b.value - a.value);
310
+ const selected: UtxoDescriptor[] = [];
311
+ let total = 0;
312
+
313
+ for (const utxo of sorted) {
314
+ selected.push(utxo);
315
+ total += utxo.value;
316
+
317
+ // Estimate fee for current selection
318
+ const fee = estimateDepositFee(selected.length, feeRate);
319
+ if (total >= targetSats + fee) {
320
+ return selected;
321
+ }
322
+ }
323
+
324
+ // Not enough funds
325
+ const fee = estimateDepositFee(selected.length, feeRate);
326
+ if (total < targetSats + fee) {
327
+ throw new Error(
328
+ `Insufficient funds: have ${total} sats, need ${targetSats + fee} sats`,
329
+ );
330
+ }
331
+
332
+ return selected;
333
+ }
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Selective ZK Disclosure (Phase 4 skeleton)
3
+ *
4
+ * Targeted ZK proofs that reveal a single fact about the prover's notes
5
+ * without handing over a viewing key. Two flavors:
6
+ *
7
+ * 1. Ownership proof — "I own commitment X, amount ≥ Y."
8
+ * Useful for: proof-of-funds to a regulator, dispute resolution,
9
+ * challenge-response with a counterparty.
10
+ *
11
+ * 2. Range-sum proof — "Σ amount_i over a slot range ≤ Z" across a
12
+ * caller-provided set of leaf indices, with a check that I have
13
+ * *committed* to the full set via a Merkle commitment over the set.
14
+ *
15
+ * Status (2026-05-13): types frozen, no live prover. Circuits for both
16
+ * (`ownership.circom`, `range_sum.circom`) need design + trusted setup before
17
+ * runtime can do real work. Until then both generator functions throw.
18
+ *
19
+ * Design notes:
20
+ *
21
+ * Ownership proof. Algorithm sketch:
22
+ * - Inputs (private): spendingPrivScalar, randomIn, valueIn, merkleProof
23
+ * - Inputs (public): commitment, merkleRoot, threshold (Y), token
24
+ * - Constraints:
25
+ * (a) commitment == Poseidon(NPK, token, valueIn) where
26
+ * NPK = Poseidon(MPK, randomIn) and MPK = Poseidon(spendingPub, nk)
27
+ * (b) commitment ∈ tree(merkleRoot)
28
+ * (c) valueIn >= threshold (range check)
29
+ * - The proof produces a *non-spending* witness: no nullifier is emitted,
30
+ * so generating the proof doesn't burn the note.
31
+ *
32
+ * Range-sum proof. Algorithm sketch:
33
+ * - Inputs (private): for each note i: randomIn[i], valueIn[i], merkleProof[i]
34
+ * - Inputs (public): leafIndices[i], merkleRoot, ceiling (Z), token,
35
+ * attestation = Poseidon(leafIndices, viewerNonce) — a binding so the
36
+ * verifier knows the prover committed to *this exact* set of leaves
37
+ * - Constraints:
38
+ * (a) each commitment ∈ tree(merkleRoot)
39
+ * (b) each commitment == Poseidon(NPK_i, token, valueIn[i])
40
+ * (c) Σ valueIn[i] <= ceiling
41
+ * (d) attestation matches
42
+ * - Coverage check (the hard part: proving the prover didn't *omit* notes
43
+ * in the slot range) is enforced by having the verifier independently
44
+ * compute attestation = Poseidon(everyKnownLeaf, viewerNonce) and
45
+ * compare. The verifier learns the leaf set from the public viewing key
46
+ * handed over for that purpose.
47
+ *
48
+ * The viewing-key-coverage dependency means range-sum is most useful when
49
+ * paired with the auditor flow from Phase 1 — a one-shot disclosure that
50
+ * proves a property over the set the auditor already verified.
51
+ */
52
+
53
+ import type { ProofData } from "./prover/web";
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Ownership proof
57
+ // ---------------------------------------------------------------------------
58
+
59
+ export interface OwnershipProofInputs {
60
+ /** Witness data for the note. */
61
+ spendingPrivScalar: bigint;
62
+ nullifyingKey: bigint;
63
+ randomIn: bigint;
64
+ valueIn: bigint;
65
+ pathElements: bigint[];
66
+ pathIndices: number[];
67
+
68
+ /** Public commitments the prover is claiming. */
69
+ commitment: bigint;
70
+ merkleRoot: bigint;
71
+ /** Minimum amount the prover asserts the note holds. */
72
+ threshold: bigint;
73
+ tokenId: bigint;
74
+ }
75
+
76
+ export interface OwnershipPublicInputs {
77
+ commitment: bigint;
78
+ merkleRoot: bigint;
79
+ threshold: bigint;
80
+ tokenId: bigint;
81
+ }
82
+
83
+ /**
84
+ * Generate an ownership ZK proof.
85
+ *
86
+ * Calls into the generic snarkjs prover with the `ownership` circuit.
87
+ * Requires the compiled wasm + zkey under `<circuitBasePath>/ownership/`.
88
+ *
89
+ * Public inputs: [commitment, merkleRoot, threshold, tokenId]
90
+ */
91
+ export async function generateOwnershipProof(
92
+ inputs: OwnershipProofInputs,
93
+ ): Promise<ProofData> {
94
+ const { generateGenericGroth16Proof } = await import("./prover/web");
95
+ return generateGenericGroth16Proof("ownership", {
96
+ commitment: inputs.commitment.toString(),
97
+ merkleRoot: inputs.merkleRoot.toString(),
98
+ threshold: inputs.threshold.toString(),
99
+ token: inputs.tokenId.toString(),
100
+ spendingPrivScalar: inputs.spendingPrivScalar.toString(),
101
+ randomIn: inputs.randomIn.toString(),
102
+ valueIn: inputs.valueIn.toString(),
103
+ pathElements: inputs.pathElements.map((e) => e.toString()),
104
+ pathIndices: inputs.pathIndices,
105
+ nullifyingKey: inputs.nullifyingKey.toString(),
106
+ });
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Range-sum proof
111
+ // ---------------------------------------------------------------------------
112
+
113
+ export interface RangeSumProofInputs {
114
+ /** One entry per note in the prover's claimed set. */
115
+ notes: ReadonlyArray<{
116
+ randomIn: bigint;
117
+ valueIn: bigint;
118
+ pathElements: bigint[];
119
+ /** @deprecated No longer used: the circuit derives path bits from leafIndex. */
120
+ pathIndices?: number[];
121
+ commitment: bigint;
122
+ leafIndex: number;
123
+ }>;
124
+
125
+ /** Common shared data. */
126
+ spendingPrivScalar: bigint;
127
+ nullifyingKey: bigint;
128
+ merkleRoot: bigint;
129
+ /** Upper bound on the sum the prover asserts. */
130
+ ceiling: bigint;
131
+ tokenId: bigint;
132
+ /** Salt the verifier supplied, binding the proof to this verification round. */
133
+ viewerNonce: bigint;
134
+ /** Poseidon(leafIndices ++ [viewerNonce]) — must match the public input. */
135
+ attestation: bigint;
136
+ }
137
+
138
+ export interface RangeSumPublicInputs {
139
+ leafIndices: number[];
140
+ merkleRoot: bigint;
141
+ ceiling: bigint;
142
+ tokenId: bigint;
143
+ /** Poseidon(leafIndices ++ [viewerNonce]) committed to by the prover. */
144
+ attestation: bigint;
145
+ }
146
+
147
+ /**
148
+ * How the variant computes `attestation` from `(leafIndices, viewerNonce)`.
149
+ *
150
+ * - "flat" — `Poseidon(leafIndices ++ [viewerNonce])`. Cheap; requires
151
+ * N+1 ≤ 16 because circomlib's Poseidon caps at arity 16.
152
+ * - "chunked" — `Poseidon(Poseidon(leafIndices[0..N/2]),
153
+ * Poseidon(leafIndices[N/2..N]),
154
+ * viewerNonce)`. Needed at N=16 where the flat
155
+ * form would require Poseidon(17). Only one nesting level
156
+ * ships today (N=16); deeper variants would extend the
157
+ * same pattern.
158
+ */
159
+ export type RangeSumAttestationStyle = "flat" | "chunked";
160
+
161
+ /**
162
+ * Compiled range-sum variants and the cardinality each handles. Add new
163
+ * entries here when you build a new sibling circuit. The `circuit` field
164
+ * must match the directory name under `circuits/build/`.
165
+ */
166
+ export const RANGE_SUM_VARIANTS = [
167
+ { n: 4, circuit: "range_sum_4" as const, attestation: "flat" as const },
168
+ { n: 8, circuit: "range_sum" as const, attestation: "flat" as const },
169
+ { n: 16, circuit: "range_sum_16" as const, attestation: "chunked" as const },
170
+ ] as const;
171
+
172
+ /** Number of notes accepted by each compiled variant. */
173
+ export const RANGE_SUM_SIZES = RANGE_SUM_VARIANTS.map((v) => v.n);
174
+
175
+ /**
176
+ * @deprecated Prefer `RANGE_SUM_VARIANTS` / `pickRangeSumVariant`. Retained
177
+ * for back-compat with older callers that assumed a single N.
178
+ */
179
+ export const RANGE_SUM_N = 8;
180
+
181
+ /** Look up the compiled circuit name for a given note cardinality. */
182
+ export function pickRangeSumVariant(n: number): typeof RANGE_SUM_VARIANTS[number] {
183
+ const v = RANGE_SUM_VARIANTS.find((x) => x.n === n);
184
+ if (!v) {
185
+ throw new Error(
186
+ `range_sum has no compiled variant for N=${n}. Compiled variants: ` +
187
+ RANGE_SUM_SIZES.join(", ") +
188
+ ". Pad with zero-value notes or compile a new variant " +
189
+ "(see circuits/scripts/build-aux.sh).",
190
+ );
191
+ }
192
+ return v;
193
+ }
194
+
195
+ /**
196
+ * Compute the range-sum attestation public input the way each compiled
197
+ * circuit expects. SDK + CLI must agree on this exactly or the verifier
198
+ * rejects the proof.
199
+ *
200
+ * - flat: Poseidon(leafIndices ++ [viewerNonce])
201
+ * - chunked: Poseidon(Poseidon(leafIndices[0..N/2]),
202
+ * Poseidon(leafIndices[N/2..N]),
203
+ * viewerNonce)
204
+ *
205
+ * `style` defaults to the cardinality's compiled variant; pass it
206
+ * explicitly if you're computing the value without going through
207
+ * `generateRangeSumProof` (e.g. inside a CLI that lays out witness data
208
+ * up front).
209
+ */
210
+ export async function computeRangeSumAttestation(
211
+ leafIndices: ReadonlyArray<number | bigint>,
212
+ viewerNonce: bigint,
213
+ style?: RangeSumAttestationStyle,
214
+ ): Promise<bigint> {
215
+ // Only consult the variants registry when the caller didn't pick a style
216
+ // explicitly. The explicit-style path is for tests / parity checks that
217
+ // want to compute the hash without first compiling a variant.
218
+ const resolvedStyle: RangeSumAttestationStyle =
219
+ style ?? pickRangeSumVariant(leafIndices.length).attestation;
220
+
221
+ // circomlibjs's Poseidon caps at the same arity (16) as the circom-side
222
+ // hash. Importing the heavy circomlibjs module lazily keeps the SDK
223
+ // bundle slim for callers that never compute an attestation.
224
+ const { buildPoseidon } = await import("circomlibjs");
225
+ // circomlibjs is untyped; cast to a minimal shape so downstream code
226
+ // doesn't drown in `unknown`s. Field elements are opaque to TypeScript
227
+ // (they're internally Uint8Arrays representing BN254 elements).
228
+ type FieldElement = unknown;
229
+ interface Poseidon {
230
+ (inputs: FieldElement[]): FieldElement;
231
+ F: { e: (x: bigint) => FieldElement; toObject: (x: FieldElement) => bigint };
232
+ }
233
+ const poseidon = (await buildPoseidon()) as Poseidon;
234
+ const F = poseidon.F;
235
+ const indicesAsField: FieldElement[] = leafIndices.map((i) => F.e(BigInt(i)));
236
+
237
+ if (resolvedStyle === "flat") {
238
+ return F.toObject(poseidon([...indicesAsField, F.e(viewerNonce)]));
239
+ }
240
+ // chunked: split in half, hash each half, then hash the two digests + nonce.
241
+ const half = leafIndices.length / 2;
242
+ if (!Number.isInteger(half)) {
243
+ throw new Error(
244
+ `chunked attestation requires an even cardinality; got N=${leafIndices.length}`,
245
+ );
246
+ }
247
+ const chunk1: FieldElement = poseidon(indicesAsField.slice(0, half));
248
+ const chunk2: FieldElement = poseidon(indicesAsField.slice(half));
249
+ return F.toObject(poseidon([chunk1, chunk2, F.e(viewerNonce)]));
250
+ }
251
+
252
+ /**
253
+ * Generate a range-sum ZK proof. The variant is picked automatically from
254
+ * `inputs.notes.length` — compile + register new variants in
255
+ * `RANGE_SUM_VARIANTS` to extend the supported cardinalities.
256
+ *
257
+ * Calls into the generic snarkjs prover with the matching circuit.
258
+ * Requires the compiled wasm + zkey under
259
+ * `<circuitBasePath>/<variant.circuit>/`.
260
+ *
261
+ * Public inputs: [leafIndices(N), merkleRoot, ceiling, token, attestation]
262
+ */
263
+ export async function generateRangeSumProof(
264
+ inputs: RangeSumProofInputs,
265
+ ): Promise<ProofData> {
266
+ const variant = pickRangeSumVariant(inputs.notes.length);
267
+ const { generateGenericGroth16Proof } = await import("./prover/web");
268
+ return generateGenericGroth16Proof(variant.circuit, {
269
+ leafIndices: inputs.notes.map((n) => n.leafIndex),
270
+ merkleRoot: inputs.merkleRoot.toString(),
271
+ ceiling: inputs.ceiling.toString(),
272
+ token: inputs.tokenId.toString(),
273
+ attestation: inputs.attestation.toString(),
274
+ spendingPrivScalar: inputs.spendingPrivScalar.toString(),
275
+ nullifyingKey: inputs.nullifyingKey.toString(),
276
+ randomIn: inputs.notes.map((n) => n.randomIn.toString()),
277
+ valueIn: inputs.notes.map((n) => n.valueIn.toString()),
278
+ pathElements: inputs.notes.map((n) => n.pathElements.map((e) => e.toString())),
279
+ // pathIndices is derived in-circuit from the public leafIndices (Num2Bits),
280
+ // so it is no longer passed as a witness input.
281
+ viewerNonce: inputs.viewerNonce.toString(),
282
+ commitmentsIn: inputs.notes.map((n) => n.commitment.toString()),
283
+ });
284
+ }