The mechanism
The mechanism, in plain terms.
PublicAllocator.reallocateTo(vault, withdrawals, supplyId)
moves assets between the vault's strategy markets under
flow-cap governance. For each withdrawn strategy
id, the allocator needs an assets
value that represents what the vault currently holds in
that strategy. It uses that value both to update flow-cap
accounting and to build the MarketAllocation
forwarded to EulerEarn.reallocate.
Two accounting systems exist for that quantity. The vault's
own _withdrawStrategy path reads
expectedSupplyAssets, a view derived from the
vault's internal per-strategy balance ledger
(config[id].balance), which only updates on
paths that go through EulerEarn.deposit or the
vault's own withdraw. The other system is
id.maxWithdraw(vault), which reflects whatever
strategy shares the vault currently holds, whether or not
those shares arrived through the vault's own deposit path.
Under normal supplier flow the two views agree. They
diverge if a share delta reaches the vault outside
EulerEarn.deposit. The concrete path Pashov
Audit Group's finding names is a direct
strategy.deposit(assets, address(vault)) call
from an external actor: the strategy mints its own shares
to the vault, but the vault's internal ledger does not
observe the delta. From that point,
strategy.maxWithdraw(vault) grows past
EulerEarn.expectedSupplyAssets(strategy).
The pre-fix PublicAllocator read the strategy's
maxWithdraw view for its assets
value, which put the allocator's flow-cap math and the
vault's actual withdraw path on different accounting
systems: flow caps updated against the inflated view, while
the reallocation moved the un-inflated internal-tracking
amount. The fix aligns the two by exposing
expectedSupplyAssets and
maxWithdrawFromStrategy on
EulerEarn and pointing the allocator at the
internal-tracking view. On
PublicAllocator.sol line 120, the change is
one line:
-
Post-fix:
uint256 assets = IEulerEarn(vault).expectedSupplyAssets(id); -
Pre-fix:
uint256 assets = id.maxWithdraw(vault);
That one line is the whole semantic content of the M-01 fix; the surrounding flow-cap guards and the reallocation-loop shape carry forward unchanged.
The invariants
The invariants that catch the class.
The case encodes three invariants against the real Euler Earn contracts. The first is the reference solvency property Pashov Audit Group names in the finding, stated per-strategy in the vault's withdraw queue:
The vault's internally tracked supply-asset amount for any strategy never exceeds what that strategy would let the vault redeem.
Concretely:
m01_solvency:
for every strategy id in the withdraw queue,
vault.expectedSupplyAssets(id) <= id.maxWithdraw(vault)
Both twins hold this invariant by construction over the case's four-primitive handler surface. It is present as the reference solvency shape the finding names, not as the class-distinguishing property.
The two invariants that distinguish the twins are Pashov
Audit Group's own assertions from
testRellocateToWithDirectStrategySharesTransfer
in test/PublicAllocatorTest.sol at the pinned
commit, lifted into a stateful setting:
m01_reallocation_movement_matches_request:
for every recorded PublicAllocator.reallocateTo call,
strategy.maxWithdraw(vault)_delta == requested_withdrawnAssets
m01_reallocation_flowcap_matches_movement:
for every recorded reallocation,
flowCaps[vault][id].maxIn_delta == actualWithdrawn
The reference for "what should have moved" is the requested
withdrawal amount the allocator was called with. On the
clean twin the strategy's maxWithdraw delta
and the flow-cap maxIn delta both match that
request. On the planted twin they diverge on any fuzzed
sequence that runs a directShareDeposit before
the next reallocateFromStrategy on the same
strategy.
The campaign is stateful. It runs 256 fuzz runs of depth 50
(12,800 handler calls per invariant) against a handler that
exposes four primitives: deposit,
directShareDeposit,
reallocateToStrategy, and
reallocateFromStrategy. The
directShareDeposit primitive is the one that
surfaces the accounting divergence the M-01 fix addresses;
fail_on_revert = false lets the campaign focus
on the conservation property instead of on the many benign
revert states the flow-cap space produces. A deterministic
regression that mirrors Pashov Audit Group's own 4-call
sequence fires the two markers seed-independently on the
planted twin.
The twin
The clean/planted twin, and what the fuzzer does with it.
The clean twin at
src/cases/m01-share-accounting-solvency/clean/PublicAllocator.sol
is a byte-faithful vendor of
PublicAllocator.sol at commit
f07f6b1c5e1e. The only differences from
upstream are six lines of import-path prefix rewrites (the
file lives at a different tree depth here, so relative
imports are rewritten through remappings). No logic
changes. git diff -w between upstream and the
clean twin, reduced to semantic non-comment
non-whitespace hunks, is empty.
The planted twin is that same file with a single-hunk revert on line 120, restoring the pre-fix source. No other lines change between the two twin files. Both preserve the upstream Morpho Labs attribution the vendored source carries in its header.
Both twins are wired through Euler Earn's own
IntegrationTest fixture, so the vault, EVC,
permit2, strategy factories, and role wiring are exactly
what Euler Earn's own test suite uses. No mocks beyond the
standard ERC20Mock and EVaultMock
that fixture already ships.
On the clean twin, all invariants and both regression legs
pass and no INVARIANT VIOLATED marker prints.
On the planted twin, the stateful fuzzer produces a short
counterexample: the reachable sequence
directShareDeposit → reallocateFromStrategy
is enough for the two identities to diverge on the first
reallocation that follows a direct share deposit.
INVARIANT VIOLATED m01_reallocation_movement_matches_request
and
INVARIANT VIOLATED m01_reallocation_flowcap_matches_movement
print on the planted run, both from the stateful campaign
and from the deterministic regression that scripts the same
4-call sequence and hits the divergence with certainty. The
solvency leg still holds on the planted twin; the
wrong-view read shows up as a movement and flow-cap
accounting divergence, not as a solvency-direction
violation.
That is the shape I want from a class-encoding test: silence and green on the correct implementation, a loud marker and a short human-readable counterexample on the specification regression.
Scope
What I actually claim, and where I stop.
This is a defender-side regression fixture on a bug class
already caught, disclosed, and fixed in public. Pashov
Audit Group flagged the class as M-01 in their
Euler Earn security review
(2025-07-25). Euler Labs shipped the fix at commit
f07f6b1c5e1e
(2025-08-11), merged as part of pull request #22,
"pag-audit-fixes". The current PublicAllocator
at that commit already reads
expectedSupplyAssets. I did not discover the
class. What I did was encode it as a runnable clean/planted
twin pair against the real Euler Earn contracts, with the
clean twin byte-faithful to the fix-commit source, the
planted twin a single-hunk revert, and both wired through
Euler Earn's own integration fixture. The campaign is
visible: 256 x 50 stateful fuzz runs per invariant, a
deterministic regression that mirrors Pashov Audit Group's
own 4-call sequence, and both twins wired into CI on every
commit.
What that gets you, if you build on Euler Earn or fork the
PublicAllocator boundary: a name-recognizable
pre-deploy CI gate for the class. If you inherit
PublicAllocator and modify its
assets read path, the case's
_deployPublicAllocator hook is the intended
port point; the invariant surface travels unchanged.
What that does not get you: a claim that CaliperForge audited Euler Earn, a claim that anything currently deployed is exposed at this class, or a claim that a stateful invariant is a substitute for an audit. It is a specific piece of CI that catches a specific class loudly and cheaply, on the real Euler Earn contracts the class actually lives on.
Credit for the class goes to Pashov Audit Group (finding
M-01 and the accompanying
testRellocateToWithDirectStrategySharesTransfer
reproducer in the same commit) and to Euler Labs (protocol
and fix). Morpho Labs is preserved as the upstream-fork
attribution the vendored PublicAllocator.sol
header carries into both twin copies. Case sources, twin
diff, and CI legs live in
caliperforge/euler-earn-invariants.
Reproducing
Running it.
The case is at
src/cases/m01-share-accounting-solvency/. Both
twins are runnable with the pinned Foundry toolchain
(forge 1.7.1, solc 0.8.26,
evm_version = "cancun",
via_ir = true, lib/euler-earn
submodule at f07f6b1c5e1e):
forge test --match-contract '^M01SolvencyClean$' -vv # all green, zero markers
forge test --match-contract '^M01SolvencyPlanted$' -vv # detection legs fail with markers
For the CI legs:
bash ci/clean_leg.sh # rc=0, no markers
bash ci/planted_leg.sh # rc=0, markers written to GITHUB_STEP_SUMMARY
The planted leg is inverted: every *Planted
suite must exit non-zero and print at least one
INVARIANT VIOLATED line; the markers are
copied into the job summary so a reviewer clicking the CI
job sees each catch.
Sources
Primary references and case artifacts.
- Pashov Audit Group, Euler Earn security review (2025-07-25), finding M-01: github.com/pashov/audits/blob/master/team/md/EulerEarn-security-review_2025-07-25.md
-
Euler Labs fix commit
f07f6b1c5e1e("M-01 fix, PAG audit"): github.com/euler-xyz/euler-earn/commit/f07f6b1c5e1e - Euler Earn pull request #22 ("pag-audit-fixes"), the merged fix set: github.com/euler-xyz/euler-earn/pull/22
-
Post-fix source,
PublicAllocator.solat commitf07f6b1c5e1e: github.com/euler-xyz/euler-earn/blob/f07f6b1c5e1eb43a04b9a09d0ab84c05f34946ed/src/PublicAllocator.sol - Our case, M01 README: github.com/caliperforge/euler-earn-invariants/blob/main/src/cases/m01-share-accounting-solvency/README.md
- Repo root: github.com/caliperforge/euler-earn-invariants