From 5bed17eb75239deb37bd3db776c6c11b99cf095d Mon Sep 17 00:00:00 2001 From: zq0951 Date: Thu, 2 Apr 2026 16:23:08 +0800 Subject: [PATCH 1/8] feat: migrate and adapt 402 challenge logic from skill to standalone CLI --- .gitignore | 6 + bun.lock | 67 ++++++ commands/check.ts | 126 ++++++++++++ commands/get-api-detail.ts | 30 +++ commands/invoke-paid-api.ts | 91 +++++++++ commands/list-paid-apis.ts | 58 ++++++ commands/mint.ts | 97 +++++++++ commands/request.ts | 395 +++++++++++++++++++++++++++++++++++ index.ts | 105 ++++++++++ marketplace/client.ts | 189 +++++++++++++++++ marketplace/types.ts | 37 ++++ package.json | 32 +++ utils.ts | 397 ++++++++++++++++++++++++++++++++++++ 13 files changed, 1630 insertions(+) create mode 100644 .gitignore create mode 100644 bun.lock create mode 100644 commands/check.ts create mode 100644 commands/get-api-detail.ts create mode 100644 commands/invoke-paid-api.ts create mode 100644 commands/list-paid-apis.ts create mode 100644 commands/mint.ts create mode 100644 commands/request.ts create mode 100644 index.ts create mode 100644 marketplace/client.ts create mode 100644 marketplace/types.ts create mode 100644 package.json create mode 100755 utils.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eafe40d --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +*.lockb +bun.lock +.env +dist/ +/tmp/ diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..a3b59d3 --- /dev/null +++ b/bun.lock @@ -0,0 +1,67 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@paynodelabs/paynode-402-cli", + "dependencies": { + "@paynodelabs/sdk-js": "^2.2.3", + "cac": "7.0.0", + }, + "devDependencies": { + "bun-types": "^1.0.0", + }, + }, + }, + "packages": { + "@adraffy/ens-normalize": ["@adraffy/ens-normalize@1.10.1", "", {}, "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw=="], + + "@ioredis/commands": ["@ioredis/commands@1.5.1", "", {}, "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw=="], + + "@noble/curves": ["@noble/curves@1.2.0", "", { "dependencies": { "@noble/hashes": "1.3.2" } }, "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw=="], + + "@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="], + + "@paynodelabs/sdk-js": ["@paynodelabs/sdk-js@2.3.0", "", { "dependencies": { "ethers": "^6.16.0", "ioredis": "^5.10.1" }, "peerDependencies": { "express": ">=4.0.0" }, "optionalPeers": ["express"] }, "sha512-n7JPnLAhjS+0XfX6vK5x31JaIRy10OVGrDGF0CF9brxowy1Bpnnhs1rH6upcxn9XaIWUTekLO3+zeXHhdOdU4g=="], + + "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], + + "aes-js": ["aes-js@4.0.0-beta.5", "", {}, "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q=="], + + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + + "cac": ["cac@7.0.0", "", {}, "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ=="], + + "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], + + "ethers": ["ethers@6.16.0", "", { "dependencies": { "@adraffy/ens-normalize": "1.10.1", "@noble/curves": "1.2.0", "@noble/hashes": "1.3.2", "@types/node": "22.7.5", "aes-js": "4.0.0-beta.5", "tslib": "2.7.0", "ws": "8.17.1" } }, "sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A=="], + + "ioredis": ["ioredis@5.10.1", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA=="], + + "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="], + + "lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="], + + "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="], + + "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], + + "tslib": ["tslib@2.7.0", "", {}, "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="], + + "ethers/@types/node": ["@types/node@22.7.5", "", { "dependencies": { "undici-types": "~6.19.2" } }, "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ=="], + + "ethers/@types/node/undici-types": ["undici-types@6.19.8", "", {}, "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="], + } +} diff --git a/commands/check.ts b/commands/check.ts new file mode 100644 index 0000000..311375a --- /dev/null +++ b/commands/check.ts @@ -0,0 +1,126 @@ +import { ethers } from '@paynodelabs/sdk-js'; +import { + getPrivateKey, + resolveNetwork, + requireMainnetConfirmation, + reportError, + jsonEnvelope, + withRetry, + EXIT_CODES, + BaseCliOptions +} from '../utils.ts'; + +interface CheckOptions extends BaseCliOptions { +} + +// Minimum gas threshold: 0.001 ETH (in wei) +const MIN_GAS_WEI = ethers.parseEther('0.001'); + +export async function checkAction(options: CheckOptions) { + const isJson = !!options.json; + const pk = getPrivateKey(isJson); + + try { + const { provider, usdcAddress, chainId, networkName, isSandbox } = await resolveNetwork( + options.rpc, + options.network, + options.rpcTimeout + ); + + // Mainnet safety gate + requireMainnetConfirmation(isSandbox, !!options.confirmMainnet, isJson); + + const wallet = new ethers.Wallet(pk, provider); + const address = wallet.address; + + // Fetch balances with retry + const [ethBalance, usdcBalance] = await withRetry( + () => Promise.all([ + provider.getBalance(address), + new ethers.Contract( + usdcAddress, + ['function balanceOf(address) view returns (uint256)'], + provider + ).balanceOf(address) + ]), + 'balanceCheck' + ); + + // BigInt comparisons are used for logic (no precision loss). + // parseFloat is only for human-readable display and non-critical JSON values. + const ethValue = parseFloat(ethers.formatEther(ethBalance)); + const usdcValue = parseFloat(ethers.formatUnits(usdcBalance, 6)); + const isGasReady = ethBalance >= MIN_GAS_WEI; + const isTokensReady = usdcBalance > 0n; + + if (isJson) { + console.log( + jsonEnvelope({ + status: 'success', + address, + eth: ethValue, + usdc: usdcValue, + network: networkName, + chainId, + is_sandbox: isSandbox, + checks: { + gas_ready: isGasReady, + tokens_ready: isTokensReady, + can_pay: isGasReady && isTokensReady + } + }) + ); + } else { + console.log(`\n๐Ÿ’Ž **PayNode Wallet Status**`); + console.log(`โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€`); + console.log(`๐Ÿ‘ค **Address**: \`${address}\``); + console.log(`๐ŸŒ **Network**: ${networkName}`); + console.log(`โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€`); + console.log(`โ›ฝ **ETH (Gas)**: ${ethValue.toFixed(6)} ETH ${isGasReady ? 'โœ… Ready' : 'โš ๏ธ Low balance'}`); + console.log(`๐Ÿ’ต **USDC**: ${usdcValue.toFixed(2)} USDC ${isTokensReady ? 'โœ… Ready' : 'โŒ Empty'}`); + console.log(`โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€`); + + if (isGasReady && isTokensReady) { + console.log(`๐Ÿš€ **Status**: Ready to handle x402 autonomous payments.`); + } else { + console.log(`โŒ **Status**: Action required. See tips below.`); + } + + // Safety Warning for Burner Wallets (Mainnet Only) + if (!isSandbox && usdcValue > 10) { + console.warn( + `\n> [!CAUTION]\n> High balance detected ($${usdcValue.toFixed(2)} USDC). This is a burner wallet. \n> Consider sweeping excess funds to cold storage to minimize risk.` + ); + } + + if (!isGasReady) { + if (isSandbox) { + console.warn( + `\n> [!WARNING]\n> Gas balance is critically low (< 0.001 ETH). Please deposit ETH to \`${address}\` on ${networkName}.` + ); + console.warn( + `> **Faucet**: [console.optimism.io/faucet](https://console.optimism.io/faucet) (Recommended)` + ); + } else { + console.warn( + `\n> [!WARNING]\n> Gas balance is critically low (< 0.001 ETH). Please deposit ETH to \`${address}\` on ${networkName}.` + ); + } + } + if (!isTokensReady) { + if (isSandbox) { + console.warn( + `\n> [!TIP]\n> You're on Testnet! Run \`bun run paynode-402 mint --network testnet\` to get 1,000 free Test USDC.` + ); + } else { + console.warn( + `\n> [!NOTE]\n> Mainnet USDC balance is 0. This wallet is currently restricted to free trials.` + ); + } + } + console.log(``); + } + } catch (error: any) { + reportError(error, isJson, EXIT_CODES.NETWORK_ERROR); + } +} diff --git a/commands/get-api-detail.ts b/commands/get-api-detail.ts new file mode 100644 index 0000000..54b9090 --- /dev/null +++ b/commands/get-api-detail.ts @@ -0,0 +1,30 @@ +import { MarketplaceClient } from '../marketplace/client.ts'; +import { jsonEnvelope, reportError, EXIT_CODES, BaseCliOptions } from '../utils.ts'; + +interface GetApiDetailOptions extends BaseCliOptions { +} + +export async function getApiDetailAction(apiId: string, options: GetApiDetailOptions) { + const isJson = !!options.json; + + try { + const client = new MarketplaceClient({ + baseUrl: options.marketUrl, + json: isJson + }); + + const detail = await client.getApiDetail(apiId, options.network); + + if (isJson) { + console.log(jsonEnvelope({ + status: 'success', + api: detail + })); + return; + } + + console.log(JSON.stringify(detail, null, 2)); + } catch (error: any) { + reportError(error, isJson, EXIT_CODES.NETWORK_ERROR); + } +} diff --git a/commands/invoke-paid-api.ts b/commands/invoke-paid-api.ts new file mode 100644 index 0000000..bbbecbc --- /dev/null +++ b/commands/invoke-paid-api.ts @@ -0,0 +1,91 @@ +import { requestAction } from './request.ts'; +import { MarketplaceClient } from '../marketplace/client.ts'; +import { reportError, EXIT_CODES, BaseCliOptions } from '../utils.ts'; + +interface InvokePaidApiOptions extends BaseCliOptions { + method?: string; + data?: string; + header?: string | string[]; + background?: boolean; + output?: string; + maxAge?: number; + taskDir?: string; + taskId?: string; +} + +function mergeHeaders( + marketplaceHeaders: Record | undefined, + cliHeader: string | string[] | undefined +): string[] { + const merged: string[] = []; + + for (const [key, value] of Object.entries(marketplaceHeaders || {})) { + merged.push(`${key}: ${value}`); + } + + if (Array.isArray(cliHeader)) { + merged.push(...cliHeader); + } else if (cliHeader) { + merged.push(cliHeader); + } + + return merged; +} + +function parsePayload(data?: string): any { + if (!data) return undefined; + + try { + return JSON.parse(data); + } catch (err: any) { + const isJsonLike = data.trim().startsWith('{') || data.trim().startsWith('['); + if (isJsonLike) { + console.warn(`โš ๏ธ [Warning] Invocation data looks like JSON but failed to parse: ${err.message}`); + console.warn(`Sending as raw string instead. Please verify your JSON syntax.`); + } else { + console.warn(`โš ๏ธ [Warning] Invocation data is not valid JSON. Sending as raw string.`); + } + return data; + } +} + +export async function invokePaidApiAction(apiId: string, options: InvokePaidApiOptions) { + const isJson = !!options.json; + + try { + const client = new MarketplaceClient({ + baseUrl: options.marketUrl, + json: isJson + }); + + const invoke = await client.prepareInvoke(apiId, { + network: options.network, + payload: parsePayload(options.data) + }); + + const requestHeaders = mergeHeaders(invoke.headers, options.header); + const hasPreparedBody = !!(invoke.body && typeof invoke.body === 'object' && Object.keys(invoke.body).length > 0); + const requestBody = hasPreparedBody + ? JSON.stringify(invoke.body) + : (options.data || undefined); // Use undefined if no data to trigger smart promotion if needed + + await requestAction(invoke.invoke_url, [], { + json: options.json, + network: options.network || invoke.network, // Delegate fallback to resolveNetwork + rpc: options.rpc, + rpcTimeout: options.rpcTimeout, + confirmMainnet: options.confirmMainnet, + method: options.method || invoke.method || 'POST', + data: requestBody, + header: requestHeaders, + background: options.background, + dryRun: options.dryRun, + output: options.output, + maxAge: options.maxAge, + taskDir: options.taskDir, + taskId: options.taskId + }); + } catch (error: any) { + reportError(error, isJson, EXIT_CODES.NETWORK_ERROR); + } +} diff --git a/commands/list-paid-apis.ts b/commands/list-paid-apis.ts new file mode 100644 index 0000000..3000830 --- /dev/null +++ b/commands/list-paid-apis.ts @@ -0,0 +1,58 @@ +import { MarketplaceClient } from '../marketplace/client.ts'; +import { jsonEnvelope, reportError, EXIT_CODES, BaseCliOptions } from '../utils.ts'; + +interface ListPaidApisOptions extends BaseCliOptions { + limit?: string | number; + tag?: string | string[]; + seller?: string; +} + +export async function listPaidApisAction(options: ListPaidApisOptions) { + const isJson = !!options.json; + + try { + const client = new MarketplaceClient({ + baseUrl: options.marketUrl, + json: isJson + }); + + const tags = Array.isArray(options.tag) + ? options.tag + : options.tag + ? [options.tag] + : []; + + const result = await client.listCatalog({ + network: options.network, + limit: options.limit ? Number(options.limit) : undefined, + tag: tags, + seller: options.seller + }); + + if (isJson) { + console.log(jsonEnvelope({ + status: 'success', + total: result.total || result.items.length, + items: result.items + })); + return; + } + + if (result.items.length === 0) { + console.log('No paid APIs found.'); + return; + } + + for (const item of result.items) { + const price = item.price_per_call ? `${item.price_per_call} ${item.currency || 'USDC'}` : 'unpriced'; + const network = item.network || 'unspecified'; + const tagsLine = item.tags && item.tags.length > 0 ? ` [${item.tags.join(', ')}]` : ''; + console.log(`- ${item.id}: ${item.name} | ${price} | ${network}${tagsLine}`); + if (item.description) { + console.log(` ${item.description}`); + } + } + } catch (error: any) { + reportError(error, isJson, EXIT_CODES.NETWORK_ERROR); + } +} diff --git a/commands/mint.ts b/commands/mint.ts new file mode 100644 index 0000000..de74f00 --- /dev/null +++ b/commands/mint.ts @@ -0,0 +1,97 @@ +import { ethers } from '@paynodelabs/sdk-js'; +import { + getPrivateKey, + resolveNetwork, + reportError, + jsonEnvelope, + withRetry, + EXIT_CODES, + BaseCliOptions +} from '../utils.ts'; + +interface MintOptions extends BaseCliOptions { + amount?: string; +} + +export async function mintAction(options: MintOptions) { + const isJson = !!options.json; + const pk = getPrivateKey(isJson); + + try { + const { provider, usdcAddress, chainId, networkName, isSandbox } = await resolveNetwork( + options.rpc, + options.network || 'testnet', + options.rpcTimeout + ); + + if (!isSandbox) { + throw new Error(`Minting is only supported on Sepolia. Current ChainID: ${chainId}`); + } + + const wallet = new ethers.Wallet(pk, provider); + const mintAmountStr = options.amount || '1000'; + + // Gas check + const balance = await provider.getBalance(wallet.address); + if (balance === 0n) { + reportError( + `Gas balance is 0 ETH on ${networkName}. Please fund \`${wallet.address}\` to continue.\n` + + `๐Ÿ’ก **Faucet**: [console.optimism.io/faucet](https://console.optimism.io/faucet) โ€” 0.01 ETH daily (Recommended)`, + isJson, + EXIT_CODES.INSUFFICIENT_FUNDS + ); + } + + // Progress messages are sent to stderr to avoid polluting stdout + // when valid JSON output is expected by the caller (e.g. via --json) + if (!isJson) { + console.error(`๐Ÿ’ฐ Connecting to ${networkName}...`); + console.error(`๐Ÿ”— Minting ${mintAmountStr} USDC for address: ${wallet.address}`); + } + + const abi = ['function mint(address to, uint256 amount) external']; + const usdc = new ethers.Contract(usdcAddress, abi, wallet); + + const amount = ethers.parseUnits(mintAmountStr, 6); + + if (!isJson) console.error('โณ Sending mint transaction...'); + const tx = await withRetry( + () => usdc.mint(wallet.address, amount), + 'mint' + ); + + if (!isJson) console.error('โณ Waiting for confirmation...'); + const receipt: any = await withRetry( + () => tx.wait(), + 'mintConfirm' + ); + + if (!receipt || receipt.status !== 1) { + throw new Error('Transaction reverted or failed.'); + } + + if (isJson) { + console.log( + jsonEnvelope({ + status: 'success', + txHash: tx.hash, + address: wallet.address, + amount: mintAmountStr, + token: 'MockUSDC', + network: networkName + }) + ); + } else { + console.log(`\nโœ… **Success!**`); + console.log(`โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€`); + console.log(`๐Ÿ’ฐ **Minted**: ${mintAmountStr} Test USDC`); + console.log(`๐ŸŒ **Network**: ${networkName}`); + console.log(`๐Ÿ”— **Tx Hash**: \`${tx.hash}\``); + console.log(`โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€`); + console.log(`๐Ÿš€ **Your wallet (\`${wallet.address}\`) is now funded and ready for testing.**`); + console.log(``); + } + } catch (error: any) { + reportError(error, isJson, EXIT_CODES.NETWORK_ERROR); + } +} diff --git a/commands/request.ts b/commands/request.ts new file mode 100644 index 0000000..83add90 --- /dev/null +++ b/commands/request.ts @@ -0,0 +1,395 @@ +import { PayNodeAgentClient, RequestOptions, ethers } from '@paynodelabs/sdk-js'; +import { join, parse } from 'path'; +import { tmpdir } from 'os'; +import fs from 'fs'; +import { spawn } from 'child_process'; +import { + getPrivateKey, + resolveNetwork, + requireMainnetConfirmation, + reportError, + jsonEnvelope, + withRetry, + generateTaskId, + isInlineContent, + cleanupOldTasks, + DEFAULT_TASK_DIR, + DEFAULT_MAX_AGE_SECONDS, + EXIT_CODES, + SKILL_VERSION, + GLOBAL_CONFIG, + BaseCliOptions, + maskAddress +} from '../utils.ts'; + +interface UnifiedRequestOptions extends BaseCliOptions { + method?: string; + data?: string; + header?: string | string[]; + background?: boolean; + output?: string; + maxAge?: number; + taskDir?: string; + taskId?: string; +} + +interface CoreResult { + result: { + url: string; + method: string; + http_status: number; + content_type: string; + body_type: 'json' | 'text' | 'binary'; + network: string; + data: any; + duration_ms: number; + dry_run?: boolean; + wallet?: string; + message?: string; + data_binary?: string; + data_size?: number; + }; + binaryBuffer?: Uint8Array; + contentType: string; +} + +// --- Background Launcher --- +function spawnBackground(url: string, args: string[], options: UnifiedRequestOptions) { + const taskId = options.taskId || generateTaskId(); // Use existing if re-spawning (though unlikely) + const taskDir = options.taskDir || DEFAULT_TASK_DIR; + const maxAge = options.maxAge || DEFAULT_MAX_AGE_SECONDS; + const outputPath = options.output || join(taskDir, `${taskId}.json`); + const logPath = join(taskDir, `${taskId}.log`); + + fs.mkdirSync(taskDir, { recursive: true }); + cleanupOldTasks(taskDir, maxAge); + + const originalArgs = process.argv.slice(2); + const flagsToRemove = ['--background', '--json', '--task-id', '--output', '--dry-run', '--max-age', '--task-dir']; + const childArgs: string[] = []; + + for (let i = 0; i < originalArgs.length; i++) { + const arg = originalArgs[i]; + if (flagsToRemove.includes(arg)) { + // If flag takes an argument, skip both flag and value + if (['--output', '--task-id', '--max-age', '--task-dir'].includes(arg) && i + 1 < originalArgs.length) { + i++; + } + continue; + } + childArgs.push(arg); + } + childArgs.push('--task-id', taskId, '--output', outputPath); + + // [SECURITY & LOGIC] + // This is a self-re-execution for background processing. + // 1. We spawn the same script (process.argv[1]) with filtered arguments. + // 2. We add '--task-id' which signals the next execution to use 'executeAndWrite' path. + // 3. This avoids infinite recursion because the sub-process will NOT have '--background' in its args. + // 4. Stderr is piped to a .log file to allow debugging of background failures. + + const logFd = fs.openSync(logPath, 'a'); + // The child command is pinned to 'process.execPath' (the current runtime) and 'process.argv[1]' (the current script). + // Arguments are filtered to prevent recursive loops. + // [SECURITY] Filter environment variables passed to background child process. + // Minimizes exposure of non-essential credentials. + const whitelist = [ + 'CLIENT_PRIVATE_KEY', + 'CUSTOM_ROUTER_ADDRESS', + 'CUSTOM_USDC_ADDRESS', + 'RPC_URL', + 'ALCHEMY_API_KEY', + 'INFURA_API_KEY', + 'ETHERSCAN_API_KEY', + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'NODE_PATH', + 'NVM_DIR', + 'BUN_INSTALL' + ]; + // Essential OS-level vars + const baseEnv = Object.fromEntries( + Object.entries({ + PATH: process.env.PATH, + HOME: process.env.HOME, + TMPDIR: process.env.TMPDIR, + USER: process.env.USER, + SHELL: process.env.SHELL + }).filter(([, v]) => v !== undefined) + ) as Record; + const childEnv: Record = { ...baseEnv }; + for (const key of whitelist) { + if (process.env[key]) childEnv[key] = process.env[key]; + } + + const child = spawn(process.execPath, [process.argv[1], ...childArgs], { + detached: true, + stdio: ['ignore', 'ignore', logFd], + env: childEnv + }); + child.unref(); + + // After unref. the parent no longer needs logFd. The child has its own copy. + fs.closeSync(logFd); + + const pendingInfo = { + status: 'pending', + task_id: taskId, + output_file: outputPath, + task_dir: taskDir, + max_age_seconds: maxAge, + command: `cat ${outputPath}`, + message: '๐Ÿ•’ x402 background request started. The wallet will automatically handle payments.' + }; + + if (options.json) { + console.log(jsonEnvelope(pendingInfo)); + } else { + console.log(`\n๐Ÿš€ **Background Task Started**`); + console.log(`- **Task ID**: \`${taskId}\``); + console.log(`- **Output**: \`${outputPath}\``); + console.log(`- **Log**: \`${logPath}\``); + console.log(`\nUse \`cat ${outputPath}\` to check progress or \`tail -f ${logPath}\` for logs.`); + } + process.exit(0); +} + +// --- Core x402 Execution --- +async function executeCore(url: string, args: string[], options: UnifiedRequestOptions): Promise { + const isJson = !!options.json || !!options.taskId; + const startTs = Date.now(); + + const { rpcUrls, networkName, isSandbox } = await resolveNetwork(options.rpc, options.network, options.rpcTimeout); + requireMainnetConfirmation(isSandbox, !!options.confirmMainnet, isJson); + + // Handle params (k=v) + const kvParams: Record = {}; + for (const p of args) { + if (!p.includes('=')) continue; + const [k, ...v] = p.split('='); + kvParams[k.trim()] = v.join('=').trim(); + } + + const method = options.method?.toUpperCase() || (options.data || Object.keys(kvParams).length > 0 ? 'POST' : 'GET'); + + // Headers parsing + const headers: Record = {}; + if (options.header) { + const headerArray = Array.isArray(options.header) ? options.header : [options.header]; + for (const h of headerArray) { + if (!h || !h.includes(':')) continue; + const [k, ...v] = h.split(':'); + headers[k.trim()] = v.join(':').trim(); + } + } + // [P1] Inject network header for Proxy validation + const paynodeNetwork = isSandbox ? 'testnet' : 'mainnet'; + if (!headers['X-PayNode-Network']) { + headers['X-PayNode-Network'] = paynodeNetwork; + } + + // Auto-sniff JSON body for manual data + if (options.data && !headers['Content-Type'] && !headers['content-type']) { + try { + JSON.parse(options.data); + headers['Content-Type'] = 'application/json'; + } catch { /* ignore */ } + } + + const requestOptions: RequestOptions = { method, headers }; + let targetUrl = url; + + if (method === 'GET') { + const urlObj = new URL(url); + for (const [k, v] of Object.entries(kvParams)) { + urlObj.searchParams.set(k, v); + } + targetUrl = urlObj.toString(); + } else { + if (options.data) { + requestOptions.body = options.data; + } else { + // [Smart Promotion] For POST/PUT, if no explicit body data is given but + // query parameters exist (either in URL or as args), put them into JSON body. + const urlObj = new URL(url); + const combinedParams = { ...kvParams }; + + // If the user only passed the URL with query params (no extra args) + if (Object.keys(combinedParams).length === 0 && urlObj.searchParams.size > 0) { + for (const [k, v] of urlObj.searchParams.entries()) { + combinedParams[k] = v; + } + } + + if (Object.keys(combinedParams).length > 0) { + requestOptions.json = combinedParams; + } + } + } + + // Dry-run + if (options.dryRun) { + const pkForAddress = GLOBAL_CONFIG.PRIVATE_KEY; + let walletAddr: string | undefined; + try { + if (pkForAddress && isJson) { + walletAddr = maskAddress((new ethers.Wallet(pkForAddress)).address); + } + } catch { /* skip if PK invalid */ } + + return { + result: { + url: targetUrl, + method, + http_status: 0, + content_type: 'application/json', + body_type: 'json', + network: networkName, + data: null, + duration_ms: 0, + dry_run: true, + wallet: walletAddr, + message: 'Dry-run: request prepared but not sent.' + }, + contentType: 'application/json' + }; + } + + const pk = getPrivateKey(isJson); + + const client = new PayNodeAgentClient(pk, rpcUrls); + const response = await withRetry( + () => client.requestGate(targetUrl, requestOptions), + 'x402:requestGate' + ); + + const contentType = response.headers.get('content-type') || 'application/octet-stream'; + const httpStatus = response.status; + let resultBody: any; + let bodyType: 'json' | 'text' | 'binary' = 'text'; + let binaryBuffer: Uint8Array | undefined; + + if (isInlineContent(contentType)) { + if (contentType.toLowerCase().includes('application/json')) { + resultBody = await response.json(); + bodyType = 'json'; + } else { + resultBody = await response.text(); + bodyType = 'text'; + } + } else { + const arrayBuf = await response.arrayBuffer(); + binaryBuffer = new Uint8Array(arrayBuf); + bodyType = 'binary'; + resultBody = null; + } + + return { + result: { + url: targetUrl, + method, + http_status: httpStatus, + content_type: contentType, + body_type: bodyType, + network: networkName, + data: resultBody, + duration_ms: Date.now() - startTs + }, + binaryBuffer, + contentType + }; +} + +// --- Persistence --- +async function executeAndWrite(url: string, args: string[], options: UnifiedRequestOptions) { + const taskId = options.taskId || generateTaskId(); + const taskDir = options.taskDir || DEFAULT_TASK_DIR; + const outputPath = options.output || join(taskDir, `${taskId}.json`); + + fs.mkdirSync(taskDir, { recursive: true }); + + try { + const { result, binaryBuffer, contentType } = await executeCore(url, args, options); + + if (binaryBuffer) { + const { dir, name } = parse(outputPath); + const binaryPath = join(dir, `${name}.bin`); + fs.writeFileSync(binaryPath, binaryBuffer); + result.data = `[binary: ${contentType}, ${binaryBuffer.length} bytes โ†’ ${binaryPath}]`; + result.data_binary = binaryPath; + result.data_size = binaryBuffer.length; + } + + const finalOutput = { + version: SKILL_VERSION, + status: 'completed', + task_id: taskId, + ...result, + completed_at: new Date().toISOString() + }; + + fs.writeFileSync(outputPath, JSON.stringify(finalOutput, null, 2)); + } catch (error: any) { + const errorResult = { + version: SKILL_VERSION, + status: 'failed', + task_id: taskId, + error: error.message, + errorCode: error?.code || 'internal_error', + completed_at: new Date().toISOString() + }; + fs.writeFileSync(outputPath, JSON.stringify(errorResult, null, 2)); + } +} + +// --- Main Entry --- +export async function requestAction(url: string, args: string[], options: UnifiedRequestOptions) { + if (options.background) { + spawnBackground(url, args, options); + return; + } + + if (options.taskId) { + await executeAndWrite(url, args, options); + return; + } + + const isJson = !!options.json; + + try { + if (!isJson && !options.dryRun) { + console.error(`๐ŸŒ x402 Request: ${url}...`); + } + + const { result, binaryBuffer, contentType } = await executeCore(url, args, options); + + if (binaryBuffer) { + const binPath = options.output + ? join(parse(options.output).dir, `${parse(options.output).name}.bin`) + : join(tmpdir(), `paynode-${Date.now().toString(36)}.bin`); + + fs.writeFileSync(binPath, binaryBuffer); + result.data = `[binary: ${contentType}, ${binaryBuffer.length} bytes โ†’ ${binPath}]`; + result.data_binary = binPath; + result.data_size = binaryBuffer.length; + } + + if (isJson) { + console.log(jsonEnvelope({ status: 'success', ...result })); + } else { + if (result.dry_run) { + console.log('๐Ÿงช DRY RUN PREPARED:'); + console.log(JSON.stringify(result, null, 2)); + } else { + if (typeof result.data === 'object') { + console.log(JSON.stringify(result.data, null, 2)); + } else { + console.log(result.data); + } + } + } + } catch (error: any) { + reportError(error, isJson, EXIT_CODES.NETWORK_ERROR); + } +} + diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..eca5bdc --- /dev/null +++ b/index.ts @@ -0,0 +1,105 @@ +#!/usr/bin/env bun +import { cac } from 'cac'; +import pkg from './package.json'; +import { checkAction } from './commands/check.ts'; +import { mintAction } from './commands/mint.ts'; +import { requestAction } from './commands/request.ts'; +import { listPaidApisAction } from './commands/list-paid-apis.ts'; +import { getApiDetailAction } from './commands/get-api-detail.ts'; +import { invokePaidApiAction } from './commands/invoke-paid-api.ts'; +import { EXIT_CODES } from './utils.ts'; + +const cli = cac('paynode-402'); + +// Global Options +cli.option('--json', 'Output results in JSON format'); +cli.option('--network ', 'Network to use: mainnet or testnet/sepolia'); +cli.option('--rpc ', 'Custom RPC URL'); +cli.option('--rpc-timeout ', 'Custom RPC timeout in milliseconds (default: 15000)'); +cli.option('--confirm-mainnet', 'Required flag for mainnet operations (real USDC)'); +cli.option('--dry-run', 'Show request details without sending'); +cli.option('--market-url ', 'Marketplace base URL'); + +// Command: check +cli + .command('check', 'Check wallet balance (ETH and USDC) on Base L2') + .action((options) => { + return checkAction(options); + }); + +// Command: mint +cli + .command('mint', 'Mint USDC on Base Sepolia') + .option('--amount ', 'Amount to mint (default: 1000)') + .action((options) => { + return mintAction(options); + }); + +// Command: request +cli + .command('request [...params]', 'Access protected API and handle x402 payments. Params: key=value pairs for query/body.') + .option('-X, --method ', 'HTTP method (GET, POST, etc.)') + .option('-d, --data ', 'Raw request body data') + .option('-H, --header [header]', 'HTTP header in "Key: Value" format (can be used multiple times)', { default: [] }) + .option('--background', 'Execute in background, return immediately (AI-friendly)') + .option('--output ', 'Output file path for result (used with --background)') + .option('--max-age ', 'Auto-delete task files older than N seconds (default: 3600)') + .option('--task-dir ', 'Task directory for background results (default: /paynode-tasks)') + .option('--task-id ', 'Internal: task ID for background worker') + .action((url, params, options) => { + return requestAction(url, params, options); + }); + +// Command: list-paid-apis +cli + .command('list-paid-apis', 'List paid APIs from the marketplace catalog') + .option('--limit ', 'Maximum number of APIs to return') + .option('--tag [tag]', 'Catalog tag filter (can be used multiple times)', { default: [] }) + .option('--seller ', 'Seller identifier filter') + .action((options) => { + return listPaidApisAction(options); + }); + +// Command: get-api-detail +cli + .command('get-api-detail ', 'Get full detail for one paid API') + .action((apiId, options) => { + return getApiDetailAction(apiId, options); + }); + +// Command: invoke-paid-api +cli + .command('invoke-paid-api ', 'Invoke one paid API through the marketplace flow') + .option('-X, --method ', 'HTTP method override') + .option('-d, --data ', 'Invocation payload as raw JSON string') + .option('-H, --header [header]', 'HTTP header in "Key: Value" format (can be used multiple times)', { default: [] }) + .option('--background', 'Execute in background, return immediately (AI-friendly)') + .option('--output ', 'Output file path for result (used with --background)') + .option('--max-age ', 'Auto-delete task files older than N seconds (default: 3600)') + .option('--task-dir ', 'Task directory for background results (default: /paynode-tasks)') + .option('--task-id ', 'Internal: task ID for background worker') + .action((apiId, options) => { + return invokePaidApiAction(apiId, options); + }); + + +cli.help(); +cli.version(pkg.version); + +try { + const result = cli.parse(); + if (result instanceof Promise) { + result.catch((err) => { + console.error(`โŒ Global Error: ${err.message}`); + process.exit(EXIT_CODES.GENERIC_ERROR); + }); + } +} catch (error: any) { + if (error.name === 'CACError') { + console.error(`โŒ Command Error: ${error.message}`); + process.exit(EXIT_CODES.INVALID_ARGS); + } else { + console.error(`โŒ Parse Error: ${error.message}`); + process.exit(EXIT_CODES.GENERIC_ERROR); + } +} diff --git a/marketplace/client.ts b/marketplace/client.ts new file mode 100644 index 0000000..a03fafd --- /dev/null +++ b/marketplace/client.ts @@ -0,0 +1,189 @@ +import { jsonEnvelope, reportError, withRetry, EXIT_CODES, GLOBAL_CONFIG } from '../utils.ts'; +import type { CatalogApiItem, CatalogListResponse, InvokePreparation } from './types.ts'; + +export interface MarketplaceClientOptions { + baseUrl?: string; + json?: boolean; +} + +export class MarketplaceError extends Error { + constructor( + message: string, + public readonly status: number, + public readonly code: string = 'unknown_error' + ) { + super(message); + this.name = 'MarketplaceError'; + } +} + +export interface ListCatalogOptions { + network?: string; + limit?: number; + tag?: string[]; + seller?: string; +} + +export interface PrepareInvokeOptions { + network?: string; + payload?: any; +} + +function joinUrl(baseUrl: string, path: string): string { + const normalizedBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + return `${normalizedBase}${normalizedPath}`; +} + +export interface RawCatalogApiItem { + id?: string; + api_id?: string; + name?: string; + title?: string; + api_name?: string; + description?: string; + tags?: string[]; + price_per_call?: string | number; + price?: string | number; + amount?: string | number; + currency?: string; + network?: string; + seller?: any; + seller_name?: string; + wallet_address?: string; + method?: string; + http_method?: string; + payable_url?: string; + payment_url?: string; + invoke_url?: string; + input_schema?: any; + sample_response?: any; + headers_template?: any; +} + +function normalizeCatalogItem(raw: RawCatalogApiItem): CatalogApiItem { + return { + id: raw.id || raw.api_id || '', + name: raw.name || raw.title || raw.api_name || raw.api_id || 'unnamed', + description: raw.description, + tags: Array.isArray(raw.tags) ? raw.tags : [], + price_per_call: String(raw.price_per_call || raw.price || raw.amount || '0'), + currency: raw.currency || 'USDC', + network: raw.network, + seller: (raw.seller && typeof raw.seller === 'object' && Object.keys(raw.seller).length > 0) ? { + name: raw.seller.name || raw.seller.seller_name, + wallet_address: raw.seller.wallet_address || raw.seller.address + } : { + name: raw.seller_name, + wallet_address: raw.wallet_address + }, + method: raw.method || raw.http_method, + payable_url: raw.payable_url || raw.payment_url, + invoke_url: raw.invoke_url, + input_schema: raw.input_schema, + sample_response: raw.sample_response, + headers_template: raw.headers_template + }; +} + +export class MarketplaceClient { + private readonly baseUrl: string; + private readonly isJson: boolean; + + constructor(options: MarketplaceClientOptions = {}) { + this.baseUrl = options.baseUrl || GLOBAL_CONFIG.MARKETPLACE_URL; + this.isJson = !!options.json; + } + + private async request(path: string, init?: RequestInit): Promise { + const url = joinUrl(this.baseUrl, path); + const response = await withRetry( + () => fetch(url, init), + `marketplace:${path}` + ); + + if (!response.ok) { + const text = await response.text(); + let errorMessage = `Marketplace request failed (${response.status}) at ${path}: ${text || 'empty response'}`; + let errorCode = 'unknown_error'; + try { + const json = JSON.parse(text); + if (json.message) errorMessage = json.message; + errorCode = json.code || json.error || errorCode; + } catch { /* use defaults if parse fails */ } + + throw new MarketplaceError(errorMessage, response.status, errorCode); + } + + return await response.json() as T; + } + + async listCatalog(options: ListCatalogOptions = {}): Promise { + const params = new URLSearchParams(); + if (options.network) params.set('network', options.network); + if (options.limit) params.set('limit', String(options.limit)); + if (options.seller) params.set('seller', options.seller); + for (const tag of options.tag || []) { + params.append('tag', tag); + } + + const query = params.toString(); + const path = `/api/v1/paid-apis${query ? `?${query}` : ''}`; + const raw = await this.request(path); + const items = Array.isArray(raw.items) + ? raw.items.map(normalizeCatalogItem) + : Array.isArray(raw) + ? raw.map(normalizeCatalogItem) + : []; + + return { + items, + total: raw.total || items.length + }; + } + + async getApiDetail(apiId: string, network?: string): Promise { + const params = new URLSearchParams(); + if (network) params.set('network', network); + const query = params.toString(); + const path = `/api/v1/paid-apis/${encodeURIComponent(apiId)}${query ? `?${query}` : ''}`; + const raw = await this.request(path); + return normalizeCatalogItem(raw); + } + + async prepareInvoke(apiId: string, options: PrepareInvokeOptions = {}): Promise { + try { + const preparation = await this.request(`/api/v1/paid-apis/${encodeURIComponent(apiId)}/invoke`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + network: options.network, + payload: options.payload ?? {} + }) + }); + + if (!preparation.invoke_url) { + throw new Error('Preparation response missing invoke_url'); + } + + return preparation; + } catch (err: any) { + console.warn(`[Marketplace] /invoke preparation failed for ${apiId}, falling back to direct proxy. Error: ${err.message}`); + const detail = await this.getApiDetail(apiId, options.network); + const invokeUrl = detail.payable_url || detail.invoke_url; + if (!invokeUrl) { + throw new Error(`API '${apiId}' is missing payable_url/invoke_url and marketplace did not provide an invoke preparation.`); + } + + return { + api_id: detail.id, + invoke_url: invokeUrl, + method: detail.method || 'POST', + headers: detail.headers_template || {}, + body: options.payload ?? {} + }; + } + } +} diff --git a/marketplace/types.ts b/marketplace/types.ts new file mode 100644 index 0000000..27a25ad --- /dev/null +++ b/marketplace/types.ts @@ -0,0 +1,37 @@ +export interface SellerInfo { + name?: string; + wallet_address?: string; +} + +export interface CatalogApiItem { + id: string; + name: string; + description?: string; + tags?: string[]; + price_per_call?: string; + currency?: string; + network?: string; + seller?: SellerInfo; + method?: string; + payable_url?: string; + invoke_url?: string; + input_schema?: Record; + sample_response?: any; + headers_template?: Record; + [key: string]: any; +} + +export interface CatalogListResponse { + items: CatalogApiItem[]; + total?: number; +} + +export interface InvokePreparation { + api_id: string; + invoke_url: string; + method?: string; + headers?: Record; + body?: any; + network?: string; + [key: string]: any; +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9f390bd --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "@paynodelabs/paynode-402-cli", + "version": "2.5.0", + "description": "The official command-line interface for the PayNode protocol. Designed for AI Agents to execute stateless micro-payments via HTTP 402.", + "type": "module", + "main": "./index.js", + "bin": { + "paynode-402": "./index.js" + }, + "keywords": [ + "paynode", + "x402", + "payment-required", + "web3", + "bun", + "cli", + "agent" + ], + "author": "PayNode Labs", + "license": "MIT", + "dependencies": { + "@paynodelabs/sdk-js": "^2.2.3", + "cac": "7.0.0" + }, + "devDependencies": { + "bun-types": "^1.0.0" + }, + "repository": { + "type": "git", + "url": "git+ssh://git@github.com/PayNodeLabs/paynode-402-cli.git" + } +} diff --git a/utils.ts b/utils.ts new file mode 100755 index 0000000..feab56a --- /dev/null +++ b/utils.ts @@ -0,0 +1,397 @@ +import { ethers } from '@paynodelabs/sdk-js'; +import * as dotenv from 'dotenv'; +import { tmpdir } from 'os'; +import { join, dirname } from 'path'; +import fs from 'fs'; +import { fileURLToPath } from 'url'; +import pkg from '../package.json'; + +// --- Environment (System Only) --- +// [SECURITY] This skill strictly uses system environment variables for better update persistence +// and to avoid plaintext private keys on disk. .env files are no longer supported. +if (!process.env.CLIENT_PRIVATE_KEY) { + // We don't exit here because some commands like 'check' or 'mint' provide their own helpful setup tips. + // getPrivateKey() will handle the final enforcement. +} + +/** + * Centralized Configuration Loader + * [SECURITY] Consolidates environment variable access for better auditing. + */ +export const GLOBAL_CONFIG = { + MARKETPLACE_URL: process.env.PAYNODE_MARKET_URL || 'https://mk.paynode.dev', + PRIVATE_KEY: process.env.CLIENT_PRIVATE_KEY, + CUSTOM_ROUTER: process.env.CUSTOM_ROUTER_ADDRESS, + CUSTOM_USDC: process.env.CUSTOM_USDC_ADDRESS, + RPC_URL_OVERRIDE: process.env.PAYNODE_RPC_URL || process.env.RPC_URL, + RPC_TIMEOUT: Number(process.env.PAYNODE_RPC_TIMEOUT) || 15_000 +}; + +/** + * Skill version for JSON output metadata. + */ +export const SKILL_VERSION = pkg.version; +export const SDK_VERSION = '2.2.3'; // Bundled default + +/** + * Shared base options for all CLI commands. + */ +export interface BaseCliOptions { + json?: boolean; + network?: string; + rpc?: string; + rpcTimeout?: number; + confirmMainnet?: boolean; + dryRun?: boolean; + marketUrl?: string; +} + + +/** + * Network configuration object. + */ +export interface NetworkConfig { + provider: ethers.JsonRpcProvider; + chainId: number; + isSandbox: boolean; + rpcUrl: string; + rpcUrls: string[]; + usdcAddress: string; + routerAddress: string; + networkName: string; +} + +/** + * CLI config from parsed arguments (CAC managed, but kept here for type reference). + */ +export interface CliConfig { + isJson: boolean; + isHelp: boolean; + isDryRun: boolean; + confirmMainnet: boolean; + background: boolean; + output?: string; + maxAge?: number; + taskDir?: string; + taskId?: string; + rpcUrl?: string; + network?: string; + marketUrl?: string; + method?: string; + data?: string; + headers?: Record; + params: string[]; +} + +/** + * Standardized Exit Codes + */ +export const EXIT_CODES = { + SUCCESS: 0, + GENERIC_ERROR: 1, + INVALID_ARGS: 2, + AUTH_FAILURE: 3, + NETWORK_ERROR: 4, + MAINNET_REJECTED: 5, + PAYMENT_FAILED: 6, + INSUFFICIENT_FUNDS: 7, + DUST_LIMIT: 8, + RPC_TIMEOUT: 9, + DUPLICATE_TRANSACTION: 10, + WRONG_CONTRACT: 11, + ORDER_MISMATCH: 12, + MISSING_RECEIPT: 13, + INTERNAL_ERROR: 14 +} as const; + +export const DEFAULT_TIMEOUT_MS = 15_000; +const MAX_RETRIES = 3; + +/** + * Executes an async operation with exponential backoff retry. + */ +export async function withRetry( + fn: () => Promise, + label: string, + maxRetries = MAX_RETRIES +): Promise { + let lastError: Error | null = null; + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + return await fn(); + } catch (error: any) { + lastError = error; + if (!isTransientError(error) || attempt >= maxRetries - 1) throw error; + const backoffMs = Math.pow(2, attempt) * 1000 * (0.5 + Math.random()); + console.error(`โš ๏ธ [${label}] ${error.message}. Retry #${attempt + 1} (of ${maxRetries - 1}) in ${Math.round(backoffMs)}ms...`); + await new Promise(resolve => setTimeout(resolve, backoffMs)); + } + } + throw lastError || new Error(`${label} failed after ${maxRetries} retries`); +} + +function isTransientError(error: any): boolean { + const msg = (error?.message || '').toLowerCase(); + const code = error?.code || ''; + + // --- Error Unwrap --- + // Extract the deepest cause if it's an RpcError wrapping another error + const details = error?.details; + const detailMsg = details + ? (details.message || (typeof details === 'string' ? details : JSON.stringify(details))).toLowerCase() + : ''; + + // Never retry if it's a known non-transient failure + const isNonRetryableCode = [ + 'CALL_EXCEPTION', + 'INVALID_ARGUMENT', + 'UNSUPPORTED_OPERATION', + 'ACTION_REJECTED', + 'INSUFFICIENT_FUNDS' + ].includes(code); + + if ( + isNonRetryableCode || + msg.includes('insufficient funds') || + msg.includes('execution reverted') || + detailMsg.includes('insufficient funds') || + detailMsg.includes('execution reverted') + ) { + return false; + } + + const isRetryableCode = [ + 'NETWORK_ERROR', + 'SERVER_ERROR', + 'TIMEOUT', + 'UNKNOWN_ERROR', + 'rpc_error' + ].includes(code); + + return ( + isRetryableCode || + msg.includes('timeout') || + msg.includes('network') || + msg.includes('fetch failed') || + msg.includes('econnrefused') || + msg.includes('econnreset') || + msg.includes('socket hang up') || + detailMsg.includes('timeout') || + detailMsg.includes('network') + ); +} + +export const DEFAULT_TASK_DIR = process.env.PAYNODE_TASK_DIR || join(tmpdir(), 'paynode-tasks'); +export const DEFAULT_MAX_AGE_SECONDS = Number(process.env.PAYNODE_MAX_AGE) || 3600; + +export function generateTaskId(): string { + const ts = Date.now().toString(36); + const rand = Math.random().toString(36).substring(2, 6); + return `${ts}-${rand}`; +} + +export function maskAddress(address: string): string { + if (!address || address.length < 10) return address; + return `${address.substring(0, 6)}...${address.substring(address.length - 4)}`; +} + +export function isInlineContent(contentType: string): boolean { + const ct = (contentType || '').split(';')[0].trim().toLowerCase(); + return ( + ct.startsWith('text/') || + ct === 'application/json' || + ct === 'application/javascript' || + ct === 'application/xml' || + ct === 'application/x-www-form-urlencoded' + ); +} + +export function cleanupOldTasks(taskDir: string, maxAgeSeconds: number): number { + try { + if (!fs.existsSync(taskDir)) return 0; + const now = Date.now(); + const cutoff = now - maxAgeSeconds * 1000; + let cleaned = 0; + for (const file of fs.readdirSync(taskDir)) { + if (file.startsWith('.')) continue; + const fullPath = join(taskDir, file); + try { + const stat = fs.statSync(fullPath); + // mtimeMs can be updated by reads (depending on mount options), + // birthtimeMs is creation. Use the minimum or birthtime for safe cleanup. + const effectiveTime = Math.min(stat.mtimeMs, stat.birthtimeMs || stat.mtimeMs); + if (effectiveTime < cutoff) { + fs.unlinkSync(fullPath); + cleaned++; + } + } catch { /* skip */ } + } + return cleaned; + } catch { return 0; } +} + + +/** + * Validates existence and format of CLIENT_PRIVATE_KEY. + */ +export function getPrivateKey(isJson: boolean): string { + const pk: string | undefined = GLOBAL_CONFIG.PRIVATE_KEY; + if (!pk || typeof pk !== 'string') { + reportError('CLIENT_PRIVATE_KEY not found in environment. Please set it as a system environment variable.', isJson, EXIT_CODES.AUTH_FAILURE); + } + const pkRegex = /^0x[0-9a-fA-F]{64}$/; + if (!pkRegex.test(pk)) { + reportError('Invalid CLIENT_PRIVATE_KEY format. Must be 0x-prefixed 64-hex chars.', isJson, EXIT_CODES.AUTH_FAILURE); + } + return pk; +} + +/** + * Validates mainnet access. + */ +export function requireMainnetConfirmation(isSandbox: boolean, confirmMainnet: boolean, isJson: boolean): void { + if (isSandbox) return; + if (!confirmMainnet) { + reportError( + 'Mainnet operation requires --confirm-mainnet flag (real USDC).', + isJson, + EXIT_CODES.MAINNET_REJECTED + ); + } +} + +/** + * Resolves network configuration with multi-RPC failover. + */ +export async function resolveNetwork(providedRpcUrl?: string, network?: string, timeoutMs = DEFAULT_TIMEOUT_MS): Promise { + const { + PAYNODE_ROUTER_ADDRESS, + PAYNODE_ROUTER_ADDRESS_SANDBOX, + BASE_USDC_ADDRESS, + BASE_USDC_ADDRESS_SANDBOX, + BASE_RPC_URLS, + BASE_RPC_URLS_SANDBOX + } = await import('@paynodelabs/sdk-js'); + + const networkAlias = (network || '').toLowerCase(); + const isTestnetRequest = + networkAlias === 'testnet' || + networkAlias === 'sepolia' || + networkAlias === 'base-sepolia' || + networkAlias === '84532' || + networkAlias === 'base-testnet'; + + const effectiveRpcUrl = providedRpcUrl || GLOBAL_CONFIG.RPC_URL_OVERRIDE; + const sdkRpcUrls = (isTestnetRequest ? (BASE_RPC_URLS_SANDBOX || []) : (BASE_RPC_URLS || [])); + const rpcUrls: string[] = effectiveRpcUrl ? [effectiveRpcUrl] : sdkRpcUrls; + let lastError: Error | null = null; + let provider: ethers.JsonRpcProvider | null = null; + let chainId: bigint | null = null; + let activeRpcUrl: string | null = null; + + for (const url of rpcUrls) { + try { + const tempProvider = new ethers.JsonRpcProvider(url, undefined, { staticNetwork: true, batchMaxCount: 1 }); + const networkInfo = await Promise.race([ + tempProvider.getNetwork(), + new Promise((_, reject) => setTimeout(() => reject(new Error('RPC timeout')), timeoutMs)) + ]); + provider = tempProvider; + chainId = networkInfo.chainId; + activeRpcUrl = url; + break; + } catch (error: any) { + lastError = error; + if (rpcUrls.length > 1) console.error(`โš ๏ธ [resolveNetwork] RPC ${url} failed: ${error.message}.`); + } + } + + if (!provider || !chainId || !activeRpcUrl) { + throw new Error(`Failed to connect to any RPC in [${rpcUrls.join(', ')}]: ${lastError?.message}`); + } + + const isSandbox = chainId === 84532n; + const networkName = isSandbox ? 'Base Sepolia (84532)' : 'Base L2 (8453)'; + const customRouter = GLOBAL_CONFIG.CUSTOM_ROUTER; + const customUsdc = GLOBAL_CONFIG.CUSTOM_USDC; + + return { + provider, + chainId: Number(chainId), + isSandbox, + rpcUrl: activeRpcUrl, + rpcUrls, + usdcAddress: customUsdc || (isSandbox ? BASE_USDC_ADDRESS_SANDBOX : BASE_USDC_ADDRESS), + routerAddress: customRouter || (isSandbox ? PAYNODE_ROUTER_ADDRESS_SANDBOX : PAYNODE_ROUTER_ADDRESS), + networkName + }; +} + +export function jsonEnvelope(data: Record): string { + return JSON.stringify({ + version: SKILL_VERSION, + skill_version: SKILL_VERSION, + sdk_version: SDK_VERSION, + ...data + }, null, 2); +} + +export function reportError(err: string | Error | any, isJson: boolean, defaultCode: number = EXIT_CODES.GENERIC_ERROR): never { + let message = typeof err === 'string' ? err : (err?.message || 'An unknown error occurred'); + let exitCode = defaultCode; + let errorCode: string | undefined; + + const isPayNodeException = err?.name === 'PayNodeException' || + (err?.code && typeof err.code === 'string' && ( + err.code.startsWith('paynode_') || + err.code.startsWith('x402_') || + (err.code === 'rpc_error' && err?.message?.toLowerCase().includes('paynode')) + )); + if (isPayNodeException) { + errorCode = err.code; + + // --- Defensive Unwrap --- + // If SDK masks a specific blockchain error as a generic 'rpc_error', try to recover it from details. + if (errorCode === 'rpc_error' && err.details) { + const detailMsg = (err.details.message || JSON.stringify(err.details)).toLowerCase(); + if (detailMsg.includes('insufficient funds') || detailMsg.includes('execution reverted')) { + errorCode = 'insufficient_funds'; + message = 'Insufficient funds for transaction gas or payment. Please verify ETH/USDC balances.'; + } else if (detailMsg.includes('user rejected')) { + errorCode = 'transaction_failed'; + message = 'Transaction was rejected by the wallet.'; + } + } + + switch (errorCode) { + case 'insufficient_funds': exitCode = EXIT_CODES.INSUFFICIENT_FUNDS; break; + case 'amount_too_low': exitCode = EXIT_CODES.DUST_LIMIT; break; + case 'rpc_error': exitCode = EXIT_CODES.RPC_TIMEOUT; break; + case 'transaction_failed': exitCode = EXIT_CODES.PAYMENT_FAILED; break; + case 'token_not_accepted': exitCode = EXIT_CODES.INVALID_ARGS; break; + case 'invalid_receipt': exitCode = EXIT_CODES.PAYMENT_FAILED; break; + case 'wrong_contract': exitCode = EXIT_CODES.WRONG_CONTRACT; break; + case 'order_mismatch': exitCode = EXIT_CODES.ORDER_MISMATCH; break; + case 'duplicate_transaction': exitCode = EXIT_CODES.DUPLICATE_TRANSACTION; break; + case 'missing_receipt': exitCode = EXIT_CODES.MISSING_RECEIPT; break; + case 'transaction_not_found': exitCode = EXIT_CODES.NETWORK_ERROR; break; + case 'internal_error': exitCode = EXIT_CODES.INTERNAL_ERROR; break; + default: exitCode = defaultCode; + } + } + + if (isJson) { + console.log(jsonEnvelope({ status: 'error', message, exitCode, errorCode, details: err?.details })); + } else { + const prefix = isPayNodeException ? `๐Ÿ›‘ [PayNode-${errorCode}]` : `โŒ ERROR:`; + console.error(`${prefix} ${message} (Code: ${exitCode})`); + if (errorCode === 'insufficient_funds') { + console.error(`๐Ÿ’ก Tip: Use 'bun run paynode-402 check' to verify ETH/USDC balances.`); + console.error(`๐Ÿ’ก Faucet (Testnet): [console.optimism.io/faucet](https://console.optimism.io/faucet)`); + } else if (errorCode === 'amount_too_low') { + const min = err?.details?.minimum || 1000; + console.error(`๐Ÿ’ก Tip: Minimum requirement is ${min} units.`); + } + } + process.exit(exitCode); +} From 26f8dbf5247875d7e0090af31b3e863d9f6ebb2a Mon Sep 17 00:00:00 2001 From: zq0951 Date: Thu, 2 Apr 2026 16:28:50 +0800 Subject: [PATCH 2/8] fix: resolve relative path imports and type coercion for mint amount --- commands/mint.ts | 2 +- utils.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/commands/mint.ts b/commands/mint.ts index de74f00..9312708 100644 --- a/commands/mint.ts +++ b/commands/mint.ts @@ -29,7 +29,7 @@ export async function mintAction(options: MintOptions) { } const wallet = new ethers.Wallet(pk, provider); - const mintAmountStr = options.amount || '1000'; + const mintAmountStr = String(options.amount || '1000'); // Gas check const balance = await provider.getBalance(wallet.address); diff --git a/utils.ts b/utils.ts index feab56a..18632c7 100755 --- a/utils.ts +++ b/utils.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'os'; import { join, dirname } from 'path'; import fs from 'fs'; import { fileURLToPath } from 'url'; -import pkg from '../package.json'; +import pkg from './package.json'; // --- Environment (System Only) --- // [SECURITY] This skill strictly uses system environment variables for better update persistence From 3abace1fc6242696a33835a3bf452906ca69f306 Mon Sep 17 00:00:00 2001 From: zq0951 Date: Thu, 2 Apr 2026 16:31:21 +0800 Subject: [PATCH 3/8] docs: update README with configuration details and security warnings --- README.md | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 61 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index db8178a..e574817 100644 --- a/README.md +++ b/README.md @@ -3,15 +3,71 @@ [![Base L2](https://img.shields.io/badge/Network-Base%20L2-0052FF?style=for-the-badge&logo=base)](https://base.org) [![HTTP 402](https://img.shields.io/badge/Protocol-x402-00ff88?style=for-the-badge)](https://paynode.dev) -The official command-line interface for the [PayNode](https://github.com/PayNodeLabs) protocol. Designed specifically for the Agentic Era, allowing AI Agents to execute zero-install, stateless micro-payments via the HTTP 402 standard. +The official command-line interface for the [PayNode](https://github.com/PayNodeLabs) protocol. Designed specifically for the **Agentic Era**, allowing AI Agents to execute zero-install, stateless micro-payments via the HTTP 402 standard. + +--- + +## ๐Ÿ› ๏ธ Configuration & Credentials + +To enable automated blockchain signing, this tool requires a private key provided via environment variables. + +> [!IMPORTANT] +> **Mandatory Variable**: `CLIENT_PRIVATE_KEY` (0x...) +> All signature and payment operations rely on this key. Ensure it is set in your environment before execution. + +> [!CAUTION] +> **BURNER WALLET ONLY**: NEVER use a primary or "cold storage" wallet. This tool is designed for autonomous agents; always use a dedicated burner wallet with minimal funds (< 10 USDC). + +### Setup Example (Unix) +```bash +export CLIENT_PRIVATE_KEY="0x..." +bunx @paynodelabs/paynode-402-cli check +``` + +--- ## ๐Ÿค– For AI Agents (Zero-Install) Your agent can use this tool dynamically via `bunx` without any manual installation: +### 1. Check Readiness (Gas & USDC) ```bash -# 1. Mint test USDC -bunx paynode-402-cli mint +bunx @paynodelabs/paynode-402-cli check --network testnet --json +``` + +### 2. Access a 402-Protected API +The CLI automatically detects the 402 challenge, performs the handshake, signs the payment (on-chain or EIP-3009), and returns the final resource. +```bash +bunx @paynodelabs/paynode-402-cli request "https://api.example.com/data" --network testnet --json +``` + +### 3. Mint Test USDC (Base Sepolia) +```bash +bunx @paynodelabs/paynode-402-cli mint --amount 100 --network testnet +``` + +--- + +## ๐Ÿ“‘ Command Summary + +| Command | Description | +| :--- | :--- | +| `check` | Check ETH/USDC balances and readiness on Base L2 | +| `mint` | Mint Mock USDC on Base Sepolia for testing | +| `request ` | Access a protected resource by handling the 402 challenge | +| `list-paid-apis` | Discover payable APIs from the PayNode Marketplace | +| `get-api-detail ` | Inspect one marketplace API | +| `invoke-paid-api ` | Invoke a marketplace API using the 402 flow | + +### Global Flags +- `--network `: `mainnet` or `testnet` (default: `testnet`). +- `--json`: Format output as machine-readable JSON (preferred for Agents). +- `--confirm-mainnet`: Explicit flag required for real USDC transactions on mainnet. +- `--background`: Execute in background and return a `task_id` for long-running handshakes. + +--- -# 2. Access a 402-protected API -PRIVATE_KEY="your_private_key" bunx paynode-402-cli request "https://api.example.com/data" +## ๐Ÿ”— References +- **Marketplace**: [https://mk.paynode.dev](https://mk.paynode.dev) +- **Protocol SPEC**: [PayNode Docs](https://docs.paynode.dev) +- **GitHub**: [PayNodeLabs/paynode-402-cli](https://github.com/PayNodeLabs/paynode-402-cli) From 4071e871b4da08ef3959a6d02b7c00522576b79f Mon Sep 17 00:00:00 2001 From: zq0951 Date: Thu, 2 Apr 2026 19:13:41 +0800 Subject: [PATCH 4/8] feat: add tsconfig and upgrade @paynodelabs/sdk-js to v2.4.0 --- bun.lock | 4 +- package-lock.json | 245 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 2 +- tsconfig.json | 19 ++++ utils.ts | 3 +- 5 files changed, 268 insertions(+), 5 deletions(-) create mode 100644 package-lock.json create mode 100644 tsconfig.json diff --git a/bun.lock b/bun.lock index a3b59d3..3b2a084 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@paynodelabs/paynode-402-cli", "dependencies": { - "@paynodelabs/sdk-js": "^2.2.3", + "@paynodelabs/sdk-js": "^2.4.0", "cac": "7.0.0", }, "devDependencies": { @@ -22,7 +22,7 @@ "@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="], - "@paynodelabs/sdk-js": ["@paynodelabs/sdk-js@2.3.0", "", { "dependencies": { "ethers": "^6.16.0", "ioredis": "^5.10.1" }, "peerDependencies": { "express": ">=4.0.0" }, "optionalPeers": ["express"] }, "sha512-n7JPnLAhjS+0XfX6vK5x31JaIRy10OVGrDGF0CF9brxowy1Bpnnhs1rH6upcxn9XaIWUTekLO3+zeXHhdOdU4g=="], + "@paynodelabs/sdk-js": ["@paynodelabs/sdk-js@2.4.0", "", { "dependencies": { "ethers": "^6.16.0", "ioredis": "^5.10.1" }, "peerDependencies": { "express": ">=4.0.0" }, "optionalPeers": ["express"] }, "sha512-OGYJcBdVB9n0WbJXTC04OqjyZu+FvWBoJvzMriSuXn+p6LjzZ4sgev+aV6wp2dQuXbXraEz2MQKq/jhZsQvzJA=="], "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..3ef25ac --- /dev/null +++ b/package-lock.json @@ -0,0 +1,245 @@ +{ + "name": "@paynodelabs/paynode-402-cli", + "version": "2.5.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@paynodelabs/paynode-402-cli", + "version": "2.5.0", + "license": "MIT", + "dependencies": { + "@paynodelabs/sdk-js": "^2.4.0", + "cac": "7.0.0" + }, + "bin": { + "paynode-402": "index.js" + }, + "devDependencies": { + "bun-types": "^1.0.0" + } + }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.10.1", + "license": "MIT" + }, + "node_modules/@ioredis/commands": { + "version": "1.5.1", + "license": "MIT" + }, + "node_modules/@noble/curves": { + "version": "1.2.0", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.3.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.3.2", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@paynodelabs/sdk-js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@paynodelabs/sdk-js/-/sdk-js-2.4.0.tgz", + "integrity": "sha512-OGYJcBdVB9n0WbJXTC04OqjyZu+FvWBoJvzMriSuXn+p6LjzZ4sgev+aV6wp2dQuXbXraEz2MQKq/jhZsQvzJA==", + "license": "MIT", + "dependencies": { + "ethers": "^6.16.0", + "ioredis": "^5.10.1" + }, + "peerDependencies": { + "express": ">=4.0.0" + }, + "peerDependenciesMeta": { + "express": { + "optional": true + } + } + }, + "node_modules/@types/node": { + "version": "25.5.0", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/aes-js": { + "version": "4.0.0-beta.5", + "license": "MIT" + }, + "node_modules/bun-types": { + "version": "1.3.11", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/cac": { + "version": "7.0.0", + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/denque": { + "version": "2.1.0", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/ethers": { + "version": "6.16.0", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/ethers-io/" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "1.10.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.2", + "@types/node": "22.7.5", + "aes-js": "4.0.0-beta.5", + "tslib": "2.7.0", + "ws": "8.17.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ethers/node_modules/@types/node": { + "version": "22.7.5", + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/ethers/node_modules/@types/node/node_modules/undici-types": { + "version": "6.19.8", + "license": "MIT" + }, + "node_modules/ioredis": { + "version": "5.10.1", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "license": "MIT" + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.7.0", + "license": "0BSD" + }, + "node_modules/undici-types": { + "version": "7.18.2", + "dev": true, + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.17.1", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json index 9f390bd..353e37d 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "author": "PayNode Labs", "license": "MIT", "dependencies": { - "@paynodelabs/sdk-js": "^2.2.3", + "@paynodelabs/sdk-js": "^2.4.0", "cac": "7.0.0" }, "devDependencies": { diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a8b42d9 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "ignoreDeprecations": "5.0", + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "paths": { + "*": ["./node_modules/*"] + }, + "types": ["bun-types"] + }, + "include": ["**/*.ts"] +} diff --git a/utils.ts b/utils.ts index 18632c7..68bd985 100755 --- a/utils.ts +++ b/utils.ts @@ -1,5 +1,4 @@ import { ethers } from '@paynodelabs/sdk-js'; -import * as dotenv from 'dotenv'; import { tmpdir } from 'os'; import { join, dirname } from 'path'; import fs from 'fs'; @@ -31,7 +30,7 @@ export const GLOBAL_CONFIG = { * Skill version for JSON output metadata. */ export const SKILL_VERSION = pkg.version; -export const SDK_VERSION = '2.2.3'; // Bundled default +export const SDK_VERSION = '2.4.0'; // Updated to Protocol Baseline /** * Shared base options for all CLI commands. From 635f794746ca3675cc62de05cd436ddda8756eb5 Mon Sep 17 00:00:00 2001 From: zq0951 Date: Thu, 2 Apr 2026 20:10:41 +0800 Subject: [PATCH 5/8] test: add comprehensive unit tests for CLI commands, network resolution, and utility functions, and update package.json to support test execution. --- package.json | 8 +++- tests/commands.test.ts | 88 ++++++++++++++++++++++++++++++++++++++++++ tests/network.test.ts | 53 +++++++++++++++++++++++++ tests/utils.test.ts | 50 ++++++++++++++++++++++++ 4 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 tests/commands.test.ts create mode 100644 tests/network.test.ts create mode 100644 tests/utils.test.ts diff --git a/package.json b/package.json index 353e37d..2f5c4c5 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,9 @@ "version": "2.5.0", "description": "The official command-line interface for the PayNode protocol. Designed for AI Agents to execute stateless micro-payments via HTTP 402.", "type": "module", - "main": "./index.js", + "main": "./index.ts", "bin": { - "paynode-402": "./index.js" + "paynode-402": "./index.ts" }, "keywords": [ "paynode", @@ -18,6 +18,10 @@ ], "author": "PayNode Labs", "license": "MIT", + "scripts": { + "test": "bun test", + "build": "echo 'No build required for Bun' && exit 0" + }, "dependencies": { "@paynodelabs/sdk-js": "^2.4.0", "cac": "7.0.0" diff --git a/tests/commands.test.ts b/tests/commands.test.ts new file mode 100644 index 0000000..2510864 --- /dev/null +++ b/tests/commands.test.ts @@ -0,0 +1,88 @@ +import { mock, describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"; + +/** + * ๐Ÿ›ก๏ธ [TEST SAFETY] + * Prevent process.exit from killing the test runner. + */ +const exitSpy = spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`PROCESS_EXIT_${code}`); +}); + +/** + * ๐Ÿงฌ [ENVIRONMENT SETUP] + * Pre-set environment variables before any module evaluation. + */ +process.env.CLIENT_PRIVATE_KEY = "0x" + "1".repeat(64); + +/** + * ๐Ÿ”’ [MOCK CORE] + */ +const mockEthers = { + parseEther: (v: string) => 1000000000000000n, + formatEther: (v: bigint) => "0.001", + formatUnits: (v: bigint, u: number) => (Number(v) / Math.pow(10, u)).toString(), + Wallet: class { + address = "0xMockAddress"; + //@ts-ignore + connect = () => this; + constructor(pk: string, provider: any) {} + }, + Contract: class { + constructor() { + //@ts-ignore + this.balanceOf = async () => 1000000n; + } + }, + JsonRpcProvider: class { + constructor(url: string) {} + //@ts-ignore + getNetwork = async () => ({ chainId: 84532n }); + //@ts-ignore + getBalance = async () => 1000000000000000n; + } +}; + +mock.module("ethers", () => mockEthers); +mock.module("@paynodelabs/sdk-js", () => ({ + ethers: mockEthers, + BASE_RPC_URLS: ["http://mock-rpc"], + BASE_RPC_URLS_SANDBOX: ["http://mock-rpc-sandbox"], + BASE_USDC_ADDRESS: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + BASE_USDC_ADDRESS_SANDBOX: "0x65c088EfBDB0E03185Dbe8e258Ad0cf4Ab7946b0", + PAYNODE_ROUTER_ADDRESS: "0x4A73696ccF76E7381b044cB95127B3784369Ed63", + PAYNODE_ROUTER_ADDRESS_SANDBOX: "0x24cD8b68aaC209217ff5a6ef1Bf55a59f2c8Ca6F" +})); + +/** + * ๐Ÿš€ [DEFERRED LOADING] + */ +const { checkAction } = await import("../commands/check.ts"); + +describe("checkAction() CLI command tests", () => { + let logSpy: any; + + beforeEach(() => { + logSpy = spyOn(console, "log").mockImplementation(() => {}); + exitSpy.mockClear(); + }); + + afterEach(() => { + logSpy.mockRestore(); + }); + + test("โœ… Should output JSON status when --json is provided", async () => { + await checkAction({ json: true }); + + expect(logSpy).toHaveBeenCalled(); + const lastCall = logSpy.mock.calls[logSpy.mock.calls.length - 1][0]; + const output = JSON.parse(lastCall); + expect(output.status).toBe("success"); + expect(output.address).toBe("0xMockAddress"); + }); + + test("โœ… Should output human-readable status by default", async () => { + await checkAction({ json: false }); + expect(logSpy).toHaveBeenCalled(); + expect(logSpy.mock.calls[0][0]).toContain("PayNode Wallet Status"); + }); +}); diff --git a/tests/network.test.ts b/tests/network.test.ts new file mode 100644 index 0000000..cb58760 --- /dev/null +++ b/tests/network.test.ts @@ -0,0 +1,53 @@ +import { mock, describe, expect, test } from "bun:test"; + +/** + * โš ๏ธ [CRITICAL] + * This mock must reside at the very top of the file to intercept + * dynamic imports within resolveNetwork() before they evaluate. + */ +mock.module("@paynodelabs/sdk-js", () => { + const mockProvider = { + getNetwork: async () => ({ chainId: 84532n }), // Force testnet for all + getBalance: async () => 1000000000001n + }; + return { + ethers: { + JsonRpcProvider: class { + constructor(public url: string) {} + getNetwork = async () => mockProvider.getNetwork(); + } + }, + BASE_RPC_URLS: ["http://mock-rpc"], + BASE_RPC_URLS_SANDBOX: ["http://mock-rpc-sandbox"], + BASE_USDC_ADDRESS: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + BASE_USDC_ADDRESS_SANDBOX: "0x65c088EfBDB0E03185Dbe8e258Ad0cf4Ab7946b0", + PAYNODE_ROUTER_ADDRESS: "0x4A73696ccF76E7381b044cB95127B3784369Ed63", + PAYNODE_ROUTER_ADDRESS_SANDBOX: "0x24cD8b68aaC209217ff5a6ef1Bf55a59f2c8Ca6F" + }; +}); + +import { resolveNetwork } from "../utils.ts"; + +describe("resolveNetwork() unit tests with direct mock", () => { + + test("โœ… Should resolve testnet (Sepolia) by alias", async () => { + const config = await resolveNetwork(undefined, "testnet"); + expect(config.chainId).toBe(84532); + expect(config.isSandbox).toBe(true); + expect(config.usdcAddress).toBe("0x65c088EfBDB0E03185Dbe8e258Ad0cf4Ab7946b0"); + expect(config.routerAddress).toBe("0x24cD8b68aaC209217ff5a6ef1Bf55a59f2c8Ca6F"); + }); + + test("โœ… Should resolve mainnet (aliased to Sandbox in this mock)", async () => { + const config = await resolveNetwork(undefined, "mainnet"); + // Due to our mock forcing 84532n, it will be treated as Sandbox. + expect(config.chainId).toBe(84532); + expect(config.networkName).toContain("84532"); + }); + + test("โœ… Should use provided custom RPC URL without timing out", async () => { + const customRpc = "http://my-mock-rpc-node.com"; + const config = await resolveNetwork(customRpc); + expect(config.rpcUrl).toBe(customRpc); + }); +}); diff --git a/tests/utils.test.ts b/tests/utils.test.ts new file mode 100644 index 0000000..ccdc8ae --- /dev/null +++ b/tests/utils.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test, mock, beforeAll, afterAll } from "bun:test"; +import { + generateTaskId, + maskAddress, + jsonEnvelope, + EXIT_CODES, + SKILL_VERSION, + SDK_VERSION +} from "../utils.ts"; +import fs from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; + +describe("PayNode CLI Utilities", () => { + + test("generateTaskId() should return unique alphanumeric strings", () => { + const id1 = generateTaskId(); + const id2 = generateTaskId(); + expect(id1).not.toBe(id2); + expect(id1).toMatch(/^[a-z0-9]+-[a-z0-9]+$/); + }); + + test("maskAddress() should mask long addresses", () => { + const addr = "0x1234567890abcdef1234567890abcdef12345678"; + const masked = maskAddress(addr); + expect(masked).toBe("0x1234...5678"); + }); + + test("maskAddress() should return short strings as-is", () => { + expect(maskAddress("abc")).toBe("abc"); + }); + + test("jsonEnvelope() should include correct version metadata", () => { + const data = { foo: "bar" }; + const envelope = JSON.parse(jsonEnvelope(data)); + + expect(envelope.foo).toBe("bar"); + expect(envelope.version).toBe(SKILL_VERSION); + expect(envelope.skill_version).toBe(SKILL_VERSION); + expect(envelope.sdk_version).toBe(SDK_VERSION); + }); + + test("EXIT_CODES should be consistent with protocol spec", () => { + expect(EXIT_CODES.SUCCESS).toBe(0); + expect(EXIT_CODES.GENERIC_ERROR).toBe(1); + expect(EXIT_CODES.AUTH_FAILURE).toBe(3); + expect(EXIT_CODES.INSUFFICIENT_FUNDS).toBe(7); + expect(EXIT_CODES.DUST_LIMIT).toBe(8); + }); +}); From a8fc31e2eb84b95b6969257736fe6b84b193a43b Mon Sep 17 00:00:00 2001 From: zq0951 Date: Thu, 2 Apr 2026 20:15:14 +0800 Subject: [PATCH 6/8] feat: add tasks command for background task management, implement URL validation, and dynamically resolve SDK version --- commands/request.ts | 5 +++ commands/tasks.ts | 74 +++++++++++++++++++++++++++++++++++++++++++++ index.ts | 9 ++++++ utils.ts | 6 +++- 4 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 commands/tasks.ts diff --git a/commands/request.ts b/commands/request.ts index 83add90..cd166b5 100644 --- a/commands/request.ts +++ b/commands/request.ts @@ -159,6 +159,11 @@ async function executeCore(url: string, args: string[], options: UnifiedRequestO const isJson = !!options.json || !!options.taskId; const startTs = Date.now(); + // [INPUT-GUARD] Validate URL structure + if (!url || typeof url !== 'string' || (!url.startsWith('http://') && !url.startsWith('https://'))) { + throw new Error(`Invalid destination URL: '${url}'. Must start with 'http://' or 'https://'.`); + } + const { rpcUrls, networkName, isSandbox } = await resolveNetwork(options.rpc, options.network, options.rpcTimeout); requireMainnetConfirmation(isSandbox, !!options.confirmMainnet, isJson); diff --git a/commands/tasks.ts b/commands/tasks.ts new file mode 100644 index 0000000..351f8d2 --- /dev/null +++ b/commands/tasks.ts @@ -0,0 +1,74 @@ +import fs from 'fs'; +import { join } from 'path'; +import { DEFAULT_TASK_DIR, jsonEnvelope, BaseCliOptions, cleanupOldTasks } from '../utils.ts'; + +interface TasksOptions extends BaseCliOptions { + clean?: boolean; +} + +export async function tasksAction(subcommand: string | undefined, options: TasksOptions) { + const isJson = !!options.json; + const taskDir = DEFAULT_TASK_DIR; + + if (subcommand === 'clean' || options.clean) { + const cleaned = cleanupOldTasks(taskDir, 0); // Cleanup everything immediately if explicit + if (isJson) { + console.log(jsonEnvelope({ status: 'success', message: `Cleaned ${cleaned} task files from ${taskDir}` })); + } else { + console.log(`โœ… Successfully cleaned ${cleaned} tasks.`); + } + return; + } + + // Default is list + if (!fs.existsSync(taskDir)) { + if (isJson) { + console.log(jsonEnvelope({ status: 'success', tasks: [] })); + } else { + console.log('No tasks found (Directory does not exist).'); + } + return; + } + + const files = fs.readdirSync(taskDir).filter(f => f.endsWith('.json') && !f.startsWith('.')); + const tasks = files.map(file => { + try { + const content = fs.readFileSync(join(taskDir, file), 'utf-8'); + const data = JSON.parse(content); + const stats = fs.statSync(join(taskDir, file)); + return { + id: data.task_id || file.replace('.json', ''), + status: data.status, + url: data.url, + method: data.method, + created_at: stats.birthtime.toISOString(), + completed_at: data.completed_at, + error: data.error + }; + } catch { + return null; + } + }).filter(Boolean).sort((a: any, b: any) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); + + if (isJson) { + console.log(jsonEnvelope({ status: 'success', total: tasks.length, tasks })); + } else { + if (tasks.length === 0) { + console.log(`No tasks found in ${taskDir}`); + return; + } + + console.log(`\n๐Ÿ“‹ Recent x402 Background Tasks in ${taskDir}:`); + console.log(`โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€`); + + for (const t of tasks as any[]) { + const statusIcon = t.status === 'completed' ? 'โœ…' : t.status === 'failed' ? 'โŒ' : '๐Ÿ•’'; + const indicator = `(${t.status || 'unknown'})`.padEnd(12); + const urlPart = t.url ? `| ${t.url}` : ''; + console.log(`${statusIcon} ${t.id.padEnd(12)} ${indicator} ${urlPart}`); + if (t.error) console.log(` โ””โ”€ Error: ${t.error}`); + } + console.log(`โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€`); + console.log(`๐Ÿ’ก Usage: 'cat ${taskDir}/.json' for full results.`); + } +} diff --git a/index.ts b/index.ts index eca5bdc..99d7788 100644 --- a/index.ts +++ b/index.ts @@ -7,6 +7,7 @@ import { requestAction } from './commands/request.ts'; import { listPaidApisAction } from './commands/list-paid-apis.ts'; import { getApiDetailAction } from './commands/get-api-detail.ts'; import { invokePaidApiAction } from './commands/invoke-paid-api.ts'; +import { tasksAction } from './commands/tasks.ts'; import { EXIT_CODES } from './utils.ts'; const cli = cac('paynode-402'); @@ -82,6 +83,14 @@ cli return invokePaidApiAction(apiId, options); }); +// Command: tasks +cli + .command('tasks [subcommand]', 'Manage background tasks (subcommands: list, clean)') + .option('--clean', 'Clean all task files immediately') + .action((subcommand, options) => { + return tasksAction(subcommand, options); + }); + cli.help(); cli.version(pkg.version); diff --git a/utils.ts b/utils.ts index 68bd985..bd0fa2b 100755 --- a/utils.ts +++ b/utils.ts @@ -29,8 +29,12 @@ export const GLOBAL_CONFIG = { /** * Skill version for JSON output metadata. */ +import sdkPkg from '@paynodelabs/sdk-js/package.json'; +/** + * Skill version and runtime SDK version. + */ export const SKILL_VERSION = pkg.version; -export const SDK_VERSION = '2.4.0'; // Updated to Protocol Baseline +export const SDK_VERSION = sdkPkg.version; // Dynamically resolved from installed package /** * Shared base options for all CLI commands. From 64be551520ef986463173bb7591d95910e578462 Mon Sep 17 00:00:00 2001 From: zq0951 Date: Thu, 2 Apr 2026 20:17:25 +0800 Subject: [PATCH 7/8] chore: update repository URL and add bugs and homepage fields to package.json --- package.json | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 2f5c4c5..a7dbf7c 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,10 @@ }, "repository": { "type": "git", - "url": "git+ssh://git@github.com/PayNodeLabs/paynode-402-cli.git" - } + "url": "https://github.com/PayNodeLabs/paynode-402-cli.git" + }, + "bugs": { + "url": "https://github.com/PayNodeLabs/paynode-402-cli/issues" + }, + "homepage": "https://github.com/PayNodeLabs/paynode-402-cli#readme" } From 8a0d993a320265b65c3cc722bf4636fdd377e56c Mon Sep 17 00:00:00 2001 From: zq0951 Date: Thu, 2 Apr 2026 20:20:28 +0800 Subject: [PATCH 8/8] chore: bump version to 2.5.1 and update package metadata configuration --- index.ts | 0 package.json | 6 +++--- 2 files changed, 3 insertions(+), 3 deletions(-) mode change 100644 => 100755 index.ts diff --git a/index.ts b/index.ts old mode 100644 new mode 100755 diff --git a/package.json b/package.json index a7dbf7c..3d8b6ff 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,11 @@ { "name": "@paynodelabs/paynode-402-cli", - "version": "2.5.0", + "version": "2.5.1", "description": "The official command-line interface for the PayNode protocol. Designed for AI Agents to execute stateless micro-payments via HTTP 402.", "type": "module", "main": "./index.ts", "bin": { - "paynode-402": "./index.ts" + "paynode-402": "index.ts" }, "keywords": [ "paynode", @@ -31,7 +31,7 @@ }, "repository": { "type": "git", - "url": "https://github.com/PayNodeLabs/paynode-402-cli.git" + "url": "git+https://github.com/PayNodeLabs/paynode-402-cli.git" }, "bugs": { "url": "https://github.com/PayNodeLabs/paynode-402-cli/issues"