@paynodelabs/paynode-402-cli 2.5.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.
package/utils.ts ADDED
@@ -0,0 +1,400 @@
1
+ import { ethers } from '@paynodelabs/sdk-js';
2
+ import { tmpdir } from 'os';
3
+ import { join, dirname } from 'path';
4
+ import fs from 'fs';
5
+ import { fileURLToPath } from 'url';
6
+ import pkg from './package.json';
7
+
8
+ // --- Environment (System Only) ---
9
+ // [SECURITY] This skill strictly uses system environment variables for better update persistence
10
+ // and to avoid plaintext private keys on disk. .env files are no longer supported.
11
+ if (!process.env.CLIENT_PRIVATE_KEY) {
12
+ // We don't exit here because some commands like 'check' or 'mint' provide their own helpful setup tips.
13
+ // getPrivateKey() will handle the final enforcement.
14
+ }
15
+
16
+ /**
17
+ * Centralized Configuration Loader
18
+ * [SECURITY] Consolidates environment variable access for better auditing.
19
+ */
20
+ export const GLOBAL_CONFIG = {
21
+ MARKETPLACE_URL: process.env.PAYNODE_MARKET_URL || 'https://mk.paynode.dev',
22
+ PRIVATE_KEY: process.env.CLIENT_PRIVATE_KEY,
23
+ CUSTOM_ROUTER: process.env.CUSTOM_ROUTER_ADDRESS,
24
+ CUSTOM_USDC: process.env.CUSTOM_USDC_ADDRESS,
25
+ RPC_URL_OVERRIDE: process.env.PAYNODE_RPC_URL || process.env.RPC_URL,
26
+ RPC_TIMEOUT: Number(process.env.PAYNODE_RPC_TIMEOUT) || 15_000
27
+ };
28
+
29
+ /**
30
+ * Skill version for JSON output metadata.
31
+ */
32
+ import sdkPkg from '@paynodelabs/sdk-js/package.json';
33
+ /**
34
+ * Skill version and runtime SDK version.
35
+ */
36
+ export const SKILL_VERSION = pkg.version;
37
+ export const SDK_VERSION = sdkPkg.version; // Dynamically resolved from installed package
38
+
39
+ /**
40
+ * Shared base options for all CLI commands.
41
+ */
42
+ export interface BaseCliOptions {
43
+ json?: boolean;
44
+ network?: string;
45
+ rpc?: string;
46
+ rpcTimeout?: number;
47
+ confirmMainnet?: boolean;
48
+ dryRun?: boolean;
49
+ marketUrl?: string;
50
+ }
51
+
52
+
53
+ /**
54
+ * Network configuration object.
55
+ */
56
+ export interface NetworkConfig {
57
+ provider: ethers.JsonRpcProvider;
58
+ chainId: number;
59
+ isSandbox: boolean;
60
+ rpcUrl: string;
61
+ rpcUrls: string[];
62
+ usdcAddress: string;
63
+ routerAddress: string;
64
+ networkName: string;
65
+ }
66
+
67
+ /**
68
+ * CLI config from parsed arguments (CAC managed, but kept here for type reference).
69
+ */
70
+ export interface CliConfig {
71
+ isJson: boolean;
72
+ isHelp: boolean;
73
+ isDryRun: boolean;
74
+ confirmMainnet: boolean;
75
+ background: boolean;
76
+ output?: string;
77
+ maxAge?: number;
78
+ taskDir?: string;
79
+ taskId?: string;
80
+ rpcUrl?: string;
81
+ network?: string;
82
+ marketUrl?: string;
83
+ method?: string;
84
+ data?: string;
85
+ headers?: Record<string, string>;
86
+ params: string[];
87
+ }
88
+
89
+ /**
90
+ * Standardized Exit Codes
91
+ */
92
+ export const EXIT_CODES = {
93
+ SUCCESS: 0,
94
+ GENERIC_ERROR: 1,
95
+ INVALID_ARGS: 2,
96
+ AUTH_FAILURE: 3,
97
+ NETWORK_ERROR: 4,
98
+ MAINNET_REJECTED: 5,
99
+ PAYMENT_FAILED: 6,
100
+ INSUFFICIENT_FUNDS: 7,
101
+ DUST_LIMIT: 8,
102
+ RPC_TIMEOUT: 9,
103
+ DUPLICATE_TRANSACTION: 10,
104
+ WRONG_CONTRACT: 11,
105
+ ORDER_MISMATCH: 12,
106
+ MISSING_RECEIPT: 13,
107
+ INTERNAL_ERROR: 14
108
+ } as const;
109
+
110
+ export const DEFAULT_TIMEOUT_MS = 15_000;
111
+ const MAX_RETRIES = 3;
112
+
113
+ /**
114
+ * Executes an async operation with exponential backoff retry.
115
+ */
116
+ export async function withRetry<T>(
117
+ fn: () => Promise<T>,
118
+ label: string,
119
+ maxRetries = MAX_RETRIES
120
+ ): Promise<T> {
121
+ let lastError: Error | null = null;
122
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
123
+ try {
124
+ return await fn();
125
+ } catch (error: any) {
126
+ lastError = error;
127
+ if (!isTransientError(error) || attempt >= maxRetries - 1) throw error;
128
+ const backoffMs = Math.pow(2, attempt) * 1000 * (0.5 + Math.random());
129
+ console.error(`⚠️ [${label}] ${error.message}. Retry #${attempt + 1} (of ${maxRetries - 1}) in ${Math.round(backoffMs)}ms...`);
130
+ await new Promise(resolve => setTimeout(resolve, backoffMs));
131
+ }
132
+ }
133
+ throw lastError || new Error(`${label} failed after ${maxRetries} retries`);
134
+ }
135
+
136
+ function isTransientError(error: any): boolean {
137
+ const msg = (error?.message || '').toLowerCase();
138
+ const code = error?.code || '';
139
+
140
+ // --- Error Unwrap ---
141
+ // Extract the deepest cause if it's an RpcError wrapping another error
142
+ const details = error?.details;
143
+ const detailMsg = details
144
+ ? (details.message || (typeof details === 'string' ? details : JSON.stringify(details))).toLowerCase()
145
+ : '';
146
+
147
+ // Never retry if it's a known non-transient failure
148
+ const isNonRetryableCode = [
149
+ 'CALL_EXCEPTION',
150
+ 'INVALID_ARGUMENT',
151
+ 'UNSUPPORTED_OPERATION',
152
+ 'ACTION_REJECTED',
153
+ 'INSUFFICIENT_FUNDS'
154
+ ].includes(code);
155
+
156
+ if (
157
+ isNonRetryableCode ||
158
+ msg.includes('insufficient funds') ||
159
+ msg.includes('execution reverted') ||
160
+ detailMsg.includes('insufficient funds') ||
161
+ detailMsg.includes('execution reverted')
162
+ ) {
163
+ return false;
164
+ }
165
+
166
+ const isRetryableCode = [
167
+ 'NETWORK_ERROR',
168
+ 'SERVER_ERROR',
169
+ 'TIMEOUT',
170
+ 'UNKNOWN_ERROR',
171
+ 'rpc_error'
172
+ ].includes(code);
173
+
174
+ return (
175
+ isRetryableCode ||
176
+ msg.includes('timeout') ||
177
+ msg.includes('network') ||
178
+ msg.includes('fetch failed') ||
179
+ msg.includes('econnrefused') ||
180
+ msg.includes('econnreset') ||
181
+ msg.includes('socket hang up') ||
182
+ detailMsg.includes('timeout') ||
183
+ detailMsg.includes('network')
184
+ );
185
+ }
186
+
187
+ export const DEFAULT_TASK_DIR = process.env.PAYNODE_TASK_DIR || join(tmpdir(), 'paynode-tasks');
188
+ export const DEFAULT_MAX_AGE_SECONDS = Number(process.env.PAYNODE_MAX_AGE) || 3600;
189
+
190
+ export function generateTaskId(): string {
191
+ const ts = Date.now().toString(36);
192
+ const rand = Math.random().toString(36).substring(2, 6);
193
+ return `${ts}-${rand}`;
194
+ }
195
+
196
+ export function maskAddress(address: string): string {
197
+ if (!address || address.length < 10) return address;
198
+ return `${address.substring(0, 6)}...${address.substring(address.length - 4)}`;
199
+ }
200
+
201
+ export function isInlineContent(contentType: string): boolean {
202
+ const ct = (contentType || '').split(';')[0].trim().toLowerCase();
203
+ return (
204
+ ct.startsWith('text/') ||
205
+ ct === 'application/json' ||
206
+ ct === 'application/javascript' ||
207
+ ct === 'application/xml' ||
208
+ ct === 'application/x-www-form-urlencoded'
209
+ );
210
+ }
211
+
212
+ export function cleanupOldTasks(taskDir: string, maxAgeSeconds: number): number {
213
+ try {
214
+ if (!fs.existsSync(taskDir)) return 0;
215
+ const now = Date.now();
216
+ const cutoff = now - maxAgeSeconds * 1000;
217
+ let cleaned = 0;
218
+ for (const file of fs.readdirSync(taskDir)) {
219
+ if (file.startsWith('.')) continue;
220
+ const fullPath = join(taskDir, file);
221
+ try {
222
+ const stat = fs.statSync(fullPath);
223
+ // mtimeMs can be updated by reads (depending on mount options),
224
+ // birthtimeMs is creation. Use the minimum or birthtime for safe cleanup.
225
+ const effectiveTime = Math.min(stat.mtimeMs, stat.birthtimeMs || stat.mtimeMs);
226
+ if (effectiveTime < cutoff) {
227
+ fs.unlinkSync(fullPath);
228
+ cleaned++;
229
+ }
230
+ } catch { /* skip */ }
231
+ }
232
+ return cleaned;
233
+ } catch { return 0; }
234
+ }
235
+
236
+
237
+ /**
238
+ * Validates existence and format of CLIENT_PRIVATE_KEY.
239
+ */
240
+ export function getPrivateKey(isJson: boolean): string {
241
+ const pk: string | undefined = GLOBAL_CONFIG.PRIVATE_KEY;
242
+ if (!pk || typeof pk !== 'string') {
243
+ reportError('CLIENT_PRIVATE_KEY not found in environment. Please set it as a system environment variable.', isJson, EXIT_CODES.AUTH_FAILURE);
244
+ }
245
+ const pkRegex = /^0x[0-9a-fA-F]{64}$/;
246
+ if (!pkRegex.test(pk)) {
247
+ reportError('Invalid CLIENT_PRIVATE_KEY format. Must be 0x-prefixed 64-hex chars.', isJson, EXIT_CODES.AUTH_FAILURE);
248
+ }
249
+ return pk;
250
+ }
251
+
252
+ /**
253
+ * Validates mainnet access.
254
+ */
255
+ export function requireMainnetConfirmation(isSandbox: boolean, confirmMainnet: boolean, isJson: boolean): void {
256
+ if (isSandbox) return;
257
+ if (!confirmMainnet) {
258
+ reportError(
259
+ 'Mainnet operation requires --confirm-mainnet flag (real USDC).',
260
+ isJson,
261
+ EXIT_CODES.MAINNET_REJECTED
262
+ );
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Resolves network configuration with multi-RPC failover.
268
+ */
269
+ export async function resolveNetwork(providedRpcUrl?: string, network?: string, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<NetworkConfig> {
270
+ const {
271
+ PAYNODE_ROUTER_ADDRESS,
272
+ PAYNODE_ROUTER_ADDRESS_SANDBOX,
273
+ BASE_USDC_ADDRESS,
274
+ BASE_USDC_ADDRESS_SANDBOX,
275
+ BASE_RPC_URLS,
276
+ BASE_RPC_URLS_SANDBOX
277
+ } = await import('@paynodelabs/sdk-js');
278
+
279
+ const networkAlias = (network || '').toLowerCase();
280
+ const isTestnetRequest =
281
+ networkAlias === 'testnet' ||
282
+ networkAlias === 'sepolia' ||
283
+ networkAlias === 'base-sepolia' ||
284
+ networkAlias === '84532' ||
285
+ networkAlias === 'base-testnet';
286
+
287
+ const effectiveRpcUrl = providedRpcUrl || GLOBAL_CONFIG.RPC_URL_OVERRIDE;
288
+ const sdkRpcUrls = (isTestnetRequest ? (BASE_RPC_URLS_SANDBOX || []) : (BASE_RPC_URLS || []));
289
+ const rpcUrls: string[] = effectiveRpcUrl ? [effectiveRpcUrl] : sdkRpcUrls;
290
+ let lastError: Error | null = null;
291
+ let provider: ethers.JsonRpcProvider | null = null;
292
+ let chainId: bigint | null = null;
293
+ let activeRpcUrl: string | null = null;
294
+
295
+ for (const url of rpcUrls) {
296
+ try {
297
+ const tempProvider = new ethers.JsonRpcProvider(url, undefined, { staticNetwork: true, batchMaxCount: 1 });
298
+ const networkInfo = await Promise.race([
299
+ tempProvider.getNetwork(),
300
+ new Promise<never>((_, reject) => setTimeout(() => reject(new Error('RPC timeout')), timeoutMs))
301
+ ]);
302
+ provider = tempProvider;
303
+ chainId = networkInfo.chainId;
304
+ activeRpcUrl = url;
305
+ break;
306
+ } catch (error: any) {
307
+ lastError = error;
308
+ if (rpcUrls.length > 1) console.error(`⚠️ [resolveNetwork] RPC ${url} failed: ${error.message}.`);
309
+ }
310
+ }
311
+
312
+ if (!provider || !chainId || !activeRpcUrl) {
313
+ throw new Error(`Failed to connect to any RPC in [${rpcUrls.join(', ')}]: ${lastError?.message}`);
314
+ }
315
+
316
+ const isSandbox = chainId === 84532n;
317
+ const networkName = isSandbox ? 'Base Sepolia (84532)' : 'Base L2 (8453)';
318
+ const customRouter = GLOBAL_CONFIG.CUSTOM_ROUTER;
319
+ const customUsdc = GLOBAL_CONFIG.CUSTOM_USDC;
320
+
321
+ return {
322
+ provider,
323
+ chainId: Number(chainId),
324
+ isSandbox,
325
+ rpcUrl: activeRpcUrl,
326
+ rpcUrls,
327
+ usdcAddress: customUsdc || (isSandbox ? BASE_USDC_ADDRESS_SANDBOX : BASE_USDC_ADDRESS),
328
+ routerAddress: customRouter || (isSandbox ? PAYNODE_ROUTER_ADDRESS_SANDBOX : PAYNODE_ROUTER_ADDRESS),
329
+ networkName
330
+ };
331
+ }
332
+
333
+ export function jsonEnvelope(data: Record<string, any>): string {
334
+ return JSON.stringify({
335
+ version: SKILL_VERSION,
336
+ skill_version: SKILL_VERSION,
337
+ sdk_version: SDK_VERSION,
338
+ ...data
339
+ }, null, 2);
340
+ }
341
+
342
+ export function reportError(err: string | Error | any, isJson: boolean, defaultCode: number = EXIT_CODES.GENERIC_ERROR): never {
343
+ let message = typeof err === 'string' ? err : (err?.message || 'An unknown error occurred');
344
+ let exitCode = defaultCode;
345
+ let errorCode: string | undefined;
346
+
347
+ const isPayNodeException = err?.name === 'PayNodeException' ||
348
+ (err?.code && typeof err.code === 'string' && (
349
+ err.code.startsWith('paynode_') ||
350
+ err.code.startsWith('x402_') ||
351
+ (err.code === 'rpc_error' && err?.message?.toLowerCase().includes('paynode'))
352
+ ));
353
+ if (isPayNodeException) {
354
+ errorCode = err.code;
355
+
356
+ // --- Defensive Unwrap ---
357
+ // If SDK masks a specific blockchain error as a generic 'rpc_error', try to recover it from details.
358
+ if (errorCode === 'rpc_error' && err.details) {
359
+ const detailMsg = (err.details.message || JSON.stringify(err.details)).toLowerCase();
360
+ if (detailMsg.includes('insufficient funds') || detailMsg.includes('execution reverted')) {
361
+ errorCode = 'insufficient_funds';
362
+ message = 'Insufficient funds for transaction gas or payment. Please verify ETH/USDC balances.';
363
+ } else if (detailMsg.includes('user rejected')) {
364
+ errorCode = 'transaction_failed';
365
+ message = 'Transaction was rejected by the wallet.';
366
+ }
367
+ }
368
+
369
+ switch (errorCode) {
370
+ case 'insufficient_funds': exitCode = EXIT_CODES.INSUFFICIENT_FUNDS; break;
371
+ case 'amount_too_low': exitCode = EXIT_CODES.DUST_LIMIT; break;
372
+ case 'rpc_error': exitCode = EXIT_CODES.RPC_TIMEOUT; break;
373
+ case 'transaction_failed': exitCode = EXIT_CODES.PAYMENT_FAILED; break;
374
+ case 'token_not_accepted': exitCode = EXIT_CODES.INVALID_ARGS; break;
375
+ case 'invalid_receipt': exitCode = EXIT_CODES.PAYMENT_FAILED; break;
376
+ case 'wrong_contract': exitCode = EXIT_CODES.WRONG_CONTRACT; break;
377
+ case 'order_mismatch': exitCode = EXIT_CODES.ORDER_MISMATCH; break;
378
+ case 'duplicate_transaction': exitCode = EXIT_CODES.DUPLICATE_TRANSACTION; break;
379
+ case 'missing_receipt': exitCode = EXIT_CODES.MISSING_RECEIPT; break;
380
+ case 'transaction_not_found': exitCode = EXIT_CODES.NETWORK_ERROR; break;
381
+ case 'internal_error': exitCode = EXIT_CODES.INTERNAL_ERROR; break;
382
+ default: exitCode = defaultCode;
383
+ }
384
+ }
385
+
386
+ if (isJson) {
387
+ console.log(jsonEnvelope({ status: 'error', message, exitCode, errorCode, details: err?.details }));
388
+ } else {
389
+ const prefix = isPayNodeException ? `🛑 [PayNode-${errorCode}]` : `❌ ERROR:`;
390
+ console.error(`${prefix} ${message} (Code: ${exitCode})`);
391
+ if (errorCode === 'insufficient_funds') {
392
+ console.error(`💡 Tip: Use 'bun run paynode-402 check' to verify ETH/USDC balances.`);
393
+ console.error(`💡 Faucet (Testnet): [console.optimism.io/faucet](https://console.optimism.io/faucet)`);
394
+ } else if (errorCode === 'amount_too_low') {
395
+ const min = err?.details?.minimum || 1000;
396
+ console.error(`💡 Tip: Minimum requirement is ${min} units.`);
397
+ }
398
+ }
399
+ process.exit(exitCode);
400
+ }