Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
node_modules/
*.lockb
bun.lock
.env
dist/
/tmp/
66 changes: 61 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <YOUR_WALLET_ADDRESS>
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 <URL>` | Access a protected resource by handling the 402 challenge |
| `list-paid-apis` | Discover payable APIs from the PayNode Marketplace |
| `get-api-detail <id>` | Inspect one marketplace API |
| `invoke-paid-api <id>` | Invoke a marketplace API using the 402 flow |

### Global Flags
- `--network <name>`: `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)
67 changes: 67 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

126 changes: 126 additions & 0 deletions commands/check.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
30 changes: 30 additions & 0 deletions commands/get-api-detail.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading