From 8c01713002326171fe573f7d4598ce4b5ddad0a1 Mon Sep 17 00:00:00 2001 From: Jacob Date: Thu, 12 Mar 2026 07:43:03 -0400 Subject: [PATCH 1/2] feat: add CLI, payments package, and demo server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @tollway/cli: `tollway init/id/fetch/policy` commands with Ed25519 DID keygen, chalk output, config stored at ~/.tollway/config.json - @tollway/payments: viem-based USDC handler for Base mainnet + Sepolia, exports createPaymentHandler() compatible with @tollway/client - @tollway/client: add onPaymentRequired callback to TollwayOptions, replaces placeholder stub — no real payment without an explicit handler - demo/: Express server with sample articles, tollway middleware, Dockerfile All packages build clean; 64 tests passing (26 client, 38 server). Co-Authored-By: Claude Sonnet 4.6 --- demo/Dockerfile | 32 ++ demo/package.json | 25 ++ demo/server.ts | 189 ++++++++ demo/tsconfig.json | 8 + package-lock.json | 276 +++++++++++- packages/tollway-cli/README.md | 89 ++++ packages/tollway-cli/package.json | 65 +++ packages/tollway-cli/src/index.ts | 423 ++++++++++++++++++ packages/tollway-cli/tsconfig.json | 8 + packages/tollway-cli/tsup.config.ts | 16 + .../src/__tests__/index.test.ts | 7 +- packages/tollway-client/src/index.ts | 25 +- packages/tollway-payments/README.md | 76 ++++ packages/tollway-payments/package.json | 65 +++ packages/tollway-payments/src/index.ts | 171 +++++++ packages/tollway-payments/tsconfig.json | 8 + packages/tollway-payments/tsup.config.ts | 12 + 17 files changed, 1481 insertions(+), 14 deletions(-) create mode 100644 demo/Dockerfile create mode 100644 demo/package.json create mode 100644 demo/server.ts create mode 100644 demo/tsconfig.json create mode 100644 packages/tollway-cli/README.md create mode 100644 packages/tollway-cli/package.json create mode 100644 packages/tollway-cli/src/index.ts create mode 100644 packages/tollway-cli/tsconfig.json create mode 100644 packages/tollway-cli/tsup.config.ts create mode 100644 packages/tollway-payments/README.md create mode 100644 packages/tollway-payments/package.json create mode 100644 packages/tollway-payments/src/index.ts create mode 100644 packages/tollway-payments/tsconfig.json create mode 100644 packages/tollway-payments/tsup.config.ts diff --git a/demo/Dockerfile b/demo/Dockerfile new file mode 100644 index 0000000..06c7afa --- /dev/null +++ b/demo/Dockerfile @@ -0,0 +1,32 @@ +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy workspace root +COPY package.json package-lock.json turbo.json tsconfig.base.json ./ + +# Copy packages needed by demo +COPY packages/tollway-server ./packages/tollway-server + +# Copy demo +COPY demo ./demo + +# Install deps and build +RUN npm ci --workspace=packages/tollway-server --workspace=demo +RUN npm run build --workspace=packages/tollway-server +RUN cd demo && npm run build + +# ─── Runtime image ──────────────────────────────────────────────────────────── +FROM node:20-alpine + +WORKDIR /app + +COPY --from=builder /app/demo/dist ./dist +COPY --from=builder /app/packages/tollway-server/dist ./node_modules/@tollway/server/dist +COPY --from=builder /app/packages/tollway-server/package.json ./node_modules/@tollway/server/package.json +COPY --from=builder /app/demo/node_modules ./node_modules + +ENV PORT=3000 +EXPOSE 3000 + +CMD ["node", "dist/server.js"] diff --git a/demo/package.json b/demo/package.json new file mode 100644 index 0000000..f3de25e --- /dev/null +++ b/demo/package.json @@ -0,0 +1,25 @@ +{ + "name": "@tollway/demo", + "version": "0.1.0", + "description": "Live demo server for the Tollway protocol", + "private": true, + "type": "module", + "scripts": { + "build": "tsc", + "start": "node dist/server.js", + "dev": "tsx server.ts" + }, + "dependencies": { + "@tollway/server": "^0.1.0", + "express": "^4.19.2" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.14.10", + "tsx": "^4.19.0", + "typescript": "^5.5.4" + }, + "engines": { + "node": ">=18" + } +} diff --git a/demo/server.ts b/demo/server.ts new file mode 100644 index 0000000..1417f28 --- /dev/null +++ b/demo/server.ts @@ -0,0 +1,189 @@ +/** + * Tollway Demo Server + * + * A live example of @tollway/server in action. + * - Serves /.well-known/tollway.json + * - Validates agent identity headers + * - Returns 402 for 'train' scope + * - Serves sample articles with structured metadata + * + * Deploy to Railway / Render / Fly with the included Dockerfile. + */ + +import express from 'express'; +import { tollwayMiddleware } from '@tollway/server'; + +const app = express(); +const PORT = parseInt(process.env.PORT ?? '3000', 10); + +// ─── Tollway Middleware ──────────────────────────────────────────────────────── + +app.use( + tollwayMiddleware({ + policy: { + freeRequestsPerDay: 500, + trainingAllowed: false, + attributionRequired: true, + attributionFormat: '{title} via Tollway Demo ({url})', + cacheAllowed: true, + cacheTtlSeconds: 3600, + prohibitedActions: ['scrape_bulk'], + paymentRequiredActions: ['train'], + pricing: [ + { action: 'read', price: '0.001' }, + { action: 'summarize', price: '0.005' }, + { action: 'train', price: '0.05' }, + ], + requestsPerMinute: 30, + requestsPerDay: 500, + }, + paymentAddress: process.env.PAYMENT_ADDRESS ?? '0x0000000000000000000000000000000000000000', + paymentNetwork: 'base-sepolia', + enableLogging: true, + onAgentRequest: (identity) => { + console.log(`[agent] ${identity.did} — ${identity.scope} — ${identity.purpose}`); + }, + }), +); + +// ─── Sample Content ──────────────────────────────────────────────────────────── + +const ARTICLES: Record = { + 'intro-to-tollway': { + title: 'Introducing the Tollway Protocol', + author: 'Tollway Team', + date: '2026-03-01', + body: ` +The Tollway protocol brings structure to how AI agents access the web. +Just as robots.txt told crawlers what they could read, tollway.json tells +agents what they can do — and how much it costs. + +Agents identify themselves with a DID (Decentralized Identifier) and sign +every request with an Ed25519 key. Sites respond with policies covering +training permissions, pricing, attribution requirements, and rate limits. + +When a resource requires payment, the server returns HTTP 402 with +payment details. The agent pays in USDC on Base and retries. + `.trim(), + }, + 'agent-identity': { + title: 'Agent Identity in the Agentic Web', + author: 'Tollway Research', + date: '2026-03-05', + body: ` +Today's AI agents browse the web pseudonymously. They present no identity, +accept no responsibility, and operate entirely in the shadows of user-agents. + +The agentic web needs something better: a lightweight identity layer that +lets agents announce who they are, what they want, and who they work for — +without requiring central registries or heavyweight authentication flows. + +The Tollway DID approach uses the W3C did:key method with Ed25519 keypairs. +Every agent generates a keypair; the public key becomes the DID. Requests +are signed so servers can verify the agent hasn't been spoofed. + `.trim(), + }, + 'x402-micropayments': { + title: 'x402: HTTP Payments for the Machine Economy', + author: 'Tollway Engineering', + date: '2026-03-08', + body: ` +HTTP 402 Payment Required has been a reserved status code since 1991, +waiting for its moment. That moment is now. + +The x402 standard (being standardized by Coinbase and others) defines how +servers express payment requirements and how clients fulfill them. Tollway +builds on x402 with USDC on Base for fast, cheap, auditable micropayments. + +An agent that wants to train on content sends a signed request. The server +responds with 402 and a payment address. The agent sends USDC on-chain and +includes the transaction hash in a retry. The server verifies and responds. + `.trim(), + }, +}; + +// ─── Routes ─────────────────────────────────────────────────────────────────── + +app.get('/', (_req, res) => { + res.setHeader('Content-Type', 'text/html'); + res.send(` + + + + + Tollway Demo + + + + + +

Tollway Protocol — Live Demo

+

This server demonstrates the Tollway protocol.

+

Try it

+
npx @tollway/cli fetch ${process.env.PUBLIC_URL ?? 'http://localhost:3000'}/articles/intro-to-tollway
+

Articles

+ +

Policy

+

See /.well-known/tollway.json

+ +`); +}); + +app.get('/articles/:slug', (req, res) => { + const article = ARTICLES[req.params.slug]; + if (!article) { + return res.status(404).json({ error: 'Article not found' }); + } + + const url = `${req.protocol}://${req.get('host')}${req.originalUrl}`; + + res.setHeader('Content-Type', 'text/html'); + res.send(` + + + + + ${article.title} + + + + + + +
+

${article.title}

+

By ${article.author} — ${article.date}

+
${article.body.split('\n').map(p => `

${p}

`).join('\n ')}
+
+ + +`); +}); + +app.get('/articles', (_req, res) => { + res.json({ + articles: Object.entries(ARTICLES).map(([slug, a]) => ({ + slug, + title: a.title, + author: a.author, + date: a.date, + url: `/articles/${slug}`, + })), + }); +}); + +app.get('/health', (_req, res) => { + res.json({ status: 'ok', version: '0.1.0', protocol: 'tollway/0.1' }); +}); + +// ─── Start ──────────────────────────────────────────────────────────────────── + +app.listen(PORT, () => { + console.log(`Tollway demo server running on port ${PORT}`); + console.log(`Policy: http://localhost:${PORT}/.well-known/tollway.json`); + console.log(`Try: npx @tollway/cli fetch http://localhost:${PORT}/articles/intro-to-tollway`); +}); diff --git a/demo/tsconfig.json b/demo/tsconfig.json new file mode 100644 index 0000000..8f35757 --- /dev/null +++ b/demo/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "." + }, + "include": ["server.ts"] +} diff --git a/package-lock.json b/package-lock.json index a7932e3..25f0f4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,11 @@ "node": ">=18" } }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", + "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -1487,6 +1492,42 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1847,6 +1888,39 @@ "win32" ] }, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", + "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "dependencies": { + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", @@ -1871,10 +1945,18 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@tollway/cli": { + "resolved": "packages/tollway-cli", + "link": true + }, "node_modules/@tollway/client": { "resolved": "packages/tollway-client", "link": true }, + "node_modules/@tollway/payments": { + "resolved": "packages/tollway-payments", + "link": true + }, "node_modules/@tollway/server": { "resolved": "packages/tollway-server", "link": true @@ -2304,6 +2386,26 @@ "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "dev": true }, + "node_modules/abitype": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", + "integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3.22.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -3485,6 +3587,11 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==" + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -4275,6 +4382,20 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "node_modules/isows": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", + "integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "peerDependencies": { + "ws": "*" + } + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -5400,6 +5521,35 @@ "node": ">= 0.8.0" } }, + "node_modules/ox": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.0.tgz", + "integrity": "sha512-WLOB7IKnmI3Ol6RAqY7CJdZKl8QaI44LN91OGF1061YIeN6bL5IsFcdp7+oQShRyamE/8fW/CBRWhJAOzI35Dw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "dependencies": { + "@adraffy/ens-normalize": "^1.11.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "1.9.1", + "@noble/hashes": "^1.8.0", + "@scure/bip32": "^1.7.0", + "@scure/bip39": "^1.6.0", + "abitype": "^1.2.3", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -6845,7 +6995,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6959,6 +7109,35 @@ "node": ">= 0.8" } }, + "node_modules/viem": { + "version": "2.47.2", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.47.2.tgz", + "integrity": "sha512-etDIwDgmDiGaPg8rUbJtUFuC3/nAJCbhMYyfh5dOcqNNkzBWTNcS2VluPSM5JVo+9U3b2hle2RkBEq3+xyvlvg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "dependencies": { + "@noble/curves": "1.9.1", + "@noble/hashes": "1.8.0", + "@scure/bip32": "1.7.0", + "@scure/bip39": "1.6.0", + "abitype": "1.2.3", + "isows": "1.0.7", + "ox": "0.14.0", + "ws": "8.18.3" + }, + "peerDependencies": { + "typescript": ">=5.0.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -7034,6 +7213,26 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -7103,9 +7302,52 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/tollway-cli": { + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@tollway/client": "^0.1.0", + "chalk": "^5.3.0", + "commander": "^12.1.0" + }, + "bin": { + "tollway": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^20.14.10", + "tsup": "^8.2.4", + "typescript": "^5.5.4" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/TollwayProtocol" + } + }, + "packages/tollway-cli/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "packages/tollway-cli/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "engines": { + "node": ">=18" + } + }, "packages/tollway-client": { "name": "@tollway/client", - "version": "0.1.1", + "version": "0.1.2", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.14", @@ -7117,11 +7359,35 @@ }, "engines": { "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/TollwayProtocol" + } + }, + "packages/tollway-payments": { + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@tollway/client": "^0.1.0", + "viem": "^2.21.0" + }, + "devDependencies": { + "@types/node": "^20.14.10", + "tsup": "^8.2.4", + "typescript": "^5.5.4" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/TollwayProtocol" } }, "packages/tollway-server": { "name": "@tollway/server", - "version": "0.1.1", + "version": "0.1.2", "license": "MIT", "dependencies": { "bs58": "^6.0.0" @@ -7139,6 +7405,10 @@ "engines": { "node": ">=18" }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/TollwayProtocol" + }, "peerDependencies": { "express": ">=4.0.0", "next": ">=13.0.0" diff --git a/packages/tollway-cli/README.md b/packages/tollway-cli/README.md new file mode 100644 index 0000000..9c8a109 --- /dev/null +++ b/packages/tollway-cli/README.md @@ -0,0 +1,89 @@ +# @tollway/cli + +Command-line interface for the [Tollway protocol](https://tollway.dev). Generate agent identities, fetch URLs with signed identity headers, and inspect site policies — all from the terminal. + +Part of the Tollway open protocol — robots.txt rebuilt for the agentic era. + +## Install + +```bash +npm install -g @tollway/cli +# or use without installing: +npx @tollway/cli +``` + +## Quick Start + +```bash +# 1. Generate an agent identity +tollway init + +# 2. Check a site's policy +tollway policy https://example.com + +# 3. Fetch a URL as an identified agent +tollway fetch https://example.com/article +``` + +## Commands + +### `tollway init` + +Generate a new Ed25519 key pair and save a `did:key` identity to `~/.tollway/config.json`. + +```bash +tollway init # generate new identity +tollway init --wallet 0xYourAddress # associate a USDC wallet +tollway init --force # overwrite existing identity +``` + +### `tollway id` + +Show the current saved identity. + +```bash +tollway id +tollway id --reputation # also fetch reputation score from oracle +``` + +### `tollway policy ` + +Fetch and display a site's `tollway.json` policy. + +```bash +tollway policy https://example.com +tollway policy https://example.com --json # raw JSON output +``` + +### `tollway fetch ` + +Fetch a URL with Tollway identity headers attached. + +```bash +tollway fetch https://example.com/article +tollway fetch https://example.com/article --scope summarize +tollway fetch https://example.com/article --purpose "Research for report" +tollway fetch https://example.com/article --json # full result as JSON +tollway fetch https://example.com/article --text # include response body +tollway fetch https://example.com/article --headers # show headers sent +``` + +## Identity File + +Identities are stored at `~/.tollway/config.json` (mode 600, owner-readable only): + +```json +{ + "did": "did:key:z6Mk...", + "privateKey": "", + "wallet": "0x..." +} +``` + +## Protocol + +`@tollway/cli` uses `@tollway/client` internally and implements the [Tollway v0.1 specification](https://github.com/TollwayProtocol/Tollway/blob/main/SPEC.md). + +- **Spec:** CC BY 4.0 +- **Code:** MIT +- **GitHub:** [TollwayProtocol/Tollway](https://github.com/TollwayProtocol/Tollway) diff --git a/packages/tollway-cli/package.json b/packages/tollway-cli/package.json new file mode 100644 index 0000000..a78f250 --- /dev/null +++ b/packages/tollway-cli/package.json @@ -0,0 +1,65 @@ +{ + "name": "@tollway/cli", + "version": "0.1.0", + "description": "CLI for the Tollway protocol — generate agent identities, fetch URLs with identity headers, inspect site policies", + "keywords": [ + "tollway", + "cli", + "ai-agent", + "did", + "robots-txt" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/TollwayProtocol/Tollway.git", + "directory": "packages/tollway-cli" + }, + "license": "MIT", + "type": "module", + "bin": { + "tollway": "./dist/index.js" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "homepage": "https://tollway.dev", + "bugs": { + "url": "https://github.com/TollwayProtocol/Tollway/issues" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/TollwayProtocol" + }, + "files": [ + "dist", + "LICENSE", + "README.md" + ], + "scripts": { + "build": "tsup", + "lint": "eslint src", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist" + }, + "dependencies": { + "@tollway/client": "^0.1.0", + "chalk": "^5.3.0", + "commander": "^12.1.0" + }, + "devDependencies": { + "@types/node": "^20.14.10", + "tsup": "^8.2.4", + "typescript": "^5.5.4" + }, + "engines": { + "node": ">=18" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/tollway-cli/src/index.ts b/packages/tollway-cli/src/index.ts new file mode 100644 index 0000000..cfc9152 --- /dev/null +++ b/packages/tollway-cli/src/index.ts @@ -0,0 +1,423 @@ +/** + * @tollway/cli — Tollway protocol CLI + * + * Commands: + * tollway init Generate a new DID + Ed25519 key pair + * tollway fetch Fetch a URL with Tollway identity headers + * tollway policy Inspect a site's tollway.json policy + * tollway id Show the current saved identity + */ + +import { Command } from 'commander'; +import chalk from 'chalk'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import * as crypto from 'crypto'; +import { fetch as tollwayFetch, getReputation } from '@tollway/client'; + +// ─── Config ─────────────────────────────────────────────────────────────────── + +const CONFIG_DIR = path.join(os.homedir(), '.tollway'); +const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json'); + +interface TollwayConfig { + did: string; + privateKey: string; + wallet?: string; +} + +function loadConfig(): TollwayConfig | null { + try { + const raw = fs.readFileSync(CONFIG_FILE, 'utf8'); + return JSON.parse(raw) as TollwayConfig; + } catch { + return null; + } +} + +function saveConfig(config: TollwayConfig): void { + fs.mkdirSync(CONFIG_DIR, { recursive: true }); + fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { + mode: 0o600, // Owner read/write only + }); +} + +// ─── DID Key Generation ─────────────────────────────────────────────────────── + +// Base58 alphabet (Bitcoin/IPFS) +const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; + +function base58Encode(bytes: Uint8Array): string { + let num = BigInt('0x' + Buffer.from(bytes).toString('hex')); + const base = BigInt(58); + const result: string[] = []; + + while (num > 0n) { + result.unshift(BASE58_ALPHABET[Number(num % base)]); + num = num / base; + } + + // Leading zeros + for (const byte of bytes) { + if (byte === 0) result.unshift('1'); + else break; + } + + return result.join(''); +} + +interface KeyPair { + did: string; + privateKeyHex: string; + publicKeyHex: string; +} + +function generateDidKeyPair(): KeyPair { + // Generate Ed25519 key pair + const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519', { + privateKeyEncoding: { type: 'pkcs8', format: 'der' }, + publicKeyEncoding: { type: 'spki', format: 'der' }, + }); + + // Extract raw 32-byte keys from DER encoding + // Ed25519 PKCS8: last 32 bytes are private key + // Ed25519 SPKI: last 32 bytes are public key + const rawPrivate = privateKey.subarray(privateKey.length - 32); + const rawPublic = publicKey.subarray(publicKey.length - 32); + + // Build did:key with multicodec prefix 0xed01 (Ed25519 public key) + const multicodecKey = new Uint8Array([0xed, 0x01, ...rawPublic]); + const did = `did:key:z${base58Encode(multicodecKey)}`; + + return { + did, + privateKeyHex: rawPrivate.toString('hex'), + publicKeyHex: rawPublic.toString('hex'), + }; +} + +// ─── Formatting ─────────────────────────────────────────────────────────────── + +function printHeader(title: string): void { + console.log(''); + console.log(chalk.bold.cyan(` ◈ ${title}`)); + console.log(chalk.dim(' ' + '─'.repeat(50))); +} + +function printField(label: string, value: string | number | boolean | null | undefined): void { + if (value === null || value === undefined) return; + const strVal = String(value); + console.log(` ${chalk.dim(label.padEnd(22))} ${chalk.white(strVal)}`); +} + +function printJson(obj: unknown): void { + console.log(JSON.stringify(obj, null, 2)); +} + +// ─── CLI ────────────────────────────────────────────────────────────────────── + +const program = new Command(); + +program + .name('tollway') + .description('Tollway protocol CLI — robots.txt rebuilt for the agentic era') + .version('0.1.0'); + +// ─── init ───────────────────────────────────────────────────────────────────── + +program + .command('init') + .description('Generate a new DID + Ed25519 key pair and save to ~/.tollway/config.json') + .option('--wallet
', 'Associate a USDC wallet address with this identity') + .option('--force', 'Overwrite existing config') + .action((opts: { wallet?: string; force?: boolean }) => { + const existing = loadConfig(); + if (existing && !opts.force) { + console.error(chalk.yellow('\n Identity already exists. Use --force to regenerate.\n')); + console.log(` DID: ${chalk.cyan(existing.did)}`); + console.log(` Config: ${chalk.dim(CONFIG_FILE)}\n`); + process.exit(1); + } + + const kp = generateDidKeyPair(); + const config: TollwayConfig = { + did: kp.did, + privateKey: kp.privateKeyHex, + ...(opts.wallet ? { wallet: opts.wallet } : {}), + }; + saveConfig(config); + + printHeader('New Tollway Identity'); + printField('DID', kp.did); + printField('Public key', kp.publicKeyHex); + printField('Config saved', CONFIG_FILE); + if (opts.wallet) printField('Wallet', opts.wallet); + console.log(''); + console.log(chalk.dim(' Your private key is stored in ~/.tollway/config.json')); + console.log(chalk.dim(' Keep it safe — it signs all your agent requests.')); + console.log(''); + console.log(' Next steps:'); + console.log(` ${chalk.cyan('tollway fetch ')} Make your first agent request`); + console.log(` ${chalk.cyan('tollway policy ')} Inspect a site's tollway policy`); + console.log(''); + }); + +// ─── id ─────────────────────────────────────────────────────────────────────── + +program + .command('id') + .description('Show the current saved identity') + .option('--reputation', 'Fetch reputation score from the oracle') + .action(async (opts: { reputation?: boolean }) => { + const config = loadConfig(); + if (!config) { + console.error(chalk.red('\n No identity found. Run: tollway init\n')); + process.exit(1); + } + + printHeader('Tollway Identity'); + printField('DID', config.did); + printField('Config', CONFIG_FILE); + if (config.wallet) printField('Wallet', config.wallet); + + if (opts.reputation) { + process.stdout.write(' ' + chalk.dim('Fetching reputation...')); + const rep = await getReputation(config.did); + process.stdout.write('\r' + ' '.repeat(40) + '\r'); + if (rep) { + printField('Reputation score', rep.score); + printField('Observations', rep.observations); + if (rep.flags.length > 0) printField('Flags', rep.flags.join(', ')); + } else { + printField('Reputation', 'Not found (new identity)'); + } + } + console.log(''); + }); + +// ─── policy ─────────────────────────────────────────────────────────────────── + +program + .command('policy ') + .description("Fetch and display a site's tollway.json policy") + .option('--json', 'Output raw JSON') + .action(async (url: string, opts: { json?: boolean }) => { + // Normalize URL to origin for policy fetch + let origin: string; + try { + origin = new URL(url).origin; + } catch { + console.error(chalk.red(`\n Invalid URL: ${url}\n`)); + process.exit(1); + } + + const policyUrl = `${origin}/.well-known/tollway.json`; + + let res: Response; + try { + res = await globalThis.fetch(policyUrl, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(5000), + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(chalk.red(`\n Failed to fetch policy: ${msg}\n`)); + process.exit(1); + } + + if (res.status === 404) { + console.log(chalk.yellow(`\n No tollway.json found at ${origin}\n`)); + console.log(chalk.dim(' This site has not adopted the Tollway protocol.\n')); + process.exit(0); + } + + if (!res.ok) { + console.error(chalk.red(`\n HTTP ${res.status} from ${policyUrl}\n`)); + process.exit(1); + } + + const policy = await res.json() as Record; + + if (opts.json) { + printJson(policy); + return; + } + + printHeader(`Policy — ${origin}`); + + const p = policy as { + version?: string; + pricing?: { currency?: string; free_requests_per_day?: number; default_per_request?: string; schedule?: Array<{ action: string; price: string }> }; + data_policy?: { training_allowed?: boolean; attribution_required?: boolean; cache_allowed?: boolean }; + actions?: { allowed?: string[]; prohibited?: string[]; require_payment?: string[] }; + rate_limits?: { requests_per_minute?: number; requests_per_day?: number }; + endpoints?: { payment_address?: string }; + }; + + printField('Version', p.version); + + if (p.pricing) { + printField('Currency', p.pricing.currency ?? 'USDC'); + printField('Free requests/day', p.pricing.free_requests_per_day); + printField('Default price', p.pricing.default_per_request + ? `${p.pricing.default_per_request} ${p.pricing.currency ?? 'USDC'}` + : undefined); + if (p.pricing.schedule?.length) { + console.log(` ${chalk.dim('Pricing schedule')}`); + for (const item of p.pricing.schedule) { + console.log(` ${chalk.dim('•')} ${item.action.padEnd(14)} ${chalk.green(item.price)} USDC`); + } + } + } + + if (p.data_policy) { + printField('Training allowed', p.data_policy.training_allowed ? chalk.green('yes') : chalk.red('no')); + printField('Attribution req.', p.data_policy.attribution_required ? chalk.yellow('yes') : 'no'); + printField('Cache allowed', p.data_policy.cache_allowed ? 'yes' : 'no'); + } + + if (p.actions) { + if (p.actions.allowed?.length) printField('Allowed actions', p.actions.allowed.join(', ')); + if (p.actions.prohibited?.length) printField('Prohibited', chalk.red(p.actions.prohibited.join(', '))); + if (p.actions.require_payment?.length) printField('Pay-to-use', p.actions.require_payment.join(', ')); + } + + if (p.rate_limits) { + printField('Rate limit/min', p.rate_limits.requests_per_minute); + printField('Rate limit/day', p.rate_limits.requests_per_day); + } + + if (p.endpoints?.payment_address) { + printField('Payment address', p.endpoints.payment_address); + } + + console.log(''); + }); + +// ─── fetch ──────────────────────────────────────────────────────────────────── + +program + .command('fetch ') + .description('Fetch a URL with Tollway identity headers') + .option('--purpose ', 'Human-readable purpose', 'CLI research request') + .option('--scope ', 'Action scope (read|search|summarize|train|scrape_bulk)', 'read') + .option('--did ', 'Override DID (uses saved identity by default)') + .option('--key ', 'Override private key hex (uses saved identity by default)') + .option('--json', 'Output full result as JSON') + .option('--headers', 'Show request headers sent') + .option('--text', 'Print the full response body') + .action(async (url: string, opts: { + purpose: string; + scope: string; + did?: string; + key?: string; + json?: boolean; + headers?: boolean; + text?: boolean; + }) => { + const config = loadConfig(); + const did = opts.did ?? config?.did; + const privateKey = opts.key ?? config?.privateKey; + + if (!did || !privateKey) { + console.error(chalk.red('\n No identity found. Run: tollway init\n')); + process.exit(1); + } + + const validScopes = ['read', 'search', 'summarize', 'train', 'scrape_bulk']; + if (!validScopes.includes(opts.scope)) { + console.error(chalk.red(`\n Invalid scope: ${opts.scope}`)); + console.error(chalk.dim(` Valid scopes: ${validScopes.join(', ')}\n`)); + process.exit(1); + } + + const tollwayOptions = { + did, + privateKey, + purpose: opts.purpose, + scope: opts.scope as 'read' | 'search' | 'summarize' | 'train' | 'scrape_bulk', + wallet: config?.wallet, + }; + + if (opts.headers) { + printHeader('Request Headers'); + // Show what headers would be sent (without signature for brevity) + printField('X-Tollway-Version', '0.1'); + printField('X-Tollway-DID', did); + printField('X-Tollway-Purpose', opts.purpose); + printField('X-Tollway-Scope', opts.scope); + printField('X-Tollway-Nonce', ''); + printField('X-Tollway-Timestamp', ''); + printField('X-Tollway-Signature', ''); + console.log(''); + } + + let result; + try { + result = await tollwayFetch(url, { tollway: tollwayOptions }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(chalk.red(`\n Fetch failed: ${msg}\n`)); + process.exit(1); + } + + if (opts.json) { + printJson({ + status: result.status, + paid: result.paid, + cost: result.cost, + attribution: result.attribution, + data: result.data, + policy: result.policy, + ...(opts.text ? { text: result.text } : {}), + }); + return; + } + + printHeader(`Fetch — ${url}`); + printField('Status', result.status === 200 ? chalk.green(result.status) : chalk.yellow(result.status)); + if (result.paid) printField('Paid', chalk.green(`${result.cost} USDC`)); + if (result.attribution) printField('Attribution', result.attribution); + + if (result.data) { + console.log(''); + console.log(` ${chalk.bold('Extracted data')}`); + for (const [k, v] of Object.entries(result.data)) { + if (v) printField(` ${k}`, String(v)); + } + } + + if (result.policy) { + console.log(''); + console.log(` ${chalk.dim('Site has tollway.json policy')}`); + if (result.policy.pricing?.free_requests_per_day) { + printField('Free requests/day', result.policy.pricing.free_requests_per_day); + } + if (result.policy.data_policy?.training_allowed === false) { + console.log(` ${chalk.red('✗')} ${chalk.dim('Training not allowed on this content')}`); + } + } else { + console.log(''); + console.log(` ${chalk.dim('No tollway.json policy found')}`); + } + + if (opts.text) { + console.log(''); + console.log(chalk.dim(' Response body:')); + console.log(result.text.slice(0, 2000)); + if (result.text.length > 2000) { + console.log(chalk.dim(` ... (${result.text.length - 2000} more chars)`)); + } + } + + console.log(''); + }); + +// ─── Run ────────────────────────────────────────────────────────────────────── + +program.parseAsync(process.argv).catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + console.error(chalk.red(`\n Error: ${msg}\n`)); + process.exit(1); +}); diff --git a/packages/tollway-cli/tsconfig.json b/packages/tollway-cli/tsconfig.json new file mode 100644 index 0000000..5a24989 --- /dev/null +++ b/packages/tollway-cli/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/tollway-cli/tsup.config.ts b/packages/tollway-cli/tsup.config.ts new file mode 100644 index 0000000..4f1bfce --- /dev/null +++ b/packages/tollway-cli/tsup.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], + dts: true, + sourcemap: true, + clean: true, + target: 'node18', + splitting: false, + treeshake: true, + banner: { + js: '#!/usr/bin/env node', + }, + noExternal: [], +}); diff --git a/packages/tollway-client/src/__tests__/index.test.ts b/packages/tollway-client/src/__tests__/index.test.ts index 614a2b9..3755798 100644 --- a/packages/tollway-client/src/__tests__/index.test.ts +++ b/packages/tollway-client/src/__tests__/index.test.ts @@ -212,10 +212,15 @@ describe('fetch', () => { .mockResolvedValueOnce(makeResponse(JSON.stringify(paymentRequest), 402, 'application/json')) .mockResolvedValueOnce(makeResponse(MOCK_HTML)); + const onPaymentRequired = jest.fn().mockResolvedValue( + JSON.stringify({ tx_hash: '0xabc', network: 'base', payment_id: 'pay_abc' }), + ); + const result = await tollwayFetch(`${origin}/`, { - tollway: { ...BASE_OPTS, wallet: '0xMyWallet', maxPriceUsdc: '0.01' }, + tollway: { ...BASE_OPTS, wallet: '0xMyWallet', maxPriceUsdc: '0.01', onPaymentRequired }, }); + expect(onPaymentRequired).toHaveBeenCalledWith(paymentRequest); expect(mockFetch).toHaveBeenCalledTimes(3); expect(result.paid).toBe(true); expect(result.cost).toBe('0.001'); diff --git a/packages/tollway-client/src/index.ts b/packages/tollway-client/src/index.ts index 8b7a465..9c62dae 100644 --- a/packages/tollway-client/src/index.ts +++ b/packages/tollway-client/src/index.ts @@ -27,6 +27,12 @@ export interface TollwayOptions { reputationOracle?: string; /** Agent framework identifier e.g. "langchain/0.3.0" */ framework?: string; + /** + * Custom payment handler invoked when the server returns HTTP 402. + * Should submit an on-chain USDC transfer and return a JSON receipt string, + * or null to skip payment. Use @tollway/payments for a ready-made handler. + */ + onPaymentRequired?: (req: PaymentRequest) => Promise; } export interface TollwayResult { @@ -201,8 +207,13 @@ async function handlePayment( paymentReq: PaymentRequest, options: TollwayOptions, ): Promise { + // Use the caller-supplied payment handler if provided (e.g. from @tollway/payments) + if (options.onPaymentRequired) { + return options.onPaymentRequired(paymentReq); + } + if (!options.wallet) { - console.warn('[tollway] Payment required but no wallet configured'); + console.warn('[tollway] Payment required but no wallet or onPaymentRequired configured'); return null; } @@ -214,16 +225,10 @@ async function handlePayment( return null; } - // In a real implementation, this would submit an on-chain USDC transaction - // via ethers.js or viem. For now, we return a placeholder. - // TODO: Implement real x402 payment flow + // No payment handler supplied — log and bail + console.warn('[tollway] Payment required. Add onPaymentRequired from @tollway/payments to pay automatically.'); console.log(`[tollway] Would pay ${price} ${paymentReq.currency} to ${paymentReq.payment_address}`); - - return JSON.stringify({ - tx_hash: '0x_placeholder_implement_real_x402', - network: paymentReq.network, - payment_id: paymentReq.payment_id, - }); + return null; } // ─── Attribution ────────────────────────────────────────────────────────────── diff --git a/packages/tollway-payments/README.md b/packages/tollway-payments/README.md new file mode 100644 index 0000000..c49f6a1 --- /dev/null +++ b/packages/tollway-payments/README.md @@ -0,0 +1,76 @@ +# @tollway/payments + +USDC micropayment handler for the [Tollway protocol](https://tollway.dev). Sends on-chain USDC transfers on Base (mainnet or Sepolia testnet) via [viem](https://viem.sh) when a site returns HTTP 402 Payment Required. + +Part of the Tollway open protocol — robots.txt rebuilt for the agentic era. + +## Install + +```bash +npm install @tollway/payments +``` + +## Quick Start + +```ts +import { createAgent } from '@tollway/client'; +import { createPaymentHandler } from '@tollway/payments'; + +const agent = createAgent({ + did: process.env.AGENT_DID, + privateKey: process.env.AGENT_KEY, + purpose: 'Research', + scope: 'read', + + // Wire up the payment handler + onPaymentRequired: createPaymentHandler({ + walletPrivateKey: process.env.WALLET_PRIVATE_KEY, + maxPriceUsdc: '0.01', // never pay more than 1 cent per request + }), +}); + +// Now agent.fetch() will automatically pay 402s +const result = await agent.fetch('https://example.com/premium-content'); +console.log(result.paid); // true if a payment was made +console.log(result.cost); // '0.001' (USDC) +``` + +## API + +### `createPaymentHandler(options)` + +Returns an `onPaymentRequired` callback compatible with `@tollway/client`'s `TollwayOptions`. + +```ts +createPaymentHandler({ + walletPrivateKey: string; // Ed25519 private key hex (with or without 0x) + maxPriceUsdc?: string; // Maximum price per request (default: '0.01') + rpcUrl?: string; // Override RPC endpoint +}) +``` + +The handler: +1. Checks the requested price against `maxPriceUsdc` +2. Resolves the USDC contract address for the requested network (Base mainnet or Base Sepolia) +3. Submits a USDC `transfer()` transaction via viem +4. Waits for on-chain confirmation +5. Returns a JSON receipt string: `{ tx_hash, network, payment_id, amount, currency }` + +### Networks + +| Network key | Chain | USDC address | +|---|---|---| +| `base` | Base mainnet | `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913` | +| `base-sepolia` | Base Sepolia testnet | `0x036CbD53842c5426634e7929541eC2318f3dCF7e` | + +## Testing + +For testnet development, use `base-sepolia`. Get free testnet USDC from the [Circle faucet](https://faucet.circle.com/). + +## Protocol + +`@tollway/payments` implements the payment flow defined in the [Tollway v0.1 specification](https://github.com/TollwayProtocol/Tollway/blob/main/SPEC.md). + +- **Spec:** CC BY 4.0 +- **Code:** MIT +- **GitHub:** [TollwayProtocol/Tollway](https://github.com/TollwayProtocol/Tollway) diff --git a/packages/tollway-payments/package.json b/packages/tollway-payments/package.json new file mode 100644 index 0000000..fad7bdf --- /dev/null +++ b/packages/tollway-payments/package.json @@ -0,0 +1,65 @@ +{ + "name": "@tollway/payments", + "version": "0.1.0", + "description": "USDC micropayment handler for the Tollway protocol — sends on-chain payments via viem on Base", + "keywords": [ + "tollway", + "usdc", + "micropayments", + "base", + "viem", + "x402", + "ai-agent" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/TollwayProtocol/Tollway.git", + "directory": "packages/tollway-payments" + }, + "license": "MIT", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "homepage": "https://tollway.dev", + "bugs": { + "url": "https://github.com/TollwayProtocol/Tollway/issues" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/TollwayProtocol" + }, + "files": [ + "dist", + "LICENSE", + "README.md" + ], + "scripts": { + "build": "tsup", + "lint": "eslint src", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist" + }, + "dependencies": { + "@tollway/client": "^0.1.0", + "viem": "^2.21.0" + }, + "devDependencies": { + "@types/node": "^20.14.10", + "tsup": "^8.2.4", + "typescript": "^5.5.4" + }, + "engines": { + "node": ">=18" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/tollway-payments/src/index.ts b/packages/tollway-payments/src/index.ts new file mode 100644 index 0000000..dbac7e6 --- /dev/null +++ b/packages/tollway-payments/src/index.ts @@ -0,0 +1,171 @@ +/** + * @tollway/payments + * USDC micropayment handler for the Tollway protocol. + * Sends on-chain USDC transfers on Base (or Base Sepolia testnet) via viem. + */ + +import { + createWalletClient, + createPublicClient, + http, + parseUnits, + type Chain, +} from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; +import { base, baseSepolia } from 'viem/chains'; +import type { PaymentRequest } from '@tollway/client'; + +// ─── USDC Contract Addresses ────────────────────────────────────────────────── + +const USDC_ADDRESS: Record = { + base: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + 'base-sepolia': '0x036CbD53842c5426634e7929541eC2318f3dCF7e', +}; + +// Minimal ERC-20 ABI for transfer +const ERC20_ABI = [ + { + name: 'transfer', + type: 'function', + stateMutability: 'nonpayable', + inputs: [ + { name: 'to', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + outputs: [{ name: '', type: 'bool' }], + }, + { + name: 'decimals', + type: 'function', + stateMutability: 'view', + inputs: [], + outputs: [{ name: '', type: 'uint8' }], + }, +] as const; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface PaymentHandlerOptions { + /** Private key for the paying wallet (hex, with or without 0x prefix) */ + walletPrivateKey: string; + /** Max price per payment in USDC (default: 0.01) */ + maxPriceUsdc?: string; + /** RPC URL override (uses public Base RPC by default) */ + rpcUrl?: string; +} + +export interface PaymentReceipt { + tx_hash: string; + network: string; + payment_id: string; + amount: string; + currency: string; +} + +// ─── Payment Handler Factory ────────────────────────────────────────────────── + +/** + * Creates a payment handler compatible with @tollway/client's onPaymentRequired callback. + * + * @example + * ```ts + * import { createPaymentHandler } from '@tollway/payments'; + * import { createAgent } from '@tollway/client'; + * + * const agent = createAgent({ + * did: process.env.AGENT_DID, + * privateKey: process.env.AGENT_KEY, + * purpose: 'Research', + * scope: 'read', + * onPaymentRequired: createPaymentHandler({ + * walletPrivateKey: process.env.WALLET_PRIVATE_KEY, + * }), + * }); + * ``` + */ +export function createPaymentHandler( + opts: PaymentHandlerOptions, +): (req: PaymentRequest) => Promise { + const maxPrice = parseFloat(opts.maxPriceUsdc ?? '0.01'); + + // Normalize private key to 0x-prefixed hex + const rawKey = opts.walletPrivateKey.startsWith('0x') + ? opts.walletPrivateKey + : `0x${opts.walletPrivateKey}`; + const account = privateKeyToAccount(rawKey as `0x${string}`); + + return async (req: PaymentRequest): Promise => { + // Price guard + const price = parseFloat(req.price); + if (price > maxPrice) { + console.warn(`[tollway/payments] Price ${price} ${req.currency} exceeds max ${maxPrice} — skipping`); + return null; + } + + // Only handle USDC + if (req.currency.toUpperCase() !== 'USDC') { + console.warn(`[tollway/payments] Unsupported currency: ${req.currency}`); + return null; + } + + // Resolve chain + const networkKey = req.network.toLowerCase(); + const chain: Chain = networkKey === 'base-sepolia' || networkKey === 'sepolia' + ? baseSepolia + : base; + + const usdcAddress = USDC_ADDRESS[ + networkKey === 'base-sepolia' || networkKey === 'sepolia' ? 'base-sepolia' : 'base' + ]; + + if (!usdcAddress) { + console.warn(`[tollway/payments] No USDC contract known for network: ${req.network}`); + return null; + } + + const rpcUrl = opts.rpcUrl ?? (chain === baseSepolia + ? 'https://sepolia.base.org' + : 'https://mainnet.base.org'); + + const transport = http(rpcUrl); + + const walletClient = createWalletClient({ account, chain, transport }); + const publicClient = createPublicClient({ chain, transport }); + + // Parse amount — USDC has 6 decimals + const amountRaw = parseUnits(req.price, 6); + + let txHash: `0x${string}`; + try { + txHash = await walletClient.writeContract({ + address: usdcAddress, + abi: ERC20_ABI, + functionName: 'transfer', + args: [req.payment_address as `0x${string}`, amountRaw], + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`[tollway/payments] Transaction failed: ${message}`); + return null; + } + + // Wait for confirmation + try { + await publicClient.waitForTransactionReceipt({ hash: txHash }); + } catch { + // Don't fail if receipt wait times out — tx may still land + console.warn(`[tollway/payments] Could not confirm tx ${txHash} — it may still be pending`); + } + + const receipt: PaymentReceipt = { + tx_hash: txHash, + network: req.network, + payment_id: req.payment_id, + amount: req.price, + currency: req.currency, + }; + + console.log(`[tollway/payments] Paid ${req.price} ${req.currency} → ${txHash}`); + return JSON.stringify(receipt); + }; +} diff --git a/packages/tollway-payments/tsconfig.json b/packages/tollway-payments/tsconfig.json new file mode 100644 index 0000000..5a24989 --- /dev/null +++ b/packages/tollway-payments/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/tollway-payments/tsup.config.ts b/packages/tollway-payments/tsup.config.ts new file mode 100644 index 0000000..25b7c09 --- /dev/null +++ b/packages/tollway-payments/tsup.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + sourcemap: true, + clean: true, + target: 'node18', + splitting: false, + treeshake: true, +}); From a92d8a5fa94d0b7c25c2bef52cbe806b0693cc91 Mon Sep 17 00:00:00 2001 From: Jacob Date: Fri, 13 Mar 2026 09:27:07 -0400 Subject: [PATCH 2/2] feat: add CLI tests, extract lib.ts, update publish workflow - Extract pure functions (base58Encode, generateDidKeyPair, loadConfig, saveConfig) into lib.ts for testability - Add 15 unit tests for CLI lib (DID generation, base58, config I/O) - Add @tollway/cli and @tollway/payments publish steps to CI workflow - Total: 79 tests passing across 3 packages Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/publish.yml | 12 ++ package-lock.json | 5 + packages/tollway-cli/package.json | 16 +++ .../tollway-cli/src/__tests__/lib.test.ts | 133 ++++++++++++++++++ packages/tollway-cli/src/index.ts | 92 +----------- packages/tollway-cli/src/lib.ts | 87 ++++++++++++ 6 files changed, 260 insertions(+), 85 deletions(-) create mode 100644 packages/tollway-cli/src/__tests__/lib.test.ts create mode 100644 packages/tollway-cli/src/lib.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3d56a24..ddcd2bb 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -44,3 +44,15 @@ jobs: working-directory: packages/tollway-server env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Publish @tollway/payments + run: npm publish --provenance --access public + working-directory: packages/tollway-payments + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Publish @tollway/cli + run: npm publish --provenance --access public + working-directory: packages/tollway-cli + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/package-lock.json b/package-lock.json index 25f0f4c..20185d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7303,6 +7303,7 @@ } }, "packages/tollway-cli": { + "name": "@tollway/cli", "version": "0.1.0", "license": "MIT", "dependencies": { @@ -7314,7 +7315,10 @@ "tollway": "dist/index.js" }, "devDependencies": { + "@types/jest": "^29.5.14", "@types/node": "^20.14.10", + "jest": "^29.7.0", + "ts-jest": "^29.2.3", "tsup": "^8.2.4", "typescript": "^5.5.4" }, @@ -7366,6 +7370,7 @@ } }, "packages/tollway-payments": { + "name": "@tollway/payments", "version": "0.1.0", "license": "MIT", "dependencies": { diff --git a/packages/tollway-cli/package.json b/packages/tollway-cli/package.json index a78f250..5c2d079 100644 --- a/packages/tollway-cli/package.json +++ b/packages/tollway-cli/package.json @@ -42,6 +42,8 @@ ], "scripts": { "build": "tsup", + "test": "jest", + "test:coverage": "jest --coverage", "lint": "eslint src", "typecheck": "tsc --noEmit", "clean": "rm -rf dist" @@ -52,10 +54,24 @@ "commander": "^12.1.0" }, "devDependencies": { + "@types/jest": "^29.5.14", "@types/node": "^20.14.10", + "jest": "^29.7.0", + "ts-jest": "^29.2.3", "tsup": "^8.2.4", "typescript": "^5.5.4" }, + "jest": { + "preset": "ts-jest/presets/default-esm", + "testEnvironment": "node", + "extensionsToTreatAsEsm": [".ts"], + "moduleNameMapper": { + "^(\\.{1,2}/.*)\\.js$": "$1" + }, + "transform": { + "^.+\\.tsx?$": ["ts-jest", { "useESM": true }] + } + }, "engines": { "node": ">=18" }, diff --git a/packages/tollway-cli/src/__tests__/lib.test.ts b/packages/tollway-cli/src/__tests__/lib.test.ts new file mode 100644 index 0000000..5e11bb2 --- /dev/null +++ b/packages/tollway-cli/src/__tests__/lib.test.ts @@ -0,0 +1,133 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + base58Encode, + generateDidKeyPair, + loadConfig, + saveConfig, +} from '../lib.js'; + +// ─── base58Encode ───────────────────────────────────────────────────────────── + +describe('base58Encode', () => { + test('encodes a known byte sequence correctly', () => { + // [0x00, 0x01, 0x02] → known base58 output + const input = new Uint8Array([0x00, 0x01, 0x02]); + const encoded = base58Encode(input); + // Leading zero byte → leading '1' + expect(encoded).toMatch(/^1/); + expect(typeof encoded).toBe('string'); + expect(encoded.length).toBeGreaterThan(0); + }); + + test('all-zeros produces only leading 1s', () => { + const input = new Uint8Array([0x00, 0x00, 0x00]); + expect(base58Encode(input)).toBe('111'); + }); + + test('produces only characters from the base58 alphabet', () => { + const input = new Uint8Array(34); + crypto.getRandomValues(input); + const encoded = base58Encode(input); + expect(encoded).toMatch(/^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+$/); + }); + + test('does not contain ambiguous characters (0, O, I, l)', () => { + for (let i = 0; i < 20; i++) { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + const encoded = base58Encode(bytes); + expect(encoded).not.toMatch(/[0OIl]/); + } + }); +}); + +// ─── generateDidKeyPair ─────────────────────────────────────────────────────── + +describe('generateDidKeyPair', () => { + test('returns did, privateKeyHex, and publicKeyHex', () => { + const kp = generateDidKeyPair(); + expect(kp).toHaveProperty('did'); + expect(kp).toHaveProperty('privateKeyHex'); + expect(kp).toHaveProperty('publicKeyHex'); + }); + + test('DID starts with did:key:z', () => { + const kp = generateDidKeyPair(); + expect(kp.did).toMatch(/^did:key:z/); + }); + + test('private key is 64 hex chars (32 bytes)', () => { + const kp = generateDidKeyPair(); + expect(kp.privateKeyHex).toMatch(/^[0-9a-f]{64}$/); + }); + + test('public key is 64 hex chars (32 bytes)', () => { + const kp = generateDidKeyPair(); + expect(kp.publicKeyHex).toMatch(/^[0-9a-f]{64}$/); + }); + + test('each call produces a unique DID', () => { + const kp1 = generateDidKeyPair(); + const kp2 = generateDidKeyPair(); + expect(kp1.did).not.toBe(kp2.did); + expect(kp1.privateKeyHex).not.toBe(kp2.privateKeyHex); + }); + + test('DID encodes the Ed25519 multicodec prefix 0xed01', () => { + // The multibase-decoded bytes should start with 0xed, 0x01 + const kp = generateDidKeyPair(); + // did:key:z + // We can't easily re-decode without a full base58 decoder here, + // but the DID should be a reasonable length (~50 chars after 'did:key:z') + const suffix = kp.did.replace('did:key:z', ''); + expect(suffix.length).toBeGreaterThan(40); + }); +}); + +// ─── loadConfig / saveConfig ────────────────────────────────────────────────── + +describe('loadConfig / saveConfig', () => { + let tmpDir: string; + let tmpFile: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tollway-test-')); + tmpFile = path.join(tmpDir, 'config.json'); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('loadConfig returns null when file does not exist', () => { + expect(loadConfig('/nonexistent/path/config.json')).toBeNull(); + }); + + test('saveConfig writes a valid JSON file', () => { + const config = { did: 'did:key:z123', privateKey: 'abcd1234' }; + saveConfig(config, tmpFile); + const raw = fs.readFileSync(tmpFile, 'utf8'); + expect(JSON.parse(raw)).toEqual(config); + }); + + test('loadConfig reads back what saveConfig wrote', () => { + const config = { did: 'did:key:zTest', privateKey: 'ff00aa', wallet: '0xWallet' }; + saveConfig(config, tmpFile); + const loaded = loadConfig(tmpFile); + expect(loaded).toEqual(config); + }); + + test('saveConfig creates parent directories', () => { + const nested = path.join(tmpDir, 'deep', 'nested', 'config.json'); + const config = { did: 'did:key:zDeep', privateKey: 'beef' }; + saveConfig(config, nested); + expect(fs.existsSync(nested)).toBe(true); + }); + + test('loadConfig returns null on invalid JSON', () => { + fs.writeFileSync(tmpFile, 'not valid json'); + expect(loadConfig(tmpFile)).toBeNull(); + }); +}); diff --git a/packages/tollway-cli/src/index.ts b/packages/tollway-cli/src/index.ts index cfc9152..aaaed83 100644 --- a/packages/tollway-cli/src/index.ts +++ b/packages/tollway-cli/src/index.ts @@ -10,92 +10,14 @@ import { Command } from 'commander'; import chalk from 'chalk'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import * as crypto from 'crypto'; import { fetch as tollwayFetch, getReputation } from '@tollway/client'; - -// ─── Config ─────────────────────────────────────────────────────────────────── - -const CONFIG_DIR = path.join(os.homedir(), '.tollway'); -const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json'); - -interface TollwayConfig { - did: string; - privateKey: string; - wallet?: string; -} - -function loadConfig(): TollwayConfig | null { - try { - const raw = fs.readFileSync(CONFIG_FILE, 'utf8'); - return JSON.parse(raw) as TollwayConfig; - } catch { - return null; - } -} - -function saveConfig(config: TollwayConfig): void { - fs.mkdirSync(CONFIG_DIR, { recursive: true }); - fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { - mode: 0o600, // Owner read/write only - }); -} - -// ─── DID Key Generation ─────────────────────────────────────────────────────── - -// Base58 alphabet (Bitcoin/IPFS) -const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; - -function base58Encode(bytes: Uint8Array): string { - let num = BigInt('0x' + Buffer.from(bytes).toString('hex')); - const base = BigInt(58); - const result: string[] = []; - - while (num > 0n) { - result.unshift(BASE58_ALPHABET[Number(num % base)]); - num = num / base; - } - - // Leading zeros - for (const byte of bytes) { - if (byte === 0) result.unshift('1'); - else break; - } - - return result.join(''); -} - -interface KeyPair { - did: string; - privateKeyHex: string; - publicKeyHex: string; -} - -function generateDidKeyPair(): KeyPair { - // Generate Ed25519 key pair - const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519', { - privateKeyEncoding: { type: 'pkcs8', format: 'der' }, - publicKeyEncoding: { type: 'spki', format: 'der' }, - }); - - // Extract raw 32-byte keys from DER encoding - // Ed25519 PKCS8: last 32 bytes are private key - // Ed25519 SPKI: last 32 bytes are public key - const rawPrivate = privateKey.subarray(privateKey.length - 32); - const rawPublic = publicKey.subarray(publicKey.length - 32); - - // Build did:key with multicodec prefix 0xed01 (Ed25519 public key) - const multicodecKey = new Uint8Array([0xed, 0x01, ...rawPublic]); - const did = `did:key:z${base58Encode(multicodecKey)}`; - - return { - did, - privateKeyHex: rawPrivate.toString('hex'), - publicKeyHex: rawPublic.toString('hex'), - }; -} +import { + CONFIG_FILE, + loadConfig, + saveConfig, + generateDidKeyPair, + type TollwayConfig, +} from './lib.js'; // ─── Formatting ─────────────────────────────────────────────────────────────── diff --git a/packages/tollway-cli/src/lib.ts b/packages/tollway-cli/src/lib.ts new file mode 100644 index 0000000..11e2e8d --- /dev/null +++ b/packages/tollway-cli/src/lib.ts @@ -0,0 +1,87 @@ +/** + * Pure utility functions for @tollway/cli — isolated for testability. + */ + +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +// ─── Config ─────────────────────────────────────────────────────────────────── + +export const CONFIG_DIR = path.join(os.homedir(), '.tollway'); +export const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json'); + +export interface TollwayConfig { + did: string; + privateKey: string; + wallet?: string; +} + +export function loadConfig(configFile = CONFIG_FILE): TollwayConfig | null { + try { + const raw = fs.readFileSync(configFile, 'utf8'); + return JSON.parse(raw) as TollwayConfig; + } catch { + return null; + } +} + +export function saveConfig(config: TollwayConfig, configFile = CONFIG_FILE): void { + fs.mkdirSync(path.dirname(configFile), { recursive: true }); + fs.writeFileSync(configFile, JSON.stringify(config, null, 2), { + mode: 0o600, + }); +} + +// ─── Base58 ─────────────────────────────────────────────────────────────────── + +const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; + +export function base58Encode(bytes: Uint8Array): string { + let num = BigInt('0x' + Buffer.from(bytes).toString('hex')); + const base = BigInt(58); + const result: string[] = []; + + while (num > 0n) { + result.unshift(BASE58_ALPHABET[Number(num % base)]); + num = num / base; + } + + for (const byte of bytes) { + if (byte === 0) result.unshift('1'); + else break; + } + + return result.join(''); +} + +// ─── DID Key Generation ─────────────────────────────────────────────────────── + +export interface KeyPair { + did: string; + privateKeyHex: string; + publicKeyHex: string; +} + +export function generateDidKeyPair(): KeyPair { + const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519', { + privateKeyEncoding: { type: 'pkcs8', format: 'der' }, + publicKeyEncoding: { type: 'spki', format: 'der' }, + }); + + // Ed25519 PKCS8: last 32 bytes are private key + // Ed25519 SPKI: last 32 bytes are public key + const rawPrivate = privateKey.subarray(privateKey.length - 32); + const rawPublic = publicKey.subarray(publicKey.length - 32); + + // did:key with multicodec prefix 0xed01 (Ed25519) + const multicodecKey = new Uint8Array([0xed, 0x01, ...rawPublic]); + const did = `did:key:z${base58Encode(multicodecKey)}`; + + return { + did, + privateKeyHex: rawPrivate.toString('hex'), + publicKeyHex: rawPublic.toString('hex'), + }; +}