Add x402 payment tools (x402_pay, x402_wallet); v0.2.0

This commit is contained in:
Qian
2026-07-18 08:07:15 +00:00
parent c19bdc950a
commit e94fb1ac68
4 changed files with 255 additions and 3 deletions
+9 -1
View File
@@ -42,13 +42,21 @@ Omit `QIAN_KEY` for read-only mode (market data still works). Use a dedicated ho
| `collect_fees` | Claim your 70% creator share of trading fees | | `collect_fees` | Claim your 70% creator share of trading fees |
| `list_stocks` / `quote_stock` | List and preview tokenized stocks, read-only (no key) | | `list_stocks` / `quote_stock` | List and preview tokenized stocks, read-only (no key) |
| `buy_stock` / `sell_stock` | Trade tokenized stocks via QianRWARouter (Robinhood Chain) | | `buy_stock` / `sell_stock` | Trade tokenized stocks via QianRWARouter (Robinhood Chain) |
| `x402_wallet` | The x402 payment wallet: address and USDC balances |
| `x402_pay` | Pay an x402 (HTTP 402) API in USDC and return its response |
## Tokenized stocks ## Tokenized stocks
Robinhood Chain carries tokenized stocks (NVDA, TSLA, SPY, and more) issued by a third party (Robinhood Assets); Qian is a non-custodial venue, not the issuer. US persons are restricted from trading them. The read tools (`list_stocks`, `quote_stock`) work for everyone, but `buy_stock` and `sell_stock` are for non-US persons only. The server is headless and cannot verify location, so that restriction is on you to honor. Robinhood Chain carries tokenized stocks (NVDA, TSLA, SPY, and more) issued by a third party (Robinhood Assets); Qian is a non-custodial venue, not the issuer. US persons are restricted from trading them. The read tools (`list_stocks`, `quote_stock`) work for everyone, but `buy_stock` and `sell_stock` are for non-US persons only. The server is headless and cannot verify location, so that restriction is on you to honor.
## x402 payments
`x402_pay` lets an agent pay for x402-gated APIs (HTTP 402 + USDC, Base mainnet or Arc testnet). It fetches the URL; on a 402 it signs an EIP-3009 USDC authorization and retries with the `X-PAYMENT` header. It never pays above the `max_amount_usdc` you pass per call.
Signing key resolution: `X402_PRIVATE_KEY`, else `QIAN_KEY`, else the server generates a burner wallet at `~/.qian/mcp-wallet.json` on first use and prints its address. Fund that wallet with a few USDC on Base and treat it as float — never store more there than you're willing to let the agent spend.
## Configuration ## Configuration
Env overrides: `QIAN_API`, `RPC_ROBINHOOD`, `RPC_BASE`, `RPC_ETHEREUM`. Env overrides: `QIAN_API`, `QIAN_KEY`, `X402_PRIVATE_KEY`, `RPC_ROBINHOOD`, `RPC_BASE`, `RPC_ETHEREUM`.
MIT. Contact: dev@qian.ag MIT. Contact: dev@qian.ag
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@qianexchange/mcp", "name": "@qianexchange/mcp",
"version": "0.1.5", "version": "0.2.0",
"description": "MCP server for the Qian multichain DEX & launchpad (qian.ag) — launch and trade tokens from any MCP-capable agent", "description": "MCP server for the Qian multichain DEX & launchpad (qian.ag) — launch and trade tokens from any MCP-capable agent",
"type": "module", "type": "module",
"bin": { "qian-mcp": "server.js" }, "bin": { "qian-mcp": "server.js" },
+109 -1
View File
@@ -194,7 +194,7 @@ async function quoteOut(chain, tokenAddr, side, inWei) {
return buy ? out : (out * 99n) / 100n // sells: 1% fee off the WETH out return buy ? out : (out * 99n) / 100n // sells: 1% fee off the WETH out
} }
const server = new McpServer({ name: 'qian', version: '0.1.0' }) const server = new McpServer({ name: 'qian', version: '0.2.0' })
server.registerTool('list_tokens', { server.registerTool('list_tokens', {
description: 'List tokens launched on Qian (all chains, newest first). Optionally filter by chain.', description: 'List tokens launched on Qian (all chains, newest first). Optionally filter by chain.',
@@ -482,5 +482,113 @@ server.registerTool('sell_stock', {
return json({ symbol: s.symbol, hash, explorer, tokens_sold: ethers.formatEther(tokens), eth_received: ethers.formatEther(expected), disclaimer: RWA_RESTRICTED }) return json({ symbol: s.symbol, hash, explorer, tokens_sold: ethers.formatEther(tokens), eth_received: ethers.formatEther(expected), disclaimer: RWA_RESTRICTED })
}) })
// --- x402 (HTTP 402 agent payments, USDC via EIP-3009) -------------------------------------------
// Generic client: pay any x402-gated API on Base mainnet or Arc testnet. Signing key resolution:
// X402_PRIVATE_KEY > QIAN_KEY > auto-generated burner persisted at ~/.qian/mcp-wallet.json.
// The burner is a payment float — fund it with a few USDC, never more.
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
const X402_NETWORKS = {
// x402 `network` identifiers -> chain facts. Arc testnet USDC contract is 6dp (native gas is
// an 18dp representation of the same asset — different thing, do not confuse).
base: { chainId: 8453, usdc: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', rpc: () => provider('base') },
'arc-testnet': { chainId: 5042002, usdc: '0x3600000000000000000000000000000000000000', rpc: () => (providers['arc-testnet'] ??= new ethers.JsonRpcProvider('https://rpc.testnet.arc.network', 5042002, { staticNetwork: true })) },
}
const X402_WALLET_FILE = join(homedir(), '.qian', 'mcp-wallet.json')
function x402Key() {
if (process.env.X402_PRIVATE_KEY) return { key: process.env.X402_PRIVATE_KEY, source: 'X402_PRIVATE_KEY' }
if (process.env.QIAN_KEY) return { key: process.env.QIAN_KEY, source: 'QIAN_KEY' }
try {
return { key: JSON.parse(readFileSync(X402_WALLET_FILE, 'utf8')).privateKey, source: X402_WALLET_FILE }
} catch {
const w = ethers.Wallet.createRandom()
mkdirSync(join(homedir(), '.qian'), { recursive: true })
writeFileSync(X402_WALLET_FILE, JSON.stringify({ address: w.address, privateKey: w.privateKey }, null, 2), { mode: 0o600 })
console.error(`x402: generated burner wallet ${w.address} -> ${X402_WALLET_FILE}. Fund it with a few USDC on Base (float only).`)
return { key: w.privateKey, source: `${X402_WALLET_FILE} (new)` }
}
}
const USDC_BAL_ABI = ['function balanceOf(address) view returns (uint256)']
server.registerTool('x402_wallet', {
description: 'The x402 payment wallet: address, key source, and USDC balances on Base + Arc testnet. If freshly generated, fund it with a few USDC on Base — treat it as a burner float, never main funds.',
inputSchema: {},
}, async () => {
const { key, source } = x402Key()
const w = new ethers.Wallet(key)
const balances = {}
for (const [net, cfg] of Object.entries(X402_NETWORKS)) {
try {
balances[net] = ethers.formatUnits(await new ethers.Contract(cfg.usdc, USDC_BAL_ABI, cfg.rpc()).balanceOf(w.address), 6)
} catch (e) {
balances[net] = `error: ${e.message}` // network may be unreachable; report, don't hide
}
}
return json({ address: w.address, key_source: source, usdc: balances, note: 'burner float only — fund with a few USDC on Base' })
})
server.registerTool('x402_pay', {
description: 'Fetch a URL; if it responds 402 (x402 protocol), pay the requested USDC (Base or Arc testnet) by signing an EIP-3009 authorization and retry. Never pays more than max_amount_usdc. Returns the response plus what was paid.',
inputSchema: {
url: z.string().url(),
method: z.enum(['GET', 'POST']).default('GET'),
body: z.string().optional().describe('request body for POST (sent as application/json)'),
max_amount_usdc: z.string().describe('hard cap for this call, e.g. "0.05" — payment above this is refused'),
},
}, async ({ url, method, body, max_amount_usdc }) => {
const init = { method, headers: {}, ...(body ? { body, headers: { 'content-type': 'application/json' } } : {}) }
const first = await fetch(url, init)
if (first.status !== 402) {
return json({ status: first.status, paid: null, body: (await first.text()).slice(0, 10_000) })
}
const reqs = await first.json().catch(() => { throw new Error('402 response body is not JSON — not an x402 endpoint') })
const accepts = reqs.accepts ?? []
const req = accepts.find((a) => a.scheme === 'exact' && X402_NETWORKS[a.network])
if (!req) throw new Error(`no payable option: server accepts ${JSON.stringify(accepts.map((a) => `${a.scheme}/${a.network}`))}, we support exact on ${Object.keys(X402_NETWORKS).join('/')}`)
const net = X402_NETWORKS[req.network]
if (req.asset && req.asset.toLowerCase() !== net.usdc.toLowerCase()) throw new Error(`asset ${req.asset} on ${req.network} is not USDC — refusing`)
const amount = BigInt(req.maxAmountRequired)
const cap = ethers.parseUnits(max_amount_usdc, 6)
if (amount > cap) throw new Error(`payment required ${ethers.formatUnits(amount, 6)} USDC exceeds your cap ${max_amount_usdc} — refused`)
const { key } = x402Key()
const w = new ethers.Wallet(key)
const now = Math.floor(Date.now() / 1000)
const authorization = {
from: w.address, to: req.payTo, value: amount,
validAfter: 0n, validBefore: BigInt(now + Number(req.maxTimeoutSeconds ?? 300)),
nonce: ethers.hexlify(ethers.randomBytes(32)),
}
const signature = await w.signTypedData(
{ name: req.extra?.name ?? 'USD Coin', version: req.extra?.version ?? '2', chainId: net.chainId, verifyingContract: net.usdc },
{ TransferWithAuthorization: [
{ name: 'from', type: 'address' }, { name: 'to', type: 'address' }, { name: 'value', type: 'uint256' },
{ name: 'validAfter', type: 'uint256' }, { name: 'validBefore', type: 'uint256' }, { name: 'nonce', type: 'bytes32' },
] },
authorization,
)
const payment = {
x402Version: reqs.x402Version ?? 1, scheme: 'exact', network: req.network,
payload: { signature, authorization: { ...authorization, value: String(amount), validAfter: '0', validBefore: String(authorization.validBefore) } },
}
const paid = await fetch(url, { ...init, headers: { ...init.headers, 'X-PAYMENT': Buffer.from(JSON.stringify(payment)).toString('base64') } })
const settlementHeader = paid.headers.get('x-payment-response')
let settlement = null
if (settlementHeader) {
try { settlement = JSON.parse(Buffer.from(settlementHeader, 'base64').toString('utf8')) }
catch (e) { settlement = { raw: settlementHeader, parseError: e.message } }
}
return json({
status: paid.status,
paid: { amount_usdc: ethers.formatUnits(amount, 6), network: req.network, to: req.payTo },
settlement,
body: (await paid.text()).slice(0, 10_000),
})
})
await server.connect(new StdioServerTransport()) await server.connect(new StdioServerTransport())
console.error(`qian-mcp ready — api=${API} write=${process.env.QIAN_KEY ? 'enabled' : 'DISABLED (no QIAN_KEY)'}`) console.error(`qian-mcp ready — api=${API} write=${process.env.QIAN_KEY ? 'enabled' : 'DISABLED (no QIAN_KEY)'}`)
+136
View File
@@ -0,0 +1,136 @@
// Self-check for the x402 client logic in server.js: mock 402 server in-process,
// assert the payment header parses, the EIP-3009 signature recovers to the payer,
// the cap is enforced, and malformed requirements fail loud.
// Run: node test-x402.mjs (exit 0 = pass)
import assert from 'node:assert/strict'
import { createServer } from 'node:http'
import { ethers } from 'ethers'
const USDC_BASE = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'
const PAY_TO = '0x92B92aA9fa5AefC957ab77904f23cf14d7369fA7'
const key = ethers.Wallet.createRandom()
process.env.X402_PRIVATE_KEY = key.privateKey
// ---- mock x402 endpoint -------------------------------------------------------
const REQUIREMENT = {
x402Version: 1,
accepts: [{
scheme: 'exact', network: 'base', asset: USDC_BASE, payTo: PAY_TO,
maxAmountRequired: '10000', maxTimeoutSeconds: 300, resource: '/paid',
extra: { name: 'USD Coin', version: '2' },
}],
}
let lastPayment = null
const srv = createServer((req, res) => {
const header = req.headers['x-payment']
if (!header) {
res.writeHead(402, { 'content-type': 'application/json' })
return res.end(JSON.stringify(REQUIREMENT))
}
lastPayment = JSON.parse(Buffer.from(header, 'base64').toString('utf8'))
const { authorization: a, signature } = lastPayment.payload
const recovered = ethers.verifyTypedData(
{ name: 'USD Coin', version: '2', chainId: 8453, verifyingContract: USDC_BASE },
{ TransferWithAuthorization: [
{ name: 'from', type: 'address' }, { name: 'to', type: 'address' }, { name: 'value', type: 'uint256' },
{ name: 'validAfter', type: 'uint256' }, { name: 'validBefore', type: 'uint256' }, { name: 'nonce', type: 'bytes32' },
] },
{ from: a.from, to: a.to, value: BigInt(a.value), validAfter: BigInt(a.validAfter), validBefore: BigInt(a.validBefore), nonce: a.nonce },
signature,
)
if (recovered.toLowerCase() !== a.from.toLowerCase()) {
res.writeHead(402, { 'content-type': 'application/json' })
return res.end(JSON.stringify({ error: 'bad signature' }))
}
res.writeHead(200, {
'content-type': 'application/json',
'x-payment-response': Buffer.from(JSON.stringify({ success: true, network: 'base', payer: a.from })).toString('base64'),
})
res.end(JSON.stringify({ ok: true, data: 'paid content' }))
})
await new Promise((r) => srv.listen(0, r))
const port = srv.address().port
// ---- exercise the real tool via the MCP server's registered handler -----------
// Import server.js would connect stdio; instead re-implement the call through the
// exported tool is not possible (single-file server). So this test drives the same
// logic path the tool uses by calling it through a child MCP instance is overkill:
// we spawn the server as a subprocess speaking MCP over stdio.
import { spawn } from 'node:child_process'
const child = spawn(process.execPath, ['server.js'], {
cwd: new URL('.', import.meta.url).pathname,
env: { ...process.env, QIAN_API: 'http://127.0.0.1:1' }, // api unused here; fail loud if touched
stdio: ['pipe', 'pipe', 'pipe'],
})
let buf = ''
const pending = new Map()
child.stdout.on('data', (d) => {
buf += d.toString()
let idx
while ((idx = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, idx); buf = buf.slice(idx + 1)
if (!line.trim()) continue
const msg = JSON.parse(line)
if (msg.id && pending.has(msg.id)) { pending.get(msg.id)(msg); pending.delete(msg.id) }
}
})
let nextId = 1
function rpc(method, params) {
const id = nextId++
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n')
return new Promise((resolve, reject) => {
pending.set(id, resolve)
setTimeout(() => reject(new Error(`rpc ${method} timed out`)), 15_000).unref()
})
}
await rpc('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 't', version: '0' } })
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n')
async function callTool(name, args) {
const res = await rpc('tools/call', { name, arguments: args })
if (res.error) throw new Error(res.error.message)
const item = res.result.content[0]
if (res.result.isError) return { error: item.text }
return JSON.parse(item.text)
}
let passed = 0
const check = (cond, label) => { assert.ok(cond, label); console.log(`PASS ${label}`); passed++ }
// 1. happy path: pays and gets content
const r1 = await callTool('x402_pay', { url: `http://127.0.0.1:${port}/paid`, max_amount_usdc: '0.05' })
check(r1.status === 200, 'paid request returns 200')
check(r1.paid.amount_usdc === '0.01', 'paid exactly 0.01 USDC')
check(r1.settlement?.success === true, 'settlement header decoded')
check(JSON.parse(r1.body).data === 'paid content', 'body delivered')
check(lastPayment.payload.authorization.from.toLowerCase() === key.address.toLowerCase(), 'authorization signed by our wallet')
check(lastPayment.scheme === 'exact' && lastPayment.network === 'base', 'scheme/network correct')
// 2. over-cap refusal: cap below required amount
const r2 = await callTool('x402_pay', { url: `http://127.0.0.1:${port}/paid`, max_amount_usdc: '0.001' })
check(String(r2.error).includes('exceeds your cap'), 'over-cap payment refused')
// 3. non-402 endpoint passes through unpaid
const plain = createServer((q, s) => { s.writeHead(200); s.end('free') })
await new Promise((r) => plain.listen(0, r))
const r3 = await callTool('x402_pay', { url: `http://127.0.0.1:${plain.address().port}/`, max_amount_usdc: '1' })
check(r3.status === 200 && r3.paid === null && r3.body === 'free', 'non-402 passes through unpaid')
// 4. malformed requirements fail loud
const bad = createServer((q, s) => { s.writeHead(402, { 'content-type': 'text/plain' }); s.end('nope') })
await new Promise((r) => bad.listen(0, r))
const r4 = await callTool('x402_pay', { url: `http://127.0.0.1:${bad.address().port}/`, max_amount_usdc: '1' })
check(String(r4.error).includes('not JSON'), 'malformed 402 body fails loud')
// 5. unsupported network refused
const alien = createServer((q, s) => {
s.writeHead(402, { 'content-type': 'application/json' })
s.end(JSON.stringify({ x402Version: 1, accepts: [{ scheme: 'exact', network: 'polygon', maxAmountRequired: '1', payTo: PAY_TO }] }))
})
await new Promise((r) => alien.listen(0, r))
const r5 = await callTool('x402_pay', { url: `http://127.0.0.1:${alien.address().port}/`, max_amount_usdc: '1' })
check(String(r5.error).includes('no payable option'), 'unsupported network refused')
console.log(`\n${passed}/10 checks passed`)
child.kill(); srv.close(); plain.close(); bad.close(); alien.close()
process.exit(0)