Skip to content

feat: total-allocation parameter for the WETH/cbBTC LP balancer + backend handbook - #83

Open
anajuliabit wants to merge 27 commits into
audit-remediationsfrom
feat/lp-v2-total-allocation
Open

feat: total-allocation parameter for the WETH/cbBTC LP balancer + backend handbook#83
anajuliabit wants to merge 27 commits into
audit-remediationsfrom
feat/lp-v2-total-allocation

Conversation

@anajuliabit

@anajuliabit anajuliabit commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

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, not main: src/LPAutoBalancerV2.sol does not exist on main at all, and this calls LPValuationLib.principalValue alongside setMaxOracleDelays / setSequencerUptimeFeed, all of which arrived with the audit work.

Supersedes #82, which GitHub auto-closed when this branch was renamed off feat/lp-v2-cbeth-bootstrap. Same commits, same diff.

History note. This branch first built the parameter as a separate proposal 014 against a cbETH/WETH pool. The bootstrap pool is WETH/cbBTC — which 011 already sets up and which has not executed — so 014 would have duplicated 011 in everything except the parameter. 014 and the cbETH venue are removed in 030c351; review the branch as a whole, not commit by commit.

What's here

File What
multisig/mamo-multisig/011_LPAutoBalancerV2Setup.sol The allocation parameter + validateAllocation()
test/LPAutoBalancerV2Setup.integration.t.sol Geometry-sized mint + the non-vacuity control — make lp-v2-setup
docs/LP_AUTO_BALANCER_V2_BACKEND_HANDBOOK.md New: backend handbook for WETH/cbBTC
docs/LP_AUTO_BALANCER_V2.md Doc-map entry

The parameter

totalAllocationUsd (8-dec USD, default $50k), set per run via setTotalAllocation(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 live sqrtPriceX96 — 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. A mint inside build() returns a tokenId observed during simulation, but the Slipstream NFPM tokenId is a global counter that anyone's mint advances in between — the encoded registerPosition(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 tickSpacing 100 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 live sqrtPriceX96 and 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_rejectsWrongAllocation is 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 minWidth on this venue, and the mitigation is the backend's. minWidth is exactly 2 * 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 mandates width >= 4 * tickSpacing = 400 per 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, preflight cast commands, 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 high xargs -P some calls return empty so gaps get computed across non-adjacent rounds. A first pass at -P 20 reported 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.

Contract Address
MAMO_LP_AUTO_BALANCER_V2_TEST 0x9f548228A41d18e2DC736ceF048cfBeC8fFC55E6
MAMO_LP_COMPOUND_MODULE_TEST 0x7B3690d05eDB4305a7F66d52b4BBe9f89F503eF3

admin = guardian = 0xd4669fCee3BE73b51438ad1f1debEB3505Eab308, manager = rebalancer = address(0) — the admin grants REBALANCER_ROLE when it's needed. Verified on chain: the Safe holds no role here, and REBALANCER_ROLE is unset.

Deployed by script/DeployLPAutoBalancerV2Test.s.sol, which is 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 — so a test address landing there would make the Safe proposal skip deployment and wire itself to an EOA-admin contract. Roles come 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.

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.

Contract Address Runtime
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

Deployer 0xDca82E03057329f53Ed4173429D46B0511E46Fb8. Runtime sizes match the local build — the balancer is 90 bytes under EIP-170.

State read back from chain rather than assumed:

  • admin and guardian are both F-MAMO
  • REBALANCER_ROLE is not granted (0xd4669fCe…false)
  • sequencerUptimeFeed is address(0) — the L2 guard is off
  • maxOracleDelay0/1 carry the constructor default (3600), not a proposal-armed value
  • the module is bound to the balancer, module admin is F-MAMO

The 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 — registerPosition is 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. /broadcast is gitignored, so only the address book is committed.

Running 011 from the CLI

forge script cannot call setTokenId / setTotalAllocation — those setters exist for the fork test, which builds the proposal in-process. A mainnet --broadcast therefore reached build() with tokenId == 0 and reverted "tokenId not set", making the proposal un-runnable as written. run() now reads INIT_TOKEN_ID (required), TOTAL_ALLOCATION_USD and ALLOCATION_TOLERANCE_BPS from the environment; setters still win, so the fork test is untouched. Pinned by test_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):

Measurement Value
liquidity across reset() 179715587681210 → 179715587681209 (1 wei)
WETH dust to feeCollector 11127 wei
cbBTC dust to feeCollector 0
cooldown blocks 2nd reset() yes
role gating blocks reset/exit/stake yes
exit() returned to Safe 1999999999999982368 WETH, slot inactive

Getting there needed three harness fixes, all in this branch:

  • Chain id was pinned to 8453 in four places. A Tenderly vnet may carry any chain id and the dashboard defaults a Base fork to 9998453. chain_sanity asserted equality; forge's broadcast directory is named after the live id so three orchestrator paths were wrong; and FPS's single-arg getAddress(name) resolves against block.chainid (Addresses.sol:110), so the harness went looking for addresses/9998453.json. Now: the id is read into $VNET_CHAIN_ID, artifact paths derive from it, and ensure_address_book materializes a copy of the book for the run and deletes it on exit. EXPECTED_CHAIN opts back into a hard assertion.
  • Three entrypoints never called _resolve() — each forge --sig run is a fresh process, so POOL was address(0) and the phase died on slot0().
  • A pending vm.prank was being eaten by an argument-position call at four sites: abi.encodeCall(lab.rebalanceUsingAlt, (_resetParams())) evaluates _resetParams() first, and its slot0() staticcall consumed the prank, so the call under test ran as the script. Verified with a throwaway probe rather than assumed. checkRoleGating had 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/Upper to RebalanceParams — 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-setup 5/5, lp-auto-balancer-v2 9/9, make test-unit 486/486.
  • make test aborts with Missing 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 fmt was not run. Local forge is 1.7.1; CI pins nightly e520767, and they wrap tuple destructuring and long-string returns oppositely. Run foundryup -i nightly-e52076714ace23c7a68e14f0048a40be3c6c8f0b && forge fmt before merge if you want it canonical.

🤖 Generated with Claude Code

anajuliabit and others added 7 commits August 21, 2026 11:41
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 anajuliabit left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • setTotalAllocation allows 10_000 bps. Then lo is 0, so the under-allocation assert is ≥ 0 and always passes. Cap below 10000 (or reject 10000) the same way you reject a zero target.
  • setUp still says the mint uses the "50/50-by-value recipe". The helper does geometry now. That comment will bite the next person.
  • validateAllocation reads feeds/pool/NFPM from the address book, not lab.position(). Fine if validatePosition already 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 (minWidth stays 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.

anajuliabit and others added 20 commits August 24, 2026 11:03
- 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>
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
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