@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/client.ts ADDED
@@ -0,0 +1,638 @@
1
+ /**
2
+ * UTXOpiaClient — high-level SDK entry point.
3
+ *
4
+ * Initialize once, use simple methods everywhere. Encapsulates config,
5
+ * keys, Poseidon init, token IDs, and note scanning so consumers don't
6
+ * chain low-level SDK calls.
7
+ *
8
+ * ```typescript
9
+ * const client = await UTXOpiaClient.init({ network: "devnet" });
10
+ * await client.loginWithWallet(wallet);
11
+ * const notes = await client.getNotes();
12
+ * const balance = client.getBalance();
13
+ * ```
14
+ *
15
+ * Phase 1: Init + auth + balance scanning
16
+ * Phase 2: Deposit + shield (future)
17
+ * Phase 3: Transfer + relay (future)
18
+ */
19
+
20
+ import { initPoseidon, poseidonHashSync } from "./poseidon";
21
+ import { computeTokenId, reduceToField } from "./poseidon";
22
+ import { initConfig, getConfig, type NetworkConfig, type NetworkId } from "./config";
23
+ import { parseMerkleProofResponse } from "./merkle";
24
+ import { eddsaPoseidonSign } from "./keys";
25
+ import {
26
+ setupKeysFromWallet,
27
+ setupKeysFromSeed,
28
+ setupKeysFromAuthSignature,
29
+ recreateStealthAddress,
30
+ serializeKeysForStorage,
31
+ deserializeKeysFromStorage,
32
+ clearUTXOpiaKeys,
33
+ type AuthSignatureKeyDerivationOptions,
34
+ type UTXOpiaKeys,
35
+ type StealthMetaAddress,
36
+ type WalletSignerAdapter,
37
+ type KeySetupResult,
38
+ } from "./keys";
39
+ import {
40
+ scanUnifiedNotesMulti,
41
+ scanAnnouncementsViewOnlyMulti,
42
+ computeNullifierHashForNote,
43
+ computeNullifierBytes,
44
+ isDepositForViewerHex,
45
+ createDepositFromConfig,
46
+ createTweakDeposit,
47
+ depositViewingNode,
48
+ createStealthOutputWithKeys,
49
+ type ScannedNote,
50
+ type ViewOnlyKeys,
51
+ type ViewOnlyScannedNote,
52
+ type StealthOutputWithKeys,
53
+ type NonInteractiveDepositResult,
54
+ type TweakDepositResult,
55
+ } from "./stealth";
56
+ import { selectUtxos, type UtxoDescriptor } from "./psbt";
57
+ import { hexToBytes, bytesToHex, bigintToBytes } from "./crypto";
58
+ import { EventClient } from "./event-client";
59
+ import { type DepositOpReturnContext } from "./taproot";
60
+
61
+ // ─── Types ──────────────────────────────────────────────────────────
62
+
63
+ export interface UTXOpiaClientConfig {
64
+ network?: NetworkId;
65
+ /** Override backend URL (default: from network config) */
66
+ backendUrl?: string;
67
+ }
68
+
69
+ export interface TokenDefinition {
70
+ symbol: string;
71
+ shieldedSymbol: string;
72
+ mint: string;
73
+ }
74
+
75
+ export interface InboxNote {
76
+ id: string;
77
+ commitmentHex: string;
78
+ amount: bigint;
79
+ leafIndex: number;
80
+ tokenSymbol: string;
81
+ isSpent: boolean;
82
+ createdAt: number;
83
+ ephemeralPub?: Uint8Array;
84
+ stealthPub?: { x: bigint; y: bigint };
85
+ commitment: Uint8Array;
86
+ }
87
+
88
+ // ─── Client ─────────────────────────────────────────────────────────
89
+
90
+ let _instance: UTXOpiaClient | null = null;
91
+
92
+ export class UTXOpiaClient {
93
+ private _keys: UTXOpiaKeys | null = null;
94
+ private _viewOnlyKeys: ViewOnlyKeys | null = null;
95
+ private _isViewOnly = false;
96
+ private _stealthAddress: StealthMetaAddress | null = null;
97
+ private _stealthAddressEncoded: string | null = null;
98
+ private _tokenIdCache = new Map<string, bigint>();
99
+ private _eventClient: EventClient | null = null;
100
+ private _backendUrl: string;
101
+ private _appNetworkId: NetworkId;
102
+
103
+ private constructor(backendUrl: string, appNetworkId: NetworkId) {
104
+ this._backendUrl = backendUrl;
105
+ this._appNetworkId = appNetworkId;
106
+ }
107
+
108
+ // ─── Lifecycle ──────────────────────────────────────────────────
109
+
110
+ /**
111
+ * Initialize the SDK. Call once at app startup.
112
+ * Sets up config, initializes Poseidon hash, creates singleton.
113
+ */
114
+ static async init(opts: UTXOpiaClientConfig = {}): Promise<UTXOpiaClient> {
115
+ // Init config (reads env vars, sets up network)
116
+ if (opts.network) {
117
+ await initConfig({ network: opts.network });
118
+ }
119
+
120
+ // Init Poseidon (required before any hashing)
121
+ await initPoseidon();
122
+
123
+ const backendUrl = opts.backendUrl || "";
124
+ const appNetworkId = opts.network ||
125
+ (typeof process !== "undefined" && (process.env?.NEXT_PUBLIC_NETWORK || process.env?.UTXOPIA_NETWORK) as NetworkId) ||
126
+ "devnet";
127
+ const client = new UTXOpiaClient(backendUrl, appNetworkId);
128
+
129
+ _instance = client;
130
+ return client;
131
+ }
132
+
133
+ /**
134
+ * Get the initialized singleton. Throws if init() hasn't been called.
135
+ */
136
+ static instance(): UTXOpiaClient {
137
+ if (!_instance) {
138
+ throw new Error("UTXOpiaClient not initialized. Call UTXOpiaClient.init() first.");
139
+ }
140
+ return _instance;
141
+ }
142
+
143
+ /**
144
+ * Check if the client has been initialized.
145
+ */
146
+ static get isInitialized(): boolean {
147
+ return _instance !== null;
148
+ }
149
+
150
+ /**
151
+ * Reset the singleton (for testing only).
152
+ */
153
+ static reset(): void {
154
+ if (_instance) {
155
+ _instance.logout();
156
+ _instance._eventClient = null;
157
+ }
158
+ _instance = null;
159
+ }
160
+
161
+ // ─── Auth ───────────────────────────────────────────────────────
162
+
163
+ /**
164
+ * Derive keys from a Solana wallet signature.
165
+ */
166
+ async loginWithWallet(wallet: WalletSignerAdapter): Promise<KeySetupResult> {
167
+ const result = await setupKeysFromWallet(wallet);
168
+ this._keys = result.keys;
169
+ this._stealthAddress = result.stealthAddress;
170
+ this._stealthAddressEncoded = result.stealthAddressEncoded;
171
+ this._isViewOnly = false;
172
+ this._viewOnlyKeys = null;
173
+ return result;
174
+ }
175
+
176
+ /**
177
+ * Derive keys from a seed (passkey PRF output or secret phrase).
178
+ */
179
+ async loginWithSeed(seed: Uint8Array): Promise<KeySetupResult> {
180
+ const result = await setupKeysFromSeed(seed);
181
+ this._keys = result.keys;
182
+ this._stealthAddress = result.stealthAddress;
183
+ this._stealthAddressEncoded = result.stealthAddressEncoded;
184
+ this._isViewOnly = false;
185
+ this._viewOnlyKeys = null;
186
+ return result;
187
+ }
188
+
189
+ /**
190
+ * Derive keys from a chain-specific auth signature (wallet personal-message signing).
191
+ */
192
+ async loginWithAuthSignature(
193
+ signature: Uint8Array,
194
+ options: AuthSignatureKeyDerivationOptions = {},
195
+ ): Promise<KeySetupResult> {
196
+ const result = await setupKeysFromAuthSignature(signature, options);
197
+ this._keys = result.keys;
198
+ this._stealthAddress = result.stealthMetaAddress;
199
+ this._stealthAddressEncoded = result.encodedStealthAddress;
200
+ this._isViewOnly = false;
201
+ this._viewOnlyKeys = null;
202
+ return {
203
+ keys: result.keys,
204
+ stealthAddress: result.stealthMetaAddress,
205
+ stealthAddressEncoded: result.encodedStealthAddress,
206
+ };
207
+ }
208
+
209
+ /**
210
+ * Restore keys from previously serialized storage (e.g., localStorage).
211
+ * @param serialized — the object from serializeKeys()
212
+ * @param solanaPublicKey — the wallet public key bytes (needed for key reconstruction)
213
+ */
214
+ restoreKeys(serialized: Record<string, unknown>, solanaPublicKey: Uint8Array): void {
215
+ const keys = deserializeKeysFromStorage(serialized as any, solanaPublicKey);
216
+ const { stealthAddress, stealthAddressEncoded } = recreateStealthAddress(keys);
217
+ this._keys = keys;
218
+ this._stealthAddress = stealthAddress;
219
+ this._stealthAddressEncoded = stealthAddressEncoded;
220
+ this._isViewOnly = false;
221
+ }
222
+
223
+ /**
224
+ * Login with view-only keys (can scan but not spend).
225
+ */
226
+ loginViewOnly(viewOnlyKeys: ViewOnlyKeys): void {
227
+ this._viewOnlyKeys = viewOnlyKeys;
228
+ this._isViewOnly = true;
229
+ this._keys = null;
230
+ }
231
+
232
+ /**
233
+ * Clear all keys and reset auth state.
234
+ */
235
+ logout(): void {
236
+ if (this._keys) {
237
+ clearUTXOpiaKeys(this._keys);
238
+ }
239
+ this._keys = null;
240
+ this._viewOnlyKeys = null;
241
+ this._isViewOnly = false;
242
+ this._stealthAddress = null;
243
+ this._stealthAddressEncoded = null;
244
+ }
245
+
246
+ /**
247
+ * Serialize current keys for encrypted storage.
248
+ */
249
+ serializeKeys(): Record<string, unknown> | null {
250
+ if (!this._keys) return null;
251
+ return { ...serializeKeysForStorage(this._keys) };
252
+ }
253
+
254
+ // ─── Getters ────────────────────────────────────────────────────
255
+
256
+ get keys(): UTXOpiaKeys | null { return this._keys; }
257
+ get stealthAddress(): StealthMetaAddress | null { return this._stealthAddress; }
258
+ get stealthAddressEncoded(): string | null { return this._stealthAddressEncoded; }
259
+ get isAuthenticated(): boolean { return this._keys !== null || this._viewOnlyKeys !== null; }
260
+ get isViewOnly(): boolean { return this._isViewOnly; }
261
+ get config(): NetworkConfig { return getConfig(); }
262
+
263
+ // ─── Token IDs ──────────────────────────────────────────────────
264
+
265
+ /**
266
+ * Get token ID for a mint address. Cached after first computation.
267
+ */
268
+ getTokenId(mintAddress: string): bigint {
269
+ const cached = this._tokenIdCache.get(mintAddress);
270
+ if (cached !== undefined) return cached;
271
+
272
+ // Requires PublicKey — import dynamically to avoid hard dep
273
+ const mintBytes = hexToBytes(mintAddress.padStart(64, "0"));
274
+ // If it's a base58 address, convert via PublicKey
275
+ let bytes: Uint8Array;
276
+ try {
277
+ // Try as raw hex first (64 chars)
278
+ if (mintAddress.length === 64 && /^[0-9a-fA-F]+$/.test(mintAddress)) {
279
+ bytes = hexToBytes(mintAddress);
280
+ } else {
281
+ // Assume base58 PublicKey — need to decode
282
+ // Use the SDK's reduceToField which handles the conversion
283
+ const { PublicKey } = require("@solana/web3.js");
284
+ bytes = new PublicKey(mintAddress).toBytes();
285
+ }
286
+ } catch {
287
+ bytes = mintBytes;
288
+ }
289
+
290
+ const tokenId = computeTokenId(bytes);
291
+ this._tokenIdCache.set(mintAddress, tokenId);
292
+ return tokenId;
293
+ }
294
+
295
+ /**
296
+ * Register multiple tokens for scanning. Caches their token IDs.
297
+ */
298
+ registerTokens(tokens: TokenDefinition[]): void {
299
+ const config = getConfig();
300
+ for (const token of tokens) {
301
+ let mint = token.mint;
302
+ if (!mint && (token.symbol === "BTC" || token.symbol === "zkBTC")) {
303
+ mint = config.zkbtcMint;
304
+ }
305
+ if (!mint) continue;
306
+ this.getTokenId(mint); // triggers computation + cache
307
+ }
308
+ }
309
+
310
+ // ─── Note Scanning ─────────────────────────────────────────────
311
+
312
+ /**
313
+ * Scan for all notes belonging to the authenticated user.
314
+ * Fetches announcements from backend, scans locally for privacy.
315
+ */
316
+ async getNotes(tokens: TokenDefinition[]): Promise<InboxNote[]> {
317
+ if (!this._keys && !this._viewOnlyKeys) return [];
318
+
319
+ // Fetch announcements via EventClient
320
+ const client = this.getEventClient();
321
+ const announcements = await client.fetchAll();
322
+
323
+ // One trial-decrypt per announcement, tested against every token id — not
324
+ // one full scan pass per token.
325
+ const config = getConfig();
326
+ type ScannedWithToken = (ScannedNote | ViewOnlyScannedNote) & { tokenSymbol: string };
327
+ const symbolByTokenId = new Map<bigint, string>();
328
+
329
+ for (const token of tokens) {
330
+ let mint = token.mint;
331
+ if (!mint && (token.symbol === "BTC" || token.symbol === "zkBTC")) {
332
+ mint = config.zkbtcMint;
333
+ }
334
+ if (!mint) continue;
335
+ const tokenId = this.getTokenId(mint);
336
+ // First token wins the id, matching the old loop's dedup order.
337
+ if (!symbolByTokenId.has(tokenId)) symbolByTokenId.set(tokenId, token.shieldedSymbol);
338
+ }
339
+
340
+ const tokenIds = [...symbolByTokenId.keys()];
341
+ const matches = this._isViewOnly && this._viewOnlyKeys
342
+ ? await scanAnnouncementsViewOnlyMulti(this._viewOnlyKeys, announcements, tokenIds)
343
+ : await scanUnifiedNotesMulti(this._keys!, announcements, tokenIds);
344
+
345
+ const scanned: ScannedWithToken[] = matches.map((note) => ({
346
+ ...note,
347
+ tokenSymbol: symbolByTokenId.get(note.tokenId)!,
348
+ }) as ScannedWithToken);
349
+
350
+ // Convert to InboxNote format
351
+ return scanned.map((note, index) => {
352
+ const rawHex = Buffer.from(note.commitment).toString("hex");
353
+ const commitmentHex = rawHex.toLowerCase().padStart(64, "0");
354
+
355
+ return {
356
+ id: `${commitmentHex.slice(0, 16)}-${index}`,
357
+ commitmentHex,
358
+ amount: typeof note.amount === "bigint" ? note.amount : BigInt(note.amount),
359
+ leafIndex: note.leafIndex,
360
+ tokenSymbol: note.tokenSymbol,
361
+ isSpent: false, // caller checks spent status separately
362
+ createdAt: (note as any).blockTime ? (note as any).blockTime * 1000 : Date.now(),
363
+ ephemeralPub: note.ephemeralPub,
364
+ stealthPub: (note as ScannedNote).stealthPub,
365
+ commitment: note.commitment,
366
+ };
367
+ });
368
+ }
369
+
370
+ /**
371
+ * Get balance per token from unspent notes.
372
+ */
373
+ getBalance(notes: InboxNote[]): Map<string, bigint> {
374
+ const balances = new Map<string, bigint>();
375
+ for (const note of notes) {
376
+ if (note.isSpent) continue;
377
+ const current = balances.get(note.tokenSymbol) ?? 0n;
378
+ balances.set(note.tokenSymbol, current + note.amount);
379
+ }
380
+ return balances;
381
+ }
382
+
383
+ /**
384
+ * Check if a deposit announcement belongs to this user (hex string inputs).
385
+ */
386
+ isMyDeposit(ephemeralPubHex: string, npkHex: string): boolean {
387
+ if (!this._keys) return false;
388
+ return isDepositForViewerHex(this._keys, ephemeralPubHex, npkHex);
389
+ }
390
+
391
+ /**
392
+ * Compute nullifier bytes for a note (for PDA existence checking).
393
+ */
394
+ computeNullifier(note: { leafIndex: number }): Uint8Array {
395
+ if (this._isViewOnly && this._viewOnlyKeys) {
396
+ return computeNullifierBytes(this._viewOnlyKeys.nullifyingKey, note.leafIndex);
397
+ }
398
+ if (this._keys) {
399
+ return computeNullifierHashForNote(this._keys, note as ScannedNote);
400
+ }
401
+ throw new Error("Not authenticated");
402
+ }
403
+
404
+ // ─── Phase 2: Deposit + Shield ─────────────────────────────────
405
+
406
+ /**
407
+ * Prepare an OP_RETURN-free BTC deposit address (`verify_deposit`, disc 25).
408
+ *
409
+ * The ephemeral key is derived from this wallet's own viewing node and
410
+ * `depositIndex`, never randomly. A deposit address commits to the ephemeral
411
+ * key through its tapleaf and its key path is a NUMS point, so an address whose
412
+ * ephemeral key is lost is one nobody can ever spend. Indexing it makes the
413
+ * viewing key a complete backup: walk `depositIndex` upward and every address
414
+ * comes back.
415
+ *
416
+ * Self-deposit only. The address is recovered by whoever holds the viewing key
417
+ * it was derived from, so generating one *for someone else* would hand them a
418
+ * note they own but coins they could never recover. Pay a third party with a
419
+ * shielded transfer instead.
420
+ *
421
+ * `depositIndex` must be persisted and monotonic. Reusing one re-derives the
422
+ * same address, which is safe on chain but links the two deposits.
423
+ */
424
+ async prepareTweakDeposit(opts: {
425
+ depositIndex: number;
426
+ ikaXOnlyPubkey: Uint8Array;
427
+ recipient?: StealthMetaAddress;
428
+ network?: "mainnet" | "testnet" | "regtest";
429
+ }): Promise<TweakDepositResult> {
430
+ if (!this._keys) {
431
+ throw new Error("No keys (login first)");
432
+ }
433
+ const meta = opts.recipient ?? this._stealthAddress;
434
+ if (!meta) throw new Error("No recipient stealth address (login first or provide recipient)");
435
+ if (opts.recipient && this._stealthAddress && opts.recipient !== this._stealthAddress) {
436
+ throw new Error(
437
+ "prepareTweakDeposit is self-deposit only: the recipient must be able to derive the " +
438
+ "ephemeral key to recover the coins, and only this wallet's viewing key can",
439
+ );
440
+ }
441
+
442
+ const network = opts.network ?? sdkBitcoinNetworkToAddressNetwork(this.config.bitcoinNetwork);
443
+ return createTweakDeposit(
444
+ meta,
445
+ opts.ikaXOnlyPubkey,
446
+ {
447
+ viewingNode: depositViewingNode(this._keys.viewingPrivKey),
448
+ depositIndex: opts.depositIndex,
449
+ },
450
+ network,
451
+ );
452
+ }
453
+
454
+ /**
455
+ * Prepare a BTC deposit: generate stealth deposit address + OP_RETURN.
456
+ * Returns the deposit result (btcAddress, opReturnPayload) ready for PSBT building.
457
+ */
458
+ async prepareDeposit(opts: {
459
+ recipient?: StealthMetaAddress;
460
+ network?: "mainnet" | "testnet" | "regtest";
461
+ opReturnContext: DepositOpReturnContext;
462
+ }): Promise<NonInteractiveDepositResult> {
463
+ const meta = opts.recipient ?? this._stealthAddress;
464
+ if (!meta) throw new Error("No recipient stealth address (login first or provide recipient)");
465
+ const network = opts.network ?? sdkBitcoinNetworkToAddressNetwork(this.config.bitcoinNetwork);
466
+ if (!opts.opReturnContext) {
467
+ throw new Error("deposit OP_RETURN context is required");
468
+ }
469
+ return createDepositFromConfig(meta, network, opts.opReturnContext);
470
+ }
471
+
472
+ /**
473
+ * Select UTXOs for a deposit amount. Returns the selected UTXO set.
474
+ */
475
+ selectUtxos(utxos: UtxoDescriptor[], targetSats: number, feeRate = 2): UtxoDescriptor[] {
476
+ return selectUtxos(utxos, targetSats, feeRate);
477
+ }
478
+
479
+ /**
480
+ * Prepare a stealth output for shielding (SPL token → shielded commitment).
481
+ * Returns npkBytes, ephemeralPub, commitment, tokenId — everything needed
482
+ * for the on-chain shield instruction.
483
+ */
484
+ async prepareShieldOutput(opts: {
485
+ amount: bigint;
486
+ mintAddress: string;
487
+ recipient?: UTXOpiaKeys;
488
+ }): Promise<StealthOutputWithKeys & { tokenId: bigint }> {
489
+ const keys = opts.recipient ?? this._keys;
490
+ if (!keys) throw new Error("No keys (login first or provide recipient)");
491
+ const tokenId = this.getTokenId(opts.mintAddress);
492
+ const output = await createStealthOutputWithKeys(keys, opts.amount, tokenId);
493
+ return { ...output, tokenId };
494
+ }
495
+
496
+ // ─── Phase 3: Transfer + Relay ─────────────────────────────────
497
+
498
+ /**
499
+ * Fetch merkle proofs for multiple commitments.
500
+ * Used before proof generation to get the on-chain tree state.
501
+ *
502
+ * @param commitmentHexes - Array of commitment hex strings
503
+ * @param apiBaseUrl - Base URL for the merkle proof API (default: "" for same-origin)
504
+ */
505
+ async fetchMerkleProofs(
506
+ commitmentHexes: string[],
507
+ apiBaseUrl = "",
508
+ ): Promise<
509
+ Array<{
510
+ commitmentHex: string;
511
+ root: bigint;
512
+ pathElements: bigint[];
513
+ pathIndices: number[];
514
+ }>
515
+ > {
516
+ const results = await Promise.all(
517
+ commitmentHexes.map(async (hex) => {
518
+ const resp = await fetch(
519
+ `${apiBaseUrl}/api/merkle/proof?commitment=${hex}`,
520
+ );
521
+ const data = await resp.json();
522
+ if (!data.success) {
523
+ throw new Error(`Note ${hex.slice(0, 16)}... not found on-chain`);
524
+ }
525
+ const parsed = parseMerkleProofResponse(data);
526
+ return { commitmentHex: hex, ...parsed };
527
+ }),
528
+ );
529
+
530
+ // Validate all proofs share the same root
531
+ const roots = results.map((r) => r.root);
532
+ if (new Set(roots.map((r) => r.toString())).size > 1) {
533
+ throw new Error(
534
+ "Input notes have different Merkle roots — tree may have changed",
535
+ );
536
+ }
537
+
538
+ return results;
539
+ }
540
+
541
+ /**
542
+ * Hash transaction inputs and sign with EdDSA-Poseidon.
543
+ *
544
+ * @param msgHashInputs - Array of bigints to hash (merkleRoot, boundParamsHash, nullifiers, commitments)
545
+ * @param eddsaSeed - The EdDSA seed bytes (from UTXOpiaKeys.eddsaSeed)
546
+ */
547
+ async signTransaction(
548
+ msgHashInputs: bigint[],
549
+ eddsaSeed: Uint8Array,
550
+ ): Promise<{
551
+ sigR8x: bigint;
552
+ sigR8y: bigint;
553
+ sigS: bigint;
554
+ msgHash: bigint;
555
+ }> {
556
+ const msgHash = poseidonHashSync(msgHashInputs);
557
+ const [sigR8x, sigR8y, sigS] = await eddsaPoseidonSign(
558
+ eddsaSeed,
559
+ msgHash,
560
+ );
561
+ return { sigR8x, sigR8y, sigS, msgHash };
562
+ }
563
+
564
+ /**
565
+ * Submit a JoinSplit transaction to the relay backend.
566
+ *
567
+ * @param payload - Transaction data including proof, nullifiers, commitments, and mode-specific fields
568
+ * @param relayUrl - URL for the relay endpoint (default is chain-aware from init network)
569
+ */
570
+ async submitToRelay(
571
+ payload: {
572
+ mode: "transfer" | "unshield" | "redeem";
573
+ nInputs: number;
574
+ nOutputs: number;
575
+ proof: string;
576
+ merkleRoot: string;
577
+ boundParamsHash: string;
578
+ nullifiers: string[];
579
+ commitmentsOut: string[];
580
+ stealthData: string[];
581
+ // Transfer-specific
582
+ relayerFeeOutputIndex?: number;
583
+ /**
584
+ * Optional Phase 2 sender memos — one 80-byte hex string per output
585
+ * (nonce(24) || ciphertext_and_tag(56)). Compose with the SDK helper
586
+ * `buildSenderMemosForTransact(viewingPrivKey, outputs)`. The relay
587
+ * forwards them opaquely; viewing keys stay client-side.
588
+ */
589
+ senderMemos?: string[];
590
+ // Unshield-specific
591
+ unshieldAmounts?: string[];
592
+ recipientAddresses?: string[];
593
+ recipientTokenAccounts?: string[];
594
+ // Redeem-specific
595
+ redeemAmounts?: string[];
596
+ btcScripts?: string[];
597
+ requestNonces?: string[];
598
+ /**
599
+ * Optional frozen source-tree PDA (base58). Set only when a spent note was committed in a
600
+ * commitment tree that has since been rotated out; the relay inserts it before the proof
601
+ * buffer so the program can prove membership against that tree. Omit otherwise.
602
+ */
603
+ sourceTree?: string;
604
+ },
605
+ relayUrl = this.defaultRelayUrl(),
606
+ ): Promise<{ success: boolean; signature?: string; error?: string }> {
607
+ const resp = await fetch(relayUrl, {
608
+ method: "POST",
609
+ headers: { "Content-Type": "application/json" },
610
+ body: JSON.stringify(payload),
611
+ });
612
+ return resp.json();
613
+ }
614
+
615
+ // ─── Private helpers ────────────────────────────────────────────
616
+
617
+ private getEventClient(): EventClient {
618
+ if (!this._eventClient) {
619
+ const config = getConfig();
620
+ this._eventClient = new EventClient({
621
+ backendUrl: this._backendUrl,
622
+ solanaRpcUrl: config.solanaRpcUrl || "",
623
+ programId: config.utxopiaProgramId,
624
+ });
625
+ }
626
+ return this._eventClient;
627
+ }
628
+
629
+ private defaultRelayUrl(): string {
630
+ return `/api/sol/relay?network=${encodeURIComponent(this._appNetworkId)}`;
631
+ }
632
+ }
633
+
634
+ function sdkBitcoinNetworkToAddressNetwork(network: NetworkConfig["bitcoinNetwork"]): "mainnet" | "testnet" | "regtest" {
635
+ if (network === "mainnet") return "mainnet";
636
+ if (network === "regtest") return "regtest";
637
+ return "testnet";
638
+ }