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

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}/psbt.d.ts +12 -2
  11. package/{packages/sdk/dist → dist}/psbt.js +17 -12
  12. package/{packages/sdk/dist → dist}/stealth.d.ts +104 -9
  13. package/{packages/sdk/dist → dist}/stealth.js +146 -14
  14. package/{packages/sdk/dist → dist}/taproot.d.ts +39 -2
  15. package/{packages/sdk/dist → dist}/taproot.js +54 -2
  16. package/package.json +86 -63
  17. package/src/announcement-client.ts +457 -0
  18. package/src/auditor-ciphertext.ts +181 -0
  19. package/src/auditor.ts +409 -0
  20. package/src/bitcoin/ika.ts +103 -0
  21. package/src/bitcoin/index.ts +5 -0
  22. package/src/bound-params.ts +322 -0
  23. package/src/chadbuffer.ts +603 -0
  24. package/src/circomlibjs.d.ts +51 -0
  25. package/src/claim-link.ts +53 -0
  26. package/src/client.ts +638 -0
  27. package/src/commitment-tree.ts +736 -0
  28. package/src/config.ts +772 -0
  29. package/src/core/esplora.ts +332 -0
  30. package/src/core/mempool.ts +159 -0
  31. package/src/crypto-babyjub.ts +385 -0
  32. package/src/crypto-ed25519.ts +297 -0
  33. package/src/crypto.ts +199 -0
  34. package/src/event-client.ts +231 -0
  35. package/src/events.ts +384 -0
  36. package/src/explorer.ts +300 -0
  37. package/src/index.ts +902 -0
  38. package/src/instructions.ts +2820 -0
  39. package/src/keys.ts +1228 -0
  40. package/src/logger.ts +41 -0
  41. package/src/magicblock.ts +278 -0
  42. package/src/merkle.ts +197 -0
  43. package/src/note.ts +754 -0
  44. package/src/pda.ts +516 -0
  45. package/src/pool-state.ts +176 -0
  46. package/src/poseidon.ts +175 -0
  47. package/src/prover/index.ts +19 -0
  48. package/src/prover/mobile.ts +303 -0
  49. package/src/prover/web.ts +771 -0
  50. package/src/psbt.ts +345 -0
  51. package/src/selective-disclosure.ts +284 -0
  52. package/src/sender-memo.ts +343 -0
  53. package/src/snarkjs.d.ts +19 -0
  54. package/src/sns-resolver.ts +333 -0
  55. package/src/solana/connection.ts +189 -0
  56. package/src/solana/priority-fee.ts +201 -0
  57. package/src/spend-doc.ts +163 -0
  58. package/src/stealth.ts +1477 -0
  59. package/src/taproot.ts +707 -0
  60. package/src/token-registry.ts +207 -0
  61. package/src/utils/encoding.ts +33 -0
  62. package/src/vk-registry.ts +295 -0
  63. package/LICENSE +0 -21
  64. package/packages/btc-client/src/esplora-client.ts +0 -153
  65. package/packages/btc-client/src/index.ts +0 -3
  66. package/packages/btc-client/src/op-return.ts +0 -93
  67. package/packages/btc-client/src/types.ts +0 -112
  68. package/packages/sdk/README.md +0 -277
  69. /package/{packages/sdk/dist → dist}/announcement-client.d.ts +0 -0
  70. /package/{packages/sdk/dist → dist}/announcement-client.js +0 -0
  71. /package/{packages/sdk/dist → dist}/auditor-ciphertext.d.ts +0 -0
  72. /package/{packages/sdk/dist → dist}/auditor-ciphertext.js +0 -0
  73. /package/{packages/sdk/dist → dist}/auditor.d.ts +0 -0
  74. /package/{packages/sdk/dist → dist}/auditor.js +0 -0
  75. /package/{packages/sdk/dist → dist}/bitcoin/ika.d.ts +0 -0
  76. /package/{packages/sdk/dist → dist}/bitcoin/ika.js +0 -0
  77. /package/{packages/sdk/dist → dist}/bitcoin/index.d.ts +0 -0
  78. /package/{packages/sdk/dist → dist}/bitcoin/index.js +0 -0
  79. /package/{packages/sdk/dist → dist}/bound-params.d.ts +0 -0
  80. /package/{packages/sdk/dist → dist}/bound-params.js +0 -0
  81. /package/{packages/sdk/dist → dist}/chadbuffer.d.ts +0 -0
  82. /package/{packages/sdk/dist → dist}/chadbuffer.js +0 -0
  83. /package/{packages/sdk/dist → dist}/claim-link.d.ts +0 -0
  84. /package/{packages/sdk/dist → dist}/claim-link.js +0 -0
  85. /package/{packages/sdk/dist → dist}/commitment-tree.d.ts +0 -0
  86. /package/{packages/sdk/dist → dist}/commitment-tree.js +0 -0
  87. /package/{packages/sdk/dist → dist}/config.d.ts +0 -0
  88. /package/{packages/sdk/dist → dist}/config.js +0 -0
  89. /package/{packages/sdk/dist → dist}/core/esplora.d.ts +0 -0
  90. /package/{packages/sdk/dist → dist}/core/esplora.js +0 -0
  91. /package/{packages/sdk/dist → dist}/core/mempool.d.ts +0 -0
  92. /package/{packages/sdk/dist → dist}/core/mempool.js +0 -0
  93. /package/{packages/sdk/dist → dist}/crypto-babyjub.d.ts +0 -0
  94. /package/{packages/sdk/dist → dist}/crypto-babyjub.js +0 -0
  95. /package/{packages/sdk/dist → dist}/crypto.d.ts +0 -0
  96. /package/{packages/sdk/dist → dist}/crypto.js +0 -0
  97. /package/{packages/sdk/dist → dist}/event-client.d.ts +0 -0
  98. /package/{packages/sdk/dist → dist}/event-client.js +0 -0
  99. /package/{packages/sdk/dist → dist}/events.d.ts +0 -0
  100. /package/{packages/sdk/dist → dist}/events.js +0 -0
  101. /package/{packages/sdk/dist → dist}/explorer.d.ts +0 -0
  102. /package/{packages/sdk/dist → dist}/explorer.js +0 -0
  103. /package/{packages/sdk/dist → dist}/keys.d.ts +0 -0
  104. /package/{packages/sdk/dist → dist}/keys.js +0 -0
  105. /package/{packages/sdk/dist → dist}/logger.d.ts +0 -0
  106. /package/{packages/sdk/dist → dist}/logger.js +0 -0
  107. /package/{packages/sdk/dist → dist}/magicblock.d.ts +0 -0
  108. /package/{packages/sdk/dist → dist}/magicblock.js +0 -0
  109. /package/{packages/sdk/dist → dist}/merkle.d.ts +0 -0
  110. /package/{packages/sdk/dist → dist}/merkle.js +0 -0
  111. /package/{packages/sdk/dist → dist}/note.d.ts +0 -0
  112. /package/{packages/sdk/dist → dist}/note.js +0 -0
  113. /package/{packages/sdk/dist → dist}/pda.d.ts +0 -0
  114. /package/{packages/sdk/dist → dist}/pda.js +0 -0
  115. /package/{packages/sdk/dist → dist}/pool-state.d.ts +0 -0
  116. /package/{packages/sdk/dist → dist}/pool-state.js +0 -0
  117. /package/{packages/sdk/dist → dist}/poseidon.d.ts +0 -0
  118. /package/{packages/sdk/dist → dist}/poseidon.js +0 -0
  119. /package/{packages/sdk/dist → dist}/prover/index.d.ts +0 -0
  120. /package/{packages/sdk/dist → dist}/prover/index.js +0 -0
  121. /package/{packages/sdk/dist → dist}/prover/mobile.d.ts +0 -0
  122. /package/{packages/sdk/dist → dist}/prover/mobile.js +0 -0
  123. /package/{packages/sdk/dist → dist}/prover/web.d.ts +0 -0
  124. /package/{packages/sdk/dist → dist}/prover/web.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/keys.ts ADDED
@@ -0,0 +1,1228 @@
1
+ /**
2
+ * Key Derivation for UTXOpia (Baby Jubjub + Ed25519)
3
+ *
4
+ * Dual-curve architecture (Railgun-style):
5
+ * - Baby Jubjub spending key: SNARK-friendly, in-circuit verification via BabyPbk()
6
+ * - Ed25519 viewing key: Standard fast curve, off-chain ECDH only
7
+ *
8
+ * Key Architecture:
9
+ * ```
10
+ * Solana Wallet (Ed25519)
11
+ * │
12
+ * │ signs message: "UTXOpia key derivation v1"
13
+ * ▼
14
+ * Signature (64 bytes)
15
+ * │
16
+ * ├──► SHA256(sig || "spend") mod BJJ_ORDER ──► Baby Jubjub Spending Key
17
+ * │
18
+ * └──► SHA256(sig || "view") ──► Ed25519 Viewing Key
19
+ * ```
20
+ *
21
+ * Stealth Address Flow:
22
+ * ```
23
+ * Sender:
24
+ * 1. ephemeral = random Ed25519 keypair
25
+ * 2. sharedSecret = X25519(ephemeral.priv, viewingPubX25519)
26
+ * 3. stealthScalar = SHA256(sharedSecret || domain) mod BJJ_ORDER
27
+ * 4. stealthPub = spendingPub + stealthScalar × BASE8
28
+ * 5. commitment = Poseidon(stealthPub.x, amount)
29
+ *
30
+ * Recipient (viewing key - can detect):
31
+ * 1. sharedSecret = X25519(viewingPriv, ephemeralPub)
32
+ * 2. Decrypt amount, derive stealthPub, verify commitment
33
+ *
34
+ * Recipient (spending key - can claim):
35
+ * 1. stealthPriv = spendingPriv + stealthScalar
36
+ * 2. Circuit proves: BabyPbk(stealthPriv).x == pub_key_x
37
+ * ```
38
+ */
39
+
40
+ import { sha256 } from "@noble/hashes/sha2.js";
41
+ import { pbkdf2 } from "@noble/hashes/pbkdf2";
42
+ import {
43
+ scalarFromBytes,
44
+ bigintToBytes,
45
+ bytesToHex,
46
+ hexToBytes,
47
+ babyJubMul,
48
+ BABYJUB_BASE8,
49
+ babyJubCompress,
50
+ babyJubDecompress,
51
+ type BabyJubPoint,
52
+ } from "./crypto";
53
+ import {
54
+ ed25519GetPublicKey,
55
+ ed25519GenerateKeyPair,
56
+ ed25519DeriveKeyFromSeed,
57
+ } from "./crypto-ed25519";
58
+ import { computeMPKSync, poseidonHashSync } from "./poseidon";
59
+ import { BABYJUB_ORDER } from "./crypto-babyjub";
60
+ // circomlibjs is lazily imported to avoid pulling ffjavascript WASM into React Native bundles.
61
+ // Only eddsaGetPubKey, eddsaGetPrivScalar, and eddsaPoseidonSign need it (web/Node.js only).
62
+ type Eddsa = any;
63
+
64
+ // ========== Types ==========
65
+
66
+ /**
67
+ * Complete UTXOpia key hierarchy derived from Solana wallet
68
+ *
69
+ * Uses Baby Jubjub for spending keys and Ed25519 for viewing keys.
70
+ */
71
+ export interface UTXOpiaKeys {
72
+ /** Solana public key (32 bytes) - user identity */
73
+ solanaPublicKey: Uint8Array;
74
+
75
+ /** Baby Jubjub spending private key (scalar) - for stealthPriv and nullifier */
76
+ spendingPrivKey: bigint;
77
+
78
+ /** Baby Jubjub spending public key (point) - share publicly */
79
+ spendingPubKey: BabyJubPoint;
80
+
81
+ /** Nullifying key (BN254 scalar) - for JoinSplit nullifier computation */
82
+ nullifyingKey: bigint;
83
+
84
+ /** Ed25519 viewing private key (32 bytes) - for X25519 ECDH scanning */
85
+ viewingPrivKey: Uint8Array;
86
+
87
+ /** Ed25519 viewing public key (32 bytes) - share publicly */
88
+ viewingPubKey: Uint8Array;
89
+
90
+ /** Raw EdDSA seed bytes (32 bytes) - for circomlibjs EdDSA-Poseidon signing */
91
+ eddsaSeed: Uint8Array;
92
+ }
93
+
94
+ /**
95
+ * Stealth meta-address for receiving funds
96
+ *
97
+ * Total size: 96 bytes (32 BJJ compressed + 32 Ed25519 + 32 MPK)
98
+ */
99
+ export interface StealthMetaAddress {
100
+ /** Baby Jubjub spending public key (32 bytes compressed) */
101
+ spendingPubKey: Uint8Array;
102
+
103
+ /** Ed25519 viewing public key (32 bytes) */
104
+ viewingPubKey: Uint8Array;
105
+
106
+ /** Master public key (32 bytes, Poseidon hash as BE bytes) */
107
+ mpk: Uint8Array;
108
+ }
109
+
110
+ /**
111
+ * Serialized stealth meta-address for display/sharing
112
+ */
113
+ export interface SerializedStealthMetaAddress {
114
+ /** Hex-encoded spending public key */
115
+ spendingPubKey: string;
116
+
117
+ /** Hex-encoded viewing public key */
118
+ viewingPubKey: string;
119
+
120
+ /** Hex-encoded master public key (Poseidon hash) */
121
+ mpk: string;
122
+ }
123
+
124
+ export interface AuthSignatureKeyDerivationOptions {
125
+ /** Wallet address or app account label used as domain context. */
126
+ account?: string;
127
+ /** Chain label used for domain separation. */
128
+ chain?: string;
129
+ /** Network label used for domain separation. */
130
+ network?: string;
131
+ }
132
+
133
+ export interface AuthSignatureKeySetupResult {
134
+ keys: UTXOpiaKeys;
135
+ stealthMetaAddress: StealthMetaAddress;
136
+ encodedStealthAddress: string;
137
+ root: Uint8Array;
138
+ }
139
+
140
+ export const PASSKEY_CHAIN_SCOPE_DOMAIN = "utxopia-passkey-chain:v1";
141
+
142
+ export type PasskeyChainScope = "sol";
143
+
144
+ export interface ChainScopedPasskeyOptions {
145
+ chain: PasskeyChainScope;
146
+ network: string;
147
+ }
148
+
149
+ /**
150
+ * Scope a passkey seed to one app chain+network identity.
151
+ *
152
+ * This convention is intentionally centralized in the SDK so apps, tests, and
153
+ * dev tooling derive the same private address without duplicating string
154
+ * literals.
155
+ */
156
+ export function deriveChainScopedPasskeySeed(
157
+ seed: Uint8Array,
158
+ options: ChainScopedPasskeyOptions,
159
+ ): Uint8Array {
160
+ const domain = new TextEncoder().encode(
161
+ `${PASSKEY_CHAIN_SCOPE_DOMAIN}:${options.chain}:${options.network}`,
162
+ );
163
+ const material = new Uint8Array(domain.length + seed.length);
164
+ material.set(domain, 0);
165
+ material.set(seed, domain.length);
166
+ return sha256(material);
167
+ }
168
+
169
+ export function passkeyStorageOwner(
170
+ credentialId: string,
171
+ options: ChainScopedPasskeyOptions,
172
+ ): string {
173
+ return `passkey:${credentialId}:${options.chain}:${options.network}`;
174
+ }
175
+
176
+ /**
177
+ * View permission flags for delegated viewing keys
178
+ */
179
+ export enum ViewPermissions {
180
+ /** Can scan announcements and see amounts */
181
+ SCAN = 1 << 0,
182
+
183
+ /** Can see full transaction history */
184
+ HISTORY = 1 << 1,
185
+
186
+ /** Can see incoming transactions only */
187
+ INCOMING_ONLY = 1 << 2,
188
+
189
+ /** Full viewing access (scan + history) */
190
+ FULL = SCAN | HISTORY,
191
+ }
192
+
193
+ /**
194
+ * Delegated viewing key for auditors/compliance
195
+ *
196
+ * Uses Ed25519 private key for viewing. Deposit verification additionally
197
+ * requires `spendingPubKeyCompressed` and `nullifyingKey` so the scanner
198
+ * can compute MPK; without them, only transfer-type announcements can be
199
+ * confidently matched.
200
+ */
201
+ export interface DelegatedViewKey {
202
+ /** Ed25519 viewing private key (32 bytes) */
203
+ viewingPrivKey: Uint8Array;
204
+
205
+ /** Permission flags */
206
+ permissions: ViewPermissions;
207
+
208
+ /** Baby Jubjub spending pubkey, compressed (32 bytes). Needed for deposit verification. */
209
+ spendingPubKeyCompressed?: Uint8Array;
210
+
211
+ /** Nullifying key (BN254 scalar). Needed for deposit verification. */
212
+ nullifyingKey?: bigint;
213
+
214
+ /** Inclusive lower slot bound for audit scope (honor-system on auditor side). */
215
+ fromSlot?: number;
216
+
217
+ /** Inclusive upper slot bound for audit scope. */
218
+ toSlot?: number;
219
+
220
+ /** Optional expiration timestamp (Unix ms) */
221
+ expiresAt?: number;
222
+
223
+ /** Optional label for identification */
224
+ label?: string;
225
+
226
+ /** Unix ms when the delegation was created (set by createDelegatedViewKey). */
227
+ issuedAt?: number;
228
+
229
+ /** Stable opaque ID for tracking this delegation in the user's audit trail. */
230
+ delegationId?: string;
231
+ }
232
+
233
+ /**
234
+ * Public record of a delegation that the *user* keeps as their audit trail of
235
+ * keys they have handed out — never carries the viewing private key itself.
236
+ */
237
+ export interface DelegationRecord {
238
+ delegationId: string;
239
+ fingerprint: string;
240
+ permissions: ViewPermissions;
241
+ fromSlot?: number;
242
+ toSlot?: number;
243
+ expiresAt?: number;
244
+ issuedAt: number;
245
+ label?: string;
246
+ /** Optional free-form note about who received the key (auditor name, firm, etc.). */
247
+ recipient?: string;
248
+ }
249
+
250
+ // ========== Constants ==========
251
+
252
+ /** Message to sign for key derivation */
253
+ export const SPENDING_KEY_DERIVATION_MESSAGE =
254
+ "UTXOpia key derivation v1";
255
+
256
+ /** Domain separator for spending key derivation */
257
+ const SPENDING_KEY_DOMAIN = "spend";
258
+
259
+ /** Domain separator for viewing key derivation */
260
+ const VIEWING_KEY_DOMAIN = "view";
261
+
262
+ /** Domain separator for nullifying key derivation */
263
+ const NULLIFYING_KEY_DOMAIN = "nullify";
264
+
265
+ const AUTH_SIGNATURE_ROOT_DOMAIN = "utxopia:auth-signature-root:v1";
266
+ const AUTH_SPENDING_DOMAIN = "utxopia:spending:eddsa-poseidon:v1";
267
+ const AUTH_NULLIFYING_DOMAIN = "utxopia:nullifier:bn254:v1";
268
+ const AUTH_VIEWING_DOMAIN = "utxopia:viewing:ed25519:v1";
269
+
270
+ // ========== EdDSA-Poseidon Helpers ==========
271
+
272
+ let eddsaInstance: Eddsa | null = null;
273
+
274
+ async function getEddsa(): Promise<Eddsa> {
275
+ if (!eddsaInstance) {
276
+ const { buildEddsa } = (await import("circomlibjs")) as unknown as {
277
+ buildEddsa(): Promise<Eddsa>;
278
+ };
279
+ eddsaInstance = await buildEddsa();
280
+ }
281
+ return eddsaInstance;
282
+ }
283
+
284
+ /**
285
+ * Derive Baby Jubjub public key from raw seed using circomlibjs EdDSA.
286
+ *
287
+ * circomlibjs internally hashes the seed (like standard EdDSA key derivation),
288
+ * producing keys compatible with the EdDSAPoseidonVerifier circuit.
289
+ * This is NOT the same as `babyJubMul(scalarFromBytes(seed), BASE8)`.
290
+ */
291
+ export async function eddsaGetPubKey(seed: Uint8Array): Promise<BabyJubPoint> {
292
+ const eddsa = await getEddsa();
293
+ const F = eddsa.babyJub.F;
294
+ const pubKey = eddsa.prv2pub(new Uint8Array(seed));
295
+ return {
296
+ x: F.toObject(pubKey[0]) as bigint,
297
+ y: F.toObject(pubKey[1]) as bigint,
298
+ };
299
+ }
300
+
301
+ /**
302
+ * Extract the internal EdDSA private scalar from a seed.
303
+ *
304
+ * circomlibjs does: BLAKE-512(seed) → pruneBuffer → fromRprLE(32 bytes) → shr(3)
305
+ * This scalar × BASE8 = the public key from `eddsaGetPubKey(seed)`.
306
+ *
307
+ * We intercept circomlibjs's `pruneBuffer` call during `prv2pub` to capture
308
+ * the intermediate buffer, then replicate the LE→bigint→shr(3) conversion.
309
+ * This avoids directly importing ffjavascript/blake-hash which aren't bundled by webpack.
310
+ */
311
+ export async function eddsaGetPrivScalar(seed: Uint8Array): Promise<bigint> {
312
+ const eddsa = await getEddsa();
313
+ const F = eddsa.babyJub.F;
314
+
315
+ // Intercept the pruneBuffer call to capture the raw scalar.
316
+ // circomlibjs prv2pub does: sBuff = pruneBuffer(blake512(seed)); s = fromRprLE(sBuff); A = Base8 * (s >> 3)
317
+ // We temporarily replace pruneBuffer to capture sBuff.
318
+ let capturedBuff: Uint8Array | null = null;
319
+ const origPrune = eddsa.pruneBuffer.bind(eddsa);
320
+ (eddsa as any).pruneBuffer = (buff: any) => {
321
+ const result = origPrune(buff);
322
+ capturedBuff = new Uint8Array(result);
323
+ return result;
324
+ };
325
+
326
+ try {
327
+ // Call prv2pub which triggers pruneBuffer internally
328
+ eddsa.prv2pub(new Uint8Array(seed));
329
+ } finally {
330
+ // Restore original
331
+ (eddsa as any).pruneBuffer = origPrune;
332
+ }
333
+
334
+ if (!capturedBuff) {
335
+ throw new Error("Failed to capture EdDSA scalar buffer");
336
+ }
337
+
338
+ // Convert first 32 bytes from little-endian to bigint (same as Scalar.fromRprLE)
339
+ let s = 0n;
340
+ for (let i = 31; i >= 0; i--) {
341
+ s = (s << 8n) | BigInt(capturedBuff[i]);
342
+ }
343
+
344
+ // Right-shift by 3 (same as Scalar.shr(s, 3) in circomlibjs)
345
+ return s >> 3n;
346
+ }
347
+
348
+ /**
349
+ * Sign a message hash with EdDSA-Poseidon (circomlibjs).
350
+ *
351
+ * Returns [R8.x, R8.y, S] compatible with the EdDSAPoseidonVerifier circuit.
352
+ */
353
+ export async function eddsaPoseidonSign(
354
+ seed: Uint8Array,
355
+ msgHash: bigint,
356
+ ): Promise<[bigint, bigint, bigint]> {
357
+ const eddsa = await getEddsa();
358
+ const F = eddsa.babyJub.F;
359
+ const msgF = F.e(msgHash);
360
+ const signature = eddsa.signPoseidon(new Uint8Array(seed), msgF);
361
+ const R8x = F.toObject(signature.R8[0]) as bigint;
362
+ const R8y = F.toObject(signature.R8[1]) as bigint;
363
+ const S = signature.S;
364
+ return [R8x, R8y, S];
365
+ }
366
+
367
+ /**
368
+ * Sign a message hash with EdDSA-Poseidon using a given private scalar directly.
369
+ *
370
+ * Unlike `eddsaPoseidonSign` which derives the scalar internally via circomlibjs's
371
+ * BLAKE-512 derivation, this function uses the provided scalar as-is.
372
+ * This is needed when the public key was derived via `scalarFromBytes` (sync)
373
+ * rather than circomlibjs's internal derivation.
374
+ *
375
+ * Returns [R8.x, R8.y, S] compatible with the EdDSAPoseidonVerifier circuit.
376
+ */
377
+ export function eddsaPoseidonSignWithScalar(
378
+ privScalar: bigint,
379
+ pubKey: BabyJubPoint,
380
+ msgHash: bigint,
381
+ ): [bigint, bigint, bigint] {
382
+ // Deterministic nonce: r = Poseidon(privScalar, msgHash) mod BABYJUB_ORDER
383
+ const r = poseidonHashSync([privScalar, msgHash]) % BABYJUB_ORDER;
384
+
385
+ // R8 = r * BASE8
386
+ const R8 = babyJubMul(r, BABYJUB_BASE8);
387
+
388
+ // hm = Poseidon(R8.x, R8.y, pubKey.x, pubKey.y, msgHash)
389
+ const hm = poseidonHashSync([R8.x, R8.y, pubKey.x, pubKey.y, msgHash]);
390
+
391
+ // S = (r + privScalar * hm) mod BABYJUB_ORDER
392
+ const S = (r + privScalar * hm) % BABYJUB_ORDER;
393
+
394
+ return [R8.x, R8.y, S];
395
+ }
396
+
397
+ // ========== Wallet Adapter Interface ==========
398
+
399
+ /**
400
+ * Minimal wallet adapter interface for signing
401
+ * Compatible with @solana/wallet-adapter-base
402
+ */
403
+ export interface WalletSignerAdapter {
404
+ publicKey: { toBytes(): Uint8Array } | null;
405
+ signMessage(message: Uint8Array): Promise<Uint8Array>;
406
+ }
407
+
408
+ // ========== Key Derivation ==========
409
+
410
+ /**
411
+ * Derive UTXOpia keys from Solana wallet signature.
412
+ *
413
+ * Uses circomlibjs EdDSA for spendingPubKey derivation so keys are
414
+ * compatible with the EdDSAPoseidonVerifier circuit.
415
+ */
416
+ export async function deriveKeysFromWallet(
417
+ wallet: WalletSignerAdapter
418
+ ): Promise<UTXOpiaKeys> {
419
+ if (!wallet.publicKey) {
420
+ throw new Error("Wallet not connected");
421
+ }
422
+
423
+ const message = new TextEncoder().encode(SPENDING_KEY_DERIVATION_MESSAGE);
424
+ const signature = await wallet.signMessage(message);
425
+
426
+ // Start with sync key derivation (babyJubMul-based)
427
+ const baseKeys = deriveKeysFromSignature(signature, wallet.publicKey.toBytes());
428
+
429
+ // Override spending keys with circomlibjs-derived versions for circuit compatibility.
430
+ // circomlibjs does: blake512(seed) → prune → fromRprLE → shr(3) → mulPointEscalar(Base8, scalar)
431
+ // Both spendingPrivKey and spendingPubKey must correspond so that:
432
+ // stealthPriv = spendingPrivKey + stealthScalar → babyJubMul(stealthPriv, BASE8) = stealthPub
433
+ const spendingPubKey = await eddsaGetPubKey(baseKeys.eddsaSeed);
434
+ const spendingPrivKey = await eddsaGetPrivScalar(baseKeys.eddsaSeed);
435
+
436
+ return {
437
+ ...baseKeys,
438
+ spendingPubKey,
439
+ spendingPrivKey,
440
+ };
441
+ }
442
+
443
+ /**
444
+ * Derive UTXOpia keys from a signature
445
+ *
446
+ * Spending key: SHA256(sig || "spend") → reduce mod BJJ_ORDER → babyJubMul(scalar, BASE8)
447
+ * Viewing key: SHA256(sig || "view") → Ed25519 private key → ed25519.getPublicKey()
448
+ */
449
+ export function deriveKeysFromSignature(
450
+ signature: Uint8Array,
451
+ solanaPublicKey: Uint8Array
452
+ ): UTXOpiaKeys {
453
+ if (signature.length !== 64) {
454
+ throw new Error("Signature must be 64 bytes");
455
+ }
456
+
457
+ if (solanaPublicKey.length !== 32) {
458
+ throw new Error("Solana public key must be 32 bytes");
459
+ }
460
+
461
+ // Derive spending key: SHA256(signature || "spend") → Baby Jubjub scalar
462
+ const spendingSeed = sha256(
463
+ concatBytes(signature, new TextEncoder().encode(SPENDING_KEY_DOMAIN))
464
+ );
465
+ // Store raw seed for circomlibjs EdDSA signing (used by deriveKeysFromWallet to override pubkey)
466
+ const eddsaSeed = new Uint8Array(spendingSeed);
467
+ const spendingPrivKey = scalarFromBytes(spendingSeed);
468
+ const spendingPubKey = babyJubMul(spendingPrivKey, BABYJUB_BASE8);
469
+
470
+ // Clear intermediate seed
471
+ clearKey(spendingSeed);
472
+
473
+ // Derive nullifying key: SHA256(signature || "nullify") → BN254 scalar
474
+ const nullifyingSeed = sha256(
475
+ concatBytes(signature, new TextEncoder().encode(NULLIFYING_KEY_DOMAIN))
476
+ );
477
+ const nullifyingKey = scalarFromBytes(nullifyingSeed);
478
+ clearKey(nullifyingSeed);
479
+
480
+ // Derive viewing key: SHA256(signature || "view") → Ed25519 private key
481
+ const viewingPrivKey = sha256(
482
+ concatBytes(signature, new TextEncoder().encode(VIEWING_KEY_DOMAIN))
483
+ );
484
+ const viewingPubKey = ed25519GetPublicKey(viewingPrivKey);
485
+
486
+ return {
487
+ solanaPublicKey,
488
+ spendingPrivKey,
489
+ spendingPubKey,
490
+ nullifyingKey,
491
+ viewingPrivKey,
492
+ viewingPubKey,
493
+ eddsaSeed,
494
+ };
495
+ }
496
+
497
+ /**
498
+ * Generate a random 65-byte signature-shaped seed for dev/test auth flows.
499
+ *
500
+ * Mirrors Fluidkey's "signature as deterministic key source" shape without
501
+ * requiring a wallet or zkLogin proof during local testing.
502
+ */
503
+ export function generateRandomAuthSignature(): Uint8Array {
504
+ const signature = new Uint8Array(65);
505
+ crypto.getRandomValues(signature);
506
+ signature[64] = signature[64] % 2 === 0 ? 27 : 28;
507
+ return signature;
508
+ }
509
+
510
+ /**
511
+ * Derive UTXOpia keys from a wallet/zkLogin signature-shaped secret.
512
+ *
513
+ * User-facing model is two keys:
514
+ * - spending seed/key
515
+ * - viewing seed/key
516
+ *
517
+ * The protocol nullifying key is internal and derived from the spending seed,
518
+ * so delegated viewing keys do not automatically carry nullifier authority.
519
+ */
520
+ export async function deriveKeysFromAuthSignature(
521
+ signature: Uint8Array,
522
+ options: AuthSignatureKeyDerivationOptions = {},
523
+ ): Promise<UTXOpiaKeys> {
524
+ const normalized = normalizeAuthSignature(signature);
525
+ const root = deriveAuthSignatureRoot(normalized, options);
526
+ const spendingSeed = deriveAuthSecret(root, AUTH_SPENDING_DOMAIN);
527
+ const viewingPrivKey = deriveAuthSecret(root, AUTH_VIEWING_DOMAIN);
528
+ const nullifyingSeed = deriveAuthSecret(spendingSeed, AUTH_NULLIFYING_DOMAIN);
529
+
530
+ const eddsaSeed = new Uint8Array(spendingSeed);
531
+ // Spending keypair must use the circomlibjs EdDSA derivation that signing uses
532
+ // (same as deriveKeysFromWallet/deriveKeysFromSeedCircuit), else notes are unspendable.
533
+ const spendingPubKey = await eddsaGetPubKey(eddsaSeed);
534
+ const spendingPrivKey = await eddsaGetPrivScalar(eddsaSeed);
535
+ const nullifyingKey = scalarFromBytes(nullifyingSeed);
536
+ const viewingPubKey = ed25519GetPublicKey(viewingPrivKey);
537
+ const identityHash = sha256(
538
+ concatBytes(root, new TextEncoder().encode("utxopia:auth-identity:v1")),
539
+ );
540
+
541
+ clearKey(spendingSeed);
542
+ clearKey(nullifyingSeed);
543
+
544
+ return {
545
+ solanaPublicKey: identityHash,
546
+ spendingPrivKey,
547
+ spendingPubKey,
548
+ nullifyingKey,
549
+ viewingPrivKey,
550
+ viewingPubKey,
551
+ eddsaSeed,
552
+ };
553
+ }
554
+
555
+ export async function setupKeysFromAuthSignature(
556
+ signature: Uint8Array,
557
+ options: AuthSignatureKeyDerivationOptions = {},
558
+ ): Promise<AuthSignatureKeySetupResult> {
559
+ const normalized = normalizeAuthSignature(signature);
560
+ const root = deriveAuthSignatureRoot(normalized, options);
561
+ const keys = await deriveKeysFromAuthSignature(normalized, options);
562
+ const stealthMetaAddress = createStealthMetaAddress(keys);
563
+ const encodedStealthAddress = encodeStealthMetaAddress(stealthMetaAddress);
564
+
565
+ return {
566
+ keys,
567
+ stealthMetaAddress,
568
+ encodedStealthAddress,
569
+ root,
570
+ };
571
+ }
572
+
573
+ /**
574
+ * Derive keys from a seed phrase (sync — for scanning/non-circuit use)
575
+ */
576
+ export function deriveKeysFromSeed(seed: Uint8Array): UTXOpiaKeys {
577
+ const fakeSig = new Uint8Array(64);
578
+ const hash1 = sha256(seed);
579
+ const hash2 = sha256(concatBytes(seed, new Uint8Array([1])));
580
+ fakeSig.set(hash1, 0);
581
+ fakeSig.set(hash2, 32);
582
+
583
+ return deriveKeysFromSignature(fakeSig, new Uint8Array(32));
584
+ }
585
+
586
+ /**
587
+ * Derive keys from a seed phrase with circomlibjs-compatible spending keys.
588
+ *
589
+ * Must be used when the keys will be used for circuit proofs (EdDSA signing).
590
+ * The sync `deriveKeysFromSeed` uses a different scalar derivation that doesn't
591
+ * match circomlibjs's internal BLAKE-512 derivation used by `eddsaPoseidonSign`.
592
+ */
593
+ export async function deriveKeysFromSeedCircuit(seed: Uint8Array): Promise<UTXOpiaKeys> {
594
+ const baseKeys = deriveKeysFromSeed(seed);
595
+
596
+ // Override spending keys with circomlibjs-derived versions (same as deriveKeysFromWallet)
597
+ const spendingPubKey = await eddsaGetPubKey(baseKeys.eddsaSeed);
598
+ const spendingPrivKey = await eddsaGetPrivScalar(baseKeys.eddsaSeed);
599
+
600
+ return {
601
+ ...baseKeys,
602
+ spendingPubKey,
603
+ spendingPrivKey,
604
+ };
605
+ }
606
+
607
+ // ========== Stealth Meta-Address ==========
608
+
609
+ /**
610
+ * Create a stealth meta-address from UTXOpia keys
611
+ *
612
+ * Size: 96 bytes (32 BJJ compressed + 32 Ed25519 + 32 MPK)
613
+ */
614
+ export function createStealthMetaAddress(keys: UTXOpiaKeys): StealthMetaAddress {
615
+ const mpk = computeMPKSync(
616
+ keys.spendingPubKey.x,
617
+ keys.spendingPubKey.y,
618
+ keys.nullifyingKey
619
+ );
620
+ return {
621
+ spendingPubKey: babyJubCompress(keys.spendingPubKey),
622
+ viewingPubKey: new Uint8Array(keys.viewingPubKey),
623
+ mpk: bigintToBytes(mpk),
624
+ };
625
+ }
626
+
627
+ /**
628
+ * Serialize a stealth meta-address for display/sharing
629
+ */
630
+ export function serializeStealthMetaAddress(
631
+ meta: StealthMetaAddress
632
+ ): SerializedStealthMetaAddress {
633
+ return {
634
+ spendingPubKey: bytesToHex(meta.spendingPubKey),
635
+ viewingPubKey: bytesToHex(meta.viewingPubKey),
636
+ mpk: bytesToHex(meta.mpk),
637
+ };
638
+ }
639
+
640
+ /**
641
+ * Deserialize a stealth meta-address from string representation
642
+ */
643
+ export function deserializeStealthMetaAddress(
644
+ serialized: SerializedStealthMetaAddress
645
+ ): StealthMetaAddress {
646
+ return {
647
+ spendingPubKey: hexToBytes(serialized.spendingPubKey),
648
+ viewingPubKey: hexToBytes(serialized.viewingPubKey),
649
+ mpk: hexToBytes(serialized.mpk),
650
+ };
651
+ }
652
+
653
+ /**
654
+ * Parse a stealth meta-address and extract public keys
655
+ *
656
+ * Returns Baby Jubjub spending pubkey and Ed25519 viewing pubkey.
657
+ */
658
+ export function parseStealthMetaAddress(meta: StealthMetaAddress): {
659
+ spendingPubKey: BabyJubPoint;
660
+ viewingPubKey: Uint8Array;
661
+ } {
662
+ return {
663
+ spendingPubKey: babyJubDecompress(meta.spendingPubKey),
664
+ viewingPubKey: new Uint8Array(meta.viewingPubKey),
665
+ };
666
+ }
667
+
668
+ /**
669
+ * Encode stealth meta-address as a single string with utxo: prefix
670
+ * Format: "utxo:" + hex(spendingPubKey (32) || viewingPubKey (32) || mpk (32))
671
+ */
672
+ export function encodeStealthMetaAddress(meta: StealthMetaAddress): string {
673
+ const combined = concatBytes(meta.spendingPubKey, meta.viewingPubKey, meta.mpk);
674
+ return "utxo:" + bytesToHex(combined);
675
+ }
676
+
677
+ /**
678
+ * Decode stealth meta-address from a string (with or without utxo: prefix)
679
+ */
680
+ export function decodeStealthMetaAddress(encoded: string): StealthMetaAddress {
681
+ // "utxo:" is 5 chars — slice(5), not slice(6). Off-by-one was eating one
682
+ // hex character and producing a 47-byte buffer that failed the length check.
683
+ const hex = encoded.startsWith("utxo:") ? encoded.slice(5) : encoded;
684
+ const bytes = hexToBytes(hex);
685
+ if (bytes.length !== 96) {
686
+ throw new Error("Invalid stealth meta-address length (expected 96 bytes)");
687
+ }
688
+ return {
689
+ spendingPubKey: bytes.slice(0, 32),
690
+ viewingPubKey: bytes.slice(32, 64),
691
+ mpk: bytes.slice(64, 96),
692
+ };
693
+ }
694
+
695
+ // ========== Viewing Key Delegation ==========
696
+
697
+ /**
698
+ * Create a delegated viewing key for auditors/compliance
699
+ *
700
+ * The returned key carries everything an auditor needs to scan announcements
701
+ * within the configured slot range, including the spending pubkey and
702
+ * nullifying key (required for deposit verification — `Poseidon(npk, token, amount)`
703
+ * must match on-chain commitment). A fresh `delegationId` and `issuedAt` are
704
+ * generated so the user can keep an [[auditable-disclosure-status]] trail of
705
+ * who they handed keys to.
706
+ */
707
+ export function createDelegatedViewKey(
708
+ keys: UTXOpiaKeys,
709
+ permissions: ViewPermissions = ViewPermissions.FULL,
710
+ options: {
711
+ fromSlot?: number;
712
+ toSlot?: number;
713
+ expiresAt?: number;
714
+ label?: string;
715
+ } = {}
716
+ ): DelegatedViewKey {
717
+ const issuedAt = Date.now();
718
+ const delegationId = generateDelegationId();
719
+ return {
720
+ viewingPrivKey: new Uint8Array(keys.viewingPrivKey),
721
+ spendingPubKeyCompressed: babyJubCompress(keys.spendingPubKey),
722
+ nullifyingKey: keys.nullifyingKey,
723
+ permissions,
724
+ fromSlot: options.fromSlot,
725
+ toSlot: options.toSlot,
726
+ expiresAt: options.expiresAt,
727
+ label: options.label,
728
+ issuedAt,
729
+ delegationId,
730
+ };
731
+ }
732
+
733
+ /**
734
+ * Build a public-only record of a delegated viewing key for the issuer's
735
+ * audit trail. Strips the secret material; only carries identifiers + scope.
736
+ */
737
+ export function makeDelegationRecord(
738
+ key: DelegatedViewKey,
739
+ options: { recipient?: string } = {}
740
+ ): DelegationRecord {
741
+ return {
742
+ delegationId: key.delegationId ?? generateDelegationId(),
743
+ fingerprint: fingerprintDelegatedKey(key),
744
+ permissions: key.permissions,
745
+ fromSlot: key.fromSlot,
746
+ toSlot: key.toSlot,
747
+ expiresAt: key.expiresAt,
748
+ issuedAt: key.issuedAt ?? Date.now(),
749
+ label: key.label,
750
+ recipient: options.recipient,
751
+ };
752
+ }
753
+
754
+ /**
755
+ * Compute a stable fingerprint for a delegated viewing key.
756
+ *
757
+ * `sha256(viewingPrivKey)[..16]` rendered as hex — short enough to display,
758
+ * long enough to make collisions astronomically unlikely. Identical viewing
759
+ * keys produce identical fingerprints, so the user can detect duplicate
760
+ * delegations across export sessions.
761
+ */
762
+ export function fingerprintDelegatedKey(key: DelegatedViewKey): string {
763
+ return bytesToHex(sha256(key.viewingPrivKey).slice(0, 16));
764
+ }
765
+
766
+ function generateDelegationId(): string {
767
+ const buf = new Uint8Array(16);
768
+ crypto.getRandomValues(buf);
769
+ return bytesToHex(buf);
770
+ }
771
+
772
+ /**
773
+ * Decide whether a slot falls within the delegated key's permitted range.
774
+ * No range bound on a side ⇒ unbounded on that side.
775
+ */
776
+ export function isSlotInDelegatedRange(
777
+ key: DelegatedViewKey,
778
+ slot: number | undefined
779
+ ): boolean {
780
+ if (slot == null) {
781
+ // Slot unknown — only accept when the key itself has no range constraint.
782
+ return key.fromSlot == null && key.toSlot == null;
783
+ }
784
+ if (key.fromSlot != null && slot < key.fromSlot) return false;
785
+ if (key.toSlot != null && slot > key.toSlot) return false;
786
+ return true;
787
+ }
788
+
789
+ /**
790
+ * Serialize a delegated viewing key for export (ENCRYPTED)
791
+ */
792
+ export async function serializeDelegatedViewKey(
793
+ key: DelegatedViewKey,
794
+ password?: string
795
+ ): Promise<string> {
796
+ if (!password) {
797
+ throw new Error(
798
+ "Password required for viewing key serialization. " +
799
+ "Unencrypted export is not permitted for security."
800
+ );
801
+ }
802
+
803
+ const passwordBytes = new TextEncoder().encode(password);
804
+ const salt = new Uint8Array(16);
805
+ crypto.getRandomValues(salt);
806
+
807
+ const PBKDF2_ITERATIONS = 600_000;
808
+ const encryptionKey = pbkdf2(sha256, passwordBytes, salt, { c: PBKDF2_ITERATIONS, dkLen: 32 });
809
+
810
+ const nonce = new Uint8Array(12);
811
+ crypto.getRandomValues(nonce);
812
+
813
+ const keyBuffer = encryptionKey.buffer.slice(
814
+ encryptionKey.byteOffset,
815
+ encryptionKey.byteOffset + encryptionKey.byteLength
816
+ ) as ArrayBuffer;
817
+ const nonceBuffer = nonce.buffer.slice(
818
+ nonce.byteOffset,
819
+ nonce.byteOffset + nonce.byteLength
820
+ ) as ArrayBuffer;
821
+ const dataBuffer = key.viewingPrivKey.buffer.slice(
822
+ key.viewingPrivKey.byteOffset,
823
+ key.viewingPrivKey.byteOffset + key.viewingPrivKey.byteLength
824
+ ) as ArrayBuffer;
825
+
826
+ const cryptoKey = await crypto.subtle.importKey(
827
+ "raw",
828
+ keyBuffer,
829
+ { name: "AES-GCM" },
830
+ false,
831
+ ["encrypt"]
832
+ );
833
+
834
+ const ciphertext = await crypto.subtle.encrypt(
835
+ { name: "AES-GCM", iv: nonceBuffer },
836
+ cryptoKey,
837
+ dataBuffer
838
+ );
839
+
840
+ const obj: Record<string, unknown> = {
841
+ version: 2,
842
+ encrypted: true,
843
+ salt: bytesToHex(salt),
844
+ nonce: bytesToHex(nonce),
845
+ ciphertext: bytesToHex(new Uint8Array(ciphertext)),
846
+ permissions: key.permissions,
847
+ expiresAt: key.expiresAt,
848
+ label: key.label,
849
+ delegationId: key.delegationId,
850
+ issuedAt: key.issuedAt,
851
+ fromSlot: key.fromSlot,
852
+ toSlot: key.toSlot,
853
+ fingerprint: fingerprintDelegatedKey(key),
854
+ };
855
+ if (key.spendingPubKeyCompressed) {
856
+ obj.spendingPubKeyCompressed = bytesToHex(key.spendingPubKeyCompressed);
857
+ }
858
+ if (key.nullifyingKey != null) {
859
+ obj.nullifyingKey = key.nullifyingKey.toString(16);
860
+ }
861
+ return JSON.stringify(obj);
862
+ }
863
+
864
+ /**
865
+ * Deserialize a delegated viewing key from JSON.
866
+ *
867
+ * v1 keys are refused at parse time by default (they lack
868
+ * `spendingPubKeyCompressed`/`nullifyingKey`, so `auditScan` would error out
869
+ * downstream anyway — failing early gives callers a clearer message and
870
+ * avoids partial setup). Set `acceptV1: true` to opt in for migration
871
+ * tools that need to crack open old blobs to re-issue them as v2.
872
+ */
873
+ export async function deserializeDelegatedViewKey(
874
+ json: string,
875
+ password?: string,
876
+ options: { acceptV1?: boolean } = {},
877
+ ): Promise<DelegatedViewKey> {
878
+ let obj;
879
+ try {
880
+ obj = JSON.parse(json);
881
+ } catch {
882
+ throw new Error("Invalid delegated view key format");
883
+ }
884
+
885
+ if (!obj.encrypted) {
886
+ const privKeyBytes = hexToBytes(obj.viewingPrivKey);
887
+ return {
888
+ viewingPrivKey: privKeyBytes,
889
+ permissions: obj.permissions,
890
+ expiresAt: obj.expiresAt,
891
+ label: obj.label,
892
+ };
893
+ }
894
+
895
+ if (!password) {
896
+ throw new Error("Password required to decrypt viewing key");
897
+ }
898
+
899
+ // Refuse v1 unless explicitly opted in. v1 keys decrypt fine but lack the
900
+ // spendingPubKey + nullifyingKey material needed by auditScan, so they
901
+ // produce a confusing late-stage failure. Better to surface it here.
902
+ if (obj.version === 1 && !options.acceptV1) {
903
+ throw new Error(
904
+ "Delegated view key is v1 (pre-mpk format). v1 keys can no longer scan " +
905
+ "deposits — re-issue as v2 via createDelegatedViewKey + " +
906
+ "encryptDelegatedViewKey. Pass { acceptV1: true } if you're running " +
907
+ "a one-shot migration.",
908
+ );
909
+ }
910
+
911
+ const salt = hexToBytes(obj.salt);
912
+ const nonce = hexToBytes(obj.nonce);
913
+ const ciphertext = hexToBytes(obj.ciphertext);
914
+ const passwordBytes = new TextEncoder().encode(password);
915
+
916
+ if (obj.version === 1 || obj.version === 2) {
917
+ const iterations = 600_000;
918
+ const encryptionKey = pbkdf2(sha256, passwordBytes, salt, { c: iterations, dkLen: 32 });
919
+
920
+ const keyBuffer = encryptionKey.buffer.slice(
921
+ encryptionKey.byteOffset,
922
+ encryptionKey.byteOffset + encryptionKey.byteLength
923
+ ) as ArrayBuffer;
924
+ const nonceBuffer = nonce.buffer.slice(
925
+ nonce.byteOffset,
926
+ nonce.byteOffset + nonce.byteLength
927
+ ) as ArrayBuffer;
928
+ const ciphertextBuffer = ciphertext.buffer.slice(
929
+ ciphertext.byteOffset,
930
+ ciphertext.byteOffset + ciphertext.byteLength
931
+ ) as ArrayBuffer;
932
+
933
+ const cryptoKey = await crypto.subtle.importKey(
934
+ "raw",
935
+ keyBuffer,
936
+ { name: "AES-GCM" },
937
+ false,
938
+ ["decrypt"]
939
+ );
940
+
941
+ try {
942
+ const plaintext = await crypto.subtle.decrypt(
943
+ { name: "AES-GCM", iv: nonceBuffer },
944
+ cryptoKey,
945
+ ciphertextBuffer
946
+ );
947
+ const out: DelegatedViewKey = {
948
+ viewingPrivKey: new Uint8Array(plaintext),
949
+ permissions: obj.permissions,
950
+ expiresAt: obj.expiresAt,
951
+ label: obj.label,
952
+ };
953
+ if (obj.version === 2) {
954
+ if (typeof obj.spendingPubKeyCompressed === "string") {
955
+ out.spendingPubKeyCompressed = hexToBytes(obj.spendingPubKeyCompressed);
956
+ }
957
+ if (typeof obj.nullifyingKey === "string") {
958
+ out.nullifyingKey = BigInt("0x" + obj.nullifyingKey);
959
+ }
960
+ if (typeof obj.fromSlot === "number") out.fromSlot = obj.fromSlot;
961
+ if (typeof obj.toSlot === "number") out.toSlot = obj.toSlot;
962
+ if (typeof obj.issuedAt === "number") out.issuedAt = obj.issuedAt;
963
+ if (typeof obj.delegationId === "string") out.delegationId = obj.delegationId;
964
+ }
965
+ return out;
966
+ } catch {
967
+ throw new Error("Invalid password or corrupted data");
968
+ }
969
+ }
970
+
971
+ throw new Error(
972
+ "Unsupported encryption format (version " + obj.version + "). " +
973
+ "Supported versions: 1, 2."
974
+ );
975
+ }
976
+
977
+ /**
978
+ * Check if a delegated viewing key is valid (not expired)
979
+ */
980
+ export function isDelegatedKeyValid(key: DelegatedViewKey): boolean {
981
+ if (!key.expiresAt) return true;
982
+ return Date.now() < key.expiresAt;
983
+ }
984
+
985
+ /**
986
+ * Check if a delegated key has a specific permission
987
+ */
988
+ export function hasPermission(
989
+ key: DelegatedViewKey,
990
+ permission: ViewPermissions
991
+ ): boolean {
992
+ return (key.permissions & permission) === permission;
993
+ }
994
+
995
+ // ========== Key Security ==========
996
+
997
+ /**
998
+ * Safely compare two keys in constant time
999
+ */
1000
+ export function constantTimeCompare(a: Uint8Array, b: Uint8Array): boolean {
1001
+ if (a.length !== b.length) return false;
1002
+ let result = 0;
1003
+ for (let i = 0; i < a.length; i++) {
1004
+ result |= a[i] ^ b[i];
1005
+ }
1006
+ return result === 0;
1007
+ }
1008
+
1009
+ /**
1010
+ * Securely clear sensitive key material from memory
1011
+ */
1012
+ export function clearKey(key: Uint8Array): void {
1013
+ crypto.getRandomValues(key);
1014
+ key.fill(0);
1015
+ }
1016
+
1017
+ /**
1018
+ * Securely clear all sensitive keys from an UTXOpiaKeys object
1019
+ */
1020
+ export function clearUTXOpiaKeys(keys: UTXOpiaKeys): void {
1021
+ (keys as { spendingPrivKey: bigint }).spendingPrivKey = 0n;
1022
+ (keys as { nullifyingKey: bigint }).nullifyingKey = 0n;
1023
+ clearKey(keys.viewingPrivKey);
1024
+ clearKey(keys.eddsaSeed);
1025
+ }
1026
+
1027
+ /**
1028
+ * Securely clear a delegated viewing key
1029
+ */
1030
+ export function clearDelegatedViewKey(key: DelegatedViewKey): void {
1031
+ clearKey(key.viewingPrivKey);
1032
+ }
1033
+
1034
+ /**
1035
+ * Derive a view-only key bundle (no spending key)
1036
+ * Safe to export/backup separately from spending key
1037
+ */
1038
+ export function extractViewOnlyBundle(keys: UTXOpiaKeys): {
1039
+ solanaPublicKey: Uint8Array;
1040
+ spendingPubKey: Uint8Array;
1041
+ viewingPrivKey: Uint8Array;
1042
+ viewingPubKey: Uint8Array;
1043
+ } {
1044
+ return {
1045
+ solanaPublicKey: keys.solanaPublicKey,
1046
+ spendingPubKey: babyJubCompress(keys.spendingPubKey),
1047
+ viewingPrivKey: new Uint8Array(keys.viewingPrivKey),
1048
+ viewingPubKey: new Uint8Array(keys.viewingPubKey),
1049
+ };
1050
+ }
1051
+
1052
+ // ========== Key Serialization ==========
1053
+
1054
+ /**
1055
+ * Serialized key storage format (all values are hex strings or string-encoded bigints).
1056
+ */
1057
+ export interface SerializedKeysForStorage {
1058
+ eddsaSeedHex: string;
1059
+ spendingPrivKeyHex: string;
1060
+ spendingPubKey: { x: string; y: string };
1061
+ nullifyingKey: string;
1062
+ viewingPrivKeyHex: string;
1063
+ viewingPubKeyHex: string;
1064
+ }
1065
+
1066
+ /**
1067
+ * Serialize UTXOpiaKeys to a plain object with hex strings (for encrypted storage).
1068
+ *
1069
+ * The result is JSON-safe. Use `deserializeKeysFromStorage` to reconstruct.
1070
+ */
1071
+ export function serializeKeysForStorage(keys: UTXOpiaKeys): SerializedKeysForStorage {
1072
+ return {
1073
+ eddsaSeedHex: bytesToHex(keys.eddsaSeed),
1074
+ spendingPrivKeyHex: keys.spendingPrivKey.toString(16),
1075
+ spendingPubKey: { x: keys.spendingPubKey.x.toString(), y: keys.spendingPubKey.y.toString() },
1076
+ nullifyingKey: keys.nullifyingKey.toString(16),
1077
+ viewingPrivKeyHex: bytesToHex(keys.viewingPrivKey),
1078
+ viewingPubKeyHex: bytesToHex(keys.viewingPubKey),
1079
+ };
1080
+ }
1081
+
1082
+ /**
1083
+ * Deserialize UTXOpiaKeys from a storage object (reverse of serializeKeysForStorage).
1084
+ *
1085
+ * Requires `solanaPublicKey` to be provided separately since it is not stored
1086
+ * in the serialized format (it comes from the connected wallet).
1087
+ */
1088
+ export function deserializeKeysFromStorage(
1089
+ data: SerializedKeysForStorage,
1090
+ solanaPublicKey: Uint8Array,
1091
+ ): UTXOpiaKeys {
1092
+ return {
1093
+ solanaPublicKey,
1094
+ spendingPrivKey: BigInt("0x" + data.spendingPrivKeyHex),
1095
+ spendingPubKey: { x: BigInt(data.spendingPubKey.x), y: BigInt(data.spendingPubKey.y) },
1096
+ nullifyingKey: BigInt("0x" + data.nullifyingKey),
1097
+ viewingPrivKey: hexToBytes(data.viewingPrivKeyHex),
1098
+ viewingPubKey: hexToBytes(data.viewingPubKeyHex),
1099
+ eddsaSeed: hexToBytes(data.eddsaSeedHex),
1100
+ };
1101
+ }
1102
+
1103
+ // ========== High-Level Key Setup ==========
1104
+
1105
+ /**
1106
+ * Result of a complete key setup operation (derivation + stealth address creation).
1107
+ */
1108
+ export interface KeySetupResult {
1109
+ keys: UTXOpiaKeys;
1110
+ stealthAddress: StealthMetaAddress;
1111
+ stealthAddressEncoded: string;
1112
+ }
1113
+
1114
+ /**
1115
+ * Derive keys from wallet signature and create stealth address in one step.
1116
+ *
1117
+ * Combines deriveKeysFromWallet + createStealthMetaAddress + encodeStealthMetaAddress.
1118
+ */
1119
+ export async function setupKeysFromWallet(
1120
+ wallet: WalletSignerAdapter,
1121
+ ): Promise<KeySetupResult> {
1122
+ const keys = await deriveKeysFromWallet(wallet);
1123
+ const stealthAddress = createStealthMetaAddress(keys);
1124
+ const stealthAddressEncoded = encodeStealthMetaAddress(stealthAddress);
1125
+ return { keys, stealthAddress, stealthAddressEncoded };
1126
+ }
1127
+
1128
+ /**
1129
+ * Derive keys from seed (passkey PRF or secret phrase) and create stealth address in one step.
1130
+ *
1131
+ * Combines deriveKeysFromSeedCircuit + createStealthMetaAddress + encodeStealthMetaAddress.
1132
+ */
1133
+ export async function setupKeysFromSeed(
1134
+ seed: Uint8Array,
1135
+ ): Promise<KeySetupResult> {
1136
+ const keys = await deriveKeysFromSeedCircuit(seed);
1137
+ const stealthAddress = createStealthMetaAddress(keys);
1138
+ const stealthAddressEncoded = encodeStealthMetaAddress(stealthAddress);
1139
+ return { keys, stealthAddress, stealthAddressEncoded };
1140
+ }
1141
+
1142
+ /**
1143
+ * Recreate stealth address from existing keys (for hydration from storage).
1144
+ *
1145
+ * Use this when keys are already deserialized and you just need the stealth address.
1146
+ */
1147
+ export function recreateStealthAddress(keys: UTXOpiaKeys): {
1148
+ stealthAddress: StealthMetaAddress;
1149
+ stealthAddressEncoded: string;
1150
+ } {
1151
+ const stealthAddress = createStealthMetaAddress(keys);
1152
+ const stealthAddressEncoded = encodeStealthMetaAddress(stealthAddress);
1153
+ return { stealthAddress, stealthAddressEncoded };
1154
+ }
1155
+
1156
+ // ========== Auditor Viewing Keypair ==========
1157
+
1158
+ /**
1159
+ * Generate a random Ed25519 auditor viewing keypair.
1160
+ *
1161
+ * Thin wrapper over `ed25519GenerateKeyPair`. The returned keys are suitable
1162
+ * for use as the auditor viewing key pair in `encryptAuditorCiphertext` /
1163
+ * `decryptAuditorCiphertext`.
1164
+ *
1165
+ * @returns 32-byte private key and 32-byte Ed25519 public key
1166
+ */
1167
+ export function generateAuditorViewingKeypair(): { privKey: Uint8Array; pubKey: Uint8Array } {
1168
+ return ed25519GenerateKeyPair();
1169
+ }
1170
+
1171
+ /**
1172
+ * Derive a deterministic Ed25519 auditor viewing keypair from a 32-byte seed.
1173
+ *
1174
+ * Uses `ed25519DeriveKeyFromSeed` which SHA-256s the seed to produce the
1175
+ * Ed25519 private key, then derives the matching public key. Same seed always
1176
+ * yields the same keypair; different seeds yield different keypairs.
1177
+ *
1178
+ * @param seed - 32-byte seed (e.g. from a KDF or random source)
1179
+ * @returns 32-byte private key and 32-byte Ed25519 public key
1180
+ */
1181
+ export function deriveAuditorViewingKeypair(seed: Uint8Array): { privKey: Uint8Array; pubKey: Uint8Array } {
1182
+ if (seed.length !== 32) {
1183
+ throw new Error("seed must be 32 bytes");
1184
+ }
1185
+ return ed25519DeriveKeyFromSeed(seed);
1186
+ }
1187
+
1188
+ // ========== Utilities ==========
1189
+
1190
+ function concatBytes(...arrays: Uint8Array[]): Uint8Array {
1191
+ const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
1192
+ const result = new Uint8Array(totalLength);
1193
+ let offset = 0;
1194
+ for (const arr of arrays) {
1195
+ result.set(arr, offset);
1196
+ offset += arr.length;
1197
+ }
1198
+ return result;
1199
+ }
1200
+
1201
+ function normalizeAuthSignature(signature: Uint8Array): Uint8Array {
1202
+ if (signature.length === 64 || signature.length === 65) {
1203
+ return new Uint8Array(signature);
1204
+ }
1205
+ throw new Error("Auth signature must be 64 or 65 bytes");
1206
+ }
1207
+
1208
+ function deriveAuthSignatureRoot(
1209
+ signature: Uint8Array,
1210
+ options: AuthSignatureKeyDerivationOptions,
1211
+ ): Uint8Array {
1212
+ const context = JSON.stringify({
1213
+ account: options.account ?? "",
1214
+ chain: options.chain ?? "sol",
1215
+ network: options.network ?? "testnet",
1216
+ });
1217
+ return sha256(
1218
+ concatBytes(
1219
+ new TextEncoder().encode(AUTH_SIGNATURE_ROOT_DOMAIN),
1220
+ new TextEncoder().encode(context),
1221
+ signature,
1222
+ ),
1223
+ );
1224
+ }
1225
+
1226
+ function deriveAuthSecret(root: Uint8Array, domain: string): Uint8Array {
1227
+ return sha256(concatBytes(new TextEncoder().encode(domain), root));
1228
+ }