@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/events.ts ADDED
@@ -0,0 +1,384 @@
1
+ /**
2
+ * Event parser for UTXOpia sol_log_data events
3
+ *
4
+ * Events are emitted by the on-chain program as base64-encoded log data.
5
+ * Transaction logs contain lines like: "Program data: <base64>"
6
+ * Each base64 segment decodes to one slice from sol_log_data.
7
+ *
8
+ * ## Events
9
+ *
10
+ * - 0x02 NullifierSpent: disc(1) + hash(32) + op_type(1) = 34 bytes
11
+ * - 0x03 StealthAnnouncement: disc(1) + type(1) + ephemeral(32) + amount(8) + commitment(32) + leaf_index(4) = 78 bytes
12
+ * - 0x0B NullifiersBatch: flat payload in single segment
13
+ * - 0x0C AnnouncementsBatch: flat payload in single segment
14
+ */
15
+
16
+ /** Event discriminators matching contracts/programs/utxopia/src/utils/events.rs */
17
+ export const EVENT_NULLIFIER_SPENT = 0x02;
18
+ export const EVENT_STEALTH_ANNOUNCEMENT = 0x03;
19
+ export const EVENT_NULLIFIERS_BATCH = 0x0b;
20
+ export const EVENT_ANNOUNCEMENTS_BATCH = 0x0c;
21
+ /** Phase 2: sender memo (XChaCha20-Poly1305 AEAD payload). */
22
+ export const EVENT_SENDER_MEMO = 0x12;
23
+ export const EVENT_BTC_ORIGIN_ATTESTATION = 0x15;
24
+ /** Method-Y: auditor ciphertext emitted alongside every shielded deposit into a permissioned pool. */
25
+ export const EVENT_AUDITOR_CIPHERTEXT = 0x16;
26
+
27
+ /** Parsed nullifier spent event */
28
+ export interface NullifierSpentEvent {
29
+ type: "nullifier_spent";
30
+ nullifierHash: Uint8Array; // 32 bytes
31
+ operationType: number;
32
+ }
33
+
34
+ /** Parsed stealth announcement event (includes token_id) */
35
+ export interface StealthAnnouncementEvent {
36
+ type: "stealth_announcement";
37
+ announcementType: number; // 0=deposit, 1=transfer
38
+ ephemeralPub: Uint8Array; // 32 bytes
39
+ encryptedAmount: Uint8Array; // 8 bytes
40
+ commitment: Uint8Array; // 32 bytes
41
+ leafIndex: number;
42
+ tokenId?: Uint8Array; // 32 bytes (present for deposit/unshield, zero for private transfers)
43
+ }
44
+
45
+ /** Parsed sender memo event (Phase 2). */
46
+ export interface SenderMemoEvent {
47
+ type: "sender_memo";
48
+ /** 24-byte XChaCha20 nonce. */
49
+ nonce: Uint8Array;
50
+ /** 56-byte ChaCha20 ciphertext + Poly1305 tag. */
51
+ ciphertextWithTag: Uint8Array;
52
+ /** Commitment of the output this memo covers (also AAD). */
53
+ commitment: Uint8Array;
54
+ /** Leaf index of the covered output (also AAD). */
55
+ leafIndex: number;
56
+ }
57
+
58
+ /**
59
+ * BTC origin attestation, emitted alongside every SPV-verified deposit.
60
+ * Lets third-party auditors anchor commitments to their on-chain BTC
61
+ * origin without trusting our backend.
62
+ *
63
+ * Layout matches the Rust `emit_btc_origin_attestation` in
64
+ * `contracts/programs/utxopia/src/utils/events.rs`.
65
+ */
66
+ export interface BtcOriginAttestationEvent {
67
+ type: "btc_origin_attestation";
68
+ blockHeight: bigint;
69
+ /** Bitcoin deposit txid in internal byte order (same as `complete_deposit` instruction data). */
70
+ depositTxid: Uint8Array;
71
+ /** Sweep transaction's output index that paid the pool. */
72
+ sweepVout: number;
73
+ /** Commitment inserted into the JoinSplit tree for this deposit. */
74
+ commitment: Uint8Array;
75
+ /** Pool-received amount in satoshis (after sweep fees). */
76
+ amountSats: bigint;
77
+ }
78
+
79
+ /**
80
+ * Auditor ciphertext event (Method-Y permissioned pools).
81
+ *
82
+ * Emitted alongside every shielded deposit into a permissioned pool so that
83
+ * a designated auditor can decrypt note viewing data off-chain.
84
+ */
85
+ export interface AuditorCiphertextEvent {
86
+ type: "auditor_ciphertext";
87
+ /** 32-byte Poseidon commitment of the shielded note. */
88
+ commitment: Uint8Array;
89
+ /** 112-byte encrypted blob: eph_pub(32) || nonce(24) || ciphertextWithTag(56). */
90
+ blob: Uint8Array;
91
+ }
92
+
93
+ export type ProgramEvent =
94
+ | NullifierSpentEvent
95
+ | StealthAnnouncementEvent
96
+ | SenderMemoEvent
97
+ | BtcOriginAttestationEvent
98
+ | AuditorCiphertextEvent;
99
+
100
+ /**
101
+ * Parse a nullifier spent event from decoded sol_log_data segments.
102
+ * Expected: disc(1) + nullifier_hash(32) + op_type(1)
103
+ */
104
+ export function parseNullifierSpentEvent(segments: Uint8Array[]): NullifierSpentEvent | null {
105
+ if (segments.length < 3) return null;
106
+ if (segments[0].length !== 1 || segments[0][0] !== EVENT_NULLIFIER_SPENT) return null;
107
+
108
+ const nullifierHash = segments[1];
109
+ if (nullifierHash.length !== 32) return null;
110
+
111
+ const opType = segments[2];
112
+ if (opType.length !== 1) return null;
113
+
114
+ return {
115
+ type: "nullifier_spent",
116
+ nullifierHash,
117
+ operationType: opType[0],
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Parse a stealth announcement event from decoded sol_log_data segments.
123
+ * v1: disc(1) + type(1) + ephemeral_pub(32) + encrypted_amount(8) + commitment(32) + leaf_index(4) = 6 segments
124
+ * v2: + token_id(32) = 7 segments
125
+ */
126
+ export function parseStealthAnnouncementEvent(segments: Uint8Array[]): StealthAnnouncementEvent | null {
127
+ if (segments.length < 6) return null;
128
+ if (segments[0].length !== 1 || segments[0][0] !== EVENT_STEALTH_ANNOUNCEMENT) return null;
129
+
130
+ const atype = segments[1];
131
+ if (atype.length !== 1) return null;
132
+
133
+ const ephemeralPub = segments[2];
134
+ if (ephemeralPub.length !== 32) return null;
135
+
136
+ const encryptedAmount = segments[3];
137
+ if (encryptedAmount.length !== 8) return null;
138
+
139
+ const commitment = segments[4];
140
+ if (commitment.length !== 32) return null;
141
+
142
+ const liBytes = segments[5];
143
+ if (liBytes.length !== 4) return null;
144
+ const view = new DataView(liBytes.buffer, liBytes.byteOffset, 4);
145
+ const leafIndex = view.getUint32(0, true);
146
+
147
+ // v2: token_id at segment 6
148
+ let tokenId: Uint8Array | undefined;
149
+ if (segments.length >= 7 && segments[6].length === 32) {
150
+ tokenId = segments[6];
151
+ }
152
+
153
+ return {
154
+ type: "stealth_announcement",
155
+ announcementType: atype[0],
156
+ ephemeralPub,
157
+ encryptedAmount,
158
+ commitment,
159
+ leafIndex,
160
+ tokenId,
161
+ };
162
+ }
163
+
164
+ /**
165
+ * Parse an association-set update event (Phase 3) from decoded sol_log_data segments.
166
+ * Layout: disc(1) + new_root(32) + status(1) + version_le(8)
167
+ */
168
+ /**
169
+ * Parse a BTC origin attestation event from decoded sol_log_data segments.
170
+ * Layout: disc(1) + block_height(8 LE) + deposit_txid(32) + sweep_vout(4 LE)
171
+ * + commitment(32) + amount_sats(8 LE)
172
+ */
173
+ export function parseBtcOriginAttestationEvent(
174
+ segments: Uint8Array[],
175
+ ): BtcOriginAttestationEvent | null {
176
+ if (segments.length < 6) return null;
177
+ if (segments[0].length !== 1 || segments[0][0] !== EVENT_BTC_ORIGIN_ATTESTATION) return null;
178
+
179
+ const bhBytes = segments[1];
180
+ if (bhBytes.length !== 8) return null;
181
+ let blockHeight = 0n;
182
+ for (let i = 7; i >= 0; i--) blockHeight = (blockHeight << 8n) | BigInt(bhBytes[i]);
183
+
184
+ const depositTxid = segments[2];
185
+ if (depositTxid.length !== 32) return null;
186
+
187
+ const voutBytes = segments[3];
188
+ if (voutBytes.length !== 4) return null;
189
+ const sweepVout = new DataView(
190
+ voutBytes.buffer,
191
+ voutBytes.byteOffset,
192
+ 4,
193
+ ).getUint32(0, true);
194
+
195
+ const commitment = segments[4];
196
+ if (commitment.length !== 32) return null;
197
+
198
+ const amtBytes = segments[5];
199
+ if (amtBytes.length !== 8) return null;
200
+ let amountSats = 0n;
201
+ for (let i = 7; i >= 0; i--) amountSats = (amountSats << 8n) | BigInt(amtBytes[i]);
202
+
203
+ return {
204
+ type: "btc_origin_attestation",
205
+ blockHeight,
206
+ depositTxid,
207
+ sweepVout,
208
+ commitment,
209
+ amountSats,
210
+ };
211
+ }
212
+
213
+ /**
214
+ * Parse a sender memo event (Phase 2) from decoded sol_log_data segments.
215
+ * Layout: disc(1) + nonce(24) + ciphertext_and_tag(56) + commitment(32) + leaf_index(4)
216
+ */
217
+ export function parseSenderMemoEvent(segments: Uint8Array[]): SenderMemoEvent | null {
218
+ if (segments.length < 5) return null;
219
+ if (segments[0].length !== 1 || segments[0][0] !== EVENT_SENDER_MEMO) return null;
220
+
221
+ const nonce = segments[1];
222
+ if (nonce.length !== 24) return null;
223
+
224
+ const ciphertextWithTag = segments[2];
225
+ if (ciphertextWithTag.length !== 56) return null;
226
+
227
+ const commitment = segments[3];
228
+ if (commitment.length !== 32) return null;
229
+
230
+ const liBytes = segments[4];
231
+ if (liBytes.length !== 4) return null;
232
+ const leafIndex = new DataView(liBytes.buffer, liBytes.byteOffset, 4).getUint32(0, true);
233
+
234
+ return { type: "sender_memo", nonce, ciphertextWithTag, commitment, leafIndex };
235
+ }
236
+
237
+ /**
238
+ * Parse batched nullifiers from a single flat segment.
239
+ * Layout: disc(1) + count(1) + op_type(1) + [hash(32)] x count
240
+ */
241
+ function parseNullifiersBatch(data: Uint8Array): NullifierSpentEvent[] {
242
+ if (data.length < 3) return [];
243
+ const count = data[1];
244
+ const opType = data[2];
245
+ const expectedLen = 3 + count * 32;
246
+ if (data.length < expectedLen) return [];
247
+
248
+ const events: NullifierSpentEvent[] = [];
249
+ for (let i = 0; i < count; i++) {
250
+ const offset = 3 + i * 32;
251
+ events.push({
252
+ type: "nullifier_spent",
253
+ nullifierHash: data.slice(offset, offset + 32),
254
+ operationType: opType,
255
+ });
256
+ }
257
+ return events;
258
+ }
259
+
260
+ /**
261
+ * Parse batched announcements from a single flat segment.
262
+ * v1: disc(1) + count(1) + [type(1) + ephemeral(32) + amount(8) + commitment(32) + leaf_index(4)] x count (77 per item)
263
+ * v2: disc(1) + count(1) + [type(1) + ephemeral(32) + amount(8) + commitment(32) + leaf_index(4) + token_id(32)] x count (109 per item)
264
+ */
265
+ function parseAnnouncementsBatch(data: Uint8Array): StealthAnnouncementEvent[] {
266
+ if (data.length < 2) return [];
267
+ const count = data[1];
268
+ if (count === 0) return [];
269
+
270
+ // Detect v1 vs v2 by checking total size
271
+ const remainingBytes = data.length - 2;
272
+ const v2ItemSize = 109;
273
+ const v1ItemSize = 77;
274
+ const isV2 = remainingBytes >= count * v2ItemSize;
275
+ const itemSize = isV2 ? v2ItemSize : v1ItemSize;
276
+
277
+ const expectedLen = 2 + count * itemSize;
278
+ if (data.length < expectedLen) return [];
279
+
280
+ const events: StealthAnnouncementEvent[] = [];
281
+ for (let i = 0; i < count; i++) {
282
+ const offset = 2 + i * itemSize;
283
+ const liView = new DataView(data.buffer, data.byteOffset + offset + 73, 4);
284
+ const event: StealthAnnouncementEvent = {
285
+ type: "stealth_announcement",
286
+ announcementType: data[offset],
287
+ ephemeralPub: data.slice(offset + 1, offset + 33),
288
+ encryptedAmount: data.slice(offset + 33, offset + 41),
289
+ commitment: data.slice(offset + 41, offset + 73),
290
+ leafIndex: liView.getUint32(0, true),
291
+ };
292
+ if (isV2) {
293
+ event.tokenId = data.slice(offset + 77, offset + 109);
294
+ }
295
+ events.push(event);
296
+ }
297
+ return events;
298
+ }
299
+
300
+ /**
301
+ * Parse an auditor ciphertext event (Method-Y) from decoded sol_log_data segments.
302
+ * Layout: disc(1) + commitment(32) + blob(112)
303
+ */
304
+ export function parseAuditorCiphertextEvent(segments: Uint8Array[]): AuditorCiphertextEvent | null {
305
+ if (segments.length < 3) return null;
306
+ if (segments[0].length !== 1 || segments[0][0] !== EVENT_AUDITOR_CIPHERTEXT) return null;
307
+
308
+ const commitment = segments[1];
309
+ if (commitment.length !== 32) return null;
310
+
311
+ const blob = segments[2];
312
+ if (blob.length !== 112) return null;
313
+
314
+ return { type: "auditor_ciphertext", commitment, blob };
315
+ }
316
+
317
+
318
+ function decodeBase64(b64: string): Uint8Array {
319
+ const binary = atob(b64);
320
+ const bytes = new Uint8Array(binary.length);
321
+ for (let i = 0; i < binary.length; i++) {
322
+ bytes[i] = binary.charCodeAt(i);
323
+ }
324
+ return bytes;
325
+ }
326
+
327
+ /**
328
+ * Parse program events from Solana transaction log messages.
329
+ *
330
+ * sol_log_data emits log lines in the format:
331
+ * "Program data: <base64_segment1> <base64_segment2> ..."
332
+ *
333
+ * @param logs - Array of log message strings from a transaction
334
+ * @param programId - Optional program ID to filter events (matches "Program <id> invoke" blocks)
335
+ */
336
+ export function parseProgramEvents(logs: string[], programId?: string): ProgramEvent[] {
337
+ const events: ProgramEvent[] = [];
338
+ const DATA_PREFIX = "Program data: ";
339
+
340
+ for (const line of logs) {
341
+ if (!line.startsWith(DATA_PREFIX)) continue;
342
+
343
+ const b64Parts = line.slice(DATA_PREFIX.length).split(" ");
344
+ const segments = b64Parts.map(decodeBase64);
345
+
346
+ if (segments.length === 0) continue;
347
+
348
+ // Handle batch events (single flat segment)
349
+ if (segments.length === 1 && segments[0].length > 1) {
350
+ const disc = segments[0][0];
351
+ if (disc === EVENT_NULLIFIERS_BATCH) {
352
+ events.push(...parseNullifiersBatch(segments[0]));
353
+ continue;
354
+ }
355
+ if (disc === EVENT_ANNOUNCEMENTS_BATCH) {
356
+ events.push(...parseAnnouncementsBatch(segments[0]));
357
+ continue;
358
+ }
359
+ }
360
+
361
+ if (segments[0].length !== 1) continue;
362
+
363
+ const disc = segments[0][0];
364
+
365
+ if (disc === EVENT_NULLIFIER_SPENT) {
366
+ const event = parseNullifierSpentEvent(segments);
367
+ if (event) events.push(event);
368
+ } else if (disc === EVENT_STEALTH_ANNOUNCEMENT) {
369
+ const event = parseStealthAnnouncementEvent(segments);
370
+ if (event) events.push(event);
371
+ } else if (disc === EVENT_SENDER_MEMO) {
372
+ const event = parseSenderMemoEvent(segments);
373
+ if (event) events.push(event);
374
+ } else if (disc === EVENT_BTC_ORIGIN_ATTESTATION) {
375
+ const event = parseBtcOriginAttestationEvent(segments);
376
+ if (event) events.push(event);
377
+ } else if (disc === EVENT_AUDITOR_CIPHERTEXT) {
378
+ const event = parseAuditorCiphertextEvent(segments);
379
+ if (event) events.push(event);
380
+ }
381
+ }
382
+
383
+ return events;
384
+ }
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Explorer utilities for UTXOPIA
3
+ *
4
+ * Types, parsers, and fetchers for browsing on-chain UTXOpia activity:
5
+ * - Deposits (from event indexer)
6
+ * - Transfers (from event indexer)
7
+ * - Redemptions (RedemptionRequest accounts)
8
+ */
9
+
10
+ import type { RpcClient } from "./commitment-tree";
11
+
12
+ // =============================================================================
13
+ // Constants
14
+ // =============================================================================
15
+
16
+ /** NullifierRecord account size (1 byte — slim layout, just discriminator) */
17
+ export const NULLIFIER_RECORD_SIZE = 1;
18
+
19
+ /** RedemptionRequest account size (178 bytes):
20
+ * disc(1) status(1) btc_script_len(1) signing_approved(1) processing_slot(4) request_id(8)
21
+ * requester(32) amount_sats(8) service_fee(8) total_input_sats(8) btc_script(34)
22
+ * token_id(32) reserved_count(1) approved_inputs(4) _padding2(3) inputs_commitment(32).
23
+ *
24
+ * This is a `dataSize` filter, so a stale value silently returns nothing rather than failing:
25
+ * it sat at 138 — the struct's length before reserved_count/approved_inputs/inputs_commitment
26
+ * were added — and matched no account on chain. `RedemptionRequest::LEN` is pinned by a test
27
+ * on the program side; update both together. Field offsets below are unaffected. */
28
+ export const REDEMPTION_REQUEST_SIZE = 178;
29
+
30
+ /** NullifierRecord discriminator byte */
31
+ export const NULLIFIER_RECORD_DISCRIMINATOR = 0x03;
32
+
33
+ /** RedemptionRequest discriminator byte */
34
+ export const REDEMPTION_REQUEST_DISCRIMINATOR = 0x04;
35
+
36
+ /** Max plausible plaintext amount: 21M BTC in sats */
37
+ const MAX_PLAINTEXT_SATS = 21_000_000n * 100_000_000n;
38
+
39
+ /** Human-readable labels for nullifier operation types */
40
+ export const OPERATION_TYPE_LABELS: Record<number, string> = {
41
+ 0: "Full Withdrawal",
42
+ 1: "Partial Withdrawal",
43
+ 2: "Private Transfer",
44
+ 3: "Transfer",
45
+ 4: "Split",
46
+ 5: "Join",
47
+ };
48
+
49
+ // =============================================================================
50
+ // Types
51
+ // =============================================================================
52
+
53
+ /** Parsed deposit from indexer event data */
54
+ export interface ExplorerDeposit {
55
+ pubkey: string;
56
+ amountSats: bigint;
57
+ leafIndex: bigint;
58
+ /** Commitment hex (from indexer events, not on-chain) */
59
+ commitment?: string;
60
+ /** Unix timestamp (from indexer events, not on-chain) */
61
+ createdAt?: number;
62
+ /** Ephemeral public key hex (from stealth announcement) */
63
+ ephemeralPub?: string;
64
+ /** Solana transaction signature */
65
+ txSignature?: string;
66
+ }
67
+
68
+ /** Transfer event — either a new commitment or a spent nullifier */
69
+ export interface ExplorerTransferEvent {
70
+ type: "commitment" | "nullifier";
71
+ pubkey: string;
72
+ timestamp: number;
73
+ commitment?: string;
74
+ leafIndex?: bigint;
75
+ nullifierHash?: string;
76
+ operationType?: string;
77
+ spentBy?: string;
78
+ }
79
+
80
+ /** Parsed redemption request */
81
+ export interface ExplorerRedemption {
82
+ pubkey: string;
83
+ requestId: bigint;
84
+ amountSats: bigint;
85
+ /** Service fee in satoshis, locked at request time */
86
+ serviceFee: bigint;
87
+ status: "Pending" | "Processing" | "Failed";
88
+ requester: string;
89
+ btcScript: string;
90
+ /** Slot when processing started (from PDA data[4..8]) — 0 if still Pending */
91
+ processingSlot: number;
92
+ }
93
+
94
+ // =============================================================================
95
+ // Helpers
96
+ // =============================================================================
97
+
98
+ function toHex(bytes: Uint8Array): string {
99
+ return Array.from(bytes)
100
+ .map((b) => b.toString(16).padStart(2, "0"))
101
+ .join("");
102
+ }
103
+
104
+ function readU64LE(data: Uint8Array, offset: number): bigint {
105
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
106
+ return view.getBigUint64(offset, true);
107
+ }
108
+
109
+ function readI64LE(data: Uint8Array, offset: number): number {
110
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
111
+ return Number(view.getBigInt64(offset, true));
112
+ }
113
+
114
+ function bs58Encode(bytes: Uint8Array): string {
115
+ const ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
116
+ let num = 0n;
117
+ for (const byte of bytes) {
118
+ num = num * 256n + BigInt(byte);
119
+ }
120
+ let encoded = "";
121
+ while (num > 0n) {
122
+ const remainder = num % 58n;
123
+ num = num / 58n;
124
+ encoded = ALPHABET[Number(remainder)] + encoded;
125
+ }
126
+ for (const byte of bytes) {
127
+ if (byte === 0) encoded = "1" + encoded;
128
+ else break;
129
+ }
130
+ return encoded || "1";
131
+ }
132
+
133
+ function decodeBase64(b64: string): Uint8Array {
134
+ const binary = atob(b64);
135
+ const bytes = new Uint8Array(binary.length);
136
+ for (let i = 0; i < binary.length; i++) {
137
+ bytes[i] = binary.charCodeAt(i);
138
+ }
139
+ return bytes;
140
+ }
141
+
142
+ // =============================================================================
143
+ // Parsers
144
+ // =============================================================================
145
+
146
+ /** Parse a NullifierRecord account (1 byte — slim layout)
147
+ * Only confirms existence (discriminator = 0x03). Metadata from indexer events. */
148
+ export function parseNullifierRecord(
149
+ pubkey: string,
150
+ _data: Uint8Array
151
+ ): ExplorerTransferEvent {
152
+ return {
153
+ type: "nullifier",
154
+ pubkey,
155
+ timestamp: 0, // metadata available from indexer
156
+ };
157
+ }
158
+
159
+ /** Parse a RedemptionRequest account (138 bytes, raw scriptPubKey) */
160
+ export function parseRedemptionRequest(
161
+ pubkey: string,
162
+ data: Uint8Array
163
+ ): ExplorerRedemption {
164
+ const statusByte = data[1];
165
+ const status: ExplorerRedemption["status"] =
166
+ statusByte === 1 ? "Processing" : statusByte === 2 ? "Failed" : "Pending";
167
+ const scriptLen = data[2];
168
+ // data[3] = padding, data[4..8] = processing_slot (u32 LE)
169
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
170
+ const processingSlot = view.getUint32(4, true);
171
+
172
+ return {
173
+ pubkey,
174
+ requestId: readU64LE(data, 8),
175
+ amountSats: readU64LE(data, 48),
176
+ serviceFee: readU64LE(data, 56),
177
+ status,
178
+ requester: bs58Encode(data.slice(16, 48)),
179
+ // btc_script starts at offset 72 (after total_input_sats at 64..72).
180
+ btcScript: toHex(data.slice(72, 72 + Math.min(scriptLen, 34))),
181
+ processingSlot,
182
+ };
183
+ }
184
+
185
+ // =============================================================================
186
+ // RPC helpers
187
+ // =============================================================================
188
+
189
+ async function fetchAccountsBySize(
190
+ rpc: RpcClient,
191
+ programId: string,
192
+ dataSize: number
193
+ ): Promise<{ pubkey: string; data: Uint8Array }[]> {
194
+ const accounts = await rpc.getProgramAccounts(programId, {
195
+ filters: [{ dataSize }],
196
+ encoding: "base64",
197
+ });
198
+
199
+ return accounts.map((acc) => {
200
+ const raw =
201
+ typeof acc.account.data === "string"
202
+ ? acc.account.data
203
+ : // @solana/kit returns [base64String, "base64"]
204
+ (acc.account.data as unknown as string[])[0];
205
+ return {
206
+ pubkey: String(acc.pubkey),
207
+ data: decodeBase64(raw),
208
+ };
209
+ });
210
+ }
211
+
212
+ // =============================================================================
213
+ // Fetchers
214
+ // =============================================================================
215
+
216
+ /** Indexer leaf data for enriching explorer deposits */
217
+ export interface IndexerLeaf {
218
+ leaf_index: number;
219
+ commitment: string; // hex
220
+ created_at: number; // unix timestamp
221
+ announcement_type?: number; // 0=deposit, 1=transfer
222
+ amount_sats?: number; // plaintext amount (deposits only)
223
+ ephemeral_pub?: string; // hex (from stealth announcement)
224
+ tx_signature?: string; // Solana tx signature
225
+ }
226
+
227
+ /** Fetch all deposit announcements from indexer data */
228
+ export async function fetchExplorerDeposits(
229
+ _rpc: RpcClient,
230
+ _programId: string,
231
+ indexerLeaves?: IndexerLeaf[]
232
+ ): Promise<ExplorerDeposit[]> {
233
+ if (!indexerLeaves || indexerLeaves.length === 0) return [];
234
+
235
+ return indexerLeaves
236
+ .filter((leaf) => leaf.announcement_type === 0) // deposits only
237
+ .map((leaf) => ({
238
+ pubkey: "", // no PDA — data comes from events
239
+ amountSats: BigInt(leaf.amount_sats ?? 0),
240
+ leafIndex: BigInt(leaf.leaf_index),
241
+ commitment: leaf.commitment,
242
+ createdAt: leaf.created_at,
243
+ ephemeralPub: leaf.ephemeral_pub,
244
+ txSignature: leaf.tx_signature,
245
+ }))
246
+ .sort((a, b) => {
247
+ const aHasTime = (a.createdAt ?? 0) > 0;
248
+ const bHasTime = (b.createdAt ?? 0) > 0;
249
+ if (aHasTime && bHasTime) return (b.createdAt ?? 0) - (a.createdAt ?? 0);
250
+ if (aHasTime && !bHasTime) return -1;
251
+ if (!aHasTime && bHasTime) return 1;
252
+ return Number(b.leafIndex - a.leafIndex);
253
+ });
254
+ }
255
+
256
+ /** Fetch all transfer events from indexer data */
257
+ export async function fetchExplorerTransfers(
258
+ _rpc: RpcClient,
259
+ _programId: string,
260
+ indexerLeaves?: IndexerLeaf[]
261
+ ): Promise<ExplorerTransferEvent[]> {
262
+ if (!indexerLeaves || indexerLeaves.length === 0) return [];
263
+
264
+ const events: ExplorerTransferEvent[] = [];
265
+
266
+ for (const leaf of indexerLeaves) {
267
+ if (leaf.announcement_type === 0) continue; // skip deposits
268
+ events.push({
269
+ type: "commitment",
270
+ pubkey: "",
271
+ timestamp: leaf.created_at ?? 0,
272
+ commitment: leaf.commitment,
273
+ leafIndex: BigInt(leaf.leaf_index),
274
+ });
275
+ }
276
+
277
+ // Sort by timestamp descending (most recent first); if no timestamp, by leafIndex high→low
278
+ events.sort((a, b) => {
279
+ const aHasTime = a.timestamp > 0;
280
+ const bHasTime = b.timestamp > 0;
281
+ if (aHasTime && bHasTime) return b.timestamp - a.timestamp;
282
+ if (aHasTime && !bHasTime) return -1;
283
+ if (!aHasTime && bHasTime) return 1;
284
+ return Number((b.leafIndex ?? 0n) - (a.leafIndex ?? 0n));
285
+ });
286
+
287
+ return events;
288
+ }
289
+
290
+ /** Fetch all redemption requests */
291
+ export async function fetchExplorerRedemptions(
292
+ rpc: RpcClient,
293
+ programId: string
294
+ ): Promise<ExplorerRedemption[]> {
295
+ const accounts = await fetchAccountsBySize(rpc, programId, REDEMPTION_REQUEST_SIZE);
296
+
297
+ return accounts
298
+ .map(({ pubkey, data }) => parseRedemptionRequest(pubkey, data))
299
+ .sort((a, b) => Number(b.requestId - a.requestId));
300
+ }