From 0289963ba571859962d7e3a5fe55d11caad92a3a Mon Sep 17 00:00:00 2001 From: Eriks Reks Date: Mon, 5 Oct 2026 11:07:32 -0400 Subject: [PATCH] docs(plugin): align Radius skills with CLI and SDK 0.3.0 --- .changeset/README.md | 6 + .claude-plugin/marketplace.json | 1 - .github/workflows/plugin-evals.yml | 16 +- README.md | 29 +- plugins/radius/.claude-plugin/plugin.json | 7 +- plugins/radius/README.md | 30 + .../radius/skills/dripping-faucet/SKILL.md | 228 +------ plugins/radius/skills/radius-dev/SKILL.md | 33 +- .../radius-dev/references/events-viem.md | 579 ++---------------- .../skills/radius-dev/references/gotchas.md | 16 +- .../radius-dev/references/micropayments.md | 36 +- .../radius-dev/references/typescript-viem.md | 20 +- plugins/radius/skills/x402/SKILL.md | 47 +- .../x402/evaluations/x402-integration.json | 56 +- .../skills/x402/references/x402-cli-cast.md | 14 +- .../skills/x402/references/x402-client.md | 40 +- .../skills/x402/references/x402-server.md | 84 ++- .../radius/skills/x402/scripts/x402-pay.mjs | 23 +- scripts/validate_plugin.py | 40 ++ 19 files changed, 403 insertions(+), 902 deletions(-) create mode 100644 plugins/radius/README.md diff --git a/.changeset/README.md b/.changeset/README.md index 85cd9bc..d86f8e8 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -5,6 +5,12 @@ changeset: `pnpm changeset`, pick the package(s), pick patch / minor / major, wr entry that will appear in the changelog. The `changeset` GitHub check refuses PRs that change a package without one; add the `no changeset` label for changes that need no release note. +The Claude plugin under `plugins/radius` is released separately. Skill and plugin changes do not +need a changeset unless the same PR also changes a publishable package. Bump the plugin version in +`plugins/radius/.claude-plugin/plugin.json` for release content changes; plugin CI enforces this. +If the skill needs a new CLI or SDK API, publish the npm package first, then release the plugin +guidance with the minimum supported package version. + Releasing is automated by `.github/workflows/release.yml`. Merging changesets to `main` opens (or refreshes) a **Version Packages** PR on the `changeset-release/main` branch: it runs `pnpm version-packages` (bumps versions, writes CHANGELOG.md files, bumps `radius-cli` whenever `radius-sdk` diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index ab4c99b..531641c 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -11,7 +11,6 @@ "plugins": [ { "name": "radius-dev", - "version": "0.0.2", "description": "Radius Network tools for x402 payments, blockchain development, and testnet faucet", "author": { "name": "Radius Technology Systems" diff --git a/.github/workflows/plugin-evals.yml b/.github/workflows/plugin-evals.yml index 1fd9fa2..eb3408f 100644 --- a/.github/workflows/plugin-evals.yml +++ b/.github/workflows/plugin-evals.yml @@ -25,9 +25,23 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + fetch-depth: 0 # compare the plugin version with the PR base persist-credentials: false - name: Validate plugin structure and scenarios run: python3 scripts/validate_plugin.py + - name: Require a plugin version bump for release content + if: github.event_name == 'pull_request' + env: + BASE_REF: origin/${{ github.base_ref }} + run: python3 scripts/validate_plugin.py --base-ref "$BASE_REF" + - name: Install Claude Code + run: | + curl -fsSL https://claude.ai/install.sh | bash -s 2.1.272 + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + - name: Validate Claude plugin and marketplace + run: | + claude plugin validate plugins/radius --strict + claude plugin validate . --strict eval: needs: validate @@ -49,8 +63,6 @@ jobs: run: | curl -fsSL https://claude.ai/install.sh | bash -s 2.1.272 echo "$HOME/.local/bin" >> "$GITHUB_PATH" - - name: Validate Claude plugin - run: claude plugin validate plugins/radius - name: Run Claude plugin evals working-directory: plugins/radius shell: bash diff --git a/README.md b/README.md index b0a27b1..c8f3a39 100644 --- a/README.md +++ b/README.md @@ -20,18 +20,37 @@ Runnable SDK examples (seller worker, agent buyer, browser demo dapp) are in [`p The [Radius Claude Code plugin](plugins/radius) contains the `radius-dev`, `x402`, and `dripping-faucet` skills. The marketplace manifest is at [`.claude-plugin/marketplace.json`](.claude-plugin/marketplace.json). These files -live outside `packages/*`, so plugin changes do not enter the npm release flow -or need a changeset. +live outside `packages/*`. The plugin has its own version in +[`plugins/radius/.claude-plugin/plugin.json`](plugins/radius/.claude-plugin/plugin.json) +and does not enter the npm Changesets release flow. The marketplace's +`metadata.version` describes the catalog, not the plugin; the marketplace entry +does not duplicate the plugin version. In Claude Code, install from this repository: ```text -/plugin marketplace add https://github.com/radiustechsystems/radius-cli.git +/plugin marketplace add radiustechsystems/radius-cli /plugin install radius-dev@radius-cli ``` -For skill changes, run `python3 scripts/validate_plugin.py` and -`claude plugin validate plugins/radius`. The path-filtered +Update an installed plugin with `claude plugin update radius-dev@radius-cli`. +For every release-worthy change under `plugins/radius/skills`, or to the plugin +manifest or README, bump the plugin manifest version in the same PR: patch for +corrections, minor for new capabilities, major for incompatible changes. The +plugin CI check enforces a version increase independently of Changesets. +After merging to `main`, users of this GitHub marketplace can update; marketplace +automatic updates are off by default unless users enable them. A plugin release +may be tagged `radius-dev--v` for a traceable release point. + +When CLI or SDK work changes a documented API, audit the affected skills in +the package PR. Publish plugin instructions for a new package API only after +the corresponding npm version is available, and state the minimum package +version in that guidance. This avoids directing installed plugin users to code +that has merged but has not yet been published to npm. + +For skill changes, run `python3 scripts/validate_plugin.py`, +`claude plugin validate plugins/radius --strict`, and +`claude plugin validate . --strict`. The path-filtered [`plugin-evals.yml`](.github/workflows/plugin-evals.yml) runs Claude Code plugin evals on trusted plugin changes. Add or update cases under [`plugins/radius/evals`](plugins/radius/evals) with each behavior change, inspect diff --git a/plugins/radius/.claude-plugin/plugin.json b/plugins/radius/.claude-plugin/plugin.json index 619cbea..e6d9d6f 100644 --- a/plugins/radius/.claude-plugin/plugin.json +++ b/plugins/radius/.claude-plugin/plugin.json @@ -1,8 +1,11 @@ { "name": "radius-dev", - "version": "0.0.2", + "version": "0.0.3", "description": "Radius Network tools for x402 payments, blockchain development, and testnet faucet", "author": { "name": "Radius Technology Systems" - } + }, + "homepage": "https://github.com/radiustechsystems/radius-cli/tree/main/plugins/radius", + "repository": "https://github.com/radiustechsystems/radius-cli", + "license": "MIT" } diff --git a/plugins/radius/README.md b/plugins/radius/README.md new file mode 100644 index 0000000..ac8b795 --- /dev/null +++ b/plugins/radius/README.md @@ -0,0 +1,30 @@ +# Radius development plugin + +The `radius-dev` Claude Code plugin provides three skills: + +- `radius-dev` for Radius Network application development +- `x402` for Radius payment integrations +- `dripping-faucet` for testnet faucet workflows + +The SDK examples target `radius-sdk` 0.3.0 or later, and terminal wallet +examples target `radius-cli` 0.3.0 or later. Install the peer dependency +needed by the SDK entry point you use (`viem` for `/client`, `hono` for `/hono`). + +Install it from the [Radius CLI repository](https://github.com/radiustechsystems/radius-cli): + +```sh +claude plugin marketplace add radiustechsystems/radius-cli +claude plugin install radius-dev@radius-cli +``` + +After a new plugin version is published, update an existing installation with +`claude plugin update radius-dev@radius-cli`. Automatic updates depend on the +user's marketplace setting. + +The plugin is versioned independently from the `radius-cli` and `radius-sdk` +npm packages. Its version is in `.claude-plugin/plugin.json`; the GitHub +marketplace lists the plugin at `.claude-plugin/marketplace.json` in the +repository root. See the [repository release instructions](https://github.com/radiustechsystems/radius-cli#agent-skills) +for validation and compatibility rules. + +This plugin is distributed under the repository's [MIT license](https://github.com/radiustechsystems/radius-cli/blob/main/LICENSE). diff --git a/plugins/radius/skills/dripping-faucet/SKILL.md b/plugins/radius/skills/dripping-faucet/SKILL.md index 359ce13..1eebc34 100644 --- a/plugins/radius/skills/dripping-faucet/SKILL.md +++ b/plugins/radius/skills/dripping-faucet/SKILL.md @@ -128,210 +128,42 @@ When running bash commands as an agent (e.g. in Claude Code), **every shell invo Every `curl` and `radius-cli` call in the examples below includes an explicit `echo` of its output. This is not optional — without it, the agent sees `(No output)` and cannot proceed. -## TypeScript Example (viem) +## TypeScript Example (radius-sdk) + +For an app that already has an operator-approved signer, use `fund()` from +`radius-sdk/client`. It signs the faucet's EIP-191 challenge and requests a drip. +Install `radius-sdk` and its `viem` peer dependency. A faucet API success is not +receipt confirmation: check the transaction hash before reporting funds as +received. ```typescript -import { defineChain, createPublicClient, http, erc20Abi, isAddress, formatUnits } from 'viem'; -import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; - -// --- Network configuration --- -type Network = 'testnet' | 'mainnet'; - -const NETWORK_CONFIG: Record = { - testnet: { - faucetUrl: 'https://testnet.radiustech.xyz/api/v1/faucet', - chain: defineChain({ - id: 72344, - name: 'Radius Testnet', - nativeCurrency: { decimals: 18, name: 'RUSD', symbol: 'RUSD' }, - rpcUrls: { default: { http: ['https://rpc.testnet.radiustech.xyz'] } }, - blockExplorers: { - default: { name: 'Radius Testnet Explorer', url: 'https://testnet.radiustech.xyz' }, - }, - fees: radiusFees, - }), - }, - mainnet: { - faucetUrl: 'https://network.radiustech.xyz/api/v1/faucet', - chain: defineChain({ - id: 723487, - name: 'Radius Mainnet', - nativeCurrency: { decimals: 18, name: 'RUSD', symbol: 'RUSD' }, - rpcUrls: { default: { http: ['https://rpc.radiustech.xyz'] } }, - blockExplorers: { - default: { name: 'Radius Explorer', url: 'https://network.radiustech.xyz' }, - }, - fees: radiusFees, - }), - }, -}; - -const SBC_CONTRACT = '0x33ad9e4BD16B69B5BFdED37D8B5D9fF9aba014Fb' as const; -const SBC_DECIMALS = 6; - -const radiusTestnet = defineChain({ - id: 72344, - name: 'Radius Testnet', - nativeCurrency: { decimals: 18, name: 'RUSD', symbol: 'RUSD' }, - rpcUrls: { default: { http: ['https://rpc.testnet.radiustech.xyz'] } }, - blockExplorers: { - default: { name: 'Radius Testnet Explorer', url: 'https://testnet.radiustech.xyz' }, - }, +import { createPublicClient, http } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; +import { radiusTestnet } from 'radius-sdk'; +import { createRadiusFetch } from 'radius-sdk/client'; + +const key = process.env.RADIUS_PRIVATE_KEY; +if (!key) throw new Error('Configure RADIUS_PRIVATE_KEY through your secrets manager'); +const signer = privateKeyToAccount(key as `0x${string}`); +const wallet = createRadiusFetch({ + network: 'testnet', + signer, + maxPerRequest: '$0.01', // required by the paying client; fund() does not spend it }); +const drip = await wallet.fund(); +if (!drip.txHash) throw new Error('Faucet accepted the request without a transaction hash'); -// --- Wallet setup --- -// Option A: We have an existing key (user's wallet, stored in .env) -// const privateKey = process.env.PRIVATE_KEY as `0x${string}`; - -// Option B: We only have an address (no signer — current deployments will reject it) -// const addressOnly = '0x...' as `0x${string}`; - -// Option C: Create a new wallet (we own the key) -const privateKey = generatePrivateKey(); -const account = privateKeyToAccount(privateKey); -// SECURITY: only log the address, never the key -console.log('Wallet address:', account.address); - -// If using Option B, set account to null — the signed fallback will not be available. -// The dripWithRetry function below handles this. - -// --- Faucet drip with eval loop --- -async function dripWithRetry( - address: string, - /** Pass null if no operator-approved signer is available. */ - signer: { signMessage: (args: { message: string }) => Promise } | null, - network: Network = 'testnet', - maxAttempts = 3 -): Promise<{ success: boolean; network: Network; tx_hash?: string; balance?: string; error?: string }> { - if (!isAddress(address)) { - return { success: false, network, error: `Invalid address: ${address}` }; - } - - const { faucetUrl, chain } = NETWORK_CONFIG[network]; - - // Both deployed faucets currently require a signature. Fail fast when no - // approved signer is available rather than making a request known to fail. - if (!signer) { - return { - success: false, - network, - error: 'signature_required_but_no_signer', - }; - } - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - // 1. Try unsigned drip first (skipping straight to signed flow on mainnet is an - // optimisation you may apply, but the unsigned attempt is safe to make here - // since the signed fallback is implemented below). - const dripRes = await fetch(`${faucetUrl}/drip`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ address, token: 'SBC' }), - }); - let drip = await dripRes.json(); - - // Error responses currently use { error: { code, message, ... } }. - let errorCode = typeof drip.error === 'string' ? drip.error : drip.error?.code; - let errorMessage = typeof drip.error === 'string' ? drip.message : drip.error?.message; - - // 2. If signature required, fall back to signed flow (only if we have a signer) - if (errorCode === 'signature_required') { - if (!signer) { - return { - success: false, - network, - error: 'signature_required_but_no_signer', - }; - } - console.log('Signature required — switching to signed flow'); - - // Check status - const statusRes = await fetch(`${faucetUrl}/status/${address}?token=SBC`); - const status = await statusRes.json(); - if (status.rate_limited) { - const waitMs = status.retry_after_ms ?? 60_000; - console.log(`Rate limited. Waiting ${waitMs}ms (attempt ${attempt}/${maxAttempts})`); - // On mainnet, retry_after_ms can be ~86_400_000 (24 hours). Do not loop — report to user. - if (waitMs > 3_600_000) { - return { success: false, network, error: `rate_limited_long_wait_ms:${waitMs}` }; - } - await new Promise((r) => setTimeout(r, waitMs)); - continue; - } - - // Get challenge — extract only the "message" field - const challengeRes = await fetch(`${faucetUrl}/challenge/${address}?token=SBC`); - const challenge = await challengeRes.json(); - const message: string = challenge.message; - - // Sign (EIP-191) - const signature = await signer.signMessage({ message }); - - // Retry drip with signature - const signedRes = await fetch(`${faucetUrl}/drip`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ address, token: 'SBC', signature }), - }); - drip = await signedRes.json(); - errorCode = typeof drip.error === 'string' ? drip.error : drip.error?.code; - errorMessage = typeof drip.error === 'string' ? drip.message : drip.error?.message; - } - - // 3. Evaluate - if (drip.success) { - // Verify on-chain (the receipt is ground truth, not the API response) - const publicClient = createPublicClient({ chain, transport: http() }); - const balance = await publicClient.readContract({ - address: SBC_CONTRACT, - abi: erc20Abi, - functionName: 'balanceOf', - args: [address as `0x${string}`], - }); - const formatted = formatUnits(balance, SBC_DECIMALS); - console.log(`SBC balance (${network}): ${formatted}`); - return { success: true, network, tx_hash: drip.tx_hash, balance: formatted }; - } - - // Critique: map error to action - console.error(`Attempt ${attempt} failed: ${errorCode} — ${errorMessage ?? ''}`); - - if (errorCode === 'rate_limited') { - const waitMs = drip.error?.retry_after_ms ?? drip.retry_after_ms ?? 60_000; - // On mainnet, a rate_limited response means ~24h. Stop immediately. - if (waitMs > 3_600_000) { - return { success: false, network, error: `rate_limited_long_wait_ms:${waitMs}` }; - } - await new Promise((r) => setTimeout(r, waitMs)); - continue; - } - if (errorCode === 'invalid_signature') { - // Re-fetch challenge in case it rotated - continue; - } - if (['faucet_empty', 'sbc_not_configured', 'internal_error'].includes(errorCode)) { - return { success: false, network, error: errorCode }; - } - } - - return { success: false, network, error: 'max_attempts_exceeded' }; -} - -// Testnet — create a throwaway wallet and sign the configured challenge -const testnetResult = await dripWithRetry(account.address, account, 'testnet'); -console.log('Testnet result:', JSON.stringify(testnetResult, null, 2)); - -// Mainnet — use an existing wallet with an approved signer; signature currently required -// const mainnetAccount = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`); -// const mainnetResult = await dripWithRetry(mainnetAccount.address, mainnetAccount, 'mainnet'); -// console.log('Mainnet result:', JSON.stringify(mainnetResult, null, 2)); - -// If you only have an address and no signer on testnet (unsigned-only): -// dripWithRetry(addressOnly, null, 'testnet'); -// NOTE: the currently deployed services require a signature, so address-only -// calls return immediately with signature_required_but_no_signer. +const publicClient = createPublicClient({ chain: radiusTestnet.chain, transport: http() }); +const receipt = await publicClient.waitForTransactionReceipt({ hash: drip.txHash }); +if (receipt.status !== 'success') throw new Error('Faucet transaction reverted'); +console.log('Funded address:', wallet.address, 'transaction:', drip.txHash); ``` +For mainnet, choose `network: 'mainnet'` deliberately and use an existing, +approved signer. If you only have an address, this signed flow cannot run; use +the web faucet or obtain signer access through the wallet owner. Do not create +an unmanaged wallet or ask anyone to paste a private key. + ## Agent-created wallet For a fresh wallet in an agent demo, use `radius-cli` with a scoped diff --git a/plugins/radius/skills/radius-dev/SKILL.md b/plugins/radius/skills/radius-dev/SKILL.md index ed191f1..783cd64 100644 --- a/plugins/radius/skills/radius-dev/SKILL.md +++ b/plugins/radius/skills/radius-dev/SKILL.md @@ -1,6 +1,6 @@ --- name: radius-dev -description: End-to-end Radius Network development playbook. Stablecoin-native EVM with sub-second finality. Uses plain viem (defineChain, createPublicClient, createWalletClient) for all TypeScript integration. wagmi for React wallet integration. Foundry for smart contract development and testing. Also covers Hardhat/ethers.js compatibility and EIP-7966 synchronous transactions. Micropayment patterns (pay-per-visit content, real-time API metering, streaming payments), x402 protocol integration, Radius x402 facilitators (Permit2 + EIP-2612), stablecoin-native fees via Turnstile, ERC-20 operations, event watching, production gotchas, and EVM compatibility differences from Ethereum. +description: Develop on Radius with radius-sdk for x402 payments, balances, ERC-20 and Permit2 actions, radius-cli for terminal wallets, viem for general EVM work, wagmi for React, and Foundry for contracts. Covers network configuration, Turnstile fees, events, and Radius EVM differences. published: true user-invocable: true --- @@ -24,12 +24,12 @@ Use this Skill when the user asks for: ## Default stack decisions (opinionated) -1) **TypeScript: viem (directly, no wrapper SDK)** -- Use `defineChain` from viem to create the Radius chain definition. -- Use `createPublicClient` for reads, `createWalletClient` for writes. -- Use viem's native `watchContractEvent`, `getLogs`, and `watchBlockNumber` for event monitoring. -- Do NOT use `@radiustechsystems/sdk` — it is deprecated. Use plain viem for everything. -- ethers.js v6 also works with no overrides. This skill defaults to viem for examples. +1) **TypeScript: radius-sdk for Radius payments and actions; viem for general EVM work** +- Use `radius-sdk/hono` for Hono x402 seller middleware and `radius-sdk/client` for paying fetch, balances, ERC-20, Permit2, and transfer watching. Install the peer dependency for the entry point used (`hono` or `viem`). +- Import network definitions, amounts, and receipt helpers from `radius-sdk`. +- Use viem `createPublicClient` and `createWalletClient` for other contract reads and writes, with `radiusTestnet.chain` or `radiusMainnet.chain` from the SDK. Define a chain directly when an app cannot use the SDK. +- `@radiustechsystems/sdk` is the deprecated package; it is distinct from `radius-sdk`. +- ethers.js v6 also works for general EVM interactions. 2) **UI: wagmi + @tanstack/react-query for React apps** - Define the Radius chain via `defineChain` and pass it to wagmi's `createConfig`. @@ -100,10 +100,9 @@ wallet handling. ``` `--x402-threshold` is a display-unit limit such as SBC, not a raw 6-decimal integer. Do not omit it in automated agent flows. -- **App code and embedded integrations:** use viem directly - (`createPublicClient`, `createWalletClient`, `privateKeyToAccount`) and load - keys from environment variables or a secrets manager. Never inline or log - private keys. +- **App code and embedded integrations:** use `radius-sdk` for its Radius + payment and token actions, with viem clients where needed. Load keys from + environment variables or a secrets manager. Never inline or log private keys. - **Smart contract development and advanced EVM workflows:** use Foundry (`forge`/`cast`) for contract builds, tests, deployment scripts, low-level contract reads, and debugging. Foundry is no longer the default agent wallet @@ -204,21 +203,21 @@ Standard ERC-20 interactions, storage operations, and events work unchanged. ### 2. Pick the right building blocks - UI: wagmi + Radius chain via `defineChain` + React hooks -- Scripts/backends: plain viem (`createPublicClient`, `createWalletClient`, `defineChain`) +- Scripts/backends: `radius-sdk/client` for Radius payment, balance, token, and transfer actions; viem clients for other EVM work - Smart contracts: Foundry (`forge` / `cast`) + OpenZeppelin - Agent wallet and terminal execution: `radius-cli` -- Micropayments: viem + server-side verification + wallet integration -- x402: Middleware pattern with Radius facilitator for settlement (Permit2 or EIP-2612) — see the **x402** skill for full implementation details +- Micropayments: `radius-sdk/hono` and `radius-sdk/client` for x402; viem for direct on-chain patterns +- x402: use the SDK's buyer and seller APIs; see the **x402** skill for implementation details ### 3. Implement with Radius-specific correctness Always be explicit about: -- Defining the Radius chain with `defineChain` -- Using `createPublicClient` for reads and `createWalletClient` for writes (plain viem) +- Using the SDK's network definitions when the SDK is installed, or defining a Radius chain with `defineChain` for plain viem integrations +- Using `createPublicClient` for reads and `createWalletClient` for writes when working directly with viem - Stablecoin fee model (no ETH needed, no gas price bidding) - Sub-second finality (no need to wait for multiple confirmations) - SBC uses 6 decimals (use `parseUnits(amount, 6)`, NOT `parseEther`) - RUSD (native token) uses 18 decimals (use `parseEther` for native transfers) -- The wallet convention above: `radius-cli`/`RADIUS_HOME` for local agent wallets and terminal execution, viem for app code, Foundry for smart-contract workflows, and no raw keys in agent-visible CLI arguments +- The wallet convention above: `radius-cli`/`RADIUS_HOME` for local agent wallets and terminal execution, SDK plus viem for app code, Foundry for smart-contract workflows, and no raw keys in agent-visible CLI arguments - Gas price from `eth_gasPrice` RPC (viem handles this automatically via the chain definition) ### 4. Watch for production gotchas diff --git a/plugins/radius/skills/radius-dev/references/events-viem.md b/plugins/radius/skills/radius-dev/references/events-viem.md index 8b883ae..9e48c83 100644 --- a/plugins/radius/skills/radius-dev/references/events-viem.md +++ b/plugins/radius/skills/radius-dev/references/events-viem.md @@ -1,564 +1,61 @@ -# Events Reference (viem) +# Events on Radius -## Overview +Radius block numbers are timestamps in milliseconds. Its RPC limits a single `eth_getLogs` range to 1,000,000 block numbers (about 16.7 minutes). Polling with viem's `watchContractEvent` can get stuck after a longer gap because its fallback queries the whole missed range at once. Use the chunked `radius-sdk/client` actions for ERC-20 `Transfer` events. -Radius supports standard EVM event watching and log queries using **plain viem** — no wrapper SDK needed. Use `publicClient.watchContractEvent()` for real-time subscriptions, `publicClient.getLogs()` for historical queries, and `publicClient.watchBlockNumber()` for block monitoring. +## ERC-20 transfer history and watching -## Setup - -Create a public client with the Radius chain definition (see [typescript-viem.md](typescript-viem.md) for the full `defineChain` pattern): +Install `radius-sdk` and its `viem` peer dependency. The Radius preset chain supplies SBC as the default token. Pass `token` for a different ERC-20. ```typescript import { createPublicClient, http } from 'viem'; -import { radiusTestnet } from './chain'; // See SKILL.md "Canonical chain definitions" to create this file - -const publicClient = createPublicClient({ - chain: radiusTestnet, - transport: http(), -}); -``` - -## Watch block numbers - -Subscribe to new blocks: - -```typescript -const unwatch = publicClient.watchBlockNumber({ - onBlockNumber: (blockNumber) => { - console.log('New block:', blockNumber); - // NOTE: On Radius, block numbers are timestamps in milliseconds, not sequential heights - }, - onError: (error) => { - console.error('Block watch error:', error); - }, -}); - -// Stop watching when done -unwatch(); -``` - -## Watch ERC-20 transfers - -### Basic transfer watching - -```typescript -import { erc20Abi } from 'viem'; - -const unwatch = publicClient.watchContractEvent({ - address: '0x33ad9e4BD16B69B5BFdED37D8B5D9fF9aba014Fb', // Token contract - abi: erc20Abi, - eventName: 'Transfer', - onLogs: (logs) => { - for (const log of logs) { - console.log('Transfer:', { - from: log.args.from, - to: log.args.to, - value: log.args.value, - txHash: log.transactionHash, - }); - } - }, - onError: (error) => { - console.error('Transfer watch error:', error); - }, -}); - -// Stop watching when done -unwatch(); -``` - -### Filter by sender - -```typescript -const unwatch = publicClient.watchContractEvent({ - address: '0x33ad9e4BD16B69B5BFdED37D8B5D9fF9aba014Fb', - abi: erc20Abi, - eventName: 'Transfer', - args: { - from: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', - }, - onLogs: (logs) => { - console.log('Outgoing transfers:', logs.length); - }, -}); -``` - -### Filter by recipient - -```typescript -const unwatch = publicClient.watchContractEvent({ - address: '0x33ad9e4BD16B69B5BFdED37D8B5D9fF9aba014Fb', - abi: erc20Abi, - eventName: 'Transfer', - args: { - to: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', - }, - onLogs: (logs) => { - console.log('Incoming transfers:', logs.length); - }, -}); -``` - -### Watch transfers for a specific address (sent and received) - -To watch both directions, set up two watchers: - -```typescript -const ADDRESS = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'; -const TOKEN = '0x33ad9e4BD16B69B5BFdED37D8B5D9fF9aba014Fb'; - -const unwatchOutgoing = publicClient.watchContractEvent({ - address: TOKEN, - abi: erc20Abi, - eventName: 'Transfer', - args: { from: ADDRESS }, - onLogs: (logs) => { - for (const log of logs) { - console.log('Sent:', log.args.value, 'to', log.args.to); - } - }, -}); - -const unwatchIncoming = publicClient.watchContractEvent({ - address: TOKEN, - abi: erc20Abi, - eventName: 'Transfer', - args: { to: ADDRESS }, - onLogs: (logs) => { - for (const log of logs) { - console.log('Received:', log.args.value, 'from', log.args.from); - } - }, -}); - -// Cleanup both -function stopWatching() { - unwatchOutgoing(); - unwatchIncoming(); -} -``` - -## Watch approvals - -```typescript -const unwatch = publicClient.watchContractEvent({ - address: '0x33ad9e4BD16B69B5BFdED37D8B5D9fF9aba014Fb', - abi: erc20Abi, - eventName: 'Approval', - onLogs: (logs) => { - for (const log of logs) { - console.log('Approval granted:', { - owner: log.args.owner, - spender: log.args.spender, - value: log.args.value, - }); - } - }, -}); -``` - -## Watch custom events - -For any custom event, use `parseAbiItem` to define the event signature: - -```typescript -import { parseAbiItem } from 'viem'; - -const unwatch = publicClient.watchContractEvent({ - address: '0x...your-contract...', - abi: [ - parseAbiItem( - 'event PaymentReceived(address indexed payer, uint256 amount, bytes32 orderId)' - ), - ], - eventName: 'PaymentReceived', - onLogs: (logs) => { - for (const log of logs) { - console.log('Payment received:', log.args); - } - }, -}); -``` - -## Watch all events from a contract - -Use `watchEvent` for unfiltered logs from a contract address: - -```typescript -const unwatch = publicClient.watchEvent({ - address: '0x33ad9e4BD16B69B5BFdED37D8B5D9fF9aba014Fb', - onLogs: (logs) => { - for (const log of logs) { - console.log('Event:', log); - } - }, -}); -``` - -## Query historical logs - -### Basic log query - -```typescript -import { erc20Abi, parseAbiItem } from 'viem'; - -const transferEvent = parseAbiItem( - 'event Transfer(address indexed from, address indexed to, uint256 value)' -); - -const logs = await publicClient.getLogs({ - address: '0x33ad9e4BD16B69B5BFdED37D8B5D9fF9aba014Fb', - event: transferEvent, - fromBlock: 1000000n, - toBlock: 1010000n, -}); - -console.log(`Found ${logs.length} transfer events`); - -for (const log of logs) { - console.log({ - from: log.args.from, - to: log.args.to, - value: log.args.value, - block: log.blockNumber, - txHash: log.transactionHash, - }); -} -``` - -### Query with filters - -```typescript -const logs = await publicClient.getLogs({ - address: '0x33ad9e4BD16B69B5BFdED37D8B5D9fF9aba014Fb', - event: transferEvent, - args: { - from: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', - }, - fromBlock: 1000000n, - toBlock: 1010000n, -}); -``` - -### Paginated log queries for large ranges - -Radius **requires** an `address` filter on all `eth_getLogs` calls (error `-33014` without it). The block range is capped at 1,000,000 units (~16 min 40 sec due to ms-granularity block numbers; error `-33002` if exceeded). Paginate large queries by chunking: - -```typescript -async function getLogsPaginated( - publicClient: PublicClient, - params: { - address: `0x${string}`; - event: any; - fromBlock: bigint; - toBlock: bigint; - chunkSize?: number; - onProgress?: (info: { currentBlock: bigint; totalBlocks: bigint; logsFetched: number }) => void; - } -) { - const { address, event, fromBlock, toBlock, chunkSize = 1000, onProgress } = params; - const allLogs: any[] = []; - const totalBlocks = toBlock - fromBlock; - - let currentFrom = fromBlock; - while (currentFrom <= toBlock) { - const currentTo = currentFrom + BigInt(chunkSize) - 1n > toBlock - ? toBlock - : currentFrom + BigInt(chunkSize) - 1n; - - try { - const logs = await publicClient.getLogs({ - address, - event, - fromBlock: currentFrom, - toBlock: currentTo, - }); - allLogs.push(...logs); - } catch (err) { - // If "block range too wide", reduce chunk size and retry - const msg = (err as Error).message || ''; - if (msg.includes('block range') && chunkSize > 10) { - const smallerChunk = Math.floor(chunkSize / 2); - const subLogs = await getLogsPaginated(publicClient, { - address, - event, - fromBlock: currentFrom, - toBlock: currentTo, - chunkSize: smallerChunk, - onProgress, - }); - allLogs.push(...subLogs); - currentFrom = currentTo + 1n; - continue; - } - throw err; - } - - onProgress?.({ - currentBlock: currentTo, - totalBlocks, - logsFetched: allLogs.length, - }); - - currentFrom = currentTo + 1n; - } - - return allLogs; -} - -// Usage -const logs = await getLogsPaginated(publicClient, { - address: '0x33ad9e4BD16B69B5BFdED37D8B5D9fF9aba014Fb', - event: transferEvent, - fromBlock: 0n, - toBlock: 1000000n, - chunkSize: 1000, - onProgress: ({ currentBlock, totalBlocks, logsFetched }) => { - const pct = totalBlocks > 0n - ? (Number(currentBlock) / Number(totalBlocks) * 100).toFixed(1) - : '100'; - console.log(`Progress: ${pct}% (${logsFetched} logs)`); - }, -}); -``` - -## Decode event logs - -Parse raw logs into typed event data using viem's `decodeEventLog`: - -```typescript -import { decodeEventLog, erc20Abi } from 'viem'; - -// Decode a single log -const decoded = decodeEventLog({ - abi: erc20Abi, - data: rawLog.data, - topics: rawLog.topics, -}); - -console.log('Event:', decoded.eventName); -console.log('Args:', decoded.args); -``` - -For filtering decoded logs by event name: - -```typescript -import { parseEventLogs } from 'viem'; - -const parsed = parseEventLogs({ - abi: erc20Abi, - logs: rawLogs, - eventName: 'Transfer', -}); - -for (const transfer of parsed) { - console.log('Transfer:', transfer.args); -} -``` - -## WebSocket transport - -Use WebSocket for lower-latency real-time event subscriptions: - -```typescript -import { createPublicClient, webSocket } from 'viem'; - -const wsClient = createPublicClient({ - chain: radiusTestnet, - transport: webSocket('wss://rpc.testnet.radiustech.xyz'), -}); - -// Real-time log subscription (the ONLY supported WebSocket subscription type) -const unwatch = wsClient.watchContractEvent({ - address: contractAddress, - abi: contractAbi, - eventName: 'Transfer', - onLogs: (logs) => { - // process logs - }, -}); -``` - -> **Warning:** WebSocket RPC on Radius requires an API key with elevated privileges. **Only `logs` subscriptions are supported.** `newHeads`, `newPendingTransactions`, and `syncing` subscriptions return error `-32602`. `watchBlockNumber` via WebSocket will NOT work — use HTTP polling (`eth_blockNumber` on a 10-30 second interval) for block tracking instead. Poll-based filter methods (`eth_newFilter`, `eth_getFilterChanges`, etc.) are also unsupported. Contact [support@radiustech.xyz](mailto:support@radiustech.xyz) for WebSocket access. +import { radiusTestnet } from 'radius-sdk'; +import { erc20Actions, transferKey } from 'radius-sdk/client'; -### WebSocket with reconnection - -```typescript -const wsClient = createPublicClient({ - chain: radiusTestnet, - transport: webSocket('wss://rpc.testnet.radiustech.xyz', { - reconnect: { - attempts: 5, - delay: 2000, - }, - keepAlive: { - interval: 30_000, // Ping every 30 seconds - }, - }), -}); -``` - -## Complete example: payment monitor - -Build a real-time payment tracker that watches for incoming token transfers: - -```typescript -import { - createPublicClient, - http, - formatEther, - erc20Abi, - type Log, -} from 'viem'; - -// Use the radiusTestnet chain definition from typescript-viem.md - -const publicClient = createPublicClient({ - chain: radiusTestnet, +const client = createPublicClient({ + chain: radiusTestnet.chain, transport: http(), -}); +}).extend(erc20Actions()); -const SERVICE_ADDRESS = '0x742d35Cc6634C0532925a3b844Bc9e7595f7E9F1'; -const TOKEN_ADDRESS = '0x33ad9e4BD16B69B5BFdED37D8B5D9fF9aba014Fb'; +const recipient = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8'; +const head = await client.getBlockNumber(); +const fromBlock = head > 1_000_000n ? head - 1_000_000n : 0n; +const toBlock = head; +const history = await client.getTransfers({ to: recipient, fromBlock, toBlock }); +for (const transfer of history) console.log(transfer.transactionHash, transfer.amount); -interface PaymentRecord { - from: string; - amount: string; - blockNumber: bigint; - transactionHash: string; - timestamp: Date; -} - -const payments: PaymentRecord[] = []; - -// Watch for incoming payments -const unwatch = publicClient.watchContractEvent({ - address: TOKEN_ADDRESS, - abi: erc20Abi, - eventName: 'Transfer', - args: { - to: SERVICE_ADDRESS, - }, - onLogs: (logs) => { - for (const log of logs) { - const payment: PaymentRecord = { - from: log.args.from!, - amount: formatEther(log.args.value!), - blockNumber: log.blockNumber!, - transactionHash: log.transactionHash!, - timestamp: new Date(), - }; - - payments.push(payment); - console.log(`Payment received: ${payment.amount} USD from ${payment.from}`); - } - }, - onError: (error) => { - console.error('Payment watch error:', error); +// Load these from durable storage when resuming after a restart. +const seen = new Set(); +let checkpoint: bigint | undefined; +const unwatch = client.watchTransfers({ + to: recipient, + ...(checkpoint === undefined ? {} : { fromBlock: checkpoint + 1n }), + onTransfer: async (transfer) => { + const key = transferKey(transfer); // transactionHash:logIndex + if (seen.has(key)) return; + console.log('New transfer:', transfer); + seen.add(key); }, + onCheckpoint: (block) => { checkpoint = block; }, + onError: (error) => console.error('Transfer watch error:', error), }); -// Graceful shutdown -process.on('SIGINT', () => { - unwatch(); - console.log('Payment monitor stopped'); - console.log(`Total payments received: ${payments.length}`); - process.exit(0); -}); - -console.log(`Monitoring payments to ${SERVICE_ADDRESS}...`); -``` - -## Best practices - -### Always unwatch when done - -Clean up subscriptions to prevent memory leaks: - -```typescript -const unwatch = publicClient.watchBlockNumber({ - onBlockNumber: (blockNumber) => console.log(blockNumber), -}); - -// Later, when component unmounts or service stops -unwatch(); +// Call unwatch() when the process no longer needs the subscription. ``` -In React components, use the cleanup pattern: +`getTransfers` pages wide ranges into bounded `eth_getLogs` calls. `watchTransfers` delivers in block and log order and checkpoints after a fully delivered chunk. Delivery is at least once: persist the checkpoint and deduplicate transfer keys in the same durable store as the downstream effect. An in-memory `Set` in the example only demonstrates the API; it does not survive a restart. Without `fromBlock`, the watcher starts at the current head and follows new transfers only. -```typescript -useEffect(() => { - const unwatch = publicClient.watchContractEvent({ ... }); - return () => unwatch(); -}, []); -``` +For a custom ERC-20, pass `token: '0x…'` to both actions or extend with `erc20Actions({ token })`. For other contract events, use viem `getLogs` with an address filter and explicit windows no wider than 1,000,000 block numbers. Persist a cursor and split gaps before polling again. Do not use block hashes or transaction indexes as unique event IDs; use transaction hash plus log index. -### Handle errors gracefully +## Block observation -```typescript -publicClient.watchContractEvent({ - address: '0x...', - abi: erc20Abi, - eventName: 'Transfer', - onLogs: (logs) => { - // Process events - }, - onError: (error) => { - console.error('Watch error:', error); - // Consider restarting the watcher after a delay - }, -}); -``` - -### Use HTTP for polling, WebSocket for real-time - -| Transport | Best for | Trade-off | -|-----------|----------|-----------| -| **HTTP** | Polling-based watching | More resilient, higher latency | -| **WebSocket** | Real-time event delivery | Lower latency, requires connection management and elevated RPC access | - -### Batch reads with multicall - -When fetching multiple independent contract reads, batch them: +For a lightweight head signal, viem's `watchBlockNumber` is still useful: ```typescript -const [totalSupply, balance, decimals] = await Promise.all([ - publicClient.readContract({ - address: TOKEN_ADDRESS, - abi: erc20Abi, - functionName: 'totalSupply', - }), - publicClient.readContract({ - address: TOKEN_ADDRESS, - abi: erc20Abi, - functionName: 'balanceOf', - args: [account.address], - }), - publicClient.readContract({ - address: TOKEN_ADDRESS, - abi: erc20Abi, - functionName: 'decimals', - }), -]); -``` - -Or use Multicall3 for a single RPC call: - -```typescript -const results = await publicClient.multicall({ - contracts: [ - { address: TOKEN_ADDRESS, abi: erc20Abi, functionName: 'totalSupply' }, - { address: TOKEN_ADDRESS, abi: erc20Abi, functionName: 'balanceOf', args: [account.address] }, - { address: TOKEN_ADDRESS, abi: erc20Abi, functionName: 'decimals' }, - ], +const stop = client.watchBlockNumber({ + onBlockNumber: (block) => console.log('Radius timestamp block:', block), + onError: (error) => console.error(error), }); +// stop() when finished ``` -### Network configuration for events - -| Setting | Value | -|---------|-------| -| **HTTP RPC** | `https://rpc.testnet.radiustech.xyz` | -| **WebSocket RPC** | `wss://rpc.testnet.radiustech.xyz` (requires elevated access) | -| **Chain ID** | `72344` (testnet) / `723487` (mainnet) | -| **Supported subscriptions** | `logs` only (`newHeads` and `newPendingTransactions` not available) | \ No newline at end of file +A block signal is not a transfer history cursor. For reconciliation, query the relevant transaction receipt or the SDK's settlement helper rather than inferring an outcome from a changing block number. diff --git a/plugins/radius/skills/radius-dev/references/gotchas.md b/plugins/radius/skills/radius-dev/references/gotchas.md index 596b3b4..2ac6afb 100644 --- a/plugins/radius/skills/radius-dev/references/gotchas.md +++ b/plugins/radius/skills/radius-dev/references/gotchas.md @@ -264,7 +264,7 @@ async function readNonce( > **For full x402 implementation details, see the x402 skill.** -x402 on Radius supports two settlement methods. Which one applies depends on the facilitator's `/supported` response. +x402 on Radius supports multiple payment schemes and transfer methods. Check the facilitator's `/supported` response and the server's actual 402 offer before choosing one. ### Permit2 flow (`permit2`) — recommended @@ -272,19 +272,13 @@ The payer signs a Permit2 `SignatureTransfer` message. The facilitator submits i - The **spender** in the signed Permit2 message is the `x402ExactPermit2Proxy` at `0x402085c248EeA27D92E8b30b2C58ed07f9E20001` (same across all supported EVM chains — see the [x402 exact EVM spec](https://github.com/coinbase/x402/blob/main/specs/schemes/exact/scheme_exact_evm.md)). - Integrators do **not** need to discover or fund a facilitator-specific settlement wallet. -- The payer must have approved the Permit2 contract (`0x000000000022D473030F116dDEE9F6B43aC78BA3`) for the payment token beforehand. +- The payer needs a token allowance for Permit2. The `eip2612GasSponsoring` extension can establish it during the first payment when the facilitator advertises support; otherwise the SDK can send a separate approval. -### EIP-2612 flow (`eip2612GasSponsoring`) +### EIP-2612 gas sponsoring extension -The facilitator uses a two-step on-chain settlement: +`eip2612GasSponsoring` is an extension to the Permit2 payment flow, not a separate payment scheme. It lets a fresh SBC wallet sign an EIP-2612 permit granting the Permit2 contract an allowance while the facilitator pays the transaction gas. The x402 payment is still authorized with the Permit2 signature and settled through the proxy. -1. `permit(owner, spender, value, deadline, v, r, s)` — sets ERC-20 allowance -2. `transferFrom(owner, paymentAddress, value)` — moves tokens - -Both transactions are sent by the facilitator from its own settlement wallet (the integrator does not operate this wallet). This means: -- The facilitator's settlement wallet address is the `spender` in the EIP-2612 permit. -- The facilitator covers gas (RUSD). -- The `paymentAddress` (token recipient) can differ from the facilitator's settlement wallet. +The EIP-2612 permit's spender is the canonical Permit2 contract (`0x000000000022D473030F116dDEE9F6B43aC78BA3`), not the facilitator or merchant. Use `createRadiusFetch` from `radius-sdk/client` for buyer signing so the permit and payment signatures match the advertised offer. --- diff --git a/plugins/radius/skills/radius-dev/references/micropayments.md b/plugins/radius/skills/radius-dev/references/micropayments.md index ce29e6f..b1bb5dd 100644 --- a/plugins/radius/skills/radius-dev/references/micropayments.md +++ b/plugins/radius/skills/radius-dev/references/micropayments.md @@ -1133,32 +1133,32 @@ setInterval(() => { ## x402 Facilitator Network -> **For full x402 implementation details** (server-side payment gating, client-side permit signing, -> facilitator API reference, and tested code patterns), see the **x402** skill. +> **For current SDK buyer and seller examples**, see the **x402** skill. Use +> `radius-sdk/client` and `radius-sdk/hono` for application integrations. -x402 is the HTTP-native payment protocol used for per-request API billing and micropayments. Facilitators handle on-chain settlement on behalf of clients. The recommended settlement method is **Permit2** — use it unless you have a specific reason to use EIP-2612. +x402 is the HTTP-native payment protocol used for per-request API billing and micropayments. Facilitators handle on-chain settlement on behalf of clients. The Radius facilitator currently advertises v2 `exact` with Permit2; other facilitators and server offers can differ. Inspect `/supported` and the actual 402 challenge rather than selecting a signing method in advance. **Permit2 (`permit2`) — recommended:** -The payer signs a Permit2 `SignatureTransfer` message. The spender is the canonical `x402ExactPermit2Proxy` contract at `0x402085c248EeA27D92E8b30b2C58ed07f9E20001` (same address across all supported EVM chains — see the [x402 exact EVM spec](https://github.com/coinbase/x402/blob/main/specs/schemes/exact/scheme_exact_evm.md)). No facilitator-specific wallet discovery is needed. Prerequisite: the payer must have approved the Permit2 contract (`0x000000000022D473030F116dDEE9F6B43aC78BA3`) for the payment token. +The payer signs a Permit2 `SignatureTransfer` message. The spender is the canonical `x402ExactPermit2Proxy` contract at `0x402085c248EeA27D92E8b30b2C58ed07f9E20001` (same address across all supported EVM chains — see the [x402 exact EVM spec](https://github.com/coinbase/x402/blob/main/specs/schemes/exact/scheme_exact_evm.md)). No facilitator-specific wallet discovery is needed. Permit2 needs a token allowance; the sponsoring extension can establish it on a fresh wallet, while other facilitators may require a separate approval. **EIP-2612 with gas sponsoring (`eip2612GasSponsoring`):** -The payer signs an EIP-2612 permit; the facilitator handles on-chain submission (`permit` + `transferFrom`) using its own gas. +This is an extension to a Permit2 payment. The payer signs an EIP-2612 permit granting Permit2 a token allowance; the facilitator sponsors the first approval while settling the Permit2 payment. It is not an alternative transfer method. -Which methods a facilitator supports is returned by its `/supported` endpoint (check the `methods` and `extensions` arrays). +The facilitator's `/supported` response lists kinds and extensions. `radius-sdk` checks it when constructing a seller challenge. ### Endorsed facilitators -| Facilitator | URL | Networks | Settlement methods | Notes | +| Facilitator | URL | Networks | Advertised capability | Notes | |-------------|-----|----------|--------------------|-------| -| **Radius (mainnet)** | `https://facilitator.radiustech.xyz` | `eip155:723487` | `permit2`, `eip2612GasSponsoring` | **Recommended** — Radius-operated | -| **Radius (testnet)** | `https://facilitator.testnet.radiustech.xyz` | `eip155:72344` | `permit2`, `eip2612GasSponsoring` | **Recommended** — Radius-operated | +| **Radius (mainnet)** | `https://facilitator.radiustech.xyz` | `eip155:723487` | v2 exact Permit2; EIP-2612 gas sponsoring extension | Radius-operated | +| **Radius (testnet)** | `https://facilitator.testnet.radiustech.xyz` | `eip155:72344` | v2 exact Permit2; EIP-2612 gas sponsoring extension | Radius-operated | | Stablecoin.xyz | `https://x402.stablecoin.xyz` | Mainnet (723487) + Testnet (72344) | See `/supported` | Absorbs gas costs | | FareSide | `https://facilitator.x402.rs` | Testnet only (72344) | See `/supported` | Free for testing | | Middlebit | `https://middlebit.com` | Mainnet (723487) | See `/supported` | Multi-facilitator routing + analytics | ### x402 v2 protocol summary -Protocol versions (v1, v2) define the HTTP transport — headers, encoding, and CAIP-2 identifiers. Settlement methods (`permit2`, `eip2612GasSponsoring`) define how tokens move on-chain. They are independent: a v2 facilitator may support either or both settlement methods. +Protocol versions (v1, v2) define the HTTP transport — headers, encoding, and CAIP-2 identifiers. A payment kind declares its scheme and transfer method. `eip2612GasSponsoring` is an extension to Permit2, not a separate settlement method. v2 uses CAIP-2 network identifiers and standardized HTTP headers: @@ -1184,17 +1184,23 @@ v2 uses CAIP-2 network identifiers and standardized HTTP headers: { "scheme": "exact", "network": "eip155:723487", - "methods": ["permit2"], - "extensions": ["eip2612GasSponsoring"] + "x402Version": 2, + "extra": { + "assetTransferMethod": "permit2", + "name": "Stable Coin", + "version": "1" + } } - ] + ], + "extensions": ["eip2612GasSponsoring"], + "signers": {} } ``` Key fields for integrators: - `kinds[].network` — CAIP-2 chain identifier the facilitator settles on -- `kinds[].methods` — settlement methods supported (e.g., `permit2`) -- `kinds[].extensions` — additional capabilities (e.g., `eip2612GasSponsoring`) +- `kinds[].extra.assetTransferMethod` — token transfer method (e.g., `permit2`) +- top-level `extensions` — additional capabilities (e.g., `eip2612GasSponsoring`) > **Note:** Verify this response shape against a live `GET /supported` call — field names or nesting may evolve between facilitator releases. diff --git a/plugins/radius/skills/radius-dev/references/typescript-viem.md b/plugins/radius/skills/radius-dev/references/typescript-viem.md index 94d63b1..e04f88c 100644 --- a/plugins/radius/skills/radius-dev/references/typescript-viem.md +++ b/plugins/radius/skills/radius-dev/references/typescript-viem.md @@ -1,22 +1,32 @@ -# TypeScript Reference (viem) +# TypeScript Reference (Radius SDK and viem) ## Overview -All Radius TypeScript integration uses **plain viem** — no wrapper SDK. You define the Radius chain with `defineChain`, create clients with `createPublicClient` and `createWalletClient`, and interact with contracts using viem's standard APIs. +Use `radius-sdk/hono` for x402 seller middleware and `radius-sdk/client` for paying fetch, balances, ERC-20, Permit2, and settlement actions. The root `radius-sdk` entry point provides networks, amounts, receipts, and errors. Use viem directly for general EVM contract operations. The older `@radiustechsystems/sdk` package is deprecated; it is not `radius-sdk`. ## Installation ```bash -pnpm add viem +pnpm add radius-sdk viem # buyer and viem action examples +# or: pnpm add radius-sdk hono # Hono seller middleware ``` Requirements: -- Node.js >= 18 +- Node.js >= 20 for radius-sdk - TypeScript 5+ (recommended) ## Chain definition -Standard `defineChain`: +When the SDK is installed, use its chain definitions instead of copying network constants: + +```typescript +import { radiusMainnet, radiusTestnet } from 'radius-sdk'; +import { createPublicClient, http } from 'viem'; + +const client = createPublicClient({ chain: radiusTestnet.chain, transport: http() }); +``` + +For a plain viem integration without the SDK, use `defineChain`: ```typescript import { defineChain } from 'viem'; diff --git a/plugins/radius/skills/x402/SKILL.md b/plugins/radius/skills/x402/SKILL.md index 7221894..057bc98 100644 --- a/plugins/radius/skills/x402/SKILL.md +++ b/plugins/radius/skills/x402/SKILL.md @@ -7,7 +7,8 @@ description: | implement EIP-2612 permit + Permit2 payment signing, build pay-per-call services on Radius using SBC token, or set up x402 middleware. Covers both server-side (protect your endpoints with payment gating) and client-side (sign and pay for x402-protected endpoints). Use - `radius-cli wallet x402` for agent/CLI endpoint consumption and viem for app-code signing. + `radius-cli wallet x402` for terminal consumption, `radius-sdk/client` for app buyers, + and `radius-sdk/hono` for Hono sellers. published: true user-invocable: true --- @@ -30,7 +31,7 @@ Use this Skill when the user asks to: ## Protocol overview -x402 is an HTTP-native micropayment protocol. Payments happen via off-chain permit signatures settled by a facilitator — no on-chain transaction from the client. +x402 is an HTTP-native micropayment protocol. The buyer signs a payment authorization; a facilitator submits settlement. A buyer may also need a one-time on-chain Permit2 approval when gas sponsoring is unavailable. The SDK selects the compatible signing method from the server's challenge. ``` Client Server Facilitator @@ -39,8 +40,8 @@ Client Server Facilitator │ │ │ │<── 402 + PAYMENT-REQUIRED ────│ │ │ │ │ - │ (sign EIP-2612 permit + │ │ - │ Permit2 authorization) │ │ + │ (sign compatible offer; │ │ + │ approval if required) │ │ │ │ │ │─── GET /api/data │ │ │ PAYMENT-SIGNATURE ────────>│ │ @@ -51,11 +52,7 @@ Client Server Facilitator │<── 200 + data + PAYMENT-RESPONSE ─│ ``` -The client signs two permits (never sends a transaction): -1. **EIP-2612 permit** — approves the Permit2 contract to spend SBC -2. **Permit2 PermitWitnessTransferFrom** — authorizes the token transfer via the x402 Proxy - -The facilitator executes both on-chain in a single settlement transaction. +For a v2 `exact` Permit2 offer with `eip2612GasSponsoring`, the buyer signs an EIP-2612 permit for the one-time approval and a Permit2 transfer authorization. Without sponsoring, the SDK can send an approval transaction. Other supported offers use different signatures: v2 `exact` may use EIP-3009, v2 `upto` uses Permit2, and v1 `exact` uses EIP-3009. `upto` authorizes a maximum; the actual charge comes from the payment receipt. Use the SDK to select and encode the offered method. HTTP x402 v2 carries protocol data in headers: - `PAYMENT-REQUIRED` — server to client, base64-encoded payment requirements @@ -182,7 +179,7 @@ Radius-operated facilitators support EIP-2612 gas sponsoring for first-time wall Follow the shared Radius wallet convention from the **radius-dev** skill: - Fresh one-shot agent demos and terminal access should use `radius-cli wallet x402`. -- App-code clients may load key material from environment variables or a secrets manager for viem signing. +- App-code clients should use `createRadiusFetch()` with a viem account or wallet client. Store any key material in the application's secrets system. - [x402-cli-cast.md](references/x402-cli-cast.md) and `scripts/x402-pay.mjs` are legacy/specialized references for environments that cannot use `radius-cli`. - Never request, log, hardcode, or pass raw private keys as CLI arguments such as `--private-key`. @@ -190,14 +187,12 @@ Follow the shared Radius wallet convention from the **radius-dev** skill: ### A. "I want to monetize my API with x402" (server-side) -1. **Install viem** — `npm install viem` (the only dependency) -2. **Create your x402 payment module** — copy the `processPayment()` pattern from [x402-server.md](references/x402-server.md) -3. **Wire into your request handler** — call `processPayment()` for protected routes; it returns a typed outcome you map to HTTP responses -4. **Set environment variables** — `PAYMENT_ADDRESS` (your wallet) and optionally `FACILITATOR_API_KEY` -5. **Test the endpoint behavior** — `curl` your local or already-hosted endpoint to verify it returns 402 with correct requirements -6. **Handle all outcome states** — see the exhaustive switch in [x402-server.md](references/x402-server.md) -7. **Get discovered** — register your service with x402 discovery endpoints so agents and buyers can find it programmatically. Facilitators that implement the `/discovery/resources` convention serve a machine-readable catalog of available services. See [x402-client.md § Discovering services](references/x402-client.md#discovering-x402-services) for the response format and known discovery endpoints. -8. **Deploy separately if needed** — after local or existing-host validation, invoke the user's Cloudflare, Wrangler, Railway, or platform-specific skill to deploy. Do not stop at "deployment is out of scope" when the user explicitly asks for deployment; hand off after the x402 behavior is correct. +1. **Install the seller dependencies** — `npm install radius-sdk hono`. +2. **Configure `radiusPayments()`** from `radius-sdk/hono` with an explicit network, recipient, route, and price. Follow the runnable [seller example](references/x402-server.md). +3. **Test the endpoint behavior** — `curl` your local or already-hosted endpoint to verify it returns 402 with the intended requirements. +4. **Handle settled requests** — the default `settle: 'before'` runs the handler after settlement. Record the payment context or use `onSettled` where the application needs it. +5. **Get discovered** — register your service with x402 discovery endpoints so agents and buyers can find it programmatically. Facilitators that implement the `/discovery/resources` convention serve a machine-readable catalog of available services. See [x402-client.md § Discovering services](references/x402-client.md#discovering-x402-services) for the response format and known discovery endpoints. +6. **Deploy separately if needed** — after local or existing-host validation, invoke the user's Cloudflare, Wrangler, Railway, or platform-specific skill to deploy. Do not stop at "deployment is out of scope" when the user explicitly asks for deployment; hand off after the x402 behavior is correct. ### B. "I want to consume a paid x402 API" (client-side) @@ -238,10 +233,8 @@ network defaults for a local or custom environment. 1. **Discover services** — query `/discovery/resources` endpoints to find available x402 services programmatically. See [x402-client.md § Discovering services](references/x402-client.md#discovering-x402-services) for code and known endpoints. Any HTTP endpoint that returns 402 with a `PAYMENT-REQUIRED` header is also an x402 service — the 402 response itself is a discovery mechanism. 2. **Request the endpoint** — receive 402 with payment requirements in the `PAYMENT-REQUIRED` header -3. **Parse the requirements** — base64-decode `PAYMENT-REQUIRED` with `parsePaymentRequired()` from [x402-client.md](references/x402-client.md) and select the `accepts[i]` whose `network` matches your wallet's chain (do not blindly pick `accepts[0]`) -4. **Sign and pay** — for one-shot agent runs use `radius-cli wallet x402`; for app code, use `signX402Payment()` from [x402-client.md](references/x402-client.md) -5. **Retry with payment** — set the `PAYMENT-SIGNATURE` header to the base64-encoded payload -6. **Receive data** — 200 response with the paid content +3. **Use the SDK for app code** — call `createRadiusFetch()` from `radius-sdk/client` with `signer`, `network`, and the required `maxPerRequest`. The client chooses a compatible offer and signs the correct payload. See [x402-client.md](references/x402-client.md). +4. **Inspect the result** — read `getPaymentReceipt(response, payFetch.network)` and reconcile the transaction when settlement evidence is required. An HTTP 2xx response alone is not payment proof. ### Environment variables @@ -263,10 +256,10 @@ network defaults for a local or custom environment. | **Permit2 spender (critical)** | Using Permit2 contract or payTo | Spender = **x402 Proxy** (`0x4020...0001`). This is the field the facilitator always validates. | | EIP-2612 domain name | `"SBC"` or `"Stablecoin"` | `"Stable Coin"` (exact, with space). Matters for first payment from a wallet (establishes Permit2 allowance on-chain). | | EIP-2612 spender | Using payTo address or x402 Proxy | Spender = **Permit2 contract** (`0x0000...8BA3`). Matters for first payment. | -| Only signing one permit | Sign just EIP-2612 or just Permit2 | Must sign **both** — EIP-2612 + Permit2. The EIP-2612 establishes Permit2 allowance; Permit2 authorizes the transfer. | -| **EIP-2612 `value` ≠ payment `amount`** | `value: 2n**256n - 1n` (max uint256) | `value` must equal `accepts[0].amount`. The Radius x402 Proxy reverts `Permit2612AmountMismatch()` (selector `0x050cda49`); facilitator still reports `success: true`, so the failure is silent unless you check the on-chain receipt. | +| Assuming every offer needs two signatures | Always sign EIP-2612 + Permit2 | Follow the advertised version, scheme, and transfer method. A sponsored Permit2 offer uses both signatures; EIP-3009 and other offers differ. | +| **EIP-2612 `value` ≠ selected payment `amount`** | `value: 2n**256n - 1n` (max uint256) | For a sponsored Permit2 offer, `value` must equal the selected requirement's `amount`. The Radius x402 Proxy can revert `Permit2612AmountMismatch()`; reconcile the on-chain receipt. | | Wrong network facilitator | Using the mainnet facilitator for testnet or the testnet facilitator for mainnet | Use `https://facilitator.radiustech.xyz` for `eip155:723487` and `https://facilitator.testnet.radiustech.xyz` for `eip155:72344` | -| Third-party first-time wallet | Assuming every facilitator sponsors first-time EIP-2612 Permit2 allowance setup | Check `/supported`; if gas sponsoring is unavailable, pre-approve Permit2 via `permit()` on SBC before first payment | +| Third-party first-time wallet | Assuming every facilitator sponsors first-time Permit2 approval | Check `/supported`; if sponsoring is unavailable, use the SDK's approval flow or an explicit Permit2 approval transaction before payment | | Address casing | Comparing addresses with `===` | Always compare case-insensitively or normalize with viem's `getAddress()` | | Missing EIP-2612 nonce | Hardcoding nonce to 0 | Read from token: `nonces(address)` on SBC contract | | Permit2 nonce | Sequential nonce | Random nonce (crypto random bytes) | @@ -294,8 +287,8 @@ network defaults for a local or custom environment. - Full Radius docs corpus: fetch `https://docs.radiustech.xyz/llms-full.txt` **Local references:** -- Server-side implementation: [x402-server.md](references/x402-server.md) -- App client signing with viem/browser wallets: [x402-client.md](references/x402-client.md) +- SDK Hono seller implementation and advanced protocol reference: [x402-server.md](references/x402-server.md) +- SDK app buyer implementation and advanced signing reference: [x402-client.md](references/x402-client.md) - Legacy one-off CLI payment access with curl + cast: [x402-cli-cast.md](references/x402-cli-cast.md) - Facilitator API reference: [facilitator-api.md](references/facilitator-api.md) diff --git a/plugins/radius/skills/x402/evaluations/x402-integration.json b/plugins/radius/skills/x402/evaluations/x402-integration.json index d2ac094..1038542 100644 --- a/plugins/radius/skills/x402/evaluations/x402-integration.json +++ b/plugins/radius/skills/x402/evaluations/x402-integration.json @@ -1,39 +1,21 @@ { - "name": "x402 Server and Client Integration", - "skills": ["x402"], - "query": "Add x402 payment gating to my API endpoint that charges 0.0001 SBC per request on Radius mainnet, then write a script that calls it with a signed payment", - "context": "User has an existing HTTP API and wants to monetize it with x402 micropayments. They have a funded wallet with SBC on Radius mainnet. The Radius-operated facilitator at facilitator.radiustech.xyz is the target.", - "expected_behavior": [ - "Creates an X402Config object with correct Radius mainnet values (asset, network eip155:723487, facilitator URL, amount '100' for 0.0001 SBC)", - "Implements processPayment() that reads PAYMENT-SIGNATURE header, decodes base64, forwards to facilitator /verify and /settle", - "Returns 402 with PAYMENT-REQUIRED header when no PAYMENT-SIGNATURE header is present", - "Decoded PAYMENT-REQUIRED object includes accepts array with assetTransferMethod: 'permit2' in extra field", - "Decoded PAYMENT-REQUIRED object advertises the eip2612GasSponsoring extension", - "Payment requirements include exact EIP-2612 domain: name 'Stable Coin', version '1'", - "Handles all PaymentOutcome states with correct HTTP status codes (402, 400, 502, 200)", - "Client script reads and decodes PAYMENT-REQUIRED header and extracts accepts[0]", - "Client reads EIP-2612 nonce from SBC contract via nonces(address)", - "Client signs both EIP-2612 permit (spender = Permit2 contract) and Permit2 PermitWitnessTransferFrom (spender = x402 Proxy)", - "Payload includes extensions.eip2612GasSponsoring with EIP-2612 signature", - "Payload includes payload.permit2Authorization with Permit2 details", - "Client base64-encodes the payload and sets PAYMENT-SIGNATURE header", - "Uses SBC with 6 decimal precision throughout (never 18)" - ], - "success_criteria": [ - "Server returns 402 with PAYMENT-REQUIRED header containing x402Version: 2 and valid accepts array when called without payment", - "SBC token address is 0x33ad9e4BD16B69B5BFdED37D8B5D9fF9aba014Fb", - "Network is eip155:723487 for mainnet", - "EIP-2612 permit domain uses name 'Stable Coin' and version '1' (not 'SBC' or other variants)", - "EIP-2612 permit spender is the Permit2 contract (0x000000000022D473030F116dDEE9F6B43aC78BA3), not the payTo address", - "Permit2 PermitWitnessTransferFrom spender is the x402 Proxy (0x402085c248EeA27D92E8b30b2C58ed07f9E20001)", - "Permit2 nonce is randomly generated, not sequential", - "Amount field uses 6-decimal raw units (e.g. '100' for 0.0001 SBC, not '100000000000000000000')", - "Private key is never logged, displayed, or hardcoded", - "Default facilitator URL for mainnet is https://facilitator.radiustech.xyz", - "Server-to-facilitator /verify and /settle body uses singular paymentRequirements object", - "Server returns PAYMENT-RESPONSE header with settlement metadata after successful settlement", - "Server returns PAYMENT-RESPONSE header with failure metadata when settlement fails", - "Server correctly forwards payment to facilitator and returns data on successful settlement", - "Client handles non-200 responses after payment (re-fetch requirements if 402, retry if 502)" - ] + "name": "x402 Server and Client Integration", + "skills": ["x402"], + "query": "Add x402 payment gating to my Hono API endpoint that charges 0.0001 SBC per request on Radius mainnet, then write a script that calls it with a signed payment", + "context": "The user has a Hono API and a funded Radius mainnet wallet. The Radius-operated facilitator is the target.", + "expected_behavior": [ + "Installs radius-sdk and hono for the seller, and radius-sdk and viem for the buyer", + "Uses radiusPayments() from radius-sdk/hono with mainnet, the merchant payTo address, and a route price of '$0.0001' or { amount: '100' }", + "Uses createRadiusFetch() from radius-sdk/client with mainnet, a signer, and a required maxPerRequest ceiling", + "Reads getPaymentReceipt() from the paid response and does not claim payment from HTTP 200 alone", + "Leaves settle: 'before' as the default so the protected route runs after facilitator settlement", + "Keeps key material out of logs and source code" + ], + "success_criteria": [ + "An unpaid request receives HTTP 402 with a PAYMENT-REQUIRED header advertising x402 v2 exact Permit2 on eip155:723487", + "The seller charges 100 atomic SBC units (0.0001 SBC) to the configured merchant address", + "The buyer uses SDK challenge selection and signing rather than hard-coded accepts[0] or manual dual-signature assembly", + "The buyer inspects PAYMENT-RESPONSE and preserves its transaction hash for on-chain reconciliation", + "The implementation does not release protected content from a custom async-settlement path before settlement" + ] } diff --git a/plugins/radius/skills/x402/references/x402-cli-cast.md b/plugins/radius/skills/x402/references/x402-cli-cast.md index 155a082..655eeec 100644 --- a/plugins/radius/skills/x402/references/x402-cli-cast.md +++ b/plugins/radius/skills/x402/references/x402-cli-cast.md @@ -3,7 +3,9 @@ `radius-cli wallet x402 ` is the canonical path for one-shot terminal and agent access to x402-gated endpoints. Use this `cast` flow only as a fallback in environments that cannot use `radius-cli` and already have a -funded Foundry keystore account. +funded Foundry keystore account. This specialized flow supports only a v2 +`exact` Permit2 offer with EIP-2612 gas sponsoring; use the CLI or SDK for +other versions and schemes. For fresh agent-created wallets, use `RADIUS_HOME=.radius radius-cli wallet x402 ...` as described in [x402-client.md](x402-client.md), not the Foundry @@ -47,7 +49,15 @@ PAYMENT_REQUIRED="$( )" printf '%s' "$PAYMENT_REQUIRED" | base64 -d | jq . > /tmp/x402-required.json -jq '.accepts[0]' /tmp/x402-required.json > /tmp/x402-accepted.json +jq -e --arg network "$NETWORK" --arg asset "$SBC_TOKEN" ' + select(.x402Version == 2 and (.extensions.eip2612GasSponsoring != null)) + | .accepts + | map(select(.network == $network + and (.asset | ascii_downcase) == ($asset | ascii_downcase) + and .scheme == "exact" + and .extra.assetTransferMethod == "permit2")) + | first // empty +' /tmp/x402-required.json > /tmp/x402-accepted.json ``` `PAYMENT-REQUIRED` contains `accepts: [...]` because the server may advertise several payment options. The client `PAYMENT-SIGNATURE` payload sends one selected option as singular `accepted: {...}`. diff --git a/plugins/radius/skills/x402/references/x402-client.md b/plugins/radius/skills/x402/references/x402-client.md index 8410210..3425080 100644 --- a/plugins/radius/skills/x402/references/x402-client.md +++ b/plugins/radius/skills/x402/references/x402-client.md @@ -1,20 +1,50 @@ # x402 Client-Side Implementation -This reference provides everything needed to consume x402-protected APIs — sign payment permits and send them with your requests. +Use `createRadiusFetch()` for application buyers. It selects a compatible offer for the configured Radius network and asset, enforces a required per-request price ceiling, signs the offered method, and handles the paid retry. -**Only dependency:** `viem` +```bash +npm install radius-sdk viem +``` + +```typescript +import { createRadiusFetch, getPaymentReceipt } from 'radius-sdk/client'; +import { privateKeyToAccount } from 'viem/accounts'; + +const buyer = createRadiusFetch({ + network: 'testnet', + signer: privateKeyToAccount(process.env.RADIUS_PRIVATE_KEY as `0x${string}`), + maxPerRequest: '$0.05', + // Optional: onPaymentRequired: (offer) => offer.payTo === trustedSeller, +}); + +const response = await buyer('https://seller.example/api/data'); +const payment = getPaymentReceipt(response, buyer.network); +if (!response.ok || !payment?.success) { + throw new Error('The paid response has no successful payment receipt'); +} +// Reconcile payment.transaction with buyer.getSettlement(transaction) when +// independent on-chain confirmation is required before recording payment. +``` + +`maxPerRequest` limits each authorization, including the maximum for `upto`; it is not a cumulative budget. A facilitator's payment response is a report, so keep the transaction hash for reconciliation. `onPaymentRequired` receives the selected network, asset, recipient, amount, version, scheme, and transfer method before signing. A buyer may need a one-time Permit2 approval when sponsoring is absent; the SDK supports `onApprovalRequired` and `permit2Approval: 'never'`. + +The SDK supports v1 `exact` via EIP-3009, v2 `exact` via EIP-3009 or Permit2, and v2 `upto` via Permit2. The Radius facilitator currently advertises v2 `exact` Permit2; inspect the server's challenge for other endpoints. The manual example below covers only a sponsored v2 `exact` Permit2 offer. + +## Advanced: manual Permit2 protocol illustration + +Use the following typed-data details to inspect or debug the protocol. The hand-written client examples below do not cover every SDK-supported scheme and do not establish settlement from HTTP 200 alone. --- ## Why two signatures? -x402 on Radius uses a **dual-signature** Permit2 flow. The client never sends a transaction — it signs two EIP-712 typed data messages: +For a sponsored v2 `exact` Permit2 offer, the client signs two EIP-712 typed data messages: 1. **EIP-2612 permit** — tells the SBC token contract: "I approve the Permit2 contract to spend X amount of my SBC." The spender is the **Permit2 contract** (`0x0000...8BA3`). 2. **Permit2 PermitWitnessTransferFrom** — tells the Permit2 contract: "I authorize the x402 Proxy to transfer X SBC from me to the payment recipient." The spender is the **x402 Proxy** (`0x4020...0001`). -The facilitator receives both signatures and executes them on-chain in a single settlement transaction. +The facilitator uses the signatures during settlement. Without sponsoring, a one-time on-chain Permit2 approval may be required instead of the EIP-2612 signature. --- @@ -521,7 +551,7 @@ After sending the `PAYMENT-SIGNATURE` header, the server may still return non-20 | Status | Meaning | Action | |--------|---------|--------| -| 200 | Payment accepted | Parse response body as normal | +| 200 | HTTP request succeeded | Inspect `PAYMENT-RESPONSE` and reconcile its transaction before claiming settlement | | 400 | Malformed PAYMENT-SIGNATURE header | Check base64 encoding, JSON structure | | 402 | Payment verification failed | Requirements may have changed — re-fetch 402 and re-sign | | 502 | Facilitator unavailable | Retry after a short delay | diff --git a/plugins/radius/skills/x402/references/x402-server.md b/plugins/radius/skills/x402/references/x402-server.md index f3fbb78..67568c9 100644 --- a/plugins/radius/skills/x402/references/x402-server.md +++ b/plugins/radius/skills/x402/references/x402-server.md @@ -1,8 +1,39 @@ # x402 Server-Side Implementation -This reference provides everything needed to add x402 payment gating to any HTTP server. The core module is framework-agnostic — it takes a standard `Request` and returns a typed outcome that you map to your framework's response. +Use `radiusPayments()` from `radius-sdk/hono` for Hono APIs. It builds the x402 v2 challenge, checks the facilitator's supported methods, and settles before the route handler runs by default. -**Only dependency:** `viem` (for types only — the module itself uses only `fetch` and `atob`). +```bash +npm install radius-sdk hono +``` + +```typescript +import { Hono } from 'hono'; +import { radiusPayments, type RadiusPaymentVariables } from 'radius-sdk/hono'; + +type AppEnv = { + Bindings: { PAY_TO: `0x${string}` }; + Variables: RadiusPaymentVariables; +}; + +const app = new Hono(); +app.use('/api/*', radiusPayments({ + network: 'testnet', + payTo: (c) => c.env.PAY_TO, + routes: { + 'GET /api/data': { price: '$0.001', description: 'One data request' }, + }, + // Optional: onSettled(receipt, c) records the facilitator's payment report. +})); + +app.get('/api/data', (c) => c.json({ data: 'example', payer: c.get('radiusPayment')?.payer })); +export default app; +``` + +The default `settle: 'before'` waits for settlement before running the handler. `settle: 'after'` runs the handler before settlement, so choose it only when that order is intended. The payment context and `PAYMENT-RESPONSE` report what the facilitator returned; use the transaction hash for on-chain reconciliation when the application needs independent proof. For other HTTP frameworks, adapt `@x402/core/server` or the Hono middleware; the custom implementation below is only a protocol illustration. + +## Advanced: manual protocol illustration + +This legacy framework-agnostic implementation is a protocol illustration, not a complete seller recipe. Its `asyncSettle` path has no facilitator result yet, and its `settled` outcome has only a facilitator report. The examples below keep protected content closed and return 202 until a separate receipt check completes. --- @@ -243,7 +274,8 @@ export async function processPayment( return { status: 'settle-pending', verifyMs, totalMs: Date.now() - t0 }; } - // Synchronous settle — wait for on-chain confirmation + // Synchronous facilitator call — still only a facilitator report until + // the transaction receipt is independently reconciled. const t1 = Date.now(); let settleRes: Response; try { @@ -329,7 +361,6 @@ After calling `processPayment()`, map every outcome to the correct HTTP response ```typescript async function handlePaidRequest(request: Request, config: X402Config): Promise { - const url = new URL(request.url); const outcome = await processPayment(config, request); switch (outcome.status) { @@ -366,12 +397,12 @@ async function handlePaidRequest(request: Request, config: X402Config): Promise< ); case 'settle-pending': - return jsonResponse({ message: 'Payment accepted', path: url.pathname }, 200, config); + return jsonResponse({ message: 'Settlement pending' }, 202, config); case 'settled': - // Payment accepted — return the paid content - // Replace with your application logic: - return jsonResponse({ message: 'Payment accepted', path: url.pathname }, 200, config, { + // A facilitator success report is not an independently checked chain receipt. + // This illustration has no receipt gate, so it does not release paid content. + return jsonResponse({ message: 'Settlement reported; receipt check pending', txHash: outcome.txHash }, 202, config, { 'PAYMENT-RESPONSE': encodeBase64Json(outcome.settlementResponse), }); } @@ -382,17 +413,17 @@ async function handlePaidRequest(request: Request, config: X402Config): Promise< ## Agent checklist: gate an existing endpoint -When adding x402 to an existing HTTP route, implement the payment behavior first and leave platform deployment to the user's Cloudflare, Wrangler, Railway, or hosting-specific skill. +When adding x402 to a Hono route, use the SDK middleware above. The checklist below applies only when adapting the manual protocol illustration to another framework; it does not include the receipt gate needed to release protected content. Required endpoint behavior: - Create an `X402Config` with the correct Radius network, SBC asset, `payTo`, facilitator URL, and 6-decimal raw amount. -- Call `processPayment(config, request)` before returning protected content. +- Call `processPayment(config, request)` to inspect the facilitator outcome, then independently check the transaction receipt before returning protected content. - On `no-payment`, return HTTP 402 with `PAYMENT-REQUIRED: `. - On `invalid-header`, return HTTP 400. - On `verify-failed`, return HTTP 402 and a fresh `PAYMENT-REQUIRED` header. - On `verify-unreachable` or `settle-unreachable`, return HTTP 502. - On `settle-failed`, return HTTP 402 with `PAYMENT-RESPONSE: ` containing facilitator failure details. -- On `settled`, return protected content with `PAYMENT-RESPONSE: ` containing settlement metadata. +- On `settled`, keep the request pending until the transaction receipt is independently checked; this illustrative implementation returns 202 and does not release content. - Expose `PAYMENT-REQUIRED` and `PAYMENT-RESPONSE` in CORS headers for browser clients. - Do not choose deployment infrastructure from this skill. After local or existing-host endpoint behavior is correct, hand off deployment to the platform-specific skill. If the user explicitly asked to deploy, do not stop at "deployment is out of scope"; validate payment behavior first, then invoke or route to Cloudflare, Wrangler, Railway, or the appropriate deployment skill. @@ -462,10 +493,8 @@ async function x402Gate(req: express.Request, res: express.Response, next: expre res.set(corsHeaders(config)); if (outcome.status === 'settled' || outcome.status === 'settle-pending') { - if (outcome.status === 'settled') { - res.set('PAYMENT-RESPONSE', encodeBase64Json(outcome.settlementResponse)); - } - next(); // Payment accepted — proceed to route handler + if (outcome.status === 'settled') res.set('PAYMENT-RESPONSE', encodeBase64Json(outcome.settlementResponse)); + res.status(202).json({ status: 'settlement_pending_receipt_check' }); return; } @@ -490,9 +519,7 @@ async function x402Gate(req: express.Request, res: express.Response, next: expre } } -app.get('/api/data', x402Gate, (req, res) => { - res.json({ data: 'your protected content here' }); -}); +// Add the protected route only after x402Gate has a successful receipt gate. ``` ### Node.js http @@ -528,11 +555,11 @@ createServer(async (req, res) => { res.end(JSON.stringify({})); } else if (outcome.status === 'settled') { res.setHeader('PAYMENT-RESPONSE', encodeBase64Json(outcome.settlementResponse)); - res.writeHead(200); - res.end(JSON.stringify({ data: 'your protected content' })); + res.writeHead(202); + res.end(JSON.stringify({ status: 'settlement_pending_receipt_check' })); } else if (outcome.status === 'settle-pending') { - res.writeHead(200); - res.end(JSON.stringify({ data: 'your protected content' })); + res.writeHead(202); + res.end(JSON.stringify({ status: 'settlement_pending_receipt_check' })); } else if (outcome.status === 'verify-failed' || outcome.status === 'settle-failed') { if (outcome.status === 'settle-failed') { res.setHeader('PAYMENT-RESPONSE', encodeBase64Json(outcome.detail)); @@ -576,7 +603,7 @@ async function handleRequest(request: Request, baseConfig: X402Config): Promise< ## Async settlement -For lower latency, return data before on-chain settlement confirms. The facilitator still settles in the background. +`asyncSettle` returns `settle-pending` before a facilitator result exists. Treat it as pending and keep the protected handler closed. The default SDK seller flow uses `settle: 'before'`. ```typescript // Cloudflare Workers — use ctx.waitUntil for background settle @@ -587,12 +614,7 @@ const outcome = await processPayment( ctx, // ExecutionContext ); -// Node.js — async settle runs as a floating promise (acceptable here because -// the facilitator is responsible for settlement, and failure doesn't affect -// the already-verified payment) -const outcome = await processPayment( - config, - request, - { asyncSettle: true }, -); +if (outcome.status === 'settle-pending') { + return new Response('Settlement pending', { status: 202 }); +} ``` diff --git a/plugins/radius/skills/x402/scripts/x402-pay.mjs b/plugins/radius/skills/x402/scripts/x402-pay.mjs index c7adc82..e7543a5 100755 --- a/plugins/radius/skills/x402/scripts/x402-pay.mjs +++ b/plugins/radius/skills/x402/scripts/x402-pay.mjs @@ -415,7 +415,7 @@ async function main() { } const body = await paidRes.text(); - if (paidRes.status !== 200) { + if (!paidRes.ok) { console.log('status=failed'); console.log(`http_status=${paidRes.status}`); console.log(`error=payment_rejected`); @@ -425,6 +425,24 @@ async function main() { process.exit(1); } + const txHash = settlement?.transaction ?? settlement?.txHash; + if (settlement?.success !== true || settlement?.network !== expectedNetwork || !/^0x[0-9a-fA-F]{64}$/.test(txHash ?? '')) { + console.log('status=unconfirmed'); + console.log(`http_status=${paidRes.status}`); + console.log('error=missing_or_invalid_payment_receipt'); + console.log(''); + console.log(body); + process.exit(1); + } + + let receipt; + try { + receipt = await publicClient.waitForTransactionReceipt({ hash: txHash, timeout: 30_000 }); + } catch (err) { + fail('settlement_receipt_unavailable', err?.message ?? String(err)); + } + if (receipt.status !== 'success') fail('settlement_reverted', txHash); + console.log('status=paid'); console.log(`http_status=${paidRes.status}`); console.log(`payer=${account.address}`); @@ -432,8 +450,7 @@ async function main() { console.log(`paid_to=${accepted.payTo}`); console.log(`network=${expectedNetwork}`); console.log(`permit_nonce=${permitNonce.toString()}`); - const txHash = settlement?.transaction ?? settlement?.txHash; - if (txHash) console.log(`tx_hash=${txHash}`); + console.log(`tx_hash=${txHash}`); console.log(''); console.log(body); } diff --git a/scripts/validate_plugin.py b/scripts/validate_plugin.py index aa69726..2b1b84d 100644 --- a/scripts/validate_plugin.py +++ b/scripts/validate_plugin.py @@ -2,8 +2,10 @@ """Check the checked-in Claude plugin and its scenario/eval inputs.""" import json +import argparse from pathlib import Path import re +import subprocess ROOT = Path(__file__).resolve().parents[1] PLUGIN = ROOT / "plugins/radius" @@ -14,13 +16,51 @@ def load_json(path): return json.load(stream) +def git(*args): + return subprocess.check_output(["git", *args], cwd=ROOT) + + +def version_tuple(version): + assert re.fullmatch(r"\d+\.\d+\.\d+", version), f"invalid plugin version: {version}" + return tuple(int(part) for part in version.split(".")) + + +def check_release_version(base_ref, current_version): + base = git("merge-base", base_ref, "HEAD").decode().strip() + changed = git("diff", "--name-only", "-z", f"{base}..HEAD", "--", "plugins/radius/") + paths = [path.decode() for path in changed.split(b"\0") if path] + release_content_changed = any( + path.startswith("plugins/radius/skills/") + or path == "plugins/radius/.claude-plugin/plugin.json" + or path == "plugins/radius/README.md" + for path in paths + ) + if not release_content_changed: + return + previous = json.loads(git("show", f"{base}:plugins/radius/.claude-plugin/plugin.json")) + old_version = previous["version"] + assert version_tuple(current_version) > version_tuple(old_version), ( + f"plugin release content changed without a version bump: " + f"{old_version} -> {current_version}" + ) + + def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base-ref", help="require a plugin version bump for release content changed since this ref") + args = parser.parse_args() marketplace = load_json(ROOT / ".claude-plugin/marketplace.json") manifest = load_json(PLUGIN / ".claude-plugin/plugin.json") entries = marketplace["plugins"] assert len(entries) == 1 assert entries[0]["source"] == "./plugins/radius" assert entries[0]["name"] == manifest["name"] + assert "version" not in entries[0], "plugin.json is the single plugin version source" + version_tuple(manifest["version"]) + assert (PLUGIN / "README.md").is_file(), "plugin README missing" + assert manifest["license"] == "MIT" + if args.base_ref: + check_release_version(args.base_ref, manifest["version"]) skills = sorted((PLUGIN / "skills").glob("*/SKILL.md")) assert skills, "no skills found"