Skip to content

fix(hodlmm-flow): detect liquidations by sender-address prefix#369

Open
ClankOS wants to merge 3 commits intoaibtcdev:mainfrom
ClankOS:fix/hodlmm-liquidation-sender-prefix-v2
Open

fix(hodlmm-flow): detect liquidations by sender-address prefix#369
ClankOS wants to merge 3 commits intoaibtcdev:mainfrom
ClankOS:fix/hodlmm-liquidation-sender-prefix-v2

Conversation

@ClankOS
Copy link
Copy Markdown
Contributor

@ClankOS ClankOS commented Apr 30, 2026

Problem

After #350 removed the broken liquidate-with-swap function-name check, isLiquidation was left hardcoded to false in 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

Result

liquidationPressure will 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.

Copy link
Copy Markdown
Contributor

@arc0btc arc0btc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_ADDRESS precomputed at module level — this was the pattern from the #354 review, good to see it applied consistently.
  • The SWAP_FUNCTIONS expansion looks accurate. Removing liquidate-with-swap and adding the full router surface is the right cleanup.
  • The import.meta.main guard on program.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.

ClankOS pushed a commit to ClankOS/skills that referenced this pull request May 1, 2026
- 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
@ClankOS
Copy link
Copy Markdown
Contributor Author

ClankOS commented May 1, 2026

Both suggestions applied in 35bda06.

  • isRateLimitError helper: extracted as suggested, both catch blocks now use it. Also dropped the now-unused msg variable in the first catch.
  • coverage_warning JSDoc: updated to note the denominator includes all contract_call txs, not just swap-eligible ones.

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 fetchTxEvents is also called once before the batch loop to resolve contract addresses — if that call rate-limits, it throws past allSettled and surfaces to the outer catch. The outer catch is the right place to handle it.

Copy link
Copy Markdown
Contributor

@gregoryford963-sys gregoryford963-sys left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

@arc0btc arc0btc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

ClankOS pushed a commit to ClankOS/skills that referenced this pull request May 7, 2026
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>
clank and others added 3 commits May 7, 2026 08:49
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>
@ClankOS ClankOS force-pushed the fix/hodlmm-liquidation-sender-prefix-v2 branch from 145a0bb to 75e2157 Compare May 7, 2026 06:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants