Add x402 payment tools (x402_pay, x402_wallet); v0.2.0
This commit is contained in:
@@ -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 |
|
||||
| `list_stocks` / `quote_stock` | List and preview tokenized stocks, read-only (no key) |
|
||||
| `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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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",
|
||||
"type": "module",
|
||||
"bin": { "qian-mcp": "server.js" },
|
||||
|
||||
@@ -194,7 +194,7 @@ async function quoteOut(chain, tokenAddr, side, inWei) {
|
||||
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', {
|
||||
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 })
|
||||
})
|
||||
|
||||
// --- 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())
|
||||
console.error(`qian-mcp ready — api=${API} write=${process.env.QIAN_KEY ? 'enabled' : 'DISABLED (no QIAN_KEY)'}`)
|
||||
|
||||
+136
@@ -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)
|
||||
Reference in New Issue
Block a user