Compare commits
10
Commits
9aac4b1925
...
439f7dca13
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
439f7dca13 | ||
|
|
10feb3d39a | ||
|
|
1cf6e3cb6f | ||
|
|
341219fe9c | ||
|
|
e94fb1ac68 | ||
|
|
c19bdc950a | ||
|
|
327c2eec06 | ||
|
|
e77103ea61 | ||
|
|
56cf12945d | ||
|
|
94fe128640 |
@@ -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.
|
||||
@@ -1,6 +1,12 @@
|
||||
# @qianexchange/mcp
|
||||
<div align="center">
|
||||
<a href="https://qian.ag"><img src="https://qian.ag/branding/qian-glyph-2048.png" alt="Qian" width="120"></a>
|
||||
|
||||
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.
|
||||
# @qianexchange/mcp
|
||||
|
||||
**[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.
|
||||
|
||||
@@ -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_positions` | Holdings, ETH value, and PnL for any address across chains |
|
||||
| `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 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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
+3
-2
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"name": "@qianexchange/mcp",
|
||||
"version": "0.1.2",
|
||||
"description": "MCP server for the Qian multichain DEX & launchpad (qian.ag) — launch and trade tokens 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" },
|
||||
"author": "[email protected]",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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',
|
||||
@@ -51,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'])
|
||||
@@ -76,6 +80,59 @@ const ERC20_ABI = [
|
||||
'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 provider = (c) => (providers[c] ??= new ethers.JsonRpcProvider(CHAINS[c].rpc, CHAINS[c].chainId, { staticNetwork: true }))
|
||||
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
|
||||
}
|
||||
|
||||
const server = new McpServer({ name: 'qian', version: '0.1.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.',
|
||||
@@ -177,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', {
|
||||
@@ -208,7 +266,7 @@ server.registerTool('my_positions', {
|
||||
})
|
||||
|
||||
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"') },
|
||||
}, async ({ chain, address: addr, 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', {
|
||||
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"') },
|
||||
}, 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) })
|
||||
})
|
||||
|
||||
@@ -271,9 +336,9 @@ server.registerTool('sell', {
|
||||
|
||||
server.registerTool('launch_token', {
|
||||
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.',
|
||||
'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(' '),
|
||||
inputSchema: {
|
||||
chain: chainSlug,
|
||||
@@ -333,7 +398,7 @@ server.registerTool('pending_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 },
|
||||
}, async ({ chain, address: addr }) => {
|
||||
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 })
|
||||
})
|
||||
|
||||
// --- 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())
|
||||
console.error(`qian-mcp ready — api=${API} write=${process.env.QIAN_KEY ? 'enabled' : 'DISABLED (no QIAN_KEY)'}`)
|
||||
|
||||
+136
@@ -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)
|
||||
Reference in New Issue
Block a user