Internal Audit 2026-09
Internal audit of the BTR DEX, run from 2026-09-02 to 2026-09-16 and closed before any
mainnet deployment. This page publishes the whole campaign for the open-source components.
Previously published as 2026-09-16 under the title “Security review, September 2026”; that
address redirects here.
Findings are published under the disclosure policy: a finding appears once its fix is deployed to every chain running the affected code. Nothing below has run on mainnet.
1. Funnel
Every row ever filed in the campaign, and what reached this page.
| Stage | Rows | Note |
|---|---|---|
| Filed | 829 | every candidate that survived refutation and was given an id |
| Not a real finding | 179 | 102 duplicate, 28 subsumed, 27 moot, 22 refuted |
| Real findings | 650 | the campaign’s actual defect and design population |
| In open-source scope | 437 | located in dex-evm, shared, sdk, front or core |
| Published here | 434 | 285 of them above informational; 3 open rows are held until their residual closes |
The remaining 213 real findings are located in the services, the keepers and the operational environment. They are out of the public scope and are disclosed to auditors under non-disclosure. Four price-feed rows owned by the upstream data team are tracked separately and are not counted above.
Published rows by severity and disposition:
| Severity | Fixed | Accepted | Closed | Total |
|---|---|---|---|---|
| Critical | 0 | 0 | 0 | 0 |
| High | 33 | 0 | 0 | 33 |
| Medium | 83 | 2 | 1 | 86 |
| Low | 156 | 3 | 7 | 166 |
| Informational | 53 | 13 | 83 | 149 |
| Total | 325 | 18 | 91 | 434 |
“Fixed” means code or documentation changed and the change is an ancestor of the published component head. “Accepted” means the behaviour is the intended design and carries an operational control instead of a code change; the reasoning is stated in the block. “Closed” means no code change was warranted: a record correction, a testnet-only property, or a step of the launch ceremony that retires the row.
The findings below are root-cause bundles, not raw rows: rows that share one mechanism and one fix are presented as one finding, and each block lists the row ids it absorbs. They are ordered Critical to Informational.
2. Scope
Audited heads, frozen 2026-09-15:
| Component | Repository | Audited head |
|---|---|---|
| AIMM pools, Admin, oracles, periphery, deploy scripts | dex-evm | 149fb16e3c |
| Shared access control, quorum, upgrade gate, timelock | shared | 183ee41264 |
| TypeScript SDK: ABIs, router, transport | sdk | 99bc688fb9 |
| Web application | front | ea7150fb |
| Rust pricing mirror | core | 0d1990f7dd |
Remediation heads, merged 2026-09-16: dex-evm aca89e4e5a, shared 957d3b0939, sdk a95aabe,
front 8557e6c7, core 8ef3ef66b9. Every fix commit cited below is an ancestor of its component
head, asserted mechanically by the workbook gate.
3. Scope and disclosure
This page covers the open-source components only. The back-end services, the keepers, the price feed producer and the operational environment were reviewed in the same campaign under the same method; those findings are disclosed to auditors under non-disclosure rather than published, because their write-ups name infrastructure, key custody and operational procedure. Rows that are still open are held until the residual closes, then published on the same rule as every other finding. Proof-of-concept exploits are never committed in any repository, and implementation-specific checklists are held: they are a map, not a method.
This audit is internal. It is not an independent opinion and does not substitute for one; third-party audit is pending and reports will be linked from the Security Overview when they land.
4. Methodology
The audit is a multi-model adversarial process, not a single pass. Several frontier model families are rotated across finding, refutation, debate, cross-validation and test generation, because different pretraining produces different blind spots. It is refute-first: every candidate goes to two independent reviewers instructed to refute it and defaulting to refuted, the party that disagrees carries the burden, and a split goes to a third reviewer who has seen neither refutation. Severity is graded at the shipping configuration, never at a hypothetical one, with exploit difficulty and blast radius stated beside the grade. Surviving findings are re-derived against the frozen tree and pinned as regression tests or property harnesses. The named lead engineer holds the final call. The full method, including the stop rule, the severity doctrine, the coverage floor and the anti-gaming register, is published at METHODOLOGY.md.
5. Findings
F-01 A rejected price push could wedge a feed permanently, and the release lever shipped incomplete
| Severity | Status | Class | Component |
|---|---|---|---|
| HIGH | Fixed | Audit | dex-evm/src/oracles/ExternalOracleV4.sol:497, dex-evm/src/oracles/ExternalOracleV4.sol:842-857, dex-evm/src/oracles/ExternalOracleV4.sol:874-893 |
Severity rationale. A single ordinary gap at live sigma and cadence could make a feed unusable for the life of the deployed, non-upgradeable oracle, which halts every pool leg quoting that asset.
Description
The V4 deviation band gates each incoming lane against the previously stored mark. When the move exceeded the band, the lane was skipped and the stored mark stayed where it was, so the next push was measured against the same stale mark and was refused for the same reason. The gap never shrank on its own. The wedge threshold in practice was far below the nominal ten times maxDeviationBps figure the design assumed, because the band is also a function of sigma and of the elapsed time since the last accepted observation.
// dex-evm/src/oracles/ExternalOracleV4.sol:497
if (pm != 0) {
uint256 nm = _decode(nl, int8(uint8(cfg >> 16)));
uint256 diff = nm > pm ? nm - pm : pm - nm;
uint256 movePbps = (diff * 1e6) / pm;
if (movePbps / 100 > uint16(cfg)) {
uint256 lsig = ((sPrev >> (lane * 24)) & SIG_MASK) << 4;
if (!FeedMathLib.withinBand(pm, nm, dt, uint16(cfg), uint32(lsig))) {
flags |= uint256(1) << (8 + lane);
continue;
}The first remediation added a governed widen lever, requestFeedWiden / executeFeedWiden, and that lever carried its own defects across several rounds: it could not release a wedge beyond the MAX_DEV_THRESHOLD ceiling of 2000 bps; it wrote an absolute band value over whatever a guardian had tightened in the meantime; it dropped the stale-payload snapshot guard its V1 twin carried; it refused to overwrite an expired pending operation; it cleared the lane and the band anchor without stamping the slot clock; and the event it emitted was not reachable by any consumer. The lever also sat at the BASE timelock tier, which is two days on mainnet, even though a pure release cannot set a price.
Impact
A wedged lane quotes a disowned mark or reverts, and on a non-upgradeable oracle the only remedies were a governed widen at a two-day delay or a full redeploy with a repoint of every consuming leg. A paused and wedged feed needed two separate ceremonies with interleavings that could re-wedge it.
Exploit scenario
- A market move larger than the lane’s adaptive band arrives at the oracle.
- The lane is skipped and the stored mark is left at its pre-move value.
- Every subsequent push is measured against that same stale mark and is refused for the same reason, so the gap never closes.
- The feed stays dark until governance executes a widen at the BASE delay.
Remediation
Rule R7 self-heal was added: on the first band refusal a lane quarantines itself by writing its own observation second and clearing the lane, so gate reverts rather than quoting a disowned mark, and the band then widens against the lane’s own gap. No new storage, no read-path change and no happy-path gas cost. The widen lever was hardened in successive rounds, and in V5 the release and the band widen are split: the release is attestReentry, quorum-attested and anchor-bounded, while the band widen is config-only at the LISTING tier with a guardian cancel. executeFeedWiden in V5 touches configuration only.
Status
Fixed. The lever hardening is verified on the fix branch. The R7 self-heal is fixed on main and is not deployed: the oracle is not upgradeable, so it lands with the redeploy and the 37-leg repoint. The tier split ships in V5.
Rows. A-003, A-048, A-054, A-055, A-056, A-060, A-061, A-063, A-064, A-066, A-067, A-068, A-077, A-078, A-085, A-164, A-179, A-588, A-693 (19 rows)
F-02 Role bootstrap and the quorum check were fail-open before the signer set was seeded
| Severity | Status | Class | Component |
|---|---|---|---|
| HIGH | Closed | Audit | dex-evm/src/oracles/ExternalOracleV5.sol:219-238, shared/evm/src/access/AccessControl.sol:535-545 |
Severity rationale. An uninitialized or zero-threshold oracle accepted unsigned marks, and the same bootstrap path let an owner install a new treasury owner instantly, so both the price surface and the treasury role were reachable without the intended quorum.
Description
_quorumCheck compared the recovered signature count n against the threshold k with n < k. At k == 0 an empty signature set satisfies the comparison and the loop body never runs, so the check passes. Nothing forced initialize to run before registerFeed or push, so an oracle that had not been seeded, or one whose threshold was zero, accepted marks with no signatures at all.
// dex-evm/src/oracles/ExternalOracleV5.sol:225 (_quorumCheck)
uint256 n = sigs.length / 65;
if (sigs.length % 65 != 0 || n < k) revert Err.NotAuth();
address prev;
for (uint256 i; i < n;) {
uint256 off;
unchecked {
off = i * 65;
}
address rec = ECDSA.recoverCalldata(digest, sigs[off:off + 65]);
if (rec <= prev || !set[rec]) revert Err.NotAuth();
prev = rec;
}On the shared access-control side, bootstrapRole(TREASURY_OWNER) had no post-arm guard. If treasuryOwnerBootstrapped was never spent, the owner could install a new treasury owner immediately, bypassing the seven-day rotation and the incumbent veto. bootstrapRole(FACTORY) was likewise ungated, and armQuorumPolicy did not require a non-zero factory, so a quorum policy could be armed around an unset or burnable factory role.
Impact
Unsigned marks on an unseeded oracle mean the price surface has no authority behind it. The bootstrap gap is an instant, veto-free replacement of a privileged role that the rotation path exists to make slow and contestable.
Exploit scenario
- An oracle is deployed but
initializehas not run, or its threshold is zero. - A caller submits a push with an empty signature blob.
_quorumCheckreturns without reverting and the marks are stored.
Remediation
_quorumCheck now rejects k == 0 and empty signature sets, and _register refuses to register against an unseeded signer set. bootstrapRole reverts post-arm for both TREASURY_OWNER and FACTORY, and armQuorumPolicy requires a non-zero factory. Covered by the quorum and oracle test pins.
d04a781, 08df008, d35ca5f, 456b10a
Status
Closed.
Rows. A-1002, A-1136, A-1204 (3 rows)
F-03 Pools created through the permissionless factory path were born with no authority and no fee sink
| Severity | Status | Class | Component |
|---|---|---|---|
| HIGH | Fixed | QA | dex-evm/src/Pool.sol:116-128, dex-evm/src/PoolFactory.sol:113, dex-evm/src/PoolFactory.sol:167-174 |
Severity rationale. The permissionless creation path was reachable by anyone at the shipping configuration and every pool it produced was either unusable or governed by the wrong key, so likelihood was certain and the impact reached protocol fee routing and pool control.
Description
createPool was permissionless, but Pool.initialize wrote only baseToken, wnative, flowCooldownSecs, factory and initialized while still accepting protoSharePct. A pool created that way accrued a protocol share to an unset treasury address.
// dex-evm/src/Pool.sol:117
function initialize(address baseToken_, address wnative_, IPool.FeeParams calldata feeParams)
external
{
if ($.initialized) revert Err.InvalidState();
// ONE definition of the fee-param bounds, shared with `adminSetFeeParams`.
PoolConfig.setFeeParams($, feeParams);
$.baseToken = baseToken_;
$.wnative = wnative_;
$.flowCooldownSecs = C.DEFAULT_FLOW_COOLDOWN;
$.factory = msg.sender;
$.initialized = true;The same path produced a pool with zero listed assets whose twenty configuration entrypoints were all gated on AccessControl(AC).owner(), so the deployer who paid for the proxy could never list an asset: the deployer address was never persisted. Where the deployer could name itself through _requireAuthoritySelfNamed, _registerPool still branded the pool official because official status keyed on the creator alone, producing a pool inside officialPools on which the owner multisig held no write at all. Adjacent defects on the same surface: initialize did not reject baseToken == 0, donate skipped the seal and allowlist gates that deposit carried, the donate-back sentinel pinned a static key instead of resolving AccessControl.owner(), setProtocolDeployer was instant while its factory sibling was timelocked, receive() accepted stray native value with no ledger, adminSetDeadSeedPow10 allowed a bounded pre-seed grief, and the bootstrap seal was read from a new pool bit while live pools had been sealed through the Admin mapping.
Impact
A stranger-created pool was a brick or, in the self-naming case, an official-branded pool outside the owner’s write surface that could list assets off its own oracle, seal bootstrap and take third-party deposits. Pools created before the fix also routed a protocol share to an unset address.
Remediation
initialize now pins $.treasury from AccessControl.treasury() and reverts on zero, and rejects baseToken == 0. The creator is recorded as poolAdmin, so a third-party pool is configurable by the party that deployed it. Official status is no longer derived from the creator: _requireAuthoritySelfNamed does not exist and only an owner-only setOfficial grants the brand, with _assertOfficialShape pinning the protocol treasury. donate shares deposit’s seal, allowlist and cap gates, the donate-back sentinel resolves through Admin, protocolDeployer and setProtocolDeployer are deleted, receive() is gated to wnative with a sweep arm, and one Admin.bootstrapSealed mapping is the single seal latch.
Status
Fixed. Verified 2026-09-11; the residual informational rows were closed on the 2026-09-14 review.
Rows. A-211, A-212, A-232, A-783, A-543, A-666, A-527, A-526, A-542, A-681, A-524 (11 rows)
F-04 Hook ledger writers booked reserves, liabilities and the liquidity index without proving the underlying balance moved
| Severity | Status | Class | Component |
|---|---|---|---|
| HIGH | Fixed | QA | dex-evm/src/Pool.sol:765-820, dex-evm/src/Pool.sol:709-753, dex-evm/src/Pool.sol:682-685 |
Severity rationale. hookWriteDown wrote the absorbing index state on the exact total-loss case that its own specification claimed to exclude, and the surrounding writers booked value on unproven balances, so a single venue loss could wipe a leg’s claim permanently.
Description
hookWriteDown applied its minLiab floor under if (liabAfter > 0 && idx > 0). At the total-loss case liabAfter is zero, so the floor was skipped and the function wrote newIdx = 0, the absorbing state.
// dex-evm/src/Pool.sol:651
uint256 liabAfter = liabBefore - cutLiab;
if (liabAfter > 0 && idx > 0) {
uint256 minLiab = (liabBefore + idx - 1) / idx;
if (liabAfter < minLiab) {
liabAfter = minLiab; // <= liabBefore for idx >= 1, so cutLiab only ever shrinks
cutLiab = liabBefore - liabAfter;
}
}The other writers on the same ledger shared the shape. hookCreditYield booked reserves, liabilities, invested and the index with no balanceOf proof, so an honest-but-buggy or donation-inflated venue NAV was sufficient to inflate the index, and it carried no HALT_MASK gate while hookDeploy did. hookDeploy booked invested with no delta proof, so a fee-on-transfer leg overstated the invested balance and blocked withdrawals. hookRecall proved its balance against the raw token, so the native sentinel spelling always read zero and reverted. collectProtocolFees lacked the requireNoFlash guard its four sibling hook writers carried.
Impact
A total venue loss set the liquidity index to zero and bricked the leg’s claim irrecoverably. Index inflation from an unproven credit was socialized across LPs at up to the daily rate cap. The hookDeploy and hookRecall defects were liveness only: blocked withdrawals and a reverting native recall path.
Remediation
The minLiab floor now applies unconditionally whenever idx > 0, so a total loss no longer writes a zero index; flooring liabAfter rather than the index preserves S*idx/WAD <= L. hookCreditYield proves bal >= R_liq + protocolFees + amount before any book move, no longer touches invested, and refuses new credit under HALT_MASK while recall and write-down stay open for exit. A hook cannot be installed on a TOKEN_EXOTIC_BIT leg, so hookDeploy never books a taxed push. hookRecall balances the wrapped leg token. collectProtocolFees calls requireNoFlash.
Commits: e36dbb86.
Status
Fixed. Verified 2026-09-10 and 2026-09-11.
Rows. A-748, A-263, A-716, A-737, A-738, A-522 (6 rows)
F-05 Genesis deploy scripts could brick an immutable mainnet deployment
| Severity | Status | Class | Component |
|---|---|---|---|
| HIGH | Fixed | QA | dex-evm/script/OracleV5Deploy.s.sol:119, dex-evm/script/PoolDeploy.s.sol:223, dex-evm/script/ArcRiskRestore.s.sol |
Severity rationale. The governance-delay omission ran against an immutable AccessControl, a beacon and a burned CREATE3 salt, so one mainnet run at the wrong schedule was unrecoverable.
Description
OracleV5Deploy._deployTier never called _govDelays(), so a class=mainnet run accepted the retired seven-tier schedule. The V4 script called it; the V5 branch dropped the call between the chain assertion and the first broadcast.
// dex-evm/script/OracleV5Deploy.s.sol:86
function _deployTier() internal returns (address ac, address oracle) {
_assertChain();
string memory outPath = _outPath();
require(
!_oracleLive(outPath) || vm.envOr("REDEPLOY", false),
string.concat("already deployed: ", outPath)
);
uint256 pk = vm.envUint("DEPLOYER_PK");
address deployer = vm.addr(pk);
address guardian = vm.envAddress("GUARDIAN");
require(guardian != address(0) && guardian != deployer, "GUARDIAN must be independent");Four further ceremony defects sat on the same scripts. PoolDeploy bound REF_ORACLE with no provenance check, so a wrong but valid oracle address was accepted as the reference tier. deployCore validated .depositors mid-broadcast, after the core singletons were already live, so a missing list left a half-deployed genesis. ArcRiskRestore gated the risk op on kappa alone in both request() and execute(), so a cap-only backfill could never arm. The deploy record for the target chain was a twin of another chain’s record, and DEPLOY.md named retired scripts, the retired governance-delay schedule and stale keeper configuration files.
Impact
A mainnet genesis run could have provisioned the wrong immutable governance schedule, bound the wrong reference oracle, or stopped half way with the core singletons already live and no path back.
Remediation
_govDelays now runs before the first broadcast. The reference tier is bound off its own record with class-aware lanes and the V4 reference record lands where PoolDeploy reads it. .depositors is validated pre-broadcast. ArcRiskRestore gates UPDATE_RISK on the whole payload. The chain deploy record is an explicit zero-address scaffold that the ceremony overwrites, with PoolDeploy refusing a zero .ac or .oracle, and DEPLOY.md was swept for the retired scripts, the three-tier delay table and the live generator names.
Commits: 921bbdbf, 719da508, 60adb83a, 99c6b56b, 3e761867, 10a8b1e9, acdc119b, efa9d800, 1f721f0c, 91e73452.
Status
Closed. Script fixes landed 2026-09-15; the deploy-record scaffold was verified on the 2026-09-14 review.
Rows. A-1100, A-1312, A-1106, A-1105, A-1113, A-1111, A-1112, A-599 (8 rows)
F-06 Oracle signer and reference governance had no tests, and revoke could leave the quorum permanently unsatisfiable
| Severity | Status | Class | Component |
|---|---|---|---|
| HIGH | Fixed | QA | dex-evm/src/oracles/ExternalOracleV5.sol:823-966, dex-evm/src/oracles/ExternalOracleV5.sol:758-772, dex-evm/test/unit/ExternalOracleV5.t.sol:333-340 |
Severity rationale. An untested governance lifecycle on an immutable oracle plus a revoke path that can raise the threshold above the signer count makes a permanent, unrecoverable authorization brick reachable through a routine key rotation.
Description
Every V5 signer and reference-signer governance function other than revokeSigner was uncovered: the grant batch execute and cancel paths on both tiers, requestRefSignerGrantBatch, revokeRefSigner, both threshold-decrease lanes and clearQuarantine. A guard could be deleted and CI would stay green.
_revoke removed the signer from the set and the enumeration list and never lowered the threshold, so revoking from a k-of-k set left threshold > signerCount and every quorum check reverted NotAuth forever. The existing test_revoke_and_enumerate asserted the shrink green with no quorum floor.
// dex-evm/src/oracles/ExternalOracleV5.sol:757
function _revoke(mapping(address => bool) storage set, address[] storage list, address a, bool ref)
private
{
if (!set[a]) return;
set[a] = false;
uint256 n = list.length;
for (uint256 i; i < n; ++i) {
if (list[i] == a) {
list[i] = list[n - 1];
list.pop();
break;
}
}Impact
A revoke taken from a k-of-k roster permanently disables mark pushes on that oracle instance. The absent tests meant the same class of regression could land unnoticed on any of the ten untested lifecycle entrypoints.
Remediation
Revoke below the threshold is now refused on both tiers, so a k-of-k roster must grant before it revokes. Nine lifecycle tests cover the grant, cancel, revoke and threshold paths, and clearQuarantine was deleted in favour of a latching quarantine bit.
Commits: 44b8858c, e20dc680, 761a7f07.
Status
Closed.
Rows. A-1200, A-1201 (2 rows)
F-07 The release shipped an unratified governance ladder and a beacon upgrade delay that contradicted it
| Severity | Status | Class | Component |
|---|---|---|---|
| HIGH | Fixed | QA | dex-evm/src/oracles/OracleBeacon.sol:39, dex-evm/src/Admin.sol:246-284, shared/evm/src/Constants.sol:28-53 |
Severity rationale. The delay a beacon reads at construction is immutable for the life of that beacon, so shipping it before the mechanism was ratified would have frozen the wrong upgrade lane into the deployment.
Description
The release tip carried a paused three-tier governance-delay ladder together with a V5 beacon whose constructor pinned the upgrade delay to the GOVERNANCE tier, while the mechanism decision itself was still open. DELAY_UPGRADE is set once in the constructor.
// dex-evm/src/oracles/OracleBeacon.sol:34
constructor(address ac_, address impl_) {
if (ac_ == address(0) || impl_ == address(0)) revert Err.ZeroAddr();
if (ac_.code.length == 0 || impl_.code.length == 0) revert Err.NotCode();
AC = ac_;
implementation = impl_;
DELAY_UPGRADE = SC.delayOf(AccessControl(ac_).GOV_DELAYS(), SC.Tier.GOVERNANCE);
}Impact
Deploying against an unratified ladder would have written a governance schedule and an oracle upgrade lane that the eventual decision did not match, on contracts where neither is changeable after deployment.
Remediation
The owner ratified an upgrade-only beacon with the oracle implementation at the LISTING tier, the guardian veto kept, the UPDATE_ORACLE repoint lane deleted and the three-tier ladder unpaused. DELAY_UPGRADE now reads the LISTING tier off the timelock op word, the UPDATE_ORACLE lane and its enum slot are gone with every consumer renumbered, the shared constants natspec states the ratified ladder, and the delegatecall overhead was measured in a test.
Commits: e4c3cb19, 1730979d, 21fea2d0, 6325fe91, a860d45f, cafa28d.
Status
Closed. Ratified 2026-09-14, verified on the same review.
Rows. A-1400 (1 row)
F-08 Guardian and foreign-pool authority lanes reached pools the protocol does not administer, allowing a permanent brick and an LP-facing rug
| Severity | Status | Class | Component |
|---|---|---|---|
| HIGH | Fixed | Audit | dex-evm/src/Admin.sol:309, dex-evm/src/Admin.sol:467, dex-evm/src/libraries/PoolConfig.sol:282-286 |
Severity rationale. A single guardian key could, without a timelock and without cooperation from the pool that owns the assets, put a foreign pool’s leg into a state that only a CRITICAL-tier operation could restore, and then veto every restore attempt.
Description
Admin exposes several levers whose authority check was protocol-scoped rather than pool-scoped. collapseAnchor was reachable by the protocol guardian on any pool, including pools with their own poolAdmin, and its mandatory companion halt makes the leg’s mark meaningless until an anchor is re-established. The only un-collapse path is executeAnchorUpdate at CRITICAL tier, and cancelTimelock took the same guardian-or-admin check and deletes a pending operation in both directions, so the same principal could cancel every re-request. Re-request is permitted, so the loop was unbounded.
// dex-evm/src/Admin.sol:309
function cancelTimelock(address pool, uint8 opType, bytes32 subject) external {
_onlyGuardianOrAdmin();
bytes32 key = _keyOf(pool, opType, subject);
if (pendingOps[key] == 0) revert Err.NoPending();
delete pendingOps[key];
delete pendingData[key];
emit TimelockCancelled(pool, key, opType);
}
// dex-evm/src/Admin.sol:467
function collapseAnchor(address pool, address token, address newAnchor) external {
_onlyGuardianOrAdmin();
IPool(pool).adminCollapseAnchor(token, newAnchor);A second defect sat on the release side. unhaltAsset took a caller-supplied src mask and PoolConfig.setHalt cleared whatever bits the mask named with no record of which principal set them, so a foreign pool admin calling unhaltAsset(pool, token, HALT_MASK) cleared the guardian’s halt alongside their own. On a pool with a non-zero poolAdmin, collapseAnchor (guardian-or-pool-admin) and unhaltAsset (pool-admin) were reachable by one principal, which is the collapse-then-unhalt rug against that pool’s own LPs. Separately, the guardian held an un-halt edge on foreign pools at all, contradicting the documented HALT / TIGHTEN / CANCEL one-direction invariant in shared/evm/src/AccessControl.sol.
setRiskFences was opt-in with no opt-out: it reverted on maxDeltaBps == 0 and no clearing function existed, so once a foreign pool admin armed fences on a leg, the protocol risk steward held permanent write access to that leg’s minLiquidity, minFee, vega and haircut. The bootstrap instant listing lanes also never expired and the guardian could not seal them.
Impact
On any pool with an independent administrator, a protocol guardian key could halt a leg indefinitely, and the pool’s own administrator could clear a guardian halt that existed for a reason. Neither direction required a timelock, so the normal governance delay offered no window to react. Withdrawals on a collapsed leg are gated, so the brick is a fund-availability event, not only a liveness one.
Exploit scenario
- A pool with a non-zero
poolAdminlists a leg and takes third-party liquidity. - The guardian calls
collapseAnchoron that leg. The mandatory halt lands with it and the mark is no longer meaningful. - The pool’s administrator requests
executeAnchorUpdateat CRITICAL tier to restore the anchor. - The guardian calls
cancelTimelockon that key. The operation is deleted before it can mature. - Steps 3 and 4 repeat without bound. The leg stays halted and withdrawals stay gated.
Remediation
Cancel is now seat-routed: on a foreign pool only the pool’s own seat can cancel, and collapseAnchor refuses foreign pools outright, so the collapse lever exists only where the protocol is the administrator. The guardian no longer holds an un-halt edge on foreign pools. Halts are refcounted by source, so clearing one source’s bit cannot clear another’s. clearRiskFences was added so an armed fence can be revoked. sealBootstrap is guardian-or-owner, a one-way tightening of the listing lane, so the instant bootstrap lanes can be closed before a pool opens to public liquidity. The haltAsset natspec was rewritten to state the single and combined HALT_MASK behaviour for governed pools and NotAuth for foreign pools; the governed path already refcounted by source, so no code change was needed there.
Commits: e13745ae, f5c281f7, 78153012, fb975b2b.
Status
Fixed. Foreign-pool cancel and collapse verified 2026-09-10; guardian seal verified 2026-09-11; natspec verified 2026-09-11. Three rows are closed rather than fixed: the partial batch sweep (try/catch plus BatchLegSkipped, no retry queue) is specified behaviour and was closed as a design note on 2026-09-14; the sweep-completeness caveat and the missing AssetHalted emission on collapse and batch legs were closed as observability-only with no fund path. Guardian sealing of a not-yet-listed sentinel pool is an accepted residual: foreign and predicted pools are not guardian-sealable, and the listing ceremony seals in one broadcast.
Rows. A-207, A-214, A-215, A-217, A-219, A-264, A-549, A-691, A-744, A-779, A-942 (11 rows)
F-09 The Rust pricing mirror settled a sell leg in spoke scale against a base-scale hub book
| Severity | Status | Class | Component |
|---|---|---|---|
| HIGH | Fixed | Audit | core/src/route.rs:629, core/src/pricing.rs:176-206, dex-evm/src/libraries/Pricing.sol:562-579 |
Severity rationale. The mirror is the quote surface’s reference implementation, and a mixed-decimal sell produced a settled amount wrong by the decimal shift between the two legs on every route through a hub whose book is held in a different scale.
Description
core quotes a leg in the spoke’s scale and then settles against the hub book. For a sell the gross quantity was passed to Endpoint::settle without being re-denominated into the hub’s raw units, while the Solidity path applies _legScaleOut before _settleQuote. The test fixture that would have caught it carried a hub with zero liabilities, which turns the coverage wall off, so the parity wall was effectively disabled on exactly the path that diverged.
// core/src/route.rs:629
// Both quantities leave the pricer in the SPOKE's scale; a sell then shifts to the base's.
let (net, gross) = if selling {
(
dec_shift(q.amount_out, s.decimals, base_dec),
dec_shift(q.gross_out, s.decimals, base_dec),
)
} else {
(q.amount_out, q.gross_out)
};Two adjacent mirror defects sit on the same surface. The cov_toll kappa == 0 arm returned gross_out directly while the Solidity V5 gross-cap path takes a different branch, so the two implementations disagreed on the kappa == 0 edge. Separately, both sigma terms of the spread were documented on the BPS scale while the code prices one percent of sigma on the PBPS scale, a hundredfold difference between the stated and the implemented semantics, and the research simulator ran STALE_Z = 100 against a chain value of 472 with its parity gate pointing at a deleted path.
Impact
A sell through a hub whose book is in a different decimal scale settled against the wrong quantity, which moves the coverage wall and the settled output. The documentation divergence on vega meant an operator reading the parameter tables would have chosen a vega a hundred times away from the intended one.
Remediation
Pricing::walk and Quote::settled were split, and a sell’s gross is rescaled into the hub’s raw units before Endpoint::settle, mirroring _legScaleOut. The hub fixture carries liabilities and kappa = 600 again, so the coverage wall is on in the parity tests, and directed sell, buy and round-trip vectors were added. The kappa == 0 short-circuits were dropped so the Rust arm follows the same branch as Pricing.sol. Vega is documented once as PBPS-scaled across contract, SDK, core and the public parameter docs; the owner decision is to keep one percent of sigma on the PBPS scale for the phase-one stable set and to rescale only alongside the volatile-core fit, so there is no pricing change. Research constants are pinned to Pricing.sol and the parity gate re-points at the sibling checkout.
Commits: 6440d93, 4485e8d, b75f647, 3f71c59, 35c8e7ad, 0d1990f, 83d87923, 8c21532, fac1138, 524cec4, 52f999f, e16806e.
Status
Fixed, verified 2026-09-14. The kappa == 0 parity row was conditional on the matching contract change landing.
Rows. A-005, A-1109, A-1411 (3 rows)
F-10 The front end transaction builder debited the full typed amount on the first leg and pinned token decimals by symbol
| Severity | Status | Class | Component |
|---|---|---|---|
| HIGH | Fixed | Audit | front/src/components/features/swap/SwapForm.tsx:1121, front/src/config/testnet-tokens.ts:205, front/scripts/lib/livePools.ts:168 |
Severity rationale. Both defects produce a correctly signed transaction carrying the wrong amount, and the decimal one is off by a factor of 1e12 on a chain where the affected token is a primary leg.
Description
On the market-first LP deposit route, leg zero was given the full typed input amount while later legs were sized from float values carried on the route steps rather than from the previous leg’s floored output. The multi-leg deposit therefore did not compose: the first hop spent the whole budget and the later hops were sized from numbers that had already lost precision.
// front/src/components/features/swap/SwapForm.tsx:1121
.map((st, i) => ({
pool: (st.poolAddr ?? poolAddr) as Address,
tokenIn: tokenAddr(st.tokenIn),
tokenOut: tokenAddr(st.tokenOut),
amountIn:
i === 0 && exactAmountIn !== undefined
? exactAmountIn
: stepBig(st.amountIn, st.tokenIn),
quotedOut: stepBig(st.amountOut, st.tokenOut),
minOut: stepBig(st.minOut, st.tokenOut),
})),Token metadata was keyed by symbol, and the table pinned USDC and EURC at six decimals for every chain. BSC USDC has eighteen. Every encoded amount and every LP floor derived from that table was out by 1e12 on that chain. Separately, the router harnesses hand-rolled curveToWire, including a median of 5000 and a written sentinel boundary, instead of calling the SDK, so the harness and the shipped encoder could disagree.
Impact
A user submitting a market-first LP deposit could send a transaction whose first leg consumed the entire typed amount. On a chain where the symbol-keyed decimals are wrong, an amount intended as one unit encodes as 1e12 units or the reverse, depending on direction, and the LP floors derived from the same metadata are wrong by the same factor.
Remediation
Encoded amounts now equal the typed amount in the token’s decimals for that chain, and every hop after the first is sized from the previous hop’s floored output rather than from a float. Token decimals resolve per chain instead of per symbol. The router harnesses call the SDK encoder, removing the hand-rolled duplicate.
Commits: c24b397, 828dc37, a2d8219, c2a7783.
Status
Fixed 2026-09-16.
Rows. A-1584, A-1585, A-1586 (3 rows)
F-11 The client-side quote read chain risk parameters that did not match the chain, first failing open on the coverage wall and then failing closed on every pool
| Severity | Status | Class | Component |
|---|---|---|---|
| HIGH | Fixed | Audit | front/src/hooks/useAllPools.ts:92, front/src/hooks/usePoolData.ts:87, front/src/config/aimm-profiles.ts:69 |
Severity rationale. The front end quotes off an in-browser replica of the on-chain pricing law, so any divergence between the replica’s parameters and the chain’s is either a systematically wrong price shown to every user or a fleet-wide loss of quoting, both reachable with no attacker and no special state.
Description
The pool state builder defaulted kappaCovBps to 0 whenever the parameter was absent from the multicall result. Zero is the value that disables the coverage wall, so the replica quoted a zero coverage toll on legs where the chain was charging 600 to 2500 basis points. The default was fail-open in the one direction that matters: the client understated the cost of the trade it was about to send.
// front/src/hooks/useAllPools.ts:92
const hub: PoolState['hub'] = baseRow
? {
res: baseRow.amount,
liab: baseRow.liabilityAmount,
vegaBps: liveProfile(cfg.tag, baseRow.params)?.vega ?? 0,
kappaCovBps: baseRow.params?.kappaCovBps ?? 0,
}
: undefined;The first remediation removed the fail-open default but routed the value through a numeric guard that rejects bigint. The SDK ABI decoder returns uint as bigint, so the guard rejected every well-formed read: the wall resolved as unknown, every pool degraded to the illustrative (non-quotable) path, and the fleet had zero quotable pools. That regression was tracked separately and fixed by giving the decoder a typed asset conversion and a bigint-aware resolver.
A third divergence sat in the static profile mirror: aimm-profiles.ts still carried vega 10000 and minimum fees of 50, 1032 and 1000 basis points after the chain had moved to 3000 to 4500 and 90, 1500 and 1200.
Impact
With the fail-open default in place the displayed quote understated the toll the chain would charge, so the user saw a better price than the one the transaction would settle at. With the regression in place the product could not quote at all. The stale profile mirror moved the replica’s spread and floor away from the chain’s on every leg it covered.
Remediation
The fail-open default is gone, the coverage wall is a required input on every leg, and the resolver accepts the bigint the decoder actually produces and floors it. The profile mirror is re-derived per class from chain values. Regression tests pin the decoder conversion and the wall resolution.
Commits: c9b0b44a, f40a8490, 39bb177, edddb2c2.
Status
Fixed. The fail-open default and the profile drift were closed on 2026-09-10 and 2026-09-11; the bigint regression was found by the 2026-09-11 validation pass and fixed in the same wave.
Rows. A-104, A-105, A-900 (3 rows)
F-12 The safety console served a stale oracle ABI, leaving the guardian pause selector empty during a live incident
| Severity | Status | Class | Component |
|---|---|---|---|
| HIGH | Fixed | Audit | front/src/pages/safety/SafetyLevers.tsx:107, front/src/pages/SafetyPage.tsx:567, front/src/pages/safety/safetyModel.tsx:300 |
Severity rationale. The emergency console is the operational path to the fail-closed levers; an empty feed selector removes the intended way to use them at exactly the moment they are needed, and the workaround is a hand-built raw transaction.
Description
The console resolved the oracle ABI by name and was served the V1 interface for a V4 address. getFeedIds() does not exist on any oracle generation and reverts on V4, so the “Pause feed” selector enumerated nothing and the guardian’s own emergency lever could not be driven from the emergency UI. During a live incident the affected feed had to be paused by a raw contract call instead.
// front/src/pages/safety/SafetyLevers.tsx:107
// A-161: the roster is a BUILD-TIME fact (SDK lane map x venue feedIds), never an on-chain
// enumeration - no oracle generation exposes `getFeedIds()`, and asking a V4 for it reverts, which
// is what left this selector (the emergency pause lever) permanently empty. `laneFeedMeta` is the
// same roster the transparency page reads; taking it whole (via `useOracleData`) would also drag
// this mount into an explorer tx-list fetch, a push decode and an indexer roster poll it has no
// use for, so only the roster + ONE getFeed multicall are borrowed.
const feedMeta = useMemo(() => laneFeedMeta(chainId), [chainId]);Four smaller console defects sit on the same surface. The pause button stayed re-fireable once a feed was already paused, producing an inert success and a duplicate event at gas cost; the admin side gated correctly. The unhalt copy claimed to clear every halt source, while an anchor halt clears only on re-attestation. The veto card offered cancel actions that always revert outside the veto window, with no getter to show a spent one-shot. The confirmation dialog rendered a human-readable summary rather than the signable bytes, with no hex payload, chain id or value; the wallet remains the signer, so this misleads rather than misauthorises.
Impact
The guardian could not pause a feed from the console. The remaining rows cost gas on inert repeats and misinform the operator about what a lever will do, without changing what the chain enforces.
Remediation
The console no longer enumerates feeds on chain. The roster is the build-time lane map plus one getFeed multicall issued against a V4 write ABI; 26 feeds were verified enumerated with the expected single paused feed. The pause controls now gate on the read-back paused state.
Commits: 48587b54.
Status
Fixed. Verified by the 2026-09-11 validation pass. The copy, veto-card and confirmation-preview rows were closed as informational on 2026-09-09 and 2026-09-10 under the low-minimum bar.
Rows. A-161, A-166, A-505, A-769, A-772, A-809 (6 rows)
F-13 Coverage-sensitive LP paths settled at per-slice rates, letting an exit outrun the pool’s own haircut
| Severity | Status | Class | Component |
|---|---|---|---|
| HIGH | Fixed | Audit | dex-evm/src/libraries/PoolLiquidity.sol:404, dex-evm/src/libraries/PoolLiquidity.sol:326-333, dex-evm/src/libraries/PoolIOLib.sol:159-190 |
Severity rationale. Under-covered pools are the state in which the haircut exists at all, and the escape was measured at the live coverage setting on both LP cross paths, so an ordinary LP could extract value from the remaining LPs with no privileged access.
Description
The pool applies a haircut when coverage is below par. The settlement arithmetic on the cross-asset LP paths took the source leg’s own coverage rather than the pool-level rate, so an exit split into slices converged on a better rate than a single exit of the same size. Measured at the live setting the escape was +61.6% on both LP cross paths.
// dex-evm/src/libraries/PoolLiquidity.sol:404
IPool.Asset storage assetFrom = $.assets[ctx.fromTk];
// withdrawValue ≤ liabilities is enforced at the quote (no clamp): the full face is always burned.
if (ctx.fromTk == ctx.toTk) {
assetFrom.reserves -= uint128(ctx.amt);
assetFrom.liabilities -= uint128(ctx.withdrawValue);
} else {
IPool.Asset storage assetTo = $.assets[ctx.toTk];
assetFrom.liabilities -= uint128(ctx.withdrawValue);
if (ctx.protoFee > 0) $.protocolFees[ctx.toTk] += ctx.protoFee;
assetTo.reserves -= uint128(ctx.amt + ctx.protoFee);
accrueLpFee(assetTo, ctx.toTk, ctx.lpFee);
}Three further defects sat on the same ledger. The mark cap that keeps an LP conversion at or below the fair oracle rate was applied at the cross-withdraw and liability-swap entrypoints only, not inside the swap pricing core, so a same-asset withdraw followed by a swap reproduced the same end state with the cap absent. That path was reproduced 25 out of 25 runs at the live preset and widened with volatility, from +9 basis points at zero sigma to +177 basis points at 5% sigma. The cap also capped the output while keeping the pre-cap fee, overcharging at dust scale. And donate booked its face at par where the specification requires face scaled by the pool coverage rate, moving surplus from every other leg’s LPs to the donor leg.
Structural observations were recorded rather than fixed. Reference bands are enforced per node, so on a spoke-hub-spoke cross both guarded nodes can sit at the same edge of their own band and the worst-case composed error is about twice the band. No independent reference exists for a composed cross rate, so per-node guarding is the strongest available control. The remaining rows on this surface are display and documentation gaps below the reporting bar: previewWithdraw ignoring the halt and liquidity floor, a natspec claim that fee accrual never reverts, an incomplete file-header event list, a dust-scale fee skip, and a transient-cache hit that skips a re-gate on a path with no reachable oracle write.
Impact
An LP in an under-covered pool could recover more than the haircut allowed by slicing the exit or by routing the same economic move through a swap, in both cases at the expense of the LPs who stayed. The donate mispricing transferred surplus between legs. The accepted reference-band bound is a known ceiling on composed cross accuracy, not a leak.
Exploit scenario
- The pool sits under-covered, so exits are haircut.
- An LP splits a withdrawal into slices rather than exiting once, and each slice settles at the source leg’s coverage instead of the pool rate.
- Alternatively the LP performs a same-asset withdraw, which is exactly coverage-preserving and reads no oracle, and then sells the withdrawn asset back through
Pricing.swap, which prices at the skew-anchored mid with no mark cap. - Either route lands the same end state as the capped cross exit at a better rate, bounded by the actor’s own position and requiring an under-covered source leg.
Remediation
Cross and liability settlement now use the pool coverage rate and never the source leg’s. The sell arm of the swap pricing core clamps execution to the mark, with the off-chain integer mirror pinned to the same behaviour. Capped outputs pro-rate their fees. donate books face at the pool coverage rate. Pool-level coverage replaced the per-leg haircut in the mint rate, so surplus enters the pool rate instead of being stranded. The withdraw liquidity gate was aligned with the settlement gate and the vault hook now measures the realised withdrawal delta rather than trusting the requested amount. Coverage proofs pin the residual escape below 1%.
Commits: b563405f, 9a9deda0, 150a94e8, e36dbb86, 84fdfa9b, 68077778, 04245d1.
Status
Fixed, verified 2026-09-10 and 2026-09-11. The per-node reference-band bound is accepted and recorded, with composed drift priced by a coverage wall at least as large as the larger of the reference band and the per-push deviation band, re-verified 2026-09-14. The remaining informational rows were closed on 2026-09-10 under the low-minimum bar.
Rows. A-001, A-025, A-026, A-199, A-231, A-250, A-540, A-556, A-560, A-610, A-665, A-684, A-907 (13 rows)
F-14 An off-factory beacon clone was a fully attacker-governed pool running the real implementation
| Severity | Status | Class | Component |
|---|---|---|---|
| HIGH | Fixed | Audit | dex-evm/src/Pool.sol:152, dex-evm/src/PoolFactory.sol:300-307, dex-evm/src/Admin.sol:155 |
Severity rationale. The clone answered the real access-control, admin and flash singletons, inherited beacon upgrades and emitted the protocol’s full event stream while remaining invisible to the official pool registry, so a third party could present a pool they governed as one of ours.
Description
Pool.initialize was unauthenticated and the factory is itself the beacon, so anyone could deploy an ERC-1967 beacon proxy against it and initialize the result. Before the authority change such a clone was inert because ownership resolved unconditionally to the access-control owner; adding a per-pool admin made it operable.
The first fix bound initialization to the beacon by comparing msg.sender to the beacon slot. That bind is circular: the attacker supplies their own beacon, which returns the real implementation, and the check passes.
// dex-evm/src/Pool.sol:152
if ($.initialized) revert Err.InvalidState();
address beacon;
assembly ("memory-safe") {
beacon := sload(_BEACON_SLOT)
}
if (msg.sender != beacon) revert Err.NotAuth();
$.poolAdmin = poolAdmin_;
if (baseToken_ == address(0)) revert Err.ZeroAddr();Three authority defects accompany it. Permissionless syncOfficial de-branded every official pool after a treasury rotation, which made the router revert with UnknownPool and silently emptied the operational halt roster. The soft per-token pool cap began binding official pools, so 128 squatter clones could keep protocol pools out of token enumeration and out of the asset-halt script’s roster. And SWEEP(NATIVE) reverted on pools with no wrapped-native token configured, while on pools that had one the native and wrapped sweeps shared a single queue key.
Impact
A third-party-governed contract could pass as a protocol pool to any integrator that trusted the implementation and the singletons rather than the official registry. The treasury-rotation and enumeration defects degraded routing and the emergency asset-halt roster without any attacker. The sweep key defect blocked a native sweep on part of the fleet.
Exploit scenario
- The attacker deploys their own beacon whose implementation getter returns the protocol’s real pool implementation.
- They deploy an ERC-1967 beacon proxy pointing at that beacon and call
initializefrom it, passing themsg.sender == beaconbind. - They are now the pool admin of a contract running the real implementation, answering the real access-control, admin and flash singletons, while absent from both the all-pools and official-pools registries.
Remediation
initialize is anchored to both the beacon and the factory recorded in access control, which closes the circular bind. A previousTreasury() shield at least as long as the pool governance delay plus grace keeps official branding across a treasury rotation. Asset-halt enumeration is restricted to official pools and a squatter leg is skipped with an event rather than reverting the batch. SWEEP is keyed on the raw token.
Commits: 2077627d, 49b2451, cb95b49, f296fe97, 149fb16e, 04aa4c09, cf5bfc69, 9e024ca7.
Status
Fixed. The authority anchor and the clone reopen are closed by the same commit and pinned by authority tests; the enumeration and sweep rows landed in the 2026-09-15 remediation wave.
Rows. A-781, A-908, A-923, A-941, A-1120 (5 rows)
F-15 Liability re-denomination applied the pool coverage rate twice and minted outside the deposit gates
| Severity | Status | Class | Component |
|---|---|---|---|
| HIGH | Fixed | Audit | dex-evm/src/libraries/PoolLiquidity.sol:546, sdk/src/pool/liability.ts:160 |
Severity rationale. The over-mint is deterministic on every call whenever pool coverage exceeds par, and the path was armed at genesis, so it dilutes existing claim holders with no attacker and no unusual state.
Description
swapLiability converts a claim on one leg into a claim on another. It scaled the incoming face by the pool coverage rate to obtain the fair input, then settled the outgoing claim at that rate a second time and credited the result as face. For a coverage rate above par the outgoing claim was over-minted by one factor of the rate, while the natspec stated the operation was coverage-neutral.
// dex-evm/src/libraries/PoolLiquidity.sol:546
// Re-denomination is a CROSS EXIT that stops short of paying out, so it settles on the same
// rate: face in, face out, both at C. It is C-NEUTRAL by construction — no reserves move and the
// spread makes B fall — so it can only raise the rate for everyone left.
uint256 fairIn = (liabIn * PoolSolvency.mintRate($)) / SC.WAD;
IPool.SwapQuote memory q = Pricing.anchorPathQuoteLp($, inTk, outTk, fairIn);
uint256 markCap = _markCap($, inTk, outTk, fairIn, q.markPrice);
if (q.amountOut > markCap) q.amountOut = markCap;The same path minted its outgoing claim without the depositor allowlist and seal checks that deposit enforces, so it was a second mint entrypoint with a weaker gate. The off-chain quote mirror also credited the raw conversion rather than the face at the coverage rate, so the client and the chain disagreed on the resulting claim.
Impact
Every liability re-denomination at a coverage rate above par minted more claim than the ledger backed, diluting the remaining holders. The missing allowlist and seal checks let a party outside the intended depositor set obtain claims. The mirror divergence meant the displayed result did not match settlement.
Remediation
The outgoing liability is re-denominated to face at the pool coverage rate after the incoming side settles at that rate, with coverage proofs pinning the result. The outgoing mint clears the depositor allowlist and the seal gate. The off-chain mirror divides the conversion by the pool rate to match, pinned by unit tests.
Commits: 97638d2e, 037e8a0a, 095a741, e19bac5.
Status
Closed.
Rows. A-1000, A-1102, A-1126 (3 rows)
F-16 Failed or stale chain sub-reads were rendered as permissive values instead of as unknown
| Severity | Status | Class | Component |
|---|---|---|---|
| HIGH | Fixed | Audit | front/src/hooks/useSafetyControl.ts:135, front/src/components/features/admin/useSafetyHistory.ts:249, front/src/components/features/oracle/oracle.tsx:244 |
Severity rationale. A partial multicall failure silently shrank the safety roster rather than reporting it, so the operator could act on a console that looked complete and was not.
Description
The safety console builds its roster from a multicall fan-out. Sub-read results were filtered for truthiness, so a failed or stale sub-read was indistinguishable from a pool that does not exist: it vanished from the roster, and downstream state fell back to permissive defaults rather than blocking the action.
// front/src/hooks/useSafetyControl.ts:135
const { data: poolRes, loading: poolsLoading } = useReadContracts({
contracts: poolCalls,
chainId,
query: { enabled: poolCalls.length > 0 },
});
const pools = useMemo(
() => poolRes.map((r) => r.result as Address | undefined).filter(Boolean) as Address[],
[poolRes],
);Two console-fidelity rows sit alongside it. The halt labels and hints were not updated after the anchor bit was split out, so the console described chain semantics that had changed. The oracle push decoder dropped any lane absent from the build-time lane map instead of surfacing it, so a newly listed lane was invisible rather than flagged.
Impact
The operator could see a roster smaller than the fleet with no indication that reads had failed, and act on it. Incorrect labels misdescribe what a lever does. Dropped lanes hide push activity from the transparency view.
Remediation
A failed or stale read now renders as unknown and disables the associated action rather than defaulting permissive. Console labels and operation keys mirror the post-split chain semantics. Unknown lanes are surfaced rather than dropped.
Commits: 2c8892a, b8b6405, 4a6041d, 1b384ff.
Status
Fixed 2026-09-16.
Rows. A-1587, A-1588, A-1589 (3 rows)
F-17 Exact-in swaps were non-monotone above the output argmax, so a larger input returned a smaller output
| Severity | Status | Class | Component |
|---|---|---|---|
| HIGH | Fixed | Audit | dex-evm/src/libraries/Pricing.sol:919-937, dex-evm/src/libraries/Pricing.sol:1001, core/src/pricing.rs:497-501 |
Severity rationale. Reachable by any taker on a live leg with no privilege and no setup, but the loss is taker-only, pool-favourable and requires the victim to sign the oversized order, which caps it below critical.
Description
The coverage wall charges a toll on the output leg that grows with the fraction of that leg’s reserves the fill consumes. Above a size threshold the toll grew faster than the gross output, so the net output fell as the input rose. The exact-in path had no cap on that region and no revert above the argmax: it reverted only where output reached exactly zero, leaving a continuous band in which paying more returned less, down to one wei.
Chain measurement on the live testnet fleet showed USDC.b to USDT returning 49,266 for a $56.5k input and 45,269 for a $58.5k input, and XAUT peaking at 11.7428 for a $65k input and returning 0 at $80k.
// core/src/pricing.rs:490-501
} else {
// base→token (buy): size the child-token volume off the mid, then traverse.
let est_out = amount_in.mul_div(WAD, mid).expect("estOut in range");
let exec = self.traverse_curve(mark, disp, start, est_out, depth, false, mid);
amount_in.mul_div(WAD, exec).expect("buy grossOut in range")
};
// `Pricing._settleQuote` (Pricing.sol:521): `quote.covToll = _covToll(cOut, …)` — selling
// delivers the counterparty, buying delivers this leg.
let out = self.settle_out(reserves, liabilities, counterparty, selling);
let cov = out.toll(gross_out);
let post_toll = gross_out.wrapping_sub(cov);Two further properties of the same toll were examined in the same pass. First, the recovery ratio of the coverage toll is rho(c) = c(-ln c - 1 + c) / (1 - c)^2, which is independent of kappaCovBps and tends to 0.5 as coverage approaches the peg: the toll can never recover more than half of the loss-versus-rebalancing it prices, at any kappa. Second, the hub leg carried kappaCovBps = 300 while the spoke ladder ran 400 and 600, so every spoke down-move drained the weaker wall first. The over-peg region is deliberately toll-free through the min(c, 1) clamp, and the toll, skew and haircut accrue as unclaimable reserve surplus rather than as a claimable index rise.
// dex-evm/src/libraries/Pricing.sol:1122-1136
function _covToll(EndpointCache memory cOut, uint256 grossOut) internal pure returns (uint256) {
if (cOut.liabilities == 0 || grossOut == 0) return 0;
uint256 r0 = uint256(cOut.reserves);
uint256 l = uint256(cOut.liabilities);
if (grossOut >= r0) return grossOut; // fully drains the leg → wall blocks the whole fill
uint256 c0 = (r0 * SC.WAD) / l;
uint256 c1 = ((r0 - grossOut) * SC.WAD) / l;
if (c0 > SC.WAD) c0 = SC.WAD;
if (c1 > SC.WAD) c1 = SC.WAD;
int256 dQ = _covQ(c0) - _covQ(c1);
if (dQ <= 0) return 0; // draining toward/at peg: no charge (charge-only)Impact
A taker who signed an order above the argmax received less than a smaller order would have returned, with the difference retained by the pool. Because the region was continuous down to one wei, an interface that sized an order from a stale or optimistic quote could route a user into it without any revert. The bounded-recovery property means the coverage toll cannot be relied on to make the pool whole against loss-versus-rebalancing at any parameterisation, and the hub-below-spoke kappa ordering concentrated the residual on hub liquidity providers.
Remediation
The chain now caps gross output and flags coverage overshoot, so a fill past the argmax is refused rather than filled at a worse price. The Rust mirror carries the same cap and its parity suite pins it. The SDK and the front end refuse a saturated quote instead of encoding it. Hub kappa dominance is enforced on-chain at the three configuration writers plus a deployment-script gate. Kappa sizing is now documented against residual loss rather than against recovery, the over-peg free drain is recorded as designed and net asset value neutral at mark for spreads of at least two theta, and pool-level coverage replaced the per-leg haircut so the surplus question is moot.
Commits: 43483cf, 577fd8d, ea7a16ce, fb975b2b.
Status
Fixed, with the recovery bound accepted as designed on 2026-09-11 and the informational design notes closed on 2026-09-09 and 2026-09-10.
Rows. A-188, A-004, A-090, A-023, A-031, A-517, A-525, A-676, A-766, A-777, A-558 (11 rows)
F-18 Pool configuration writers lacked bounds, un-stage levers and roster invariants
| Severity | Status | Class | Component |
|---|---|---|---|
| HIGH | Fixed | Audit | dex-evm/src/libraries/PoolConfig.sol:650-673, dex-evm/src/libraries/PoolConfig.sol:93-105, shared/evm/src/Constants.sol:60 |
Severity rationale. The reported perpetual admin option would have been unconditional and would have won the correction race, but the staged-admin path turned out not to exist in code, so no live configuration surface carried it.
Description
The pool administration handover was reported as holding a pendingPoolAdmin slot with no un-stage call and no expiry, which would give a staged recipient a perpetual unconditional option and let them win the seven-day correction race, contradicting the natspec at Admin.sol:961-966. Re-examination showed pendingPoolAdmin exists only as two reserved storage words in IPool.sol:252-259 and is not wired to any code path.
The surrounding configuration surface carried real defects. kappaCovBps had no upper bound in any writer while the mainnet set raised it four to eight times, putting a fat-finger inside reach. deregisterPool deleted poolToTokens without repopulating it, so setBaseToken’s completeness scan read an empty roster and failed open, and isInteriorCapable returned false for every leg, silently handing out MAX_DISPERSION_PBPS instead of the interior dispersion cap. setAssetHook never checked hook.token() == t, so a sentinel-constructed hook could block recall and deadlock a leg with non-zero inventory. collapseAnchor never wrapped newAnchor, so a guardian alias mistake reverted with InvalidAnchor and the halt never landed. On the constants side, the production LOW timelock tier was cut from one day to one hour during the delta with ADD_ASSET riding it, and the MIN_ARMED_DELAY natspec still described the old one-day floor.
// dex-evm/src/libraries/PoolConfig.sol:651-663
if (presetId == 0 || dispRefPbps == 0) revert Err.InvalidInput(); // 0 = the no-shape sentinel
// A live preset's wall-requirement is immutable across refits: assets were assigned against its
// current FLAG_REQUIRES_WALL. Flipping it on an in-use preset would strand referencing assets
// (an unwalled asset left on a now-wall-required needle, or vice versa). Changing the wall
// requirement needs a fresh presetId; a refit keeps the flag byte.
uint256 existing = $.curves[presetId].header;
int256 oldSpan;
if (existing != 0) {
if (uint8(existing >> 248) != flags) revert Err.InvalidInput();
(, oldSpan) = NUQuartic.rangeQ($.curves[presetId], existing);
}Impact
Without an upper bound on kappaCovBps, a single mistyped write could raise the coverage wall far enough to make a leg effectively untradable. The deregisterPool roster gap removed the interior displacement bound from every leg and turned a safety scan into a no-op. The hook binding gap could deadlock recall on a funded leg. The timelock tier cut placed asset listing, and the oracle configuration that rides it, behind a one-hour delay on production.
Remediation
poolToTokens now survives deregistration, so both consumers keep a populated roster. setAssetHook requires IPoolHooksToken(hook).token() == leg, making a sentinel-constructed hook uninstallable. collapseAnchor wraps newAnchor. ADD_ASSET rides the one-day LISTING tier on production and the Constants.sol natspec states the TUNING tier as one hour. kappaCovBps is bounded at the writers. The staged-admin report is recorded as refuted: the reserved words are documented as reserved.
Commits: f5c281f7, e36dbb86, 3f5b4e42, 06ee592, 600c6d3.
Status
Fixed. Coverage is pinned by PoolLifecycle.t.sol:1096, TokenContainment.t.sol, PoolAnchorTree.t.sol:547 and DeployBaseSchedule.t.sol:46-50.
Rows. A-784, A-208, A-202, A-089, A-136, A-150, A-793, A-795 (8 rows)
F-19 A single unusable leg mark froze every credit and cross-exit path pool-wide
| Severity | Status | Class | Component |
|---|---|---|---|
| HIGH | Fixed | Audit | dex-evm/src/libraries/PoolSolvency.sol:53-63 |
Severity rationale. One dead or out-of-band feed on any roster leg is an ordinary operational event, and it took every deposit, cross exit and hook path in the pool with it.
Description
PoolSolvency.solvency read a mark for every roster leg, including legs with zero reserves and zero liabilities, and returned (0, false, 0, 0) as soon as one read was unusable. A leg that had been funded and then emptied, or whose feed had simply gone dark, therefore froze deposit, donate, swapLiability, hookCreditYield, hookWriteDown and every cross exit across the whole pool, with no leg-removal or force-skip lever.
Separately, the sum ran every leg’s mark through markToBaseWad under only the halt gate and the freshness gate. The reference-band depeg breaker was not applied, so a fresh but out-of-band mark moved pool coverage for every liquidity-provider entrypoint, with cross-leg extraction bounded only by refBandBps.
// dex-evm/src/libraries/PoolSolvency.sol:57-66
for (uint256 i; i < n; ++i) {
address leg = $.legs[i];
IPool.Asset storage a = $.assets[leg];
// A-1121: a 0/0 leg contributes exactly 0 to BOTH sums, so skip it BEFORE the oracle read. A
// dead feed on a funded-then-emptied leg otherwise returned (0,false,..) and froze deposits,
// cross exits and harvest pool-wide. The skip is exact: r == l == 0 adds nothing either way.
if (a.reserves == 0 && a.liabilities == 0) continue;
(uint256 px, bool okk, bool bandOk) = Pricing.markToBaseWad($, leg);
uint256 d = a.decimals;
uint256 r = _backedReserves($, leg, a.reserves);Impact
A zero-exposure leg with a dead feed was enough to halt all liquidity operations on a pool that was otherwise fully healthy. An out-of-band but fresh mark on any leg moved the pool coverage rate that funds cross exits, so a depeg inside the reference band translated directly into value extracted from the other legs.
Remediation
Zero-exposure legs are skipped before the oracle read. An out-of-band leg’s mark is bounded at the reference-band edge. The intermediate par-degrade was reverted to a fail-closed semantic: an unusable mark yields ok = false, mintRate and the weight and cap gates revert FeedUnavailable, and the exit cap pays min(WAD, lastGoodC) so the same-asset hatch stays open. Freezing credit paths on a funded dead leg is now the deliberate design: there is no leg-removal lever and the heal is on the feed side. The bandOk plumbing was removed from Pricing.markToBaseWad and the natspec matches the code.
Commits: d9a6b556, 1058318d, 64d80d65, 88e3d3f8.
Status
Closed on 2026-09-15. Pinned by PoolSolvency.t.sol:615, PoolWriteDown.t.sol:183 and PoolSolvencyDegraded.t.sol:96,:119,:161.
Rows. A-1121, A-1305, A-1300 (3 rows)
F-20 Testnet-only deploy ceremony paths ran unguarded on a mainnet-class chain
| Severity | Status | Class | Component |
|---|---|---|---|
| HIGH | Fixed | Audit | dex-evm/script/PoolDeploy.s.sol:341, dex-evm/deployments/bnb-risk-params.json:84, dex-evm/script/lib/ChainParams.sol:168 |
Severity rationale. A single ceremony run on a production chain would have funded a public faucet with real tokens and minted a pool whose fee floor breached the H-2 design gate, and every gate that would have caught it was inert before listing.
Description
The pool deploy ceremony branched on roster content rather than on chain class. _fundFaucet ran on the same broadcast as _createPool, so a mainnet run reached the testnet faucet path with real tokens, and the V4 greenfield path could claim a production CREATE3 row. A swap-gate comment in the same file described a check that no longer existed.
// dex-evm/script/PoolDeploy.s.sol:341
Deploy.Addrs memory core = _loadCore(cfg, outPath);
_requireSeedBudget(cfg, syms);
vm.startBroadcast(pk);
pool = _createPool(core, cfg, syms, true);
_fundFaucet(cfg, TestnetFaucet(vm.parseJsonAddress(vm.readFile(outPath), ".faucet")), syms);
vm.stopBroadcast();The same ceremony carried three further classes of defect. The BNB scaffold shipped minFee below two theta on USDT and WBNB and a stable TTL of 7200 s against private-relay-only pushes, both of which breach the H-2 and PAR-2 gates. ChainParams trusted operator input: the wrong-RPC gate compared a value derived from the same source it was meant to validate, uint16 casts truncated before the ceiling checks ran, and signer environment overrides applied on mainnet. CI checked out the shared dependency at its default branch rather than a pinned commit, and the Arc operator scripts proved swing caps against the target minDispersion with a hardcoded cap while restore and unwedge batches aborted on the first non-ready entry.
Impact
A production ceremony could have broadcast a pool with a public faucet holding real tokens, with fee and TTL parameters outside the shipped risk design, against a shared build that no commit pinned. The parameter defects are economic: a fee floor below two theta and a 7200 s stable TTL both widen the window in which a stale lane can be traded against.
Remediation
The ceremony now refuses testnet lanes when the manifest declares class=mainnet, validates operator input and runs its preflight before startBroadcast rather than mid-broadcast. Scaffold parameters were corrected against H-2 and PAR-2, the BNB manifest notes were reconciled with the shipped design, ChainParams casts and gates were made non-tautological, and CI pins the shared checkout.
633621d, 8920758, 3513580, a8897e7, 7398ab1, 8b67c89, bf1caca, 279ec8e, 5539d9a
Status
Fixed 2026-09-16. Two informational rows are accepted rather than fixed: the published events.json carries V5 FeedRegistered and FeedWiden* shapes while the live Arc deployment still emits V4 shapes, which is handled as a step in the Arc upgrade runbook and not in code; and pool-level solvency stays unarmed after a beacon swap until a governance BACKFILL_LEGS call, with no reinitializer.
Rows. A-1500, A-1501, A-1503, A-1504, A-1505, A-1506, A-1507, A-1508 (8 rows)
F-21 Optimizer settings and unpinned artifacts broke cross-repo bytecode parity and left upgrade gates without a machine check
| Severity | Status | Class | Component |
|---|---|---|---|
| HIGH | Fixed | Audit | dex-evm/foundry.toml:30, dex-evm/script/UpgradePoolImpl.s.sol:48-54, dex-evm/src/interfaces/IAdmin.sol:54-72 |
Severity rationale. The parity gate was red at tip, so the bytecode a deployment produced for dex-evm no longer matched the shared build it linked against, and every downstream storage and library pin that was supposed to catch a mismatch was tautological or unenforced.
Description
dex-evm was built at optimizer_runs = 5000 to fit the Pool implementation under EIP-170 while shared stayed at 10000. That split the two repositories’ bytecode and turned the CI parity gate red at tip. Admin overflowed at either setting and was built separately into an un-pinned out-admin directory, which the upgrade path then loaded from, so an upgrade could install a locally stale Admin implementation.
# dex-evm/foundry.toml:30
solc = "0.8.36"
optimizer = true
# 5000, not 10000: with the C124-econ-1 band check the Pool impl is 24,907 B at 10000 (331 over
# EIP-170) and 24,316 B here. The size decision is what buys the band check, at ~+387 gas on a
# depth-1 swap (~0.2%). Admin overflows either way and is built on its own at runs 200
# (`forge build src/Admin.sol --optimizer-runs 200 --out out-admin`), where it is 24,419 B.
optimizer_runs = 5000
via_ir = trueThe checks that would have caught a layout or link mismatch did not check anything. IAdmin.RiskFences member layout was comment-enforced only, so a same-width reorder passed silently through setAssetParamsBounded. ExternalOracleV5 storage had no machine pin, so a same-width slot swap passed the beacon upgrade gate. PoolStorageLayout.t.sol hashed the sentinels it had just written and compared them to a hash of those same sentinels, and asserted local-constant arithmetic, so neither test pinned the compiled artifact. The four library bytecode pins lived only inside _assertLibPins in a deploy script that CI never ran, and the sole test harness overrode that function with an empty body.
Impact
A stale or foreign library link, a reordered RiskFences struct, or a swapped ExternalOracleV5 slot could all have reached a live upgrade without any gate refusing them. The parity break meant the two repositories no longer agreed on the bytecode of the shared code they both compile.
Remediation
optimizer_runs was restored to 10000 to match shared, with the CI parity gate green at tip; the bandOk plumbing was deleted so the Pool implementation fits at 24,508 B, and Admin now fits at the default profile without a side build. The RiskFences and OracleData frames are pinned by ArtifactGuards.t.sol, the tautological layout asserts were deleted, and the CREATE2 library pins were re-derived and are now enforced by LibraryPins.t.sol against the tree’s own out/.
d9a6b556, 235d6b2d, 46bac105, d38f6935, 66207fe5, 05dc7b04, 88e3d3f8, 230ebc23, f039e1e7, c95621e5, 6c6a65a6
Status
Fixed. Closed at the rev2 signoff.
Rows. A-1129, A-1130, A-1132, A-1209, A-1210, A-1401 (6 rows)
F-22 The v2 swap send path was unshippable: the client floor check disagreed with the server formula and cross-core routes were read as an outage
| Severity | Status | Class | Component |
|---|---|---|---|
| HIGH | Fixed | Audit | sdk/src/router/index.ts:126-136, front/src/lib/quoteV2.ts:45, sdk/src/amm/aimm.ts:694 |
Severity rationale. Every real spread quote failed the client-side floor assertion and every cross-core pair returned no quote at all, so the v2 send path could not complete a swap; all three failure modes were fail-closed, so no funds were at risk.
Description
assertServerFloor is the trust boundary for a server-authored slippage floor: the client recomputes the floor rather than trusting the served min_out. The two sides computed it differently. The server derived min_out from a percentage of the spread on a 1e8 scale while the SDK asserted amount_out * (1e6 - tol_pbps) / 1e6, so the two differed by the residue of the spread term modulo 100 and the assertion threw on every quote carrying a real spread.
// sdk/src/router/index.ts:126
export function assertServerFloor(amountOut: bigint, tolPbps: number, minOut: bigint): void {
if (minOut > amountOut) {
throw new Error(`server floor ${minOut} exceeds amount_out ${amountOut}`);
}
const expected = (amountOut * (1_000_000n - BigInt(tolPbps))) / 1_000_000n;
const diff = expected > minOut ? expected - minOut : minOut - expected;
if (diff > 1n) {
throw new Error(
`server floor ${minOut} != amount_out*(1e6-${tolPbps})/1e6 (=${expected})`,
);
}
}A second defect fed the same assertion the wrong input: the call sites passed the f64-reconstructed plan amountOut, built as toUnits(Number(formatUnits(...))) by the front plan builder, rather than the served amount_out. Any non-round 18-decimal output differed from the served value by more than one wei, so the check threw and the send aborted silently. Separately, the front took the direct-pool /v2/quote endpoint for every pair; a cross-core pair has no single pool holding both tokens, so the endpoint answered 422 and the front rendered it as a quote outage rather than as a routing question. Finally, every quote re-uploaded the whole 37-leg fleet, 44.5 KB per POST, with two byte-identical route requests per keystroke.
Impact
On the v2 path, spread quotes and non-round outputs both aborted at the client floor check, and cross-core pairs showed a false outage banner instead of a route. The fleet re-upload put 44.5 KB on the wire for every quote.
Remediation
The floor is now one formula on both sides, derived from the returned tol_pbps and checked against the raw served amount_out rather than a reconstructed plan value. The front prices the form on /v2 chain quotes, takes /v2/quote only when a single pool holds both tokens and otherwise posts /v2/route, flooring off best.floors[] per output token; a no_route error reads as “No route” rather than as an outage, and a null best reads as no fill rather than as a stale quote. LP rows now carry pool C.
d58f3414, 308ed84f, 45d9412e, e28d5db6, 6afe57a6, cbfba89f, 20e4af4b, fab5907a, 2ce27a94, eb5c289c, 8c1d216, 19f0122, a46208e, e22c2e8, 6915e3d
Status
Fixed. Deletion of the superseded v1 quote routes remains as ceremony step C-8.
Rows. A-116, A-1119, A-1302, A-1406 (4 rows)
F-23 A rebias left the next push unbanded, and the band anchor added to close that gap was itself conditional
| Severity | Status | Class | Component |
|---|---|---|---|
| MEDIUM | Fixed | Audit | dex-evm/src/oracles/ExternalOracleV4.sol:739, dex-evm/src/oracles/ExternalOracleV4.sol:484, dex-evm/src/oracles/ExternalOracleV4.sol:769-771 |
Severity rationale. Every rebias, exponent-bias change or guardian break-glass ceremony opened a window in which one push landed with no deviation band, and the deployment concerned had that ceremony as its only wedge-relief path.
Description
_rebias zeroes the lane. The band gate reads the lane for the previous mark, so a zeroed lane carried no previous mark and the next push was compared against nothing. The first remediation stored the pre-rebias decoded mark in _bandAnchor1e18 and made the band fall back to it. That anchor is written only when the lane is live and in ttl, but read unconditionally, so a ceremony on a dark or out-of-ttl lane wrote no anchor and the unbanded push returned. A guardian ttl tighten reopened the same window from a different direction.
// dex-evm/src/oracles/ExternalOracleV4.sol:739 (_rebias)
uint256 word = uint256(priceSlot[slotId]);
uint256 pl = (word >> shift) & LANE_MASK;
if (pl & MANT_MSB != 0) _bandAnchor1e18[gi] = _decode(pl, int8(uint8(cfg >> CFG_BIAS_SHIFT)));
uint256 lanes = (word & ~(LANE_MASK << shift)) & ((uint256(1) << TS_SHIFT) - 1);
priceSlot[slotId] =
bytes32((_dayMod(block.timestamp) << DAY_MOD_SHIFT) | (nowDs() << TS_SHIFT) | lanes);
_zeroSigmaConfLane(slotId, uint32(gi % LANES_PER_SLOT));Three further consequences followed from the same construction. The anchor was never invalidated by the push that resolved it, so a later rebias could band against a mark that was no longer current. Sigma is zeroed alongside the lane, so the post-rebias re-entry band was maxDeviationBps only, with no adaptive term. And because the clock is re-stamped to now, the elapsed-time term of the anchored band collapsed from the real gap to zero, which tightens the band precisely when the lane has been dark longest. A later fix anchored the mark to the slot clock rather than the lane’s own observation second, which re-collapsed the same term when a second ceremony ran on the same slot. registerFeed also took no sigma or confidence seed, so a cold-start band was the bare floor.
Impact
An unbanded push accepts an arbitrary mark for one block on the affected lane, which is the price input every pool leg on that asset quotes from. The collapsed-time variants tighten the re-entry band instead, which turns a recovery ceremony into a fresh refusal.
Remediation
The anchor carries its own observation timestamp and the band’s elapsed-time term is computed from it, the out-of-ttl exemption is removed, and the anchor is invalidated by the push that resolves it. V5 registerFeed takes sigma, confidence and mark seeds with sigmaSeed at or above a non-zero floor.
Status
Fixed, verified in the following round. One row, the slot-clock re-stamp on a market-closed slot, is Closed with a proof and no code change. The V5 mark seed registers a live lane, and the stale-scaffold risk that creates is tracked separately.
Rows. A-006, A-010, A-013, A-035, A-038, A-040, A-049, A-050, A-053, A-059, A-065, A-076, A-082, A-224 (14 rows)
F-24 Lane state was shared across a slot or optional on the wire where it had to be per-lane and mandatory
| Severity | Status | Class | Component |
|---|---|---|---|
| MEDIUM | Fixed | Audit | dex-evm/src/oracles/ExternalOracleV4.sol:514, dex-evm/src/oracles/ExternalOracleV4.sol:691-692, dex-evm/src/libraries/FeedMathLib.sol:57-61 |
Severity rationale. Staleness is enforced off a clock eight lanes share, so a lane could hold an old mark while its slot mates kept its ttl alive, with the staleness premium at zero throughout.
Description
The V4 price slot carries one timestamp for all eight lanes. A lane omitted from a routine partial blob, whether through a selective re-fetch or a sigma-only heartbeat, kept its old mark while its mates advanced the shared clock. The ttl therefore never fired, the gate passed and the staleness premium stayed at zero. A paused lane had the same shape from the other side: its entries counted as accepted, so the pause advanced the clock it was supposed to freeze.
// dex-evm/src/oracles/ExternalOracleV4.sol:514
if (uint16(cfg) == 0) {
flags |= uint256(1) << (8 + lane); // unregistered: fail-soft skip
continue;
}
if (cfg & SCFG_PAUSED != 0) {
flags += uint256(1) << 64; // paused counts accepted, mark frozen
continue;
}Three related defects sit on the same surface. The confidence section of the wire was optional, so a mark could advance while the previous confidence was preserved, and a genesis value of zero reads as maximally confident; the gate halts only above 1000, so a stale-low confidence undercharges the premium. registerFeed admitted a ttl above MAX_RECON_AGE, so an out-of-window or aliased lane passed registration. A lane at sigma zero could stall its own heal, because a refused push skips the sigma slot while sigma-only blobs still persist sigma. Separately, six on-chain thresholds are policy expressed as constants rather than per-class or adaptive values.
Impact
A stale mark that the ttl never rejects is quoted at full confidence with no staleness premium, which is the exact input the adaptive fee is meant to charge for. The registration and confidence gaps widen the same window.
Remediation
V5 gives every lane its own clock, pinned by a clock-isolation test, and mandates confidence and price entries in lockstep. pauseFeed is fail-closed on release. The named heal stall is fixed. The oracle half of the threshold row is closed by per-feed sigmaFloor and maxDeviationBps in V5.
Status
Fixed in V5. The deployed V4 retains the per-lane clock residual until the repoint. The threshold row is Accepted as residual informational: the base depeg halt is an owner risk decision, the staleness z-score is physics, and the remaining global confidence halt constant is accepted. The peg-leg confidence row is Closed and accepted with zero internal legs listed, to be reopened on the first internal listing. Verified 2026-09-14.
Rows. A-022, A-074, A-180, A-203, A-225, A-678, A-786 (7 rows)
F-25 A solvency pin asserted on the wrong path, and two test files were order-dependent or formatter-dirty
| Severity | Status | Class | Component |
|---|---|---|---|
| MEDIUM | Fixed | Audit | dex-evm/test/unit/PoolSolvency.t.sol:420, dex-evm/test/unit/OracleDeployScripts.t.sol, dex-evm/test/unit/ExternalOracleSigned.t.sol:190-468 |
Severity rationale. The design’s headline solvency claim rested on an assertion that could neither confirm nor refute it, so a real regression in that property would have passed the suite.
Description
The pin for the third-party round-trip property asserted on previewWithdraw, the same-asset path, whose rate is min(c_leg, C). At the probe case in question the hub sits at c = 0.999973 with C > 1, so the minimum picks c_leg, the assertion returns identical wei whatever pool-level C does, and the test is blind to the property it was written for. The claim holds in claim value, not in in-kind delivery.
// dex-evm/test/unit/PoolSolvency.t.sol:344
/// A-749. RESTATED PIN. The design's headline — "pool-level C closes econ-probe case 3" — is FALSE
/// as the original was written: `testFuzz_third_party_after_swap_roundtrip` asserted on
/// `previewWithdraw`, the SAME-ASSET path, whose rate is `min(c_leg, C)`. Case 3's hub sits at
/// c = 0.999973 with C > 1, so the minimum picks `c_leg`, the assertion returns the identical wei
/// whatever C does, and the pin can neither confirm nor refute pooling.
function testFuzz_third_party_claim_value_survives_a_round_trip(Two hygiene defects sit beside it. The oracle deploy-script test was order-dependent, failing roughly two runs in five of the whole suite with a different message each time while passing in isolation. And a signed-oracle test file was formatter-dirty over roughly 280 lines, so a blanket format sweep would rewrite unrelated code, the known formatter-sweep hazard.
Impact
A pin that cannot fail does not protect the property it names. A flaky test erodes the signal from the suite, and a formatter-dirty file turns any routine format run into a large unrelated diff.
Remediation
The pin is restated against claim value, the sum of face times C, which is what a cross exit actually delivers, and asserts that a stranger’s swap round trip cannot lower a third party’s claim value on any leg pair at any coverage. The deploy-script test is made order-independent, and the signed-oracle test file is formatted after a de-brace scan that found no hazard sites.
Status
Fixed.
Rows. A-091, A-157, A-749 (3 rows)
F-26 The LP exit could settle below the minimum it displayed, and the pair-persist write was unguarded
| Severity | Status | Class | Component |
|---|---|---|---|
| MEDIUM | Closed | Audit | front/src/hooks/usePoolData.ts:169-174, front/src/components/features/swap/LpTab.tsx:335-360, front/src/pages/swap/pairState.ts:71 |
Severity rationale. The LP tab printed a minimum received under a tooltip promising the batch would revert below it, while the same-asset withdraw path could be sent with a floor lower than that figure.
Description
The same-asset LP withdraw originally sent minAmountOut = 0n while the recap rendered a minimum received derived from the quote. The chain enforces nothing at zero. The first fix threaded a required floor through the hook; the floor helper then took the lower of the promised figure and a slipped fresh preview, which reintroduces a send below the number on screen whenever the fresh preview is lower.
// front/src/hooks/usePoolData.ts:169 (lpExitFloor, before the fix)
export const lpExitFloor = (promised: bigint, previewOut: bigint, slipFrac: number): bigint => {
if (previewOut <= 0n) return promised;
if (previewOut < promised) throw new Error(LP_EXIT_STALE_ERROR);
const fresh = applySlip(previewOut, slipFrac);
return fresh < promised ? fresh : promised;
};On the same surface, stale poll data was rendered as live, and the swap-pair persist effect called localStorage.setItem without a guard, which throws a SecurityError on browsers with storage blocked and has no error boundary above it.
Impact
A user could sign an exit that settles below the minimum the interface guaranteed. The storage write is an uncaught exception that takes down the swap surface for any viewer with site data blocked.
Remediation
lpExitFloor throws LP_EXIT_STALE_ERROR when the chain pays below the promise and otherwise returns the promised figure; the minimum is deleted and the behaviour is documented in the helper’s own natspec. Staleness is threaded through the liability and LP rows so stale values are shown as stale, and the pair persist goes through the guarded storageSet, with a test pinning the blocked-storage log.
45d9412, 93dc791, 80bc4fe, a371e56, e92b52e
Status
Closed. The LP floor change is signed off on the second revision, 2026-09-14; the storage guard landed 2026-09-15.
Rows. A-244, A-937 (2 rows)
F-27 The factory upgrade lane had no storage-version gate and no execute-time revalidation
| Severity | Status | Class | Component |
|---|---|---|---|
| MEDIUM | Fixed | QA | dex-evm/src/PoolFactory.sol:396-409, dex-evm/src/PoolFactory.sol:432, dex-evm/src/PoolFactory.sol:175 |
Severity rationale. PoolFactory is not upgradeable, so landing the authority change without the storage-version gate would have spent the single irreversible redeploy window; the residual paths need a compromised owner and are covered by notice, guardian cancel and grace expiry.
Description
No STORAGE_VERSION existed anywhere in the sources or tests, and neither _validateImplementation nor executeReferenceUpgrade carried a version check. The execute path also skipped request-time revalidation: it checked timing and pending state, then swapped the implementation for the entire live fleet without re-pinning the candidate against live wiring.
// dex-evm/src/PoolFactory.sol:327
function executeReferenceUpgrade() external onlyAdmin {
if (block.timestamp < upgradeTimelock) revert Err.NotReady();
// A matured pending upgrade expires SC.GRACE_PERIOD after its eta so a forgotten,
// stale-vetted impl cannot be executed months later; must be re-requested past the window.
if (block.timestamp > upgradeTimelock + SC.GRACE_PERIOD) revert Err.Expired();
if (pendingReferencePool == address(0)) revert Err.NoPending();
address oldImpl = implementation;
address newImpl = pendingReferencePool;
delete pendingReferencePool;
delete upgradeTimelock;
// Atomic fleet upgrade: every live beacon proxy reads this slot, so they all move together.
implementation = newImpl;
emit ReferencePoolUpgraded(oldImpl, newImpl);
}The registry lifecycle on the same contract had three further defects. deregisterPool deleted isPool, which is written only inside _registerPool and has no re-register path, so de-listing also and permanently revoked the pool’s factory write credential: registerTokens and setPoolBaseToken both reverted, and PoolConfig.initAsset calls registerTokens unconditionally. A guardian could not evict a pool at all, only the owner could. A related fix for a pending-reference guard touched only one of the two cited sites. Registry fill by cheap proxies was capped at 128 entries, and storage-layout safety was build-time only.
Impact
Without the version gate an implementation with an incompatible storage layout could be accepted into a fleet-atomic beacon swap. De-listing a pool silently removed its ability to list any further asset or migrate its base token, with no lift path.
Remediation
Pool exposes storageVersion(), PoolFactory pins a STORAGE_VERSION and applies a forward-only check at both request and execute, and execute re-pins the candidate against live wiring and the recorded layout. An isSpawn flag gates registerTokens and setPoolBaseToken and survives deregisterPool, so de-listing is no longer a write revocation. A guardian can call setOfficial(false) while deregisterPool stays owner-only. The forward-version gate plus the layout pins are the accepted control for the build-time-only layout check.
Commits: f5c281f7.
Status
Fixed. Verified 2026-09-11; the advisory and informational rows were closed on the 2026-09-14 review.
Rows. A-785, A-218, A-209, A-220, A-810, A-083, A-222, A-033 (8 rows)
F-28 The published ABI surface was ambiguous and its generator gate was red
| Severity | Status | Class | Component |
|---|---|---|---|
| MEDIUM | Fixed | QA | dex-evm/abi/gen.py:74-90, dex-evm/src/interfaces/IPool.sol:464, dex-evm/abi/events.json |
Severity rationale. Integrators decode events off the published artifacts, so a name carrying two signatures produced silent mis-decodes; the checker failure meant the artifact could not be regenerated to correct it.
Description
abi/gen.py --check failed on event-name collisions between generations: the generator collects every event signature per name and exits non-zero when any name carries more than one, and three names did. With the check red, events.json could not be regenerated, so it kept publishing the first generation’s topic0 for both FeedWiden events and lacked SolvencyUpdated and UntrackedSynced entirely. LegsBackfilled was declared with two signatures across the interfaces.
// dex-evm/src/interfaces/IAdmin.sol:206
// SWEEP and BACKFILL_LEGS executes emit nothing here: the pool logs `IPool.Swept` and
// `IPool.LegsBackfilled` itself. A second declaration of either name on this interface put two
// signatures (or two indexed layouts) behind one event name, which no off-chain decoder resolves.Five further rows recorded interface-to-implementation gaps that affected documentation only, since all known consumers use the full artifacts: IPoolFactory declared none of the creation, upgrade or discovery functions, IAdmin declared the batch risk events and enum without the functions and setRiskFences without a reader, IOracle declared revert types the first generation never throws, and a natspec block grouped an owner-direct function under the admin singleton.
Impact
An off-chain decoder resolving an ambiguous event name picked one of two layouts, so a consumer of the published artifacts could mis-decode the wedge-release events or miss two events completely.
Remediation
IPool now declares one LegsBackfilled, events.json carries SolvencyUpdated and UntrackedSynced, the later-generation events are deferred through an explicit pending list in the generator, and gen.py --check runs as a CI gate.
Status
Fixed. The interface-gap rows were closed as informational on 2026-09-10 under the minimum-severity bar.
Rows. A-906, A-081, A-084, A-550, A-551, A-553, A-554, A-555 (8 rows)
F-29 The documentation described retired levers and omitted shipped bounds and gates
| Severity | Status | Class | Component |
|---|---|---|---|
| MEDIUM | Fixed | QA | dex-evm/src/Admin.sol:549, shared/evm/src/Constants.sol:60, dex-evm/src/libraries/PoolLiquidity.sol:266-277 |
Severity rationale. Operators act on the runbook, and the drifted pages told them a defensive tighten was instant when the code queues it, so an incident response would have been mistimed; no funds are at risk directly.
Description
The parameter documentation still presented a vegaBps raise as an instant defensive tighten. The bounded path is deliberately not setAssetParams behind a flag and the absolute path queues, so the documented behaviour did not match either lane.
// dex-evm/src/Admin.sol:527
/// @dev Deliberately NOT `setAssetParams` behind a lane flag: fenced, clamped, never queues.
function setAssetParamsBounded(
address pool,
address token,
uint128 minLiquidity,
uint16 minFeePbps,
uint16 vegaBps
) external {
_onlySteward(pool, false);Four further drifts sat on the same pages. The developer guide advertised SDK quote APIs that had been deleted or now throw. Three documentation sites gave the production LOW tier as one day where the shared constants set one hour. The upper bound kappaCovBps gained was recorded nowhere. And the trade feature bits were described as gating trading through a leg when they gate their own entrypoints: with the swap bits cleared, deposit plus cooldown plus a cross withdrawTo reproduces a swap fee for fee at parity. That last one is not a value leak, because the mark cap on the liquidity path keeps it no better than a swap and collapses it off parity, and the halt-only gating is deliberate so a trading pause cannot trap an LP’s only exit; the defect is that an operator clearing the swap bit for a wind-down still sees fee-identical flow.
Impact
An operator following the documentation would have queued a tighten expecting it to be instant, provisioned the wrong governance delay, or cleared a feature bit believing it stopped flow through the leg.
Remediation
One sweep per surface shipped alongside the contract changes: three tiers and the production delay table, the current push path, the [50, BPS] kappa bound, the deposit-gated bit and the deposit cap code, the face-at-C table, the exotic-token bit row with a note that feature bits gate entrypoints, the unpause runbook, and an SDK page stating that chained parts are floored by the server or not at all.
Commits: c7df8ad, 94189ab, ded071f, cd38999, 387d7db, 843ed3b, 19d5de5, db2a395, 6a12938, 5840abb.
Status
Closed. Documentation sweeps landed 2026-09-15; the residual wording was verified on the 2026-09-14 review.
Rows. A-118, A-121, A-133, A-148, A-210 (5 rows)
F-30 The client authored its own swap floors and mis-allocated the server floor across split and chained parts
| Severity | Status | Class | Component |
|---|---|---|---|
| MEDIUM | Fixed | QA | sdk/src/router/index.ts:325, sdk/src/router/index.ts:84, dex-evm/src/libraries/PoolIOLib.sol:93-101 |
Severity rationale. A wrong floor either reverts the batch after the first hop has mined or leaves roughly twice the intended tolerance extractable on the legacy two-hop path where no router contract exists.
Description
planToLegs wrote the end-to-end server floor onto each split part and onto hop 2 of a chained part. Hop 2 is funded by hop 1’s floor, not hop 1’s quote, so flooring it on the unscaled end-to-end quote left zero margin and ordinary noise reverted the batch after hop 1 had already mined.
// sdk/src/router/index.ts:324
const server = opts.serverFloors?.[t2out.address.toLowerCase()];
// The server's own tolerance scales the intermediate hop when it is present; the legacy
// caller-supplied fraction only stands in for a plan with no server floor.
const leg1MinOut = server
? applyTolPbps(leg1Quoted, server.tolPbps)
: applySlip(leg1Quoted, slip);
const leg2Quoted = toUnits(part.quote.amountOut, t2out.decimals);Alongside it, the SDK kept authoring floors of its own through refloorLeg, refloorRouterPlan and a DEFAULT_SLIP constant, and a fork had lost the deletions and the split tests from an earlier integration change. The legacy two-hop path floored hop 2 at the squared tolerance while the interface promised the single tolerance. On the contract side, the fee-on-transfer output leg measured the pull but pushed face, so minAmountOut was checked against face while the recipient received less.
Impact
Users on the legacy two-hop path faced roughly double the tolerance they set, and split or chained plans reverted with a threshold violation after paying for the first hop.
Remediation
The SDK takes server floors only: refloorLeg, refloorRouterPlan and lpRoutes DEFAULT_SLIP are gone, slippageFrac is required, and a chained part without a server floor is refused. planToLegs builds a per-part map keyed by the floor itself, allocates each end-to-end floor pro rata to the quoted output across the parts landing that token with the residual on the largest, and returns null on a zero quoted total. The chained hop 2 takes its own slice. The pool re-checks the liquidity floor after an exotic-leg push, the front end prices the form on chain quotes and hands the chain floor to the approval preview, and the integration documentation states that minAmountOut is checked on face.
Commits: aa10f3b3, e284f43, d902a2d, ccc60d0, 1fc7854, d89c83a, d58f3414, d183c660, 8f260a6b, a1eec1ec, f9f29d4, 843ed3b.
Status
Closed. Landed 2026-09-15 and verified on the 2026-09-14 review.
Rows. A-1404, A-1403, A-926, A-201 (4 rows)
F-31 A server-authored output floor was accepted at any tolerance and was never bounded by the user’s slippage
| Severity | Status | Class | Component |
|---|---|---|---|
| MEDIUM | Fixed | Audit | sdk/src/router/index.ts:104, sdk/src/utils/format.ts:88, sdk/src/router/index.ts:210 |
Severity rationale. The quote service was the sole author of the floor and the client applied it unchecked up to 999000 pbps, so a compromised or misconfigured quote service could set an effectively zero floor on every swap the SDK builds.
Description
The SDK treated the quote service as the sole floor author. A returned tol_pbps was applied as given, with no cross-check against the quoted output and no ceiling from the slippage the user had chosen, so the user’s setting never bounded the floor actually encoded into the transaction.
// sdk/src/router/index.ts:104
export function assertServerFloor(amountOut: bigint, tolPbps: number, minOut: bigint): void {
if (minOut > amountOut) {
throw new Error(`server floor ${minOut} exceeds amount_out ${amountOut}`);
}
const expected = applyTolPbps(amountOut, tolPbps);
const diff = expected > minOut ? expected - minOut : minOut - expected;
if (diff > 1n) {
throw new Error(`server floor ${minOut} != amount_out*(1e6-${tolPbps})/1e6 (=${expected})`);
}
}Two helper defects were found on the same review. formatUnits returned a wrong value for decimals = 0 and garbled negative bigints rather than throwing, and approve() had no zero-first reset for tokens that require one.
Impact
A user’s slippage setting did not bound the floor written into their swap, so the transaction could execute far below the price the interface showed.
Remediation
A server floor is now bounded by the user’s slippage and cross-checked against the quoted output using the same formula the service uses, throwing rather than quietly lowering the floor. The formatting helpers throw on out-of-domain input.
Commits: 58eb51c, 8633f8e, f4ef785, 58d301f.
Status
Fixed 2026-09-16. The approval reset row is Accepted: no listed token requires the zero-first pattern at the shipping configuration.
Rows. A-1547, A-1548, A-1549 (3 rows)
F-32 A queued absolute risk update silently overwrote a defensive tighten that landed during its delay
| Severity | Status | Class | Component |
|---|---|---|---|
| MEDIUM | Fixed | QA | dex-evm/src/Admin.sol:932-937, dex-evm/src/libraries/PoolConfig.sol:617-661 |
Severity rationale. The tighten is the incident-response lever, so its silent reversal at the end of a tuning delay reintroduces the exact exposure the operator had just closed.
Description
A queued UPDATE_RISK op carried an absolute RiskConfig with no request-time snapshot and no per-field compare-and-swap. The execute decoded the stored payload and wrote it wholesale, so an instant setRiskConfigTighten landing during the tuning delay was overwritten without a revert: the cap re-raised, the gate re-cleared, kappa re-lowered.
// dex-evm/src/Admin.sol:932
function executeUpdateRiskConfig(address pool, address token) external {
_onlyPoolAdmin(pool);
IPool.RiskConfig memory cfg =
abi.decode(_consume(_keyToken(pool, OP_UPDATE_RISK, token)), (IPool.RiskConfig));
IPool(pool).adminSetRiskConfig(token, cfg, false);
emit RiskConfigUpdated(pool, token, cfg.flags);
}The first fix refused an instant tighten while a risk op was live, which made the de-risking lane depend on a privileged cancel: a tighten queued behind a cancelable op could be delayed for the delay plus the grace window by whoever held the cancel authority.
Impact
A defensive parameter tighten applied during an incident could be silently undone when the pending tuning op executed, or blocked for the length of the delay plus grace by the cancel authority.
Remediation
setRiskConfigTighten is refused while an UPDATE_RISK op is live, a kappa raise voids the stale queued key so the operator re-queues against current state, and the tighten caller can cancel the timelock itself rather than waiting on another authority. adminSetDeadSeedPow10 stays instant by governance decision.
Status
Closed.
Rows. A-1001, A-1137 (2 rows)
F-33 A queued asset-parameter operation carried an absolute payload and no version tag, so execution undid an instant defensive tighten
| Severity | Status | Class | Component |
|---|---|---|---|
| MEDIUM | Fixed | Audit | dex-evm/src/Admin.sol:396-413, dex-evm/src/Admin.sol:423-435, dex-evm/src/Admin.sol:78 |
Severity rationale. The queue delay is the exposure window: any tighten applied inside it was silently reverted at execute time, and the operation that did so looked routine.
Description
UPDATE_ASSET_PARAMS queued an absolute payload. Nothing in the queued blob recorded the values it was authored against, so executing a matured operation wrote its payload over whatever the leg held at that moment, including a defensive tighten landed through the instant steward lane during the delay. The first remediation refused at execute time on leg state rather than on what the payload actually moves, which merely changed the failure mode: an operation that did not conflict still reverted, and the refusal did not distinguish who had tightened, handing the risk steward a repeatable veto over the owner’s lane. The execute-time guard also covered two of the three fenced fields, leaving a steward haircutSuppressor write unguarded.
The blob shape then changed without a version tag and without an on-chain reader, so an operation queued under one Admin and executed under another decoded into the wrong fields.
// dex-evm/src/Admin.sol:528
if (blob.length == ASSET_PARAMS_BLOB_LEGACY_BYTES) {
p = abi.decode(blob, (AssetParamsPayload));
snap.minLiquidity = cur.minLiquidity;
snap.minFeePbps = cur.minFeePbps;
snap.vegaBps = cur.vegaBps;
snap.haircutSuppressorBps = cur.haircutSuppressorBps;
return (p, snap);
}
uint8 ver;
(ver, p, snap.minLiquidity, snap.minFeePbps, snap.vegaBps, snap.haircutSuppressorBps) =
abi.decode(blob, (uint8, AssetParamsPayload, uint128, uint16, uint16, uint16));
if (ver != ASSET_PARAMS_BLOB_V1) revert Err.InvalidInput();The legacy arm was first keyed to a 192-byte shape that no deployed Admin ever queued, so the only shape that had actually been written to the queue had no arm at all. The compatibility arm was then one-directional: a newer blob executed under an older Admin decoded the version byte into minLiquidity. Two further rows sit on the same queue: an expired operation blocked a re-queue because it required a cancel plus a re-request per key with no on-chain read of pending state, and the instant lane wrote parameters directly with no compare-and-set, so a steward write between an owner’s request and its execute made the owner’s operation revert.
Impact
A risk tighten applied during a queued window was undone by an operation that had been authored before the tighten existed. The decode mismatch could write parameters nobody authored, or leave a key occupied after a reverting consume, which is the wedge the arm existed to prevent.
Remediation
The queued payload carries a per-field snapshot and execution compares each field against the live value, so an operation that does not conflict executes and one that does reverts naming the field. The guard covers all fenced fields. The blob is version-tagged with a length-keyed legacy arm matching the shape that was actually queued, and the rollback hazard is documented in the contract: pending UPDATE_ASSET_PARAMS keys must be cancelled before rolling Admin back. The steward may not write a field a live owner operation holds, and the instant lane is per-field compare-and-set at execute.
Status
Fixed. Successive remediation rounds each re-verified in the following round; the final blob-compatibility arm was verified in round five against the round-one fix branch. One residual is recorded and accepted: the held-field guard also blocks the steward’s tighten on that field for the delay plus the grace period.
Rows. A-007, A-015, A-037, A-041, A-044, A-045, A-047, A-051, A-052, A-057, A-073, A-252 (12 rows)
F-34 Instant risk-write lanes had no cumulative limit, no dispersion re-check, and resolved the native sentinel under a second key
| Severity | Status | Class | Component |
|---|---|---|---|
| MEDIUM | Fixed | Audit | dex-evm/src/Admin.sol:394, dex-evm/src/Admin.sol:571, dex-evm/src/libraries/PoolIOLib.sol:43 |
Severity rationale. These are untimelocked writes on live legs; the worst realized case is a leg whose routes all revert, reachable in one transaction by a single key.
Description
The steward lane had no on-chain rate limit. The hard fences were the entire 24 hour envelope, and the envelope could be reached in one call and then reached again the next day, so a lane intended for small defensive moves carried the full fence magnitude at every step.
Admin treated any vegaBps raise as an instant defensive tighten with no dispersionCap re-check, so one untimelocked write could push a leg’s live dispersion past the interior swing cap, after which every route through that leg reverts. setFlowCooldown was untimelocked in both directions and absent from the tier table, the only risk write in that position; the locks read the cooldown live, so setting it to zero retroactively unlocked.
// dex-evm/src/Admin.sol:394
function setFlowCooldown(address pool, uint16 cooldownSecs) external {
_onlyAdmin();
IPool(pool).adminSetFlowCooldown(cooldownSecs);
emit FlowCooldownUpdated(pool, cooldownSecs);
}Separately, the native sentinel is a keying pattern rather than a single bug. Every token-keyed store outside PoolIOLib.wrap keyed on the raw argument while the pool resolves the sentinel to the wrapped native token for its own storage, so the same leg had two names: risk fences armed under one name read as zero under the other, _keyToken admitted two pending operations for one leg, PoolFactory._addTokens registered the raw token so enumeration by one name missed pools, and Donated emitted the raw token so the indexer orphaned the row. Finally, one listed leg held kappa = 0 with haircutSuppressor = 10000, outside the coverage regime every other leg sits in.
Impact
A single instant write could make a leg unroutable. The sentinel aliasing meant a fence could be bypassed through the second name on the owner lane, and off-chain enumeration and indexing disagreed about which pools hold a given token.
Remediation
An on-chain cumulative 24 hour steward window bounds the lane, and raiseKappa was added as a raise-only steward lever so the parameter governing undercoverage persistence is reachable defensively without leaving the fast lane’s bounds. A vegaBps raise re-checks the dispersion cap. setFlowCooldown raises instantly and lowers through the TUNING tier with a one second floor. The sentinel and the wrapped token resolve through one key at all five sites: fence and queue keys resolve through Admin._rt, PoolFactory._addTokens resolves through IPool.resolve, and Donated emits the wrapped token. The orphan leg was corrected on chain on 2026-09-04 to a suppressor of zero and kappa of 600, and stays halted.
Commits: 06bdd179, 30b8c235, f5c281f7.
Status
Fixed, sentinel resolution verified 2026-09-11. One row in this group was refuted rather than fixed: the reported release-clock alias existed only on a parked per-pool-authority branch and never on the audited head, where halts are refcounted mask bits. One residual is open and latent: Router.swap is not payable while Pool.swap is, and the router’s sentinel input path has no ERC-20 to pull; the router is not deployed and no native leg is live. A build-blocking row, where the contract tree depended on an unpushed shared commit, was closed once the shared repository was in sync with its main branch at eba0496. Two residual notes on the steward window did not survive refutation and are recorded as informational: a raise does not ratchet the window anchor, so a mid-window defensive raise is one-call reversible.
Rows. A-014, A-019, A-029, A-086, A-088, A-216, A-547, A-587 (8 rows)
F-35 Oracle push semantics admitted an aliased replay and let a quarantined lane heal past its own band
| Severity | Status | Class | Component |
|---|---|---|---|
| MEDIUM | Fixed | Audit | dex-evm/src/oracles/ExternalOracleV5.sol:363-372, dex-evm/src/oracles/ExternalOracleV4.sol:243-258, sdk/src/oracle/wire.ts:28 |
Severity rationale. Exploiting the replay needs a stalled slot plus a drift inside a specific window and costs only gas, and the heal bound governs how far a dark lane can jump when it comes back.
Description
The signed-blob push carried no expiry and no nonce, and reconstruction aliased an old timestamp to the present across a 24 hour header and a 96 hour stored cycle. A slot stalled beyond six hours skipped the monotonic check with a maximum delta, the band admitted up to ten times maxDev, and the clock was rewritten as fresh, so a permissionless replay could rejuvenate a stalled slot. A future-dated observation inside the accepted bound made reads fail closed as stale until the chain caught up.
The heal bound was the second half. attestReentry used an allowance whose sigma term grew with the gap rather than being capped at the intended multiple of maxDev, so a long-gap, high-sigma lane could heal past the band, and the quarantine cap was self-healable by the same quorum.
// dex-evm/src/oracles/ExternalOracleV5.sol:362
function _bandPass(OracleStorageV5.OracleData storage $, BandCtx memory c)
private
returns (bool)
{
uint256 obs = (c.pl >> OBS_SHIFT) & OBS_MASK;
uint256 dt = c.srcSecs > obs ? c.srcSecs - obs : 0;
uint256 rw = uint256($.riskSlot[c.slotId]);
uint256 sigmaStored = (rw >> (c.lane * RISK_STRIDE)) & RISK_SIGMA_MASK;
uint256 sigmaFloor = uint32(c.scfg >> SCFG_SIGMA_FLOOR_SHIFT);
uint256 sigma = sigmaStored > sigmaFloor ? sigmaStored : sigmaFloor;V5 had also dropped V4’s realized-move sigma floor: _bandPass read only the producer-stored sigma against the static per-lane floor, so a sub-maxDev realized move never raised sigma and the band was producer-determined. The first fix for that was inert for a price-only submission, because the move array was allocated only when sigma entries were present. The reentry nonce had no getter and incremented under unchecked, so a consumer could not sequence attestations, and after the heal fix the quarantine bit was write-only, making the natspec claim that clearing it re-arms the lane false. On the read side the published SDK codec was pinned to wire version five while the contract shipped version six, so a version-six envelope failed the SDK’s version byte.
Impact
A replayed alias restored a stalled slot to apparent freshness, which is the state consumers gate on. An uncapped heal let a lane that had been dark return with a jump larger than the configured band permits, which is exactly the move the band exists to price. The sigma floor gap left the band producer-determined, affecting both liveness and adverse-selection cost.
Remediation
One decoder now treats a lane past the u128 boundary as stale, closing the alias. A bounded future observation reads as fresh with confidence floored at the realized move. Sigma is floored at the realized move on every blob shape, including price-only submissions, where the price path itself writes the maximum of the stored sigma and the move, and the sigma term is capped at maxDev times the configured multiple on every lane. attestReentry, the reference set and the quarantine bit were deleted, so a dark lane heals only through a normal push under the capped band; the reference tier remains a separate instance with its own push roster. The SDK decodes version six against a fixture that is byte-identical to the contract’s.
Commits: 230d0190, 11cc7dd9, 20f7af74, 81c8bf96, 761a7f07, 25afd339, 33d56272, e092951d, 7a183eac, 19b65df5, 47423b03, 060af9d, 91b84d0.
Status
Fixed, verified 2026-09-14 and 2026-09-15. One row is closed with no action: the bounded reentry nonce reverted at its 16-bit ceiling, which is moot now that the lever is deleted. The off-chain quote producer still emits the prior wire version; that cutover is tracked outside this scope and is not a contract change.
Rows. A-262, A-652, A-1003, A-1007, A-1103, A-1127, A-1207, A-1308, A-1309, A-1316, A-1318, A-1402 (12 rows)
F-36 Hook yield was booked at par while donations were booked at coverage, and the face conversion divided by zero
| Severity | Status | Class | Component |
|---|---|---|---|
| MEDIUM | Fixed | Audit | dex-evm/src/Pool.sol:692-693, dex-evm/src/libraries/PoolLiquidity.sol:183, dex-evm/src/Pool.sol:701 |
Severity rationale. Every harvest moved coverage on a live book, so the divergence compounded with harvest frequency rather than needing an attacker.
Description
hookCreditYield credited the liability at par while donate credited it at coverage, so the two liability-credit sites disagreed on the unit they book in. Coverage therefore moved on every harvest, transferring value between legs.
// dex-evm/src/Pool.sol:692
$.assetHooks[t].lastCreditAt = uint32(block.timestamp);
a.reserves += uint128(amount);
a.liabilities += uint128(amount);The remediation introduced two follow-on defects on the same path. Booking at face added an oracle dependency to harvest, so hookCreditYield reverted on any unusable non-target mark and one bad leg blocked every harvest. The daily rate bucket then mixed units, capping a token amount against the face book, which leaves the effective cap loose by the inverse of coverage whenever coverage is not one. Separately, the face conversion divides by coverage in deposit, donate, hookCreditYield and hookWriteDown, and only the swap-liability site carried the zero guard, so a zero coverage produced a panic; donate and hookCreditYield also lacked the zero-face guard.
Impact
Hook yield booked at the wrong unit shifts coverage, and coverage is what the wall and the toll are priced from, so the error is a cross-leg value transfer rather than an accounting cosmetic. The missing zero guard turns a degenerate coverage into a revert on four user-facing paths.
Remediation
Hook yield and the LP fee are booked at face = amount * WAD / C, the cap applies to the face while the whole push is booked, and an unusable mark degrades fail-closed rather than reverting the harvest. Five face-at-coverage sites refuse C == 0 and the zero-face guards were added, with tests on each.
Commits: 2e5e8363, eaf555ff, 9e00d331, bd689d6b, d9a6b556, 518c2c2f, 9dc67a46.
Status
Fixed 2026-09-15. The harvest degrade row is conditional on the solvency-degrade decision holding: if the degrade were reverted to a hard failure, harvest would re-block on a funded dead leg, which is an accepted fail-closed outcome with keeper retry.
Rows. A-1004, A-1122, A-1123, A-1128 (4 rows)
F-37 Hook cancel and write-down authority was protocol-scoped, so a foreign pool’s hook slot was reachable from outside
| Severity | Status | Class | Component |
|---|---|---|---|
| MEDIUM | Fixed | Audit | dex-evm/src/hooks/YieldHook.sol:65-69, dex-evm/src/Pool.sol:614 |
Severity rationale. The reachable action is a write-down or a queued-operation cancel on a pool the caller does not administer; it requires a privileged role, not an anonymous caller.
Description
YieldHook’s cancel authority was the protocol guardian or owner rather than the pool’s own seat, so a foreign pool’s seat could not cancel its own queued hook operation while the protocol could cancel one belonging to a foreign seat. The hook seat authority was not pool-scoped on the write-down and recall paths either, so a keeper push could reach another pool’s hook slot.
// dex-evm/src/hooks/YieldHook.sol:65
modifier onlyGuardianOrOwner() {
AccessControl ac_ = AccessControl(AC);
if (!ac_.isGuardianOrAuth(msg.sender, ac_.owner())) revert Err.NotAuth();
_;
}Impact
Authority over a foreign pool’s hook lifecycle sat with the protocol rather than with the pool that owns the assets, in both directions: the protocol could cancel a foreign seat’s operation, and the foreign seat could not cancel its own.
Remediation
Cancels are seat-routed on foreign pools, the write-down and recall paths are target-bound, and the hook’s pool is immutable, so a keeper push cannot reach another pool’s slot.
Status
Fixed. Both rows closed in the same change.
Rows. A-1009, A-1138 (2 rows)
F-38 Every live leg shipped with the outflow floor disarmed
| Severity | Status | Class | Component |
|---|---|---|---|
| MEDIUM | Fixed | Audit | dex-evm/src/libraries/PoolIOLib.sol:147 |
Severity rationale. With flash disabled and routes at depth one the setting is currently inert, but it is the only hard outflow floor in the protocol and it was zero on every listed leg.
Description
minLiquidity was zero on all 38 live legs, so the only hard outflow floor was disarmed fleet-wide. Coverage prices flow but never blocks it, which leaves PoolIOLib.exec with nothing to revert against.
// dex-evm/src/libraries/PoolIOLib.sol:147
if (liq < a.minLiquidity) revert Err.ThresholdViolation(liq, a.minLiquidity);The deploy manifest it came from was an Arc copy: minimum fee below two sigma, dead emitter paths, kappa absent from the fit artifact, and USD-quoted legs at a zero quote unit.
Impact
If flash were ever enabled, no hard floor would bound a drain on any leg. Today the exposure is bounded by flash being off and by single-hop routing.
Remediation
The deploy script carries a minLiquidity manifest column with flash disabled at genesis, at the owner-chosen 0.02 times initial liquidity per leg. The emitter was re-based with a kappa floor on every row, and the parameter fit that populates the manifest is a listing-ceremony step rather than a code change.
Commits: e6e9bf4b, bc1fac7, ada46da, ab939e8, 7f53d7d.
Status
Fixed 2026-09-15. Applying the fitted values to a chain is a listing-ceremony step.
Rows. A-110 (1 row)
F-39 The claim periphery accepted a malformed venue tree, and its first claim after funding hits the anti-JIT window by design
| Severity | Status | Class | Component |
|---|---|---|---|
| MEDIUM | Closed | Audit | dex-evm/src/periphery/WombexClaim.sol:91, dex-evm/src/periphery/WombexClaim.sol:140-163, dex-evm/src/LPToken.sol:145-160 |
Severity rationale. Both rows require a deployment-time mistake or a specific one-transaction sequence rather than an adversary, and the failure mode is a stranded or mispriced leg in a periphery contract, not a loss from a pool.
Description
The claim contract’s constructor validated the leg array but not the tree root, and did not reject duplicate legs. A root of zero or a repeated leg produced a tree that walks without reverting and can mis-price or strand a leg.
// dex-evm/src/periphery/WombexClaim.sol:91
constructor(address pool_, address funder_, bytes32 root_, address[] memory legs_) {
if (pool_ == address(0) || funder_ == address(0)) revert Err.ZeroAddr();
uint256 n = legs_.length;
if (n == 0 || n > MAX_LEGS) revert BadLegs();
pool = IPool(pool_);
funder = funder_;
root = root_;
legCount = n;
for (uint256 k; k < n; ++k) {
if (legs_[k] == address(0)) revert Err.ZeroAddr();
legs[k] = legs_[k];
}
}Separately, the first claim following fund reverts with CooldownActive: the distributor’s own LP receipt inherits the pool’s mint freeze, so funding and claiming cannot settle in one transaction. The revert is pinned by an existing test.
Impact
A mis-shaped tree deployed once would mis-price or strand a leg for the life of the contract. The cooldown revert is friction on an operational sequence that is never run as one transaction.
Remediation
A zero root and duplicate legs are refused at construction, pinned by unit tests. The anti-JIT window is kept deliberately.
Status
Closed. The tree validation is fixed; the cooldown is closed as intended behaviour, re-verified 2026-09-14, because funding and claiming are never issued in one transaction.
Rows. A-1015, A-1208 (2 rows)
F-40 An issuer pause or blocklist on a listed token turns an LP cross exit into an unrefillable leg
| Severity | Status | Class | Component |
|---|---|---|---|
| MEDIUM | Closed | Audit | dex-evm/src/libraries/PoolLiquidity.sol:340 |
Severity rationale. It requires a third-party issuer action rather than an attacker, but the affected legs are listed centrally issued tokens where that action is a real and exercised capability.
Description
The withdraw entrypoint gates both endpoints on the pool’s own halt flags. It has no view of the token issuer’s state, so a leg whose issuer has paused transfers or blocklisted the pool still passes the gate and the cross branch cannot be refilled.
// dex-evm/src/libraries/PoolLiquidity.sol:360
// HALT_MASK check on BOTH endpoints: withdrawTo is a value-moving user
// entrypoint (esp. cross-asset, priced off the output mark). Without this a halt is
// bypassed: draining a halted asset's reserves, or pushing a good asset
// out priced by a halted/compromised feed. Interior-node halts (Pricing) don't cover
// endpoints, and the direct spoke→base case has no interior node at all.
PoolIOLib.checkRiskFlags(assetFrom.flags, 0);
PoolIOLib.checkRiskFlags(assetTo.flags, 0);Impact
While an issuer pause or blocklist is in force, the affected leg cannot be refilled through a cross exit. The residual is a liveness constraint imposed from outside the protocol, not a solvency defect.
Remediation
The hub token is probed off chain through its baseToken() view and a revert, never an RPC error, is treated as a verdict; a blocked hub pages the operators. The issuer-pause residual is owner-accepted and remains page-only, since no on-chain control can pre-empt an issuer action.
Status
Closed. Monitoring landed on the second remediation revision and the accepted residual was re-signed off on 2026-09-14.
Rows. A-009 (1 row)
F-41 The interior displacement ceiling was a field-width artifact charged to every leg
| Severity | Status | Class | Component |
|---|---|---|---|
| MEDIUM | Fixed | Audit | dex-evm/src/libraries/Pricing.sol:45,48-50, dex-evm/src/libraries/Pricing.sol:236-250, dex-evm/src/libraries/PoolConfig.sol:73-80 |
Severity rationale. No fund loss and no attacker path; the cost was that a risk-shaped bound was in fact a storage-packing bound, applied to legs it could not describe.
Description
The 50 bp interior displacement ceiling was derived from the width of the uint16 spread field, not from any property of a leg’s move distribution. The fence is ceil(x * PBPS / (PBPS - cap/2)), and at most MAX_INTERIOR_LEGS = 6 interior legs must sum under 65535, which solves to a cap of about 10862 pbps. That worst-case number was then applied to all pools and all legs regardless of depth.
It bound minDispersionPbps at write time even on depth-1 legs that can never be routed as an interior node, which is why one equity leg could not carry its tail. Being a constant fraction of mark, it is simultaneously far too wide for a stable pair and too narrow for an equity at the open. The same field width also let the composed spread saturate a uint16 at peak confidence interval, waiving the tail premium at a 3.28% maximum fee against 11% modelled, though the fence component itself never saturates.
// dex-evm/src/libraries/Pricing.sol:44-50
uint256 private constant FENCE_BUDGET_PBPS = uint256(type(uint16).max) / MAX_INTERIOR_LEGS;
/// @notice Interior-leg mid swingPbps ceiling, PBPS — SOLVED FOR, never chosen. br.market/docs.
/// @dev DERIVATION: `_fenceOfSwingPbps` is ceil(x*P/(P - cap/2)) over P = PBPS, so the budget line
/// `N*fence <= uint16.max` at the worst case x = cap is ceil(cap*P/(P - cap/2)) <= B, i.e.
/// cap <= 2*B*P/(2P + B) — this expression. MAX_DEPTH 4 => N 6, B 10922, cap 10862, per-leg
/// fence 10922, composed 65532 <= 65535.Two adjacent documentation defects sat on the same code. The interior-fence guard suite, fifteen tests, had been red since the 2026-08-21 removal of sigma damping. The _legMid natspec asserted the mid is never zero, which is overbroad: flooredOffsetPrice(0) is zero and the guarantee actually lives upstream at the mark gate.
Impact
Legs that can never be interior were denied dispersion they could safely carry, and the ceiling gave no protection proportional to any leg’s actual volatility. The red guard suite meant the property the fence is supposed to hold was unverified for the duration.
Remediation
Interior-capability scoping is in tree: PoolConfig.dispersionCeiling and isInteriorCapable charge the cap only to legs that can be routed as an interior node, which closes the substantive complaint. The depth-aware storage re-pack was considered and dropped; the uint16 saturation is retained deliberately, since the fence component is bounded at 65532 and cannot saturate, and reverting instead would deny service to legitimate quotes. The guard suite is green at 895 passing. The _legMid guarantee is restated at its true locus.
Status
Fixed for the scoping and the test suite. The saturation behaviour is accepted as designed and the residual re-pack is recorded as decided against, with one workplan entry noted as contradicting that decision.
Rows. A-102, A-020, A-671, A-561 (4 rows)
F-42 The Rust mirror and off-chain replicas drifted from the shipped Solidity pricing law
| Severity | Status | Class | Component |
|---|---|---|---|
| MEDIUM | Fixed | Audit | core/src/fixed.rs:702, core/src/pricing.rs:124, core/src/mitch.rs:41 |
Severity rationale. The mirror is the quoting and simulation authority off-chain, so a constant that lags the chain produces confidently wrong quotes with no revert to signal it.
Description
STALE_Z was raised from 100 to 472 in Solidity only. The Rust mirror, the SDK’s generated constants and the published documentation all stayed at 100, and the mirror’s own parity test pinned the stale value, so the drift was invisible to the suite that exists to catch it.
// core/src/fixed.rs:701-705
// Pricing.sol
pub const STALE_Z: U256 = U256::from_u64(100);
pub const STALE_GRACE_CAP_SECS: U256 = U256::from_u64(30);
pub const MAX_DISPERSION_PBPS: U256 = U256::from_u64(900_000); // PoolConstantsLibThe same mirror was not updated for the 2026-09-04 interior swing cap. Its merged single-source-of-truth module also contradicted itself, declaring MAX_INSTRUMENT_TYPE as FUND (0xC) while a STRUCTURED type existed above it, and carried roughly 67 lines of unreferenced code including two constant tables that had silently gone stale.
A related family of view-versus-execution divergences sat on the same law. _legExecPrice reverted ZeroValue when the price floored to zero on an analytics-gated view path while execution settled the same dust, so off-chain routing failed on executable dust. Fee and toll flooring rounded one wei toward the trader rather than the pool. The full-drain preview clamped to reserves and omitted cross fees and caps, while execution reverted or confirmed through minAmountOut. Off-chain decimal fallbacks guessed 18 on route, liability and allowance paths, with the money path fail-closed behind on-chain bounds.
Impact
A replica quoting against a stale staleness multiplier prices the staleness premium wrong in the direction that understates it. The dust revert broke off-chain routing for orders the chain would have settled. The rounding direction handed the pool’s dust to the trader. None of these moved funds beyond dust, but each made the off-chain price disagree with the chain.
Remediation
STALE_Z and the interior swing cap are now derived in the mirror rather than written as literals, with sol_const_pin.rs diffing the derivation against live Solidity source and pinning the values per function. The instrument and class tables are derived as the single source of truth, which also removes the unreferenced tables. _legExecPrice returns zero on dust instead of reverting, and fee and toll now round toward the pool with the mirror following.
Commits: 590b3e1, b671bc9, 3f71c59, 1511d696, 4f8a42a, ccded83.
Status
Fixed on 2026-09-11. Pinned by pricing_parity.rs and PricingRounding.t.sol:30,:56,:67,:78. The remaining informational items were closed in the 2026-09-10 review.
Rows. A-901, A-130, A-137, A-138, A-239, A-523, A-518, A-617 (8 rows)
F-43 The swap submit path re-anchored its floor and admitted a double submit
| Severity | Status | Class | Component |
|---|---|---|---|
| MEDIUM | Fixed | Audit | front/src/components/features/swap/SwapForm.tsx:1163-1198, front/src/components/features/swap/SwapForm.tsx:1153-1157, front/src/components/features/swap/SwapForm.tsx:749 |
Severity rationale. Both defects need only an ordinary user on an ordinary tape; the outcome is a fill below the displayed floor or a duplicated order, each of which the user must still sign.
Description
On submit the form replaced the displayed plan with a fresh route plan and derived every floor from it, never comparing against the amountOut the user was shown off an eight-second poll and never re-prompting. An adverse move inside the display window therefore executed below the on-screen minimum received, whose tooltip promised a revert. The SDK’s refloorRouterPlan takes the minimum of quoted and fresh output, which guards the favourable direction only; the caller obligation to refuse or warn when the fresh output falls below the slipped quoted output was documented but unimplemented. The gap is bounded by adverse drift over at most eight seconds plus fetch latency, and the executed price is still fresh market minus tolerance, default 0.5%.
The re-quote, the balance re-check and the plan build all ran before setSubmitting, leaving roughly an 800 ms window with no spinner in which a second click launched a parallel batch on sequential nonces, debiting the input amount twice. Each flight required its own wallet confirmation, so there was no silent double spend, but the absent spinner invited the second click.
// front/src/components/features/swap/SwapForm.tsx:1165-1180
try {
const spendable = spendableOf(fromSym, rawBalanceOf(fromSym));
if (spendable !== undefined && exactAmountIn !== undefined && exactAmountIn > spendable) {
return void addNotification('error', `Not enough ${fromD}: balance moved since the quote.`);
}
} catch {
// Unparseable size: the batch builder below reports it, not the funds gate.
}
const slipFrac = slippagePct / 100;
const rawLegs = planToLegs(best, {
slippageFrac: slipFrac,
tokenOf: getToken,Adjacent to these, the cross-withdraw fallback used when the routing backend was unreachable previewed a full-face USD value with no haircut, spread or fees, deriving a minimum output too high and producing a ThresholdViolation revert. The send-path slippage fraction was unclamped, so auto mode above 100% displayed a negative minimum before the assertion aborted the send. Liquidity-provider execution sent aged floors with no send-time re-quote, a preflight dry run failing cleanly before the prompt. Step context was dropped on liquidity-provider submit, the success line printed the quote rather than the receipt, and the swap interface read only the feed gate, so a halted-leg swap built and then reverted.
Impact
A user could receive less than the minimum received figure the interface displayed and described as enforced. A second click during the pre-submit window produced two mined swaps for one intent. The backend-down cross-withdraw path produced a guaranteed revert rather than a conservative preview, costing gas.
Remediation
The form now encodes the displayed floor and refuses on a lower re-quote, with the SDK carrying the matching caller-side check. A latch drops a second click before the asynchronous re-quote and plan build. The unfloored USD fallback is gone and cross-withdraw without a plan is gated. The remaining display and friction items were reviewed and closed below the low-severity bar.
Commits: 48587b54.
Status
Fixed on 2026-09-11, pinned by swapTx.test.ts. Informational rows closed on 2026-09-09 and 2026-09-10.
Rows. A-243, A-568, A-800, A-653, A-626, A-627, A-808, A-770 (8 rows)
F-44 The router library trusted backend-supplied pool addresses and mis-scaled chained floors
| Severity | Status | Class | Component |
|---|---|---|---|
| MEDIUM | Fixed | Audit | sdk/src/router/index.ts:352-369, sdk/src/router/index.ts:277-293, sdk/src/router/lpRoutes.ts:451-475 |
Severity rationale. The library grants an approval to whatever pool address it is handed, so a consumer that builds plans from an untrusted route service loses the approved balance; the shipped front end defuses it, direct consumers do not.
Description
planToLegs accepted the backend-supplied poolAddr verbatim with no allowlist. The legacy N-call approval path grants per pool, so a rogue pool address served in a route plan received an approval and could drain it, amplified when approveMax was set. The front end defuses this by sending the token universe to the backend, tag-allowlisting the result, defaulting approveMax to false, preferring the router and backstopping with UnknownPool; direct SDK and legacy consumers had none of that.
// sdk/src/router/index.ts:350-369
export function buildApprovalCalls(legs: ExecLeg[], opts: BuildOpts): ExecCall[] {
const wnative = opts.wrappedNative?.toLowerCase();
const { wrapValue } = validateLegs(legs, wnative);
const exactByKey = new Map<string, bigint>();
for (const leg of legs) {
const key = `${leg.tokenIn.toLowerCase()}:${leg.pool.toLowerCase()}`;
exactByKey.set(key, (exactByKey.get(key) ?? 0n) + leg.amountIn);
}
const approveAmt = (key: string): bigint =>
opts.approveMax ? MAX_UINT256 : (exactByKey.get(key) ?? 0n);The route enumerator existed twice. The second copy in route.ts had no production caller and had already drifted: its three-hop arm was gated on no two-hop route existing, so a better three-hop route was unreachable whenever any two-hop route was found.
Three further defects sat on the same surface. The chained two-leg batch undersized leg two and over-floored it with zero margin, producing spurious ThresholdViolation reverts on ordinary noise. The liquidity-provider route and liability replica still modelled the deleted per-leg haircut, applying coverage on input and then output, while the chain settles at pool-level coverage, giving wrong route rankings and wrong floors in both directions. The unwrap path had a recipient mismatch: the router pays wrapped native to the recipient while the SDK’s unwrap call withdraws from the sender, so a recipient different from the sender either reverted empty or debited the sender from a prior balance. Scales above 18 decimals were truncated, unreachable today because listing rejects them.
Impact
A consumer building plans from an untrusted route service could have an approved balance taken by an address of the service’s choosing. The duplicate enumerator silently excluded better three-hop routes. The mirror drift produced floors that did not match how the chain settles, and the chained floor produced avoidable reverts.
Remediation
planToLegs now requires opts.isOfficialPool per hop, and approvals default to the exact amount with approveMax opt-in. The duplicate enumerator was removed, leaving one. The chained leg-two floor is scaled by the ratio of leg one’s minimum output to its quoted output. The route and liability replicas mirror pool-level coverage, with the front-end callers updated in the same change. assertUnwrapSelfDirected refuses a native-out plan whose recipient is not the sender, and the front end passes the sender into the unwrap path.
Commits: 3a03ab7, bfd0170, 8b822d6, 095a741, b3d71b06, 4f452bc0.
Status
Fixed on 2026-09-10 and 2026-09-11. Pinned by liability.test.ts:40 and lpRoutes.test.ts:215. The decimal-scale item was closed below the low-severity bar on 2026-09-10.
Rows. A-508, A-169, A-614, A-919, A-675, A-618 (6 rows)
F-45 Trading routes rendered before the access gate and continuous integration had not run since 09-14
| Severity | Status | Class | Component |
|---|---|---|---|
| MEDIUM | Fixed | QA | front/.github/workflows/ci.yml:44, front/src/App.tsx:318, front/src/pages/metrics/metricsModel.tsx:121 |
Severity rationale. A broken pipeline and an ungated route are both certain, not probabilistic; the impact is loss of the gate that was supposed to hold, not loss of funds.
Description
The workflow pinned bun 1.4.3, a version with no GitHub release, so every run on the main branch since 2026-09-14 died at the setup step. No gate had actually executed in that window.
# front/.github/workflows/ci.yml:42-44
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.4.3"The /swap-form and /chart routes rendered before the invite and disclaimer gate, so a direct link reached the trading interface without passing it. Three smaller defects sat alongside: the vite dev plugin referenced an undeclared source variable, merged-asset strategyApr took the first non-null value instead of a value weighted by total value locked, and prependCandles evicted the newest candles rather than the oldest once past the retention limit.
Impact
For two days the pipeline reported failure at setup rather than running any check, so nothing merged in that window was gated. The ungated routes exposed the trading interface without the invite and disclaimer step. The aggregate rate displayed on the metrics page was not representative of the merged position.
Remediation
The bun pin moved to a released version and the gates run again. Every trading route now sits behind the invite and disclaimer gate. The dev plugin declares its source, and displayed aggregates are weighted by total value locked. The candle eviction order was reviewed and accepted as it stands.
Commits: fe4fdcc, 5a56106, e2694a8, 92885d3, 0fcd1b9.
Status
Closed on 2026-09-16, with the candle eviction row accepted, no change required.
Rows. A-1594, A-1595, A-1596, A-1597, A-1598 (5 rows)
F-46 The wallet send lifecycle could report a broadcast transaction as cancelled
| Severity | Status | Class | Component |
|---|---|---|---|
| MEDIUM | Fixed | Audit | front/src/lib/walletCalls.ts:208, front/src/lib/wallet.tsx:477, front/src/components/shared/TxSteps.tsx:79 |
Severity rationale. A user acting on a false “cancelled, no gas spent” message will resubmit, and the resubmission is a second real transaction; no attacker is required.
Description
sentDespiteRejection decided whether a rejected prompt had nevertheless broadcast by comparing the pending nonce before and after, retrying once after 1.2 s. A transaction that reached the mempool after that window read as unchanged, and the interface certified it as cancelled with no gas spent.
// front/src/lib/walletCalls.ts:203-214
export async function sentDespiteRejection(
provider: Eip1193Provider,
from: Address,
before: number | undefined,
): Promise<boolean> {
if (before === undefined) return false;
for (const waitMs of [0, 1200]) {
if (waitMs) await new Promise((r) => setTimeout(r, waitMs));
const after = await pendingNonce(provider, from);
if (after !== undefined && after > before) return true;
}
return false;
}In a non-atomic batch, calls that had already mined never emitted a confirmation once a later receipt reverted, so the interface showed nothing for work that had actually settled. The transaction overlay labelled every approval rung with hop one’s token, pool and amount, so a multi-hop plan displayed the wrong subject on each step. Two separate gas-reserve formulas existed and could disagree.
Impact
A user told a broadcast swap was cancelled will retry and pay twice. A user whose earlier batch calls mined saw no record of them. Mislabelled approval rungs meant the wallet prompt and the interface disagreed about what was being approved.
Remediation
The send lifecycle now reports what was actually signed, broadcast and mined, and refreshes balances and allowances afterwards. Mined predecessor calls emit their confirmations even when a later call in the batch reverts. Each approval rung carries its own token, pool and amount. The duplicated gas-reserve formulas were reviewed and accepted as they stand.
Commits: 93383ae, 710cbbf, 1d7b649, 0193040, 6e9238c.
Status
Closed on 2026-09-16, with the gas-reserve row accepted, no change required.
Rows. A-1590, A-1591, A-1592, A-1593 (4 rows)
F-47 A pool with reserves and no liabilities returned a full coverage rate and handed the surplus to the next depositor
| Severity | Status | Class | Component |
|---|---|---|---|
| MEDIUM | Fixed | Audit | dex-evm/src/libraries/PoolSolvency.sol:79, dex-evm/src/libraries/PoolSolvency.sol:131 |
Severity rationale. The stranded state is reachable through ordinary use, a full same-asset exit below par, and the next depositor captures the stranded reserves with no special access.
Description
solvency returned WAD whenever the claim book was empty, including when the pool still held reserves. A full same-asset exit at a coverage rate below one strands reserves with zero liabilities, and the next depositor mints face value against a book that already holds surplus, capturing it. The natspec claimed the pooled rate was preserved.
// dex-evm/src/libraries/PoolSolvency.sol:76-80
navBase += (r * px) / SC.WAD;
claimBase += (l * px) / SC.WAD;
}
if (claimBase == 0) return (SC.WAD, true, navBase, claimBase);
cWad = (navBase * SC.WAD) / claimBase;The first integrated fix returned (0, false) for that state, which hard-refused it: mintRate reverted FeedUnavailable, so deposit, donate, liability swap and hook credit all froze with no heal lever until value was added or the claim book re-seeded, while the same-asset exit cap still paid the last good coverage rate. A separate concern, that lastGoodCWad had no age bound, applied to the degrade fallback.
Impact
Before the fix, reserves left behind by a below-par exit accrued to whoever deposited next rather than to the exiting liquidity providers. After the first fix, the same state froze every credit path in the pool instead.
Remediation
A stranded book with reserves and no liabilities now reads (WAD, false, A, 0), and only the pool owner’s deposit re-seeds it, so the surplus is neither captured by an arbitrary depositor nor permanently frozen. The degrade fallback that needed an age bound was removed together with the fail-closed revert of the par-degrade.
Commits: 1a02316d, d460cf93, 62065e8d, c4e13474, 96fc09ac, 230ebc23, afeff22a.
Status
Closed. Pinned by PoolSolvency.t.sol:569.
Rows. A-1304, A-1315 (2 rows)
F-48 Deploy and operator scripts still targeted retired oracle surfaces and levers that no deployed contract exposes
| Severity | Status | Class | Component |
|---|---|---|---|
| MEDIUM | Fixed | Audit | dex-evm/script/Deploy.s.sol:80-88, dex-evm/script/OracleV4Unwedge.s.sol:61-64, dex-evm/script/lib/ChainParams.sol:74-78 |
Severity rationale. Every failure mode here is fail-closed at broadcast rather than a mispricing, but the incident-time scripts are exactly the ones whose failure matters most, and a new listing built against a retired contract surface would have to be re-run.
Description
The listing path still compiled against the V1 ExternalOracle surface. Deploy.s.sol used the V1 type as the shared feed ABI and ArcOracleDeploy.s.sol:180 constructed it, so a listing performed with the shipped scripts would have targeted a retired contract. The same ceremony shape persisted throughout ArcPoolDeploy.s.sol: the legacy-oracle gate reverted without a REF_ORACLE bypass, the mirror and anchor helpers called a V1-only addFeed, the mark helper required seed keys a greenfield run never writes, and FeedOrderLib.write required a getFeedIds the live contract does not expose.
// dex-evm/script/Deploy.s.sol:80
function _broadcastDeployWith(address acOverride) internal returns (Addrs memory a) {
uint256 pk = vm.envUint("DEPLOYER_PK");
a.deployer = vm.addr(pk);
// If DEPLOYER is set it must match the PK-derived address.
try vm.envAddress("DEPLOYER") returns (address d) {
require(d == a.deployer, "DEPLOYER/DEPLOYER_PK mismatch");
} catch {}
a.treasury_owner = _resolveTreasury(a.deployer);
vm.startBroadcast(pk);The unwedge ceremony had the inverse problem. The scripts are typed against a pendingFeedWiden / requestFeedWiden / executeFeedWiden / cancelFeedWiden lever that landed after the live oracles were deployed, so those selectors are absent from every deployed runtime blob. The script’s preview() touches only read functions, so the dry run was clean and the failure was deferred to broadcast. execute() also ignored the per-lane selection filter, releasing all pending entries rather than the still-selected ones. Alongside these, SafetyOps.s.sol was a bare Script with no chain assertion, so an incident-time halt aimed at the wrong RPC would revert or mis-halt; UpgradePoolImpl.s.sol minted a Pool without enforcing the library pins its own comments mandated and logged a hardcoded 6 h delay; ChainParams read a .chain.govDelays manifest key that no script parses, contradicting its own natspec; the CREATE3 fleet manifest carried no chain-id binding; and the legacy and mocks gates could be bypassed by pointing RISK_PARAMS at a fixture file.
Impact
A listing run would have deployed against a retired oracle surface. The unwedge and halt ceremonies, both incident-time tools, would fail at broadcast rather than in the dry run, and the batch release could touch lanes the operator had deselected. The testnet faucet let any address self-whitelist, so its per-address cap was not a cap.
Remediation
The V1 oracle sources and the V1 ceremony surface were deleted and the listing scripts ported to the V4 registerFeed surface with REF_ORACLE mandatory, with forge build green. OracleV4Unwedge now probes for lever presence by code and selector and prints LEVER ABSENT instead of reverting at broadcast, and its execute() releases only still-selected, unexpired lanes. SafetyOps extends ChainParams with _assertChain on each entry point and documents the guardian halt path. UpgradePoolImpl asserts all four derived library pins against the linked library address before broadcast, so a stale or foreign link reverts instead of minting. ChainParams refuses a manifest .chain.govDelays key and pins PROD_DELAYS, reads the CREATE3 fleet from a chain-id-bound manifest, and refuses a RISK_PARAMS override off a local-class chain.
46622d71, 93fd1951, a236b6f2, 6623561b, df7a2c35, fb975b2b
Status
Fixed, verified 2026-09-10 and 2026-09-11 and at the rev2 chair review. Four informational rows were closed rather than fixed: the testnet faucet is testnet-only and is replaced by a pool-side deposit allowlist; the FORCE_EXECUTE dark-leg repoint is a deliberate opt-in lever, default off; the hardcoded 6 h delay in the UpgradePoolImpl log is correct on the chain it runs on; and the standing claim that no production file imported the V1 oracle was corrected in the record.
Rows. A-032, A-079, A-100, A-156, A-192, A-227, A-228, A-233, A-247, A-260, A-604, A-801, A-803, A-804, A-806 (15 rows)
F-49 Launch manifests shipped dispersion floors and feed-id bindings that did not match the signed feed set
| Severity | Status | Class | Component |
|---|---|---|---|
| MEDIUM | Fixed | Audit | dex-evm/deployments/arc-risk-params.json:593, dex-evm/script/OracleV4Deploy.s.sol:127-133, dex-evm/deployments/bnb-risk-params.json |
Severity rationale. Too-narrow dispersion is the direction that picks off liquidity providers, and an identity base depeg lane means the halt that protects every swap on the chain can never fire; both ship in the manifest and neither is caught by a code-level cap.
Description
The 2026-09-03 patch set minDispersion flat at 1950 pbps (19.5 bp) for all ten equity names. The measured q999 tail for those names runs 22.4 to 38.7 bp, so the floor was narrower than the tail on all ten. Per-name dispersion had never been written. The value passes every code cap; the defect is that the floor underprices tail dispersion.
The BNB scaffold had two binding defects. The deploy registered feed ids as keccak(token, quote) while the push side addresses feeds by their MITCH bytes32(ticker_id), so every genesis lane, the base USDC-USD lane included, would have gone stale on day one. Separately, the manifest pinned the .USD unit to USDC itself, which makes the base depeg lane keccak(base, base): an identity that nothing quotes, so a Binance-Peg USDC depeg trips no halt. The manifest called that lane “owner pinned” with no owner record behind it.
// dex-evm/script/OracleV4Deploy.s.sol:130
/// @dev P1-RISK-1 (owner, 2026-09-11): production halts on a REAL USDC-USD depeg. A USD unit equal
/// to the base makes that lane keccak(base, base), an identity no signer prices, so the halt
/// it exists for can never fire. Refused on a production chain.
function _usdUnit() internal view returns (address u) {
u = _tokenOf("USD");
require(
!_isProductionChain() || u != _tokenOf(_baseSym()),
"USD unit == base on a production chain: the depeg lane is an inert identity (P1-RISK-1)"
);
}Impact
Under the flat equity floor, quoted dispersion sat inside the measured tail on all ten names, which transfers value from liquidity providers to informed flow on tail moves. On BNB, the id mismatch would have staled every genesis lane at listing, and the identity USD lane would have left a real stablecoin depeg with no halt.
Remediation
Per-name dispersion floors were written on chain on 2026-09-04 and verified on all ten names; three legs remain deliberately clamped short of the measured tail. BNB genesis now binds MITCH ids, with the deploy record and the push-side manifest asserted equal by OracleDeployScripts.t.sol, and the base depeg lane is a real signed USDC-USD lane halting at 5 percent per PoolConstantsLib.sol:94. The USD unit is refused outright when it equals the base on a production chain.
Status
Fixed. Per-name equity floors were applied on chain 2026-09-04; the BNB bindings landed with the V5 genesis. The residual blanket optional=true on the push-side feed list and the stale “inert” wording in the BNB manifest are tracked as a separate open row. One duplicate informational row on the same equity floors was closed under the informational purge of 2026-09-09.
Rows. A-099, A-727, A-904, A-905 (4 rows)
F-50 The SDK build-time ABI integrity check was a tautology and fell back to stale artifacts
| Severity | Status | Class | Component |
|---|---|---|---|
| MEDIUM | Fixed | Audit | sdk/scripts/fetch-abis.ts:84, sdk/src/abis/fetch.ts:7-19, sdk/src/cache.ts:38-53 |
Severity rationale. The check that was meant to authenticate a fetched ABI derived its expected value from the fetched payload itself, so a hostile response passed; reaching it requires control of the serving API, which is a trusted tier.
Description
fetch-abis.ts pinned each ABI by function names plus one selector. The selector comparison recomputed the selector from the signature string it had just read out of the fetched payload, so both sides of the comparison came from the same untrusted input and any payload that named the right functions passed.
// sdk/scripts/fetch-abis.ts:84
for (const [sig, want] of Object.entries(target.pins)) {
const e = fns.find((f) => sigOf(f) === sig);
if (!e) throw new Error(`integrity: ${target.name} ABI missing pinned ${sig}`);
const got = selectorOf(sig);
if (got !== want) throw new Error(`integrity: ${target.name} ${sig} -> ${got}, want ${want}`);
}
}The same script fell back to keeping the existing on-disk ABI and exiting zero when the fetch failed, with only a warning, so offline or pinned-reference container builds could ship a silently stale ABI. At runtime, fetchAbi and fetchVenues applied no integrity check at all and cached results in localStorage with no version key, so a poisoned entry persisted across a version bump. The oracle ABI export was documented as a V2 alias when it was the V4 ABI plus V2-only entries, and a V1 ABI shipped beside a V4 fleet.
Impact
A build-time or runtime ABI substitution would not have been refused by the integrity check. Value-moving calls encode against static ABIs and a mis-encode fails closed, so the realistic outcome is a stale or wrong read surface rather than a wrong transfer.
Remediation
The ABI pin is now a content hash over normalised entries, recorded in abis.lock.json, and fetch-abis fails closed on a mismatch or a missing pin rather than keeping a stale artifact. The cold ABI cache and the runtime venue fetch were deleted and pinned ABIs are served locally. The V2 alias was dropped from the docs, leaving EXTERNAL_ORACLE_V4_ABI as the only oracle export.
Status
Fixed, verified 2026-09-10 and 2026-09-11. Covered by abi-pin.test.ts.
Rows. A-115, A-151, A-257, A-259, A-509 (5 rows)
F-51 SDK transaction encoding and nonce allocation produced unsendable or permanently gapped transactions
| Severity | Status | Class | Component |
|---|---|---|---|
| MEDIUM | Fixed | Audit | sdk/src/eth/rlp.ts:42-44, sdk/src/eth/client.ts:113, sdk/src/eth/abi.ts:335-338 |
Severity rationale. The RLP defect corrupts the signing preimage of any EIP-1559 transaction carrying an access list, and the nonce defect can stall a signer permanently; both are liveness failures with no path to an incorrect transfer.
Description
encodeRlp returned the empty-string prefix 0x80 for a zero-length input, and the EIP-1559 encoder passed the empty access list through that path. RLP requires the empty-list prefix 0xc0. The signing preimage was therefore wrong and the signed transaction was rejected.
// sdk/src/eth/rlp.ts:42
// Empty string
if (bytes.length === 0) {
return new Uint8Array([0x80]);
}
// Single byte < 0x80
if (bytes.length === 1 && bytes[0] < 0x80) {
return bytes;Nonce handling had two successive defects. getTransactionCount sat behind a request deduper, so concurrent signTransaction calls in the same tick shared one result and reused a nonce, producing a replacement or a dropped transaction. The fix then introduced a regression: the allocator recorded lastIssued at allocation rather than at acceptance, so a single failed estimateGas or send left a nonce gap that never healed for the lifetime of the process. Alongside these, the client trusted the chain id the RPC reported rather than checking it against an expected value, getPlan resolved ABI entries first-name-wins with no overload dispatch, signTypedData crashed on a bigint chain id because JSON.stringify throws on bigints, withDecodedRevert replaced a typed revert error with a SyntaxError on truncated revert data, and the contract read and write helpers never surfaced decoded revert data at all.
Impact
Transactions carrying a non-empty access list were rejected outright. A signer that hit one send failure stopped being able to send at all until restarted. The missing chain guard leaves a signed payload’s chain binding dependent on whatever endpoint answered.
Remediation
The RLP encoder emits 0xc0 for an empty access list, with transaction vectors added. Same-tick nonce reuse was removed and the allocator now releases a nonce on a failed send, so a failure no longer leaves a gap. signTransaction refuses an endpoint chain id that differs from the expected one and a tx.chainId mismatch at the preimage, and the healthy-RPC selector fails closed. getPlan refuses an ambiguous overload unless the full signature is named, signTypedData uses a bigint-safe JSON replacer, and withDecodedRevert keeps the typed revert error on clipped data.
3a03ab7, 84bc248, d378c53, 62d05b9
Status
Fixed, verified 2026-09-10 and 2026-09-11. Covered by tx-vectors.test.ts:223 and correctness.test.ts:319. Four informational rows were closed under the purge of 2026-09-10 rather than fixed: undecoded revert data on the generic contract helpers, multicall split-batch tearing against a stale RPC, a float-based haircut preview that is display-only, and the absent getCode check on the canonical multicall address.
Rows. A-636, A-637, A-639, A-640, A-642, A-643, A-644, A-683, A-690, A-925, A-943 (11 rows)
F-52 The oracle page presented a governance push band as extractable value
| Severity | Status | Class | Component |
|---|---|---|---|
| MEDIUM | Fixed | Audit | front/src/components/features/oracle/FeedsTable.tsx:45-46, front/src/components/features/oracle/useOracleData.ts:244-245, front/src/components/features/oracle/oracleMarks.ts |
Severity rationale. The figure was displayed to operators as live extractable value while being a governance parameter product, overstating it by 25 to 50 times; no contract reads it, so the impact is decision quality rather than funds.
Description
The feeds table computed a per-feed figure from maxDeviationBps, the class-pinned sigmaPbps and the feed TTL, and labelled it “OEV”. That product is the widest band a push may legally carry, a governance ceiling, not value anyone extracted. It read 75 to 274 bp of TVL per feed against measured live deviations of 0.0 to 5.5 bp, and it is static by construction because sigma is pinned per asset class.
// front/src/components/features/oracle/FeedsTable.tsx:45
/** Widest band the next accepted push can carry, evaluated at the feed's own TTL (the widest
* legal source gap) so the OEV figure is a ceiling rather than a snapshot. */
const oevBandBps = (row: FeedRow): number =>
pushBandBps(row.maxDeviationBps, row.sigmaPbps, row.ttl);A second defect in the same area was a literal NUL byte committed as a memo separator inside a string literal in the feed-gate helper. Git classified the entire 2169-line file as binary, so it produced no diff in review, no three-way merge, and git show and grep returned nothing for it. The merge had to be resolved by hand and the defect was invisible to every prior review of that file.
Impact
Operators read a static governance ceiling as a live loss figure. The NUL byte removed a whole file from code review and from text tooling for as long as it was present.
Remediation
The oracle page now distinguishes the governance push band from measured live deviation. The NUL byte was replaced by the equivalent backslash-u escape, which keeps the file text and restores diffs, merges and grep.
Status
Fixed. The oracle page change is on the audit/oev-live branch and is not yet merged. Two informational rows were closed under the purge of 2026-09-10: stale marks are unfiltered in the portfolio revaluation path, which is display-only since the swap gate filters them, and the gate feed row drops a source timestamp that is currently redundant because the clocks are equal under V4.
Rows. A-103, A-186, A-623, A-674 (4 rows)
F-53 Natspec and comments overclaimed against the shipped constants and levers
| Severity | Status | Class | Component |
|---|---|---|---|
| MEDIUM | Fixed | Audit | dex-evm/src/oracles/ExternalOracleV5.sol, dex-evm/src/libraries/Pricing.sol:52, dex-evm/src/oracles/OracleBeacon.sol |
Severity rationale. Documentation-only divergence, but on the interior swing cap and the beacon upgrade gate it describes safety properties the code does not have, which is what operators act on.
Description
Three comment defects shipped alongside the code they described. The interior swing cap was cited as 10000 when the shipped constant is 10862, with the interior-scoped dispersion ceiling documented against the old number, and Admin natspec carried stale 5000 and 1000 figures. The beacon layout natspec overclaimed upgrade safety, and the adminBackfillLegs comment understated a fail-closed brick.
The third was live rather than stale: after the preceding quarantine fix, the quarantine bit became write-only. clearQuarantine cleared a bit that no longer affected _allowed, and no getter exposed it, so the natspec claim that clearing re-arms the lane was false.
// dex-evm/src/oracles/ExternalOracleV5.sol:586
/// @dev TIGHTENING: clears ONLY the lane's quarantine bit, re-arming the X cap on a dark lane.
function clearQuarantine(bytes32 feedId) external {
requireGuardianOrOwner(AC);
OracleStorageV5.OracleData storage $ = OracleStorageV5.get();
(uint256 gi,) = _giCfg(feedId);
uint256 lane = gi % LANES_PER_SLOT;
$.riskSlot[uint32(gi / LANES_PER_SLOT)] &=
~bytes32(uint256(1) << (QUARANTINE_SHIFT + lane));
emit QuarantineCleared(feedId, msg.sender);
}Impact
An operator following the beacon natspec would have assumed an upgrade-safety check that the gate does not perform, and one following the quarantine natspec would have believed a dead call re-armed a dark lane.
Remediation
The cap numbers were made symbolic or corrected to 10862 in both the contracts and the published documentation; the stale Admin natspec was deleted. The beacon layout and backfill comments were corrected. The quarantine bit and clearQuarantine were deleted outright rather than documented, with zero remaining references across the oracle sources, the interface and the client packages.
235d6b2d, f9e19361, c42848dd, f039e1e7, 3d215a2c, 99eef0f6, 761a7f07, b58f78d, 3e4834e
Status
Fixed and closed, verified at the rev2 signoff. One stale natspec residual left by the quarantine deletion is tracked as a separate row.
Rows. A-127, A-1017, A-1309 (3 rows)
F-54 The V5 sigma floor was one-way and was not applied on read, so releasing a dark lane left a permanent wide band
| Severity | Status | Class | Component |
|---|---|---|---|
| MEDIUM | Fixed | Audit | dex-evm/src/oracles/ExternalOracleV5.sol:450, dex-evm/src/oracles/ExternalOracleV5.sol:436 |
Severity rationale. Releasing a dark lane is a routine operation and the only mechanism available raised a floor that could never be lowered, leaving that lane permanently accepting pushes up to ten times its configured maximum deviation.
Description
The only way to release a dark lane was to raise sigmaFloor, and sigmaFloor had no downward path. The released lane therefore kept a per-push band of ten times maxDev for the rest of its life. The self-heal path ignored the sigma carried by the blob it had just refused, so the floor could not converge back on the observed volatility.
// dex-evm/src/oracles/ExternalOracleV5.sol:344
uint256 sigmaStored = (rw >> (c.lane * RISK_STRIDE)) & RISK_SIGMA_MASK;
uint256 sigmaFloor = uint32(c.scfg >> SCFG_SIGMA_FLOOR_SHIFT);
uint256 sigma = sigmaStored > sigmaFloor ? sigmaStored : sigmaFloor;
uint256 newMark = _decodeLane(c.nl);
(uint256 devBps, uint256 allowed) =
FeedMathLib.bandVals(c.prevMark, newMark, dt, uint16(c.scfg), uint32(sigma));
if (devBps <= allowed) return true;Separately, PoolIOLib natspec claimed a relay-only, one-push-per-block property that push() does not enforce.
Impact
Any lane that had been released once accepted pushes inside a band an order of magnitude wider than its configured deviation cap, permanently, which is the band that bounds how far a single accepted push can move the mark a pool prices against.
Remediation
sigmaFloor is now the single lower bound on sigma applied on every read, and it is adjustable in both directions under the timelock, so a released lane can be tightened again.
Status
Fixed 2026-09-16. The absence of a ttlSecs ceiling below the uint16 maximum of 18.2 h on registerFeed and requestFeedWiden was accepted on 2026-09-16 with no code change. The PoolIOLib natspec residual is tracked in a separate open row.
Rows. A-1517, A-1519 (2 rows)
F-55 Published PoolFactory ABIs diverged from the contract, so consumers encoded selectors that do not exist
| Severity | Status | Class | Component |
|---|---|---|---|
| MEDIUM | Fixed | Audit | dex-evm/src/PoolFactory.sol, sdk/src/abis |
Severity rationale. A consumer encoding a removed selector produces a call that reverts rather than one that succeeds incorrectly, but the divergence covered the official-pool grant path, which is a routing credential.
Description
The pinned consumer ABIs lagged the contract. executeOfficial and cancelOfficial existed on PoolFactory but were missing from the published ABI, while the removed setProtocolDeployer was still exported. Consumers therefore had no way to encode the two live calls and a way to encode one that no longer exists.
// dex-evm/src/PoolFactory.sol:326
function executeOfficial(address pool) external override onlyAdmin {
uint256 eta = pendingOfficial[pool];
if (eta == 0) revert Err.NoPending();
if (block.timestamp < eta) revert Err.NotReady();
if (block.timestamp > eta + SC.GRACE_PERIOD) revert Err.Expired();
delete pendingOfficial[pool];
// The shape is re-asserted HERE, not only at request: the delay is exactly the window in which
// the pool's sink / seal / listing can move, and branding is a routing credential.
_grantOfficial(pool);
}Impact
The queued official-pool grant could not be executed or cancelled through a published ABI, and a consumer could construct a call to a selector the contract no longer implements.
Remediation
ABIs are now generated in dex-evm and consumed by path rather than hand-copied, IOracle.json is published, and the hashes are locked and recomputed equal across consumers. No CI parity gate exists yet for this surface.
Status
Fixed and closed.
Rows. A-1131 (1 row)
F-56 Session grants, sequence numbers and signer-set changes did not bound what their documentation claimed
| Severity | Status | Class | Component |
|---|---|---|---|
| LOW | Fixed | Audit | dex-evm/src/oracles/ExternalOracleV4.sol:292-293, dex-evm/src/oracles/ExternalOracleV4.sol:334-341, dex-evm/src/oracles/NxrSignerSet.sol:159 |
Severity rationale. Every item here is liveness or documentation accuracy at the shipping configuration; the strongest, a public push at a frozen mark, is unprofitable against the live gate.
Description
Session seq was bounded by maxSeq only and never stored, so the number of pushes in a session was effectively unbounded. The fix stored a session-global lastSeq and required strict monotonicity, which then rejected honest pushes: on the shipped wire seq is the minimum observed value across the blob, not a monotone counter, and the chain orders by transaction, not by that field. The stored gate bounded nothing it claimed to bound while costing honest pushes, and the accompanying natspec asserted a property that does not hold at the shipped grant shape. Session and bias nonces were checked uint16 additions, so the arithmetic reverts permanently at 65535.
An open session is in-band mark authorship for the granted relay address: pushV4 verifies no signature, only the sender, the expiry and seq <= maxSeq, bounded thereafter by the per-lane deviation band, monotonic replay and the reference band. Three operator conclusions stated a relay key could not author a mark. The durable cutoff is revokeSigner below threshold, which makes every future openSession unsatisfiable, not revokeSession alone. Separately, a permissionless push against a frozen mark is reachable on the V1 oracle path, and a guardian mass revoke followed by serial single-slot re-grants produces a fleet-wide push outage of N times the BASE delay.
Impact
Liveness and operator accuracy. The rejected-honest-push regressions stall feeds rather than exposing value, the nonce overflow is a permanent denial of a governance path at a bound no deployment reaches, and the documentation defects would misdirect an on-call responder holding a leaked relay key.
Exploit scenario
- An address with an open session observes a lane whose mark is frozen.
- It lands an in-band push in the same block as its own trade, at up to the band’s gross allowance.
- The deviation available is bounded by theta plus drift while the gate charges a spread of at least two theta, so the round trip is net negative at the live configuration. The primitive is latent only on wide-band legs.
Remediation
The session sequence gate is reworked so it no longer rejects honest same-second or older-source pushes, and the natspec is corrected to state what it actually bounds. Nonce arithmetic no longer reverts at the uint16 bound. The guardian and keeper operations pages state that an open session is in-band mark authority and that revokeSession is not a durable cutoff. The mark-push client dual-sends privately and never falls back to a public rescue push. V5 grants a batch of signers under one BASE operation and holds the revoke floor at the threshold.
Status
Fixed, verified in the following round. The documentation rows are verified 2026-09-11. The V5 signer-set changes retire the V4 residual at the repoint.
Rows. A-021, A-028, A-034, A-036, A-039, A-042, A-043, A-046, A-058, A-249, A-775, A-812 (12 rows)
F-57 Oracle natspec, interfaces and rollout notes drifted from the shipped code
| Severity | Status | Class | Component |
|---|---|---|---|
| LOW | Closed | Audit | dex-evm/src/oracles/ExternalOracleV4.sol:80-95, dex-evm/src/interfaces/IExternalOracleV4.sol:78-79, dex-evm/src/oracles/ExternalOracle.sol:349 |
Severity rationale. Documentation and code-hygiene defects with no reachable on-chain consequence; the operational rows would have cost time during a rollout, not value.
Description
The rollout note added in an earlier round instructed a grantSigner call that does not exist and prescribed a signer step that does not match the shipped surface. The same note’s storage-layout argument was moot, since the oracle is not behind a proxy. The packing natspec for the new public pending-widen getter omitted a field. A natspec trim dropped the only record of the oracle salt re-mine ceremony rule. “Shipping in the next release” was applied inconsistently to levers already present in src at head. The source tree still carried four full oracle generations with the signer-governance surface duplicated across them. Three further rows are interface ergonomics: the canonical interface declares no signer functions, an updateFeed comment was stale after the widen lever began writing the same word, and the view surface omits getters for the band anchor, lane configuration and exponent bias, all of which are derivable from shipped events and public mappings.
Impact
None on chain. An integrator or an incident responder reading the interface or the rollout note would have to reconstruct the real behaviour from the implementation.
Remediation
The rollout note is corrected against the shipped functions, the packing natspec is completed, and the salt re-mine ceremony rule is recorded in the oracles chapter of the documentation. The “next release” language is removed from the published content. src/oracles now holds only the current oracle and the signer set; V1 is deleted.
Status
Fixed for the code and content rows. Three informational rows are Closed with no change, below the low-severity minimum bar, 2026-09-10.
Rows. A-062, A-069, A-070, A-072, A-144, A-153, A-154, A-552, A-557, A-589 (10 rows)
F-58 Deploy ceremonies signed unbound salts and zeroed risk parameters
| Severity | Status | Class | Component |
|---|---|---|---|
| LOW | Closed | Audit | dex-evm/script/lib/Create3Base.sol:37-50, dex-evm/script/lib/OracleV4Base.sol:27-41, dex-evm/script/ArcRiskRestore.s.sol:806-849 |
Severity rationale. Both defects require a signer to execute a prepared ceremony; neither is reachable by a third party, and the risk-restore case emits a visible warning.
Description
_create3 read the salt and the expected address from the same role row, and the salt assertion was a no-op everywhere except the router deployment. A wrong role therefore self-agrees and can squat the address another role had reserved; the code’s own natspec named the hazard. Separately, the risk-restore preview emitted blobs with minLiquidity and flags at zero, with a warning only, so signing the preview verbatim would restore those zeroes.
Impact
A misconfigured ceremony could consume a reserved deterministic address, or restore a pool configuration with no minimum liquidity, in both cases from a payload that was presented as ready to sign.
Remediation
The salt is bound in every live ceremony, with a role re-derivation in the oracle deployments and a preimage pin in the router deployment, held by a deterministic-deployment fleet test. The risk-restore preview now signs the live minLiquidity.
Status
Closed on the second revision, signed off 2026-09-14; the preview fix landed 2026-09-15. The remaining ordered upgrade and re-seed is tracked as the next ceremony step. Two legacy scripts still inherit the no-op salt assertion; they are unreachable under the current deployment shape and are scheduled for deletion.
Rows. A-190, A-606 (2 rows)
F-59 An owner unhalt could relist a re-anchored leg without re-attestation
| Severity | Status | Class | Component |
|---|---|---|---|
| LOW | Closed | Audit | dex-evm/src/libraries/PoolConfig.sol, dex-evm/src/Admin.sol:299-303 |
Severity rationale. Requires the owner role and a leg that has already collapsed and been re-anchored, but the unhalt silently clears a latch that exists to force a fresh attestation.
Description
The collapse halt reused the guardian halt bit. Because unhaltAsset, the batch risk operation and the fleet-wide unhalt all clear that bit, an owner unhalt relisted a leg whose anchor had been changed with no re-attestation of the new anchor. The access model’s non-composability rule for this case was not expressed in code.
Impact
A leg could return to quoting against a re-anchored configuration that no one attested after the re-anchor.
Remediation
The collapse halt gets its own anchor-owned latch on chain, mirrored in the SDK and in the pricing core, and the interface sends only the settable halt mask on unhalt. Pinned by a halt-source test.
dacde55, cd8d703, 6823437, ea7150f
Status
Closed, 2026-09-15.
Rows. A-924 (1 row)
F-60 Router output floors were measured at the wrong place and quote views omitted execution guards
| Severity | Status | Class | Component |
|---|---|---|---|
| LOW | Fixed | QA | dex-evm/src/Router.sol:190-195, dex-evm/src/Pool.sol:285, dex-evm/src/LPToken.sol:154 |
Severity rationale. All of these need an owner-listed fee-on-transfer token or produce a revert rather than a loss, and none is live at the shipping configuration.
Description
The router floored on the amount it gained rather than on the amount the recipient received, so a fee-on-transfer output leg passed the check while the recipient netted less. Two rows recorded the same accounting distinction from different angles, and a third recorded the mirror on the input leg: the router approves and pulls the nominal amount, so a fee-on-transfer input reverts at the pool pull, fail closed and self-inflicted.
getSwapQuote omitted the execution-only guards, so it returned a non-zero quote on a path that would revert at execution: the swap-enabled bit, the pre-outflow liquidity check and the reference band are all checked in the swap and not in the view. getAsset returned a zero struct for an unlisted asset rather than reverting not-found, which reads as maximum coverage on a zero liability.
On the share token, a burn never decremented the frozen amount, so a partial burn left shares over-locked for up to the maximum cooldown, and raising the flow cooldown applied retroactively to positions already held. A delayed just-in-time liquidity argument against the same cooldown was analysed and closed: the profit was not demonstrated once gas and inventory seasoning are counted.
Impact
Bounded accounting differences and reverting views. No theft path, and the fee-on-transfer rows require the owner to list such a token first.
Remediation
The router measures the recipient’s own balance delta and floors on the amount delivered rather than the amount gained, with a regression test pinning both rows. The quote-view guard omissions are documented. The remaining rows were closed as informational under the minimum-severity bar.
Commits: e36dbb86, 0e323816, 89b058f5.
Status
Fixed for the router floor and the quote-view documentation, verified 2026-09-10. The informational rows were closed on 2026-09-09 and 2026-09-10 under the minimum-severity bar.
Rows. A-613, A-686, A-619, A-719, A-720, A-687, A-661, A-773, A-778, A-200 (10 rows)
F-61 A hook that refused recall could not be replaced, and a halted leg blocked the keeper’s evacuation
| Severity | Status | Class | Component |
|---|---|---|---|
| LOW | Closed | QA | dex-evm/src/libraries/PoolConfig.sol:764-772, dex-evm/src/hooks/YieldHook.sol:108-117, dex-evm/src/libraries/PoolConfig.sol:801 |
Severity rationale. Liveness only and conditioned on an already-compromised or failing hook with a non-zero invested balance; no extraction path.
Description
Replacing or clearing an asset hook was locked while the invested balance was non-zero, so a hook that refused recall left the operator with neither lever and blocked outflows on that leg. The rebalance path reverted on a halted or over-cap leg, so the keeper could neither trim nor evacuate a venue during exactly the incident that made evacuation necessary.
Two accounting and binding defects sat alongside. The hook credit rate bucket capped a token amount against the face book, so the effective daily cap was loose by a factor of the coverage ratio whenever it was not one. setAssetHook did not bind the hook target to the pool and access-control surface, so a hook constructed against a sentinel was accepted on the wrong pool.
Impact
A failing hook could hold a leg’s outflows hostage until an operator intervention that did not exist, and the daily credit cap did not bind as specified off parity.
Remediation
An adminForceClearHook lane clears a hook that refuses recall, with the served and packaged ABIs re-pinned to carry it. Evacuation stays live on a halted leg. The hook credit cap and the LP fee are booked at the coverage ratio, and the whole hook push is booked with only the face capped. setAssetHook checks pool() and AC() on the target.
Commits: 42efec95, 45f88d84, eaf555ff, 9e00d331, 58cdad56, 99bc688.
Status
Closed, with one residual carried into the deployment plan as ceremony step C-9: the venue adapter’s virtual-balance read exists only on the newer lending-pool revision, so the target chain’s fork must be confirmed to expose it before the first hook install. Landed 2026-09-15.
Rows. A-718, A-917, A-1114, A-1123 (4 rows)
F-62 The guardian arming runbook named files and secrets that no longer exist
| Severity | Status | Class | Component |
|---|---|---|---|
| LOW | Closed | QA | shared/evm/src/access/AccessControl.sol:278-280, dex-evm/src/Admin.sol:370-408, dex-evm/src/oracles/ExternalOracle.sol:334-336 |
Severity rationale. Documentation drift on a dormant role, not an availability gap: the owner authority is a strict superset of the guardian authority on every named lever, so the freeze is one transaction today.
Description
The single granted guardian address has never transacted and the guardian quorum policy is not armed. The arming procedure could not be executed as written: its first step invokes a deployment target whose values file was deleted, its second names a secret that does not exist, an alerting page cites a runbook file that does not exist, and a deployment comment contradicts a flag the tool actually accepts.
The harm claim that every guardian lever is therefore owner-only was checked against the authority code and does not hold: AccessControl.isGuardianOrAuth is a disjunction, and every named lever routes through it with the owner as the alternate authority. The halt, unhalt, batch risk, timelock cancel, session revoke, rebias and feed pause paths are all reachable by the owner without a timelock today. What is genuinely missing is role separation and automation, both already tracked separately.
Impact
An operator following the runbook during an incident would stop at a missing file. The freeze itself remains available through the owner path.
Remediation
No code change. Arming at least two guardian Safes is deployment plan step C-4, a fresh disjoint signer roster is step C-3, and the record sync is step C-2.
Status
Closed 2026-09-15 into the deployment ceremony steps C-2, C-3 and C-4.
Rows. A-248 (1 row)
F-63 Governance lever scope: a steward raise voided unrelated owner operations and a sentinel-pool guardian halt carried no release clock
| Severity | Status | Class | Component |
|---|---|---|---|
| LOW | Fixed | Audit | dex-evm/src/Admin.sol:552, dex-evm/src/Admin.sol:330, dex-evm/src/oracles/OracleBeacon.sol:88 |
Severity rationale. Each item is a bounded authority-scope defect on a lever that already requires a privileged role; the worst case is a repeatable denial of a governance operation whose magnitude the owner had already ratified.
Description
raiseKappa dropped any live owner UPDATE_RISK operation on the same leg, whether or not the queued operation touched the field the steward was raising.
// dex-evm/src/Admin.sol:558
function raiseKappa(address pool, address token, uint16 kappaCovBps) external {
_onlySteward(pool, true);
bytes32 key = _keyToken(pool, OP_UPDATE_RISK, token);
if (_live(key)) _drop(key, pool, uint8(IPool.OpType.UPDATE_RISK));
AdminParams.raiseKappa(pool, token, kappaCovBps);
}A guardian halt on a sentinel pool was not stamped, so a seat taking the pool later lifted it with no release delay. The reference-tier access control root also stayed deployer-owned with no handover step.
Impact
A steward key could repeatedly cancel an owner-ratified risk operation, and a guardian halt could be cleared immediately once a seat existed, removing the delay that halt is supposed to buy.
Remediation
raiseKappa voids only a conflicting operation. Guardian halts are stamped and carry the release delay. The handover step and an owner assertion were added, so every control root requires a disjoint two-party owner.
Commits: 41e10bc, 56020da, 0e0cd78, 957d3b0.
Status
Fixed 2026-09-16 for the two code rows. Five rows are accepted with no code change, recorded 2026-09-16: the oracle beacon upgrade sits at the LISTING tier with a nominal validation and one owner key reaching both beacons; a foreign-pool seat can widen its own fences and write inside them in one transaction, which will be stated in the LP documentation; revokeSigner refuses to go below the signing threshold, so at exactly the threshold the leak response is a per-feed pause; the guardian can seal a sentinel pool mid-ceremony; and _tier has no UPDATE_ASSET_PARAMS arm, so a request with that operation type reverts.
Rows. A-1510, A-1511, A-1512, A-1513, A-1514, A-1515, A-1516 (7 rows)
F-64 Yield adapters trusted venue-reported amounts and left owner levers untimelocked
| Severity | Status | Class | Component |
|---|---|---|---|
| LOW | Fixed | Audit | dex-evm/src/hooks/ERC4626YieldHook.sol:43, dex-evm/src/hooks/YieldHook.sol:122-134, dex-evm/src/hooks/MorphoBlueYieldHook.sol:27 |
Severity rationale. No hook is installed on a live pool and installation is itself privileged, so every item here requires a privileged action or a misbehaving venue before it has any effect.
Description
_venueWithdraw returned the requested amount rather than the measured token delta on both the ERC-4626 and the Compound V2 paths, so a venue with a withdrawal fee or a lossy share price caused the ledger to over-credit and hookRecall to over-decrement.
// dex-evm/src/hooks/ERC4626YieldHook.sol:43
function _venueWithdraw(uint256 assets) internal override returns (uint256 got) {
uint256 before = _tokenBalance(address(this));
vault.withdraw(assets, address(this), address(this));
got = _tokenBalance(address(this)) - before;
}Harvest credited a donation-inflated NAV with no deposit check. On the Morpho adapter, loan-token rewards were never sweepable because the token and the position were both skipped with no override, and bare hook cash was not counted in NAV, so it was a permanent strand even for the owner; the adapter also held an unbounded venue allowance with no revoker, unlike its siblings which approve exactly and then zero. The owner’s setBuffer and forceWriteDown were instant with no hook timelock, although installing a hook is itself a high-tier operation, which is inconsistent tiering. One commit at the head had been flagged unreviewed by its author and had stripped the ERC-4626 deposit-loss invariant.
Impact
A lossy or fee-charging venue over-credited the hook ledger, and a donation-inflated NAV booked yield that did not exist. Stranded loan-token rewards were unrecoverable. The untimelocked owner levers allow an arbitrary LP haircut, which is griefing rather than extraction since it pays the owner nothing.
Remediation
Both adapters return the measured delta. Harvest realizes the measured venue delta before crediting, and the credit path proves the balance covers liabilities, protocol fees and the credited amount, so a donation-inflated NAV cannot book phantom claim. The Morpho adapter counts idle loan tokens in NAV and pushes hook idle before the venue on recall, and approves exactly then zeroes. Risk-increasing hook levers are timelocked at the risk-up delay with de-risking instant and a guardian cancel. The deposit-loss invariant was restored.
Commits: 3565c671, c9761fbd, ea976690, ad55c693.
Status
Fixed, verified 2026-09-10 and 2026-09-11.
Rows. A-147, A-511, A-513, A-514, A-724, A-798 (6 rows)
F-65 Hook and periphery ledgers: force-clear ran inside flash context, rounding dust became a permanent index cut, and over-WAD claim weights stranded the last claimants
| Severity | Status | Class | Component |
|---|---|---|---|
| LOW | Fixed | Audit | dex-evm/src/Pool.sol:756, dex-evm/src/hooks/YieldHook.sol:393, dex-evm/src/periphery/WombexClaim.sol:141 |
Severity rationale. Each needs a privileged or adversarially timed call, and the periphery case needs a malformed Merkle root at publication time.
Description
adminForceClearHook lacked requireNoFlash, so a write-down could be sized against a coverage value observed mid-loan rather than at rest, and the hookCreditYield natspec contradicted the code about where the over-cap slice lands. _harvest booked any NAV below book as a loss, so a one-wei share-rounding difference wedged rebalance on an unrelated stale feed and turned a transient NAV dip into a permanent index cut. In the periphery claim contract, Merkle weights summing above WAD hard-reverted for the final claimants against an immutable root with no rescue path.
Impact
A write-down sized at a mid-loan coverage is not the write-down the operator intended. Rounding dust blocking rebalance is a liveness problem on a healthy leg. The claim case permanently disables the last claimants once the root is published.
Remediation
Force-clear refuses flash context. Venue rounding is absorbed instead of reverting. Claim weights are bounded so the final claimants always settle.
Commits: 5188023, df0367d, 1ff4d9f.
Status
Fixed 2026-09-16.
Rows. A-1528, A-1529, A-1530 (3 rows)
F-66 A pool de-listing left no on-chain record
| Severity | Status | Class | Component |
|---|---|---|---|
| LOW | Fixed | Audit | dex-evm/src/PoolFactory.sol:207 |
Severity rationale. Observability only, with no fund path; the consequence is that an off-chain monitor cannot reconstruct when a pool left the pending set.
Description
A pending-official de-listing emitted nothing, so the transition was invisible to off-chain monitoring. The report also raised an unbounded chain-reads allowlist with no per-instance count.
Impact
A silent de-listing could not be audited from events alone.
Remediation
The pending-official flag is dropped inside deregisterPool and PoolDeregistered is emitted. The allowlist half is moot: no writer and no chain-reads allowlist exist, and the storage word is reserved.
Status
Fixed.
Rows. A-1013 (1 row)
F-67 Fee-free LP flows were a toll-free substitute for a swap, and the internal depeg breaker compared the wrong pair
| Severity | Status | Class | Component |
|---|---|---|---|
| LOW | Closed | Audit | dex-evm/src/libraries/PoolLiquidity.sol:414, dex-evm/src/libraries/PoolIOLib.sol:368, dex-evm/src/libraries/PoolConfig.sol:808 |
Severity rationale. Each row needs a specific configuration or a migration step to bind, and the measured gap is a fee-scale advantage rather than a drain of principal.
Description
LP flows settled at the oracle rate without the swap toll, so they could be used as a cheaper trading channel: exiting ahead of a predictable push, straddling the skew, converting through deposit and cross exit, or pairing a liability swap with a same-asset exit.
The internal-mode depeg breaker compared the primary feed to the reference feed, not to the peg at which the pool actually prices the leg, so it did not test what the configuration promises. setBaseToken re-anchored anchor-unit spokes without re-denominating them, leaving up to roughly twice the 5% band mispriced until each anchor update landed.
Three positions were accepted rather than changed. One ungateable leg, including a leg carrying surplus but no LPs, freezes deposit, donate, cross exit, liability swap and hook credit pool-wide, which is the intended fail-closed direction. Every armed spoke swap requires a fresh reference tier, making the reference fleet a second hard liveness dependency; it is alarmed off chain at half the time-to-live. Cross withdrawal and interior hops do not check the swap-enabled bit, and the spread field saturates at its 16-bit ceiling, discarding volatility and staleness premium above 6.55%.
Impact
Until the toll was applied, a trader could route around the swap fee through LP flows at a measurable saving. The breaker mismatch meant an internal-mode depeg could pass a check it should have failed. The base migration left a mispricing window.
Remediation
LP flows now carry the toll on the paths that could substitute for a swap, the depeg breaker compares against what the configuration promises, and setBaseToken re-denominates on migration. The accepted positions are recorded in the audit report.
Commits: e464872, a9fd0e2, ff2b49c, 08b9981.
Status
Closed 2026-09-16. Four rows fixed, three accepted as design positions with the reference-tier dependency alarmed off chain.
Rows. A-1521, A-1522, A-1523, A-1524, A-1525, A-1526, A-1527 (7 rows)
F-68 The off-chain quote mirror could not carry feed confidence and defaulted a missing coverage wall to zero
| Severity | Status | Class | Component |
|---|---|---|---|
| LOW | Fixed | Audit | sdk/src/amm/index.ts:105, sdk/src/amm/aimm.ts:559, sdk/src/amm/aimm.ts:260 |
Severity rationale. The mirror prices no settlement of its own, so divergence from the chain is a wrong displayed quote or a refused route rather than a wrong fill.
Description
The leg builder had no field for feed confidence or staleness excess, so every spoke reached the pricing service as uncertain and was refused, and a missing coverage wall defaulted to zero, which is the fail-open value.
The curve serialiser pinned the curve median at 5000 and overran the median field at 14 knots. Three producers wrote a maxIn field that no consumer read. Two informational divergences were recorded and closed below the reporting bar: float packing is lossy past 2^53, with relative error around 1e-7 or roughly 0.001 basis points, and the mirror’s interior swing cap and dispersion cap were tighter than the values the chain accepts, rejecting legal configurations off chain in the fail-closed direction.
Impact
The confidence gap made the mirror refuse routes the chain would have priced. The zero coverage-wall default understated the toll in the same direction as the front-end defect. The serialiser defect produced a curve that did not round-trip at high knot counts.
Remediation
The coverage wall is a required input on every leg and the legacy leg-construction surface was deleted. The leg builder carries feed confidence and staleness excess to the wire, with confidence encoded as null rather than zero when absent and pinned by wire tests. The serialiser packs a real median and only interior boundaries. The write-only field was removed across the client, the mirror and the pricing service in one change.
Commits: 61c4063, 7af5592, 3a03ab7.
Status
Fixed on the second remediation revision; the write-only field was removed on 2026-09-11. The two informational rows were closed on 2026-09-09 and 2026-09-10 under the low-minimum bar.
Rows. A-134, A-142, A-611, A-774, A-929 (5 rows)
F-69 Chained two-hop router legs were not self-directed, and the chain-56 registry named the wrong native token
| Severity | Status | Class | Component |
|---|---|---|---|
| LOW | Closed | Audit | sdk/src/router/index.ts:436, sdk/src/eth/tokens.ts:41, sdk/src/venues/deployments.generated.ts:12 |
Severity rationale. The chained-leg shape requires a recipient different from the sender on a two-hop route, and the registry labels are a naming defect on a chain not yet carrying a venue record.
Description
On a chained two-hop leg, hop one paid the recipient while hop two pulled the intermediate asset from the sender. Only the unwrap path enforced that sender and recipient are the same address, so the other chained shapes could be built with a mismatched pair and the second hop would have nothing to pull.
The chain-56 token registry labelled the wrapped native token as native BNB and labelled Binance-peg ETH as WETH. There is also no chain-56 venue record, and the deployments file is hand-maintained with no generator.
Impact
A chained two-hop route with a recipient other than the sender fails at the second hop. Mislabelled registry entries misidentify the native and wrapped tokens to any consumer that trusts the registry.
Remediation
Chained legs are self-directed. Chain-56 records name the correct native and wrapped tokens. The missing venue record is tracked as a close-out step in the deployment plan rather than a code change.
Status
Closed 2026-09-16.
Rows. A-1550, A-1551, A-1552 (3 rows)
F-70 Display surfaces overstated what the pool would actually quote or settle
| Severity | Status | Class | Component |
|---|---|---|---|
| LOW | Fixed | Audit | front/src/lib/density.ts:170, front/src/lib/density.ts:120, front/src/pages/pools/poolPosition.ts:65-78 |
Severity rationale. None of these rows is read by sizing or execution; each one can mislead the reader about the state of a pool while the transaction path enforces the true values.
Description
The chart’s installed band was drawn from the impact curve and labelled as the quotable book, although it omits the coverage wall, the toll and fees. A halted tape kept showing the last density with no badge, leaving a picture several seconds stale. A live self-pair defect compared a roster symbol to a feed symbol and requested a density for an asset against itself.
The pool and portfolio surfaces carried the same class of overstatement. Position value was computed as face times mark with no haircut, a 44% gap in the observed example, while the send path uses the haircut maximum. Pool value fell back to a hard-coded reference mark when the feed was missing, inventing a value on a dead oracle. Independent polls at 10, 30 and 12 seconds could be read together and tear. Halted legs rendered at face with the manage control always available, blocked downstream by a hard gate. Deep scroll-back evicted the live edge of the candle store, recoverable by reloading. Observed density width mixed timeframe-variant and timeframe-invariant references, in a derived overlay that is disclosed, off by default and read by nothing. Stale-fit fields on the chart were unconsumed, with the production endpoint serving nothing.
Impact
A reader could take a displayed band, position value or pool value as the executable one. Execution is gated elsewhere in every case, so no incorrect fill follows from these rows.
Remediation
The chart legend now labels the impact curve as such and the tooltip states that the net book is not drawn, pinned by a display-honesty test. A gated leg threads a “Feed gated” badge into the density legend. The density key compares like symbols, so the self-pair request is gone.
Commits: c9b0b44a.
Status
Fixed for the chart honesty and gating rows, verified 2026-09-11; the self-pair defect is fixed on the safety and chart branch. The remaining rows were closed on 2026-09-10 as informational under the low-minimum bar.
Rows. A-167, A-535, A-536, A-537, A-538, A-621, A-622, A-624, A-625, A-807 (10 rows)
F-71 Client transaction plumbing did not rebuild after approvals, explain every revert, or survive blocked browser storage
| Severity | Status | Class | Component |
|---|---|---|---|
| LOW | Fixed | Audit | front/src/lib/txError.ts:15-53, front/src/lib/settings.tsx:47, front/src/hooks/usePoolData.ts:611-786 |
Severity rationale. These are friction, availability and clarity defects on the client; the preflight simulation and the chain’s own reverts prevent a bad transaction from landing in every case.
Description
LP writes baked their deadline and built their floors once, with no rebuild after an approval landed, so a slow approval could leave an expired action to be caught by preflight. The error taxonomy covered two pool copies and five selectors, leaving the expired and stale-data reverts to surface as raw pre-prompt text. Settings and theme read and wrote browser storage unguarded at initialisation, which throws during render in a browser with storage blocked and takes the page down with no boundary.
Six smaller rows were recorded and closed below the reporting bar: allowance preview over-reported the approval need by one wei; the pool version key omitted the hub endpoint so a hub-only write could serve a toll up to eight seconds stale; the cost model was inert on the client path, with ranking done on gross rather than net; response payloads were assigned with no schema validation, so a missing metric raised a type error instead of a no-feed state; amount normalisation collapsed precision through a float round trip on the display path; and a transport failure was painted as an empty book rather than as a failure, with no refetch loop. Two consolidation rows were verified already closed: the rate-limit latch is a single shared module with no standalone copies left, and the chart primitive attach and detach boilerplate is now one base class with no standalone implementations.
Impact
A user could meet an unexplained revert message, an expired action on a slow approval, or a blank application in a storage-blocked browser. No gas was lost to the expired path because preflight blocks it before the prompt.
Remediation
The LP route rebuilds its action calls after approvals when the calls are not bundled. The error taxonomy covers the expired, stale-data, feature-disabled and base-depegged reverts. Browser storage sits behind one guarded layer whose accessors never throw.
Commits: 48587b54.
Status
Fixed, verified 2026-09-11. The informational rows were closed on 2026-09-10 under the low-minimum bar, and the two consolidation rows were verified closed in code.
Rows. A-135, A-141, A-573, A-574, A-575, A-590, A-591, A-654, A-698, A-699, A-702 (11 rows)
F-72 Wallet transport stamped a stale chain identity and dropped failure detail from batches
| Severity | Status | Class | Component |
|---|---|---|---|
| LOW | Fixed | Audit | front/src/lib/wallet.tsx:406-469, front/src/lib/walletConnect.ts:151-178, front/src/lib/walletCalls.ts:104-140 |
Severity rationale. Every outcome is a failed or aborted transaction with gas burned, never a mis-executed one, because EIP-155 replay protection stops a wrong-chain send from landing.
Description
The batch send path stamped the chain identifier held in component state and never re-read it live; only the atomic-requirement check re-read. The sequential path did re-read. Because pools are deployed per chain through CREATE3 and EIP-155 blocks cross-chain execution, a chain moved under the interface produced a failed or aborted transaction rather than a wrong-chain fill. The WalletConnect transport went further and hardcoded the chain identifier from a session constant, permanently stale until reconnect, with the safety reads using the same stale value and therefore self-consistent.
The failed-batch path reported only a hash and lost the revert reason, while the confirmed path replayed and decoded it. The replay helper itself dropped the call value that the dry run forwards, so a payable call on the wrap path was misattributed. A time-of-check to time-of-use gap remained between the batch preflight and the send, spanning the wallet prompt with no re-simulation, so a feed push, a halt or a slippage shift during the prompt produced a mined revert.
On the depth display, the denomination toggle relabelled without converting the cumulative column, the invert control flipped labels without reordering rungs, and a bounded net-times-gross approximation was presented as exact.
Impact
A user whose wallet moved chain mid-flow burned gas on a transaction that could not land, with no message explaining why. A failed batch gave no reason at all. The depth panel could be read with the wrong unit or the wrong orientation, though fills re-quote through the router.
Remediation
sendCalls re-reads the live chain identifier from the provider and refuses a moved chain. The WalletConnect transport holds a mutable chain updated on chainChanged and on wallet-initiated switches. The failed-batch path replays the plan for a decoded reason, and the replay helper forwards the call value. The prompt-dwell gap is accepted: the whole plan is re-preflighted immediately before the prompt and there is no in-application lever over the dwell itself, with the cost bounded to gas.
Status
Fixed on 2026-09-11. The prompt-dwell gap is accepted and the display rows were closed below the low-severity bar on 2026-09-10.
Rows. A-571, A-572, A-694, A-696, A-695, A-700, A-701, A-703 (8 rows)
F-73 Coverage and upgrade-order test pins were absent, bare or vacuous
| Severity | Status | Class | Component |
|---|---|---|---|
| LOW | Fixed | Audit | dex-evm/test/unit/CoverageProofs.t.sol:753-796, dex-evm/test/unit/PoolHooks.t.sol:1472-1499, dex-evm/test/unit/Base.t.sol:84-86 |
Severity rationale. No production code is affected; the exposure is that the invariants relied on for the coverage findings were passing without exercising the paths they claim to cover.
Description
The fuzz handler behind the coverage floor invariant drove only part of the surface. Cross withdraw, liability swap, donate and base were missing, four selectors, while the invariant claimed arbitrary interleavings. The invariant was therefore green exactly where the original coverage finding lives.
Several closure pins were stale or weak in the same way: weight-cap tests used bare expectRevert rather than a typed error, liability-swap slicing and two hook findings were unpinned, and one pin still allowed 100 bps. The two named ledger invariants had no on-chain pins at all, confidence pairing was tested on lane zero only, the mark packing fuzz test exercised a local copy rather than the library, golden fixtures were not read, the fork test could not run, and the upgrade-order matrix covered three of eighteen selectors. A vm.skip(true) placeholder sat in the fork directory as a test that could never execute.
Impact
Findings were closed against evidence that did not exercise the closing path. A regression in any of the four missing selectors, or in the fifteen uncovered upgrade-order selectors, would not have been caught.
Remediation
The coverage floor handler was widened so the invariant is no longer vacuous. The listed bare reverts are typed and the full upgrade-order matrix restored. The named ledger pins landed along with a hook-writer handler, a mark-packing pin against the real library, golden fixture reads, confidence lockstep and the coverage-strength gaps. The vm.skip(true) fork placeholder was deleted; the fork directory now holds one test with a real body behind a chain-identifier gate. The upgrade-order rehearsal is a deployment runbook step rather than a test.
Commits: 82e2b23c, 51b4a44c, 89fae854, 296cac19, cc796065, f65f959d, 3b0503b3, a9d23050, 2e0760d0, 9b014388.
Status
Closed on 2026-09-15.
Rows. A-609, A-940, A-1010 (3 rows)
F-74 RPC endpoints were trusted without chain attestation and the ABI encoder accepted malformed input
| Severity | Status | Class | Component |
|---|---|---|---|
| LOW | Fixed | Audit | sdk/src/eth/chains.ts:116-123, sdk/src/eth/transport.ts:136-148, sdk/src/eth/abi.ts:169-177 |
Severity rationale. Exploiting the transport requires a malicious endpoint in the configured ring, and transport-layer security rules out a man in the middle on the defaults; the encoder defect fails closed on revert rather than losing value.
Description
The transport held a ring of public endpoint URLs and failed over by attempt index with no chain identifier or height attestation of any kind. The health probe checked only that the HTTP response was successful, so a poisoned read from a hostile endpoint was accepted as truth.
The ABI encoder performed no range or shape checks. An address of the wrong length, a uint8 given 300, a negative value that wraps, and a bytesN of the wrong length all encoded without complaint. The resulting call reverts on chain rather than silently losing funds, but the failure surfaces late and without a usable message.
Impact
A consumer configured with a hostile endpoint could act on fabricated chain state, including balances, allowances and quote reads. Malformed encoder input produced opaque on-chain reverts instead of a local error naming the offending argument.
Remediation
Each endpoint now attests to the chain it serves before use, a wrong-chain endpoint is evicted once, the retry moves past it without backoff, and the private-key client’s transport is pinned to its chain. The front end pins every read provider to the chain it serves. The encoder checks exact length for bytesN, element count for fixed arrays, and address, boolean and integer bounds, with dynamic byte strings validated against a hexadecimal pattern.
Commits: f7bec7e, 2871558, 66e877c, 85fc982, 62d05b9, 48a4b30, 1dc17c90.
Status
Closed on 2026-09-15. Pinned by abi.test.ts:315-322 and abi.test.ts:325-329.
Rows. A-638, A-641 (2 rows)
F-75 Shared access control let a live pending rotation be overwritten silently and let a compromised treasury owner veto its own eviction
| Severity | Status | Class | Component |
|---|---|---|---|
| LOW | Fixed | Audit | shared/evm/src/access/AccessControl.sol:337-344, shared/evm/src/access/AccessControl.sol:385, shared/evm/src/base/UpgradeGate.sol:106 |
Severity rationale. Both defects require an already-compromised owner key or an operator mistake, and both move in the safe direction on their own, but they remove the cancel signal monitoring depends on and one of them made an eviction unreachable.
Description
queueRole overwrote a live pending rotation without the AlreadyPending ban and without emitting a cancellation event, unlike its sibling queues. Overwriting restarts the full delay, which is the safe direction, but monitoring loses the signal that a queued rotation was replaced.
// shared/evm/src/access/AccessControl.sol:337
/// @dev Re-queueing overwrites and restarts the full delay; the queued address is never live, so
/// there is no exit-notice clock to silently restart (contrast `Admin.requestOp`, which
/// bans a re-queue for exactly that reason).
function queueRole(Role role, address a) external onlyOwner {
_validateAddr(a);
uint64 eta = uint64(block.timestamp) + _rotationDelay(role);
pendingRole[role] = Queued(a, eta);A compromised treasuryOwner could veto every TREASURY_OWNER rotation indefinitely: the guardian is excluded from that path, the bootstrap override is spent once, and no other override exists. The rule requiring a quorum on timelock-shaped queues had reached four of six such queues, leaving UpgradeGate.requestUpgrade among those it had not. A shared test asserting that the zero schedule is refused at deploy time was red, which gated a deploy-time guard; the cause was a vm.setEnv race across concurrent test functions, not a product defect.
Impact
A compromised treasury-owner key could block its own eviction forever, converting a key compromise into a permanent governance deadlock on that pointer. Fee custody remains bounded per pool throughout.
Remediation
queueRole now refuses a live pending rotation with AlreadyPending and emits RoleCancelled before overwriting an expired one. The treasury-owner self-veto is capped at one per queued rotation, pinned by AccessControl.t.sol. The quorum rule was extended to the remaining timelock-shaped queues, and the red shared test was fixed at its actual cause.
Status
Fixed, verified 2026-09-11. The veto cap reverses the balance in the other direction: a compromised owner can now evict an honest treasury owner after one veto. That residual was ratified as “incumbent once” in the governance timelock decision record and accepted, verified at the rev2 chair review. The dead TREASURY and FACTORY pointers, which have no on-chain readers, were closed as governance-ceremony hygiene under the purge of 2026-09-09.
Rows. A-080, A-107, A-705, A-797, A-799, A-921 (6 rows)
F-76 The SDK build pinned ABIs but fetched them from the live production API, and mirror constants drifted from chain
| Severity | Status | Class | Component |
|---|---|---|---|
| LOW | Fixed | QA | sdk/scripts/fetch-abis.ts:41, sdk/src/amm/aimm.ts:191, sdk/README.md:46 |
Severity rationale. A release or rollback performed in the wrong order could have failed every SDK and front build at once; no on-chain behaviour depends on either the mirror constant or the documentation.
Description
The build pinned its ABIs but sourced them from the live production API, so build success depended on live production state and on the order in which a release or rollback was performed. The SDK mirror of the interior swing cap was 10_000 while both the contracts and the integer core use 10_862, and the documentation still described an off-chain @sdk/amm pricer after the f64 replica had been deleted and swaps moved to the /v2 quote path.
// sdk/src/amm/aimm.ts:191
export const INTERIOR_SWING_CAP_PBPS = 10_000;
export const MAX_DISPERSION_PBPS = 900_000;Impact
Every SDK and front build shared a single live dependency with no local fallback. The stale mirror constant and documentation misdescribe the shipped quote path to integrators.
Remediation
The pinned-ABI build no longer depends on live production state, the mirror constants equal the chain constants, and the documentation describes the shipped quote path.
154904a, ec109f1, faade8d, 2185748
Status
Fixed 2026-09-16 and closed.
Rows. A-1553, A-1554, A-1555 (3 rows)
F-77 keccak256 panicked on inputs whose length was a non-zero multiple of the rate
| Severity | Status | Class | Component |
|---|---|---|---|
| INFO | Fixed | QA | core/src/keccak.rs:120 |
Severity rationale. A panic on a well-formed input of a specific length, in a pure hashing primitive with no on-chain consumer at the audited revision.
Description
The pure-standard-library keccak256 implementation panicked when the input length was a non-zero multiple of 136 bytes, the sponge rate, rather than absorbing a final padded block.
Impact
Any caller hashing an input of such a length aborted instead of returning a digest.
Remediation
The exported keccak256 now equals the EVM KECCAK256 opcode for every input length.
Commits: 8ef3ef6.
Status
Fixed 2026-09-16.
Rows. A-1556 (1 row)
6. Reporting a finding
Findings against deployed contracts go to security@btr.markets. Please do not open a public issue for anything exploitable. We confirm receipt, say whether the finding is already in the private ledger, and tell you when the fix is deployed; once it is, the finding is published with attribution unless you ask otherwise. See Bug Bounty for scope, rewards and safe harbour.