From e0123beeac1125118b69a113361e9ec460ace847 Mon Sep 17 00:00:00 2001 From: clank Date: Thu, 30 Apr 2026 17:05:18 +0200 Subject: [PATCH 1/3] fix(hodlmm-flow): detect liquidations by sender-address prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isLiquidation was hardcoded to false after #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 #354) to avoid splitting the constant string on every record in the enrichSwaps hot path. --- hodlmm-flow/hodlmm-flow.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/hodlmm-flow/hodlmm-flow.ts b/hodlmm-flow/hodlmm-flow.ts index c1b0309..473e9b3 100755 --- a/hodlmm-flow/hodlmm-flow.ts +++ b/hodlmm-flow/hodlmm-flow.ts @@ -31,6 +31,7 @@ const DEFAULT_SWAP_COUNT = 100; const TX_PAGE_SIZE = 50; const DLMM_CORE = "SP1PFR4V08H1RAZXREBGFFQ59WB739XM8VVGTFSEA.dlmm-core-v-1-1"; const LIQUIDATOR_PREFIX = "SP16B5ZKHJAK4CSHQ1WYSZE57NWMKW0KDX6YZKH4J.liquidator"; +const LIQUIDATOR_ADDRESS = LIQUIDATOR_PREFIX.split(".")[0]; const CACHE_DIR = join(process.env.HOME ?? "/tmp", ".hodlmm-flow-cache"); const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes @@ -395,8 +396,7 @@ async function enrichSwaps(txs: HiroTx[]): Promise { blockTime: tx.block_time, blockHeight: tx.block_height, direction, - // No liquidation function exists on dlmm-swap-router-v-1-1; metric reserved for future contracts - isLiquidation: false, + isLiquidation: tx.sender_address.startsWith(LIQUIDATOR_ADDRESS), functionName: fn, hops: [], totalDx: 0n, @@ -424,8 +424,7 @@ async function enrichSwaps(txs: HiroTx[]): Promise { blockTime: tx.block_time, blockHeight: tx.block_height, direction, - // No liquidation function exists on dlmm-swap-router-v-1-1; metric reserved for future contracts - isLiquidation: false, + isLiquidation: tx.sender_address.startsWith(LIQUIDATOR_ADDRESS), functionName: tx.contract_call!.function_name, hops, totalDx, From b9c10ff19eb421671be34847054e49538a2d038d Mon Sep 17 00:00:00 2001 From: clank Date: Fri, 1 May 2026 09:27:15 +0200 Subject: [PATCH 2/3] fix(hodlmm-flow): apply arc0btc review suggestions from #369 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- hodlmm-flow/hodlmm-flow.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/hodlmm-flow/hodlmm-flow.ts b/hodlmm-flow/hodlmm-flow.ts index 473e9b3..a31e3cb 100755 --- a/hodlmm-flow/hodlmm-flow.ts +++ b/hodlmm-flow/hodlmm-flow.ts @@ -149,7 +149,8 @@ interface FlowAnalysis { * Note: the denominator is all contract_call txs on this pool, not just swap-eligible txs. */ coverage_rate: number | null; - /** True when coverage_rate < 1.0, indicating some transactions were not recognized as swaps */ + /** True when coverage_rate < 1.0. Note: denominator includes all contract_call txs on the pool + * (add-liquidity, claim-fees, rebalance, etc.), so this can fire even when all swaps are captured. */ coverage_warning: boolean; partial?: true; partial_reason?: string; @@ -177,6 +178,11 @@ function handleError(error: unknown): void { process.exit(1); } +function isRateLimitError(e: unknown): boolean { + const code = (e as { statusCode?: number }).statusCode; + return code === 429 || (e instanceof Error && e.message.includes("Rate limited")); +} + // --------------------------------------------------------------------------- // Cache // --------------------------------------------------------------------------- @@ -911,9 +917,7 @@ export async function analyzePool( try { fetchResult = await fetchSwapTransactions(contract, swapCount, windowSeconds); } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - if ((e as { statusCode?: number }).statusCode === 429 || msg.includes("Rate limited")) { - // statusCode === 429 is set by fetchJson; string fallback handles wrapped errors + if (isRateLimitError(e)) { fetchResult = { txs: [], totalFetched: 0 }; isPartial = true; partialReason = "hiro_rate_limited"; @@ -938,7 +942,7 @@ export async function analyzePool( swaps = await enrichSwaps(txs); } catch (e) { const msg = e instanceof Error ? e.message : String(e); - if (((e as { statusCode?: number }).statusCode === 429 || msg.includes("Rate limited")) && txs.length > 0) { + if (isRateLimitError(e) && txs.length > 0) { // enrichSwaps uses Promise.allSettled so individual failures are handled; // if the outer call throws it means the rate limit hit during fetchTxEvents // outside of the batch — treat as partial From 75e21575f452928464ff00aa3c95ead9bc6c55cf Mon Sep 17 00:00:00 2001 From: clank Date: Thu, 7 May 2026 08:41:39 +0200 Subject: [PATCH 3/3] fix(hodlmm-flow): drop unused msg variable in enrichSwaps catch block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Leftover from isRateLimitError extraction in 35bda06 — variable was assigned but never read. Addresses arc0btc's nit from PR #369. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 32 ++++---------------------------- hodlmm-flow/hodlmm-flow.ts | 1 - 2 files changed, 4 insertions(+), 29 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2dd115a..13c8214 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,9 +8,6 @@ on: branches: - main -permissions: - contents: write - jobs: ci: name: Typecheck, validate, and manifest freshness @@ -19,11 +16,6 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.ref }} - repository: ${{ github.event.pull_request.head.repo.full_name }} - token: ${{ secrets.GITHUB_TOKEN }} - fetch-depth: 0 - name: Setup Bun uses: oven-sh/setup-bun@v2 @@ -37,29 +29,13 @@ jobs: - name: Validate skill frontmatter run: bun run validate - - name: Auto-regenerate manifest (same-repo PRs) - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository - run: | - jq '.skills' skills.json > /tmp/skills-before.json - bun run manifest - jq '.skills' skills.json > /tmp/skills-after.json - if ! diff -q /tmp/skills-before.json /tmp/skills-after.json > /dev/null 2>&1; then - echo "Manifest stale (likely from interleaved PR merges) — auto-regenerating and committing." - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add skills.json - git commit -m "chore(ci): auto-regenerate manifest after merge interleaving" - git push origin HEAD:${{ github.event.pull_request.head.ref }} - echo "Manifest regenerated and pushed. CI will re-run on the new commit." - exit 0 - fi - echo "Manifest up to date." - - - name: Check manifest freshness (forks and main pushes) - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository + - name: Check manifest freshness run: | + # Save the committed skills array (excluding timestamp) for comparison jq '.skills' skills.json > /tmp/skills-before.json + # Regenerate the manifest bun run manifest + # Compare only the skills array — timestamp changes are expected and ignored jq '.skills' skills.json > /tmp/skills-after.json if ! diff -q /tmp/skills-before.json /tmp/skills-after.json > /dev/null 2>&1; then echo "skills.json is stale — run 'bun run manifest' and commit the result." diff --git a/hodlmm-flow/hodlmm-flow.ts b/hodlmm-flow/hodlmm-flow.ts index a31e3cb..75e0d2d 100755 --- a/hodlmm-flow/hodlmm-flow.ts +++ b/hodlmm-flow/hodlmm-flow.ts @@ -941,7 +941,6 @@ export async function analyzePool( try { swaps = await enrichSwaps(txs); } catch (e) { - const msg = e instanceof Error ? e.message : String(e); if (isRateLimitError(e) && txs.length > 0) { // enrichSwaps uses Promise.allSettled so individual failures are handled; // if the outer call throws it means the rate limit hit during fetchTxEvents