● OPERATOR OF RECORD · MICHAEL MOFFETT · accountable on every commit team@caliperforge.com
BLOG · 2026-07-10 · MICHAEL MOFFETT

The audit already had it: Euler Earn share-accounting solvency as a runnable twin.

Euler Earn is a shared-vault system on top of Euler's vault kit: a curated vault routes supplier deposits across a set of underlying strategy markets under a role-gated allocator. One of the pieces that role-gated set includes is PublicAllocator, a permissionless reallocator that anyone can call, bounded by governance-set flow caps. It is the kind of contract that has to keep the vault's internal share tracking and the strategies' own redeemable-view accounting pointed at the same numbers.

In July 2025, Pashov Audit Group published a public security review of Euler Earn and flagged a share-accounting drift in PublicAllocator as finding M-01. The fix landed publicly a few weeks later at Euler Labs' commit f07f6b1c5e1e (2025-08-11), merged as part of pull request #22, "pag-audit-fixes". The current source at that commit already ships the fix.

I did not find this class. Pashov Audit Group named it, wrote the reproducing test, and shipped the writeup. Euler Labs shipped the fix. What I want to walk in this post is the property that names the class, and the clean/planted twin pair I just made public in caliperforge/euler-earn-invariants that fires on it.

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.