@utxopia/sdk 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +171 -0
  3. package/package.json +108 -0
  4. package/packages/btc-client/src/esplora-client.ts +153 -0
  5. package/packages/btc-client/src/index.ts +3 -0
  6. package/packages/btc-client/src/op-return.ts +93 -0
  7. package/packages/btc-client/src/types.ts +112 -0
  8. package/packages/sdk/README.md +277 -0
  9. package/packages/sdk/dist/announcement-client.d.ts +64 -0
  10. package/packages/sdk/dist/announcement-client.js +337 -0
  11. package/packages/sdk/dist/auditor-ciphertext.d.ts +72 -0
  12. package/packages/sdk/dist/auditor-ciphertext.js +135 -0
  13. package/packages/sdk/dist/auditor.d.ts +115 -0
  14. package/packages/sdk/dist/auditor.js +292 -0
  15. package/packages/sdk/dist/bitcoin/ika.d.ts +47 -0
  16. package/packages/sdk/dist/bitcoin/ika.js +74 -0
  17. package/packages/sdk/dist/bitcoin/index.d.ts +2 -0
  18. package/packages/sdk/dist/bitcoin/index.js +1 -0
  19. package/packages/sdk/dist/bound-params.d.ts +96 -0
  20. package/packages/sdk/dist/bound-params.js +249 -0
  21. package/packages/sdk/dist/chadbuffer.d.ts +124 -0
  22. package/packages/sdk/dist/chadbuffer.js +405 -0
  23. package/packages/sdk/dist/claim-link.d.ts +30 -0
  24. package/packages/sdk/dist/claim-link.js +50 -0
  25. package/packages/sdk/dist/client.d.ts +233 -0
  26. package/packages/sdk/dist/client.js +416 -0
  27. package/packages/sdk/dist/commitment-tree.d.ts +268 -0
  28. package/packages/sdk/dist/commitment-tree.js +564 -0
  29. package/packages/sdk/dist/config.d.ts +177 -0
  30. package/packages/sdk/dist/config.js +426 -0
  31. package/packages/sdk/dist/core/esplora.d.ts +110 -0
  32. package/packages/sdk/dist/core/esplora.js +187 -0
  33. package/packages/sdk/dist/core/mempool.d.ts +60 -0
  34. package/packages/sdk/dist/core/mempool.js +107 -0
  35. package/packages/sdk/dist/crypto-babyjub.d.ts +105 -0
  36. package/packages/sdk/dist/crypto-babyjub.js +312 -0
  37. package/packages/sdk/dist/crypto-ed25519.d.ts +116 -0
  38. package/packages/sdk/dist/crypto-ed25519.js +235 -0
  39. package/packages/sdk/dist/crypto.d.ts +59 -0
  40. package/packages/sdk/dist/crypto.js +140 -0
  41. package/packages/sdk/dist/event-client.d.ts +64 -0
  42. package/packages/sdk/dist/event-client.js +165 -0
  43. package/packages/sdk/dist/events.d.ts +127 -0
  44. package/packages/sdk/dist/events.js +305 -0
  45. package/packages/sdk/dist/explorer.d.ts +86 -0
  46. package/packages/sdk/dist/explorer.js +197 -0
  47. package/packages/sdk/dist/index.d.ts +61 -0
  48. package/packages/sdk/dist/index.js +216 -0
  49. package/packages/sdk/dist/instructions.d.ts +1125 -0
  50. package/packages/sdk/dist/instructions.js +1760 -0
  51. package/packages/sdk/dist/keys.d.ts +467 -0
  52. package/packages/sdk/dist/keys.js +799 -0
  53. package/packages/sdk/dist/logger.d.ts +10 -0
  54. package/packages/sdk/dist/logger.js +40 -0
  55. package/packages/sdk/dist/magicblock.d.ts +90 -0
  56. package/packages/sdk/dist/magicblock.js +164 -0
  57. package/packages/sdk/dist/merkle.d.ts +82 -0
  58. package/packages/sdk/dist/merkle.js +141 -0
  59. package/packages/sdk/dist/note.d.ts +348 -0
  60. package/packages/sdk/dist/note.js +483 -0
  61. package/packages/sdk/dist/pda.d.ts +160 -0
  62. package/packages/sdk/dist/pda.js +384 -0
  63. package/packages/sdk/dist/pool-state.d.ts +100 -0
  64. package/packages/sdk/dist/pool-state.js +126 -0
  65. package/packages/sdk/dist/poseidon.d.ts +53 -0
  66. package/packages/sdk/dist/poseidon.js +136 -0
  67. package/packages/sdk/dist/prover/index.d.ts +10 -0
  68. package/packages/sdk/dist/prover/index.js +10 -0
  69. package/packages/sdk/dist/prover/mobile.d.ts +45 -0
  70. package/packages/sdk/dist/prover/mobile.js +227 -0
  71. package/packages/sdk/dist/prover/web.d.ts +136 -0
  72. package/packages/sdk/dist/prover/web.js +597 -0
  73. package/packages/sdk/dist/psbt.d.ts +73 -0
  74. package/packages/sdk/dist/psbt.js +202 -0
  75. package/packages/sdk/dist/selective-disclosure.d.ts +181 -0
  76. package/packages/sdk/dist/selective-disclosure.js +172 -0
  77. package/packages/sdk/dist/sender-memo.d.ts +149 -0
  78. package/packages/sdk/dist/sender-memo.js +250 -0
  79. package/packages/sdk/dist/sns-resolver.d.ts +121 -0
  80. package/packages/sdk/dist/sns-resolver.js +229 -0
  81. package/packages/sdk/dist/solana/connection.d.ts +77 -0
  82. package/packages/sdk/dist/solana/connection.js +133 -0
  83. package/packages/sdk/dist/solana/priority-fee.d.ts +78 -0
  84. package/packages/sdk/dist/solana/priority-fee.js +137 -0
  85. package/packages/sdk/dist/stealth.d.ts +429 -0
  86. package/packages/sdk/dist/stealth.js +703 -0
  87. package/packages/sdk/dist/taproot.d.ts +172 -0
  88. package/packages/sdk/dist/taproot.js +494 -0
  89. package/packages/sdk/dist/token-registry.d.ts +96 -0
  90. package/packages/sdk/dist/token-registry.js +122 -0
  91. package/packages/sdk/dist/utils/encoding.d.ts +19 -0
  92. package/packages/sdk/dist/utils/encoding.js +29 -0
  93. package/packages/sdk/dist/vk-registry.d.ts +100 -0
  94. package/packages/sdk/dist/vk-registry.js +212 -0
@@ -0,0 +1,277 @@
1
+ # @utxopia/sdk
2
+
3
+ TypeScript SDK for interacting with the UTXOpia protocol - a privacy-preserving Bitcoin-to-Solana bridge using Zero-Knowledge Proofs.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ bun add @utxopia/sdk
9
+ # or
10
+ npm install @utxopia/sdk
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ```typescript
16
+ import {
17
+ deriveKeysFromWallet,
18
+ createStealthDeposit,
19
+ scanAnnouncements,
20
+ resolveSnsName,
21
+ } from '@utxopia/sdk';
22
+
23
+ // 1. Derive keys from wallet
24
+ const keys = await deriveKeysFromWallet(walletAdapter);
25
+
26
+ // 2. Look up recipient by .utxopia.sol name
27
+ const recipient = await resolveSnsName(connection, 'alice');
28
+
29
+ // 3. Create stealth deposit
30
+ const deposit = await createStealthDeposit(recipient, 100000n);
31
+
32
+ // 4. Scan for incoming deposits
33
+ const notes = await scanAnnouncements(keys, announcements);
34
+ ```
35
+
36
+ ## Core Features
37
+
38
+ ### Key Derivation
39
+
40
+ Derive spending and viewing keys from a Solana wallet signature (RAILGUN-style):
41
+
42
+ ```typescript
43
+ import { deriveKeysFromWallet, type UTXOpiaKeys } from '@utxopia/sdk';
44
+
45
+ const keys: UTXOpiaKeys = await deriveKeysFromWallet(walletAdapter);
46
+ // keys.spendingPubKey - for receiving funds
47
+ // keys.viewingPubKey - for scanning deposits
48
+ // keys.spendingPrivKey - for claiming (keep secret!)
49
+ // keys.viewingPrivKey - for scanning (can delegate)
50
+ ```
51
+
52
+ ### Stealth Addresses (EIP-5564/DKSAP Pattern)
53
+
54
+ Create private deposits that only the recipient can detect and claim:
55
+
56
+ ```typescript
57
+ import {
58
+ createStealthDeposit,
59
+ scanAnnouncements,
60
+ prepareClaimInputs,
61
+ } from '@utxopia/sdk';
62
+
63
+ // Sender: Create stealth deposit
64
+ const deposit = await createStealthDeposit(recipientMeta, amountSats);
65
+ // deposit.ephemeralPub - publish on-chain
66
+ // deposit.commitment - add to Merkle tree
67
+ // deposit.amountSats - verified BTC amount
68
+
69
+ // Recipient: Scan for deposits
70
+ const notes = await scanAnnouncements(keys, onChainAnnouncements);
71
+
72
+ // Recipient: Prepare claim inputs for ZK proof
73
+ const claimInputs = await prepareClaimInputs(keys, note, merkleProof);
74
+ ```
75
+
76
+ ### Note Generation
77
+
78
+ Create and manage shielded notes:
79
+
80
+ ```typescript
81
+ import {
82
+ generateNote,
83
+ deriveNote,
84
+ createClaimLink,
85
+ parseClaimLink,
86
+ } from '@utxopia/sdk';
87
+
88
+ // Generate random note
89
+ const note = generateNote(100000n);
90
+
91
+ // Derive deterministic note from seed
92
+ const note = deriveNote('my-secret-phrase', 0, 100000n);
93
+
94
+ // Create shareable claim link
95
+ const link = createClaimLink(note);
96
+
97
+ // Parse claim link
98
+ const parsed = parseClaimLink(link);
99
+ ```
100
+
101
+ ### Taproot Address Derivation
102
+
103
+ Generate BTC deposit addresses:
104
+
105
+ ```typescript
106
+ import { deriveTaprootAddress, verifyTaprootAddress } from '@utxopia/sdk';
107
+
108
+ // Use the configured FROST/Ika custody public key; there is no safe default.
109
+ const custodyInternalKey = getConfiguredCustodyInternalKey();
110
+ const { address } = deriveTaprootAddress(
111
+ commitment,
112
+ 'testnet',
113
+ custodyInternalKey,
114
+ );
115
+
116
+ // Verify address matches commitment
117
+ const isValid = verifyTaprootAddress(address, commitment, custodyInternalKey);
118
+ ```
119
+
120
+ ### Merkle Proofs
121
+
122
+ Work with the on-chain commitment tree:
123
+
124
+ ```typescript
125
+ import {
126
+ createMerkleProof,
127
+ proofToNoirFormat,
128
+ TREE_DEPTH,
129
+ } from '@utxopia/sdk';
130
+
131
+ const proof = createMerkleProof(leaves, leafIndex);
132
+ const noirProof = proofToNoirFormat(proof);
133
+ ```
134
+
135
+ ## API Reference
136
+
137
+ ### Stealth Module
138
+
139
+ | Function | Description |
140
+ |----------|-------------|
141
+ | `createStealthDeposit(recipient, amount)` | Create stealth deposit for recipient |
142
+ | `scanAnnouncements(keys, announcements)` | Scan for deposits using viewing key |
143
+ | `prepareClaimInputs(keys, note, proof)` | Prepare inputs for ZK claim proof |
144
+ | `scanUnifiedNotes(keys, announcements)` | Scan announcement events for owned notes |
145
+ | `resolveSnsName(conn, name)` | Look up .utxopia.sol name to stealth address |
146
+
147
+ ### Key Derivation Module
148
+
149
+ | Function | Description |
150
+ |----------|-------------|
151
+ | `deriveKeysFromWallet(wallet)` | Derive keys from wallet signature |
152
+ | `deriveKeysFromSignature(sig)` | Derive keys from raw signature |
153
+ | `deriveKeysFromSeed(seed)` | Derive keys from seed bytes |
154
+ | `createStealthMetaAddress(keys)` | Create stealth meta-address |
155
+ | `createDelegatedViewKey(keys, perms, expiry)` | Create delegated view key |
156
+
157
+ ### Constants
158
+
159
+ ```typescript
160
+ // Program IDs
161
+ UTXOPIA_PROGRAM_ID // Main UTXOpia program (devnet)
162
+ CHADBUFFER_PROGRAM_ID // ChadBuffer for SPV proofs
163
+
164
+ // Merkle Tree
165
+ TREE_DEPTH // 20
166
+ MAX_LEAVES // 2^20
167
+ ZERO_VALUE // Empty leaf value
168
+
169
+ ```
170
+
171
+ ## Types
172
+
173
+ ### UTXOpiaKeys
174
+
175
+ ```typescript
176
+ interface UTXOpiaKeys {
177
+ spendingPubKey: BabyJubPoint;
178
+ spendingPrivKey: bigint;
179
+ viewingPubKey: Uint8Array;
180
+ viewingPrivKey: Uint8Array;
181
+ nullifyingKey: bigint;
182
+ }
183
+ ```
184
+
185
+ ### StealthDeposit
186
+
187
+ ```typescript
188
+ interface StealthDeposit {
189
+ ephemeralPub: Uint8Array; // 32 bytes (Ed25519)
190
+ amountSats: bigint;
191
+ commitment: Uint8Array; // 32 bytes
192
+ createdAt: number;
193
+ }
194
+ ```
195
+
196
+ ### ScannedNote
197
+
198
+ ```typescript
199
+ interface ScannedNote {
200
+ amount: bigint;
201
+ ephemeralPub: Uint8Array;
202
+ stealthPub: BabyJubPoint;
203
+ leafIndex: number;
204
+ commitment: Uint8Array;
205
+ }
206
+ ```
207
+
208
+ ### ConnectionAdapter
209
+
210
+ ```typescript
211
+ interface ConnectionAdapter {
212
+ getAccountInfo: (
213
+ pubkey: { toBytes(): Uint8Array }
214
+ ) => Promise<{ data: Uint8Array } | null>;
215
+ }
216
+ ```
217
+
218
+ ## Security Considerations
219
+
220
+ 1. **Never expose spending private key** - Only needed for claiming
221
+ 2. **Viewing key can be delegated** - For balance monitoring without spend capability
222
+ 3. **Nullifiers prevent double-spending** - Derived from spending key + leaf index
223
+ 4. **Commitments hide amounts** - Poseidon hash of NPK, token, and amount
224
+
225
+ ## Development
226
+
227
+ ```bash
228
+ # Install dependencies
229
+ bun install
230
+
231
+ # Build
232
+ bun run build
233
+
234
+ # Run tests
235
+ bun test
236
+ ```
237
+
238
+ ## Verify it yourself: rebuild the tree from chain
239
+
240
+ Spending a note needs a Merkle proof, and a Merkle proof needs the whole leaf
241
+ set — which normally comes from our indexer. If the leaves weren't recoverable
242
+ from the chain itself, "you can exit without the operator" would be a promise
243
+ rather than a property.
244
+
245
+ This script rebuilds a pool's entire leaf set from Solana transaction logs and
246
+ checks the result against the root the program is verifying against. It talks to
247
+ a public RPC endpoint and nothing else — no backend, no indexer, no API key:
248
+
249
+ ```bash
250
+ TREE=<commitment-tree-pda> bun run scripts/rebuild-tree-from-chain.ts
251
+ ```
252
+
253
+ ```
254
+ on-chain: 123 leaves, root 2994f7d670d12cd8dcbd89af708ff55f9e877bbb387aac4cf09e688a615650ce
255
+ 134/134 scanned, 123 leaves
256
+ recovered 123/123 leaves from logs
257
+ rebuilt: 123 leaves, root 2994f7d670d12cd8dcbd89af708ff55f9e877bbb387aac4cf09e688a615650ce
258
+
259
+ MATCH — the leaf set is recoverable from chain alone
260
+ ```
261
+
262
+ It exits non-zero on a mismatch or on missing leaves, so it works as a check in
263
+ CI as well as by hand.
264
+
265
+ | Env | Default | |
266
+ |---|---|---|
267
+ | `RPC` | devnet | Any Solana RPC endpoint |
268
+ | `TREE` | `DEVNET_CONFIG.commitmentTreePda` | Commitment tree PDA to rebuild |
269
+ | `EPOCH_SIG` | — | Tree's `INITIALIZE` signature. Optional; set it to skip a dead epoch if the PDA was closed and recreated |
270
+ | `PACE_MS` | `120` | Delay between requests. Raise it if your endpoint throttles you |
271
+
272
+ Public endpoints rate-limit this scan aggressively — the script paces itself and
273
+ backs off, so expect it to take a minute or two rather than to fail.
274
+
275
+ ## License
276
+
277
+ MIT
@@ -0,0 +1,64 @@
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
+ import type { OnChainStealthAnnouncement } from "./stealth";
10
+ export interface AnnouncementClientConfig {
11
+ /** Backend REST base URL, e.g. "http://localhost:8080" */
12
+ backendUrl: string;
13
+ /** Backend WS base URL — derived from backendUrl if omitted */
14
+ backendWsUrl?: string;
15
+ /** Solana RPC URL (for direct RPC fallback) */
16
+ solanaRpcUrl: string;
17
+ /** UTXOpia program ID base58 (for direct RPC fallback) */
18
+ programId: string;
19
+ /** Commitment tree PDA base58 — query this instead of program ID for fewer results */
20
+ commitmentTreeAddress?: string;
21
+ /** REST request timeout in ms (default 5000) */
22
+ restTimeoutMs?: number;
23
+ /** Max WS reconnect delay in ms (default 30000) */
24
+ wsMaxReconnectMs?: number;
25
+ /** Max leaves backend can lag before supplementing with RPC (default 2) */
26
+ maxLagLeaves?: number;
27
+ }
28
+ export type AnnouncementListener = (announcements: OnChainStealthAnnouncement[]) => void;
29
+ export declare class AnnouncementClient {
30
+ protected config: AnnouncementClientConfig;
31
+ protected ws: WebSocket | null;
32
+ protected wsReconnectDelay: number;
33
+ protected wsConnected: boolean;
34
+ protected listeners: Set<AnnouncementListener>;
35
+ protected cachedAnnouncements: OnChainStealthAnnouncement[];
36
+ protected latestLeafIndex: number;
37
+ protected backendHealthy: boolean;
38
+ protected closed: boolean;
39
+ protected reconnectTimer: ReturnType<typeof setTimeout> | null;
40
+ private lastRpcFetchAt;
41
+ private rpcCooldownMs;
42
+ protected restTimeout: number;
43
+ protected wsMaxReconnect: number;
44
+ private maxLag;
45
+ protected wsUrl: string;
46
+ constructor(config: AnnouncementClientConfig);
47
+ /** Start WS connection and perform initial data load */
48
+ start(): Promise<void>;
49
+ /** Fetch all announcements using fallback chain */
50
+ fetchAll(): Promise<OnChainStealthAnnouncement[]>;
51
+ /** Subscribe to new announcement events. Returns unsubscribe function. */
52
+ onAnnouncement(listener: AnnouncementListener): () => void;
53
+ /** Stop client, close WS */
54
+ close(): void;
55
+ /** Whether the backend WS is currently connected */
56
+ get isWsConnected(): boolean;
57
+ /** Whether the backend REST was reachable on last attempt */
58
+ get isBackendHealthy(): boolean;
59
+ protected fetchFromBackend(since?: number): Promise<OnChainStealthAnnouncement[]>;
60
+ private fetchFromRpc;
61
+ private checkConsistency;
62
+ protected connectWs(): void;
63
+ protected scheduleReconnect(): void;
64
+ }
@@ -0,0 +1,337 @@
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
+ import { parseProgramEvents } from "./events";
10
+ import { hexToBytes } from "./crypto";
11
+ // ---------------------------------------------------------------------------
12
+ // Helpers
13
+ // ---------------------------------------------------------------------------
14
+ function rowToAnnouncement(row) {
15
+ return {
16
+ announcementType: row.announcement_type,
17
+ ephemeralPub: hexToBytes(row.ephemeral_pub),
18
+ encryptedAmount: hexToBytes(row.encrypted_amount),
19
+ commitment: hexToBytes(row.commitment),
20
+ leafIndex: row.leaf_index,
21
+ blockTime: row.block_time ?? 0,
22
+ slot: row.slot,
23
+ tokenIdHex: row.token_id ?? undefined,
24
+ };
25
+ }
26
+ function wsUpdateToAnnouncement(update) {
27
+ return {
28
+ announcementType: update.announcement_type,
29
+ ephemeralPub: hexToBytes(update.ephemeral_pub),
30
+ encryptedAmount: hexToBytes(update.encrypted_amount),
31
+ commitment: hexToBytes(update.commitment),
32
+ leafIndex: update.leaf_index,
33
+ };
34
+ }
35
+ // ---------------------------------------------------------------------------
36
+ // AnnouncementClient
37
+ // ---------------------------------------------------------------------------
38
+ export class AnnouncementClient {
39
+ constructor(config) {
40
+ this.config = config;
41
+ this.ws = null;
42
+ this.wsReconnectDelay = 1000;
43
+ this.wsConnected = false;
44
+ this.listeners = new Set();
45
+ this.cachedAnnouncements = [];
46
+ this.latestLeafIndex = -1;
47
+ this.backendHealthy = true;
48
+ this.closed = false;
49
+ this.reconnectTimer = null;
50
+ this.lastRpcFetchAt = 0;
51
+ this.rpcCooldownMs = 30000; // minimum 30s between RPC fallback calls
52
+ this.restTimeout = config.restTimeoutMs ?? 5000;
53
+ this.wsMaxReconnect = config.wsMaxReconnectMs ?? 30000;
54
+ this.maxLag = config.maxLagLeaves ?? 2;
55
+ this.wsUrl =
56
+ config.backendWsUrl ??
57
+ config.backendUrl.replace("http://", "ws://").replace("https://", "wss://");
58
+ }
59
+ // -----------------------------------------------------------------------
60
+ // Public API
61
+ // -----------------------------------------------------------------------
62
+ /** Start WS connection and perform initial data load */
63
+ async start() {
64
+ if (this.closed)
65
+ return;
66
+ // Initial load via REST (+ RPC fallback)
67
+ await this.fetchAll();
68
+ // Open WS for real-time push
69
+ this.connectWs();
70
+ }
71
+ /** Fetch all announcements using fallback chain */
72
+ async fetchAll() {
73
+ try {
74
+ const announcements = await this.fetchFromBackend();
75
+ this.backendHealthy = true;
76
+ this.cachedAnnouncements = announcements;
77
+ this.latestLeafIndex = announcements.length > 0
78
+ ? Math.max(...announcements.map((a) => a.leafIndex))
79
+ : -1;
80
+ // Consistency check — supplement from RPC if backend is behind
81
+ await this.checkConsistency();
82
+ return this.cachedAnnouncements;
83
+ }
84
+ catch {
85
+ this.backendHealthy = false;
86
+ }
87
+ // Fallback: direct RPC
88
+ try {
89
+ const announcements = await this.fetchFromRpc();
90
+ this.cachedAnnouncements = announcements;
91
+ this.latestLeafIndex = announcements.length > 0
92
+ ? Math.max(...announcements.map((a) => a.leafIndex))
93
+ : -1;
94
+ return this.cachedAnnouncements;
95
+ }
96
+ catch (e) {
97
+ console.error("[AnnouncementClient] All sources failed:", e);
98
+ return this.cachedAnnouncements;
99
+ }
100
+ }
101
+ /** Subscribe to new announcement events. Returns unsubscribe function. */
102
+ onAnnouncement(listener) {
103
+ this.listeners.add(listener);
104
+ return () => this.listeners.delete(listener);
105
+ }
106
+ /** Stop client, close WS */
107
+ close() {
108
+ this.closed = true;
109
+ if (this.reconnectTimer) {
110
+ clearTimeout(this.reconnectTimer);
111
+ this.reconnectTimer = null;
112
+ }
113
+ if (this.ws) {
114
+ this.ws.close();
115
+ this.ws = null;
116
+ }
117
+ this.listeners.clear();
118
+ }
119
+ /** Whether the backend WS is currently connected */
120
+ get isWsConnected() {
121
+ return this.wsConnected;
122
+ }
123
+ /** Whether the backend REST was reachable on last attempt */
124
+ get isBackendHealthy() {
125
+ return this.backendHealthy;
126
+ }
127
+ // -----------------------------------------------------------------------
128
+ // Internal: Backend REST
129
+ // -----------------------------------------------------------------------
130
+ async fetchFromBackend(since) {
131
+ const url = since != null
132
+ ? `${this.config.backendUrl}/api/announcements?since=${since}`
133
+ : `${this.config.backendUrl}/api/announcements`;
134
+ const resp = await fetch(url, {
135
+ signal: AbortSignal.timeout(this.restTimeout),
136
+ });
137
+ if (!resp.ok)
138
+ throw new Error(`Backend REST ${resp.status}`);
139
+ const data = await resp.json();
140
+ if (!data.success)
141
+ throw new Error("Backend returned success=false");
142
+ return data.announcements.map(rowToAnnouncement);
143
+ }
144
+ // -----------------------------------------------------------------------
145
+ // Internal: Direct RPC fallback
146
+ // -----------------------------------------------------------------------
147
+ async fetchFromRpc(since) {
148
+ // Cooldown to avoid 429 spam when backend is down
149
+ const now = Date.now();
150
+ if (now - this.lastRpcFetchAt < this.rpcCooldownMs) {
151
+ return this.cachedAnnouncements;
152
+ }
153
+ this.lastRpcFetchAt = now;
154
+ // Query commitment tree PDA (only txs that insert leaves = stealth announcements)
155
+ // Falls back to program ID if commitmentTreeAddress not configured
156
+ const queryAddress = this.config.commitmentTreeAddress || this.config.programId;
157
+ const sigsResp = await fetch(this.config.solanaRpcUrl, {
158
+ method: "POST",
159
+ headers: { "Content-Type": "application/json" },
160
+ body: JSON.stringify({
161
+ jsonrpc: "2.0",
162
+ id: 1,
163
+ method: "getSignaturesForAddress",
164
+ params: [queryAddress, { limit: 200 }],
165
+ }),
166
+ signal: AbortSignal.timeout(this.restTimeout * 2),
167
+ });
168
+ const sigsData = await sigsResp.json();
169
+ const signatures = (sigsData.result || []).map((s) => s.signature);
170
+ if (signatures.length === 0)
171
+ return [];
172
+ // Fetch transactions in batches of 10
173
+ const announcements = [];
174
+ const batchSize = 10;
175
+ for (let i = 0; i < signatures.length; i += batchSize) {
176
+ const batch = signatures.slice(i, i + batchSize);
177
+ const txResponses = await Promise.all(batch.map((sig) => fetch(this.config.solanaRpcUrl, {
178
+ method: "POST",
179
+ headers: { "Content-Type": "application/json" },
180
+ body: JSON.stringify({
181
+ jsonrpc: "2.0",
182
+ id: 1,
183
+ method: "getTransaction",
184
+ params: [sig, { encoding: "json", maxSupportedTransactionVersion: 0 }],
185
+ }),
186
+ }).then((r) => r.json())));
187
+ for (const txData of txResponses) {
188
+ const logs = txData?.result?.meta?.logMessages;
189
+ if (!logs)
190
+ continue;
191
+ const events = parseProgramEvents(logs, this.config.programId);
192
+ for (const event of events) {
193
+ if (event.type === "stealth_announcement") {
194
+ if (since != null && event.leafIndex <= since)
195
+ continue;
196
+ announcements.push({
197
+ announcementType: event.announcementType,
198
+ ephemeralPub: event.ephemeralPub,
199
+ encryptedAmount: event.encryptedAmount,
200
+ commitment: event.commitment,
201
+ leafIndex: event.leafIndex,
202
+ });
203
+ }
204
+ }
205
+ }
206
+ }
207
+ // Deduplicate by leafIndex and sort
208
+ const seen = new Map();
209
+ for (const a of announcements) {
210
+ if (!seen.has(a.leafIndex))
211
+ seen.set(a.leafIndex, a);
212
+ }
213
+ return Array.from(seen.values()).sort((a, b) => a.leafIndex - b.leafIndex);
214
+ }
215
+ // -----------------------------------------------------------------------
216
+ // Internal: Consistency check
217
+ // -----------------------------------------------------------------------
218
+ async checkConsistency() {
219
+ try {
220
+ const statusResp = await fetch(`${this.config.backendUrl}/api/announcements/status`, { signal: AbortSignal.timeout(this.restTimeout) });
221
+ if (!statusResp.ok)
222
+ return;
223
+ const status = await statusResp.json();
224
+ const backendLatest = status.latest_leaf_index ?? -1;
225
+ const onChainNext = status.tree_next_index;
226
+ // If backend is more than maxLag behind on-chain, supplement from RPC
227
+ if (onChainNext - 1 - backendLatest > this.maxLag) {
228
+ console.warn(`[AnnouncementClient] Backend behind: latest=${backendLatest}, on-chain next=${onChainNext}. Supplementing from RPC.`);
229
+ const supplement = await this.fetchFromRpc(backendLatest);
230
+ if (supplement.length > 0) {
231
+ // Merge: existing + supplement, dedup by leafIndex
232
+ const merged = new Map();
233
+ for (const a of this.cachedAnnouncements)
234
+ merged.set(a.leafIndex, a);
235
+ for (const a of supplement)
236
+ merged.set(a.leafIndex, a);
237
+ this.cachedAnnouncements = Array.from(merged.values()).sort((a, b) => a.leafIndex - b.leafIndex);
238
+ this.latestLeafIndex = Math.max(this.latestLeafIndex, ...supplement.map((a) => a.leafIndex));
239
+ }
240
+ }
241
+ }
242
+ catch {
243
+ // Non-fatal — consistency check is best-effort
244
+ }
245
+ }
246
+ // -----------------------------------------------------------------------
247
+ // Internal: WebSocket
248
+ // -----------------------------------------------------------------------
249
+ connectWs() {
250
+ if (this.closed)
251
+ return;
252
+ try {
253
+ const url = `${this.wsUrl}/ws/announcements`;
254
+ this.ws = new WebSocket(url);
255
+ this.ws.onopen = () => {
256
+ this.wsConnected = true;
257
+ this.wsReconnectDelay = 1000; // reset backoff
258
+ };
259
+ this.ws.onmessage = (event) => {
260
+ try {
261
+ const update = JSON.parse(typeof event.data === "string" ? event.data : "");
262
+ if (update.type !== "stealth_announcement")
263
+ return;
264
+ const announcement = wsUpdateToAnnouncement(update);
265
+ // Update cache
266
+ if (announcement.leafIndex > this.latestLeafIndex) {
267
+ this.latestLeafIndex = announcement.leafIndex;
268
+ }
269
+ // Avoid duplicates in cache
270
+ if (!this.cachedAnnouncements.some((a) => a.leafIndex === announcement.leafIndex)) {
271
+ this.cachedAnnouncements.push(announcement);
272
+ }
273
+ // Notify listeners
274
+ for (const listener of this.listeners) {
275
+ try {
276
+ listener([announcement]);
277
+ }
278
+ catch {
279
+ // Listener errors shouldn't crash the client
280
+ }
281
+ }
282
+ }
283
+ catch {
284
+ // Ignore malformed messages
285
+ }
286
+ };
287
+ this.ws.onclose = () => {
288
+ this.wsConnected = false;
289
+ this.ws = null;
290
+ this.scheduleReconnect();
291
+ };
292
+ this.ws.onerror = () => {
293
+ // onclose will fire after onerror
294
+ };
295
+ }
296
+ catch {
297
+ this.wsConnected = false;
298
+ this.scheduleReconnect();
299
+ }
300
+ }
301
+ scheduleReconnect() {
302
+ if (this.closed)
303
+ return;
304
+ this.reconnectTimer = setTimeout(async () => {
305
+ this.reconnectTimer = null;
306
+ // Fetch missed announcements before reconnecting WS
307
+ if (this.latestLeafIndex >= 0) {
308
+ try {
309
+ const missed = await this.fetchFromBackend(this.latestLeafIndex);
310
+ if (missed.length > 0) {
311
+ for (const a of missed) {
312
+ if (!this.cachedAnnouncements.some((c) => c.leafIndex === a.leafIndex)) {
313
+ this.cachedAnnouncements.push(a);
314
+ }
315
+ if (a.leafIndex > this.latestLeafIndex) {
316
+ this.latestLeafIndex = a.leafIndex;
317
+ }
318
+ }
319
+ // Notify listeners of catch-up
320
+ for (const listener of this.listeners) {
321
+ try {
322
+ listener(missed);
323
+ }
324
+ catch { /* ignore */ }
325
+ }
326
+ }
327
+ }
328
+ catch {
329
+ // Backend may still be down
330
+ }
331
+ }
332
+ this.connectWs();
333
+ }, this.wsReconnectDelay);
334
+ // Exponential backoff
335
+ this.wsReconnectDelay = Math.min(this.wsReconnectDelay * 2, this.wsMaxReconnect);
336
+ }
337
+ }