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:
@@ -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) |
|
||||
| `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) |
|
||||
| `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
|
||||
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@qianexchange/mcp",
|
||||
"version": "0.2.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",
|
||||
"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",
|
||||
"type": "module",
|
||||
"files": ["server.js", "SECURITY.md"],
|
||||
"bin": { "qian-mcp": "server.js" },
|
||||
|
||||
@@ -17,10 +17,7 @@ import { ethers } from 'ethers'
|
||||
|
||||
const API = process.env.QIAN_API ?? 'https://api.qian.ag'
|
||||
|
||||
// V2 stack (2026-07-16): core addresses are IDENTICAL on every chain (one fresh
|
||||
// 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.
|
||||
// Core addresses are identical on every chain; only rpc/chainId/explorer differ.
|
||||
const CORE = {
|
||||
factoryV4: '0x750d0bc66baa12CF1710a8ba939B97DB4aD005c3',
|
||||
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
|
||||
}
|
||||
|
||||
const server = new McpServer({ name: 'qian', version: '0.2.0' })
|
||||
const server = new McpServer({ name: 'qian', version: '0.3.0' })
|
||||
|
||||
server.registerTool('list_tokens', {
|
||||
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) -------------------------------------------
|
||||
// Generic client: pay any x402-gated API on Base mainnet or Arc testnet. Signing key resolution:
|
||||
// X402_PRIVATE_KEY > QIAN_KEY > auto-generated burner persisted at ~/.qian/mcp-wallet.json.
|
||||
// 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'
|
||||
@@ -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())
|
||||
console.error(`qian-mcp ready — api=${API} write=${process.env.QIAN_KEY ? 'enabled' : 'DISABLED (no QIAN_KEY)'}`)
|
||||
|
||||
Reference in New Issue
Block a user