@utxopia/sdk 0.1.0-alpha.1 → 0.1.0-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/README.md +214 -108
  2. package/{packages/sdk/dist → dist}/client.d.ts +25 -1
  3. package/{packages/sdk/dist → dist}/client.js +36 -1
  4. package/{packages/sdk/dist → dist}/config.d.ts +34 -3
  5. package/{packages/sdk/dist → dist}/config.js +88 -18
  6. package/{packages/sdk/dist → dist}/crypto-ed25519.d.ts +14 -0
  7. package/{packages/sdk/dist → dist}/crypto-ed25519.js +14 -0
  8. package/{packages/sdk/dist → dist}/index.d.ts +5 -4
  9. package/{packages/sdk/dist → dist}/index.js +5 -4
  10. package/{packages/sdk/dist → dist}/instructions.d.ts +88 -1
  11. package/{packages/sdk/dist → dist}/instructions.js +104 -3
  12. package/dist/spend-doc.d.ts +63 -0
  13. package/dist/spend-doc.js +100 -0
  14. package/{packages/sdk/dist → dist}/stealth.d.ts +124 -1
  15. package/{packages/sdk/dist → dist}/stealth.js +172 -4
  16. package/{packages/sdk/dist → dist}/taproot.d.ts +48 -0
  17. package/{packages/sdk/dist → dist}/taproot.js +71 -0
  18. package/package.json +86 -63
  19. package/src/announcement-client.ts +457 -0
  20. package/src/auditor-ciphertext.ts +181 -0
  21. package/src/auditor.ts +409 -0
  22. package/src/bitcoin/ika.ts +103 -0
  23. package/src/bitcoin/index.ts +5 -0
  24. package/src/bound-params.ts +322 -0
  25. package/src/chadbuffer.ts +603 -0
  26. package/src/circomlibjs.d.ts +51 -0
  27. package/src/claim-link.ts +53 -0
  28. package/src/client.ts +638 -0
  29. package/src/commitment-tree.ts +736 -0
  30. package/src/config.ts +772 -0
  31. package/src/core/esplora.ts +332 -0
  32. package/src/core/mempool.ts +159 -0
  33. package/src/crypto-babyjub.ts +385 -0
  34. package/src/crypto-ed25519.ts +297 -0
  35. package/src/crypto.ts +199 -0
  36. package/src/event-client.ts +231 -0
  37. package/src/events.ts +384 -0
  38. package/src/explorer.ts +300 -0
  39. package/src/index.ts +902 -0
  40. package/src/instructions.ts +2820 -0
  41. package/src/keys.ts +1228 -0
  42. package/src/logger.ts +41 -0
  43. package/src/magicblock.ts +278 -0
  44. package/src/merkle.ts +197 -0
  45. package/src/note.ts +754 -0
  46. package/src/pda.ts +516 -0
  47. package/src/pool-state.ts +176 -0
  48. package/src/poseidon.ts +175 -0
  49. package/src/prover/index.ts +19 -0
  50. package/src/prover/mobile.ts +303 -0
  51. package/src/prover/web.ts +771 -0
  52. package/src/psbt.ts +333 -0
  53. package/src/selective-disclosure.ts +284 -0
  54. package/src/sender-memo.ts +343 -0
  55. package/src/snarkjs.d.ts +19 -0
  56. package/src/sns-resolver.ts +333 -0
  57. package/src/solana/connection.ts +189 -0
  58. package/src/solana/priority-fee.ts +201 -0
  59. package/src/spend-doc.ts +163 -0
  60. package/src/stealth.ts +1477 -0
  61. package/src/taproot.ts +707 -0
  62. package/src/token-registry.ts +207 -0
  63. package/src/utils/encoding.ts +33 -0
  64. package/src/vk-registry.ts +295 -0
  65. package/LICENSE +0 -21
  66. package/packages/btc-client/src/esplora-client.ts +0 -153
  67. package/packages/btc-client/src/index.ts +0 -3
  68. package/packages/btc-client/src/op-return.ts +0 -93
  69. package/packages/btc-client/src/types.ts +0 -112
  70. package/packages/sdk/README.md +0 -277
  71. /package/{packages/sdk/dist → dist}/announcement-client.d.ts +0 -0
  72. /package/{packages/sdk/dist → dist}/announcement-client.js +0 -0
  73. /package/{packages/sdk/dist → dist}/auditor-ciphertext.d.ts +0 -0
  74. /package/{packages/sdk/dist → dist}/auditor-ciphertext.js +0 -0
  75. /package/{packages/sdk/dist → dist}/auditor.d.ts +0 -0
  76. /package/{packages/sdk/dist → dist}/auditor.js +0 -0
  77. /package/{packages/sdk/dist → dist}/bitcoin/ika.d.ts +0 -0
  78. /package/{packages/sdk/dist → dist}/bitcoin/ika.js +0 -0
  79. /package/{packages/sdk/dist → dist}/bitcoin/index.d.ts +0 -0
  80. /package/{packages/sdk/dist → dist}/bitcoin/index.js +0 -0
  81. /package/{packages/sdk/dist → dist}/bound-params.d.ts +0 -0
  82. /package/{packages/sdk/dist → dist}/bound-params.js +0 -0
  83. /package/{packages/sdk/dist → dist}/chadbuffer.d.ts +0 -0
  84. /package/{packages/sdk/dist → dist}/chadbuffer.js +0 -0
  85. /package/{packages/sdk/dist → dist}/claim-link.d.ts +0 -0
  86. /package/{packages/sdk/dist → dist}/claim-link.js +0 -0
  87. /package/{packages/sdk/dist → dist}/commitment-tree.d.ts +0 -0
  88. /package/{packages/sdk/dist → dist}/commitment-tree.js +0 -0
  89. /package/{packages/sdk/dist → dist}/core/esplora.d.ts +0 -0
  90. /package/{packages/sdk/dist → dist}/core/esplora.js +0 -0
  91. /package/{packages/sdk/dist → dist}/core/mempool.d.ts +0 -0
  92. /package/{packages/sdk/dist → dist}/core/mempool.js +0 -0
  93. /package/{packages/sdk/dist → dist}/crypto-babyjub.d.ts +0 -0
  94. /package/{packages/sdk/dist → dist}/crypto-babyjub.js +0 -0
  95. /package/{packages/sdk/dist → dist}/crypto.d.ts +0 -0
  96. /package/{packages/sdk/dist → dist}/crypto.js +0 -0
  97. /package/{packages/sdk/dist → dist}/event-client.d.ts +0 -0
  98. /package/{packages/sdk/dist → dist}/event-client.js +0 -0
  99. /package/{packages/sdk/dist → dist}/events.d.ts +0 -0
  100. /package/{packages/sdk/dist → dist}/events.js +0 -0
  101. /package/{packages/sdk/dist → dist}/explorer.d.ts +0 -0
  102. /package/{packages/sdk/dist → dist}/explorer.js +0 -0
  103. /package/{packages/sdk/dist → dist}/keys.d.ts +0 -0
  104. /package/{packages/sdk/dist → dist}/keys.js +0 -0
  105. /package/{packages/sdk/dist → dist}/logger.d.ts +0 -0
  106. /package/{packages/sdk/dist → dist}/logger.js +0 -0
  107. /package/{packages/sdk/dist → dist}/magicblock.d.ts +0 -0
  108. /package/{packages/sdk/dist → dist}/magicblock.js +0 -0
  109. /package/{packages/sdk/dist → dist}/merkle.d.ts +0 -0
  110. /package/{packages/sdk/dist → dist}/merkle.js +0 -0
  111. /package/{packages/sdk/dist → dist}/note.d.ts +0 -0
  112. /package/{packages/sdk/dist → dist}/note.js +0 -0
  113. /package/{packages/sdk/dist → dist}/pda.d.ts +0 -0
  114. /package/{packages/sdk/dist → dist}/pda.js +0 -0
  115. /package/{packages/sdk/dist → dist}/pool-state.d.ts +0 -0
  116. /package/{packages/sdk/dist → dist}/pool-state.js +0 -0
  117. /package/{packages/sdk/dist → dist}/poseidon.d.ts +0 -0
  118. /package/{packages/sdk/dist → dist}/poseidon.js +0 -0
  119. /package/{packages/sdk/dist → dist}/prover/index.d.ts +0 -0
  120. /package/{packages/sdk/dist → dist}/prover/index.js +0 -0
  121. /package/{packages/sdk/dist → dist}/prover/mobile.d.ts +0 -0
  122. /package/{packages/sdk/dist → dist}/prover/mobile.js +0 -0
  123. /package/{packages/sdk/dist → dist}/prover/web.d.ts +0 -0
  124. /package/{packages/sdk/dist → dist}/prover/web.js +0 -0
  125. /package/{packages/sdk/dist → dist}/psbt.d.ts +0 -0
  126. /package/{packages/sdk/dist → dist}/psbt.js +0 -0
  127. /package/{packages/sdk/dist → dist}/selective-disclosure.d.ts +0 -0
  128. /package/{packages/sdk/dist → dist}/selective-disclosure.js +0 -0
  129. /package/{packages/sdk/dist → dist}/sender-memo.d.ts +0 -0
  130. /package/{packages/sdk/dist → dist}/sender-memo.js +0 -0
  131. /package/{packages/sdk/dist → dist}/sns-resolver.d.ts +0 -0
  132. /package/{packages/sdk/dist → dist}/sns-resolver.js +0 -0
  133. /package/{packages/sdk/dist → dist}/solana/connection.d.ts +0 -0
  134. /package/{packages/sdk/dist → dist}/solana/connection.js +0 -0
  135. /package/{packages/sdk/dist → dist}/solana/priority-fee.d.ts +0 -0
  136. /package/{packages/sdk/dist → dist}/solana/priority-fee.js +0 -0
  137. /package/{packages/sdk/dist → dist}/token-registry.d.ts +0 -0
  138. /package/{packages/sdk/dist → dist}/token-registry.js +0 -0
  139. /package/{packages/sdk/dist → dist}/utils/encoding.d.ts +0 -0
  140. /package/{packages/sdk/dist → dist}/utils/encoding.js +0 -0
  141. /package/{packages/sdk/dist → dist}/vk-registry.d.ts +0 -0
  142. /package/{packages/sdk/dist → dist}/vk-registry.js +0 -0
package/package.json CHANGED
@@ -1,73 +1,88 @@
1
1
  {
2
2
  "name": "@utxopia/sdk",
3
- "version": "0.1.0-alpha.1",
4
- "description": "UTXOpia public SDK workspace",
5
- "license": "MIT",
6
- "repository": {
7
- "type": "git",
8
- "url": "git+https://github.com/UTXOpia/utxopia-sdk.git"
9
- },
10
- "homepage": "https://github.com/UTXOpia/utxopia-sdk#readme",
11
- "bugs": {
12
- "url": "https://github.com/UTXOpia/utxopia-sdk/issues"
13
- },
3
+ "version": "0.1.0-alpha.3",
4
+ "description": "UTXOpia SDK - Private Bitcoin across Solana and Sui",
14
5
  "type": "module",
15
- "private": false,
16
- "publishConfig": {
17
- "access": "public"
18
- },
19
- "files": [
20
- "packages/sdk/dist",
21
- "packages/btc-client/src",
22
- "README.md",
23
- "LICENSE"
24
- ],
25
- "sideEffects": false,
26
- "main": "./packages/sdk/dist/index.js",
27
- "module": "./packages/sdk/dist/index.js",
28
- "types": "./packages/sdk/dist/index.d.ts",
6
+ "main": "dist/index.js",
7
+ "module": "dist/index.js",
8
+ "types": "dist/index.d.ts",
29
9
  "exports": {
30
10
  ".": {
31
- "types": "./packages/sdk/dist/index.d.ts",
32
- "import": "./packages/sdk/dist/index.js",
33
- "default": "./packages/sdk/dist/index.js"
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "default": "./dist/index.js"
34
14
  },
35
15
  "./prover": {
36
- "types": "./packages/sdk/dist/prover/index.d.ts",
37
- "import": "./packages/sdk/dist/prover/index.js",
38
- "default": "./packages/sdk/dist/prover/index.js"
16
+ "types": "./dist/prover/index.d.ts",
17
+ "import": "./dist/prover/index.js",
18
+ "default": "./dist/prover/index.js"
39
19
  },
40
20
  "./prover/web": {
41
- "types": "./packages/sdk/dist/prover/web.d.ts",
42
- "import": "./packages/sdk/dist/prover/web.js",
43
- "default": "./packages/sdk/dist/prover/web.js"
21
+ "types": "./dist/prover/web.d.ts",
22
+ "import": "./dist/prover/web.js",
23
+ "default": "./dist/prover/web.js"
44
24
  },
45
25
  "./prover/mobile": {
46
- "types": "./packages/sdk/dist/prover/mobile.d.ts",
47
- "import": "./packages/sdk/dist/prover/mobile.js",
48
- "default": "./packages/sdk/dist/prover/mobile.js"
26
+ "types": "./dist/prover/mobile.d.ts",
27
+ "import": "./dist/prover/mobile.js",
28
+ "default": "./dist/prover/mobile.js"
29
+ },
30
+ "./stealth": {
31
+ "types": "./dist/stealth/index.d.ts",
32
+ "import": "./dist/stealth/index.js",
33
+ "default": "./dist/stealth/index.js"
49
34
  },
50
35
  "./bitcoin": {
51
- "types": "./packages/sdk/dist/bitcoin/index.d.ts",
52
- "import": "./packages/sdk/dist/bitcoin/index.js",
53
- "default": "./packages/sdk/dist/bitcoin/index.js"
36
+ "types": "./dist/bitcoin/index.d.ts",
37
+ "import": "./dist/bitcoin/index.js",
38
+ "default": "./dist/bitcoin/index.js"
54
39
  },
55
- "./stealth": {
56
- "types": "./packages/sdk/dist/stealth.d.ts",
57
- "import": "./packages/sdk/dist/stealth.js",
58
- "default": "./packages/sdk/dist/stealth.js"
40
+ "./solana": {
41
+ "types": "./dist/solana/index.d.ts",
42
+ "import": "./dist/solana/index.js",
43
+ "default": "./dist/solana/index.js"
44
+ },
45
+ "./watcher": {
46
+ "types": "./dist/watcher/index.d.ts",
47
+ "import": "./dist/watcher/index.js",
48
+ "default": "./dist/watcher/index.js"
49
+ },
50
+ "./watcher/web": {
51
+ "types": "./dist/watcher/web.d.ts",
52
+ "import": "./dist/watcher/web.js",
53
+ "default": "./dist/watcher/web.js"
59
54
  },
60
- "./btc-client": {
61
- "types": "./packages/btc-client/src/index.ts",
62
- "import": "./packages/btc-client/src/index.ts",
63
- "default": "./packages/btc-client/src/index.ts"
55
+ "./watcher/native": {
56
+ "types": "./dist/watcher/native.d.ts",
57
+ "import": "./dist/watcher/native.js",
58
+ "default": "./dist/watcher/native.js"
59
+ },
60
+ "./react": {
61
+ "types": "./dist/react/index.d.ts",
62
+ "import": "./dist/react/index.js",
63
+ "default": "./dist/react/index.js"
64
64
  }
65
65
  },
66
+ "files": [
67
+ "dist",
68
+ "src",
69
+ "circuits"
70
+ ],
66
71
  "scripts": {
67
- "build": "cd packages/sdk && bunx tsc",
68
- "test": "cd packages/sdk && bun install --ignore-scripts && bun test",
69
- "prepare": "cd packages/sdk && bunx tsc",
70
- "copy-circuits": "cd packages/sdk && bun run copy-circuits"
72
+ "build": "tsc",
73
+ "clean": "rm -rf dist",
74
+ "prepublishOnly": "bun run build",
75
+ "test": "bun test",
76
+ "test:unit": "bun test src/ test/commitment.test.ts test/commitment-onchain.test.ts",
77
+ "test:e2e": "bun test test/e2e/",
78
+ "test:e2e:devnet": "NETWORK=devnet bun test test/e2e/",
79
+ "test:e2e:claim": "bun test test/e2e/claim.test.ts",
80
+ "test:e2e:split": "bun test test/e2e/split.test.ts",
81
+ "test:e2e:partial": "bun test test/e2e/partial-public.test.ts",
82
+ "test:e2e:nullifier": "bun test test/e2e/nullifier.test.ts",
83
+ "test:e2e:tree": "bun test test/e2e/tree-consistency.test.ts",
84
+ "copy-circuits": "CIRCUITS_DIR=${UTXOPIA_CIRCUITS_DIR:-../../utxopia-circuits/circuits}; mkdir -p circuits && cp -r \"$CIRCUITS_DIR\"/build/* circuits/",
85
+ "e2e": "bun run scripts/e2e-mock-spv.ts"
71
86
  },
72
87
  "dependencies": {
73
88
  "@magicblock-labs/ephemeral-rollups-kit": "0.16.2",
@@ -82,6 +97,12 @@
82
97
  "circomlibjs": "^0.1.7",
83
98
  "poseidon-lite": "0.3.0"
84
99
  },
100
+ "devDependencies": {
101
+ "@solana/web3.js": "^1.98.4",
102
+ "@types/node": "^20.10.0",
103
+ "@types/react": "^19",
104
+ "typescript": "^5.3.0"
105
+ },
85
106
  "peerDependencies": {
86
107
  "react": ">=17.0.0",
87
108
  "snarkjs": "^0.7.5"
@@ -94,15 +115,17 @@
94
115
  "optional": true
95
116
  }
96
117
  },
97
- "devDependencies": {
98
- "@solana/web3.js": "^1.98.4",
99
- "@types/node": "^20.10.0",
100
- "@types/react": "^19",
101
- "typescript": "^5.3.0"
102
- },
103
- "overrides": {
104
- "uuid": "11.1.1",
105
- "valibot": "1.4.2",
106
- "ws": "8.21.1"
107
- }
118
+ "keywords": [
119
+ "solana",
120
+ "bitcoin",
121
+ "bridge",
122
+ "privacy",
123
+ "zkp",
124
+ "circom",
125
+ "groth16",
126
+ "snarkjs",
127
+ "taproot"
128
+ ],
129
+ "author": "UTXOpia Team",
130
+ "license": "MIT"
108
131
  }
@@ -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
+ }