io.github.profitphil/qmax
Qubic DEX aggregator: best-price quotes across QX and QSwap, order books, candles, trade plans.
التثبيت
أضف هذا إلى إعدادات MCP في عميلك (Claude Desktop: claude_desktop_config.json، وCursor: .cursor/mcp.json، ويستخدم VS Code وغيره الصيغة نفسها «mcpServers»).
claude mcp add --transport http qmax 'https://qmax.exchange/api/mcp'
{
"mcpServers": {
"qmax": {
"type": "http",
"url": "https://qmax.exchange/api/mcp"
}
}
}
التوثيق
مأخوذ من ملف README للمشروع على GitHub ويُحدَّث أسبوعيًا. تفتح الروابط على GitHub.
المحتويات
- QMax
- What costs money (and what does not)
- Fee model (from qubic/core Qx.h and Qswap.h; verify before trusting)
- HTTP API
- Web app and wallet
- Trading
- Market comparison, arbitrage, orders and trade-ready
- Order books and price charts
- Arbitrage settings (web, Discord and API)
- Share management (QX and QSwap)
- QMax pass (optional, off by default)
- How it works
- Not done yet
- Discord bot
- API billing for other sites (optional, off by default: API_ACCESS=billing)
- Market intelligence (what no other Qubic site shows)
- Savings receipts (what the routing is worth)
- Usage tracking (who trades through QMax)
- Who is selling an asset (the Sellers tab)
- Token pages (a page for every asset)
- Subscribers and profit sharing
- Adding an endpoint
- Being found by agents (and search)
- For agents: self-signed trades (and optional x402 sessions)
- For other sites: SDK and links
- Security
QMax
About this repository. This is the open part of QMax: the router, the HTTP API, the website, the SDK, the MCP server and the x402 helpers. Some parts of QMax's own deployment are not included: the Max planner, the Max pass logic, the Discord bot (apart from its QU price feed), the server deployment files and every secret. Where code here depends on a withheld part there is a small stand-in file that keeps the types and says so, so the rest builds and runs. All rights reserved: no license is granted.
QMax finds the best route to get you the max for your money on Qubic. Given buy 5000 ASSET, it prices the order on the QX order book and the QSwap pool (fees, price impact and transfer costs included) and routes to the cheaper venue, or splits across both.
npm i
npm run qmax -- buy 100000 [--snapshot file.json] [--no-split]
npm test
What costs money (and what does not)
Almost everything is free, to help adoption, since other Qubic sites are free: the website (no pass, no per-trade fee), the HTTP API (no key, no billing), the SDK, the MCP server and agents. The one thing sold is Max quotes for agents (GET /v1/max, API_MAX_PRICE_QU, 100 QU a plan on the live server): an agent pays from a prepaid key's balance or holds an x402 session, while the website's own Max and QMax's own keys never pay (below). The Discord bot's subscription exists in the code but is switched off. "Free" is not "unlimited": callers without QMax's own key are limited per IP on the market endpoints (API_FREE_PER_MIN, default 60 a minute) so one of them cannot swamp the Qubic node, and QMax's own key (the Discord bot) is not limited. Income is therefore the Max quotes agents buy (and any support tips), and the Discord subscriptions if they are ever switched back on, which is also what the subscribers' profit share is made from (below).
API_ACCESS=free(default): everyone, no key.billing: the old prepaid per-call prices and x402 sessions, kept but off.private: onlyAPI_KEYgets in.API_MAX_PRICE_QU(default 0: Max quotes are free; 100 on the live server) sells Max plans to agents insideAPI_ACCESS=free: the meter then serves onlyGET /v1/max, and keys and top-ups work (POST /v1/keys,GET /v1/topup,POST /v1/topup/claim,GET /v1/account) but nothing else is charged. Who pays: an agent with a prepaid key (x-api-key) is charged the price after the plan is made, so a request that cannot be planned costs nothing (and one whose balance was spent meanwhile gets a 402, not the plan); an agent with an x402 session (GET /v1/x402,API_SESSION_PRICE_QUforAPI_SESSION_SECONDS) pays nothing per plan; anyone else gets a402that names the price and both ways to pay. Never charged: QMax's own keys (API_KEY,ADMIN_KEY) and requests the website's own page makes (a browser marks themSec-Fetch-Site: same-origin, which a page's script cannot change, so another site's page is charged). That is a soft check, not a lock: a program outside a browser can send the same header and save the price, which is why the price is small. A per-plan payment is not offered on purpose: QPayhub keeps at least 100 QU of every payment and a payment takes about ten seconds, so a 100 QU payment per call would lose all its value to the fee. A top-up of 10,000 QU buys 100 plans for a fee of about 1%.- The website's pass is off. Build the site with
VITE_REQUIRE_PASS=onto ask for it again (PAYWALLin src/config.ts sets its price). - The sections below on the pass, prepaid API keys and x402 sessions describe those optional modes.
Fee model (from qubic/core Qx.h and Qswap.h; verify before trusting)
- QX: trade fee 0.3% (
tradeFee3,000,000 / 1e9), rounded up per match (floor(v*rate)+1). In the contract it comes out of the seller's QU proceeds, so the buyer pays no fee (the docs say "the party holding QUBIC"; check this). No flat fee on an order (checked on-chain on 2026-10-08: a bid attaches exactly price x quantity and fills for no more, e.g. 1,303 QMINE at 3,989 sent 5,197,667 QU and cost exactly that; an ask attaches nothing). The 100 QU asset transfer fee is charged only when management rights are moved between QX and QSwap, which is a step of its own. - QSwap: swap fee 0.3% (
swapFeeRate30 / 10,000). Buys use QuoteExactAssetOutput (fee on QU in, rounded up); sells use QuoteExactAssetInput (fee deducted from QU out). The protocol/shareholder/QX shares are paid out of that 0.3% and are not extra. A flat 100,000 QU per-operation fee plus the asset transfer fee is modeled as QSwap's fixed cost. - Fees, rounding and fixed costs live in
QxConfig/QswapConfig, so they can be changed without touching the router.
HTTP API
Other DEX sites can call QMax over HTTP (no wallet or SDK needed): npm run api (env: PORT, SNAPSHOT, optional API_KEY).
GET /v1/quote?side=buy&asset=DEMO&qty=100000[&split=false]
POST /v1/quote {"side":"sell","asset":"DEMO","qty":500}
GET /v1/quotes?side=sell&asset=DEMO&qtys=1000,10000,50000 (several sizes at once; free access only)
GET /v1/assets[?category=contract|token&q=CF] GET /v1/assets/search?name=CFB
GET /v1/openapi.json GET /health
The quote response gives route (venue, qty, effective price, price impact, fees, QX depth used), totalQu, fillable, single-venue alternatives and warnings, and a plain-language summary (src/quotesummary.ts): the total, the average, where the order goes and what the split is worth, for an assistant that reads rather than computes. GET /v1/quotes (src/quotesroute.ts) prices up to 10 sizes of the same order from one reading of the markets and says how much worse each size is than the smallest (averageVsSmallestPct), so a bot can choose a piece size in one call; it is offered only with API_ACCESS=free, because a prepaid deployment bills each split quote and a batch would get around that. Every call that is limited per address carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (seconds), so a caller can slow down before it is refused with a 429 and Retry-After; and every error has a steady code (bad_request, unauthorized, payment_required, not_found, rate_limited, …) beside its sentence. CORS is open so browsers can call it directly; set API_KEY to require an x-api-key header.
Asset list
GET /v1/assets returns every tradable asset (a QX book or a QSwap pool), most liquid first, each with id, symbol, issuer, category (contract = smart contract shares, token = everything else), venues, priceQu and liquidityQu. ready is false until the first network scan finishes; results are cached in .cache/catalog.json so restarts are instant.
src/catalog.ts builds the list: all contract shares (issued by the zero identity), the tokens named in assets.json, and the network's issuance list (which the RPC caps at 150 entries). A token that is not in the list can be added with GET /v1/assets/search?name=ABC, which looks the name up on the network, checks that it trades, and keeps it. If two issuers share a name the token gets an id like GARTH.PHOEN; the contract share keeps the plain symbol. Scanning is throttled (3 requests/s) and uses its own client, so it never slows a quote.
Activity filter
Each asset in GET /v1/assets has activity (active, inactive or unknown) and lastActiveAt. inactive means no QX order and no pool change within two epochs (14 days). src/activity.ts reads QX order history from the archive Query API (/query/v1/getTransactionsForIdentity, order types 5 and 6, in 100,000-tick windows, splitting any window that hits the 10,000-result cap). The first scan covers 14 days in a few minutes; after that only new ticks are read, and progress is kept in .cache/activity.json. The response also carries activity: { ready, progress } while the first scan runs.
Limits: this counts orders placed on QX (including market makers' orders), not completed trades. QSwap has no cheap history, so a pool counts as active when QMax has seen its reserves change since it started watching; a pool is only called quiet after 24 hours of watching, until then it is unknown and is not hidden.
Live data
npm run api serves live Qubic data (set ASSETS=file to use another seed file; it is a JSON list of token names or { symbol, issuer } objects; QUBIC_RPC_URL to change the RPC, default https://rpc.qubic.org). Without that file, or with SNAPSHOT=file set, it serves the demo snapshot and says so on startup.
src/live.ts reads, per asset, through /live/v1/querySmartContract:
- QX (contract 1):
Fees(trade fee, transfer fee) andAssetAskOrders/AssetBidOrders, paged 256 at a time withoffsetup to 2,048 orders per side (aQX book has N+ orderswarning appears if the cap is hit). - QSwap (contract 13):
Fees(swap fee, transfer fee) andGetPoolBasicStatefor the reserves. The flat 100,000 QU per-swap fee is the contract'sQSWAP_ADDITIONAL_FEE. - State is cached for 5 seconds per asset, and failed reads are retried and never cached.
- Each QSwap allocation is re-checked against the contract's own
QuoteExactAssetOutput/QuoteExactAssetInputand reported asonChainCheck(differenceQushould be 0).
To always include a token, add its name to assets.json. A snapshot-backed or custom source can be used by implementing MarketData in src/data.ts.
Web app and wallet
web/ is a Vite + React app (npm run dev). The wallet code in web/wallet/ is ported from the connect module in double-k-3033/QRaffle-frontend, with the same four login options: MetaMask Snap, WalletConnect (including the existing WalletConnect project ID), private seed and .qubic-vault file, plus a fifth, the Qubic Wallet browser extension (qubic/wallet-extension, beta; it puts window.qubic on the page, web/wallet/extension.ts). QubicConnectContext.getSignedTx signs transactions for whichever method is connected. The extension signs with whichever account is active in it, so the signed bytes it returns are checked against exactly what was asked (account, destination, amount, tick, call and data: src/signedtx.ts) before anything is broadcast, and switching account or disconnecting inside the extension is followed on the page. A WalletConnect session and an extension connection are picked up again after a reload (a seed or vault was only ever in memory, so those start from the dialog). A phone puts the browser to sleep while the wallet app is open and may throw the page away, so a WalletConnect approval that arrives while the page is away is taken up when it comes back (the page remembers it started a pairing), the dialog looks again at once when the tab returns, and on a phone the button goes through QMax's own hand-off page (/open, the one the Discord bot uses).
Changes from the Qraffle module: the connect modal is plain React/CSS (no Tailwind or UI kit), the jotai balance store is dropped, a vault login reveals the selected account's seed so it can sign, and the wallet saved to localStorage never includes the private key (the original saved it in plain text).
The app quotes through the API (/api in dev is proxied to npm run api; set VITE_API_URL otherwise) and lists the assets the API serves. Start both with npm run api and npm run dev. Trying it on a phone (same Wi-Fi): npm run start:lan starts the API and the website and prints the address to open, for example http://10.0.0.4:5173. Only the website is shared with the network (it is the same dev server, which cannot read .env or .cache); the API stays on 127.0.0.1 and the website's server passes /api on to it. The page is plain http, so a phone browser treats it as an insecure context: browsing, charts and the trade dialog work, but wallet connections that need HTTPS may not. A macOS firewall prompt for node has to be allowed, and a guest network that isolates devices will not work.
Look and feel. The interface is a trading terminal, not a landing page: flat charcoal panels with hairline borders, small system type with tabular numerals for every figure, one blue accent, an orange mark on the active tab, and green and red only for buy and sell. The logo is the supplied artwork (brand/qmax-logo-source.jpg): the letters "QMa" with the two-arrow X in place of the "x". The letters are a picture used as a CSS mask over the text colour (so they follow the theme), and the X is vector, which is also what is shown alone where there is no room for the whole logo (a phone's title bar, the favicon, the home-screen icon); npm run brand (scripts/make-brand.mjs, macOS) rebuilds web/public/brand, favicon.svg, favicon-32.png and apple-touch-icon.png from the artwork. It is one design system in web/style.css (colour, radius and font tokens at the top; dark and light themes, switched by the sun/moon button, remembered in this browser, following the system until chosen) with the workspace layout and dense overrides in web/styles/zz-terminal.css, which loads last. From 1000px wide the page is a workspace (between 1000 and 1260px the whole workspace is drawn at a smaller scale, useWorkspaceScale in web/media.ts, so a normal browser window shows the same three-panel layout rather than the phone one; the chart is counter-scaled so the pointer and crosshair stay exact): a title bar with the Trade, Orders, History, Pools and Swap screens, a market bar (asset counts and the latest trades), a watchlist on the left (Mine, Favorites, All, Contracts, Tokens; click a column heading to sort, and pick 24h, 72h or 7d beside Vol: the volume and the price-change column both follow that window), and the chart with its tools taking the middle and most of the width and height, in the arrangement of a trading platform like thinkorswim: assets on the left, a large chart in the centre, the latest trades on the right at the top and the order panel (side, amount, quote, readiness and the Buy or Sell button) on the right underneath them. The chart is the first thing anyone sees: until an asset is picked the centre is Qubic's own price chart (web/QubicDesk.tsx, the same chart and tools as every asset, from the MEXC and Gate.io candles) with the latest trades of every asset beside it and, under them, a Swap panel (the Swap section's form, QU for a token or a token for QU on QSwap, and one asset for another with Max on; compact mode of SwapPanel) where an asset's order panel would be The symbol at the top has arrows and a search to move to another asset without closing anything ([ and ] go to the previous and next by volume, / opens the search in the trade dialog), the "Hide order panel" strip puts the order panel away (remembered in this browser; the trades stay), and "Wide chart" hides the whole right-hand side for a still bigger chart. The dividers between the watchlist, the chart and the right-hand side can be dragged to resize them (double-click one to put it back, or focus it and use the arrow keys; web/Resizer.tsx and web/layout.ts: each width is kept in this browser, stays within limits and never leaves the chart narrower than a set minimum). The chart's own buttons (fit, latest, settings, picture, full screen) float over its top-right corner. The trades list has this asset's trades and a tab for every asset's (columns: time, side, quantity, price, QU, market; a time under an hour is shown as 4 min, with no "ago"). Below 1000px wide (a tablet, a phone) the same three columns are three screens in one row, swiped sideways, with a fourth for the portfolio past them (web/Pager.tsx, one scroll-snap row; the layout is the workspace's own components): Assets on the left (the markets: Favorites, All assets, Contracts and Tokens, with search, sort and filters), the Chart in the middle (it opens on this one, with Qubic's chart until an asset is picked, the symbol bar and figures pinned above whichever of the two right-hand screens is showing), Trades & order on the right (the latest trades with the order panel under them, or the Swap panel for Qubic), and Portfolio last (My assets with their worth and profit, Orders, History, Pools and Swap, the same tabs as the workspace's Portfolio, Orders, History, Pools and Swap pages). A strip along the bottom names the screen and a tap goes to another; picking an asset in the list slides to its chart, and a link that brings an amount opens at the order panel. A sideways touch drag on the chart swipes to the next screen instead of panning the chart (the chart pans with the mouse, pinch zooms it, and the buttons above it move it); an up-down drag scrolls the screen. Above the list, the tab bar comes first (on a phone in two rows, yours on top and the markets under them, so nothing scrolls sideways) and the search, the sort and the filters follow it as one group, the sort and the filters the same segmented control; on a phone the sort also holds the 24h, 72h or 7d menu that sets the window for the volume and the price change (a wide screen has it in the Vol column heading). The filters are a choice of one at a time (Both markets: assets that trade on QX and QSwap; Active: traded in the last 2 epochs; Arbitrage, part of Max), and picking the one that is on shows the whole list again. On a phone each asset is a compact row under column titles (Asset, Price, the 24h change, Vol: tapping a title sorts): star, logo, name with the market it trades on beneath it (QX, QSwap or both), price, the change and the volume; the grade, what it is and the "cheaper on" hint are in its chart's header. The whole row opens the asset, so there are no Buy and Sell buttons and no spread there. The latest-trades strip under the title bar slides slowly from right to left, new trades joining at the right edge; it stops under the pointer or with its pause button, and is a plain row you scroll by hand if your system asks for less motion. The pieces shared by every screen are in web/ui.tsx (icons, the QMax mark, an accessible Modal that closes on Esc and keeps focus inside, avatars, step markers) and the page frame (title bar, wallet menu with balance, market bar, status bar) in web/chrome.tsx. / focuses search. No web fonts are loaded, and the asset logos are copies served from QMax's own address (web/public/logos, saved from qubictrade.com by npm run logos; an asset with no logo of its own keeps a lettered badge; a round logo on a plain square, white or see-through at the corners, is looked at once it loads, and its badge is filled with the colour of the logo's rim with the picture cut to its disc, so no white corners show: src/logofill.ts, web/logofill.ts), so nothing is fetched from a third party to draw the page.
Limit orders. Each ticket has a Market / Limit switch. A limit order is a QX bid or ask at a price you choose: whatever already rests at that price or better matches at once (at the resting prices, never worse than yours) and the rest waits on the book until it fills or you cancel it under Orders. QSwap is a pool with no order book, so limit orders are QX only. The order is built in the browser from your own price, amount and the asset and issuer from the asset list (src/limit.ts) and checked against exactly that before it is signed (one bid or ask to QX, the right asset, price and units, a bid attaching price times units, nothing else); afterwards the wallet and book are read again and the result says what matched and what is waiting.
My assets: worth, cost and profit. The My assets tab (and the Portfolio page in the workspace) shows what each holding would really fetch if sold now: POST /v1/liquidation runs the whole holding through the router a real sale uses, so the depth of the QX book and the pool and every fee are in it (a holding the market cannot take in full is marked, and only what it can take is counted). Beside it are the average cost and profit or loss against that sale value, and when it was first bought, from the wallet's trade ledger for the last 365 days (src/portfolio.ts); units bought earlier or received from another wallet have a worth but no profit. Totals are also shown in dollars. What it costs the server, and why that stays small: pricing a sale reads the live order book and pool of each asset (about 10 to 15 seconds for 25 holdings, longer while the public node is limiting requests), and the same sale of the same asset is priced once and shared for two minutes between everyone who asks (LIQUIDATION_CACHE_SECONDS) (many wallets hold the same few shares), with at most three portfolios priced at once and a per-address limit; the page keeps the last pricing in the browser, shows it at once whenever My assets is opened again, and prices again only when it is 15 minutes old or when Refresh is pressed (the line under the figures says how old it is). The purchase history is the expensive part (it reads the public archive, which limits how often it is asked), so a wallet's ledger is built once and reused for five minutes by everyone (LEDGER_CACHE_SECONDS), only two are built at once (the rest are told to ask again in a few seconds and do, automatically), a wallet's history is kept in the browser for fifteen minutes, and when the archive says it is rate limiting no new ledger is started for 30 seconds.
Looking at a wallet without connecting it. Some people will not connect a wallet to a site they have just found, and everything the portfolio shows is public on the Qubic network. So every portfolio screen (My assets, Orders, History, Pools) has a box to paste an address into (web/ViewWallet.tsx, web/viewwallet.ts): the address is taken from whatever was pasted (the 60 letters, in either case, or a link to an explorer page) and its last four letters, a checksum, are checked, so a typo is refused with a sentence instead of showing an empty wallet. The portfolio then shows that wallet's holdings (worth if sold now, cost, profit), open orders, trade history and pool positions, under a line saying whose it is and that it is read-only, with Change and Stop viewing. Nothing for a wallet looked at this way can be signed: the Buy, Sell and Swap buttons become Open, orders cannot be cancelled, pool positions have no Add or Remove, and Swap says it needs your own wallet. The address is remembered in this browser (qmax.view) until the person stops, and connecting a wallet of their own ends it. It is read straight from the public node and QMax's ledger API, the same as for a connected wallet.
Max, and QMax Pro. Normal Buy and Sell are ordinary orders, and every one of them already goes at the best route (the router prices each market alone and a split, and takes the cheapest). Max is the part that searches for the best position for a trade (how to execute it, what size, an arbitrage between the two markets, and the best exit for what is held). It is one switch, Max mode, in Settings, and it is served by GET /v1/max. The Max planner is not part of this repository: src/maxplan.ts keeps its types (the shape of a plan, as the API documents it) and a stand-in that says so, and the pass logic behind it (src/pro.ts, src/proapi.ts, src/procover.ts) are stand-ins that make Max free. QMax's own server runs the real ones.
The welcome window (web/WelcomeModal.tsx) opens when the site loads: a few plain lines on what QMax does (free for everyone, you keep your keys), a Support QMax button (web/SupportModal.tsx: the support address, a QR code with the QMax X in it, a Copy button) and a "Don't show this again" box (kept in this browser).
Live prices. The list is read again every 20 seconds while the page is in view (and at once when it comes back into view), and so is the open asset's header. Prices are drawn from QX. Every asset trades on QX (a QSwap pool is an extra market beside it, never the only one), so there is always a QX price: the price of the asset's newest QX trade when that is recent (within 7 days). A QSwap pool is only a reserve ratio and stays put when nobody trades against it (QMINE's said 4,157 for days while QX traded at 3,994), and the middle of a thin QX book can sit far from every trade (QSILVER's was 3.65 million against a last trade at 100,001). An older trade may no longer be where the market is (QEARN's, 146 days ago, was above today's best ask), so it is kept inside the current QX bid and ask; an asset with no QX trade takes the middle of its QX bid and ask. GET /v1/assets puts that price in priceQu and also gives lastPriceQu (the raw trade), lastTradeAt, bookPriceQu (the order-book or pool price) and probedAt (when the books were last read, every 10 minutes, to spare the public node); the trade list is read every 15 seconds on the server and the website's list every 20 (src/api.ts priceFromQx, src/liveprice.ts). The trades list and the ticker poll every few seconds, the Qubic dollar price every minute.
All, and Max. A button that fills in everything you have (all of a token you hold, all the QU you can put into a pool) says All. Max is reserved for QMax's search for the best position, and for swapping one asset for another.
What Max mode unlocks beyond the search. The ordinary trading screens are the same with Max off. These are part of Max (src/chartaccess.ts lists the free set): the market each trade in the recent-trades lists was on (with Max off its place holds a dash), the notice of which market is cheaper (on each row of the list, and the QX-against-QSwap comparison in the trade panel; with Max off a row only says there is a cheaper price), the arbitrage flags (the ARB tag on a row, the Arbitrage filter and its count, the Arbitrage figure in the market bar, and the live arbitrage check in the trade panel; with Max off the tag, the filter and the figure are muted teasers that switch Max on, the filter is ignored and the live check is not made, and the arbitrage limits in Settings apply while Max is on; the API's /v1/arbitrage stays as it was), and most of the chart. The chart is free with candles and the line, every range, every candle width (automatic, 1, 5, 15 and 30 minutes, 1 and 4 hours, a day), volume, the 20-candle average, the linear axis, the market choice, a trend line and a horizontal line, fit, full screen, and a picture at screen size. The other chart styles, the other indicators, epoch marks, filling gaps, the log and percent axes, the chart settings panel, pictures bigger than the screen and the other drawing tools are marked in words ("part of Max" in a tooltip, and "· Max" on a choice), and picking one while Max is off switches Max on. There are no little marks on Max features any more, since Max is free. A choice made while Max was on is kept and comes back with it.
The trade card. When a market order, a limit order or a swap is signed, a card at the top of the dialog follows it (src/tradecard.ts, web/TradeCard.tsx): a bar with a part for each step and one for the wallet check, what it is waiting for (your wallet, the chain, the check), how long it has taken and how far along it is. The whole dialog is outlined by how it is going: a steady blue outline while it is under way, a green outline and a bounce when the wallet confirms it went through, a red outline and a sideways shake when a step failed (or the order was refused before anything was signed), and amber, still, when it needs a look (part of it done, or nothing moved yet). Nothing moves for anyone whose system asks for less motion; the colours stay.
The price of QU. The middle of the title bar always shows QU in dollars and how it moved in 24 hours; clicking it opens the same chart any asset has (candle styles, intervals from a minute to a day, indicators, drawing tools, epoch lines, volume, chart settings, saving a picture), drawn in dollars for one million QU because one QU is worth a tiny fraction of a cent (GET /v1/qu, /v1/qu/candles). The price is the checked median of five sources, the same one that prices the Discord subscription (bot/price.ts); the market figures are CoinGecko's, and the candles are the QUBIC/USDT market on MEXC (Gate.io if MEXC cannot be reached; USDT counted as a dollar), up to 5,000 per answer and read in 500-candle pages. MEXC lists QU from 17 July 2024; for a longer range at 4-hour or daily candles the days before come from Gate.io, where QU first traded on 9 May 2024 (Gate.io will not give hourly or finer candles that far back, and no free source has an earlier price). CoinGecko's free plan only reaches back a year, so it supplies the market figures and the one-year-or-less candles of /v1/qu/candles without interval. Everything is kept for a minute to a few minutes, per range and width, so a crowd of people opening the chart makes the server ask the exchange a handful of times, not once each. /v1/qu/candles without interval still gives CoinGecko's own candles.
Chart settings. The gear on a chart opens settings for the colour scheme, a flat or gradient background, candle, wick and line colours, hollow candles, line thickness, grid, volume strength, typeface and text size. They are kept in this browser, checked again when read back (src/chartstyle.ts), and used by saved pictures too. Epochs (Wednesday 12:00 UTC, src/epochs.ts) can be marked on any candle chart.
Trading
src/exec.ts turns a quote into the ordered transactions the wallet signs. The quote API returns each leg's limits in route[].execution (set with slippageBps, default 100 = 1%), so any front end can build the same transactions:
| Leg | Contract call | QU sent | Protection |
|---|---|---|---|
| Buy on QX | AddToBidOrder | limit price x qty (excess refunded) | limit price = worst ask reached + slippage |
| Sell on QX | AddToAskOrder | 0 | limit price = worst bid reached - slippage |
| Buy on QSwap | SwapQuForExactAsset | max QU in + 100,000 fee (excess refunded) | max QU in = quote + slippage |
| Sell on QSwap | SwapExactAssetForQu | 100,000 fee | min QU out = quote - slippage |
| Move shares | TransferShareManagementRights | transfer fee (100) | sells only: shares must be managed by the venue that trades them |
| QMax pass | QPAYHUB.Pay | 1,000 QU, once per 24 hours | only when the pass has ended (see below) |
ExecuteModal re-quotes right before signing, warns if the price moved, checks the wallet balance (buys) and share holdings (sells), then signs, broadcasts and waits for each transaction in turn (web/exec/), stopping at the first failure. The API only returns execution hints when it serves live data.
After the trade, ExecuteModal re-reads the wallet (src/verify.ts): it compares QU balance and share holdings with a baseline taken at signing time and reports shares filled vs requested, QU actually spent or received (venue fees included), the real price and how far it was from the quote, plus any QX orders still resting on the book (and the QU locked in them). Open QX orders are read with EntityBidOrders / EntityAskOrders.
Used with real funds. Trades made on the website have been signed, broadcast and confirmed on mainnet: QX trades, QSwap swaps and QSwap liquidity changes. The usage record (GET /v1/stats, see Usage tracking) checks every trade it counts against the chain, so the count is a measurement, not a claim. Payload layouts and transaction sizes are also unit-tested against the contract source and the Qubic library. The Discord bot and the agent SDK build the same transactions with the same code, but have had less real-money use than the website: start with a small amount (for an agent, the dry run, agentPlan). Try a tiny trade first with anything new. Known limits: a QX bid that is not fully matched stays on the book as an open order, and balances can take a few ticks to update, so the result check may need a moment.
Market comparison, arbitrage, orders and trade-ready
- Price comparison (
src/arbitrage.ts,comparePrices): for assets on both QX and QSwap, per-unit buy and sell prices after the 0.3% fees, shown on the card ("buy 5% cheaper on QSwap") and in the trade panel (both part of Max mode). - Arbitrage flag (
findArbitrage): looks for a buy on one market and sell on the other that ends with more QU after every fee (QX's 0.3%, QSwap's 0.3% and flat 100,000 QU, QMax adds nothing), the pool's price impact, and the depth of the top of the QX book. An ARB pill marks any asset where it finds one, there is an Arbitrage filter, and the trade panel explains it. It is only a snapshot: the two legs are separate transactions, and QMax does not execute arbitrage for you. - Live arbitrage check (
GET /v1/arbitrage?asset=ID,findArbitrageVenues): the card flag comes from cached top-of-book data (up to 10 minutes old), so the trade panel re-checks against the live full order book and pool before showing anything. At the time of writing none of the 16 two-market assets had one. - Settings (gear in the header,
src/settings.ts,web/settings.tsx): default slippage (capped at 10%), hide quiet assets, default sort, short or full prices, and the arbitrage filters (see below). Stored in the browser's localStorage; invalid stored values fall back to defaults. - Open orders (
src/verify.tsfetchAllRestingOrders,web/OrdersPanel.tsx): lists the wallet's resting QX bids and asks across all assets and cancels them withRemoveFromBidOrder/RemoveFromAskOrder(buildCancelStepinsrc/exec.ts). Cancelling is free, and a cancelled bid returns its locked QU. - Trade-ready checklist (
src/readiness.ts): before Review is enabled, the panel checks wallet connected, enough liquidity, enough QU for the fees (also when selling), shares in the right contract (or the extra move step it needs), and warns about quiet markets and fee-heavy trades. Failures block the trade, warnings do not.
Order books and price charts
- Order book (
GET /v1/book?asset=ID[&levels=15],src/book.ts): the QX orders grouped by price with cumulative size and cost, the best bid and ask, the middle and the spread (and whether QX has more orders than the 256 one query returns), and the QSwap pool: its price, reserves, fee, and how far a trade of 0.1%, 0.5%, 1%, 2% and 5% of the pool would move the price (fee included, QSwap's flat 100,000 QU on top). It reads the same live data the router uses. - Price history (
GET /v1/history?asset=ID&range=1d|7d|30d|90d|all[&interval=1h|4h|1d],src/history.ts,src/trades.ts,src/events.ts): two sources, joined into one line. Past trades: QX logs aTradeMessagefor every order it matches and QSwap aSwapMessagefor every swap, and the archive Query API serves those event logs (/query/v1/getEventLogs, beta). QMax reads them once, back as far asTRADE_HISTORY_DAYS(default 1,825, five years: far more than exists, because the Qubic archive's trade logs only begin around epoch 207, April 2026, so the scan finds nothing older and simply stops at the first month with trades; if the archive ever adds older logs they are picked up), sums them into minutes per asset and venue (volume-weighted price, open/high/low/close, volume; hours, and candles of any whole number of minutes up to a day, are built from those; the first version kept hours, and its file is put aside as.cache/trades.json.v1.bakand everything read again once, newest first) in.cache/trades.json, and reads only new ticks after that, so a restart resumes where it stopped. Each hour with trades becomes one history point, markedsrc: "trades"; an asset with a QSwap pool is drawn from its swaps (as its live price is the pool's), any other from its QX trades. The first read takes about 1.5 minutes and fills the chart as it goes. Live samples: every time the catalog re-reads the markets (about every 10 minutes) QMax also saves each asset's price, best bid and ask, pool price and liquidity to.cache/history.json. Recorded samples always win: a rebuilt point is only added to an hour (a day, for thinned old data) that has nothing recorded, andrecordedSincein the response says where live recording starts. Quiet hours have no point (a line is drawn between neighbours), and a swap's price includes the pool fee and price impact, so rebuilt points differ slightly from the pool's marginal price (about 0.0–0.4% on the assets compared). Recent samples are all kept, anything older than 3 days is thinned to one an hour, and older than 90 days to one a day.intervalalso returns open/high/low/close candles.TRADE_HISTORY=offturns the trade scan off;npm run trades-check -- 210shows what it finds without writing anything. Checked against the archive's own counts: QX matched exactly (21,518 trades over 27 epochs), and in the busiest QSwap epoch 137,305 of 137,339 events decoded (the rest are zero-size). - Older QX history (Quhub), optional: the Qubic archive has no trade records before April 2026, but Quhub (a community site running an open copy of the QX service) has daily summaries back to October 2024 and, for any asset with 1,000 trades or fewer in its whole life (most of them), every trade with its time.
npm run import-quhubreads it once (about 150 requests, one a second, from the public API its own page uses;--dry-runwrites nothing,--only CFBtries one asset) into.cache/quhub.json, which the server loads at start; QMax never calls Quhub while it runs. Older candles are put in front of the archive's only on the candle chart, only for QX (not "QSwap only"; and where they exist, "Best market" means QX and QSwap together, so the older QX candles run on into a series that is not QSwap alone), only for days before the archive's first day, and are markedsrc: "quhub"in the API; the chart says so in a line under it. Busy assets get a daily summary drawn as a candle (opens at the previous day's average price, closes at the day's), so for them nothing narrower than a day exists before April 2026. The chain cannot confirm any of it, and where both sources exist they agree to within about 1% (a few assets and days differ more), so the backtester, health grades, pools and everything else keep reading only what the archive confirms (src/quhub.ts). - Candles and volume (
GET /v1/candles?asset=ID&range=1d|7d|30d|90d|all[&interval=1m|5m|15m|30m|1h|4h|1d][&venue=auto|QX|QSwap|all],candleChartSvginsrc/chart.ts): real open, high, low, close, QU volume, units and trade count per candle, built from the same hourly trade sums, so a candle is at least a minute wide and a bucket with no trades has no candle (the chart leaves the gap, it does not squeeze it away). The width defaults to suit the range.venue=alltreats QX and QSwap as one market (highs and lows span both, volumes add up);autofollows the market the live price comes from. The response also carries the last 24 hours of volume across both venues. The web chart shows candles with a volume panel by default (Line is one tap away), and Discord's/chartdoes the same with a Line button.QMaxClient.candles()in the SDK. - Website charts are TradingView Lightweight Charts (Apache-2.0; its "TradingView" attribution link on each chart stays, as the licence asks). The library only draws what it is given, so QMax adds the rest (
web/ChartView.tsx,web/LwChart.tsx,web/chart/, tested pure code insrc/):- Styles: candles, Heikin-Ashi, OHLC bars, area, and the price-history line. Candle width (auto, or 1, 5, 15 or 30 minutes, 1 hour, 4 hours, 1 day: trades are kept by the minute, so all of them are real; auto picks 5 minutes for 1D, 15 minutes for 7D, 1 hour for 30D, 4 hours for 90D and a day for All, a few hundred candles each; one answer holds at most the latest 5,000) and market (a "QX chart" and a "QSwap chart" button, the second only for an asset that trades on QSwap; it opens on QX, or on QSwap for an asset that trades only there) are choices, not fixed; ranges 1D to All. A range that is too quiet to read (fewer than 12 candles) widens itself one step and says so, unless you picked it. The data refreshes every minute and your zoom stays where it was.
- Indicators (
src/indicators.ts, checked against independent calculations): SMA 20 and 50, EMA 21, Bollinger Bands (20, 2), VWAP (the exact average price paid, from QU and units traded since the first candle shown) on the price; RSI 14, MACD (12, 26, 9), Stochastic (14, 3, 3), ATR 14 and OBV in panes of their own under it. Linear, Log and % price axes, and a volume toggle. The legend shows every active value at the crosshair. Indicators count the candles that exist, so in a quiet market a 20-candle average covers more than 20 candles' worth of time (Fill gaps carries the last price through empty candles so the series is unbroken); one that needs more candles than the range has says so. - Drawing tools (
src/drawings.ts,web/chart/drawings-primitive.ts): trend line, ray, horizontal and vertical line, rectangle, Fibonacci retracement and a measuring tool (change in QU, percent, candles and time). Click to place, click a drawing to select it, drag it or its dots to change it, Delete removes it, Esc cancels; six colours. Drawings are fixed to a time and a price (so they stay put when you change candle width or axis), kept per asset in this browser only (localStorage, validated on load, never sent anywhere), and are in the saved picture. - Layout: the trade dialog is nearly the whole window with the chart as its main part and a slim order ticket beside it; the chart fills the height that is left under its controls, the drawing tools run down its left side as on a trading terminal, and Wide chart hides the ticket (Show ticket brings it back). On a phone the tools fold behind one "Chart tools" button so the chart comes first.
- Pictures: the camera button saves the chart (with its drawings) as a PNG at screen size, Full HD, 4K or 8K (7,680 px across). A big picture is the same chart built again off screen at that size, with type, lines and drawings scaled to match (
web/chart/build.ts), so it is sharp, not a stretched screenshot; if a browser refuses a canvas that large the next size down is used. On screen the charts draw at the display's native resolution. - Also: jump to the latest candle, fit all, and full screen (the browser's Fullscreen API). Not here: text notes, alerts drawn on the chart, and TradingView's own full toolset (their separate Advanced Charts library needs a licence from them).
- Charts (
src/chart.ts): a price line and a depth chart drawn as SVG text from plain numbers, shared by the website (shown inline) and the Discord bot (turned into a PNG with@resvg/resvg-js; text needs a font on the machine running the bot, which any Mac or Windows PC has, and a bare Linux server needs one such as DejaVu; if a picture cannot be drawn the bot falls back to a row of block characters). - Website: the trade panel has Order book and Chart tabs (the book refreshes every 20 seconds while open; the chart has 1D / 7D / 30D / 90D / All and says plainly when history is still short).
- Discord:
/book ASSET(depth picture and a text ladder with the pool line),/chart ASSET [range], and Order book and Chart buttons on the/menuhome screen (pick an asset from the dropdown or search by name; chart screens have range buttons and a switch to the book). All free. - API and SDK: both endpoints are free and rate limited per IP without a key (they are not billed),
client.book()andclient.history()in the SDK, andpriceChartSvg/depthChartSvgare exported. - Other sites' data: neither QubicTrade nor QubicSwap publishes an API or developer docs (
/api,/docsand/developersare all 404 on both). QubicTrade's page gets its trade history and volumes over a private WebSocket server meant for its own page; QMax does not use it. If you want their historical data, ask them for access.
Arbitrage settings (web, Discord and API)
Everyone can say what counts as an arbitrage worth showing, in three filters (src/arbfilters.ts; 0 means no minimum or limit): the smallest profit in QU, the smallest profit as a percentage of the QU put in, and a budget, the most QU to put in. The filters apply during the search, so a budget returns the best loop that fits it (a smaller one), not the biggest loop with the rest thrown away, and a minimum that nothing clears returns "none".
- Web: Settings (gear), Arbitrage section: minimum profit in QU (default 10,000), minimum percentage and budget. They shape the ARB flag on cards, the Arbitrage filter, and the live check in the trade panel. Stored in that browser only, like the other settings.
- Discord:
/arbitrage-settings min_profit min_profit_pct budgetsaves them for your Discord account (no options shows them;resetclears them) and every/arbitrageuses them, including the preview non-subscribers get. - API:
GET /v1/arbitrage?asset=ID&minProfitQu=…&minProfitPct=…&maxCostQu=…. A result that nothing clears is "none", which is free, so filters never raise a bill.
Share management (QX and QSwap)
A venue can only trade shares it manages: shares bought on QX are managed by QX, shares bought on QSwap by QSwap. QMax handles this in two ways.
- Selling moves what the sale needs, first.
buildExecutionPlanchecks which contract manages the shares for each leg. A sell through QSwap (alone or as part of a split) starts with aTransferShareManagementRightscall to put exactly the shortfall under QSwap, and a QX leg gets the same in the other direction. The move is its own transaction that must confirm before the swap or ask is sent, it is shown in the steps ("Move 40 CFB from QX to QSwap management so QSwap can sell them") and in the trade-ready checklist, and its fee goes to the contract taking the shares over. Only the shortfall moves, so in a split sell the QX leg keeps its own shares. Shares managed by anything else (staked in a smart contract, say) cannot be sold here and the error says so. - Keep everything under one contract (optional, QX by default). It is asked while a buy is being built: the review dialog has After buying, keep my ASSET under one contract with QX (default) or QSwap, and says what it adds from the route ("move 400 CFB from QSwap to QX, fee about 100 QU"; a QX-only buy under QX has nothing to move). If ticked, the move is signed as the last step once the buy has confirmed, with no second question, and it also moves any of that asset already held under the other contract. The saved setting (Settings, Share management) only sets the starting answer, and after a sale it offers the leftover move with a button. My assets > Keep all under one contract moves every asset in the wallet at once, one signed transaction per asset (
src/consolidate.ts; shares under other contracts are listed but not touched). In Discord, a buy quote has two buttons, Buy now and Buy and keep under QX (the second runs the move as the last step);/keep-shares under:makes that one the main button and offers the move after sales, and/consolidatemoves everything. Nothing is signed until the button is pressed, and each move costs the receiving contract's transfer fee (read live from QX and QSwap).
QMax pass (optional, off by default)
Trading is free now: this describes the pass that comes back with VITE_REQUIRE_PASS=on. QMax takes nothing from a trade. Prices, quotes and the whole market view are free; executing a trade would need a pass: PAYWALL.priceQu (1,000 QU) paid once for PAYWALL.hours (24) hours of unlimited trading, set in src/config.ts.
- How it is paid: through QPayhub (contract 29, token QPAY), so it feeds QPAY. The wallet signs
Pay(seller = QMax address, resourceId = "QMAXPASS", nonce)with 1,000 QU attached (src/pass.ts). QPayhub keeps 0.75% (at least 100 QU) for its shareholders and QPAY holders, forwards the rest to the QMax address in the same transaction, and records a receipt. - Verified, not assumed: QPayhub refunds (and the transaction still confirms) when it refuses a payment, so after the payment the app reads the receipt from the contract (
fetchReceipt:ComputeReceiptKeythenGetReceipt, retried for a few seconds) and only then saves the pass. I checked the receipt lookup against real QPayhub payments on mainnet. - Where it shows: the trade-ready checklist has a "QMax pass" line; without one the button reads "Unlock trading: 1,000 QU for 24 hours" and opens
PassModal. The review step re-checks the pass (inside its 24 hours, and QPayhub still holds the receipt) just before signing. If the network cannot be reached it trusts the saved pass, so a hiccup never makes someone pay twice. - Limits, honestly: the pass belongs to one wallet and is remembered in that browser (a new browser asks again; paying while a pass is already active is not blocked). The check runs in the user's own browser, so it cannot stop someone who builds their own transactions or edits the app, and the 24 hours are counted from the time saved in the browser, with the on-chain receipt proving only that a payment was made. Enforcing a paywall for every caller would need a QMax smart contract that calls QX and QSwap itself.
How it works
- QX depth: the book is walked order by order. Quotes report orders used and share of depth consumed; an order larger than the book returns "cannot fill" for QX. A book of 256 orders (the query cap) triggers a warning because more depth may exist.
- QSwap size dependence: the price depends on trade size, so the router quotes each venue at 25/50/75/100% of the order, interpolates between those quotes, picks the best split, then adds quotes around the winning sizes and searches again. Final prices are always real quotes, and a split is only chosen if it beats single-venue execution after fixed costs.
quoteCallsshows how many quotes this cost. src/venues.ts: venue models.src/router.ts: ladder + split search.src/cli.ts: runs against a JSON snapshot.
Not done yet
- Live data: venues read from a snapshot. Adapters must fetch the QX order book and QSwap reserves (or call the on-chain Quote* functions through the
variableNetQuhook) from a Qubic RPC. - Execution: quotes only; no transaction building, and no slippage protection against state changing between quote and fill.
Discord bot
The Discord bot's source is not part of this repository (it runs on QMax's own server); this section describes the hosted bot. Nothing in the API or the website depends on it, apart from the QU price feed in bot/price.ts.
Trade from Discord with npm run bot (the QMax API must be running; npm start runs it). The bot never holds keys: it only asks the user's own wallet to sign, over WalletConnect.
/menuis a tap-through dashboard so nothing has to be typed. It is private to you and edits itself as you go: Buy / Sell / Quote (pick an asset from a dropdown: what you hold for a sale, the most liquid otherwise, or Search by name…; then a quantity button, All when selling, or Custom amount…; then the same quote and confirm buttons as the commands), Arbitrage (scan every asset, check one, or set your filters), Wallet, Connect / Disconnect, Subscribe and Settings (arbitrage filters, keep-shares choice, move everything to QX or QSwap). Every screen has a Menu or Back button, and the slash commands still work and use the same code, so subscriptions and checks apply the same way. Discord limits dropdowns to 25 entries and buttons cannot take typing, which is why Search by name… and Custom amount… open small pop-up forms (bot/menu-ui.ts)./connectshows a QR code (and a link for phones) to pair a Qubic wallet./walletshows the address, balance and plans;/disconnectunlinks./quote,/buy,/sellprice a trade across QX and QSwap with the same router as the web app./buyand/selladd a Confirm button; each step then arrives as a signing request in the wallet, and the bot reports what really moved in the wallet afterwards.- Free for everyone, with a way to support it. QMax's owner decided on 2026-10-07 to give it to the community: the bot's subscription is switched off (
BOT_SUBSCRIPTIONSis unset;onbrings everything below back), so trading and the arbitrage details are free in Discord like everything else,/support(and Support QMax in/menu) goes through a Q+Pay tip jar: setSUPPORT_URLto the tip jar link from the QPay dashboard (https://useqpay.com/tipjar/<id>, https only) and Support shows a Tip with Q+Pay button and a QR code of that link (with the QMax X in it); the supporter chooses the amount at Q+Pay's checkout. UntilSUPPORT_URLis set (or if it is not a plain https link) it falls back to the QMax address to send QU to (SUPPORT_ADDRESS, default the address QMax is paid to), as text and as a QR. The website has the Qubic heart in the title bar (a pixel-art heart,QubicHeartinweb/ui.tsx, where the Max switch used to be; it is a link straight to the tip jar whenSUPPORT_URLis set, and opens the Support window with the address and QR while it is not), and a Support QMax button in the footer, Settings and welcome window;GET /v1/plansreportssupport: { url, address }andagents: { maxPriceQu, sessionPriceQu, sessionSeconds }(what agents pay, read from the same settings; the welcome window's agent line shows these numbers, so it never states a stale price). The rest of this item describes the subscription for when it is switched on. - Alerts are the one paid part of the bot (
BOT_ALERTS_PRICE_QU: 100,000 QU forBOT_SUB_DAYS(30) days on the live server; unset means free). The bot checks the market for every alert about every minute, which uses real resources. With it set,/subscribe(and Subscribe in/menu) buys the alerts plan through QPayhub exactly like the subscription below (a receipt underQMAXSUB, checked before it counts, each payment once, paying early adds days), and an alert of any kind needs it: without one,/alerts add-…says what it costs, and a lapsed subscriber's alerts are paused, not deleted, cost the market no checks, and resume on their own when they subscribe. Trading, quotes, charts, the arbitrage details and everything else stay free (BOT_SUBSCRIPTIONSstays off), and the launch free period (BOT_FREE_UNTIL) does not cover alerts.GET /v1/plansreportsdiscord.alertsPriceQu, and the website's welcome window reads it. The income is subscription income, so it feeds the subscribers' profit share like the subscription does. - One subscription, no per-trade fee (separate from the web pass). It costs 100,000 QU for 30 days: a fixed amount (
BOT_SUBSCRIPTION_PRICE_QU;BOT_SUB_DAYSsets the length). LeaveBOT_SUBSCRIPTION_PRICE_QUout and the price isBOT_SUBSCRIPTION_USDdollars (default $1) at the live QU price instead (below).GET /v1/plansgives the website the current price, length and free period. Subscribe in the/menu(or/subscribe) takes the payment through QPayhub like the web pass, so it feeds QPAY too and leaves an on-chain receipt. The bot checks that receipt before it counts the payment, counts each payment once, and a retried payment reuses its nonce, so QPayhub refunds a double payment instead of charging it. Paying again before it ends adds the days to what is left.- Needs the subscription: any trade from Discord:
/buyand/sell, whatever the route (one market or a split across both), and the full/arbitragedetails (which asset, direction and size)./arbitrage ASSETchecks one asset live at full depth; with no asset it scans every asset on both markets. Without the subscription/buyand/sellstill show the full quote and say what it costs, and/arbitragesays how many opportunities exist and what the best is worth, but not which. - Free:
/quote(the full best route, splits included),/connect,/wallet,/disconnect,/consolidate,/keep-shares, the settings commands and the/menuitself.
- Needs the subscription: any trade from Discord:
- A free launch period (
BOT_FREE_UNTIL, a date such as2026-11-06). Until then everyone may trade and see the arbitrage details from Discord whether or not they subscribed, and the menu and wallet screen say "Free for everyone until …". It is only an answer the bot gives (no payment, no receipt, nothing on the chain), so it does not extend a paid subscription, and a subscription that runs past it still counts after it; the Subscribe screen tells people they do not need to pay yet. Leave the setting out and only subscribers trade. - The connect code (the website's WalletConnect step and the bot's
/connect) is a QR code with the QMax X on a white plate in its middle (src/qrlogo.ts: error-correction level Q, so the covered middle is repaired when it is read; checked to decode at 420, 300, 240 and 180 pixels); on the website the code and the buttons under it share one width. - Keeping it at $1 while the QU price moves (only when no fixed QU price is set) (
bot/price.ts). Every five minutes the bot reads the QU price from five independent places (CoinGecko, CoinPaprika, Gate.io, MEXC and Bitget; exchange pairs are USDT, treated as dollars) and uses the median of those that agree. Readings more than 15% from the median are dropped, sources that split into camps are refused, and a move of more than 25% from the last accepted price is only believed when two or more sources back it. The amount is $1 divided by that price, rounded up to three significant figures (for example 1,760,000 QU), so it is never below $1 and never more than 1% over. When someone presses Subscribe the amount is held for 15 minutes and shown on the Pay button: the button carries the amount it showed, and if the held price has run out or changed, the person sees a fresh offer instead of paying a number they did not read. Any amount that was offered under the same payment counts, so a price that drifted between two offers cannot strand someone who paid. If every source fails the bot keeps the last good price for up to 6 hours, then says it cannot work out a price (and keeps all the other commands working). To test cheaply, setBOT_SUBSCRIPTION_PRICE_QUto a fixed QU amount (the live price is then not used). The web pass and the API stay priced in QU. - Open in wallet (same phone as Discord). Discord buttons can only open web addresses, so with
BOT_PUBLIC_URLset (the address a phone can reach the API on, for examplehttps://api.example.com) the Connect screen gets an Open in wallet button that goes to the API's/openpage (src/openpage.ts). The page reads the pairing link from the part of the address after the#, which browsers never send to any server, so the secret in it is not logged anywhere. It checks the link is a real, unexpired WalletConnect link, opens the wallet app withqubic-wallet://pairwc/…(the same address the website uses), clears the link from the address bar, and has a real Copy link button as a backup. It loads nothing from other sites and sends nothing out (strictContent-Security-Policy,no-store, no referrer). WithoutBOT_PUBLIC_URLthe Connect screen has the QR code and a Copy link button that sends the link alone in a private message (press and hold, Copy Text). - Every reply that involves a wallet is private to the user (ephemeral), including the pairing QR code.
Setup: create an application and bot at discord.com/developers, put its token in .env as DISCORD_TOKEN, get a free WALLETCONNECT_PROJECT_ID from cloud.walletconnect.com, and, if you want a price other than $1, BOT_SUBSCRIPTION_USD (see .env.example). Invite the bot with the applications.commands scope. State (wallet address, session id, plan end dates) is in .cache/bot/.
API billing for other sites (optional, off by default: API_ACCESS=billing)
The API is free now (see "What costs money"); this describes the prepaid mode that API_ACCESS=billing switches back on.
What other sites pay for is the split: QX and QSwap routing on its own is something DEXes like qubictrade.com and qubicswap.com already have, so the API only bills a quote whose best route is a split across both venues. Sites prepay a balance, topped up through QPayhub, so it feeds QPAY too. Docs: GET /v1/openapi.json.
- What is billed: a
/v1/quotesent with anx-api-keywhose best route uses both venues costsAPI_SPLIT_PRICE_QU(default 100 QU). A/v1/arbitragecheck that finds an opportunity costsAPI_ARBITRAGE_PRICE_QU(default 50 QU); "none right now" is free, and so are quotes that resolve to one venue, failed calls, listings, search and the docs. Responses carryx-qmax-charged-quandx-qmax-balance-qu. With too little balance a split quote gets a402that withholds the route and says how much the split would have saved (splitWouldSaveQu), and an arbitrage result gets one that withholds the details and shows the profit (opportunityProfitQu), so a site sees what it would gain. Sendingsplit=falsealways gives a free single-venue quote. - No key: quotes work but are limited to
API_FREE_PER_MIN(default 60) per IP, which is plenty for the QMax web app and a trial, and not for a busy site. Keys are capped at ten times that, since free quotes still use the node. Behind a proxy you control, setTRUST_PROXY=1so limits use the real client IP. - Getting started:
POST /v1/keysreturns{ key, keyId }(free, balance 0; shown once; a few per hour per IP). Only a hash of the key is stored.GET /v1/topup?keyId=…&amountQu=…returns a transaction to sign with any wallet: a QPayhubPayof that many QU (at leastAPI_MIN_TOPUP_QU, default 10,000, because QPayhub keeps at least 100 QU of every payment) with a receipt that names the key.- After it confirms,
POST /v1/topup/claim { keyId, payer, nonce }. The server reads the receipt from QPayhub and adds the amount paid to the balance. Each payment counts once, and a payment only fills the key it names, so nobody can claim someone else's. GET /v1/accountshows the balance. Prepaying 10,000 QU buys 100 split quotes.
- Your own keys:
API_KEYis never charged or limited; the Discord bot sends it (API_KEYin its.envmust match).ADMIN_KEY(a different value, kept out of the bot's environment) is the owner's key for the subscriber list, profit sharing, payouts and usage stats: a bot that leaks its key leaks only market access. Without the meter (API_METER=off) the API is open, or guarded byAPI_KEYalone as before. - Limits, honestly: balances live in
.cache/meter.jsonon the server (back it up: it is the record of what people prepaid), per-call charges are written within half a second, and a top-up must be claimed while QPayhub still holds its receipt (about two epochs). The free tier is per IP, so it slows heavy users down without stopping anyone willing to rotate addresses; the paid path is the way to get reliable volume.
Market intelligence (what no other Qubic site shows)
All of it is built from the same public data: six months of every QX fill and QSwap swap from the archive's event log (src/trades.ts), the live book and pools, and what the contracts' source says about fees. Every endpoint is described in /v1/openapi.json, has a typed QMaxClient method, and is an MCP tool (mcp/). Each states what it ignores.
- Asset health (
GET /v1/health?asset=ID,GET /v1/health/all,src/health.ts): a 0 to 100 score and A to E grade from the depth within 2% of the price (measured from the pool's price when there is one, not the middle of a wide QX book), the spread (QX prices are whole QU, so a one-QU gap is not "wide"), trading frequency and age, with plain-English reasons. Wash-trade detection is deliberately strict: the archive shows what traded but not who, so it flags only a pattern that cannot be ordinary trading (an hour of at least 300 tiny swaps averaging at most 5,000 QU, repeated, in one rhythm). In the real data that is exactly one asset, CFB, for ten days in epoch 211: one wallet buying and selling 103 QU swaps with itself, 137,096 swaps worth about 0.1% of the pool's volume. It is reported as "looks like", and an old burst is informational only (wash-past, costs no points). Large wash trades are not detectable this way, so no flag is not a clean bill of health. - Markets tab (
GET /v1/premium?asset=ID&range=...,src/premium.ts): how far apart the QX and QSwap prices were, hour by hour, and in how many hours the gap was wider than the trading costs for a trade of a given size (break-even is worked out from the fee constants: the 0.3% pool fee, QX's 0.3% seller fee and QSwap's flat 100,200 QU, so it is about 1.3% to 1.6% at 10M QU and over 10% at 1M QU). It is an upper bound, not a profit: hourly averages leave out the QX bid-ask spread (often wider than the costs) and the delay between two transactions. Only hours when both markets traded are compared unless you allow a price to stand for up to 3 hours (carry). - Pools (
GET /v1/pools,GET /v1/pools/detail,src/pools.ts): every QSwap pool ranked by what it really earned in fees. From the contract source and the liveFeesfunction: a swap pays 0.3% of its QU side, of which 64% stays in the pool for liquidity providers, so 0.192% of each swap's volume reaches LPs (the rest goes to QSwap shareholders, QX, rewards and a burn); the flat 100,000 QU per swap, add and remove is never LP income. Fee APR is trailing, price change and impermanent loss come from the swap prices, and a deposit calculator estimates fees and IL for a size. Pools with thin or bursty volume are flagged and ranked after clean ones; nothing is hidden or capped. - Backtest (
POST /v1/backtest,src/backtest.ts): replayshold,dcaorbandsover real hourly candles with the venues' real fees (QX's 0.3% seller fee, QSwap's flat 100,100 QU per swap; QSwap's 0.3% is already inside the logged prices and is not charged twice). Decisions are made on an hour's close and filled at the next hour's open, no price is ever invented for an hour with no trades, and the result always carries warnings: there is no order book depth in the history (so large amounts are optimistic), and it counts hours whose price was more than 40% off its neighbours. A weekly 500,000 QU DCA into QDOGE over 90 days returned about +8% with 13% of the starting QU paid in fees: the flat QSwap fee is why small regular buys belong on QX. - Live tape and order flow (
GET /v1/tape,GET /v1/flow,src/tape.ts): one feed of every new fill and swap on both markets with its direction. A QSwap swap's direction is in the event; a QX fill is logged the same for both sides, so the direction comes from the transaction that caused it (the sender is the taker: call 6 = bought, 5 = sold), which was checked against who received the shares on every QX transaction of a day (107 of 109 exactly; the other 2 were an account trading with itself). Served from memory, so it is cheap to poll. The trade index reads new ticks every 15 seconds. - Wallet ledger and P&L (
GET /v1/ledger?identity=ID&format=json|csv&days=1..365,src/ledger.ts, the History tab): what a wallet bought and sold on QX and QSwap, rebuilt from the archive's event log (QU and share transfers grouped by transaction; any identity works because it is public chain data) with price, fees, running position, average-cost realized profit, and unrealized profit at the current price. CSV export has stable columns (date, tx hash, kind, venue, asset, issuer, quantity, price, QU net, estimated fee, position, realized P&L) followed by a positions table. Checked on 7 real wallets and 707 trades: 232 of 232 sampled trades matched the contracts' own records exactly, 59 of 59 QX sale fees, and every own transaction that moved shares has an entry. It is an estimate and says where: the archive has no events at all for about 10 to 14 June 2026 (anything in that stretch is missing, and the ledger warns), units that arrived by transfer or before the window have no known cost (selling them is counted separately, not as profit), QSwap liquidity changes appear as transfers, and unrealized profit uses the mid or pool price, which misleads on thin markets. A build costs 3 to 29 archive requests (about 1 to 13 s), is cached 60 s per wallet, and at most 2 run at once. Not tax advice. - Swap, and Max swap (
web/SwapPanel.tsxfor the Swap tab): the tab has two parts. With Max off the tab is the plain swap: QU for a token, or a token back to QU, straight through the QSwap pool (a QSwap-only quote fromGET /v1/venue-quote, signed like any trade; only tokens with a pool are listed, and a buy so small that the pool would keep the whole payment is refused). With Max mode on (the switch in Settings), the tab can swap one asset for another, and that is part of QMax Pro (free to try for a while, see "Max, and QMax Pro"); the Swap button on each held asset in My assets (shown only with Max on) is the same thing. The rest of this paragraph is the Max swap. Token-to-token swap (POST /v1/swap-quote,src/swap.ts,web/SwapModal.tsxfor signing): a from and a to token, an amount, and a live plan with the best-deal proof. Withcompare: truethe server plans the same swap four more times, forcing the sale and the purchase each onto QX or QSwap, and compares what each combination is worth against QMax's own route (bestDeal). "Worth" is the units bought plus the QU left over (at that plan's own purchase price) minus the fees paid up front, because units alone mislead: a plan that buys at a better price is sized against a worst-case limit and can buy fewer units while keeping the rest as QU. Unavailable combinations are listed with the reason, and no gain is claimed when a single market is already best. It sells one token for QU, then buys another with that QU, planned and signed in one review: two trades, never one atomic transaction, and the design is built around that: leg 2 is sized to the least leg 1 can pay within its limits (minOutQty, guaranteed only while neither price moves past your slippage limit), the QU leg 1's flat fees need is shown up front, and leg 2 is re-sized from the wallet's real balance after leg 1 settles and refused (you keep the QU) rather than ever attaching QU the wallet lacks. An independent audit re-derived the money math fromQx.handQswap.hand tested it against a simulator of both contracts over 4,000 random swaps with markets moving between the steps; it found and fixed four bugs, the worst being a real contract trap (below). There is no size limit unless the operator sets one (SWAP_MAX_OUTLAY_QUin the server's environment refuses any plan that could put more than that many QU at risk); the remaining limits are the contracts' own. Not verified: any real transaction, and a real wallet flow with two signature prompts in a row. - A contract trap QMax now avoids on every buy. In
Qswap.h'sSwapQuForExactAsset, if the QU a buy needs is under the 36 QU protocol fee, the contract refunds only that small amount and keeps everything else you attached, including the flat 100,000 QU fee. A buy of a couple of units of a ~1 QU token on a pool-only asset would have lost 100,000 QU.route()now never offers a QSwap buy that needs under 1,000 QU of input (QSWAP_MIN_BUY_QU; sells refund the flat fee on every failure and have no such trap); the quote says why. Only in-process callers that size orders themselves (the swap planner, which applies its own check) can bypass it, never an API request. - Add and remove liquidity on QSwap (
GET /v1/liquidity/positions?identity=ID,GET /v1/liquidity/pool?asset=ID,src/liquidity.ts,web/LiquidityModal.tsx,web/PoolPositions.tsx, the Pools tab): "Your liquidity" lists a wallet's positions (units, share of the pool, what removing it all pays, value at the pool's price, the contract's ownearnedFees), read live fromGetLiquidityOfandGetPoolBasicState; "Add liquidity" on a pool and "Remove" on a position open a plan that is checked again against the pool right before signing. It plans only; the wallet signs. Everything was read fromQswap.hline by line and every refusal path was mapped. Three traps the planner works around, all verified in the source:- Adding can silently give tokens' fractions away. If the QU you attach is what limits the deposit, the contract takes all of it for a rounded-down number of tokens and never checks
quAmountMin, so a falling price does not stop it (a real QCAP deposit put in 6,000,000 QU for 21 tokens; another lost 150,186 QU). QMax fixes the token side instead and attaches enough QU that the contract takes the exact path, so rounding is a few QU. A deposit too small to leave one token of slippage room (QCAP is about 270,000 QU a token) is refused with the smallest amount that works. - Removing with no minimums can pay nothing while keeping the flat fee and burning the liquidity. Every removal is signed with minimums of at least 1 on both sides, so the contract refuses and refunds instead, and a removal that would pay 0 on a side is refused up front.
- A whole-number token payout means the fraction is left in the pool; a partial removal burns the least liquidity that pays the same tokens, so the rest stays in your position. Each add and each remove also costs the flat 100,000 QU (kept only when liquidity actually moves; refunded on every refusal), the tokens must be under QSwap's management (QMax moves them from QX first, a step that attaches 100 QU which comes straight back), and creating a pool (the first deposit, which locks 1,000 units for ever) is not offered. Read-only SDK methods
liquidityPositions/liquidityPooland MCP tools exist; building and signing is only in the web app.npm run liquidity-checkreplays every QSwap event of the epoch against the live contract (adds 7 matched / 0 not, removes 1 / 0, swaps 189 / 0at the last run). An independent audit re-readQswap.hand replayed BITE's whole history from its first deposit (14 of 14 adds, 5 of 5 removes, 123 of 123 swaps, end state equal to the live pool; this covers the token-side path) and found no error in the maths. It found and fixed problems around it: the pool was not read again between a share move and the deposit (a price rise in that window could cost up to about one token's worth, now stopped at review, at signing and after the move), "nothing changed" messages that blamed the contract when the wallet had simply declined, a partial removal that could strand a remainder worth less than the fee (now warned), a stale positions list after a change (fresh=1), and a warning when a deposit would be over 20% of a thin pool. Removals worth less than the 100,000 QU fee, and deposits under 200,000 QU in value, are refused on purpose. Not verified: any real add or remove transaction, and the connected-wallet signing flow (sign rejected, tick expired, share move refused). Do one small real deposit and removal on a deep pool (GARTH or CFB) before relying on it.
- Adding can silently give tokens' fractions away. If the QU you attach is what limits the deposit, the contract takes all of it for a rounded-down number of tokens and never checks
- Alerts (Discord
/alerts,src/alerts.ts,bot/alerts.ts): price crossings, arbitrage opportunities under your own filters (part of the subscription) and big trades (from the tape), delivered as a private message with one-tap Buy and Sell buttons that open the existing quote and wallet-signing flow. A price rule fires on a crossing, not every minute it stays beyond the level, with a cooldown; if you have closed your DMs your alerts switch off with a reason and a Resume button. - Hosted MCP server at
https://qmax.exchange/api/mcp(src/mcphost.ts): the same tools over MCP's Streamable HTTP transport, so an assistant connects with nothing to download or run (claude mcp add --transport http qmax https://qmax.exchange/api/mcp). Stateless, free, no key, and without the paid Max plan (the caller's own wallet pays for that, through x402). The tools reach the API on the loopback address naming the caller's address, so the free per-address limits apply to MCP use as to direct calls; the MCP calls themselves are limited to 120 a minute per address.MCP_HOSTED=offstops serving it. - MCP server to run yourself (
mcp/, see mcp/README.md): the above as tools for AI agents, all read-only, withqmax_build_planreturning the unsigned transactions for the caller's own wallet. Optional x402 payment is off unless you set a seed yourself, and is capped.
Savings receipts (what the routing is worth)
src/savings.ts compares every quote's route with each market on its own, using the single-market prices the quote already carries (alternatives), so the number is arithmetic on the quote, not a guess about another site. The trade ticket says "Splitting across QX and QSwap saves you N QU (x%) versus the best single market" (or, when one market wins, what the other would have cost) with each market's total listed. After a trade the receipt shows the same measured at the prices quoted before signing, next to your actual result, with a "Copy receipt" line. A running total is kept per wallet in this browser (and per user in the Discord bot's store) and shown in the wallet menu. It counts only the conservative figure (the saving against the best single market, on split trades, scaled down for a partial fill), never the saving against the worse market, and a trade that moved nothing adds nothing.
Usage tracking (who trades through QMax)
The wallet signs an order and the app sends it to the network, so QMax has to be told about it. Two ways, used together. The order passes through QMax: the page (and the Discord bot, and an agent using the SDK) sends a signed order to POST /v1/broadcast, which checks it is an order, a swap or a liquidity change for QX or QSwap (nothing else is carried: src/relayable.ts), checks it was signed by the wallet named, passes it to the Qubic node unchanged and notes its transaction id for counting (src/relay.ts). It is counted from the moment it is sent, so it counts even if the page is closed before the order is confirmed, and nobody can name someone else's order, because QMax only counts what it carried itself. If QMax cannot take the order (it is down, slow or refuses) the sender goes to the network directly after a short wait, so QMax can never stop a trade. The order is reported as well: once a step is confirmed the app also reports its transaction id (POST /v1/trade-report; one hook in web/exec/run.ts, web/usage.ts), which covers the case where the relay could not be used and is how counting worked before the relay. Either way src/usage.ts checks every id against the archive before counting it: the transaction must have been sent by that wallet to QX or QSwap, be a buy, sell, swap or liquidity change, have been reported within 30 minutes (so old history cannot be back-filled), and either have filled (the contracts' own event log gives the asset, quantity and QU) or be an order resting on the QX book (counted apart, as a resting order, with what it is worth at its price: its asset, size and price are read from the order's own input; a buy whose QU the contract gave straight back is dropped, and a refused swap or a stranger's wallet is dropped, and the reasons are counted). A malformed id is refused at once; an id the archive does not know is retried for 15 minutes (a real one is indexed within a minute or so); one whose events lag, or while the archive is down, for up to two hours. One IP address can have at most 100 ids waiting, and reports and relays are rate limited (QMax's own key, the bot's, is not), so no one caller can fill the queue or use the relay to hammer the node.
- Read it:
GET /v1/stats?days=30with the owner's key (x-api-key: $ADMIN_KEY, or$API_KEYwhen no separateADMIN_KEYis set; with neither set nobody can read it). It gives totals (orders: every order or swap placed, the ones that filled plus the QX orders still resting;trades: the ones that filled;restingandrestingQu: orders placed that had not filled and what they are worth at their prices, not volume; wallets, QU volume of the fills, new wallets, liquidity operations), by day, byref(a partner's link tag, ordirect), by channel and venue, top assets, the top 20 wallets by volume, and verification counts.GET /v1/stats?wallet=IDlists one wallet's trades and payments. Data is kept in.cache/usage.json;sincesays when counting began. - Sales need no report. QPayhub is a contract, so every payment to QMax's address is on-chain. Every few minutes
scanPaymentsreads thePaycalls, keeps those to QMax's address and says what each was for (the resource id:QMAXPASSpass,QMAXSUBDiscord subscription,QMAXAPIAPI top-up, anything else such as an x402 session asother). A payment counts only if QPayhub forwarded it to QMax (a refused payment still confirms as a transaction, but the money goes back), so the forward in the event log is the proof; refused ones are counted apart. Checked on the real chain: the scan found the one real Discord subscription so far. - What it proves and what it does not. An order QMax carried is known to have come through QMax (it saw it go by). The older report path is weaker: it proves that wallet made that trade, not that it went through QMax (anyone can name a real recent trade of any wallet), and no on-chain marker exists (there is no per-trade fee to carry one), so the order count is the number of orders QMax sent plus the ones it was told about. A resting order is counted once, when it is placed; its later fill is not counted again (the fill is the contract's, not a transaction the wallet sent). A sell that rests carries nothing to refund, so a sell the contract refused after it was included (rare, since the app checks holdings first) would be counted as placed. A person who turns the privacy switch off, or
/privacy count:falseon Discord, is not counted. Treat it as measured usage, not as proof for paying anyone. - Where each kind of order is counted. The website, as
web(with the partnerrefof the link). The Discord bot, asdiscord: it sends each order through QMax with its own key, reports confirmed orders, and every user can switch it off with/privacy count:false(saved per user, applied by wallet; the order preview says orders are counted). An agent usingagentTradein the SDK, asagent: through QMax by default,reportTo: falseto send straight to the network. Discord subscriptions, passes and API top-ups are counted from the chain separately (see below). - Privacy. The report carries the wallet address and a transaction id, both public on the network. The review dialogs say so in one line, and Settings has a switch (Privacy: let QMax count my orders) that stops all reporting and sends orders straight to the network.
TRADE_USAGE=offon the server stops the counting there, andORDER_RELAY=offstops it carrying orders (they then go to the network directly and are counted from the reports alone). - Check it yourself:
npm run usage-checkverifies real recent trades against the archive, shows a stranger's wallet and a late report being refused, and runs the payment scan on real QPayhub history.
Who is selling an asset (the Sellers tab)
Under each asset's trade section there is a Sellers tab (web/SellersView.tsx, GET /v1/sellers?asset=ID, src/sellers.ts): who has sold the asset on QX or QSwap in the last 7 days, how long they had held it, what they paid and whether they sold above or below that. It is read from the public archive. The archive's share-movement events can be filtered by asset, so one query pulls a week of one asset (a few hundred events, 689 for QDOGE) with who sent what to whom. The biggest senders (12 by default; about 58% of the units sent, for QDOGE with 8) each have their trade ledger read (the same ledger the Portfolio uses, a year back), and each sale is matched first in, first out to the purchase or transfer it came from. That gives, per sale, how long the unit had been held (bought in the last 7 days, 8 to 30 days, 1 to 3 months, 3 to 12 months, over a year ago), what it cost (fees included) and the result, and which units were received by transfer (a distribution, an airdrop, a move from another wallet: no cost on record). Units sent on to another wallet without a sale are shown as moved, not sold; a seller who received about as many units in the same week as it sold is flagged as a round trip (a bot, a market maker or units passed on); one that sold nearly everything it held is flagged as sold out.
The page says what the numbers are in plain sentences ("what the numbers say") and never claims a motive: the chain shows who sold and what they paid, not why. A reading takes a minute or two (about 20 archive requests per wallet, paced to 2 a second), so the first request for an asset answers status: "working" with its progress and the figures known so far, the page asks again every 4 seconds, and a finished reading is kept six hours. To keep the archive free for everything else: one reading at a time, at most 12 new readings an hour, a wallet that cannot be read is skipped (an error only if none can be), and the five busiest assets are read ahead every six hours (SELLERS_WARM=off stops that, SELLERS=off stops the feature). Assistants have it as the qmax_get_sellers tool and QMaxClient.sellers.
Token pages (a page for every asset)
GET /token/<id> (src/tokenpages.ts) is a plain-HTML page for every asset in the list, from one template and the live data the API already serves: the price (and its value in dollars), changes and volume over 24 hours and 7 days, the QX best bid and ask and the QSwap pool, and what selling and buying three sizes (worth about 10 million, 100 million and 1 billion QU at the current price) would fetch, priced by the router across both markets from one reading of them (buildQuotes). GET /token/ lists them, busiest first. The pages have no script and are served with their own strict policy, so a search engine or an AI assistant reads them as they are. Pricing the sizes reads the markets, so it is done once per asset for ten minutes (and once at a time), whatever the number of visits; a crawler walking all the pages cannot make the API read the node for each.
What search engines are offered. Only an asset that traded in the last seven days has index and a place in GET /sitemap-tokens.xml (with lastmod the date of its last trade); an idle one still has its page for people, marked noindex, so a long tail of idle tokens does not fill the index with thin pages. It moves in or out by itself as trading starts and stops. Smart-contract shares are judged by the same rule. Two assets that share a symbol get separate pages, named by id (TWIN and TWIN.BBBBB). Another spelling of an address (case, a trailing slash) goes to the one address by a 301; an unknown asset is a 404; while the first scan of the network is still running the pages answer 503 with Retry-After, never a 404, so a crawler comes back instead of learning that a page does not exist. TOKEN_PAGES=off stops serving them.
nginx hands /token/, /token and /sitemap-tokens.xml to the API (deploy/nginx-tokens.sh patches the live file once; deploy/nginx-qmax.conf has the same locations for a fresh install). robots.txt names the second sitemap.
Subscribers and profit sharing
Subscribers are tracked from the chain, by wallet. A Discord subscription is a QPayhub payment to QMax's address that the subscriber's own wallet signs; the receipt's resource id carries the Discord user id (QMAXSUB). So the payer wallet is on-chain, and the same wallet is recognised on the website with nothing to link or log in to: GET /v1/membership?wallet=ID (public, any wallet, its own rate limit) says whether the wallet is an active member (a subscription lasts BOT_SUB_DAYS from each payment, stacking, like the bot; a pass lasts 24 hours), until when, and what it has earned (which Discord accounts it paid for is for the owner only: GET /v1/subscribers). A member who has just paid is seen at once: the lookup reads that wallet's own payments instead of waiting for the five-minute scan. Trading is free for everyone now, so membership unlocks nothing on the website; the wallet menu shows it (and the profit share) for the connected wallet. It must be the wallet that paid: a subscription paid from another wallet belongs to that wallet.
- What is shared. Each calendar month (UTC) is a period. The pool is
PROFIT_SHARE_PCT(75) % of the month's net income: what QPayhub forwarded to QMax's address. With everything else free that is the Discord subscriptions (PROFIT_SHARE_INCOMEcan add passes, API top-ups or x402 sessions if those modes are switched on; anything else sent to the address is not counted). It is split among subscribers in proportion to what each paid in the month (net of QPayhub's cut), rounded down in exact integer arithmetic, and no wallet earns more thanPROFIT_SHARE_CAP(1) x what it paid. The cap is what keeps it honest: shares follow a wallet's own payments, so splitting one subscription across many wallets gains nothing, and nobody is ever paid back more than they paid. What the cap and rounding hold back stays with QMax. QMax's own address andPROFIT_SHARE_EXCLUDEwallets (your test wallets) are left out entirely. The programme counts fromPROFIT_SHARE_START(the month it was first switched on); a month is owed only once it has ended and the payment scan has read past its end. - Read it (the owner's key,
x-api-key: $ADMIN_KEY, or$API_KEYwhen there is no separate one):GET /v1/subscribers(everyone who ever subscribed: Discord ids, active until, paid, earned, paid out, owed),GET /v1/profit-share?period=2026-10(income by kind, the pool, each wallet's weight, share, cap and earnings, and warnings),GET /v1/profit-share/balances. With the real chain so far: one Discord subscriber (1,810,000 QU, QPayhub forwarded 1,796,425), so October's pool is 1,347,318 QU and that wallet's estimated share is all of it (under its cap of 1,810,000). If that is your own test wallet, put it inPROFIT_SHARE_EXCLUDE. - Paying out: you sign, QMax never holds a key.
npm run payoutshows what is owed and the transactions that would pay it, and signs nothing.npm run payout -- --sendasks for the seed of QMax's address (typed hidden, so it is not in your shell history or the process list; never in a file or chat;QMAX_PAYOUT_SEEDalso works, set withread -s), checks the plan, refuses a seed that is not QMax's address or a wallet that cannot cover it, asks you to type the total, then signs and sends each batch. A batch is one QUtilSendToManyV1transaction (contract 4, procedure 1) paying up to 25 wallets; the QU attached must equal the amounts plus QUtil's fee exactly (10 QU, read from the contract each time, burned) or QUtil refunds everything. The signer re-checks the bytes against the listed wallets and amounts before signing (checkBatchView), and adds up the totals it shows, funds and asks you to confirm from the batches themselves, not from the plan's own summary; it also refuses a plan that pays a wallet in two batches, pays QMax's own address, or pays a wallet more than it is owed (checkPlan). The ledger (.cache/profitshare.json, with the previous save kept as.bak) is written to disk the moment a batch is reserved or its transaction reported; if it exists but cannot be read, profit share switches itself off with a message rather than starting empty, which would plan everyone's past payouts again. A sent batch is given up on only after the archive itself has processed an hour past the signing without showing it (an archive that is behind, or errors, never releases the wallets), and a transaction that shows up later is still credited. Balances underPROFIT_SHARE_MIN_PAYOUT_QUwait for the next payout. - The chain decides what was paid. A batch moves prepared (a plan: asking again replaces it harmlessly), sent (being signed, or a transaction found: its wallets are reserved so they cannot be planned again) and verified only when the owner's own transaction to QUtil, with exactly that payload and amount, is in and QUtil's transfer events show each wallet received exactly its line. QMax also searches the owner's own send-to-many transactions for each batch's bytes, so a batch signed without telling QMax, or signed late after the plan was dropped, is still found and credited; a batch signed twice is flagged and both payments counted so the books match the chain; a refunded transaction (wrong fee) credits nothing and frees its wallets. The same lines owed again next month are a new batch, matched to their own transaction by time.
- Limits, honestly. No real payout has been signed yet: everything here was tested against fake chains and read-only against the real chain (a real QUtil send-to-many was inspected to confirm the layout and the transfer events; the live fee read is 10 QU). The membership the website sees is chain-derived and can differ by seconds from the bot's own record (the bot extends from when it confirms). Discord ids and wallets are public on-chain (a payment's resource id carries the Discord id), but the public membership lookup does not hand them out. Sharing fee income with paying customers can have securities or tax consequences depending on where you and they are; check before announcing it as "profit sharing" rather than a fee rebate.
Adding an endpoint
Features bring their own endpoints without editing the server: src/routes.ts defines Route (method, path, limited, doc, handler), createApi takes routes: Route[], serves them beside the built-in ones (rate limited like the other market endpoints and sold by x402 session like them unless limited: false), and merges each doc into /v1/openapi.json. A handler returns JSON, or new Raw(body, contentType, filename) for a download, and throws RouteError(status, message) for a refused request. Web feature styles go in web/styles/*.css (loaded automatically).
Being found by agents (and search)
The MCP server and the SDK are hosted on the site under /agents/ (a guide page, qmax-mcp.mjs, qmax-sdk.tgz, qmax-sdk.js and SHA256SUMS). npm run agents:build (scripts/build-agents.mjs) builds them into web/public/agents/ (the MCP file with the live API as its default address; the SDK packed, its type declaration for the planner cut down to the plan's shape; the checksums), and the deploy runs it before building the site. /llms.txt and the sitemap link to them.
Verifying the downloads
SHA256SUMS (the hash of each file under /agents/) is signed with the maintainer's own minisign key, kept on the maintainer's computer and never on the server. The public key:
RWRMUiy37f8/2tR95zGd1GrgIuOk9ichS/Ohe99rlJrq7Xtd3d+Dfu3c
curl -O https://qmax.exchange/agents/SHA256SUMS
curl -O https://qmax.exchange/agents/SHA256SUMS.minisig
minisign -V -P RWRMUiy37f8/2tR95zGd1GrgIuOk9ichS/Ohe99rlJrq7Xtd3d+Dfu3c -m SHA256SUMS # the list came from the key's owner
shasum -a 256 -c SHA256SUMS # each file matches the list
This README is the copy of the key to trust: the website is what is being checked, so do not take the key from the site alone. scripts/sign-release.sh makes the signature (the deploy runs it)
web/index.html carries the title, description, canonical address, Open Graph and Twitter cards (the 1200x630 web/public/brand/og.png), JSON-LD (WebSite, WebApplication, WebAPI), <link>s to /llms.txt and the OpenAPI document, and a <noscript> summary for crawlers that do not run JavaScript (the app itself is client-rendered, so without it a crawler would see an empty page). web/public/robots.txt welcomes crawlers (and keeps them out of the API's endpoints, but not its OpenAPI document), sitemap.xml lists the one page. /llms.txt (src/llms.ts, passed through by nginx) is the Markdown guide an agent reads first: what QMax is, the API's address and main endpoints, what is free, what a Max plan costs and how to pay with x402; it is written from the same settings as the API (PUBLIC_BASE_URL, API_MAX_PRICE_QU, the session settings), so it cannot state a stale price. The OpenAPI document lists the API's public address under servers, so a client builds working URLs (set PUBLIC_BASE_URL=https://qmax.exchange/api; it is also the resource.url an x402 challenge names).
For agents: self-signed trades (and optional x402 sessions)
Agents use the free API like anyone else, except that a Max plan costs 100 QU (GET /v1/max, QMaxClient.max(), the MCP tool qmax_best_position): from a prepaid key's balance, or free inside an x402 session, which API_MAX_PRICE_QU switches on for the meter and x402 gate even though everything else stays free. Sessions by x402 for the rest of the API exist only with API_ACCESS=billing; the part about agents signing their own trades applies as it is.
QMax follows the same x402 wire format as Q+Pay (qubic-x402: scheme exact, network qubic:mainnet, asset QUBIC, settlement through QPayhub), so an agent that already pays Q+Pay resources can pay QMax the same way, with no account and no key.
- Why a session, not a payment per call. A split quote costs 100 QU and an arbitrage result 50 QU, which is below what QPayhub accepts (100 QU minimum payment, and a 100 QU minimum fee, so the fee dominates under about 13,334 QU), and a payment takes about ten seconds to confirm. So an agent buys a session once:
API_SESSION_PRICE_QU(default 10,000 QU) forAPI_SESSION_SECONDS(default 3,600). Inside it there is no per-call charge, and the rate limit is the keyed one (ten times the free limit), counted for the session rather than the address.API_X402=offturns it off; it also needs the meter on. - The loop. An agent that goes over the free limit (or calls
GET /v1/session) gets a402with the price, the QPayhub address, the seller, the resource and a ticket (GET /v1/x402describes the offer without a 402). It pays withQPAYHUB.Pay(the nonce comes from the ticket, the amount must match exactly), then repeats the request withX-PAYMENT. The server finds the payment in QPayhub's receipt, checks payer, seller, resource, nonce, amount and age, counts each payment once (.cache/x402-used.json), and answers withX-ACCESS-GRANT, a signed token for the rest of the session. Tickets and grants are HMAC-signed with a secret in.cache/x402-secrets.json(mode 0600), orX402_TICKET_SECRETandX402_GRANT_SECRETif you set them (one serves for both; use two different ones in a real deployment, since a ticket secret that leaks lets someone mint tickets). If the transaction is not confirmed yet, the reason isinvalid_transaction_state: wait and send the same header again. - Agents sign their own trades. QMax never holds an agent's key and never trades for it.
@qmax/sdk/agentquotes, checks the quote against limits the agent set, signs with the agent's own seed and sends each step.maxOutlayQuis required (the most QU that may leave the wallet, fees included); a sale also needsminAveragePriceQu, because a sale's outlay is only fees;maxAveragePriceQuandallowedAssetsare optional. The quote's hints are treated as untrusted: the plan may only call QX and QSwap (and a share-management move between them), and the x402 payer can only pay QPayhub, whatever a 402 says. If anything is outside the limits, nothing is signed andTradeRefusedlists why. Steps are sent one at a time and stop at the first failure, then the wallet is read back to report what happened.agentPlanis the dry run: the same quote, the same checks against the limits and the same steps, with nothing signed or sent (it reads the wallet's public balance, so it also says whether the wallet could make the trade). Pass oneTradeBudget(new TradeBudget(totalQu)) to everyagentTradecall to cap what all of an agent's trades together may spend, however many run at once: each holds its worst-case outlay when it passes the checks and keeps it once handed to the wallet. - People are unaffected. The web app (wallet-signed, QMax pass), the Discord bot (subscription) and prepaid keys work as before. x402 only adds sessions, and turns the answer to a free-limit overrun from
429into a402an agent can act on. - Try it.
npm run agent-demoprints the offer and spends nothing (setQMAX_API=https://qmax.exchange/apito point it at the live site; the default is a local server). WithQMAX_AGENT_SEEDset in your own terminal,--max QDOGE --yesruns the whole agent flow in one go: it asks for a Max plan, is answered402, pays for a session once through QPayhub (10,000 QU), gets the plan, and asks again inside the session without paying again.--session --yesonly buys a session (it is forgotten when the run ends), and--buy CFB 10 --max-outlay 5000 --yesmakes a guarded trade. The spending line counts a payment from the moment it is sent, so one that has not confirmed yet still counts: check it on the explorer. Use a wallet that holds only what you are happy to test with, and never paste a seed into a chat or a file. - Limits, honestly. Payment freshness is judged in ticks (0.55 s each, as measured on mainnet in October 2026; change
TICK_SECONDSinsrc/x402.tsif the network speed changes). Receipts are kept by QPayhub for about two epochs, and the local used-payment ledger is the replay guard beyond that. QMax does not callConsume, and does not use the on-chain USD price oracle (session prices are fixed in QU, like the web pass). Not yet done: an MCP server wrapper, and delegating verification to a Q+Pay facilitator.
For other sites: SDK and links
Two ways in, both described in sdk/README.md:
- SDK (
sdk/, build withnpm run sdk:build, runs in Node or a browser):QMaxClientwraps the API (quote, arbitrage with filters, key creation, top-up and claim) with typed answers and aQMaxErrorthat says what a refused call would have gained;buildExecutionPlanturns a quote into the transactions a wallet signs (rights move first when selling, then one call per venue), so a site that already has its own wallet flow only has to sign them. - Prefilled link:
?asset=CFB&side=buy&qty=5000&ref=partneropens QMax's trade screen with the order filled in (buildDeepLink/parseDeepLinkinsrc/deeplink.ts). Bad parts of a link are dropped; an unknown asset shows a message. Arefis counted byPOST /v1/ref(an open, and a finished trade with its transaction ids) and read back withGET /v1/refsusing QMax's own key (.cache/refs.json). The reports come from users' browsers, so they show which partners send traffic but prove nothing: check the transaction ids on-chain before paying anyone a share.
Security
A review of the whole project (web app, API, bot, SDK/MCP, payouts) was done in October 2026; what it found and what was done is in .cache/security-review/TRACKER.md. What to know when running it:
- Keys and secrets.
.envand everything in.cacheare owner-only (mode 600/700; the code writes its own files that way and tightens older ones on the next save). Use long random values:openssl rand -hex 24forAPI_KEYand forADMIN_KEY. The bot getsAPI_KEYonly;ADMIN_KEY(subscribers, profit sharing, payouts, stats) stays on the server and in your shell fornpm run payout. The payout seed is typed hidden when you runnpm run payout -- --send; nothing in QMax stores a seed. - The API is public by default, so it limits per address (
API_FREE_PER_MIN), caps its queue to the Qubic node (a busy node gets a 503 andRetry-After, not a pile of waiting requests), and answers hostile input with errors rather than crashes. Behind your own proxy setTRUST_PROXY=1(the number of proxies you run); without it, forwarded-address headers are ignored. It listens on this machine only (127.0.0.1);HOST=0.0.0.0opens it to the network, which belongs behind your own proxy. - The website does not trust the API with your money. Before it signs, it checks the quote's transactions against what you asked for (same asset, issuer, side and amount; only QX and QSwap; limits no looser than your slippage; the contracts' real fees), and the review dialog states the most that can leave your wallet. A strict content security policy and security headers are set (
vite.config.ts,web/public/_headers). - Agents and the bot run the same plan check (
prepareTrade), and agent trades can share aTradeBudget. The bot limits what one Discord user can do per minute, never pings anyone, and removes pairing links from its logs. - Asset names from the chain are listed only if they are plain letters and digits (up to seven), so a hostile issuer cannot put markup or look-alikes into the site, the bot or the API.
- Not covered: a wallet you sign with is yours to protect; QMax does not verify the on-chain price of a token for you (a thin book can be moved: the health grade and the review dialog say what they can).
npm auditreports moderate issues in a dependency of the MetaMask snap library that this app does not reach.
الأسئلة الشائعة
ما هو io.github.profitphil/qmax؟
Qubic DEX aggregator: best-price quotes across QX and QSwap, order books, candles, trade plans.
كيف أثبّت io.github.profitphil/qmax؟
يعمل كخادم بعيد. في Claude Code: claude mcp add --transport http qmax 'https://qmax.exchange/api/mcp'. وفي العملاء الآخرين، الصق إعداد JSON من هذه الصفحة في إعدادات MCP الخاصة بهم.
هل يحتاج io.github.profitphil/qmax إلى مفتاح API؟
لا يذكر إدراجه في السجل أي مفتاح أو سر إلزامي. قد تتطلب بعض الميزات حسابًا لدى الخدمة التي يتصل بها.
هل io.github.profitphil/qmax مفتوح المصدر؟
شيفرته علنية على GitHub، لكن المستودع لا يعلن ترخيصًا قياسيًا.
من يصون io.github.profitphil/qmax؟
ينشره profitphil. يدرجه AI.tn لكنه لا يصونه ولا يوصي به.
من سجل MCP الرسمي. الإدراج ليس توصية: راجع شيفرة الخادم وصلاحياته قبل منحه الوصول إلى بياناتك.