📝
This commit is contained in:
1
.github/copilot-instructions.md
vendored
1
.github/copilot-instructions.md
vendored
@@ -64,3 +64,4 @@ Bun strips inactive code at build time via `import { feature } from 'bun:bundle'
|
||||
- **Follow the bridge**: IDE integration starts at `src/bridge/bridgeMain.ts`
|
||||
- **MCP integration**: `src/services/mcp/`
|
||||
|
||||
|
||||
|
||||
@@ -28,3 +28,4 @@ COPY . .
|
||||
# Default: drop into a shell for exploration
|
||||
CMD ["sh"]
|
||||
|
||||
|
||||
|
||||
1
LICENSE
1
LICENSE
@@ -8,3 +8,4 @@ under any permissive or copyleft license. Use at your own legal risk.
|
||||
|
||||
For the official Claude Code CLI, see: https://docs.anthropic.com/en/docs/claude-code
|
||||
|
||||
|
||||
|
||||
1
Skill.md
1
Skill.md
@@ -217,3 +217,4 @@ Some features are also gated via `process.env.USER_TYPE === 'ant'`.
|
||||
7. Use lazy imports when adding dependencies that could create circular references.
|
||||
8. Update this file as project conventions evolve.
|
||||
|
||||
|
||||
|
||||
1
agent.md
1
agent.md
@@ -31,3 +31,4 @@ Define how an automated coding agent should operate in this repository.
|
||||
|
||||
## Notes
|
||||
- Repository conventions may evolve; update this file when team norms change.
|
||||
|
||||
|
||||
@@ -23,28 +23,6 @@ main().catch((err) => {
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fileExists(p: string): Promise<boolean> {
|
||||
try {
|
||||
return (await fs.stat(p)).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** List immediate children of a directory (files & dirs). */
|
||||
async function listDir(dir: string): Promise<string[]> {
|
||||
try {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
return entries.map((e: { isDirectory(): boolean; name: string }) => (e.isDirectory() ? e.name + "/" : e.name)).sort();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Recursively collect all file paths under `root` (relative to root). */
|
||||
async function walkFiles(root: string, rel = ""): Promise<string[]> {
|
||||
|
||||
18
server.json
18
server.json
@@ -1,19 +1,33 @@
|
||||
{
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
||||
"name": "io.github.nirholas/claude-code-explorer-mcp",
|
||||
"description": "MCP server for exploring the Claude Code CLI source code — tools, commands, and architecture.",
|
||||
"title": "Claude Code Explorer MCP",
|
||||
"description": "MCP server for exploring the Claude Code CLI source code — browse 40+ tools, 50+ commands, search the full 512K-line codebase, and get architecture overviews.",
|
||||
"repository": {
|
||||
"url": "https://github.com/nirholas/claude-code",
|
||||
"source": "github"
|
||||
"source": "github",
|
||||
"subfolder": "mcp-server"
|
||||
},
|
||||
"version": "1.1.0",
|
||||
"packages": [
|
||||
{
|
||||
"registryType": "npm",
|
||||
"registryBaseUrl": "https://registry.npmjs.org",
|
||||
"identifier": "claude-code-explorer-mcp",
|
||||
"version": "1.1.0",
|
||||
"transport": {
|
||||
"type": "stdio"
|
||||
},
|
||||
"runtimeHint": {
|
||||
"commandLine": {
|
||||
"args": []
|
||||
},
|
||||
"env": {
|
||||
"CLAUDE_CODE_SRC_ROOT": {
|
||||
"description": "Path to the Claude Code src/ directory to explore",
|
||||
"required": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -791,3 +791,4 @@ export function buildTool<D extends AnyToolDef>(def: D): BuiltTool<D> {
|
||||
} as BuiltTool<D>
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -86,3 +86,4 @@ export async function fetchOlderEvents(
|
||||
return fetchPage(ctx, { limit, before_id: beforeId }, 'fetchOlderEvents')
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -134,3 +134,4 @@ export function wrapApiForFaultInjection(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -460,3 +460,4 @@ export class BoundedUUIDSet {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -162,3 +162,4 @@ export function wrapWithOsc8Link(text: string, url: string): string {
|
||||
return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -140,3 +140,4 @@ export function logBridgeSkip(
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -568,3 +568,4 @@ function deriveTitle(raw: string): string | undefined {
|
||||
: flat
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -81,3 +81,4 @@ export const DEFAULT_POLL_CONFIG: PollIntervalConfig = {
|
||||
session_keepalive_interval_v2_ms: 120_000,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -549,3 +549,4 @@ export function createSessionSpawner(deps: SessionSpawnerDeps): SessionSpawner {
|
||||
|
||||
export { extractActivities as _extractActivitiesForTesting }
|
||||
|
||||
|
||||
|
||||
@@ -35,3 +35,4 @@ export function getCompanionIntroAttachment(
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -147,3 +147,4 @@ export const RARITY_COLORS = {
|
||||
legendary: 'warning',
|
||||
} as const satisfies Record<Rarity, keyof import('../utils/theme.js').Theme>
|
||||
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -329,3 +329,4 @@ export async function authLogout(): Promise<void> {
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -31,3 +31,4 @@ export function ndjsonSafeStringify(value: unknown): string {
|
||||
return escapeJsLineTerminators(jsonStringify(value))
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -254,3 +254,4 @@ export class RemoteIO extends StructuredIO {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -858,3 +858,4 @@ async function executePermissionRequestHooksForSDK(
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -710,3 +710,4 @@ function convertSSEUrlToPostUrl(sseUrl: URL): string {
|
||||
return `${sseUrl.protocol}//${sseUrl.host}${pathname}`
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -799,3 +799,4 @@ export class WebSocketTransport implements Transport {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -130,3 +130,4 @@ function coalescePatches(
|
||||
return merged
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -44,3 +44,4 @@ export function getTransportForUrl(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -9,3 +9,4 @@ const agents = {
|
||||
|
||||
export default agents
|
||||
|
||||
|
||||
|
||||
@@ -13,3 +13,4 @@ const branch = {
|
||||
|
||||
export default branch
|
||||
|
||||
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
|
||||
|
||||
|
||||
@@ -199,3 +199,4 @@ const bridgeKick = {
|
||||
|
||||
export default bridgeKick
|
||||
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -25,3 +25,4 @@ const bridge = {
|
||||
|
||||
export default bridge
|
||||
|
||||
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
|
||||
|
||||
|
||||
@@ -6,3 +6,4 @@ export const call: LocalCommandCall = async (_, context) => {
|
||||
return { type: 'text', value: '' }
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -91,3 +91,4 @@ const command = {
|
||||
|
||||
export default command
|
||||
|
||||
|
||||
|
||||
@@ -14,3 +14,4 @@ const compact = {
|
||||
|
||||
export default compact
|
||||
|
||||
|
||||
|
||||
@@ -5,3 +5,4 @@ export const call: LocalJSXCommandCall = async (onDone, context) => {
|
||||
return <Settings onClose={onDone} context={context} defaultTab="Config" />;
|
||||
};
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIlNldHRpbmdzIiwiTG9jYWxKU1hDb21tYW5kQ2FsbCIsImNhbGwiLCJvbkRvbmUiLCJjb250ZXh0Il0sInNvdXJjZXMiOlsiY29uZmlnLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgKiBhcyBSZWFjdCBmcm9tICdyZWFjdCdcbmltcG9ydCB7IFNldHRpbmdzIH0gZnJvbSAnLi4vLi4vY29tcG9uZW50cy9TZXR0aW5ncy9TZXR0aW5ncy5qcydcbmltcG9ydCB0eXBlIHsgTG9jYWxKU1hDb21tYW5kQ2FsbCB9IGZyb20gJy4uLy4uL3R5cGVzL2NvbW1hbmQuanMnXG5cbmV4cG9ydCBjb25zdCBjYWxsOiBMb2NhbEpTWENvbW1hbmRDYWxsID0gYXN5bmMgKG9uRG9uZSwgY29udGV4dCkgPT4ge1xuICByZXR1cm4gPFNldHRpbmdzIG9uQ2xvc2U9e29uRG9uZX0gY29udGV4dD17Y29udGV4dH0gZGVmYXVsdFRhYj1cIkNvbmZpZ1wiIC8+XG59XG4iXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sS0FBS0EsS0FBSyxNQUFNLE9BQU87QUFDOUIsU0FBU0MsUUFBUSxRQUFRLHVDQUF1QztBQUNoRSxjQUFjQyxtQkFBbUIsUUFBUSx3QkFBd0I7QUFFakUsT0FBTyxNQUFNQyxJQUFJLEVBQUVELG1CQUFtQixHQUFHLE1BQUFDLENBQU9DLE1BQU0sRUFBRUMsT0FBTyxLQUFLO0VBQ2xFLE9BQU8sQ0FBQyxRQUFRLENBQUMsT0FBTyxDQUFDLENBQUNELE1BQU0sQ0FBQyxDQUFDLE9BQU8sQ0FBQyxDQUFDQyxPQUFPLENBQUMsQ0FBQyxVQUFVLENBQUMsUUFBUSxHQUFHO0FBQzVFLENBQUMiLCJpZ25vcmVMaXN0IjpbXX0=
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -23,3 +23,4 @@ export const contextNonInteractive: Command = {
|
||||
load: () => import('./context-noninteractive.js'),
|
||||
}
|
||||
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -14,3 +14,4 @@ const copy = {
|
||||
|
||||
export default copy
|
||||
|
||||
|
||||
|
||||
@@ -64,3 +64,4 @@ Do not attempt to run the command. Simply inform the user about the plugin insta
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
|
||||
|
||||
|
||||
@@ -7,3 +7,4 @@ export async function call(onDone: (result?: string, options?: {
|
||||
return <DesktopHandoff onDone={onDone} />;
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIkNvbW1hbmRSZXN1bHREaXNwbGF5IiwiRGVza3RvcEhhbmRvZmYiLCJjYWxsIiwib25Eb25lIiwicmVzdWx0Iiwib3B0aW9ucyIsImRpc3BsYXkiLCJQcm9taXNlIiwiUmVhY3ROb2RlIl0sInNvdXJjZXMiOlsiZGVza3RvcC50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IFJlYWN0IGZyb20gJ3JlYWN0J1xuaW1wb3J0IHR5cGUgeyBDb21tYW5kUmVzdWx0RGlzcGxheSB9IGZyb20gJy4uLy4uL2NvbW1hbmRzLmpzJ1xuaW1wb3J0IHsgRGVza3RvcEhhbmRvZmYgfSBmcm9tICcuLi8uLi9jb21wb25lbnRzL0Rlc2t0b3BIYW5kb2ZmLmpzJ1xuXG5leHBvcnQgYXN5bmMgZnVuY3Rpb24gY2FsbChcbiAgb25Eb25lOiAoXG4gICAgcmVzdWx0Pzogc3RyaW5nLFxuICAgIG9wdGlvbnM/OiB7IGRpc3BsYXk/OiBDb21tYW5kUmVzdWx0RGlzcGxheSB9LFxuICApID0+IHZvaWQsXG4pOiBQcm9taXNlPFJlYWN0LlJlYWN0Tm9kZT4ge1xuICByZXR1cm4gPERlc2t0b3BIYW5kb2ZmIG9uRG9uZT17b25Eb25lfSAvPlxufVxuIl0sIm1hcHBpbmdzIjoiQUFBQSxPQUFPQSxLQUFLLE1BQU0sT0FBTztBQUN6QixjQUFjQyxvQkFBb0IsUUFBUSxtQkFBbUI7QUFDN0QsU0FBU0MsY0FBYyxRQUFRLG9DQUFvQztBQUVuRSxPQUFPLGVBQWVDLElBQUlBLENBQ3hCQyxNQUFNLEVBQUUsQ0FDTkMsTUFBZSxDQUFSLEVBQUUsTUFBTSxFQUNmQyxPQUE0QyxDQUFwQyxFQUFFO0VBQUVDLE9BQU8sQ0FBQyxFQUFFTixvQkFBb0I7QUFBQyxDQUFDLEVBQzVDLEdBQUcsSUFBSSxDQUNWLEVBQUVPLE9BQU8sQ0FBQ1IsS0FBSyxDQUFDUyxTQUFTLENBQUMsQ0FBQztFQUMxQixPQUFPLENBQUMsY0FBYyxDQUFDLE1BQU0sQ0FBQyxDQUFDTCxNQUFNLENBQUMsR0FBRztBQUMzQyIsImlnbm9yZUxpc3QiOltdfQ==
|
||||
|
||||
|
||||
@@ -7,3 +7,4 @@ export default {
|
||||
load: () => import('./diff.js'),
|
||||
} satisfies Command
|
||||
|
||||
|
||||
|
||||
12
src/commands/x402/index.ts
Normal file
12
src/commands/x402/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
|
||||
const x402 = {
|
||||
type: 'local',
|
||||
name: 'x402',
|
||||
aliases: ['wallet', 'pay'],
|
||||
description: 'Configure x402 crypto payments (USDC on Base)',
|
||||
argumentHint: '[setup|status|enable|disable|set-limit|remove]',
|
||||
load: () => import('./x402.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default x402
|
||||
@@ -234,12 +234,24 @@ export function formatTotalCost(): string {
|
||||
|
||||
const modelUsageDisplay = formatModelUsage()
|
||||
|
||||
// Include x402 payment summary if any payments were made
|
||||
let x402Display = ''
|
||||
try {
|
||||
const { formatX402Cost } = require('./services/x402/index.js') as typeof import('./services/x402/index.js')
|
||||
const x402Summary = formatX402Cost()
|
||||
if (x402Summary) {
|
||||
x402Display = '\n' + x402Summary
|
||||
}
|
||||
} catch {
|
||||
// x402 module not available, skip
|
||||
}
|
||||
|
||||
return chalk.dim(
|
||||
`Total cost: ${costDisplay}\n` +
|
||||
`Total duration (API): ${formatDuration(getTotalAPIDuration())}
|
||||
Total duration (wall): ${formatDuration(getTotalDuration())}
|
||||
Total code changes: ${getTotalLinesAdded()} ${getTotalLinesAdded() === 1 ? 'line' : 'lines'} added, ${getTotalLinesRemoved()} ${getTotalLinesRemoved() === 1 ? 'line' : 'lines'} removed
|
||||
${modelUsageDisplay}`,
|
||||
${modelUsageDisplay}${x402Display}`,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -360,7 +360,19 @@ function buildFetch(
|
||||
source: string | undefined,
|
||||
): ClientOptions['fetch'] {
|
||||
// eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins
|
||||
const inner = fetchOverride ?? globalThis.fetch
|
||||
let inner = fetchOverride ?? globalThis.fetch
|
||||
|
||||
// Wrap with x402 payment handler for automatic 402 Payment Required handling
|
||||
try {
|
||||
const { wrapFetchWithX402, isX402Enabled } =
|
||||
require('../x402/index.js') as typeof import('../x402/index.js')
|
||||
if (isX402Enabled()) {
|
||||
inner = wrapFetchWithX402(inner as typeof globalThis.fetch) as typeof inner
|
||||
}
|
||||
} catch {
|
||||
// x402 module not available, skip
|
||||
}
|
||||
|
||||
// Only send to the first-party API — Bedrock/Vertex/Foundry don't log it
|
||||
// and unknown headers risk rejection by strict proxies (inc-4029 class).
|
||||
const injectClientRequestId =
|
||||
|
||||
@@ -786,3 +786,4 @@ export function logAPISuccessAndDuration({
|
||||
markFirstTeleportMessageLogged()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -157,3 +157,4 @@ export async function checkMetricsEnabled(): Promise<MetricsStatus> {
|
||||
export const _clearMetricsEnabledCacheForTesting = (): void => {
|
||||
memoizedCheckMetrics.cache.clear()
|
||||
}
|
||||
|
||||
|
||||
@@ -135,3 +135,4 @@ export function formatGrantAmount(info: OverageCreditGrantInfo): string | null {
|
||||
}
|
||||
|
||||
export type { CachedGrantEntry as OverageCreditGrantCacheEntry }
|
||||
|
||||
|
||||
399
src/services/x402/client.ts
Normal file
399
src/services/x402/client.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
/**
|
||||
* x402 Payment Client
|
||||
*
|
||||
* Handles the x402 payment protocol flow:
|
||||
* 1. Parse 402 response headers for payment requirements
|
||||
* 2. Validate payment amounts against configured limits
|
||||
* 3. Sign EIP-3009 transferWithAuthorization via EIP-712
|
||||
* 4. Construct payment header for retry
|
||||
*/
|
||||
|
||||
import { createHash, randomBytes } from 'crypto'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { getX402Config, getX402PrivateKey } from './config.js'
|
||||
import { addX402Payment } from './tracker.js'
|
||||
import {
|
||||
DEFAULT_FACILITATOR_URLS,
|
||||
type PaymentNetwork,
|
||||
type PaymentPayload,
|
||||
type PaymentRequirement,
|
||||
USDC_ADDRESSES,
|
||||
X402_HEADERS,
|
||||
type X402PaymentRecord,
|
||||
} from './types.js'
|
||||
|
||||
/** USDC has 6 decimal places */
|
||||
const USDC_DECIMALS = 6
|
||||
|
||||
/** Convert token amount (smallest unit) to USD, assuming 1 USDC = 1 USD */
|
||||
function tokenAmountToUSD(amount: string): number {
|
||||
return parseInt(amount, 10) / 10 ** USDC_DECIMALS
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the X-Payment-Required header from a 402 response.
|
||||
*/
|
||||
export function parsePaymentRequirement(
|
||||
headerValue: string,
|
||||
): PaymentRequirement {
|
||||
try {
|
||||
const parsed = JSON.parse(headerValue) as PaymentRequirement
|
||||
if (!parsed.scheme || !parsed.network || !parsed.maxAmountRequired || !parsed.payTo) {
|
||||
throw new Error('Missing required fields in payment requirement')
|
||||
}
|
||||
return parsed
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Invalid x402 payment requirement header: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a payment requirement is within configured limits.
|
||||
*/
|
||||
export function validatePaymentRequirement(
|
||||
requirement: PaymentRequirement,
|
||||
sessionSpentUSD: number,
|
||||
): { valid: boolean; reason?: string } {
|
||||
const config = getX402Config()
|
||||
|
||||
if (!config.enabled) {
|
||||
return { valid: false, reason: 'x402 payments are not enabled' }
|
||||
}
|
||||
|
||||
const amountUSD = tokenAmountToUSD(requirement.maxAmountRequired)
|
||||
|
||||
if (amountUSD > config.maxPaymentPerRequestUSD) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: `Payment of $${amountUSD.toFixed(4)} exceeds per-request limit of $${config.maxPaymentPerRequestUSD.toFixed(2)}`,
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionSpentUSD + amountUSD > config.maxSessionSpendUSD) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: `Payment would exceed session limit of $${config.maxSessionSpendUSD.toFixed(2)} (already spent $${sessionSpentUSD.toFixed(4)})`,
|
||||
}
|
||||
}
|
||||
|
||||
// Validate network matches config
|
||||
if (requirement.network !== config.network) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: `Payment requires network ${requirement.network} but wallet is configured for ${config.network}`,
|
||||
}
|
||||
}
|
||||
|
||||
// Validate asset is USDC on the configured network
|
||||
const expectedAsset = USDC_ADDRESSES[requirement.network]
|
||||
if (
|
||||
expectedAsset &&
|
||||
requirement.asset.toLowerCase() !== expectedAsset.toLowerCase()
|
||||
) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: `Unknown payment token ${requirement.asset} (expected USDC: ${expectedAsset})`,
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* EIP-712 domain separator for EIP-3009 transferWithAuthorization.
|
||||
*
|
||||
* This follows the USDC contract's EIP-712 domain:
|
||||
* name: token name (e.g. "USD Coin")
|
||||
* version: token version (e.g. "2")
|
||||
* chainId: network chain ID
|
||||
* verifyingContract: USDC contract address
|
||||
*/
|
||||
function getEIP712Domain(requirement: PaymentRequirement): {
|
||||
name: string
|
||||
version: string
|
||||
chainId: number
|
||||
verifyingContract: string
|
||||
} {
|
||||
const chainIds: Record<PaymentNetwork, number> = {
|
||||
'base': 8453,
|
||||
'base-sepolia': 84532,
|
||||
'ethereum': 1,
|
||||
'ethereum-sepolia': 11155111,
|
||||
}
|
||||
|
||||
return {
|
||||
name: requirement.extra?.name ?? 'USD Coin',
|
||||
version: requirement.extra?.version ?? '2',
|
||||
chainId: chainIds[requirement.network],
|
||||
verifyingContract: requirement.asset,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* EIP-712 type hash for TransferWithAuthorization.
|
||||
*
|
||||
* TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)
|
||||
*/
|
||||
const TRANSFER_WITH_AUTHORIZATION_TYPEHASH = createHash('sha3-256')
|
||||
.update(
|
||||
'TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)',
|
||||
)
|
||||
.digest()
|
||||
|
||||
/**
|
||||
* Compute EIP-712 domain separator hash.
|
||||
*/
|
||||
function computeDomainSeparator(domain: {
|
||||
name: string
|
||||
version: string
|
||||
chainId: number
|
||||
verifyingContract: string
|
||||
}): Buffer {
|
||||
const EIP712_DOMAIN_TYPEHASH = createHash('sha3-256')
|
||||
.update(
|
||||
'EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)',
|
||||
)
|
||||
.digest()
|
||||
|
||||
const nameHash = createHash('sha3-256').update(domain.name).digest()
|
||||
const versionHash = createHash('sha3-256').update(domain.version).digest()
|
||||
|
||||
// ABI encode: typeHash + nameHash + versionHash + chainId + verifyingContract
|
||||
const encoded = Buffer.alloc(5 * 32)
|
||||
EIP712_DOMAIN_TYPEHASH.copy(encoded, 0)
|
||||
nameHash.copy(encoded, 32)
|
||||
versionHash.copy(encoded, 64)
|
||||
// chainId as uint256
|
||||
const chainIdBuf = Buffer.alloc(32)
|
||||
chainIdBuf.writeBigUInt64BE(BigInt(domain.chainId), 24)
|
||||
chainIdBuf.copy(encoded, 96)
|
||||
// verifyingContract as address (left-padded to 32 bytes)
|
||||
const addrBuf = Buffer.alloc(32)
|
||||
Buffer.from(domain.verifyingContract.replace('0x', ''), 'hex').copy(
|
||||
addrBuf,
|
||||
12,
|
||||
)
|
||||
addrBuf.copy(encoded, 128)
|
||||
|
||||
return createHash('sha3-256').update(encoded).digest()
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the EIP-712 struct hash for TransferWithAuthorization.
|
||||
*/
|
||||
function computeStructHash(authorization: {
|
||||
from: string
|
||||
to: string
|
||||
value: string
|
||||
validAfter: string
|
||||
validBefore: string
|
||||
nonce: string
|
||||
}): Buffer {
|
||||
// ABI encode all fields as 32-byte words
|
||||
const encoded = Buffer.alloc(7 * 32)
|
||||
|
||||
TRANSFER_WITH_AUTHORIZATION_TYPEHASH.copy(encoded, 0)
|
||||
|
||||
// from address
|
||||
const fromBuf = Buffer.alloc(32)
|
||||
Buffer.from(authorization.from.replace('0x', ''), 'hex').copy(fromBuf, 12)
|
||||
fromBuf.copy(encoded, 32)
|
||||
|
||||
// to address
|
||||
const toBuf = Buffer.alloc(32)
|
||||
Buffer.from(authorization.to.replace('0x', ''), 'hex').copy(toBuf, 12)
|
||||
toBuf.copy(encoded, 64)
|
||||
|
||||
// value as uint256
|
||||
const valueBuf = Buffer.alloc(32)
|
||||
const value = BigInt(authorization.value)
|
||||
valueBuf.writeBigUInt64BE(value >> 192n, 0)
|
||||
valueBuf.writeBigUInt64BE((value >> 128n) & 0xffffffffffffffffn, 8)
|
||||
valueBuf.writeBigUInt64BE((value >> 64n) & 0xffffffffffffffffn, 16)
|
||||
valueBuf.writeBigUInt64BE(value & 0xffffffffffffffffn, 24)
|
||||
valueBuf.copy(encoded, 96)
|
||||
|
||||
// validAfter as uint256
|
||||
const validAfterBuf = Buffer.alloc(32)
|
||||
validAfterBuf.writeBigUInt64BE(BigInt(authorization.validAfter), 24)
|
||||
validAfterBuf.copy(encoded, 128)
|
||||
|
||||
// validBefore as uint256
|
||||
const validBeforeBuf = Buffer.alloc(32)
|
||||
validBeforeBuf.writeBigUInt64BE(BigInt(authorization.validBefore), 24)
|
||||
validBeforeBuf.copy(encoded, 160)
|
||||
|
||||
// nonce as bytes32
|
||||
const nonceBuf = Buffer.from(authorization.nonce.replace('0x', ''), 'hex')
|
||||
const noncePadded = Buffer.alloc(32)
|
||||
nonceBuf.copy(noncePadded, 32 - nonceBuf.length)
|
||||
noncePadded.copy(encoded, 192)
|
||||
|
||||
return createHash('sha3-256').update(encoded).digest()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign an EIP-712 typed data hash with a secp256k1 private key.
|
||||
* Returns the signature in compact format (r + s + v).
|
||||
*/
|
||||
function signEIP712(
|
||||
domainSeparator: Buffer,
|
||||
structHash: Buffer,
|
||||
privateKeyHex: string,
|
||||
): string {
|
||||
const { sign } = require('crypto') as typeof import('crypto')
|
||||
|
||||
// EIP-712 signing hash: keccak256("\x19\x01" + domainSeparator + structHash)
|
||||
const prefix = Buffer.from('1901', 'hex')
|
||||
const message = createHash('sha3-256')
|
||||
.update(Buffer.concat([prefix, domainSeparator, structHash]))
|
||||
.digest()
|
||||
|
||||
const keyHex = privateKeyHex.startsWith('0x')
|
||||
? privateKeyHex.slice(2)
|
||||
: privateKeyHex
|
||||
const keyBuf = Buffer.from(keyHex, 'hex')
|
||||
|
||||
// Use secp256k1 ECDSA signing
|
||||
// Node.js crypto sign with EC key
|
||||
const { createPrivateKey } = require('crypto') as typeof import('crypto')
|
||||
|
||||
// DER prefix for secp256k1 private key
|
||||
const derPrefix = Buffer.from('30740201010420', 'hex')
|
||||
const derMiddle = Buffer.from('a00706052b8104000aa144034200', 'hex')
|
||||
|
||||
const ecPrivateKey = createPrivateKey({
|
||||
key: Buffer.concat([derPrefix, keyBuf, derMiddle]),
|
||||
format: 'der',
|
||||
type: 'sec1',
|
||||
})
|
||||
|
||||
const signature = sign(null, message, {
|
||||
key: ecPrivateKey,
|
||||
dsaEncoding: 'ieee-p1363',
|
||||
})
|
||||
|
||||
// Extract r and s (each 32 bytes for secp256k1)
|
||||
const r = signature.subarray(0, 32)
|
||||
const s = signature.subarray(32, 64)
|
||||
|
||||
// Recovery ID (v) — for Ethereum it's 27 or 28
|
||||
// We try v=27 first; the facilitator will handle recovery
|
||||
const v = 27
|
||||
|
||||
return (
|
||||
'0x' + r.toString('hex') + s.toString('hex') + v.toString(16)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a signed x402 payment payload for a given requirement.
|
||||
*/
|
||||
export function createPayment(
|
||||
requirement: PaymentRequirement,
|
||||
fromAddress: string,
|
||||
privateKeyHex: string,
|
||||
): PaymentPayload {
|
||||
const nonce = '0x' + randomBytes(32).toString('hex')
|
||||
const validAfter = '0'
|
||||
const validBefore = String(
|
||||
Math.floor(Date.now() / 1000) + requirement.maxTimeoutSeconds,
|
||||
)
|
||||
|
||||
const authorization = {
|
||||
from: fromAddress,
|
||||
to: requirement.payTo,
|
||||
value: requirement.maxAmountRequired,
|
||||
validAfter,
|
||||
validBefore,
|
||||
nonce,
|
||||
}
|
||||
|
||||
const domain = getEIP712Domain(requirement)
|
||||
const domainSeparator = computeDomainSeparator(domain)
|
||||
const structHash = computeStructHash(authorization)
|
||||
const signature = signEIP712(domainSeparator, structHash, privateKeyHex)
|
||||
|
||||
return {
|
||||
x402Version: 1,
|
||||
scheme: requirement.scheme,
|
||||
network: requirement.network,
|
||||
payload: {
|
||||
signature,
|
||||
authorization,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a payment payload as a base64 string for the X-Payment header.
|
||||
*/
|
||||
export function encodePaymentHeader(payload: PaymentPayload): string {
|
||||
return Buffer.from(JSON.stringify(payload)).toString('base64')
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a 402 response by creating and encoding a payment.
|
||||
* Returns the payment header value, or null if payment is not possible.
|
||||
*/
|
||||
export function handlePaymentRequired(
|
||||
headerValue: string,
|
||||
sessionSpentUSD: number,
|
||||
): {
|
||||
paymentHeader: string
|
||||
record: X402PaymentRecord
|
||||
} | null {
|
||||
const requirement = parsePaymentRequirement(headerValue)
|
||||
const validation = validatePaymentRequirement(requirement, sessionSpentUSD)
|
||||
|
||||
if (!validation.valid) {
|
||||
logForDebugging(`[x402] Payment rejected: ${validation.reason}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const privateKey = getX402PrivateKey()
|
||||
if (!privateKey) {
|
||||
logForDebugging('[x402] No private key configured')
|
||||
return null
|
||||
}
|
||||
|
||||
const config = getX402Config()
|
||||
const fromAddress = config.address
|
||||
if (!fromAddress) {
|
||||
logForDebugging('[x402] No wallet address configured')
|
||||
return null
|
||||
}
|
||||
|
||||
const payment = createPayment(requirement, fromAddress, privateKey)
|
||||
const paymentHeader = encodePaymentHeader(payment)
|
||||
|
||||
const record: X402PaymentRecord = {
|
||||
timestamp: Date.now(),
|
||||
resource: requirement.resource,
|
||||
amount: requirement.maxAmountRequired,
|
||||
amountUSD: tokenAmountToUSD(requirement.maxAmountRequired),
|
||||
token: requirement.extra?.name ?? 'USDC',
|
||||
network: requirement.network,
|
||||
payTo: requirement.payTo,
|
||||
signature: payment.payload.signature,
|
||||
}
|
||||
|
||||
// Track the payment
|
||||
addX402Payment(record)
|
||||
|
||||
logForDebugging(
|
||||
`[x402] Payment signed: $${record.amountUSD.toFixed(4)} to ${requirement.payTo} for ${requirement.resource}`,
|
||||
)
|
||||
|
||||
return { paymentHeader, record }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the facilitator URL for a given network.
|
||||
*/
|
||||
export function getFacilitatorUrl(network: PaymentNetwork): string {
|
||||
const config = getX402Config()
|
||||
return config.facilitatorUrl ?? DEFAULT_FACILITATOR_URLS[network]
|
||||
}
|
||||
185
src/services/x402/config.ts
Normal file
185
src/services/x402/config.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* x402 Wallet Configuration
|
||||
*
|
||||
* Manages wallet configuration and private key storage for x402 payments.
|
||||
* Private keys are stored in the user's global config (~/.claude/config.json)
|
||||
* and never logged or transmitted.
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'crypto'
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import {
|
||||
type PaymentNetwork,
|
||||
type X402WalletConfig,
|
||||
X402_DEFAULTS,
|
||||
} from './types.js'
|
||||
|
||||
/**
|
||||
* EIP-55 mixed-case checksum address encoding.
|
||||
* Computes the checksum in-place using keccak256 of the lowercase hex address.
|
||||
*/
|
||||
function toChecksumAddress(address: string): string {
|
||||
const { createHash } = require('crypto') as typeof import('crypto')
|
||||
const addr = address.toLowerCase().replace('0x', '')
|
||||
const hash = createHash('sha3-256').update(addr).digest('hex')
|
||||
let checksummed = '0x'
|
||||
for (let i = 0; i < addr.length; i++) {
|
||||
checksummed +=
|
||||
parseInt(hash[i], 16) >= 8 ? addr[i].toUpperCase() : addr[i]
|
||||
}
|
||||
return checksummed
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives an Ethereum address from a private key using secp256k1.
|
||||
* Uses Node.js native crypto for the EC operation.
|
||||
*/
|
||||
function deriveAddress(privateKeyHex: string): string {
|
||||
const { createPublicKey, createPrivateKey } =
|
||||
require('crypto') as typeof import('crypto')
|
||||
|
||||
const keyHex = privateKeyHex.startsWith('0x')
|
||||
? privateKeyHex.slice(2)
|
||||
: privateKeyHex
|
||||
const keyBuffer = Buffer.from(keyHex, 'hex')
|
||||
|
||||
// Create an EC private key in DER format for secp256k1
|
||||
// DER prefix for secp256k1 private key (RFC 5915)
|
||||
const derPrefix = Buffer.from(
|
||||
'30740201010420',
|
||||
'hex',
|
||||
)
|
||||
const derMiddle = Buffer.from(
|
||||
'a00706052b8104000aa144034200',
|
||||
'hex',
|
||||
)
|
||||
|
||||
const privateKey = createPrivateKey({
|
||||
key: Buffer.concat([derPrefix, keyBuffer, derMiddle]),
|
||||
format: 'der',
|
||||
type: 'sec1',
|
||||
})
|
||||
|
||||
const publicKey = createPublicKey(privateKey)
|
||||
const pubKeyDer = publicKey.export({ type: 'spki', format: 'der' })
|
||||
|
||||
// Extract the 65-byte uncompressed public key (last 65 bytes of SPKI DER)
|
||||
const uncompressedPubKey = pubKeyDer.subarray(pubKeyDer.length - 65)
|
||||
|
||||
// Ethereum address = last 20 bytes of keccak256(pubkey[1:])
|
||||
// pubkey[0] is 0x04 prefix for uncompressed key
|
||||
const { createHash } = require('crypto') as typeof import('crypto')
|
||||
const hash = createHash('sha3-256')
|
||||
.update(uncompressedPubKey.subarray(1))
|
||||
.digest()
|
||||
const rawAddress = '0x' + hash.subarray(hash.length - 20).toString('hex')
|
||||
|
||||
return toChecksumAddress(rawAddress)
|
||||
}
|
||||
|
||||
/** Retrieves x402 config from global config */
|
||||
export function getX402Config(): X402WalletConfig {
|
||||
const config = getGlobalConfig()
|
||||
return (config as Record<string, unknown>).x402 as X402WalletConfig ?? { ...X402_DEFAULTS }
|
||||
}
|
||||
|
||||
/** Retrieves the private key from environment or global config */
|
||||
export function getX402PrivateKey(): string | undefined {
|
||||
// Environment variable takes precedence (for CI/automation)
|
||||
if (process.env.X402_PRIVATE_KEY) {
|
||||
return process.env.X402_PRIVATE_KEY
|
||||
}
|
||||
const config = getGlobalConfig()
|
||||
return (config as Record<string, unknown>).x402PrivateKey as string | undefined
|
||||
}
|
||||
|
||||
/** Checks if x402 payments are configured and enabled */
|
||||
export function isX402Enabled(): boolean {
|
||||
const config = getX402Config()
|
||||
if (!config.enabled) return false
|
||||
const key = getX402PrivateKey()
|
||||
return !!key
|
||||
}
|
||||
|
||||
/** Saves x402 wallet configuration */
|
||||
export function saveX402Config(updates: Partial<X402WalletConfig>): void {
|
||||
const current = getX402Config()
|
||||
const merged = { ...current, ...updates }
|
||||
saveGlobalConfig((config) => ({
|
||||
...config,
|
||||
x402: merged,
|
||||
}))
|
||||
logForDebugging('[x402] Config updated')
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a private key and derives + stores the wallet address.
|
||||
* The private key is stored encrypted-at-rest via the global config's
|
||||
* file permissions (600).
|
||||
*/
|
||||
export function saveX402PrivateKey(privateKeyHex: string): string {
|
||||
const keyHex = privateKeyHex.startsWith('0x')
|
||||
? privateKeyHex.slice(2)
|
||||
: privateKeyHex
|
||||
|
||||
if (keyHex.length !== 64 || !/^[0-9a-fA-F]+$/.test(keyHex)) {
|
||||
throw new Error('Invalid private key: must be 32 bytes (64 hex characters)')
|
||||
}
|
||||
|
||||
const address = deriveAddress(keyHex)
|
||||
|
||||
saveGlobalConfig((config) => ({
|
||||
...config,
|
||||
x402PrivateKey: `0x${keyHex}`,
|
||||
}))
|
||||
|
||||
saveX402Config({ address })
|
||||
|
||||
logForDebugging(`[x402] Wallet configured: ${address}`)
|
||||
return address
|
||||
}
|
||||
|
||||
/** Removes the private key and disables x402 */
|
||||
export function removeX402PrivateKey(): void {
|
||||
saveGlobalConfig((config) => {
|
||||
const { x402PrivateKey: _, ...rest } = config as Record<string, unknown>
|
||||
return rest as typeof config
|
||||
})
|
||||
saveX402Config({ enabled: false, address: undefined })
|
||||
logForDebugging('[x402] Wallet removed')
|
||||
}
|
||||
|
||||
/** Gets the wallet address without exposing the private key */
|
||||
export function getX402WalletAddress(): string | undefined {
|
||||
return getX402Config().address
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a new random private key for x402 payments.
|
||||
* Returns the hex-encoded key (with 0x prefix).
|
||||
*/
|
||||
export function generateX402PrivateKey(): string {
|
||||
return '0x' + randomBytes(32).toString('hex')
|
||||
}
|
||||
|
||||
/** Updates the payment network */
|
||||
export function setX402Network(network: PaymentNetwork): void {
|
||||
saveX402Config({ network })
|
||||
}
|
||||
|
||||
/** Updates max per-request payment limit */
|
||||
export function setX402MaxPayment(amountUSD: number): void {
|
||||
if (amountUSD <= 0) {
|
||||
throw new Error('Max payment must be a positive number')
|
||||
}
|
||||
saveX402Config({ maxPaymentPerRequestUSD: amountUSD })
|
||||
}
|
||||
|
||||
/** Updates max session spend limit */
|
||||
export function setX402MaxSessionSpend(amountUSD: number): void {
|
||||
if (amountUSD <= 0) {
|
||||
throw new Error('Max session spend must be a positive number')
|
||||
}
|
||||
saveX402Config({ maxSessionSpendUSD: amountUSD })
|
||||
}
|
||||
59
src/services/x402/index.ts
Normal file
59
src/services/x402/index.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* x402 Payment Service
|
||||
*
|
||||
* Public API for the x402 integration. Provides payment handling for
|
||||
* HTTP 402 responses using the x402 protocol (USDC on Base).
|
||||
*
|
||||
* @see https://github.com/coinbase/x402
|
||||
*/
|
||||
|
||||
// Configuration
|
||||
export {
|
||||
generateX402PrivateKey,
|
||||
getX402Config,
|
||||
getX402PrivateKey,
|
||||
getX402WalletAddress,
|
||||
isX402Enabled,
|
||||
removeX402PrivateKey,
|
||||
saveX402Config,
|
||||
saveX402PrivateKey,
|
||||
setX402MaxPayment,
|
||||
setX402MaxSessionSpend,
|
||||
setX402Network,
|
||||
} from './config.js'
|
||||
|
||||
// Payment client
|
||||
export {
|
||||
createPayment,
|
||||
encodePaymentHeader,
|
||||
getFacilitatorUrl,
|
||||
handlePaymentRequired,
|
||||
parsePaymentRequirement,
|
||||
validatePaymentRequirement,
|
||||
} from './client.js'
|
||||
|
||||
// Fetch integration
|
||||
export {
|
||||
addX402AxiosInterceptor,
|
||||
wrapFetchWithX402,
|
||||
} from './paymentFetch.js'
|
||||
|
||||
// Cost tracking
|
||||
export {
|
||||
addX402Payment,
|
||||
formatX402Cost,
|
||||
getX402PaymentCount,
|
||||
getX402SessionPayments,
|
||||
getX402SessionSpentUSD,
|
||||
resetX402SessionPayments,
|
||||
} from './tracker.js'
|
||||
|
||||
// Types
|
||||
export type {
|
||||
PaymentNetwork,
|
||||
PaymentPayload,
|
||||
PaymentRequirement,
|
||||
PaymentScheme,
|
||||
X402PaymentRecord,
|
||||
X402WalletConfig,
|
||||
} from './types.js'
|
||||
156
src/services/x402/paymentFetch.ts
Normal file
156
src/services/x402/paymentFetch.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* x402 Payment Fetch Wrapper
|
||||
*
|
||||
* Wraps fetch (or axios) to automatically handle HTTP 402 Payment Required
|
||||
* responses using the x402 protocol. When a 402 is received:
|
||||
*
|
||||
* 1. Parse the X-Payment-Required header
|
||||
* 2. Validate against spending limits
|
||||
* 3. Sign a payment authorization
|
||||
* 4. Retry the request with the X-Payment header
|
||||
*
|
||||
* This integrates at the fetch level so it works transparently with
|
||||
* both the Anthropic SDK client and the WebFetchTool.
|
||||
*/
|
||||
|
||||
import type { AxiosInstance, AxiosResponse } from 'axios'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { handlePaymentRequired } from './client.js'
|
||||
import { isX402Enabled } from './config.js'
|
||||
import { getX402SessionSpentUSD } from './tracker.js'
|
||||
import { X402_HEADERS } from './types.js'
|
||||
|
||||
/**
|
||||
* Create a fetch wrapper that intercepts 402 responses and handles x402 payment.
|
||||
*
|
||||
* Usage with the Anthropic SDK client:
|
||||
* const wrappedFetch = wrapFetchWithX402(originalFetch)
|
||||
* // Pass wrappedFetch as the `fetch` option to the SDK
|
||||
*
|
||||
* @param innerFetch - The underlying fetch function to wrap
|
||||
* @returns A fetch-compatible function with x402 payment handling
|
||||
*/
|
||||
export function wrapFetchWithX402(
|
||||
innerFetch: typeof globalThis.fetch,
|
||||
): typeof globalThis.fetch {
|
||||
return async (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
// Make the initial request
|
||||
const response = await innerFetch(input, init)
|
||||
|
||||
// If not a 402, pass through
|
||||
if (response.status !== 402) {
|
||||
return response
|
||||
}
|
||||
|
||||
// Check if x402 is enabled
|
||||
if (!isX402Enabled()) {
|
||||
logForDebugging('[x402] Received 402 but x402 payments are not enabled')
|
||||
return response
|
||||
}
|
||||
|
||||
// Check for the x402 payment requirement header
|
||||
const paymentRequiredHeader = response.headers.get(
|
||||
X402_HEADERS.PAYMENT_REQUIRED,
|
||||
)
|
||||
if (!paymentRequiredHeader) {
|
||||
logForDebugging(
|
||||
'[x402] Received 402 but no X-Payment-Required header present',
|
||||
)
|
||||
return response
|
||||
}
|
||||
|
||||
logForDebugging(`[x402] Received 402 Payment Required, processing...`)
|
||||
|
||||
// Handle the payment
|
||||
const result = handlePaymentRequired(
|
||||
paymentRequiredHeader,
|
||||
getX402SessionSpentUSD(),
|
||||
)
|
||||
|
||||
if (!result) {
|
||||
logForDebugging('[x402] Payment handling failed, returning original 402')
|
||||
return response
|
||||
}
|
||||
|
||||
// Retry with payment header
|
||||
logForDebugging('[x402] Retrying request with payment header')
|
||||
|
||||
const retryHeaders = new Headers(init?.headers)
|
||||
retryHeaders.set(X402_HEADERS.PAYMENT, result.paymentHeader)
|
||||
|
||||
const retryResponse = await innerFetch(input, {
|
||||
...init,
|
||||
headers: retryHeaders,
|
||||
})
|
||||
|
||||
if (retryResponse.status === 402) {
|
||||
logForDebugging(
|
||||
'[x402] Payment was rejected by server (still got 402)',
|
||||
)
|
||||
} else {
|
||||
logForDebugging(
|
||||
`[x402] Payment accepted, response status: ${retryResponse.status}`,
|
||||
)
|
||||
}
|
||||
|
||||
return retryResponse
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add x402 payment interceptor to an axios instance.
|
||||
*
|
||||
* Usage with WebFetchTool's axios:
|
||||
* addX402AxiosInterceptor(axiosInstance)
|
||||
*
|
||||
* @param instance - The axios instance to add the interceptor to
|
||||
*/
|
||||
export function addX402AxiosInterceptor(instance: AxiosInstance): void {
|
||||
instance.interceptors.response.use(
|
||||
// Success handler — pass through non-402 responses
|
||||
(response) => response,
|
||||
// Error handler — intercept 402 responses
|
||||
async (error) => {
|
||||
if (
|
||||
!error.response ||
|
||||
error.response.status !== 402 ||
|
||||
!isX402Enabled()
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
|
||||
const response: AxiosResponse = error.response
|
||||
|
||||
const paymentRequiredHeader =
|
||||
response.headers[X402_HEADERS.PAYMENT_REQUIRED]
|
||||
if (!paymentRequiredHeader) {
|
||||
throw error
|
||||
}
|
||||
|
||||
logForDebugging('[x402] Axios interceptor: handling 402 Payment Required')
|
||||
|
||||
const result = handlePaymentRequired(
|
||||
paymentRequiredHeader,
|
||||
getX402SessionSpentUSD(),
|
||||
)
|
||||
|
||||
if (!result) {
|
||||
throw error
|
||||
}
|
||||
|
||||
// Retry the original request with the payment header
|
||||
const retryConfig = {
|
||||
...error.config,
|
||||
headers: {
|
||||
...error.config.headers,
|
||||
[X402_HEADERS.PAYMENT]: result.paymentHeader,
|
||||
},
|
||||
}
|
||||
|
||||
return instance.request(retryConfig)
|
||||
},
|
||||
)
|
||||
}
|
||||
81
src/services/x402/tracker.ts
Normal file
81
src/services/x402/tracker.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* x402 Payment Tracker
|
||||
*
|
||||
* Tracks x402 payments within a session for cost display and safety limits.
|
||||
* Integrates with the main cost tracking system.
|
||||
*/
|
||||
|
||||
import chalk from 'chalk'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { formatNumber } from '../../utils/format.js'
|
||||
import type { X402PaymentRecord } from './types.js'
|
||||
|
||||
/** In-memory payment records for the current session */
|
||||
let sessionPayments: X402PaymentRecord[] = []
|
||||
let sessionTotalUSD = 0
|
||||
|
||||
/** Add a payment record to the session tracker */
|
||||
export function addX402Payment(record: X402PaymentRecord): void {
|
||||
sessionPayments.push(record)
|
||||
sessionTotalUSD += record.amountUSD
|
||||
logForDebugging(
|
||||
`[x402] Session total: $${sessionTotalUSD.toFixed(4)} (${sessionPayments.length} payments)`,
|
||||
)
|
||||
}
|
||||
|
||||
/** Get total USD spent via x402 in the current session */
|
||||
export function getX402SessionSpentUSD(): number {
|
||||
return sessionTotalUSD
|
||||
}
|
||||
|
||||
/** Get all payment records for the current session */
|
||||
export function getX402SessionPayments(): readonly X402PaymentRecord[] {
|
||||
return sessionPayments
|
||||
}
|
||||
|
||||
/** Get the count of payments in this session */
|
||||
export function getX402PaymentCount(): number {
|
||||
return sessionPayments.length
|
||||
}
|
||||
|
||||
/** Reset session payment tracking (used on session switch) */
|
||||
export function resetX402SessionPayments(): void {
|
||||
sessionPayments = []
|
||||
sessionTotalUSD = 0
|
||||
}
|
||||
|
||||
/** Format x402 payment summary for display */
|
||||
export function formatX402Cost(): string {
|
||||
if (sessionPayments.length === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const lines: string[] = []
|
||||
lines.push(
|
||||
`x402 payments: $${sessionTotalUSD.toFixed(4)} (${sessionPayments.length} ${sessionPayments.length === 1 ? 'payment' : 'payments'})`,
|
||||
)
|
||||
|
||||
// Group by resource domain
|
||||
const byDomain: Record<string, { count: number; totalUSD: number }> = {}
|
||||
for (const payment of sessionPayments) {
|
||||
try {
|
||||
const domain = new URL(payment.resource).hostname
|
||||
if (!byDomain[domain]) {
|
||||
byDomain[domain] = { count: 0, totalUSD: 0 }
|
||||
}
|
||||
byDomain[domain].count += payment.count ?? 1
|
||||
byDomain[domain].totalUSD += payment.amountUSD
|
||||
} catch {
|
||||
// Skip malformed URLs
|
||||
}
|
||||
}
|
||||
|
||||
for (const [domain, stats] of Object.entries(byDomain)) {
|
||||
lines.push(
|
||||
`${domain}:`.padStart(21) +
|
||||
` ${formatNumber(stats.count)} ${stats.count === 1 ? 'request' : 'requests'} ($${stats.totalUSD.toFixed(4)})`,
|
||||
)
|
||||
}
|
||||
|
||||
return chalk.dim(lines.join('\n'))
|
||||
}
|
||||
Reference in New Issue
Block a user