@wopr-network/crypto-plugins 1.0.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 (211) hide show
  1. package/.github/workflows/ci.yml +33 -0
  2. package/.github/workflows/publish.yml +12 -0
  3. package/biome.json +23 -0
  4. package/dist/__tests__/bitcoin-encoder.test.d.ts +2 -0
  5. package/dist/__tests__/bitcoin-encoder.test.d.ts.map +1 -0
  6. package/dist/__tests__/bitcoin-encoder.test.js +97 -0
  7. package/dist/__tests__/bitcoin-encoder.test.js.map +1 -0
  8. package/dist/__tests__/dogecoin-encoder.test.d.ts +2 -0
  9. package/dist/__tests__/dogecoin-encoder.test.d.ts.map +1 -0
  10. package/dist/__tests__/dogecoin-encoder.test.js +57 -0
  11. package/dist/__tests__/dogecoin-encoder.test.js.map +1 -0
  12. package/dist/__tests__/litecoin-encoder.test.d.ts +2 -0
  13. package/dist/__tests__/litecoin-encoder.test.d.ts.map +1 -0
  14. package/dist/__tests__/litecoin-encoder.test.js +44 -0
  15. package/dist/__tests__/litecoin-encoder.test.js.map +1 -0
  16. package/dist/__tests__/registry.test.d.ts +2 -0
  17. package/dist/__tests__/registry.test.d.ts.map +1 -0
  18. package/dist/__tests__/registry.test.js +75 -0
  19. package/dist/__tests__/registry.test.js.map +1 -0
  20. package/dist/__tests__/rpc.test.d.ts +2 -0
  21. package/dist/__tests__/rpc.test.d.ts.map +1 -0
  22. package/dist/__tests__/rpc.test.js +31 -0
  23. package/dist/__tests__/rpc.test.js.map +1 -0
  24. package/dist/__tests__/solana-encoder.test.d.ts +2 -0
  25. package/dist/__tests__/solana-encoder.test.d.ts.map +1 -0
  26. package/dist/__tests__/solana-encoder.test.js +85 -0
  27. package/dist/__tests__/solana-encoder.test.js.map +1 -0
  28. package/dist/__tests__/solana-watcher.test.d.ts +2 -0
  29. package/dist/__tests__/solana-watcher.test.d.ts.map +1 -0
  30. package/dist/__tests__/solana-watcher.test.js +281 -0
  31. package/dist/__tests__/solana-watcher.test.js.map +1 -0
  32. package/dist/__tests__/sweep-key-parity.test.d.ts +2 -0
  33. package/dist/__tests__/sweep-key-parity.test.d.ts.map +1 -0
  34. package/dist/__tests__/sweep-key-parity.test.js +236 -0
  35. package/dist/__tests__/sweep-key-parity.test.js.map +1 -0
  36. package/dist/__tests__/tron-encoder.test.d.ts +2 -0
  37. package/dist/__tests__/tron-encoder.test.d.ts.map +1 -0
  38. package/dist/__tests__/tron-encoder.test.js +93 -0
  39. package/dist/__tests__/tron-encoder.test.js.map +1 -0
  40. package/dist/__tests__/utxo-watcher.test.d.ts +2 -0
  41. package/dist/__tests__/utxo-watcher.test.d.ts.map +1 -0
  42. package/dist/__tests__/utxo-watcher.test.js +218 -0
  43. package/dist/__tests__/utxo-watcher.test.js.map +1 -0
  44. package/dist/bitcoin/encoder.d.ts +15 -0
  45. package/dist/bitcoin/encoder.d.ts.map +1 -0
  46. package/dist/bitcoin/encoder.js +286 -0
  47. package/dist/bitcoin/encoder.js.map +1 -0
  48. package/dist/bitcoin/index.d.ts +4 -0
  49. package/dist/bitcoin/index.d.ts.map +1 -0
  50. package/dist/bitcoin/index.js +20 -0
  51. package/dist/bitcoin/index.js.map +1 -0
  52. package/dist/dogecoin/encoder.d.ts +19 -0
  53. package/dist/dogecoin/encoder.d.ts.map +1 -0
  54. package/dist/dogecoin/encoder.js +145 -0
  55. package/dist/dogecoin/encoder.js.map +1 -0
  56. package/dist/dogecoin/index.d.ts +4 -0
  57. package/dist/dogecoin/index.d.ts.map +1 -0
  58. package/dist/dogecoin/index.js +20 -0
  59. package/dist/dogecoin/index.js.map +1 -0
  60. package/dist/evm/encoder.d.ts +7 -0
  61. package/dist/evm/encoder.d.ts.map +1 -0
  62. package/dist/evm/encoder.js +43 -0
  63. package/dist/evm/encoder.js.map +1 -0
  64. package/dist/evm/eth-watcher.d.ts +38 -0
  65. package/dist/evm/eth-watcher.d.ts.map +1 -0
  66. package/dist/evm/eth-watcher.js +138 -0
  67. package/dist/evm/eth-watcher.js.map +1 -0
  68. package/dist/evm/index.d.ts +16 -0
  69. package/dist/evm/index.d.ts.map +1 -0
  70. package/dist/evm/index.js +34 -0
  71. package/dist/evm/index.js.map +1 -0
  72. package/dist/evm/types.d.ts +43 -0
  73. package/dist/evm/types.d.ts.map +1 -0
  74. package/dist/evm/types.js +101 -0
  75. package/dist/evm/types.js.map +1 -0
  76. package/dist/evm/watcher.d.ts +42 -0
  77. package/dist/evm/watcher.d.ts.map +1 -0
  78. package/dist/evm/watcher.js +162 -0
  79. package/dist/evm/watcher.js.map +1 -0
  80. package/dist/index.d.ts +7 -0
  81. package/dist/index.d.ts.map +1 -0
  82. package/dist/index.js +7 -0
  83. package/dist/index.js.map +1 -0
  84. package/dist/litecoin/encoder.d.ts +8 -0
  85. package/dist/litecoin/encoder.d.ts.map +1 -0
  86. package/dist/litecoin/encoder.js +16 -0
  87. package/dist/litecoin/encoder.js.map +1 -0
  88. package/dist/litecoin/index.d.ts +4 -0
  89. package/dist/litecoin/index.d.ts.map +1 -0
  90. package/dist/litecoin/index.js +20 -0
  91. package/dist/litecoin/index.js.map +1 -0
  92. package/dist/shared/test-helpers/index.d.ts +9 -0
  93. package/dist/shared/test-helpers/index.d.ts.map +1 -0
  94. package/dist/shared/test-helpers/index.js +30 -0
  95. package/dist/shared/test-helpers/index.js.map +1 -0
  96. package/dist/shared/utxo/index.d.ts +5 -0
  97. package/dist/shared/utxo/index.d.ts.map +1 -0
  98. package/dist/shared/utxo/index.js +3 -0
  99. package/dist/shared/utxo/index.js.map +1 -0
  100. package/dist/shared/utxo/rpc.d.ts +24 -0
  101. package/dist/shared/utxo/rpc.d.ts.map +1 -0
  102. package/dist/shared/utxo/rpc.js +75 -0
  103. package/dist/shared/utxo/rpc.js.map +1 -0
  104. package/dist/shared/utxo/types.d.ts +40 -0
  105. package/dist/shared/utxo/types.d.ts.map +1 -0
  106. package/dist/shared/utxo/types.js +2 -0
  107. package/dist/shared/utxo/types.js.map +1 -0
  108. package/dist/shared/utxo/watcher.d.ts +55 -0
  109. package/dist/shared/utxo/watcher.d.ts.map +1 -0
  110. package/dist/shared/utxo/watcher.js +150 -0
  111. package/dist/shared/utxo/watcher.js.map +1 -0
  112. package/dist/solana/encoder.d.ts +13 -0
  113. package/dist/solana/encoder.d.ts.map +1 -0
  114. package/dist/solana/encoder.js +62 -0
  115. package/dist/solana/encoder.js.map +1 -0
  116. package/dist/solana/index.d.ts +17 -0
  117. package/dist/solana/index.d.ts.map +1 -0
  118. package/dist/solana/index.js +32 -0
  119. package/dist/solana/index.js.map +1 -0
  120. package/dist/solana/sweeper.d.ts +47 -0
  121. package/dist/solana/sweeper.d.ts.map +1 -0
  122. package/dist/solana/sweeper.js +151 -0
  123. package/dist/solana/sweeper.js.map +1 -0
  124. package/dist/solana/types.d.ts +49 -0
  125. package/dist/solana/types.d.ts.map +1 -0
  126. package/dist/solana/types.js +2 -0
  127. package/dist/solana/types.js.map +1 -0
  128. package/dist/solana/watcher.d.ts +59 -0
  129. package/dist/solana/watcher.d.ts.map +1 -0
  130. package/dist/solana/watcher.js +251 -0
  131. package/dist/solana/watcher.js.map +1 -0
  132. package/dist/sweep/evm-sweeper.d.ts +31 -0
  133. package/dist/sweep/evm-sweeper.d.ts.map +1 -0
  134. package/dist/sweep/evm-sweeper.js +229 -0
  135. package/dist/sweep/evm-sweeper.js.map +1 -0
  136. package/dist/sweep/index.d.ts +22 -0
  137. package/dist/sweep/index.d.ts.map +1 -0
  138. package/dist/sweep/index.js +290 -0
  139. package/dist/sweep/index.js.map +1 -0
  140. package/dist/sweep/tron-sweeper.d.ts +40 -0
  141. package/dist/sweep/tron-sweeper.d.ts.map +1 -0
  142. package/dist/sweep/tron-sweeper.js +363 -0
  143. package/dist/sweep/tron-sweeper.js.map +1 -0
  144. package/dist/sweep/utxo-sweeper.d.ts +14 -0
  145. package/dist/sweep/utxo-sweeper.d.ts.map +1 -0
  146. package/dist/sweep/utxo-sweeper.js +13 -0
  147. package/dist/sweep/utxo-sweeper.js.map +1 -0
  148. package/dist/tron/address-convert.d.ts +15 -0
  149. package/dist/tron/address-convert.d.ts.map +1 -0
  150. package/dist/tron/address-convert.js +95 -0
  151. package/dist/tron/address-convert.js.map +1 -0
  152. package/dist/tron/encoder.d.ts +20 -0
  153. package/dist/tron/encoder.d.ts.map +1 -0
  154. package/dist/tron/encoder.js +67 -0
  155. package/dist/tron/encoder.js.map +1 -0
  156. package/dist/tron/index.d.ts +6 -0
  157. package/dist/tron/index.d.ts.map +1 -0
  158. package/dist/tron/index.js +20 -0
  159. package/dist/tron/index.js.map +1 -0
  160. package/dist/tron/sha256.d.ts +6 -0
  161. package/dist/tron/sha256.d.ts.map +1 -0
  162. package/dist/tron/sha256.js +90 -0
  163. package/dist/tron/sha256.js.map +1 -0
  164. package/dist/tron/watcher.d.ts +42 -0
  165. package/dist/tron/watcher.d.ts.map +1 -0
  166. package/dist/tron/watcher.js +168 -0
  167. package/dist/tron/watcher.js.map +1 -0
  168. package/package.json +47 -0
  169. package/src/__tests__/bitcoin-encoder.test.ts +115 -0
  170. package/src/__tests__/dogecoin-encoder.test.ts +66 -0
  171. package/src/__tests__/litecoin-encoder.test.ts +51 -0
  172. package/src/__tests__/registry.test.ts +91 -0
  173. package/src/__tests__/rpc.test.ts +36 -0
  174. package/src/__tests__/solana-encoder.test.ts +103 -0
  175. package/src/__tests__/solana-watcher.test.ts +316 -0
  176. package/src/__tests__/sweep-key-parity.test.ts +302 -0
  177. package/src/__tests__/tron-encoder.test.ts +108 -0
  178. package/src/__tests__/utxo-watcher.test.ts +252 -0
  179. package/src/bitcoin/encoder.ts +320 -0
  180. package/src/bitcoin/index.ts +23 -0
  181. package/src/dogecoin/encoder.ts +161 -0
  182. package/src/dogecoin/index.ts +23 -0
  183. package/src/evm/encoder.ts +49 -0
  184. package/src/evm/eth-watcher.ts +168 -0
  185. package/src/evm/index.ts +46 -0
  186. package/src/evm/types.ts +146 -0
  187. package/src/evm/watcher.ts +189 -0
  188. package/src/index.ts +21 -0
  189. package/src/litecoin/encoder.ts +18 -0
  190. package/src/litecoin/index.ts +23 -0
  191. package/src/shared/test-helpers/index.ts +36 -0
  192. package/src/shared/utxo/index.ts +12 -0
  193. package/src/shared/utxo/rpc.ts +80 -0
  194. package/src/shared/utxo/types.ts +43 -0
  195. package/src/shared/utxo/watcher.ts +195 -0
  196. package/src/solana/encoder.ts +72 -0
  197. package/src/solana/index.ts +36 -0
  198. package/src/solana/sweeper.ts +196 -0
  199. package/src/solana/types.ts +52 -0
  200. package/src/solana/watcher.ts +282 -0
  201. package/src/sweep/evm-sweeper.ts +296 -0
  202. package/src/sweep/index.ts +353 -0
  203. package/src/sweep/tron-sweeper.ts +467 -0
  204. package/src/sweep/utxo-sweeper.ts +23 -0
  205. package/src/tron/address-convert.ts +91 -0
  206. package/src/tron/encoder.ts +74 -0
  207. package/src/tron/index.ts +23 -0
  208. package/src/tron/sha256.ts +100 -0
  209. package/src/tron/watcher.ts +208 -0
  210. package/tsconfig.json +17 -0
  211. package/vitest.config.ts +8 -0
@@ -0,0 +1,282 @@
1
+ import type {
2
+ IChainWatcher,
3
+ IWatcherCursorStore,
4
+ PaymentEvent,
5
+ WatcherOpts,
6
+ } from "@wopr-network/platform-core/crypto-plugin";
7
+ import type { SignatureInfo, SolanaRpcCall, SolanaTransaction } from "./types.js";
8
+
9
+ /** Microdollars per cent. Used for oracle price conversion. */
10
+ const MICROS_PER_CENT = 10_000n;
11
+
12
+ /** SOL has 9 decimals (lamports). */
13
+ const SOL_DECIMALS = 9;
14
+
15
+ /** Create a Solana JSON-RPC caller. */
16
+ export function createSolanaRpcCaller(rpcUrl: string, extraHeaders?: Record<string, string>): SolanaRpcCall {
17
+ let id = 0;
18
+ const headers: Record<string, string> = { "Content-Type": "application/json", ...extraHeaders };
19
+ return async (method: string, params: unknown[]): Promise<unknown> => {
20
+ const res = await fetch(rpcUrl, {
21
+ method: "POST",
22
+ headers,
23
+ body: JSON.stringify({ jsonrpc: "2.0", id: ++id, method, params }),
24
+ });
25
+ if (!res.ok) {
26
+ const body = await res.text().catch(() => "");
27
+ throw new Error(`Solana RPC ${method} failed: ${res.status} ${body.slice(0, 200)}`);
28
+ }
29
+ const data = (await res.json()) as { result?: unknown; error?: { message: string } };
30
+ if (data.error) throw new Error(`Solana RPC ${method} error: ${data.error.message}`);
31
+ return data.result;
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Convert native SOL amount (lamports) to USD cents using oracle price in microdollars.
37
+ */
38
+ function nativeToCents(lamports: bigint, priceMicros: number, decimals: number): number {
39
+ if (lamports < 0n) throw new Error("lamports must be non-negative");
40
+ if (!Number.isInteger(priceMicros) || priceMicros <= 0) {
41
+ throw new Error(`priceMicros must be a positive integer, got ${priceMicros}`);
42
+ }
43
+ return Number((lamports * BigInt(priceMicros)) / (MICROS_PER_CENT * 10n ** BigInt(decimals)));
44
+ }
45
+
46
+ /**
47
+ * Solana chain watcher.
48
+ *
49
+ * Monitors watched addresses for incoming SOL transfers and SPL token transfers.
50
+ * Uses getSignaturesForAddress + getTransaction RPCs. Cursor is a slot number.
51
+ *
52
+ * For native SOL: detects balance increases to watched addresses.
53
+ * For SPL tokens: detects token transfer instructions to watched addresses.
54
+ */
55
+ export class SolanaWatcher implements IChainWatcher {
56
+ private _cursor = 0;
57
+ private _stopped = false;
58
+ private readonly chain: string;
59
+ private readonly token: string;
60
+ private readonly rpc: SolanaRpcCall;
61
+ private readonly confirmations: number;
62
+ private readonly decimals: number;
63
+ private readonly cursorStore: IWatcherCursorStore;
64
+ private readonly watcherId: string;
65
+ private readonly contractAddress?: string;
66
+ private _watchedAddresses: Set<string>;
67
+
68
+ constructor(opts: WatcherOpts) {
69
+ this.chain = opts.chain;
70
+ this.token = opts.token;
71
+ this.rpc = createSolanaRpcCaller(opts.rpcUrl, opts.rpcHeaders);
72
+ this._cursor = 0;
73
+ this.confirmations = opts.confirmations;
74
+ this.decimals = opts.decimals;
75
+ this.cursorStore = opts.cursorStore;
76
+ this.watcherId = `solana:${opts.chain}:${opts.token}`;
77
+ this.contractAddress = opts.contractAddress;
78
+ this._watchedAddresses = new Set<string>();
79
+ }
80
+
81
+ async init(): Promise<void> {
82
+ const saved = await this.cursorStore.get(this.watcherId);
83
+ if (saved !== null) this._cursor = saved;
84
+ }
85
+
86
+ setWatchedAddresses(addresses: string[]): void {
87
+ this._watchedAddresses = new Set(addresses);
88
+ }
89
+
90
+ getCursor(): number {
91
+ return this._cursor;
92
+ }
93
+
94
+ stop(): void {
95
+ this._stopped = true;
96
+ }
97
+
98
+ /**
99
+ * Poll for SOL or SPL token transfers to watched addresses.
100
+ *
101
+ * For each watched address:
102
+ * 1. Call getSignaturesForAddress to find recent transactions
103
+ * 2. Call getTransaction for each to extract transfer details
104
+ * 3. Detect native SOL transfers (balance diff) or SPL token transfers
105
+ *
106
+ * Cursor is the highest slot seen, advanced only for finalized transactions.
107
+ */
108
+ async poll(): Promise<PaymentEvent[]> {
109
+ if (this._stopped || this._watchedAddresses.size === 0) return [];
110
+
111
+ const events: PaymentEvent[] = [];
112
+
113
+ for (const address of this._watchedAddresses) {
114
+ if (this._stopped) break;
115
+
116
+ const sigs = await this.getRecentSignatures(address);
117
+ if (sigs.length === 0) continue;
118
+
119
+ for (const sig of sigs) {
120
+ if (this._stopped) break;
121
+ if (sig.err) continue;
122
+ if (sig.slot <= this._cursor) continue;
123
+
124
+ const tx = (await this.rpc("getTransaction", [
125
+ sig.signature,
126
+ { encoding: "json", maxSupportedTransactionVersion: 0 },
127
+ ])) as SolanaTransaction | null;
128
+
129
+ if (!tx?.meta || tx.meta.err) continue;
130
+
131
+ const txEvents = this.contractAddress
132
+ ? this.extractSplTransferEvents(tx, address, sig.signature)
133
+ : this.extractNativeTransferEvents(tx, address, sig.signature);
134
+
135
+ for (const evt of txEvents) {
136
+ // Skip if already emitted at this confirmation count
137
+ const txKey = `${sig.signature}:${evt.to}`;
138
+ const lastConf = await this.cursorStore.getConfirmationCount(this.watcherId, txKey);
139
+ if (lastConf !== null && evt.confirmations <= lastConf) continue;
140
+
141
+ events.push(evt);
142
+ await this.cursorStore.saveConfirmationCount(this.watcherId, txKey, evt.confirmations);
143
+ }
144
+
145
+ // Advance cursor for finalized slots
146
+ if (sig.confirmationStatus === "finalized" && sig.slot > this._cursor) {
147
+ this._cursor = sig.slot;
148
+ await this.cursorStore.save(this.watcherId, this._cursor);
149
+ }
150
+ }
151
+ }
152
+
153
+ return events;
154
+ }
155
+
156
+ /** Fetch recent signatures for an address, filtering by slot > cursor. */
157
+ private async getRecentSignatures(address: string): Promise<SignatureInfo[]> {
158
+ const params: Record<string, unknown> = { limit: 100 };
159
+ const sigs = (await this.rpc("getSignaturesForAddress", [address, params])) as SignatureInfo[];
160
+ // Filter to only signatures after our cursor and sort ascending by slot
161
+ return sigs.filter((s) => s.slot > this._cursor).sort((a, b) => a.slot - b.slot);
162
+ }
163
+
164
+ /**
165
+ * Extract native SOL transfer events from a transaction.
166
+ *
167
+ * Detects balance increases to the watched address by comparing
168
+ * preBalances and postBalances in the transaction metadata.
169
+ */
170
+ private extractNativeTransferEvents(
171
+ tx: SolanaTransaction,
172
+ watchedAddress: string,
173
+ signature: string,
174
+ ): PaymentEvent[] {
175
+ const events: PaymentEvent[] = [];
176
+ const { accountKeys } = tx.transaction.message;
177
+ const meta = tx.meta;
178
+ if (!meta) return events;
179
+ const { preBalances, postBalances } = meta;
180
+
181
+ const addrIndex = accountKeys.indexOf(watchedAddress);
182
+ if (addrIndex === -1) return events;
183
+
184
+ const pre = BigInt(preBalances[addrIndex]);
185
+ const post = BigInt(postBalances[addrIndex]);
186
+ const diff = post - pre;
187
+
188
+ if (diff <= 0n) return events;
189
+
190
+ // Determine sender: the first account with a balance decrease
191
+ let from = accountKeys[0]; // fee payer as fallback
192
+ for (let i = 0; i < accountKeys.length; i++) {
193
+ if (i === addrIndex) continue;
194
+ const senderDiff = BigInt(postBalances[i]) - BigInt(preBalances[i]);
195
+ if (senderDiff < 0n) {
196
+ from = accountKeys[i];
197
+ break;
198
+ }
199
+ }
200
+
201
+ // Get current slot for confirmation count
202
+ const confs = this.confirmations; // simplified; real impl would compare against current slot
203
+
204
+ events.push({
205
+ chain: this.chain,
206
+ token: this.token,
207
+ from,
208
+ to: watchedAddress,
209
+ rawAmount: diff.toString(),
210
+ amountUsdCents: nativeToCents(diff, 1, SOL_DECIMALS), // price conversion done by caller via oracle
211
+ txHash: signature,
212
+ blockNumber: tx.slot,
213
+ confirmations: confs,
214
+ confirmationsRequired: this.confirmations,
215
+ });
216
+
217
+ return events;
218
+ }
219
+
220
+ /**
221
+ * Extract SPL token transfer events from a transaction.
222
+ *
223
+ * Compares preTokenBalances and postTokenBalances for the watched address
224
+ * filtered by the configured token mint (contractAddress).
225
+ */
226
+ private extractSplTransferEvents(tx: SolanaTransaction, watchedAddress: string, signature: string): PaymentEvent[] {
227
+ const events: PaymentEvent[] = [];
228
+ const { accountKeys } = tx.transaction.message;
229
+ const mint = this.contractAddress;
230
+ if (!mint) return events;
231
+
232
+ const pre = tx.meta?.preTokenBalances ?? [];
233
+ const post = tx.meta?.postTokenBalances ?? [];
234
+
235
+ // Find post-balances for our token mint owned by the watched address
236
+ for (const postBal of post) {
237
+ if (postBal.mint !== mint) continue;
238
+ if (postBal.owner !== watchedAddress) continue;
239
+
240
+ const postAmount = BigInt(postBal.uiTokenAmount.amount);
241
+
242
+ // Find matching pre-balance
243
+ const preBal = pre.find(
244
+ (p) => p.accountIndex === postBal.accountIndex && p.mint === mint && p.owner === watchedAddress,
245
+ );
246
+ const preAmount = preBal ? BigInt(preBal.uiTokenAmount.amount) : 0n;
247
+
248
+ const diff = postAmount - preAmount;
249
+ if (diff <= 0n) continue;
250
+
251
+ // Determine sender: owner of any token account that decreased
252
+ let from = accountKeys[0];
253
+ for (const preTb of pre) {
254
+ if (preTb.mint !== mint) continue;
255
+ if (preTb.owner === watchedAddress) continue;
256
+ const matchingPost = post.find((p) => p.accountIndex === preTb.accountIndex && p.mint === mint);
257
+ if (matchingPost) {
258
+ const senderDiff = BigInt(matchingPost.uiTokenAmount.amount) - BigInt(preTb.uiTokenAmount.amount);
259
+ if (senderDiff < 0n && preTb.owner) {
260
+ from = preTb.owner;
261
+ break;
262
+ }
263
+ }
264
+ }
265
+
266
+ events.push({
267
+ chain: this.chain,
268
+ token: this.token,
269
+ from,
270
+ to: watchedAddress,
271
+ rawAmount: diff.toString(),
272
+ amountUsdCents: 0, // SPL stablecoins: conversion done by caller
273
+ txHash: signature,
274
+ blockNumber: tx.slot,
275
+ confirmations: this.confirmations,
276
+ confirmationsRequired: this.confirmations,
277
+ });
278
+ }
279
+
280
+ return events;
281
+ }
282
+ }
@@ -0,0 +1,296 @@
1
+ /**
2
+ * EVM sweep strategy -- consolidates ETH + ERC-20s from deposit addresses to treasury.
3
+ *
4
+ * 3-phase sweep:
5
+ * 1. Sweep ETH first -- deposit addresses self-fund gas, treasury receives ETH
6
+ * 2. Fund gas -- treasury sends ETH to ERC-20 deposit addresses
7
+ * 3. Sweep ERC-20s -- deposit addresses send all tokens to treasury
8
+ */
9
+ import type { DepositInfo, ISweepStrategy, KeyPair, SweepResult } from "@wopr-network/platform-core/crypto-plugin";
10
+ import {
11
+ type Address,
12
+ type Chain,
13
+ createPublicClient,
14
+ createWalletClient,
15
+ defineChain,
16
+ formatEther,
17
+ formatUnits,
18
+ http,
19
+ type PublicClient,
20
+ type Transport,
21
+ } from "viem";
22
+ import { privateKeyToAccount } from "viem/accounts";
23
+
24
+ const ERC20_ABI = [
25
+ {
26
+ name: "balanceOf",
27
+ type: "function",
28
+ stateMutability: "view",
29
+ inputs: [{ name: "account", type: "address" }],
30
+ outputs: [{ name: "", type: "uint256" }],
31
+ },
32
+ {
33
+ name: "transfer",
34
+ type: "function",
35
+ stateMutability: "nonpayable",
36
+ inputs: [
37
+ { name: "to", type: "address" },
38
+ { name: "amount", type: "uint256" },
39
+ ],
40
+ outputs: [{ name: "", type: "bool" }],
41
+ },
42
+ ] as const;
43
+
44
+ export interface EvmToken {
45
+ name: string;
46
+ address: Address;
47
+ decimals: number;
48
+ }
49
+
50
+ export interface EvmSweeperOpts {
51
+ rpcUrl: string;
52
+ chainName: string;
53
+ tokens: EvmToken[];
54
+ }
55
+
56
+ export class EvmSweeper implements ISweepStrategy {
57
+ private readonly rpcUrl: string;
58
+ private readonly chainName: string;
59
+ private readonly tokens: EvmToken[];
60
+ private readonly chain: Chain;
61
+ private readonly publicClient: PublicClient<Transport, Chain>;
62
+
63
+ constructor(opts: EvmSweeperOpts) {
64
+ this.rpcUrl = opts.rpcUrl;
65
+ this.chainName = opts.chainName;
66
+ this.tokens = opts.tokens;
67
+
68
+ this.chain = defineChain({
69
+ id: 1,
70
+ name: this.chainName,
71
+ nativeCurrency: { name: "ETH", symbol: "ETH", decimals: 18 },
72
+ rpcUrls: { default: { http: [this.rpcUrl] } },
73
+ });
74
+
75
+ this.publicClient = createPublicClient({
76
+ chain: this.chain,
77
+ transport: http(this.rpcUrl),
78
+ });
79
+ }
80
+
81
+ async scan(keys: KeyPair[], _treasury: string): Promise<DepositInfo[]> {
82
+ const deposits: DepositInfo[] = [];
83
+
84
+ for (const key of keys) {
85
+ const addr = key.address as Address;
86
+ const ethBalance = await this.publicClient.getBalance({ address: addr });
87
+
88
+ const tokenBalances: DepositInfo["tokenBalances"] = [];
89
+ for (const token of this.tokens) {
90
+ try {
91
+ const balance = await this.publicClient.readContract({
92
+ address: token.address,
93
+ abi: ERC20_ABI,
94
+ functionName: "balanceOf",
95
+ args: [addr],
96
+ });
97
+ if (balance > 0n) {
98
+ tokenBalances.push({
99
+ token: token.name,
100
+ balance,
101
+ decimals: token.decimals,
102
+ });
103
+ }
104
+ } catch {
105
+ // Contract may not exist on this chain
106
+ }
107
+ }
108
+
109
+ if (ethBalance > 0n || tokenBalances.length > 0) {
110
+ deposits.push({
111
+ index: key.index,
112
+ address: key.address,
113
+ nativeBalance: ethBalance,
114
+ tokenBalances,
115
+ });
116
+ }
117
+ }
118
+
119
+ return deposits;
120
+ }
121
+
122
+ async sweep(keys: KeyPair[], treasury: string, dryRun: boolean): Promise<SweepResult[]> {
123
+ const treasuryAddress = treasury as Address;
124
+ const deposits = await this.scan(keys, treasury);
125
+
126
+ if (deposits.length === 0) {
127
+ console.log(" No EVM deposits with balances.");
128
+ return [];
129
+ }
130
+
131
+ const gasPrice = await this.publicClient.getGasPrice();
132
+ const ethTransferGas = 21_000n * gasPrice;
133
+ const erc20TransferGas = 65_000n * gasPrice;
134
+
135
+ const ethDeposits = deposits.filter((d) => d.nativeBalance > ethTransferGas);
136
+ const tokenDeposits = deposits.filter((d) => d.tokenBalances.length > 0);
137
+
138
+ // Print scan summary
139
+ const totalEth = ethDeposits.reduce((sum, d) => sum + d.nativeBalance, 0n);
140
+ console.log(` Found ${ethDeposits.length} ETH deposits (${formatEther(totalEth)} ETH)`);
141
+ for (const token of this.tokens) {
142
+ const total = tokenDeposits.reduce(
143
+ (sum, d) => sum + (d.tokenBalances.find((t) => t.token === token.name)?.balance ?? 0n),
144
+ 0n,
145
+ );
146
+ if (total > 0n) {
147
+ console.log(` ${formatUnits(total, token.decimals)} ${token.name}`);
148
+ }
149
+ }
150
+
151
+ if (dryRun) return [];
152
+
153
+ const results: SweepResult[] = [];
154
+ const keyMap = new Map(keys.map((k) => [k.index, k]));
155
+
156
+ // Phase 1: Sweep ETH (self-funded gas)
157
+ if (ethDeposits.length > 0) {
158
+ console.log(" Phase 1: Sweeping ETH to treasury (self-funded gas)");
159
+ for (const dep of ethDeposits) {
160
+ const key = keyMap.get(dep.index);
161
+ if (!key) continue;
162
+
163
+ const privHex = toHexString(key.privateKey);
164
+ const depAccount = privateKeyToAccount(privHex);
165
+ const depWallet = createWalletClient({
166
+ chain: this.chain,
167
+ transport: http(this.rpcUrl),
168
+ account: depAccount,
169
+ });
170
+
171
+ const sweepAmount = dep.nativeBalance - ethTransferGas;
172
+ if (sweepAmount <= 0n) {
173
+ console.log(` [${dep.index}] Balance too low to cover gas, skipping`);
174
+ continue;
175
+ }
176
+
177
+ const hash = await depWallet.sendTransaction({
178
+ to: treasuryAddress,
179
+ value: sweepAmount,
180
+ });
181
+ console.log(` [${dep.index}] Swept ${formatEther(sweepAmount)} ETH: ${hash}`);
182
+ await this.publicClient.waitForTransactionReceipt({ hash });
183
+ results.push({
184
+ index: dep.index,
185
+ address: dep.address,
186
+ token: "ETH",
187
+ amount: formatEther(sweepAmount),
188
+ txHash: hash,
189
+ });
190
+ }
191
+ }
192
+
193
+ // Phase 2: Fund gas for ERC-20 sweeps
194
+ if (tokenDeposits.length > 0) {
195
+ const treasuryKey = keys.find((k) => k.address.toLowerCase() === treasury.toLowerCase());
196
+ if (!treasuryKey) {
197
+ // Treasury key not in the deposit keys -- derive from the first key's parent
198
+ // The caller must ensure the treasury key is passed separately or handle this
199
+ console.log(" Warning: treasury key not found in key set, using external treasury wallet");
200
+ }
201
+
202
+ console.log(" Phase 2: Funding gas for ERC-20 sweeps");
203
+ const treasuryEth = await this.publicClient.getBalance({
204
+ address: treasuryAddress,
205
+ });
206
+ const totalGasNeeded = erc20TransferGas * BigInt(tokenDeposits.reduce((n, d) => n + d.tokenBalances.length, 0));
207
+ console.log(` Treasury ETH: ${formatEther(treasuryEth)}, gas needed: ${formatEther(totalGasNeeded)}`);
208
+
209
+ if (treasuryEth < totalGasNeeded) {
210
+ console.error(
211
+ ` Insufficient treasury ETH for gas. Need ${formatEther(totalGasNeeded)}, have ${formatEther(treasuryEth)}.`,
212
+ );
213
+ return results;
214
+ }
215
+
216
+ // We need to create a treasury wallet -- derive key for chain=1, index=0
217
+ // This is handled by the caller passing treasuryPrivKey via env or key array
218
+ // For now, we assume the treasury has enough gas from the ETH sweep
219
+ const treasuryPrivHex = treasuryKey ? toHexString(treasuryKey.privateKey) : null;
220
+ if (!treasuryPrivHex) {
221
+ console.error(" Cannot fund gas: treasury private key not available");
222
+ return results;
223
+ }
224
+
225
+ const treasuryWallet = createWalletClient({
226
+ chain: this.chain,
227
+ transport: http(this.rpcUrl),
228
+ account: privateKeyToAccount(treasuryPrivHex),
229
+ });
230
+
231
+ for (const dep of tokenDeposits) {
232
+ const depEth = await this.publicClient.getBalance({
233
+ address: dep.address as Address,
234
+ });
235
+ const needed = erc20TransferGas * BigInt(dep.tokenBalances.length);
236
+ if (depEth >= needed) {
237
+ console.log(` [${dep.index}] Already has gas, skipping`);
238
+ continue;
239
+ }
240
+
241
+ const hash = await treasuryWallet.sendTransaction({
242
+ to: dep.address as Address,
243
+ value: needed - depEth,
244
+ });
245
+ console.log(` [${dep.index}] Funded ${formatEther(needed - depEth)} ETH: ${hash}`);
246
+ await this.publicClient.waitForTransactionReceipt({ hash });
247
+ }
248
+
249
+ // Phase 3: Sweep ERC-20s
250
+ console.log(" Phase 3: Sweeping ERC-20s to treasury");
251
+ for (const dep of tokenDeposits) {
252
+ const key = keyMap.get(dep.index);
253
+ if (!key) continue;
254
+
255
+ const privHex = toHexString(key.privateKey);
256
+ const depAccount = privateKeyToAccount(privHex);
257
+ const depWallet = createWalletClient({
258
+ chain: this.chain,
259
+ transport: http(this.rpcUrl),
260
+ account: depAccount,
261
+ });
262
+
263
+ for (const tokenBal of dep.tokenBalances) {
264
+ const tokenDef = this.tokens.find((t) => t.name === tokenBal.token);
265
+ if (!tokenDef) continue;
266
+
267
+ const hash = await depWallet.writeContract({
268
+ address: tokenDef.address,
269
+ abi: ERC20_ABI,
270
+ functionName: "transfer",
271
+ args: [treasuryAddress, tokenBal.balance],
272
+ });
273
+ console.log(
274
+ ` [${dep.index}] Swept ${formatUnits(tokenBal.balance, tokenBal.decimals)} ${tokenBal.token}: ${hash}`,
275
+ );
276
+ await this.publicClient.waitForTransactionReceipt({
277
+ hash,
278
+ });
279
+ results.push({
280
+ index: dep.index,
281
+ address: dep.address,
282
+ token: tokenBal.token,
283
+ amount: formatUnits(tokenBal.balance, tokenBal.decimals),
284
+ txHash: hash,
285
+ });
286
+ }
287
+ }
288
+ }
289
+
290
+ return results;
291
+ }
292
+ }
293
+
294
+ function toHexString(bytes: Uint8Array): `0x${string}` {
295
+ return `0x${Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")}` as `0x${string}`;
296
+ }