# Audit — SB reserve-currency system (`contracts/src/sb`)

Written as an independent, hostile auditor: no trust extended to this
codebase's own comments, doc-strings, or `SB_SPEC.md`'s own claims about
what is and isn't possible — every property below was proven or refuted
with a real fork test, not accepted because a comment said so. Scope:
`contracts/src/sb/Staking.sol`, `Redemption.sol`, `Treasury.sol`,
`LiquidityLocker.sol`, `FeeProcessor.sol`, `SBLauncher.sol`,
`SBToken.sol`, per `SB_SPEC.md` §4's own attack checklist (used as a
starting point, not a ceiling — several findings below go beyond what
that section names). Every test runs against Avalanche C-Chain mainnet
at `FORK_BLOCK=95834900` via `vm.createSelectFork`, matching the rest of
this repo's fork-test convention. Nothing was signed or broadcast. No
production code was changed — this is a findings-only pass; fixes are
for the team to apply and independently re-verify afterward, the same
way `AUDIT-2.md`'s F1-F4 were.

All new tests live under `contracts/test/audit-independent/scope3/`, in
seven files: `Scope3Redemption.t.sol`, `Scope3StakingRebase.t.sol`,
`Scope3TreasuryLocker.t.sol`, `Scope3FeeProcessorSandwich.t.sol`,
`Scope3FeeProcessorMaliciousToken.t.sol`, `Scope3SBLauncher.t.sol`,
`Scope3Immutability.t.sol`. None of them re-run or duplicate the
existing `contracts/test/sb/SBImmutability.t.sol` or other pre-existing
SB tests — every test here is a fresh, independently-conceived attempt to
break something. `forge build` succeeds cleanly; `forge test
--match-path "test/audit-independent/scope3/**/*"` → **44 passed, 0
failed, 0 skipped** (37 original + 7 new/fuzz tests added while fixing
Findings 1 and 2, see their "Fix" sections below).

## Summary

| # | Finding | Severity | Status |
|---|---|---|---|
| 1 | `Redemption`'s payout denominator (`Staking.totalShares`) excludes any staker whose warmup has fully matured by wall-clock time but who hasn't yet triggered a state-mutating call to materialize it — the first redeemer to act captures the treasury as if slower, equally-entitled stakers didn't exist | **High** | **Fixed** — see "Fix" below Finding 1 |
| 2 | `FeeProcessor.process()`'s `minOut` is derived from a live, same-transaction QuoterV2 quote with no manipulation-resistant floor on fresh/thin pools (the TWAP guard is skipped entirely below a cardinality threshold) — pre-existing sell pressure measurably starves Treasury of USDC | **Medium** | **Fixed** — see "Fix" below Finding 2 |
| 3 | A fast-moving newcomer captures their full pro-rata share of an already-accrued Treasury balance after only the mandatory 2-epoch (2-hour) warmup, having contributed zero trading volume | **Informational** | Accepted by design — documented (see below), now also surfaced in the site's REDEEM tab help text |

Everything else specifically checked (SB_SPEC.md §4's checklist, plus
several items beyond it) held up — see "Non-findings" below, each with
its own PoC.

---

## Finding 1 — Lazy warmup-claim denominator lets a first-mover redeemer capture other matured stakers' unclaimed share (High)

**Where**: `Staking.burnForRedemption` (`Staking.sol:170-186`), specifically
`totalActiveBeforeBurn = totalShares * index / WAD;` (line 178), read
immediately after `_claimMaturedWarmup(user)` claims **only the caller's
own** matured warmup (line 177) — combined with `Redemption.redeem`'s
`payout = usdcBalance * burned / totalActiveBefore` (`Redemption.sol:46`).

**What happens**: warmup **maturity** is purely time-based
(`block.timestamp >= warmupUnlockAt[user]`), but warmup **materialization**
into `Staking.totalShares` — the denominator every redeemer's payout is
computed against — is action-based and lazy: it only happens when that
specific address's own `stake`/`unstake`/`burnForRedemption`/`claimWarmup`
call runs `_claimMaturedWarmup` for them personally. Two stakers whose
warmup has equally, fully elapsed, but who have both simply not yet
interacted again, are both economically "staked outside warmup" per
`SB_SPEC.md` §3's own description ("redeem(amount) sur des sSB hors
warmup") — yet only the one who acts first gets counted. Whoever redeems
first divides the entire current Treasury balance by a denominator that
silently excludes every other matured-but-not-yet-materialized staker,
receiving a share vastly larger than their true economic proportion. The
stakers left behind aren't just diluted proportionally — they are
directly worse off, because the treasury balance they'll eventually
divide against (once they do materialize and redeem) has already been
drained by the first mover's oversized claim.

This requires no special access and no unusual precondition: it is
triggered by the ordinary, expected gap between "warmup timer elapses"
and "someone gets around to calling something." `claimWarmup(user)` is
permissionless and could in principle be called by a keeper bot the
instant every address's warmup matures to close this window — but no
such keeper exists by default in this system, and even with one, any
staker who redeems in the very same block their own warmup matures,
before a keeper reaches everyone else, still wins the race.

**PoC** (`contracts/test/audit-independent/scope3/Scope3Redemption.t.sol`,
`test_FINDING_firstRedeemerCapturesUnclaimedPeersShare_dueToLazyWarmupClaim`):
alice (90% of the eventual total stake) and bob (10%) both stake and both
fully mature (2 epochs elapse) — **neither explicitly claims**.
`staking.totalShares()` is confirmed still `0` at this point (sanity
check). Bob, the *smaller* staker, redeems first: his own `redeem()` call
auto-claims only his own warmup, so `totalActiveBeforeBurn` is 100% his
own 1,000,000 SB, not the true 10,000,000 SB total. Against a 1,000,000
USDC treasury, bob's true fair share (10%, net of the 5% team fee) is
95,000 USDC — he actually receives more than 5x that. Alice, redeeming
second, has her own shares materialize only then, too late: she ends up
with *less* than bob's fair share despite being 9x his size. Both
numbers are asserted directly, not just logged.

**Impact**: real, permissionless, systematic extraction of value from
slower stakers by faster ones, reachable by any two or more stakers whose
warmup matures around the same time — a routine occurrence, not an edge
case, since warmup is a fixed 2-hour duration from each stake and many
users will stake around similar times (e.g., following a public
announcement or a price dip). A staker (or a bot watching public
`warmupAmount`/`warmupUnlockAt`/`shares` state) can deliberately time
their own redemption to land the instant their warmup matures, ahead of
any other equally-matured-but-slower stakers, capturing a share of the
Treasury disproportionate to their real contribution. This directly
undermines the system's own stated design goal ("seuls les stakers ont un
droit sur le trésor," implying *proportionate* to actual stake) and
represents genuine, first-mover-driven fund extraction, not merely a
theoretical accounting quirk.

**Suggested direction (not applied — this pass is findings-only)**: the
denominator needs to represent every staker whose warmup has matured by
wall-clock time, not merely those who have already been claimed into
`totalShares`. This likely means either (a) tracking a
"matured-but-unclaimed" total continuously (incrementally maintained
rather than lazily materialized), or (b) forcing `_claimMaturedWarmup`
to run for *all* addresses with elapsed warmup before any redemption's
denominator is computed (impractical without an enumerable staker list),
or (c) redefining the payout formula so the denominator is provably
insensitive to claim ordering. Whatever the fix, it should be re-tested
against the exact scenario in `test_FINDING_...` above before being
considered closed.

### Fix (applied)

Direction taken: **(a)**, continuous/global tracking — matching the
owner's own explicit design brief for this fix, which independently
arrived at the same "(a)" shape and additionally pinned down exactly
*how* to make it O(1): `Staking.sol` now buckets every warmup deposit by
its **maturity epoch number**, not by staker. `warmupBucketAmount[E]`
holds the aggregate raw SB maturing at epoch `E` across every staker.
A single shared `_syncEpochs()` step — called before `stake`, `unstake`,
`burnForRedemption`, AND `rebase()`, so no entry point can ever act on a
stale denominator — walks every epoch boundary crossed since the last
sync and, for each one:

1. applies that epoch's own rebase growth to shares that were ALREADY
   active before it (never to the bucket about to mature at that same
   epoch — a deposit still nominally "in warmup" for the whole of that
   epoch must not retroactively earn its growth);
2. records the resulting index as `epochIndex[E]`;
3. folds `warmupBucketAmount[E]` into the GLOBAL `totalShares` at that
   exact `epochIndex[E]`, in O(1), regardless of whether any of the
   depositing stakers have personally interacted since.

`_claimMaturedWarmup(user)` still exists, but its role changed
completely: it no longer touches `totalShares` at all (that already
happened globally, at the epoch boundary, via `_syncEpochs`) — it only
moves that user's own already-globally-counted share of the bucket into
their personal `shares[user]` bookkeeping, using the same frozen
`epochIndex[E]` the bucket itself was priced at. Claim order can
therefore no longer change any redemption's payout, closing the exact
gap this finding described. The bounded catch-up loop is capped at
`MAX_EPOCHS_PER_CALL` (168, one week) per call, so a long-neglected sync
still completes (in several transactions if needed) without ever
reverting from gas exhaustion — re-tested directly on an ~2-year gap.

**One place this implementation is more precise than the brief's own
phrasing, and why**: the brief asks that "somme des parts de tous les
utilisateurs == total des parts" after sync. Exact equality is not
generally achievable without fragile, cross-user remainder bookkeeping:
each user's own claim independently floor-divides their raw SB amount by
`epochIndex[E]`, and floor is *subadditive* —
`floor(a/c) + floor(b/c) <= floor((a+b)/c)` for any split of one bucket
across multiple users. So the sum of individually-claimed shares can be
a few wei *under* the bucket-level shares credited to `totalShares`, but
provably never over. This is the same direction as requirement, just
using `<=` (never mints value, dust always sits in the treasury's favor)
rather than a brittle `==` — tested directly as an invariant (see below)
rather than assumed.

**`unstake` during warmup** (the brief's explicit "no gain, but also no
block" requirement): now draws from the still-pending, not-yet-matured
warmup deposit at exactly 1:1 once active shares run out, instead of
reverting — a stake immediately followed by an unstake within the same
warmup window is a strict no-op, never a gain.

**Tests added** (all in
`contracts/test/audit-independent/scope3/Scope3Redemption.t.sol` unless
noted):

- `test_REGRESSION_redeemOrderIndependent_noFirstMoverCapture` — the
  original PoC (alice 90% / bob 10%), rewritten as a non-regression test:
  proves `totalShares` is already fully populated (exactly 10,000,000e18)
  immediately after sync, before either staker personally claims, and
  that redeeming in either order (bob-first or alice-first) yields
  identical payouts (order-independence), each within ~1% of their true
  pro-rata share.
- `testFuzz_redeemOrderIndependent_NStakers` — fuzz, N=2..20 matured
  stakers, random amounts and random stagger between stakes (so maturity
  epochs differ across stakers): every staker's payout is identical
  (wei-level rounding dust only) whether redemptions happen in staking
  order or reverse order.
- `testFuzz_invariant_sumOfClaimedSharesNeverExceedsTotalShares` — the
  `<=` invariant above, fuzzed over N=2..20 stakers.
- `testFuzz_invariant_sumOfAllPayoutsNeverExceedsTreasury` — sum of every
  staker's actual USDC payout never exceeds the funded treasury balance,
  fuzzed over N=2..20 stakers.
- `testFuzz_roundingAlwaysFavorsTreasury_neverTheRedeemer` — splitting one
  redemption into 2..37 smaller ones can never extract strictly more
  total USDC than a single equivalent redemption (fuzzed split count);
  this also caught and fixed a genuine, separate rounding leak in
  `Redemption.redeem` (see below).
- `test_unstake_duringWarmupDrawsFromPendingAt1to1NoGain` and
  `test_unstake_revertsIfExceedsActivePlusPending` /
  `test_unstake_revertsWithNothingAtAll` (`test/sb/SBStakingLifecycle.t.sol`)
  — the no-gain/no-block unstake-during-warmup requirement.

**Bonus fix caught by the new fuzz suite, not in the original brief**:
`Redemption.redeem`'s team-fee split computed `teamCut = payout * 500 /
10000` (floor) and `usdcToUser = payout - teamCut`, which means
`usdcToUser` was effectively **rounded UP** whenever `payout` wasn't an
exact multiple of 200 — a genuine, if wei-scale, rounding-in-favor-of-
the-redeemer bug, directly violating "les arrondis vont toujours en
faveur du trésor, jamais du redeemer." Fixed by floor-dividing
`usdcToUser` directly (`payout * 9500 / 10000`) and giving the team side
the exact remainder (`teamCut = payout - usdcToUser`) — this also
guarantees `usdcToUser + teamCut == payout` exactly, so no dust is ever
lost either.

---

## Finding 2 — `FeeProcessor.process()` has no manipulation-resistant price floor on fresh/thin pools (Medium)

**Where**: `FeeProcessor.process` (`FeeProcessor.sol:87-123`), specifically
the `minOut` derivation (`IQuoterV2.quoteExactInputSingle`/`quoteExactInput`,
lines 104-107 / 112-114) and `_checkTwapIfAvailable` (lines 125-141).

**What happens**: `process(token)`'s slippage floor comes from a
QuoterV2 call executed **inside the same transaction** as the swap it's
meant to protect — not a pre-committed price, not an oracle anchored to
history. The only defense against a manipulated spot price is the TWAP-
deviation check, and that check is **skipped entirely, not conservatively
failed-closed**, whenever the first-hop pool's observation history is too
short to answer (`try ... catch { }`, silently proceeding). This is
precisely the state of a freshly-launched, thin launchpad pool — exactly
the kind of token `FeeProcessor` is designed to process (`SB_SPEC.md`
§3: "memecoins," i.e. every token ever launched on the platform,
including the newest and least liquid ones). An attacker who sells into
a pool immediately before `process()` executes moves the price the
*same* on-chain quote call will then read as its baseline — the "2%
slippage off the live quote" protects against further movement during
execution, but not against manipulation that already happened before the
quote was taken.

**PoC** (`contracts/test/audit-independent/scope3/Scope3FeeProcessorSandwich.t.sol`,
`test_sandwichProcess_measuresTreasuryShortfallAndAttackerProfit`):
launched a real token via `StocksBankLaunchFactory` at the same $10k-FDV
policy used elsewhere in this repo, generated genuine buy volume (a
brand-new curve position is single-sided and has no liquidity beneath the
start price, so a sell is only meaningful after some real trading has
happened), then gave `FeeProcessor` a realistic accumulated balance (5%
of total supply, standing in for collected trading fees). Compared, from
an identical snapshot: (a) an unmanipulated baseline `process()` call
versus (b) one preceded by a large front-run sell (10% of supply) and
followed by a back-run buy-back spending the exact WAVAX proceeds.
**Measured result**: Treasury received strictly less USDC in the
manipulated case than the baseline (asserted directly, not just logged);
across representative runs the shortfall was on the order of 100+ USDC
out of a ~150 USDC baseline delivery (roughly 79% reduction) at the
tested position size. The test deliberately reports — rather than
assumes — whether the round-trip was net-profitable for the sandwicher
in *token* terms (the correct metric for a sell-then-buy-back, since the
WAVAX leg nets to ~0 by construction): at the specific size tested here,
the naive symmetric round-trip was **not** shown to be profitable for the
attacker (a net token loss was measured), so this finding is proven as
**real, quantifiable damage to Treasury's proceeds**, not (yet) as a
proven-profitable clean sandwich at this exact size. A more
sophisticated or differently-sized attack, organic sell pressure with no
attacker profit motive at all (pure value destruction), or an attacker
hedged elsewhere (e.g. short the token on another venue, or simply
willing to eat a small token-side loss for a larger USDC-side gain
elsewhere) remain untested but plausible extensions of the same root
cause.

**Impact**: any address can unilaterally reduce how much USDC actually
reaches SB's Treasury from a given `process()` call, for every fresh or
low-liquidity token the platform ever processes fees for — which, by the
system's own design, includes every newly-launched token during exactly
the period its pool is too new to have TWAP history (the only condition
under which the guard is meant to help but doesn't). This is a real gap
in Treasury's revenue integrity, independent of whether a specific
attacker configuration is provably profitable for the attacker
themselves — the damage lands on Treasury (and therefore, downstream, on
every staker's redemption value) regardless of the attacker's own P&L.

**Suggested direction (not applied)**: fail closed rather than open when
TWAP history is unavailable (revert instead of silently skipping the
check), and/or require a minimum observation cardinality before a pool is
eligible for `process()` at all, and/or accept an off-chain-computed
`minOut` parameter (signed/attested some other way) rather than deriving
the floor from a same-transaction on-chain quote.

**Also verified** (same file): `process()` can never redirect its USDC
output away from the immutable `treasury` address regardless of caller
(`test_process_alwaysPaysImmutableTreasury_neverCaller`), and processing
one token never touches a different token's balance held by the same
contract (`test_process_doesNotTouchUnrelatedTokenBalances`) — no drain
vector beyond the sandwich above was found.

### Fix (applied)

Direction taken: fail-closed, matching the owner's exact brief.
`process()` now compares the QuoterV2-quoted output's implied price
against the first hop pool's own `TWAP_WINDOW`-second (30 minutes) TWAP,
and reverts `TwapDeviationTooHigh` if spot has drifted more than
`MAX_TICK_DEVIATION` (~3%, 300 ticks) from it — no conversion ever
executes without that reference price agreeing with spot.

`TWAP_WINDOW` was set to exactly 30 minutes so the SAME `observe()` call
serves as both the deviation check AND the "does this pool even have
enough history" gate the brief separately asked for ("moins de 30
minutes" of history → don't sell): if `observe()` reverts (too little
retained history for that lookback), `process()` requests more
observation-cardinality buffer on the pool via
`increaseObservationCardinalityNext` — permissionless, no factory change,
exactly as specified — and returns `0` **without selling and without
reverting**. This detail matters and is NOT what a first draft of this
fix did: reverting after the cardinality-bump call would have rolled
that bump back too (a revert undoes every state change made earlier in
the same call), permanently defeating the "pool accumulates history for
next time" mechanism the brief asked for. So this path succeeds (leaves
the tokens untouched, emits `SkippedInsufficientTwapHistory`) rather than
reverting — only genuine spot/TWAP disagreement (an already-history-
capable pool showing signs of manipulation) is a hard revert, since
there's no cardinality side effect worth preserving there. The existing
25%-per-call sell cap is unchanged. Fees stuck in a memecoin whose pool
never accumulates enough real, un-manipulated volume to answer this are
an accepted, documented cost — the pool simply never gets processed, and
that is by design, not an oversight.

**Also caught and fixed while wiring this up, not in the original
finding**: `process()`'s pool lookup for the direct `token == WAVAX` path
queried `factory.getPool(WAVAX, WAVAX, WAVAX_USDC_FEE)` — a self-pair
that can never exist and always resolved to `address(0)`. Under the OLD
silent-skip behavior this was harmless (the check was skipped and the
sale proceeded against the real WAVAX/USDC price via QuoterV2 anyway).
Under the new fail-closed gate it would have permanently blocked every
single WAVAX sale (`address(0)` always reads as "no reliable price").
Fixed to correctly resolve the real WAVAX/USDC pool for that path.

**Tests added** (`Scope3FeeProcessorSandwich.t.sol` unless noted), all
four scenarios required by the brief:

- `test_sandwichByProcessCaller_reverts` — sandwich performed by
  `process()`'s own caller (front-run dump, then call `process`
  themselves) → reverts `TwapDeviationTooHigh`, treasury untouched.
- `test_poolManipulatedSameBlock_byThirdParty_reverts` — pool manipulated
  by a different address than the one who then calls `process` in the
  same block → still reverts (the guard is state-based, not caller-based).
- `test_poolTooYoung_doesNotSell_butPersistsCardinalityBump` — freshly
  launched pool (default 1-slot cardinality): `process()` succeeds,
  sells nothing (`usdcOut == 0`), FeeProcessor's balance is untouched,
  AND the cardinality-buffer bump genuinely persists (checked via
  `slot0()` before/after) since the call did not revert; a later call
  after the pool accumulates real history then succeeds.
- `test_multiHopPath_onlyFirstHopManipulated_stillReverts` — two-hop path
  (`token -> WAVAX -> USDC`) with only the first (checked, thin) hop
  manipulated, the second (deep, real WAVAX/USDC) hop untouched → still
  reverts, proving the first-hop check alone is sufficient.
- `test_process_alwaysPaysImmutableTreasury_neverCaller` /
  `test_process_doesNotTouchUnrelatedTokenBalances` — re-verified against
  a properly TWAP-matured pool (previously these accidentally exercised
  the old silent-skip path on a fresh pool, not the swap logic they
  claim to test).
- `Scope3FeeProcessorMaliciousToken.t.sol`'s three tests (fee-on-transfer,
  reentrant, false-return tokens) updated to mature their pool's TWAP
  history first, so they exercise the actual malicious-transfer path
  rather than short-circuiting at the new history gate.
- `test/sb/SBFeeFlow.t.sol` — unchanged assertions, all still pass (the
  real, deep mainnet WAVAX/USDC pool has ample TWAP history already).

---

## Informational — quantified opportunistic-stake capture (by design, not a defect)

**Where**: `Staking`'s warmup mechanism (`WARMUP_EPOCHS = 2`,
`EPOCH_DURATION = 1 hours` — a 2-hour window) combined with
`Redemption`'s pro-rata-on-current-balance payout formula.

**What happens**: because backing is claimed pro-rata against whatever
USDC currently sits in Treasury, with no vesting or time-weighting beyond
the flat 2-hour warmup, a staker who joins immediately before a large
USDC inflow and redeems the moment their warmup matures captures close to
their full pro-rata share of that inflow, having contributed nothing to
earning it (no trading volume, no time-at-risk beyond two hours). This is
the same permissionless-backing-claim property every OHM-style reserve
currency has by construction — not unique to this implementation, and
explicitly acknowledged as intentional in this codebase's own design
language (stakers, not liquid holders, have a claim on the treasury; the
warmup's only stated job is to delay, not prevent, that claim).

**PoC** (`Scope3Redemption.t.sol::test_opportunisticStakeBeforeFeeInflow_quantifyCapturedShare`):
an established staker (alice, already active) is joined by a newcomer
staking exactly 10% of the total, immediately before a 1,000,000 USDC
Treasury inflow; the newcomer redeems the moment their 2-epoch warmup
matures and captures ≈9.5% of the raw inflow (their fair ~10% share, net
of the 5% team fee, within a 10% relative tolerance that accounts for
alice's own compounding via `rebase()` during the newcomer's warmup
window) — quantitatively confirming the 2-hour warmup delays, but does
not prevent, fast-mover capture of unearned backing.

**Assessment**: not a code defect — this is a tokenomics/parameter
question (is 2 hours long enough?), separate from Finding 1 above, which
*is* a code defect (it lets a fast mover capture *more than* their fair
share, not merely their fair share early).

**Disposition: accepted by design, now documented.** `SB_SPEC.md` §7 was
updated with a mandatory additional sentence under the site's REDEEM tab
help text: "The redeem value per sSB goes down when new stakers reach
maturity, and goes up when protocol fees come in." — so a redeemer sees
this property surfaced directly at the point of decision, rather than
only in this audit document. No code change: the underlying economic
behavior is exactly what `SB_SPEC.md` §1/§3 already describes ("seuls les
stakers ont un droit sur le trésor... au prorata").

---

## Non-findings (checked, held up — PoC for each)

### Redemption

| Target checked | Result | Test |
|---|---|---|
| Reentrancy into `redeem`/`stake` during the USDC payout callback | Not reachable — real USDC has no transfer hooks (`receive()` never fires); `nonReentrant` on both `Redemption.redeem` and `Staking.burnForRedemption` would block same-function reentry regardless | `test_redeem_reentrancy_notExploitable` |
| Rounding across many small redemptions vs. one large redemption, same account | Algebraically, `usdcBalance/totalActive` is invariant across sequential same-account redemptions with no interleaving — splitting can only ever lose MORE to floor-rounding, never gain; proven empirically, gap bounded to single-digit wei over 10 splits | `test_manySmallRedemptions_neverExceedOneLargeRedemption_sameAccount` |
| Interleaved redemptions by *different*, already-claimed stakers | Pro-rata share tracks true stake ratio regardless of redemption order, once both are properly materialized into `totalShares` (see Finding 1 for what happens when they are NOT) | `test_interleavedRedemptions_preserveProRataInvariant` |
| Warmup bypass: redeem immediately after staking | Reverts (`InsufficientBalance`) | `test_warmupBypass_immediateRedeemAfterStake_reverts` |
| Warmup bypass: redeem one second before the 2-epoch window elapses | Still reverts — no off-by-one | `test_warmupBypass_oneSecondEarly_reverts` |

### Staking

| Target checked | Result | Test |
|---|---|---|
| Rebase-sniping via a stake placed just before a rebase | Not possible — warmup funds stay outside `totalShares` until claimed, and claiming converts flat SB into shares using the index AT CLAIM time (post-rebase), never crediting rebases that occurred while pending | `test_pendingWarmupDeposit_doesNotAlterActiveStakerCompounding`, `test_maturedWarmupBalance_hasNoRetroactiveRebaseCredit` |
| Index drift / precision loss over many epochs | No overflow, no degeneracy, stable behavior through exhaustion, across a multi-thousand-epoch run | `test_indexDrift_manyEpochs_noOverflowNoDegeneracy_stableAfterExhaustion` |
| Chunked `rebase()` calls (many small) vs. fewer large calls covering the same elapsed epochs | Produce identical final `index` — no exploitable advantage from call timing/frequency | `test_chunkingInvariance_manySmallRebasesEqualFewLargeRebases` |
| Reserve exhaustion at the exact epoch boundary | `emissionsEnded` latches correctly; the final partial-epoch payout is distributed uniformly per share, no one staker can grab more than their share by timing around the boundary | `test_exhaustionEpoch_finalPartialPayout_isUniformPerShare` |
| `LiquidityLocker.collectFees()` reserve top-up gamed by a flash-timed stake | Reserve inflows benefit all active stakers proportionally regardless of timing — no flash-snipe advantage found | `test_collectFeesTopUp_cannotBeFlashSniped` |
| Griefing `rebase()` — reverting it for others, wasting others' gas, long-neglect catch-up | No revert path any address can trigger against another caller; a ~2-year-idle first call completes without gas exhaustion thanks to `MAX_EPOCHS_PER_CALL` chunking, needing multiple calls but never reverting; the caller receives no reward/advantage from calling, so there is no "favorable moment" to race for | `test_rebase_longNeglectCatchUp_noGasExhaustion_noCallerAdvantage`, `test_rebase_noInputSurface_cannotBeGriefedByAnyCaller` |

### Treasury

| Target checked | Result | Test |
|---|---|---|
| `payOut` callable by anyone but the wired `redemption` address | Reverts for every other caller | `test_treasury_payOut_onlyRedemptionCanCall` |
| `wireRedemption` called a second time (even by `launcher` itself) | Reverts `AlreadyWired` | `test_treasury_wireRedemption_cannotBeCalledTwice` |
| Treasury holding an arbitrary/unrelated ERC20 it wasn't meant to | Holds it (inflows are unrestricted by design), but has no function of any kind capable of moving it out — the only outflow selector at all is `payOut`, gated to `redemption` | `test_treasury_holdsArbitraryTokens_butHasNoWayToMoveThem` |

### LiquidityLocker

| Target checked | Result | Test |
|---|---|---|
| Direct `decreaseLiquidity` on the locker's NFT position, called by an attacker | Reverts — Uniswap's own NFT-ownership check rejects it; the locker never `approve`s any operator | `test_locker_directDecreaseLiquidity_revertsForAttacker` |
| Direct `burn` of the locker's NFT position, called by an attacker | Reverts, same reason | `test_locker_directBurn_revertsForAttacker` |
| Any liquidity-removal selector reachable through `LiquidityLocker` itself | None exists — only `collect()` is ever called on the position manager | `test_locker_hasNoLiquidityRemovalFunction` |
| `collectFees()` called by an attacker (it's permissionless) | Still pays only the hardcoded `staking`/`feeProcessor` destinations, never the caller | `test_locker_collectFees_calledByAttacker_stillPaysHardcodedDestinations` |

### FeeProcessor — malicious token behaviors

| Target checked | Result | Test |
|---|---|---|
| Fee-on-transfer token passed as `token` | Reverts cleanly rather than mispricing (Uniswap V3 core's balance-delta check on exact-input rejects an under-delivered `transferFrom`) | `test_feeOnTransferToken_revertsCleanly_doesNotMisprice` |
| Reentrant token (malicious `transfer`/`transferFrom` hook) | Reentry into `process()` itself is blocked (`nonReentrant`); reentry into a *different* SB contract (`Staking.stake`) succeeds as a call but achieves nothing exploitable — harmless | `test_reentrantToken_reentryIntoStaking_isHarmless` |
| Token whose `transfer`/`transferFrom` returns `false` instead of reverting | Reverts cleanly | `test_falseReturnToken_revertsCleanly` |

### SBLauncher

| Target checked | Result | Test |
|---|---|---|
| Front-running the atomic constructor's pool creation (pre-computing `sbToken`'s deterministic future address and pre-griefing its WAVAX pool at a hostile price before `SBLauncher` deploys) | Constructor reverts cleanly with `BadStartPrice`; confirmed FULLY atomic — zero code exists at every predicted internal-CREATE address after the revert, no partial state, no orphaned contracts; a genuine redeploy at a fresh nonce/address succeeds normally afterward | `test_frontRun_preGriefedPool_revertsCleanly_andIsFullyAtomic` |
| Residual power after a successful deployment, even under direct impersonation of the launcher's own address | `vm.prank(address(launcher))` attempting `wireRedemption`/`wirePositions`/`initializeAntiSnipe` again all revert `AlreadyWired` — the one-time guards hold as a second line of defense independent of `SBLauncher` having no code path to call them again | `test_noResidualPower_evenImpersonatingLauncherDirectly` |
| `SBLauncher` exposing any callable function post-deploy beyond auto-generated `public immutable` getters | None found | `test_sbLauncher_hasNoCallableFunctionsPostDeploy` |

### Immutability — fresh, independent enumeration (not a re-run of the existing suite)

Every contract's full external/public interface was independently
re-enumerated from source (not from the existing `SBImmutability.t.sol`)
and probed directly for any owner/admin/pause/sweep/redirect selector:

| Contract | Result | Test |
|---|---|---|
| `SBToken` | No mint function beyond the one-time constructor mint, no owner, no pause | `test_sbToken_noMintNoOwnerNoPause` |
| `Staking` | No owner, no parameter (`RATE_WAD`/`EPOCH_DURATION`/etc.) is settable by anyone, ever | `test_staking_noOwnerNoParameterChange` |
| `Treasury` | No owner/admin selector beyond the one-time `wireRedemption` | `test_treasury_noOwnerNoAdminSelector` |
| `Redemption` | No setters of any kind — every field is `immutable` | `test_redemption_noSetters` |
| `LiquidityLocker` | No owner, no fee-redirection selector | `test_liquidityLocker_noOwnerNoRedirect` |
| `FeeProcessor` | No owner, no redirection selector | `test_feeProcessor_noOwnerNoRedirect` |

**Conclusion**: independently reached the same top-line conclusion as
this codebase's own claim — there is no owner, admin, pause, or upgrade
role anywhere in the SB system once `SBLauncher`'s constructor returns —
but arrived at it via fresh probing of every contract's interface, not
by trusting the claim or re-running the existing regression file.

---

## Re-test round 2 — a NEW zero-context sub-agent re-tested the Finding 1/2
fixes above and found two NEW High findings, both introduced by the fixes
themselves, plus one new Medium and two informational nits. Verified
directly against source before recording here (not taken on the
sub-agent's word). All five are now resolved — see below.

### NEW-1 (High, in the epoch-bucket design) — warmup/lock bypass after a long unsynced gap

**Where**: `Staking.stake()`'s old `newMaturityEpoch = currentEpoch +
WARMUP_EPOCHS`, where `currentEpoch` was a sync CURSOR capped at
`MAX_EPOCHS_PER_CALL` (168) per call, not wall-clock time.

**What happened**: after any gap longer than 168 epochs with nobody
calling anything, the cursor fell behind real time. A fresh deposit's
maturity epoch was computed against the lagging cursor and could already
be in the past relative to wall-clock time; a second call in the SAME
transaction then caught the cursor up past that point, maturing the
deposit with zero real elapsed time — fully bypassing the 2-epoch
warmup. PoC: after 200 idle epochs, a staker deposited then, in the same
transaction, redeemed ~95% of a test treasury with no wait at all.

**Resolution**: eliminated by design, not patched. The whole epoch-bucket
warmup/maturation system is gone — see the "DESIGN SIMPLIFIÉ" rewrite
below. There is no cursor left to lag; the flash-exit guard is now a
per-account REAL-CLOCK timestamp (`lastStakeTime[account] +
LOCK_DURATION`), which has nothing to desynchronize regardless of how
long a gap is. Regression tests:
`test_REGRESSION_NEW1_longIdleGap_thenFlashStakeRedeem_stillReverts` /
`..._thenFlashStakeUnstake_stillReverts` (400-epoch idle gap, then an
atomic flash stake+exit attempt — still reverts `Locked`, exactly as
with no gap at all), plus a positive control proving the gap itself is
never the problem (`test_longIdleGap_thenNormalExitAfterLockExpiry_succeeds`).

### NEW-2 (High, in the epoch-bucket design) — a stake in the exhaustion transaction could permanently brick the contract

**Where**: `Staking.stake()` called `SB.safeTransferFrom` BEFORE
`_syncEpochs()`, while the sync read `SB.balanceOf(address(this))` to
compute remaining reserve.

**What happened**: if the reserve-exhaustion epoch was crossed inside a
`stake()` call, the just-arrived deposit was already inside the read
balance but not yet inside `totalWarmupPending`, inflating the computed
"remaining reserve" by the deposit amount. This could skip latching
`emissionsEnded`, overpaying growth the reserve didn't actually have; the
next epoch-crossing call then underflowed on an arithmetic subtraction
and reverted permanently — no owner, no admin, no recovery path anywhere
in this system, so every staked SB and the entire Treasury claim would
have been locked forever. No attacker required, just unlucky timing.

**Resolution**: eliminated by design. Nothing in `Staking.sol`'s
accounting ever reads `SB.balanceOf(address(this))` anymore (a bare
transfer credits nothing — see design point 4 below); `rewardReserve` is
an explicit state variable, incremented only by `fundReserve()` and
decremented only as `_sync()` pays out. Every state-changing function
syncs BEFORE transferring any token (checks-effects-interactions,
design point 5), so an incoming deposit can never be miscounted as spare
reserve in the first place. Regression test:
`test_REGRESSION_NEW2_stakeInExhaustionEpoch_doesNotBrickContract` —
binary-locates the exact epoch a 70M-SB stake exhausts the reserve at,
replays with a second staker's deposit landing in that EXACT
epoch-crossing call, confirms `emissionsEnded` still latches correctly,
and confirms `rebase`/`stake`/`unstake`/`redeem` all remain callable
afterward.

### NEW-3 (Medium, in the Finding-2 fix) — the TWAP gate was blind on cardinality-1 pools

**Where**: `FeeProcessor._hasReliableTwap`'s use of `observe()`'s
try/catch as the "does this pool have enough history" signal.

**What happened**: on a pool at the default cardinality-1 (every
freshly-launched launchpad pool), once its single stored observation is
older than the requested lookback, Uniswap's `observe()` does NOT
revert — it extrapolates at the CURRENT tick, so the returned "30-minute
TWAP" is identical to spot by construction, `tickDiff == 0`, regardless
of how far the price has been moved. Since `observe()` never throws in
this case, the `catch` branch (which requested more cardinality) never
ran either — the pool stayed blind forever, and the deviation check
passed trivially no matter what.

**Resolution**: `_hasReliableTwap` no longer infers history sufficiency
from whether `observe()` reverts. It directly reads the pool's oldest
retained observation's age (`_oldestObservationSecondsAgo`, mirroring
Uniswap periphery's `OracleLibrary.getOldestObservationSecondsAgo`: the
ring-buffer slot right after the current write index, or index 0 if that
slot isn't initialized yet) and only proceeds to the actual TWAP-vs-spot
comparison once that age is genuinely `>= TWAP_WINDOW` (30 minutes).
Tolerances also split as specified: 3% between major, deep assets
(WAVAX/USDC), 8% on a launchpad token's own thinner pool against its
quote — both looser-tolerance and tighter-history-check working
together, not either alone.

**Measured worst case** (`test_MEASURE_maxSandwichDamageUnder8PercentLaunchpadTolerance`,
`Scope3FeeProcessorSandwich.t.sol`): binary-searched the largest
same-block front-run that still clears the 8% launchpad gate, on a
representative launch (5% of supply held as fees, 25%-per-call sell cap
applied). Baseline `process()` delivers 147.957 USDC to Treasury
unmanipulated; the maximum-tolerated manipulation still clearing the
gate delivers 137.040 USDC — a **737 bps (7.37%) shortfall**, i.e. moving
spot right up to (but not over) the 8% tolerance costs Treasury slightly
less than 8% of that call's proceeds, as expected from the tolerance
design. This is the accepted, quantified cost of the wider
launchpad-token tolerance (a launchpad pool's own TWAP is inherently
thinner than a major-asset pair's, so a tighter tolerance there would
mean far more legitimate `process()` calls failing on ordinary price
noise, not just manipulation).

### NEW-4 (Informational, in the Finding-2 fix) — wrong `slot0()` field read in the cardinality-bump branch

**Where**: the old `_hasReliableTwap`'s catch branch read `slot0()`'s
index 3 (`observationCardinality`, the actual current count) instead of
index 4 (`observationCardinalityNext`, the already-requested target).

**Impact**: no fund-relevant effect — just redundant
`increaseObservationCardinalityNext` calls (a cheap no-op once already
requested) on every subsequent skipped `process()` call until actual
cardinality caught up to the target.

**Resolution**: fixed as a natural consequence of the NEW-3 rewrite —
the new `_hasReliableTwap` destructures `slot0()` once, correctly, into
named `observationCardinality`/`observationCardinalityNext` locals used
for their respective purposes (the oldest-observation lookup uses the
former, the cardinality-bump decision uses the latter).

### NEW-5 (Informational, in the epoch-bucket design) — `warmupBucketAmount[e]` never cleared after folding

**Where**: the old `_syncEpochs()` read but never zeroed a matured
epoch's bucket.

**Resolution**: moot — the entire bucket-mapping concept no longer
exists in the redesigned `Staking.sol`.

### Design simplification applied (NEW-1/NEW-2's actual fix)

Both new High findings were symptoms of the same root cause: an
epoch-bucket warmup system with its own cursor and its own
balance-derived reserve accounting was too complex for a contract with
no owner and no recovery path. Rather than patch each symptom, `Staking`
was rewritten around seven simplifying principles:

1. **No warmup, no epoch buckets, no maturation cursor.** A stake is
   active immediately — shares minted at the current (post-sync) index,
   right away. `totalShares` is therefore always exact and complete;
   there is no "matured but not yet materialized" state for a lazy claim
   to get wrong, because there is no lazy claim.
2. **A per-account, real-clock time lock instead.** `lastStakeTime
   [account]` is set on every `stake()`; both `unstake()` and redemption
   require `block.timestamp >= lastStakeTime[account] + LOCK_DURATION`
   (2h) — flash-stake and stake-then-immediately-exit protection, with
   nothing that can lag wall-clock time.
3. **sSB stays non-transferable** (it always was — no `transfer`/
   `approve` exists) — otherwise the per-account lock would be
   trivially bypassable by moving a position to a fresh address.
   `SB_SPEC.md` §7 now states this explicitly.
4. **Strict internal accounting.** `rewardReserve` and `totalPrincipal`
   are explicit state variables. `rewardReserve` only increases via
   `fundReserve()` (called once by `SBLauncher` at genesis, and by
   `LiquidityLocker` for SB-side trading fees) and only decreases as
   rebases pay out. Nothing reads `SB.balanceOf(address(this))` — a bare
   transfer credits nothing (proven by
   `test_directDonation_isInert_doesNotFundReserve`).
5. **Sync first, compute next, transfer last**, in every state-changing
   function — checks-effects-interactions throughout.
6. **Closed-form catch-up.** Between two syncs `totalShares` cannot
   itself change (nothing can mutate it without first calling `_sync`),
   so N missed epochs compound as one geometric step —
   `index_new = index_old * RATE_WAD^N` via exponentiation by squaring
   (`_compoundSaturating`), O(log N), no per-epoch loop, no per-call cap.
   If the reserve can't fund the full compounded growth, this saturates
   at exactly `currentActive + rewardReserve` (paying out everything
   left, latching `emissionsEnded` permanently) using a saturating
   multiply (`_mulDivCapped`) proven never to overflow regardless of how
   large N, the index, or the reserve are — the pre-multiplication
   overflow check (`b > type(uint256).max / a`) means the actual
   multiplication only ever executes once it's already known to be safe.
7. **No reachable state should make `rebase`/`stake`/`unstake`/`redeem`
   permanently revert.** Proved by a stateful Foundry invariant suite
   (`StakingInvariant.t.sol`): a handler randomly calls
   stake/unstake/redeem/rebase/fundReserve/a raw direct donation/time
   warps from 1 second to 1 year, across 6 actors; after every such
   sequence, `invariant_coreFunctionsRemainCallable` directly (not in a
   try/catch) exercises `rebase`, `fundReserve`, `stake`, and — once its
   own lock clears — `unstake`/`redeem` again, so a revert there fails
   the invariant itself. A companion invariant confirms every core state
   variable stays representable (no underflowed state to read back — the
   exact NEW-2 failure mode), and a third confirms the sum of every
   actor's balance never exceeds principal-in plus rewards distributed
   so far.

**Tests added for this round**
(`test/audit-independent/scope3/Scope3StakingRebase.t.sol`,
`Scope3Redemption.t.sol`, `Scope3FeeProcessorSandwich.t.sol`, and the new
`StakingInvariant.t.sol`): the two NEW-1/NEW-2 regressions above, the
original bob/alice PoC (kept, now trivially order-independent since
there's no claim step at all), order-independence fuzz (N=2–20 stakers),
a stake landing in the exact exhaustion epoch and in the epoch
immediately after, total inactivity of 1 day/1 week/1 year then normal
resumption, flash stake+redeem and stake+unstake (same transaction and
within the same hour) reverting, rounding always favoring the treasury
and the reserve, the sum-of-balances/sum-of-payouts invariants, and the
stateful invariant suite itself.

---

## Re-test round 3 — one new High found, resolved by removing an attacker's
degrees of freedom rather than patching the math

A THIRD zero-context sub-agent re-audited `Staking`, `Redemption`,
`FeeProcessor` and their interactions with `Treasury`/`LiquidityLocker`/
`SBLauncher` after round 2's redesign. Verdict: **NEW-1 (lock bypass) and
NEW-2 (reserve accounting corruption) are genuinely closed** — round 2's
redesign held on both. But it found **one new High**, in the same bug
family, plus two Medium and one Low in `FeeProcessor`. Verified directly
against source before recording here.

### Round-3 High — a tiny first staker could permanently brick `stake()`

**Where**: `Staking.stake()`'s `newShares = amount * WAD / index`
combined with `_sync`'s exhaustion branch, `index = cap * WAD /
totalShares`.

**What happened**: nothing stopped `totalShares` (equivalently, the
active principal backing it) from being driven down to something tiny —
a first staker depositing 1 wei of SB was enough. The 80M reserve stayed
full and kept compounding against that single share. Round 2's
closed-form catch-up (`_compoundSaturating`, exponentiation by squaring)
was built to jump through many epochs in O(log n) multiplications — but
unlike genuinely sequential per-epoch floor-rounding, where
`floor(1 * RATE_WAD / WAD) == 1` forever (a 1-wei principal can never
generate a nonzero reward, so it truly never grows), the exponentiated
form wrongly treated this as continuous compounding that eventually
reaches the reserve cap regardless of how small the starting point was.
After a bounded number of epochs (a single permissionless `rebase()`
call), the entire reserve could be attributed to that one tiny position,
pushing `index` to ~8e43. At that point `newShares` floored to zero for
every realistic stake, permanently — no owner, no recovery path.

**Round-2's own invariant suite could not have caught this by
construction**: its stake probe rescaled itself to the current `index`
("guarantees >= ~2e18 shares minted... regardless of how large index has
grown"), so it always found a workaround unavailable to a normal-sized
user, and its handler bounds (`[1e15, 2e6 ether]`) never reached the
dust regime where the bug actually lives.

### Fix (applied) — three cumulative protections, removing capability rather than adding a check

Per the owner's explicit direction: not a fourth patch to the math, but
removing the conditions that make the attack possible in the first
place.

1. **Reward is derived from SB actually payable, never from the rate in
   isolation.** Each epoch: `reward = min(totalPrincipal * RATE,
   rewardReserve)`, `index *= (totalPrincipal + reward) /
   totalPrincipal`, `totalPrincipal += reward`, `rewardReserve -=
   reward`. Before ever compounding forward, `_sync` checks whether a
   SINGLE epoch's reward at the CURRENT `totalPrincipal` would floor to
   zero — if so, growth is impossible for any `n` (principal only grows
   via reward, reward only grows via principal: a genuine fixed point at
   zero, not merely "slow"), so the closed-form "skip ahead" pathway
   that caused the round-3 bug never runs for a degenerate starting
   point. `totalPrincipal` replaces `totalShares * index / WAD`
   (`currentActive`) as the load-bearing quantity, maintained
   incrementally and exactly (not recomputed).
2. **A permanent genesis stake.** `SBLauncher` now calls
   `Staking.stakeGenesis(1,000 SB)` — carved out of what would otherwise
   be the full 80M reserve (funded with 79,999,000 instead) — minting
   ordinary shares to `Staking.GENESIS_STAKE_RECIPIENT`, the conventional
   `0x...dEaD` burn address. Nobody controls that address's private key,
   so this position can never be unstaked or redeemed by anyone, ever —
   exactly the principle Uniswap V2 uses permanently burning the first
   1,000 wei of LP tokens. This is removing an attacker's ability to
   ever be the sole/first staker at a trivial amount, not a compensating
   check bolted onto the math: combined with point 1, it bounds
   worst-case index inflation to a fixed `(1,000 + reserve) / 1,000 ≈
   80,000x` regardless of what any other staker does.
3. **Index precision in RAY (1e27), not WAD (1e18)**, with FLOOR
   rounding for shares minted (`stake`) and CEILING rounding for shares
   burned (`unstake`/`burnForRedemption`, via `Math.ceilDiv`) — extra
   headroom against the wider index range point 2 accepts as its worst
   case, and rounding that structurally favors the protocol/other
   stakers in both directions. Provably safe: for any `amount <=
   balanceOf(user)`, `ceil(amount * RAY / index) <= shares[user]`
   (ceiling of a real value already `<=` an integer is at most that
   integer) — so burning can never be shortchanged relative to what's
   paid out, and `totalPrincipal` is debited by the EXACT value removed
   (`sharesBurned * index / RAY`), keeping `totalPrincipal ==
   totalShares * index / RAY` exact rather than approximate.

**Also fixed**: two Medium findings and one Low in `FeeProcessor` this
same re-audit surfaced —
- The TWAP deviation check was mathematically vacuous on a cardinality-1
  pool once 30 minutes have passed: `observe()` extrapolates from the
  single slot at the current tick, so `tickDiff` is identically 0
  regardless of manipulation. (Round 2's history-age gate correctly
  blocks selling BEFORE 30 minutes elapse; it does not, on its own,
  protect the deviation check itself once that time has passed on a
  pool that never grew past cardinality 1.)
- A same-block manipulation that doesn't cross an integer tick writes no
  new observation and isn't reflected in the QuoterV2 price either
  (already anchored to the manipulated state) — measured at 67% shortfall
  in the re-audit's PoC.
- `_oldestObservationSecondsAgo`'s `% observationCardinality` had no
  zero guard, `Panic`ing instead of gracefully skipping a pool that was
  `createPool`'d but never `initialize()`d (`cardinality == 0`, `getPool()
  != address(0)`, so the `pool == address(0)` early-return misses it).

**Root cause, all three at once**: the round-2 fix checked whether the
pool's *oldest* retained observation was old enough (`>= 30 min`). That
answers "has this pool existed a while", not the actual question a TWAP
read needs answered — "is there a second, genuinely distinct reference
point to compare against spot". `observe()` extrapolates forward from the
*latest* observation at the current tick whenever the requested timestamp
is at or after it (never reverts for this); a `secondsAgos = [1800, 0]`
read only has two different endpoints when the *latest* stored
observation is younger than 1800s — if it's older (a quiet pool, or one
stuck at cardinality 1 after its very first trade), BOTH endpoints
extrapolate from that same single point and `avgTick == spotTick`
trivially, independent of any manipulation. This is what F-1 and F-2 both
actually were.

**Fix applied** (`FeeProcessor.sol`, `_hasReliableTwap`): gate on the
*latest* observation's freshness instead of the oldest one's age —
`block.timestamp - observations(observationIndex).timestamp < TWAP_WINDOW`
— then call `observe()` inside a `try/catch` as a second, independent
safety net (a fresh-but-shallow pool can still legitimately revert `OLD`
if `secondsAgos[0]` predates its retained history; that's caught the same
way as staleness — bump the cardinality buffer, sell nothing, no revert).
Needs no ring-buffer modulo arithmetic at all, since `observationIndex`
always points at an initialized slot once `observationCardinality >= 1`
— closes F-3 (the `% observationCardinality` Panic) as a structural
side effect, not a separate patch. F-2 (same-block, no-tick-crossing
manipulation) turned out to share F-1's exact root cause in the re-audit's
own PoC (`observationCardinality = 1` was explicitly logged there too) —
verified by hand that the fix's freshness gate closes both together, no
separate mechanism needed; see the regression test below.

**Tests added** (`Scope3StakingRebase.t.sol`, `Scope3Redemption.t.sol`,
`StakingInvariant.t.sol`, `Scope3FeeProcessorSandwich.t.sol`): the exact
round-3 PoC shape as a non-regression test (1 wei staked, 1 year of
inactivity, then a normal 1 SB stake receives correct shares and can
redeem), index bounded by `(principal + reserve) / principal` fuzzed
across a wide range of principal/reserve/elapsed-epoch values, `rebase()`
with zero stakers is a true no-op, the conservation invariant
(`sum(balanceOf) == totalPrincipal` up to per-user floor-rounding dust)
fuzzed over N stakers, a dedicated "tiny first staker" handler added to
the stateful invariant suite so it can no longer be structurally blind to
this exact scenario, and `test_REGRESSION_round3_idleCardinalityOnePool_refusesToSell_insteadOfTrivialPass`
(a cardinality-1 pool traded once then left idle 40 minutes, no
manipulation at all — `process()` must refuse to sell instead of the old
behavior of silently executing at whatever spot happened to be).

---

## Total

Round 1: 3 findings (1 High, 1 Medium, 1 Informational), all fixed/accepted.
Round 2 (independent re-test of round 1's fixes): 2 new High + 1 new
Medium + 2 informational, all introduced by round 1's own fixes and all
resolved by round 2's `Staking.sol` redesign (see "Re-test round 2"
above), not a mechanical patch.
Round 3 (independent re-test of round 2's fixes): confirmed NEW-1/NEW-2
genuinely closed, found 1 new High (index-inflation DoS) + 2 Medium + 1
Low in `FeeProcessor`, all now resolved — the High by removing an
attacker's degrees of freedom (a permanent genesis floor + reward
derived from actually-payable SB + finer index precision), not another
patch to the compounding math (see "Re-test round 3" above).

Full combined run, everything (`test/{launchpad,sb,audit-independent}
/**/*`, invariant suite excluded from this count since it runs
separately): **242 passed, 0 failed, 0 skipped**. Stateful invariant
suite (`StakingInvariant.t.sol`, proving no reachable state permanently
reverts `stake`/`unstake`/`redeem`/`rebase`/`fundReserve`): **3 passed, 0
failed** (12 runs × depth 15 each = 180 real calls per invariant, all
green). A brand-new, zero-context sub-agent is being dispatched to
independently re-audit `Staking`, `Redemption`, `FeeProcessor` and their
interactions with `Treasury`/`LiquidityLocker`/`SBLauncher` — verdict
recorded in `STATUS.md`.

---

## EIP-3860 initcode-size restructuring (2026-09-22) — `SBLauncher` exceeded the initcode size limit

Same discovery moment as the parallel `StocksBankLaunchFactory` fix (see AUDIT-2.md's "EIP-170/EIP-3860 size restructuring" section for the full shared context — this section covers only what's specific to `SBLauncher`/`StakingDeployer`). `forge build --sizes` showed `SBLauncher`'s INITCODE at **58,871 bytes**, 20% over the EIP-3860 49,152-byte limit — it would have reverted at real deployment. Unlike the factory, `SBLauncher`'s deployed RUNTIME size is tiny (2,289 bytes — after construction it only exposes view getters), so this was purely an initcode problem: its constructor does `new SBToken(...)`, `new Staking(...)`, `new Treasury(...)`, `new Redemption(...)`, `new FeeProcessor(...)`, `new LiquidityLocker(...)` for six sub-contracts, atomically, by design (anti-front-run — see the contract's own doc comment), and Solidity embeds each one's full creation bytecode into SBLauncher's own.

### Fix

Externalizing the math libraries (shared fix, see AUDIT-2.md) closed almost all of it on its own: `LaunchMath` (used by `SBLauncher` for the same curve derivation the launchpad uses, with SB's own 16M/4M split) going from `internal` to `public` took SBLauncher from 58,871 to 49,169 bytes of initcode — 17 bytes short of clearing the limit.

Rather than trim just enough to scrape by, `Staking`'s creation (its single biggest sub-contract, ~10.9 KB) was moved to a dedicated `StakingDeployer` (`src/sb/StakingDeployer.sol`), deployed **separately, before** `SBLauncher` (new required constructor parameter, `stakingDeployer_`), and called via a plain external call from the constructor instead of an inline `new`. Landed at 38,469 bytes — **10,683 bytes of margin.**

**A genuine mistake caught and corrected during this work, worth recording**: the first attempt instantiated `StakingDeployer` INLINE inside `SBLauncher`'s own constructor (`new StakingDeployer().deploy(...)`) rather than requiring a pre-existing one. This made the problem WORSE (49,169 → 49,916 bytes) instead of better — an inline `new StakingDeployer()` embeds `StakingDeployer`'s own full creation bytecode (which itself embeds `Staking`'s) into `SBLauncher`'s initcode, just with an extra layer of wrapper overhead on top; it does not remove anything. Only a genuinely separate, pre-existing deployment (a real external call to an address that already has code) keeps the embedded bytecode out.

### Independent security review, three specific checks requested

**1. Is pre-deploying `StakingDeployer` separately a front-running/atomicity regression?** No. It is generic, stateless, and knows nothing SB-specific (`sb`/`launcher` are supplied by the caller of `deploy`, not baked in) — deploying it in an earlier, separate transaction reveals nothing about the upcoming SB-specific sequence and gives an attacker no actionable edge (there is nothing SB-specific to front-run in `StakingDeployer` itself). The SB-specific sequence — pool creation, both position mints, the genesis stake — still runs as ONE atomic call inside `SBLauncher`'s constructor, unchanged. Confirmed directly: `test_frontRun_preGriefedPool_revertsCleanly_andIsFullyAtomic` (`test/audit-independent/scope3/Scope3SBLauncher.t.sol`, pre-existing test updated for the new deploy topology, not a new one) still proves a pre-griefed pool causes a clean, fully atomic revert — no partial `SBToken`/`Treasury`/`Redemption`/`FeeProcessor`/`LiquidityLocker` deployment survives, AND no partial `Staking` instance survives either (its address is now predicted via `StakingDeployer`'s own nonce, not `SBLauncher`'s, and explicitly checked).

**2. Could a third party grief this the same way as the token deployer (concern raised explicitly by analogy)?** No — and this is a structurally different case, not just "checked and found fine": `StakingDeployer.deploy` uses a PLAIN `CREATE` (no salt), never `CREATE2`. A plain `CREATE` address depends only on the deployer's own address and its current nonce, which increments per call — there is no address for a third party to "predict and squat" ahead of a specific future call the way `CREATE2`'s deterministic addressing allowed for the token. Multiple parties calling `StakingDeployer.deploy` concurrently simply each get their own distinct address; none can collide with or block another's call.

**3. What if `stakingDeployer_` itself is wrong or malicious?** This isn't a live-attacker threat model in the first place — `stakingDeployer_` is a constructor argument only the SAME trusted party deploying `SBLauncher` ever supplies; no third party can force a different value in. Treated as defense-in-depth against a deploy-time mistake or a compromised deploy script/tool anyway, per the explicit request. `SBLauncher`'s constructor now `require`s `stakingDeployer_.code.length > 0` (`BadStakingDeployer`) before calling it, and after the call, `require`s the returned address has code AND that `Staking(result).SB() == address(sbToken)` AND `Staking(result).launcher() == address(this)` (`BadStaking`) — i.e. the returned contract must genuinely honor the exact constructor arguments `SBLauncher` passed it. **Documented limit, not overclaimed**: this does NOT prove the returned address runs unmodified `Staking.sol` bytecode byte-for-byte — proving that on-chain without re-embedding the exact creation code (which would undo the whole fix) isn't achievable with the tools Solidity gives a constructor. What it does catch: a non-contract, a deployer that reverts, one that returns an empty/uninitialized address, and one that silently ignores or swaps its arguments — the realistic shapes a deploy-time mistake or a naively-compromised helper would actually take.

**Tests** (`test/sb/StakingDeployerVerification.t.sol`):
- `test_REGRESSION_stakingDeployer_mustBeAContract` — an EOA or `address(0)` passed as `stakingDeployer_` reverts `BadStakingDeployer`.
- `test_REGRESSION_stakingDeployer_returningNonContract_reverts` / `_returningWrongContract_reverts` — a deployer that hands back a fixed non-contract address, or a fixed pre-existing unrelated contract, reverts `BadStaking`.
- `test_REGRESSION_stakingDeployer_wrongConstructorArgs_reverts` — a deployer that DOES genuinely create a `Staking` instance but with swapped/wrong constructor arguments still reverts `BadStaking` — proving the `SB()`/`launcher()` state comparison is the check doing the real work, not just code-existence.
- `test_legitimateStakingDeployer_reusedAcrossTwoLaunchers_bothCorrectlyWired` — the legitimate path: the SAME `StakingDeployer` instance, reused across two independent `SBLauncher` deployments, correctly and independently wires each to its own distinct `Staking` instance — confirming reuse of a stateless, generic deployer is safe (there is no "already used, now tainted" concern for something that holds no state at all).

Full run after the restructuring: see STATUS.md for the current count.

---

## Test-infrastructure bug found and fixed during the size restructuring (2026-09-22) — not a contract bug

While confirming the full suite after the restructuring above, `SBFeeFlowTest.test_feeProcessor_sellsWavaxToUsdc_forwardsToTreasury` started failing deterministically (`TwapDeviationTooHigh()`, reproducible against the pinned fork block, not a flake). Root cause was in the shared test helper `_forceObservationWrite` (`test/sb/SBBase.sol`, added earlier this session for the round-3 F-1/F-2 FeeProcessor fix's test maturation), not in any contract: it reused ONE raw wei `amount` for swaps in BOTH directions when forcing a tick-crossing observation write, alternating between `tokenA`→`tokenB` and `tokenB`→`tokenA`. That's fine when both tokens share the same decimals (every launchpad token is 18dp, matching WAVAX), but the WAVAX/USDC leg mixes 18dp (WAVAX) and 6dp (USDC) — the SAME raw integer `1e16` means "0.01 WAVAX" on one side and "10,000,000,000 USDC" on the other. The oversized USDC→WAVAX leg swung the real pool's spot price far enough from its historical TWAP to trip `FeeProcessor`'s own 3% major-asset deviation check — the check did exactly its job; the test helper was fighting the thing it was trying to set up. Fixed by deriving an equivalent-*scale* (not equivalent-*integer*) starting amount for the second token from the first, adjusted for the decimals difference, both growing proportionally together. Confirmed: same test now passes on the first attempt (gas dropped from ~20M to ~3.3M, consistent with no wasted retries), reproducible across 5 repeated runs, and the rest of `test/sb/**`/`test/audit-independent/scope3/**` unaffected (both sides of `_matureTokenWavaxTwap`'s launchpad-token/WAVAX pairing are 18dp/18dp, so that path was never exposed to this bug).

---

## Round 5 — independent re-audit of the round-4 protections (2026-09-22)

Zero-context hostile re-test of `Staking.sol`, `Redemption.sol`,
`FeeProcessor.sol`, `Treasury.sol`, `LiquidityLocker.sol`, `SBLauncher.sol`,
`SBToken.sol`, `StakingDeployer.sol`. Every claim in this document's round-4
section was re-derived against the actual current source, not trusted.
Baseline suite re-run independently: `test/sb/**` and
`test/audit-independent/scope3/**` (119 tests incl. the 3-invariant
stateful suite) — all green, confirmed, not just cited.

**Verdict: no new High, no new Medium.**

### Finding — Low/Informational: `Staking.stakeGenesis` didn't self-enforce `GENESIS_STAKE_AMOUNT`

The round-4 fix's entire safety argument ("bounds worst-case index
inflation to a fixed ~80,000x regardless of any other staker") rested on
the genesis stake being exactly `GENESIS_STAKE_AMOUNT` — but `stakeGenesis`
took an arbitrary caller-supplied `amount` and never checked it. A PoC on
a standalone `Staking` instance (`stakeGenesis(1_000 wei)` instead of
`1_000e18`, 1 year idle, one `rebase()`) fully reproduced round 3's
index-inflation High: `index` reached `8×10^49`, and a completely normal
1,000 SB stake by an uninvolved later staker reverted `ZeroAmount` —
`stake()` permanently bricked, despite `stakeGenesis` having been
dutifully called.

**Not a live High/Medium against SB as deployed**: `stakeGenesis` is
`launcher`-gated, one-time (`genesisStaked` flag), and only ever called
from inside `SBLauncher`'s own atomic constructor, which always passes
the real constant — confirmed by both the pre-existing
`SBLauncherDeploy.t.sol::test_reserveFundedWithRemainder` and this
round's own positive control. No external attacker could ever call
`stakeGenesis` themselves or supply a different amount against the real
system. Rated Low/Informational, not High/Medium: a defense-in-depth gap
(the invariant round 4 relies on wasn't self-enforced by the contract
that needs it, only by caller discipline), not a live attack path.

**Fixed anyway** — cheaply, and more thoroughly than "add a check":
`stakeGenesis`'s `amount` parameter is removed entirely rather than
validated. It now takes no argument and always stakes exactly
`GENESIS_STAKE_AMOUNT` internally. Not "validate the input" but "there is
no input left to get wrong" — round 3's bug can never be silently
reintroduced by a future/alternate launcher or a deploy-tooling mistake
reusing this exact `Staking.sol`, by construction, not by a runtime
`require`. `SBLauncher.sol`'s call site updated to `staking.stakeGenesis()`
(no args). Test rewritten from an exploit PoC into a direct regression
(`test_REGRESSION_stakeGenesisAlwaysStakesExactlyTheConstant_noAmountParameterLeft`,
`test/audit-independent/scope4/Scope4Round5.t.sol`): confirms the position
is always exactly `GENESIS_STAKE_AMOUNT` and that a normal later stake
still mints real shares after a long idle period.

### Also actively hunted for and NOT found: a global div-by-zero brick of `redeem()`

Hypothesis: could many-actor, many-cycle stake/unstake dust drift (from
`_debitPrincipal`'s ceil-vs-floor rounding asymmetry) ever clamp
`totalPrincipal` toward zero while non-genesis shares are still
outstanding? That would divide-by-zero-panic `Redemption.redeem`'s global
`usdcBalance * burned / totalActiveBefore` for *every* remaining staker,
not just the account that triggers it — a real High if reachable.
Stress-tested directly: 300 stake/unstake cycles across 6 actors on the
real genesis-seeded system, deliberately using awkward 1–4001-wei dust
amounts to maximize per-call rounding drift rather than average it away
(`test_manyActorsManyDustCycles_totalPrincipalNeverFalselyZeroes_redeemNeverPanics`).
`totalPrincipal` stayed strictly positive throughout; every subsequent
`redeem()` attempt completed or reverted with a legitimate guard, never
an arithmetic panic. Confirms the design's own stated rounding
philosophy (floor on mint, ceil on burn) always drifts `totalPrincipal`
in the protocol's favor, never toward a false zero.

### Also re-confirmed, not just re-cited

`SBLauncher` constructor atomicity (no window for an external actor
between `StakingDeployer.deploy()` returning and `stakeGenesis()`
executing); `FeeProcessor`'s same-block-manipulation resistance
(re-derived from Uniswap V3's actual pre-swap-tick observation semantics,
not assumed); `Redemption` can never pay out more than `Treasury` holds
(re-proven from the RAY ceiling-rounding guarantee); no reentrancy
through `Redemption`'s `Treasury.payOut` calls or `LiquidityLocker`'s
cross-contract call into `Staking.fundReserve`; `stakeGenesis` still
uncallable twice or by a non-launcher.

**Chantier 3 status**: with this round confirming zero new High/Medium
(the one Low fixed anyway), the audit loop closes here — the standing
"three failed fix attempts → remove functionality" rule was never
triggered, since nothing about the currently-deployed protection was
actually broken this round.
