## 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.