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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/sdk-framework-agnostic-server.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"radius-sdk": minor
---

Accept payments from any HTTP stack, not just Hono. New `radius-sdk/server` entry point:

- `radiusPayments()` is a web-standard handler (`Request` in, `Response` out) for Cloudflare Workers without a framework, Bun, Deno, Node 18+, and Next.js / SvelteKit / Remix route handlers. Paid handlers receive the settled receipt as a second argument.
- `createRadiusServer()` exposes the Radius x402 resource server and a `routes()` builder that plug straight into the upstream adapters: `paymentMiddleware(radius.routes({ … }), radius.server)` with `@x402/express`, `@x402/next` or `@x402/hono`.
- `onSettled` is registered on the resource server, so it fires whichever adapter served the request (it receives the x402 request context; `requestOf(context)` returns the `Request` for the SDK's own adapters). Errors thrown by `onSettled` are logged by x402 core instead of failing the request.
- `radius-sdk/hono` is now a thin wrapper over the web-standard handler with the same options; `RadiusHonoAdapter` is gone.
- `@x402/core` / `@x402/evm` bumped to 2.27.0 (the version line the upstream adapters require).
- New examples: `examples/worker-plain` (no framework) and `examples/express-seller` (`@x402/express`).
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ Tools for the [Radius Network](https://radiustech.xyz), managed as one pnpm work
| Package | What |
| --- | --- |
| [`packages/cli`](./packages/cli) | [`radius-cli`](https://www.npmjs.com/package/radius-cli) — CLI wallet for Radius, modeled on Foundry's `cast`; `wallet x402` pays through `radius-sdk` |
| [`packages/sdk`](./packages/sdk) | [`radius-sdk`](https://www.npmjs.com/package/radius-sdk) — accept and make Radius payments over x402 v2 (Hono / Cloudflare Workers first), plus balance and settlement helpers |
| [`packages/sdk`](./packages/sdk) | [`radius-sdk`](https://www.npmjs.com/package/radius-sdk) — accept and make Radius payments over x402 v2 from any web-standard runtime, Hono, or the upstream x402 framework adapters (Express, Next.js), plus balance and settlement helpers |

```bash
npx radius-cli wallet balance # the CLI
pnpm add radius-sdk hono # SDK, seller side
pnpm add radius-sdk # SDK, seller side (add hono, or @x402/express + express, for those stacks)
pnpm add radius-sdk viem # SDK, buyer / agent side
```

Runnable SDK examples (seller worker, agent buyer, browser demo dapp) are in [`packages/sdk/examples`](./packages/sdk/examples).
Runnable SDK examples (seller workers with and without Hono, an Express seller, agent buyer, browser demo dapp) are in [`packages/sdk/examples`](./packages/sdk/examples).

## Agent skills

Expand Down
102 changes: 83 additions & 19 deletions packages/sdk/README.md
Original file line number Diff line number Diff line change
@@ -1,31 +1,40 @@
# radius-sdk

Accept and make [Radius](https://radiustech.xyz) payments over standard [x402 v2](https://x402.org).
Hono and Cloudflare Workers first. SBC is the default currency, mainnet the default network.
Sellers run on any stack that speaks web-standard `Request`/`Response` (Cloudflare Workers, Bun,
Deno, Node, Next.js and SvelteKit route handlers), on Hono, or on Express / Next.js through the
upstream x402 adapters. SBC is the default currency, mainnet the default network.

Pre-1.0: minor versions may change the API. Release notes are in [CHANGELOG.md](./CHANGELOG.md).

| Entry point | What | Needs |
| --- | --- | --- |
| `radius-sdk` | networks, amounts, receipts, errors, `radiusEnv` (no viem at runtime) | — |
| `radius-sdk/hono` | `radiusPayments()` seller middleware | `hono` |
| `radius-sdk/server` | `radiusPayments()` web-standard seller handler; `createRadiusServer()` for the upstream `@x402/*` adapters | — |
| `radius-sdk/hono` | `radiusPayments()` Hono middleware (wraps `radius-sdk/server`) | `hono` |
| `radius-sdk/client` | `createRadiusFetch()` paying fetch, balance and settlement actions | `viem` |

## Install

Install the peer dependencies for the entry point you use:

```sh
# Hono seller
# Seller on Workers / Bun / Deno / Node / route handlers
pnpm add radius-sdk

# Seller on Hono
pnpm add radius-sdk hono

# Seller on Express (or Next.js with @x402/next)
pnpm add radius-sdk @x402/express express

# Buyer / agent (including applications that also accept payments)
pnpm add radius-sdk viem
```

Hono and viem are optional peer dependencies. The `radius-sdk/hono` entry point requires Hono;
`radius-sdk/client` requires viem `^2.48.11`. Root and Hono entry points do not load viem at
runtime. Network definitions remain compatible with viem's `Chain` type; TypeScript consumers
`radius-sdk/client` requires viem `^2.48.11`. Root, server and Hono entry points do not load
viem at runtime. Network definitions remain compatible with viem's `Chain` type; TypeScript consumers
that resolve those declarations may also need viem installed for its types.

Applications already using a compatible viem version can use that installation for the SDK's
Expand All @@ -39,42 +48,95 @@ across the full dependency tree depends on compatible ranges and the package man

## Accept payments (seller)

```ts
import { Hono } from 'hono';
import { radiusPayments, type RadiusPaymentVariables } from 'radius-sdk/hono';
The payment configuration is the same everywhere; only the wrapper changes.

type Env = { Bindings: { PAY_TO: `0x${string}` }; Variables: RadiusPaymentVariables };
const app = new Hono<Env>();
**Any web-standard runtime** (Cloudflare Workers without a framework, Bun, Deno, Node 18+,
Next.js / SvelteKit / Remix route handlers): a handler that takes a `Request` and returns a
`Response`. Paid handlers receive the settled receipt.

app.use('/api/*', radiusPayments<Env>({
```ts
import { radiusPayments } from 'radius-sdk/server';

const pay = radiusPayments({
network: 'testnet', // default 'mainnet'; or a custom instance, see below
payTo: (c) => c.env.PAY_TO, // or a literal address
payTo: '0xYourWallet', // or (request) => …
routes: {
'GET /api/lookup': { price: '$0.001', description: 'One lookup' },
'POST /api/query': '$0.01', // shorthand
'GET /api/raw': { price: { amount: '100' } }, // atomic units (6 decimals for SBC)
},
});

export default {
fetch: pay.wrap((request, payment) => Response.json({ ok: true, paidBy: payment?.payer })),
};
// or, with your own router: `pay(request, (request, payment) => router.handle(request))`
```

**Hono** (`pnpm add hono`): the same options as middleware; dynamic `payTo`/`price` and
`onSettled` receive the Hono context and paid handlers read `c.get('radiusPayment')`.

```ts
import { Hono } from 'hono';
import { radiusPayments, type RadiusPaymentVariables } from 'radius-sdk/hono';

type Env = { Bindings: { PAY_TO: `0x${string}` }; Variables: RadiusPaymentVariables };
const app = new Hono<Env>();

app.use('/api/*', radiusPayments<Env>({
network: 'testnet',
payTo: (c) => c.env.PAY_TO,
routes: { 'GET /api/lookup': { price: '$0.001', description: 'One lookup' } },
}));

app.get('/api/lookup', (c) => c.json({ ok: true, paidBy: c.get('radiusPayment')?.payer }));
export default app;
```

**Express, Next.js, or any other framework with an upstream x402 adapter**: the SDK provides
the Radius resource server and routes, the adapter provides the middleware.

```ts
import express from 'express';
import { paymentMiddleware } from '@x402/express'; // or `paymentProxy` from '@x402/next'
import { createRadiusServer } from 'radius-sdk/server';

const radius = createRadiusServer({ network: 'testnet' });
const app = express();
app.use(paymentMiddleware(
radius.routes({ payTo: '0xYourWallet', routes: { 'GET /api/lookup': '$0.001' } }),
radius.server,
));
app.get('/api/lookup', (_req, res) => res.json({ ok: true }));
```

`radius.routes()` accepts the same route specs; dynamic `payTo`/`price` receive the x402 request
context. `radius.http(...)` returns an `x402HTTPResourceServer` for adapters' `…FromHTTPServer`
variants, and `radius.server` can be used with any `HTTPAdapter` of your own for stacks nobody
has an adapter for yet.

What you get, on the wire, with no Radius-specific client knowledge required:

- Unpaid request → `402` with a `PAYMENT-REQUIRED` header: `exact` scheme, SBC via Permit2,
`eip2612GasSponsoring` declared so first-time wallets need no on-chain approval.
- Paid request (`PAYMENT-SIGNATURE`) → settled on Radius through the Radius facilitator
**before** your handler runs (`settle: 'after'` switches to the x402 default flow), then a
`PAYMENT-RESPONSE` header with the transaction hash.
- `c.get('radiusPayment')` in the handler, and `onSettled(receipt, c)` for logging.
- The receipt in the handler (second argument / `c.get('radiusPayment')`), and `onSettled` for
logging with every adapter: it receives the `Request` (web-standard handler), the Hono context,
or the x402 request context (`createRadiusServer`).
- `eip2612GasSponsoring` is declared only when the facilitator's `/supported` lists it
(`gasSponsoring: true | false` overrides), so clients never send a permit nobody will honour.
- No I/O at module scope (Workers-safe): the facilitator's `/supported` is fetched lazily on the
first paid request after each cold start. Server bundle is ~65 KiB gzipped, no viem.
- No I/O at module scope (Workers-safe): the SDK's handlers fetch the facilitator's `/supported`
lazily on the first paid request after each cold start. The upstream adapters fetch it at
construction by default (fine on Node; pass their `syncFacilitatorOnStart: false` and call
`radius.server.initialize()` yourself where module-scope I/O is forbidden). Server bundle is
~65 KiB gzipped, no viem.

Any x402 v2 client can pay it: verified with the pre-SDK `radius-cli wallet x402` 0.1.5 as well as
`createRadiusFetch` (which `radius-cli` uses from 0.2.0) paying a local `wrangler dev` worker on testnet.
Any x402 v2 client can pay it: verified with `radius-cli wallet x402` (which uses
`createRadiusFetch` from 0.2.0, and paid the SDK's 402s with its hand-rolled client before that)
against `examples/worker-plain` under `wrangler dev`, `examples/express-seller` on Node, and the
Hono `examples/worker-seller`, all on testnet.

## Make payments (buyer / agent)

Expand Down Expand Up @@ -323,8 +385,10 @@ self-hosted facilitator with your own auth or routing.

| Path | What |
| --- | --- |
| `src/` | `networks`, `balances`, `erc20`, `permit2`, `amounts`, `receipt`, `settlement`, `schemes`, `env`, `errors`; `hono/` (server); `client/` (buyer) |
| `examples/worker-seller` | Hono worker: free `/`, paid `/api/lookup` and `/api/query` (`pnpm --filter radius-worker-seller dev`) |
| `src/` | `networks`, `balances`, `erc20`, `permit2`, `amounts`, `receipt`, `settlement`, `schemes`, `env`, `errors`; `server/` (seller core: web-standard handler, x402 resource server, facilitator, scheme); `hono/` (Hono wrapper); `client/` (buyer) |
| `examples/worker-plain` | Worker with no framework, the web-standard handler: free `/`, paid `/api/lookup` and `/api/query` (`pnpm --filter radius-worker-plain dev`, port 8788) |
| `examples/worker-seller` | Same API on Hono (`pnpm --filter radius-worker-seller dev`) |
| `examples/express-seller` | Same API on Express through `@x402/express` (`pnpm --filter radius-express-seller start`, port 8789) |
| `examples/agent-buyer` | `buy.mjs` (pay a URL), `fresh-wallet.mjs` (gasless proof from a new wallet), `permit2-pull.mjs` (sign a Permit2 transfer off-chain, pull it from another account) |
| `examples/demo-dapp` | Test-dapp style page exercising both sides in the browser (burner wallet or MetaMask) |
| `test/` | unit tests (facilitator and RPC mocked; `client-parity.test.ts` pins the wire format against radius-cli's; `balances.test.ts` runs the native-balance init code in a real EVM; `erc20.semantics.test.ts` runs the ERC-20 actions against `evmNode.ts`, a JSON-RPC node backed by @ethereumjs/evm executing the forge-compiled `fixtures/TestToken` (rebuild with `fixtures/build.sh` after editing the .sol; the artifact is committed because CI has no forge)); `test/e2e` real settlement, balance reconciliation and ERC-20 round trips on testnet or mainnet (`RADIUS_E2E=1 RADIUS_PRIVATE_KEY=… [RADIUS_NETWORK=mainnet] pnpm test:e2e`) |
Expand Down
19 changes: 19 additions & 0 deletions packages/sdk/examples/express-seller/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"name": "radius-express-seller",
"private": true,
"type": "module",
"scripts": {
"start": "node --experimental-strip-types src/index.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@x402/express": "2.27.0",
"express": "^5.2.1",
"radius-sdk": "workspace:*"
},
"devDependencies": {
"@types/express": "^5.0.6",
"@types/node": "^24.0.0",
"typescript": "^5.9.0"
}
}
44 changes: 44 additions & 0 deletions packages/sdk/examples/express-seller/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// A paid API on Express using the upstream x402 adapter. The SDK supplies the Radius
// resource server (facilitator, SBC pricing, gas sponsoring); `@x402/express` supplies
// the middleware. `@x402/next` and `@x402/hono` take the same two arguments.
import express from 'express';
import { paymentMiddleware } from '@x402/express';
import { createRadiusServer } from 'radius-sdk/server';

const PAY_TO = (process.env.PAY_TO ?? '0x1eF420190c299D4d133fE9227F780D7d5cE91BeE') as `0x${string}`;
const NETWORK = (process.env.RADIUS_NETWORK ?? 'testnet') as 'mainnet' | 'testnet';
const PORT = Number(process.env.PORT ?? 8789);

const radius = createRadiusServer({
network: NETWORK,
onSettled: (receipt) => console.log('settled', receipt.transaction, receipt.payer),
});

const app = express();
app.get('/', (_req, res) => {
res.json({ ok: true, paid: ['GET /api/lookup?ip=… $0.001', 'POST /api/query $0.01'] });
});

app.use(
paymentMiddleware(
radius.routes({
payTo: PAY_TO,
routes: {
'GET /api/lookup': { price: '$0.001', description: 'Synthetic threat-intel lookup for one IP' },
'POST /api/query': { price: '$0.01', description: 'Batch query' },
},
}),
radius.server,
),
);

// Handlers only run after the payment has settled on Radius (the SDK defaults to settling first).
app.get('/api/lookup', (req, res) => {
const ip = typeof req.query.ip === 'string' ? req.query.ip : '0.0.0.0';
res.json({ ip, reputation: ip.startsWith('10.') ? 'private' : 'clean', score: 7 });
});
app.post('/api/query', express.json(), (req, res) => {
res.json({ received: req.body ?? {}, results: [] });
});

app.listen(PORT, () => console.log(`radius-express-seller on http://localhost:${PORT} (${NETWORK}, pay to ${PAY_TO})`));
14 changes: 14 additions & 0 deletions packages/sdk/examples/express-seller/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true,
"types": ["node"]
},
"include": ["src"]
}
18 changes: 18 additions & 0 deletions packages/sdk/examples/worker-plain/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"name": "radius-worker-plain",
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler dev --port 8788",
"deploy": "wrangler deploy",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"radius-sdk": "workspace:*"
},
"devDependencies": {
"@cloudflare/workers-types": "^5.20260910.1",
"typescript": "^5.9.0",
"wrangler": "^4.131.0"
}
}
41 changes: 41 additions & 0 deletions packages/sdk/examples/worker-plain/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// A paid API on Cloudflare Workers with no framework: the SDK's web-standard handler
// takes a `Request` and returns a `Response`. The same code runs on Bun, Deno, Node 18+
// (`Bun.serve({ fetch })`, `Deno.serve(fetch)`) and in Next.js / SvelteKit route handlers.
import { radiusPayments, type PaymentHandler } from 'radius-sdk/server';

type Env = { PAY_TO: `0x${string}`; RADIUS_NETWORK: 'mainnet' | 'testnet' };

let pay: PaymentHandler | undefined;

// Built on the first request so config can come from bindings (Workers forbid I/O at
// module scope; the handler does none, but env is only available per request).
function payments(env: Env): PaymentHandler {
return (pay ??= radiusPayments({
network: env.RADIUS_NETWORK,
payTo: env.PAY_TO,
routes: {
'GET /api/lookup': { price: '$0.001', description: 'Synthetic threat-intel lookup for one IP' },
'POST /api/query': { price: '$0.01', description: 'Batch query' },
},
onSettled: (receipt, request) => console.log('settled', receipt.transaction, receipt.payer, new URL(request.url).pathname),
}));
}

export default {
fetch(request: Request, env: Env): Promise<Response> {
// `payment` is the settled receipt: handlers only run for money already received.
return payments(env)(request, async (request, payment) => {
const url = new URL(request.url);
if (url.pathname === '/') return Response.json({ ok: true, paid: ['GET /api/lookup?ip=… $0.001', 'POST /api/query $0.01'] });
if (url.pathname === '/api/lookup') {
const ip = url.searchParams.get('ip') ?? '0.0.0.0';
return Response.json({ ip, reputation: ip.startsWith('10.') ? 'private' : 'clean', score: 7, paidBy: payment?.payer, tx: payment?.transaction });
}
if (url.pathname === '/api/query' && request.method === 'POST') {
const body = await request.json().catch(() => ({}));
return Response.json({ received: body, results: [] });
}
return Response.json({ error: 'not_found' }, { status: 404 });
});
},
};
12 changes: 12 additions & 0 deletions packages/sdk/examples/worker-plain/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["@cloudflare/workers-types"]
},
"include": ["src"]
}
8 changes: 8 additions & 0 deletions packages/sdk/examples/worker-plain/wrangler.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
name = "radius-worker-plain"
main = "src/index.ts"
compatibility_date = "2026-09-01"

[vars]
RADIUS_NETWORK = "testnet"
# Set PAY_TO to your wallet address (or put it in .dev.vars locally / `wrangler secret put` in prod).
PAY_TO = "0x1eF420190c299D4d133fE9227F780D7d5cE91BeE"
Loading
Loading