Invariants the protocol enforces, attack vectors it defends against,
and what's been verified vs what's deferred. Companion to
MATH.md (the formulas) and spec.md §13 (the canonical
list).
| Status | |
|---|---|
| FULL / EUROPEAN core flow | ✅ implemented + tested (Phases 3–5) |
| Solidity invariant suite | ✅ 10 invariants, 6 covered with fuzz, 1 documented as design observation |
| Quant audit | ✅ external multi-LLM audit (GPT-5 + Gemini 2.5), 9 fixes in-band, 6 mainnet-TODOs explicit |
| Solidity static analysis (Slither) | ✅ Slither 0.11.5, 27 findings triaged (1 fixed, 26 accepted) — see §4.3 |
| Mainnet-fork integration | ⏸️ Task 5.11 (pending) |
| Sepolia deploy | ⏸️ Task 5.14 (pending) |
| PARTIAL mode | ⏸️ Phase 15 (stretch — gated on quant; not in v1) |
| Stylus ILMath cross-check | ⏸️ Task 2.11 (pending — home-PC track) |
CI scope: GitHub Actions (
.github/workflows/ci.yml) runs onlyforge fmt+forge build+forge test. It does not runcargo/cargo stylus, the Node benchmark scripts, or the Python suite — so all Stylus and quant validation is manual (local Nitro / WSL2). The SolidityFairValueOracleis exercised in CI (it is whatforge testcompiles), which is why it is retained as the revm-executable cross-check of the shipped Stylus oracle.
All ten invariants from spec §13 with their verification status. Test
references point to files in packages/contracts/test/.
| # | Invariant | Where enforced | Verified by |
|---|---|---|---|
| I1 | payout ≤ collateral == MaxIL (FULL: no bad debt) |
InflexionCore.settle payout cap |
InflexionCore.invariants.t.sol::testFuzz_I1_I2_payoutCappedAtMaxIL (256 runs) |
| I2 | payout == min(realised_IL, MaxIL) |
same | same |
| I3 | No underflow when V_lp > V_hold |
ILMath._ilAt (guarded max(0, …)) |
ILMath.t.sol::testFuzz_I3_ilNonNegative (256 runs) |
| I4 | LP never profits from the swap | same clamp | ILMath.t.sol::test_il_atEntryIsZero + I3 fuzz |
| I5 | locked[mm] ≤ deposited[mm] per MM |
UnderwriterVault.{lockCollateral, releaseAndDistribute} arithmetic |
InflexionCore.invariants.t.sol::invariant_I5_lockedLeDeposited (256 runs × 128,000 fuzzed vault op calls × 0 reverts) |
| I6 | Settlement uses L stored at creation, never re-read |
SwapRecord.liquidity stored once; settle passes s.liquidity to IILMath.computeIL |
InflexionCore.invariants.t.sol::testFuzz_I6_recordsStoredLiquidity (256 runs, MockILMath recorder pins the actual liquidity arg) + ILVault.t.sol::testFuzz_F2_externalIncreaseLiquidity (256 runs) |
| I7 | consumedNotional[quoteId] ≤ maxNotionalV0; cancelled-nonce bit cannot fill |
createSwap PHASE 3 effects: atomic check-and-increment before any external call |
InflexionCore.t.sol::test_createSwap_consumedNotional_tracked + test_createSwap_rejectsUsedNonce. Full handler-driven stateful fuzz deferred to post-mainnet. |
| I8 | settle() always succeeds within expiry + LIVENESS_WINDOW + MAX_STALENESS + GRACE_PERIOD (no permanent fund lockup) |
OracleManager.getSettlementPrice Fork-1 (Option B) |
OracleManager.invariant.t.sol — 5 fuzz tests, 1,280 runs across the feasible window. See §3 below for a documented Fork-1 design observation. |
| I9 | Quote auto-voids on-chain iff absBps(P_live, quote.quotePrice) > priceBandBps (Fork 2) |
createSwap PHASE 2 band check |
InflexionCore.invariants.t.sol::testFuzz_I9_priceBandViolationReverts + WithinBandAccepts (256 runs each, both directions) |
| I10 | premium ≤ FairPremium · (1 + maxLoadBps) (price cap; LAUNCH; by construction, upstream of settle) |
Mechanical, deterministic clamp baseLoad + util_skew + dispersion_skew ≤ maxLoad on Path A + require(loadBps ≤ maxLoadBps) on Path B, in PHASE 1 (pricing, READ). Orthogonal to the no-bad-debt proof — does not touch settle, MaxIL, or I1–I9. |
InflexionCore.invariants.t.sol — fuzz FairPremium / skews / loadBps across their ranges and assert charged ≤ fairPremium · (1e4 + maxLoadBps) / 1e4 (spec §13 I10) |
Sourced from spec §9 + the external audit findings memory
(inflexion-audit-findings, 12 fixes applied → spec v3.3, 2 forks
resolved). The audit work is summarised in PR #5 + Phase 14.11.
| Vector | Mitigation | Reference |
|---|---|---|
| MM bad debt under price move | FULL collateral = MaxIL + payout = min(IL, MaxIL) cap. Structural, not heuristic. |
I1 + I2 |
| Lone-spike Chainlink glitch at expiry | Round-at-T pinning + lone-spike sanity check vs both neighbours (≥5% from both = lone) + liveness backstop at 24h | spec §6.1 / OracleManager.getSettlementPrice |
| Permanent fund lockup if oracle deferral never resolves | Liveness backstop accepts the round at expiry past LIVENESS_WINDOW |
I8 — but see §3 below |
| Stale-quote pickoff (Fork 2 — original GPT High audit finding) | Oracle-anchored price band: absBps(P_live, quote.quotePrice) ≤ priceBandBps. Auto-voids on-chain, deterministic — not last-look. |
I9 / spec §4.3.3 |
| LP lies about position range geometry | sqrtPa / sqrtPb derived on-chain from positions(tokenId).tickLower / tickUpper via TickMath. LP cannot under-state range to shrink MaxIL. |
InflexionCore.createSwap / PR #9 |
External increaseLiquidity on custodied NFT (F-#2) |
L snapshotted at creation, stored in SwapRecord.liquidity, settlement uses STORED value |
I6 |
| Bearer-quote replay after off-chain cancel (F-#7) | Permit2-style bitmap nonces + per-MM cancelNonces([]) to flip bits selectively |
spec §4.3.2 / InflexionCore.cancelNonces |
| Capacity over-consumption from concurrent fills (F-#6) | consumedNotional[quoteId] atomic check-and-increment in PHASE 3 effects, before any external call |
I7 |
| Dust-swap MM griefing (F-#8/13) | MIN_POSITION_V0 = $100 and MIN_PREMIUM = $1 gates |
InflexionCore.createSwap PHASE 2 |
| Premium floor under-charge from integer division (F-#8) | premium = ceilDiv(rate · MaxIL, 10_000) — rounds UP |
spec §5.2 / Math.ceilDiv |
| Sequencer downtime exploitation | OracleManager._requireSequencerHealthy enforces uptime + 1h grace, fails-closed on future-dated updatedAt |
spec §6.2 |
| NFT custody hijack | ILVault.onERC721Received accepts NFTs ONLY from the canonical NonfungiblePositionManager configured at deploy; claimFees recipient FORCED to registered LP |
ILVault |
| Locked-collateral utilization-gated withdraw failure (F-#3) | Yield adapter interface enforces isInstantlyRedeemable(); never route locked collateral into Aave/Compound; only liquid wrappers (sDAI, tokenized T-bills) for the idle slice |
IYieldAdapter |
These are NOT bugs — they're behaviours of the current implementation that differ from the most ambitious reading of the spec and that we've chosen to surface in NatSpec rather than paper over. Tracked for spec review.
Spec wording (§13 I8): "settle() always succeeds within
expiry + LIVENESS_WINDOW + MAX_STALENESS + GRACE_PERIOD."
Implementation reality: the staleness require in spec §6.1's
literal code fires before the backstop can recover the round. The
actual usable settlement window is:
[ expiry + LIVENESS_WINDOW, expiry + MAX_STALENESS )
≈ 1 hour wide (86,400s → 90,000s with current constants)
Past expiry + MAX_STALENESS the pinned round is "stale" per the
spec-literal check; funds can still be settled via a newer Chainlink
round (Arbitrum majors tick frequently), but not via the round-at-T.
The 1h window is symmetric for both LP and MM (preserves fairness),
just tighter than the I8 wording suggests.
Documented in: OracleManager.getSettlementPrice NatSpec + the
boundary-pin test
OracleManager.invariant.t.sol::test_I8_revertsPastStaleness_boundary.
Possible remediations (for spec review, not Phase 1):
- Bypass the staleness check when the backstop fires (one-line change, spec change required)
- Widen
MAX_STALENESSto give the backstop more breathing room - Accept the 1h window as the protocol guarantee and update spec wording
Spec §4.4 defines marketId = keccak(token0, token1, fee, durationSeconds)
but doesn't specify how the contract resolves those four values from a
marketId. We added an owner-managed MarketConfig registry; governance
pre-registers each market with (token0, token1, fee, durationSeconds, oracleToken). createSwap cross-checks the position's actual
(token0, token1, fee) against the registered tuple.
Documented in: InflexionCore NatSpec. Worth promoting into spec
as a first-class concept.
Spec table:
±5% range → MaxIL ≈ 0.3% of V0
±10% range → MaxIL ≈ 1.2% of V0
±20% range → MaxIL ≈ 4.8% of V0
±50% range → MaxIL ≈ 25% of V0
Empirically measured (quant/notebooks/03_path_to_il.ipynb):
±5% range → MaxIL ≈ 1.2% of V0
±10% range → MaxIL ≈ 2.4% of V0
±20% range → MaxIL ≈ 4.7% of V0
±50% range → MaxIL ≈ 13.8% of V0
Spec placeholders should be updated. Tracked in
docs/MATH.md §3.
A review found that createSwap and settle accepted a caller-supplied sqrt
price. settle computed the payout from the caller's sqrtP_T (the validated
Chainlink price was only emitted), so any permissionless settler could set the
payout anywhere in [0, MaxIL]; createSwap took the entry sqrtP0 bounded only
to [Pa, Pb], letting an LP pick the entry that sets MaxIL / premium / V0. Both
prices are now derived on-chain from Chainlink via _oracleSqrtPriceX96
(decimal- and orientation-aware; MarketConfig decimals are read on-chain at
registerMarket, never trusted from calldata; the oracle orientation is immutable
across re-registration). This restores invariants I2 / I4 and the Fork-1
oracle pinning, and aligns the code to spec §5.2/§5.4 (which already specified
P0 = oracle.getPrice). Accepted residuals, by design:
- In-range is now defined at the ORACLE price, not the pool's
slot0. ThePa ≤ P0 ≤ Pbgate compares the Chainlink-derivedP0. When oracle and pool spot diverge (normal basis, thin pool, transient dislocation), a position that is in-range by pool-truth but out-of-range by the oracle is rejected (and vice versa). Intentional and consistent with settlement (also oracle-pinned); not a fund-loss vector. - Bounded entry-timing freedom.
P0is the latest Chainlink round at the LP's chosen block, clamped to withinpriceBandBpsof the MM-signedquotePriceover a 5–15 s validity window, so an LP can prefer a marginally favourable round — bounded by the MM's chosen band, blunted by premium scaling with MaxIL. This is the intended firm-quote + oracle-band design (I9), not a drain. - Phase-boundary liveness.
getSettlementPricereads neighbour rounds tolerantly (_tryRound): across a ChainlinkphaseIdboundary (whereroundId ± 1is not the real neighbour) a missing next round degrades to the liveness backstop (accept the pinned round pastLIVENESS_WINDOW) rather than a permanent fund-lock (invariant I8). It never lets a settler choose the price — the round-at-T is uniquely bracketed below byupdatedAt ≤ expiry.
Roadmap hardening (not blocking): decimals are validated on-chain today; for any future non-USD-quoted pair the USD-stable = $1 numéraire assumption (spec §19) must be revisited before listing.
fairRate = E_Q[min(IL, MaxIL)] / MaxIL is priced under risk-neutral GBM with the
forward pinned at F = P0 (martingale, r = 0, q = 0). A nonzero financing rate
r or ETH staking yield q tilts the true risk-neutral forward to
F = P0·exp((r − q)·T), and the v3 IL payoff is asymmetric about P0, so the tilt
is not strictly price-neutral. Sized and found economically negligible at launch
scope (re-pricing il.py under a forward-tilted terminal across widths ±5–50% and
7/30/90-day durations, σ = 0.6):
- Worst absolute move: +0.32 pts of MaxIL (±20%, 90d, aggressive
θ = r − q = −0.08); worst relative move: +0.7%. - Worked $50k / ±10% / 30d position (MaxIL $1,280): premium moves ≤ $1.30 even at
θ = −0.08; ≤ $2.50 at 90d — at/below the $1 dust-floor resolution for typical carry (|θ| ≤ 0.04) and well inside the load.
Conclusion: r = 0 is acceptable for the buildathon; revisit only for long-dated
(≥ 90d) or high-carry pairs. (Original red-team sizing; the scratch script that
produced these figures has been removed — this paragraph is the retained record.)
Multi-LLM external audit (Claude Opus 4.7, GPT, Gemini) of the protocol design. Result: 12 fixes applied → spec v3.3 + 2 architectural forks resolved:
- Fork 1 (Option B) — oracle settlement deadlock: round-at-T pinning
- lone-spike sanity + liveness backstop. Implemented in
OracleManager(Phase 3).
- lone-spike sanity + liveness backstop. Implemented in
- Fork 2 (Option B) — firm-quote pickoff: oracle-anchored price band.
Implemented as the
absBps(P_live, quotePrice) ≤ priceBandBpscheck inInflexionCore.createSwap(Phase 5). Invariant I9.
Audit details live in project memory (inflexion-audit-findings); the
original audit files were cleared after integration into spec v3.3 per
project policy.
Independent adversarial audit of the Monte Carlo solvency model (GPT-5 + Code Interpreter, Gemini 2.5 Pro). 6 HIGH-severity findings; 9 fixed in-band, 6 deferred as mainnet-TODOs with named owners.
Key fixes (per PR #5):
n_runs1,000 → 50,000 (1k was statistically meaningless for a 0.1%-tail estimate)- VaR → CVaR for
fund_target(coherent, tail-aware) - Mean-pnl criterion for fee curve (not median)
- Fixed-point bisection for
c_min ↔ fund_target(replaces hand-picked bootstrap) - Severe stress params re-anchored to historical episodes (Terra, FTX, March 2020, May 2021)
parameter_provenanceper field (calibrated / heuristic / deferred)- Annualised ruin budget surfaced (
0.1%/30d ≈ 1.21%/year) - 8 hand-calculated
il.pyfixtures from Uniswap §6.30 (audit C1)
Deferred to mainnet (documented in quant/params.json.notes):
- Portfolio-level multi-market calibration
- Empirical position mix from Uniswap subgraph
- Joint c_min × fee_curve optimisation
- Multi-period fund evolution
- MM / LP behavioural models
- Multi-σ floor curve
- Depeg / oracle-failure / liquidation cascade mechanisms
Ran Slither 0.11.5 against packages/contracts, filtered to src/
(test/, script/, lib/ excluded). Total: 27 findings → 1 fixed, 26
accepted false-positives. Reproduce:
slither packages/contracts --filter-paths "test|script|lib"1 medium fixed — reentrancy-no-eth in InflexionCore.settle:
Before the fix, s.status = SETTLED was written after oracle.getSettlementPrice
and ilMath.computeIL. A malicious oracle or IL-math implementation
could re-enter settle on the same swapId while it was still
ACTIVE. Both are owner-deployed trusted contracts in practice, but the
hardening is essentially free.
Fix: hoisted s.status = Status.SETTLED to immediately after the
validity checks, before any external call. Strict CEI. Re-entry now
hits the Status.ACTIVE precondition and reverts. PR/commit: 5.13.
Accepted findings (26) — disposition:
| Detector | Count | Disposition |
|---|---|---|
unused-return (medium) |
10 | All on multi-field tuple destructuring from NPM.positions() (12 fields, we use 6), Chainlink.latestRoundData() / getRoundData() (5 fields, we use 2), and Uniswap.pool.observe() (2 fields, we use 1). Intentional + idiomatic. |
reentrancy-benign (low) |
1 | InflexionCore.createSwap writes swaps[swapId] after underwriterVault.lockCollateral. lockCollateral is an internal trusted contract that does not transfer tokens — only updates internal accounting. If it reverts the entire tx reverts, so the post-call SwapRecord write is atomic with the lock. |
reentrancy-events (low) |
4 | Events emitted after external calls in ILVault.{claimFees, returnNFT} and InflexionCore.{createSwap, settle}. Canonical Solidity pattern (event metadata depends on call results). Not a vulnerability — events are observational only. |
timestamp (low) |
7 | Expiry / validity / staleness comparisons against block.timestamp in InflexionCore + OracleManager. Expected — these are time-based protocol checks where small miner-window drift (seconds) is irrelevant against our windows (minutes–hours). |
cyclomatic-complexity (info) |
1 | createSwap has complexity 20 (Slither threshold: 11). Driven by the spec §5.2 4-phase CEI pattern with explicit per-revert custom errors. Splitting would obscure the spec-traceable structure for negligible win. |
solc-version (info) |
2 | pragma solidity 0.8.24; (exact, not range). Slither suggests using >=0.8.0 but we pin to a specific version per project convention. |
naming-convention (info) |
3 | setCore(address _core) / setTreasury(address _treasury) use leading-underscore parameter names. Project convention for setter args (avoids shadowing the storage variable). |
Manual review pass: verified all I1–I9 invariants are enforced by the code paths the tests cover (see §1). No additional findings beyond the documented Fork-1 design observation (§3.1).
Re-ran Slither 0.11.5 on the cvAMM additions (ConvexityVault, FairValueOracle,
VolOracle) + the modified InflexionCore (createSwap / createSwapPathA /
createSwapRouted + the settle dispatch). 74 results → 0 high, 0 confirmed
vulnerabilities; 1 cleanup applied (the stray foundry-template Counter.sol /
Counter.t.sol / Counter.s.sol deleted — they were the only ^0.8.13 usage).
Reproduce: slither packages/contracts --filter-paths "test|script|lib".
| Detector | Sev | Disposition |
|---|---|---|
reentrancy-benign |
low | createSwap{,PathA,Routed} write swaps[swapId] after lockCollateral + NFT safeTransferFrom + accruePremium. All callees are owner-deployed trusted contracts (ConvexityVault / UnderwriterVault, ILVault, canonical NPM); swapId is fresh (nextSwapId++) so there is no pre-existing state to corrupt, and any callee revert reverts the whole tx. |
reentrancy-events |
low | Events (SwapCreated/SwapRouted/SwapSettled, ILVault.{claimFees,returnNFT}) emitted after external calls. Observational; field values depend on call results. settle keeps the status-flip-first CEI from 5.13. |
incorrect-equality |
med | ConvexityVault exact-zero guards (supply==0 ⇒ first-deposit 1:1, total==0 empty pool, t==0, r.shares==0). Control-flow guards on exact zero, not balance/threshold comparisons — not a manipulation vector. |
unused-return |
med | Multi-field tuple destructuring: NPM.positions() (12→≤7), Chainlink latest/getRoundData (5→2), pool.observe() (2→1), ConvexityVault.inventory() (5→2: util+conc), VolOracle.poke() (σ_ref read separately via fairPremium), FairValueOracle.fairPremium() (fairRateWad unused). Intentional + idiomatic. |
shadowing-local |
low | IConvexityVault.inventory() named returns mirror the same-named getters — deliberate API symmetry. |
timestamp |
low | Time-based protocol checks (validity / expiry / cooldown / staleness) + Slither lumping the ==0 guards here. Seconds-scale drift is irrelevant against minutes–hours windows. |
cyclomatic-complexity |
info | createSwap (17), getSettlementPrice (12) — spec-traceable multi-phase CEI / round-walking logic. |
naming-convention |
info | Leading-underscore setter args (avoid storage shadowing) + P0/Pa/Pb math notation in FairValueOracle.fairRateFromPrices. Project convention. |
unindexed-event-address |
info | CvammConfigured(address,address,address) — a one-time owner config event; indexing deferred. |
Manual review. ConvexityVault: the junior-first-loss waterfall
(releaseAndDistribute caps payout at juniorAssets), the withdrawal cooldown +
locked/free accounting (the pool cannot be run), and lockCollateral's
lockedAfter ≤ juniorAssets check (the u ≤ 1−sf senior-protection condition);
share math handles zero supply/assets. FairValueOracle / VolOracle: pure/view math
- a single permissionless
poke; no fund custody, no reentrant surface. The FULL no-bad-debt guarantee (I1) is structural and independent of these oracles —settlecaps payout atMaxIL == collateral. No findings beyond the documented design observations (§3.1). The Stylus FairValueOracle is upstream of settle and is a pure pricer; its accuracy/gas record is indocs/STYLUS_FAIRVALUE_BENCHMARK.md.
Settlement is non-custodial and on-chain. The off-chain matching engine's power is strictly bounded:
| Engine CAN | Engine CANNOT |
|---|---|
| Censor / reorder quotes (liveness / fairness) | Steal funds (settlement is against MM's own collateral) |
| Pick which signed quotes to surface | Forge quotes (every quote is MM-signed via EIP-712) |
| Drop quotes from its book | Force a stale quote (validity + on-chain nonce) |
| Bypass band check (price-band enforced on-chain) |
Mitigations against the powers it has (F-#13, spec §4.5):
- Direct-to-contract fallback. Any LP holding a valid signed quote
can call
createSwapdirectly, bypassing the engine. The SDK exposes this path — a censoring operator simply loses the user. - Append-only quote log. Engine publishes its full quote stream and match decisions to a rotating log (Phase 6.7).
- Deterministic price-time / FIFO matching rules.
The decentralisation roadmap is an Arbitrum Orbit chain hosting the quote book fully on-chain (Hyperliquid pattern) — out of hackathon scope but the clean answer to "isn't the matcher centralised?".
- PARTIAL mode + Insurance Fund security — Phase 15 (stretch).
Quant track has produced
params.jsonv2.0.0 with the parameters Phase 15 will deploy with, but the on-chain InsuranceVault contracts don't exist yet. - Smart-contract upgradability — there is none. All deployed
contracts are immutable; the
setCore+freezeCorepattern lets us one-shot wire vaults to InflexionCore and then permanently lock the wiring. - Front-end / engine security — out of scope of this doc; covered
in
docs/SECURITY.mdofapps/webandpackages/engine(Phase 10 / Phase 6).