add quote_sell, my_positions, pending_fees; make quotes keyless via canonical quoters
This commit is contained in:
@@ -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_trades` / `get_holders` | Market activity |
|
||||
| `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) |
|
||||
| `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 |
|
||||
|
||||
## Configuration
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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",
|
||||
"type": "module",
|
||||
"bin": { "qian-mcp": "server.js" },
|
||||
|
||||
@@ -32,20 +32,27 @@ const CHAINS = {
|
||||
chainId: 4663,
|
||||
rpc: process.env.RPC_ROBINHOOD ?? 'https://rpc.mainnet.chain.robinhood.com',
|
||||
explorer: 'https://robinhoodchain.blockscout.com',
|
||||
quoterV2: '0x33e885ed0ec9bf04ecfb19341582aadcb4c8a9e7',
|
||||
v4Quoter: '0x8dc178efb8111bb0973dd9d722ebeff267c98f94',
|
||||
},
|
||||
base: {
|
||||
...CORE,
|
||||
chainId: 8453,
|
||||
rpc: process.env.RPC_BASE ?? 'https://base-rpc.publicnode.com',
|
||||
explorer: 'https://basescan.org',
|
||||
quoterV2: '0x3d4e44Eb1374240CE5F1B871ab261CD16335B76a',
|
||||
v4Quoter: '0x0d5e0F971ED27FBfF6c2837bf31316121532048D',
|
||||
},
|
||||
ethereum: {
|
||||
...CORE,
|
||||
chainId: 1,
|
||||
rpc: process.env.RPC_ETHEREUM ?? 'https://ethereum-rpc.publicnode.com',
|
||||
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 address = z.string().regex(/^0x[0-9a-fA-F]{40}$/, 'EVM address')
|
||||
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 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 = [
|
||||
'function balanceOf(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)
|
||||
}
|
||||
|
||||
// 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' })
|
||||
|
||||
server.registerTool('list_tokens', {
|
||||
@@ -145,15 +186,43 @@ server.registerTool('my_wallet', {
|
||||
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', {
|
||||
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"') },
|
||||
}, async ({ chain, address: addr, eth_amount }) => {
|
||||
const router = new ethers.Contract(CHAINS[chain].swapRouter, ROUTER_ABI, wallet(chain))
|
||||
const out = await router.buy.staticCall(addr, 0n, { value: ethers.parseEther(eth_amount) })
|
||||
const out = await quoteOut(chain, addr, 'buy', ethers.parseEther(eth_amount))
|
||||
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) {
|
||||
const tx = await txPromise
|
||||
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) })
|
||||
})
|
||||
|
||||
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', {
|
||||
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 },
|
||||
|
||||
Reference in New Issue
Block a user