Compare commits

..
10 Commits
Author SHA1 Message Date
Qian 439f7dca13 Add CHANGELOG covering 0.1.0 through 0.4.0 2026-07-19 18:59:19 +00:00
Qian 10feb3d39a v0.4.0: QianPay on Arc testnet
Payment links and invoices take a chain argument, Base or Arc testnet, and
pay_link settles on whichever chain the link was created on. Adds Arc to the
chain registry as a pay-only entry, so the x402 client and the pay tools share
one provider. my_wallet now names the gas asset per chain, since Arc bills gas
in USDC rather than ETH.

Subscriptions stay on Base. The contract is deployed on Arc but no indexer or
keeper runs there, so a subscription would never charge; the tools reject that
chain instead of writing the offer to Base.
2026-07-19 18:50:34 +00:00
Qian 1cf6e3cb6f v0.3.0: QianPay tools for links, invoices and subscriptions
Adds the merchant surface on Base: create payment links and itemized
invoices, publish subscription offers, read the dashboard, manage API keys
and signed webhooks. Adds the payer side too, so an agent can settle a link
or invoice in USDC on-chain and manage its own subscriptions.

Funds settle to the QIAN_KEY wallet, which signs a short-lived session. The
subscribe approval covers three periods rather than an unlimited amount.
Comments trimmed to one-liners before publish.
2026-07-19 18:35:17 +00:00
Qian 341219fe9c Publish hygiene: files allowlist, x402 in blurb 2026-07-18 08:30:38 +00:00
Qian e94fb1ac68 Add x402 payment tools (x402_pay, x402_wallet); v0.2.0 2026-07-18 08:07:15 +00:00
Qian c19bdc950a docs: refresh slogan, drop ETH-only framing 2026-07-18 07:53:57 +00:00
Qian 327c2eec06 docs: add glyph branding header, refresh positioning 2026-07-18 07:36:55 +00:00
Qian e77103ea61 Strip em-dashes from tool descriptions 2026-07-18 05:43:43 +00:00
Qian 56cf12945d Add tokenized-stock (RWA) trade tools 2026-07-18 04:45:17 +00:00
Qian 94fe128640 quote_sell: friendly message when pool lacks WETH liquidity 2026-07-17 08:13:18 +00:00
5 changed files with 704 additions and 21 deletions
+30
View File
@@ -0,0 +1,30 @@
# Changelog
Notable changes to `@qianexchange/mcp`. Versions match the npm releases.
## 0.4.0 (2026-07-19)
- QianPay works on Arc testnet. `create_payment_link` and `create_invoice` take a `chain` argument (`base` or `arc-testnet`), and `pay_link` settles on whichever chain the link was created on.
- Arc joins the chain registry as a pay-only entry, so the x402 client and the QianPay tools share one provider.
- `my_wallet` names the gas asset per chain. Arc bills gas in USDC rather than ETH, so reporting every balance as ETH was wrong.
- `create_subscription_offer` now takes `chain` and rejects any chain without a keeper. It previously ignored the argument and wrote the offer to Base.
- Subscriptions stay on Base. The contract is deployed on Arc, but no indexer or keeper runs there, so a subscription would never charge.
## 0.3.0 (2026-07-19)
- Adds the QianPay merchant surface: `create_payment_link`, `create_invoice`, `create_subscription_offer`, `pay_dashboard`, `set_pay_profile`, `create_api_key`, `revoke_api_key`, `set_webhook`, `rotate_webhook_secret`.
- Adds the payer side: `pay_link`, `subscribe`, `cancel_subscription`, `charge_subscription`, plus the public reads `get_payment_link`, `get_subscription_offer`, `get_subscriptions`.
- Funds settle to the `QIAN_KEY` wallet, which signs a short-lived session per call. The subscribe approval covers three periods rather than an unlimited amount.
## 0.2.0 (2026-07-18)
- Adds x402 payment tools. `x402_pay` pays any x402-gated API in USDC over EIP-3009 on Base or Arc testnet, with a per-call spend cap. `x402_wallet` reports the payment wallet and its balances.
- Signing key resolution: `X402_PRIVATE_KEY`, then `QIAN_KEY`, then a burner generated at `~/.qian/mcp-wallet.json`.
- Restricts the published tarball to a `files` allowlist.
## 0.1.0 to 0.1.5 (2026-07-17 to 2026-07-18)
- First release. Read tools for tokens, trades, holders and positions, all keyless.
- Trading and launching on Robinhood Chain, Base and Ethereum: `buy`, `sell`, `launch_token`, `quote_buy`, `quote_sell`, `pending_fees`, `collect_fees`.
- Tokenized-stock tools: `list_stocks`, `quote_stock`, `buy_stock`, `sell_stock`.
- Quotes read the canonical quoters directly, so they work without a key.
+47 -4
View File
@@ -1,6 +1,12 @@
<div align="center">
<a href="https://qian.ag"><img src="https://qian.ag/branding/qian-glyph-2048.png" alt="Qian" width="120"></a>
# @qianexchange/mcp # @qianexchange/mcp
MCP server for [Qian](https://qian.ag), the multichain DEX and launchpad. Any MCP-capable agent can launch tokens, trade, and read market data on Robinhood Chain, Base, and Ethereum. **[qian.ag](https://qian.ag)** · [npm](https://www.npmjs.com/package/@qianexchange/mcp) · [Docs](https://docs.qian.ag/mcp) · [License: MIT](LICENSE)
</div>
MCP server for [Qian](https://qian.ag), a multichain exchange and payments layer for every asset. Any MCP-capable agent can launch tokens, trade, and read market data on every chain Qian supports (Robinhood Chain, Base, Ethereum), trade tokenized stocks on Robinhood Chain, and pay x402-gated APIs in USDC.
Non-custodial: the server runs on your machine and signs with your key. Qian never holds keys or funds. Every launched token's liquidity is locked forever in QianLocker. Non-custodial: the server runs on your machine and signs with your key. Qian never holds keys or funds. Every launched token's liquidity is locked forever in QianLocker.
@@ -30,13 +36,50 @@ Omit `QIAN_KEY` for read-only mode (market data still works). Use a dedicated ho
| `my_wallet` | Your address + ETH balance per chain | | `my_wallet` | Your address + ETH balance per chain |
| `my_positions` | Holdings, ETH value, and PnL for any address across chains | | `my_positions` | Holdings, ETH value, and PnL for any address across chains |
| `quote_buy` / `quote_sell` | Preview a trade, read-only (no key or approval) | | `quote_buy` / `quote_sell` | Preview a trade, read-only (no key or approval) |
| `buy` / `sell` | Trade via QianSwapRouter (plain ETH in/out, auto-approve, slippage-protected) | | `buy` / `sell` | Trade via QianSwapRouter (auto-approve, slippage-protected) |
| `launch_token` | Deploy a token: name/symbol/image/socials, FDV tier 14, creator fee 0.520%, optional dev buy | | `launch_token` | Deploy a token: name/symbol/image/socials, FDV tier 14, creator fee 0.520%, optional dev buy |
| `pending_fees` | Unclaimed creator/platform fees for a token (read-only) | | `pending_fees` | Unclaimed creator/platform fees for a token (read-only) |
| `collect_fees` | Claim your 70% creator share of trading fees | | `collect_fees` | Claim your 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 |
| `create_payment_link` / `create_invoice` | Create a QianPay link or itemized invoice (Base), payable in USDC |
| `create_subscription_offer` | Create a recurring USDC subscription offer |
| `pay_dashboard` | Your merchant dashboard: links, invoices, offers, subscribers, keys, webhook |
| `set_pay_profile` / `create_api_key` / `revoke_api_key` | Merchant account + API-key management |
| `set_webhook` / `rotate_webhook_secret` | Signed webhook config (intent.paid, invoice.paid, subscription.*) |
| `get_payment_link` / `get_subscription_offer` / `get_subscriptions` | Read link/offer/subscription state (no key) |
| `pay_link` | Pay a QianPay link or invoice in USDC on-chain |
| `subscribe` / `cancel_subscription` / `charge_subscription` | Subscribe to an offer, cancel, or force a charge |
## 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.
## QianPay
An agent can run the full QianPay merchant surface: create payment links and
itemized invoices, publish recurring subscription offers, read its dashboard
(paid/unpaid links, subscribers, MRR), manage API keys and signed webhooks.
Payment links and invoices work on Base and on Arc testnet, chosen with the
`chain` argument. Subscriptions are Base only, because no keeper runs on Arc
yet and a subscription there would never charge. Funds settle to the `QIAN_KEY` wallet. The server signs a
short-lived session with that key, so nothing is custodial.
It can also pay from the agent side: `pay_link` settles a link or invoice in
USDC on-chain, and `subscribe` / `cancel_subscription` manage subscriptions
(the subscribe approval is 3× the period amount, never infinite). Payment links
settle in USDC through the QianPay contract, a different rail from x402.
## 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
+3 -2
View File
@@ -1,8 +1,9 @@
{ {
"name": "@qianexchange/mcp", "name": "@qianexchange/mcp",
"version": "0.1.2", "version": "0.4.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 Qian (qian.ag): trade tokens and stocks, launch tokens, run QianPay payment links and invoices on Base and Arc, and pay x402 APIs in USDC from any MCP-capable agent",
"type": "module", "type": "module",
"files": ["server.js", "SECURITY.md"],
"bin": { "qian-mcp": "server.js" }, "bin": { "qian-mcp": "server.js" },
"author": "[email protected]", "author": "[email protected]",
"license": "MIT", "license": "MIT",
+487 -14
View File
@@ -17,10 +17,7 @@ import { ethers } from 'ethers'
const API = process.env.QIAN_API ?? 'https://api.qian.ag' const API = process.env.QIAN_API ?? 'https://api.qian.ag'
// V2 stack (2026-07-16): core addresses are IDENTICAL on every chain (one fresh // Core addresses are identical on every chain; only rpc/chainId/explorer differ.
// key at nonce 0 each). No feature hooks — the only approved hook is the GuardHook,
// applied automatically when deployCoin passes hookAddress=0, and the creator's tax
// is the V4 pool fee. Only rpc/chainId/explorer differ per chain.
const CORE = { const CORE = {
factoryV4: '0x750d0bc66baa12CF1710a8ba939B97DB4aD005c3', factoryV4: '0x750d0bc66baa12CF1710a8ba939B97DB4aD005c3',
feeRouter: '0xDe598E5F0b428B38EFaA6e887458FEF70bFA9436', feeRouter: '0xDe598E5F0b428B38EFaA6e887458FEF70bFA9436',
@@ -51,6 +48,13 @@ const CHAINS = {
quoterV2: '0x61fFE014bA17989E743c5F6cB21bF9697530B21e', quoterV2: '0x61fFE014bA17989E743c5F6cB21bF9697530B21e',
v4Quoter: '0x52F0E24D1c21C8A0cB1e5a5dD6198556BD9E1203', v4Quoter: '0x52F0E24D1c21C8A0cB1e5a5dD6198556BD9E1203',
}, },
// Pay-only: no DEX contracts, and gas is USDC (18dp native) rather than ETH.
'arc-testnet': {
chainId: 5042002,
rpc: process.env.RPC_ARC_TESTNET ?? 'https://rpc.testnet.arc.network',
explorer: 'https://testnet.arcscan.app',
native: 'USDC',
},
} }
const V3_POOL_FEE = 10_000 // 1% tier (factory constant); ROUTER takes 1% off the WETH side each direction const V3_POOL_FEE = 10_000 // 1% tier (factory constant); ROUTER takes 1% off the WETH side each direction
const chainSlug = z.enum(['robinhood', 'base', 'ethereum']) const chainSlug = z.enum(['robinhood', 'base', 'ethereum'])
@@ -76,6 +80,59 @@ const ERC20_ABI = [
'function approve(address,uint256) returns (bool)', 'function approve(address,uint256) returns (bool)',
] ]
// --- RWA / tokenized stocks (Robinhood Chain only) -----------------------------------------------
// Stock tokens have no WETH pool, so every trade is the 2-hop ETH<->WETH<->USDG<->token. 1% fee on WETH.
const RWA_ROUTER = '0xEDAB0aB0059A1e3794c3Ab8fF4569242b97f2eE6'
const RWA_USDG = '0x5fc5360d0400a0fd4f2af552add042d716f1d168'
const RWA_POOL_FEE = 3000, RWA_TICK_SPACING = 60, RWA_DEADLINE_SECS = 600
const RWA_ROUTER_ABI = [
'function buy((address currency0,address currency1,uint24 fee,int24 tickSpacing,address hooks)[] path, address token, uint256 minTokensOut, uint256 deadline) payable returns (uint256)',
'function sell((address currency0,address currency1,uint24 fee,int24 tickSpacing,address hooks)[] path, address token, uint256 tokensIn, uint256 minEthOut, uint256 deadline) returns (uint256)',
]
// `soon` = deployed but no USDG pool yet, so untradeable.
const RWA_CATALOG = [
{ symbol: 'NVDA', name: 'NVIDIA', address: '0xd0601CE157Db5bdC3162BbaC2a2C8aF5320D9EEC' },
{ symbol: 'TSLA', name: 'Tesla', address: '0x322F0929c4625eD5bAd873c95208D54E1c003b2d' },
{ symbol: 'SPY', name: 'S&P 500 ETF', address: '0x117cc2133c37B721F49dE2A7a74833232B3B4C0C' },
{ symbol: 'QQQ', name: 'Nasdaq 100 ETF', address: '0xD5f3879160bc7c32ebb4dC785F8a4F505888de68', soon: true },
{ symbol: 'COIN', name: 'Coinbase', address: '0x6330D8C3178a418788dF01a47479c0ce7CCF450b', soon: true },
{ symbol: 'AMZN', name: 'Amazon', address: '0x12f190a9F9d7D37a250758b26824B97CE941bF54' },
{ symbol: 'GOOGL', name: 'Alphabet', address: '0x2e0847E8910a9732eB3fb1bb4b70a580ADAD4FE3' },
{ symbol: 'SLV', name: 'Silver ETF', address: '0x411eFb0E7f985935DAec3D4C3ebaEa0d0AD7D89f', soon: true },
]
// Advisory surfaced on the trade tools; the server is headless and can't gate by location.
const RWA_RESTRICTED = 'US persons are restricted from trading tokenized stocks (they are securities). This tool cannot verify location. Calling buy_stock/sell_stock confirms you are not a US person and not trading from the US. US users may view and quote only. Tokens are issued by a third party (Robinhood Assets); Qian is a non-custodial venue, not the issuer.'
function resolveStock(q) {
const isAddr = /^0x[0-9a-fA-F]{40}$/.test(q)
const a = RWA_CATALOG.find((s) => (isAddr ? s.address.toLowerCase() === q.toLowerCase() : s.symbol.toLowerCase() === q.toLowerCase()))
if (!a) throw new Error(`unknown stock "${q}" — call list_stocks for the ticker/address list`)
return a
}
const rwaPoolKey = (a, b) => {
const [c0, c1] = BigInt(a) < BigInt(b) ? [a, b] : [b, a]
return { currency0: c0, currency1: c1, fee: RWA_POOL_FEE, tickSpacing: RWA_TICK_SPACING, hooks: ethers.ZeroAddress }
}
const rwaPath = (token, weth, side) =>
side === 'buy' ? [rwaPoolKey(weth, RWA_USDG), rwaPoolKey(token, RWA_USDG)] : [rwaPoolKey(token, RWA_USDG), rwaPoolKey(weth, RWA_USDG)]
async function rwaQuoteHop(quoter, tokenIn, other, inWei) {
const [c0, c1] = BigInt(tokenIn) < BigInt(other) ? [tokenIn, other] : [other, tokenIn]
const params = { poolKey: { currency0: c0, currency1: c1, fee: RWA_POOL_FEE, tickSpacing: RWA_TICK_SPACING, hooks: ethers.ZeroAddress }, zeroForOne: tokenIn.toLowerCase() === c0.toLowerCase(), exactAmount: inWei, hookData: '0x' }
return (await quoter.quoteExactInputSingle.staticCall(params)).amountOut
}
async function quoteStock(tokenAddr, side, inWei) {
const q = new ethers.Contract(CHAINS.robinhood.v4Quoter, V4_QUOTER_ABI, provider('robinhood'))
const weth = WETH.robinhood
if (side === 'buy') {
const usdg = await rwaQuoteHop(q, weth, RWA_USDG, (inWei * 99n) / 100n) // 1% ETH fee up front
return rwaQuoteHop(q, RWA_USDG, tokenAddr, usdg)
}
const usdg = await rwaQuoteHop(q, tokenAddr, RWA_USDG, inWei)
const wethOut = await rwaQuoteHop(q, RWA_USDG, weth, usdg)
return (wethOut * 99n) / 100n // 1% fee off the WETH out
}
const providers = {} const providers = {}
const provider = (c) => (providers[c] ??= new ethers.JsonRpcProvider(CHAINS[c].rpc, CHAINS[c].chainId, { staticNetwork: true })) const provider = (c) => (providers[c] ??= new ethers.JsonRpcProvider(CHAINS[c].rpc, CHAINS[c].chainId, { staticNetwork: true }))
function wallet(c) { function wallet(c) {
@@ -141,7 +198,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.4.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.',
@@ -177,13 +234,14 @@ server.registerTool('get_holders', {
}, async ({ chain, address: addr }) => json((await api(`/tokens/${chain}/${addr}/holders`)).holders)) }, async ({ chain, address: addr }) => json((await api(`/tokens/${chain}/${addr}/holders`)).holders))
server.registerTool('my_wallet', { server.registerTool('my_wallet', {
description: 'Your wallet address (from QIAN_KEY) and native ETH balance on each chain.', description: 'Your wallet address (from QIAN_KEY) and native gas balance on each chain. Arc testnet gas is USDC, every other chain is ETH.',
inputSchema: {}, inputSchema: {},
}, async () => { }, async () => {
const w = wallet('robinhood') const w = wallet('robinhood')
const balances = {} const balances = {}
for (const c of Object.keys(CHAINS)) balances[c] = ethers.formatEther(await provider(c).getBalance(w.address)) for (const [c, cfg] of Object.entries(CHAINS))
return json({ address: w.address, eth: balances }) balances[c] = `${ethers.formatEther(await provider(c).getBalance(w.address))} ${cfg.native ?? 'ETH'}`
return json({ address: w.address, gas: balances })
}) })
server.registerTool('my_positions', { server.registerTool('my_positions', {
@@ -208,7 +266,7 @@ server.registerTool('my_positions', {
}) })
server.registerTool('quote_buy', { server.registerTool('quote_buy', {
description: 'Preview buying a token with ETH via QianSwapRouter (1% router fee included). Read-only no wallet or key needed. Returns expected tokens out.', description: 'Preview buying a token with ETH via QianSwapRouter (1% router fee included). Read-only, no wallet or key needed. Returns expected tokens out.',
inputSchema: { chain: chainSlug, address, eth_amount: z.string().describe('ETH to spend, e.g. "0.05"') }, inputSchema: { chain: chainSlug, address, eth_amount: z.string().describe('ETH to spend, e.g. "0.05"') },
}, async ({ chain, address: addr, eth_amount }) => { }, async ({ chain, address: addr, eth_amount }) => {
const out = await quoteOut(chain, addr, 'buy', ethers.parseEther(eth_amount)) const out = await quoteOut(chain, addr, 'buy', ethers.parseEther(eth_amount))
@@ -216,10 +274,17 @@ server.registerTool('quote_buy', {
}) })
server.registerTool('quote_sell', { server.registerTool('quote_sell', {
description: 'Preview selling a token for ETH via QianSwapRouter (1% router fee included). Read-only — no wallet, key, or approval needed. Returns expected ETH out.', description: 'Preview selling a token for ETH via QianSwapRouter (1% router fee included). Read-only. No wallet, key, or approval needed. Returns expected ETH out.',
inputSchema: { chain: chainSlug, address, token_amount: z.string().describe('Tokens to sell in whole tokens, e.g. "1000000"') }, inputSchema: { chain: chainSlug, address, token_amount: z.string().describe('Tokens to sell in whole tokens, e.g. "1000000"') },
}, async ({ chain, address: addr, token_amount }) => { }, async ({ chain, address: addr, token_amount }) => {
const out = await quoteOut(chain, addr, 'sell', ethers.parseEther(token_amount)) let out
try {
out = await quoteOut(chain, addr, 'sell', ethers.parseEther(token_amount))
} catch (e) {
// Quoters revert when the pool can't cover the WETH out (thin/one-sided liquidity).
if (e?.code === 'CALL_EXCEPTION') throw new Error(`can't sell ${token_amount} tokens — the pool doesn't have enough WETH liquidity for a sell this size (try a smaller amount)`)
throw e
}
return json({ token_in: token_amount, eth_out: ethers.formatEther(out) }) return json({ token_in: token_amount, eth_out: ethers.formatEther(out) })
}) })
@@ -271,9 +336,9 @@ server.registerTool('sell', {
server.registerTool('launch_token', { server.registerTool('launch_token', {
description: [ description: [
'Launch a new token on Qian: 1B supply, full supply as locked one-sided liquidity (LP owned forever by QianLocker no rug possible), fees stream to you as creator (70%) and the platform (30%).', 'Launch a new token on Qian: 1B supply, full supply as locked one-sided liquidity (LP owned forever by QianLocker, no rug possible), fees stream to you as creator (70%) and the platform (30%).',
'Costs the deploy fee (~0.0005 ETH) + optional dev_buy_eth. Tiers set starting FDV: 1=1 ETH, 2=2, 3=5, 4=10.', 'Costs the deploy fee (~0.0005 ETH) + optional dev_buy_eth. Tiers set starting FDV: 1=1 ETH, 2=2, 3=5, 4=10.',
'creator_fee_pct is the trading tax (0.5%20%) you earn on every swap — it is the V4 pool fee, immutable once launched. There are no anti-snipe / dead-window / max-wallet options in V2.', 'creator_fee_pct is the trading tax (0.5%20%) you earn on every swap. It is the V4 pool fee, immutable once launched. There are no anti-snipe / dead-window / max-wallet options in V2.',
].join(' '), ].join(' '),
inputSchema: { inputSchema: {
chain: chainSlug, chain: chainSlug,
@@ -333,7 +398,7 @@ server.registerTool('pending_fees', {
}) })
server.registerTool('collect_fees', { server.registerTool('collect_fees', {
description: 'Collect accrued trading fees for a token via QianFeeRouter (permissionless anyone can trigger; payout always goes 70% to the token creator, 30% to the platform).', description: 'Collect accrued trading fees for a token via QianFeeRouter (permissionless, anyone can trigger; payout always goes 70% to the token creator, 30% to the platform).',
inputSchema: { chain: chainSlug, address }, inputSchema: { chain: chainSlug, address },
}, async ({ chain, address: addr }) => { }, async ({ chain, address: addr }) => {
const feeRouter = new ethers.Contract(CHAINS[chain].feeRouter, FEE_ROUTER_ABI, wallet(chain)) const feeRouter = new ethers.Contract(CHAINS[chain].feeRouter, FEE_ROUTER_ABI, wallet(chain))
@@ -341,5 +406,413 @@ server.registerTool('collect_fees', {
return json({ hash, explorer }) return json({ hash, explorer })
}) })
// --- RWA / tokenized-stock tools (Robinhood Chain only) ------------------------------------------
const stockArg = z.string().describe('ticker (e.g. "NVDA") or the token address')
server.registerTool('list_stocks', {
description: 'List tokenized stocks tradeable on Qian (Robinhood Chain). Tokens issued by a third party (Robinhood Assets); Qian is a non-custodial venue. Some are shown but not yet tradeable (no liquidity pool).',
inputSchema: {},
}, async () => json({
chain: 'robinhood',
disclaimer: RWA_RESTRICTED,
stocks: RWA_CATALOG.map((s) => ({
symbol: s.symbol, name: s.name, address: s.address,
tradeable: !s.soon,
url: `https://qian.ag/rwa`,
})),
}))
server.registerTool('quote_stock', {
description: 'Preview a tokenized-stock trade (Robinhood Chain, 1% router fee included). Read-only, no wallet or key needed. buy: amount is ETH to spend; sell: amount is tokens to sell.',
inputSchema: { stock: stockArg, side: z.enum(['buy', 'sell']), amount: z.string().describe('ETH for buy, tokens for sell, e.g. "0.1"') },
}, async ({ stock, side, amount }) => {
const s = resolveStock(stock)
if (s.soon) throw new Error(`${s.symbol} is not tradeable yet — no liquidity pool`)
let out
try {
out = await quoteStock(s.address, side, ethers.parseEther(amount))
} catch (e) {
if (e?.code === 'CALL_EXCEPTION') throw new Error(`quote failed for ${s.symbol} — pool can't fill ${amount} (try a smaller size)`)
throw e
}
return json(side === 'buy'
? { symbol: s.symbol, eth_in: amount, tokens_out: ethers.formatEther(out) }
: { symbol: s.symbol, tokens_in: amount, eth_out: ethers.formatEther(out) })
})
server.registerTool('buy_stock', {
description: `Buy a tokenized stock with plain ETH via QianRWARouter (Robinhood Chain). Simulates first, applies slippage to set min-out, then sends. ⚠️ ${RWA_RESTRICTED}`,
inputSchema: {
stock: stockArg,
eth_amount: z.string().describe('ETH to spend, e.g. "0.1"'),
slippage_bps: z.number().int().min(1).max(2000).default(300),
},
}, async ({ stock, eth_amount, slippage_bps }) => {
const s = resolveStock(stock)
if (s.soon) throw new Error(`${s.symbol} is not tradeable yet — no liquidity pool`)
const w = wallet('robinhood')
const router = new ethers.Contract(RWA_ROUTER, RWA_ROUTER_ABI, w)
const value = ethers.parseEther(eth_amount)
const path = rwaPath(s.address, WETH.robinhood, 'buy')
const deadline = BigInt(Math.floor(Date.now() / 1000) + RWA_DEADLINE_SECS)
const expected = await router.buy.staticCall(path, s.address, 0n, deadline, { value })
const minOut = (expected * BigInt(10_000 - slippage_bps)) / 10_000n
const { hash, explorer } = await sendAndWait(router.buy(path, s.address, minOut, deadline, { value }), 'robinhood', 'buy_stock')
const bal = await new ethers.Contract(s.address, ERC20_ABI, provider('robinhood')).balanceOf(w.address)
return json({ symbol: s.symbol, hash, explorer, spent_eth: eth_amount, tokens_received: ethers.formatEther(expected), wallet_balance: ethers.formatEther(bal), disclaimer: RWA_RESTRICTED })
})
server.registerTool('sell_stock', {
description: `Sell a tokenized stock for ETH via QianRWARouter (Robinhood Chain; approves the router if needed). amount "all" sells your full balance. ⚠️ ${RWA_RESTRICTED}`,
inputSchema: {
stock: stockArg,
amount: z.string().describe('token amount in whole tokens, or "all"'),
slippage_bps: z.number().int().min(1).max(2000).default(300),
},
}, async ({ stock, amount, slippage_bps }) => {
const s = resolveStock(stock)
if (s.soon) throw new Error(`${s.symbol} is not tradeable yet — no liquidity pool`)
const w = wallet('robinhood')
const erc = new ethers.Contract(s.address, ERC20_ABI, w)
const router = new ethers.Contract(RWA_ROUTER, RWA_ROUTER_ABI, w)
const tokens = amount === 'all' ? await erc.balanceOf(w.address) : ethers.parseEther(amount)
if (tokens === 0n) throw new Error(`nothing to sell — ${s.symbol} balance is 0`)
if ((await erc.allowance(w.address, RWA_ROUTER)) < tokens)
await sendAndWait(erc.approve(RWA_ROUTER, ethers.MaxUint256), 'robinhood', 'approve')
const path = rwaPath(s.address, WETH.robinhood, 'sell')
const deadline = BigInt(Math.floor(Date.now() / 1000) + RWA_DEADLINE_SECS)
const expected = await router.sell.staticCall(path, s.address, tokens, 0n, deadline)
const minOut = (expected * BigInt(10_000 - slippage_bps)) / 10_000n
const { hash, explorer } = await sendAndWait(router.sell(path, s.address, tokens, minOut, deadline), 'robinhood', '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) -------------------------------------------
// Key resolution: X402_PRIVATE_KEY > QIAN_KEY > burner 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: () => provider('arc-testnet') },
}
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),
})
})
// --- QianPay (payment links, invoices, subscriptions) --------------------------------------------
// Per-chain addresses: QianPay is NOT part of the matched core set.
const PAY_CHAINS = {
base: {
qianpay: '0xa3faE6c7e97506145c29f2E7a887b345f49Cf36B',
usdc: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
subs: '0xc3a89be96b8BDb1123d2Fa40f18000e3FB1A7aAf',
},
'arc-testnet': {
qianpay: '0x73Da198D58fFD43B5754a2d72D989cab1c9aF0ce',
usdc: '0x3600000000000000000000000000000000000000',
// Contract is deployed, but no Arc sub indexer or keeper runs, so a
// subscription there would never auto-charge. Base only until both exist.
subs: null,
},
}
const PAY_CHAIN = 'base' // default when a caller names no chain
const payChain = z.enum(['base', 'arc-testnet']).default('base').describe('payment chain; arc-testnet is a testnet')
function payCfg(chain) {
const c = PAY_CHAINS[chain]
if (!c) throw new Error(`QianPay is not deployed on ${chain}`)
return c
}
function subsCfg(chain) {
const c = payCfg(chain)
if (!c.subs) throw new Error(`subscriptions are not available on ${chain} (no indexer or keeper runs there, so charges would never fire)`)
return c
}
const QIANPAY_ABI = ['function payUSDC(bytes32 intentRef, address recipient, uint256 amount)']
const QIANSUBS_ABI = [
'function subscribe(address merchant, uint256 amount, uint256 period, bytes32 offerRef) returns (bytes32)',
'function cancel(bytes32 subId)',
'function charge(bytes32 subId)',
]
const toUsdc = (dollars) => ethers.parseUnits(String(dollars), 6).toString() // "10.5" -> "10500000"
const fromUsdc = (raw) => ethers.formatUnits(BigInt(raw), 6)
const usdcAmount = z.string().describe('USDC amount in dollars, e.g. "10.50"')
const payRef = z.string().regex(/^0x[0-9a-fA-F]{64}$/, '32-byte ref')
// Merchant account routes are session-authed. Mint a fresh session by signing the
// login message with QIAN_KEY — cheap enough to do per call, so no cache.
async function paySession() {
if (!process.env.QIAN_KEY) throw new Error('QIAN_KEY not set — QianPay merchant tools need a private key')
const w = new ethers.Wallet(process.env.QIAN_KEY)
const address = w.address.toLowerCase()
const issuedAt = Math.floor(Date.now() / 1000)
const signature = await w.signMessage(`qianpay:login:${address}:${issuedAt}`)
const { token } = await api('/pay/auth', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ address, issuedAt, signature }) })
return token
}
async function sessionReq(path, method, body) {
const token = await paySession()
const init = { method, headers: { authorization: `Bearer ${token}` } }
if (body !== undefined) { init.headers['content-type'] = 'application/json'; init.body = JSON.stringify(body) }
return api(path, init)
}
const payUrl = (ref) => `https://pay.qian.ag/link/${ref}`
server.registerTool('create_payment_link', {
description: 'Create a QianPay payment link on Base or Arc testnet. Funds go to your QIAN_KEY wallet; the payer settles in USDC. Returns the shareable pay.qian.ag link.',
inputSchema: { usdc_amount: usdcAmount, memo: z.string().max(140).default(''), chain: payChain },
}, async ({ usdc_amount, memo, chain }) => {
const w = new ethers.Wallet(process.env.QIAN_KEY ?? (() => { throw new Error('QIAN_KEY not set') })())
const recipient = w.address.toLowerCase()
const amount = toUsdc(usdc_amount)
const signature = await w.signMessage(`qianpay:create:${chain}:${recipient}:${amount}:${memo}`)
const { ref } = await api('/pay/intents', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ chain, recipient, usdcAmount: amount, memo, signature }) })
return json({ ref, url: payUrl(ref), recipient, usdc_amount, memo, chain })
})
server.registerTool('create_invoice', {
description: 'Create a QianPay invoice on Base or Arc testnet: line items summed to a total, payable in USDC at pay.qian.ag. Funds go to your QIAN_KEY wallet.',
inputSchema: {
chain: payChain,
items: z.array(z.object({
desc: z.string().max(120),
qty: z.number().int().min(1).max(1_000_000).default(1),
unit_usdc: usdcAmount,
})).min(1).max(20),
due_date: z.number().int().optional().describe('epoch seconds, optional'),
payer_label: z.string().max(80).optional(),
note: z.string().max(500).optional(),
},
}, async ({ items, due_date, payer_label, note, chain }) => {
const body = {
chain,
items: items.map((it) => ({ desc: it.desc, qty: it.qty, unitUsdc: toUsdc(it.unit_usdc) })),
dueDate: due_date ?? null, payerLabel: payer_label ?? '', note: note ?? '',
}
const { ref, usdcAmount: total } = await sessionReq('/pay/invoices', 'POST', body)
return json({ ref, url: payUrl(ref), total_usdc: fromUsdc(total), chain })
})
server.registerTool('create_subscription_offer', {
description: 'Create a recurring subscription offer (Base): payers subscribe on-chain and are charged usdc_amount every period. Returns the offer ref + subscribe link.',
inputSchema: {
name: z.string().min(1).max(80),
usdc_amount: usdcAmount,
period_seconds: z.number().int().min(3600).max(365 * 86_400).describe('billing period, 1 hour to 365 days'),
chain: payChain,
},
}, async ({ name, usdc_amount, period_seconds, chain }) => {
subsCfg(chain) // rejects a chain with no keeper instead of quietly writing the offer to Base
const { ref } = await sessionReq('/pay/offers', 'POST', { chain, name, usdcAmount: toUsdc(usdc_amount), period: period_seconds })
return json({ ref, url: `https://pay.qian.ag/subscribe/${ref}`, name, usdc_amount, period_seconds, chain })
})
server.registerTool('pay_dashboard', {
description: 'Your QianPay merchant dashboard (from QIAN_KEY): payment links with view/paid stats, invoices, subscription offers, active subscribers, API keys, and webhook config.',
inputSchema: {},
}, async () => json(await sessionReq('/pay/dashboard', 'GET')))
server.registerTool('set_pay_profile', {
description: 'Set your QianPay merchant display name (shown on your payment/checkout pages).',
inputSchema: { name: z.string().max(40) },
}, async ({ name }) => json(await sessionReq('/pay/profile', 'POST', { name })))
server.registerTool('create_api_key', {
description: 'Create a QianPay API key (qp_…) for embedding in merchant pages / checkout.js. Returned once. Keys can create payment links/invoices pinned to your wallet — nothing else.',
inputSchema: { label: z.string().max(40).optional() },
}, async ({ label }) => json(await sessionReq('/pay/keys', 'POST', { label: label ?? '' })))
server.registerTool('revoke_api_key', {
description: 'Revoke a QianPay API key by its id (from pay_dashboard.keys).',
inputSchema: { id: z.number().int() },
}, async ({ id }) => json(await sessionReq('/pay/keys/revoke', 'POST', { id })))
server.registerTool('set_webhook', {
description: 'Set (or clear with empty string) your QianPay webhook URL — https only, no private hosts. Delivers signed intent.paid / invoice.paid / subscription.* events.',
inputSchema: { url: z.string().describe('https URL, or "" to clear') },
}, async ({ url }) => json(await sessionReq('/pay/webhook', 'POST', { url })))
server.registerTool('rotate_webhook_secret', {
description: 'Generate a new whsec_ webhook signing secret (returned once). Sign-verify inbound webhooks with it: HMAC-SHA256 over the raw body, x-qianpay-signature header.',
inputSchema: {},
}, async () => json(await sessionReq('/pay/webhook/secret', 'POST', {})))
server.registerTool('get_payment_link', {
description: 'Read a QianPay payment link / invoice by ref: amount, memo, paid status, and (for invoices) line items. Public, no key.',
inputSchema: { ref: payRef },
}, async ({ ref }) => {
const row = await api(`/pay/intents/${ref}`)
return json({
ref: row.intent_ref, chain: row.chain, recipient: row.recipient, recipient_name: row.recipient_name,
usdc_amount: fromUsdc(row.usdc_amount), memo: row.memo,
paid: !!row.paid_tx, paid_tx: row.paid_tx ?? null, payer: row.payer ?? null,
invoice: row.invoice, url: payUrl(row.intent_ref),
})
})
server.registerTool('get_subscription_offer', {
description: 'Read a QianPay subscription offer by ref: merchant, amount, period, contract. Public, no key.',
inputSchema: { ref: payRef },
}, async ({ ref }) => {
const o = await api(`/pay/offers/${ref}`)
return json({ ref: o.offer_ref, chain: o.chain, merchant: o.recipient, merchant_name: o.recipient_name, usdc_amount: fromUsdc(o.usdc_amount), period_seconds: o.period, name: o.name, contract: o.contract })
})
server.registerTool('get_subscriptions', {
description: 'List subscriptions for an address (both roles, all chains): asMerchant (you get paid) and asPayer (you pay). Defaults to your QIAN_KEY wallet.',
inputSchema: { address: address.optional() },
}, async ({ address: addr }) => {
const who = addr ?? (process.env.QIAN_KEY ? new ethers.Wallet(process.env.QIAN_KEY).address.toLowerCase() : null)
if (!who) throw new Error('no address — pass one or set QIAN_KEY')
return json({ address: who, ...(await api(`/pay/subs/${who}`)) })
})
async function ensureUsdcAllowance(chain, spender, need) {
const w = wallet(chain)
const usdc = new ethers.Contract(payCfg(chain).usdc, ERC20_ABI, w)
if ((await usdc.allowance(w.address, spender)) < need)
await sendAndWait(usdc.approve(spender, need), chain, 'approve USDC')
}
server.registerTool('pay_link', {
description: 'Pay a QianPay payment link / invoice in USDC from your QIAN_KEY wallet, on whichever chain the link was created on (Base or Arc testnet). Approves USDC if needed, then settles on-chain. Refuses if already paid.',
inputSchema: { ref: payRef },
}, async ({ ref }) => {
const row = await api(`/pay/intents/${ref}`)
const cfg = payCfg(row.chain) // the link's chain decides; throws if we can't pay there
if (row.paid_tx) throw new Error(`already paid: ${row.paid_tx}`)
const amount = BigInt(row.usdc_amount)
await ensureUsdcAllowance(row.chain, cfg.qianpay, amount)
const pay = new ethers.Contract(cfg.qianpay, QIANPAY_ABI, wallet(row.chain))
const { hash, explorer } = await sendAndWait(pay.payUSDC(ref, row.recipient, amount), row.chain, 'payUSDC')
return json({ hash, explorer, paid_usdc: fromUsdc(amount), recipient: row.recipient, chain: row.chain })
})
server.registerTool('subscribe', {
description: 'Subscribe to a QianPay offer (Base) from your QIAN_KEY wallet. Charges the first period in the same tx; approves 3× the period amount (not infinite). Returns the on-chain subId.',
inputSchema: { offer_ref: payRef },
}, async ({ offer_ref }) => {
const o = await api(`/pay/offers/${offer_ref}`)
const subsAddr = subsCfg(o.chain).subs
const amount = BigInt(o.usdc_amount)
await ensureUsdcAllowance(o.chain, subsAddr, amount * 3n) // UI convention: amount×3, re-approve later
const subs = new ethers.Contract(subsAddr, QIANSUBS_ABI, wallet(o.chain))
const subId = await subs.subscribe.staticCall(o.recipient, amount, o.period, offer_ref)
const { hash, explorer } = await sendAndWait(subs.subscribe(o.recipient, amount, o.period, offer_ref), o.chain, 'subscribe')
return json({ sub_id: subId, hash, explorer, merchant: o.recipient, usdc_amount: fromUsdc(amount), period_seconds: o.period })
})
server.registerTool('cancel_subscription', {
description: 'Cancel a subscription by subId (Base). Either party may cancel; effective immediately, no further charges.',
inputSchema: { sub_id: payRef, chain: payChain },
}, async ({ sub_id, chain }) => {
const subs = new ethers.Contract(subsCfg(chain).subs, QIANSUBS_ABI, wallet(chain))
const { hash, explorer } = await sendAndWait(subs.cancel(sub_id), chain, 'cancel')
return json({ hash, explorer, sub_id })
})
server.registerTool('charge_subscription', {
description: 'Trigger a due subscription charge by subId (Base, permissionless). Normally the Qian keeper does this every 5 min; use only to force a charge. Reverts if not yet due.',
inputSchema: { sub_id: payRef, chain: payChain },
}, async ({ sub_id, chain }) => {
const subs = new ethers.Contract(subsCfg(chain).subs, QIANSUBS_ABI, wallet(chain))
const { hash, explorer } = await sendAndWait(subs.charge(sub_id), chain, 'charge')
return json({ hash, explorer, sub_id })
})
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)