Skill: paynodelabs/payment-402 (version 1.0.1)

--- name: payment-402 description: Access protected APIs and digital resources via the x402 "Payment Required" protocol on Base L2. This skill automates cryptographic handshakes and USDC micro-payments to unlock restricted content. Built for "Pay-per-Resource" agentic billing. license: MIT compatibility: Requires Bun (v1.0+). required_env_vars: - CLIENT_PRIVATE_KEY required_binaries: - bun primary_credential: CLIENT_PRIVATE_KEY install: bun install --- # 💳 PayNode 402 Protocol Skill (Base & Bun) Use this skill to automate **Resource-based Billing**. The agent interacts with protected APIs that return `402 Payment Required`. Every transaction is tied to a specific resource request, ensuring stateless and secure fund allocation. ## 🚀 Pre-execution Readiness Before any request, verify the environment and balances: 1. **Initialize Dependencies**: Run `bun install` once within this directory. 2. **Verify Environment**: Ensure a `.env` file is created in the root directory. The agent **MUST** verify the existence and exact format of this variable before proceeding: ```env # [REQUIRED] PayNode Burner Wallet Private Key # Format: 66-character hex string MUST start with '0x' CLIENT_PRIVATE_KEY=0xYourBurnerWalletPrivateKeyHere... ``` 3. **Check Balances**: Run `bun run check-wallet --json --network <NETWORK>`. 4. **Validate Readiness**: Ensure `checks.can_pay` is `true`. ### Expected `check-wallet` Output: ```json { "status": "success", "address": "0x...", "eth": 0.01, "usdc": 100.0, "checks": { "gas_ready": true, // Has > 0.001 ETH for gas "tokens_ready": true, // Has USDC for payments "can_pay": true // Ready for x402 flow } } ``` ## 🛠️ x402 Execution Workflow Follow this sequence to unlock protected resources. ### Step 1: Identify Resource Determine the URL of the protected API you need to access. ### Step 2: Request & Handle Challenge Run `bun run request "<URL>" --json --network <NETWORK>`. - **Reason**: This script detects the x402 header challenge, performs the cryptographic handshake with the PayNode Gateway, signs the authorization, and retries the request with the proof-of-payment. ### Step 3: Capture Resource Upon success, capture the JSON response. The `txHash` will be included in the logs if a payment occurred. --- ## 🛡️ Security Rules & Agent Safety > [!IMPORTANT] > **ADHERE TO THESE RULES TO PROTECT FUNDS.** ### 💸 Burner Wallet Policy (Minimal Exposure) The `CLIENT_PRIVATE_KEY` **MUST** belong to a dedicated "burner" wallet acting as petty cash. **NEVER** hold excessive funds. Maintain balances strictly sufficient for immediate tasks (e.g., < 10 USDC). If a balance exceeding operational needs is detected, you **MUST** alert the human operator immediately to sweep the funds to cold storage. ### 🤖 Autonomous Usage & Human Consent If you are operating autonomously, be aware that this skill can **sign and broadcast blockchain transactions** without manual confirmation. - **Threshold Limit**: For any single transaction exceeding `2.0 USDC`, you **MUST** request explicit human approval before execution. - **Merchant Verification**: Before initial payment to a new domain, perform a `read_url_content` check on the merchant's reputation (if possible) or alert the user. ### 🕵️ Zero-Trust Pre-Flight Validation (Resource Consistency) Prevent "Confused Deputy" attacks. Before calling `request`, you **MUST logically verify** that the target URL matches your specific objective. - Ask: _Is this resource exactly what I need? Is the cost reasonable for this resource?_ - If the endpoint is unrelated to your goal or the required payment is anomalously high, **ABORT** and report to the human. - **Sandbox-First**: Whenever testnet is available, prioritize testing against sandbox endpoints (Sepolia/Base-Sepolia) before using real funds. --- ## ⚠️ Gotchas & Troubleshooting - **Handshake Failures**: If `request` fails with a 402 exit code, the gateway may be down or the merchant's contract is invalid. - **Private Key Format**: The `CLIENT_PRIVATE_KEY` **MUST** start with `0x`. - **Gas vs. Tokens**: You can have $1M USDC but if `gas_ready` is `false` (low ETH), the transaction **WILL** fail. - **Exit Codes**: - `2`: Invalid arguments. - `3`: Auth Failure (Check `.env` key format). - `4`: Network/RPC Failure. - `402`: Challenge detected but automated resolution failed. --- ## 🧪 Sandbox & Testing Always test against sandbox endpoints first. - **Mint Test Tokens**: `bun run mint-test-tokens --json` - **Test Merchant (Doodle Wall)**: `https://paynode.dev/api/pom?network=testnet` (**Note**: Use `&network=mainnet` for mainnet Doodle Wall) - **Reference**: See [Testing Guide](references/TESTING.md) for more details. --- ## 🔗 References - **Official Docs**: [PayNode Documentation](https://github.com/PayNodeLabs/paynode-docs) - **SDKs**: [Node.js SDK](@paynodelabs/sdk-js) - **Hub**: [PayNode Hub](https://github.com/PayNodeLabs/paynode-web) - **Testing**: [Detailed Testing & Examples](references/TESTING.md)

path: package.json
content: {
  "name": "@paynode/skill-payment",
  "version": "1.0.1",
  "type": "module",
  "scripts": {
    "check-wallet": "bun run scripts/check-wallet.ts",
    "request": "bun run scripts/request.ts",
    "mint-test-tokens": "bun run scripts/mint-test-tokens.ts"
  },
  "dependencies": {
    "@paynodelabs/sdk-js": "1.4.0",
    "dotenv": "16.4.5",
    "ethers": "6.13.0"
  }
}

path: scripts/check-wallet.ts
content: #!/usr/bin/env bun
import { ethers } from 'ethers';
import { parseArgs, getPrivateKey, resolveNetwork, reportError, EXIT_CODES } from './utils.ts';

async function main() {
    const args = parseArgs();

    if (args.isHelp) {
        console.log(
            `
Usage: bun run scripts/check-wallet.ts [OPTIONS]

Checks the wallet balance (ETH and USDC) on Base L2.

Options:
  --network <NAME>  Optional. Network to use: mainnet or testnet/sepolia.
  --rpc <URL>       Optional. Custom RPC URL for the network.
  --json            Output results in JSON format.
  --help            Show this help message.

Environment:
  CLIENT_PRIVATE_KEY  Required. 64-character hex string starting with 0x.

Example:
  bun run scripts/check-wallet.ts --network testnet --json
`.trim()
        );
        process.exit(EXIT_CODES.SUCCESS);
    }

    const pk = getPrivateKey(args.isJson);

    try {
        const { provider, usdcAddress, chainId, networkName, isSandbox } = await resolveNetwork(
            args.rpcUrl,
            args.network
        );
        const wallet = new ethers.Wallet(pk, provider);
        const address = wallet.address;

        const [ethBalance, usdcBalance] = await Promise.all([
            provider.getBalance(address),
            new ethers.Contract(
                usdcAddress,
                ['function balanceOf(address) view returns (uint256)'],
                provider
            ).balanceOf(address)
        ]);

        const ethValue = parseFloat(ethers.formatEther(ethBalance));
        const usdcValue = parseFloat(ethers.formatUnits(usdcBalance, 6));

        // Thresholds
        const isGasReady = ethValue >= 0.001;
        const isTokensReady = usdcValue > 0;

        if (args.isJson) {
            console.log(
                JSON.stringify(
                    {
                        status: 'success',
                        address: address,
                        eth: ethValue,
                        usdc: usdcValue,
                        network: networkName,
                        chainId: chainId,
                        is_sandbox: isSandbox,
                        checks: {
                            gas_ready: isGasReady,
                            tokens_ready: isTokensReady,
                            can_pay: isGasReady && isTokensReady
                        }
                    },
                    null,
                    2
                )
            );
        } else {
            console.log(`\n### 💳 Wallet Status (${networkName})`);
            console.log(`| Attribute | Value | Status |`);
            console.log(`| :--- | :--- | :--- |`);
            console.log(`| **Address** | \`${address}\` | - |`);
            console.log(`| **ETH (Gas)** | ${ethValue.toFixed(6)} ETH | ${isGasReady ? '✅ Ready' : '⚠️ Low'} |`);
            console.log(`| **USDC** | ${usdcValue.toFixed(2)} USDC | ${isTokensReady ? '✅ Ready' : '⚠️ Empty'} |`);

            if (!isGasReady) {
                console.warn(
                    `\n> [!WARNING]\n> Gas balance is critically low (< 0.001 ETH). Please deposit ETH to \`${address}\`.`
                );
            }
            if (!isTokensReady && !isSandbox) {
                console.warn(
                    `\n> [!NOTE]\n> USDC balance is 0. If this is a test, use \`scripts/mint-test-tokens.ts\` on Sepolia.`
                );
            }
        }
    } catch (error: any) {
        reportError(`Network Error: ${error.message}`, args.isJson, EXIT_CODES.NETWORK_ERROR);
    }
}

main().catch((err) => {
    console.error(err);
    process.exit(1);
});


path: scripts/mint-test-tokens.ts
content: #!/usr/bin/env bun
import { ethers } from 'ethers';
import { parseArgs, getPrivateKey, resolveNetwork, reportError, EXIT_CODES } from './utils.ts';

async function main() {
    const args = parseArgs();

    if (args.isHelp) {
        console.log(
            `
Usage: bun run scripts/mint-test-tokens.ts [OPTIONS]

Mints 1,000 Test USDC on the Base Sepolia Testnet.

Options:
  --network <NAME>  Optional. Network to use: testnet/sepolia.
  --rpc <URL>       Optional. Custom RPC URL (must be a Sepolia network).
  --json            Output results in JSON format.
  --help            Show this help message.

Environment:
  CLIENT_PRIVATE_KEY  Required. Must have some Sepolia ETH for gas.

Example:
  bun run scripts/mint-test-tokens.ts --json
`.trim()
        );
        process.exit(EXIT_CODES.SUCCESS);
    }

    const pk = getPrivateKey(args.isJson);

    try {
        const { provider, usdcAddress, chainId, networkName, isSandbox } = await resolveNetwork(
            args.rpcUrl,
            args.network || 'testnet'
        );

        if (!isSandbox) {
            throw new Error(`Minting is only supported on Sepolia. Current ChainID: ${chainId}`);
        }

        const wallet = new ethers.Wallet(pk, provider);

        if (!args.isJson) {
            console.error(`💰 Connecting to ${networkName}...`);
            console.error(`🔗 Minting 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('1000', 6);

        if (!args.isJson) console.error('⏳ Sending mint transaction...');
        const tx = await usdc.mint(wallet.address, amount);

        if (!args.isJson) console.error('⏳ Waiting for confirmation...');
        const receipt = await tx.wait();

        if (receipt.status !== 1) {
            throw new Error('Transaction reverted.');
        }

        if (args.isJson) {
            console.log(
                JSON.stringify(
                    {
                        status: 'success',
                        txHash: tx.hash,
                        address: wallet.address,
                        amount: '1000',
                        [REDACTED_SECRET]',
                        network: networkName
                    },
                    null,
                    2
                )
            );
        } else {
            console.log(`✅ SUCCESS: You now have 1,000 Test USDC on ${networkName}!`);
        }
    } catch (error: any) {
        reportError(`Minting failed: ${error.message}`, args.isJson, EXIT_CODES.NETWORK_ERROR);
    }
}

main().catch((err) => {
    console.error(err);
    process.exit(1);
});


path: scripts/request.ts
content: #!/usr/bin/env bun
import { PayNodeAgentClient, RequestOptions } from '@paynodelabs/sdk-js';
import { parseArgs, getPrivateKey, resolveNetwork, reportError, EXIT_CODES } from './utils.ts';

async function main() {
    const args = parseArgs();

    if (args.isHelp || args.params.length < 1) {
        console.log(
            `
Usage: bun run scripts/request.ts [OPTIONS] <URL> [KEY=VALUE ...]

Accesses a URL and automatically handles on-chain payments if it returns a 402 error.

Arguments:
  URL            The protected API endpoint URL.
  KEY=VALUE ...  Optional. Additional parameters to assemble as JSON body (for POST/PUT) or query string (for GET).

Options:
  -X, --method <METHOD>  HTTP method (GET, POST, etc.). Default: GET (or POST if parameters are provided).
  -d, --data <DATA>      Raw request body data.
  -H, --header <HEADER>  HTTP header in "Key: Value" format. Can be used multiple times.
  --network <NAME>       Optional. Network to use: mainnet or testnet/sepolia.
  --rpc <URL>            Optional. Custom RPC URL for the network.
  --json                 Output results (and error) in JSON format.
  --help                 Show this help message.

Environment:
  CLIENT_PRIVATE_KEY     Required. 64-character hex string starting with 0x.

Example:
  bun run scripts/request.ts "https://api.example.com/data" --json --network testnet
`.trim()
        );
        process.exit(args.params.length < 1 && !args.isHelp ? EXIT_CODES.INVALID_ARGS : EXIT_CODES.SUCCESS);
    }

    let url = args.params[0];
    const extraParams = args.params.slice(1);
    const pk = getPrivateKey(args.isJson);

    try {
        const { rpcUrls, networkName } = await resolveNetwork(args.rpcUrl, args.network);

        const kvParams: Record<string, string> = {};
        for (const p of extraParams) {
            const [k, ...v] = p.split('=');
            if (k && v.length > 0) {
                kvParams[k] = v.join('=');
            }
        }

        const method = args.method?.toUpperCase() || (args.data || Object.keys(kvParams).length > 0 ? 'POST' : 'GET');
        const options: RequestOptions = {
            method,
            headers: args.headers || {}
        };

        if (method === 'GET') {
            const urlObj = new URL(url);
            for (const [k, v] of Object.entries(kvParams)) {
                urlObj.searchParams.append(k, v);
            }
            url = urlObj.toString();
        } else {
            if (args.data) {
                options.body = args.data;
            } else if (Object.keys(kvParams).length > 0) {
                options.json = kvParams;
            }
        }

        if (!args.isJson) {
            console.error(`🌐 [${method}] Requesting ${url} on ${networkName}...`);
        }

        const client = new PayNodeAgentClient(pk, rpcUrls);
        const response = await client.requestGate(url, options);

        const contentType = response.headers.get('content-type') || '';
        let body: any;

        if (contentType.includes('application/json')) {
            body = await response.json();
        } else {
            body = await response.text();
        }

        if (args.isJson) {
            console.log(
                JSON.stringify(
                    {
                        status: 'success',
                        url,
                        method,
                        http_status: response.status,
                        content_type: contentType,
                        network: networkName,
                        data: body
                    },
                    null,
                    2
                )
            );
        } else {
            if (typeof body === 'object') {
                console.log(JSON.stringify(body, null, 2));
            } else {
                console.log(body);
            }
        }
    } catch (error: any) {
        reportError(`Request failed: ${error.message}`, args.isJson, EXIT_CODES.NETWORK_ERROR);
    }
}

main().catch((err) => {
    console.error(err);
    process.exit(1);
});


path: scripts/utils.ts
content: #!/usr/bin/env bun
import { ethers } from 'ethers';
import * as dotenv from 'dotenv';
import {
    PAYNODE_ROUTER_ADDRESS,
    PAYNODE_ROUTER_ADDRESS_SANDBOX,
    BASE_USDC_ADDRESS,
    BASE_USDC_ADDRESS_SANDBOX,
    BASE_RPC_URLS,
    BASE_RPC_URLS_SANDBOX
} from '@paynodelabs/sdk-js';

// Load environment variables
dotenv.config();

/**
 * Note: In Bun's self-contained mode, versioned imports auto-resolve at runtime.
 */

export interface CliConfig {
    isJson: boolean;
    isHelp: boolean;
    isDryRun: boolean;
    rpcUrl?: string;
    network?: string;
    method?: string;
    data?: string;
    headers?: Record<string, string>;
    params: string[];
}

export interface NetworkConfig {
    provider: ethers.JsonRpcProvider;
    chainId: number;
    isSandbox: boolean;
    rpcUrl: string;
    rpcUrls: string[];
    usdcAddress: string;
    routerAddress: string;
    networkName: string;
}

/**
 * Exit codes for agentic use.
 */
export const EXIT_CODES = {
    SUCCESS: 0,
    GENERIC_ERROR: 1,
    INVALID_ARGS: 2,
    AUTH_FAILURE: 3,
    NETWORK_ERROR: 4,
    PAYMENT_REQUIRED: 402 // Specialized exit code for X402 challenges if needed
};

/**
 * Utility to mask sensitive data for logging.
 */
export function maskString(str: string, visiblePrefix = 6, visibleSuffix = 4): string {
    if (!str || str.length <= visiblePrefix + visibleSuffix) return '********';
    return `${str.substring(0, visiblePrefix)}...${str.substring(str.length - visibleSuffix)}`;
}

/**
 * Parses common CLI flags and arguments.
 * Standard format: [OPTIONS] <PARAMS...>
 * Options: --json, --help, --rpc <URL>, --network <name>, --dry-run
 */
export function parseArgs(): CliConfig {
    const args = process.argv.slice(2);
    const config: CliConfig = {
        isJson: false,
        isHelp: false,
        isDryRun: false,
        params: []
    };

    for (let i = 0; i < args.length; i++) {
        const arg = args[i];
        if (arg === '--json') {
            config.isJson = true;
        } else if (arg === '--help') {
            config.isHelp = true;
        } else if (arg === '--dry-run') {
            config.isDryRun = true;
        } else if (arg === '--rpc') {
            config.rpcUrl = args[++i];
        } else if (arg === '--network') {
            config.network = args[++i];
        } else if (arg === '-X' || arg === '--method') {
            config.method = args[++i];
        } else if (arg === '-d' || arg === '--data') {
            config.data = args[++i];
        } else if (arg === '-H' || arg === '--header') {
            const header = args[++i];
            if (header) {
                const [key, ...valueParts] = header.split(':');
                if (key && valueParts.length > 0) {
                    if (!config.headers) config.headers = {};
                    config.headers[key.trim()] = valueParts.join(':').trim();
                }
            }
        } else if (!arg.startsWith('--')) {
            config.params.push(arg);
        }
    }

    return config;
}

/**
 * Validates existence and format of CLIENT_PRIVATE_KEY.
 */
export function getPrivateKey(isJson: boolean): string {
    const pk = process.env.CLIENT_PRIVATE_KEY;
    if (!pk) {
        reportError('CLIENT_PRIVATE_KEY not found in environment. Check .env file.', isJson, EXIT_CODES.AUTH_FAILURE);
    }
    // Simple format check (0x followed by 64 hex characters)
    const pkRegex = /^0x[0-9a-fA-F]{64}$/;
    if (!pkRegex.test(pk!)) {
        reportError('Invalid CLIENT_PRIVATE_KEY format. Must be a 0x-prefixed 64-character hex string.', isJson, EXIT_CODES.AUTH_FAILURE);
    }
    return pk!;
}

/**
 * Resolves network configuration (Provider and Addresses) based on RPC URL or Network Name.
 */
export async function resolveNetwork(rpcUrl?: string, network?: string): Promise<NetworkConfig> {
    let finalRpcUrls: string[] = [];

    const networkAlias = (network || '').toLowerCase();
    const isTestnetRequest = 
        networkAlias === 'testnet' || 
        networkAlias === 'sepolia' || 
        networkAlias === 'base-sepolia' || 
        networkAlias === '84532' ||
        networkAlias === 'base-testnet';

    if (rpcUrl) {
        finalRpcUrls = [rpcUrl];
    } else {
        if (isTestnetRequest) {
            finalRpcUrls = BASE_RPC_URLS_SANDBOX;
        } else {
            // Default to mainnet if nothing specified or 'mainnet'/'base' passed
            finalRpcUrls = BASE_RPC_URLS;
        }
    }

    const primaryRpc = finalRpcUrls[0];
    let provider: ethers.JsonRpcProvider;

    try {
        provider = new ethers.JsonRpcProvider(primaryRpc, undefined, { staticNetwork: true });
    } catch (e) {
        throw new Error(`Invalid RPC URL format: ${primaryRpc}`);
    }

    let chainId: bigint;
    try {
        const networkInfo = await provider.getNetwork();
        chainId = networkInfo.chainId;
    } catch (error: any) {
        throw new Error(`Failed to connect to RPC (${primaryRpc}): ${error.message}`);
    }

    const isSandbox = chainId === 84532n; // Base Sepolia
    const networkName = isSandbox ? 'Base Sepolia (84532)' : 'Base L2 (8453)';

    return {
        provider,
        chainId: Number(chainId),
        isSandbox,
        rpcUrl: primaryRpc,
        rpcUrls: finalRpcUrls,
        usdcAddress: isSandbox ? BASE_USDC_ADDRESS_SANDBOX : BASE_USDC_ADDRESS,
        routerAddress: isSandbox ? PAYNODE_ROUTER_ADDRESS_SANDBOX : PAYNODE_ROUTER_ADDRESS,
        networkName: networkName
    };
}

/**
 * Standard error reporter.
 */
export function reportError(message: string, isJson: boolean, exitCode: number = EXIT_CODES.GENERIC_ERROR) {
    if (isJson) {
        // Output JSON errors to stdout so agents can decode consistently
        console.log(JSON.stringify({ status: 'error', message, exitCode }));
    } else {
        console.error(`❌ ERROR: ${message} (Exit Code: ${exitCode})`);
    }
    process.exit(exitCode);
}


path: references/TESTING.md
content: # Sandbox & Testing (Base Sepolia)

This guide covers how to test the PayNode payment flow in a safe environment using the Base Sepolia Testnet.

## 1. Prerequisites

- Your wallet must have **Base Sepolia ETH** for gas.
- You must have **MockUSDC** on the testnet.

## 2. Mint Test Tokens

If your testnet wallet is low on USDC, run the minting script to receive **1,000 MockUSDC**:

```bash
# Execute from the skill root
bun run mint-test-tokens --json
```

Alternatively, you can mint manually via [Basescan Sepolia](https://sepolia.basescan.org/address/0x109AEddD656Ed2761d1e210E179329105039c784#writeContract).

## 3. Test Merchant: Doodle Wall (PoM)

The **Doodle Wall** is a live testnet merchant where you can "leave your mark" by completing a 0.01 USDC payment.

### API Specifications

| Method   | Description                                                                              | Input                                                                                                                                                                                | Output                                                                                                                                                                                                                                |
| :------- | :--------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **POST** | **Handshake**: Get payment parameters.<br>**Verification**: Verify on-chain transaction. | **Query**: `network` ("testnet") <br> **Header**: `x-paynode-receipt` (Optional), `x-paynode-order-id` (Required for verification) <br> **Body**: `agent_name` (Optional) | **402 (Payment Required)**: Returns headers (`x-paynode-*`) with contract, merchant, amount, token, order ID, etc.<br>**200 (Success)**: Returns JSON object with `status: "SUCCESS"`.<br>**400/500 (Error)**: Returns error message. |

- **Basic URL**: `https://paynode.dev/api/pom`
- **Method**: `POST`
- **Query Parameter**: `network=testnet`
- **Request Body (JSON)**:
  - `agent_name`: (String, Optional) The name you want to display on the leaderboard.

### How to Call (Using CLI)

Using our `request` script, it will automatically handle the 402 handshake, perform the payment, and verify the receipt in one go:

```bash
# Handshake + Auto-pay + Verification
bun run request "https://paynode.dev/api/pom?network=testnet" \
    --network testnet \
    -X POST \
    agent_name="My AI Agent" \
    --json
```

- **Web UI**: Visit [paynode.dev/pom?network=testnet](https://paynode.dev/pom?network=testnet) to see your Agent's name on the leaderboard.

## 4. Sandbox Configuration

- **Network**: Base Sepolia (Chain ID: `84532`)
- **Router Address**: [REDACTED_SECRET]
- **USDC Address**: [REDACTED_SECRET]
- **Merchant Address**: [REDACTED_SECRET] (Treasury for demo)


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)