@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.
- package/README.md +214 -108
- package/{packages/sdk/dist → dist}/client.d.ts +25 -1
- package/{packages/sdk/dist → dist}/client.js +36 -1
- package/{packages/sdk/dist → dist}/crypto-ed25519.d.ts +14 -0
- package/{packages/sdk/dist → dist}/crypto-ed25519.js +14 -0
- package/{packages/sdk/dist → dist}/index.d.ts +3 -3
- package/{packages/sdk/dist → dist}/index.js +3 -3
- package/{packages/sdk/dist → dist}/instructions.d.ts +28 -11
- package/{packages/sdk/dist → dist}/instructions.js +45 -15
- package/{packages/sdk/dist → dist}/psbt.d.ts +12 -2
- package/{packages/sdk/dist → dist}/psbt.js +17 -12
- package/{packages/sdk/dist → dist}/stealth.d.ts +104 -9
- package/{packages/sdk/dist → dist}/stealth.js +146 -14
- package/{packages/sdk/dist → dist}/taproot.d.ts +39 -2
- package/{packages/sdk/dist → dist}/taproot.js +54 -2
- package/package.json +86 -63
- package/src/announcement-client.ts +457 -0
- package/src/auditor-ciphertext.ts +181 -0
- package/src/auditor.ts +409 -0
- package/src/bitcoin/ika.ts +103 -0
- package/src/bitcoin/index.ts +5 -0
- package/src/bound-params.ts +322 -0
- package/src/chadbuffer.ts +603 -0
- package/src/circomlibjs.d.ts +51 -0
- package/src/claim-link.ts +53 -0
- package/src/client.ts +638 -0
- package/src/commitment-tree.ts +736 -0
- package/src/config.ts +772 -0
- package/src/core/esplora.ts +332 -0
- package/src/core/mempool.ts +159 -0
- package/src/crypto-babyjub.ts +385 -0
- package/src/crypto-ed25519.ts +297 -0
- package/src/crypto.ts +199 -0
- package/src/event-client.ts +231 -0
- package/src/events.ts +384 -0
- package/src/explorer.ts +300 -0
- package/src/index.ts +902 -0
- package/src/instructions.ts +2820 -0
- package/src/keys.ts +1228 -0
- package/src/logger.ts +41 -0
- package/src/magicblock.ts +278 -0
- package/src/merkle.ts +197 -0
- package/src/note.ts +754 -0
- package/src/pda.ts +516 -0
- package/src/pool-state.ts +176 -0
- package/src/poseidon.ts +175 -0
- package/src/prover/index.ts +19 -0
- package/src/prover/mobile.ts +303 -0
- package/src/prover/web.ts +771 -0
- package/src/psbt.ts +345 -0
- package/src/selective-disclosure.ts +284 -0
- package/src/sender-memo.ts +343 -0
- package/src/snarkjs.d.ts +19 -0
- package/src/sns-resolver.ts +333 -0
- package/src/solana/connection.ts +189 -0
- package/src/solana/priority-fee.ts +201 -0
- package/src/spend-doc.ts +163 -0
- package/src/stealth.ts +1477 -0
- package/src/taproot.ts +707 -0
- package/src/token-registry.ts +207 -0
- package/src/utils/encoding.ts +33 -0
- package/src/vk-registry.ts +295 -0
- package/LICENSE +0 -21
- package/packages/btc-client/src/esplora-client.ts +0 -153
- package/packages/btc-client/src/index.ts +0 -3
- package/packages/btc-client/src/op-return.ts +0 -93
- package/packages/btc-client/src/types.ts +0 -112
- package/packages/sdk/README.md +0 -277
- /package/{packages/sdk/dist → dist}/announcement-client.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/announcement-client.js +0 -0
- /package/{packages/sdk/dist → dist}/auditor-ciphertext.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/auditor-ciphertext.js +0 -0
- /package/{packages/sdk/dist → dist}/auditor.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/auditor.js +0 -0
- /package/{packages/sdk/dist → dist}/bitcoin/ika.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/bitcoin/ika.js +0 -0
- /package/{packages/sdk/dist → dist}/bitcoin/index.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/bitcoin/index.js +0 -0
- /package/{packages/sdk/dist → dist}/bound-params.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/bound-params.js +0 -0
- /package/{packages/sdk/dist → dist}/chadbuffer.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/chadbuffer.js +0 -0
- /package/{packages/sdk/dist → dist}/claim-link.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/claim-link.js +0 -0
- /package/{packages/sdk/dist → dist}/commitment-tree.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/commitment-tree.js +0 -0
- /package/{packages/sdk/dist → dist}/config.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/config.js +0 -0
- /package/{packages/sdk/dist → dist}/core/esplora.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/core/esplora.js +0 -0
- /package/{packages/sdk/dist → dist}/core/mempool.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/core/mempool.js +0 -0
- /package/{packages/sdk/dist → dist}/crypto-babyjub.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/crypto-babyjub.js +0 -0
- /package/{packages/sdk/dist → dist}/crypto.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/crypto.js +0 -0
- /package/{packages/sdk/dist → dist}/event-client.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/event-client.js +0 -0
- /package/{packages/sdk/dist → dist}/events.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/events.js +0 -0
- /package/{packages/sdk/dist → dist}/explorer.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/explorer.js +0 -0
- /package/{packages/sdk/dist → dist}/keys.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/keys.js +0 -0
- /package/{packages/sdk/dist → dist}/logger.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/logger.js +0 -0
- /package/{packages/sdk/dist → dist}/magicblock.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/magicblock.js +0 -0
- /package/{packages/sdk/dist → dist}/merkle.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/merkle.js +0 -0
- /package/{packages/sdk/dist → dist}/note.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/note.js +0 -0
- /package/{packages/sdk/dist → dist}/pda.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/pda.js +0 -0
- /package/{packages/sdk/dist → dist}/pool-state.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/pool-state.js +0 -0
- /package/{packages/sdk/dist → dist}/poseidon.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/poseidon.js +0 -0
- /package/{packages/sdk/dist → dist}/prover/index.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/prover/index.js +0 -0
- /package/{packages/sdk/dist → dist}/prover/mobile.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/prover/mobile.js +0 -0
- /package/{packages/sdk/dist → dist}/prover/web.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/prover/web.js +0 -0
- /package/{packages/sdk/dist → dist}/selective-disclosure.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/selective-disclosure.js +0 -0
- /package/{packages/sdk/dist → dist}/sender-memo.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/sender-memo.js +0 -0
- /package/{packages/sdk/dist → dist}/sns-resolver.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/sns-resolver.js +0 -0
- /package/{packages/sdk/dist → dist}/solana/connection.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/solana/connection.js +0 -0
- /package/{packages/sdk/dist → dist}/solana/priority-fee.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/solana/priority-fee.js +0 -0
- /package/{packages/sdk/dist → dist}/spend-doc.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/spend-doc.js +0 -0
- /package/{packages/sdk/dist → dist}/token-registry.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/token-registry.js +0 -0
- /package/{packages/sdk/dist → dist}/utils/encoding.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/utils/encoding.js +0 -0
- /package/{packages/sdk/dist → dist}/vk-registry.d.ts +0 -0
- /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
|
+
}
|
package/src/explorer.ts
ADDED
|
@@ -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
|
+
}
|