# Aggregated findings — intuition_c1_extended Reviewed 2 files via dual-LLM (Claude + Gemini) + Slither. Generated 2026-04-21 00:34. Estimated LLM cost this run: $0.0000 (see `llm_cost.json`). ## src/protocol/emissions/TrustBonding.sol ### Claude Opus 4.7 ## Findings ### 1. `_balanceOf` uses latest user checkpoint for arbitrary past timestamps → users who lock late can steal rewards for prior epochs - **Title** — Historical `userBondedBalanceAtEpochEnd` query is inflated for users whose only checkpoint is after the queried timestamp, enabling theft of prior-epoch rewards. - **Severity** — **Critical** (realistic, permissionless loss of protocol-owned / other users' rewards). - **Location** — `src/external/curve/VotingEscrow.sol:_balanceOf(address,uint256)` (lines 630–638, as reported by Slither), consumed by `TrustBonding.userBondedBalanceAtEpochEnd` (l.203–213) and `TrustBonding._userEligibleRewardsForEpoch` (l.475–492). - **Root cause** — Per the Slither dump, `_balanceOf(address,uint256)` is 8 lines long and performs a single extrapolation off the user's *most recent* `user_point_history` entry (`last_point.bias -= last_point.slope * (_t - last_point.ts)` then clamp to 0). It does **not** binary-search `user_point_history` by `_t` (the project *has* a `_find_user_timestamp_epoch` helper but it isn't invoked from `_balanceOf`). When `_t < last_point.ts`, the subtraction `_t - last_point.ts` is effectively negative (or, given unsigned semantics in the underlying Vyper/Solidity port, yields a large value that is then subtracted, the result never goes negative enough to clamp), so the returned bias is *inflated* rather than zero. `TrustBonding` nevertheless passes historical epoch-end timestamps into this function: ``` _balanceOf(account, _epochTimestampEnd(epoch)) // l.213 ``` Meanwhile, `_totalSupply(_epochTimestampEnd(epoch))` uses `point_history` which *is* properly historical. The asymmetry means the numerator (user balance) is wrong while the denominator (total balance) is correct. - **Impact** — An attacker who has never locked can, immediately after epoch `N` ends: 1. Call `create_lock(value, unlock_time)` → creates their first (and only) `user_point_history` entry at `ts ≈ now`. 2. Call `claimRewards(recipient)` in epoch `N+1`. Internally `userBondedBalanceAtEpochEnd(attacker, N)` extrapolates the attacker's single point backward to `epochEnd(N)`, producing a large positive "historical" balance. 3. Their pro-rata share `userBalance * emissions / totalBalance` is claimed from the epoch `N` pot, at the expense of legitimate lockers who are active throughout epoch `N`. An attacker can repeat this each epoch (fresh lock / increase amount → new point always in the future of the queried timestamp), siphoning emissions they never bonded for. Because `_emissionsForEpoch` is gated by system utilization, but `userBondedBalanceAtEpochEnd` only governs distribution share, this directly transfers TRUST from the SatelliteEmissionsController to the attacker. - **Preconditions** — Permissionless. Works from epoch 1 onward. Only requires `_balanceOf` implementation to be the non-searching variant (consistent with the 630–638 line range in Slither output). Exploit size scales with attacker's lock amount and the slope (longer unlock time ⇒ larger bias ⇒ larger extrapolated historical balance). - **PoC sketch** ```solidity // Assume epoch length = 1 week, we are just after epochEnd(N). // Honest user Bob locked 1000 TRUST for 2 years before epoch N started. // Eve has never interacted. vm.warp(epochEnd(N) + 1); // we're now in epoch N+1 trust.approve(ve, 10_000 ether); ve.create_lock(10_000 ether, block.timestamp + 2 * 365 days); // Eve's first point at t=now // userBondedBalanceAtEpochEnd(Eve, N) extrapolates Eve's point *backwards*: // bias_at(epochEnd(N)) = bias_now - slope * (epochEnd(N) - ts_now) // = bias_now + slope * (ts_now - epochEnd(N)) <-- INFLATED uint256 share = ve.userBondedBalanceAtEpochEnd(address(eve), N); assertGt(share, ve.userBondedBalanceAtEpochEnd(address(bob), N)); // Eve > Bob tb.claimRewards(address(eve)); // Eve drains emissions for epoch N she never earned ``` - **Remediation** — In `VotingEscrow._balanceOf(address,uint256)`, locate the user epoch for `_t` via `_find_user_timestamp_epoch(addr, _t, user_point_epoch[addr])` and extrapolate from *that* snapshot instead of the latest one. Additionally clamp the return to 0 when `_t < user_point_history[addr][1].ts` (i.e. before the user ever locked). Equivalently, replace the call-site with a properly historical helper (analogous to `balanceOfAt` but timestamp-based). --- (No other specific, exploitable, non-trivial findings located in `TrustBonding.sol` beyond what is already documented by Slither as false positives.) ## Slither False Positives - **divide-before-multiply in `VotingEscrow._checkpoint`, `_create_lock`, `_increase_unlock_time`, `_supply_at`** — These are intentional WEEK-alignments (`(t / WEEK) * WEEK`). Matches Curve reference. - **incorrect-equality (`== 0`, `<= 0`, etc.)** — All are simple zero/edge-case gates, not dangerous equalities (no balance rounding attack surface). - **timestamp comparisons** — Expected in an epoch/lock system; no block-time manipulation attack here (attacker can't meaningfully shift `block.timestamp` vs epoch boundaries). - **uninitialized-local `userRewards`/`personalUtilization` in `getUserInfo`** — Zero-init is semantically correct; only overwritten when `_currEpoch > 0`. - **shadowing-local `epoch` in many functions** — Shadows a state var but is read-only; no behavioral bug. - **missing-zero-check on `VotingEscrow.changeController`** — privileged setter, documented role. - **erc20-interface on `ISatelliteEmissionsController.transfer`** — intentional custom-signature controller call, not ERC20. - **cyclomatic-complexity / assembly / pragma / solc-version / naming / unindexed-event / unused-state / dead-code** — style/informational. ## High-confidence observations - `claimRewards` mark uses `userClaimedRewardsForEpoch[msg.sender][prevEpoch] > 0`; since `userRewards == 0` reverts earlier, the flag cannot be set to 0 by design, so double-claim prevention is sound. - `totalClaimedRewardsForEpoch[epoch]` accumulates utilization-adjusted amounts while `getUnclaimedRewardsForEpoch` subtracts from `getEmissionsAtEpoch(epoch)` (max emissions); the delta is intentional and documented in-code. - `_getSystemUtilizationRatio` and `_getPersonalUtilizationRatio` correctly short-circuit epochs 0/1 to 100% and clamp to their respective lower bounds; lower bounds are validated against `MINIMUM_*` constants. - `_emissionsForEpoch` scales by system utilization only for `epoch >= 2`, consistent with the utilization-target bootstrap. - Reentrancy: `claimRewards` is `nonReentrant`; the only external call (`SatelliteEmissionsController.transfer`) happens after state updates (CEI respected). - Timelock gating on all parameter setters; initializer sets timelock non-zero. - `getUserApy` output units: `currentApy` and `maxApy` both resolve to BPs when `locked` and `userRewards` share token scale; consistent. - Rewards forfeiture (only `prevEpoch` claimable) is explicit and documented; not a bug. - `_previousEpoch()` underflow-safe via `curr == 0` branch. ### Gemini 3 Pro An analysis of the `TrustBonding` contract yields two critical design vulnerabilities stemming from how rewards are allocated based on single point-in-time snapshots. ### 1. Zero-balance reward eclipse when lock expiration strictly equals the epoch's end timestamp **Severity**: High **Location**: `src/protocol/emissions/TrustBonding.sol` -> `userBondedBalanceAtEpochEnd()` and `_userEligibleRewardsForEpoch()` **Root cause**: Epoch rewards are calculated via `_userEligibleRewardsForEpoch()` using the exact `_balanceOf()` of the user at `_epochTimestampEnd(epoch)`. In Curve's `VotingEscrow` logic, an account's lock balance strictly decays to `0` exactly at `unlock_time`. Because `VotingEscrow` locks are purposefully aligned to multiple block periods matching `_epochLength`, a user's final lock expiration will almost certainly land *exactly* on an epoch's end timestamp. At that exact block/timestamp, their evaluated balance evaluates to mathematically `0`. Consequently, `userBondedBalanceAtEpochEnd` returns `0`, causing the user to forfeit 100% of their rewards for their entire final epoch—even though their tokens were fully locked and exposed to risk over the entire duration of that epoch. **Impact**: Users systematically lose the final epoch of yield for their locks. For a minimum lock duration (1 epoch), the user will lock funds but receive absolutely 0 rewards. This represents a deterministic loss of user funds and unrecoverable protocol yield. **Preconditions**: Permissionless. Happens organically at the conclusion of any naturally aligned lock. **PoC sketch**: 1. Global `epochLength` = 1 week. Alice calls `create_lock()` for exactly 1 week at the beginning of Epoch 1. 2. Curve's logic sets her lock `end` at the exact boundary aligned with `_epochTimestampEnd(1)`. 3. Alice participates for the entirety of Epoch 1 with her tokens functionally locked. 4. Epoch 1 completes. In Epoch 2, Alice calls `claimRewards()` requesting Epoch 1 rewards. 5. The contract calls `userBondedBalanceAtEpochEnd(Alice, 1)` at `_epochTimestampEnd(1)`. At this exact timestamp, `_balanceOf` decays to 0. 6. Alice receives `0` user eligible rewards and gets nothing for her lock. **Remediation**: Do not evaluate the balance strictly at the end of the epoch. Because the lock held weight for the actual duration, querying `_balanceOf(account, _epochTimestampEnd(epoch) - epochLength)` (or calculating a time-weighted integral `cumulative_ve`) will properly respect the duration. To stick with a snapshot, snapshotting at `- 1` second from the epoch bounds prevents decaying to an absolute zero entirely. ### 2. Single point-in-time snapshot enables zero-duration JIT (Just-In-Time) reward extraction **Severity**: High **Location**: `src/protocol/emissions/TrustBonding.sol` -> `_userEligibleRewardsForEpoch()` **Root cause**: The protocol uses a single end-of-epoch snapshot `userBondedBalanceAtEpochEnd(account, epoch)` and `totalBondedBalanceAtEpochEnd(epoch)` to determine a user's proportional share of the emissions for the ENTIRE epoch. It does not integrate a time-weighted balance over the epoch's lifecycle (unlike Curve's gauge distributions which use `integrate_fraction`). **Impact**: An attacker can completely bypass the economic risk and opportunity cost of locking tokens during the epoch. They can monitor the mempool or simply wait until the very last block before `_epochTimestampEnd` triggers, deposit massive capital to create a lock, and instantly be entitled to the full proportional share of the *entire preceding epoch's* rewards. While their funds must remain locked for the minimum duration going forward, they have stolen a near-complete epoch's emissions from honest long-term lockers who held through the whole period. **Preconditions**: Permissionless. **PoC sketch**: 1. Epoch 1 lasts 7 days. Honest users create locks on Day 1. 2. At Day 6, 23 hours, 59 minutes, an attacker executes `create_lock()` with a massive amount of TRUST, setting minimum unlock time. 3. Because evaluating `userBondedBalanceAtEpochEnd` strictly reads the balance exactly at Day 7, the attacker commands an overwhelming percentage of `totalBalance`. 4. In Epoch 2, the attacker immediately claims the majority of Epoch 1's rewards despite only locking capital for 1 block of active epoch duration. **Remediation**: Transition strictly to a time-weighted balance integration (like standard Curve `Gauge` metrics checking `user_point_history`) or issue block-by-block checkpoints mapped to epoch rewards, so that eligible rewards are directly proportional to the amount *and* duration of time spent holding in the epoch. ## Slither False Positives - **`divide-before-multiply` in `VotingEscrow._checkpoint` and `_create_lock`**: False positive. Standard Curve veCRV rounding algorithm. It intentionally leverages floor division to align timestamps perfectly with fixed `WEEK` / `_epochLength` boundaries (e.g., `(t / WEEK) * WEEK`). - **`timestamp` comparisons & dangerous strict equalities**: False positive. Timestamps are structurally necessary to compute linear rate decay accurately inside ve-systems, and `block.timestamp == t` indices track discrete boundaries. - **`erc20-interface` warning on `ISatelliteEmissionsController.transfer(address,uint256)`**: False positive. Slither expects `boolean` return value for ERC-20 `transfer()`. The Satellite Emissions Controller is not a strict ERC-20 but rather a protocol treasury contract with a specialized method signature. ## High-confidence observations - **Safe Zero-Utilization Bounds:** System utilization logic inherently protects against catastrophic division-by-zero during empty/idle epochs; `_getSystemUtilizationRatio` successfully intercepts instances where `rawUtilizationDelta == 0` prior to parsing a `target == 0`. - **Claim Stability via Time Windows:** `_getSystemUtilizationRatio(N)` cleanly evaluates `totalClaimedRewardsForEpoch[N-1]`. Because rewards for `N` can only be calculated and paid out in `N+1` (where claims for `N-1` are structurally locked out), the parameters affecting `userRewards` remain immutable at payout time, preventing APY drift vulnerabilities during the claim process. --- ## src/protocol/curves/OffsetProgressiveCurve.sol ### Claude Opus 4.7 ## Findings **No high-confidence, specific exploitable vulnerabilities were found in `OffsetProgressiveCurve.sol` on this review.** The rounding directions in the four user-facing quotes all favor the protocol: | Function | Quantity rounded | Direction | Effect | |---|---|---|---| | `_convertToShares` (deposit) | `sqrt(s² + assets/halfSlope) − s` | down (plain `div`, plain `sqrt`) | fewer shares minted | | `previewMint` (shares→assets) | `(sNext² − s²)·halfSlope` | up (`squareUp`, `mulUp`) | more assets charged | | `previewWithdraw` (assets→shares) | `s − sqrt(s² − assets/halfSlope)` | up (`divUp` on deduct, `sqrt` down) | more shares burned | | `_convertToAssets` (redeem) | `(s² − sNext²)·halfSlope` | down (`square`, `mul`) | fewer assets returned | Without the `PCMath` library source and `BaseCurve` bounds checks in scope, I cannot verify boundary invariants (e.g., whether `squareUp(sNext)` can overflow when `sNext` is near `sqrt(uMAX/uUNIT)` in `previewMint`, or whether `MAX_SHARES` truly precludes overflow after the `add(s, shares)`). That is the main residual risk area, but the checks in `_checkMintBounds`/`_checkCurveDomains` appear to intend to prevent it. ### Things checked and considered clean - `slope18 % 2 == 0` guard makes `HALF_SLOPE = slope/2` exact, so `2·HALF_SLOPE = SLOPE` everywhere (no off-by-one price drift). - `OFFSET` shift is applied symmetrically to both sides of every area computation (`s² − sNext²` cancels offset-only contributions), so introducing `OFFSET` does not create an extraction inconsistency between deposit/mint vs withdraw/redeem. - `MAX_SHARES = sqrt(uMAX/uUNIT) − OFFSET` ensures `(s+OFFSET)²` stays within `uMAX_UD60x18` domain for `square` if `square` is the `mulWad`-style squaring. - `initializer` modifier prevents re-init; constructor disables initializers on the implementation. - `currentPrice` returns `(totalShares + OFFSET)·SLOPE` (wad mul) — consistent with derivative of the integrated area. - `previewWithdraw` uses `divUp` for `deduct`, which is the correct direction to make `inner` smaller and thus shares-burned larger. ### Things NOT verifiable from this file alone (flag to co-auditor) 1. `PCMath.square` vs `PCMath.squareUp` vs `PCMath.mulUp` / `divUp` semantics — specifically whether `square` is non-wad (raw `s*s`) or wad (`mulWad(s,s)`). The math only stays dimensionally consistent if `square` is raw (no /1e18) and `mul(·, HALF_SLOPE)` does wad-scale. If `PCMath.square` quietly wad-scales but `PCMath.squareUp` does not (or vice-versa), `previewMint` vs `_convertToAssets` would diverge catastrophically. Worth diffing the library. 2. `_checkDepositBounds` / `_checkMintBounds` / `_checkWithdraw` / `_checkRedeem` in `BaseCurve` — whether they reject `shares == 0` / `assets == 0` inputs. If not, a dust deposit can round to 0 shares and `_checkDepositOut` should catch it; need to verify. 3. Whether `sub(s, sqrt(inner))` in `previewWithdraw` can underflow when `inner` rounding combined with `sqrt` rounding produces `sqrt(inner) > s`. Because `deduct` is rounded up and `inner = s² − deduct`, `inner ≤ s²` so `sqrt(inner) ≤ s` — this is safe. ✔ ## Slither False Positives - **incorrect-exp / incorrect-shift / divide-before-multiply / assembly in `FixedPointMathLib.*`** — All inside Solady's audited math library; the `^` and shift patterns are intentional bit-packed lookup tables and Newton iterations, not bugs. Not in scope and not reachable via OffsetProgressiveCurve in any way that weaponizes the pattern. - **pragma (4 different versions)** — OZ/PRB/Solady standard; benign. - **cyclomatic-complexity in `exp2`/`log10`** — PRB-Math internal; not called by this contract. - **dead-code in prb-math** — unused casting/helpers in a generic math library. - **assembly in `Initializable._getInitializableStorage`** — OZ-standard ERC-7201 storage slot access. ## High-confidence observations - Rounding direction is correct in all four conversion paths (table above). - `slope18 % 2 == 0` enforces exactness of `HALF_SLOPE·2 == SLOPE`. - OFFSET is symmetric in deposit↔withdraw math; `(s+OFFSET)² − (sNext+OFFSET)²` identity holds, so round-trip consistency is preserved relative to the non-offset curve. - `MAX_SHARES`/`MAX_ASSETS` are computed once at init from `uMAX_UD60x18/uUNIT` bound, and applied via `_checkCurveDomains` on every path. - `initializer` + `_disableInitializers()` in constructor is correct for UUPS-style implementation. - No external calls, no reentrancy surface; contract is pure math with storage reads. - `currentPrice` matches the instantaneous derivative of the assets integral (consistent with mint/redeem area formula). ### Gemini 3 Pro I have reviewed the `OffsetProgressiveCurve` smart contract. The contract logic elegantly scales the standard progressively sloped bonding curve ($P(x) = M \cdot x$) by applying a shares-axis offset ($P(x) = M \cdot (x + Offset)$) without introducing vulnerability paths or invariant drift. Appropriate rounding directions that favor the protocol over the user ensure continuous collateralization safety, and explicit maximum bounds effectively neuter precision-loss magnification. There are **0** high, medium, or structurally critical vulnerabilities in the analyzed contract. --- ### ## Slither False Positives The Slither trace identifies numerous issues under the `incorrect-exp`, `incorrect-shift`, and `divide-before-multiply` detectors. All of these occurrences originate from the standard **Solady `FixedPointMathLib`** implementation. 1. **Incorrect Exponentiation (`incorrect-exp`) & Incorrect Shift (`incorrect-shift`)** - **False Positive:** Slither flags the bitwise `^` operators in Solady as an attempted `**` exponentiation operator. In reality, Solady deliberately employs highly optimized bit-twiddling logic that naturally relies on bitwise XOR and raw shifts for operations inside `cbrt`, `lnWad`, `lambertW0Wad`, and `fullMulDiv`. 2. **Divide Before Multiply (`divide-before-multiply`)** - **False Positive:** Slither typically prefers implementations to scale up before dividing to prevent truncation cascading, but flags Solady's internal convergence logic. The Solady math engine carefully sequences its assembly operations across approximations where bounds checking and sequential division logic deliberately prevents overflow constraints during convergence loops. None of the provided tool output represents a vulnerability within the Intuition implementation. --- ### ## High-confidence observations * **Precision Accumulation & Rounding Orientation**: The developer uses mathematically accurate, ERC4626-aligned rounding logic using `UD60x18` (e.g. `.mulUp` vs exact evaluation vs truncation). Mints/Deposits systematically ceiling the required incoming collateral (`squareUp`) and floor the outgoing issued shares. Conversely, Burns/Redeems ceiling the shares needed and floor the distributed assets (`square` rather than `squareUp`), fully mitigating microscopic pool depletion attacks. * **Initialization Constraints limit Overflow Risks**: The mathematical upper bound `uMAX_UD60x18` natively limits unbounded curve growth. The explicit check `slope18 % 2 != 0` strictly blocks any chance of fractional truncation when creating `HALF_SLOPE`. * **Scale Normalization**: `wrap()` operations properly constrain variable mapping up consistently (from ERC20 nominal precision arrays directly to 18-decimal fixed-point equivalents) throughout the entire `_checkCurveDomains` operational space. Unwrapping retains identical units symmetrically. * **Negligible Dust-Level Math Discontinuity**: If an attacker attempts to process deposits for ultra-low precision fragments (e.g. `1 wei`) during a state where `totalShares` carries an irrational layout (e.g. ends in precisely `...01 wei`), rounding direction between `inner = \text{add}(PCMath.square(...), div(...))` and `sqrt(inner)` theoretically enforces a fractional underflow triggering a core `sub` revert natively. Because this only interrupts non-economic dust inputs (< 1e-15 cents of value flow), it inherently guarantees state safety as an operational "Low/Info". ---