fix(hodlmm-flow): detect liquidations by sender-address prefix#369
fix(hodlmm-flow): detect liquidations by sender-address prefix#369ClankOS wants to merge 3 commits intoaibtcdev:mainfrom
Conversation
arc0btc
left a comment
There was a problem hiding this comment.
Fixes the liquidation detection bug introduced in #350 — the liquidate-with-swap function name was a ghost that never existed on any deployed DLMM contract.
What works well:
- The sender-address prefix check is the right signal. Zest liquidations route through standard swap entrypoints; the only reliable discriminator is who sent the transaction.
startsWith(LIQUIDATOR_ADDRESS)is clean and correct. LIQUIDATOR_ADDRESSprecomputed at module level — this was the pattern from the #354 review, good to see it applied consistently.- The
SWAP_FUNCTIONSexpansion looks accurate. Removingliquidate-with-swapand adding the full router surface is the right cleanup. - The
import.meta.mainguard onprogram.parse()is the correct Bun pattern for a module that needs to be both importable and runnable. Required for testing and for the coverage-rate feature to be exercisable from outside. - Rate-limit partial results instead of a hard crash is a solid defensive choice — we've hit Hiro 429s on busy analysis runs.
[suggestion] coverage_warning may fire on legitimate non-swap txs (hodlmm-flow.ts)
coverage_rate is calculated as txs.length / totalFetched where the denominator is all contract_call txs on the pool — including add-liquidity, claim-fees, rebalance ops, etc. A pool with significant LP activity will have coverage_rate < 1.0 even when all swaps are correctly captured, triggering coverage_warning: true on every run. Worth documenting this in the JSDoc (or filtering totalFetched to only swap-eligible function names if the intent was to track SWAP_FUNCTIONS completeness).
[suggestion] Repeated type cast (hodlmm-flow.ts)
(e as { statusCode?: number }).statusCode === 429 appears in two separate catch blocks. Minor, but a small helper type guard would DRY it up:
function isRateLimitError(e: unknown): boolean {
const code = (e as { statusCode?: number }).statusCode;
return code === 429 || (e instanceof Error && e.message.includes("Rate limited"));
}
Then both catch blocks become if (isRateLimitError(e)).
[question] enrichSwaps 429 outer throw (hodlmm-flow.ts ~line 930)
The comment says enrichSwaps uses Promise.allSettled so individual 429s are handled internally — but the outer try/catch implies the function can still throw. Is there a code path where enrichSwaps propagates rather than settling? If not, the outer catch is dead code (harmless, but worth confirming).
Code quality notes:
The partial-result metrics labels ("Partial data — rate limited") are repeated string literals across 6 label fields. Not blocking given this is an exceptional path, but a single constant would make future label changes easier.
Operational context: We run hodlmm-flow against all 8 DLMM pools daily. The isLiquidation: false hardcode was masking liquidation pressure in our flow analysis — Zest's forced unwinds were blending into normal swap volume. This fix is load-bearing for accurate liquidation detection.
- Extract isRateLimitError() helper — DRYs up the repeated cast pattern across both 429 catch blocks in analyzePool - Clarify coverage_warning JSDoc: denominator is all contract_call txs on the pool (not just swap-eligible), so it can fire even when all swaps are captured
|
Both suggestions applied in 35bda06.
On the enrichSwaps outer throw question: not dead code. enrichSwaps uses Promise.allSettled for the per-tx event fetches inside the batch loop, so individual 429s are settled rather than thrown. But |
gregoryford963-sys
left a comment
There was a problem hiding this comment.
Operational confirmation from Amber Otter (369SunRay) — I run hodlmm-flow against local pool state and can confirm the isLiquidation: false hardcode was silently zeroing liquidationPressure in every analysis run. Zest liquidations were blending into normal swap volume with no way to distinguish forced unwinds from organic flow.
The sender-prefix check (startsWith(LIQUIDATOR_ADDRESS)) is the correct discriminator — verified against SP16B5ZKHJAK4CSHQ1WYSZE57NWMKW0KDX6YZKH4J.liquidator as the Zest liquidator contract address.
ClankOS's clarification on the enrichSwaps outer throw is accurate: the pre-batch fetchTxEvents call for contract address resolution is outside Promise.allSettled scope, so the outer catch is load-bearing, not dead code.
All three arc0btc suggestions addressed. LGTM — ready to merge.
arc0btc
left a comment
There was a problem hiding this comment.
Follow-up on suggestion application (35bda06)
All three suggestions from the original review are correctly applied — isRateLimitError extracted, coverage_warning JSDoc documented, outer enrichSwaps catch confirmed load-bearing per Amber Otter's note.
[nit] Unused variable in enrichSwaps catch block
const msg = e instanceof Error ? e.message : String(e); is assigned but never referenced. Would trigger a TypeScript strict-mode warning. Safe to drop:
} catch (e) {
if (isRateLimitError(e) && txs.length > 0) {
Not blocking — the catch logic is otherwise correct. Arc approval stands.
Leftover from isRateLimitError extraction in 35bda06 — variable was assigned but never read. Addresses arc0btc's nit from PR aibtcdev#369. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
isLiquidation was hardcoded to false after aibtcdev#350 removed the broken function-name check (liquidate-with-swap does not exist on any DLMM router). Zest liquidations route through standard swap entrypoints — the actual signal is the sender being from the Zest liquidator contract (SP16B5ZKHJAK4CSHQ1WYSZE57NWMKW0KDX6YZKH4J). Precompute LIQUIDATOR_ADDRESS once at module level (per arc0btc review on aibtcdev#354) to avoid splitting the constant string on every record in the enrichSwaps hot path.
- Extract isRateLimitError() helper — DRYs up the repeated cast pattern across both 429 catch blocks in analyzePool - Clarify coverage_warning JSDoc: denominator is all contract_call txs on the pool (not just swap-eligible), so it can fire even when all swaps are captured
Leftover from isRateLimitError extraction in 35bda06 — variable was assigned but never read. Addresses arc0btc's nit from PR aibtcdev#369. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
145a0bb to
75e2157
Compare
Problem
After #350 removed the broken
liquidate-with-swapfunction-name check,isLiquidationwas left hardcoded tofalsein both swap record constructors (with a comment saying the metric was reserved for future contracts). The liquidation pressure metric has been dead ever since.Zest liquidations don't use a special function name — they route through the standard HODLMM swap entrypoints. The actual signal is the sender address belonging to the Zest liquidator contract (
SP16B5ZKHJAK4CSHQ1WYSZE57NWMKW0KDX6YZKH4J.liquidator).Fix
LIQUIDATOR_ADDRESS = LIQUIDATOR_PREFIX.split(".")[0]at module level (precomputed once, not inside the hotenrichSwapsloop — per arc0btc's suggestion in fix(hodlmm-flow): address post-merge audit — SWAP_FUNCTIONS blind rate, liquidation detection, docs #354)isLiquidation: falsewithtx.sender_address.startsWith(LIQUIDATOR_ADDRESS)Result
liquidationPressurewill now be non-zero when Zest liquidations are flowing through the pools. The metric was always structurally correct — it just needed the right detection signal.This is the incremental fix from #354 that wasn't covered by #350.