add quote_sell, my_positions, pending_fees; make quotes keyless via canonical quoters

This commit is contained in:
Qian
2026-07-17 07:58:50 +00:00
parent 6ee1a5d0e7
commit 9aac4b1925
3 changed files with 86 additions and 6 deletions
+3 -1
View File
@@ -28,9 +28,11 @@ Omit `QIAN_KEY` for read-only mode (market data still works). Use a dedicated ho
| `get_token` | Full detail: price, FDV, hook, metadata | | `get_token` | Full detail: price, FDV, hook, metadata |
| `get_trades` / `get_holders` | Market activity | | `get_trades` / `get_holders` | Market activity |
| `my_wallet` | Your address + ETH balance per chain | | `my_wallet` | Your address + ETH balance per chain |
| `quote_buy` | Simulate a buy (no gas) | | `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 (plain ETH in/out, 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) |
| `collect_fees` | Claim your 70% creator share of trading fees | | `collect_fees` | Claim your 70% creator share of trading fees |
## Configuration ## Configuration
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@qianexchange/mcp", "name": "@qianexchange/mcp",
"version": "0.1.1", "version": "0.1.2",
"description": "MCP server for the Qian multichain DEX & launchpad (qian.ag) — launch and trade tokens from any MCP-capable agent", "description": "MCP server for the Qian multichain DEX & launchpad (qian.ag) — launch and trade tokens from any MCP-capable agent",
"type": "module", "type": "module",
"bin": { "qian-mcp": "server.js" }, "bin": { "qian-mcp": "server.js" },
+82 -4
View File
@@ -32,20 +32,27 @@ const CHAINS = {
chainId: 4663, chainId: 4663,
rpc: process.env.RPC_ROBINHOOD ?? 'https://rpc.mainnet.chain.robinhood.com', rpc: process.env.RPC_ROBINHOOD ?? 'https://rpc.mainnet.chain.robinhood.com',
explorer: 'https://robinhoodchain.blockscout.com', explorer: 'https://robinhoodchain.blockscout.com',
quoterV2: '0x33e885ed0ec9bf04ecfb19341582aadcb4c8a9e7',
v4Quoter: '0x8dc178efb8111bb0973dd9d722ebeff267c98f94',
}, },
base: { base: {
...CORE, ...CORE,
chainId: 8453, chainId: 8453,
rpc: process.env.RPC_BASE ?? 'https://base-rpc.publicnode.com', rpc: process.env.RPC_BASE ?? 'https://base-rpc.publicnode.com',
explorer: 'https://basescan.org', explorer: 'https://basescan.org',
quoterV2: '0x3d4e44Eb1374240CE5F1B871ab261CD16335B76a',
v4Quoter: '0x0d5e0F971ED27FBfF6c2837bf31316121532048D',
}, },
ethereum: { ethereum: {
...CORE, ...CORE,
chainId: 1, chainId: 1,
rpc: process.env.RPC_ETHEREUM ?? 'https://ethereum-rpc.publicnode.com', rpc: process.env.RPC_ETHEREUM ?? 'https://ethereum-rpc.publicnode.com',
explorer: 'https://etherscan.io', explorer: 'https://etherscan.io',
quoterV2: '0x61fFE014bA17989E743c5F6cB21bF9697530B21e',
v4Quoter: '0x52F0E24D1c21C8A0cB1e5a5dD6198556BD9E1203',
}, },
} }
const V3_POOL_FEE = 10_000 // 1% tier (factory constant); ROUTER takes 1% off the WETH side each direction
const chainSlug = z.enum(['robinhood', 'base', 'ethereum']) const chainSlug = z.enum(['robinhood', 'base', 'ethereum'])
const address = z.string().regex(/^0x[0-9a-fA-F]{40}$/, 'EVM address') const address = z.string().regex(/^0x[0-9a-fA-F]{40}$/, 'EVM address')
const SUPPLY = 1_000_000_000n * 10n ** 18n const SUPPLY = 1_000_000_000n * 10n ** 18n
@@ -59,7 +66,10 @@ const ROUTER_ABI = [
'function buy(address token, uint256 minTokensOut) payable returns (uint256)', 'function buy(address token, uint256 minTokensOut) payable returns (uint256)',
'function sell(address token, uint256 tokensIn, uint256 minEthOut) returns (uint256)', 'function sell(address token, uint256 tokensIn, uint256 minEthOut) returns (uint256)',
] ]
const FEE_ROUTER_ABI = ['function collectFees(address token)'] // collectFees returns the split it pays out; staticCall reads unclaimed fees without spending gas.
const FEE_ROUTER_ABI = ['function collectFees(address token) returns (uint256 creatorWeth, uint256 treasuryWeth)']
const QUOTER_V2_ABI = ['function quoteExactInputSingle((address tokenIn, address tokenOut, uint256 amountIn, uint24 fee, uint160 sqrtPriceLimitX96) params) returns (uint256 amountOut, uint160 sqrtPriceX96After, uint32 initializedTicksCrossed, uint256 gasEstimate)']
const V4_QUOTER_ABI = ['function quoteExactInputSingle(((address currency0, address currency1, uint24 fee, int24 tickSpacing, address hooks) poolKey, bool zeroForOne, uint128 exactAmount, bytes hookData) params) returns (uint256 amountOut, uint256 gasEstimate)']
const ERC20_ABI = [ const ERC20_ABI = [
'function balanceOf(address) view returns (uint256)', 'function balanceOf(address) view returns (uint256)',
'function allowance(address,address) view returns (uint256)', 'function allowance(address,address) view returns (uint256)',
@@ -100,6 +110,37 @@ function priceEthPerToken(x96, token, chain) {
return tokenIs0 ? p * p : 1 / (p * p) return tokenIs0 ? p * p : 1 / (p * p)
} }
// Read-only pool quote via the canonical Uniswap quoters, mirroring the frontend:
// 1% router fee off the WETH side (buys up front, sells on the way out). No key, no approval.
async function quoteOut(chain, tokenAddr, side, inWei) {
const C = CHAINS[chain]
const { token } = await api(`/tokens/${chain}/${tokenAddr}`)
const buy = side === 'buy'
const weth = WETH[chain]
const amtIn = buy ? (inWei * 99n) / 100n : inWei // buys: 1% ETH fee before the pool
let out
if (token.pool_kind === 'v3') {
const q = new ethers.Contract(C.quoterV2, QUOTER_V2_ABI, provider(chain))
const params = buy
? { tokenIn: weth, tokenOut: tokenAddr, amountIn: amtIn, fee: V3_POOL_FEE, sqrtPriceLimitX96: 0n }
: { tokenIn: tokenAddr, tokenOut: weth, amountIn: amtIn, fee: V3_POOL_FEE, sqrtPriceLimitX96: 0n }
out = (await q.quoteExactInputSingle.staticCall(params)).amountOut
} else {
const k = token.pool_key
if (!k) throw new Error(`v4 token ${tokenAddr} has no pool_key in the index yet`)
const wethIsC0 = k[0].toLowerCase() === weth
const q = new ethers.Contract(C.v4Quoter, V4_QUOTER_ABI, provider(chain))
const params = {
poolKey: { currency0: k[0], currency1: k[1], fee: k[2], tickSpacing: k[3], hooks: k[4] },
zeroForOne: buy ? wethIsC0 : !wethIsC0,
exactAmount: amtIn,
hookData: '0x',
}
out = (await q.quoteExactInputSingle.staticCall(params)).amountOut
}
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.1.0' })
server.registerTool('list_tokens', { server.registerTool('list_tokens', {
@@ -145,15 +186,43 @@ server.registerTool('my_wallet', {
return json({ address: w.address, eth: balances }) return json({ address: w.address, eth: balances })
}) })
server.registerTool('my_positions', {
description: 'Token holdings for an address across all chains, with current ETH value and PnL. Defaults to your own wallet (from QIAN_KEY); pass an address to inspect any wallet (read-only, no key needed).',
inputSchema: { address: address.optional().describe('wallet to inspect; defaults to your QIAN_KEY address') },
}, async ({ address: addr }) => {
const who = addr ?? (process.env.QIAN_KEY ? new ethers.Wallet(process.env.QIAN_KEY).address : null)
if (!who) throw new Error('no address — pass one or set QIAN_KEY')
const { positions } = await api(`/profile/${who}`)
const rows = positions.map((p) => {
const priceEth = p.last_price_x96 ? priceEthPerToken(p.last_price_x96, p.token, p.chain) : null
const balance = Number(p.balance) / 1e18
const valueEth = priceEth === null ? null : balance * priceEth
const pnlEth = Number(p.sell_eth) / 1e18 + (valueEth ?? 0) - Number(p.buy_eth) / 1e18
return {
chain: p.chain, address: p.token, symbol: p.symbol, balance: String(balance),
price_eth: priceEth, value_eth: valueEth, pnl_eth: pnlEth,
url: `https://qian.ag/${p.chain}/token/${p.token}`,
}
})
return json({ address: who, positions: rows })
})
server.registerTool('quote_buy', { server.registerTool('quote_buy', {
description: 'Simulate buying a token with ETH via QianSwapRouter (1% router fee included). Returns expected tokens out. Your wallet must hold at least eth_amount for the simulation.', 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"') }, inputSchema: { chain: chainSlug, address, eth_amount: z.string().describe('ETH to spend, e.g. "0.05"') },
}, async ({ chain, address: addr, eth_amount }) => { }, async ({ chain, address: addr, eth_amount }) => {
const router = new ethers.Contract(CHAINS[chain].swapRouter, ROUTER_ABI, wallet(chain)) const out = await quoteOut(chain, addr, 'buy', ethers.parseEther(eth_amount))
const out = await router.buy.staticCall(addr, 0n, { value: ethers.parseEther(eth_amount) })
return json({ eth_in: eth_amount, tokens_out: ethers.formatEther(out), pct_of_supply: Number((out * 10000n) / SUPPLY) / 100 }) return json({ eth_in: eth_amount, tokens_out: ethers.formatEther(out), pct_of_supply: Number((out * 10000n) / SUPPLY) / 100 })
}) })
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.',
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))
return json({ token_in: token_amount, eth_out: ethers.formatEther(out) })
})
async function sendAndWait(txPromise, chain, label) { async function sendAndWait(txPromise, chain, label) {
const tx = await txPromise const tx = await txPromise
const rc = await tx.wait() const rc = await tx.wait()
@@ -254,6 +323,15 @@ server.registerTool('launch_token', {
return json({ token, hash, explorer, page: `https://qian.ag/${p.chain}/token/${token}`, metadata_uri: uri, deploy_fee_eth: ethers.formatEther(fee) }) return json({ token, hash, explorer, page: `https://qian.ag/${p.chain}/token/${token}`, metadata_uri: uri, deploy_fee_eth: ethers.formatEther(fee) })
}) })
server.registerTool('pending_fees', {
description: 'Unclaimed trading fees for a token, in WETH. Read-only (simulates the permissionless collect). Split is 70% creator / 30% platform; call collect_fees to pay it out.',
inputSchema: { chain: chainSlug, address },
}, async ({ chain, address: addr }) => {
const feeRouter = new ethers.Contract(CHAINS[chain].feeRouter, FEE_ROUTER_ABI, provider(chain))
const [creatorWeth, treasuryWeth] = await feeRouter.collectFees.staticCall(addr, { from: ethers.ZeroAddress })
return json({ creator_weth: ethers.formatEther(creatorWeth), platform_weth: ethers.formatEther(treasuryWeth) })
})
server.registerTool('collect_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 }, inputSchema: { chain: chainSlug, address },