@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/stealth.ts ADDED
@@ -0,0 +1,1477 @@
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
+
35
+ // ========== Constants (defined before imports to ensure availability) ==========
36
+
37
+ /** Announcement type: deposit (plaintext amount) */
38
+ export const ANNOUNCEMENT_TYPE_DEPOSIT = 0;
39
+
40
+ /** Announcement type: transfer (XOR-encrypted amount) */
41
+ export const ANNOUNCEMENT_TYPE_TRANSFER = 1;
42
+
43
+ function isKnownAnnouncementType(value: number): boolean {
44
+ return value === ANNOUNCEMENT_TYPE_DEPOSIT || value === ANNOUNCEMENT_TYPE_TRANSFER;
45
+ }
46
+
47
+ // ========== Imports ==========
48
+
49
+ import { sha256 } from "@noble/hashes/sha2.js";
50
+ import {
51
+ bigintToBytes,
52
+ bytesToBigint,
53
+ bytesToHex,
54
+ hexToBytes,
55
+ BN254_FIELD_PRIME,
56
+ babyJubMul,
57
+ babyJubAdd,
58
+ babyJubCompress,
59
+ babyJubDecompress,
60
+ BABYJUB_BASE8,
61
+ BABYJUB_ORDER,
62
+ scalarFromBytes,
63
+ type BabyJubPoint,
64
+ } from "./crypto";
65
+ import {
66
+ ed25519GenerateKeyPair,
67
+ ed25519KeyPairFromMaterial,
68
+ x25519Ecdh,
69
+ encryptAmountEd25519,
70
+ decryptAmountEd25519,
71
+ } from "./crypto-ed25519";
72
+ import type { StealthMetaAddress, UTXOpiaKeys, WalletSignerAdapter } from "./keys";
73
+ import { deriveKeysFromWallet, parseStealthMetaAddress, constantTimeCompare } from "./keys";
74
+ import {
75
+ poseidonHashSync,
76
+ computeNullifierSync as poseidonComputeNullifier,
77
+ computeMPKSync,
78
+ computeNPKSync,
79
+ computeJoinSplitCommitmentSync,
80
+ computeJoinSplitNullifierSync,
81
+ } from "./poseidon";
82
+ import { getConfig } from "./config";
83
+ import { deriveRawXOnlyP2TRAddress } from "./bitcoin/ika";
84
+ import type { DepositOpReturnContext } from "./taproot";
85
+
86
+ // ========== Amount Encryption Helpers ==========
87
+
88
+ /**
89
+ * Encrypt amount using XOR with shared secret
90
+ */
91
+ export function encryptAmount(amount: bigint, sharedSecret: Uint8Array): Uint8Array {
92
+ return encryptAmountEd25519(amount, sharedSecret);
93
+ }
94
+
95
+ /**
96
+ * Decrypt amount using XOR with shared secret
97
+ */
98
+ export function decryptAmount(encryptedAmount: Uint8Array, sharedSecret: Uint8Array): bigint {
99
+ return decryptAmountEd25519(encryptedAmount, sharedSecret);
100
+ }
101
+
102
+ // Re-export combined note data encryption
103
+ export { encryptNoteData, decryptNoteData } from "./crypto-ed25519";
104
+
105
+ // ========== Type Guard ==========
106
+
107
+ /**
108
+ * Type guard to distinguish between WalletSignerAdapter and UTXOpiaKeys
109
+ */
110
+ export function isWalletAdapter(source: unknown): source is WalletSignerAdapter {
111
+ return (
112
+ typeof source === "object" &&
113
+ source !== null &&
114
+ "signMessage" in source &&
115
+ typeof (source as WalletSignerAdapter).signMessage === "function"
116
+ );
117
+ }
118
+
119
+ // ========== Types ==========
120
+
121
+ /**
122
+ * Stealth Deposit with single Ed25519 ephemeral key
123
+ */
124
+ export interface StealthDeposit {
125
+ /** Ed25519 ephemeral public key (32 bytes) */
126
+ ephemeralPub: Uint8Array;
127
+
128
+ /** Encrypted amount (8 bytes) */
129
+ encryptedAmount: Uint8Array;
130
+
131
+ /** Commitment for Merkle tree (32 bytes) - Poseidon(stealthPub.x, amount) */
132
+ commitment: Uint8Array;
133
+
134
+ /** Unix timestamp when created */
135
+ createdAt: number;
136
+ }
137
+
138
+ /**
139
+ * Scanned note from announcement (viewing key can detect)
140
+ */
141
+ export interface ScannedNote {
142
+ /** Amount in satoshis */
143
+ amount: bigint;
144
+
145
+ /** Ed25519 ephemeral public key (needed for shared secret) */
146
+ ephemeralPub: Uint8Array;
147
+
148
+ /** Computed stealth public key (Baby Jubjub) */
149
+ stealthPub: BabyJubPoint;
150
+
151
+ /** Leaf index in Merkle tree */
152
+ leafIndex: number;
153
+
154
+ /** Original announcement commitment */
155
+ commitment: Uint8Array;
156
+
157
+ /** Unix timestamp (seconds) from on-chain block_time, 0 if unavailable */
158
+ blockTime?: number;
159
+ }
160
+
161
+ /**
162
+ * Prepared claim inputs for JoinSplit ZK proof (requires spending key)
163
+ */
164
+ export interface ClaimInputs {
165
+ stealthPrivKey: bigint;
166
+ nullifyingKey: bigint;
167
+ amount: bigint;
168
+ leafIndex: number;
169
+ merklePath: bigint[];
170
+ merkleIndices: number[];
171
+ merkleRoot: bigint;
172
+ nullifier: bigint;
173
+ npk: bigint;
174
+ random: bigint;
175
+ }
176
+
177
+ // ========== On-chain Announcement ==========
178
+
179
+ /**
180
+ * Parsed stealth announcement from on-chain data
181
+ */
182
+ export interface OnChainStealthAnnouncement {
183
+ /** 0 = deposit (plaintext amount), 1 = transfer (encrypted amount) */
184
+ announcementType: number;
185
+ ephemeralPub: Uint8Array;
186
+ /** Raw amount bytes: plaintext if type=0, encrypted if type=1 */
187
+ encryptedAmount: Uint8Array;
188
+ /** Commitment = Poseidon(npk, token, amount) stored on-chain */
189
+ commitment: Uint8Array;
190
+ leafIndex: number;
191
+ /** Unix timestamp (seconds) from on-chain block_time, 0 if unavailable */
192
+ blockTime?: number;
193
+ /** Solana slot the announcement was emitted in. Needed for auditor slot-range scoping. */
194
+ slot?: number;
195
+ /** Token id hex from the backend indexer, when available. */
196
+ tokenIdHex?: string;
197
+ }
198
+
199
+ // ========== Helper Functions ==========
200
+
201
+ /** Domain separator for stealth key derivation.
202
+ * "Utxopia-stealth-v1" is LOAD-BEARING — once real stealth addresses exist this
203
+ * exact byte sequence is FROZEN; sender and receiver must use the identical
204
+ * value or payments won't be detected. Bump the suffix only as a deliberate
205
+ * v2 break. */
206
+ const STEALTH_KEY_DOMAIN = new TextEncoder().encode("Utxopia-stealth-v1");
207
+
208
+ // tokenId removed — use computeTokenId(mintBytes) from poseidon.ts instead
209
+
210
+ /**
211
+ * Derive stealth scalar from X25519 shared secret
212
+ *
213
+ * stealthScalar = SHA256(sharedSecret || domain) mod BJJ_ORDER
214
+ */
215
+ function deriveStealthScalar(sharedSecret: Uint8Array): bigint {
216
+ const hashInput = new Uint8Array(sharedSecret.length + STEALTH_KEY_DOMAIN.length);
217
+ hashInput.set(sharedSecret, 0);
218
+ hashInput.set(STEALTH_KEY_DOMAIN, sharedSecret.length);
219
+
220
+ const hash = sha256(hashInput);
221
+ return scalarFromBytes(hash);
222
+ }
223
+
224
+ /**
225
+ * Derive stealth public key (Baby Jubjub)
226
+ *
227
+ * stealthPub = spendingPub + stealthScalar × BASE8
228
+ */
229
+ function deriveStealthPubKey(
230
+ spendingPub: BabyJubPoint,
231
+ sharedSecret: Uint8Array
232
+ ): BabyJubPoint {
233
+ const scalar = deriveStealthScalar(sharedSecret);
234
+ const scalarPoint = babyJubMul(scalar, BABYJUB_BASE8);
235
+ return babyJubAdd(spendingPub, scalarPoint);
236
+ }
237
+
238
+ /**
239
+ * Derive stealth private key (Baby Jubjub scalar addition)
240
+ *
241
+ * stealthPriv = spendingPriv + stealthScalar (mod BJJ_ORDER)
242
+ */
243
+ function deriveStealthPrivKey(
244
+ spendingPriv: bigint,
245
+ sharedSecret: Uint8Array
246
+ ): bigint {
247
+ const scalar = deriveStealthScalar(sharedSecret);
248
+ return (spendingPriv + scalar) % BABYJUB_ORDER;
249
+ }
250
+
251
+ // ========== Sender Functions ==========
252
+
253
+ /**
254
+ * Create a stealth deposit (JoinSplit-compatible)
255
+ *
256
+ * 1. Generate Ed25519 ephemeral keypair
257
+ * 2. sharedSecret = X25519(ephemeral.priv, viewingPub)
258
+ * 3. stealthPub = spendingPub + hash(sharedSecret) × BASE8
259
+ * 4. stealthMPK = Poseidon(stealthPub.x, stealthPub.y, nullifyingKey)
260
+ * (sender uses recipientMPK from meta-address for stealth deposits)
261
+ * 5. npk = Poseidon(recipientMPK, random)
262
+ * 6. commitment = Poseidon(npk, tokenId, amount)
263
+ * 7. encryptedAmount = amount XOR sha256(sharedSecret)[0..8]
264
+ */
265
+ export async function createStealthDeposit(
266
+ recipientMeta: StealthMetaAddress,
267
+ amountSats: bigint,
268
+ tokenId: bigint,
269
+ outgoing?: OutgoingRecoveryMaterial,
270
+ ): Promise<StealthDeposit> {
271
+ // Only viewingPubKey + mpk needed (spendingPubKey not used by sender)
272
+ const viewingPubKey = new Uint8Array(recipientMeta.viewingPubKey);
273
+
274
+ // Indexed off the sender's outgoing node when supplied, so they can recompute
275
+ // this payment later; random otherwise, which works but leaves no trace the
276
+ // sender can recover. Optional rather than required because the cost of
277
+ // omitting it is a lost record, not lost funds — unlike a deposit address,
278
+ // where the ephemeral key IS the only way to ever spend the coins.
279
+ const ephemeral = outgoing
280
+ ? outgoingEphemeralKeyPair(outgoing)
281
+ : ed25519GenerateKeyPair();
282
+
283
+ // X25519 ECDH: shared secret
284
+ const sharedSecret = x25519Ecdh(ephemeral.privKey, viewingPubKey);
285
+
286
+ // Derive stealth scalar as the random value for NPK
287
+ const stealthScalar = deriveStealthScalar(sharedSecret);
288
+
289
+ // Use recipient's MPK from meta-address to compute NPK
290
+ const recipientMPK = bytesToBigint(recipientMeta.mpk);
291
+ const npk = computeNPKSync(recipientMPK, stealthScalar);
292
+
293
+ // Compute JoinSplit commitment = Poseidon(npk, token, amount)
294
+ const commitmentBigint = computeJoinSplitCommitmentSync(npk, tokenId, amountSats);
295
+ const commitment = bigintToBytes(commitmentBigint);
296
+
297
+ // Encrypt amount
298
+ const encryptedAmount = encryptAmount(amountSats, sharedSecret);
299
+
300
+ return {
301
+ ephemeralPub: new Uint8Array(ephemeral.pubKey),
302
+ encryptedAmount,
303
+ commitment,
304
+ createdAt: Date.now(),
305
+ };
306
+ }
307
+
308
+ /**
309
+ * Extended stealth output data including the derived stealth pub key
310
+ */
311
+ export interface StealthOutputWithKeys extends StealthOutputData {
312
+ stealthPubKeyX: bigint;
313
+ /** npk as 32-byte LE Uint8Array — ready for on-chain instruction data */
314
+ npkBytes: Uint8Array;
315
+ }
316
+
317
+ /**
318
+ * Create stealth deposit with npk for JoinSplit circuit input
319
+ */
320
+ export async function createStealthDepositWithKeys(
321
+ recipientMeta: StealthMetaAddress,
322
+ amountSats: bigint,
323
+ tokenId: bigint,
324
+ ): Promise<StealthOutputWithKeys> {
325
+ // Only viewingPubKey + mpk needed (spendingPubKey not used by sender)
326
+ const viewingPubKey = new Uint8Array(recipientMeta.viewingPubKey);
327
+
328
+ const ephemeral = ed25519GenerateKeyPair();
329
+ const sharedSecret = x25519Ecdh(ephemeral.privKey, viewingPubKey);
330
+
331
+ const stealthScalar = deriveStealthScalar(sharedSecret);
332
+ const recipientMPK = bytesToBigint(recipientMeta.mpk);
333
+ const npk = computeNPKSync(recipientMPK, stealthScalar);
334
+
335
+ const commitmentBigint = computeJoinSplitCommitmentSync(npk, tokenId, amountSats);
336
+ const commitment = bigintToBytes(commitmentBigint);
337
+ const encryptedAmount = encryptAmount(amountSats, sharedSecret);
338
+
339
+ return {
340
+ ephemeralPub: new Uint8Array(ephemeral.pubKey),
341
+ encryptedAmount,
342
+ commitment,
343
+ stealthPubKeyX: npk,
344
+ npkBytes: bigintToBytes(npk),
345
+ };
346
+ }
347
+
348
+ // ========== Non-Interactive Deposit (OP_RETURN) ==========
349
+
350
+ /**
351
+ * Result of a non-interactive deposit preparation.
352
+ * Contains everything needed to build a PSBT with an OP_RETURN output.
353
+ *
354
+ * npk-based flow: user can send any amount of BTC. The commitment is
355
+ * computed on-chain from npk + actual amount.
356
+ */
357
+ export interface NonInteractiveDepositResult {
358
+ /** Taproot address to send BTC to */
359
+ btcAddress: string;
360
+ /** 32-byte x-only output key for the deposit P2TR output */
361
+ depositOutputKey: Uint8Array;
362
+ /** 73-byte OP_RETURN payload: header || poolTag || ephemeralPub || npk */
363
+ opReturnPayload: Uint8Array;
364
+ /** 32-byte note public key (for tracking) */
365
+ npk: Uint8Array;
366
+ /** 32-byte Ed25519 ephemeral public key */
367
+ ephemeralPub: Uint8Array;
368
+ }
369
+
370
+ /**
371
+ * Extended result when a user refund pubkey is provided.
372
+ * Includes Taproot script-path data for the refund spending path.
373
+ */
374
+ export interface NonInteractiveDepositWithRefundResult extends NonInteractiveDepositResult {
375
+ /** 32-byte Merkle root (TapLeaf hash of the refund script) */
376
+ merkleRoot: Uint8Array;
377
+ /** 33-byte control block for script-path spend (leaf_version|parity + internal_key) */
378
+ controlBlock: Uint8Array;
379
+ /** 73-byte refund script */
380
+ refundScript: Uint8Array;
381
+ }
382
+
383
+ /**
384
+ * Create a non-interactive stealth deposit (npk-based).
385
+ *
386
+ * This is the client-side-only deposit flow: no backend API call needed.
387
+ * The ephemeral key and npk are embedded in the BTC transaction's OP_RETURN
388
+ * output so the backend can passively detect them.
389
+ *
390
+ * The user can send ANY amount of BTC — the commitment is computed on-chain
391
+ * from the npk + actual BTC amount received.
392
+ *
393
+ * When `userRefundPubkey` is provided, the Taproot address includes a
394
+ * script-path with a time-locked refund spending condition (144 blocks).
395
+ *
396
+ * @param recipientMeta - Recipient's stealth meta-address
397
+ * @param custodyInternalKey - Pool custody x-only pubkey (32 bytes), used as the Taproot internal key
398
+ * @param network - Bitcoin network for address encoding
399
+ * @param userRefundPubkey - Optional 32-byte x-only pubkey for refund script path
400
+ */
401
+ export async function createNonInteractiveDeposit(
402
+ recipientMeta: StealthMetaAddress,
403
+ custodyInternalKey: Uint8Array,
404
+ network?: "mainnet" | "testnet" | "regtest",
405
+ userRefundPubkey?: undefined,
406
+ opReturnContext?: DepositOpReturnContext,
407
+ ): Promise<NonInteractiveDepositResult>;
408
+ export async function createNonInteractiveDeposit(
409
+ recipientMeta: StealthMetaAddress,
410
+ custodyInternalKey: Uint8Array,
411
+ network: "mainnet" | "testnet" | "regtest",
412
+ userRefundPubkey: Uint8Array,
413
+ opReturnContext: DepositOpReturnContext,
414
+ ): Promise<NonInteractiveDepositWithRefundResult>;
415
+ export async function createNonInteractiveDeposit(
416
+ recipientMeta: StealthMetaAddress,
417
+ custodyInternalKey: Uint8Array,
418
+ network: "mainnet" | "testnet" | "regtest" = "testnet",
419
+ userRefundPubkey?: Uint8Array,
420
+ opReturnContext?: DepositOpReturnContext,
421
+ ): Promise<NonInteractiveDepositResult | NonInteractiveDepositWithRefundResult> {
422
+ if (!opReturnContext) {
423
+ throw new Error("deposit OP_RETURN context is required");
424
+ }
425
+ // Only viewingPubKey + mpk needed (spendingPubKey not used by sender)
426
+ const viewingPubKey = new Uint8Array(recipientMeta.viewingPubKey);
427
+
428
+ // 1. Generate ephemeral Ed25519 keypair
429
+ const ephemeral = ed25519GenerateKeyPair();
430
+
431
+ // 2. X25519 ECDH shared secret
432
+ const sharedSecret = x25519Ecdh(ephemeral.privKey, viewingPubKey);
433
+
434
+ // 3. Derive stealth scalar → NPK (no commitment — computed on-chain)
435
+ const stealthScalar = deriveStealthScalar(sharedSecret);
436
+ const recipientMPK = bytesToBigint(recipientMeta.mpk);
437
+ const npkBigint = computeNPKSync(recipientMPK, stealthScalar);
438
+ const npk = bigintToBytes(npkBigint);
439
+
440
+ const ephemeralPub = new Uint8Array(ephemeral.pubKey);
441
+
442
+ if (userRefundPubkey) {
443
+ // Refund path: Taproot with script tree containing refund script
444
+ const { deriveTaprootAddressWithRefund, buildDepositOpReturn } = await import("./taproot");
445
+ const {
446
+ address: btcAddress,
447
+ outputKey,
448
+ merkleRoot,
449
+ controlBlock,
450
+ refundScript,
451
+ } = deriveTaprootAddressWithRefund(npk, userRefundPubkey, custodyInternalKey, network);
452
+
453
+ // Still build OP_RETURN so the backend can detect the deposit
454
+ const opReturnPayload = buildDepositOpReturn(ephemeralPub, npk, opReturnContext);
455
+
456
+ return {
457
+ btcAddress,
458
+ depositOutputKey: outputKey,
459
+ opReturnPayload,
460
+ npk,
461
+ ephemeralPub,
462
+ merkleRoot,
463
+ controlBlock,
464
+ refundScript,
465
+ };
466
+ }
467
+
468
+ // Standard path: key-path-only Taproot address
469
+ const { deriveTaprootAddress, buildDepositOpReturn } = await import("./taproot");
470
+ const { address: btcAddress, outputKey } = deriveTaprootAddress(npk, network, custodyInternalKey);
471
+
472
+ const opReturnPayload = buildDepositOpReturn(ephemeralPub, npk, opReturnContext);
473
+
474
+ return {
475
+ btcAddress,
476
+ depositOutputKey: outputKey,
477
+ opReturnPayload,
478
+ npk,
479
+ ephemeralPub,
480
+ };
481
+ }
482
+
483
+ /**
484
+ * Create a non-interactive deposit directly to an Ika-controlled vault.
485
+ *
486
+ * The BTC address is the raw Ika x-only Taproot witness program, so Ika can
487
+ * later sign and spend the UTXO. Privacy/ownership metadata stays per-deposit
488
+ * in OP_RETURN(header || poolTag || ephemeralPub || npk), and the destination chain
489
+ * credits the note from that transaction.
490
+ */
491
+ export async function createDirectVaultDeposit(
492
+ recipientMeta: StealthMetaAddress,
493
+ vaultXOnlyPubkey: Uint8Array,
494
+ network: "mainnet" | "testnet" | "regtest" = "testnet",
495
+ opReturnContext?: DepositOpReturnContext,
496
+ ): Promise<NonInteractiveDepositResult> {
497
+ if (!opReturnContext) {
498
+ throw new Error("deposit OP_RETURN context is required");
499
+ }
500
+ if (vaultXOnlyPubkey.length !== 32) {
501
+ throw new Error("vaultXOnlyPubkey must be 32 bytes");
502
+ }
503
+
504
+ const viewingPubKey = new Uint8Array(recipientMeta.viewingPubKey);
505
+ const ephemeral = ed25519GenerateKeyPair();
506
+ const sharedSecret = x25519Ecdh(ephemeral.privKey, viewingPubKey);
507
+ const stealthScalar = deriveStealthScalar(sharedSecret);
508
+ const recipientMPK = bytesToBigint(recipientMeta.mpk);
509
+ const npkBigint = computeNPKSync(recipientMPK, stealthScalar);
510
+ const npk = bigintToBytes(npkBigint);
511
+ const ephemeralPub = new Uint8Array(ephemeral.pubKey);
512
+
513
+ const { buildDepositOpReturn } = await import("./taproot");
514
+ const opReturnPayload = buildDepositOpReturn(ephemeralPub, npk, opReturnContext);
515
+
516
+ return {
517
+ btcAddress: deriveRawXOnlyP2TRAddress(vaultXOnlyPubkey, network),
518
+ depositOutputKey: vaultXOnlyPubkey,
519
+ opReturnPayload,
520
+ npk,
521
+ ephemeralPub,
522
+ };
523
+ }
524
+
525
+ const DEPOSIT_VIEWING_NODE_DOMAIN = new TextEncoder().encode("utxopia:deposit-viewing-node:v1");
526
+ const DEPOSIT_EPHEMERAL_DOMAIN = new TextEncoder().encode("utxopia:deposit-ephemeral:v1");
527
+
528
+ /**
529
+ * The delegable half of deposit recovery.
530
+ *
531
+ * Deposit addresses are derived from this node, so whoever holds it can rebuild
532
+ * every address the owner was ever handed, and with it every tapleaf and control
533
+ * block needed to spend those coins. It grants no spend authority over notes:
534
+ * that needs the spending key and the nullifying key, neither of which is
535
+ * derivable from here.
536
+ *
537
+ * Derived from the viewing key rather than the master seed on purpose. It means
538
+ * an owner can hand a chosen party — another device, a custodian, the pool
539
+ * operator — the ability to recover their BTC without handing over the ability
540
+ * to spend their notes. Same split Fluidkey uses for its ephemeral key nodes.
541
+ */
542
+ export function depositViewingNode(viewingPrivKey: Uint8Array): Uint8Array {
543
+ if (viewingPrivKey.length === 0) {
544
+ throw new Error("viewingPrivKey must not be empty");
545
+ }
546
+ const material = new Uint8Array(DEPOSIT_VIEWING_NODE_DOMAIN.length + viewingPrivKey.length);
547
+ material.set(DEPOSIT_VIEWING_NODE_DOMAIN, 0);
548
+ material.set(viewingPrivKey, DEPOSIT_VIEWING_NODE_DOMAIN.length);
549
+ return sha256(material);
550
+ }
551
+
552
+ const OUTGOING_VIEWING_NODE_DOMAIN = new TextEncoder().encode(
553
+ "utxopia:outgoing-viewing-node:v1",
554
+ );
555
+ const OUTGOING_EPHEMERAL_DOMAIN = new TextEncoder().encode("utxopia:outgoing-ephemeral:v1");
556
+
557
+ /**
558
+ * The delegable half of *outgoing* history.
559
+ *
560
+ * An announcement is encrypted to the recipient's viewing key, and the sender
561
+ * discards the ephemeral private key — so today a sender cannot rediscover what
562
+ * they paid out. Their history exists only in local storage. Indexing the
563
+ * ephemeral key off this node makes every outgoing payment recomputable from
564
+ * keys alone. Zcash calls the equivalent an outgoing viewing key.
565
+ *
566
+ * Deliberately a different node from `depositViewingNode`. The two authorise
567
+ * different things, and one should not smuggle in the other:
568
+ *
569
+ * - deposit node → "you can recover my BTC"
570
+ * - outgoing node → "you can see who I paid"
571
+ */
572
+ export function outgoingViewingNode(viewingPrivKey: Uint8Array): Uint8Array {
573
+ if (viewingPrivKey.length === 0) {
574
+ throw new Error("viewingPrivKey must not be empty");
575
+ }
576
+ const material = new Uint8Array(OUTGOING_VIEWING_NODE_DOMAIN.length + viewingPrivKey.length);
577
+ material.set(OUTGOING_VIEWING_NODE_DOMAIN, 0);
578
+ material.set(viewingPrivKey, OUTGOING_VIEWING_NODE_DOMAIN.length);
579
+ return sha256(material);
580
+ }
581
+
582
+ /** Which outgoing payment an ephemeral key belongs to. */
583
+ export interface OutgoingRecoveryMaterial {
584
+ /** From `outgoingViewingNode(viewingPrivKey)`. */
585
+ outgoingNode: Uint8Array;
586
+ /** Monotonic per-sender counter. One payment per index. */
587
+ sendIndex: number;
588
+ }
589
+
590
+ /** `sha256(domain || outgoingNode || sendIndex)` → Ed25519 ephemeral keypair. */
591
+ export function outgoingEphemeralKeyPair(outgoing: OutgoingRecoveryMaterial): {
592
+ privKey: Uint8Array;
593
+ pubKey: Uint8Array;
594
+ } {
595
+ const { outgoingNode, sendIndex } = outgoing;
596
+ if (outgoingNode.length === 0) {
597
+ throw new Error("outgoingNode must not be empty");
598
+ }
599
+ if (!Number.isInteger(sendIndex) || sendIndex < 0) {
600
+ throw new Error(`invalid sendIndex: ${sendIndex}`);
601
+ }
602
+
603
+ const material = new Uint8Array(OUTGOING_EPHEMERAL_DOMAIN.length + outgoingNode.length + 4);
604
+ material.set(OUTGOING_EPHEMERAL_DOMAIN, 0);
605
+ material.set(outgoingNode, OUTGOING_EPHEMERAL_DOMAIN.length);
606
+ new DataView(material.buffer).setUint32(
607
+ OUTGOING_EPHEMERAL_DOMAIN.length + outgoingNode.length,
608
+ sendIndex,
609
+ true,
610
+ );
611
+ return ed25519KeyPairFromMaterial(material);
612
+ }
613
+
614
+ /**
615
+ * Recover the next unused send index by walking 0 upward against the ephemeral
616
+ * pubkeys already on chain.
617
+ *
618
+ * An index is "used" when some announcement carries the ephemeral pubkey it
619
+ * derives. Scanning stops after `gapLimit` consecutive misses, because a
620
+ * derived-but-never-broadcast payment leaves a hole — an abandoned or failed
621
+ * transaction — and stopping at the first hole would hand back an index that is
622
+ * already spoken for.
623
+ *
624
+ * This is a FLOOR, not the live counter. A payment broadcast but not yet indexed
625
+ * is invisible here, so a sender that keeps local state must take
626
+ * `max(localCounter, findNextSendIndex(...))`. Reusing an index re-derives the
627
+ * same ephemeral key, and to the same recipient that means the same note
628
+ * commitment twice.
629
+ */
630
+ export function findNextSendIndex(
631
+ outgoingNode: Uint8Array,
632
+ seenEphemeralPubs: Iterable<Uint8Array>,
633
+ gapLimit = 20,
634
+ ): number {
635
+ const seen = new Set<string>();
636
+ for (const pub of seenEphemeralPubs) seen.add(bytesToHex(pub));
637
+
638
+ let highestUsed = -1;
639
+ let misses = 0;
640
+ for (let index = 0; misses <= gapLimit; index++) {
641
+ if (seen.has(bytesToHex(outgoingEphemeralKeyPair({ outgoingNode, sendIndex: index }).pubKey))) {
642
+ highestUsed = index;
643
+ misses = 0;
644
+ } else {
645
+ misses++;
646
+ }
647
+ }
648
+ return highestUsed + 1;
649
+ }
650
+
651
+ /**
652
+ * What makes a deposit address reconstructable.
653
+ *
654
+ * The ephemeral key is NOT random. A deposit address commits to it via the
655
+ * tapleaf, and the key path is a NUMS point, so an address whose ephemeral key
656
+ * is lost is an address nobody — not the owner, not the pool — can ever spend.
657
+ * Indexing it off the viewing node makes that node a complete backup: walk
658
+ * `depositIndex` upward and every address comes back.
659
+ */
660
+ export interface DepositRecoveryMaterial {
661
+ /** From `depositViewingNode(viewingPrivKey)`. */
662
+ viewingNode: Uint8Array;
663
+ /** Monotonic per-owner counter. One address per index. */
664
+ depositIndex: number;
665
+ }
666
+
667
+ /** `sha256(domain || viewingNode || depositIndex)` → Ed25519 ephemeral keypair. */
668
+ export function depositEphemeralKeyPair(recovery: DepositRecoveryMaterial): {
669
+ privKey: Uint8Array;
670
+ pubKey: Uint8Array;
671
+ } {
672
+ const { viewingNode, depositIndex } = recovery;
673
+ if (viewingNode.length === 0) {
674
+ throw new Error("deposit viewingNode must not be empty");
675
+ }
676
+ if (!Number.isInteger(depositIndex) || depositIndex < 0) {
677
+ throw new Error(`invalid depositIndex: ${depositIndex}`);
678
+ }
679
+
680
+ const material = new Uint8Array(DEPOSIT_EPHEMERAL_DOMAIN.length + viewingNode.length + 4);
681
+ material.set(DEPOSIT_EPHEMERAL_DOMAIN, 0);
682
+ material.set(viewingNode, DEPOSIT_EPHEMERAL_DOMAIN.length);
683
+ new DataView(material.buffer).setUint32(
684
+ DEPOSIT_EPHEMERAL_DOMAIN.length + viewingNode.length,
685
+ depositIndex,
686
+ true,
687
+ );
688
+ return ed25519KeyPairFromMaterial(material);
689
+ }
690
+
691
+ /** A deposit whose address alone binds the note keys — no OP_RETURN. */
692
+ export interface TweakDepositResult {
693
+ /** Taproot address to send BTC to */
694
+ btcAddress: string;
695
+ /** 32-byte x-only output key for the deposit P2TR output */
696
+ depositOutputKey: Uint8Array;
697
+ /** 32-byte note public key */
698
+ npk: Uint8Array;
699
+ /** 32-byte Ed25519 ephemeral public key */
700
+ ephemeralPub: Uint8Array;
701
+ /** sha256(npk || ephemeralPub) — the commitment carried in the tapleaf */
702
+ tweakCommitment: Uint8Array;
703
+ /** The tapleaf: `<commitment> OP_DROP <ika_xonly> OP_CHECKSIG` */
704
+ leafScript: Uint8Array;
705
+ /** Its BIP-341 tapleaf hash, which is also the merkle root (single leaf) */
706
+ leafHash: Uint8Array;
707
+ /** Script-path witness is `[signature, leafScript, controlBlock]` */
708
+ controlBlock: Uint8Array;
709
+ }
710
+
711
+ /**
712
+ * Create a deposit for the OP_RETURN-free flow (`verify_deposit`, disc 25).
713
+ *
714
+ * The transaction carries nothing but a payment, so anything that can send to a
715
+ * P2TR address can fund it — a hardware wallet, an exchange withdrawal, a faucet.
716
+ * The note keys are recovered from instruction data at completion time and proven
717
+ * against this address's tapleaf, so substituting either key derives a different
718
+ * leaf, and so a different address that the funding transaction never paid.
719
+ *
720
+ * The address is spendable only by `vaultXOnlyPubkey` via the script path; its
721
+ * key path is a NUMS point. That keeps the deposit under Ika custody from the
722
+ * moment it confirms, and it is also the only shape Ika can sign for — its MPC
723
+ * cannot produce a signature for a tweaked key.
724
+ *
725
+ * `recovery` is not optional on purpose. The address commits to the ephemeral
726
+ * key and the key path is unspendable, so a random ephemeral key that is later
727
+ * lost burns the coins outright. Indexing it off the viewing node means that
728
+ * node is the backup — and it can be delegated without granting spend authority.
729
+ *
730
+ * Register the address with the tracker BEFORE any coins are sent — a deposit
731
+ * with no OP_RETURN is invisible to block scanning, so an unregistered address
732
+ * is one nobody is watching.
733
+ */
734
+ export async function createTweakDeposit(
735
+ recipientMeta: StealthMetaAddress,
736
+ vaultXOnlyPubkey: Uint8Array,
737
+ recovery: DepositRecoveryMaterial,
738
+ network: "mainnet" | "testnet" | "regtest" = "testnet",
739
+ ): Promise<TweakDepositResult> {
740
+ if (vaultXOnlyPubkey.length !== 32) {
741
+ throw new Error("vaultXOnlyPubkey must be 32 bytes");
742
+ }
743
+
744
+ const viewingPubKey = new Uint8Array(recipientMeta.viewingPubKey);
745
+ const ephemeral = depositEphemeralKeyPair(recovery);
746
+ const sharedSecret = x25519Ecdh(ephemeral.privKey, viewingPubKey);
747
+ const stealthScalar = deriveStealthScalar(sharedSecret);
748
+ const recipientMPK = bytesToBigint(recipientMeta.mpk);
749
+ const npk = bigintToBytes(computeNPKSync(recipientMPK, stealthScalar));
750
+ const ephemeralPub = new Uint8Array(ephemeral.pubKey);
751
+
752
+ const { depositTweakCommitment, deriveDepositAddress } = await import("./taproot");
753
+ const tweakCommitment = depositTweakCommitment(npk, ephemeralPub);
754
+ const { address, outputKey, leafScript, leafHash, controlBlock } = deriveDepositAddress(
755
+ tweakCommitment,
756
+ vaultXOnlyPubkey,
757
+ network,
758
+ );
759
+
760
+ return {
761
+ btcAddress: address,
762
+ depositOutputKey: outputKey,
763
+ npk,
764
+ ephemeralPub,
765
+ tweakCommitment,
766
+ leafScript,
767
+ leafHash,
768
+ controlBlock,
769
+ };
770
+ }
771
+
772
+ /**
773
+ * Create a non-interactive deposit using the current SDK config.
774
+ *
775
+ * Direct-vault/Ika deposit helper.
776
+ *
777
+ * Deposits go to the raw Ika x-only P2TR vault address. Recipient binding
778
+ * stays per-deposit in OP_RETURN(header || poolTag || ephemeralPub || npk), and the destination chain
779
+ * credits the note by SPV-verifying that deposit transaction directly. Legacy sweep-mode
780
+ * address derivation is intentionally not selected from config anymore.
781
+ */
782
+ export async function createDepositFromConfig(
783
+ recipientMeta: StealthMetaAddress,
784
+ network: "mainnet" | "testnet" | "regtest" = "testnet",
785
+ opReturnContext?: DepositOpReturnContext,
786
+ ): Promise<NonInteractiveDepositResult> {
787
+ const config = getConfig();
788
+ const ikaKey = pickIkaCustodyKey(config);
789
+ if (!ikaKey) {
790
+ throw new Error("Ika direct-vault deposits require ikaDwalletXOnlyPubkey in config");
791
+ }
792
+ if (config.depositMode && !isDirectVaultDepositMode(config.depositMode)) {
793
+ throw new Error(`Unsupported depositMode "${config.depositMode}"; only Ika direct-vault deposits are supported`);
794
+ }
795
+ return createDirectVaultDeposit(recipientMeta, ikaKey, network, opReturnContext);
796
+ }
797
+
798
+ export function isDirectVaultDepositMode(mode?: string): boolean {
799
+ return mode === "direct" || mode === "direct_vault" || mode === "ika_direct";
800
+ }
801
+
802
+ /**
803
+ * Choose the Taproot internal key for deposit-address derivation.
804
+ * The Ika dWallet x-only pubkey is the sole custody key; throws if unset.
805
+ * Exported for unit tests; non-test callers should use `createDepositFromConfig`.
806
+ */
807
+ export function pickCustodyInternalKey(config: {
808
+ ikaDwalletXOnlyPubkey?: string;
809
+ }): Uint8Array {
810
+ const ikaKey = pickIkaCustodyKey(config);
811
+ if (!ikaKey) {
812
+ throw new Error(
813
+ "PoolConfig.ika_dwallet_xonly_pubkey is required; pool custody key is not configured",
814
+ );
815
+ }
816
+ return ikaKey;
817
+ }
818
+
819
+ export function pickIkaCustodyKey(config: {
820
+ ikaDwalletXOnlyPubkey?: string;
821
+ }): Uint8Array | null {
822
+ const ikaHex = config.ikaDwalletXOnlyPubkey ?? "";
823
+ if (ikaHex && /[1-9a-f]/i.test(ikaHex)) {
824
+ return hexToBytes(ikaHex);
825
+ }
826
+ return null;
827
+ }
828
+
829
+ // ========== Recipient Scanning (Viewing Key Only) ==========
830
+
831
+ /**
832
+ * Scan announcements using viewing key only
833
+ */
834
+ export async function scanAnnouncements(
835
+ source: WalletSignerAdapter | UTXOpiaKeys,
836
+ announcements: {
837
+ ephemeralPub: Uint8Array;
838
+ encryptedAmount: Uint8Array;
839
+ commitment: Uint8Array;
840
+ leafIndex: number;
841
+ }[],
842
+ tokenId: bigint,
843
+ ): Promise<ScannedNote[]> {
844
+ const keys = isWalletAdapter(source) ? await deriveKeysFromWallet(source) : source;
845
+
846
+ const found: ScannedNote[] = [];
847
+ const MAX_SATS = 21_000_000n * 100_000_000n;
848
+
849
+ // Compute MPK for this key set
850
+ const mpk = computeMPKSync(keys.spendingPubKey.x, keys.spendingPubKey.y, keys.nullifyingKey);
851
+
852
+ for (const ann of announcements) {
853
+ try {
854
+ // X25519 ECDH with viewing key
855
+ const sharedSecret = x25519Ecdh(keys.viewingPrivKey, ann.ephemeralPub);
856
+
857
+ // Decrypt amount
858
+ const amount = decryptAmount(ann.encryptedAmount, sharedSecret);
859
+
860
+ if (amount <= 0n || amount > MAX_SATS) {
861
+ continue;
862
+ }
863
+
864
+ // Derive stealth public key (still needed for spending)
865
+ const stealthPub = deriveStealthPubKey(keys.spendingPubKey, sharedSecret);
866
+
867
+ // Derive stealth scalar as random for NPK
868
+ const stealthScalar = deriveStealthScalar(sharedSecret);
869
+
870
+ // Compute expected NPK and commitment (JoinSplit format)
871
+ const npk = computeNPKSync(mpk, stealthScalar);
872
+ const expectedCommitment = computeJoinSplitCommitmentSync(npk, tokenId, amount);
873
+ const actualCommitment = bytesToBigint(ann.commitment);
874
+
875
+ if (expectedCommitment !== actualCommitment) {
876
+ continue;
877
+ }
878
+
879
+ found.push({
880
+ amount,
881
+ ephemeralPub: ann.ephemeralPub,
882
+ stealthPub,
883
+ leafIndex: ann.leafIndex,
884
+ commitment: ann.commitment,
885
+ });
886
+ } catch (error) {
887
+ // Re-throw programming errors; only skip data/crypto mismatches
888
+ if (error instanceof TypeError || error instanceof RangeError) {
889
+ throw error;
890
+ }
891
+ continue;
892
+ }
893
+ }
894
+
895
+ return found;
896
+ }
897
+
898
+ // ========== View-Only Scanning ==========
899
+
900
+ /**
901
+ * View-only keys for scanning without spending capability
902
+ */
903
+ export interface ViewOnlyKeys {
904
+ /** Ed25519 viewing private key (32 bytes) */
905
+ viewingPrivKey: Uint8Array;
906
+ /** Baby Jubjub spending public key */
907
+ spendingPubKey: BabyJubPoint;
908
+ /** Nullifying key (needed for MPK computation in JoinSplit scanning) */
909
+ nullifyingKey: bigint;
910
+ }
911
+
912
+ /**
913
+ * Scanned note from view-only scanning
914
+ */
915
+ export interface ViewOnlyScannedNote {
916
+ amount: bigint;
917
+ leafIndex: number;
918
+ commitment: Uint8Array;
919
+ ephemeralPub: Uint8Array;
920
+ /** Unix timestamp (seconds) from on-chain block_time, 0 if unavailable */
921
+ blockTime?: number;
922
+ }
923
+
924
+ /**
925
+ * Scan announcements with VIEW-ONLY keys against SEVERAL token ids in one pass.
926
+ * Same one-ECDH-per-announcement economics as {@link scanUnifiedNotesMulti}.
927
+ */
928
+ export async function scanAnnouncementsViewOnlyMulti(
929
+ viewOnlyKeys: ViewOnlyKeys,
930
+ announcements: {
931
+ announcementType: number;
932
+ ephemeralPub: Uint8Array;
933
+ encryptedAmount: Uint8Array;
934
+ commitment: Uint8Array;
935
+ leafIndex: number;
936
+ blockTime?: number;
937
+ }[],
938
+ tokenIds: bigint[],
939
+ ): Promise<Array<ViewOnlyScannedNote & { tokenId: bigint }>> {
940
+ return matchAnnouncements(viewOnlyKeys, announcements, tokenIds).map((match) => ({
941
+ amount: match.amount,
942
+ leafIndex: match.announcement.leafIndex,
943
+ commitment: match.commitment,
944
+ ephemeralPub: match.announcement.ephemeralPub,
945
+ blockTime: match.announcement.blockTime ?? 0,
946
+ tokenId: match.tokenId,
947
+ }));
948
+ }
949
+
950
+ /**
951
+ * Scan announcements with VIEW-ONLY keys.
952
+ * Latest announcement rows must carry an explicit type.
953
+ */
954
+ export async function scanAnnouncementsViewOnly(
955
+ viewOnlyKeys: ViewOnlyKeys,
956
+ announcements: {
957
+ announcementType: number;
958
+ ephemeralPub: Uint8Array;
959
+ encryptedAmount: Uint8Array;
960
+ commitment: Uint8Array;
961
+ leafIndex: number;
962
+ blockTime?: number;
963
+ }[],
964
+ tokenId: bigint,
965
+ ): Promise<ViewOnlyScannedNote[]> {
966
+ return scanAnnouncementsViewOnlyMulti(viewOnlyKeys, announcements, [tokenId]);
967
+ }
968
+
969
+ /**
970
+ * Export view-only keys from full UTXOpiaKeys
971
+ */
972
+ export function exportViewOnlyKeys(keys: UTXOpiaKeys): ViewOnlyKeys {
973
+ return {
974
+ viewingPrivKey: keys.viewingPrivKey,
975
+ spendingPubKey: keys.spendingPubKey,
976
+ nullifyingKey: keys.nullifyingKey,
977
+ };
978
+ }
979
+
980
+ /**
981
+ * Encode view-only keys as a hex string for sharing
982
+ * Format: viewingPrivKey(32) + compressedSpendingPub(32) + nullifyingKey(32) = 96 bytes
983
+ */
984
+ export function encodeViewOnlyKeys(keys: ViewOnlyKeys): string {
985
+ const compressed = babyJubCompress(keys.spendingPubKey);
986
+ const nullBytes = bigintToBytes(keys.nullifyingKey);
987
+ const combined = new Uint8Array(96);
988
+ combined.set(keys.viewingPrivKey, 0);
989
+ combined.set(compressed, 32);
990
+ combined.set(nullBytes, 64);
991
+ return bytesToHex(combined);
992
+ }
993
+
994
+ /**
995
+ * Decode view-only keys from a hex string
996
+ */
997
+ export function decodeViewOnlyKeys(encoded: string): ViewOnlyKeys {
998
+ const bytes = hexToBytes(encoded);
999
+ if (bytes.length !== 96) {
1000
+ throw new Error("Invalid view-only key length (expected 96 bytes)");
1001
+ }
1002
+ const viewingPrivKey = bytes.slice(0, 32);
1003
+ const compressed = bytes.slice(32, 64);
1004
+ const spendingPubKey = babyJubDecompress(compressed);
1005
+ const nullifyingKey = bytesToBigint(bytes.slice(64, 96));
1006
+ return { viewingPrivKey, spendingPubKey, nullifyingKey };
1007
+ }
1008
+
1009
+ // ========== Claim Preparation (Spending Key Required) ==========
1010
+
1011
+ /**
1012
+ * Prepare claim inputs for ZK proof generation
1013
+ */
1014
+ export async function prepareClaimInputs(
1015
+ source: WalletSignerAdapter | UTXOpiaKeys,
1016
+ note: ScannedNote,
1017
+ merkleProof: {
1018
+ root: bigint;
1019
+ pathElements: bigint[];
1020
+ pathIndices: number[];
1021
+ }
1022
+ ): Promise<ClaimInputs> {
1023
+ const keys = isWalletAdapter(source) ? await deriveKeysFromWallet(source) : source;
1024
+
1025
+ // X25519 ECDH to recover shared secret
1026
+ const sharedSecret = x25519Ecdh(keys.viewingPrivKey, note.ephemeralPub);
1027
+
1028
+ // Derive stealth private key (Baby Jubjub scalar addition)
1029
+ const stealthPrivKey = deriveStealthPrivKey(keys.spendingPrivKey, sharedSecret);
1030
+
1031
+ // Verify stealth public key matches
1032
+ const expectedStealthPub = babyJubMul(stealthPrivKey, BABYJUB_BASE8);
1033
+ if (expectedStealthPub.x !== note.stealthPub.x || expectedStealthPub.y !== note.stealthPub.y) {
1034
+ throw new Error(
1035
+ "Stealth key mismatch - this note may not belong to you or the announcement is invalid"
1036
+ );
1037
+ }
1038
+
1039
+ // Derive the random value (stealth scalar) for NPK
1040
+ const stealthScalar = deriveStealthScalar(sharedSecret);
1041
+
1042
+ // Compute MPK and NPK
1043
+ const mpk = computeMPKSync(keys.spendingPubKey.x, keys.spendingPubKey.y, keys.nullifyingKey);
1044
+ const npk = computeNPKSync(mpk, stealthScalar);
1045
+
1046
+ // Compute JoinSplit nullifier
1047
+ const nullifier = computeJoinSplitNullifierSync(keys.nullifyingKey, BigInt(note.leafIndex));
1048
+
1049
+ return {
1050
+ stealthPrivKey,
1051
+ nullifyingKey: keys.nullifyingKey,
1052
+ amount: note.amount,
1053
+ leafIndex: note.leafIndex,
1054
+ merklePath: merkleProof.pathElements,
1055
+ merkleIndices: merkleProof.pathIndices,
1056
+ merkleRoot: merkleProof.root,
1057
+ nullifier,
1058
+ npk,
1059
+ random: stealthScalar,
1060
+ };
1061
+ }
1062
+
1063
+ // ========== Unified Note Scanning ==========
1064
+
1065
+ /** Announcement shape the scanners need. Wider than OnChainStealthAnnouncement
1066
+ * so view-only callers can pass their own rows. */
1067
+ interface ScannableAnnouncement {
1068
+ announcementType: number;
1069
+ ephemeralPub: Uint8Array;
1070
+ encryptedAmount: Uint8Array;
1071
+ commitment: Uint8Array;
1072
+ leafIndex: number;
1073
+ blockTime?: number;
1074
+ }
1075
+
1076
+ /** What a scan recovers before it is shaped into a note. */
1077
+ interface AnnouncementMatch {
1078
+ announcement: ScannableAnnouncement;
1079
+ amount: bigint;
1080
+ tokenId: bigint;
1081
+ commitment: Uint8Array;
1082
+ sharedSecret: Uint8Array;
1083
+ }
1084
+
1085
+ const MAX_SATS = 21_000_000n * 100_000_000n;
1086
+
1087
+ /**
1088
+ * Trial-decrypt each announcement ONCE and test the result against every token
1089
+ * id, instead of redoing the whole derivation per token.
1090
+ *
1091
+ * Everything up to the commitment — the x25519 ECDH, the amount, the stealth
1092
+ * scalar, the NPK — is token-independent; only the closing
1093
+ * Poseidon(npk, tokenId, amount) comparison is not. Scanning T tokens the naive
1094
+ * way therefore paid T ECDHs per announcement to answer one question, and the
1095
+ * ECDH is the expensive half.
1096
+ *
1097
+ * A commitment binds exactly one token id, so the first match wins and the
1098
+ * remaining ids are skipped.
1099
+ */
1100
+ function matchAnnouncements(
1101
+ viewKeys: ViewOnlyKeys,
1102
+ announcements: ScannableAnnouncement[],
1103
+ tokenIds: bigint[],
1104
+ ): AnnouncementMatch[] {
1105
+ if (tokenIds.length === 0) return [];
1106
+
1107
+ const matches: AnnouncementMatch[] = [];
1108
+ const mpk = computeMPKSync(
1109
+ viewKeys.spendingPubKey.x,
1110
+ viewKeys.spendingPubKey.y,
1111
+ viewKeys.nullifyingKey,
1112
+ );
1113
+
1114
+ for (const ann of announcements) {
1115
+ try {
1116
+ if (!isKnownAnnouncementType(ann.announcementType)) {
1117
+ continue;
1118
+ }
1119
+
1120
+ // X25519 ECDH with viewing key — once per announcement, not per token
1121
+ const sharedSecret = x25519Ecdh(viewKeys.viewingPrivKey, ann.ephemeralPub);
1122
+
1123
+ // Get amount based on type
1124
+ let amount: bigint;
1125
+ if (ann.announcementType === ANNOUNCEMENT_TYPE_DEPOSIT) {
1126
+ // Plaintext u64 LE
1127
+ const view = new DataView(ann.encryptedAmount.buffer, ann.encryptedAmount.byteOffset, 8);
1128
+ amount = view.getBigUint64(0, true);
1129
+ } else {
1130
+ // XOR-encrypted
1131
+ amount = decryptAmount(ann.encryptedAmount, sharedSecret);
1132
+ }
1133
+
1134
+ if (amount <= 0n || amount > MAX_SATS) {
1135
+ continue;
1136
+ }
1137
+
1138
+ // Derive stealth scalar and expected NPK (computed locally)
1139
+ const stealthScalar = deriveStealthScalar(sharedSecret);
1140
+ const npk = computeNPKSync(mpk, stealthScalar);
1141
+ const onChain = bytesToBigint(ann.commitment);
1142
+
1143
+ for (const tokenId of tokenIds) {
1144
+ // Verify the recomputed commitment against the on-chain one for BOTH
1145
+ // deposits and transfers — a foreign transfer whose XOR-decrypted amount
1146
+ // lands in range would otherwise become a phantom note.
1147
+ const commitmentBigint = computeJoinSplitCommitmentSync(npk, tokenId, amount);
1148
+ if (commitmentBigint !== onChain) continue;
1149
+
1150
+ matches.push({
1151
+ announcement: ann,
1152
+ amount,
1153
+ tokenId,
1154
+ // Use on-chain commitment bytes for transfers (preserves exact on-chain value)
1155
+ commitment: ann.announcementType === ANNOUNCEMENT_TYPE_DEPOSIT
1156
+ ? bigintToBytes(commitmentBigint)
1157
+ : new Uint8Array(ann.commitment),
1158
+ sharedSecret,
1159
+ });
1160
+ break;
1161
+ }
1162
+ } catch (error) {
1163
+ if (error instanceof TypeError || error instanceof RangeError) {
1164
+ throw error;
1165
+ }
1166
+ continue;
1167
+ }
1168
+ }
1169
+
1170
+ return matches;
1171
+ }
1172
+
1173
+ /** A note found by a multi-token scan, tagged with the token id it matched. */
1174
+ export type MultiScannedNote = ScannedNote & { tokenId: bigint };
1175
+
1176
+ /**
1177
+ * Scan unified StealthAnnouncement notes (both deposits and transfers) against
1178
+ * SEVERAL token ids in one pass.
1179
+ *
1180
+ * Prefer this over calling {@link scanUnifiedNotes} once per token: the cost is
1181
+ * one ECDH per announcement either way, plus one Poseidon per token id tried.
1182
+ */
1183
+ export async function scanUnifiedNotesMulti(
1184
+ source: WalletSignerAdapter | UTXOpiaKeys,
1185
+ announcements: OnChainStealthAnnouncement[],
1186
+ tokenIds: bigint[],
1187
+ ): Promise<MultiScannedNote[]> {
1188
+ const keys = isWalletAdapter(source) ? await deriveKeysFromWallet(source) : source;
1189
+
1190
+ return matchAnnouncements(exportViewOnlyKeys(keys), announcements, tokenIds).map((match) => ({
1191
+ amount: match.amount,
1192
+ ephemeralPub: match.announcement.ephemeralPub,
1193
+ // Stealth public key, for spending
1194
+ stealthPub: deriveStealthPubKey(keys.spendingPubKey, match.sharedSecret),
1195
+ leafIndex: match.announcement.leafIndex,
1196
+ commitment: match.commitment,
1197
+ blockTime: match.announcement.blockTime ?? 0,
1198
+ tokenId: match.tokenId,
1199
+ }));
1200
+ }
1201
+
1202
+ /**
1203
+ * Scan unified StealthAnnouncement notes (both deposits and transfers).
1204
+ *
1205
+ * For each announcement:
1206
+ * - type=0 (deposit): amount is plaintext u64 LE in amount_bytes
1207
+ * - type=1 (transfer): amount is XOR-encrypted in amount_bytes
1208
+ *
1209
+ * Commitment is computed locally: Poseidon(npk, tokenId, amount) and compared
1210
+ * against the on-chain one, which is what proves the note is ours.
1211
+ */
1212
+ export async function scanUnifiedNotes(
1213
+ source: WalletSignerAdapter | UTXOpiaKeys,
1214
+ announcements: OnChainStealthAnnouncement[],
1215
+ tokenId: bigint,
1216
+ ): Promise<ScannedNote[]> {
1217
+ return scanUnifiedNotesMulti(source, announcements, [tokenId]);
1218
+ }
1219
+
1220
+ // ========== Connection Adapter ==========
1221
+
1222
+ import type { Address } from "@solana/kit";
1223
+
1224
+ export interface ConnectionAdapter {
1225
+ getAccountInfo: (
1226
+ pubkey: Address
1227
+ ) => Promise<{ data: Uint8Array } | null>;
1228
+ }
1229
+
1230
+ // ========== Stealth Output Creation ==========
1231
+
1232
+ export interface StealthOutputData {
1233
+ /** Ed25519 ephemeral public key (32 bytes) */
1234
+ ephemeralPub: Uint8Array;
1235
+ /** XOR encrypted amount (8 bytes) */
1236
+ encryptedAmount: Uint8Array;
1237
+ /** Commitment = Poseidon(stealthPub.x, amount) */
1238
+ commitment: Uint8Array;
1239
+ }
1240
+
1241
+ /**
1242
+ * Circuit-ready stealth output data
1243
+ */
1244
+ export interface CircuitStealthOutput {
1245
+ /** Ephemeral pubkey (32 bytes as bigint) */
1246
+ ephemeralPubX: bigint;
1247
+ /** Packed: bits 0-63 = encrypted amount, bit 64 = reserved (0 for Ed25519) */
1248
+ encryptedAmountWithSign: bigint;
1249
+ }
1250
+
1251
+ /**
1252
+ * Pack encrypted amount (no y_sign needed for Ed25519 — 32-byte keys, no prefix)
1253
+ *
1254
+ * Layout: bits 0-63 = encrypted amount (little-endian), bit 64 = 0 (reserved)
1255
+ */
1256
+ export function packEncryptedAmountWithSign(encryptedAmount: Uint8Array, _ySign: boolean = false): bigint {
1257
+ if (encryptedAmount.length !== 8) {
1258
+ throw new Error("Encrypted amount must be 8 bytes");
1259
+ }
1260
+
1261
+ let amount = 0n;
1262
+ for (let i = 7; i >= 0; i--) {
1263
+ amount = (amount << 8n) | BigInt(encryptedAmount[i]);
1264
+ }
1265
+
1266
+ // For Ed25519, we don't have a y_sign prefix, but keep the bit for compatibility
1267
+ if (_ySign) {
1268
+ amount |= (1n << 64n);
1269
+ }
1270
+
1271
+ return amount;
1272
+ }
1273
+
1274
+ /**
1275
+ * Convert StealthOutputData to circuit-ready format
1276
+ */
1277
+ export function packStealthOutputForCircuit(output: StealthOutputData): CircuitStealthOutput {
1278
+ // Ed25519 ephemeral pub is 32 bytes — interpret as big-endian bigint
1279
+ const ephemeralPubX = bytesToBigint(output.ephemeralPub);
1280
+ const encryptedAmountWithSign = packEncryptedAmountWithSign(output.encryptedAmount);
1281
+
1282
+ return {
1283
+ ephemeralPubX,
1284
+ encryptedAmountWithSign,
1285
+ };
1286
+ }
1287
+
1288
+ /**
1289
+ * Unpack encrypted amount from packed Field element
1290
+ */
1291
+ export function unpackEncryptedAmountWithSign(packed: bigint): { encryptedAmount: Uint8Array; ySign: boolean } {
1292
+ const ySign = (packed & (1n << 64n)) !== 0n;
1293
+ const amount = packed & ((1n << 64n) - 1n);
1294
+
1295
+ const encryptedAmount = new Uint8Array(8);
1296
+ let temp = amount;
1297
+ for (let i = 0; i < 8; i++) {
1298
+ encryptedAmount[i] = Number(temp & 0xffn);
1299
+ temp >>= 8n;
1300
+ }
1301
+
1302
+ return { encryptedAmount, ySign };
1303
+ }
1304
+
1305
+ /**
1306
+ * Create stealth output data for a self-send (change output)
1307
+ */
1308
+ export async function createStealthOutput(
1309
+ keys: UTXOpiaKeys,
1310
+ amountSats: bigint,
1311
+ tokenId: bigint,
1312
+ ): Promise<StealthOutputData> {
1313
+ const ephemeral = ed25519GenerateKeyPair();
1314
+ const sharedSecret = x25519Ecdh(ephemeral.privKey, keys.viewingPubKey);
1315
+
1316
+ const stealthScalar = deriveStealthScalar(sharedSecret);
1317
+ const mpk = computeMPKSync(keys.spendingPubKey.x, keys.spendingPubKey.y, keys.nullifyingKey);
1318
+ const npk = computeNPKSync(mpk, stealthScalar);
1319
+
1320
+ const commitmentBigint = computeJoinSplitCommitmentSync(npk, tokenId, amountSats);
1321
+ const commitment = bigintToBytes(commitmentBigint);
1322
+ const encryptedAmount = encryptAmount(amountSats, sharedSecret);
1323
+
1324
+ return {
1325
+ ephemeralPub: new Uint8Array(ephemeral.pubKey),
1326
+ encryptedAmount,
1327
+ commitment,
1328
+ };
1329
+ }
1330
+
1331
+ /**
1332
+ * Create stealth output with npk for JoinSplit circuit input
1333
+ */
1334
+ export async function createStealthOutputWithKeys(
1335
+ keys: UTXOpiaKeys,
1336
+ amountSats: bigint,
1337
+ tokenId: bigint,
1338
+ ): Promise<StealthOutputWithKeys> {
1339
+ const ephemeral = ed25519GenerateKeyPair();
1340
+ const sharedSecret = x25519Ecdh(ephemeral.privKey, keys.viewingPubKey);
1341
+
1342
+ const stealthScalar = deriveStealthScalar(sharedSecret);
1343
+ const mpk = computeMPKSync(keys.spendingPubKey.x, keys.spendingPubKey.y, keys.nullifyingKey);
1344
+ const npk = computeNPKSync(mpk, stealthScalar);
1345
+
1346
+ const commitmentBigint = computeJoinSplitCommitmentSync(npk, tokenId, amountSats);
1347
+ const commitment = bigintToBytes(commitmentBigint);
1348
+ const encryptedAmount = encryptAmount(amountSats, sharedSecret);
1349
+
1350
+ return {
1351
+ ephemeralPub: new Uint8Array(ephemeral.pubKey),
1352
+ encryptedAmount,
1353
+ commitment,
1354
+ stealthPubKeyX: npk,
1355
+ npkBytes: bigintToBytes(npk),
1356
+ };
1357
+ }
1358
+
1359
+ /**
1360
+ * Create stealth output data with pre-computed commitment
1361
+ */
1362
+ export async function createStealthOutputForCommitment(
1363
+ keys: UTXOpiaKeys,
1364
+ amountSats: bigint,
1365
+ existingCommitment: Uint8Array
1366
+ ): Promise<StealthOutputData> {
1367
+ const ephemeral = ed25519GenerateKeyPair();
1368
+ const sharedSecret = x25519Ecdh(ephemeral.privKey, keys.viewingPubKey);
1369
+
1370
+ const encryptedAmount = encryptAmount(amountSats, sharedSecret);
1371
+
1372
+ return {
1373
+ ephemeralPub: new Uint8Array(ephemeral.pubKey),
1374
+ encryptedAmount,
1375
+ commitment: existingCommitment,
1376
+ };
1377
+ }
1378
+
1379
+ // ========== Nullifier Computation ==========
1380
+
1381
+ /**
1382
+ * Compute nullifier hash for a scanned note
1383
+ */
1384
+ export function computeNullifierHashForNote(
1385
+ keys: UTXOpiaKeys,
1386
+ note: ScannedNote
1387
+ ): Uint8Array {
1388
+ // In JoinSplit model, nullifier = Poseidon(nullifyingKey, leafIndex)
1389
+ // No extra hash layer — the nullifier IS the public output
1390
+ const nullifier = computeJoinSplitNullifierSync(keys.nullifyingKey, BigInt(note.leafIndex));
1391
+ return bigintToBytes(nullifier);
1392
+ }
1393
+
1394
+ /**
1395
+ * Compute nullifier hash for a note and return as raw bytes.
1396
+ * Convenience wrapper — avoids importing computeJoinSplitNullifierSync + bigintToBytes in consumers.
1397
+ */
1398
+ export function computeNullifierBytes(nullifyingKey: bigint, leafIndex: number): Uint8Array {
1399
+ const nullifier = computeJoinSplitNullifierSync(nullifyingKey, BigInt(leafIndex));
1400
+ return bigintToBytes(nullifier);
1401
+ }
1402
+
1403
+ // ========== Announcement Parsing ==========
1404
+
1405
+ /**
1406
+ * Parse backend announcement rows (hex strings) into the format scanUnifiedNotes expects.
1407
+ */
1408
+ export function parseAnnouncementsFromHex(rows: Array<{
1409
+ announcement_type: number;
1410
+ ephemeral_pub: string;
1411
+ encrypted_amount: string;
1412
+ commitment: string;
1413
+ leaf_index: number;
1414
+ token_id?: string | null;
1415
+ }>): Array<{
1416
+ announcementType: number;
1417
+ ephemeralPub: Uint8Array;
1418
+ encryptedAmount: Uint8Array;
1419
+ commitment: Uint8Array;
1420
+ leafIndex: number;
1421
+ tokenIdHex?: string;
1422
+ }> {
1423
+ return rows.map((r) => ({
1424
+ announcementType: r.announcement_type,
1425
+ ephemeralPub: hexToBytes(r.ephemeral_pub),
1426
+ encryptedAmount: hexToBytes(r.encrypted_amount),
1427
+ commitment: hexToBytes(r.commitment),
1428
+ leafIndex: r.leaf_index,
1429
+ tokenIdHex: r.token_id ?? undefined,
1430
+ }));
1431
+ }
1432
+
1433
+ // ========== Deposit Ownership Check ==========
1434
+
1435
+ /**
1436
+ * Check if a deposit (identified by its OP_RETURN ephemeralPub + npk) belongs
1437
+ * to the given viewing key holder.
1438
+ *
1439
+ * Performs X25519 ECDH between the viewer's private key and the deposit's
1440
+ * ephemeral public key, derives the expected NPK, and compares it with the
1441
+ * deposit's actual NPK.
1442
+ */
1443
+ export function isDepositForViewer(
1444
+ viewingPrivKey: Uint8Array,
1445
+ spendingPubKey: { x: bigint; y: bigint },
1446
+ nullifyingKey: bigint,
1447
+ ephemeralPub: Uint8Array,
1448
+ depositNpk: bigint,
1449
+ ): boolean {
1450
+ try {
1451
+ const sharedSecret = x25519Ecdh(viewingPrivKey, ephemeralPub);
1452
+ const mpk = computeMPKSync(spendingPubKey.x, spendingPubKey.y, nullifyingKey);
1453
+ const stealthScalar = deriveStealthScalar(sharedSecret);
1454
+ const expectedNpk = computeNPKSync(mpk, stealthScalar);
1455
+ return expectedNpk === depositNpk;
1456
+ } catch {
1457
+ return false;
1458
+ }
1459
+ }
1460
+
1461
+ /**
1462
+ * Check if a deposit belongs to this viewer — accepts hex string inputs.
1463
+ * Convenience wrapper around isDepositForViewer for frontend use.
1464
+ */
1465
+ export function isDepositForViewerHex(
1466
+ keys: { viewingPrivKey: Uint8Array; spendingPubKey: { x: bigint; y: bigint }; nullifyingKey: bigint },
1467
+ ephemeralPubHex: string,
1468
+ npkHex: string,
1469
+ ): boolean {
1470
+ try {
1471
+ const ephPub = hexToBytes(ephemeralPubHex);
1472
+ const npk = bytesToBigint(hexToBytes(npkHex));
1473
+ return isDepositForViewer(keys.viewingPrivKey, keys.spendingPubKey, keys.nullifyingKey, ephPub, npk);
1474
+ } catch {
1475
+ return false;
1476
+ }
1477
+ }