Single-cycle attack extracts 21.3 ETH (2.13%) from 1000 ETH LM:
buy 31.9 ETH → recenter → sell all KRK
Key finding: thin pre-recenter positions allow massive price impact,
recenter rebuilds deep positions at manipulated price, sell through
deep positions recovers most ETH. IL crystallized during recenter.
This is the optimal single-buy amount — 31.95+ hits max tick,
<31 ETH extracts proportionally less.
- DeployBase.sol: remove broken inline second recenter() (would always
revert with 'recenter cooldown' in same Forge broadcast); replace with
operator instructions to run the new BootstrapVWAPPhase2.s.sol script
at least 60 s after deployment
- BootstrapVWAPPhase2.s.sol: new script for the second VWAP bootstrap
recenter on Base mainnet deployments
- StrategyExecutor.sol: update stale docstring that still described the
removed recenterAccess bypass; reflect permissionless model with vm.warp
- TestBase.sol: remove vestigial recenterCaller parameter from all four
setupEnvironment* functions (parameter was silently ignored after
setRecenterAccess was removed); update all callers across six test files
- bootstrap-common.sh: fix misleading retry recenter in
seed_application_state() — add evm_increaseTime 61 before evm_mine so
the recenter cooldown actually clears and the retry can succeed
All 210 tests pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add inline Basescan URL comment identifying V3_FACTORY as the Uniswap V3
Factory on Base mainnet, consistent with the existing comment style used
for NPM_ADDR in both files.
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>
Replace 0x27F971cb582BF9E50F397e4d29a5C7A34f11faA2 (Base Sepolia
NonfungiblePositionManager) with the correct Base mainnet address
0x03a520B32c04bf3beef7BEb72E919cF822Ed34F3 in all four files that
referenced it, and add an inline comment citing the chain and source.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
vm.warp in forge script --broadcast only affects the local simulation
phase, not the actual Anvil node. The pool.observe([300,0]) call in
recenter() therefore reverted with OLD when Forge pre-flighted the
broadcast transactions on Anvil.
Fix:
- Remove the vm.warp + 2-recenter + SeedSwapper VWAP bootstrap from
DeployLocal.sol (only contract deployment now, simpler and reliable).
- Add bootstrap_vwap() to bootstrap-common.sh that uses Anvil RPC
evm_increaseTime + evm_mine to advance chain time before each recenter,
then executes a 0.5 ETH WETH->KRK seed swap between them.
- Call bootstrap_vwap() before fund_liquidity_manager() in both
containers/bootstrap.sh and ci-bootstrap.sh so the LM is seeded with
thin positions (1 ETH) during bootstrap, ensuring the 0.5 ETH swap
moves the price >400 ticks (amplitude gate).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Forge resets block.timestamp to its pre-warp value after each state-changing
call (e.g. recenter()). The second vm.warp(block.timestamp + 301) in the VWAP
bootstrap was therefore warping to the same timestamp as the first warp, so
lastRecenterTime + 60 > block.timestamp and the second recenter() reverted
with "recenter cooldown".
Fix: store ts = block.timestamp + 301 before the first warp and increment it
explicitly (ts += 301) before the second warp, mirroring the same pattern
applied to VWAPFloorProtection.t.sol and SupplyCorruption.t.sol.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SPDX license:
- Restore GPL-3.0-or-later SPDX header to DeployBase.sol (removed by
the em-dash sed fix in an earlier commit).
SeedSwapper deduplication:
- Extract SeedSwapper into onchain/script/DeployCommon.sol — a single
canonical definition shared by both deploy scripts. This eliminates
duplicate Foundry artifacts (previously both DeployLocal.sol and
DeployBase.sol produced a SeedSwapper artifact, causing ambiguity for
verification and coverage tools).
- Remove inline SeedSwapper and redundant IWETH9 import from
DeployLocal.sol and DeployBase.sol; add `import "./DeployCommon.sol"`.
SeedSwapper hardening (in DeployCommon.sol):
- Replace magic-literal price sentinels with named constants
SQRT_PRICE_LIMIT_MIN / SQRT_PRICE_LIMIT_MAX.
- Wrap both weth.transfer() calls with require() so a non-standard
WETH9 false-return is caught rather than silently ignored.
- Add post-swap WETH sweep in executeSeedBuy(): if the price limit is
reached before the full input is spent, the residual WETH balance is
returned to `recipient` instead of being stranded in the contract.
bootstrap-common.sh:
- Normalise cumulativeVolume output through `cast to-dec` before the
string comparison, guarding against a future change in cast output
format (decimal vs hex).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The 0.01 ETH seed swap only moved the tick 127 ticks from the start
and 37 ticks from the ANCHOR center — far below the 400-tick minimum
amplitude (2 × TICK_SPACING). As a result, the second recenter()
always reverted with "amplitude not reached", preventing VWAP bootstrap.
Root cause: SEED_SWAP_ETH was 1 % of SEED_LM_ETH. The ANCHOR
position holds ~25 % of SEED_LM_ETH as WETH across ~7 200 ticks, so
consuming half of that WETH (≈0.125 ETH) is already enough to move
the price 3 600 ticks past centre.
Fix: raise SEED_SWAP_ETH from 0.01 ether to 0.5 ether (50 % of
SEED_LM_ETH), giving a 4× margin over the minimum required. Verified
against a Base-Sepolia fork at block 20 000 000 (same environment as
CI): VWAP is now bootstrapped and cumulativeVolume > 0 after deployment.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Deploy scripts (DeployLocal.sol and DeployBase.sol) now execute a
seed buy + double-recenter sequence before handing control to users:
1. Temporarily grant deployer recenterAccess (via self as feeDestination)
2. Fund LM with a small amount and call recenter() -> places thin positions
3. SeedSwapper executes a small buy, generating a non-zero WETH fee
4. Second recenter() hits the cumulativeVolume==0 bootstrap path with
ethFee>0 -> _recordVolumeAndPrice fires -> cumulativeVolume>0
5. Revoke recenterAccess and restore the real feeDestination
After deployment, cumulativeVolume>0, so the bootstrap path is
unreachable by external users and cannot be front-run by an attacker
inflating the initial VWAP anchor with a whale buy.
Also adds:
- tools/deploy-optimizer.sh: verification step checks cumulativeVolume>0
after a fresh local deployment
- test_vwapBootstrappedBySeedTrade() in VWAPFloorProtection.t.sol:
confirms the deploy sequence (recenter + buy + recenter) leaves
cumulativeVolume>0 and getVWAP()>0
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>
Add `buy_recenter_loop` batch op to AttackRunner — executes N×(buy→recenter)
cycles in a single Solidity loop, emitting snapshots after each recenter.
Rewrite il-crystallization-80.jsonl from 153 individual JSONL steps to 2 lines
using the new op with count=80, matching the intended attack name. Also corrects
the cycle count from 76 (previous file) to the intended 80.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
After a buy→sell round-trip the net price movement is near zero, so
recenter() reverts with "amplitude not reached" and aborts the whole
AttackRunner script.
Wrap the recenter() call in a try/catch so amplitude failures are
caught and logged as a skipped step rather than propagating as a fatal
revert. When recenter is skipped, no state snapshot is emitted and the
attack sequence continues — matching the intended semantics: round-trip
trading should not cause the fitness scorer to crash.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Stake.nextPositionId starts at 654_321, so attack files cannot use literal
on-chain IDs (e.g. positionId=1 always reverts with PositionNotFound).
Fix AttackRunner to treat the JSONL positionId field as a 1-based index into
the list of positions created by stake ops during the current run:
- Add IStake.snatch returns (uint256) to the interface so the returned ID is
captured.
- Track returned IDs in _stakedPositionIds[] (inserted in creation order).
- _executeUnstake resolves positionId to _stakedPositionIds[positionId-1]
before calling exitPosition, matching the natural "unstake position 1"
semantics in the attack DSL.
KRK approval for Stake was already present in _setup(); no other changes needed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- **Bug**: Fix JSON malformation in _snapshotPositions — closing literal was '"}}}' (three
braces) but only '"}}' is needed (close discovery{} + positions{}). The third brace
prematurely closed the root object, making every snapshot unparseable downstream.
- **Nit**: _executeStake local variable renamed taxRateIndex → taxRate to match the
IStake interface and Stake.sol. JSONL field key '.taxRateIndex' is kept for backward
compatibility with existing attack files; the comment and NatDoc header now say so.
- **Nit**: recenter_is_up now emits JSON null (not false) before the first recenter call,
via a new _hasRecentered flag. Downstream parsers can distinguish "no recenter yet"
from "last recenter moved price down" (false). _hasRecentered is set to true alongside
_lastRecenterIsUp in the recenter handler.
- **Nit**: Added a comment to _logSnapshot explaining that pool.slot0() is a view call
and forge-std finalises broadcast state before executing it, so tick/sqrtPrice are
always post-broadcast accurate.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- **Bug**: `_positionEthValue` now sums both the ETH component and the KRK component
(converted to ETH via `FullMath.mulDiv` at current sqrtPriceX96) so `lm_eth_total`
correctly reflects LM TVL for all price ranges (below/in/above range).
- **Bug**: `recenter()` return value (`bool isUp` — price direction) is now captured in
`_lastRecenterIsUp` state variable and emitted as `"recenter_is_up"` in every snapshot.
Note: `recenter()` reverts on failure; `false` means price moved *down*, not a no-op.
- **Bug**: Discovery position now emits `"ethValue"` in its snapshot JSON object,
matching the floor and anchor fields for symmetric automated parsing.
- **Warning**: `IStake.snatch` interface parameter renamed `taxRateIndex` → `taxRate` to
match the actual `Stake.sol` signature (the value is a raw rate, not a lookup index).
- **Warning**: Unknown op codes in the JSONL file now emit a `console.log` warning
instead of silently skipping, catching typos in attack sequences.
- **Nit**: `_setup()` now wraps 9 000 ETH (up from 1 000) to cover heavy buy sequences
that would otherwise exhaust the adversary's WETH.
- **Nit**: `_computeVwapTick` documents the int128 overflow guard and its tick=0 sentinel
meaning so callers can distinguish "VWAP unavailable" from tick zero.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace the ethPerToken metric (free balance / adjusted supply) with total
LM ETH (free + WETH + position-locked) using a forge script with exact
Uni V3 integer math. Collapses 4+ RPC calls and Python float approximation
into a single forge script call using LiquidityAmounts + TickMath.
Also updates red-team prompt, report format, memory extraction, and adds
roadmap items for #536-#538 (backtesting pipeline, Push3 evolution).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add AttackRunner.s.sol: structured forge script that reads attack ops from a
JSONL file (ATTACK_FILE env), executes them against the local Anvil deployment,
and emits full state snapshots (tick, positions, VWAP, optimizer output,
adversary balances) as JSON lines after every recenter and at start/end.
- Add 5 canonical attack files in onchain/script/backtesting/attacks/:
* il-crystallization-15.jsonl — 15 buy-recenter cycles + sell (extraction)
* il-crystallization-80.jsonl — 80 buy-recenter cycles + sell (extraction)
* fee-drain-oscillation.jsonl — buy-recenter-sell-recenter oscillation
* round-trip-safe.jsonl — 20 full round-trips (regression: safe)
* staking-safe.jsonl — staking manipulation (regression: safe)
- Add scripts/harb-evaluator/export-attacks.py: parses red-team-stream.jsonl
for tool_use Bash blocks containing cast send commands and converts them to
AttackRunner-compatible JSONL (buy/sell/recenter/stake/unstake/mint_lp/burn_lp).
- Update scripts/harb-evaluator/red-team.sh: after each agent run, automatically
exports the attack sequence via export-attacks.py and replays it with
AttackRunner to capture structured snapshots in tmp/red-team-snapshots.jsonl.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add an explanatory comment to uniswapV3SwapCallback clarifying that
address(this) is pre-funded by _replaySwap before pool.swap() is
called, so no inline mint is required (unlike uniswapV3MintCallback).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Fix fee attribution: distribute fees only to positions whose tick range
contains the active tick at close time (in-range weight), not by raw
liquidity. FLOOR is priced far below current tick and rarely earns fees;
the old approach would over-credit it and corrupt capital-efficiency and
net-P&L numbers. Fallback to raw-liquidity weighting with a WARN log
when no position is in range.
- Warn on first-close skip: when _closePosition finds no open record
(first recenter, before any tracking), log [TRACKER][WARN] instead of
silently returning so the gap is visible in reports.
- Add tick range assertion: require() that the incoming close snapshot
tick range matches the stored open record — a mismatch would mean IL
is computed across different ranges (apples vs oranges).
- Fix finalBlock accuracy: logSummary now calls
tracker.logFinalSummary(tracker.lastNotifiedBlock()) instead of
lastRecenterBlock, so the summary reflects the actual last replay block
rather than potentially hundreds of blocks early.
- Initialize lastRecenterBlock = block.number in StrategyExecutor
constructor to defer the first recenter attempt by recenterInterval
blocks and document the invariant.
- Extract shared FormatLib: _str(uint256) and _istr(int256) were
copy-pasted in both PositionTracker and StrategyExecutor. Extracted to
FormatLib.sol internal library; both contracts now use `using FormatLib`.
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>
- Add BacktestKraiken.sol: extends MockToken with Kraiken-compatible interface
(dual mint overloads — public mint(address,uint256) for EventReplayer and
restricted mint(uint256) for LiquidityManager; peripheryContracts() stubs
staking pool as address(0))
- Add KrAIkenDeployer.sol: library deploying OptimizerV3Push3 + LiquidityManager
on the shadow pool, wiring BacktestKraiken permissions, setting fee destination,
and funding LM with configurable initial mock-WETH capital (default 10 ETH)
- Add StrategyExecutor.sol: time-based recenter trigger (configurable block
interval, default 100 blocks); logs block, pre/post positions (Floor/Anchor/
Discovery tick ranges + liquidity), fees collected, and revert reason on skip;
negligible-impact assumption documented as TODO(#319)
- Modify EventReplayer.sol: add overloaded replay() accepting an optional
StrategyExecutor hook; maybeRecenter() called after each block advancement
without halting replay on failure
- Modify BacktestRunner.s.sol: replace tokenA/B with MockWETH + BacktestKraiken,
integrate KrAIkenDeployer + StrategyExecutor into broadcast block; configurable
via RECENTER_INTERVAL and INITIAL_CAPITAL_WETH env vars; executor.logSummary()
printed after replay
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Guard final drift sample with `idx % LOG_INTERVAL != 0` to prevent
double-counting stats when totalReplayed is an exact multiple of
LOG_INTERVAL (the loop's _logCheckpoint already fired for that state)
- Hoist pool.slot0() before the guard and pass finalSqrtPrice/finalTick
to _logSummary(), eliminating the redundant slot0 read inside it
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Cache pool.tickSpacing() as immutable in EventReplayer constructor
to avoid a repeated external call per _replayMint() invocation
- Rename driftCount → driftCheckpoints for consistency with log label
- Add sqrtDriftBps to the per-checkpoint progress log line, using the
now-live lastExpectedSqrtPrice field (previously written but never read)
- Guard _replaySwap(): skip and count events where amountSpecified ≤ 0,
which would silently flip exact-input into exact-output mode
- Add a final drift sample after the while-loop for trailing events not
covered by the last LOG_INTERVAL checkpoint
- Move EventReplayer construction outside the broadcast block in
BacktestRunner (it uses vm.* cheat codes incompatible with real RPC)
- Change second vm.closeFile() from try/catch to a direct call so errors
surface rather than being silently swallowed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace hardcoded Infura API key with INFURA_API_KEY env var; fail fast
with a helpful message if unset and no --rpc-url is given
- Add onchain/script/backtesting/.gitignore (cache/) instead of relying on
the opaque root pattern; remove force-tracked cache/.gitkeep (mkdirSync
creates the directory at runtime)
- Document resume constraint: reliable only when both --start-block and
--end-block are explicit, or --output is set
- Fix batch-number display: derive batchNum inside the loop from the actual
`from` block so it stays correct when resumeFromBlock isn't BATCH_SIZE-aligned
- Guard log.logIndex === null consistently with blockNumber/transactionHash
- console.warn on decode errors instead of silently discarding them
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add onchain/script/backtesting/fetch-events.ts — a tsx script that:
- Fetches Swap/Mint/Burn events from a Uniswap V3 pool via Infura (Base mainnet)
- Batches eth_getLogs in 2 000-block chunks with 100 ms inter-batch delay
- Decodes each log with viem and writes one JSON Line per event
- Supports resume: reads last block from existing cache file on re-run
- Retries with exponential back-off on 429 / rate-limit errors
- Prints per-batch progress: "Fetching blocks X-Y... N events (B/T batches)"
Also adds package.json, tsconfig.json, and cache/.gitkeep.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
OptimizerV3Push3 is an equivalence-proof contract with only isBullMarket().
It cannot serve as an ERC1967Proxy implementation because it has no initialize()
or getLiquidityParams(). The CI bootstrap was failing because the proxy
deployment reverted when calling initialize() on the Push3 implementation.
Switch deploy scripts to Optimizer.sol (the base UUPS contract) which has the
full interface required by ERC1967Proxy and LiquidityManager.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Renamed core contract from Harberg.sol to Kraiken.sol
- Updated token symbol from HARB to KRK
- Renamed TypeScript library from harb-lib to kraiken-lib
- Updated all contract imports and references across smart contracts
- Modified subgraph schema and source files for new naming
- Updated transaction bot dependencies and service references
- Fixed test files to use new contract and token names
- Updated documentation in CLAUDE.md and README.md
- Regenerated subgraph types and ABI files
- Added new deployment script (DeployScript2.sol)
All components compile successfully and tests pass.
Smart contracts: ✅ Compilation and tests pass
TypeScript library: ✅ Package renamed and configured
Subgraph: ✅ Code generation and build successful
Transaction bot: ✅ Dependencies updated
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>