- 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>
- Skip UUPS upgradeTo: etch + vm.store ERC1967 implementation slot directly
(OptimizerV3Push3 is standalone, no UUPS inheritance needed for evolution)
- Use deployedBytecode (runtime) instead of bytecode (creation) for vm.etch
- Inject transpiled body into OptimizerV3.sol (has getLiquidityParams via Optimizer)
instead of using standalone OptimizerV3Push3.sol
- Wrap buy/sell/stake/unstake in try/catch — attack ops should not abort the batch
- Add /tmp read to fs_permissions for batch-eval manifest files
- Bootstrap recenter returns bool instead of reverting (soft-fail per candidate)
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>
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>
- Wrap upgradeTo() in try/catch: malformed candidate bytecode no longer
aborts the entire batch; emit {"fitness":0,"error":"upgrade_failed"} and
continue to the next candidate
- Bootstrap recenter: require() after 5 retry attempts so silent failure
(all scores identically equal to free WETH only) is surfaced as a hard
test failure rather than silently producing meaningless results
- mint_lp: capture the NPM tokenId returned by mint() and push it to
_mintedNpmTokenIds; burn_lp now uses a 1-based index into that array
(same pattern as stake/unstake), making attack files fork-block-independent
- Remove dead atkBaseSnap variable and its compiler-warning suppression
- Remove orphaned vm.snapshot() after vm.revertTo() in the attack loop
- Fix misleading comment on delete _stakedPositionIds
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace per-candidate Anvil+forge-script pipeline with in-process EVM
execution using Foundry's native revm backend, achieving 10-100× speedup
for evolutionary search at scale.
New files:
- onchain/test/FitnessEvaluator.t.sol — Forge test that forks Base once,
deploys the full KRAIKEN stack, then for each candidate uses vm.etch to
inject the compiled optimizer bytecode, UUPS-upgrades the proxy, runs all
attack sequences with in-memory vm.snapshot/revertTo (no RPC overhead),
and emits one {"candidate_id","fitness"} JSON line per candidate.
Skips gracefully when BASE_RPC_URL is unset (CI-safe).
- tools/push3-evolution/revm-evaluator/batch-eval.sh — Wrapper that
transpiles+compiles each candidate sequentially, writes a two-file
manifest (ids.txt + bytecodes.txt), then invokes FitnessEvaluator.t.sol
in a single forge test run and parses the score JSON from stdout.
Modified:
- tools/push3-evolution/evolve.sh — Adds EVAL_MODE env var (anvil|revm).
When EVAL_MODE=revm, batch-scores every candidate in a generation with
one batch-eval.sh call instead of N sequential fitness.sh processes;
scores are looked up from the JSONL output in the per-candidate loop.
Default remains EVAL_MODE=anvil for backward compatibility.
Key design decisions:
- Per-candidate Solidity compilation is unavoidable (each Push3 candidate
produces different Solidity); the speedup is in the evaluation phase.
- vm.snapshot/revertTo in forge test are O(1) memory operations (true
revm), not RPC calls — this is the core speedup vs Anvil.
- recenterAccess is set in bootstrap so TWAP stability checks are bypassed
during attack sequences (mirrors the existing fitness.sh bootstrap).
- Test skips cleanly when BASE_RPC_URL is absent, keeping CI green.
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>
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>
- 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
- **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>
- 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>
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>
- _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>
- 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>
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 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>
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>