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.
This commit is contained in:
Qian
2026-07-19 18:35:17 +00:00
parent 341219fe9c
commit 1cf6e3cb6f
3 changed files with 224 additions and 11 deletions
+23 -2
View File
@@ -39,11 +39,19 @@ Omit `QIAN_KEY` for read-only mode (market data still works). Use a dedicated ho
| `buy` / `sell` | Trade via QianSwapRouter (auto-approve, slippage-protected) | | `buy` / `sell` | Trade via QianSwapRouter (auto-approve, slippage-protected) |
| `launch_token` | Deploy a token: name/symbol/image/socials, FDV tier 1–4, creator fee 0.5–20%, optional dev buy | | `launch_token` | Deploy a token: name/symbol/image/socials, FDV tier 1–4, creator fee 0.5–20%, 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) | | `list_stocks` / `quote_stock` | List and preview tokenized stocks, read-only (no key) |
| `buy_stock` / `sell_stock` | Trade tokenized stocks via QianRWARouter (Robinhood Chain) | | `buy_stock` / `sell_stock` | Trade tokenized stocks via QianRWARouter (Robinhood Chain) |
| `x402_wallet` | The x402 payment wallet: address and USDC balances | | `x402_wallet` | The x402 payment wallet: address and USDC balances |
| `x402_pay` | Pay an x402 (HTTP 402) API in USDC and return its response | | `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 ## Tokenized stocks
@@ -53,7 +61,20 @@ Robinhood Chain carries tokenized stocks (NVDA, TSLA, SPY, and more) issued by a
`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. `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. 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 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
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
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "@qianexchange/mcp", "name": "@qianexchange/mcp",
"version": "0.2.0", "version": "0.3.0",
"description": "MCP server for Qian (qian.ag) — trade tokens and stocks, launch tokens, 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/invoices/subscriptions, 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" },
+199 -7
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',
@@ -194,7 +191,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.2.0' }) const server = new McpServer({ name: 'qian', version: '0.3.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.',
@@ -483,8 +480,7 @@ server.registerTool('sell_stock', {
}) })
// --- x402 (HTTP 402 agent payments, USDC via EIP-3009) ------------------------------------------- // --- 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: // Key resolution: X402_PRIVATE_KEY > QIAN_KEY > burner at ~/.qian/mcp-wallet.json.
// 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. // The burner is a payment float — fund it with a few USDC, never more.
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs' import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
import { homedir } from 'node:os' import { homedir } from 'node:os'
@@ -590,5 +586,201 @@ 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
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 (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 }) => {
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 })
})
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.',
inputSchema: {
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 }) => {
const body = {
chain: PAY_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) })
})
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'),
},
}, 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 })
})
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(spender, need) {
const w = wallet(PAY_CHAIN)
const usdc = new ethers.Contract(USDC_ADDR, ERC20_ABI, w)
if ((await usdc.allowance(w.address, spender)) < need)
await sendAndWait(usdc.approve(spender, need), PAY_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.',
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}`)
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 })
})
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}`)
if (o.chain !== PAY_CHAIN) throw new Error(`offer is on ${o.chain}; MCP can only subscribe on ${PAY_CHAIN}`)
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))
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')
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')
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')
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)'}`)