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:
@@ -65,10 +65,12 @@ Signing key resolution: `X402_PRIVATE_KEY`, else `QIAN_KEY`, else the server gen
|
||||
|
||||
## QianPay
|
||||
|
||||
An agent can run the full QianPay merchant surface on Base: create payment
|
||||
links and itemized invoices, publish recurring subscription offers, read its
|
||||
dashboard (paid/unpaid links, subscribers, MRR), manage API keys and signed
|
||||
webhooks. Funds settle to the `QIAN_KEY` wallet. The server signs a
|
||||
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
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@qianexchange/mcp",
|
||||
"version": "0.3.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",
|
||||
"version": "0.4.0",
|
||||
"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",
|
||||
"files": ["server.js", "SECURITY.md"],
|
||||
"bin": { "qian-mcp": "server.js" },
|
||||
|
||||
@@ -48,6 +48,13 @@ const CHAINS = {
|
||||
quoterV2: '0x61fFE014bA17989E743c5F6cB21bF9697530B21e',
|
||||
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 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
|
||||
}
|
||||
|
||||
const server = new McpServer({ name: 'qian', version: '0.3.0' })
|
||||
const server = new McpServer({ name: 'qian', version: '0.4.0' })
|
||||
|
||||
server.registerTool('list_tokens', {
|
||||
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))
|
||||
|
||||
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: {},
|
||||
}, async () => {
|
||||
const w = wallet('robinhood')
|
||||
const balances = {}
|
||||
for (const c of Object.keys(CHAINS)) balances[c] = ethers.formatEther(await provider(c).getBalance(w.address))
|
||||
return json({ address: w.address, eth: balances })
|
||||
for (const [c, cfg] of Object.entries(CHAINS))
|
||||
balances[c] = `${ethers.formatEther(await provider(c).getBalance(w.address))} ${cfg.native ?? 'ETH'}`
|
||||
return json({ address: w.address, gas: balances })
|
||||
})
|
||||
|
||||
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
|
||||
// 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 })) },
|
||||
'arc-testnet': { chainId: 5042002, usdc: '0x3600000000000000000000000000000000000000', rpc: () => provider('arc-testnet') },
|
||||
}
|
||||
const X402_WALLET_FILE = join(homedir(), '.qian', 'mcp-wallet.json')
|
||||
|
||||
@@ -587,12 +595,33 @@ server.registerTool('x402_pay', {
|
||||
})
|
||||
|
||||
// --- QianPay (payment links, invoices, subscriptions) --------------------------------------------
|
||||
// Live on Base only (contract + indexer both wired there). ponytail: single-chain
|
||||
// until the operator deploys QianPay + an indexer on another chain — add it here then.
|
||||
const PAY_CHAIN = 'base'
|
||||
const QIANPAY_ADDR = '0xa3faE6c7e97506145c29f2E7a887b345f49Cf36B'
|
||||
const QIANSUBS_ADDR = '0xc3a89be96b8BDb1123d2Fa40f18000e3FB1A7aAf'
|
||||
const USDC_ADDR = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' // Base USDC, 6dp
|
||||
// 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)',
|
||||
@@ -624,20 +653,21 @@ async function sessionReq(path, method, body) {
|
||||
const payUrl = (ref) => `https://pay.qian.ag/link/${ref}`
|
||||
|
||||
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.',
|
||||
inputSchema: { usdc_amount: usdcAmount, memo: z.string().max(140).default('') },
|
||||
}, async ({ usdc_amount, memo }) => {
|
||||
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:${PAY_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 }) })
|
||||
return json({ ref, url: payUrl(ref), recipient, usdc_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, 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 (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: {
|
||||
chain: payChain,
|
||||
items: z.array(z.object({
|
||||
desc: z.string().max(120),
|
||||
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(),
|
||||
note: z.string().max(500).optional(),
|
||||
},
|
||||
}, async ({ items, due_date, payer_label, note }) => {
|
||||
}, async ({ items, due_date, payer_label, note, chain }) => {
|
||||
const body = {
|
||||
chain: PAY_CHAIN,
|
||||
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) })
|
||||
return json({ ref, url: payUrl(ref), total_usdc: fromUsdc(total), chain })
|
||||
})
|
||||
|
||||
server.registerTool('create_subscription_offer', {
|
||||
@@ -663,10 +693,12 @@ server.registerTool('create_subscription_offer', {
|
||||
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 }) => {
|
||||
const { ref } = await sessionReq('/pay/offers', 'POST', { chain: PAY_CHAIN, name, usdcAmount: toUsdc(usdc_amount), period: period_seconds })
|
||||
return json({ ref, url: `https://pay.qian.ag/subscribe/${ref}`, name, usdc_amount, period_seconds })
|
||||
}, 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', {
|
||||
@@ -729,25 +761,25 @@ server.registerTool('get_subscriptions', {
|
||||
return json({ address: who, ...(await api(`/pay/subs/${who}`)) })
|
||||
})
|
||||
|
||||
async function ensureUsdcAllowance(spender, need) {
|
||||
const w = wallet(PAY_CHAIN)
|
||||
const usdc = new ethers.Contract(USDC_ADDR, ERC20_ABI, w)
|
||||
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), PAY_CHAIN, 'approve USDC')
|
||||
await sendAndWait(usdc.approve(spender, need), chain, 'approve USDC')
|
||||
}
|
||||
|
||||
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 },
|
||||
}, async ({ 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}`)
|
||||
const amount = BigInt(row.usdc_amount)
|
||||
await ensureUsdcAllowance(QIANPAY_ADDR, amount)
|
||||
const pay = new ethers.Contract(QIANPAY_ADDR, QIANPAY_ABI, wallet(PAY_CHAIN))
|
||||
const { hash, explorer } = await sendAndWait(pay.payUSDC(ref, row.recipient, amount), PAY_CHAIN, 'payUSDC')
|
||||
return json({ hash, explorer, paid_usdc: fromUsdc(amount), recipient: row.recipient })
|
||||
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', {
|
||||
@@ -755,30 +787,30 @@ server.registerTool('subscribe', {
|
||||
inputSchema: { offer_ref: payRef },
|
||||
}, async ({ 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)
|
||||
await ensureUsdcAllowance(QIANSUBS_ADDR, amount * 3n) // UI convention: amount×3, re-approve later
|
||||
const subs = new ethers.Contract(QIANSUBS_ADDR, QIANSUBS_ABI, wallet(PAY_CHAIN))
|
||||
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), 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 })
|
||||
})
|
||||
|
||||
server.registerTool('cancel_subscription', {
|
||||
description: 'Cancel a subscription by subId (Base). Either party may cancel; effective immediately, no further charges.',
|
||||
inputSchema: { sub_id: payRef },
|
||||
}, async ({ sub_id }) => {
|
||||
const subs = new ethers.Contract(QIANSUBS_ADDR, QIANSUBS_ABI, wallet(PAY_CHAIN))
|
||||
const { hash, explorer } = await sendAndWait(subs.cancel(sub_id), PAY_CHAIN, 'cancel')
|
||||
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 },
|
||||
}, async ({ sub_id }) => {
|
||||
const subs = new ethers.Contract(QIANSUBS_ADDR, QIANSUBS_ABI, wallet(PAY_CHAIN))
|
||||
const { hash, explorer } = await sendAndWait(subs.charge(sub_id), PAY_CHAIN, 'charge')
|
||||
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 })
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user