Move the orphaned NatSpec block (originally for calculateParams) from
above getLiquidityParams to directly precede calculateParams, and give
getLiquidityParams only its own @inheritdoc block.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Optimizer: add `is IOptimizer` and mark getLiquidityParams() with
`override`, making the interface conformance explicit at the base level.
OptimizerV3 inherits it transitively via Optimizer.
- OptimizerV3Push3: add `is IOptimizer` and implement getLiquidityParams()
that calls calculateParams() with zeroed inputs, returning bear-mode
defaults (ci=0, anchorShare=0.3e18, anchorWidth=100, discoveryDepth=0.3e18).
Behaviour is identical to the previous try/catch fallback used by
LiquidityManager and the backtesting deployer.
- Update backtesting comments to reflect that getLiquidityParams() now
exists on OptimizerV3Push3 (returns bear defaults via zeroed inputs).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Extract magic number into named constant MAX_ANCHOR_WIDTH = 100 in LiquidityManager.sol
- Document effective ceiling in IOptimizer.sol natspec for anchorWidth return value
- Add testAnchorWidthAbove100IsClamped in LiquidityManager.t.sol asserting that
optimizer-returned anchorWidth=150 is silently clamped to 100 (not rejected)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add IOptimizer interface with getLiquidityParams() signature to IOptimizer.sol
so upgrade-compatibility is explicit and static analysis can catch ABI mismatches.
Update LiquidityManager to hold optimizer as IOptimizer instead of concrete Optimizer.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fixes#635
## Changes
The implementation is complete and committed. All 211 tests pass.
## Summary of changes
### `onchain/src/Optimizer.sol`
- **Replaced raw slot inputs** with normalized indicators in `getLiquidityParams()`:
- Slot 2 `pricePosition`: where current price sits within VWAP ± 11 000 ticks (0 = lower bound, 0.5e18 = at VWAP, 1e18 = upper bound)
- Slot 3 `volatility`: `|shortTwap − longTwap| / 1000 ticks`, capped at 1e18
- Slot 4 `momentum`: 0 = falling, 0.5e18 = flat, 1e18 = rising (5-min vs 30-min TWAP delta)
- Slot 5 `timeSinceRecenter`: `elapsed / 86400s`, capped at 1e18
- Slot 6 `utilizationRate`: 1e18 if current tick is within anchor position range, else 0
- **Extended `setDataSources()`** to accept `liquidityManager` + `token0isWeth` (needed for correct tick direction in momentum/utilizationRate)
- **Added `_vwapToTick()`** helper: converts `vwapX96 = price × 2⁹⁶` to tick via `sqrt(vwapX96) << 48`, with TickMath bounds clamping
- All slots gracefully default to 0 when data sources are unconfigured or TWAP history is insufficient (try/catch on `pool.observe()`)
### `onchain/src/OptimizerV3Push3.sol`
- Updated NatSpec to document the new `[0, 1e18]` slot semantics
### New tests (`onchain/test/`)
- `OptimizerNormalizedInputsTest`: 18 tests covering all new slots, token ordering, TWAP fallback, and a bounded fuzz test
- `mocks/MockPool.sol`: configurable `slot0()` + `observe()` with TWAP tick math
- `mocks/MockLiquidityManagerPositions.sol`: configurable anchor position bounds
Co-authored-by: openhands <openhands@all-hands.dev>
Reviewed-on: https://codeberg.org/johba/harb/pulls/649
Reviewed-by: review_bot <review_bot@noreply.codeberg.org>
Three defensive layers so every Push3 program runs without reverting:
Layer A (transpiler/index.ts): assign bear defaults (CI=0, AS=0.3e18,
AW=100, DD=0.3e18) to all four outputs at the top of calculateParams.
Any output the evolved program does not overwrite keeps the safe default.
Layer B (transpiler/transpiler.ts): graceful stack underflow — dpop/bpop
return '0'/'false' instead of throwing, and the final output-pop falls
back to bear-default literals when fewer than 4 values remain on the
stack. Wrong output count no longer aborts transpilation.
Layer C (transpiler/transpiler.ts + index.ts): wrap the entire function
body in `unchecked {}` so integer overflow wraps (matching Push3), and
emit `(b == 0 ? 0 : a / b)` for every DYADIC./ (div-by-zero → 0,
matching Push3 no-op semantics).
Layer 2 (Optimizer.sol getLiquidityParams): clamp the three fraction
outputs (capitalInefficiency, anchorShare, discoveryDepth) to [0, 1e18]
after abi.decode so a buggy evolved program cannot produce out-of-range
values even if it runs without reverting.
Regenerated OptimizerV3Push3.sol with the updated transpiler; all 193
tests pass (34 Optimizer/OptimizerV3Push3 tests explicitly).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Optimizer.sol: move CALCULATE_PARAMS_GAS_LIMIT constant to top of
contract (after error declaration) to avoid mid-contract placement.
Expand natspec with EIP-150 63/64 note: callers need ~203 175 gas to
deliver the full 200 000 budget to the inner staticcall.
- Optimizer.sol: add ret.length < 128 guard before abi.decode in
getLiquidityParams(). Malformed return data (truncated / wrong ABI)
from an evolved program now falls back to _bearDefaults() instead of
propagating an unhandled revert. The 128-byte minimum is the ABI
encoding of (uint256, uint256, uint24, uint256) — four 32-byte slots.
- Optimizer.sol: add cross-reference comment to _bearDefaults() noting
that its values must stay in sync with LiquidityManager.recenter()'s
catch block to prevent silent divergence.
- FitnessEvaluator.t.sol: add CALCULATE_PARAMS_GAS_LIMIT mirror constant
(must match Optimizer.sol). Disqualify candidates whose measured gas
exceeds the production cap with fitness=0 and error="gas_over_limit"
— prevents the pipeline from selecting programs that are functionally
dead on-chain (would always produce bear defaults in production).
- batch-eval.sh: update output format comment to document the gas_used
field and over-gas-limit error object added by this feature.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Optimizer.getLiquidityParams() now forwards calculateParams through a
staticcall capped at 200 000 gas. Programs that exceed the budget or
revert fall back to bear defaults (CI=0, AS=30%, AW=100, DD=0.3e18),
so a bloated evolved optimizer can never OOG-revert inside recenter().
- FitnessEvaluator.t.sol measures gas used by calculateParams against
fixed representative inputs (50% staked, 5% avg tax) after each
bootstrap. A soft penalty of GAS_PENALTY_FACTOR (1e13 wei/gas) is
subtracted from total fitness before the JSON score line is emitted.
Leaner programs win ties; gas_used is included in the output for
observability. At ~15k gas (current seed) the penalty is ~1.5e17 wei;
at the 200k hard cap boundary it reaches ~2e18 wei.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove unreachable else branch in VWAP recording logic. The branch was
only reachable if lastRecenterTick==0 and cumulativeVolume>0, which
requires tick==0 on the very first recenter — virtually impossible on a
live pool. Collapse else-if into else and delete the corresponding
testVWAPElseBranch test that exercised the path via vm.store.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fix the @return NatSpec for recenter() isUp: the previous description
was wrong for the token0=WETH ordering (claimed tick above center, but
the actual check is currentTick < centerTick when token0isWeth). The
correct invariant is isUp=true ↔ KRK price in ETH rose (buy event /
net ETH inflow), regardless of token ordering.
Also address review nit: StrategyExecutor._logRecenter() now logs
'direction=BOOTSTRAP' instead of 'direction=DOWN' when no anchor
position existed before the recenter (aLiqPre==0), eliminating the
misleading directional label on the first recenter.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add NatSpec to recenter() documenting that the function always reverts
on failure (never silently returns false), listing all four revert
conditions, and clarifying that both true/false return values represent
a successfully-executed recenter with the value indicating price
direction (up vs down relative to previous anchor centre).
Also fix StrategyExecutor.maybeRecenter() to capture the isUp return
value from lm.recenter() and include it in the log output, making
price direction visible in backtesting replays.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- LiquidityManager.setFeeDestination: add CREATE2 bypass guard — also
blocks re-assignment when the current feeDestination has since acquired
bytecode (was a plain address when set, contract deployed to it later)
- LiquidityManager.setFeeDestination: expand NatSpec to document the
EOA-mutability trade-off and the CREATE2 guard explicitly
- Test: add testSetFeeDestinationEOAToContract_Locks covering the
realistic EOA→contract transition (the primary lock-activation path)
- red-team.sh: add comment that DEPLOYER_PK is Anvil account-0 and must
only be used against a local ephemeral Anvil instance
- ARCHITECTURE.md: document feeDestination conditional-lock semantics and
contrast with Kraiken's strictly set-once liquidityManager/stakingPool
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add `feeDestinationLocked` bool to LiquidityManager
- Replace one-shot setter with conditional trapdoor: EOAs may be set
repeatedly, but setting a contract address locks permanently
- Remove `AddressAlreadySet` error (superseded by the new lock mechanic)
- Replace fragile SLOT7 storage hack in red-team.sh with a proper
`setFeeDestination()` call using the deployer key
- Update tests: replace AddressAlreadySet test with three new tests
covering EOA multi-set, contract lock, and locked revert
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add require(mantissa >= 0) guards in calculateParams before the uint256()
casts on inputs[0] and inputs[1], preventing negative int256 values from
wrapping to huge uint256 numbers and corrupting liquidity calculations.
Add two regression tests covering the revert paths for both slots.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace pool.observe() TWAP price source with current pool tick (pool.slot0()) sampled once per recenter
- Remove _getTWAPOrFallback() and TWAPFallback event (added by PR #575)
- _scrapePositions now takes int24 currentTick instead of uint256 prevTimestamp; price computed via _priceAtTick before the burn loop
- Volume weighting (ethFee * 100) is unchanged — fees proxy swap volume over the recenter interval
- Direction fix from #566 (shouldRecordVWAP only on sell events) is preserved
- Remove test_twapReflectsAveragePriceNotJustLastSwap (tested reverted TWAP behaviour)
- ORACLE_CARDINALITY / increaseObservationCardinalityNext retained for _isPriceStable()
- All 188 tests pass
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add virtual to Optimizer.calculateParams() for UUPS override
- Create OptimizerV3.sol: UUPS-upgradeable optimizer with transpiled Push3 logic
- Update deploy-optimizer.sh to deploy OptimizerV3 instead of Optimizer
- Add ~/.foundry/bin to PATH in evolve.sh, fitness.sh, deploy-optimizer.sh
- Fix unsafe int32 intermediate cast: int56(int32(elapsed)) → int56(uint56(elapsed))
to prevent TWAP tick sign inversion for intervals above int32 max (~68 years)
- Remove redundant lastRecenterTimestamp state variable; capture prevTimestamp
from existing lastRecenterTime instead (saves ~20k gas per recenter)
- Use pool.increaseObservationCardinalityNext(ORACLE_CARDINALITY) in constructor
instead of recomputing the pool address; extract magic 100 to named constant
- Add TWAPFallback(uint32 elapsed) event emitted when pool.observe() reverts
so monitoring can distinguish degraded operation from normal bootstrap
- Remove conditional bypass paths in test_twapReflectsAveragePriceNotJustLastSwap;
assert vwapAfter > 0 and vwapAfter > initialPriceX96 unconditionally
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add lastRecenterTimestamp to track recenter interval for TWAP
- Increase pool observation cardinality to 100 in constructor
- In _scrapePositions, use pool.observe([elapsed, 0]) to get TWAP tick
over the full interval between recenters; falls back to anchor midpoint
when elapsed==0 or pool.observe() reverts (insufficient history)
- Add test_twapReflectsAveragePriceNotJustLastSwap: verifies TWAP-based
VWAP reflects the average price across the recenter interval, not just
the last-swap anchor snapshot
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- _scrapePositions natspec: 'ETH inflow' → 'ETH outflow / price fell or at bootstrap'
- Inline comment above VWAP block: remove inverted 'KRK sold out / ETH inflow' rationale,
replace with a neutral forward-reference to recenter() where the direction logic lives
- VWAPFloorProtection.t.sol: remove unused Kraiken and forge-std/Test.sol imports
(both are already provided by UniSwapHelper)
- test_floorConservativeAfterBuyOnlyAttack: add assertFalse(token0isWeth) guard so a
future change to the setUp parameter cannot silently invert the gap-direction assertion
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Investigation findings:
- VWAP WAS being fed during buy-only cycles (shouldRecordVWAP = true on ETH inflow / price rising).
Over 80 buy-recenter cycles VWAP converged toward the inflated current price.
- When VWAP ≈ currentTick, mirrorTick = currentTick + vwapDistance ≈ currentTick, placing
the floor near the inflated price. Adversary sells back through the high floor, extracting
nearly all LM ETH.
- Optimizer parameters (anchorShare, CI) were not the primary cause.
Fix (LiquidityManager.sol):
Flip shouldRecordVWAP from buy direction to sell direction. VWAP is now recorded only when
price falls (ETH outflow / sell events) or at initial bootstrap (cumulativeVolume == 0).
Buy-only attack cycles leave VWAP frozen at the historical baseline, keeping mirrorTick and
the floor conservatively anchored far from the inflated current price.
Also updated onchain/AGENTS.md to document the corrected recording direction.
Regression test (VWAPFloorProtection.t.sol):
- test_vwapNotInflatedByBuyOnlyAttack: asserts getVWAP() stays at bootstrap after N buy cycles.
- test_floorConservativeAfterBuyOnlyAttack: asserts floor center is far below inflated tick.
- test_vwapBootstrapsOnFirstFeeEvent: confirms bootstrap path unchanged.
- test_recenterSucceedsOnSellDirectionWithoutReverts: confirms sell-direction recenters work.
All 187 tests pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When feeDestination == address(this), _scrapePositions() now skips the
fee safeTransfer calls so collected WETH/KRK stays in the LM balance
and is redeployed as liquidity on the next _setPositions() call.
Also fixes _getOutstandingSupply(): kraiken.outstandingSupply() already
subtracts balanceOf(liquidityManager), so when feeDestination IS the LM
the old code double-subtracted LM-held KRK, causing an arithmetic
underflow once positions were scraped. The subtraction is now skipped
for the self-referencing case.
VWAP recording is refactored to a single unconditional block so it fires
regardless of fee destination.
New test testSelfFeeDestination_FeesAccrueAsLiquidity() demonstrates
that a two-recenter cycle with self-feeDestination completes without
underflow and without leaking WETH to any external address.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add PositionTracker.sol: tracks position lifecycle (open/close per
recenter), records tick ranges, liquidity, entry/exit blocks/timestamps,
token amounts (via LiquidityAmounts math), fees (proportional to
liquidity share), IL (LP exit value − HODL value at exit price), and
net P&L per position. Aggregates total fees, cumulative IL, net P&L,
rebalance count, Anchor time-in-range, and capital efficiency accumulators.
Logs with [TRACKER][TYPE] prefix; emits cumulative P&L every 500 blocks.
- Modify StrategyExecutor.sol: add IUniswapV3Pool + token0isWeth to
constructor (creates PositionTracker internally), call
tracker.notifyBlock() on every block for time-in-range, and call
tracker.recordRecenter() on each successful recenter. logSummary()
now delegates to tracker.logFinalSummary().
- Modify BacktestRunner.s.sol: pass sp.pool and token0isWeth to
StrategyExecutor constructor; log tracker address.
- forge fmt: reformat all backtesting scripts and affected src/test files
to project style (number_underscore=thousands, multiline_func_header=all).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Check pos.creationTime == 0 before pos.owner != msg.sender so that
calling exitPosition on a non-existent position correctly reverts with
PositionNotFound instead of the misleading NoPermission(caller, 0x0).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add `require(averageTaxRate <= 1e18, "Invalid tax rate")` to match
the existing `percentageStaked` guard and prevent silent acceptance
of out-of-range values.
- Expand contract-level NatSpec with a @dev note clarifying this is an
equivalence proof only: it intentionally exposes `isBullMarket` alone
and is not a deployable upgrade (full optimizer interface missing).
All 15 Foundry tests pass (15 unit + fuzz).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PROBLEM:
Recenter operations were burning ~137,866 KRK tokens instead of minting
them, causing severe deflation when inflation should occur. This was due
to the liquidity manager burning ALL collected tokens from old positions
and then minting tokens for new positions separately, causing asymmetric
supply adjustments to the staking pool.
ROOT CAUSE:
During recenter():
1. _scrapePositions() collected tokens from old positions and immediately
burned them ALL (+ proportional staking pool adjustment)
2. _setPositions() minted tokens for new positions (+ proportional
staking pool adjustment)
3. The burn and mint operations used DIFFERENT totalSupply values in
their proportion calculations, causing imbalanced adjustments
4. When old positions had more tokens than new positions needed, the net
result was deflation
WHY THIS HAPPENED:
When KRK price increases (users buying), the same liquidity depth
requires fewer KRK tokens. The old code would:
- Burn 120k KRK from old positions (+ 30k from staking pool)
- Mint 10k KRK for new positions (+ 2.5k to staking pool)
- Net: -137.5k KRK total supply (WRONG!)
FIX:
1. Modified uniswapV3MintCallback() to use existing KRK balance first
before minting new tokens
2. Removed burn() from _scrapePositions() - keep collected tokens
3. Removed burn() from end of recenter() - don't burn "excess"
4. Tokens held by LiquidityManager are already excluded from
outstandingSupply(), so they don't affect staking calculations
RESULT:
Now during recenter, only the NET difference is minted or used:
- Collect old positions into LiquidityManager balance
- Use that balance for new positions
- Only mint additional tokens if more are needed
- Keep any unused balance for future recenters
- No more asymmetric burn/mint causing supply corruption
VERIFICATION:
- All 107 existing tests pass
- Added 2 new regression tests in test/SupplyCorruption.t.sol
- testRecenterDoesNotCorruptSupply: verifies single recenter preserves supply
- testMultipleRecentersPreserveSupply: verifies no accumulation over time
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Replace hardcoded anchorWidth=100 with dynamic calculation that uses staking data as a decentralized oracle.
Changes:
- Add _calculateAnchorWidth() function to Optimizer.sol
- Base width 40% with adjustments based on staking percentage and average tax rate
- Staking adjustment: -20% to +20% (inverse relationship)
- Tax rate adjustment: -10% to +30% (direct relationship)
- Final range clamped to 10-80% for safety
Rationale:
- High staking % = bullish sentiment → narrower anchor (20-35%) for fee optimization
- Low staking % = bearish/uncertain → wider anchor (60-80%) for defensive positioning
- High tax rates = volatility expected → wider anchor to reduce rebalancing
- Low tax rates = stability expected → narrower anchor for fee collection
The Harberger tax mechanism acts as a prediction market where stakers' self-assessed valuations reveal market expectations.
Tests:
- Add comprehensive unit tests in test/Optimizer.t.sol
- Add mock contracts for testing (MockStake.sol, MockKraiken.sol)
- Manual verification confirms all scenarios calculate correctly
Documentation:
- Add detailed analysis of anchorWidth price ranges
- Add staking-based strategy recommendations
- Add verification of calculation logic
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
- Updated all production code references from 'harb' to 'kraiken'
- Changed 'Harberger tax' references to 'self-assessed tax'
- Updated function names (_getHarbToken -> _getKraikenToken)
- Modified documentation and comments to reflect new branding
- Updated token symbol from HARB to KRAIKEN in tests
- Maintained backward compatibility with test variable names
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
The discovery position was incorrectly calculating ETH amount instead
of KRAIKEN amount when determining how much to subtract from outstanding
supply. This caused the floor position to be placed at extreme ticks
(141k+) instead of bordering the anchor position.
When token0isWeth=true:
- Before: discoveryAmount = getAmount0 (ETH amount)
- After: discoveryAmount = getAmount1 (KRAIKEN amount)
This ensures the outstanding supply calculation properly excludes all
KRAIKEN tokens locked in liquidity positions.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
The VWAP tracker stores price² (squared price) in X96 format, but the floor position
calculation was using it as regular price. This caused two issues:
1. ETH scarcity calculation overestimated required ETH by using price² instead of price
2. ETH abundance floor placement was incorrect due to passing price² to _tickAtPriceRatio
Fixed by taking sqrt(vwapX96) before using it in both ETH scarcity and abundance cases.
Also updated BullMarketOptimizer documentation to be more accurate.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>