- 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>
496 lines
20 KiB
Solidity
496 lines
20 KiB
Solidity
// SPDX-License-Identifier: GPL-3.0-or-later
|
||
pragma solidity ^0.8.19;
|
||
|
||
import { FormatLib } from "./FormatLib.sol";
|
||
import { LiquidityAmounts } from "@aperture/uni-v3-lib/LiquidityAmounts.sol";
|
||
import { TickMath } from "@aperture/uni-v3-lib/TickMath.sol";
|
||
import { Math } from "@openzeppelin/utils/math/Math.sol";
|
||
import { IUniswapV3Pool } from "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol";
|
||
import { console2 } from "forge-std/console2.sol";
|
||
|
||
/**
|
||
* @title PositionTracker
|
||
* @notice Tracks KrAIken's three-position lifecycle and computes P&L metrics
|
||
* for backtesting analysis.
|
||
*
|
||
* For each position lifecycle (open → close on next recenter), records:
|
||
* - Tick range (tickLower, tickUpper) and liquidity
|
||
* - Entry/exit block and timestamp
|
||
* - Token amounts at entry vs exit (via Uniswap V3 LiquidityAmounts math)
|
||
* - Fees earned — attributed only to positions whose tick range contained the
|
||
* active tick at the time of close (the only positions that could have been
|
||
* accruing fees). Falls back to raw-liquidity weighting if no position is
|
||
* in range at close time (rare edge case).
|
||
* - Impermanent loss vs holding the initial token amounts
|
||
* - Net P&L = fees value + IL (IL is negative when LP underperforms HODL)
|
||
*
|
||
* Aggregate metrics:
|
||
* - Total fees (token0 + token1 raw, and token0-equivalent)
|
||
* - Cumulative IL and net P&L (token0 units)
|
||
* - Rebalance count
|
||
* - Time in range: % of notified blocks where Anchor tick range contains current tick
|
||
* - Capital efficiency accumulators (feesToken0 / liqBlocks) for offline calculation
|
||
*
|
||
* All output lines carry a [TRACKER][TYPE] prefix for downstream parseability.
|
||
* Cumulative P&L is logged every CUMULATIVE_LOG_INTERVAL blocks.
|
||
*
|
||
* Not a Script — no vm access. Uses console2 for output only.
|
||
*
|
||
* WARNING — backtesting use only: `recordRecenter` has no access control. Do not
|
||
* reuse this contract in production contexts where untrusted callers exist.
|
||
*/
|
||
contract PositionTracker {
|
||
using Math for uint256;
|
||
using FormatLib for uint256;
|
||
using FormatLib for int256;
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Types
|
||
// -------------------------------------------------------------------------
|
||
|
||
/**
|
||
* @notice Snapshot of all three KrAIken positions at a point in time.
|
||
* @dev Stage ordinals: 0 = FLOOR, 1 = ANCHOR, 2 = DISCOVERY.
|
||
*/
|
||
struct PositionSnapshot {
|
||
uint128 floorLiq;
|
||
int24 floorLo;
|
||
int24 floorHi;
|
||
uint128 anchorLiq;
|
||
int24 anchorLo;
|
||
int24 anchorHi;
|
||
uint128 discLiq;
|
||
int24 discLo;
|
||
int24 discHi;
|
||
}
|
||
|
||
struct OpenPosition {
|
||
int24 tickLower;
|
||
int24 tickUpper;
|
||
uint128 liquidity;
|
||
uint256 entryBlock;
|
||
uint256 entryTimestamp;
|
||
uint256 entryAmount0;
|
||
uint256 entryAmount1;
|
||
bool active;
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Constants
|
||
// -------------------------------------------------------------------------
|
||
|
||
uint256 internal constant Q96 = 2 ** 96;
|
||
|
||
/// @notice Blocks between cumulative P&L log lines.
|
||
uint256 public constant CUMULATIVE_LOG_INTERVAL = 500;
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Immutables
|
||
// -------------------------------------------------------------------------
|
||
|
||
IUniswapV3Pool public immutable pool;
|
||
|
||
/// @notice True when pool token0 is WETH; affects fees0/fees1 mapping.
|
||
bool public immutable token0isWeth;
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Open position state (indexed 0=FLOOR, 1=ANCHOR, 2=DISCOVERY)
|
||
// -------------------------------------------------------------------------
|
||
|
||
OpenPosition[3] public openPositions;
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Cumulative aggregate metrics
|
||
// -------------------------------------------------------------------------
|
||
|
||
uint256 public totalFees0;
|
||
uint256 public totalFees1;
|
||
uint256 public rebalanceCount;
|
||
|
||
/// @notice Cumulative IL across all closed positions in token0 units.
|
||
/// Negative means LP underperformed HODL.
|
||
int256 public totalILToken0;
|
||
|
||
/// @notice Cumulative net P&L = IL + fees value (token0 units).
|
||
int256 public totalNetPnLToken0;
|
||
|
||
// Time-in-range (Anchor position).
|
||
uint256 public blocksChecked;
|
||
uint256 public blocksAnchorInRange;
|
||
uint256 public lastNotifiedBlock;
|
||
|
||
// Capital efficiency accumulators.
|
||
/// @notice Cumulative fees expressed in token0 units.
|
||
uint256 public totalFeesToken0;
|
||
/// @notice Sum of (liquidity × blocksOpen) for all closed positions.
|
||
uint256 public totalLiquidityBlocks;
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Internal
|
||
// -------------------------------------------------------------------------
|
||
|
||
uint256 internal _lastCumulativeLogBlock;
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Constructor
|
||
// -------------------------------------------------------------------------
|
||
|
||
constructor(IUniswapV3Pool _pool, bool _token0isWeth) {
|
||
pool = _pool;
|
||
token0isWeth = _token0isWeth;
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Integration API (called by StrategyExecutor)
|
||
// -------------------------------------------------------------------------
|
||
|
||
/**
|
||
* @notice Notify the tracker that a new block has been observed.
|
||
* Updates Anchor time-in-range and emits cumulative P&L lines at
|
||
* every CUMULATIVE_LOG_INTERVAL blocks.
|
||
* @param blockNum Block number as advanced by EventReplayer.
|
||
*/
|
||
function notifyBlock(uint256 blockNum) external {
|
||
if (blockNum == lastNotifiedBlock) return;
|
||
lastNotifiedBlock = blockNum;
|
||
|
||
// Track Anchor time-in-range.
|
||
OpenPosition storage anchor = openPositions[1]; // ANCHOR = 1
|
||
if (anchor.active) {
|
||
(, int24 tick,,,,,) = pool.slot0();
|
||
blocksChecked++;
|
||
if (tick >= anchor.tickLower && tick < anchor.tickUpper) {
|
||
blocksAnchorInRange++;
|
||
}
|
||
}
|
||
|
||
// Log cumulative P&L at regular intervals.
|
||
if (_lastCumulativeLogBlock == 0) {
|
||
_lastCumulativeLogBlock = blockNum;
|
||
} else if (blockNum - _lastCumulativeLogBlock >= CUMULATIVE_LOG_INTERVAL) {
|
||
_logCumulative(blockNum);
|
||
_lastCumulativeLogBlock = blockNum;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @notice Record a successful recenter: close old positions, open new ones.
|
||
*
|
||
* Fee attribution: fees are distributed only to positions whose tick range
|
||
* contained the active tick at the moment of close — the only positions that
|
||
* could have been accruing fees in Uniswap V3. When no position is in range
|
||
* at close time (rare), attribution falls back to raw liquidity weighting and
|
||
* a warning is logged.
|
||
*
|
||
* @dev No access control — this contract is backtesting-only tooling.
|
||
* @param oldPos Pre-recenter snapshot (positions being burned).
|
||
* @param newPos Post-recenter snapshot (positions being minted).
|
||
* @param feesWeth Total WETH fees collected across all positions this recenter.
|
||
* @param feesKrk Total KRK fees collected across all positions this recenter.
|
||
* @param blockNum Block number of the recenter.
|
||
* @param timestamp Block timestamp of the recenter.
|
||
*/
|
||
function recordRecenter(
|
||
PositionSnapshot calldata oldPos,
|
||
PositionSnapshot calldata newPos,
|
||
uint256 feesWeth,
|
||
uint256 feesKrk,
|
||
uint256 blockNum,
|
||
uint256 timestamp
|
||
)
|
||
external
|
||
{
|
||
(uint160 sqrtPriceX96, int24 currentTick,,,,,) = pool.slot0();
|
||
|
||
// Map WETH/KRK fees to pool token0/token1 based on pool ordering.
|
||
uint256 fees0 = token0isWeth ? feesWeth : feesKrk;
|
||
uint256 fees1 = token0isWeth ? feesKrk : feesWeth;
|
||
|
||
totalFees0 += fees0;
|
||
totalFees1 += fees1;
|
||
totalFeesToken0 += _valueInToken0(fees0, fees1, sqrtPriceX96);
|
||
rebalanceCount++;
|
||
|
||
// Uniswap V3 only accrues fees to a position when the active tick is
|
||
// inside its range. Weight fee attribution by in-range liquidity only.
|
||
uint256 fFloorWeight = _inRangeLiq(oldPos.floorLiq, oldPos.floorLo, oldPos.floorHi, currentTick);
|
||
uint256 fAnchorWeight = _inRangeLiq(oldPos.anchorLiq, oldPos.anchorLo, oldPos.anchorHi, currentTick);
|
||
uint256 fDiscWeight = _inRangeLiq(oldPos.discLiq, oldPos.discLo, oldPos.discHi, currentTick);
|
||
uint256 totalFeeWeight = fFloorWeight + fAnchorWeight + fDiscWeight;
|
||
|
||
// Fallback to raw-liquidity weights when no position covers the current tick.
|
||
if (totalFeeWeight == 0) {
|
||
console2.log("[TRACKER][WARN] no position in range at close: fee attribution falling back to raw liquidity");
|
||
fFloorWeight = oldPos.floorLiq;
|
||
fAnchorWeight = oldPos.anchorLiq;
|
||
fDiscWeight = oldPos.discLiq;
|
||
totalFeeWeight = uint256(oldPos.floorLiq) + uint256(oldPos.anchorLiq) + uint256(oldPos.discLiq);
|
||
}
|
||
|
||
// Close old positions, passing per-position fee weights.
|
||
_closePosition(0, oldPos.floorLiq, oldPos.floorLo, oldPos.floorHi, fees0, fees1, fFloorWeight, totalFeeWeight, sqrtPriceX96, blockNum);
|
||
_closePosition(1, oldPos.anchorLiq, oldPos.anchorLo, oldPos.anchorHi, fees0, fees1, fAnchorWeight, totalFeeWeight, sqrtPriceX96, blockNum);
|
||
_closePosition(2, oldPos.discLiq, oldPos.discLo, oldPos.discHi, fees0, fees1, fDiscWeight, totalFeeWeight, sqrtPriceX96, blockNum);
|
||
|
||
// Open new positions.
|
||
_openPosition(0, newPos.floorLiq, newPos.floorLo, newPos.floorHi, sqrtPriceX96, blockNum, timestamp);
|
||
_openPosition(1, newPos.anchorLiq, newPos.anchorLo, newPos.anchorHi, sqrtPriceX96, blockNum, timestamp);
|
||
_openPosition(2, newPos.discLiq, newPos.discLo, newPos.discHi, sqrtPriceX96, blockNum, timestamp);
|
||
}
|
||
|
||
/**
|
||
* @notice Log the final aggregate summary. Call once at the end of replay.
|
||
* @param blockNum Final block number (for context in the summary line).
|
||
*/
|
||
function logFinalSummary(uint256 blockNum) external view {
|
||
// Compute incremental IL from still-open positions without mutating state.
|
||
(uint160 sqrtPriceX96,,,,,,) = pool.slot0();
|
||
int256 finalIL = totalILToken0;
|
||
int256 finalNetPnL = totalNetPnLToken0;
|
||
for (uint8 i = 0; i < 3; i++) {
|
||
OpenPosition storage pos = openPositions[i];
|
||
if (!pos.active) continue;
|
||
(uint256 exitAmt0, uint256 exitAmt1) = _positionAmounts(pos.liquidity, pos.tickLower, pos.tickUpper, sqrtPriceX96);
|
||
int256 il = _computeIL(pos.entryAmount0, pos.entryAmount1, exitAmt0, exitAmt1, sqrtPriceX96);
|
||
finalIL += il;
|
||
finalNetPnL += il; // no fees for unclosed positions
|
||
}
|
||
|
||
uint256 timeInRangeBps = blocksChecked > 0 ? (blocksAnchorInRange * 10_000) / blocksChecked : 0;
|
||
|
||
console2.log("[TRACKER][SUMMARY] === Final P&L Summary ===");
|
||
console2.log(string.concat("[TRACKER][SUMMARY] finalBlock=", blockNum.str()));
|
||
console2.log(string.concat("[TRACKER][SUMMARY] rebalances=", rebalanceCount.str()));
|
||
console2.log(
|
||
string.concat("[TRACKER][SUMMARY] totalFees0=", totalFees0.str(), " totalFees1=", totalFees1.str(), " totalFeesToken0=", totalFeesToken0.str())
|
||
);
|
||
console2.log(string.concat("[TRACKER][SUMMARY] totalIL=", finalIL.istr(), " netPnL=", finalNetPnL.istr(), " (token0 units)"));
|
||
console2.log(
|
||
string.concat(
|
||
"[TRACKER][SUMMARY] timeInRange=", timeInRangeBps.str(), " bps blocksAnchorInRange=", blocksAnchorInRange.str(), "/", blocksChecked.str()
|
||
)
|
||
);
|
||
console2.log(string.concat("[TRACKER][SUMMARY] capitalEff: feesToken0=", totalFeesToken0.str(), " liqBlocks=", totalLiquidityBlocks.str()));
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Internal: position lifecycle
|
||
// -------------------------------------------------------------------------
|
||
|
||
function _openPosition(
|
||
uint8 stageIdx,
|
||
uint128 liquidity,
|
||
int24 tickLower,
|
||
int24 tickUpper,
|
||
uint160 sqrtPriceX96,
|
||
uint256 blockNum,
|
||
uint256 timestamp
|
||
)
|
||
internal
|
||
{
|
||
if (liquidity == 0) return;
|
||
(uint256 amt0, uint256 amt1) = _positionAmounts(liquidity, tickLower, tickUpper, sqrtPriceX96);
|
||
openPositions[stageIdx] = OpenPosition({
|
||
tickLower: tickLower,
|
||
tickUpper: tickUpper,
|
||
liquidity: liquidity,
|
||
entryBlock: blockNum,
|
||
entryTimestamp: timestamp,
|
||
entryAmount0: amt0,
|
||
entryAmount1: amt1,
|
||
active: true
|
||
});
|
||
console2.log(
|
||
string.concat(
|
||
"[TRACKER][OPEN] stage=",
|
||
_stageName(stageIdx),
|
||
" block=",
|
||
blockNum.str(),
|
||
" ts=",
|
||
timestamp.str(),
|
||
" tick=[",
|
||
int256(tickLower).istr(),
|
||
",",
|
||
int256(tickUpper).istr(),
|
||
"] liq=",
|
||
uint256(liquidity).str(),
|
||
" amt0=",
|
||
amt0.str(),
|
||
" amt1=",
|
||
amt1.str()
|
||
)
|
||
);
|
||
}
|
||
|
||
function _closePosition(
|
||
uint8 stageIdx,
|
||
uint128 liquidity,
|
||
int24 tickLower,
|
||
int24 tickUpper,
|
||
uint256 fees0Total,
|
||
uint256 fees1Total,
|
||
uint256 posWeight, // fee weight for this position (0 = out-of-range)
|
||
uint256 totalWeight, // sum of fee weights across all positions
|
||
uint160 sqrtPriceX96,
|
||
uint256 blockNum
|
||
)
|
||
internal
|
||
{
|
||
if (liquidity == 0) return;
|
||
|
||
OpenPosition storage pos = openPositions[stageIdx];
|
||
if (!pos.active) {
|
||
// First recenter: no prior open record exists (LM deployed with positions
|
||
// already placed before tracking began). Log a warning so the gap is visible.
|
||
console2.log(
|
||
string.concat("[TRACKER][WARN] stage=", _stageName(stageIdx), " close skipped at block=", blockNum.str(), " (no open record: first recenter)")
|
||
);
|
||
return;
|
||
}
|
||
|
||
// Guard: the incoming snapshot must match the stored open record.
|
||
// A mismatch would mean IL is computed across mismatched tick ranges.
|
||
require(tickLower == pos.tickLower && tickUpper == pos.tickUpper, "PositionTracker: tick range mismatch");
|
||
|
||
(uint256 exitAmt0, uint256 exitAmt1) = _positionAmounts(liquidity, tickLower, tickUpper, sqrtPriceX96);
|
||
|
||
// Attribute fees proportional to this position's in-range weight.
|
||
uint256 posLiq = uint256(liquidity);
|
||
uint256 myFees0 = totalWeight > 0 ? fees0Total.mulDiv(posWeight, totalWeight) : 0;
|
||
uint256 myFees1 = totalWeight > 0 ? fees1Total.mulDiv(posWeight, totalWeight) : 0;
|
||
|
||
// IL = LP exit value (ex-fees) − HODL value at exit price (both in token0 units).
|
||
int256 il = _computeIL(pos.entryAmount0, pos.entryAmount1, exitAmt0, exitAmt1, sqrtPriceX96);
|
||
int256 feesToken0 = int256(_valueInToken0(myFees0, myFees1, sqrtPriceX96));
|
||
int256 netPnL = il + feesToken0;
|
||
|
||
totalILToken0 += il;
|
||
totalNetPnLToken0 += netPnL;
|
||
|
||
uint256 blocksOpen = blockNum > pos.entryBlock ? blockNum - pos.entryBlock : 0;
|
||
totalLiquidityBlocks += posLiq * blocksOpen;
|
||
|
||
console2.log(
|
||
string.concat(
|
||
"[TRACKER][CLOSE] stage=",
|
||
_stageName(stageIdx),
|
||
" block=",
|
||
blockNum.str(),
|
||
" entryBlock=",
|
||
pos.entryBlock.str(),
|
||
" tick=[",
|
||
int256(tickLower).istr(),
|
||
",",
|
||
int256(tickUpper).istr(),
|
||
"] liq=",
|
||
posLiq.str()
|
||
)
|
||
);
|
||
console2.log(
|
||
string.concat(
|
||
"[TRACKER][CLOSE] entryAmt0=",
|
||
pos.entryAmount0.str(),
|
||
" entryAmt1=",
|
||
pos.entryAmount1.str(),
|
||
" exitAmt0=",
|
||
exitAmt0.str(),
|
||
" exitAmt1=",
|
||
exitAmt1.str(),
|
||
" fees0=",
|
||
myFees0.str(),
|
||
" fees1=",
|
||
myFees1.str()
|
||
)
|
||
);
|
||
console2.log(string.concat("[TRACKER][CLOSE] IL=", il.istr(), " netPnL=", netPnL.istr(), " (token0 units)"));
|
||
|
||
delete openPositions[stageIdx];
|
||
}
|
||
|
||
function _logCumulative(uint256 blockNum) internal view {
|
||
uint256 timeInRangeBps = blocksChecked > 0 ? (blocksAnchorInRange * 10_000) / blocksChecked : 0;
|
||
console2.log(
|
||
string.concat(
|
||
"[TRACKER][CUMULATIVE] block=",
|
||
blockNum.str(),
|
||
" rebalances=",
|
||
rebalanceCount.str(),
|
||
" totalFees0=",
|
||
totalFees0.str(),
|
||
" totalFees1=",
|
||
totalFees1.str(),
|
||
" IL=",
|
||
totalILToken0.istr(),
|
||
" netPnL=",
|
||
totalNetPnLToken0.istr(),
|
||
" timeInRange=",
|
||
timeInRangeBps.str(),
|
||
" bps"
|
||
)
|
||
);
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Internal: Uniswap V3 math
|
||
// -------------------------------------------------------------------------
|
||
|
||
/**
|
||
* @notice Return `liquidity` if currentTick is inside [tickLo, tickHi), else 0.
|
||
* Used to attribute fees only to positions that were potentially in range.
|
||
*/
|
||
function _inRangeLiq(uint128 liquidity, int24 tickLo, int24 tickHi, int24 currentTick) internal pure returns (uint256) {
|
||
if (liquidity == 0) return 0;
|
||
return (currentTick >= tickLo && currentTick < tickHi) ? uint256(liquidity) : 0;
|
||
}
|
||
|
||
/**
|
||
* @notice Compute (amount0, amount1) for a liquidity position at the given sqrt price.
|
||
*/
|
||
function _positionAmounts(
|
||
uint128 liquidity,
|
||
int24 tickLower,
|
||
int24 tickUpper,
|
||
uint160 sqrtPriceX96
|
||
)
|
||
internal
|
||
pure
|
||
returns (uint256 amount0, uint256 amount1)
|
||
{
|
||
uint160 sqrtRatioLow = TickMath.getSqrtRatioAtTick(tickLower);
|
||
uint160 sqrtRatioHigh = TickMath.getSqrtRatioAtTick(tickUpper);
|
||
(amount0, amount1) = LiquidityAmounts.getAmountsForLiquidity(sqrtPriceX96, sqrtRatioLow, sqrtRatioHigh, liquidity);
|
||
}
|
||
|
||
/**
|
||
* @notice Impermanent loss for a position close (token0 units).
|
||
* IL = lpExitValue − hodlValue at the exit price.
|
||
* Negative when LP underperforms HODL (the typical case).
|
||
*/
|
||
function _computeIL(uint256 entryAmt0, uint256 entryAmt1, uint256 exitAmt0, uint256 exitAmt1, uint160 sqrtPriceX96) internal pure returns (int256) {
|
||
uint256 lpVal = _valueInToken0(exitAmt0, exitAmt1, sqrtPriceX96);
|
||
uint256 hodlVal = _valueInToken0(entryAmt0, entryAmt1, sqrtPriceX96);
|
||
return int256(lpVal) - int256(hodlVal);
|
||
}
|
||
|
||
/**
|
||
* @notice Convert (amount0, amount1) to token0-equivalent units at the given sqrt price.
|
||
* @dev value = amount0 + amount1 / price
|
||
* = amount0 + amount1 × Q96² / sqrtPriceX96²
|
||
* = amount0 + mulDiv(mulDiv(amount1, Q96, sqrtPrice), Q96, sqrtPrice)
|
||
*/
|
||
function _valueInToken0(uint256 amount0, uint256 amount1, uint160 sqrtPriceX96) internal pure returns (uint256) {
|
||
if (sqrtPriceX96 == 0 || amount1 == 0) return amount0;
|
||
uint256 amt1InT0 = Math.mulDiv(Math.mulDiv(amount1, Q96, uint256(sqrtPriceX96)), Q96, uint256(sqrtPriceX96));
|
||
return amount0 + amt1InT0;
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Formatting helper
|
||
// -------------------------------------------------------------------------
|
||
|
||
function _stageName(uint8 idx) internal pure returns (string memory) {
|
||
if (idx == 0) return "FLOOR";
|
||
if (idx == 1) return "ANCHOR";
|
||
return "DISC";
|
||
}
|
||
}
|