nova-privacy-sdk 1.0.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.
Files changed (69) hide show
  1. package/.github/workflows/npm-publish.yml +55 -0
  2. package/PUBLISH.md +122 -0
  3. package/README.md +177 -0
  4. package/__tests__/e2e.test.ts +56 -0
  5. package/__tests__/e2espl.test.ts +73 -0
  6. package/__tests__/encryption.test.ts +1635 -0
  7. package/circuit2/transaction2.wasm +0 -0
  8. package/circuit2/transaction2.zkey +0 -0
  9. package/dist/config.d.ts +9 -0
  10. package/dist/config.js +12 -0
  11. package/dist/deposit.d.ts +18 -0
  12. package/dist/deposit.js +392 -0
  13. package/dist/depositSPL.d.ts +20 -0
  14. package/dist/depositSPL.js +448 -0
  15. package/dist/exportUtils.d.ts +11 -0
  16. package/dist/exportUtils.js +11 -0
  17. package/dist/getUtxos.d.ts +29 -0
  18. package/dist/getUtxos.js +294 -0
  19. package/dist/getUtxosSPL.d.ts +33 -0
  20. package/dist/getUtxosSPL.js +395 -0
  21. package/dist/index.d.ts +125 -0
  22. package/dist/index.js +302 -0
  23. package/dist/models/keypair.d.ts +26 -0
  24. package/dist/models/keypair.js +43 -0
  25. package/dist/models/utxo.d.ts +49 -0
  26. package/dist/models/utxo.js +85 -0
  27. package/dist/utils/address_lookup_table.d.ts +9 -0
  28. package/dist/utils/address_lookup_table.js +45 -0
  29. package/dist/utils/constants.d.ts +31 -0
  30. package/dist/utils/constants.js +62 -0
  31. package/dist/utils/encryption.d.ts +107 -0
  32. package/dist/utils/encryption.js +376 -0
  33. package/dist/utils/logger.d.ts +9 -0
  34. package/dist/utils/logger.js +35 -0
  35. package/dist/utils/merkle_tree.d.ts +92 -0
  36. package/dist/utils/merkle_tree.js +186 -0
  37. package/dist/utils/node-shim.d.ts +5 -0
  38. package/dist/utils/node-shim.js +5 -0
  39. package/dist/utils/prover.d.ts +36 -0
  40. package/dist/utils/prover.js +147 -0
  41. package/dist/utils/utils.d.ts +69 -0
  42. package/dist/utils/utils.js +182 -0
  43. package/dist/withdraw.d.ts +21 -0
  44. package/dist/withdraw.js +270 -0
  45. package/dist/withdrawSPL.d.ts +23 -0
  46. package/dist/withdrawSPL.js +306 -0
  47. package/package.json +77 -0
  48. package/setup-git.sh +51 -0
  49. package/setup-github.sh +36 -0
  50. package/src/config.ts +22 -0
  51. package/src/deposit.ts +487 -0
  52. package/src/depositSPL.ts +567 -0
  53. package/src/exportUtils.ts +13 -0
  54. package/src/getUtxos.ts +396 -0
  55. package/src/getUtxosSPL.ts +528 -0
  56. package/src/index.ts +350 -0
  57. package/src/models/keypair.ts +52 -0
  58. package/src/models/utxo.ts +106 -0
  59. package/src/utils/address_lookup_table.ts +78 -0
  60. package/src/utils/constants.ts +84 -0
  61. package/src/utils/encryption.ts +464 -0
  62. package/src/utils/logger.ts +42 -0
  63. package/src/utils/merkle_tree.ts +207 -0
  64. package/src/utils/node-shim.ts +6 -0
  65. package/src/utils/prover.ts +222 -0
  66. package/src/utils/utils.ts +242 -0
  67. package/src/withdraw.ts +332 -0
  68. package/src/withdrawSPL.ts +394 -0
  69. package/tsconfig.json +28 -0
@@ -0,0 +1,396 @@
1
+ import { Connection, Keypair, LAMPORTS_PER_SOL, PublicKey } from '@solana/web3.js';
2
+ import BN from 'bn.js';
3
+ import { Keypair as UtxoKeypair } from './models/keypair.js';
4
+ import { Utxo } from './models/utxo.js';
5
+ import { EncryptionService } from './utils/encryption.js';
6
+ import { WasmFactory } from '@lightprotocol/hasher.rs';
7
+ //@ts-ignore
8
+ import * as ffjavascript from 'ffjavascript';
9
+ import { FETCH_UTXOS_GROUP_SIZE, RELAYER_API_URL, LSK_ENCRYPTED_OUTPUTS, LSK_FETCH_OFFSET, PROGRAM_ID } from './utils/constants.js';
10
+ import { logger } from './utils/logger.js';
11
+
12
+ // Use type assertion for the utility functions (same pattern as in get_verification_keys.ts)
13
+ const utils = ffjavascript.utils as any;
14
+ const { unstringifyBigInts, leInt2Buff } = utils;
15
+
16
+ /**
17
+ * Interface for the UTXO data returned from the API
18
+ */
19
+ interface ApiUtxo {
20
+ commitment: string;
21
+ encrypted_output: string; // Hex-encoded encrypted UTXO data
22
+ index: number;
23
+ nullifier?: string; // Optional, might not be present for all UTXOs
24
+ }
25
+
26
+ /**
27
+ * Interface for the API response format that includes count and encrypted_outputs
28
+ */
29
+ interface ApiResponse {
30
+ count: number;
31
+ encrypted_outputs: string[];
32
+ }
33
+
34
+ function sleep(ms: number): Promise<string> {
35
+ return new Promise(resolve => setTimeout(() => {
36
+ resolve('ok')
37
+ }, ms))
38
+ }
39
+
40
+ export function localstorageKey(key: PublicKey) {
41
+ return PROGRAM_ID.toString().substring(0, 6) + key.toString()
42
+ }
43
+
44
+ let roundStartIndex = 0
45
+ let decryptionTaskFinished = 0;
46
+ /**
47
+ * Fetch and decrypt all UTXOs for a user
48
+ * @param signed The user's signature
49
+ * @param connection Solana connection to fetch on-chain commitment accounts
50
+ * @param setStatus A global state updator. Set live status message showing on webpage
51
+ * @returns Array of decrypted UTXOs that belong to the user
52
+ */
53
+
54
+ export async function getUtxos({ publicKey, connection, encryptionService, storage, abortSignal, offset }: {
55
+ publicKey: PublicKey,
56
+ connection: Connection,
57
+ encryptionService: EncryptionService,
58
+ storage: Storage,
59
+ abortSignal?: AbortSignal
60
+ offset?: number
61
+ }): Promise<Utxo[]> {
62
+
63
+ let valid_utxos: Utxo[] = []
64
+ let valid_strings: string[] = []
65
+ let history_indexes: number[] = []
66
+ let offsetStr = storage.getItem(LSK_FETCH_OFFSET + localstorageKey(publicKey))
67
+ if (offsetStr) {
68
+ roundStartIndex = Number(offsetStr)
69
+ } else {
70
+ roundStartIndex = 0
71
+ }
72
+ decryptionTaskFinished = 0
73
+ if (!offset) {
74
+ offset = 0
75
+ }
76
+ roundStartIndex = Math.max(offset, roundStartIndex)
77
+ while (true) {
78
+ if (abortSignal?.aborted) {
79
+ throw new Error('aborted')
80
+ }
81
+ let offsetStr = storage.getItem(LSK_FETCH_OFFSET + localstorageKey(publicKey))
82
+ let fetch_utxo_offset = offsetStr ? Number(offsetStr) : 0
83
+ if (offset) {
84
+ fetch_utxo_offset = Math.max(offset, fetch_utxo_offset)
85
+ }
86
+ let fetch_utxo_end = fetch_utxo_offset + FETCH_UTXOS_GROUP_SIZE
87
+ let fetch_utxo_url = `${RELAYER_API_URL}/utxos/range?start=${fetch_utxo_offset}&end=${fetch_utxo_end}`
88
+ let fetched = await fetchUserUtxos({ publicKey, connection, url: fetch_utxo_url, encryptionService, storage, initOffset: offset })
89
+ let am = 0
90
+
91
+ const nonZeroUtxos: Utxo[] = [];
92
+ const nonZeroEncrypted: any[] = [];
93
+ for (let [k, utxo] of fetched.utxos.entries()) {
94
+ history_indexes.push(utxo.index)
95
+ if (utxo.amount.toNumber() > 0) {
96
+ nonZeroUtxos.push(utxo);
97
+ nonZeroEncrypted.push(fetched.encryptedOutputs[k]);
98
+ }
99
+ }
100
+ if (nonZeroUtxos.length > 0) {
101
+ const spentFlags = await areUtxosSpent(connection, nonZeroUtxos);
102
+ for (let i = 0; i < nonZeroUtxos.length; i++) {
103
+ if (!spentFlags[i]) {
104
+ logger.debug(`found unspent encrypted_output ${nonZeroEncrypted[i]}`)
105
+ am += nonZeroUtxos[i].amount.toNumber();
106
+ valid_utxos.push(nonZeroUtxos[i]);
107
+ valid_strings.push(nonZeroEncrypted[i]);
108
+ }
109
+ }
110
+ }
111
+ storage.setItem(LSK_FETCH_OFFSET + localstorageKey(publicKey), (fetch_utxo_offset + fetched.len).toString())
112
+ if (!fetched.hasMore) {
113
+ break
114
+ }
115
+ await sleep(20)
116
+ }
117
+
118
+ // get history index
119
+ let historyKey = 'tradeHistory' + localstorageKey(publicKey)
120
+ let rec = storage.getItem(historyKey)
121
+ let recIndexes: number[] = []
122
+ if (rec?.length) {
123
+ recIndexes = rec.split(',').map(n => Number(n))
124
+ }
125
+ if (recIndexes.length) {
126
+ history_indexes = [...history_indexes, ...recIndexes]
127
+ }
128
+ let unique_history_indexes = Array.from(new Set(history_indexes));
129
+ let top20 = unique_history_indexes.sort((a, b) => b - a).slice(0, 20);
130
+ if (top20.length) {
131
+ storage.setItem(historyKey, top20.join(','))
132
+ }
133
+ // store valid strings
134
+ logger.debug(`valid_strings len before set: ${valid_strings.length}`)
135
+ valid_strings = [...new Set(valid_strings)];
136
+ logger.debug(`valid_strings len after set: ${valid_strings.length}`)
137
+ storage.setItem(LSK_ENCRYPTED_OUTPUTS + localstorageKey(publicKey), JSON.stringify(valid_strings))
138
+ return valid_utxos
139
+
140
+ }
141
+
142
+ async function fetchUserUtxos({ publicKey, connection, url, storage, encryptionService, initOffset }: {
143
+ publicKey: PublicKey,
144
+ connection: Connection,
145
+ url: string,
146
+ encryptionService: EncryptionService,
147
+ storage: Storage
148
+ initOffset: number
149
+ }): Promise<{
150
+ encryptedOutputs: string[],
151
+ utxos: Utxo[],
152
+ hasMore: boolean,
153
+ len: number
154
+ }> {
155
+ const lightWasm = await WasmFactory.getInstance();
156
+
157
+ // Derive the UTXO keypair from the wallet keypair
158
+ const utxoPrivateKey = encryptionService.deriveUtxoPrivateKey();
159
+ const utxoKeypair = new UtxoKeypair(utxoPrivateKey, lightWasm);
160
+
161
+
162
+ // Fetch all UTXOs from the API
163
+ let encryptedOutputs: string[] = [];
164
+ logger.debug('fetching utxo data', url)
165
+ let res = await fetch(url)
166
+ if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
167
+ const data: any = await res.json()
168
+ logger.debug('got utxo data')
169
+ if (!data) {
170
+ throw new Error('API returned empty data')
171
+ } else if (Array.isArray(data)) {
172
+ // Handle the case where the API returns an array of UTXOs
173
+ const utxos: ApiUtxo[] = data;
174
+ // Extract encrypted outputs from the array of UTXOs
175
+ encryptedOutputs = utxos
176
+ .filter(utxo => utxo.encrypted_output)
177
+ .map(utxo => utxo.encrypted_output);
178
+ } else if (typeof data === 'object' && data.encrypted_outputs) {
179
+ // Handle the case where the API returns an object with encrypted_outputs array
180
+ const apiResponse = data as ApiResponse;
181
+ encryptedOutputs = apiResponse.encrypted_outputs;
182
+ } else {
183
+ throw new Error(`API returned unexpected data format: ${JSON.stringify(data).substring(0, 100)}...`);
184
+ }
185
+
186
+ // Try to decrypt each encrypted output
187
+ const myUtxos: Utxo[] = [];
188
+ const myEncryptedOutputs: string[] = [];
189
+ let decryptionAttempts = 0;
190
+ let successfulDecryptions = 0;
191
+
192
+ let cachedStringNum = 0
193
+ let cachedString = storage.getItem(LSK_ENCRYPTED_OUTPUTS + localstorageKey(publicKey))
194
+ if (cachedString) {
195
+ cachedStringNum = JSON.parse(cachedString).length
196
+ }
197
+
198
+
199
+ let decryptionTaskTotal = data.total + cachedStringNum - roundStartIndex;
200
+
201
+ let batchRes = await decrypt_outputs(encryptedOutputs, encryptionService, utxoKeypair, lightWasm)
202
+ decryptionTaskFinished += encryptedOutputs.length
203
+ logger.debug('batchReslen', batchRes.length)
204
+ for (let i = 0; i < batchRes.length; i++) {
205
+ let dres = batchRes[i]
206
+ if (dres.status == 'decrypted' && dres.utxo) {
207
+ myUtxos.push(dres.utxo)
208
+ myEncryptedOutputs.push(dres.encryptedOutput!)
209
+ }
210
+ }
211
+ logger.info(`(decrypting cached utxo: ${decryptionTaskFinished + 1}/${decryptionTaskTotal}...)`)
212
+ // check cached string when no more fetching tasks
213
+ if (!data.hasMore) {
214
+ if (cachedString) {
215
+ let cachedEncryptedOutputs = JSON.parse(cachedString)
216
+ if (decryptionTaskFinished % 100 == 0) {
217
+ logger.info(`(decrypting cached utxo: ${decryptionTaskFinished + 1}/${decryptionTaskTotal}...)`)
218
+ }
219
+ let batchRes = await decrypt_outputs(cachedEncryptedOutputs, encryptionService, utxoKeypair, lightWasm)
220
+ decryptionTaskFinished += cachedEncryptedOutputs.length
221
+ logger.debug('cachedbatchReslen', batchRes.length, ' source', cachedEncryptedOutputs.length)
222
+ for (let i = 0; i < batchRes.length; i++) {
223
+ let dres = batchRes[i]
224
+ if (dres.status == 'decrypted' && dres.utxo) {
225
+ myUtxos.push(dres.utxo)
226
+ myEncryptedOutputs.push(dres.encryptedOutput!)
227
+ }
228
+ }
229
+ }
230
+ }
231
+
232
+ return { encryptedOutputs: myEncryptedOutputs, utxos: myUtxos, hasMore: data.hasMore, len: encryptedOutputs.length };
233
+ }
234
+
235
+ /**
236
+ * Check if a UTXO has been spent
237
+ * @param connection Solana connection
238
+ * @param utxo The UTXO to check
239
+ * @returns Promise<boolean> true if spent, false if unspent
240
+ */
241
+ export async function isUtxoSpent(connection: Connection, utxo: Utxo): Promise<boolean> {
242
+ try {
243
+ // Get the nullifier for this UTXO
244
+ const nullifier = await utxo.getNullifier();
245
+ logger.debug(`Checking if UTXO with nullifier ${nullifier} is spent`);
246
+
247
+ // Convert decimal nullifier string to byte array (same format as in proofs)
248
+ // This matches how commitments are handled and how the Rust code expects the seeds
249
+ const nullifierBytes = Array.from(
250
+ leInt2Buff(unstringifyBigInts(nullifier), 32)
251
+ ).reverse() as number[];
252
+
253
+ // Try nullifier0 seed
254
+ const [nullifier0PDA] = PublicKey.findProgramAddressSync(
255
+ [Buffer.from("nullifier0"), Buffer.from(nullifierBytes)],
256
+ PROGRAM_ID
257
+ );
258
+
259
+ logger.debug(`Derived nullifier0 PDA: ${nullifier0PDA.toBase58()}`);
260
+ const nullifier0Account = await connection.getAccountInfo(nullifier0PDA);
261
+ if (nullifier0Account !== null) {
262
+ logger.debug(`UTXO is spent (nullifier0 account exists)`);
263
+ return true;
264
+ }
265
+
266
+
267
+ const [nullifier1PDA] = PublicKey.findProgramAddressSync(
268
+ [Buffer.from("nullifier1"), Buffer.from(nullifierBytes)],
269
+ PROGRAM_ID
270
+ );
271
+
272
+ logger.debug(`Derived nullifier1 PDA: ${nullifier1PDA.toBase58()}`);
273
+ const nullifier1Account = await connection.getAccountInfo(nullifier1PDA);
274
+ if (nullifier1Account !== null) {
275
+ logger.debug(`UTXO is spent (nullifier1 account exists)`);
276
+ return true
277
+ }
278
+ return false;
279
+ } catch (error: any) {
280
+ console.error('Error checking if UTXO is spent:', error);
281
+ await new Promise(resolve => setTimeout(resolve, 3000));
282
+ return await isUtxoSpent(connection, utxo)
283
+ }
284
+ }
285
+
286
+ async function areUtxosSpent(
287
+ connection: Connection,
288
+ utxos: Utxo[]
289
+ ): Promise<boolean[]> {
290
+ try {
291
+ const allPDAs: { utxoIndex: number; pda: PublicKey }[] = [];
292
+
293
+ for (let i = 0; i < utxos.length; i++) {
294
+ const utxo = utxos[i];
295
+ const nullifier = await utxo.getNullifier();
296
+
297
+ const nullifierBytes = Array.from(
298
+ leInt2Buff(unstringifyBigInts(nullifier), 32)
299
+ ).reverse() as number[];
300
+
301
+ const [nullifier0PDA] = PublicKey.findProgramAddressSync(
302
+ [Buffer.from("nullifier0"), Buffer.from(nullifierBytes)],
303
+ PROGRAM_ID
304
+ );
305
+ const [nullifier1PDA] = PublicKey.findProgramAddressSync(
306
+ [Buffer.from("nullifier1"), Buffer.from(nullifierBytes)],
307
+ PROGRAM_ID
308
+ );
309
+
310
+ allPDAs.push({ utxoIndex: i, pda: nullifier0PDA });
311
+ allPDAs.push({ utxoIndex: i, pda: nullifier1PDA });
312
+ }
313
+
314
+ const results: any[] =
315
+ await connection.getMultipleAccountsInfo(allPDAs.map((x) => x.pda));
316
+
317
+ const spentFlags = new Array(utxos.length).fill(false);
318
+ for (let i = 0; i < allPDAs.length; i++) {
319
+ if (results[i] !== null) {
320
+ spentFlags[allPDAs[i].utxoIndex] = true;
321
+ }
322
+ }
323
+
324
+ return spentFlags;
325
+ } catch (error: any) {
326
+ console.error("Error checking if UTXOs are spent:", error);
327
+ await new Promise((resolve) => setTimeout(resolve, 3000));
328
+ return await areUtxosSpent(connection, utxos);
329
+ }
330
+ }
331
+
332
+ // Calculate total balance
333
+ export function getBalanceFromUtxos(utxos: Utxo[]) {
334
+ const totalBalance = utxos.reduce((sum, utxo) => sum.add(utxo.amount), new BN(0));
335
+ // const LAMPORTS_PER_SOL = new BN(1_000_000_000);
336
+ // const balanceInSol = totalBalance.div(LAMPORTS_PER_SOL);
337
+ // const remainderLamports = totalBalance.mod(LAMPORTS_PER_SOL);
338
+ return { lamports: totalBalance.toNumber() }
339
+ }
340
+
341
+ // Decrypt single output to Utxo
342
+ type DecryptRes = { status: 'decrypted' | 'skipped' | 'unDecrypted', utxo?: Utxo, encryptedOutput?: string }
343
+
344
+ async function decrypt_outputs(
345
+ encryptedOutputs: string[],
346
+ encryptionService: EncryptionService,
347
+ utxoKeypair: UtxoKeypair,
348
+ lightWasm: any,
349
+ ): Promise<DecryptRes[]> {
350
+ let results: DecryptRes[] = [];
351
+
352
+ // decript all UTXO
353
+ for (const encryptedOutput of encryptedOutputs) {
354
+ if (!encryptedOutput) {
355
+ results.push({ status: 'skipped' });
356
+ continue;
357
+ }
358
+ try {
359
+ const utxo = await encryptionService.decryptUtxo(
360
+ encryptedOutput,
361
+ lightWasm
362
+ );
363
+ results.push({ status: 'decrypted', utxo, encryptedOutput });
364
+ } catch {
365
+ results.push({ status: 'unDecrypted' });
366
+ }
367
+ }
368
+ results = results.filter(r => r.status == 'decrypted')
369
+ if (!results.length) {
370
+ return []
371
+ }
372
+
373
+ // update utxo index
374
+ if (results.length > 0) {
375
+ let encrypted_outputs = results.map(r => r.encryptedOutput)
376
+
377
+ let url = RELAYER_API_URL + `/utxos/indices`
378
+ let res = await fetch(url, {
379
+ method: 'POST', headers: { "Content-Type": "application/json" },
380
+ body: JSON.stringify({ encrypted_outputs })
381
+ })
382
+ let j = await res.json()
383
+ if (!j.indices || !Array.isArray(j.indices) || j.indices.length != encrypted_outputs.length) {
384
+ throw new Error('failed fetching /utxos/indices')
385
+ }
386
+ for (let i = 0; i < results.length; i++) {
387
+ let utxo = results[i].utxo
388
+ if (utxo!.index !== j.indices[i] && typeof j.indices[i] == 'number') {
389
+ logger.debug(`Updated UTXO index from ${utxo!.index} to ${j.indices[i]}`);
390
+ utxo!.index = j.indices[i]
391
+ }
392
+ }
393
+ }
394
+
395
+ return results;
396
+ }