From 3f111487a45786789ce5bac52a9e6e35166c5b04 Mon Sep 17 00:00:00 2001 From: kite-builds Date: Tue, 9 Jun 2026 15:50:07 +0200 Subject: [PATCH] fix(lucidly): distinct keys for same-chain parks at the same clock value ParkedPositions were keyed `chain:clock()`. Since LucidlyAutoPark takes an injectable deterministic clock (documented "Pluggable clock for deterministic tests"), two parks on the same chain at the same clock value mapped to the same dict key, so the second ParkedPosition silently overwrote the first. `_total_parked` still summed both, so the positions dict (and yield_report()["positions"] / per-position yield_accrued_usd) diverged from the parked total. Real time.time() can also collide on rapid successive parks. Fix: append a monotonic per-instance sequence to the key so distinct park events never collide. yield_report()/status() only parse `k.split(":")[0]` and `k.startswith(f"{chain}:")`, so the extra suffix is safe. +1 regression test (red before: positions==1, green after: 2). --- switchboard/adapters/lucidly.py | 6 +++++- tests/test_lucidly.py | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/switchboard/adapters/lucidly.py b/switchboard/adapters/lucidly.py index 36c38ad..7a295fa 100644 --- a/switchboard/adapters/lucidly.py +++ b/switchboard/adapters/lucidly.py @@ -108,6 +108,9 @@ def __init__( self._wallet_total_usd: dict[str, float] = {} self._total_parked: float = 0.0 self._total_yield: float = 0.0 + # Monotonic suffix so two parks on the same chain at the same clock + # value get distinct keys instead of silently overwriting each other. + self._position_seq: int = 0 def _target_bps(self, chain: str) -> int: return self.config.per_chain_targets.get(chain, self.config.idle_target_bps) @@ -157,7 +160,8 @@ def rebalance(self, chain: str, liquid_balance_usd: float) -> dict[str, Any]: syUSD_shares=shares, parked_at=self.clock(), ) - self._positions[f"{chain}:{self.clock()}"] = position + self._position_seq += 1 + self._positions[f"{chain}:{self.clock()}:{self._position_seq}"] = position self._liquid_buffer[chain] = current_liquid - excess self._total_parked += excess diff --git a/tests/test_lucidly.py b/tests/test_lucidly.py index b0fc224..022325b 100644 --- a/tests/test_lucidly.py +++ b/tests/test_lucidly.py @@ -141,3 +141,24 @@ def test_ensure_liquid_unparks_to_threshold_buffer(): assert returned == 500.0 assert park.status("base")["liquid_buffer_usd"] == 1_500.0 + + +def test_two_parks_same_chain_same_clock_are_distinct_positions(): + # The position key was `chain:clock()`. The adapter takes an injectable + # deterministic clock ("Pluggable clock for deterministic tests"), so two + # parks on the same chain at the same clock value mapped to the SAME key — + # the second ParkedPosition silently overwrote the first, dropping its + # per-position yield accrual even though `_total_parked` still counted both. + fixed_t = 1_000_000.0 + park = LucidlyAutoPark(clock=lambda: fixed_t) + + r1 = park.rebalance("base", liquid_balance_usd=10_000.0) + r2 = park.rebalance("base", liquid_balance_usd=10_000.0) + assert r1["action"] == "parked" + assert r2["action"] == "parked" + + report = park.yield_report() + # Both park events must be retained as distinct positions, and the position + # count must stay consistent with the parked total (which already sums both). + assert report["positions"] == 2 + assert report["total_parked_usd"] == r1["amount_usd"] + r2["amount_usd"]