# FTW Swap > FTW Swap is a constant-product AMM on Neo N3. Every pool pairs a NEP-17 token with GAS. Traders pay a 0.25% fee to the liquidity providers. Liquidity positions are NEP-11 tokens (symbol `FTW-LP`). This file is for developers, trading bots and AI agents; the same text is at `/llms.txt` and `/developers` on the web app. ## Contracts and networks | Network | FTW Swap | Network magic | Public RPC | Web app | |---|---|---|---|---| | MainNet | `0xe616c88476203581c9766e0f78278fa6de4d0fc5` | 860833102 | https://mainnet1.neo.coz.io:443 | https://swap.forthewin.network | | TestNet | `0xe7786eec28e6cb651cd47feee8a7d34010160337` | 894710606 | https://testnet1.neo.coz.io:443 | https://swap.testnet.forthewin.network | - GAS: `0xd2a4cff31913016155e38e474a2c06d08be276cf` (8 decimals). Every pool is token/GAS. - Contract manifest name: `FTW Swap`. The owner is a multisig; code updates are timelocked (see Governance). - Optional history API (indexer): https://n3-api.forthewin.network (MainNet), https://n3-api-testnet.forthewin.network (TestNet). - FTW fun launchpad: the launchpad FTW Swap trusts to open pools at graduation is `getLaunchpad()` (TestNet: `0x3f6c38c88f98f4ff25075d79ab23e104f109a5e0`; MainNet: not deployed yet). A token it launched can open its FTW Swap pool at graduation (`getCurve(token)[10] == 2`); the web app marks it with an "FTW fun graduated" badge. Graduation is not verification. SDK: `LaunchpadReader.getGraduatedTokens()`. ## Units - Amounts are integers in base units: 1 GAS = 100000000. Read a token's `decimals()` before converting. - `deadlineMs` and `lockUntilMs` are Unix time in **milliseconds**, compared with the block time. - `slippageBps` (addLiquidity only) is in basis points: 100 = 1%. Allowed: 1..4999. - Fee: `amountIn / 400` (0.25%, integer division), paid by the trader and kept in the pool for the LPs. ## Methods ### Trading The `account` argument must sign the transaction (see "Witness scope"). `swap`, `swapB` and `addLiquidity` fail while the market is stopped; `removeLiquidity` always works. - `swap(account, tokenIn, amountIn, tokenOut, amountOutMin, deadlineMs)` — sell exactly `amountIn`. Fails unless you receive at least `amountOutMin` (which must be > 0). - `swapB(account, tokenIn, tokenOut, amountOut, amountInMax, deadlineMs)` — buy exactly `amountOut`. Fails if it would cost more than `amountInMax` (which must be > 0). - `addLiquidity(account, tokenA, amountA, tokenB, amountB, deadlineMs, lockUntilMs, slippageBps)` — one of the two tokens must be GAS; the order is yours. For a **new pool** both amounts are taken as given and set the starting price. For an **existing pool** the first amount (`amountA` of the first token you pass) is exact, and the second is taken at the pool ratio: `amountA * reserveOfB / reserveOfA`. The call fails unless that is within `slippageBps` of your `amountB`. Mints one LP NFT to `account`. `lockUntilMs` = 0 means no lock; a locked position cannot be removed until the block time passes it. - `removeLiquidity(account, tokenId, deadlineMs)` — burns the LP NFT and pays out both sides pro rata. - NEP-11 `transfer(to, tokenId, data)` moves an LP position to another account. ### Reads (safe, free with `invokefunction`) - `getPairs()` — every pool: `tokenA, tokenB, amountA, amountB, totalShare, tokenASymbol, tokenBSymbol, tokenADecimals, tokenBDecimals`. - `getReserve(tokenA, tokenB)` — one pool, in the order you ask; zeros if it does not exist. - `getSwapEstimate(tokenA, tokenB, tokenIn, amountIn)` → `{estimated, fee, decimals}` — exact-in quote (`tokenA`, `tokenB` name the pool). - `getSwapBEstimate(tokenIn, tokenOut, amountOut)` → `{estimated, fee, decimals}` — exact-out cost. - `getLPTokensByUser(account)`, `properties(tokenId)` — LP positions with `amount`, `amountA`, `amountB` (current value), `sharesPercentage` (bps), `lock`, `createdAt`, symbols and decimals. - `getLP(tokenA, tokenB, n)`, `getLPs(tokenA, tokenB, perPage, page)` — a pool's LP history. - `getContractOwner()`, `getMarketStatus()`, `getUpdateDelay()`, `getPendingUpdate()`, `getTotalLiquidityHeight()`, `getLiquidityHeight(pairKey)`, `getPariKey(tokenA, tokenB)`, `searchTokensOf(owner)`. - `getLaunchpad()`, `isTokenAllowed(token)`, `getAllowedTokens()` (the pool-creation allowlist, see Listing). - NEP-11: `symbol` (`FTW-LP`), `decimals` (0), `totalSupply`, `balanceOf`, `ownerOf`, `tokens`, `tokensOf`. ### Owner only `stopPlatform`, `activePlatform`, `changeOwnership(newOwner)`, `proposeUpdate(nefFile, manifest)`, `cancelUpdate`, `update(nefFile, manifest, data)`, `setUpdateDelay(delayMs)`, `setLaunchpad(launchpad)`, `allowToken(token, allowed)` (see Listing). ### Events | Event | Parameters | |---|---| | `Swap` | account, tokenIn, tokenOut, amountIn, amountOut, fee | | `AddLiquidity` | account, tokenA, tokenB, amountA, amountB (pool order) | | `RemoveLiquidity` | account, tokenA, tokenB, amountA, amountB (pool order) | | `NewPoolCreated` | account, tokenA, tokenASymbol, tokenADecimals, tokenB, tokenBSymbol, tokenBDecimals | | `Transfer` | from, to, amount, tokenId (NEP-11; from is null on mint, to is null on burn) | | `Fault` | message, data (emitted just before a call fails) | | `MarketStatusChanged` | active | | `OwnerChanged` | oldOwner, newOwner | | `UpdateProposed` | codeHash, executableAt, expiresAt, executableAtBlock, expiresAtBlock | | `UpdateCancelled` | — | | `UpdateDelayChanged` | delayMs | | `LaunchpadChanged` | launchpad | | `TokenAllowed` | token, allowed | ## Pair order and LP ids A pool's `tokenA` is the token whose script hash, read as a little-endian signed integer, is larger. (The displayed big-endian hex is that integer's magnitude, with the sign bit in its first byte.) LP ids are `symbolA-symbolB-n`, for example `FTWT-GAS-1`, where `n` counts every LP position ever minted. An id longer than 43 bytes cannot be minted, so a token with a very long symbol cannot be pooled. Tokens with 0 decimals (such as NEO) cannot be pooled. ## Listing `addLiquidity` opens a brand-new token/GAS pool (spec §2.6) only when the non-GAS token is either: - on the owner's allowlist (`isTokenAllowed(token)` is true — owner call `allowToken(token, true)`), or - being paired by a call from the registered FTW fun launchpad (`getLaunchpad()`), matched on the calling contract so only a real graduation qualifies, not just any transaction that names the graduating user. Otherwise it fails with `Token is not allowed.` (SDK `TOKEN_NOT_ALLOWED`). This only gates a token's *first* pool: adding to an existing pool, or re-seeding one that has been emptied to 0/0, works for anyone regardless of the allowlist, and un-allowing a token later never closes its pool. `getAllowedTokens()` lists every owner-allowed token; `getListing()` (SDK) reads the launchpad and the allowlist together. `setLaunchpad` and `allowToken` both take effect immediately — unlike `update`, neither goes through the timelock (24 h by default, up to 30 days). Clearing the launchpad (`setLaunchpad` with the zero hash) or pointing it at a different contract stops future graduations the same way `stopPlatform` stops future swaps: any pool a token already has is unaffected, and `removeLiquidity` is never gated by the allowlist or the launchpad, so LPs can always exit. Do not allow a token that pays GAS to its holders on transfer (NEO-style, N3's own GAS rebate is the classic example): FTW Swap's pool receipt check (`pool balance invalid.`, below) reads its own balance change on every transfer it receives, so a token whose transfer routes GAS (or any other asset) to the trader alongside itself will make that check fail and reject the trade. **Launchpad trust.** Registering a launchpad hands it a standing exception to the allowlist: whoever controls that contract's code (its owner, if it has one) can open a new FTW Swap pool for *any* token by calling `addLiquidity` from it, bypassing `allowToken` entirely. Register only a launchpad whose code you trust and have reviewed — the same trust assumption FTW Swap's own owner multisig carries (see Cautions). **Decimals rule.** The owner only allows tokens with 6 or more decimals (8 recommended). A token with fewer decimals makes the add-liquidity slippage check too coarse to catch a moved price (final review FR-L1), and lets swaps under 400 base units pay no fee (FR-I1, see Known rounding and read caveats below). ## Math Quotes are integer math; every division truncates. The SDK reproduces the contract exactly (golden vectors in the repository's `tests/vectors/`). - Exact in: `fee = amountIn / 400; amountOut = reserveOut - (reserveIn * reserveOut / (reserveIn + amountIn - fee) + 1)` - Exact out: `k = reserveIn * reserveOut; newReserveOut = reserveOut - amountOut; pre = ceil(k / newReserveOut) - reserveIn; fee = pre / 399; amountIn = pre + fee`. Rounds the input up (fixed 2026-09-27, audit A-1): `amountIn` is asserted `> 0` (`amountOut is too small.`), so a swapB that would otherwise cost 0 is rejected instead of draining the pool. - New pool: `totalShare = floor(sqrt(amountA * amountB))`. 1000 of those shares stay in the pool itself, and the first LP's NFT gets `totalShare - 1000` instead of the full amount. This is what stops a tiny first deposit from letting its creator steal the next depositor's share (re-audit N-1). `totalShare` has to end up over 1000, or the call fails with `Initial liquidity is too small.`. SDK: `newPoolShare(amountToken, amountGas)` returns what the first LP actually gets (0 if it would be rejected). - Add to a pool: `amountSecond = amountFirst * reserveSecond / reserveFirst; share = min(amountFirst * totalShare / reserveFirst, amountSecond * totalShare / reserveSecond)` — the smaller of what either side's amount is worth, on the amounts actually charged (Uniswap V2 rule; final review F-2). A deposit that rounds down to 0 shares fails with `Liquidity share is too small.` instead of taking your tokens for nothing. A deposit that rounds down to a *few* shares still succeeds but can lose real value — check the SDK's `addLossBps(reserve, totalShare, amount, share)` (rounding loss in bps, on the side you typed and pay exactly — final review F-1) before sending when the deposit is small relative to the pool. - **`amountSecond` must be at least 100 base units.** The contract rounds its own estimate of the second amount down, then checks it against your `amountSecond` give or take `floor(amountSecond * slippageBps / 10000)` (which is 0 all the way up to 199 at the 50 bps default). Below 100 base units, the contract's rounding alone lets the pool price move by more than 1% (up to 1/amountSecond, almost 2× at 1) without the slippage check noticing; a sandwich can take that difference (final review FR-L1; a real repro on a 1-decimal token quoted a 0.5% loss but executed a 29% one). Low-decimal tokens are the ones at risk; an 8-decimal pool's `amountSecond` clears 100 base units in ordinary use. Check with the SDK's `checkSecondAmount(amountSecond)` (throws a `RangeError` with `MIN_SECOND_AMOUNT_MESSAGE`) or the `MIN_SECOND_AMOUNT` constant before sending; the web app and MCP server both refuse these deposits before signing, with that same message. - Remove: each side pays `reserve * lpAmount / totalShare` ## Known rounding and read caveats - Swaps of 399 base units or fewer pay no fee (`fee = amountIn / 400` on `swap`, `pre / 399` on `swapB`, both truncate to 0 below that). The pool still never loses value from this — the output/input rounding in the formulas above always favors the pool — but it is worth knowing before reading a small trade's fee as "free" for any other reason (final review FR-I1/FR-4). This is another reason low-decimal tokens are off the allowlist: on an 8-decimal token 399 base units is dust, but on a 1-decimal token it's tens of whole tokens. - `AddLiquidity` does not carry the minted share (final review FR-5). To recompute it from events alone, apply the same rules the contract does. The first deposit into a pool (the one that also emits `NewPool`): the pool's `totalShare` starts at `isqrt(amountA * amountB)` and the first LP gets `isqrt(amountA * amountB) - 1000`. Every deposit after that: `min(amountA * totalShare / reserveA, amountB * totalShare / reserveB)`, on reserves and `totalShare` from just before the call. Or read the NEP-11 `Transfer(null, account, 1, tokenId)` that mints the LP position and look up its `amount`: `properties(tokenId)` only works while the position exists (it fails once a removal burns the LP) and it calls both tokens' `decimals()`, so a faulting token breaks it too. The pool's LP history, `getLP(tokenA, tokenB, n)` / `getLPs(tokenA, tokenB, perPage, page)`, has neither problem: it keeps every minted position (burned ones with `isActive: false`), with its `amount`, and never calls the token. - `getPairs()` and `getLPTokensByUser()` call every listed token's `symbol()`/`decimals()` to fill in display fields. If one allowed token later faults on those calls (its own contract was updated or destroyed), the whole call fails, not just that token's row (final review FR-I4). Reading item by item doesn't get around the token: `getReserve(tokenA, tokenB)` calls both tokens' `symbol()` and `decimals()`, `properties(tokenId)` calls their `decimals()`, and `getSwapEstimate`/`getSwapBEstimate` call `decimals()`. So per-item reads only narrow the failure down to that token's own rows. For the faulting token itself, `getLP`/`getLPs` (the LP history) and NEP-11 `tokensOf` still work; its reserves need the indexer or a raw `getstorage` read (key `0x06` + `getPariKey(tokenA, tokenB)`, a serialized struct). ## Witness scope FTW Swap takes your tokens by calling `token.transfer(account, FTW Swap, amount)`. The token checks your witness while FTW Swap is the calling contract, so **a `CalledByEntry` signature is not enough**. Use only scope `WitnessRules` (64) with the rule `Allow Or(CalledByEntry, CalledByContract())`. **Never `CustomContracts` or `Global`:** a malicious token in the trade could then move your other GAS and LP NFTs. `CustomContracts` must list every token the call moves so that it can call `token.transfer`, and that same permission lets a malicious token's `transfer` implementation call `GAS.transfer` or the FTW Swap NEP-11 `transfer` while it is the calling contract, moving assets that have nothing to do with the trade (audit A-2). NEP-11 LP `transfer` needs only `CalledByEntry`. ## Errors A failing call FAULTs. The VM message looks like `ABORTMSG is executed. Reason: ` or `unhandled exception: ""`. The SDK's `parseFault` maps it: | Message contains | SDK code | Meaning | |---|---|---| | `Market is not active` | `MARKET_STOPPED` | The market is stopped: swaps and adds fail, removals work | | `Estimated amount is less than slippage` | `SLIPPAGE` | `swap` would pay less than `amountOutMin` | | `Estimated token A amount is higher than maxTokenAAmount` | `SLIPPAGE` | `swapB` would cost more than `amountInMax` | | `Estimate token B amount is lower than slippage set`, `… higher than slippage set` | `SLIPPAGE` | The pool ratio is outside `slippageBps` of your second amount | | `Exceeded the deadline` | `DEADLINE` | The block time is past `deadlineMs` | | `This LP token is locked until ` | `LP_LOCKED` | The position is still locked | | `Pair must include GAS` | `PAIR_NEEDS_GAS` | Pools are token/GAS only | | `Reentrancy Not Allowed` | `REENTRANCY` | FTW Swap was called again from inside one of its own calls | | `Pair doesn't exist` | `PAIR_NOT_FOUND` | No pool for the pair | | `Pool doesn't have enough fund to swap` | `INSUFFICIENT_LIQUIDITY` | The exact-out amount is at or above the pool's own-side reserve | | `decimal should be higher than 0` | `ZERO_DECIMALS` | 0-decimal tokens cannot be pooled | | `You are not owner` | `NOT_AUTHORIZED` | `account` did not sign, or does not own the LP position | | `Transfer Fail`, `balance invalid` | `TRANSFER_FAILED` | A token transfer failed, or moved a different amount than requested. `token in balance invalid.` / `token out balance invalid.` are the trader's own balance not moving by the recorded amount; `pool balance invalid.` is FTW Swap's own balance not growing by the recorded amount (swap, swapB and addLiquidity all check this on every leg they receive) — see Cautions | | `Cannot find LP token with given token Id` | `LP_NOT_FOUND` | The LP position no longer exists; it may already be removed | | `is out of range` | `BAD_AMOUNT` | An amount (`amountA`, `amountB`, `amountIn`, `desiredAmount`, …) is too small or invalid | | `amountOut is too small` | `BAD_AMOUNT` | A `swapB` whose required input would round to 0 is rejected instead of executing for free | | `Initial liquidity is too small` | `BAD_AMOUNT` | A new pool's `isqrt(amountA * amountB)` doesn't clear the 1000 shares every pool keeps (re-audit N-1); add more on both sides | | `Liquidity share is too small` | `BAD_AMOUNT` | An existing-pool deposit rounded down to 0 shares; add more, or check `addLossBps` first | | `Token is not allowed` | `TOKEN_NOT_ALLOWED` | A new pool needs an owner-allowed token or a launchpad graduation (see Listing) | | anything else | `UNKNOWN` | The raw message | ## Cautions - **Never send tokens to FTW Swap with a plain `transfer`.** They join no pool and cannot be recovered. - **FTW Swap is not a price oracle.** Several FTW Swap calls can run in one transaction, so a pool's spot price can be moved and restored inside a single transaction. A read made from a token callback during a trade also sees the reserves from before that trade. - **`account` must not move what it just received, in the same call.** FTW Swap checks that `account` (or the pool, on `addLiquidity`) actually holds the amount it's owed right after each transfer. If `account` is a contract whose `onNEP17Payment` forwards or returns the tokens it just got — a router, an aggregator, or a NEO-style token that pays out GAS on every transfer — that check fails and the call reverts. Trade and add liquidity from a plain wallet, or from a contract that keeps what it receives until the call returns. - **Fee-on-transfer tokens.** The contract's checks are exact-*receipt* checks, not general fee detection, and only cover what each call actually receives: the trader's `amountOut` on `swap`/`swapB` (`token out balance invalid.`) and the pool's own receipt on every leg the pool receives — tokenIn on `swap`/`swapB`, both tokens on `addLiquidity` (`pool balance invalid.`, spec §2.7). A token that shorts one of those receipts is rejected. A token that instead takes its fee from the *sender* when the pool pays out — tokenOut on `swap`/`swapB`, or either leg of `removeLiquidity`, which has none of these checks — is not detected: the recipient still gets exactly what they're owed, so only that token's own pool ends up short. Pools are isolated, so this can only hurt the LPs of that one pool. The allowlist (see Listing) is the main protection here — a pool is only as safe as its token contract. A token whose `transfer` also moves the *other* token of the pair to or from the trader (for example an unusual token that piggy-backs a GAS payment on its own transfer) fails one of the checks above and cannot be traded or pooled. - **Trust assumption.** FTW Swap is upgradeable by its owner multisig after the timelock (24 h by default, up to 30 days). New code can change every rule, including LP locks and graduated liquidity. Watch `UpdateProposed`. The same trust extends to whichever contract is the registered launchpad (see Listing): its code can open a new pool for any token, bypassing the allowlist. - A pool is only as safe as its token contract. Pools are separate: a bad token can only hurt its own pool's LPs. Check a token's hash before trading it; the web app marks tokens that are neither on its verified list nor graduated from the FTW fun launchpad. - Locked liquidity cannot be removed before its lock time, not even by its owner. The fees it earns stay locked with it. ## Several calls in one transaction Sequential FTW Swap calls in one script work: trade then trade, add then remove. A nested call — FTW Swap reached again from inside one of its own calls, for example from a token's `transfer` — fails with `Reentrancy Not Allowed`. Route X → GAS → Y in one transaction. The second leg spends only the GAS the first leg guarantees; any extra GAS stays in your wallet: ```ts const px = (await reader.getPool(X))!; const py = (await reader.getPool(Y))!; const gasOut = quoteExactIn(px.reserveToken, px.reserveGas, amountX).amountOut; const gasMin = minOut(gasOut, 50); // 0.5% slippage const yOut = quoteExactIn(py.reserveGas, py.reserveToken, gasMin).amountOut; const ctx = { swapHash, account: account.scriptHash }; const legs = [ swapExactIn(ctx, { tokenIn: X, tokenOut: GAS_HASH, amountIn: amountX, amountOutMin: gasMin }), swapExactIn(ctx, { tokenIn: GAS_HASH, tokenOut: Y, amountIn: gasMin, amountOutMin: minOut(yOut, 50) }), ]; const script = toScript(legs); // both calls, in order const signers = toNeonSigners(legs); // then simulate, sign and send as in the SDK example below ``` An arbitrage bot works the same way with its own limits: if any leg would fall short of its minimum, the whole transaction FAULTs and no tokens move. Read before you trade: send the exact script to the RPC method `invokescript` first. It needs no signature and sends nothing; it returns HALT or FAULT, the FAULT message and the GAS the script uses. ## SDK `@forthewin/ftw-swap-sdk` is TypeScript (ESM) for browsers and Node 18+. Its only dependency is `@cityofzion/neon-core`. It lives in the FTW Swap repository (`sdk/`); an npm release will follow. Amounts are `bigint` base units. Quote, build the invocation, simulate, sign and send with neon-js: ```ts import { rpc, tx, u, wallet } from "@cityofzion/neon-core"; import { GAS_HASH, NETWORKS, SwapReader, minOut, parseFault, quoteExactIn, swapExactIn, toNeonSigners, toScript } from "@forthewin/ftw-swap-sdk"; const net = NETWORKS.testnet; const swapHash = net.swapHash!; const reader = new SwapReader({ swapHash, rpcUrl: net.rpc }); const client = new rpc.RPCClient(net.rpc); const account = new wallet.Account(process.env.WIF); // your key; never share it // 1. Quote: 1 GAS → FTWT (the TestNet test token) const FTWT = "0x6c446ac0ef07cfb06a3db2931a91199785d96acf"; const pool = await reader.getPool(FTWT); if (!pool) throw new Error("no pool"); const { amountOut } = quoteExactIn(pool.reserveGas, pool.reserveToken, 100000000n); // 2. Invocation: 0.5% slippage, deadline now + 10 minutes (the default) const inv = swapExactIn({ swapHash, account: account.scriptHash }, { tokenIn: GAS_HASH, tokenOut: FTWT, amountIn: 100000000n, amountOutMin: minOut(amountOut, 50) }); // 3. Simulate, then sign and send const script = toScript(inv); const signers = toNeonSigners(inv); const sim = await client.invokeScript(u.HexString.fromHex(script), signers); if (sim.state !== "HALT") throw new Error(parseFault(sim.exception ?? "").message); const t = new tx.Transaction({ script, signers, validUntilBlock: (await client.getBlockCount()) + 100, systemFee: u.BigInteger.fromNumber(Math.ceil(Number(sim.gasconsumed) * 1.01)) }); t.witnesses = [new tx.Witness({ invocationScript: "", verificationScript: u.HexString.fromBase64(account.contract.script).toBigEndian() })]; t.networkFee = u.BigInteger.fromNumber(await client.calculateNetworkFee(t)); t.witnesses = []; t.sign(account, net.magic); const txid = await client.sendRawTransaction(t); ``` With NeoLine the invocation is exactly the argument of `invoke`: ```ts const neoline = new (window as any).NEOLineN3.Init(); const { address } = await neoline.getAccount(); const inv = swapExactIn({ swapHash, account: wallet.getScriptHashFromAddress(address) }, { tokenIn: GAS_HASH, tokenOut: FTWT, amountIn: 100000000n, amountOutMin: minOut(amountOut, 50) }); const { txid } = await neoline.invoke(inv); ``` Main exports: - Networks and ids: `NETWORKS`, `GAS_HASH`, `normHash`, `isTokenA`, `sortPair`, `gasPairLpId` - Math: `quoteExactIn`, `quoteExactOut`, `minOut`, `maxIn`, `priceImpactBps`, `priceGas`, `quoteAdd`, `addBounds`, `newPoolShare`, `addLossBps`, `MIN_LIQUIDITY`, `checkSecondAmount`, `MIN_SECOND_AMOUNT`, `MIN_SECOND_AMOUNT_MESSAGE`, `quoteRemove`, `shareBps` - Invocations: `swapExactIn`, `swapExactOut`, `addLiquidity`, `removeLiquidity`, `transferLp`, `swapSigner` (the only supported signer scope), `toScript`, `toNeonSigners` - Reads: `SwapReader` (`getPools`, `getPool`, `getPairs`, `getReserve`, `getSwapEstimate`, `getSwapBEstimate`, `getLpPositions`, `getLpPosition`, `getStatus`, `tokenMeta`, `balanceOf`, `getLaunchpad`, `isTokenAllowed`, `getAllowedTokens`, `getListing`), `parseReceipt` - Errors and tokens: `parseFault`, `TOKENS`, `isVerified`, `LaunchpadReader` (graduated FTW fun tokens) - Indexer: `createIndexer` (every call returns `null` on failure), `fillCandleGaps` ## Indexer (optional) - `GET /prices` → `{"GAS": {"usd": "1.54", "ts": "…"}, "NEO": {"usd": "…", "ts": "…"}}` - `GET /swap/pools`, `GET /swap/pools/{token}`, `GET /swap/pools/{token}/trades?limit=50&before=`, `GET /swap/pools/{token}/candles?interval=5m|1h|1d&from=&to=` — `{token}` is the `0x` script hash. Integers are decimal strings in base units; `priceGas` is GAS per whole token. Every response carries `indexedBlock`. The indexer can lag or be down. The chain is the source of truth. ## Governance The owner is a multisig. It can stop the market (removals keep working), restart it, hand over ownership and update the code. A code update must first be announced with `proposeUpdate`, which emits `UpdateProposed(codeHash, executableAt, expiresAt, executableAtBlock, expiresAtBlock)` with `codeHash = sha256(sha256(nef) || sha256(manifest))`. `update` then installs exactly that code, and only once BOTH the block time reaches `executableAt` (Unix time in milliseconds) AND the block height reaches `executableAtBlock` (the proposal's height + 28,800, about 24 hours at 3 s blocks); a chain stall or a skewed clock alone cannot shorten that wait. The proposal then stays valid until BOTH `expiresAt` (`executableAt` + 7 days) and `expiresAtBlock` (`executableAtBlock` + 201,600) have passed — whichever clock is slower keeps it alive; once both pass, `update` fails until the owner cancels or proposes again. `getPendingUpdate()` returns `[codeHash, executableAt, expiresAt, executableAtBlock, expiresAtBlock]` or null. `getUpdateDelay()` returns the ms part of the delay, 86,400,000 ms (24 hours) by default, and it can only grow, up to 30 days (`setUpdateDelay(delayMs)`, `UpdateDelayChanged(delayMs)`; above 2,592,000,000 ms it fails with `delay is too long`, re-audit I-1 — an unbounded delay could otherwise seal upgrades by accident); the 28,800-block floor is fixed and does not change with the delay. New code can change every rule the contract enforces today, including LP locks and graduated FTW fun liquidity (see Cautions). If you hold liquidity, watch for `UpdateProposed`. Live status: `/status` on the web app. The "about 24 hours" and "about 7 days" above assume MainNet's block time, `Policy.getMillisecondsPerBlock` = 3000 ms (verified 2026-09-26). The 28,800/201,600-block counts themselves are fixed; if the Neo committee changes the block time, only how long they take in wall-clock time changes (final review FR-I2/FR-1) — the ms clocks (`executableAt`/`expiresAt`) always still have to pass too, since `update` needs BOTH clocks to open and BOTH to expire. ## MCP server FTW Swap ships an MCP server (`mcp/` in this repository) so an AI agent — Claude, or any MCP client — can read pools, get quotes, build unsigned transactions and simulate them, all through conversation instead of code. Give it a key and it can sign and send too. It runs locally over stdio, on your own machine. ```sh claude mcp add ftw-swap -- node /abs/path/ftw-swap/mcp/dist/index.js ``` Its tools split into five groups: reads (`network_info`, `list_pools`, `get_pool`, `get_balances`, `get_positions`, `get_status`, `get_history`), quotes (`quote_swap`, `quote_add_liquidity`, `quote_remove_liquidity`), unsigned builds (`build_swap`, `build_add_liquidity`, `build_remove_liquidity`, `build_transfer_lp`), a dry-run simulation (`simulate`), and `send`. No key configured, no `send` tool — every other tool never touches one. Setup for Claude Code and Claude Desktop, the environment variables, and the safeguards on `send` (GAS limits, the MainNet lock, the unverified-token gate) are all in `mcp/README.md`.