This commit is contained in:
nirholas
2026-03-31 10:26:23 +00:00
parent d55e4a852e
commit 8ba939ba68
58 changed files with 982 additions and 26 deletions

View File

@@ -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/`

View File

@@ -28,3 +28,4 @@ COPY . .
# Default: drop into a shell for exploration
CMD ["sh"]

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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[]> {

View File

@@ -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
}
}
}
}
]

View File

@@ -791,3 +791,4 @@ export function buildTool<D extends AnyToolDef>(def: D): BuiltTool<D> {
} as BuiltTool<D>
}

View File

@@ -86,3 +86,4 @@ export async function fetchOlderEvents(
return fetchPage(ctx, { limit, before_id: beforeId }, 'fetchOlderEvents')
}

View File

@@ -134,3 +134,4 @@ export function wrapApiForFaultInjection(
}
}

View File

@@ -460,3 +460,4 @@ export class BoundedUUIDSet {
}
}

View File

@@ -162,3 +162,4 @@ export function wrapWithOsc8Link(text: string, url: string): string {
return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`
}

View File

@@ -140,3 +140,4 @@ export function logBridgeSkip(
})
}

View File

@@ -568,3 +568,4 @@ function deriveTitle(raw: string): string | undefined {
: flat
}

View File

@@ -81,3 +81,4 @@ export const DEFAULT_POLL_CONFIG: PollIntervalConfig = {
session_keepalive_interval_v2_ms: 120_000,
}

View File

@@ -549,3 +549,4 @@ export function createSessionSpawner(deps: SessionSpawnerDeps): SessionSpawner {
export { extractActivities as _extractActivitiesForTesting }

View File

@@ -35,3 +35,4 @@ export function getCompanionIntroAttachment(
]
}

View File

@@ -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

View File

@@ -329,3 +329,4 @@ export async function authLogout(): Promise<void> {
process.exit(0)
}

File diff suppressed because one or more lines are too long

View File

@@ -31,3 +31,4 @@ export function ndjsonSafeStringify(value: unknown): string {
return escapeJsLineTerminators(jsonStringify(value))
}

View File

@@ -254,3 +254,4 @@ export class RemoteIO extends StructuredIO {
}
}

View File

@@ -858,3 +858,4 @@ async function executePermissionRequestHooksForSDK(
return undefined
}

View File

@@ -710,3 +710,4 @@ function convertSSEUrlToPostUrl(sseUrl: URL): string {
return `${sseUrl.protocol}//${sseUrl.host}${pathname}`
}

View File

@@ -799,3 +799,4 @@ export class WebSocketTransport implements Transport {
}
}

View File

@@ -130,3 +130,4 @@ function coalescePatches(
return merged
}

View File

@@ -44,3 +44,4 @@ export function getTransportForUrl(
}
}

View File

@@ -9,3 +9,4 @@ const agents = {
export default agents

View File

@@ -13,3 +13,4 @@ const branch = {
export default branch

View File

@@ -1,2 +1,3 @@
export default { isEnabled: () => false, isHidden: true, name: 'stub' };

View File

@@ -199,3 +199,4 @@ const bridgeKick = {
export default bridgeKick

File diff suppressed because one or more lines are too long

View File

@@ -25,3 +25,4 @@ const bridge = {
export default bridge

View File

@@ -1,2 +1,3 @@
export default { isEnabled: () => false, isHidden: true, name: 'stub' };

View File

@@ -6,3 +6,4 @@ export const call: LocalCommandCall = async (_, context) => {
return { type: 'text', value: '' }
}

View File

@@ -91,3 +91,4 @@ const command = {
export default command

View File

@@ -14,3 +14,4 @@ const compact = {
export default compact

View File

@@ -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

View File

@@ -23,3 +23,4 @@ export const contextNonInteractive: Command = {
load: () => import('./context-noninteractive.js'),
}

File diff suppressed because one or more lines are too long

View File

@@ -14,3 +14,4 @@ const copy = {
export default copy

View File

@@ -64,3 +64,4 @@ Do not attempt to run the command. Simply inform the user about the plugin insta
}
}

View File

@@ -1,2 +1,3 @@
export default { isEnabled: () => false, isHidden: true, name: 'stub' };

View File

@@ -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==

View File

@@ -7,3 +7,4 @@ export default {
load: () => import('./diff.js'),
} satisfies Command

View 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

View File

@@ -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}`,
)
}

View File

@@ -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 =

View File

@@ -786,3 +786,4 @@ export function logAPISuccessAndDuration({
markFirstTeleportMessageLogged()
}
}

View File

@@ -157,3 +157,4 @@ export async function checkMetricsEnabled(): Promise<MetricsStatus> {
export const _clearMetricsEnabledCacheForTesting = (): void => {
memoizedCheckMetrics.cache.clear()
}

View File

@@ -135,3 +135,4 @@ export function formatGrantAmount(info: OverageCreditGrantInfo): string | null {
}
export type { CachedGrantEntry as OverageCreditGrantCacheEntry }

399
src/services/x402/client.ts Normal file
View 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
View 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 })
}

View 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'

View 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)
},
)
}

View 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'))
}