Trust · Safety and operator powers
wars_dex audit trail
No external audit firm (decision of 2026-10-12). In its place, four things, each leaving a record here:
- Fresh-eyes AI reviews. Every change to the package is reviewed before it ships by a new AI session that did not write it, briefed only with the spec, the invariants and the code, and told to break it with exploit tests. After mainnet, the whole package is reviewed again every month.
- Properties, not opinions. Move property tests (
tests/wars_pool_tests.move) run randomised sequences of swaps, deposits and withdrawals against the invariants; the Move Prover (sources/wars_pool.spec.move) proves the ones it can. - This file. Every review: date, commit reviewed, reviewer, findings, the fix commit and the test that pins each finding, in the shape of an audit report so a partner or an exchange can read it.
- Caps and a bounty. Pools capped at 5,000 SUPRA for the first two weeks on mainnet (
set_supra_cap), and a public bug bounty in SUPRA, for what the reviews miss.
Invariants
| Invariant | Where it is checked | |
|---|---|---|
| I1 | A swap never lowers k = reserve_a × reserve_b (fee-adjusted, the v2 check) | on chain in swap (E_K); Prover P1; property runs (check 100) |
| I1s | Upgrade 14: a swap on a stable pool never lowers the curve's D (balances without the fee the pool keeps; to the solver's precision of one scaled unit, with the fee on top) | on chain in swap (E_K); tests/stable_tests.move; reference-model fuzz (audit 2026-10-23) |
| I2 | Adding liquidity never mints more LP than the deposit's share; removing never pays more than the burned share; k / supply² never falls except by the protocol's settled share | property runs (checks 101 to 104, 110, 111) |
| I3 | Reserves equal the stores' balances, less donations since the last sync | property runs (check 105) |
| I4 | The 1,000 locked LP units stay at the pool's address, so supply and both reserves never reach zero | property runs (check 106); F1 tests |
| I5 | Only an asset's holder moves it: no signer but the pool's own (held by the module) can withdraw from a pool | F1 tests |
| P1 | get_amount_out < reserve, and the pool is paid at least the constant-product price | Prover |
| P2 | get_amount_in always buys at least the output asked for | Prover; swap_maths_rounds_in_the_pools_favour |
| P3 | A pool's assets, stores and fee tier never change; a pool is never removed | Prover (global update invariants over every function) |
| P4 | A deposit never takes more than offered, at the pool's price | Prover |
| P5 | Every setting changes only by the operator's signature; the role only by the proposed operator | Prover |
| P6 | The protocol share stays within 0 to 1/2 (1/6 before upgrade 3) and the creation fee within 0 to 10,000 SUPRA | Prover (struct invariants) |
| P7 | The Floor bridge: only the operator opens it; only the account it names reserves or seeds a pool; a reserved flag is only ever cleared (a pool is seeded once) and a Floor pool's token never changes | Prover; tests/bridge_tests.move |
| P8 | An open LP lock's id, pool, owner and amount never change; its unlock time only moves later (upgrade 6 spec, A6-I5) | Prover (written; not yet run, see the upgrade 6 entry); lp_lock_tests, audit6_tests |
| Conservation: across a whole random run, users + treasury + pool hold exactly what existed | property runs (checks 107 to 109) |
Tooling
- Tests:
node chains/supra/scripts/move-test.js dex(Aptos CLI 7.2.0, the Supra framework from Entropy-Foundation/aptos-coredev). - Prover:
BOOGIE_EXE=… Z3_EXE=… node chains/supra/scripts/move-test.js prove dex, with Boogie 3.2.4 and Z3 4.11.2 (the binaries from aptos-labs/prover-dependency). A negative control was run once: a deliberately false postcondition and a deliberately added fee-tier write were both reported, so the Prover is checking, not passing vacuously.
Log
2026-10-12 · review 1 · phase 1 pool module (pre-commit working tree, first version)
Reviewer: a fresh Claude session, briefed with the spec, the invariants and the code only; it wrote exploit tests in a copy. Result: 1 critical, 1 medium/low design point, 2 low, 5 informational. All the critical and low ones fixed in the commit that adds this file; each pinned by a test (audit_f… in tests/wars_pool_tests.move).
| # | Severity | Finding | Status | Pinned by |
|---|---|---|---|---|
| F1 | Critical | object::owns walks the owner chain. Stores were owned by their pool, pools by the registry, the registry by the deployer, so the deployer's own signer could empty any pool and take its locked LP through the framework's public fungible_asset::transfer, even after handing the operator role away (and could then brick the pool by draining it to zero). | Fixed: the registry and every pool own themselves (own_itself); any other signer now aborts in owns at the nesting limit. | audit_f1_the_deployer_cannot_move_a_pools_reserves, audit_f1_the_deployer_cannot_move_the_locked_lp, audit_f1_the_pools_and_the_registry_own_themselves |
| F2 | Low | Changing the protocol share reached back: fees earned while the share was off were minted to the treasury when it was turned on again (and a raise applied to old growth). | Fixed: share_epoch counts changes; growth across a change is left to the LPs. The harvest settles every pool before a change. | audit_f2_turning_the_share_back_on_takes_nothing_earned_while_off, audit_f2_raising_the_share_does_not_reach_back |
| F3 | Medium/low (design) | One pool per pair: anyone can create a pair's pool first, at a fee tier and price of their choosing, and the pool's tier is fixed for life. Today nothing depends on creating a specific pool. | Closed by phase 3 (the Floor bridge): the Floor reserves a pair's pool at launch (reserve_pool, floor-only), so nobody can open it first; its tier is the Floor's. Other pairs stay first come, first served (nothing graduates into them). | bridge_tests (pair reserved once, pair cannot be created, seeded once) |
| F4 | Low | sync could fold donated SUPRA past the launch cap, blocking SUPRA-in swaps and deposits on that pool until sells brought it back. | Fixed: sync checks the cap. | audit_f4_sync_respects_the_launch_cap |
| F5 | Info | An asset's issuer holding a TransferRef or BurnRef can freeze or take from a pool's store of that asset. Inherent to fungible assets. | Accepted. The Pools page should warn about assets with live refs (later). | — |
| F6 | Info | Anyone can call collect_protocol_fee often to round the protocol's share down by about one LP unit a call. | Accepted (negligible; costs the caller gas). | — |
| F7 | Info | A protocol fee above u64 was skipped silently. | Fixed: it aborts (E_OVERFLOW). Unreachable in practice. | — |
| F8 | Info | Every swap needed SUPRA's paired asset to exist (supra_meta). | Fixed: is_supra checks without aborting. | — |
| F9 | Info | No Mint event at creation; pool_info.locked_lp was a constant; pool_list could overflow on a huge start. | Fixed, all three. | audit_f1_the_pools_and_the_registry_own_themselves (locked balance) |
Also raised: the package's upgrade policy is compatible, so whoever holds the deployer key can upgrade the code. Not a code defect; it is in the "Before mainnet" list of README.md (multisig-held publisher or immutable).
Checked and found fine by the reviewer: swap and liquidity rounding (always in the pool's favour), the u256 k check, the protocol-fee formula against Uniswap v2 at 1/6, the first-depositor and donation profiles, LP token refs (no TransferRef exists, so nobody can freeze LP), object transfer disabled on the pool, stores and registry, dispatchable assets refused (accepted since upgrade 7, see below), no reentrancy path, deadline and minimum checks on every entry function, collision-free pool seeds, take for SUPRA, no unbounded loops.
The same ownership pattern was looked for in the Floor package: its vault is a resource account (no key exists for it) that holds its assets directly, so F1 does not apply there.
2026-10-12 · review 2 · the fixes above (commit 7e1d05b)
Reviewer: a second fresh Claude session, briefed with the fixes and told to break them; 8 more exploit tests in a copy. Result: no critical, high, medium or low finding; all four fixes hold. F1 was checked against every framework path that consults ownership (withdraw, transfer, primary_fungible_store::withdraw, upgrade_store_to_concurrent, object::burn, object::transfer): the deployer, the operator, the treasury and a child object made under a pool all abort. No ref that could move a pool or registry is kept anywhere. F2's epoch rule was traced through every path that settles or marks, including exemption flips interleaved with share changes.
| # | Severity | Finding | Status |
|---|---|---|---|
| R2-1 | Info | A donation folded in by sync counts as fee growth, so the protocol share of it goes to the treasury (as in Uniswap v2). | Accepted; documented on sync and in the README. |
| R2-2 | Info | Because pools own themselves, object::owns(pool or its store, X) aborts instead of returning false, and object::root_owner never ends, for third-party code that asks. Nothing in the framework paths the module uses calls them. | Accepted (it is the F1 fix); documented in the README for integrators. |
| R2-3 | Info | The creation Mint event counted the 1,000 locked units as the creator's. | Fixed: Mint.lp is the creator's amount; supply = 1,000 + Mint − Burn + ProtocolFee. |
| R2-4 | Info | share_epoch was in neither ConfigChanged nor config(). | Fixed: both carry it (config() gains an 8th value). |
| R2-5 | Info | sync checked the cap even when it lowered the SUPRA reserve. | Fixed: only when SUPRA goes up. |
| R2-6 | Info | locked_lp can be inflated by sending LP to the pool's address (the LP is lost). | Accepted, cosmetic. |
| R2-7 | Info | The accept_operator spec leaves out the @0x0 abort (hidden by the partial-aborts pragma). | Accepted; not a bug. |
After the fixes: 43/43 tests, the Prover passes, the indexer test passes.
2026-10-12 · review 3 · the whole module as published to testnet (commit 4ceb34b), Fable
Reviewer: a fresh session on Claude Fable 5.1, briefed with the spec, the invariants, the two reviews above and the code, and told to break it. It wrote 25 adversarial tests in a copy, now in the repo as tests/review3_tests.move. Result: no critical, high, medium or low finding. Five informational items, two of them fixed.
| # | Severity | Finding | Status |
|---|---|---|---|
| R3-1 | Info | swap_exact_out delivers get_amount_out(need), which can be a little more than amount_out (the input is rounded up to a whole unit). Never less, and the user pays exactly the quote. | Accepted; documented on both functions. The existing exact-out test now asserts the output equals quote_out(need). |
| R3-2 | Info | set_protocol_share to the value it already had bumped share_epoch, forfeiting every pool's unsettled protocol fee to its LPs. | Fixed: the same value is a no-op. Pinned by audit_r3_2_resending_the_same_share_keeps_pending_fees and r3_info_resetting_the_share_to_the_same_value_keeps_pending_fees. |
| R3-3 | Info | Side effect of F4: with a pool at the launch cap, one donated octa of SUPRA made sync abort, stranding any other donation to that pool. | Fixed: sync folds SUPRA in only up to the cap and the rest waits in the store; the other side always folds in. Pinned by audit_f4_sync_respects_the_launch_cap (rewritten) and r3_info_a_dust_supra_donation_no_longer_blocks_sync_at_the_cap. |
| R3-4 | Info | quote_out ignores swaps_paused and quotes an input that would overflow the reserve; the swap then aborts with an arithmetic error rather than a module code. Views only. | Accepted. |
| R3-5 | Info | SUPRA paid out lands in the paired fungible-asset store, never back in the CoinStore. coin::balance and take cover both, so nothing is lost; a wallet reading only CoinStore shows less. | Accepted; noted in the README. |
Also noted: every SUPRA path needs the coin-to-fungible-asset pairing to exist on the network (it does on testnet; confirm on mainnet before publishing).
Attacked and found fine, each pinned by a r3_ test: value extraction by random dust round trips and at u64 scale (k never falls, rounding in the pool's favour at every step, quotes equal swap results to the unit); the first-depositor inflation attack (costs the attacker a thousand times the victim's loss); the protocol fee equal to Uniswap v2's bit for bit and never above its share across a 250-step run interleaving share changes, exemption flips, donations and harvests; ownership (operator, deployer, treasury and strangers cannot transfer the pool or its stores, withdraw reserves or the locked LP); entry argument checks and the cap boundary; pool_list paging bounds; nested pools of LP tokens; coin and fungible-asset SUPRA holders on every entry function. F1, F2, F4, F7 to F9 and R2-3 to R2-5 re-checked and holding.
After the fixes: 69/69 tests (45 + 24 adversarial), the Prover passes, the indexer test passes.
2026-10-12 · review 4 · the Floor bridge, phase 3 (commits 56f103d to ab2af12), Fable
Reviewer: a fresh session on Claude Fable 5.1, briefed with the bridge spec, P7, the three reviews above and both packages, and told to break the bridge from both sides. It wrote 20 adversarial tests on the DEX (tests/review4_tests.move) and 18 on the Floor (../contracts-floor/tests/review4_tests.move). Result: no critical, high or medium finding. Two low findings (liveness, operator-recoverable, no value at risk), both fixed; the rest informational.
| # | Severity | Finding | Status |
|---|---|---|---|
| R4-1 | Low | Two switches in two packages had to agree: with the Floor's set_dex on while the DEX bridge was closed or named another account, every launch aborted in the DEX (E_NOT_FLOOR) and a pre-switch token's filling buy aborted in graduate, parking the curve just under its target. | Fixed: set_dex(on) refuses a bridge that does not name the vault, or a missing SUPRA pairing (0x30018); and launch / graduate take the old paths when the bridge has moved since. Pinned by r4_set_dex_refuses_a_bridge_that_does_not_name_the_vault, r4_a_launch_with_the_bridge_moved_away_reserves_nothing_and_still_works, r4_a_pre_switch_curve_graduates_by_the_old_path_while_the_bridge_is_closed. |
| R4-2 | Low | A stage-5 harvest slice worth under one unit on either side aborted inside the DEX (remove_liquidity refuses a zero output), so with min_harvest 0 a 300-octa swap made a whole harvest_range page abort. | Fixed: harvest_plan returns no LP when either output rounds to zero; the page skips the token. Pinned by r4_a_dust_harvest_on_a_dex_pool_is_skipped_not_aborted, r4_harvest_range_skips_dust_on_a_dex_pool. |
| R4-3 | Info | Two bridge_tests donation tests deposited into the pool address's primary store, which no pool code reads. | Fixed: they donate into the pool's real stores (store_addrs); the claims hold there too (r4_donations_before_the_seed_only_enrich_the_floor). |
| R4-4 | Info | sync still applies the launch cap to a Floor pool while check_cap skips it: an over-cap donation waits in the store. | Documented (README). |
| R4-5 | Info | reserve_pool / seed_pool ignore creation_paused. | Intended (graduations keep going); documented. |
| R4-6 | Info | set_floor to a new address hands every open reservation to it. | Documented; the vault resource account never changes, and R4-1's guard refuses a foreign bridge. |
| R4-7 | Info | A token rescued on the curve leaves its reserved pool reserved forever (an empty registry entry). | Accepted and documented; holders are made whole by the vault. A Floor-only release path can come with a later upgrade if the dead entries bother anyone. |
| R4-8 | Info | reserve_pool needs SUPRA's paired fungible asset; it exists from genesis on testnet. | Covered by R4-1's set_dex check; on the mainnet checklist. |
| R4-9 | Info | At harvest_bps 100% the Floor's principal is left one LP unit short by mine * inv0 / inv flooring. | Accepted (not extractable). Pinned with a tolerance by r4_harvest_never_takes_the_principal. |
| R4-10 | Info | Token.volume / trades do not count stage-5 trades (the server reads DEX events); floor::arb handled stage 4 only. | Volume: accepted. Arb: done in Floor upgrade 12 (floor_arb, the quotes and the scan work on a stage-5 token through wars_dex::wars_pool::swap; the quote is the DEX's own get_amount_out). Pinned by on_the_dex_a_dump_opens_a_gap_and_the_arb_closes_it and two more in arb_tests. |
Attacked and found fine, each pinned by an r4_ test: a reserved pool untradeable on every path, entry and composable (swap, exact-out, add, remove, quotes); operator switches and fee settlement on a reserved pool are no-ops; neither the operator nor the Floor's own signer can move a reserved pool or its stores; donations before the seed set neither the price nor the LP count; no creation fee or cap for the Floor even at a 10,000 SUPRA fee and a 1-octa cap; a reservation seeds only its own pair, two reservations keep their tiers; I1 to I4 across seed, 20 swaps, a stranger's add and remove and the Floor's partial remove; the Floor's LP is exactly sqrt(ab) - 1000; the protocol-share epoch rule on a later un-exempted Floor pool; a mixed-page view check. On the Floor: the DEX creation pause blocks nothing; a reserved token graduates on the DEX even after set_dex(off) with Atmos on; launch_and_buy reserves and the lock survives; a squatter cannot take a reserved pair; 50,000 SUPRA pushed into a reserved pool's real stores do not move the opening price; a sandwiched harvest leaves the attacker poorer and the principal intact; a stage-5 rescue takes only the Floor's share; stage-5 quotes equal the DEX's results to the unit; the review-A redeem fix holds on the DEX path. Upgrade compatibility: no existing struct changed in either package (new resources and events only).
After the fixes: DEX 111/111 (91 + 20), Floor 96/96 (77 + 19), the Prover passes.
2026-10-12 · upgrade 2 · the per-swap platform cut on Floor pools
Decision (AoS): the platform's share of a Floor pool's fee is paid on every swap instead of by the Floor's harvest. swap on a Floor pool charges the fee in SUPRA both ways (on the input when SUPRA comes in; else on the constant-product output) and sends floor_fee (bps, wallet; default 35% to the treasury, set_floor_fee) of it to the wallet in the same transaction; the rest stays in the pool. The k check keeps I1 with the cut taken out: adj_in adds the cut back on the input side, adj_out takes the pool's kept fee out on the output side, so the fee-adjusted k never falls on either path (a_full_share_sends_the_whole_fee_and_k_still_never_falls, 40 swaps at a 100% share). quote_out, quote_in, swap_exact_out and swap_exact_out_entry go through the same amounts_for / input_for, so quotes match swaps to the unit on both paths (exact_out_for_supra_pays_the_quoted_input_and_the_cut). Ordinary pools are untouched (an_ordinary_pool_pays_no_cut_either_way). New resource FloorFee at the registry and two events; no existing struct changed (compatible upgrade). Tests: tests/fee_tests.move (10), the two bridge_tests reserve checks now net of the cut. Floor side: harvest_plan returns nothing for a DEX pool (upgrade 13), five Floor tests rewritten to pin the per-swap payout and that nothing moves the principal. DEX 121/121, Floor 100/100, DEX Prover passing. The Floor Prover run fails in the cached framework's object.spec.move ("cannot appear in both arithmetic and bitwise operation") on the base branch too: a framework/prover version mismatch, not this change; noted here until the framework cache is refreshed. Fresh review before mainnet, as for every DEX change.
2026-10-13 · upgrade 3 · protocol share up to 1/2
The operator's protocol share on ordinary pools may now be set from 0 to 1/2 of each swap fee (MAX_PROTOCOL_SHARE 3000 of 6000; it was 1000, 1/6, Uniswap v2's fee switch). The default stays 1/6 (DEFAULT_PROTOCOL_SHARE), and an upgrade does not touch the published config. mint_protocol_fee already takes the share as a parameter: its settlement S·φ·(√k − √k₀) / ((1 − φ)·√k + φ·√k₀) is Uniswap v2's general form and holds for any φ < 1, pinned at 1/6 by r3_protocol_fee_equals_uniswap_v2_exactly and at 1/2 by at_a_half_the_treasury_gets_half_of_the_fees_as_lp. Launchpad pools stay exempt. A constant change only: a compatible upgrade.
2026-10-13 · the module is renamed wars_pool
wars_dex::pool is now wars_dex::wars_pool, so transactions read <address>::wars_pool::swap_exact_in on the explorer. The code is otherwise unchanged, apart from one new view, is_pool(address). The registry seed moves with the name (wars_dex::wars_pool::registry), so the registry and every pool address are new. On testnet the package upgrade keeps the old pool module beside the new one, because a published module can't be removed: its pools stay withdrawable there, but the site, the Floor and the arbitrage package use wars_pool only. Mainnet publishes wars_pool alone. DEX 122/122.
2026-10-13 · upgrade 4 · the protocol share is paid on every swap
The protocol share no longer waits in the pool as sqrt(k) growth for a settlement. It leaves for the treasury inside each swap, the way the Floor pools' platform cut has since upgrade 2. A pool with SUPRA now charges its fee in SUPRA whichever way the swap goes (fee_on_output: on the output when SUPRA comes out), so the treasury is paid in SUPRA. A pool without SUPRA charges on the input and pays the treasury in the asset paid in. The cut (cut_for) is fee · protocol_share / 6000, none on an exempt pool. A Floor pool pays its floor_fee share to its wallet and never the protocol share, exempt or not. The k check in swap already took a cut on either side, so it is unchanged.
No new settlement period starts: seed_into and set_protocol_fee_exempt mark with share 0, and mint_protocol_fee always returns 0. A pool with a period from before the upgrade still settles that growth once, at the old rule (the epoch check included), through collect_protocol_fee or its next liquidity change. lp_value is therefore exact. Struct layouts and public signatures are unchanged: a compatible upgrade. Sellers on SUPRA pools now pay the fee on the SUPRA out rather than on the token in; the difference is within 0.1% of the output at the 1% tier (selling_for_supra_pays_the_platform_in_supra_not_tokens).
Tests: fee_tests covers both directions on an ordinary SUPRA pool, exempt and zero-share pools, and a pool without SUPRA. The tests of the LP settlement now start a pre-upgrade period with the test-only legacy_mark_for_test. The epoch tests that depended on a new period starting after a settlement were rewritten for the per-swap rule. DEX 125/125, Floor 124/124, arb 19/19.
2026-10-13 · phase 2 · the router (wars_dex::router) · review 5
A new module, no change to wars_pool: multi-hop swaps along a path of 2 to 4 assets (swap_exact_in, swap_exact_out, the composable swap_path), split swaps over up to three paths with common ends (swap_split_exact_in), and the views quote_out / quote_in. Every hop is a plain wars_pool::swap, so each pool's fee, its protocol or platform cut, the pause, the reservation and the launch cap apply hop by hop. The router has no storage and holds nothing between hops. A compatible upgrade (an added module).
Reviewer: a fresh Claude session, briefed with the code, this file and the README; it wrote exploit tests in a copy (now tests/router_review_tests.move). Result: nothing Low or above; three informational findings, fixed.
| # | Severity | Finding | Status | Pinned by |
|---|---|---|---|---|
| R5-I1 | Info | take asked wars_pool::supra_asset(), which aborts without SUPRA's coin pairing, so on such a network every routed swap aborted, even one with no SUPRA on the path (the shape of review 1's F8). | Fixed: take checks the pairing without aborting. | review_i1_routes_without_supra_work_without_the_supra_pairing |
| R5-I2 | Info | A split's amounts were added in u64, so parts summing past u64 aborted with a VM arithmetic error. | Fixed: added in u128, over u64 is E_BAD_SPLIT (0x1001F). | review_i2_split_parts_that_overflow_u64_are_a_bad_split |
| R5-I3 | Info | A split read its first path's ends before checking it, so an empty first path aborted with a vector error. | Fixed: the first path is checked first (E_BAD_PATH). | review_i3_split_with_an_empty_first_path_is_a_bad_path |
| R5-I4 | Info | Quotes don't check pauses or reservations; a route through a reserved pool is refused by wars_pool (0x30016) at swap time. | As designed: hop aborts carry wars_pool's codes. | review_route_through_a_reserved_floor_pool_is_refused |
Held: an exact-out sweep of 81 cases (2 and 3 hops, fee on input and on output, SUPRA first, middle and last, a Floor hop) always took exactly quote_in's input and paid quote_out's output to the unit, at least the amount asked; cycles abort on every entry; two split paths through one pool pay the moved price under the total min_out. Compiles with the Supra CLI's compiler v1. DEX 150/150.
2026-10-14 · review 6 · wars_pool, router and the cycle arb at testnet 85e4c6d
A fresh session reviewed the package and found 0 critical, 0 high, 0 medium, 3 low and 5 info findings. The full report is in docs/supwars/MOVE-CONTRACTS-AUDIT-2026-10-14.md (DEX section), and the exploit tests are in docs/supwars/audit-2026-10-14/audit6_tests.move.
| # | Severity | Finding | Status |
|---|---|---|---|
| A6-L1 | Low | A pool whose settlement period from before upgrade 4 is still open pays the protocol share twice (per-swap cut, then LP minted at settlement) | Fixed in upgrade 6 (below), plus a one-off operator step at publish |
| A6-L2 | Low | Anyone with one LP unit can grow the shared LpLocks.by_pool vector without bound, raising lock gas and eventually blocking new locks on that pool | Fixed in upgrade 6: LockIndex |
| A6-L3 | Low | Lock, extend, burn and creation fees have no user-side maximum, so the operator can raise them in the same block | Fixed in upgrade 6: *_v2 entries with max_fee, used by the site |
| A6-I1 to I5 | Info | burn_lp fee optional; dust sells pay no cut; operator power through the bridge; arb views overflow on large reserves; spec coverage lags the code | I2, I4, I5 fixed in upgrade 6; I1, I3 accepted (below) |
Held: k never falls on either fee path; pools, stores and locks are self-owned; the router and the cycle keep nothing and check limits on the final amount. F1, F2, F4 and contracts-review-5 stay fixed; D-01 (publisher key) and C-01 (Atmos signature) stay open. DEX 225/225, arb 26/26.
2026-10-14 · upgrade 6 · the review 6 fixes (wars_dex and wars_arbitrage)
Fixes every Low finding of review 6 and the cheap Info ones. A compatible upgrade of both packages: two new structs (LockIndex, PoolSlot), new entry functions and one new view; no existing struct, public or entry signature or event changed, and no function the Floor calls changed (the Floor builds and passes against it unchanged). The exploit tests of docs/supwars/audit-2026-10-14/audit6_tests.move are now regression tests in tests/audit6_tests.move, asserting the fixed behaviour.
| # | Fix | Pinned by |
|---|---|---|
| A6-L1 | swap (wars_pool.move:463) closes an open pre-upgrade-4 period without minting at a pool's first swap: from then on the growth comes from swaps that pay the share in the swap, and mint_protocol_fee cannot tell it from old growth, so the period's growth stays with the LPs (the same rule as an epoch change). Growth with no swap since still settles once at the old rule. The swaps between upgrade 4 and this upgrade are already in any open testnet period, so the operator voids those at publish with two set_protocol_share calls (README, "Upgrade 6 publish"). Mainnet is unaffected either way. The settlement maths tests now open a legacy period with the test-only legacy_period_for_test(pool, k_last) after their swaps, standing for pre-upgrade growth | a6_l1_the_first_swap_closes_a_legacy_period_unminted, a6_l1_legacy_growth_with_no_swap_since_still_settles_once, a6_l1_bumping_the_epoch_voids_the_legacy_period; r3_protocol_fee_equals_uniswap_v2_exactly and the other settlement tests |
| A6-L2 | New LockIndex at the registry (:218): (pool, position) -> id, id -> position and a count per pool, one table entry per lock, with swap-remove on unlock (:948-985). lock_lp / lock_lp_v2 add there (:723) and never touch LpLocks.by_pool; unlock_lp takes a lock out of the index, or out of the old vector for a lock made before the upgrade (:759). That vector can only shrink now, so lock and unlock cost O(1) whatever a griefer adds. pool_locks (:854) lists the old vector's locks first, then the index; new view pool_lock_count (:875). Chosen over a minimum lock size: it needs no new setting, keeps every lock legal, and keeps the old data readable without a migration. by_owner stays a vector: only the owner grows their own | a6_l2_one_unit_locks_do_not_grow_a_shared_vector, a6_l2_the_index_stays_exact_beside_pre_upgrade_locks; lp_lock_tests unchanged |
| A6-L3 | create_pool_entry_v2 (:526), lock_lp_v2 (:701), extend_lock_v2 (:734), burn_lp_v2 (:771) take max_fee and abort with E_FEE_ABOVE_MAX (0x1001C) above it (pay_lp_fee, :925). The old entries call them with no maximum and behave as before. The Pools page sends the v2 entries with the fee it showed (a lock allows the band its length can fall into in the next 10 minutes) once /api/dex reports maxFee (server/dex.js reads pool_lock_count); chain.js lists the four with gas | a6_l3_a_raised_lock_fee_aborts_lock_lp_v2, a6_l3_a_raised_extend_fee_aborts_extend_lock_v2, a6_l3_a_raised_burn_fee_aborts_burn_lp_v2, a6_l3_a_raised_creation_fee_aborts_create_pool_entry_v2, a6_l3_v2_entries_pay_the_fee_within_the_max, a6_l3_create_pool_entry_v2_pays_the_fee_within_the_max |
| A6-I1 | Accepted and documented on burn_lp_v2: a plain LP transfer to the pool address is the same burn without the fee. Counting burns separately would need a new tally and gains only fee revenue | a6_i1_burn_by_transfer_is_accepted_and_burn_lp_v2_charges |
| A6-I2 | The fee on the output rounds up (amounts_for, :1302), so a dust sell pays at least one octa. Exact-out still delivers at least amount_out (g − ceil(g·f/B) ≥ a whenever g·(B−f) ≥ a·B), and the k check only gets easier. The Swap page (dex-paths.js), the arb bot's DEX hop (server/arb.js) and qa/dex_qa.js round the same way; the router numbers pinned on both sides moved by one unit (987128711, 483899765) | a6_i2_a_dust_sell_pays_a_fee, qa_info_dust_sells_pay_no_fee (now asserts the octa), two_hops_through_supra_pay_what_the_two_pools_quote, fee_tests |
| A6-I3 | Accepted: operator trust, as R4-6. Restricting set_floor needs the Floor vault's address in this package, which would tie the DEX to one Floor deployment; to be decided with the multisig before mainnet | — |
| A6-I4 | cycle.move: quote_mixed takes the Atmos fee in u128 (:165); best_two_pool_mixed reduces each side's x·y·k/BPS (below 2^128) before multiplying (:190-191) and returns (0, 0) when the sized input would not fit u64 (:196); isqrt starts at n/2 + 1, which cannot overflow | a6_i4_quote_weighted_takes_a_huge_amount, a6_i4_best_two_pool_sizes_a_trade_on_huge_reserves (contracts-arb/tests/qa_cycle_edge_tests.move; both fail on the old code) |
| A6-I5 | wars_pool.spec.move: operator-only specs for set_floor_fee, set_lp_fees, set_protocol_fee_exempt, the creation fee bound on set_creation_fee, create_pool_entry_v2's max, and P8 (an open lock's id, pool, owner and amount never change, its unlock time only rises). The specs type-check in every test build; the Prover was not run in this session (no Boogie or Z3 here), so run move-test.js prove dex before the publish. Not added: locked[pool] equal to the sum of open locks (a sum over a table needs a ghost variable) and router specs | type-check in move-test.js dex |
Tests: DEX 238/238 (225 + 13 in tests/audit6_tests.move; 10 existing tests rewritten for the rounding and the legacy period), arb 28/28 (26 + 2). QA: dex_qa 290/290, http_qa 462/462, contracts_qa static checks for wars_pool and arb all passing, chains/supra/test/dex_test.js and dex_paths_test.js passing. Publish: wars_dex, then the operator's two set_protocol_share calls, then wars_arbitrage, then the site (README, "Upgrade 6 publish"). Fresh review before mainnet, as for every DEX change.
2026-10-15 · upgrade 7 · assets with dispatch hooks
Pools accept assets with dispatch hooks (tokens launched on Atmos carry its hooks). Every move in or out of a pool store goes through dispatchable_fungible_asset (put, pull) and is counted by what the store gained or lost, so I3 holds whatever a hook does. Independent review of the change (same day), all fixed in the same upgrade:
- M1
add_liquidity: with a deposit-taxed token, the plain side went in full while LP was minted for the taxed arrival, so the depositor's extra SUPRA went to the other LPs andmin_x/min_y(checked on the planned amounts) missed it. Now the hooked side goes in first, the plain side is matched to what arrived and the rest returned, and the entry checks its minimums against the reserves' growth. Tests:a_taxed_add_takes_only_the_matching_supra,a_taxed_add_checks_its_minimums_on_what_arrived. - L1 exact-out swaps with a hooked input aborted with an internal code. Now refused up front:
E_DISPATCHABLEinwars_pool,E_HOOKED(32) in the router; new viewhooked(pool, asset). - L2 on a pool without SUPRA, the protocol cut on a hooked input ran its hook for the treasury, so a hook refusing the treasury stopped those swaps. The cut on a hooked input now stays in the pool for the LPs.
- L3
PoolCreated,PoolSeeded,BurnandPlatformCutcarry measured amounts (seed_intoreturns arrivals). - L4 a withdraw hook burning more than a reserve makes the subtraction abort: the same class as a token that refuses to move; commented, no change.
Checked fine by the reviewer: no value path from a hook to SUPRA, other LPs, the treasury or the Floor cut; I1 and I3 hold, including for a hook that adds to the store; a hook calling back into wars_pool aborts with the VM's re-entry lock (RUNTIME_DISPATCH_ERROR 4037), pinned by a_hook_cannot_reenter_the_pool; sync and the helpers read the raw store balance, never a derived balance; the upgrade is compatible (no struct or public signature changes). What no code can undo, documented and warned about on the site: a hook that refuses to move stops its pool for both sides, a hook that burns extra from the pool's store on a payout makes buys fail the k check, and a hook's module can be upgraded by its owner. Hooked pools need the chain's dispatchable fungible asset feature on.
2026-10-16 · review 10 · upgrade 10 · coin-standard tokens (wars_dex::coins), Fable
Scope. The new module sources/coins.move: nine generic entries (swap_exact_in<X>, swap_exact_out<X>, swap_path_exact_in<X>, swap_path_exact_out<X>, add_liquidity<X>, add_liquidity2<X,Y>, create_pool<X>, create_pool2<X,Y>, zap_in<X>), three views (asset_of<X>, balance<X>, coin_of) and their helpers, read against the wars_pool and router functions they call, against the asset-address entries they must match, and against the framework's coin.move (withdraw, balance, deposit, paired_metadata, paired_coin, coin_to_fungible_asset, the paired refs). wars_pool.move and router.move are unchanged by this upgrade.
Method. A fresh session, briefed with this file's invariants and the code only, traced every entry for what leaves the caller and what comes back, the min/max/deadline/zero checks, every guard of the asset-address entry (assert_live, pauses, Floor reservation, E_ON_FLOOR, creation fee and max_fee, launch cap, hook refusal on exact-out, path shape), type-argument abuse, the framework's pairing side effect, hook re-entry, rounding, events and views, and wrote 25 adversarial tests (tests/coins_audit_tests.move, with a test-only hooked token whose withdraw hook calls back into wars_pool when armed). Package: 303/303 (coins_tests.move 20, audit 25).
Result: no critical, high or medium finding; one low, eight informational. The module adds no state, holds no refs and no signer but the caller's, and reaches pool state only through the public wars_pool / router functions the asset-address entries use, so every one of their guards holds; the caller never loses more than the pool receives (plus the creation fee and the swap fee) and the unused remainder always comes back.
- C10-1 Low · events.
swap_path_exact_in<X>,swap_path_exact_out<X>andzap_in<X>build on the composablerouter::swap_pathand so emit norouter::RouteSwap/router::ZapIn; the pools' ownSwap,MintandSyncevents are emitted. The server reads none of the router events, so the site is unaffected. Documented in the module doc and the site's DEX docs; moving the emits into composable router functions is the fix if routed volume is ever counted from those events. - C10-2 Info · hook re-entry asymmetry. In
add_liquidity<X>andcreate_pool<X>theotherasset is taken beforewars_poolis entered, so a withdraw hook onothercan callwars_pool(the asset entries hold the VM's re-entry lock, 4037). No value path: a hook has no signer, so onlysyncandcollect_protocol_feeare reachable, it cannot touch a pool holding its own asset, anddepositchecks the minimums on the reserves' growth after the add, so a price moved mid-entry is caught exactly as a front-run. Pinned by two tests; accepted and commented. - C10-3 Info ·
take_assetaborted on a network without the SUPRA pairing (review 5's R5-I1 shape). Fixed: it now mirrorsrouter::takeand checks the pairing without aborting. - C10-4 Info ·
coin_ofaborted in the framework for a non-object address. Fixed: it answers(false, …)(audit_coin_of_answers_false_on_a_non_asset_address). - C10-5 Info.
otheris always drawn from the primary store, never from a coin balance; two coin sides go throughadd_liquidity2/create_pool2, which is what the site sends. Documented. - C10-6 Info · inherited double rounding.
sizedmatches the offer withoptimal_amountsand the pool runs it again on what arrived, so a minimum equal to the sized amount can abort by one unit (same inadd_liquidity_entry); nothing is lost and the unit comes back. The site sends minimums less its slippage. - C10-7 Info · coin form. Everything the caller receives lands in the primary store as the paired asset. The module's first doc claimed the framework keeps the asset-to-coin direction private; that is wrong,
coin::withdraw<T>draws the asset part andcoin::depositinto a registeredCoinStore<T>keeps it as coins, socoin::transfer<T>to self restores the coin form. Doc corrected (module, README, DEX docs). - C10-8 Info · paired refs. A coin issuer that kept its capabilities can claim the paired asset's mint, transfer and burn refs: review 1's F5 for every coin that trades here. The paired asset can never carry dispatch hooks. Accepted; the Pools page tags coin pairs so the caution can be shown.
- C10-9 Info · pairing as a side effect. The exact-in and create entries pair the coin themselves (the framework's deterministic named object, owned by a system address), the others refuse an unpaired coin with
E_NO_PAIRING. A stranger pairing a coin first changes nothing (audit_a_stranger_pairs_the_coin_first_and_nothing_changes).
Attacked and found fine, each pinned by an audit_ test: exact-out takes exactly quote_in and pays at least what was asked; routed swaps pay the quotes to the unit with nothing stranded between hops; deposits and the zap conserve value; Floor-reserved pools refuse coin deposits and swaps; a coin the Floor holds gets no other pool; swap and creation pauses, the launch cap, the creation fee, duplicate pools and a coin with itself are refused; cycle paths are refused on both path entries; a type that is not a coin cannot be spent; SupraCoin as X pays what the asset entry pays; an asset-only holder with no CoinStore<X> sells through the coin entry. No test written as an exploit passed unexpectedly.
Verdict. Safe to publish as a compatible upgrade (additive module, no struct or signature changes). The compile against the real framework passed here; the v1-compiler check (move-test.js compile dex with APTOS_V1) could not be run in this environment and should be run before the publish, as the README says.
2026-10-23 · upgrade 14 · stable pools, Fable
Scope: wars_pool (the StableSwap curve, create_stable_pool*, the D check in swap, pools_for, the new views), the two coins create entries, and the off-chain copies (public/js/dex-paths.js, server/stableflags.js, server/dex.js, server/arb.js, public/js/pools.js) at 86e0641. Independent Fable audit, report and proof scripts in the project's audits/2026-10-23-dex-stable-pools/. No Critical, High or Medium findings.
- F1 Low · the D check cannot see a fall of less than one scaled unit. The swap compares D before and after, and the curve's own rounding slack (one scaled unit, in the pool's favour) is below the solver's precision. In 4% of fuzzed swaps the exact D of the balances without the kept fee fell by less than one raw unit (worst 0.8 raw unit, in a pool drained to 6 raw units); the fee the pool keeps comes on top of that, and no profitable round trip was found. Accepted: the maths is unchanged and the comments now say the check holds to one scaled unit, with the fee on top.
- F2 Low · a transient error marked the flags view as missing for 10 minutes. Only a "function not found" answer (and only before the view ever answered) now marks an older DEX; any other failure is retried on the next poll, and until then the arbitrage bot keeps unknown pools out of its cycles. Fixed (
server/stableflags.js,arb.js, tests intest/stable_math_test.js). - F3 Info ·
stablePoolsmeant "a pool was learned". It now means the DEX has the upgrade (the view answered once,store.data.dex.stableView). Fixed (dex.js,pools.jscomment). - F4 Info · 1 bps rounds to no fee below 10,000 raw units. A dust swap pays no fee; the D check still holds, so the pool loses nothing. Accepted, documented here.
- F5 Info ·
best_pathsaid 15 quotes. With five pools a pair it is up to 30. Doc fixed (router.move).
Checked by the build: 16 stable tests (creation and address, duplicate, fee and decimals checks, fee cap, pauses, swap prices against the mock, exact-out, 6 and 8 decimals, fee on output, round trips, a huge drain, liquidity, locks, burns, router paths, zap), all 398 dex tests and every dependent package (arb 51, arbspikey 33, arbdexlyn 31, rewards 84, migrate 36, orders 116, floor 340). A Python reference model ran hundreds of thousands of random swaps with no D fall and no abort; the JS copy matches the Move results to the unit.
Verdict. Safe to publish as a compatible upgrade.