agentic-x402 0.2.33 → 0.3.0

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.
@@ -0,0 +1,235 @@
1
+ // Background Payment Watcher
2
+ // Polls USDC balanceOf for tracked routers and detects balance increases
3
+
4
+ import type {
5
+ PluginLogger,
6
+ PluginService,
7
+ TrackedRouter,
8
+ PaymentEvent,
9
+ WatcherStatus,
10
+ } from './types.js';
11
+
12
+ const ERC20_BALANCE_ABI = [{
13
+ name: 'balanceOf',
14
+ type: 'function',
15
+ stateMutability: 'view',
16
+ inputs: [{ name: 'account', type: 'address' }],
17
+ outputs: [{ name: '', type: 'uint256' }],
18
+ }] as const;
19
+
20
+ interface WatcherOptions {
21
+ logger: PluginLogger;
22
+ gatewayPort: number;
23
+ pollIntervalMs?: number;
24
+ notifyOnPayment?: boolean;
25
+ }
26
+
27
+ export class PaymentWatcher implements PluginService {
28
+ readonly id = 'x402-payment-watcher';
29
+
30
+ private logger: PluginLogger;
31
+ private gatewayPort: number;
32
+ private pollIntervalMs: number;
33
+ private notifyOnPayment: boolean;
34
+
35
+ private trackedRouters: Map<string, TrackedRouter> = new Map();
36
+ private paymentsDetected = 0;
37
+ private lastPollAt: Date | null = null;
38
+ private timer: ReturnType<typeof setInterval> | null = null;
39
+ private isPolling = false;
40
+ private seeded = false;
41
+
42
+ constructor(options: WatcherOptions) {
43
+ this.logger = options.logger;
44
+ this.gatewayPort = options.gatewayPort;
45
+ this.pollIntervalMs = options.pollIntervalMs ?? 30_000;
46
+ this.notifyOnPayment = options.notifyOnPayment ?? true;
47
+ }
48
+
49
+ async start(): Promise<void> {
50
+ this.logger.info(`Starting payment watcher (poll every ${this.pollIntervalMs / 1000}s)`);
51
+
52
+ // Initial poll to seed state
53
+ await this.poll();
54
+ this.seeded = true;
55
+
56
+ // Start periodic polling
57
+ this.timer = setInterval(() => this.poll(), this.pollIntervalMs);
58
+ }
59
+
60
+ async stop(): Promise<void> {
61
+ if (this.timer) {
62
+ clearInterval(this.timer);
63
+ this.timer = null;
64
+ }
65
+ this.logger.info('Payment watcher stopped');
66
+ }
67
+
68
+ getStatus(): WatcherStatus {
69
+ const routers: WatcherStatus['trackedRouters'] = [];
70
+ for (const r of this.trackedRouters.values()) {
71
+ routers.push({
72
+ address: r.address,
73
+ name: r.name,
74
+ balance: formatUnitsLocal(r.lastBalance, 6),
75
+ lastChecked: new Date(r.lastChecked).toISOString(),
76
+ });
77
+ }
78
+
79
+ return {
80
+ running: this.timer !== null,
81
+ pollIntervalMs: this.pollIntervalMs,
82
+ trackedRouters: routers,
83
+ paymentsDetected: this.paymentsDetected,
84
+ lastPollAt: this.lastPollAt?.toISOString() ?? null,
85
+ };
86
+ }
87
+
88
+ private async poll(): Promise<void> {
89
+ if (this.isPolling) return;
90
+ this.isPolling = true;
91
+
92
+ try {
93
+ await this.refreshRouters();
94
+ await this.checkBalances();
95
+ this.lastPollAt = new Date();
96
+ } catch (err) {
97
+ this.logger.error(`Watcher poll error: ${err instanceof Error ? err.message : String(err)}`);
98
+ } finally {
99
+ this.isPolling = false;
100
+ }
101
+ }
102
+
103
+ private async refreshRouters(): Promise<void> {
104
+ // Dynamic import to avoid loading viem at registration time
105
+ const { getWalletAddress } = await import('../core/client.js');
106
+ const { getConfig } = await import('../core/config.js');
107
+
108
+ const config = getConfig();
109
+ const address = getWalletAddress();
110
+ const apiUrl = `${config.x402LinksApiUrl}/api/links/beneficiary/${address}`;
111
+
112
+ const response = await fetch(apiUrl);
113
+ if (!response.ok) {
114
+ this.logger.warn(`Failed to fetch routers: ${response.status}`);
115
+ return;
116
+ }
117
+
118
+ const data = await response.json() as {
119
+ success: boolean;
120
+ links?: Array<{
121
+ router_address: string;
122
+ metadata?: { name?: string };
123
+ }>;
124
+ };
125
+
126
+ if (!data.success || !data.links) return;
127
+
128
+ // Add any new routers we haven't seen yet
129
+ for (const link of data.links) {
130
+ const addr = link.router_address.toLowerCase();
131
+ if (!this.trackedRouters.has(addr)) {
132
+ this.trackedRouters.set(addr, {
133
+ address: link.router_address,
134
+ name: link.metadata?.name ?? 'Unnamed',
135
+ lastBalance: 0n,
136
+ lastChecked: 0,
137
+ });
138
+ this.logger.info(`Tracking router: ${link.metadata?.name ?? 'Unnamed'} (${link.router_address})`);
139
+ }
140
+ }
141
+ }
142
+
143
+ private async checkBalances(): Promise<void> {
144
+ if (this.trackedRouters.size === 0) return;
145
+
146
+ const { getClient } = await import('../core/client.js');
147
+ const { getConfig, getUsdcAddress } = await import('../core/config.js');
148
+
149
+ const config = getConfig();
150
+ const client = getClient();
151
+ const usdcAddress = getUsdcAddress(config.chainId);
152
+
153
+ for (const [key, router] of this.trackedRouters) {
154
+ try {
155
+ const balance = await client.publicClient.readContract({
156
+ address: usdcAddress,
157
+ abi: ERC20_BALANCE_ABI,
158
+ functionName: 'balanceOf',
159
+ args: [router.address as `0x${string}`],
160
+ });
161
+
162
+ const previous = router.lastBalance;
163
+ router.lastBalance = balance;
164
+ router.lastChecked = Date.now();
165
+
166
+ // Skip notifications on first poll (seeding state)
167
+ if (!this.seeded) continue;
168
+
169
+ if (balance > previous) {
170
+ const increase = balance - previous;
171
+ this.paymentsDetected++;
172
+
173
+ const event: PaymentEvent = {
174
+ routerAddress: router.address,
175
+ routerName: router.name,
176
+ previousBalance: formatUnitsLocal(previous, 6),
177
+ newBalance: formatUnitsLocal(balance, 6),
178
+ increase: formatUnitsLocal(increase, 6),
179
+ detectedAt: new Date().toISOString(),
180
+ };
181
+
182
+ this.logger.info(
183
+ `Payment detected on ${router.name}: +${event.increase} USDC (${event.previousBalance} → ${event.newBalance})`
184
+ );
185
+
186
+ if (this.notifyOnPayment) {
187
+ await this.sendHook(event);
188
+ }
189
+ } else if (balance < previous) {
190
+ this.logger.debug(
191
+ `Distribution from ${router.name}: ${formatUnitsLocal(previous, 6)} → ${formatUnitsLocal(balance, 6)} USDC`
192
+ );
193
+ }
194
+ } catch (err) {
195
+ this.logger.warn(
196
+ `Failed to check balance for ${router.name} (${router.address}): ${err instanceof Error ? err.message : String(err)}`
197
+ );
198
+ }
199
+ }
200
+ }
201
+
202
+ private async sendHook(event: PaymentEvent): Promise<void> {
203
+ const hookUrl = `http://127.0.0.1:${this.gatewayPort}/hooks/agent`;
204
+
205
+ try {
206
+ const response = await fetch(hookUrl, {
207
+ method: 'POST',
208
+ headers: { 'Content-Type': 'application/json' },
209
+ body: JSON.stringify({
210
+ name: 'x402-payment',
211
+ wakeMode: 'now',
212
+ data: event,
213
+ }),
214
+ });
215
+
216
+ if (!response.ok) {
217
+ this.logger.warn(`Hook POST failed: ${response.status} ${response.statusText}`);
218
+ } else {
219
+ this.logger.debug(`Hook delivered for payment on ${event.routerName}`);
220
+ }
221
+ } catch (err) {
222
+ this.logger.warn(`Hook POST error: ${err instanceof Error ? err.message : String(err)}`);
223
+ }
224
+ }
225
+ }
226
+
227
+ /** Format bigint with decimals (avoids importing viem just for this) */
228
+ function formatUnitsLocal(value: bigint, decimals: number): string {
229
+ const str = value.toString().padStart(decimals + 1, '0');
230
+ const intPart = str.slice(0, str.length - decimals) || '0';
231
+ const fracPart = str.slice(str.length - decimals);
232
+ // Trim trailing zeros but keep at least 2 decimal places
233
+ const trimmed = fracPart.replace(/0+$/, '').padEnd(2, '0');
234
+ return `${intPart}.${trimmed}`;
235
+ }