feat: total-allocation parameter for the WETH/cbBTC LP balancer + backend handbook - #83
feat: total-allocation parameter for the WETH/cbBTC LP balancer + backend handbook#83anajuliabit wants to merge 27 commits into
Conversation
Adds the bootstrap LPAutoBalancerV2 deployment for cbETH/WETH on Aerodrome Slipstream, with the total allocation as a configurable, validated proposal parameter, plus the backend handbook for the off-chain rebalancer. - multisig/mamo-multisig/014: deploys its own balancer + compound module (one balancer = one pool, so 011 is untouched), arms the sequencer guard and both per-feed oracle bounds BEFORE registerPosition, registers the position and grants REBALANCER_ROLE. totalAllocationUsd is settable per run and validate() asserts the registered principal lands in band, priced with the balancer's own feeds, staleness bounds and sequencer guard. - docs/LP_AUTO_BALANCER_V2_BACKEND_HANDBOOK.md: venue, allocation sizing, venue economics, process shape, preflight, failure playbook, go-live checklist. - test/: 5 fork tests, including a non-vacuity control for the allocation band. - addresses/8453.json: cbETH, pool, gauge, cbETH/USD feed. - .markdownlint-cli2.yaml: MD013 off for wide reference tables. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cut ~20% (5017 -> 4005 words) without dropping facts: removed the emphatic restatements and doubled justifications in the economics, config, process-shape and decision sections. Spelled "onchain"/"offchain" throughout and dropped the "load-bearing" phrasing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pool volume roughly halved between 2026-08-21 and 2026-08-22 ($6.63M -> $3.67M) while emissions held flat, inverting the stake/unstake ranking: staked (AERO) now leads unstaked (fees) by ~60%, where unstaked led by 12% the day before. - §3: refreshed the pool table with both dates side by side, flipped the conclusion, and noted the backend's first live action is stake(). - §3: APR rule of thumb 4.7% -> 4.4% / width_ticks; refreshed the width grid (width 50 is now ~$0.12/day, ~0.086% APR) and added the width-200 row. - §6.5: replaced the "within 12% of each other" note with the measured 60% gap and the fact that it flipped in one day. - §11: first live action is stake(), then claim/compound. Heartbeats re-measured at the same time and unchanged: cbETH/USD max consecutive gap 1290s over 33.8h, ETH/USD 1234s over 30.6h, both tolerating >= 2 missed rounds under the armed 3600s bounds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The APR/$-per-day figures were a simulation of the backend's own runtime computation, and they went stale within a day of being written (the stake-vs-unstake winner flipped between two consecutive measurements). Yield, cost and payback belong to the backend, which reads them from live state per cycle. §3 is now venue MECHANICS only: fees XOR emissions, income accruing per unit of liquidity rather than capital, and incumbent concentration near spot. The two operational consequences that do not depend on the day's numbers are kept -- the recenter gate often reading TOO_THIN at minWidth, and why the config does not chase 2-6 tick ranges. Removed the pool $/day table, the APR-vs-width grid, the "4.4% / width_ticks" rule of thumb and the stake-wins-by-60% claim. §2, §6.3, §6.5, §9 and §11 updated to point at the backend's live figures instead of the deleted table. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Repo-wide lint config added only to silence MD013 in one editor session. Not worth carrying in the repo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bootstrap pool is WETH/cbBTC, which 011 already sets up and which has not executed. A separate 014 would have duplicated it in everything except the allocation parameter, so 014 and its cbETH/WETH venue are dropped and the parameter lands in 011. 011: - totalAllocationUsd (8-dec USD, default $50k) + setTotalAllocation(target, tol) - validateAllocation(): asserts the registered principal lands inside the band, priced with the balancer's own feeds, staleness bounds and sequencer guard at the pool's live sqrtPriceX96. Leg decimals are asymmetric here -- WETH 18dp, cbBTC 8dp -- so the valuation carries both. - header notes why the size is VALIDATED rather than minted (FPS replays build() as calldata, and the NFPM tokenId is a global counter that advances between simulation and execution). Test: - mint sized from the RANGE GEOMETRY, not a 50/50 value split. At tickSpacing 100 the aligned centre sits up to 99 ticks from spot, where the legs bind ~25/75 and the NFPM refunds about a third of the intended size -- the first version of this test failed for exactly that reason. - test_validate_rejectsWrongAllocation: the non-vacuity control (same position, 2x and 0.5x targets, both must fail). Handbook retargeted to WETH/cbBTC. Substantive venue differences from the discarded cbETH draft: tickSpacing 100 so widths are multiples of 200; the legs have different decimals; the pair is uncorrelated, so the swap path earns its keep rather than being optional; and minWidth == 2*tickSpacing means the R7 branch collision is REACHABLE -- the backend must submit width >= 400 per cycle, which is now called out in the config section, preflight and monitoring. Feed heartbeats re-measured for both legs: ETH/USD max consecutive gap 1234s over 30.6h, BTC/USD 1232s over 52.0h, each tolerating >= 2 missed rounds under the armed 3600s bounds. The measurement recipe now warns about the two traps that produce false over-limit readings (phase-encoded latestRound on the proxy, and non-adjacent rounds at high xargs concurrency). Verified: make lp-v2-setup 5/5, lp-auto-balancer-v2 9/9, test-unit 486/486. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The cbETH venue entries are gone; this drops the stray trailing newline the rewrite added so the file is untouched relative to the base branch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
anajuliabit
left a comment
There was a problem hiding this comment.
Comment, not blocking. The allocation parameter does what it claims: validate size, don't mint in build(). Geometry sizing + the 2×/0.5× non-vacuity test are the real meat. CI is still empty on this head (unstable / no statuses), so I wouldn't merge on that yet.
What holds up
validateAllocation() prices principal with LPValuationLib.principalValue at live sqrtPriceX96, 18/8 decimals, and the proposal's own delay + sequencer config. Zero target is rejected. test_validate_rejectsWrongAllocation actually moves the target, not the position.
_mintAllocationTo sizes off amountsForLiquidityAtTicks then scales (plus 50 bps headroom). Range is center ± 200 (width 400), which matches the handbook's R7 operating width. Rebalance params in the lifecycle test also use 400.
Nits
setTotalAllocationallows 10_000 bps. Thenlois 0, so the under-allocation assert is≥ 0and always passes. Cap below 10000 (or reject 10000) the same way you reject a zero target.setUpstill says the mint uses the "50/50-by-value recipe". The helper does geometry now. That comment will bite the next person.validateAllocationreads feeds/pool/NFPM from the address book, notlab.position(). Fine ifvalidatePositionalready pins those to the balancer; if it doesn't, this can agree with a book that isn't what got registered.- R7 is still a backend rule (
minWidthstays 200). Honest in the handbook; it is not closed onchain. Treat the width PAGE as load-bearing, not documentation flavor. - Test mint still uses
amount0Min/amount1Min= 0. Fine for the fork; don't copy that for the Safe mint.
Did not re-run make lp-v2-setup.
- reject 100% tolerance so the under-allocation bound cannot collapse to 0 - price validateAllocation off the registered balancer, not the address book - fix stale 50/50 mint comment; document fork-only zero mins - note R7 residual on MIN_WIDTH
- reject 100% tolerance so the under-allocation bound cannot collapse to 0 - price validateAllocation off the registered balancer, not the address book - fix stale 50/50 mint comment; document fork-only zero mins - note R7 residual on MIN_WIDTH
- reject 100% tolerance so the under-allocation bound cannot collapse to 0 - price validateAllocation off the registered balancer, not the address book - fix stale 50/50 mint comment; document fork-only zero mins - note R7 residual on MIN_WIDTH
- reject 100% tolerance so the under-allocation bound cannot collapse to 0 - price validateAllocation off the registered balancer, not the address book - fix stale 50/50 mint comment; document fork-only zero mins - note R7 residual on MIN_WIDTH
0xd4669fCee3BE73b51438ad1f1debEB3505Eab308 (EOA, nonce 381 on Base) is now the MAMO_LP_REBALANCER address-book entry, so 011's _resolveRebalancer() picks it up with no setter -- the path a production run actually takes, since the real execution does not call setRebalancerEOA. Adds test_resolvesRebalancerFromAddressBook, which covers exactly that branch: every other test injects a made-up EOA via setRebalancerEOA and therefore never exercises the fallback. Mutation-verified -- pointing the fallback at F-MAMO fails the test, restoring it passes. Handbook: rebalancer EOA added to the venue table, and the preflight role check now names the key it must equal. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A Tenderly vnet may be given any chain id, and the dashboard's default for a Base fork is a 999-prefixed custom id (9998453). The harness died on such a vnet at "expected chain 8453, got '9998453'", and would have died again later even with the assertion relaxed, because forge names its broadcast directory after the LIVE chain id while three call sites hardcoded .../8453/<sig>-latest.json. - chain_sanity now reads the id into $VNET_CHAIN_ID instead of asserting it. EXPECTED_CHAIN opts back into a hard assertion; ADDR_CHAIN stays independent since it selects the ADDRESS BOOK (real Base addresses either way). - New broadcast_artifact() builds the artifact path from $VNET_CHAIN_ID, and dies if called before chain_sanity rather than silently returning a path with an empty segment. - --no-storage-caching stays unconditional; the comment now explains it is only strictly required when the vnet DOES report 8453. The chain id was never what proved the fork was Base -- the callers already assert gauge.nft() == the real NFPM and gauge.rewardToken() == real AERO, which only answer correctly on a Base fork. That assertion is untouched. Verified against a stubbed `cast`: custom id resolves and yields .../9998453/deployAndMint-latest.json; plain 8453 unchanged; EXPECTED_CHAIN mismatch still dies; broadcast_artifact before chain_sanity dies. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Second half of the chain-id fix. Relaxing the shell assertion and deriving the broadcast path got the run as far as deployAndMint(), which then died on `vm.readFile: addresses/9998453.json: No such file`. The harnesses build FPS `Addresses` with `chainIds[0] = block.chainid`, but that constructor argument is not the binding part: FPS's single-argument `getAddress(name)` resolves against `block.chainid` internally (Addresses.sol:110), so on a 9998453 vnet every lookup is `_addresses[name][9998453]` regardless. The options were to rewrite every call site to the two-argument form, or to give that chain id a book. ensure_address_book() copies addresses/$ADDR_CHAIN.json to addresses/$VNET_CHAIN_ID.json for the run and teardown removes it. The copy is faithful -- the vnet is a Base fork, so FPS's isContract validation passes against real code -- and a pre-existing book is left alone rather than clobbered, since addresses/ is not gitignored and a stray file there would read as a real deployment record. Wired into all three orchestrators after chain_sanity. Verified against a stubbed `cast`: 9998453 materializes an identical book and removes it on teardown; 8453 is a no-op that does not touch the real book; a pre-existing book survives; the working tree is clean afterwards. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… position
Two latent breaks in LPV2TenderlyHarness, both surfaced by the audit
remediation that appended expectedTickLower/Upper to RebalanceParams. That
change made _resetParams() read the pool's slot0 to build the tick commitment;
before it, _resetParams() touched no external contract and neither bug bit.
1. MISSING _resolve(). Each forge --sig invocation is a fresh process, so
POOL/NFPM/... start at address(0). checkRoleGating, checkCalmGate and
checkStaleOracle never called _resolve(), so the new slot0() read died with
"call to non-contract address 0x0" before testing anything.
2. PRANK CONSUMED BY AN ARGUMENT CALL. Four sites read
vm.prank(x);
address(lab).call(abi.encodeCall(lab.rebalanceUsingAlt, (_resetParams())));
Solidity evaluates the argument FIRST, so _resetParams()'s slot0() staticcall
consumed the one-shot prank and the call under test ran as the script.
Verified with a throwaway probe: inline, the target sees the script address
rather than the prank target; hoisted, the prank reaches it.
The consequences differed per site and none were benign:
- checkRoleGating asserted a non-REBALANCER is rejected, but was really
testing the script address -- passing for the wrong reason.
- doReset's cooldown check pranks the REAL rebalancer and expects Cooldown;
unpranked it would have reverted AccessControl and failed the assertion.
- checkCalmGate/checkStaleOracle expect TwapDeviation/StaleOracle and would
have failed the same way.
vm.startBroadcast is persistent rather than one-shot, so justReset's identical
shape is unaffected and is left alone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
script/tenderly/lpv2-vnet.json captures the run that went green after the
chain-id and prank fixes: vnet chain id 9998453, fork block 50408613, the three
linked libraries, both scenarios' balancer addresses and position tokenIds, and
the measured invariants (1 wei of liquidity across reset, 11127 wei WETH dust to
feeCollector, cooldown and role gating both blocking, exit returning the full
principal and marking the slot inactive).
NOT addresses/8453.json, deliberately. Those addresses exist only on the vnet --
verified: neither has code on Base mainnet -- and 011 gates its deploy on
`!addresses.isAddressSet("MAMO_LP_AUTO_BALANCER_V2")`, so registering one there
would make the production Safe run skip deployment and wire itself to a contract
that does not exist on mainnet.
The vnet RPC endpoint is not recorded either: a Tenderly vnet URL is effectively
a bearer credential, granting tenderly_setBalance / tenderly_setStorageAt / evm_*
to anyone holding it. The manifest points at TENDERLY_VNET_RPC_URL in .env.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
011 could not be executed from the CLI. `forge script` cannot call setTokenId / setTotalAllocation -- those setters exist for the fork test, which builds the proposal in-process -- so a mainnet `--broadcast` reached build() with tokenId == 0 and reverted "tokenId not set". (Safely: forge simulates the whole script before sending, so the revert cost nothing. But the proposal was un-runnable as written.) run() now calls _loadRunInputs(): INIT_TOKEN_ID required -- the NFT the Safe minted in Phase B2 TOTAL_ALLOCATION_USD optional, 8-dec USD, defaults to the field's $50k ALLOCATION_TOLERANCE_BPS optional, defaults to the field's 500 A value already set via a setter wins, so the fork test is untouched. Kept out of the field initializers on purpose -- those run at construction and would force every in-process caller to define the vars too. test_runLoadsInputsFromEnv pins the wiring with every DO_* stage off, so it tests the INPUT path rather than re-testing the lifecycle. It asserts the defaults differ from the env values first, so it cannot pass vacuously. Mutation-verified: deleting the INIT_TOKEN_ID read fails it (0 != 424242). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deployed from 0xDca82E03057329f53Ed4173429D46B0511E46Fb8 (DEPLOYER_EOA), all five contracts verified on Etherscan v2 (chainid 8453): MAMO_LP_AUTO_BALANCER_V2 0x9CDBe7DB9F967E793E7261e0ffd546E5D29b476f 24,486 B MAMO_LP_COMPOUND_MODULE 0x699318E0641518eF39418a8D86F93BF8b0715c88 7,588 B LPGeometryLib 0x54823d912575bAF4831a0E7a3173321429209a49 3,642 B LPPositionLib 0xC8fB25B6769ea283e6aeF5896A0CD70e9BAd5cE6 6,713 B LPValuationLib 0x8A30d89B720F61c0fF8F1f3558CBFd4e5cCc6368 4,911 B Runtime sizes match the local build; the balancer is 90 bytes under EIP-170. Contracts only -- no position, no roles beyond the constructor. Verified on chain: admin and guardian are both F-MAMO, REBALANCER_ROLE is NOT granted, sequencerUptimeFeed is address(0) and maxOracleDelay0/1 carry the constructor default. The 011 proposal arms all of that; until the Safe executes it the balancer is inert. Only the address book is committed -- /broadcast is gitignored. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first mainnet deployment put admin+guardian on the F-MAMO Safe, which is right for production but wrong for the test position we actually wanted. This adds a separate deploy script and records the fresh test deployment. MAMO_LP_AUTO_BALANCER_V2_TEST 0x9f548228A41d18e2DC736ceF048cfBeC8fFC55E6 MAMO_LP_COMPOUND_MODULE_TEST 0x7B3690d05eDB4305a7F66d52b4BBe9f89F503eF3 admin = guardian = 0xd4669fCee3BE73b51438ad1f1debEB3505Eab308 on both; manager = rebalancer = address(0), granted later by the admin. Verified on chain: the Safe holds NO role on these, and REBALANCER_ROLE is unset. Both verified on Etherscan v2; runtime sizes match the production pair byte for byte (24,486 / 7,588), so this is the same bytecode under different roles. DELIBERATELY NOT a parameterisation of DeployLPAutoBalancerV2. That script hardcodes F-MAMO and registers under MAMO_LP_AUTO_BALANCER_V2 -- the exact key 011.deploy() tests for idempotency. A test address landing there would make the Safe proposal skip deployment and wire itself to an EOA-admin contract. The new script registers under _TEST names and takes its roles from LP_TEST_ADMIN / _GUARDIAN / _MANAGER / _REBALANCER. An EOA admin holds exit(), setPool, setOracles and recoverERC20. Fine for a test position, not for real funds -- the production entries are untouched and still point at the F-MAMO-admin pair. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CoJL5ERdrp3dfVGBYd1NA
The deployed balancer targeted pool 0x70aCDF (tickSpacing 100) from the original Slipstream factory. The team uses the pool from the new factory 0xf8f2eB49: pool 0x42d4a22C (tickSpacing 10), gauge 0x61E0B104. That gauge only accepts NFTs from the new factory's NFPM 0xe1f8cd9A, and the balancer's POSITION_MANAGER is immutable, so a redeploy is required. - addresses: new pool/gauge, add AERODROME_SLIPSTREAM_NFPM_V2, drop the retired 0x9CDBe7/0x699318 deployment (wrong NFPM baked in) - 011 + deploy scripts + tests + tenderly harness: new NFPM name, TICK_SPACING 10 - R7 note: at spacing 10 the collision width (20) is below minWidth (200), so R7 is closed at config level on this pool - integration test dust bound recalibrated (sub-$0.01 dust forwarding is larger in wei terms on the 10ts pool; still 5+ orders below principal) Suites green: LPAutoBalancerV2Integration 9/9, LPAutoBalancerV2SetupTest 7/7 (fork, pinned 47.6M). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NtQXLceAGjL9s71U66pwtj
MAMO_LP_AUTO_BALANCER_V2_TEST 0x6E5d79C41a358BE46D919c87F03683E9D8f48c36 MAMO_LP_COMPOUND_MODULE_TEST 0xEA05B89CA6045f65E5040519354aBca93b695425 Verified on chain: POSITION_MANAGER = 0xe1f8cd9A (new-factory Slipstream NFPM the 10ts gauge accepts), admin = 0xd4669f EOA, module wired to the new balancer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NtQXLceAGjL9s71U66pwtj
The redeployed MAMO_LP_*_TEST contracts landed at block 50_561_856; the Addresses constructor validates every entry against the fork, so the old 47_600_000 pin failed every LPV2 setUp. Re-pin the three suites, derive the lifecycle test's out-of-range bound from the minted range instead of a spot-specific constant, and point the deploy test at AERODROME_SLIPSTREAM_NFPM_V2 to match the script. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CoJL5ERdrp3dfVGBYd1NA
vm.setEnv is process-global. test_runLoadsInputsFromEnv left DO_DEPLOY/DO_BUILD/... at "false", so every proposal built by a later suite in the same forge run skipped deploy() and MultiMarketStrategyTest died on "MARKET_REGISTRY not set". Masked until now because this suite's setUp failed before reaching the setEnv calls. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CoJL5ERdrp3dfVGBYd1NA
Makes the total allocation a configurable, onchain-validated parameter of the WETH/cbBTC setup proposal, and adds the backend handbook for operating the position.
Based on
audit-remediations, notmain:src/LPAutoBalancerV2.soldoes not exist onmainat all, and this callsLPValuationLib.principalValuealongsidesetMaxOracleDelays/setSequencerUptimeFeed, all of which arrived with the audit work.What's here
multisig/mamo-multisig/011_LPAutoBalancerV2Setup.solvalidateAllocation()test/LPAutoBalancerV2Setup.integration.t.solmake lp-v2-setupdocs/LP_AUTO_BALANCER_V2_BACKEND_HANDBOOK.mddocs/LP_AUTO_BALANCER_V2.mdThe parameter
totalAllocationUsd(8-dec USD, default $50k), set per run viasetTotalAllocation(target, toleranceBps).validate()asserts the registered principal lands inside the band, priced with the balancer's own feeds, staleness bounds and sequencer guard at the pool's livesqrtPriceX96— not an independent estimate that could agree by luck. Leg decimals are asymmetric on this pair (WETH 18dp, cbBTC 8dp) and the valuation carries both.It validates the size rather than minting it because FPS records
build()'s actions as calldata and replays them at Safe-execution time. Amintinsidebuild()returns a tokenId observed during simulation, but the Slipstream NFPM tokenId is a global counter that anyone's mint advances in between — the encodedregisterPosition(tokenId)would name someone else's NFT.Reviewer attention
The mint must be sized from the range geometry, not 50/50 by value. A 50/50 split is only correct when spot sits at the range's centre; at
tickSpacing100 the aligned centre can be up to 99 ticks away, at which point the legs bind ~25/75 and the NFPM refunds about a third of the intended size. The first version of this test failed for exactly that reason, ~33% under target. The helper now prices one reference unit of liquidity across the actual[tl, tu]at livesqrtPriceX96and scales to the target, so both legs bind together. Recipe is in handbook §2.1 — worth checking, since anyone doing the production mint follows it.test_validate_rejectsWrongAllocationis the non-vacuity control. Same position, same tolerance, 2x and 0.5x targets, both must revert. Without it a band that accepts everything reads identically to a working one.R7 is live at
minWidthon this venue, and the mitigation is the backend's.minWidthis exactly2 * tickSpacing, where the balanced and token1-single-sided tick pairs collide — so a spot push inside the calm gate can turn a committed two-sided mint into a single-sided one with a zeroed mint minimum, and the tick commitment cannot detect it (it pins the pair, not the branch). 011 deliberately did not close this at config level. The handbook therefore mandateswidth >= 4 * tickSpacing = 400per cycle, asserted in preflight and watched in monitoring. Flagging it because it is a config-level residual discharged by an offchain rule, which is exactly the kind of thing that gets lost.Handbook
Operating-side companion to the existing setup runbook (which stays the Safe-side deployment doc). Venue facts, allocation sizing, venue mechanics, process shape lifted from
mamo-rebalancer, preflightcastcommands, failure playbook, env, go-live.Deliberately no yield or APR projections — those are the backend's, computed from live state per cycle. An earlier draft carried them and the stake-vs-unstake conclusion inverted within a day of being written, which is the argument for keeping mechanics and dropping numbers.
Feed heartbeats measured, not assumed — a slow feed under the armed 3600s bound would brick every priced path. ETH/USD max consecutive gap 1234s over 30.6h; BTC/USD 1232s over 52.0h; each tolerates >= 2 missed rounds. The documented recipe warns about the two traps that produce false over-limit readings:
latestRound()on the proxy is phase-encoded and overflows shell arithmetic, and at highxargs -Psome calls return empty so gaps get computed across non-adjacent rounds. A first pass at-P 20reported a 3662s ETH/USD gap that did not exist.Deployed to Base mainnet
Two deployments of the same bytecode, differing only in who holds the roles. Both verified on Etherscan v2 (chainid 8453).
Test pair — EOA admin (
7412b72)What we actually wanted. Use this one for the test position.
MAMO_LP_AUTO_BALANCER_V2_TEST0x9f548228A41d18e2DC736ceF048cfBeC8fFC55E6MAMO_LP_COMPOUND_MODULE_TEST0x7B3690d05eDB4305a7F66d52b4BBe9f89F503eF3admin = guardian = 0xd4669fCee3BE73b51438ad1f1debEB3505Eab308,manager = rebalancer = address(0)— the admin grantsREBALANCER_ROLEwhen it's needed. Verified on chain: the Safe holds no role here, andREBALANCER_ROLEis unset.Deployed by
script/DeployLPAutoBalancerV2Test.s.sol, which is deliberately not a parameterisation ofDeployLPAutoBalancerV2. That script hardcodes F-MAMO and registers underMAMO_LP_AUTO_BALANCER_V2— the exact key011.deploy()tests for idempotency — so a test address landing there would make the Safe proposal skip deployment and wire itself to an EOA-admin contract. Roles come fromLP_TEST_ADMIN/_GUARDIAN/_MANAGER/_REBALANCER.An EOA admin holds
exit(),setPool,setOraclesandrecoverERC20. Fine for a test position, not for real funds.Production pair — F-MAMO admin (
dbdb5bd)This one is correctly configured for production and is what 011 resolves through; it simply landed earlier than intended.
MAMO_LP_AUTO_BALANCER_V20x9CDBe7DB9F967E793E7261e0ffd546E5D29b476fMAMO_LP_COMPOUND_MODULE0x699318E0641518eF39418a8D86F93BF8b0715c88LPGeometryLib0x54823d912575bAF4831a0E7a3173321429209a49LPPositionLib0xC8fB25B6769ea283e6aeF5896A0CD70e9BAd5cE6LPValuationLib0x8A30d89B720F61c0fF8F1f3558CBFd4e5cCc6368Deployer
0xDca82E03057329f53Ed4173429D46B0511E46Fb8. Runtime sizes match the local build — the balancer is 90 bytes under EIP-170.State read back from chain rather than assumed:
REBALANCER_ROLEis not granted (0xd4669fCe…→false)sequencerUptimeFeedisaddress(0)— the L2 guard is offmaxOracleDelay0/1carry the constructor default (3600), not a proposal-armed valueThe balancer is inert. 011 arms the guards, transfers and registers the position, and grants the rebalancer. Until the Safe executes it there is no position and nothing to protect —
registerPositionis admin-only, so nothing can be registered outside the proposal.MAMO_LP_REBALANCER(0xd4669fCee3BE73b51438ad1f1debEB3505Eab308) is registered in the address book so 011's_resolveRebalancer()fallback grants the role to it./broadcastis gitignored, so only the address book is committed.Running 011 from the CLI
forge scriptcannot callsetTokenId/setTotalAllocation— those setters exist for the fork test, which builds the proposal in-process. A mainnet--broadcasttherefore reachedbuild()withtokenId == 0and reverted "tokenId not set", making the proposal un-runnable as written.run()now readsINIT_TOKEN_ID(required),TOTAL_ALLOCATION_USDandALLOCATION_TOLERANCE_BPSfrom the environment; setters still win, so the fork test is untouched. Pinned bytest_runLoadsInputsFromEnv, mutation-verified.Tenderly vnet run (pre-deployment)
Deployed and driven end to end on a Base-fork vnet, both scenarios green (
script/tenderly/lpv2-vnet.json):reset()feeCollectorfeeCollectorreset()exit()returned to SafeGetting there needed three harness fixes, all in this branch:
9998453.chain_sanityasserted equality; forge's broadcast directory is named after the live id so three orchestrator paths were wrong; and FPS's single-arggetAddress(name)resolves againstblock.chainid(Addresses.sol:110), so the harness went looking foraddresses/9998453.json. Now: the id is read into$VNET_CHAIN_ID, artifact paths derive from it, andensure_address_bookmaterializes a copy of the book for the run and deletes it on exit.EXPECTED_CHAINopts back into a hard assertion._resolve()— eachforge --sigrun is a fresh process, soPOOLwasaddress(0)and the phase died onslot0().vm.prankwas being eaten by an argument-position call at four sites:abi.encodeCall(lab.rebalanceUsingAlt, (_resetParams()))evaluates_resetParams()first, and itsslot0()staticcall consumed the prank, so the call under test ran as the script. Verified with a throwaway probe rather than assumed.checkRoleGatinghad been passing for the wrong reason;doReset's cooldown check and the calm-gate/stale-oracle checks would have failed outright.Both latent bugs were dormant until the audit remediation appended
expectedTickLower/UppertoRebalanceParams— that made_resetParams()read the pool for the first time.No vnet address goes into
addresses/8453.json. They have no code on Base mainnet (verified) and a fresh vnet reassigns them; more importantly 011 gates its deploy on!addresses.isAddressSet("MAMO_LP_AUTO_BALANCER_V2"), so registering one would make the production Safe run skip deployment and wire itself to a nonexistent contract. The manifest also omits the vnet RPC URL, which is effectively a bearer credential for the cheat RPCs.Verification
make lp-v2-setup5/5,lp-auto-balancer-v29/9,make test-unit486/486.make testaborts withMissing operator fee scalar for isthmus L1 Block. Pre-existing — it comes from the self-forking suites that predate this branch (LPAutoBalancerV2SetupTest,LPAutoBalancerV2Integration,DeployLPAutoBalancerV2Test,ERC20StrategyV2Test) colliding with--fork-url; excluding those four makes it pass. Unchanged by this PR.forge fmtwas not run. Local forge is 1.7.1; CI pins nightlye520767, and they wrap tuple destructuring and long-string returns oppositely. Runfoundryup -i nightly-e52076714ace23c7a68e14f0048a40be3c6c8f0b && forge fmtbefore merge if you want it canonical.🤖 Generated with Claude Code