@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.
- 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}/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 +333 -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}/psbt.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/psbt.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
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AnnouncementClient — Production-ready stealth announcement fetcher
|
|
3
|
+
* with three-tier fallback: Backend WS → Backend REST → Direct RPC.
|
|
4
|
+
*
|
|
5
|
+
* - Backend WS: sub-second push via /ws/announcements
|
|
6
|
+
* - Backend REST: initial load + catch-up via /api/announcements
|
|
7
|
+
* - Direct RPC: last resort if backend is unavailable
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { OnChainStealthAnnouncement } from "./stealth";
|
|
11
|
+
import { parseProgramEvents } from "./events";
|
|
12
|
+
import { hexToBytes } from "./crypto";
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Types
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
export interface AnnouncementClientConfig {
|
|
19
|
+
/** Backend REST base URL, e.g. "http://localhost:8080" */
|
|
20
|
+
backendUrl: string;
|
|
21
|
+
/** Backend WS base URL — derived from backendUrl if omitted */
|
|
22
|
+
backendWsUrl?: string;
|
|
23
|
+
/** Solana RPC URL (for direct RPC fallback) */
|
|
24
|
+
solanaRpcUrl: string;
|
|
25
|
+
/** UTXOpia program ID base58 (for direct RPC fallback) */
|
|
26
|
+
programId: string;
|
|
27
|
+
/** Commitment tree PDA base58 — query this instead of program ID for fewer results */
|
|
28
|
+
commitmentTreeAddress?: string;
|
|
29
|
+
/** REST request timeout in ms (default 5000) */
|
|
30
|
+
restTimeoutMs?: number;
|
|
31
|
+
/** Max WS reconnect delay in ms (default 30000) */
|
|
32
|
+
wsMaxReconnectMs?: number;
|
|
33
|
+
/** Max leaves backend can lag before supplementing with RPC (default 2) */
|
|
34
|
+
maxLagLeaves?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type AnnouncementListener = (
|
|
38
|
+
announcements: OnChainStealthAnnouncement[],
|
|
39
|
+
) => void;
|
|
40
|
+
|
|
41
|
+
/** Shape returned by backend /api/announcements */
|
|
42
|
+
interface BackendAnnouncementRow {
|
|
43
|
+
leaf_index: number;
|
|
44
|
+
announcement_type: number;
|
|
45
|
+
ephemeral_pub: string;
|
|
46
|
+
encrypted_amount: string;
|
|
47
|
+
commitment: string;
|
|
48
|
+
tx_signature: string;
|
|
49
|
+
slot: number;
|
|
50
|
+
block_time?: number;
|
|
51
|
+
token_id?: string | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface BackendAnnouncementsResponse {
|
|
55
|
+
success: boolean;
|
|
56
|
+
announcements: BackendAnnouncementRow[];
|
|
57
|
+
count: number;
|
|
58
|
+
latest_leaf_index: number | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface BackendStatusResponse {
|
|
62
|
+
count: number;
|
|
63
|
+
latest_leaf_index: number | null;
|
|
64
|
+
tree_next_index: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** WS message shape from /ws/announcements */
|
|
68
|
+
interface WsAnnouncementUpdate {
|
|
69
|
+
type: string;
|
|
70
|
+
announcement_type: number;
|
|
71
|
+
ephemeral_pub: string;
|
|
72
|
+
encrypted_amount: string;
|
|
73
|
+
commitment: string;
|
|
74
|
+
leaf_index: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Helpers
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
function rowToAnnouncement(row: BackendAnnouncementRow): OnChainStealthAnnouncement {
|
|
82
|
+
return {
|
|
83
|
+
announcementType: row.announcement_type,
|
|
84
|
+
ephemeralPub: hexToBytes(row.ephemeral_pub),
|
|
85
|
+
encryptedAmount: hexToBytes(row.encrypted_amount),
|
|
86
|
+
commitment: hexToBytes(row.commitment),
|
|
87
|
+
leafIndex: row.leaf_index,
|
|
88
|
+
blockTime: row.block_time ?? 0,
|
|
89
|
+
slot: row.slot,
|
|
90
|
+
tokenIdHex: row.token_id ?? undefined,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function wsUpdateToAnnouncement(update: WsAnnouncementUpdate): OnChainStealthAnnouncement {
|
|
95
|
+
return {
|
|
96
|
+
announcementType: update.announcement_type,
|
|
97
|
+
ephemeralPub: hexToBytes(update.ephemeral_pub),
|
|
98
|
+
encryptedAmount: hexToBytes(update.encrypted_amount),
|
|
99
|
+
commitment: hexToBytes(update.commitment),
|
|
100
|
+
leafIndex: update.leaf_index,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// AnnouncementClient
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
export class AnnouncementClient {
|
|
109
|
+
protected ws: WebSocket | null = null;
|
|
110
|
+
protected wsReconnectDelay = 1000;
|
|
111
|
+
protected wsConnected = false;
|
|
112
|
+
protected listeners = new Set<AnnouncementListener>();
|
|
113
|
+
protected cachedAnnouncements: OnChainStealthAnnouncement[] = [];
|
|
114
|
+
protected latestLeafIndex = -1;
|
|
115
|
+
protected backendHealthy = true;
|
|
116
|
+
protected closed = false;
|
|
117
|
+
protected reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
118
|
+
private lastRpcFetchAt = 0;
|
|
119
|
+
private rpcCooldownMs = 30_000; // minimum 30s between RPC fallback calls
|
|
120
|
+
|
|
121
|
+
protected restTimeout: number;
|
|
122
|
+
protected wsMaxReconnect: number;
|
|
123
|
+
private maxLag: number;
|
|
124
|
+
protected wsUrl: string;
|
|
125
|
+
|
|
126
|
+
constructor(protected config: AnnouncementClientConfig) {
|
|
127
|
+
this.restTimeout = config.restTimeoutMs ?? 5000;
|
|
128
|
+
this.wsMaxReconnect = config.wsMaxReconnectMs ?? 30000;
|
|
129
|
+
this.maxLag = config.maxLagLeaves ?? 2;
|
|
130
|
+
this.wsUrl =
|
|
131
|
+
config.backendWsUrl ??
|
|
132
|
+
config.backendUrl.replace("http://", "ws://").replace("https://", "wss://");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// -----------------------------------------------------------------------
|
|
136
|
+
// Public API
|
|
137
|
+
// -----------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
/** Start WS connection and perform initial data load */
|
|
140
|
+
async start(): Promise<void> {
|
|
141
|
+
if (this.closed) return;
|
|
142
|
+
// Initial load via REST (+ RPC fallback)
|
|
143
|
+
await this.fetchAll();
|
|
144
|
+
// Open WS for real-time push
|
|
145
|
+
this.connectWs();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Fetch all announcements using fallback chain */
|
|
149
|
+
async fetchAll(): Promise<OnChainStealthAnnouncement[]> {
|
|
150
|
+
try {
|
|
151
|
+
const announcements = await this.fetchFromBackend();
|
|
152
|
+
this.backendHealthy = true;
|
|
153
|
+
this.cachedAnnouncements = announcements;
|
|
154
|
+
this.latestLeafIndex = announcements.length > 0
|
|
155
|
+
? Math.max(...announcements.map((a) => a.leafIndex))
|
|
156
|
+
: -1;
|
|
157
|
+
|
|
158
|
+
// Consistency check — supplement from RPC if backend is behind
|
|
159
|
+
await this.checkConsistency();
|
|
160
|
+
return this.cachedAnnouncements;
|
|
161
|
+
} catch {
|
|
162
|
+
this.backendHealthy = false;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Fallback: direct RPC
|
|
166
|
+
try {
|
|
167
|
+
const announcements = await this.fetchFromRpc();
|
|
168
|
+
this.cachedAnnouncements = announcements;
|
|
169
|
+
this.latestLeafIndex = announcements.length > 0
|
|
170
|
+
? Math.max(...announcements.map((a) => a.leafIndex))
|
|
171
|
+
: -1;
|
|
172
|
+
return this.cachedAnnouncements;
|
|
173
|
+
} catch (e) {
|
|
174
|
+
console.error("[AnnouncementClient] All sources failed:", e);
|
|
175
|
+
return this.cachedAnnouncements;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Subscribe to new announcement events. Returns unsubscribe function. */
|
|
180
|
+
onAnnouncement(listener: AnnouncementListener): () => void {
|
|
181
|
+
this.listeners.add(listener);
|
|
182
|
+
return () => this.listeners.delete(listener);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Stop client, close WS */
|
|
186
|
+
close(): void {
|
|
187
|
+
this.closed = true;
|
|
188
|
+
if (this.reconnectTimer) {
|
|
189
|
+
clearTimeout(this.reconnectTimer);
|
|
190
|
+
this.reconnectTimer = null;
|
|
191
|
+
}
|
|
192
|
+
if (this.ws) {
|
|
193
|
+
this.ws.close();
|
|
194
|
+
this.ws = null;
|
|
195
|
+
}
|
|
196
|
+
this.listeners.clear();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Whether the backend WS is currently connected */
|
|
200
|
+
get isWsConnected(): boolean {
|
|
201
|
+
return this.wsConnected;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Whether the backend REST was reachable on last attempt */
|
|
205
|
+
get isBackendHealthy(): boolean {
|
|
206
|
+
return this.backendHealthy;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// -----------------------------------------------------------------------
|
|
210
|
+
// Internal: Backend REST
|
|
211
|
+
// -----------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
protected async fetchFromBackend(
|
|
214
|
+
since?: number,
|
|
215
|
+
): Promise<OnChainStealthAnnouncement[]> {
|
|
216
|
+
const url = since != null
|
|
217
|
+
? `${this.config.backendUrl}/api/announcements?since=${since}`
|
|
218
|
+
: `${this.config.backendUrl}/api/announcements`;
|
|
219
|
+
|
|
220
|
+
const resp = await fetch(url, {
|
|
221
|
+
signal: AbortSignal.timeout(this.restTimeout),
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
if (!resp.ok) throw new Error(`Backend REST ${resp.status}`);
|
|
225
|
+
|
|
226
|
+
const data: BackendAnnouncementsResponse = await resp.json();
|
|
227
|
+
if (!data.success) throw new Error("Backend returned success=false");
|
|
228
|
+
|
|
229
|
+
return data.announcements.map(rowToAnnouncement);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// -----------------------------------------------------------------------
|
|
233
|
+
// Internal: Direct RPC fallback
|
|
234
|
+
// -----------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
private async fetchFromRpc(
|
|
237
|
+
since?: number,
|
|
238
|
+
): Promise<OnChainStealthAnnouncement[]> {
|
|
239
|
+
// Cooldown to avoid 429 spam when backend is down
|
|
240
|
+
const now = Date.now();
|
|
241
|
+
if (now - this.lastRpcFetchAt < this.rpcCooldownMs) {
|
|
242
|
+
return this.cachedAnnouncements;
|
|
243
|
+
}
|
|
244
|
+
this.lastRpcFetchAt = now;
|
|
245
|
+
|
|
246
|
+
// Query commitment tree PDA (only txs that insert leaves = stealth announcements)
|
|
247
|
+
// Falls back to program ID if commitmentTreeAddress not configured
|
|
248
|
+
const queryAddress = this.config.commitmentTreeAddress || this.config.programId;
|
|
249
|
+
const sigsResp = await fetch(this.config.solanaRpcUrl, {
|
|
250
|
+
method: "POST",
|
|
251
|
+
headers: { "Content-Type": "application/json" },
|
|
252
|
+
body: JSON.stringify({
|
|
253
|
+
jsonrpc: "2.0",
|
|
254
|
+
id: 1,
|
|
255
|
+
method: "getSignaturesForAddress",
|
|
256
|
+
params: [queryAddress, { limit: 200 }],
|
|
257
|
+
}),
|
|
258
|
+
signal: AbortSignal.timeout(this.restTimeout * 2),
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const sigsData = await sigsResp.json();
|
|
262
|
+
const signatures: string[] = (sigsData.result || []).map(
|
|
263
|
+
(s: { signature: string }) => s.signature,
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
if (signatures.length === 0) return [];
|
|
267
|
+
|
|
268
|
+
// Fetch transactions in batches of 10
|
|
269
|
+
const announcements: OnChainStealthAnnouncement[] = [];
|
|
270
|
+
const batchSize = 10;
|
|
271
|
+
|
|
272
|
+
for (let i = 0; i < signatures.length; i += batchSize) {
|
|
273
|
+
const batch = signatures.slice(i, i + batchSize);
|
|
274
|
+
const txResponses = await Promise.all(
|
|
275
|
+
batch.map((sig) =>
|
|
276
|
+
fetch(this.config.solanaRpcUrl, {
|
|
277
|
+
method: "POST",
|
|
278
|
+
headers: { "Content-Type": "application/json" },
|
|
279
|
+
body: JSON.stringify({
|
|
280
|
+
jsonrpc: "2.0",
|
|
281
|
+
id: 1,
|
|
282
|
+
method: "getTransaction",
|
|
283
|
+
params: [sig, { encoding: "json", maxSupportedTransactionVersion: 0 }],
|
|
284
|
+
}),
|
|
285
|
+
}).then((r) => r.json()),
|
|
286
|
+
),
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
for (const txData of txResponses) {
|
|
290
|
+
const logs: string[] | undefined =
|
|
291
|
+
txData?.result?.meta?.logMessages;
|
|
292
|
+
if (!logs) continue;
|
|
293
|
+
|
|
294
|
+
const events = parseProgramEvents(logs, this.config.programId);
|
|
295
|
+
for (const event of events) {
|
|
296
|
+
if (event.type === "stealth_announcement") {
|
|
297
|
+
if (since != null && event.leafIndex <= since) continue;
|
|
298
|
+
announcements.push({
|
|
299
|
+
announcementType: event.announcementType,
|
|
300
|
+
ephemeralPub: event.ephemeralPub,
|
|
301
|
+
encryptedAmount: event.encryptedAmount,
|
|
302
|
+
commitment: event.commitment,
|
|
303
|
+
leafIndex: event.leafIndex,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Deduplicate by leafIndex and sort
|
|
311
|
+
const seen = new Map<number, OnChainStealthAnnouncement>();
|
|
312
|
+
for (const a of announcements) {
|
|
313
|
+
if (!seen.has(a.leafIndex)) seen.set(a.leafIndex, a);
|
|
314
|
+
}
|
|
315
|
+
return Array.from(seen.values()).sort((a, b) => a.leafIndex - b.leafIndex);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// -----------------------------------------------------------------------
|
|
319
|
+
// Internal: Consistency check
|
|
320
|
+
// -----------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
private async checkConsistency(): Promise<void> {
|
|
323
|
+
try {
|
|
324
|
+
const statusResp = await fetch(
|
|
325
|
+
`${this.config.backendUrl}/api/announcements/status`,
|
|
326
|
+
{ signal: AbortSignal.timeout(this.restTimeout) },
|
|
327
|
+
);
|
|
328
|
+
if (!statusResp.ok) return;
|
|
329
|
+
|
|
330
|
+
const status: BackendStatusResponse = await statusResp.json();
|
|
331
|
+
const backendLatest = status.latest_leaf_index ?? -1;
|
|
332
|
+
const onChainNext = status.tree_next_index;
|
|
333
|
+
|
|
334
|
+
// If backend is more than maxLag behind on-chain, supplement from RPC
|
|
335
|
+
if (onChainNext - 1 - backendLatest > this.maxLag) {
|
|
336
|
+
console.warn(
|
|
337
|
+
`[AnnouncementClient] Backend behind: latest=${backendLatest}, on-chain next=${onChainNext}. Supplementing from RPC.`,
|
|
338
|
+
);
|
|
339
|
+
const supplement = await this.fetchFromRpc(backendLatest);
|
|
340
|
+
if (supplement.length > 0) {
|
|
341
|
+
// Merge: existing + supplement, dedup by leafIndex
|
|
342
|
+
const merged = new Map<number, OnChainStealthAnnouncement>();
|
|
343
|
+
for (const a of this.cachedAnnouncements) merged.set(a.leafIndex, a);
|
|
344
|
+
for (const a of supplement) merged.set(a.leafIndex, a);
|
|
345
|
+
this.cachedAnnouncements = Array.from(merged.values()).sort(
|
|
346
|
+
(a, b) => a.leafIndex - b.leafIndex,
|
|
347
|
+
);
|
|
348
|
+
this.latestLeafIndex = Math.max(
|
|
349
|
+
this.latestLeafIndex,
|
|
350
|
+
...supplement.map((a) => a.leafIndex),
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
} catch {
|
|
355
|
+
// Non-fatal — consistency check is best-effort
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// -----------------------------------------------------------------------
|
|
360
|
+
// Internal: WebSocket
|
|
361
|
+
// -----------------------------------------------------------------------
|
|
362
|
+
|
|
363
|
+
protected connectWs(): void {
|
|
364
|
+
if (this.closed) return;
|
|
365
|
+
|
|
366
|
+
try {
|
|
367
|
+
const url = `${this.wsUrl}/ws/announcements`;
|
|
368
|
+
this.ws = new WebSocket(url);
|
|
369
|
+
|
|
370
|
+
this.ws.onopen = () => {
|
|
371
|
+
this.wsConnected = true;
|
|
372
|
+
this.wsReconnectDelay = 1000; // reset backoff
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
this.ws.onmessage = (event) => {
|
|
376
|
+
try {
|
|
377
|
+
const update: WsAnnouncementUpdate = JSON.parse(
|
|
378
|
+
typeof event.data === "string" ? event.data : "",
|
|
379
|
+
);
|
|
380
|
+
if (update.type !== "stealth_announcement") return;
|
|
381
|
+
|
|
382
|
+
const announcement = wsUpdateToAnnouncement(update);
|
|
383
|
+
|
|
384
|
+
// Update cache
|
|
385
|
+
if (announcement.leafIndex > this.latestLeafIndex) {
|
|
386
|
+
this.latestLeafIndex = announcement.leafIndex;
|
|
387
|
+
}
|
|
388
|
+
// Avoid duplicates in cache
|
|
389
|
+
if (!this.cachedAnnouncements.some((a) => a.leafIndex === announcement.leafIndex)) {
|
|
390
|
+
this.cachedAnnouncements.push(announcement);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Notify listeners
|
|
394
|
+
for (const listener of this.listeners) {
|
|
395
|
+
try {
|
|
396
|
+
listener([announcement]);
|
|
397
|
+
} catch {
|
|
398
|
+
// Listener errors shouldn't crash the client
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
} catch {
|
|
402
|
+
// Ignore malformed messages
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
this.ws.onclose = () => {
|
|
407
|
+
this.wsConnected = false;
|
|
408
|
+
this.ws = null;
|
|
409
|
+
this.scheduleReconnect();
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
this.ws.onerror = () => {
|
|
413
|
+
// onclose will fire after onerror
|
|
414
|
+
};
|
|
415
|
+
} catch {
|
|
416
|
+
this.wsConnected = false;
|
|
417
|
+
this.scheduleReconnect();
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
protected scheduleReconnect(): void {
|
|
422
|
+
if (this.closed) return;
|
|
423
|
+
|
|
424
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
425
|
+
this.reconnectTimer = null;
|
|
426
|
+
// Fetch missed announcements before reconnecting WS
|
|
427
|
+
if (this.latestLeafIndex >= 0) {
|
|
428
|
+
try {
|
|
429
|
+
const missed = await this.fetchFromBackend(this.latestLeafIndex);
|
|
430
|
+
if (missed.length > 0) {
|
|
431
|
+
for (const a of missed) {
|
|
432
|
+
if (!this.cachedAnnouncements.some((c) => c.leafIndex === a.leafIndex)) {
|
|
433
|
+
this.cachedAnnouncements.push(a);
|
|
434
|
+
}
|
|
435
|
+
if (a.leafIndex > this.latestLeafIndex) {
|
|
436
|
+
this.latestLeafIndex = a.leafIndex;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
// Notify listeners of catch-up
|
|
440
|
+
for (const listener of this.listeners) {
|
|
441
|
+
try { listener(missed); } catch { /* ignore */ }
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
} catch {
|
|
445
|
+
// Backend may still be down
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
this.connectWs();
|
|
449
|
+
}, this.wsReconnectDelay);
|
|
450
|
+
|
|
451
|
+
// Exponential backoff
|
|
452
|
+
this.wsReconnectDelay = Math.min(
|
|
453
|
+
this.wsReconnectDelay * 2,
|
|
454
|
+
this.wsMaxReconnect,
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auditor ciphertext — ECDH encrypt-to-auditor for Method-Y permissioned pools.
|
|
3
|
+
*
|
|
4
|
+
* Lets a depositor encrypt note viewing data (tokenId, amount) TO a permissioned-pool
|
|
5
|
+
* auditor's Ed25519 viewing PUBLIC key. Only the auditor's private key can decrypt.
|
|
6
|
+
*
|
|
7
|
+
* Cipher: X25519 ECDH key-agreement + XChaCha20-Poly1305 AEAD.
|
|
8
|
+
*
|
|
9
|
+
* Wire format (112 bytes):
|
|
10
|
+
* eph_pub :: 32 bytes (ephemeral X25519 public key)
|
|
11
|
+
* nonce :: 24 bytes (random XChaCha20 nonce)
|
|
12
|
+
* ciphertext_tag :: 56 bytes (encrypted tokenId(32 BE) || amount(8 LE) + Poly1305 tag(16))
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { xchacha20poly1305 } from "@noble/ciphers/chacha.js";
|
|
16
|
+
import { sha256 } from "@noble/hashes/sha2.js";
|
|
17
|
+
import { x25519 } from "@noble/curves/ed25519.js";
|
|
18
|
+
import {
|
|
19
|
+
ed25519PubToX25519,
|
|
20
|
+
ed25519PrivToX25519,
|
|
21
|
+
x25519PubFromPriv,
|
|
22
|
+
} from "./crypto-ed25519";
|
|
23
|
+
|
|
24
|
+
const DOMAIN = new TextEncoder().encode("utxopia.auditor-ciphertext.v1");
|
|
25
|
+
|
|
26
|
+
const EPH_PUB_BYTES = 32;
|
|
27
|
+
const NONCE_BYTES = 24;
|
|
28
|
+
const PLAINTEXT_BYTES = 40; // tokenId(32) + amount(8)
|
|
29
|
+
const TAG_BYTES = 16;
|
|
30
|
+
const CT_WITH_TAG_BYTES = PLAINTEXT_BYTES + TAG_BYTES; // 56
|
|
31
|
+
export const AUDITOR_CIPHERTEXT_BYTES = EPH_PUB_BYTES + NONCE_BYTES + CT_WITH_TAG_BYTES; // 112
|
|
32
|
+
|
|
33
|
+
export interface AuditorNotePlain {
|
|
34
|
+
tokenId: bigint;
|
|
35
|
+
amount: bigint;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function deriveKey(shared: Uint8Array): Uint8Array {
|
|
39
|
+
const buf = new Uint8Array(shared.length + DOMAIN.length);
|
|
40
|
+
buf.set(shared, 0);
|
|
41
|
+
buf.set(DOMAIN, shared.length);
|
|
42
|
+
return sha256(buf);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function packPlaintext(plain: AuditorNotePlain): Uint8Array {
|
|
46
|
+
const out = new Uint8Array(PLAINTEXT_BYTES);
|
|
47
|
+
// tokenId: 32 bytes big-endian
|
|
48
|
+
let t = plain.tokenId;
|
|
49
|
+
for (let i = 31; i >= 0; i--) {
|
|
50
|
+
out[i] = Number(t & 0xffn);
|
|
51
|
+
t >>= 8n;
|
|
52
|
+
}
|
|
53
|
+
if (t !== 0n) throw new RangeError("tokenId exceeds 256 bits");
|
|
54
|
+
// amount: 8 bytes little-endian
|
|
55
|
+
let a = plain.amount;
|
|
56
|
+
for (let i = 0; i < 8; i++) {
|
|
57
|
+
out[32 + i] = Number(a & 0xffn);
|
|
58
|
+
a >>= 8n;
|
|
59
|
+
}
|
|
60
|
+
if (a !== 0n) throw new RangeError("amount exceeds 64 bits");
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function unpackPlaintext(bytes: Uint8Array): AuditorNotePlain {
|
|
65
|
+
let tokenId = 0n;
|
|
66
|
+
for (let i = 0; i < 32; i++) tokenId = (tokenId << 8n) | BigInt(bytes[i]);
|
|
67
|
+
let amount = 0n;
|
|
68
|
+
for (let i = 7; i >= 0; i--) amount = (amount << 8n) | BigInt(bytes[32 + i]);
|
|
69
|
+
return { tokenId, amount };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Encrypt note viewing data to an auditor's Ed25519 viewing public key.
|
|
74
|
+
*
|
|
75
|
+
* @param auditorViewingPubKey - 32-byte Ed25519 public key of the auditor
|
|
76
|
+
* @param plain - tokenId + amount to encrypt
|
|
77
|
+
* @param commitment - 32-byte note commitment (AAD — binds blob to note)
|
|
78
|
+
* @param ephemeralPriv - optional 32-byte X25519 private key (random if omitted)
|
|
79
|
+
* @returns 112-byte blob: eph_pub(32) || nonce(24) || ciphertextWithTag(56)
|
|
80
|
+
*/
|
|
81
|
+
export function encryptAuditorCiphertext(
|
|
82
|
+
auditorViewingPubKey: Uint8Array,
|
|
83
|
+
plain: AuditorNotePlain,
|
|
84
|
+
commitment: Uint8Array,
|
|
85
|
+
ephemeralPriv?: Uint8Array,
|
|
86
|
+
): Uint8Array {
|
|
87
|
+
if (auditorViewingPubKey.length !== 32) throw new Error("auditorViewingPubKey must be 32 bytes");
|
|
88
|
+
if (commitment.length !== 32) throw new Error("commitment must be 32 bytes");
|
|
89
|
+
|
|
90
|
+
const ephPriv = ephemeralPriv ?? crypto.getRandomValues(new Uint8Array(32));
|
|
91
|
+
const ephPub = x25519PubFromPriv(ephPriv);
|
|
92
|
+
const auditorX = ed25519PubToX25519(auditorViewingPubKey);
|
|
93
|
+
const shared = x25519.getSharedSecret(ephPriv, auditorX);
|
|
94
|
+
const key = deriveKey(shared);
|
|
95
|
+
|
|
96
|
+
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_BYTES));
|
|
97
|
+
const aead = xchacha20poly1305(key, nonce, commitment);
|
|
98
|
+
const ciphertextWithTag = aead.encrypt(packPlaintext(plain));
|
|
99
|
+
|
|
100
|
+
const blob = new Uint8Array(AUDITOR_CIPHERTEXT_BYTES);
|
|
101
|
+
blob.set(ephPub, 0);
|
|
102
|
+
blob.set(nonce, EPH_PUB_BYTES);
|
|
103
|
+
blob.set(ciphertextWithTag, EPH_PUB_BYTES + NONCE_BYTES);
|
|
104
|
+
return blob;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Convenience wrapper: build a 112-byte auditor ciphertext blob from note fields.
|
|
109
|
+
*
|
|
110
|
+
* Thin wrapper over {@link encryptAuditorCiphertext} for callers who have the raw
|
|
111
|
+
* note fields (tokenId, amount, commitment) rather than a pre-packed `AuditorNotePlain`.
|
|
112
|
+
*
|
|
113
|
+
* @param input.auditorViewingPubKey - 32-byte Ed25519 public key of the auditor
|
|
114
|
+
* @param input.tokenId - token id (field element, fits in 256 bits)
|
|
115
|
+
* @param input.amount - note amount (≤ 64 bits)
|
|
116
|
+
* @param input.commitment - 32-byte note commitment (binds ciphertext to note)
|
|
117
|
+
* @returns 112-byte blob: eph_pub(32) || nonce(24) || ciphertextWithTag(56)
|
|
118
|
+
*/
|
|
119
|
+
export function buildAuditorCiphertextForNote(input: {
|
|
120
|
+
auditorViewingPubKey: Uint8Array;
|
|
121
|
+
tokenId: bigint;
|
|
122
|
+
amount: bigint;
|
|
123
|
+
commitment: Uint8Array;
|
|
124
|
+
}): Uint8Array {
|
|
125
|
+
return encryptAuditorCiphertext(
|
|
126
|
+
input.auditorViewingPubKey,
|
|
127
|
+
{ tokenId: input.tokenId, amount: input.amount },
|
|
128
|
+
input.commitment,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Normalise the `auditorCiphertext` field accepted by permissioned builders.
|
|
134
|
+
*
|
|
135
|
+
* Builders accept either:
|
|
136
|
+
* - a pre-computed `Uint8Array` blob (advanced / off-chain computed), OR
|
|
137
|
+
* - a plain-fields object `{ auditorViewingPubKey, tokenId, amount, commitment }`
|
|
138
|
+
* which will be encrypted on the fly via `buildAuditorCiphertextForNote`.
|
|
139
|
+
*
|
|
140
|
+
* If both forms are supplied the explicit `Uint8Array` wins.
|
|
141
|
+
*/
|
|
142
|
+
export type AuditorCiphertextInput =
|
|
143
|
+
| Uint8Array
|
|
144
|
+
| { auditorViewingPubKey: Uint8Array; tokenId: bigint; amount: bigint; commitment: Uint8Array };
|
|
145
|
+
|
|
146
|
+
export function resolveAuditorCiphertext(input: AuditorCiphertextInput): Uint8Array {
|
|
147
|
+
if (input instanceof Uint8Array) return input;
|
|
148
|
+
return buildAuditorCiphertextForNote(input);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Decrypt an auditor ciphertext blob using the auditor's Ed25519 viewing private key.
|
|
153
|
+
*
|
|
154
|
+
* @param auditorViewingPrivKey - 32-byte Ed25519 private key of the auditor
|
|
155
|
+
* @param blob - 112-byte blob from `encryptAuditorCiphertext`
|
|
156
|
+
* @param commitment - 32-byte note commitment (must match AAD used during encrypt)
|
|
157
|
+
* @returns decrypted plain, or null on any failure (wrong key, wrong commitment, corrupt)
|
|
158
|
+
*/
|
|
159
|
+
export function decryptAuditorCiphertext(
|
|
160
|
+
auditorViewingPrivKey: Uint8Array,
|
|
161
|
+
blob: Uint8Array,
|
|
162
|
+
commitment: Uint8Array,
|
|
163
|
+
): AuditorNotePlain | null {
|
|
164
|
+
if (blob.length !== AUDITOR_CIPHERTEXT_BYTES) return null;
|
|
165
|
+
if (commitment.length !== 32) return null;
|
|
166
|
+
|
|
167
|
+
const ephPub = blob.slice(0, EPH_PUB_BYTES);
|
|
168
|
+
const nonce = blob.slice(EPH_PUB_BYTES, EPH_PUB_BYTES + NONCE_BYTES);
|
|
169
|
+
const ciphertextWithTag = blob.slice(EPH_PUB_BYTES + NONCE_BYTES);
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
const auditorXPriv = ed25519PrivToX25519(auditorViewingPrivKey);
|
|
173
|
+
const shared = x25519.getSharedSecret(auditorXPriv, ephPub);
|
|
174
|
+
const key = deriveKey(shared);
|
|
175
|
+
const aead = xchacha20poly1305(key, nonce, commitment);
|
|
176
|
+
const plaintext = aead.decrypt(ciphertextWithTag);
|
|
177
|
+
return unpackPlaintext(plaintext);
|
|
178
|
+
} catch {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
}
|