@utxopia/sdk 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +171 -0
  3. package/package.json +108 -0
  4. package/packages/btc-client/src/esplora-client.ts +153 -0
  5. package/packages/btc-client/src/index.ts +3 -0
  6. package/packages/btc-client/src/op-return.ts +93 -0
  7. package/packages/btc-client/src/types.ts +112 -0
  8. package/packages/sdk/README.md +277 -0
  9. package/packages/sdk/dist/announcement-client.d.ts +64 -0
  10. package/packages/sdk/dist/announcement-client.js +337 -0
  11. package/packages/sdk/dist/auditor-ciphertext.d.ts +72 -0
  12. package/packages/sdk/dist/auditor-ciphertext.js +135 -0
  13. package/packages/sdk/dist/auditor.d.ts +115 -0
  14. package/packages/sdk/dist/auditor.js +292 -0
  15. package/packages/sdk/dist/bitcoin/ika.d.ts +47 -0
  16. package/packages/sdk/dist/bitcoin/ika.js +74 -0
  17. package/packages/sdk/dist/bitcoin/index.d.ts +2 -0
  18. package/packages/sdk/dist/bitcoin/index.js +1 -0
  19. package/packages/sdk/dist/bound-params.d.ts +96 -0
  20. package/packages/sdk/dist/bound-params.js +249 -0
  21. package/packages/sdk/dist/chadbuffer.d.ts +124 -0
  22. package/packages/sdk/dist/chadbuffer.js +405 -0
  23. package/packages/sdk/dist/claim-link.d.ts +30 -0
  24. package/packages/sdk/dist/claim-link.js +50 -0
  25. package/packages/sdk/dist/client.d.ts +233 -0
  26. package/packages/sdk/dist/client.js +416 -0
  27. package/packages/sdk/dist/commitment-tree.d.ts +268 -0
  28. package/packages/sdk/dist/commitment-tree.js +564 -0
  29. package/packages/sdk/dist/config.d.ts +177 -0
  30. package/packages/sdk/dist/config.js +426 -0
  31. package/packages/sdk/dist/core/esplora.d.ts +110 -0
  32. package/packages/sdk/dist/core/esplora.js +187 -0
  33. package/packages/sdk/dist/core/mempool.d.ts +60 -0
  34. package/packages/sdk/dist/core/mempool.js +107 -0
  35. package/packages/sdk/dist/crypto-babyjub.d.ts +105 -0
  36. package/packages/sdk/dist/crypto-babyjub.js +312 -0
  37. package/packages/sdk/dist/crypto-ed25519.d.ts +116 -0
  38. package/packages/sdk/dist/crypto-ed25519.js +235 -0
  39. package/packages/sdk/dist/crypto.d.ts +59 -0
  40. package/packages/sdk/dist/crypto.js +140 -0
  41. package/packages/sdk/dist/event-client.d.ts +64 -0
  42. package/packages/sdk/dist/event-client.js +165 -0
  43. package/packages/sdk/dist/events.d.ts +127 -0
  44. package/packages/sdk/dist/events.js +305 -0
  45. package/packages/sdk/dist/explorer.d.ts +86 -0
  46. package/packages/sdk/dist/explorer.js +197 -0
  47. package/packages/sdk/dist/index.d.ts +61 -0
  48. package/packages/sdk/dist/index.js +216 -0
  49. package/packages/sdk/dist/instructions.d.ts +1125 -0
  50. package/packages/sdk/dist/instructions.js +1760 -0
  51. package/packages/sdk/dist/keys.d.ts +467 -0
  52. package/packages/sdk/dist/keys.js +799 -0
  53. package/packages/sdk/dist/logger.d.ts +10 -0
  54. package/packages/sdk/dist/logger.js +40 -0
  55. package/packages/sdk/dist/magicblock.d.ts +90 -0
  56. package/packages/sdk/dist/magicblock.js +164 -0
  57. package/packages/sdk/dist/merkle.d.ts +82 -0
  58. package/packages/sdk/dist/merkle.js +141 -0
  59. package/packages/sdk/dist/note.d.ts +348 -0
  60. package/packages/sdk/dist/note.js +483 -0
  61. package/packages/sdk/dist/pda.d.ts +160 -0
  62. package/packages/sdk/dist/pda.js +384 -0
  63. package/packages/sdk/dist/pool-state.d.ts +100 -0
  64. package/packages/sdk/dist/pool-state.js +126 -0
  65. package/packages/sdk/dist/poseidon.d.ts +53 -0
  66. package/packages/sdk/dist/poseidon.js +136 -0
  67. package/packages/sdk/dist/prover/index.d.ts +10 -0
  68. package/packages/sdk/dist/prover/index.js +10 -0
  69. package/packages/sdk/dist/prover/mobile.d.ts +45 -0
  70. package/packages/sdk/dist/prover/mobile.js +227 -0
  71. package/packages/sdk/dist/prover/web.d.ts +136 -0
  72. package/packages/sdk/dist/prover/web.js +597 -0
  73. package/packages/sdk/dist/psbt.d.ts +73 -0
  74. package/packages/sdk/dist/psbt.js +202 -0
  75. package/packages/sdk/dist/selective-disclosure.d.ts +181 -0
  76. package/packages/sdk/dist/selective-disclosure.js +172 -0
  77. package/packages/sdk/dist/sender-memo.d.ts +149 -0
  78. package/packages/sdk/dist/sender-memo.js +250 -0
  79. package/packages/sdk/dist/sns-resolver.d.ts +121 -0
  80. package/packages/sdk/dist/sns-resolver.js +229 -0
  81. package/packages/sdk/dist/solana/connection.d.ts +77 -0
  82. package/packages/sdk/dist/solana/connection.js +133 -0
  83. package/packages/sdk/dist/solana/priority-fee.d.ts +78 -0
  84. package/packages/sdk/dist/solana/priority-fee.js +137 -0
  85. package/packages/sdk/dist/stealth.d.ts +429 -0
  86. package/packages/sdk/dist/stealth.js +703 -0
  87. package/packages/sdk/dist/taproot.d.ts +172 -0
  88. package/packages/sdk/dist/taproot.js +494 -0
  89. package/packages/sdk/dist/token-registry.d.ts +96 -0
  90. package/packages/sdk/dist/token-registry.js +122 -0
  91. package/packages/sdk/dist/utils/encoding.d.ts +19 -0
  92. package/packages/sdk/dist/utils/encoding.js +29 -0
  93. package/packages/sdk/dist/vk-registry.d.ts +100 -0
  94. package/packages/sdk/dist/vk-registry.js +212 -0
@@ -0,0 +1,799 @@
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
+ import { sha256 } from "@noble/hashes/sha2.js";
40
+ import { pbkdf2 } from "@noble/hashes/pbkdf2";
41
+ import { scalarFromBytes, bigintToBytes, bytesToHex, hexToBytes, babyJubMul, BABYJUB_BASE8, babyJubCompress, babyJubDecompress, } from "./crypto";
42
+ import { ed25519GetPublicKey, ed25519GenerateKeyPair, ed25519DeriveKeyFromSeed, } from "./crypto-ed25519";
43
+ import { computeMPKSync, poseidonHashSync } from "./poseidon";
44
+ import { BABYJUB_ORDER } from "./crypto-babyjub";
45
+ export const PASSKEY_CHAIN_SCOPE_DOMAIN = "utxopia-passkey-chain:v1";
46
+ /**
47
+ * Scope a passkey seed to one app chain+network identity.
48
+ *
49
+ * This convention is intentionally centralized in the SDK so apps, tests, and
50
+ * dev tooling derive the same private address without duplicating string
51
+ * literals.
52
+ */
53
+ export function deriveChainScopedPasskeySeed(seed, options) {
54
+ const domain = new TextEncoder().encode(`${PASSKEY_CHAIN_SCOPE_DOMAIN}:${options.chain}:${options.network}`);
55
+ const material = new Uint8Array(domain.length + seed.length);
56
+ material.set(domain, 0);
57
+ material.set(seed, domain.length);
58
+ return sha256(material);
59
+ }
60
+ export function passkeyStorageOwner(credentialId, options) {
61
+ return `passkey:${credentialId}:${options.chain}:${options.network}`;
62
+ }
63
+ /**
64
+ * View permission flags for delegated viewing keys
65
+ */
66
+ export var ViewPermissions;
67
+ (function (ViewPermissions) {
68
+ /** Can scan announcements and see amounts */
69
+ ViewPermissions[ViewPermissions["SCAN"] = 1] = "SCAN";
70
+ /** Can see full transaction history */
71
+ ViewPermissions[ViewPermissions["HISTORY"] = 2] = "HISTORY";
72
+ /** Can see incoming transactions only */
73
+ ViewPermissions[ViewPermissions["INCOMING_ONLY"] = 4] = "INCOMING_ONLY";
74
+ /** Full viewing access (scan + history) */
75
+ ViewPermissions[ViewPermissions["FULL"] = 3] = "FULL";
76
+ })(ViewPermissions || (ViewPermissions = {}));
77
+ // ========== Constants ==========
78
+ /** Message to sign for key derivation */
79
+ export const SPENDING_KEY_DERIVATION_MESSAGE = "UTXOpia key derivation v1";
80
+ /** Domain separator for spending key derivation */
81
+ const SPENDING_KEY_DOMAIN = "spend";
82
+ /** Domain separator for viewing key derivation */
83
+ const VIEWING_KEY_DOMAIN = "view";
84
+ /** Domain separator for nullifying key derivation */
85
+ const NULLIFYING_KEY_DOMAIN = "nullify";
86
+ const AUTH_SIGNATURE_ROOT_DOMAIN = "utxopia:auth-signature-root:v1";
87
+ const AUTH_SPENDING_DOMAIN = "utxopia:spending:eddsa-poseidon:v1";
88
+ const AUTH_NULLIFYING_DOMAIN = "utxopia:nullifier:bn254:v1";
89
+ const AUTH_VIEWING_DOMAIN = "utxopia:viewing:ed25519:v1";
90
+ // ========== EdDSA-Poseidon Helpers ==========
91
+ let eddsaInstance = null;
92
+ async function getEddsa() {
93
+ if (!eddsaInstance) {
94
+ const { buildEddsa } = (await import("circomlibjs"));
95
+ eddsaInstance = await buildEddsa();
96
+ }
97
+ return eddsaInstance;
98
+ }
99
+ /**
100
+ * Derive Baby Jubjub public key from raw seed using circomlibjs EdDSA.
101
+ *
102
+ * circomlibjs internally hashes the seed (like standard EdDSA key derivation),
103
+ * producing keys compatible with the EdDSAPoseidonVerifier circuit.
104
+ * This is NOT the same as `babyJubMul(scalarFromBytes(seed), BASE8)`.
105
+ */
106
+ export async function eddsaGetPubKey(seed) {
107
+ const eddsa = await getEddsa();
108
+ const F = eddsa.babyJub.F;
109
+ const pubKey = eddsa.prv2pub(new Uint8Array(seed));
110
+ return {
111
+ x: F.toObject(pubKey[0]),
112
+ y: F.toObject(pubKey[1]),
113
+ };
114
+ }
115
+ /**
116
+ * Extract the internal EdDSA private scalar from a seed.
117
+ *
118
+ * circomlibjs does: BLAKE-512(seed) → pruneBuffer → fromRprLE(32 bytes) → shr(3)
119
+ * This scalar × BASE8 = the public key from `eddsaGetPubKey(seed)`.
120
+ *
121
+ * We intercept circomlibjs's `pruneBuffer` call during `prv2pub` to capture
122
+ * the intermediate buffer, then replicate the LE→bigint→shr(3) conversion.
123
+ * This avoids directly importing ffjavascript/blake-hash which aren't bundled by webpack.
124
+ */
125
+ export async function eddsaGetPrivScalar(seed) {
126
+ const eddsa = await getEddsa();
127
+ const F = eddsa.babyJub.F;
128
+ // Intercept the pruneBuffer call to capture the raw scalar.
129
+ // circomlibjs prv2pub does: sBuff = pruneBuffer(blake512(seed)); s = fromRprLE(sBuff); A = Base8 * (s >> 3)
130
+ // We temporarily replace pruneBuffer to capture sBuff.
131
+ let capturedBuff = null;
132
+ const origPrune = eddsa.pruneBuffer.bind(eddsa);
133
+ eddsa.pruneBuffer = (buff) => {
134
+ const result = origPrune(buff);
135
+ capturedBuff = new Uint8Array(result);
136
+ return result;
137
+ };
138
+ try {
139
+ // Call prv2pub which triggers pruneBuffer internally
140
+ eddsa.prv2pub(new Uint8Array(seed));
141
+ }
142
+ finally {
143
+ // Restore original
144
+ eddsa.pruneBuffer = origPrune;
145
+ }
146
+ if (!capturedBuff) {
147
+ throw new Error("Failed to capture EdDSA scalar buffer");
148
+ }
149
+ // Convert first 32 bytes from little-endian to bigint (same as Scalar.fromRprLE)
150
+ let s = 0n;
151
+ for (let i = 31; i >= 0; i--) {
152
+ s = (s << 8n) | BigInt(capturedBuff[i]);
153
+ }
154
+ // Right-shift by 3 (same as Scalar.shr(s, 3) in circomlibjs)
155
+ return s >> 3n;
156
+ }
157
+ /**
158
+ * Sign a message hash with EdDSA-Poseidon (circomlibjs).
159
+ *
160
+ * Returns [R8.x, R8.y, S] compatible with the EdDSAPoseidonVerifier circuit.
161
+ */
162
+ export async function eddsaPoseidonSign(seed, msgHash) {
163
+ const eddsa = await getEddsa();
164
+ const F = eddsa.babyJub.F;
165
+ const msgF = F.e(msgHash);
166
+ const signature = eddsa.signPoseidon(new Uint8Array(seed), msgF);
167
+ const R8x = F.toObject(signature.R8[0]);
168
+ const R8y = F.toObject(signature.R8[1]);
169
+ const S = signature.S;
170
+ return [R8x, R8y, S];
171
+ }
172
+ /**
173
+ * Sign a message hash with EdDSA-Poseidon using a given private scalar directly.
174
+ *
175
+ * Unlike `eddsaPoseidonSign` which derives the scalar internally via circomlibjs's
176
+ * BLAKE-512 derivation, this function uses the provided scalar as-is.
177
+ * This is needed when the public key was derived via `scalarFromBytes` (sync)
178
+ * rather than circomlibjs's internal derivation.
179
+ *
180
+ * Returns [R8.x, R8.y, S] compatible with the EdDSAPoseidonVerifier circuit.
181
+ */
182
+ export function eddsaPoseidonSignWithScalar(privScalar, pubKey, msgHash) {
183
+ // Deterministic nonce: r = Poseidon(privScalar, msgHash) mod BABYJUB_ORDER
184
+ const r = poseidonHashSync([privScalar, msgHash]) % BABYJUB_ORDER;
185
+ // R8 = r * BASE8
186
+ const R8 = babyJubMul(r, BABYJUB_BASE8);
187
+ // hm = Poseidon(R8.x, R8.y, pubKey.x, pubKey.y, msgHash)
188
+ const hm = poseidonHashSync([R8.x, R8.y, pubKey.x, pubKey.y, msgHash]);
189
+ // S = (r + privScalar * hm) mod BABYJUB_ORDER
190
+ const S = (r + privScalar * hm) % BABYJUB_ORDER;
191
+ return [R8.x, R8.y, S];
192
+ }
193
+ // ========== Key Derivation ==========
194
+ /**
195
+ * Derive UTXOpia keys from Solana wallet signature.
196
+ *
197
+ * Uses circomlibjs EdDSA for spendingPubKey derivation so keys are
198
+ * compatible with the EdDSAPoseidonVerifier circuit.
199
+ */
200
+ export async function deriveKeysFromWallet(wallet) {
201
+ if (!wallet.publicKey) {
202
+ throw new Error("Wallet not connected");
203
+ }
204
+ const message = new TextEncoder().encode(SPENDING_KEY_DERIVATION_MESSAGE);
205
+ const signature = await wallet.signMessage(message);
206
+ // Start with sync key derivation (babyJubMul-based)
207
+ const baseKeys = deriveKeysFromSignature(signature, wallet.publicKey.toBytes());
208
+ // Override spending keys with circomlibjs-derived versions for circuit compatibility.
209
+ // circomlibjs does: blake512(seed) → prune → fromRprLE → shr(3) → mulPointEscalar(Base8, scalar)
210
+ // Both spendingPrivKey and spendingPubKey must correspond so that:
211
+ // stealthPriv = spendingPrivKey + stealthScalar → babyJubMul(stealthPriv, BASE8) = stealthPub
212
+ const spendingPubKey = await eddsaGetPubKey(baseKeys.eddsaSeed);
213
+ const spendingPrivKey = await eddsaGetPrivScalar(baseKeys.eddsaSeed);
214
+ return {
215
+ ...baseKeys,
216
+ spendingPubKey,
217
+ spendingPrivKey,
218
+ };
219
+ }
220
+ /**
221
+ * Derive UTXOpia keys from a signature
222
+ *
223
+ * Spending key: SHA256(sig || "spend") → reduce mod BJJ_ORDER → babyJubMul(scalar, BASE8)
224
+ * Viewing key: SHA256(sig || "view") → Ed25519 private key → ed25519.getPublicKey()
225
+ */
226
+ export function deriveKeysFromSignature(signature, solanaPublicKey) {
227
+ if (signature.length !== 64) {
228
+ throw new Error("Signature must be 64 bytes");
229
+ }
230
+ if (solanaPublicKey.length !== 32) {
231
+ throw new Error("Solana public key must be 32 bytes");
232
+ }
233
+ // Derive spending key: SHA256(signature || "spend") → Baby Jubjub scalar
234
+ const spendingSeed = sha256(concatBytes(signature, new TextEncoder().encode(SPENDING_KEY_DOMAIN)));
235
+ // Store raw seed for circomlibjs EdDSA signing (used by deriveKeysFromWallet to override pubkey)
236
+ const eddsaSeed = new Uint8Array(spendingSeed);
237
+ const spendingPrivKey = scalarFromBytes(spendingSeed);
238
+ const spendingPubKey = babyJubMul(spendingPrivKey, BABYJUB_BASE8);
239
+ // Clear intermediate seed
240
+ clearKey(spendingSeed);
241
+ // Derive nullifying key: SHA256(signature || "nullify") → BN254 scalar
242
+ const nullifyingSeed = sha256(concatBytes(signature, new TextEncoder().encode(NULLIFYING_KEY_DOMAIN)));
243
+ const nullifyingKey = scalarFromBytes(nullifyingSeed);
244
+ clearKey(nullifyingSeed);
245
+ // Derive viewing key: SHA256(signature || "view") → Ed25519 private key
246
+ const viewingPrivKey = sha256(concatBytes(signature, new TextEncoder().encode(VIEWING_KEY_DOMAIN)));
247
+ const viewingPubKey = ed25519GetPublicKey(viewingPrivKey);
248
+ return {
249
+ solanaPublicKey,
250
+ spendingPrivKey,
251
+ spendingPubKey,
252
+ nullifyingKey,
253
+ viewingPrivKey,
254
+ viewingPubKey,
255
+ eddsaSeed,
256
+ };
257
+ }
258
+ /**
259
+ * Generate a random 65-byte signature-shaped seed for dev/test auth flows.
260
+ *
261
+ * Mirrors Fluidkey's "signature as deterministic key source" shape without
262
+ * requiring a wallet or zkLogin proof during local testing.
263
+ */
264
+ export function generateRandomAuthSignature() {
265
+ const signature = new Uint8Array(65);
266
+ crypto.getRandomValues(signature);
267
+ signature[64] = signature[64] % 2 === 0 ? 27 : 28;
268
+ return signature;
269
+ }
270
+ /**
271
+ * Derive UTXOpia keys from a wallet/zkLogin signature-shaped secret.
272
+ *
273
+ * User-facing model is two keys:
274
+ * - spending seed/key
275
+ * - viewing seed/key
276
+ *
277
+ * The protocol nullifying key is internal and derived from the spending seed,
278
+ * so delegated viewing keys do not automatically carry nullifier authority.
279
+ */
280
+ export async function deriveKeysFromAuthSignature(signature, options = {}) {
281
+ const normalized = normalizeAuthSignature(signature);
282
+ const root = deriveAuthSignatureRoot(normalized, options);
283
+ const spendingSeed = deriveAuthSecret(root, AUTH_SPENDING_DOMAIN);
284
+ const viewingPrivKey = deriveAuthSecret(root, AUTH_VIEWING_DOMAIN);
285
+ const nullifyingSeed = deriveAuthSecret(spendingSeed, AUTH_NULLIFYING_DOMAIN);
286
+ const eddsaSeed = new Uint8Array(spendingSeed);
287
+ // Spending keypair must use the circomlibjs EdDSA derivation that signing uses
288
+ // (same as deriveKeysFromWallet/deriveKeysFromSeedCircuit), else notes are unspendable.
289
+ const spendingPubKey = await eddsaGetPubKey(eddsaSeed);
290
+ const spendingPrivKey = await eddsaGetPrivScalar(eddsaSeed);
291
+ const nullifyingKey = scalarFromBytes(nullifyingSeed);
292
+ const viewingPubKey = ed25519GetPublicKey(viewingPrivKey);
293
+ const identityHash = sha256(concatBytes(root, new TextEncoder().encode("utxopia:auth-identity:v1")));
294
+ clearKey(spendingSeed);
295
+ clearKey(nullifyingSeed);
296
+ return {
297
+ solanaPublicKey: identityHash,
298
+ spendingPrivKey,
299
+ spendingPubKey,
300
+ nullifyingKey,
301
+ viewingPrivKey,
302
+ viewingPubKey,
303
+ eddsaSeed,
304
+ };
305
+ }
306
+ export async function setupKeysFromAuthSignature(signature, options = {}) {
307
+ const normalized = normalizeAuthSignature(signature);
308
+ const root = deriveAuthSignatureRoot(normalized, options);
309
+ const keys = await deriveKeysFromAuthSignature(normalized, options);
310
+ const stealthMetaAddress = createStealthMetaAddress(keys);
311
+ const encodedStealthAddress = encodeStealthMetaAddress(stealthMetaAddress);
312
+ return {
313
+ keys,
314
+ stealthMetaAddress,
315
+ encodedStealthAddress,
316
+ root,
317
+ };
318
+ }
319
+ /**
320
+ * Derive keys from a seed phrase (sync — for scanning/non-circuit use)
321
+ */
322
+ export function deriveKeysFromSeed(seed) {
323
+ const fakeSig = new Uint8Array(64);
324
+ const hash1 = sha256(seed);
325
+ const hash2 = sha256(concatBytes(seed, new Uint8Array([1])));
326
+ fakeSig.set(hash1, 0);
327
+ fakeSig.set(hash2, 32);
328
+ return deriveKeysFromSignature(fakeSig, new Uint8Array(32));
329
+ }
330
+ /**
331
+ * Derive keys from a seed phrase with circomlibjs-compatible spending keys.
332
+ *
333
+ * Must be used when the keys will be used for circuit proofs (EdDSA signing).
334
+ * The sync `deriveKeysFromSeed` uses a different scalar derivation that doesn't
335
+ * match circomlibjs's internal BLAKE-512 derivation used by `eddsaPoseidonSign`.
336
+ */
337
+ export async function deriveKeysFromSeedCircuit(seed) {
338
+ const baseKeys = deriveKeysFromSeed(seed);
339
+ // Override spending keys with circomlibjs-derived versions (same as deriveKeysFromWallet)
340
+ const spendingPubKey = await eddsaGetPubKey(baseKeys.eddsaSeed);
341
+ const spendingPrivKey = await eddsaGetPrivScalar(baseKeys.eddsaSeed);
342
+ return {
343
+ ...baseKeys,
344
+ spendingPubKey,
345
+ spendingPrivKey,
346
+ };
347
+ }
348
+ // ========== Stealth Meta-Address ==========
349
+ /**
350
+ * Create a stealth meta-address from UTXOpia keys
351
+ *
352
+ * Size: 96 bytes (32 BJJ compressed + 32 Ed25519 + 32 MPK)
353
+ */
354
+ export function createStealthMetaAddress(keys) {
355
+ const mpk = computeMPKSync(keys.spendingPubKey.x, keys.spendingPubKey.y, keys.nullifyingKey);
356
+ return {
357
+ spendingPubKey: babyJubCompress(keys.spendingPubKey),
358
+ viewingPubKey: new Uint8Array(keys.viewingPubKey),
359
+ mpk: bigintToBytes(mpk),
360
+ };
361
+ }
362
+ /**
363
+ * Serialize a stealth meta-address for display/sharing
364
+ */
365
+ export function serializeStealthMetaAddress(meta) {
366
+ return {
367
+ spendingPubKey: bytesToHex(meta.spendingPubKey),
368
+ viewingPubKey: bytesToHex(meta.viewingPubKey),
369
+ mpk: bytesToHex(meta.mpk),
370
+ };
371
+ }
372
+ /**
373
+ * Deserialize a stealth meta-address from string representation
374
+ */
375
+ export function deserializeStealthMetaAddress(serialized) {
376
+ return {
377
+ spendingPubKey: hexToBytes(serialized.spendingPubKey),
378
+ viewingPubKey: hexToBytes(serialized.viewingPubKey),
379
+ mpk: hexToBytes(serialized.mpk),
380
+ };
381
+ }
382
+ /**
383
+ * Parse a stealth meta-address and extract public keys
384
+ *
385
+ * Returns Baby Jubjub spending pubkey and Ed25519 viewing pubkey.
386
+ */
387
+ export function parseStealthMetaAddress(meta) {
388
+ return {
389
+ spendingPubKey: babyJubDecompress(meta.spendingPubKey),
390
+ viewingPubKey: new Uint8Array(meta.viewingPubKey),
391
+ };
392
+ }
393
+ /**
394
+ * Encode stealth meta-address as a single string with utxo: prefix
395
+ * Format: "utxo:" + hex(spendingPubKey (32) || viewingPubKey (32) || mpk (32))
396
+ */
397
+ export function encodeStealthMetaAddress(meta) {
398
+ const combined = concatBytes(meta.spendingPubKey, meta.viewingPubKey, meta.mpk);
399
+ return "utxo:" + bytesToHex(combined);
400
+ }
401
+ /**
402
+ * Decode stealth meta-address from a string (with or without utxo: prefix)
403
+ */
404
+ export function decodeStealthMetaAddress(encoded) {
405
+ // "utxo:" is 5 chars — slice(5), not slice(6). Off-by-one was eating one
406
+ // hex character and producing a 47-byte buffer that failed the length check.
407
+ const hex = encoded.startsWith("utxo:") ? encoded.slice(5) : encoded;
408
+ const bytes = hexToBytes(hex);
409
+ if (bytes.length !== 96) {
410
+ throw new Error("Invalid stealth meta-address length (expected 96 bytes)");
411
+ }
412
+ return {
413
+ spendingPubKey: bytes.slice(0, 32),
414
+ viewingPubKey: bytes.slice(32, 64),
415
+ mpk: bytes.slice(64, 96),
416
+ };
417
+ }
418
+ // ========== Viewing Key Delegation ==========
419
+ /**
420
+ * Create a delegated viewing key for auditors/compliance
421
+ *
422
+ * The returned key carries everything an auditor needs to scan announcements
423
+ * within the configured slot range, including the spending pubkey and
424
+ * nullifying key (required for deposit verification — `Poseidon(npk, token, amount)`
425
+ * must match on-chain commitment). A fresh `delegationId` and `issuedAt` are
426
+ * generated so the user can keep an [[auditable-disclosure-status]] trail of
427
+ * who they handed keys to.
428
+ */
429
+ export function createDelegatedViewKey(keys, permissions = ViewPermissions.FULL, options = {}) {
430
+ const issuedAt = Date.now();
431
+ const delegationId = generateDelegationId();
432
+ return {
433
+ viewingPrivKey: new Uint8Array(keys.viewingPrivKey),
434
+ spendingPubKeyCompressed: babyJubCompress(keys.spendingPubKey),
435
+ nullifyingKey: keys.nullifyingKey,
436
+ permissions,
437
+ fromSlot: options.fromSlot,
438
+ toSlot: options.toSlot,
439
+ expiresAt: options.expiresAt,
440
+ label: options.label,
441
+ issuedAt,
442
+ delegationId,
443
+ };
444
+ }
445
+ /**
446
+ * Build a public-only record of a delegated viewing key for the issuer's
447
+ * audit trail. Strips the secret material; only carries identifiers + scope.
448
+ */
449
+ export function makeDelegationRecord(key, options = {}) {
450
+ return {
451
+ delegationId: key.delegationId ?? generateDelegationId(),
452
+ fingerprint: fingerprintDelegatedKey(key),
453
+ permissions: key.permissions,
454
+ fromSlot: key.fromSlot,
455
+ toSlot: key.toSlot,
456
+ expiresAt: key.expiresAt,
457
+ issuedAt: key.issuedAt ?? Date.now(),
458
+ label: key.label,
459
+ recipient: options.recipient,
460
+ };
461
+ }
462
+ /**
463
+ * Compute a stable fingerprint for a delegated viewing key.
464
+ *
465
+ * `sha256(viewingPrivKey)[..16]` rendered as hex — short enough to display,
466
+ * long enough to make collisions astronomically unlikely. Identical viewing
467
+ * keys produce identical fingerprints, so the user can detect duplicate
468
+ * delegations across export sessions.
469
+ */
470
+ export function fingerprintDelegatedKey(key) {
471
+ return bytesToHex(sha256(key.viewingPrivKey).slice(0, 16));
472
+ }
473
+ function generateDelegationId() {
474
+ const buf = new Uint8Array(16);
475
+ crypto.getRandomValues(buf);
476
+ return bytesToHex(buf);
477
+ }
478
+ /**
479
+ * Decide whether a slot falls within the delegated key's permitted range.
480
+ * No range bound on a side ⇒ unbounded on that side.
481
+ */
482
+ export function isSlotInDelegatedRange(key, slot) {
483
+ if (slot == null) {
484
+ // Slot unknown — only accept when the key itself has no range constraint.
485
+ return key.fromSlot == null && key.toSlot == null;
486
+ }
487
+ if (key.fromSlot != null && slot < key.fromSlot)
488
+ return false;
489
+ if (key.toSlot != null && slot > key.toSlot)
490
+ return false;
491
+ return true;
492
+ }
493
+ /**
494
+ * Serialize a delegated viewing key for export (ENCRYPTED)
495
+ */
496
+ export async function serializeDelegatedViewKey(key, password) {
497
+ if (!password) {
498
+ throw new Error("Password required for viewing key serialization. " +
499
+ "Unencrypted export is not permitted for security.");
500
+ }
501
+ const passwordBytes = new TextEncoder().encode(password);
502
+ const salt = new Uint8Array(16);
503
+ crypto.getRandomValues(salt);
504
+ const PBKDF2_ITERATIONS = 600000;
505
+ const encryptionKey = pbkdf2(sha256, passwordBytes, salt, { c: PBKDF2_ITERATIONS, dkLen: 32 });
506
+ const nonce = new Uint8Array(12);
507
+ crypto.getRandomValues(nonce);
508
+ const keyBuffer = encryptionKey.buffer.slice(encryptionKey.byteOffset, encryptionKey.byteOffset + encryptionKey.byteLength);
509
+ const nonceBuffer = nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength);
510
+ const dataBuffer = key.viewingPrivKey.buffer.slice(key.viewingPrivKey.byteOffset, key.viewingPrivKey.byteOffset + key.viewingPrivKey.byteLength);
511
+ const cryptoKey = await crypto.subtle.importKey("raw", keyBuffer, { name: "AES-GCM" }, false, ["encrypt"]);
512
+ const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonceBuffer }, cryptoKey, dataBuffer);
513
+ const obj = {
514
+ version: 2,
515
+ encrypted: true,
516
+ salt: bytesToHex(salt),
517
+ nonce: bytesToHex(nonce),
518
+ ciphertext: bytesToHex(new Uint8Array(ciphertext)),
519
+ permissions: key.permissions,
520
+ expiresAt: key.expiresAt,
521
+ label: key.label,
522
+ delegationId: key.delegationId,
523
+ issuedAt: key.issuedAt,
524
+ fromSlot: key.fromSlot,
525
+ toSlot: key.toSlot,
526
+ fingerprint: fingerprintDelegatedKey(key),
527
+ };
528
+ if (key.spendingPubKeyCompressed) {
529
+ obj.spendingPubKeyCompressed = bytesToHex(key.spendingPubKeyCompressed);
530
+ }
531
+ if (key.nullifyingKey != null) {
532
+ obj.nullifyingKey = key.nullifyingKey.toString(16);
533
+ }
534
+ return JSON.stringify(obj);
535
+ }
536
+ /**
537
+ * Deserialize a delegated viewing key from JSON.
538
+ *
539
+ * v1 keys are refused at parse time by default (they lack
540
+ * `spendingPubKeyCompressed`/`nullifyingKey`, so `auditScan` would error out
541
+ * downstream anyway — failing early gives callers a clearer message and
542
+ * avoids partial setup). Set `acceptV1: true` to opt in for migration
543
+ * tools that need to crack open old blobs to re-issue them as v2.
544
+ */
545
+ export async function deserializeDelegatedViewKey(json, password, options = {}) {
546
+ let obj;
547
+ try {
548
+ obj = JSON.parse(json);
549
+ }
550
+ catch {
551
+ throw new Error("Invalid delegated view key format");
552
+ }
553
+ if (!obj.encrypted) {
554
+ const privKeyBytes = hexToBytes(obj.viewingPrivKey);
555
+ return {
556
+ viewingPrivKey: privKeyBytes,
557
+ permissions: obj.permissions,
558
+ expiresAt: obj.expiresAt,
559
+ label: obj.label,
560
+ };
561
+ }
562
+ if (!password) {
563
+ throw new Error("Password required to decrypt viewing key");
564
+ }
565
+ // Refuse v1 unless explicitly opted in. v1 keys decrypt fine but lack the
566
+ // spendingPubKey + nullifyingKey material needed by auditScan, so they
567
+ // produce a confusing late-stage failure. Better to surface it here.
568
+ if (obj.version === 1 && !options.acceptV1) {
569
+ throw new Error("Delegated view key is v1 (pre-mpk format). v1 keys can no longer scan " +
570
+ "deposits — re-issue as v2 via createDelegatedViewKey + " +
571
+ "encryptDelegatedViewKey. Pass { acceptV1: true } if you're running " +
572
+ "a one-shot migration.");
573
+ }
574
+ const salt = hexToBytes(obj.salt);
575
+ const nonce = hexToBytes(obj.nonce);
576
+ const ciphertext = hexToBytes(obj.ciphertext);
577
+ const passwordBytes = new TextEncoder().encode(password);
578
+ if (obj.version === 1 || obj.version === 2) {
579
+ const iterations = 600000;
580
+ const encryptionKey = pbkdf2(sha256, passwordBytes, salt, { c: iterations, dkLen: 32 });
581
+ const keyBuffer = encryptionKey.buffer.slice(encryptionKey.byteOffset, encryptionKey.byteOffset + encryptionKey.byteLength);
582
+ const nonceBuffer = nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength);
583
+ const ciphertextBuffer = ciphertext.buffer.slice(ciphertext.byteOffset, ciphertext.byteOffset + ciphertext.byteLength);
584
+ const cryptoKey = await crypto.subtle.importKey("raw", keyBuffer, { name: "AES-GCM" }, false, ["decrypt"]);
585
+ try {
586
+ const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv: nonceBuffer }, cryptoKey, ciphertextBuffer);
587
+ const out = {
588
+ viewingPrivKey: new Uint8Array(plaintext),
589
+ permissions: obj.permissions,
590
+ expiresAt: obj.expiresAt,
591
+ label: obj.label,
592
+ };
593
+ if (obj.version === 2) {
594
+ if (typeof obj.spendingPubKeyCompressed === "string") {
595
+ out.spendingPubKeyCompressed = hexToBytes(obj.spendingPubKeyCompressed);
596
+ }
597
+ if (typeof obj.nullifyingKey === "string") {
598
+ out.nullifyingKey = BigInt("0x" + obj.nullifyingKey);
599
+ }
600
+ if (typeof obj.fromSlot === "number")
601
+ out.fromSlot = obj.fromSlot;
602
+ if (typeof obj.toSlot === "number")
603
+ out.toSlot = obj.toSlot;
604
+ if (typeof obj.issuedAt === "number")
605
+ out.issuedAt = obj.issuedAt;
606
+ if (typeof obj.delegationId === "string")
607
+ out.delegationId = obj.delegationId;
608
+ }
609
+ return out;
610
+ }
611
+ catch {
612
+ throw new Error("Invalid password or corrupted data");
613
+ }
614
+ }
615
+ throw new Error("Unsupported encryption format (version " + obj.version + "). " +
616
+ "Supported versions: 1, 2.");
617
+ }
618
+ /**
619
+ * Check if a delegated viewing key is valid (not expired)
620
+ */
621
+ export function isDelegatedKeyValid(key) {
622
+ if (!key.expiresAt)
623
+ return true;
624
+ return Date.now() < key.expiresAt;
625
+ }
626
+ /**
627
+ * Check if a delegated key has a specific permission
628
+ */
629
+ export function hasPermission(key, permission) {
630
+ return (key.permissions & permission) === permission;
631
+ }
632
+ // ========== Key Security ==========
633
+ /**
634
+ * Safely compare two keys in constant time
635
+ */
636
+ export function constantTimeCompare(a, b) {
637
+ if (a.length !== b.length)
638
+ return false;
639
+ let result = 0;
640
+ for (let i = 0; i < a.length; i++) {
641
+ result |= a[i] ^ b[i];
642
+ }
643
+ return result === 0;
644
+ }
645
+ /**
646
+ * Securely clear sensitive key material from memory
647
+ */
648
+ export function clearKey(key) {
649
+ crypto.getRandomValues(key);
650
+ key.fill(0);
651
+ }
652
+ /**
653
+ * Securely clear all sensitive keys from an UTXOpiaKeys object
654
+ */
655
+ export function clearUTXOpiaKeys(keys) {
656
+ keys.spendingPrivKey = 0n;
657
+ keys.nullifyingKey = 0n;
658
+ clearKey(keys.viewingPrivKey);
659
+ clearKey(keys.eddsaSeed);
660
+ }
661
+ /**
662
+ * Securely clear a delegated viewing key
663
+ */
664
+ export function clearDelegatedViewKey(key) {
665
+ clearKey(key.viewingPrivKey);
666
+ }
667
+ /**
668
+ * Derive a view-only key bundle (no spending key)
669
+ * Safe to export/backup separately from spending key
670
+ */
671
+ export function extractViewOnlyBundle(keys) {
672
+ return {
673
+ solanaPublicKey: keys.solanaPublicKey,
674
+ spendingPubKey: babyJubCompress(keys.spendingPubKey),
675
+ viewingPrivKey: new Uint8Array(keys.viewingPrivKey),
676
+ viewingPubKey: new Uint8Array(keys.viewingPubKey),
677
+ };
678
+ }
679
+ /**
680
+ * Serialize UTXOpiaKeys to a plain object with hex strings (for encrypted storage).
681
+ *
682
+ * The result is JSON-safe. Use `deserializeKeysFromStorage` to reconstruct.
683
+ */
684
+ export function serializeKeysForStorage(keys) {
685
+ return {
686
+ eddsaSeedHex: bytesToHex(keys.eddsaSeed),
687
+ spendingPrivKeyHex: keys.spendingPrivKey.toString(16),
688
+ spendingPubKey: { x: keys.spendingPubKey.x.toString(), y: keys.spendingPubKey.y.toString() },
689
+ nullifyingKey: keys.nullifyingKey.toString(16),
690
+ viewingPrivKeyHex: bytesToHex(keys.viewingPrivKey),
691
+ viewingPubKeyHex: bytesToHex(keys.viewingPubKey),
692
+ };
693
+ }
694
+ /**
695
+ * Deserialize UTXOpiaKeys from a storage object (reverse of serializeKeysForStorage).
696
+ *
697
+ * Requires `solanaPublicKey` to be provided separately since it is not stored
698
+ * in the serialized format (it comes from the connected wallet).
699
+ */
700
+ export function deserializeKeysFromStorage(data, solanaPublicKey) {
701
+ return {
702
+ solanaPublicKey,
703
+ spendingPrivKey: BigInt("0x" + data.spendingPrivKeyHex),
704
+ spendingPubKey: { x: BigInt(data.spendingPubKey.x), y: BigInt(data.spendingPubKey.y) },
705
+ nullifyingKey: BigInt("0x" + data.nullifyingKey),
706
+ viewingPrivKey: hexToBytes(data.viewingPrivKeyHex),
707
+ viewingPubKey: hexToBytes(data.viewingPubKeyHex),
708
+ eddsaSeed: hexToBytes(data.eddsaSeedHex),
709
+ };
710
+ }
711
+ /**
712
+ * Derive keys from wallet signature and create stealth address in one step.
713
+ *
714
+ * Combines deriveKeysFromWallet + createStealthMetaAddress + encodeStealthMetaAddress.
715
+ */
716
+ export async function setupKeysFromWallet(wallet) {
717
+ const keys = await deriveKeysFromWallet(wallet);
718
+ const stealthAddress = createStealthMetaAddress(keys);
719
+ const stealthAddressEncoded = encodeStealthMetaAddress(stealthAddress);
720
+ return { keys, stealthAddress, stealthAddressEncoded };
721
+ }
722
+ /**
723
+ * Derive keys from seed (passkey PRF or secret phrase) and create stealth address in one step.
724
+ *
725
+ * Combines deriveKeysFromSeedCircuit + createStealthMetaAddress + encodeStealthMetaAddress.
726
+ */
727
+ export async function setupKeysFromSeed(seed) {
728
+ const keys = await deriveKeysFromSeedCircuit(seed);
729
+ const stealthAddress = createStealthMetaAddress(keys);
730
+ const stealthAddressEncoded = encodeStealthMetaAddress(stealthAddress);
731
+ return { keys, stealthAddress, stealthAddressEncoded };
732
+ }
733
+ /**
734
+ * Recreate stealth address from existing keys (for hydration from storage).
735
+ *
736
+ * Use this when keys are already deserialized and you just need the stealth address.
737
+ */
738
+ export function recreateStealthAddress(keys) {
739
+ const stealthAddress = createStealthMetaAddress(keys);
740
+ const stealthAddressEncoded = encodeStealthMetaAddress(stealthAddress);
741
+ return { stealthAddress, stealthAddressEncoded };
742
+ }
743
+ // ========== Auditor Viewing Keypair ==========
744
+ /**
745
+ * Generate a random Ed25519 auditor viewing keypair.
746
+ *
747
+ * Thin wrapper over `ed25519GenerateKeyPair`. The returned keys are suitable
748
+ * for use as the auditor viewing key pair in `encryptAuditorCiphertext` /
749
+ * `decryptAuditorCiphertext`.
750
+ *
751
+ * @returns 32-byte private key and 32-byte Ed25519 public key
752
+ */
753
+ export function generateAuditorViewingKeypair() {
754
+ return ed25519GenerateKeyPair();
755
+ }
756
+ /**
757
+ * Derive a deterministic Ed25519 auditor viewing keypair from a 32-byte seed.
758
+ *
759
+ * Uses `ed25519DeriveKeyFromSeed` which SHA-256s the seed to produce the
760
+ * Ed25519 private key, then derives the matching public key. Same seed always
761
+ * yields the same keypair; different seeds yield different keypairs.
762
+ *
763
+ * @param seed - 32-byte seed (e.g. from a KDF or random source)
764
+ * @returns 32-byte private key and 32-byte Ed25519 public key
765
+ */
766
+ export function deriveAuditorViewingKeypair(seed) {
767
+ if (seed.length !== 32) {
768
+ throw new Error("seed must be 32 bytes");
769
+ }
770
+ return ed25519DeriveKeyFromSeed(seed);
771
+ }
772
+ // ========== Utilities ==========
773
+ function concatBytes(...arrays) {
774
+ const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
775
+ const result = new Uint8Array(totalLength);
776
+ let offset = 0;
777
+ for (const arr of arrays) {
778
+ result.set(arr, offset);
779
+ offset += arr.length;
780
+ }
781
+ return result;
782
+ }
783
+ function normalizeAuthSignature(signature) {
784
+ if (signature.length === 64 || signature.length === 65) {
785
+ return new Uint8Array(signature);
786
+ }
787
+ throw new Error("Auth signature must be 64 or 65 bytes");
788
+ }
789
+ function deriveAuthSignatureRoot(signature, options) {
790
+ const context = JSON.stringify({
791
+ account: options.account ?? "",
792
+ chain: options.chain ?? "sol",
793
+ network: options.network ?? "testnet",
794
+ });
795
+ return sha256(concatBytes(new TextEncoder().encode(AUTH_SIGNATURE_ROOT_DOMAIN), new TextEncoder().encode(context), signature));
796
+ }
797
+ function deriveAuthSecret(root, domain) {
798
+ return sha256(concatBytes(new TextEncoder().encode(domain), root));
799
+ }