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.
This commit is contained in:
Qian
2026-07-19 18:50:34 +00:00
parent 1cf6e3cb6f
commit 10feb3d39a
3 changed files with 86 additions and 52 deletions
+6 -4
View File
@@ -65,10 +65,12 @@ Signing key resolution: `X402_PRIVATE_KEY`, else `QIAN_KEY`, else the server gen
## QianPay ## QianPay
An agent can run the full QianPay merchant surface on Base: create payment An agent can run the full QianPay merchant surface: create payment links and
links and itemized invoices, publish recurring subscription offers, read its itemized invoices, publish recurring subscription offers, read its dashboard
dashboard (paid/unpaid links, subscribers, MRR), manage API keys and signed (paid/unpaid links, subscribers, MRR), manage API keys and signed webhooks.
webhooks. Funds settle to the `QIAN_KEY` wallet. The server signs a 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. 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 It can also pay from the agent side: `pay_link` settles a link or invoice in
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "@qianexchange/mcp", "name": "@qianexchange/mcp",
"version": "0.3.0", "version": "0.4.0",
"description": "MCP server for Qian (qian.ag) — trade tokens and stocks, launch tokens, run QianPay payment links/invoices/subscriptions, and pay x402 APIs in USDC 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"], "files": ["server.js", "SECURITY.md"],
"bin": { "qian-mcp": "server.js" }, "bin": { "qian-mcp": "server.js" },
+78 -46
View File
@@ -48,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'])
@@ -191,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.3.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.',
@@ -227,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', {
@@ -490,7 +498,7 @@ const X402_NETWORKS = {
// x402 `network` identifiers -> chain facts. Arc testnet USDC contract is 6dp (native gas is // 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). // an 18dp representation of the same asset — different thing, do not confuse).
base: { chainId: 8453, usdc: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', rpc: () => provider('base') }, 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 })) }, 'arc-testnet': { chainId: 5042002, usdc: '0x3600000000000000000000000000000000000000', rpc: () => provider('arc-testnet') },
} }
const X402_WALLET_FILE = join(homedir(), '.qian', 'mcp-wallet.json') const X402_WALLET_FILE = join(homedir(), '.qian', 'mcp-wallet.json')
@@ -587,12 +595,33 @@ server.registerTool('x402_pay', {
}) })
// --- QianPay (payment links, invoices, subscriptions) -------------------------------------------- // --- QianPay (payment links, invoices, subscriptions) --------------------------------------------
// Live on Base only (contract + indexer both wired there). ponytail: single-chain // Per-chain addresses: QianPay is NOT part of the matched core set.
// until the operator deploys QianPay + an indexer on another chain — add it here then. const PAY_CHAINS = {
const PAY_CHAIN = 'base' base: {
const QIANPAY_ADDR = '0xa3faE6c7e97506145c29f2E7a887b345f49Cf36B' qianpay: '0xa3faE6c7e97506145c29f2E7a887b345f49Cf36B',
const QIANSUBS_ADDR = '0xc3a89be96b8BDb1123d2Fa40f18000e3FB1A7aAf' usdc: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
const USDC_ADDR = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' // Base USDC, 6dp 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 QIANPAY_ABI = ['function payUSDC(bytes32 intentRef, address recipient, uint256 amount)']
const QIANSUBS_ABI = [ const QIANSUBS_ABI = [
'function subscribe(address merchant, uint256 amount, uint256 period, bytes32 offerRef) returns (bytes32)', 'function subscribe(address merchant, uint256 amount, uint256 period, bytes32 offerRef) returns (bytes32)',
@@ -624,20 +653,21 @@ async function sessionReq(path, method, body) {
const payUrl = (ref) => `https://pay.qian.ag/link/${ref}` const payUrl = (ref) => `https://pay.qian.ag/link/${ref}`
server.registerTool('create_payment_link', { server.registerTool('create_payment_link', {
description: 'Create a QianPay payment link (Base). Funds go to your QIAN_KEY wallet; payer settles in USDC (0.5% fee). Returns the shareable pay.qian.ag 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('') }, inputSchema: { usdc_amount: usdcAmount, memo: z.string().max(140).default(''), chain: payChain },
}, async ({ usdc_amount, memo }) => { }, async ({ usdc_amount, memo, chain }) => {
const w = new ethers.Wallet(process.env.QIAN_KEY ?? (() => { throw new Error('QIAN_KEY not set') })()) const w = new ethers.Wallet(process.env.QIAN_KEY ?? (() => { throw new Error('QIAN_KEY not set') })())
const recipient = w.address.toLowerCase() const recipient = w.address.toLowerCase()
const amount = toUsdc(usdc_amount) const amount = toUsdc(usdc_amount)
const signature = await w.signMessage(`qianpay:create:${PAY_CHAIN}:${recipient}:${amount}:${memo}`) 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: PAY_CHAIN, recipient, usdcAmount: amount, memo, signature }) }) 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 }) return json({ ref, url: payUrl(ref), recipient, usdc_amount, memo, chain })
}) })
server.registerTool('create_invoice', { server.registerTool('create_invoice', {
description: 'Create a QianPay invoice (Base): line items summed to a total, payable in USDC at pay.qian.ag. Funds go to your QIAN_KEY wallet.', 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: { inputSchema: {
chain: payChain,
items: z.array(z.object({ items: z.array(z.object({
desc: z.string().max(120), desc: z.string().max(120),
qty: z.number().int().min(1).max(1_000_000).default(1), qty: z.number().int().min(1).max(1_000_000).default(1),
@@ -647,14 +677,14 @@ server.registerTool('create_invoice', {
payer_label: z.string().max(80).optional(), payer_label: z.string().max(80).optional(),
note: z.string().max(500).optional(), note: z.string().max(500).optional(),
}, },
}, async ({ items, due_date, payer_label, note }) => { }, async ({ items, due_date, payer_label, note, chain }) => {
const body = { const body = {
chain: PAY_CHAIN, chain,
items: items.map((it) => ({ desc: it.desc, qty: it.qty, unitUsdc: toUsdc(it.unit_usdc) })), items: items.map((it) => ({ desc: it.desc, qty: it.qty, unitUsdc: toUsdc(it.unit_usdc) })),
dueDate: due_date ?? null, payerLabel: payer_label ?? '', note: note ?? '', dueDate: due_date ?? null, payerLabel: payer_label ?? '', note: note ?? '',
} }
const { ref, usdcAmount: total } = await sessionReq('/pay/invoices', 'POST', body) const { ref, usdcAmount: total } = await sessionReq('/pay/invoices', 'POST', body)
return json({ ref, url: payUrl(ref), total_usdc: fromUsdc(total) }) return json({ ref, url: payUrl(ref), total_usdc: fromUsdc(total), chain })
}) })
server.registerTool('create_subscription_offer', { server.registerTool('create_subscription_offer', {
@@ -663,10 +693,12 @@ server.registerTool('create_subscription_offer', {
name: z.string().min(1).max(80), name: z.string().min(1).max(80),
usdc_amount: usdcAmount, usdc_amount: usdcAmount,
period_seconds: z.number().int().min(3600).max(365 * 86_400).describe('billing period, 1 hour to 365 days'), 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 }) => { }, async ({ name, usdc_amount, period_seconds, chain }) => {
const { ref } = await sessionReq('/pay/offers', 'POST', { chain: PAY_CHAIN, name, usdcAmount: toUsdc(usdc_amount), period: period_seconds }) subsCfg(chain) // rejects a chain with no keeper instead of quietly writing the offer to Base
return json({ ref, url: `https://pay.qian.ag/subscribe/${ref}`, name, usdc_amount, period_seconds }) 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', { server.registerTool('pay_dashboard', {
@@ -729,25 +761,25 @@ server.registerTool('get_subscriptions', {
return json({ address: who, ...(await api(`/pay/subs/${who}`)) }) return json({ address: who, ...(await api(`/pay/subs/${who}`)) })
}) })
async function ensureUsdcAllowance(spender, need) { async function ensureUsdcAllowance(chain, spender, need) {
const w = wallet(PAY_CHAIN) const w = wallet(chain)
const usdc = new ethers.Contract(USDC_ADDR, ERC20_ABI, w) const usdc = new ethers.Contract(payCfg(chain).usdc, ERC20_ABI, w)
if ((await usdc.allowance(w.address, spender)) < need) if ((await usdc.allowance(w.address, spender)) < need)
await sendAndWait(usdc.approve(spender, need), PAY_CHAIN, 'approve USDC') await sendAndWait(usdc.approve(spender, need), chain, 'approve USDC')
} }
server.registerTool('pay_link', { server.registerTool('pay_link', {
description: 'Pay a QianPay payment link / invoice in USDC (Base) from your QIAN_KEY wallet. Approves USDC if needed, then settles on-chain. Refuses if already paid.', 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 }, inputSchema: { ref: payRef },
}, async ({ ref }) => { }, async ({ ref }) => {
const row = await api(`/pay/intents/${ref}`) const row = await api(`/pay/intents/${ref}`)
if (row.chain !== PAY_CHAIN) throw new Error(`link is on ${row.chain}; MCP can only pay on ${PAY_CHAIN}`) 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}`) if (row.paid_tx) throw new Error(`already paid: ${row.paid_tx}`)
const amount = BigInt(row.usdc_amount) const amount = BigInt(row.usdc_amount)
await ensureUsdcAllowance(QIANPAY_ADDR, amount) await ensureUsdcAllowance(row.chain, cfg.qianpay, amount)
const pay = new ethers.Contract(QIANPAY_ADDR, QIANPAY_ABI, wallet(PAY_CHAIN)) const pay = new ethers.Contract(cfg.qianpay, QIANPAY_ABI, wallet(row.chain))
const { hash, explorer } = await sendAndWait(pay.payUSDC(ref, row.recipient, amount), PAY_CHAIN, 'payUSDC') 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 }) return json({ hash, explorer, paid_usdc: fromUsdc(amount), recipient: row.recipient, chain: row.chain })
}) })
server.registerTool('subscribe', { server.registerTool('subscribe', {
@@ -755,30 +787,30 @@ server.registerTool('subscribe', {
inputSchema: { offer_ref: payRef }, inputSchema: { offer_ref: payRef },
}, async ({ offer_ref }) => { }, async ({ offer_ref }) => {
const o = await api(`/pay/offers/${offer_ref}`) const o = await api(`/pay/offers/${offer_ref}`)
if (o.chain !== PAY_CHAIN) throw new Error(`offer is on ${o.chain}; MCP can only subscribe on ${PAY_CHAIN}`) const subsAddr = subsCfg(o.chain).subs
const amount = BigInt(o.usdc_amount) const amount = BigInt(o.usdc_amount)
await ensureUsdcAllowance(QIANSUBS_ADDR, amount * 3n) // UI convention: amount×3, re-approve later await ensureUsdcAllowance(o.chain, subsAddr, amount * 3n) // UI convention: amount×3, re-approve later
const subs = new ethers.Contract(QIANSUBS_ADDR, QIANSUBS_ABI, wallet(PAY_CHAIN)) 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 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), PAY_CHAIN, 'subscribe') 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 }) return json({ sub_id: subId, hash, explorer, merchant: o.recipient, usdc_amount: fromUsdc(amount), period_seconds: o.period })
}) })
server.registerTool('cancel_subscription', { server.registerTool('cancel_subscription', {
description: 'Cancel a subscription by subId (Base). Either party may cancel; effective immediately, no further charges.', description: 'Cancel a subscription by subId (Base). Either party may cancel; effective immediately, no further charges.',
inputSchema: { sub_id: payRef }, inputSchema: { sub_id: payRef, chain: payChain },
}, async ({ sub_id }) => { }, async ({ sub_id, chain }) => {
const subs = new ethers.Contract(QIANSUBS_ADDR, QIANSUBS_ABI, wallet(PAY_CHAIN)) const subs = new ethers.Contract(subsCfg(chain).subs, QIANSUBS_ABI, wallet(chain))
const { hash, explorer } = await sendAndWait(subs.cancel(sub_id), PAY_CHAIN, 'cancel') const { hash, explorer } = await sendAndWait(subs.cancel(sub_id), chain, 'cancel')
return json({ hash, explorer, sub_id }) return json({ hash, explorer, sub_id })
}) })
server.registerTool('charge_subscription', { 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.', 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 }, inputSchema: { sub_id: payRef, chain: payChain },
}, async ({ sub_id }) => { }, async ({ sub_id, chain }) => {
const subs = new ethers.Contract(QIANSUBS_ADDR, QIANSUBS_ABI, wallet(PAY_CHAIN)) const subs = new ethers.Contract(subsCfg(chain).subs, QIANSUBS_ABI, wallet(chain))
const { hash, explorer } = await sendAndWait(subs.charge(sub_id), PAY_CHAIN, 'charge') const { hash, explorer } = await sendAndWait(subs.charge(sub_id), chain, 'charge')
return json({ hash, explorer, sub_id }) return json({ hash, explorer, sub_id })
}) })