@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,80 @@
1
+ import type { RpcCall } from "./types.js";
2
+
3
+ interface RpcConfig {
4
+ rpcUrl: string;
5
+ rpcUser: string;
6
+ rpcPassword: string;
7
+ }
8
+
9
+ /**
10
+ * Parse RPC credentials from a URL with embedded basic auth.
11
+ * e.g. "http://user:pass@host:8332" -> { rpcUrl: "http://host:8332", rpcUser: "user", rpcPassword: "pass" }
12
+ * If no auth in URL, returns the URL as-is with empty credentials.
13
+ */
14
+ export function parseRpcUrl(url: string): RpcConfig {
15
+ try {
16
+ const parsed = new URL(url);
17
+ if (parsed.username) {
18
+ const rpcUser = decodeURIComponent(parsed.username);
19
+ const rpcPassword = decodeURIComponent(parsed.password);
20
+ parsed.username = "";
21
+ parsed.password = "";
22
+ return { rpcUrl: parsed.toString().replace(/\/$/, ""), rpcUser, rpcPassword };
23
+ }
24
+ } catch {
25
+ // Not a valid URL with auth, return as-is
26
+ }
27
+ return { rpcUrl: url, rpcUser: "", rpcPassword: "" };
28
+ }
29
+
30
+ /**
31
+ * Create a JSON-RPC caller for a bitcoind-compatible node (BTC, LTC, DOGE).
32
+ * Uses basic auth and JSON-RPC 1.0 protocol.
33
+ */
34
+ export function createBitcoindRpc(rpcUrl: string, rpcUser: string, rpcPassword: string): RpcCall {
35
+ let id = 0;
36
+ const auth = btoa(`${rpcUser}:${rpcPassword}`);
37
+ return async (method: string, params: unknown[]): Promise<unknown> => {
38
+ const res = await fetch(rpcUrl, {
39
+ method: "POST",
40
+ headers: {
41
+ "Content-Type": "application/json",
42
+ Authorization: `Basic ${auth}`,
43
+ },
44
+ body: JSON.stringify({ jsonrpc: "1.0", id: ++id, method, params }),
45
+ });
46
+ if (!res.ok) throw new Error(`RPC ${method} failed: ${res.status}`);
47
+ const data = (await res.json()) as { result?: unknown; error?: { message: string } };
48
+ if (data.error) throw new Error(`RPC ${method}: ${data.error.message}`);
49
+ return data.result;
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Create a bitcoind-compatible RPC caller from WatcherOpts-style config.
55
+ * Parses rpcUrl for embedded credentials, falls back to rpcHeaders Authorization.
56
+ */
57
+ export function createRpcFromOpts(rpcUrl: string, rpcHeaders: Record<string, string>): RpcCall {
58
+ // Try parsing credentials from URL
59
+ const parsed = parseRpcUrl(rpcUrl);
60
+ if (parsed.rpcUser) {
61
+ return createBitcoindRpc(parsed.rpcUrl, parsed.rpcUser, parsed.rpcPassword);
62
+ }
63
+
64
+ // Fall back to rpcHeaders (may contain Authorization header)
65
+ let id = 0;
66
+ return async (method: string, params: unknown[]): Promise<unknown> => {
67
+ const res = await fetch(rpcUrl, {
68
+ method: "POST",
69
+ headers: {
70
+ "Content-Type": "application/json",
71
+ ...rpcHeaders,
72
+ },
73
+ body: JSON.stringify({ jsonrpc: "1.0", id: ++id, method, params }),
74
+ });
75
+ if (!res.ok) throw new Error(`RPC ${method} failed: ${res.status}`);
76
+ const data = (await res.json()) as { result?: unknown; error?: { message: string } };
77
+ if (data.error) throw new Error(`RPC ${method}: ${data.error.message}`);
78
+ return data.result;
79
+ };
80
+ }
@@ -0,0 +1,43 @@
1
+ /** Configuration for a bitcoind-compatible JSON-RPC node (BTC, LTC, DOGE). */
2
+ export interface UtxoNodeConfig {
3
+ readonly rpcUrl: string;
4
+ readonly rpcUser: string;
5
+ readonly rpcPassword: string;
6
+ readonly network: "mainnet" | "testnet" | "regtest";
7
+ readonly confirmations: number;
8
+ }
9
+
10
+ /** A single "received by address" entry from listreceivedbyaddress. */
11
+ export interface ReceivedByAddress {
12
+ address: string;
13
+ amount: number;
14
+ confirmations: number;
15
+ txids: string[];
16
+ }
17
+
18
+ /** Transaction detail from gettransaction. */
19
+ export interface TxDetail {
20
+ address: string;
21
+ amount: number;
22
+ category: string;
23
+ }
24
+
25
+ /** Response from gettransaction RPC call. */
26
+ export interface GetTransactionResponse {
27
+ details: TxDetail[];
28
+ confirmations: number;
29
+ }
30
+
31
+ /** Descriptor info response from getdescriptorinfo. */
32
+ export interface DescriptorInfo {
33
+ descriptor: string;
34
+ }
35
+
36
+ /** Result of importdescriptors RPC call. */
37
+ export interface ImportDescriptorResult {
38
+ success: boolean;
39
+ error?: { message: string };
40
+ }
41
+
42
+ /** JSON-RPC call signature for bitcoind-compatible nodes. */
43
+ export type RpcCall = (method: string, params: unknown[]) => Promise<unknown>;
@@ -0,0 +1,195 @@
1
+ import type {
2
+ IChainWatcher,
3
+ IPriceOracle,
4
+ IWatcherCursorStore,
5
+ PaymentEvent,
6
+ WatcherOpts,
7
+ } from "@wopr-network/platform-core/crypto-plugin";
8
+
9
+ import type {
10
+ DescriptorInfo,
11
+ GetTransactionResponse,
12
+ ImportDescriptorResult,
13
+ ReceivedByAddress,
14
+ RpcCall,
15
+ } from "./types.js";
16
+
17
+ /**
18
+ * Convert raw native units to USD cents using microdollar pricing.
19
+ *
20
+ * priceMicros = microdollars (10^-6 USD) per 1 whole coin.
21
+ * rawAmount is in the smallest unit (sats for BTC, litoshis for LTC, etc).
22
+ * decimals = number of decimal places (8 for BTC/LTC/DOGE).
23
+ *
24
+ * Formula: (rawAmount * priceMicros) / (10_000 * 10^decimals)
25
+ * where 10_000 converts microdollars to cents (1 cent = 10,000 microdollars).
26
+ */
27
+ function nativeToCents(rawAmount: bigint, priceMicros: number, decimals: number): number {
28
+ if (rawAmount < 0n) throw new Error("rawAmount must be non-negative");
29
+ if (!Number.isInteger(priceMicros) || priceMicros <= 0) {
30
+ throw new Error(`priceMicros must be a positive integer, got ${priceMicros}`);
31
+ }
32
+ const MICROS_PER_CENT = 10_000n;
33
+ return Number((rawAmount * BigInt(priceMicros)) / (MICROS_PER_CENT * 10n ** BigInt(decimals)));
34
+ }
35
+
36
+ export interface UtxoWatcherConfig {
37
+ /** JSON-RPC call function for the node. */
38
+ rpc: RpcCall;
39
+ /** Chain identifier for the price oracle (e.g. "BTC", "LTC", "DOGE"). */
40
+ token: string;
41
+ /** Chain name for PaymentEvent (e.g. "bitcoin", "litecoin", "dogecoin"). */
42
+ chain: string;
43
+ /** Number of decimal places for this chain's native unit (8 for BTC/LTC/DOGE). */
44
+ decimals: number;
45
+ /** Required confirmations before marking fully processed. */
46
+ confirmations: number;
47
+ /** Price oracle for USD conversion. */
48
+ oracle: IPriceOracle;
49
+ /** Cursor store for dedup and confirmation tracking. */
50
+ cursorStore: IWatcherCursorStore;
51
+ }
52
+
53
+ /**
54
+ * Generic UTXO chain watcher that works with any bitcoind-compatible node.
55
+ * Polls listreceivedbyaddress for payments and tracks confirmations.
56
+ *
57
+ * Reusable for BTC, LTC, and DOGE.
58
+ */
59
+ export class UtxoWatcher implements IChainWatcher {
60
+ private readonly rpc: RpcCall;
61
+ private readonly addresses: Set<string> = new Set();
62
+ private readonly token: string;
63
+ private readonly chain: string;
64
+ private readonly decimals: number;
65
+ private readonly minConfirmations: number;
66
+ private readonly oracle: IPriceOracle;
67
+ private readonly cursorStore: IWatcherCursorStore;
68
+ private readonly watcherId: string;
69
+ private cursor = 0;
70
+ private stopped = false;
71
+
72
+ constructor(config: UtxoWatcherConfig) {
73
+ this.rpc = config.rpc;
74
+ this.token = config.token;
75
+ this.chain = config.chain;
76
+ this.decimals = config.decimals;
77
+ this.minConfirmations = config.confirmations;
78
+ this.oracle = config.oracle;
79
+ this.cursorStore = config.cursorStore;
80
+ this.watcherId = `${config.chain}:${config.token}`;
81
+ }
82
+
83
+ async init(): Promise<void> {
84
+ // Load persisted cursor (block height not used for UTXO, but kept for interface compat)
85
+ const saved = await this.cursorStore.get(this.watcherId);
86
+ if (saved !== null) this.cursor = saved;
87
+ }
88
+
89
+ setWatchedAddresses(addresses: string[]): void {
90
+ this.addresses.clear();
91
+ for (const a of addresses) this.addresses.add(a);
92
+ }
93
+
94
+ getCursor(): number {
95
+ return this.cursor;
96
+ }
97
+
98
+ stop(): void {
99
+ this.stopped = true;
100
+ }
101
+
102
+ /**
103
+ * Import an address into the node's wallet (watch-only).
104
+ * Uses importdescriptors (modern) with fallback to importaddress (legacy).
105
+ */
106
+ async importAddress(address: string): Promise<void> {
107
+ try {
108
+ const info = (await this.rpc("getdescriptorinfo", [`addr(${address})`])) as DescriptorInfo;
109
+ const result = (await this.rpc("importdescriptors", [
110
+ [{ desc: info.descriptor, timestamp: 0 }],
111
+ ])) as ImportDescriptorResult[];
112
+ if (result[0] && !result[0].success) {
113
+ throw new Error(result[0].error?.message ?? "importdescriptors failed");
114
+ }
115
+ } catch {
116
+ // Fallback: legacy importaddress
117
+ await this.rpc("importaddress", [address, "", false]);
118
+ }
119
+ this.addresses.add(address);
120
+ }
121
+
122
+ /**
123
+ * Poll for payments to watched addresses.
124
+ * Returns PaymentEvent[] for each new or updated confirmation.
125
+ */
126
+ async poll(): Promise<PaymentEvent[]> {
127
+ if (this.stopped || this.addresses.size === 0) return [];
128
+
129
+ const events: PaymentEvent[] = [];
130
+
131
+ // Poll with minconf=0 to see unconfirmed txs
132
+ const received = (await this.rpc("listreceivedbyaddress", [
133
+ 0, // minconf=0: see ALL txs including unconfirmed
134
+ false, // include_empty
135
+ true, // include_watchonly
136
+ ])) as ReceivedByAddress[];
137
+
138
+ const { priceMicros } = await this.oracle.getPrice(this.token);
139
+
140
+ for (const entry of received) {
141
+ if (!this.addresses.has(entry.address)) continue;
142
+
143
+ for (const txid of entry.txids) {
144
+ // Skip fully-processed txids
145
+ const confirmCount = await this.cursorStore.getConfirmationCount(this.watcherId, txid);
146
+
147
+ // Get transaction details for the exact amount
148
+ const tx = (await this.rpc("gettransaction", [txid, true])) as GetTransactionResponse;
149
+
150
+ const detail = tx.details.find((d) => d.address === entry.address && d.category === "receive");
151
+ if (!detail) continue;
152
+
153
+ // Check if confirmations have increased since last seen
154
+ if (confirmCount !== null && tx.confirmations <= confirmCount) continue;
155
+
156
+ // Skip if already at or past threshold on a previous poll
157
+ if (confirmCount !== null && confirmCount >= this.minConfirmations) continue;
158
+
159
+ const rawAmount = BigInt(Math.round(detail.amount * 10 ** this.decimals));
160
+ const amountUsdCents = nativeToCents(rawAmount, priceMicros, this.decimals);
161
+
162
+ events.push({
163
+ chain: this.chain,
164
+ token: this.token,
165
+ from: "", // UTXO chains don't have a single sender
166
+ to: entry.address,
167
+ rawAmount: rawAmount.toString(),
168
+ amountUsdCents,
169
+ txHash: txid,
170
+ blockNumber: 0, // UTXO chains use txid-based tracking, not block numbers
171
+ confirmations: tx.confirmations,
172
+ confirmationsRequired: this.minConfirmations,
173
+ });
174
+
175
+ // Persist confirmation count
176
+ await this.cursorStore.saveConfirmationCount(this.watcherId, txid, tx.confirmations);
177
+ }
178
+ }
179
+
180
+ return events;
181
+ }
182
+ }
183
+
184
+ /** Create a UtxoWatcher from the standard WatcherOpts interface. */
185
+ export function createUtxoWatcher(opts: WatcherOpts, rpc: RpcCall): IChainWatcher {
186
+ return new UtxoWatcher({
187
+ rpc,
188
+ token: opts.token,
189
+ chain: opts.chain,
190
+ decimals: opts.decimals,
191
+ confirmations: opts.confirmations,
192
+ oracle: opts.oracle,
193
+ cursorStore: opts.cursorStore,
194
+ });
195
+ }
@@ -0,0 +1,72 @@
1
+ import type { EncodingParams, IAddressEncoder } from "@wopr-network/platform-core/crypto-plugin";
2
+
3
+ /** Base58 alphabet used by Bitcoin/Solana. */
4
+ const ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
5
+
6
+ /**
7
+ * Encode a Uint8Array as a Base58 string (Bitcoin/Solana alphabet).
8
+ *
9
+ * This is a standalone implementation — no external dependency needed.
10
+ */
11
+ export function base58Encode(bytes: Uint8Array): string {
12
+ // Count leading zeros
13
+ let zeros = 0;
14
+ for (let i = 0; i < bytes.length && bytes[i] === 0; i++) {
15
+ zeros++;
16
+ }
17
+
18
+ // Convert to base58
19
+ // Allocate enough space in big-endian base58 representation
20
+ const size = Math.ceil((bytes.length * 138) / 100) + 1;
21
+ const b58 = new Uint8Array(size);
22
+
23
+ for (let i = zeros; i < bytes.length; i++) {
24
+ let carry = bytes[i];
25
+ for (let j = size - 1; j >= 0; j--) {
26
+ carry += 256 * b58[j];
27
+ b58[j] = carry % 58;
28
+ carry = Math.floor(carry / 58);
29
+ }
30
+ }
31
+
32
+ // Skip leading zeros in base58 result
33
+ let start = 0;
34
+ while (start < size && b58[start] === 0) {
35
+ start++;
36
+ }
37
+
38
+ // Build string: leading '1's for zero bytes + base58 digits
39
+ let result = "";
40
+ for (let i = 0; i < zeros; i++) {
41
+ result += ALPHABET[0];
42
+ }
43
+ for (let i = start; i < size; i++) {
44
+ result += ALPHABET[b58[i]];
45
+ }
46
+
47
+ return result;
48
+ }
49
+
50
+ /**
51
+ * Derive a Solana address from a raw 32-byte Ed25519 public key.
52
+ *
53
+ * Solana addresses are simply the Base58 encoding of the raw public key.
54
+ * No hashing, no version byte, no checksum.
55
+ */
56
+ function encodeSolana(pubkey: Uint8Array): string {
57
+ if (pubkey.length !== 32) {
58
+ throw new Error(`Solana address requires 32-byte Ed25519 public key, got ${pubkey.length} bytes`);
59
+ }
60
+ return base58Encode(pubkey);
61
+ }
62
+
63
+ /** Solana address encoder implementing IAddressEncoder. */
64
+ export class SolanaAddressEncoder implements IAddressEncoder {
65
+ encode(publicKey: Uint8Array, _params: EncodingParams): string {
66
+ return encodeSolana(publicKey);
67
+ }
68
+
69
+ encodingType(): string {
70
+ return "base58-solana";
71
+ }
72
+ }
@@ -0,0 +1,36 @@
1
+ import type { IChainPlugin, SweeperOpts, WatcherOpts } from "@wopr-network/platform-core/crypto-plugin";
2
+ import { SolanaAddressEncoder } from "./encoder.js";
3
+ import { SolanaSweeper } from "./sweeper.js";
4
+ import { SolanaWatcher } from "./watcher.js";
5
+
6
+ export { base58Encode, SolanaAddressEncoder } from "./encoder.js";
7
+ export { SolanaSweeper } from "./sweeper.js";
8
+ export type { SignatureInfo, SolanaRpcCall, SolanaTransaction, TokenBalance, TransactionMeta } from "./types.js";
9
+ export { createSolanaRpcCaller, SolanaWatcher } from "./watcher.js";
10
+
11
+ const encoder = new SolanaAddressEncoder();
12
+
13
+ /**
14
+ * Solana chain plugin.
15
+ *
16
+ * Supports native SOL watching and SPL token watching (e.g. USDC).
17
+ * Uses Ed25519 curve for key derivation. Addresses are Base58-encoded
18
+ * raw 32-byte public keys (no hashing, no checksum).
19
+ *
20
+ * The watcher detects incoming transfers by scanning transaction history
21
+ * for each watched address via getSignaturesForAddress + getTransaction.
22
+ */
23
+ export const solanaPlugin: IChainPlugin = {
24
+ pluginId: "solana",
25
+ supportedCurve: "ed25519",
26
+ encoders: {
27
+ "base58-solana": encoder,
28
+ },
29
+ createWatcher(opts: WatcherOpts) {
30
+ return new SolanaWatcher(opts);
31
+ },
32
+ createSweeper(opts: SweeperOpts) {
33
+ return new SolanaSweeper(opts);
34
+ },
35
+ version: 1,
36
+ };
@@ -0,0 +1,196 @@
1
+ import type {
2
+ DepositInfo,
3
+ ISweepStrategy,
4
+ KeyPair,
5
+ SweeperOpts,
6
+ SweepResult,
7
+ } from "@wopr-network/platform-core/crypto-plugin";
8
+ import type { SolanaRpcCall } from "./types.js";
9
+ import { createSolanaRpcCaller } from "./watcher.js";
10
+
11
+ /** Transaction fee estimate (in lamports). */
12
+ const TX_FEE = 5_000n;
13
+
14
+ /**
15
+ * Solana sweep strategy.
16
+ *
17
+ * Scans deposit addresses for SOL balances and SPL token balances,
18
+ * then creates transfer transactions to sweep funds to the treasury.
19
+ */
20
+ export class SolanaSweeper implements ISweepStrategy {
21
+ private readonly rpc: SolanaRpcCall;
22
+ private readonly token: string;
23
+ private readonly chain: string;
24
+ private readonly contractAddress?: string;
25
+ private readonly decimals: number;
26
+
27
+ constructor(opts: SweeperOpts) {
28
+ this.rpc = createSolanaRpcCaller(opts.rpcUrl, opts.rpcHeaders);
29
+ this.token = opts.token;
30
+ this.chain = opts.chain;
31
+ this.contractAddress = opts.contractAddress;
32
+ this.decimals = opts.decimals;
33
+ }
34
+
35
+ /**
36
+ * Scan deposit addresses for balances.
37
+ *
38
+ * For each key:
39
+ * - Check native SOL balance via getBalance
40
+ * - Check SPL token balances via getTokenAccountsByOwner
41
+ */
42
+ async scan(keys: KeyPair[], _treasury: string): Promise<DepositInfo[]> {
43
+ const results: DepositInfo[] = [];
44
+
45
+ for (const key of keys) {
46
+ const balance = (await this.rpc("getBalance", [key.address])) as { value: number };
47
+ const nativeBalance = BigInt(balance.value);
48
+
49
+ const tokenBalances: Array<{ token: string; balance: bigint; decimals: number }> = [];
50
+
51
+ if (this.contractAddress) {
52
+ const tokenAccounts = (await this.rpc("getTokenAccountsByOwner", [
53
+ key.address,
54
+ { mint: this.contractAddress },
55
+ { encoding: "jsonParsed" },
56
+ ])) as {
57
+ value: Array<{
58
+ account: {
59
+ data: {
60
+ parsed: {
61
+ info: {
62
+ tokenAmount: { amount: string; decimals: number };
63
+ mint: string;
64
+ };
65
+ };
66
+ };
67
+ };
68
+ }>;
69
+ };
70
+
71
+ for (const ta of tokenAccounts.value) {
72
+ const info = ta.account.data.parsed.info;
73
+ const bal = BigInt(info.tokenAmount.amount);
74
+ if (bal > 0n) {
75
+ tokenBalances.push({
76
+ token: info.mint,
77
+ balance: bal,
78
+ decimals: info.tokenAmount.decimals,
79
+ });
80
+ }
81
+ }
82
+ }
83
+
84
+ if (nativeBalance > 0n || tokenBalances.length > 0) {
85
+ results.push({
86
+ index: key.index,
87
+ address: key.address,
88
+ nativeBalance,
89
+ tokenBalances,
90
+ });
91
+ }
92
+ }
93
+
94
+ return results;
95
+ }
96
+
97
+ /**
98
+ * Sweep funds from deposit addresses to treasury.
99
+ *
100
+ * For native SOL: transfers balance minus fee.
101
+ * For SPL tokens: transfers full token balance using token transfer instruction.
102
+ *
103
+ * In dry-run mode, returns what would be swept without broadcasting.
104
+ */
105
+ async sweep(keys: KeyPair[], treasury: string, dryRun: boolean): Promise<SweepResult[]> {
106
+ const deposits = await this.scan(keys, treasury);
107
+ const results: SweepResult[] = [];
108
+
109
+ for (const deposit of deposits) {
110
+ const key = keys.find((k) => k.index === deposit.index);
111
+ if (!key) continue;
112
+
113
+ // Sweep SPL tokens first
114
+ for (const tb of deposit.tokenBalances) {
115
+ if (dryRun) {
116
+ results.push({
117
+ index: deposit.index,
118
+ address: deposit.address,
119
+ token: tb.token,
120
+ amount: tb.balance.toString(),
121
+ txHash: "dry-run",
122
+ });
123
+ continue;
124
+ }
125
+
126
+ // In production, this would build and sign an SPL token transfer transaction
127
+ // using @solana/web3.js. For now, placeholder for the transaction submission.
128
+ const txHash = await this.submitSplTransfer(key, treasury, tb.token, tb.balance);
129
+ results.push({
130
+ index: deposit.index,
131
+ address: deposit.address,
132
+ token: tb.token,
133
+ amount: tb.balance.toString(),
134
+ txHash,
135
+ });
136
+ }
137
+
138
+ // Sweep native SOL (leave enough for rent + fee if token accounts exist)
139
+ const sweepableNative = deposit.nativeBalance - TX_FEE;
140
+ if (sweepableNative > 0n) {
141
+ if (dryRun) {
142
+ results.push({
143
+ index: deposit.index,
144
+ address: deposit.address,
145
+ token: "SOL",
146
+ amount: sweepableNative.toString(),
147
+ txHash: "dry-run",
148
+ });
149
+ continue;
150
+ }
151
+
152
+ const txHash = await this.submitSolTransfer(key, treasury, sweepableNative);
153
+ results.push({
154
+ index: deposit.index,
155
+ address: deposit.address,
156
+ token: "SOL",
157
+ amount: sweepableNative.toString(),
158
+ txHash,
159
+ });
160
+ }
161
+ }
162
+
163
+ return results;
164
+ }
165
+
166
+ /**
167
+ * Submit a native SOL transfer.
168
+ *
169
+ * Builds a SystemProgram.transfer instruction, signs with the deposit keypair,
170
+ * and submits via sendTransaction.
171
+ */
172
+ private async submitSolTransfer(key: KeyPair, treasury: string, lamports: bigint): Promise<string> {
173
+ // Get recent blockhash
174
+ const blockhashResult = (await this.rpc("getLatestBlockhash", [{ commitment: "finalized" }])) as {
175
+ value: { blockhash: string; lastValidBlockHeight: number };
176
+ };
177
+
178
+ // In production, build + sign the transaction using @solana/web3.js or manual serialization.
179
+ // For Phase 1, we use a simplified approach:
180
+ throw new Error(
181
+ `SOL transfer not yet implemented — would send ${lamports} lamports from ${key.address} to ${treasury} with blockhash ${blockhashResult.value.blockhash}`,
182
+ );
183
+ }
184
+
185
+ /**
186
+ * Submit an SPL token transfer.
187
+ *
188
+ * Builds a TokenProgram.transfer instruction for the given mint,
189
+ * signs with the deposit keypair, and submits via sendTransaction.
190
+ */
191
+ private async submitSplTransfer(key: KeyPair, treasury: string, mint: string, amount: bigint): Promise<string> {
192
+ throw new Error(
193
+ `SPL transfer not yet implemented — would send ${amount} of ${mint} from ${key.address} to ${treasury}`,
194
+ );
195
+ }
196
+ }
@@ -0,0 +1,52 @@
1
+ /** JSON-RPC call function signature. */
2
+ export type SolanaRpcCall = (method: string, params: unknown[]) => Promise<unknown>;
3
+
4
+ /** Solana signature info from getSignaturesForAddress. */
5
+ export interface SignatureInfo {
6
+ signature: string;
7
+ slot: number;
8
+ err: unknown | null;
9
+ memo: string | null;
10
+ blockTime: number | null;
11
+ confirmationStatus: "processed" | "confirmed" | "finalized" | null;
12
+ }
13
+
14
+ /** Solana transaction metadata. */
15
+ export interface TransactionMeta {
16
+ err: unknown | null;
17
+ fee: number;
18
+ preBalances: number[];
19
+ postBalances: number[];
20
+ preTokenBalances?: TokenBalance[];
21
+ postTokenBalances?: TokenBalance[];
22
+ }
23
+
24
+ /** SPL token balance entry in transaction metadata. */
25
+ export interface TokenBalance {
26
+ accountIndex: number;
27
+ mint: string;
28
+ uiTokenAmount: {
29
+ amount: string;
30
+ decimals: number;
31
+ uiAmountString: string;
32
+ };
33
+ owner?: string;
34
+ }
35
+
36
+ /** Parsed Solana transaction from getTransaction. */
37
+ export interface SolanaTransaction {
38
+ slot: number;
39
+ blockTime: number | null;
40
+ meta: TransactionMeta | null;
41
+ transaction: {
42
+ message: {
43
+ accountKeys: string[];
44
+ instructions: Array<{
45
+ programIdIndex: number;
46
+ accounts: number[];
47
+ data: string;
48
+ }>;
49
+ };
50
+ signatures: string[];
51
+ };
52
+ }