Skip to content

fix: Bailsec Core audit remediation (breaking: redemption ABI) - #33

Merged
FabienCoutant merged 28 commits into
devfrom
fix/bailsec-core-audit
Oct 5, 2026
Merged

FabienCoutant merged 28 commits into
devfrom
fix/bailsec-core-audit

Conversation

@FabienCoutant

@FabienCoutant FabienCoutant commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

Remediation for the Bailsec Core first report (September 2026). All 46 findings carry a response,
sent to Bailsec separately for their comments and resolution columns.

Breaking change

redeem, redeemWithForfeit and redeemWithAuthorization take a new expectedTokensHash, so their
selectors change and every redemption signature outstanding at upgrade time is invalidated.
Integrators must move to quote, hash, redeem. This is Issue_09, and the report anticipated the break.

Fixed in code

# What
02 Rotating a rebalancer asset or deposit address revokes the outgoing approval; resetAllowance added
03 setMaxSlippage writes the field the rebalance reads; the dead maxTokenSlippage mapping is gone
04, 05 harvest restricted to trusted callers; MAX oracle ratchet bounded at 5% per update and logged
06 A non-zero flash loan fee is settled against the caller's budget instead of rejected
07, 08 Router allowance cleared after every swap; unconsumed input returned rather than stranded
09 Redemption output list bound into the call and into the derived EIP-3009 nonce
15 RewardHandler calls a governance-set swapRouter instead of a hardcoded Ethereum address
19 The Parallelizer itself is rejected as a surplus payee

Each fix has a regression test that fails without it.

Fixed by configuration

Issue_10. The deployed redemption curve still made the donation attack pay above roughly a third
of supply, with no overvalued collateral needed. Profitability is set by the slope of the segment
climbing back to y[last], so the same 4.5 point deterrent now spans a 60% to 100% ratio band.
Slope goes from 2.25 to 0.112.

Issue_22. setOracle already enforces userDeviation <= burnRatioDeviation. Two mainnet
collaterals violate it because they predate the check, which means the shipped config would not
deploy. The tolerance moves to the burn side.

Also in here

The Harvester contracts are renamed to Rebalancer to match what is deployed, YieldBearingParams
packs into two slots instead of three, and the path independence invariant now measures path
independence: it was comparing split forfeited redemptions, which diverge by design.

Governance actions after merge

Three calls on each of the five chains, none performed by the upgrade:

  1. setRedemptionCurveParams, the Issue_10 ramp
  2. setOracle, the Issue_22 deviation pairing
  3. setSwapRouter, without which sellRewards reverts

Still outstanding

The live Rebalancers on Base (0xa130adc5) and Avalanche (0x45c8db32) run unaudited code without
Issues 03, 04 and 05. They are only covered once redeployed from this branch.

Verification

53 test suites, 415 tests, no failures. forge fmt --check reports fewer diffs than dev, and
solhint reports no errors.

FabienCoutant added 22 commits September 7, 2026 09:34
Covers commit 0ce59af across the parallelizer and tokens repositories.
harvest forwards caller-supplied router calldata and refreshes the
collateral oracle, so it cannot be exposed to arbitrary callers. This
aligns GenericHarvester with MultiBlockHarvester, which already gates
both of its entry points.

Closes Bailsec Core Issue_04, Issue_05 and Issue_07.
updateOracle raised the stored target to the current spot with no bound
on the increase, and emitted nothing, so a transient upward print could
be locked in permanently and left no trace in the logs.

A single update is now capped at MAX_ORACLE_RATCHET_STEP and emits
OracleTargetUpdated. Failing to ratchet is the conservative direction:
readMint clamps at the lower of spot and target, and readBurn cannot
raise a stale target above its stored value.

Part of Bailsec Core Issue_04.
setMaxSlippage wrote to maxTokenSlippage, a mapping no harvest path
reads: every live check uses yieldBearingData[asset].maxSlippage. A
governance tightening therefore appeared to succeed on chain while
leaving the effective limit untouched.

The setter now writes the parameter the harvests read, the dead mapping
is removed, and the bound matches setYieldBearingAssetData: 1e9 means
zero slippage protection and is rejected.

Closes Bailsec Core Issue_03.
…ement

Two defects in onFlashLoan, both already carried by the unmerged
rebalancer work and ported here so the audit remediation is complete on
its own.

The router was assumed to consume the full declared input while only the
output balance was checked. A partial fill left both the unspent input
and its standing allowance on the contract, outside the Parallelizer's
accounting. The input balance is now compared across the swap.

Settlement was also asymmetric: a shortfall was charged to the initiating
caller while a surplus stayed as an unattributed contract balance the
caller could not withdraw. The positive branch now credits the caller.

Closes Bailsec Core Issue_07 and Issue_08.
The contracts adjust the Parallelizer's exposure between a yield bearing
asset and its underlying, they do not collect yield, so Rebalancer is the
accurate name. It also aligns this repository with the contracts already
deployed under that name on Base and Avalanche.

Renamed: BaseHarvester, GenericHarvester and MultiBlockHarvester, along
with their deploy scripts, configuration keys and registry entries. The
IHarvester interface and the legacy ContractType.Harvester entry keep
their names so registry ordinals are unchanged, matching the deployed
contracts. Existing deployment records are left untouched: they document
what was deployed under the old name.
…ords

Completes the rename: the interface follows the contracts that implement
it. The upstream reference to Angle's IHarvester is left as written since
it names a file in their repository.

The GenericHarvester deployment records are removed. They are internal
helper contracts, no code resolves them by name, and the entries would
otherwise point at an implementation this repository no longer builds.
The rename also rewrote the references to Angle's own files, so the
attribution named contracts that do not exist upstream and the links no
longer resolved. Angle's files are BaseHarvester, GenericHarvester and
MultiBlockHarvester, and the notices now name them again.

BaseRebalancer and GenericRebalancer are marked as substantially
modified, which is accurate for both; MultiBlockRebalancer is unchanged
beyond its name and keeps the plain notice.
The previous check rejected any route that consumed less than it was
offered. That is too strict: the input is sized on chain from the live
Parallelizer state while the router calldata is built off chain, so the
two cannot be expected to match exactly, and any upward drift between
the keeper's simulation and inclusion would have failed the rebalance.

The remainder is now swapped back through the Parallelizer and added to
the settlement, so it reaches the initiating caller's budget rather than
being stranded. The router allowance is also cleared after the swap so a
partial fill leaves nothing standing.

Refines Bailsec Core Issue_07.
onFlashLoan had no coverage at all, so neither the router leg nor the
budget settlement was exercised. A zero-fee ERC-3156 lender mock lets the
existing MockRouter drive a route that consumes less input than it was
offered.

Without the recovery the run leaves 781.198 of the input stranded on the
rebalancer; the test asserts nothing is left, no router allowance
survives, and the recovered value reaches the caller's budget.
…hind

Rebalancing grants unlimited allowances and never reduces them, while
rotating a yield bearing asset or a deposit address only overwrote the
configuration. The outgoing spender kept authority over any of that token
the contract later held, for the rest of the contract's life.

Both rotations now revoke the approval they made obsolete, and governance
gets resetAllowance as the escape hatch for anything else, as recommended.

Closes Bailsec Core Issue_02.
`onFlashLoan` refused any callback reporting a non-zero fee, so enabling the
USDp flash loan fee would have left the rebalancer with no working path to
adjust the Parallelizer's yield bearing exposure.

The fee is now folded into what the lender pulls back and settled against the
budget of the address that called `harvest`, alongside the usual shortfall or
surplus. A fee the caller cannot cover fails the rebalance rather than reaching
into another caller's budget.

Bailsec Core Issue_06
FlashParallelToken stores `feesRate` as a uint16 in basis points, capped at
100%. The mock scaled by 1e9 instead, so its rates read five orders of
magnitude away from anything governance could actually set.

The budget shortfall case now uses that real ceiling against a route that
clears `minAmountOut` with nothing spare, rather than a fee rate the live
contract would reject.
`minAmountOuts` is positional and the output list is rebuilt at execution time
from the live `collateralList`. A revoke (swap and pop) followed by an add
preserves the length while reshuffling the tokens, so each minimum ends up
guarding a token the caller never quoted.

The three redeem entry points now take an `expectedTokensHash` over the output
list, checked against the live one, and the authorized path folds it into the
derived EIP-3009 nonce so a relayer cannot hold a signature until governance
rotates collateral.

This changes the ABI of `redeem`, `redeemWithForfeit` and
`redeemWithAuthorization`, and invalidates any redemption signature outstanding
at upgrade time.

Bailsec Core Issue_09
…endence

`invariant_PathIndependenceCollateralRatio` failed once during the audit work at
0.2176% against a 0.2% tolerance. The driver was not the documented one.

`ArbitragerWithSplit` split a redemption that forfeited tokens. Forfeited tokens
stay in the reserve while the others shrink, so the composition drifts and each
later redemption forfeits a larger share of value. Measured against a plain
redemption split the same way:

  splits          2      4      8     16
  no forfeit      0      0      0      0   bps
  forfeit B     193    289    337    360   bps
  forfeit B+Y   146    209    238    252   bps

So a single split already sat an order of magnitude above the tolerance, and the
suite only passed because the fuzzer rarely landed a large forfeited redemption.
The comment credited non-flat fee curves and the entry-time penalty, both of
which measure zero here.

Forfeit is what breaks the comparison when split, not forfeit itself, so it moves
to its own `redeemWithForfeit` handler that applies the same unsplit redemption to
both systems. They drift together, the forfeit branch and its payout assertions
stay covered, and only the plain redemption is split.

Across 25 seeded campaigns the residual divergence peaks at 1.4e-8%, down from
4.1e-6%, so the collateral ratio tolerance drops from 0.2% to 0.01%, matching the
total supply one.
`maxSlippage` was a uint96, a width inherited from the Angle fork where it was a
standalone variable that had a slot to itself. Inside `YieldBearingParams` its 12
bytes no longer fit beside the four uint64 exposures, so it spilled into a third
slot and left 20 bytes unused.

The value is bounded below 1e9, so uint64 is ample and closes the second slot
exactly:

  asset             slot 0  offset  0
  targetExposure    slot 0  offset 20
  maxExposure       slot 1  offset  0
  minExposure       slot 1  offset  8
  overrideExposures slot 1  offset 16
  maxSlippage       slot 1  offset 24

Configuring an asset drops from 104588 to 82501 gas.
`sellRewards` forwarded its payload to `ODOS_ROUTER`, a constant holding the
Ethereum Odos address. The same facet bytecode ships to every chain, so the
target was wrong everywhere but mainnet. Checked on the five live deployments:

  mainnet     bytecode present, the Odos router
  sonic       bytecode present, an unrelated contract
  base        no bytecode
  avalanche   no bytecode
  hyperevm    no bytecode

A raw call to an address with no code returns success with empty returndata, so
the flow ran on to find no balance increase and reverted with InvalidSwap. The
selector is wired on all five diamonds.

The target moves to `swapRouter`, appended to `ParallelizerStorage` and set by
governance. Nothing is Odos specific: the payload is executed with a raw call, so
any aggregator or DEX can be configured. `ODOS_ROUTER` is gone, `OdosSwapFailed`
becomes `RewardSwapFailed`, and `MockOdosRouter` becomes `MockSwapRouter`.

An unset router now reverts instead of calling address zero, which would have
reported success and left the swap silently unperformed.

Layout.sol had drifted: it documented 9 slots while the struct had grown to 15
with the surplus distribution fields. It now mirrors all 16, and the layout test
asserts the tail so an append cannot slip through unnoticed again.

Governance must call `setSwapRouter` on every chain after the upgrade, before
rewards can be sold.

Bailsec Core Issue_15
Bailsec Core Issue_10 records the remediation as smoothing the redemption curve
to prevent profitability. The curve live on all five chains is indeed shallow:

  collateral ratio  75%    85%    95%    97%
  payout factor     99.5%  95%    95%    99.5%

A 4.5 point band, against the 40 point swing the finding illustrates. It raises
the bar without closing it. Against those exact parameters, with a second holder
present so the transfer is visible and a correctly priced donation asset:

  attacker share    25%     30%     35%     40%     50%
  net result       -444    -133    +178    +489   +1111

Break-even sits between 30% and 35% of supply. At 50% the attacker gains 1111
while the other holder loses 1106, so it is a transfer, not an artefact. No
overvalued collateral is needed, unlike the finding's illustration.

What decides profitability is not that a deterrent exists but how steeply it
climbs back. The attacker pays in ratio points and is repaid in payout points on
their own redemption, recovering the share `f` they still hold a claim on, so a
ramp starves the attack when its slope stays below (1 - f) / (f * H/S). Holding
the same 4.5 point deterrent and widening the band:

  slope            0.900   0.450   0.225   0.112
  attacker at 50%   loses   loses   loses   loses
  attacker at 90%   GAINS   GAINS   GAINS   loses

Measured thresholds match the formula: about 1.05 against half the supply, about
0.12 against ninety percent. The deployed curve sits at 2.25.

So the deterrent does not have to be given up. Ramping the same 4.5 points from a
60% ratio up to 100% lands at slope 0.112 and starves the attack even against a
holder of ninety percent of the supply, while removing the current shape's oddity
of paying its best rate at the deepest undercollateralization.

Flattening also works, and `y[last]` prices every redemption above a 100% ratio,
so it has to flatten at 99.5%: flattening at 95% would cost healthy redeemers
4.52% instead of 0.5%.

Bounds are asserted rather than only printed, so changing the curve fails these
tests and forces the trade-off to be re-measured.
The deployed curve returned to 99.5% over two ratio points, a slope of 2.25. An
attacker pays in ratio points and is repaid in payout points on their own
redemption while recovering the share they still hold a claim on, so a donation
pays whenever that slope exceeds (1 - f) / (f * H/S): about 1.05 against half the
supply, about 0.12 against ninety percent.

  x  75%    85%    95%    97%          ->   0      60%    100%
  y  99.5%  95%    95%    99.5%        ->   95%    95%    99.5%

The same 4.5 point deterrent now spans a 60% to 100% band, a slope of 0.112,
under the threshold even for a holder of ninety percent of the supply. `y[last]`
stays at 99.5% because it also prices every redemption above a 100% ratio.

Ramping monotonically also drops the previous shape's oddity of paying its best
rate at the deepest undercollateralization.

Carried in the six deploy configs for fresh deployments, and in
`SetRedemptionCurve.s.sol` for the five live ones, one run per chain.

Bailsec Core Issue_10
Bailsec Core Issue_22 asks that fees be set so `userDeviation` cannot be
exploited. Reading the code, the finding points instead at a live configuration
the contract already refuses to accept.

`userDeviation` replaces a spot inside its band with the target for both mint and
burn. So `readMint` credits a discounted collateral at face value, and because
the snap runs before the burn firewall, the collateral stops contributing any
depeg haircut while it sits inside the band.

`setOracle` already guards this:

  if (userDeviation > burnRatioDeviation) revert InvalidParams();

The invariant is exactly right: while the snap applies the firewall would not
have fired anyway, so the snap can never suppress it. Two mainnet collaterals
violate it, pairing a 5 bps snap band with a zero firewall band, and they predate
the check. `DiamondInitializer` routes through the same `setOracle`, so the
shipped config would no longer deploy.

The tolerance moves to the burn side, matching the two collaterals already
configured that way. Measured, nothing is lost:

                mint pricing                  firewall 4 bps   firewall 20 bps
  before        face value, discount taken    suppressed       suppressed
  after         true spot, discount to minter tolerated        engages

Seven collateral entries across five chains corrected in the deploy configs.
Governance must apply the same change on chain through `setOracle`.
`_addPayee` validated only that the share was non-zero. The Parallelizer holds
the income being distributed, so a share allocated to itself is handed straight
back to `release` as fresh income on the next call, redistributing itself.

`address(0)` deliberately stays valid. `LibSurplus._release` treats it as the
sentinel for burning that share, and the live surplus policy uses it. An earlier
draft rejected both and broke the surplus tests, which is how the sentinel
surfaced.

Bailsec Core Issue_19
`bun run lint:sol` reports it as an error rather than a warning, so it would fail
CI. Introduced by the Issue_04 fix.
FabienCoutant added 4 commits September 28, 2026 10:08
…at zero

The ratchet bounds each step against the stored target, so a target left at 0
can never be raised: every positive spot exceeds 0 by more than 5%. New chains
would deploy with a permanently frozen oracle.

The initializer now seeds the target from spot once, and the ratchet governs
every later move. Seeding is refused on an already-set target, so it cannot be
used to sidestep the bound.

Reported by Bailsec against the Issue_04 remediation.
…tates

Asset rotation cleared `oldUnderlying -> yieldBearingAsset`, the vault path's
relationship. The MultiBlock deposit path instead approves
`underlying -> depositAddress`, so that allowance survived the rotation and the
regression test asserted the same wrong pair.

A virtual hook lets each rebalancer revoke what it actually granted. The new
test fails without the override.

Reported by Bailsec against the Issue_02 remediation.
…steady state

processSurplus drives the ratio to surplusBufferRatio, 100.5% on mainnet, so the
margin over the profitable region is under a point rather than the headroom a
spot reading suggests.

The measurements also correct the earlier framing: the break-even sits just under
100% whatever the ramp, so the defence is the cap at 100% rather than the slope.
Widening the ramp or trimming the deterrent barely moves it.
…lus cycle

processSurplus runs monthly, so the ratio sawtooths between the buffer and
whatever yield adds before the next run. The trough is the block the run lands
in, which is both the thinnest and the most predictable moment.

Both ends of the cycle stay loss-making, and by almost the same margin: above
100% the curve is capped, so extra headroom buys nothing. That is the cap doing
the work rather than the size of the buffer.
FabienCoutant added 2 commits September 28, 2026 13:47
solhint caps lines at 120 and the two GenericRebalancer constructions ran to 122,
which failed the lint job and skipped the test jobs behind it.
Shortening the facet names left the markdown columns unaligned, which failed the
prettier check in the lint job.
@FabienCoutant
FabienCoutant merged commit f4367fd into dev Oct 5, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant