# Security Review — PleaHook (PLEA run 8.5) **Repo:** https://github.com/khaed1/plea @ commit `af57f6df709acdf7fe754a58482ee35eef9e2178` (branch `testnet8.5`) — verified reachable on the remote via `git ls-remote`. **On-chain:** PleaHook `0x48f607FbdBD955e283C57A28E57309b8Ec9028cC` on Sepolia (deployed 2026-10-16, not seeded at time of review). **Scope (per client instruction — audited files only):** - `src/PleaHook.sol` (1116 lines, 863 nSLOC per client) - `src/interfaces/IPleaParts.sol` - `src/interfaces/IStacker.sol` All other `src/` files (`CabalGate.sol`, `Seasons.sol`, `PLEA.sol`, `PleaLaunch.sol`, `Tribunal.sol`, `Letterbox.sol`, `OracleAttestation.sol`, `PleaDistributor.sol`) were read only as context for how they call into PleaHook — no findings are filed against them. **Methodology:** Four-phase independent review — context/protocol mapping (3 parallel agents), breadth checklist pass (7 ethskills domain agents: general, precision-math, defi-amm/UniV4-hooks, access-control, erc20, dos, assembly), depth attack-hunt (12 pashov-methodology agents across math-precision, access-control, economic-security, execution-trace, invariants, periphery, first-principles, asymmetry, boundary, and three cross-lens gap-hunters), then hybrid reconciliation against the client's own `THREAT-MODEL.md` and `FINDINGS.md` (fetched at commit `54da76be64c6f01764e27e6cc5b65811475d5e77`) to exclude anything already fixed or accepted by design. This is a fresh, independent run — no finding below was taken from a prior report; several findings below were independently reached by 2–3 of the 19 total agents, which is noted as a confidence signal, not a citation. **Severity counts:** 0 Critical · 1 High · 2 Medium · 4 Low · 3 Informational --- ## Findings ### [H-1] A cashback that fails to deliver through both the Stacker and the plain-transfer fallback lands in the wall reserve, breaking the stated invariant that cashback can never reach it **Severity:** High **Location:** `_payCashback()`, `PleaHook.sol:755-778` ```solidity function _payCashback(address trader, uint256 amount) internal { if (amount == 0) return; imd.forceApprove(address(stacker), amount); _requireGas(creditGas * 64 / 63 + CREDIT_RESERVE); totalCashback += amount; if (address(stacker).code.length != 0) { try stacker.credit{gas: creditGas}(trader, amount) { imd.forceApprove(address(stacker), 0); emit CashbackStacked(trader, amount); return; } catch {} } imd.forceApprove(address(stacker), 0); (bool ok, bytes memory r) = address(imd).call(abi.encodeCall(IERC20.transfer, (trader, amount))); if (ok && (r.length == 0 || abi.decode(r, (bool)))) { emit CashbackPaidPlain(trader, amount); } else { totalCashback -= amount; retainedImd += amount; // <-- L776 } } ``` **Description.** When both the Stacker's `credit()` call reverts (or has no code) *and* the raw IMD transfer fallback also fails or returns `false`, the code at line 776 folds the undelivered cashback into `retainedImd` — "Wall reserve held as IMD by the hook" (`PleaHook.sol:160-161`), which is exactly the pool `_rebalance()` deploys into the IMD buy-wall (`amount = retainedImd;`, `PleaHook.sol:883`). The client's own `THREAT-MODEL.md` §2, invariant 21, states explicitly: *"...a cashback credit can never reach the wall reserve (the Stacker allowance is per credit)."* The parenthetical only defends the allowance path (the Stacker can never pull more than the one credit it's approved for) — it does not defend this *fallback-of-the-fallback* path, which this code introduces on its own, independent of the Stacker. **Concrete path.** A trader buys PLEA; `_payFees` computes a nonzero `cashback` and the hook already holds the real IMD for it (via `poolManager.take` inside `_payFees`). In `_payCashback`: (1) `stacker.credit()` reverts — plausible if the Stacker is paused, or if IMD/sIMD (both named "Trusted externals" in `THREAT-MODEL.md` §1, but not infallible) reject the pull for this specific trader; (2) the fallback `imd.transfer(trader, amount)` *also* fails — requires IMD to revert or return `false` specifically for this `trader` (a blocklist or pause condition). When both conditions co-occur, the trader gets nothing, and their fee becomes part of the wall's IMD reserve instead — permanently, since `retainedImd` has no path back to a specific trader. **Who gains / what breaks.** No one profits; this is a fund-misdirection bug, not an extraction. The trader who earned the cashback loses it; the wall reserve (which benefits no one specifically — the hook's own documentation says the wall "pays the caller nothing: the wall's IMD never leaves the pool," `PleaHook.sol:842-844`) grows by that amount instead. This is rated High because it is a clean violation of a numbered §2 invariant, which the client's own severity table states is "at least High" regardless of how narrow the trigger window is — but the reachability genuinely requires two independent external failures stacking, which the report flags explicitly so the client can weigh likelihood against their own knowledge of IMD's and the Stacker's actual behavior. **Fix.** On a failed Stacker credit *and* a failed plain transfer, mint the trader an ERC-6909 IMD claim (the same pattern already used for every other "PoolManager can't pay right now" case in this file — `_refund`, the owner/prize payout shortage path) instead of folding the amount into `retainedImd`. --- ### [M-1] The opening-buy payout exception can reopen after a real buy should have closed it permanently, because the test it depends on is a live, reversible function of price **Severity:** Medium **Location:** `_payFees()`, `PleaHook.sol:587-636` (the exception at `L611-614`, the `own` measurement at `L604`) ```solidity uint256 own = _imdIn(market, priceBefore) + _imdIn(wall, priceBefore) + retainClaims; // L604 bool asClaims = own < out && imd.balanceOf(address(poolManager)) < out; // L605 // ...no flag, so no dust buy can use the exception up for the buy that follows it... if (asClaims && buy && own + imdFee < PAYOUT_RESERVE) { // L611 emit PayoutsToPool(out); (cashback, toOwner, toPrize, out) = (0, 0, 0, 0); } ``` **Description.** `THREAT-MODEL.md` §3 describes this exception ("the opening buys...") entirely in terms of repeated *small buys* being unable to reopen it once a sufficiently large buy has closed it — "no flag, so no dust buy can use the exception up for the buy that follows it," and separately, "the launch owner's own 2 IMD opening buy right after seed... closes it." Nowhere does the client's own documentation consider a *sell* lowering `own` back down. But `own` (line 604) is purely a live function of the market band's and wall's current IMD holdings at `priceBefore` — it rises when buys land IMD in the band, and falls just as readily when a sell pulls the band back toward its PLEA-heavy edge. There is no one-way latch; "closing" the exception is not sticky. **Concrete path (worked numbers).** 1. Seed state: market band is 100% PLEA, `own = 0`. 2. A buyer does a 150 IMD exact-input buy during the launch window (`launchExtraBps ≈ 7000`, `rate = 7175` bps): `fee ≈ 62.66e18`, `specAmt ≈ 87.34e18`. `own(0) + imdFee(62.66e18)` is far above `PAYOUT_RESERVE` (2e18) — the exception does **not** fire, everyone is paid normally, exactly as intended. This buy's ~87.34e18 IMD principal is now inside the market band, so `own` is now ≈87.34e18 — far above the reserve, matching the client's own "closes it" framing. 3. Anyone sells PLEA back (post-Cabal-death this needs no gate; while alive it goes through CabalGate as normal), reversing price toward the band's PLEA-heavy edge. `_imdIn(market, ...)` falls back toward 0; net cost to the seller is only the ordinary round-trip fee (~1.5 IMD net on an 87 IMD reversal, after their own cashback). 4. A small, unrelated buyer does a 10 IMD buy post-launch-window (`rate = 175` bps): `imdFee ≈ 0.172e18`. Now `own(≈0) + imdFee(0.172e18) < PAYOUT_RESERVE` — **the exception fires again**: this buyer's cashback (~0.0491e18), the owner's cut (~0.0737e18) and the season pot's cut (~0.0246e18) are all zeroed, and the whole fee is redirected into `retainClaims` instead. This repeats for every subsequent small buy until the redirected fees themselves happen to cross the reserve again. **Who gains / what breaks.** No one extracts value for themselves; small buyers lose cashback, the owner loses their 0.75% cut, and the season pot loses its 0.25% cut — all redirected into pool liquidity instead. This can happen through ordinary trading with no special privilege, and does not require the seller to be the same party as the later small buyers (it can happen by accident through normal two-sided trading, not only as a deliberate attack). **Fix.** Track whether the opening exception has already been used with a one-way storage flag, set the first time `own + imdFee` clears `PAYOUT_RESERVE`, instead of re-deriving the test from the live, reversible market-band price on every swap. --- ### [M-2] The specified-side fee is cast to `int128` with no bounds check and can silently wrap for a large exact-output sell **Severity:** Medium (confidence: the mechanism is confirmed reachable; the full downstream consequence through v4-core's delta accounting was not traced end-to-end — see note below) **Location:** `beforeSwap()`, `PleaHook.sol:464-479` ```solidity uint256 amount = exactIn ? uint256(-params.amountSpecified) : uint256(params.amountSpecified); bool specifiedIsImd = buy == exactIn; uint256 rate = specifiedIsImd ? _imdRate(buy) : PLEA_BURN_BPS; uint256 fee = exactIn ? amount * rate / (BPS + rate) : amount * rate / (BPS - rate); if (!specifiedIsImd && buy) _checkMaxBuy(amount + fee); ... return (IHooks.beforeSwap.selector, toBeforeSwapDelta(int128(int256(fee)), 0), 0); // L479 ``` **Description.** `_checkMaxBuy` is the *only* bound anywhere on `amount`/`fee`, and it only fires on the `!specifiedIsImd && buy` path (an exact-output buy) during the 90-minute launch window. An **exact-output sell** (`buy == false`, `exactIn == false`) has no bound at all: `specifiedIsImd = true`, `rate = IMD_FEE_BPS = 175`, `fee ≈ amount * 175 / 9825`. `fee` exceeds `type(int128).max` (≈1.7e38) once `amount` exceeds roughly `9.56e39`. Solidity's checked arithmetic (0.8.26) protects `+ - *`, but **not** an explicit narrowing cast like `int128(int256(fee))` — if `fee` is out of `int128` range, the cast silently wraps to an unrelated (likely negative) value with no revert. **Reachability.** Sells are restricted to the CabalGate address only `while (!buy && alive && sender != IPLEA(plea).gate())` (`L453`). Once `_alive()` is false — the Cabal's documented terminal state (the dead-man's switch, 33 hours with no verdict, per `THREAT-MODEL.md` invariant 6) — **any address** can call `poolManager.swap()` directly for an exact-output sell with an arbitrary `amountSpecified`, reaching this cast with no gate at all. The attacker does not need the pool to actually hold enough liquidity to fill such a request — `beforeSwap`'s fee math runs on the *requested* `amountSpecified`, before the pool attempts to fill anything. **What this breaks.** The wrapped value is returned to the PoolManager as the hook's specified-side delta via `toBeforeSwapDelta`, decoupling it from the correct, full-precision `fee` the hook separately stores via `tstore(T_SPEC_FEE, fee)` for its own later accounting in `afterSwap`. We traced this as far as confirming the wrap is real and reachable; we did **not** complete a full trace through v4-core's `Hooks.sol`/`Pool.sol` delta-application logic to determine whether the most likely outcome is simply an unrelated revert elsewhere (e.g. a swap-amount sanity check failing downstream, which would make this a self-limiting DoS of the attacker's own transaction) or something that could be steered toward a different, more exploitable outcome. We rate this Medium rather than higher specifically because of that unresolved uncertainty, and recommend the fix regardless of which it turns out to be, since silent truncation is never the intended behavior. **Fix.** Bound `fee` before the cast (e.g. `require(fee <= uint256(type(int128).max))`), or route every `int128(int256(...))` conversion in this file through a `SafeCast`-style helper that reverts instead of wrapping. --- ### [L-1] `stacker.credit()`'s success is trusted without verifying that IMD actually moved **Severity:** Low (the client's own `THREAT-MODEL.md` §1 lists the Stacker as a "trusted external," which is why this is rated as a hardening gap rather than a live vulnerability — but two independent review passes converged on the same code smell, which is worth noting for the record) **Location:** `_payCashback()`, `PleaHook.sol:762-768` **Description.** `imd.forceApprove(address(stacker), amount)` only *caps* what `credit()` may pull; nothing in `IStacker.sol`'s interface, and nothing PleaHook checks afterward, confirms that a non-reverting `credit()` call actually pulled the full `amount` via its own `transferFrom`. On success the code immediately zeroes the allowance and emits `CashbackStacked(trader, amount)` as if the full amount left the contract — with no `balanceOf` snapshot before/after the call. Any shortfall (a Stacker that takes an internal fee off the top, floors a dust amount to zero, or is partially degraded for a specific recipient while still returning normally) stays as plain, un-tracked IMD balance on the hook: not `retainedImd`, not `retainClaims`, and with no sweep/rescue function anywhere in the contract to recover it later. **Fix.** Snapshot `imd.balanceOf(address(this))` immediately before the `try` call and compare after a successful return; if the balance did not fall by at least `amount`, route the shortfall the same way a failed credit already is (fall through to the plain-transfer path, or mint a claim for the difference). --- ### [L-2] Naming CabalGate as the resolved trader makes a buyer's own swap revert **Severity:** Low (self-harm only — no third-party victim identified) **Location:** `_validTrader()`, `PleaHook.sol:710-716`; consumed at `afterSwap()` `PleaHook.sol:540` and `_refund()` `PleaHook.sol:665` **Description.** `_validTrader` excludes `address(0)`, `stacker`, `imd`, `simd`, `address(this)` and `poolManager` from being named as `trader` via `hookData` — but not `IPLEA(plea).gate()`. If a caller sets `hookData = abi.encode(gate)`, `_resolveTrader` happily resolves `trader = gate`. On an alive exact-input buy, `afterSwap` then does `poolManager.take(Currency.wrap(plea), gate, ...)` (`L540`), which calls `plea.transfer(gate, amount)` — and `PLEA._update` (`PLEA.sol:134-136`) refuses any transfer landing on `gate` unless `msg.sender == gate`; here the caller is `poolManager`, so the transfer reverts, reverting the whole swap. The same applies to `_refund`'s PLEA-side branch (`L665`). This only reverts the caller's own transaction — we did not find a way to use it against an unrelated third party's funds, since `hookData` is chosen per-swap by whoever is paying for that swap. **Fix.** Add `IPLEA(plea).gate()` to `_validTrader`'s denylist, purely for robustness — not urgent given the self-limited impact. --- ### [L-3] `beforeSwap` and `afterSwap` each independently re-derive Cabal liveness instead of sharing one snapshot **Severity:** Low **Location:** `_alive()` (`PleaHook.sol:368-370`); called at `beforeSwap` `L452`, and again at `afterSwap` `L538` and `L598` **Description.** `alive` is computed once in `beforeSwap` (`L452`) but is not threaded through the existing transient-storage handoff alongside `trader`/`fee`/`buy`/`priceBefore`. `afterSwap` calls `_alive()` independently two more times — three external calls to `IPLEA(plea).restricted()` → `IGate.cabalAlive()` per swap in total. We confirmed this is **not** currently exploitable: `cabalAlive()` (`CabalGate.sol:202-203`) is a pure function of `block.timestamp` (fixed for the whole transaction) and `lastVerdictAt`, and nothing reachable in this call path (no attacker-controlled callback, no transfer hook on PLEA/IMD) can advance `lastVerdictAt` mid-swap. This is reported as a design-robustness gap — an unstated dependency on CabalGate's exact current implementation — rather than a live bug. **Fix.** Thread `alive` through the existing transient-storage handoff as a fifth slot, both to save two external calls per swap and to remove the implicit cross-contract assumption. --- ### [L-4] Two staticcalls copy their full return data into memory before checking its length **Severity:** Low **Location:** `_vaultOf()` `PleaHook.sol:679-684`; `_resolveTrader()`'s router probe, `PleaHook.sol:702-703` **Description.** Both the Stacker's `SIMD()` probe and the router's `msgSender()` probe decode into `bytes memory ret` and check `ret.length != 32` only *after* the call returns — Solidity's calling convention always copies the full returndata into memory before any Solidity-level check can run. For `_resolveTrader`'s probe, this is reachable on every swap through a router that omits `hookData`. Quantified: the call is capped at `MSG_SENDER_GAS = 30,000`, and EIP-150 bounds the callee's own achievable memory expansion within that stipend to roughly 100 KB, so the realistic worst-case overhead is on the order of 60,000 gas — comfortably inside the `SWAP_RESERVE = 1,000,000` slack already reserved before this call runs (`beforeSwap` `L449` precedes `L462`). `_vaultOf`'s staticcall has no gas cap, but it only runs once, in the constructor, against a deployer-supplied address — not an attacker-reachable, per-swap surface. **Fix.** Bound the copy explicitly (e.g. `returndatacopy` only the first 32 bytes) rather than relying on the gas stipend as the sole backstop. --- ### [I-1] No defense against a non-standard (fee-on-transfer, rebasing, or blocklisting) IMD token **Severity:** Informational — IMD is a specific, known token in this ecosystem and very likely behaves as a standard ERC-20; this is flagged for completeness given the contract's total immutability (no admin path exists to react if this assumption is ever wrong) **Location:** `fundWall()` `PleaHook.sol:948-951`; `_settleImd()` `PleaHook.sol:990-995` (called from `_rebalance()` `L922`); `_payCashback`'s plain-transfer fallback `PleaHook.sol:771-777` **Description.** Every IMD-moving function records the *nominal* amount requested, never a measured balance delta. If IMD were ever fee-on-transfer: `fundWall` would overstate `retainedImd`; a subsequent `_settleImd` inside `_rebalance`'s wall-deployment step would then try to send more than the PoolManager actually receives, so `unlock()`'s zero-delta check would revert the whole `rebalance()` call — and since the revert leaves `retainedImd` unchanged, *every future call* to the permissionless `rebalance()` would fail identically, permanently (there is no admin function to intervene, by design). Separately, `_payCashback`'s raw-call fallback (`L771-777`) treats a `false`-returning-but-successful transfer (e.g. Tether Gold's documented behavior) as a failure and credits `retainedImd` for tokens that already left the contract — inflating the tracked reserve above the real balance. Neither issue is reachable with a standard, non-fee, non-rebasing, bool-or-void-returning ERC-20. **Fix.** If IMD's behavior is not contractually guaranteed, measure actual balance deltas around transfers rather than trusting nominal amounts and boolean returns; otherwise, this can be accepted as a documented deployment assumption. --- ### [I-2] No `decimals()` assertion on IMD despite several hardcoded 18-decimal-scaled constants **Severity:** Informational **Location:** Constructor `PleaHook.sol:198-219`; constants `MARKET_CAP_IMD` (`L86`), `LAUNCH_MAX_BUY` (`L82`), `PAYOUT_RESERVE` (`L85`), `WALL_THRESHOLD` (`L93`), `WALL_MIN_FILL` (`L94`) **Description.** All of the above hardcode `1e18`-scaled values assuming IMD uses 18 decimals, with no `decimals()` check anywhere in the constructor. If IMD were ever deployed with a different decimal count, every economic threshold in the contract would silently rescale by the corresponding power of ten. This is a deploy-time assumption, not something an attacker can trigger post-deployment, and is almost certainly moot in practice since IMD's decimals are presumably fixed and known by the deploying team. **Fix.** A constructor-time `require(IERC20Metadata(address(imd_)).decimals() == 18)` closes this for free if desired; otherwise documenting the assumption is sufficient. --- ### [I-3] `_refund`'s trader-facing payouts lack the failure-tolerant fallback `_payCashback` uses for the identical risk **Severity:** Informational **Location:** `_refund()` `PleaHook.sol:663-674` (direct `poolManager.take(..., trader, ...)` at `L665` and `L668`) **Description.** `_payCashback` is deliberately hardened against a reverting or blocklisted transfer to `trader` (a raw `.call()` with a fallback into `retainedImd` on failure, rather than reverting the swap). `_refund` calls `poolManager.take` directly with no such fallback — if IMD or PLEA ever revert a transfer to a specific `trader`, the entire swap reverts instead of degrading gracefully. This is a design-consistency gap rather than a demonstrated exploit, since it depends on token behavior outside this file's scope, and PLEA sells are routed only through the trusted Gate while the Cabal is alive, which limits who `trader` can be during that period. **Fix.** If IMD or PLEA can ever revert a transfer to a specific holder, apply the same claims-or-fallback pattern `_payCashback` already uses. --- ## Reviewed and excluded (already fixed, already accepted by design, or found immaterial) Per the client's instruction not to re-report fixed or accepted items, the following were investigated — several independently, by more than one of the 19 review agents — and are **not** included above: - **The 5M PLEA launch-window cap is per-swap, not cumulative** (`_checkMaxBuy`, `PleaHook.sol:718-722`). Three of the twelve depth agents independently converged on this as a seemingly novel high-confidence finding — batching several swaps inside one `unlock()` call bypasses the per-window limit. **This is already known and documented**: `FINDINGS.md` R2-A3-6 records it verbatim ("the 5M PLEA launch cap is per swap... a router may chain swaps"), status `documented`, and the current `THREAT-MODEL.md` §3 restates it as deliberate: *"per swap: a router may chain swaps in one transaction, so the cap bounds one swap's price impact, not one buyer's total."* Confirmed the code still matches this description; not re-reported. - **The inventory-cap decay never clips elapsed time to one day**, unlike the wall guard's explicit one-day clip (`_applyCap`, `PleaHook.sol:793-797` vs. `_updateWallGuard`, `L1036`). One depth agent flagged this as a novel finding (a frozen multi-week window lets the whole 15-day decay schedule collapse into one swap). **This is explicitly accepted design**: `THREAT-MODEL.md` §3 states it in exactly these terms — *"The cap's daily allowance banks over idle days (a quiet stretch lets one buy ratchet the cap by several days' worth at once; the floor and the 15-day pace are unchanged), unlike the wall guard, whose idle time is capped at one day."* Confirmed the asymmetry the agent found is the asymmetry the client already described and accepted; not re-reported. - **The wall-placement guard moves instantly to a lower price and only slowly (400 ticks/day) back up**, which two agents (one in the breadth pass, one in the depth pass) flagged as letting the IMD buy-wall be stranded below a recovered market price after a transient dip. This exact mechanic — "drops at once to any lower traded price and rises at most 400 ticks a day" — is `THREAT-MODEL.md` §2 invariant 21, an agreed design, not a finding. - **Naming an unrelated address as a swap's "trader" via `hookData`** was flagged by five of the twelve depth agents from different angles (diluting a victim's holding-time record on a buy; fully zeroing a victim's cost basis on a sell; routing a cashback claim into the season pot by naming `seasons`). On investigation: the buy-side dilution is `FINDINGS.md` R2-A3-2, already accepted in writing by the owner as a known limit. The sell-side "full wipe" variant requires naming an arbitrary trader on a *sell*, but sells are gated to CabalGate while the Cabal is alive, and CabalGate's own code binds `hookData` to the actual plea's seller (`address seller = p.seller;` read from the stored plea record at `CabalGate.sol:613`, then passed through `_unlock` and encoded as `abi.encode(seller)` at `CabalGate.sol:668`) — an outside caller cannot choose this value. After the Cabal's death this gate lifts, but `basisOf` is read externally only by `CabalGate._factScoreAt` for fact-scoring a sell request, which no longer matters once selling no longer requires a plea — so the "wipe" has no identifiable victim with anything left to lose by that point. Not re-reported as a scored finding (noted as investigated for completeness). - **Sub-threshold sells rounding a trader's own cost-basis reduction to zero** (`_updateBasis`, `PleaHook.sol:737-745`) was flagged by the precision-math pass. Same reachability/materiality analysis as above applies (gated pre-death through CabalGate's own, non-attacker-chosen seller binding; immaterial post-death) — not re-reported as a scored finding. - All mechanisms the client's own `FINDINGS.md` records as fixed in prior rounds (R3-A3-1 flash-take payout stripping, R7-A3-1/R7-A3-2 post-death oversized-sale handling and fee-refund ordering, R4-A3-1 exact-output partial-fill, R3-A1-1 checkpoint-ring eviction, R2-A3-3 zero-fill-swap reference-tick walking, and others) were spot-checked against the current code and confirmed still correctly fixed; none are re-reported. --- ## Coverage summary - **Entry points** (per the phase-0 access-control inventory): `seed()`, `beforeInitialize`, `beforeAddLiquidity`, `beforeSwap`, `afterSwap`, `rebalance()`, `fundWall()`, `unlockCallback()` — all 8 examined; each has either a finding above or an explicit "examined, no issue" note in the review trail (constructor-immutability, `noSelfCall` gating, one-shot `seeded` guard, permissionless-by-design `rebalance`/`fundWall`). - **Threat rows**: actors × entry points × assets were walked by the dedicated access-control and economic-security passes; no unguarded value-moving entry point was left unexamined. - **Agreed invariants** (`THREAT-MODEL.md` §2, items touching PleaHook — 19 through 22): all four were independently re-derived and stress-tested by the invariant-specialty and asymmetry-specialty depth agents. One (21, cashback-never-reaches-wall-reserve) is violated under the double-failure condition in [H-1]. The others hold as designed, including under deliberate attempts to defeat them with chained swaps, flash-takes, and donation-based balance manipulation. - No pass depended on executing the target's own code (build/test); all analysis is static source review, per audit policy. --- > This review was performed by an automated multi-agent pipeline (ethskills breadth + pashov-methodology depth, 19 agents across two blind phases, reconciled against the client's own threat model and findings ledger). Automated analysis can never verify the complete absence of vulnerabilities. Independent human review, the project's own test suite (823 tests per the client's description), and continued monitoring after launch are recommended alongside this report.