fix: address review feedback on PositionTracker and StrategyExecutor

- 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>
This commit is contained in:
openhands 2026-02-27 12:02:29 +00:00
parent cfcf750084
commit cf8e7ee6ee
3 changed files with 159 additions and 106 deletions

View file

@ -3,6 +3,7 @@ pragma solidity ^0.8.19;
import { LiquidityManager } from "../../src/LiquidityManager.sol";
import { ThreePositionStrategy } from "../../src/abstracts/ThreePositionStrategy.sol";
import { FormatLib } from "./FormatLib.sol";
import { PositionTracker } from "./PositionTracker.sol";
import { IERC20 } from "@openzeppelin/token/ERC20/IERC20.sol";
import { IUniswapV3Pool } from "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol";
@ -33,6 +34,9 @@ import { console2 } from "forge-std/console2.sol";
* amounts based on KrAIken's active tick ranges.
*/
contract StrategyExecutor {
using FormatLib for uint256;
using FormatLib for int256;
// -------------------------------------------------------------------------
// Configuration (immutable)
// -------------------------------------------------------------------------
@ -50,6 +54,10 @@ contract StrategyExecutor {
// Runtime state
// -------------------------------------------------------------------------
/// @notice Block of the last recenter attempt (successful or not).
/// Initialised to block.number at construction so the first recenter
/// attempt is deferred by recenterInterval blocks rather than firing
/// immediately on the first observed historical block.
uint256 public lastRecenterBlock;
uint256 public totalRecenters;
uint256 public failedRecenters;
@ -73,6 +81,9 @@ contract StrategyExecutor {
feeDestination = _feeDestination;
recenterInterval = _recenterInterval;
tracker = new PositionTracker(_pool, _token0isWeth);
// Defer the first recenter attempt by recenterInterval blocks so we don't
// try to recenter before any meaningful price movement has occurred.
lastRecenterBlock = block.number;
}
// -------------------------------------------------------------------------
@ -118,7 +129,7 @@ contract StrategyExecutor {
}
if (!success) {
console2.log(string.concat("[recenter SKIP @ block ", _str(blockNum), "] reason: ", failReason));
console2.log(string.concat("[recenter SKIP @ block ", blockNum.str(), "] reason: ", failReason));
return;
}
@ -201,11 +212,14 @@ contract StrategyExecutor {
console2.log("Total recenters: ", totalRecenters);
console2.log("Failed recenters: ", failedRecenters);
console2.log("Recenter interval:", recenterInterval, "blocks");
console2.log(string.concat("Final Floor: tick [", _istr(fLo), ", ", _istr(fHi), "] liq=", _str(uint256(fLiq))));
console2.log(string.concat("Final Anchor: tick [", _istr(aLo), ", ", _istr(aHi), "] liq=", _str(uint256(aLiq))));
console2.log(string.concat("Final Discovery: tick [", _istr(dLo), ", ", _istr(dHi), "] liq=", _str(uint256(dLiq))));
console2.log(string.concat("Final Floor: tick [", int256(fLo).istr(), ", ", int256(fHi).istr(), "] liq=", uint256(fLiq).str()));
console2.log(string.concat("Final Anchor: tick [", int256(aLo).istr(), ", ", int256(aHi).istr(), "] liq=", uint256(aLiq).str()));
console2.log(string.concat("Final Discovery: tick [", int256(dLo).istr(), ", ", int256(dHi).istr(), "] liq=", uint256(dLiq).str()));
tracker.logFinalSummary(lastRecenterBlock);
// Use lastNotifiedBlock from the tracker as the authoritative final block
// it reflects the last block actually processed by the replay, which may be
// up to recenterInterval blocks later than lastRecenterBlock.
tracker.logFinalSummary(tracker.lastNotifiedBlock());
}
// -------------------------------------------------------------------------
@ -238,38 +252,13 @@ contract StrategyExecutor {
internal
view
{
console2.log(string.concat("=== Recenter #", _str(totalRecenters), " @ block ", _str(blockNum), " ==="));
console2.log(string.concat(" Floor pre: tick [", _istr(fLoPre), ", ", _istr(fHiPre), "] liq=", _str(uint256(fLiqPre))));
console2.log(string.concat(" Anchor pre: tick [", _istr(aLoPre), ", ", _istr(aHiPre), "] liq=", _str(uint256(aLiqPre))));
console2.log(string.concat(" Disc pre: tick [", _istr(dLoPre), ", ", _istr(dHiPre), "] liq=", _str(uint256(dLiqPre))));
console2.log(string.concat(" Floor post: tick [", _istr(fLoPost), ", ", _istr(fHiPost), "] liq=", _str(uint256(fLiqPost))));
console2.log(string.concat(" Anchor post: tick [", _istr(aLoPost), ", ", _istr(aHiPost), "] liq=", _str(uint256(aLiqPost))));
console2.log(string.concat(" Disc post: tick [", _istr(dLoPost), ", ", _istr(dHiPost), "] liq=", _str(uint256(dLiqPost))));
console2.log(string.concat(" Fees WETH: ", _str(feesWeth), " Fees KRK: ", _str(feesKrk)));
}
// -------------------------------------------------------------------------
// Formatting helpers (no vm dependency required)
// -------------------------------------------------------------------------
function _str(uint256 v) internal pure returns (string memory) {
if (v == 0) return "0";
uint256 tmp = v;
uint256 len;
while (tmp != 0) {
len++;
tmp /= 10;
}
bytes memory buf = new bytes(len);
while (v != 0) {
buf[--len] = bytes1(uint8(48 + v % 10));
v /= 10;
}
return string(buf);
}
function _istr(int256 v) internal pure returns (string memory) {
if (v >= 0) return _str(uint256(v));
return string.concat("-", _str(uint256(-v)));
console2.log(string.concat("=== Recenter #", totalRecenters.str(), " @ block ", blockNum.str(), " ==="));
console2.log(string.concat(" Floor pre: tick [", int256(fLoPre).istr(), ", ", int256(fHiPre).istr(), "] liq=", uint256(fLiqPre).str()));
console2.log(string.concat(" Anchor pre: tick [", int256(aLoPre).istr(), ", ", int256(aHiPre).istr(), "] liq=", uint256(aLiqPre).str()));
console2.log(string.concat(" Disc pre: tick [", int256(dLoPre).istr(), ", ", int256(dHiPre).istr(), "] liq=", uint256(dLiqPre).str()));
console2.log(string.concat(" Floor post: tick [", int256(fLoPost).istr(), ", ", int256(fHiPost).istr(), "] liq=", uint256(fLiqPost).str()));
console2.log(string.concat(" Anchor post: tick [", int256(aLoPost).istr(), ", ", int256(aHiPost).istr(), "] liq=", uint256(aLiqPost).str()));
console2.log(string.concat(" Disc post: tick [", int256(dLoPost).istr(), ", ", int256(dHiPost).istr(), "] liq=", uint256(dLiqPost).str()));
console2.log(string.concat(" Fees WETH: ", feesWeth.str(), " Fees KRK: ", feesKrk.str()));
}
}