@utxopia/sdk 0.1.0-alpha.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.
Files changed (94) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +171 -0
  3. package/package.json +108 -0
  4. package/packages/btc-client/src/esplora-client.ts +153 -0
  5. package/packages/btc-client/src/index.ts +3 -0
  6. package/packages/btc-client/src/op-return.ts +93 -0
  7. package/packages/btc-client/src/types.ts +112 -0
  8. package/packages/sdk/README.md +277 -0
  9. package/packages/sdk/dist/announcement-client.d.ts +64 -0
  10. package/packages/sdk/dist/announcement-client.js +337 -0
  11. package/packages/sdk/dist/auditor-ciphertext.d.ts +72 -0
  12. package/packages/sdk/dist/auditor-ciphertext.js +135 -0
  13. package/packages/sdk/dist/auditor.d.ts +115 -0
  14. package/packages/sdk/dist/auditor.js +292 -0
  15. package/packages/sdk/dist/bitcoin/ika.d.ts +47 -0
  16. package/packages/sdk/dist/bitcoin/ika.js +74 -0
  17. package/packages/sdk/dist/bitcoin/index.d.ts +2 -0
  18. package/packages/sdk/dist/bitcoin/index.js +1 -0
  19. package/packages/sdk/dist/bound-params.d.ts +96 -0
  20. package/packages/sdk/dist/bound-params.js +249 -0
  21. package/packages/sdk/dist/chadbuffer.d.ts +124 -0
  22. package/packages/sdk/dist/chadbuffer.js +405 -0
  23. package/packages/sdk/dist/claim-link.d.ts +30 -0
  24. package/packages/sdk/dist/claim-link.js +50 -0
  25. package/packages/sdk/dist/client.d.ts +233 -0
  26. package/packages/sdk/dist/client.js +416 -0
  27. package/packages/sdk/dist/commitment-tree.d.ts +268 -0
  28. package/packages/sdk/dist/commitment-tree.js +564 -0
  29. package/packages/sdk/dist/config.d.ts +177 -0
  30. package/packages/sdk/dist/config.js +426 -0
  31. package/packages/sdk/dist/core/esplora.d.ts +110 -0
  32. package/packages/sdk/dist/core/esplora.js +187 -0
  33. package/packages/sdk/dist/core/mempool.d.ts +60 -0
  34. package/packages/sdk/dist/core/mempool.js +107 -0
  35. package/packages/sdk/dist/crypto-babyjub.d.ts +105 -0
  36. package/packages/sdk/dist/crypto-babyjub.js +312 -0
  37. package/packages/sdk/dist/crypto-ed25519.d.ts +116 -0
  38. package/packages/sdk/dist/crypto-ed25519.js +235 -0
  39. package/packages/sdk/dist/crypto.d.ts +59 -0
  40. package/packages/sdk/dist/crypto.js +140 -0
  41. package/packages/sdk/dist/event-client.d.ts +64 -0
  42. package/packages/sdk/dist/event-client.js +165 -0
  43. package/packages/sdk/dist/events.d.ts +127 -0
  44. package/packages/sdk/dist/events.js +305 -0
  45. package/packages/sdk/dist/explorer.d.ts +86 -0
  46. package/packages/sdk/dist/explorer.js +197 -0
  47. package/packages/sdk/dist/index.d.ts +61 -0
  48. package/packages/sdk/dist/index.js +216 -0
  49. package/packages/sdk/dist/instructions.d.ts +1125 -0
  50. package/packages/sdk/dist/instructions.js +1760 -0
  51. package/packages/sdk/dist/keys.d.ts +467 -0
  52. package/packages/sdk/dist/keys.js +799 -0
  53. package/packages/sdk/dist/logger.d.ts +10 -0
  54. package/packages/sdk/dist/logger.js +40 -0
  55. package/packages/sdk/dist/magicblock.d.ts +90 -0
  56. package/packages/sdk/dist/magicblock.js +164 -0
  57. package/packages/sdk/dist/merkle.d.ts +82 -0
  58. package/packages/sdk/dist/merkle.js +141 -0
  59. package/packages/sdk/dist/note.d.ts +348 -0
  60. package/packages/sdk/dist/note.js +483 -0
  61. package/packages/sdk/dist/pda.d.ts +160 -0
  62. package/packages/sdk/dist/pda.js +384 -0
  63. package/packages/sdk/dist/pool-state.d.ts +100 -0
  64. package/packages/sdk/dist/pool-state.js +126 -0
  65. package/packages/sdk/dist/poseidon.d.ts +53 -0
  66. package/packages/sdk/dist/poseidon.js +136 -0
  67. package/packages/sdk/dist/prover/index.d.ts +10 -0
  68. package/packages/sdk/dist/prover/index.js +10 -0
  69. package/packages/sdk/dist/prover/mobile.d.ts +45 -0
  70. package/packages/sdk/dist/prover/mobile.js +227 -0
  71. package/packages/sdk/dist/prover/web.d.ts +136 -0
  72. package/packages/sdk/dist/prover/web.js +597 -0
  73. package/packages/sdk/dist/psbt.d.ts +73 -0
  74. package/packages/sdk/dist/psbt.js +202 -0
  75. package/packages/sdk/dist/selective-disclosure.d.ts +181 -0
  76. package/packages/sdk/dist/selective-disclosure.js +172 -0
  77. package/packages/sdk/dist/sender-memo.d.ts +149 -0
  78. package/packages/sdk/dist/sender-memo.js +250 -0
  79. package/packages/sdk/dist/sns-resolver.d.ts +121 -0
  80. package/packages/sdk/dist/sns-resolver.js +229 -0
  81. package/packages/sdk/dist/solana/connection.d.ts +77 -0
  82. package/packages/sdk/dist/solana/connection.js +133 -0
  83. package/packages/sdk/dist/solana/priority-fee.d.ts +78 -0
  84. package/packages/sdk/dist/solana/priority-fee.js +137 -0
  85. package/packages/sdk/dist/stealth.d.ts +429 -0
  86. package/packages/sdk/dist/stealth.js +703 -0
  87. package/packages/sdk/dist/taproot.d.ts +172 -0
  88. package/packages/sdk/dist/taproot.js +494 -0
  89. package/packages/sdk/dist/token-registry.d.ts +96 -0
  90. package/packages/sdk/dist/token-registry.js +122 -0
  91. package/packages/sdk/dist/utils/encoding.d.ts +19 -0
  92. package/packages/sdk/dist/utils/encoding.js +29 -0
  93. package/packages/sdk/dist/vk-registry.d.ts +100 -0
  94. package/packages/sdk/dist/vk-registry.js +212 -0
@@ -0,0 +1,703 @@
1
+ /**
2
+ * Stealth address utilities for UTXOPIA
3
+ *
4
+ * Dual-curve stealth flow (Railgun-style):
5
+ *
6
+ * Stealth Deposit Flow:
7
+ * ```
8
+ * Sender:
9
+ * 1. ephemeral = random Ed25519 keypair
10
+ * 2. sharedSecret = X25519(ephemeral.priv, recipientViewingPub)
11
+ * 3. stealthScalar = SHA256(sharedSecret || domain) mod BJJ_ORDER
12
+ * 4. stealthPub = spendingPub + stealthScalar × BASE8 (Baby Jubjub)
13
+ * 5. commitment = Poseidon(stealthPub.x, amount)
14
+ * 6. encryptedAmount = amount XOR sha256(sharedSecret)[0..8]
15
+ *
16
+ * Recipient (viewing key only - can detect and see amount):
17
+ * 1. sharedSecret = X25519(viewingPriv, ephemeralPub)
18
+ * 2. amount = encryptedAmount XOR sha256(sharedSecret)[0..8]
19
+ * 3. stealthPub = spendingPub + stealthScalar × BASE8
20
+ * 4. Verify: commitment == Poseidon(stealthPub.x, amount)
21
+ *
22
+ * Recipient (spending key - can claim):
23
+ * 1. stealthPriv = spendingPriv + stealthScalar (mod BJJ_ORDER)
24
+ * 2. nullifier = Poseidon(stealthPriv, leafIndex)
25
+ * ```
26
+ *
27
+ * Format (90 bytes on-chain):
28
+ * - ephemeral_pub (32 bytes) - Ed25519 public key
29
+ * - encrypted_amount (8 bytes) - XOR encrypted with shared secret
30
+ * - commitment (32 bytes) - Poseidon hash for Merkle tree
31
+ * - leaf_index (8 bytes) - Position in Merkle tree
32
+ * - created_at (8 bytes) - Timestamp
33
+ */
34
+ // ========== Constants (defined before imports to ensure availability) ==========
35
+ /** Announcement type: deposit (plaintext amount) */
36
+ export const ANNOUNCEMENT_TYPE_DEPOSIT = 0;
37
+ /** Announcement type: transfer (XOR-encrypted amount) */
38
+ export const ANNOUNCEMENT_TYPE_TRANSFER = 1;
39
+ function isKnownAnnouncementType(value) {
40
+ return value === ANNOUNCEMENT_TYPE_DEPOSIT || value === ANNOUNCEMENT_TYPE_TRANSFER;
41
+ }
42
+ // ========== Imports ==========
43
+ import { sha256 } from "@noble/hashes/sha2.js";
44
+ import { bigintToBytes, bytesToBigint, bytesToHex, hexToBytes, babyJubMul, babyJubAdd, babyJubCompress, babyJubDecompress, BABYJUB_BASE8, BABYJUB_ORDER, scalarFromBytes, } from "./crypto";
45
+ import { ed25519GenerateKeyPair, x25519Ecdh, encryptAmountEd25519, decryptAmountEd25519, } from "./crypto-ed25519";
46
+ import { deriveKeysFromWallet } from "./keys";
47
+ import { computeMPKSync, computeNPKSync, computeJoinSplitCommitmentSync, computeJoinSplitNullifierSync, } from "./poseidon";
48
+ import { getConfig } from "./config";
49
+ import { deriveRawXOnlyP2TRAddress } from "./bitcoin/ika";
50
+ // ========== Amount Encryption Helpers ==========
51
+ /**
52
+ * Encrypt amount using XOR with shared secret
53
+ */
54
+ export function encryptAmount(amount, sharedSecret) {
55
+ return encryptAmountEd25519(amount, sharedSecret);
56
+ }
57
+ /**
58
+ * Decrypt amount using XOR with shared secret
59
+ */
60
+ export function decryptAmount(encryptedAmount, sharedSecret) {
61
+ return decryptAmountEd25519(encryptedAmount, sharedSecret);
62
+ }
63
+ // Re-export combined note data encryption
64
+ export { encryptNoteData, decryptNoteData } from "./crypto-ed25519";
65
+ // ========== Type Guard ==========
66
+ /**
67
+ * Type guard to distinguish between WalletSignerAdapter and UTXOpiaKeys
68
+ */
69
+ export function isWalletAdapter(source) {
70
+ return (typeof source === "object" &&
71
+ source !== null &&
72
+ "signMessage" in source &&
73
+ typeof source.signMessage === "function");
74
+ }
75
+ // ========== Helper Functions ==========
76
+ /** Domain separator for stealth key derivation.
77
+ * "Utxopia-stealth-v1" is LOAD-BEARING — once real stealth addresses exist this
78
+ * exact byte sequence is FROZEN; sender and receiver must use the identical
79
+ * value or payments won't be detected. Bump the suffix only as a deliberate
80
+ * v2 break. */
81
+ const STEALTH_KEY_DOMAIN = new TextEncoder().encode("Utxopia-stealth-v1");
82
+ // tokenId removed — use computeTokenId(mintBytes) from poseidon.ts instead
83
+ /**
84
+ * Derive stealth scalar from X25519 shared secret
85
+ *
86
+ * stealthScalar = SHA256(sharedSecret || domain) mod BJJ_ORDER
87
+ */
88
+ function deriveStealthScalar(sharedSecret) {
89
+ const hashInput = new Uint8Array(sharedSecret.length + STEALTH_KEY_DOMAIN.length);
90
+ hashInput.set(sharedSecret, 0);
91
+ hashInput.set(STEALTH_KEY_DOMAIN, sharedSecret.length);
92
+ const hash = sha256(hashInput);
93
+ return scalarFromBytes(hash);
94
+ }
95
+ /**
96
+ * Derive stealth public key (Baby Jubjub)
97
+ *
98
+ * stealthPub = spendingPub + stealthScalar × BASE8
99
+ */
100
+ function deriveStealthPubKey(spendingPub, sharedSecret) {
101
+ const scalar = deriveStealthScalar(sharedSecret);
102
+ const scalarPoint = babyJubMul(scalar, BABYJUB_BASE8);
103
+ return babyJubAdd(spendingPub, scalarPoint);
104
+ }
105
+ /**
106
+ * Derive stealth private key (Baby Jubjub scalar addition)
107
+ *
108
+ * stealthPriv = spendingPriv + stealthScalar (mod BJJ_ORDER)
109
+ */
110
+ function deriveStealthPrivKey(spendingPriv, sharedSecret) {
111
+ const scalar = deriveStealthScalar(sharedSecret);
112
+ return (spendingPriv + scalar) % BABYJUB_ORDER;
113
+ }
114
+ // ========== Sender Functions ==========
115
+ /**
116
+ * Create a stealth deposit (JoinSplit-compatible)
117
+ *
118
+ * 1. Generate Ed25519 ephemeral keypair
119
+ * 2. sharedSecret = X25519(ephemeral.priv, viewingPub)
120
+ * 3. stealthPub = spendingPub + hash(sharedSecret) × BASE8
121
+ * 4. stealthMPK = Poseidon(stealthPub.x, stealthPub.y, nullifyingKey)
122
+ * (sender uses recipientMPK from meta-address for stealth deposits)
123
+ * 5. npk = Poseidon(recipientMPK, random)
124
+ * 6. commitment = Poseidon(npk, tokenId, amount)
125
+ * 7. encryptedAmount = amount XOR sha256(sharedSecret)[0..8]
126
+ */
127
+ export async function createStealthDeposit(recipientMeta, amountSats, tokenId) {
128
+ // Only viewingPubKey + mpk needed (spendingPubKey not used by sender)
129
+ const viewingPubKey = new Uint8Array(recipientMeta.viewingPubKey);
130
+ // Generate Ed25519 ephemeral keypair
131
+ const ephemeral = ed25519GenerateKeyPair();
132
+ // X25519 ECDH: shared secret
133
+ const sharedSecret = x25519Ecdh(ephemeral.privKey, viewingPubKey);
134
+ // Derive stealth scalar as the random value for NPK
135
+ const stealthScalar = deriveStealthScalar(sharedSecret);
136
+ // Use recipient's MPK from meta-address to compute NPK
137
+ const recipientMPK = bytesToBigint(recipientMeta.mpk);
138
+ const npk = computeNPKSync(recipientMPK, stealthScalar);
139
+ // Compute JoinSplit commitment = Poseidon(npk, token, amount)
140
+ const commitmentBigint = computeJoinSplitCommitmentSync(npk, tokenId, amountSats);
141
+ const commitment = bigintToBytes(commitmentBigint);
142
+ // Encrypt amount
143
+ const encryptedAmount = encryptAmount(amountSats, sharedSecret);
144
+ return {
145
+ ephemeralPub: new Uint8Array(ephemeral.pubKey),
146
+ encryptedAmount,
147
+ commitment,
148
+ createdAt: Date.now(),
149
+ };
150
+ }
151
+ /**
152
+ * Create stealth deposit with npk for JoinSplit circuit input
153
+ */
154
+ export async function createStealthDepositWithKeys(recipientMeta, amountSats, tokenId) {
155
+ // Only viewingPubKey + mpk needed (spendingPubKey not used by sender)
156
+ const viewingPubKey = new Uint8Array(recipientMeta.viewingPubKey);
157
+ const ephemeral = ed25519GenerateKeyPair();
158
+ const sharedSecret = x25519Ecdh(ephemeral.privKey, viewingPubKey);
159
+ const stealthScalar = deriveStealthScalar(sharedSecret);
160
+ const recipientMPK = bytesToBigint(recipientMeta.mpk);
161
+ const npk = computeNPKSync(recipientMPK, stealthScalar);
162
+ const commitmentBigint = computeJoinSplitCommitmentSync(npk, tokenId, amountSats);
163
+ const commitment = bigintToBytes(commitmentBigint);
164
+ const encryptedAmount = encryptAmount(amountSats, sharedSecret);
165
+ return {
166
+ ephemeralPub: new Uint8Array(ephemeral.pubKey),
167
+ encryptedAmount,
168
+ commitment,
169
+ stealthPubKeyX: npk,
170
+ npkBytes: bigintToBytes(npk),
171
+ };
172
+ }
173
+ export async function createNonInteractiveDeposit(recipientMeta, custodyInternalKey, network = "testnet", userRefundPubkey, opReturnContext) {
174
+ if (!opReturnContext) {
175
+ throw new Error("deposit OP_RETURN context is required");
176
+ }
177
+ // Only viewingPubKey + mpk needed (spendingPubKey not used by sender)
178
+ const viewingPubKey = new Uint8Array(recipientMeta.viewingPubKey);
179
+ // 1. Generate ephemeral Ed25519 keypair
180
+ const ephemeral = ed25519GenerateKeyPair();
181
+ // 2. X25519 ECDH shared secret
182
+ const sharedSecret = x25519Ecdh(ephemeral.privKey, viewingPubKey);
183
+ // 3. Derive stealth scalar → NPK (no commitment — computed on-chain)
184
+ const stealthScalar = deriveStealthScalar(sharedSecret);
185
+ const recipientMPK = bytesToBigint(recipientMeta.mpk);
186
+ const npkBigint = computeNPKSync(recipientMPK, stealthScalar);
187
+ const npk = bigintToBytes(npkBigint);
188
+ const ephemeralPub = new Uint8Array(ephemeral.pubKey);
189
+ if (userRefundPubkey) {
190
+ // Refund path: Taproot with script tree containing refund script
191
+ const { deriveTaprootAddressWithRefund, buildDepositOpReturn } = await import("./taproot");
192
+ const { address: btcAddress, outputKey, merkleRoot, controlBlock, refundScript, } = deriveTaprootAddressWithRefund(npk, userRefundPubkey, custodyInternalKey, network);
193
+ // Still build OP_RETURN so the backend can detect the deposit
194
+ const opReturnPayload = buildDepositOpReturn(ephemeralPub, npk, opReturnContext);
195
+ return {
196
+ btcAddress,
197
+ depositOutputKey: outputKey,
198
+ opReturnPayload,
199
+ npk,
200
+ ephemeralPub,
201
+ merkleRoot,
202
+ controlBlock,
203
+ refundScript,
204
+ };
205
+ }
206
+ // Standard path: key-path-only Taproot address
207
+ const { deriveTaprootAddress, buildDepositOpReturn } = await import("./taproot");
208
+ const { address: btcAddress, outputKey } = deriveTaprootAddress(npk, network, custodyInternalKey);
209
+ const opReturnPayload = buildDepositOpReturn(ephemeralPub, npk, opReturnContext);
210
+ return {
211
+ btcAddress,
212
+ depositOutputKey: outputKey,
213
+ opReturnPayload,
214
+ npk,
215
+ ephemeralPub,
216
+ };
217
+ }
218
+ /**
219
+ * Create a non-interactive deposit directly to an Ika-controlled vault.
220
+ *
221
+ * The BTC address is the raw Ika x-only Taproot witness program, so Ika can
222
+ * later sign and spend the UTXO. Privacy/ownership metadata stays per-deposit
223
+ * in OP_RETURN(header || poolTag || ephemeralPub || npk), and the destination chain
224
+ * credits the note from that transaction.
225
+ */
226
+ export async function createDirectVaultDeposit(recipientMeta, vaultXOnlyPubkey, network = "testnet", opReturnContext) {
227
+ if (!opReturnContext) {
228
+ throw new Error("deposit OP_RETURN context is required");
229
+ }
230
+ if (vaultXOnlyPubkey.length !== 32) {
231
+ throw new Error("vaultXOnlyPubkey must be 32 bytes");
232
+ }
233
+ const viewingPubKey = new Uint8Array(recipientMeta.viewingPubKey);
234
+ const ephemeral = ed25519GenerateKeyPair();
235
+ const sharedSecret = x25519Ecdh(ephemeral.privKey, viewingPubKey);
236
+ const stealthScalar = deriveStealthScalar(sharedSecret);
237
+ const recipientMPK = bytesToBigint(recipientMeta.mpk);
238
+ const npkBigint = computeNPKSync(recipientMPK, stealthScalar);
239
+ const npk = bigintToBytes(npkBigint);
240
+ const ephemeralPub = new Uint8Array(ephemeral.pubKey);
241
+ const { buildDepositOpReturn } = await import("./taproot");
242
+ const opReturnPayload = buildDepositOpReturn(ephemeralPub, npk, opReturnContext);
243
+ return {
244
+ btcAddress: deriveRawXOnlyP2TRAddress(vaultXOnlyPubkey, network),
245
+ depositOutputKey: vaultXOnlyPubkey,
246
+ opReturnPayload,
247
+ npk,
248
+ ephemeralPub,
249
+ };
250
+ }
251
+ /**
252
+ * Create a non-interactive deposit using the current SDK config.
253
+ *
254
+ * Direct-vault/Ika deposit helper.
255
+ *
256
+ * Deposits go to the raw Ika x-only P2TR vault address. Recipient binding
257
+ * stays per-deposit in OP_RETURN(header || poolTag || ephemeralPub || npk), and the destination chain
258
+ * credits the note by SPV-verifying that deposit transaction directly. Legacy sweep-mode
259
+ * address derivation is intentionally not selected from config anymore.
260
+ */
261
+ export async function createDepositFromConfig(recipientMeta, network = "testnet", opReturnContext) {
262
+ const config = getConfig();
263
+ const ikaKey = pickIkaCustodyKey(config);
264
+ if (!ikaKey) {
265
+ throw new Error("Ika direct-vault deposits require ikaDwalletXOnlyPubkey in config");
266
+ }
267
+ if (config.depositMode && !isDirectVaultDepositMode(config.depositMode)) {
268
+ throw new Error(`Unsupported depositMode "${config.depositMode}"; only Ika direct-vault deposits are supported`);
269
+ }
270
+ return createDirectVaultDeposit(recipientMeta, ikaKey, network, opReturnContext);
271
+ }
272
+ export function isDirectVaultDepositMode(mode) {
273
+ return mode === "direct" || mode === "direct_vault" || mode === "ika_direct";
274
+ }
275
+ /**
276
+ * Choose the Taproot internal key for deposit-address derivation.
277
+ * The Ika dWallet x-only pubkey is the sole custody key; throws if unset.
278
+ * Exported for unit tests; non-test callers should use `createDepositFromConfig`.
279
+ */
280
+ export function pickCustodyInternalKey(config) {
281
+ const ikaKey = pickIkaCustodyKey(config);
282
+ if (!ikaKey) {
283
+ throw new Error("PoolConfig.ika_dwallet_xonly_pubkey is required; pool custody key is not configured");
284
+ }
285
+ return ikaKey;
286
+ }
287
+ export function pickIkaCustodyKey(config) {
288
+ const ikaHex = config.ikaDwalletXOnlyPubkey ?? "";
289
+ if (ikaHex && /[1-9a-f]/i.test(ikaHex)) {
290
+ return hexToBytes(ikaHex);
291
+ }
292
+ return null;
293
+ }
294
+ // ========== Recipient Scanning (Viewing Key Only) ==========
295
+ /**
296
+ * Scan announcements using viewing key only
297
+ */
298
+ export async function scanAnnouncements(source, announcements, tokenId) {
299
+ const keys = isWalletAdapter(source) ? await deriveKeysFromWallet(source) : source;
300
+ const found = [];
301
+ const MAX_SATS = 21000000n * 100000000n;
302
+ // Compute MPK for this key set
303
+ const mpk = computeMPKSync(keys.spendingPubKey.x, keys.spendingPubKey.y, keys.nullifyingKey);
304
+ for (const ann of announcements) {
305
+ try {
306
+ // X25519 ECDH with viewing key
307
+ const sharedSecret = x25519Ecdh(keys.viewingPrivKey, ann.ephemeralPub);
308
+ // Decrypt amount
309
+ const amount = decryptAmount(ann.encryptedAmount, sharedSecret);
310
+ if (amount <= 0n || amount > MAX_SATS) {
311
+ continue;
312
+ }
313
+ // Derive stealth public key (still needed for spending)
314
+ const stealthPub = deriveStealthPubKey(keys.spendingPubKey, sharedSecret);
315
+ // Derive stealth scalar as random for NPK
316
+ const stealthScalar = deriveStealthScalar(sharedSecret);
317
+ // Compute expected NPK and commitment (JoinSplit format)
318
+ const npk = computeNPKSync(mpk, stealthScalar);
319
+ const expectedCommitment = computeJoinSplitCommitmentSync(npk, tokenId, amount);
320
+ const actualCommitment = bytesToBigint(ann.commitment);
321
+ if (expectedCommitment !== actualCommitment) {
322
+ continue;
323
+ }
324
+ found.push({
325
+ amount,
326
+ ephemeralPub: ann.ephemeralPub,
327
+ stealthPub,
328
+ leafIndex: ann.leafIndex,
329
+ commitment: ann.commitment,
330
+ });
331
+ }
332
+ catch (error) {
333
+ // Re-throw programming errors; only skip data/crypto mismatches
334
+ if (error instanceof TypeError || error instanceof RangeError) {
335
+ throw error;
336
+ }
337
+ continue;
338
+ }
339
+ }
340
+ return found;
341
+ }
342
+ /**
343
+ * Scan announcements with VIEW-ONLY keys against SEVERAL token ids in one pass.
344
+ * Same one-ECDH-per-announcement economics as {@link scanUnifiedNotesMulti}.
345
+ */
346
+ export async function scanAnnouncementsViewOnlyMulti(viewOnlyKeys, announcements, tokenIds) {
347
+ return matchAnnouncements(viewOnlyKeys, announcements, tokenIds).map((match) => ({
348
+ amount: match.amount,
349
+ leafIndex: match.announcement.leafIndex,
350
+ commitment: match.commitment,
351
+ ephemeralPub: match.announcement.ephemeralPub,
352
+ blockTime: match.announcement.blockTime ?? 0,
353
+ tokenId: match.tokenId,
354
+ }));
355
+ }
356
+ /**
357
+ * Scan announcements with VIEW-ONLY keys.
358
+ * Latest announcement rows must carry an explicit type.
359
+ */
360
+ export async function scanAnnouncementsViewOnly(viewOnlyKeys, announcements, tokenId) {
361
+ return scanAnnouncementsViewOnlyMulti(viewOnlyKeys, announcements, [tokenId]);
362
+ }
363
+ /**
364
+ * Export view-only keys from full UTXOpiaKeys
365
+ */
366
+ export function exportViewOnlyKeys(keys) {
367
+ return {
368
+ viewingPrivKey: keys.viewingPrivKey,
369
+ spendingPubKey: keys.spendingPubKey,
370
+ nullifyingKey: keys.nullifyingKey,
371
+ };
372
+ }
373
+ /**
374
+ * Encode view-only keys as a hex string for sharing
375
+ * Format: viewingPrivKey(32) + compressedSpendingPub(32) + nullifyingKey(32) = 96 bytes
376
+ */
377
+ export function encodeViewOnlyKeys(keys) {
378
+ const compressed = babyJubCompress(keys.spendingPubKey);
379
+ const nullBytes = bigintToBytes(keys.nullifyingKey);
380
+ const combined = new Uint8Array(96);
381
+ combined.set(keys.viewingPrivKey, 0);
382
+ combined.set(compressed, 32);
383
+ combined.set(nullBytes, 64);
384
+ return bytesToHex(combined);
385
+ }
386
+ /**
387
+ * Decode view-only keys from a hex string
388
+ */
389
+ export function decodeViewOnlyKeys(encoded) {
390
+ const bytes = hexToBytes(encoded);
391
+ if (bytes.length !== 96) {
392
+ throw new Error("Invalid view-only key length (expected 96 bytes)");
393
+ }
394
+ const viewingPrivKey = bytes.slice(0, 32);
395
+ const compressed = bytes.slice(32, 64);
396
+ const spendingPubKey = babyJubDecompress(compressed);
397
+ const nullifyingKey = bytesToBigint(bytes.slice(64, 96));
398
+ return { viewingPrivKey, spendingPubKey, nullifyingKey };
399
+ }
400
+ // ========== Claim Preparation (Spending Key Required) ==========
401
+ /**
402
+ * Prepare claim inputs for ZK proof generation
403
+ */
404
+ export async function prepareClaimInputs(source, note, merkleProof) {
405
+ const keys = isWalletAdapter(source) ? await deriveKeysFromWallet(source) : source;
406
+ // X25519 ECDH to recover shared secret
407
+ const sharedSecret = x25519Ecdh(keys.viewingPrivKey, note.ephemeralPub);
408
+ // Derive stealth private key (Baby Jubjub scalar addition)
409
+ const stealthPrivKey = deriveStealthPrivKey(keys.spendingPrivKey, sharedSecret);
410
+ // Verify stealth public key matches
411
+ const expectedStealthPub = babyJubMul(stealthPrivKey, BABYJUB_BASE8);
412
+ if (expectedStealthPub.x !== note.stealthPub.x || expectedStealthPub.y !== note.stealthPub.y) {
413
+ throw new Error("Stealth key mismatch - this note may not belong to you or the announcement is invalid");
414
+ }
415
+ // Derive the random value (stealth scalar) for NPK
416
+ const stealthScalar = deriveStealthScalar(sharedSecret);
417
+ // Compute MPK and NPK
418
+ const mpk = computeMPKSync(keys.spendingPubKey.x, keys.spendingPubKey.y, keys.nullifyingKey);
419
+ const npk = computeNPKSync(mpk, stealthScalar);
420
+ // Compute JoinSplit nullifier
421
+ const nullifier = computeJoinSplitNullifierSync(keys.nullifyingKey, BigInt(note.leafIndex));
422
+ return {
423
+ stealthPrivKey,
424
+ nullifyingKey: keys.nullifyingKey,
425
+ amount: note.amount,
426
+ leafIndex: note.leafIndex,
427
+ merklePath: merkleProof.pathElements,
428
+ merkleIndices: merkleProof.pathIndices,
429
+ merkleRoot: merkleProof.root,
430
+ nullifier,
431
+ npk,
432
+ random: stealthScalar,
433
+ };
434
+ }
435
+ const MAX_SATS = 21000000n * 100000000n;
436
+ /**
437
+ * Trial-decrypt each announcement ONCE and test the result against every token
438
+ * id, instead of redoing the whole derivation per token.
439
+ *
440
+ * Everything up to the commitment — the x25519 ECDH, the amount, the stealth
441
+ * scalar, the NPK — is token-independent; only the closing
442
+ * Poseidon(npk, tokenId, amount) comparison is not. Scanning T tokens the naive
443
+ * way therefore paid T ECDHs per announcement to answer one question, and the
444
+ * ECDH is the expensive half.
445
+ *
446
+ * A commitment binds exactly one token id, so the first match wins and the
447
+ * remaining ids are skipped.
448
+ */
449
+ function matchAnnouncements(viewKeys, announcements, tokenIds) {
450
+ if (tokenIds.length === 0)
451
+ return [];
452
+ const matches = [];
453
+ const mpk = computeMPKSync(viewKeys.spendingPubKey.x, viewKeys.spendingPubKey.y, viewKeys.nullifyingKey);
454
+ for (const ann of announcements) {
455
+ try {
456
+ if (!isKnownAnnouncementType(ann.announcementType)) {
457
+ continue;
458
+ }
459
+ // X25519 ECDH with viewing key — once per announcement, not per token
460
+ const sharedSecret = x25519Ecdh(viewKeys.viewingPrivKey, ann.ephemeralPub);
461
+ // Get amount based on type
462
+ let amount;
463
+ if (ann.announcementType === ANNOUNCEMENT_TYPE_DEPOSIT) {
464
+ // Plaintext u64 LE
465
+ const view = new DataView(ann.encryptedAmount.buffer, ann.encryptedAmount.byteOffset, 8);
466
+ amount = view.getBigUint64(0, true);
467
+ }
468
+ else {
469
+ // XOR-encrypted
470
+ amount = decryptAmount(ann.encryptedAmount, sharedSecret);
471
+ }
472
+ if (amount <= 0n || amount > MAX_SATS) {
473
+ continue;
474
+ }
475
+ // Derive stealth scalar and expected NPK (computed locally)
476
+ const stealthScalar = deriveStealthScalar(sharedSecret);
477
+ const npk = computeNPKSync(mpk, stealthScalar);
478
+ const onChain = bytesToBigint(ann.commitment);
479
+ for (const tokenId of tokenIds) {
480
+ // Verify the recomputed commitment against the on-chain one for BOTH
481
+ // deposits and transfers — a foreign transfer whose XOR-decrypted amount
482
+ // lands in range would otherwise become a phantom note.
483
+ const commitmentBigint = computeJoinSplitCommitmentSync(npk, tokenId, amount);
484
+ if (commitmentBigint !== onChain)
485
+ continue;
486
+ matches.push({
487
+ announcement: ann,
488
+ amount,
489
+ tokenId,
490
+ // Use on-chain commitment bytes for transfers (preserves exact on-chain value)
491
+ commitment: ann.announcementType === ANNOUNCEMENT_TYPE_DEPOSIT
492
+ ? bigintToBytes(commitmentBigint)
493
+ : new Uint8Array(ann.commitment),
494
+ sharedSecret,
495
+ });
496
+ break;
497
+ }
498
+ }
499
+ catch (error) {
500
+ if (error instanceof TypeError || error instanceof RangeError) {
501
+ throw error;
502
+ }
503
+ continue;
504
+ }
505
+ }
506
+ return matches;
507
+ }
508
+ /**
509
+ * Scan unified StealthAnnouncement notes (both deposits and transfers) against
510
+ * SEVERAL token ids in one pass.
511
+ *
512
+ * Prefer this over calling {@link scanUnifiedNotes} once per token: the cost is
513
+ * one ECDH per announcement either way, plus one Poseidon per token id tried.
514
+ */
515
+ export async function scanUnifiedNotesMulti(source, announcements, tokenIds) {
516
+ const keys = isWalletAdapter(source) ? await deriveKeysFromWallet(source) : source;
517
+ return matchAnnouncements(exportViewOnlyKeys(keys), announcements, tokenIds).map((match) => ({
518
+ amount: match.amount,
519
+ ephemeralPub: match.announcement.ephemeralPub,
520
+ // Stealth public key, for spending
521
+ stealthPub: deriveStealthPubKey(keys.spendingPubKey, match.sharedSecret),
522
+ leafIndex: match.announcement.leafIndex,
523
+ commitment: match.commitment,
524
+ blockTime: match.announcement.blockTime ?? 0,
525
+ tokenId: match.tokenId,
526
+ }));
527
+ }
528
+ /**
529
+ * Scan unified StealthAnnouncement notes (both deposits and transfers).
530
+ *
531
+ * For each announcement:
532
+ * - type=0 (deposit): amount is plaintext u64 LE in amount_bytes
533
+ * - type=1 (transfer): amount is XOR-encrypted in amount_bytes
534
+ *
535
+ * Commitment is computed locally: Poseidon(npk, tokenId, amount) and compared
536
+ * against the on-chain one, which is what proves the note is ours.
537
+ */
538
+ export async function scanUnifiedNotes(source, announcements, tokenId) {
539
+ return scanUnifiedNotesMulti(source, announcements, [tokenId]);
540
+ }
541
+ /**
542
+ * Pack encrypted amount (no y_sign needed for Ed25519 — 32-byte keys, no prefix)
543
+ *
544
+ * Layout: bits 0-63 = encrypted amount (little-endian), bit 64 = 0 (reserved)
545
+ */
546
+ export function packEncryptedAmountWithSign(encryptedAmount, _ySign = false) {
547
+ if (encryptedAmount.length !== 8) {
548
+ throw new Error("Encrypted amount must be 8 bytes");
549
+ }
550
+ let amount = 0n;
551
+ for (let i = 7; i >= 0; i--) {
552
+ amount = (amount << 8n) | BigInt(encryptedAmount[i]);
553
+ }
554
+ // For Ed25519, we don't have a y_sign prefix, but keep the bit for compatibility
555
+ if (_ySign) {
556
+ amount |= (1n << 64n);
557
+ }
558
+ return amount;
559
+ }
560
+ /**
561
+ * Convert StealthOutputData to circuit-ready format
562
+ */
563
+ export function packStealthOutputForCircuit(output) {
564
+ // Ed25519 ephemeral pub is 32 bytes — interpret as big-endian bigint
565
+ const ephemeralPubX = bytesToBigint(output.ephemeralPub);
566
+ const encryptedAmountWithSign = packEncryptedAmountWithSign(output.encryptedAmount);
567
+ return {
568
+ ephemeralPubX,
569
+ encryptedAmountWithSign,
570
+ };
571
+ }
572
+ /**
573
+ * Unpack encrypted amount from packed Field element
574
+ */
575
+ export function unpackEncryptedAmountWithSign(packed) {
576
+ const ySign = (packed & (1n << 64n)) !== 0n;
577
+ const amount = packed & ((1n << 64n) - 1n);
578
+ const encryptedAmount = new Uint8Array(8);
579
+ let temp = amount;
580
+ for (let i = 0; i < 8; i++) {
581
+ encryptedAmount[i] = Number(temp & 0xffn);
582
+ temp >>= 8n;
583
+ }
584
+ return { encryptedAmount, ySign };
585
+ }
586
+ /**
587
+ * Create stealth output data for a self-send (change output)
588
+ */
589
+ export async function createStealthOutput(keys, amountSats, tokenId) {
590
+ const ephemeral = ed25519GenerateKeyPair();
591
+ const sharedSecret = x25519Ecdh(ephemeral.privKey, keys.viewingPubKey);
592
+ const stealthScalar = deriveStealthScalar(sharedSecret);
593
+ const mpk = computeMPKSync(keys.spendingPubKey.x, keys.spendingPubKey.y, keys.nullifyingKey);
594
+ const npk = computeNPKSync(mpk, stealthScalar);
595
+ const commitmentBigint = computeJoinSplitCommitmentSync(npk, tokenId, amountSats);
596
+ const commitment = bigintToBytes(commitmentBigint);
597
+ const encryptedAmount = encryptAmount(amountSats, sharedSecret);
598
+ return {
599
+ ephemeralPub: new Uint8Array(ephemeral.pubKey),
600
+ encryptedAmount,
601
+ commitment,
602
+ };
603
+ }
604
+ /**
605
+ * Create stealth output with npk for JoinSplit circuit input
606
+ */
607
+ export async function createStealthOutputWithKeys(keys, amountSats, tokenId) {
608
+ const ephemeral = ed25519GenerateKeyPair();
609
+ const sharedSecret = x25519Ecdh(ephemeral.privKey, keys.viewingPubKey);
610
+ const stealthScalar = deriveStealthScalar(sharedSecret);
611
+ const mpk = computeMPKSync(keys.spendingPubKey.x, keys.spendingPubKey.y, keys.nullifyingKey);
612
+ const npk = computeNPKSync(mpk, stealthScalar);
613
+ const commitmentBigint = computeJoinSplitCommitmentSync(npk, tokenId, amountSats);
614
+ const commitment = bigintToBytes(commitmentBigint);
615
+ const encryptedAmount = encryptAmount(amountSats, sharedSecret);
616
+ return {
617
+ ephemeralPub: new Uint8Array(ephemeral.pubKey),
618
+ encryptedAmount,
619
+ commitment,
620
+ stealthPubKeyX: npk,
621
+ npkBytes: bigintToBytes(npk),
622
+ };
623
+ }
624
+ /**
625
+ * Create stealth output data with pre-computed commitment
626
+ */
627
+ export async function createStealthOutputForCommitment(keys, amountSats, existingCommitment) {
628
+ const ephemeral = ed25519GenerateKeyPair();
629
+ const sharedSecret = x25519Ecdh(ephemeral.privKey, keys.viewingPubKey);
630
+ const encryptedAmount = encryptAmount(amountSats, sharedSecret);
631
+ return {
632
+ ephemeralPub: new Uint8Array(ephemeral.pubKey),
633
+ encryptedAmount,
634
+ commitment: existingCommitment,
635
+ };
636
+ }
637
+ // ========== Nullifier Computation ==========
638
+ /**
639
+ * Compute nullifier hash for a scanned note
640
+ */
641
+ export function computeNullifierHashForNote(keys, note) {
642
+ // In JoinSplit model, nullifier = Poseidon(nullifyingKey, leafIndex)
643
+ // No extra hash layer — the nullifier IS the public output
644
+ const nullifier = computeJoinSplitNullifierSync(keys.nullifyingKey, BigInt(note.leafIndex));
645
+ return bigintToBytes(nullifier);
646
+ }
647
+ /**
648
+ * Compute nullifier hash for a note and return as raw bytes.
649
+ * Convenience wrapper — avoids importing computeJoinSplitNullifierSync + bigintToBytes in consumers.
650
+ */
651
+ export function computeNullifierBytes(nullifyingKey, leafIndex) {
652
+ const nullifier = computeJoinSplitNullifierSync(nullifyingKey, BigInt(leafIndex));
653
+ return bigintToBytes(nullifier);
654
+ }
655
+ // ========== Announcement Parsing ==========
656
+ /**
657
+ * Parse backend announcement rows (hex strings) into the format scanUnifiedNotes expects.
658
+ */
659
+ export function parseAnnouncementsFromHex(rows) {
660
+ return rows.map((r) => ({
661
+ announcementType: r.announcement_type,
662
+ ephemeralPub: hexToBytes(r.ephemeral_pub),
663
+ encryptedAmount: hexToBytes(r.encrypted_amount),
664
+ commitment: hexToBytes(r.commitment),
665
+ leafIndex: r.leaf_index,
666
+ tokenIdHex: r.token_id ?? undefined,
667
+ }));
668
+ }
669
+ // ========== Deposit Ownership Check ==========
670
+ /**
671
+ * Check if a deposit (identified by its OP_RETURN ephemeralPub + npk) belongs
672
+ * to the given viewing key holder.
673
+ *
674
+ * Performs X25519 ECDH between the viewer's private key and the deposit's
675
+ * ephemeral public key, derives the expected NPK, and compares it with the
676
+ * deposit's actual NPK.
677
+ */
678
+ export function isDepositForViewer(viewingPrivKey, spendingPubKey, nullifyingKey, ephemeralPub, depositNpk) {
679
+ try {
680
+ const sharedSecret = x25519Ecdh(viewingPrivKey, ephemeralPub);
681
+ const mpk = computeMPKSync(spendingPubKey.x, spendingPubKey.y, nullifyingKey);
682
+ const stealthScalar = deriveStealthScalar(sharedSecret);
683
+ const expectedNpk = computeNPKSync(mpk, stealthScalar);
684
+ return expectedNpk === depositNpk;
685
+ }
686
+ catch {
687
+ return false;
688
+ }
689
+ }
690
+ /**
691
+ * Check if a deposit belongs to this viewer — accepts hex string inputs.
692
+ * Convenience wrapper around isDepositForViewer for frontend use.
693
+ */
694
+ export function isDepositForViewerHex(keys, ephemeralPubHex, npkHex) {
695
+ try {
696
+ const ephPub = hexToBytes(ephemeralPubHex);
697
+ const npk = bytesToBigint(hexToBytes(npkHex));
698
+ return isDepositForViewer(keys.viewingPrivKey, keys.spendingPubKey, keys.nullifyingKey, ephPub, npk);
699
+ }
700
+ catch {
701
+ return false;
702
+ }
703
+ }