-
Notifications
You must be signed in to change notification settings - Fork 2
docs(plugin): align Radius skills with CLI and SDK 0.3.0 #46
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Still don't love this naming convention.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Any other naming ideas?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. /plugin marketplace add radiustechsystems/tools It would require this monorepo be renamed to "tools"
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm supportive of this rename. Is @radius available in skills marketplaces?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Within this setting "radius" is not part of a global namespace so should be fine |
||
| ``` | ||
|
|
||
| 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). | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<Network, { faucetUrl: string; chain: Chain }> = { | ||
| 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({ | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We may want a generic fetch that can just answer whatever the seller provides. I started down a path of making the SDK more multi-EVM compatible #40 but am holding off until we can discuss. |
||
| 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<string> } | 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 | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We probably should have a page either on the docs or www that acts as this projects homepage.