Add tokenized-stock (RWA) trade tools
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# @qianexchange/mcp
|
||||
|
||||
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.
|
||||
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, plus trade tokenized stocks on Robinhood Chain.
|
||||
|
||||
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.
|
||||
|
||||
@@ -34,6 +34,12 @@ Omit `QIAN_KEY` for read-only mode (market data still works). Use a dedicated ho
|
||||
| `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 |
|
||||
| `list_stocks` / `quote_stock` | List and preview tokenized stocks, read-only (no key) |
|
||||
| `buy_stock` / `sell_stock` | Trade tokenized stocks via QianRWARouter (Robinhood Chain, plain ETH in/out) |
|
||||
|
||||
## 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.
|
||||
|
||||
## Configuration
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qianexchange/mcp",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"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" },
|
||||
|
||||
@@ -76,6 +76,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) {
|
||||
@@ -348,5 +401,86 @@ 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 })
|
||||
})
|
||||
|
||||
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