@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/auditor.ts ADDED
@@ -0,0 +1,409 @@
1
+ /**
2
+ * Auditor toolkit (Phase 1)
3
+ *
4
+ * Given a {@link DelegatedViewKey}, produce a structured audit record for
5
+ * every announcement that decrypts cleanly to a positive amount. The scanner:
6
+ *
7
+ * - enforces the delegation's `expiresAt` (wall-clock check)
8
+ * - enforces the delegation's `[fromSlot, toSlot]` honor-system range
9
+ * - honors `ViewPermissions.INCOMING_ONLY` once outgoing memos exist
10
+ * (Phase 2). Today everything coming back is treated as IN since no
11
+ * sender-memo channel has shipped yet.
12
+ *
13
+ * No on-chain enforcement is possible (the auditor already holds the secret);
14
+ * scope is enforced *inside this scanner* so a compliant tool obeys it and the
15
+ * issuer can prove what scope they handed over.
16
+ */
17
+
18
+ import {
19
+ scanAnnouncementsViewOnly,
20
+ ANNOUNCEMENT_TYPE_DEPOSIT,
21
+ type OnChainStealthAnnouncement,
22
+ type ViewOnlyKeys,
23
+ } from "./stealth";
24
+ import { babyJubDecompress, bytesToHex } from "./crypto";
25
+ import {
26
+ ViewPermissions,
27
+ hasPermission,
28
+ isDelegatedKeyValid,
29
+ isSlotInDelegatedRange,
30
+ type DelegatedViewKey,
31
+ } from "./keys";
32
+ import { decryptSenderMemo, type SenderMemoCiphertext } from "./sender-memo";
33
+ import { decryptAuditorCiphertext } from "./auditor-ciphertext";
34
+ export type { SenderMemoCiphertext };
35
+
36
+ /** Direction of a record relative to the audited user. */
37
+ export type AuditDirection = "IN" | "OUT" | "SELF" | "UNKNOWN" | "AUDITOR_VISIBLE";
38
+
39
+ /** A single matched announcement, ready for report rendering. */
40
+ export interface AuditRecord {
41
+ slot: number;
42
+ blockTime: number;
43
+ leafIndex: number;
44
+ direction: AuditDirection;
45
+ announcementType: number;
46
+ tokenId: bigint;
47
+ amount: bigint;
48
+ commitmentHex: string;
49
+ ephemeralPubHex: string;
50
+ }
51
+
52
+ export interface AuditScanOptions {
53
+ /** Token IDs to scan for. Required — auditor must know which token namespaces to look in. */
54
+ tokenIds: bigint[];
55
+ /** Override slot range (otherwise uses key's fromSlot/toSlot). */
56
+ fromSlot?: number;
57
+ toSlot?: number;
58
+ /** Clock for `expiresAt` check. Override for tests; defaults to `Date.now()`. */
59
+ now?: () => number;
60
+ /** Sender-memo events (Phase 2). When supplied, OUT records will be produced. */
61
+ senderMemos?: ReadonlyArray<OnChainSenderMemo>;
62
+ /** Auditor ciphertext events (Method-Y). When supplied, AUDITOR_VISIBLE records will be produced. */
63
+ auditorCiphertexts?: ReadonlyArray<{ commitment: Uint8Array; blob: Uint8Array; slot?: number; blockTime?: number }>;
64
+ /** Auditor viewing private key (32-byte Ed25519). Required to decrypt auditorCiphertexts. */
65
+ auditorViewingPrivKey?: Uint8Array;
66
+ }
67
+
68
+ /**
69
+ * Sender-memo event as observed on-chain. Emitted by `emit_sender_memo` in the
70
+ * UTXOpia program. The memo carries its own `commitment` and `leafIndex` so
71
+ * the auditor can join it back to the corresponding tree leaf, even though
72
+ * the memo itself isn't a leaf.
73
+ */
74
+ export interface OnChainSenderMemo extends SenderMemoCiphertext {
75
+ blockTime?: number;
76
+ slot?: number;
77
+ }
78
+
79
+ export interface AuditScanSummary {
80
+ records: AuditRecord[];
81
+ /** How many announcements fell outside the slot range. */
82
+ outOfRangeSkipped: number;
83
+ /** How many announcements lacked a slot but the key required one. */
84
+ unscopedSkipped: number;
85
+ /** How many announcements decrypted to something invalid (wrong key, out of range amount). */
86
+ notForViewerSkipped: number;
87
+ /** Effective slot range that was actually enforced. */
88
+ effectiveFromSlot?: number;
89
+ effectiveToSlot?: number;
90
+ }
91
+
92
+ /**
93
+ * Annotated announcement input — carries the same shape as
94
+ * {@link OnChainStealthAnnouncement} but the `tokenId` is opt-in. When
95
+ * omitted, the caller will be matched against every `tokenIds` entry.
96
+ */
97
+ export interface AuditScanAnnouncement extends OnChainStealthAnnouncement {
98
+ /** Optional — when present, scanner only tries the matching tokenId. */
99
+ tokenId?: bigint;
100
+ }
101
+
102
+ // Range intersection: undefined means unbounded on that side.
103
+ function intersectLower(a?: number, b?: number): number | undefined {
104
+ if (a == null) return b;
105
+ if (b == null) return a;
106
+ return Math.max(a, b);
107
+ }
108
+ function intersectUpper(a?: number, b?: number): number | undefined {
109
+ if (a == null) return b;
110
+ if (b == null) return a;
111
+ return Math.min(a, b);
112
+ }
113
+
114
+ function buildAuditorVisibleRecord(
115
+ plain: { tokenId: bigint; amount: bigint },
116
+ commitment: Uint8Array,
117
+ slot: number,
118
+ blockTime: number,
119
+ ): AuditRecord {
120
+ return {
121
+ slot,
122
+ blockTime,
123
+ leafIndex: -1,
124
+ direction: "AUDITOR_VISIBLE",
125
+ announcementType: -1,
126
+ tokenId: plain.tokenId,
127
+ amount: plain.amount,
128
+ commitmentHex: bytesToHex(commitment),
129
+ ephemeralPubHex: "",
130
+ };
131
+ }
132
+
133
+ /**
134
+ * Scan announcements with a delegated viewing key and produce {@link AuditRecord}s.
135
+ *
136
+ * Throws if the key is expired or lacks `SCAN` permission, or if the key is
137
+ * missing the spending-pub/nullifying-key material required to verify deposits.
138
+ */
139
+ export async function auditScan(
140
+ key: DelegatedViewKey,
141
+ announcements: ReadonlyArray<AuditScanAnnouncement>,
142
+ options: AuditScanOptions,
143
+ ): Promise<AuditScanSummary> {
144
+ if (!hasPermission(key, ViewPermissions.SCAN)) {
145
+ throw new Error("Delegated key missing SCAN permission");
146
+ }
147
+ if (!isDelegatedKeyValid(key)) {
148
+ throw new Error("Delegated key has expired");
149
+ }
150
+ if (!key.spendingPubKeyCompressed || key.nullifyingKey == null) {
151
+ throw new Error(
152
+ "Delegated key missing spendingPubKey/nullifyingKey — re-export with a v2 key",
153
+ );
154
+ }
155
+ if (options.tokenIds.length === 0) {
156
+ throw new Error("auditScan requires at least one tokenId");
157
+ }
158
+
159
+ // Intersect the CLI range with the key's grant — options may only narrow the
160
+ // delegated [fromSlot, toSlot], never widen it (undefined = unbounded that side).
161
+ const effectiveFromSlot = intersectLower(options.fromSlot, key.fromSlot);
162
+ const effectiveToSlot = intersectUpper(options.toSlot, key.toSlot);
163
+
164
+ const keyForRange: DelegatedViewKey = {
165
+ ...key,
166
+ fromSlot: effectiveFromSlot,
167
+ toSlot: effectiveToSlot,
168
+ };
169
+
170
+ let outOfRangeSkipped = 0;
171
+ let unscopedSkipped = 0;
172
+
173
+ const inScope: AuditScanAnnouncement[] = [];
174
+ for (const ann of announcements) {
175
+ if (effectiveFromSlot != null || effectiveToSlot != null) {
176
+ if (ann.slot == null) {
177
+ unscopedSkipped++;
178
+ continue;
179
+ }
180
+ if (!isSlotInDelegatedRange(keyForRange, ann.slot)) {
181
+ outOfRangeSkipped++;
182
+ continue;
183
+ }
184
+ }
185
+ inScope.push(ann);
186
+ }
187
+
188
+ const viewOnly: ViewOnlyKeys = {
189
+ viewingPrivKey: key.viewingPrivKey,
190
+ spendingPubKey: babyJubDecompress(key.spendingPubKeyCompressed),
191
+ nullifyingKey: key.nullifyingKey,
192
+ };
193
+
194
+ // Honor INCOMING_ONLY: when set, skip OUT records derived from sender memos.
195
+ // The auditor still scans incoming announcements; just doesn't produce
196
+ // outgoing-direction rows. Enforcement is honor-system (the key holder could
197
+ // run auditScan locally with this branch removed), but the issuance contract
198
+ // is that an INCOMING_ONLY delegation produces no OUT records under the
199
+ // canonical SDK path.
200
+ const incomingOnly = hasPermission(key, ViewPermissions.INCOMING_ONLY);
201
+
202
+ const records: AuditRecord[] = [];
203
+ const seenLeafIndex = new Set<number>();
204
+ // Index inScope by leafIndex so we can recover slot after the inner scan,
205
+ // which strips the slot field from its output.
206
+ const slotByLeafIndex = new Map<number, number | undefined>();
207
+ for (const ann of inScope) slotByLeafIndex.set(ann.leafIndex, ann.slot);
208
+
209
+ for (const tokenId of options.tokenIds) {
210
+ // Either the announcement explicitly targets this token, or its tokenId is
211
+ // unknown and we try every requested token id (commitment match disambiguates).
212
+ const subset = inScope.filter(
213
+ (a) => a.tokenId == null || a.tokenId === tokenId,
214
+ );
215
+ if (subset.length === 0) continue;
216
+
217
+ const matched = await scanAnnouncementsViewOnly(viewOnly, subset, tokenId);
218
+ for (const m of matched) {
219
+ if (seenLeafIndex.has(m.leafIndex)) continue;
220
+ seenLeafIndex.add(m.leafIndex);
221
+ records.push({
222
+ slot: slotByLeafIndex.get(m.leafIndex) ?? 0,
223
+ blockTime: m.blockTime ?? 0,
224
+ leafIndex: m.leafIndex,
225
+ direction: "IN",
226
+ announcementType:
227
+ subset.find((a) => a.leafIndex === m.leafIndex)?.announcementType ??
228
+ ANNOUNCEMENT_TYPE_DEPOSIT,
229
+ tokenId,
230
+ amount: m.amount,
231
+ commitmentHex: bytesToHex(m.commitment),
232
+ ephemeralPubHex: bytesToHex(m.ephemeralPub),
233
+ });
234
+ }
235
+ }
236
+
237
+ const notForViewerSkipped = inScope.length - seenLeafIndex.size;
238
+
239
+ // Sender memos (Phase 2): produce OUT records the user emitted.
240
+ // Skipped entirely when the delegation is INCOMING_ONLY.
241
+ if (!incomingOnly && options.senderMemos && options.senderMemos.length > 0) {
242
+ for (const memo of options.senderMemos) {
243
+ if (effectiveFromSlot != null || effectiveToSlot != null) {
244
+ if (memo.slot == null) {
245
+ unscopedSkipped++;
246
+ continue;
247
+ }
248
+ if (!isSlotInDelegatedRange(keyForRange, memo.slot)) {
249
+ outOfRangeSkipped++;
250
+ continue;
251
+ }
252
+ }
253
+ const plain = decryptSenderMemo(key.viewingPrivKey, memo);
254
+ if (!plain) continue;
255
+ // Sender memos are encrypted to the user's own viewing key, so the
256
+ // primary sanity check is non-zero. The 8-byte field already caps amount at u64::MAX.
257
+ if (plain.amount <= 0n) continue;
258
+ // Token filter: respect the requested tokenIds set.
259
+ if (!options.tokenIds.some((t) => t === plain.tokenId)) continue;
260
+
261
+ records.push({
262
+ slot: memo.slot ?? 0,
263
+ blockTime: memo.blockTime ?? 0,
264
+ leafIndex: memo.leafIndex,
265
+ direction: "OUT",
266
+ announcementType: -1, // distinct from deposit (0) / transfer (1)
267
+ tokenId: plain.tokenId,
268
+ amount: plain.amount,
269
+ commitmentHex: bytesToHex(memo.commitment),
270
+ ephemeralPubHex: "",
271
+ });
272
+ }
273
+ }
274
+
275
+ // Auditor ciphertexts (Method-Y): produce AUDITOR_VISIBLE records.
276
+ if (
277
+ options.auditorViewingPrivKey &&
278
+ options.auditorCiphertexts &&
279
+ options.auditorCiphertexts.length > 0
280
+ ) {
281
+ for (const entry of options.auditorCiphertexts) {
282
+ if (effectiveFromSlot != null || effectiveToSlot != null) {
283
+ if (entry.slot == null) {
284
+ unscopedSkipped++;
285
+ continue;
286
+ }
287
+ if (!isSlotInDelegatedRange(keyForRange, entry.slot)) {
288
+ outOfRangeSkipped++;
289
+ continue;
290
+ }
291
+ }
292
+ const plain = decryptAuditorCiphertext(
293
+ options.auditorViewingPrivKey,
294
+ entry.blob,
295
+ entry.commitment,
296
+ );
297
+ if (!plain) continue;
298
+ if (plain.amount <= 0n) continue;
299
+ if (!options.tokenIds.some((t) => t === plain.tokenId)) continue;
300
+
301
+ records.push(buildAuditorVisibleRecord(plain, entry.commitment, entry.slot ?? 0, entry.blockTime ?? 0));
302
+ }
303
+ }
304
+
305
+ return {
306
+ records,
307
+ outOfRangeSkipped,
308
+ unscopedSkipped,
309
+ notForViewerSkipped: Math.max(0, notForViewerSkipped),
310
+ effectiveFromSlot,
311
+ effectiveToSlot,
312
+ };
313
+ }
314
+
315
+ /**
316
+ * Scan auditor ciphertexts (Method-Y) using only an auditor viewing private key.
317
+ *
318
+ * Unlike {@link auditScan}, this function does NOT require a full {@link DelegatedViewKey}
319
+ * with spendingPubKey/nullifyingKey — it only needs the auditor's 32-byte Ed25519
320
+ * viewing private key. This is the primary entry point for Method-Y auditor use cases.
321
+ */
322
+ export async function auditScanCiphertexts(
323
+ auditorViewingPrivKey: Uint8Array,
324
+ ciphertexts: ReadonlyArray<{ commitment: Uint8Array; blob: Uint8Array; slot?: number; blockTime?: number }>,
325
+ options?: { tokenIds?: bigint[]; fromSlot?: number; toSlot?: number },
326
+ ): Promise<AuditScanSummary> {
327
+ const records: AuditRecord[] = [];
328
+ let outOfRangeSkipped = 0;
329
+ let unscopedSkipped = 0;
330
+ let notForViewerSkipped = 0;
331
+
332
+ const effectiveFromSlot = options?.fromSlot;
333
+ const effectiveToSlot = options?.toSlot;
334
+
335
+ for (const entry of ciphertexts) {
336
+ if (effectiveFromSlot != null || effectiveToSlot != null) {
337
+ if (entry.slot == null) {
338
+ unscopedSkipped++;
339
+ continue;
340
+ }
341
+ if (effectiveFromSlot != null && entry.slot < effectiveFromSlot) {
342
+ outOfRangeSkipped++;
343
+ continue;
344
+ }
345
+ if (effectiveToSlot != null && entry.slot > effectiveToSlot) {
346
+ outOfRangeSkipped++;
347
+ continue;
348
+ }
349
+ }
350
+
351
+ const plain = decryptAuditorCiphertext(auditorViewingPrivKey, entry.blob, entry.commitment);
352
+ if (!plain) {
353
+ notForViewerSkipped++;
354
+ continue;
355
+ }
356
+ if (plain.amount <= 0n) {
357
+ notForViewerSkipped++;
358
+ continue;
359
+ }
360
+ if (options?.tokenIds && options.tokenIds.length > 0 && !options.tokenIds.some((t) => t === plain.tokenId)) {
361
+ notForViewerSkipped++;
362
+ continue;
363
+ }
364
+
365
+ records.push(buildAuditorVisibleRecord(plain, entry.commitment, entry.slot ?? 0, entry.blockTime ?? 0));
366
+ }
367
+
368
+ return {
369
+ records,
370
+ outOfRangeSkipped,
371
+ unscopedSkipped,
372
+ notForViewerSkipped,
373
+ effectiveFromSlot,
374
+ effectiveToSlot,
375
+ };
376
+ }
377
+
378
+ /**
379
+ * Render audit records as CSV. Always returns a single trailing newline.
380
+ *
381
+ * Columns: slot, block_time_iso, leaf_index, direction, type, token_id, amount, commitment, ephemeral_pub
382
+ */
383
+ export function auditRecordsToCsv(records: ReadonlyArray<AuditRecord>): string {
384
+ const header =
385
+ "slot,block_time_iso,leaf_index,direction,type,token_id,amount,commitment,ephemeral_pub";
386
+ const lines = records.map((r) => {
387
+ const ts = r.blockTime > 0 ? new Date(r.blockTime * 1000).toISOString() : "";
388
+ const typeLabel =
389
+ r.direction === "OUT"
390
+ ? "sender-memo"
391
+ : r.direction === "AUDITOR_VISIBLE"
392
+ ? "auditor-ciphertext"
393
+ : r.announcementType === ANNOUNCEMENT_TYPE_DEPOSIT
394
+ ? "deposit"
395
+ : "transfer";
396
+ return [
397
+ r.slot,
398
+ ts,
399
+ r.leafIndex,
400
+ r.direction,
401
+ typeLabel,
402
+ r.tokenId.toString(),
403
+ r.amount.toString(),
404
+ r.commitmentHex,
405
+ r.ephemeralPubHex,
406
+ ].join(",");
407
+ });
408
+ return [header, ...lines].join("\n") + "\n";
409
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Ika dWallet → Bitcoin P2TR address derivation.
3
+ *
4
+ * UTXOpia v2 custody is held by an Ika dWallet on Solana. The dWallet's
5
+ * compressed secp256k1 public key (or its x-only form) identifies the Bitcoin
6
+ * key that Ika can sign for. Ika pre-alpha signs for the raw x-only key; it
7
+ * does not currently produce signatures for UTXOpia's per-deposit tweaked keys.
8
+ */
9
+
10
+ import { taggedHash, hexToBytes, bytesToHex } from "../crypto";
11
+ import { bech32m } from "bech32";
12
+ import { secp256k1 } from "@noble/curves/secp256k1.js";
13
+
14
+ /**
15
+ * Reference to an Ika dWallet for address derivation.
16
+ *
17
+ * `literal-pubkey` is the synchronous form — pass a 33-byte compressed or
18
+ * 32-byte x-only pubkey directly. The `id` form is async and must be resolved
19
+ * to a pubkey via the Ika SDK before reaching this helper.
20
+ */
21
+ export type IkaDWalletRef =
22
+ | { type: "literal-xonly"; xonlyPubkey: Uint8Array } // 32 bytes
23
+ | { type: "literal-compressed"; compressedPubkey: Uint8Array } // 33 bytes (0x02/0x03 || x)
24
+ | { type: "id"; dwalletId: string };
25
+
26
+ /**
27
+ * Derive the BIP-341 P2TR (key-path-only) address controlled by an Ika dWallet.
28
+ *
29
+ * BIP-341 (no script tree):
30
+ * t = h_tapTweak(P)
31
+ * Q = P + t*G
32
+ * output_key = x(Q)
33
+ * address = bech32m(hrp, [witness_version=1, ...words(output_key)])
34
+ *
35
+ * @param ref The Ika dWallet reference (literal pubkey, or future async id).
36
+ * @param network "mainnet" | "testnet" | "regtest"
37
+ * @returns The P2TR address (`bc1p…` / `tb1p…` / `bcrt1p…`)
38
+ */
39
+ export function deriveCustodyAddressFromIkaDWallet(
40
+ ref: IkaDWalletRef,
41
+ network: "mainnet" | "testnet" | "regtest"
42
+ ): string {
43
+ const xonly = extractXOnly(ref);
44
+ const tweak = taggedHash("TapTweak", xonly);
45
+ const tweakScalar = bytesToBigIntBe(tweak);
46
+
47
+ // Lift x-only to a full point with even y (BIP-340 convention).
48
+ const internalPoint = secp256k1.Point.fromHex("02" + bytesToHex(xonly));
49
+ const tweakPoint = secp256k1.Point.BASE.multiply(tweakScalar);
50
+ const outputPoint = internalPoint.add(tweakPoint);
51
+
52
+ // Drop the 1-byte parity prefix to get the x-only output key.
53
+ const outputKey = hexToBytes(outputPoint.toHex(true).slice(2));
54
+
55
+ const hrp =
56
+ network === "mainnet" ? "bc" : network === "regtest" ? "bcrt" : "tb";
57
+ const words = bech32m.toWords(outputKey);
58
+ return bech32m.encode(hrp, [1, ...words]);
59
+ }
60
+
61
+ /**
62
+ * Encode a raw x-only public key as a P2TR witness program.
63
+ *
64
+ * This intentionally does not apply BIP-341's no-script TapTweak. It is the
65
+ * direct-vault address used by the current Ika pre-alpha mock signer, because
66
+ * the signer returns Schnorr signatures for the dWallet's raw x-only key.
67
+ */
68
+ export function deriveRawXOnlyP2TRAddress(
69
+ xonlyPubkey: Uint8Array,
70
+ network: "mainnet" | "testnet" | "regtest"
71
+ ): string {
72
+ if (xonlyPubkey.length !== 32) {
73
+ throw new Error("xonlyPubkey must be 32 bytes");
74
+ }
75
+ const hrp =
76
+ network === "mainnet" ? "bc" : network === "regtest" ? "bcrt" : "tb";
77
+ return bech32m.encode(hrp, [1, ...bech32m.toWords(xonlyPubkey)]);
78
+ }
79
+
80
+ function extractXOnly(ref: IkaDWalletRef): Uint8Array {
81
+ if (ref.type === "literal-xonly") {
82
+ if (ref.xonlyPubkey.length !== 32) {
83
+ throw new Error("xonlyPubkey must be 32 bytes");
84
+ }
85
+ return ref.xonlyPubkey;
86
+ }
87
+ if (ref.type === "literal-compressed") {
88
+ if (ref.compressedPubkey.length !== 33) {
89
+ throw new Error("compressedPubkey must be 33 bytes");
90
+ }
91
+ return ref.compressedPubkey.subarray(1);
92
+ }
93
+ throw new Error(
94
+ "deriveCustodyAddressFromIkaDWallet: 'id' resolution requires the Ika SDK; " +
95
+ "resolve dwalletId → pubkey first and pass via literal-xonly or literal-compressed"
96
+ );
97
+ }
98
+
99
+ function bytesToBigIntBe(bytes: Uint8Array): bigint {
100
+ let n = 0n;
101
+ for (const b of bytes) n = (n << 8n) | BigInt(b);
102
+ return n;
103
+ }
@@ -0,0 +1,5 @@
1
+ export {
2
+ deriveCustodyAddressFromIkaDWallet,
3
+ deriveRawXOnlyP2TRAddress,
4
+ } from "./ika";
5
+ export type { IkaDWalletRef } from "./ika";