# Mystic Router — LLM/agent integration contract This file is the complete API contract. You do not need the source. Human guide: GET /integration.md. Swagger: GET /docs. Base URL: https://router.mysticfinance.xyz — business routes under /v1; root routes: /health /llm.txt /integration.md /docs /metrics. ## HARD RULES (violating these is the usual cause of a failed integration) 1. Amounts are ALWAYS integer strings in the token's smallest unit. Never floats, never human units. 1 USDC(6dp)="1000000"; 1 WETH(18dp)="1000000000000000000". 2. Native asset = sentinel 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE for sellToken/buyToken. No wrapping. No approval when selling native. 3. Approve `approval.spender`, NOT `txRequest.to`. They are different contracts. This is the single commonest bug. 4. txRequest has NO gas field. Run eth_estimateGas. A quote's `estimatedGas` is a ranking input, not a limit; if you use it, buffer 1.25x–2.5x. 5. Read `partner.feeBps` from the quote and apply it dynamically. Do not hardcode a fee rate. 6. Request bodies are strictly validated: an unrecognised field returns 400, it is not ignored. 7. buyAmount / minBuyAmount / outAmount / minOutAmount are GROSS of the Mystic fee. Net = amount * (10000 - partner.feeBps) / 10000 when the fee is taken in the bought token. 8. Branch on the error `code`, never on message text. ## FLOW quote -> build -> approve (if needed) -> send -> GET status 1. POST /v1/swap/quote -> { quoteSetId, quoteId, , quotes[] } 2. POST /v1/swap/build -> { txRequest, approval? } (unsigned) 3. if approval && allowance < approval.amount: approve(approval.token, approval.spender, approval.amount) 4. send txRequest from userAddress 5. GET /v1/tx/:hash Registration is NOT required. A Mystic swap carries a correlation uuid in its calldata; the indexer matches it to the quote on-chain. POST /v1/tx exists and shortens booking latency but is optional and NOT needed for fee attribution. One-call variant: POST /v1/swap/build with swap parameters instead of quoteSetId/quoteId — quotes, picks the winner, returns its tx. Trade-off: no ranked alternatives shown to the user, price fixed at build time. ## AUTH & LIMITS - Auth is OPTIONAL. Quote/build/status work with no credentials. - Header: `x-api-key: `, or `Authorization: Bearer `. - Invalid key -> 401. Omitting a key is fine; sending a bad one is not. - Rate limit (per-IP without a key, per-key with one). Operator-configurable; defaults 10 req/s anonymous, 100 req/s keyed. 429 carries Retry-After and X-RateLimit-*. - Keep keys SERVER-SIDE. CORS is open, so a browser-shipped key is readable and usable by anyone. Proxy browser traffic through your backend. - You can earn fees with NO key: pass `referrer`. See FEES. ## POST /v1/swap/quote Request (required: chainId, sellToken, buyToken, sellAmount, taker): { "chainId": 14, "sellToken": "0x..", "buyToken": "0x..", "sellAmount": "1000000", "taker": "0xUser", "slippageBps?": 50, // 0–5000, default 50 (=0.5%) "recipient?": "0x..", // default = taker; see RECIPIENT "deadlineSeconds?": 1200, // 60–86400 "minOutput?": "6300000", // hard floor, base units; routes that cannot guarantee it are DROPPED, not quoted "includeDexes?": ["sparkdex"], // ids from GET /v1/dexes "excludeDexes?": ["openocean"], "includeAdapters?": ["uniswap-v3"], "excludeAdapters?": ["cowswap"], // adapterId granularity; merged with dex filters "referrer?": "0xYourWallet", // any EOA; no registration "referrerFee?": 1, // PERCENT (1 = 1%), range 0.01–5 "bestOnly?": false, // true = omit quotes[]; flattened winner + quoteId remain "mevProtect?": false, "useSmartAccount?": false, "partnerId?": "acme", "partnerFeeBpsOverride?": 15 } Response — winner flattened onto the root AND the ranked list (same route, two views): { "quoteSetId": "qs_..", "quoteId": "algebra::qs_..", "adapterId": "algebra", "chainId": 14, "inToken": {"address","symbol","name","decimals"}, "outToken": {...}, "inAmount": "10000000000000000000", "outAmount": "64199", "minOutAmount": "63878", "estimatedGas": 250000, "price_impact": "0.12%", "from": "0xUser", "to": "0x..", "value": "0", "data": "0x..", "partner": { "partnerId": "protocol|referrer:0x..|", "feeBps": 15, "recipient": "0x.." }, "mevAdvice": { "protect": false, "privateRpc": null }, "dexFilter?": { "notHonored": ["enosys-v3"] }, "quotes": [ { "quoteId","adapterId","rank","tier","venueName","routeSummary","fillType","route[]", "sellAmount","buyAmount","minBuyAmount","priceImpactBps","estimatedGas","estimatedGasUsd", "estimatedAmountOutUsd","partnerFeeBps","partnerFeeApplied","validUntil","approvalTarget", "permit2","warnings[]","score","raw" } ] } - quotes[0] is best (ranked by net output; ties by gas/reliability). rank 1 = best. - tier: "tier-1" = Mystic's own pathfinder; every other source, including direct-DEX adapters, is "tier-2". - partnerFeeApplied: "native" (venue skims it) | "pending" (applied at build). - validUntil is epoch ms. Building after it -> 410 QUOTE_EXPIRED. Quote sets stay readable ~2 min. Nothing is reserved by quoting, so quote as often as needed. - dexFilter.notHonored appears ONLY when a dex id could not be applied (unknown id, or filtering it would have caught sibling venues on the same adapter). Filters are never applied silently. - 404 INSUFFICIENT_LIQUIDITY = no source can fill. ## POST /v1/swap/build A) From a quote: { "quoteSetId", "quoteId", "userAddress", "recipient?" } B) Without a quote (omit BOTH ids): { "userAddress", "chainId", "sellToken", "buyToken", "sellAmount", "slippageBps?", "minOutput?", "includeDexes?", "excludeDexes?", "includeAdapters?", "excludeAdapters?", "referrer?", "referrerFee?", "recipient?" } Response: { "quoteSetId", "adapterId", "feeMode": "augustus|native|bundle|none", "txRequest": { "chainId", "to", "data", "value", "from" }, "approval": { "token", "spender", "amount" } | null, "permit2": {..} | null, "simulation": { "ok": true }, "directTxRequest": {..}, "partner": { "partnerId", "feeBps", "protocolBps", "partnerBps", "partnerRecipient" } } - feeMode tells you HOW the fee is collected; in every case there is nothing extra for you to do. - directTxRequest is the un-wrapped venue tx, informational. SEND txRequest. - On (B) the body's referrer/referrerFee apply. On (A) they are read from the QUOTE's request, so a build can never change the payee or raise the fee the user was quoted. - 404 unknown quoteSetId/quoteId, or (B) missing parameters — the message names them. 410 QUOTE_EXPIRED. ## GET /v1/tx/:hash -> { chainId, hash, status: "SUCCESS|FAILED", blockNumber, gasUsed, effectiveGasPrice, from, to, quoteSetId?, quoteId?, receipt } Unindexed hash -> { hash, status: "UNKNOWN", adopting: true }. Not an error: a background match was started; poll again shortly. Fees are booked from the on-chain event, so a spoofed or mismatched hash books nothing. POST /v1/tx { chainId, hash, from, to?, quoteSetId?, quoteId? } — optional. ## GET /v1/dexes?chainId= -> [ { "id","name","chainId","type": "dex|aggregator","protocol","router","adapter" } ] (omit chainId for all chains) `id` is what includeDexes/excludeDexes take. Filtering operates at ADAPTER granularity: excluding one venue whose adapter also serves siblings you did not name is reported in dexFilter.notHonored rather than applied. Name every sibling to make it work. ## POST /v1/swap/decode { "data": "0x..", "chainId?": 14 } -> { "kind": "augustus-simple-swap|call", "augustus?": { fromToken,toToken,fromAmount,toAmount,expectedAmount,beneficiary,partner,feePercent,feeBps,feeFrom:"src|dest",deadline,uuid }, "calls": [ { "selector","function","to","value","args" } ] } Unknown selector -> function: null. Never throws. ## GET /v1/allowance?chainId=&account=&tokens=0xA,0xB[&spender=][&amount=] Pre-flight ERC-20 allowance check, so a UI can decide whether to show an Approve step. -> [ { "token","spender","allowance","sufficient?" } ] (sufficient only when `amount` is given) - `tokens` is comma-separated. Values are base-unit strings. - `spender` defaults to the chain's canonical pull address (the Augustus TokenTransferProxy). External aggregators use their own spender, known only once a route is chosen — so `approval.spender` from POST /v1/swap/build remains AUTHORITATIVE. Pass ?spender= to check that one. - Native sentinel -> allowance = 2^256-1, sufficient = true (never needs approving). - An unreadable token or a dead RPC reads as 0, i.e. "you must approve" — the safe answer, not an error. - No canonical spender on that chain -> 400 telling you to pass ?spender=. ## POST /v1/swap/quote/reverse (buy flow / exact-out) State the OUTPUT you want; get the input required plus a real, buildable quote. { "chainId", "sellToken", "buyToken", "buyAmount", "taker", "slippageBps?", "recipient?", "includeDexes?", "excludeDexes?", "includeAdapters?", "excludeAdapters?", "partnerId?", "referrer?", "referrerFee?", "toleranceBps?": 50 } -> the SAME shape as POST /v1/swap/quote, plus: "reverse": { "requestedBuyAmount","achievedBuyAmount","sellAmount","offByBps","withinTolerance","passes" } - TOKEN ORIENTATION IS NOT REVERSED. Unlike OpenOcean's /reverseQuote, `sellToken` still means what leaves the wallet, so nothing needs flipping relative to your UI. - No venue offers true exact-out, so this prices the pair with a real quote and solves for the input, correcting on the rate each pass actually returned. Max 3 passes; it stops early once within toleranceBps (default 50 = 0.5%). - ALWAYS check `withinTolerance`. When false, `achievedBuyAmount` is what the route really offers — decide whether to accept it. Nothing is synthesised: the returned quote is buildable with its quoteId. - Cost: 2–3 full fan-outs, so noticeably slower than a forward quote. ## GET /v1/gas-price?chainId= Current gas price in three speed tiers. Omit chainId for every supported chain (best-effort; a dead RPC is skipped, not fatal). -> { "chainId": 14, "isEip1559": true, "baseFeePerGas": "10000000000", "standard": { "legacyGasPrice","maxPriorityFeePerGas","maxFeePerGas","waitTimeEstimate" }, "fast": {...}, "instant": {...} } ALL VALUES ARE WEI STRINGS — the same units txRequest uses, so nothing needs converting before signing (unlike OpenOcean, which wants gwei-with-decimals). On a legacy chain maxPriorityFeePerGas is "0" and the tiers scale legacyGasPrice instead. maxFeePerGas = 2*baseFee + priority. Tiers scale the priority fee 100%/125%/150%; waitTimeEstimate is indicative, not measured. Cached ~5s server-side. Mystic does NOT take gasPrice as a quote parameter: routes are ranked with a live gas estimate internally. Use this endpoint to price the transaction you send, not to influence routing. ## GET /v1/tokens, /v1/tokens/resolve, /v1/chains - GET /v1/tokens?chainId= -> [{ chainId,address,symbol,decimals,name,coingeckoId?,tags[] }]. tags = stable|correlated|common|exotic (drives fee tiering). - GET /v1/tokens/resolve?chainId=&address= -> reads ON-CHAIN ERC-20 metadata and adds it to the registry. METADATA ONLY, not a tradeability check. A non-ERC-20 resolves to symbol "UNKNOWN", decimals 18 — treat that as a warning, not an error. - There is NO pool-check endpoint. THE QUOTE IS THE CHECK: quotes present = tradeable; 404 INSUFFICIENT_LIQUIDITY = no route. quote() auto-resolves both tokens, so pre-resolving is for UI display only. - GET /v1/chains -> [{ chainId,name,shortName,isEip1559,multicall3,permit2,weth,uniswapV3[],algebra[],rollupParent }] ## FEES Charged in the BOUGHT token (exception: a Nest MINT takes it from the deposit asset), collected on-chain to one protocol wallet at swap time. - Anonymous: protocol fee only. - REFERRAL — no key, no registration. `referrer` (EOA) + `referrerFee` (PERCENT, 0.01–5). It is the WHOLE fee charged and SUPERSEDES whatever an API key would apply. Referrer keeps 85%, Mystic 15%. `referrer` with NO `referrerFee` charges the normal protocol fee and splits it the same way. `referrerFee: 0` = no referral. >5% -> 400 FEE_VIOLATION. - PARTNER (API key), two models: * share — DEFAULT for every partner, including keys with no partners row: user pays the normal fee, partner earns revenueSharePct OF it (default 75%). Attaching a key never worsens a quote. * surcharge — opt-in: partner bps ADDED on top of the protocol fee. Requires feeModel:"surcharge" explicitly AND a defaultFeeBps; absent feeModel means share, never surcharge. partnerFeeBpsOverride sets the TOTAL fee for the call (same meaning as referrerFee) — NOT an addition to the protocol fee and NOT a share percentage. Your slice is carved out of it: share partners get revenueSharePct of it, surcharge partners get min(defaultFeeBps, maxFeeBps, total). Capped by the platform cap; above it -> 400 FEE_VIOLATION. - Payout: total collected on-chain; your share is booked to a ledger when the swap confirms, settled on the operator's cycle (default every 30 minutes). You may route before setting a payout wallet — fees accrue and are held, then pay out at the next settlement. - Operator: GET /v1/partners/:id/owed -> {asOf, lines:[{chainId,token,owed,partnerRecipient}]}; pay; POST /v1/partners/:id/settle?asOf=&chainId=&token=. GET /v1/partners/owed = all. POST /v1/partners/collect?chainId=&dryRun=. ## RECIPIENT Set recipient != taker to redirect output. Only sources that can honor a distinct recipient are offered, so funds never land on the taker by accident. If that filter leaves nothing routable -> 404 INSUFFICIENT_LIQUIDITY; retry without recipient and transfer separately. Any-case address is re-checksummed. ## BRIDGE POST /v1/bridge/quote { fromChainId,toChainId,fromToken,toToken,fromAmount,fromAddress,toAddress,slippageBps } POST /v1/bridge/build { quoteSetId,quoteId,userAddress } GET /v1/bridge/status/:adapterId/:ref ## CHAINS 1 Ethereum · 8453 Base · 42161 Arbitrum · 10 Optimism · 137 Polygon · 56 BNB · 43114 Avalanche · 59144 Linea · 146 Sonic · 14 Flare · 98866 Plume · 4114 Citrea. Authoritative list: GET /v1/chains. ## ERRORS Body: { "code": "...", "message": "..." } | HTTP | code | action | | 400 | (validation) | fix the request; unknown fields are rejected | | 400 | UNSUPPORTED_CHAIN | check GET /v1/chains | | 400 | UNSUPPORTED_TOKEN | bad token, or sellAmount <= 0 | | 400 | FEE_VIOLATION | fee above cap, or unknown/inactive partner | | 401 | — | invalid API key (omit it to fall back to anonymous) | | 404 | INSUFFICIENT_LIQUIDITY | no route, incl. none honoring `recipient` | | 404 | — | unknown quoteSetId/quoteId/adapter, or missing build params | | 410 | QUOTE_EXPIRED | re-quote and rebuild | | 429 | — | back off using Retry-After | | 502 | ADAPTER_* | upstream venue failed; retry, the fan-out usually routes around it | | 500 | — | retry with backoff | ## PSEUDOCODE q = POST /v1/swap/quote {chainId, sellToken, buyToken, sellAmount, taker, slippageBps:50} b = POST /v1/swap/build {quoteSetId:q.quoteSetId, quoteId:q.quoteId, userAddress:taker} if b.approval and allowance(b.approval.token, taker, b.approval.spender) < b.approval.amount: send approve(b.approval.token, b.approval.spender, b.approval.amount) hash = send({to:b.txRequest.to, data:b.txRequest.data, value:BigInt(b.txRequest.value)}) GET /v1/tx/{hash} // no registration needed