
Hodlmm Flow
- 29 installs
- 9 repo stars
- Updated July 16, 2026
- aibtcdev/skills
hodlmm-flow is a Claude Code skill that analyzes Bitflow HODLMM swap flow to compute market microstructure metrics and an LP safety verdict.
About
hodlmm-flow fetches on-chain swap transactions for Bitflow HODLMM pools from the Hiro API and parses DLMM contract events to compute six market microstructure metrics. It produces an LP safety verdict with a predicted range lifespan so an agent can detect toxic flow and set range parameters. A developer uses it to see who is trading, in what direction, and what it means for LPs, without moving any funds.
- Analyzes Bitflow HODLMM swap flow rather than pool state for LP decisions
- Computes six microstructure metrics: direction bias, flow toxicity, bin velocity, whale concentration, liquidation press
- Read-only, no wallet required, produces an LP safety verdict and predicted range lifespan
Hodlmm Flow by the numbers
- 29 all-time installs (skills.sh)
- Ranked #249 of 479 Web3 & Blockchain skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
hodlmm-flow capabilities & compatibility
Free; a Hiro API key is optional and recommended only for analyzing more than 100 swaps to avoid rate limits
- Capabilities
- swap flow analysis · toxicity detection · whale analysis · defi monitoring
- Works with
- github
- Use cases
- data analysis · research · trading
- Runs
- Runs locally
- Pricing
- Free
What hodlmm-flow says it does
Swap flow intelligence for Bitflow HODLMM concentrated liquidity pools.
computes six market microstructure metrics: direction bias, flow toxicity, bin velocity, whale concentration, liquidation pressure, and bot/organic classification. Produces an LP safety verdict with a
npx skills add https://github.com/aibtcdev/skills --skill hodlmm-flowAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 29 |
|---|---|
| repo stars | ★ 9 |
| Last updated | July 16, 2026 |
| Repository | aibtcdev/skills ↗ |
What it does
Analyze Bitflow HODLMM swap flow to detect toxic trading and predict how long an LP range will stay in range.
Who is it for?
Agents choosing HODLMM range parameters that need to detect adverse selection and toxic flow
Skip if: Executing LP moves; it only analyzes flow and never writes on-chain
When should I use this skill?
Before setting or adjusting a HODLMM range, to check flow direction, toxicity, and predicted lifespan
What you get
Six flow metrics plus an LP safety verdict and predicted range lifespan for a given pool.
- Six-metric flow analysis JSON
- LP safety verdict with predicted range lifespan
By the numbers
- 6 microstructure metrics computed
- Covers 8 HODLMM pools (dlmm_1 to dlmm_8)
- Default analyzes last 100 swaps
Files
HODLMM Flow
Swap flow intelligence for Bitflow HODLMM concentrated liquidity pools.
What it does
Every other HODLMM tool looks at pool state — TVL, bin distribution, positions. This one looks at swap flow: what's actually trading, in what direction, by whom, and what it means for LPs.
Fetches on-chain swap transactions from Hiro API, parses DLMM core contract events to extract per-bin-hop volumes, and computes six market microstructure metrics: direction bias, flow toxicity, bin velocity, whale concentration, liquidation pressure, and bot/organic classification. Produces an LP safety verdict with a predicted range lifespan.
Why agents need it
Concentrated liquidity LPs face adverse selection — informed traders extract value from stale positions. Without flow analysis, an agent choosing range parameters is flying blind. This skill gives agents the data to:
- Detect toxic flow — consecutive same-direction swaps signal informed trading that picks off LPs
- Predict range lifespan — bin velocity tells you how long a ±N-bin position will stay in range
- Identify who's trading — bot vs organic vs liquidator classification reveals market structure
- Assess directional risk — strong bias means one side of your position is getting drained
- Spot liquidation cascades — Zest liquidation flow through HODLMM pools signals collateral stress
Safety notes
- Read-only — never submits transactions or moves funds
- No wallet required — safe to call from any agent without authentication
- Mainnet-only — Bitflow HODLMM is mainnet-only
- Uses Hiro transactions + events APIs. A Hiro API key (
--hiro-api-key) is recommended for analyzing more than 100 swaps to avoid rate limits
Commands
doctor
Checks connectivity to Hiro API (transactions, events) and Bitflow APIs (quotes, app). Verifies the full data pipeline is accessible.
bun run hodlmm-flow/hodlmm-flow.ts doctorinstall-packs
No-op subcommand for registry compatibility. This skill has no additional packs to install.
bun run hodlmm-flow/hodlmm-flow.ts install-packsflow --pool-id
Analyze swap flow for a single pool. Default: last 100 swaps.
bun run hodlmm-flow/hodlmm-flow.ts flow --pool-id dlmm_3flow --pool-id --window
Time-windowed analysis. Analyzes swaps within the specified duration.
bun run hodlmm-flow/hodlmm-flow.ts flow --pool-id dlmm_3 --window 24hflow --all
Protocol-wide flow summary across all 8 HODLMM pools (dlmm_1 through dlmm_8).
bun run hodlmm-flow/hodlmm-flow.ts flow --allOptions:
--pool-id <id>— Pool to analyze (dlmm_1 through dlmm_8)--window <duration>— Time window (e.g. 24h, 7d, 30m)--swaps <count>— Number of swaps to analyze (default: 100)--all— Analyze all 8 HODLMM pools--hiro-api-key <key>— Hiro API key for elevated rate limits
Output contract
All outputs are JSON to stdout.
Success (single pool):
{
"status": "success",
"network": "mainnet",
"timestamp": "2026-04-09T20:00:00.000Z",
"poolId": "dlmm_3",
"pair": "STX/USDCx",
"swapsAnalyzed": 100,
"timeSpanHours": 4.2,
"metrics": {
"directionBias": -0.31,
"directionBiasLabel": "Moderate sell-X pressure",
"flowToxicity": 0.62,
"flowToxicityLabel": "Elevated — directional momentum present",
"binVelocity": 12.5,
"binVelocityLabel": "Moderate — normal volatility",
"whaleConcentration": 0.45,
"whaleConcentrationLabel": "Concentrated — few actors drive most volume",
"liquidationPressure": 0.02,
"liquidationPressureLabel": "Low — 1 liquidation(s), minimal impact",
"botFlowRatio": 0.72,
"botFlowRatioLabel": "Bot-heavy — majority of flow is automated"
},
"verdict": {
"lpSafety": "caution",
"score": 52,
"reasoning": "Strong directional pressure (selling X). Concentrated flow — few actors dominating volume.",
"recommendation": "Monitor flow direction. Consider asymmetric range if bias persists.",
"rangeLifespanHours": 0.8
},
"topActors": [
{ "address": "SP2V3J7G...", "swapCount": 45, "volumeShare": 82.1, "label": "bot" }
]
}Error:
{ "error": "descriptive message" }Metrics reference
| Metric | Range | What it measures |
|---|---|---|
| Direction bias | [-1, +1] | Net buying vs selling pressure. -1 = all selling X, +1 = all buying X |
| Flow toxicity | [0, 1] | Consecutive same-direction ratio. >0.6 = informed flow adversely selecting LPs |
| Bin velocity | bins/hour | Active bin change rate. Predicts how fast positions go out of range |
| Whale concentration | [0, 1] | Herfindahl index on swap volume. >0.25 = concentrated, >0.5 = monopolistic |
| Liquidation pressure | [0, 1] | Volume fraction from Zest liquidate-with-swap transactions |
| Bot flow ratio | [0, 1] | Volume fraction from automated addresses (>10 swaps/hour or >30% of flow) |
Data source
Swap data is sourced from Hiro API (/extended/v1/address/{pool}/transactions + /extended/v1/tx/events). Each swap transaction's DLMM core contract logs are parsed to extract per-bin-hop amounts (dx, dy), active bin IDs, callers, and swap direction.
Bitflow does not currently expose swap history via their own API. This skill recommends they add a /trades or /swaps endpoint — they already have the data server-side. This would eliminate Hiro dependency and enable real-time flow monitoring.
Known constraints
- Free-tier Hiro supports ~100 swaps per run (~100-150 API calls). Use
--hiro-api-keyfor larger analyses - Only ~25-30% of transactions hitting pool contracts are swaps — the rest are add/withdraw liquidity. The skill pages through until it collects enough swap txs
- Multi-hop swap transactions (crossing multiple bins) produce multiple events per tx — all are aggregated correctly
swap-simple-multifunction calls don't indicate direction in the function name — direction is resolved from contract events- Stale data: Hiro API indexes with a slight delay (~1-2 blocks). Very recent swaps may not appear immediately
Origin
Winner of AIBTC x Bitflow Skills Pay the Bills competition. Original author: @ClankOS Competition PR: https://github.com/BitflowFinance/bff-skills/pull/257
Agent Behavior — HODLMM Flow
Decision order
1. Run doctor first. If Hiro events API fails, stop — flow analysis requires it. 2. For a specific pool question, run flow --pool-id <id>. 3. For a protocol-wide overview, run flow --all. 4. Use --window 24h when the user asks about recent conditions specifically. 5. Parse JSON output and route on verdict.lpSafety.
Guardrails
- Read-only skill — never attempts to move funds or submit transactions
- Never expose API keys in logs or output
- Rate limit awareness — if Hiro rate limit is hit mid-analysis, partial results are returned. Suggest
--hiro-api-keyor reducing--swapscount - Default to 100 swaps when no count specified — balances depth vs API cost
Interpreting metrics
| Metric | Threshold | Agent action |
|---|---|---|
| flowToxicity > 0.7 | Danger | Warn user: informed flow is adversely selecting LPs |
| directionBias > ±0.4 | Warning | Suggest asymmetric range or reduced exposure on drained side |
| binVelocity > 30 | Danger | Recommend widening range or waiting for volatility to settle |
| whaleConcentration > 0.5 | Warning | Single actor dominates — flow may reverse suddenly when they stop |
| liquidationPressure > 0.2 | Warning | Lending market stress — monitor underlying collateral health |
| botFlowRatio > 0.8 | Info | Flow is mostly automated — organic price discovery limited |
Verdict routing
| lpSafety | Agent action |
|---|---|
| safe | Report conditions, confirm range parameters are appropriate |
| caution | Surface specific risk factors, suggest monitoring or range adjustment |
| danger | Recommend reducing exposure or exiting position until conditions normalize |
On error
- Log the full error payload
- Do not retry silently — surface to user with guidance
- If rate-limited: suggest
--hiro-api-keyor narrower--swapscount - If no swap txs found: pool may be dormant, suggest checking a different pool
On success
- Lead with the LP safety verdict and score
- Highlight the most concerning metric (lowest-scoring component)
- If rangeLifespanHours < 12, emphasize this prominently
- Surface top actors with their labels
Integration with other skills
- hodlmm-deadweight → Flow analysis reveals why capital is stranded (directional flow pushed price away from positions)
- hodlmm-advisor → Pair flow verdict with pool health for a complete picture before entering a position
- hodlmm-pulse → Flow metrics complement volume/fee data — toxicity explains quality of volume, not just quantity
/**
* Unit tests for hodlmm-flow skill.
*
* Test 1: SWAP_FUNCTIONS snapshot — verifies the array matches all 8 live
* entrypoints on SM1FKXGNZJWSTWDWXQZJNF7B5TV5ZB235JTCXYXKD.dlmm-swap-router-v-1-1.
* This is a static fixture test; it fails if the array drifts without a deliberate update.
*
* Test 2: 429 partial-result contract — verifies that when fetchSwapTransactions
* throws a "Rate limited" error, analyzePool returns an object with
* partial: true, partial_reason: "hiro_rate_limited", and valid metric keys.
*/
import { test, expect, mock, afterEach } from "bun:test";
import { SWAP_FUNCTIONS, analyzePool } from "./hodlmm-flow";
// ---------------------------------------------------------------------------
// Test 1 — Router surface coverage
// ---------------------------------------------------------------------------
/**
* Verified entrypoints on SM1FKXGNZJWSTWDWXQZJNF7B5TV5ZB235JTCXYXKD.dlmm-swap-router-v-1-1
* (confirmed via Hiro API, 2025-04). If this test fails after a router upgrade,
* update SWAP_FUNCTIONS in hodlmm-flow.ts and this fixture together.
*/
const EXPECTED_ROUTER_FUNCTIONS = [
"swap-multi",
"swap-simple-multi",
"swap-x-for-y-same-multi",
"swap-x-for-y-simple-multi",
"swap-x-for-y-simple-range-multi",
"swap-y-for-x-same-multi",
"swap-y-for-x-simple-multi",
"swap-y-for-x-simple-range-multi",
];
test("SWAP_FUNCTIONS covers all live dlmm-swap-router-v-1-1 entrypoints", () => {
// Exact count
expect(SWAP_FUNCTIONS.length).toBe(8);
// Every expected function must be present
for (const fn of EXPECTED_ROUTER_FUNCTIONS) {
expect(SWAP_FUNCTIONS).toContain(fn);
}
// No extra functions beyond the verified set (fail loudly if array drifts)
for (const fn of SWAP_FUNCTIONS) {
expect(EXPECTED_ROUTER_FUNCTIONS).toContain(fn);
}
});
// ---------------------------------------------------------------------------
// Test 2 — 429 partial-result contract
// ---------------------------------------------------------------------------
// We need fetch to be mockable. analyzePool calls fetchJson which calls globalThis.fetch.
// We mock globalThis.fetch to simulate a 429 on the first Hiro call (fetchSwapTransactions)
// while returning valid fixture data for the Bitflow pool-info calls (getPoolInfo).
const MOCK_POOL_LIST = {
pools: [
{ pool_id: "dlmm_3", bin_step: 10 },
],
};
const MOCK_POOL_DETAIL = {
tokens: {
tokenX: { symbol: "STX", decimals: 6 },
tokenY: { symbol: "USDCx", decimals: 6 },
},
};
afterEach(() => {
// Reset to real fetch after each test
globalThis.fetch = fetch;
});
test("analyzePool returns partial result with hiro_rate_limited when fetch throws 429", async () => {
let callIndex = 0;
// Replace globalThis.fetch with a mock that:
// - Calls 0,1: Bitflow pool-info (pool list + pool detail) — return fixture data
// - Call 2+: Hiro transactions API — respond with 429
globalThis.fetch = mock(async (url: string | URL | Request, _init?: RequestInit) => {
const urlStr = url instanceof Request ? url.url : String(url);
callIndex++;
if (urlStr.includes("bitflowapis.finance") && urlStr.includes("/pools") && !urlStr.includes("dlmm_")) {
// Bitflow quotes /pools (pool list)
return new Response(JSON.stringify(MOCK_POOL_LIST), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
if (urlStr.includes("bitflowapis.finance") && urlStr.includes("dlmm_3")) {
// Bitflow app /pools/dlmm_3 (pool detail)
return new Response(JSON.stringify(MOCK_POOL_DETAIL), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
// All Hiro calls → 429
return new Response("Too Many Requests", {
status: 429,
statusText: "Too Many Requests",
});
}) as typeof fetch;
const result = await analyzePool("dlmm_3", 10, undefined, /* skipCache */ true);
// Core partial-result contract assertions
expect(result.partial).toBe(true);
expect(result.partial_reason).toBe("hiro_rate_limited");
// swapsAnalyzed must be a number (0 is valid for an empty partial result)
expect(typeof result.swapsAnalyzed).toBe("number");
// coverage_rate must be null or a number (field must exist — not undefined)
expect(result.coverage_rate === null || typeof result.coverage_rate === "number").toBe(true);
// Structural integrity: required top-level fields must be present
expect(result.status).toBe("success");
expect(result.network).toBe("mainnet");
expect(typeof result.poolId).toBe("string");
expect(typeof result.pair).toBe("string");
expect(result.metrics).toBeDefined();
expect(result.verdict).toBeDefined();
expect(Array.isArray(result.topActors)).toBe(true);
});
#!/usr/bin/env bun
/**
* HODLMM Flow — Swap Flow Intelligence for Bitflow HODLMM
*
* Market microstructure analysis for concentrated liquidity pools.
* Analyzes on-chain swap transactions to compute direction bias, flow toxicity,
* bin velocity, whale concentration, liquidation pressure, and bot/organic classification.
*
* Usage:
* bun run skills/hodlmm-flow/hodlmm-flow.ts doctor
* bun run skills/hodlmm-flow/hodlmm-flow.ts flow --pool-id dlmm_3
* bun run skills/hodlmm-flow/hodlmm-flow.ts flow --pool-id dlmm_3 --window 24h
* bun run skills/hodlmm-flow/hodlmm-flow.ts flow --all
*/
import { Command } from "commander";
import { mkdirSync, readFileSync, writeFileSync, existsSync } from "fs";
import { join } from "path";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const HIRO_API = "https://api.hiro.so";
const BITFLOW_QUOTES_API = "https://bff.bitflowapis.finance/api/quotes/v1";
const BITFLOW_APP_API = "https://bff.bitflowapis.finance/api/app/v1";
const FETCH_TIMEOUT_MS = 30_000;
const BATCH_SIZE = 10;
const BATCH_DELAY_MS = 300;
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
const POOL_CONTRACTS: Record<string, string> = {
dlmm_1: "SM1FKXGNZJWSTWDWXQZJNF7B5TV5ZB235JTCXYXKD.dlmm-pool-sbtc-usdcx-v-1-bps-10",
dlmm_2: "SM1FKXGNZJWSTWDWXQZJNF7B5TV5ZB235JTCXYXKD.dlmm-pool-sbtc-usdcx-v-1-bps-1",
dlmm_3: "SM1FKXGNZJWSTWDWXQZJNF7B5TV5ZB235JTCXYXKD.dlmm-pool-stx-usdcx-v-1-bps-10",
dlmm_4: "SM1FKXGNZJWSTWDWXQZJNF7B5TV5ZB235JTCXYXKD.dlmm-pool-stx-usdcx-v-1-bps-4",
dlmm_5: "SM1FKXGNZJWSTWDWXQZJNF7B5TV5ZB235JTCXYXKD.dlmm-pool-stx-usdcx-v-1-bps-1",
dlmm_6: "SM1FKXGNZJWSTWDWXQZJNF7B5TV5ZB235JTCXYXKD.dlmm-pool-stx-sbtc-v-1-bps-15",
dlmm_7: "SM1FKXGNZJWSTWDWXQZJNF7B5TV5ZB235JTCXYXKD.dlmm-pool-aeusdc-usdcx-v-1-bps-1",
dlmm_8: "SM1FKXGNZJWSTWDWXQZJNF7B5TV5ZB235JTCXYXKD.dlmm-pool-usdh-usdcx-v-1-bps-1",
};
// All HODLMM pools (used for --all protocol-wide summary)
const PRIMARY_POOLS = ["dlmm_1", "dlmm_2", "dlmm_3", "dlmm_4", "dlmm_5", "dlmm_6", "dlmm_7", "dlmm_8"];
// Swap function names on the dlmm-swap-router-v-1-1 contract (verified via Hiro API).
// "liquidate-with-swap" does NOT exist on any DLMM contract and has been removed.
export const SWAP_FUNCTIONS = [
"swap-multi",
"swap-simple-multi",
"swap-x-for-y-same-multi",
"swap-x-for-y-simple-multi",
"swap-x-for-y-simple-range-multi",
"swap-y-for-x-same-multi",
"swap-y-for-x-simple-multi",
"swap-y-for-x-simple-range-multi",
];
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface HiroTx {
tx_id: string;
tx_status: string;
tx_type: string;
sender_address: string;
block_time: number;
block_height: number;
contract_call?: {
contract_id: string;
function_name: string;
};
}
interface SwapBinHop {
action: string; // "swap-x-for-y" or "swap-y-for-x"
caller: string;
activeBinId: number;
binId: number;
binPrice: number;
dx: bigint;
dy: bigint;
}
interface SwapRecord {
txId: string;
sender: string;
blockTime: number;
blockHeight: number;
direction: "buy-x" | "buy-y" | "unknown";
isLiquidation: boolean;
functionName: string;
hops: SwapBinHop[];
totalDx: bigint;
totalDy: bigint;
activeBinStart: number;
activeBinEnd: number;
}
interface ActorProfile {
address: string;
swapCount: number;
totalVolumeX: bigint;
totalVolumeY: bigint;
label: "bot" | "organic" | "liquidator" | "router";
avgSwapsPerHour: number;
}
interface FlowMetrics {
directionBias: number; // [-1, +1] — negative = net selling X, positive = net buying X
directionBiasLabel: string;
flowToxicity: number; // [0, 1] — ratio of consecutive same-direction runs
flowToxicityLabel: string;
binVelocity: number; // bins per hour
binVelocityLabel: string;
whaleConcentration: number; // Herfindahl index [0, 1]
whaleConcentrationLabel: string;
liquidationPressure: number; // [0, 1] — liquidation volume / total volume
liquidationPressureLabel: string;
botFlowRatio: number; // [0, 1] — bot volume / total volume
botFlowRatioLabel: string;
}
interface FlowVerdict {
lpSafety: "safe" | "caution" | "danger";
score: number; // 0-100
reasoning: string;
recommendation: string;
rangeLifespanHours: number | null;
}
interface FlowAnalysis {
status: "success";
network: "mainnet";
timestamp: string;
poolId: string;
pair: string;
swapsAnalyzed: number;
timeSpanHours: number;
/**
* Fraction of fetched transactions that matched SWAP_FUNCTIONS (0–1), or null if no txs fetched.
* 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. 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;
metrics: FlowMetrics;
verdict: FlowVerdict;
topActors: Array<{
address: string;
swapCount: number;
volumeShare: number;
label: string;
}>;
}
// ---------------------------------------------------------------------------
// Output helpers
// ---------------------------------------------------------------------------
function printJson(data: unknown): void {
console.log(JSON.stringify(data, null, 2));
}
function handleError(error: unknown): void {
const message = error instanceof Error ? error.message : String(error);
printJson({ error: message });
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
// ---------------------------------------------------------------------------
function cacheKey(poolId: string, swapCount: number, windowSeconds?: number): string {
return `${poolId}_${swapCount}_${windowSeconds ?? "all"}`;
}
function cachePath(key: string): string {
return join(CACHE_DIR, `${key}.json`);
}
function readCache(key: string): FlowAnalysis | null {
const path = cachePath(key);
if (!existsSync(path)) return null;
try {
const raw = readFileSync(path, "utf-8");
const cached = JSON.parse(raw) as FlowAnalysis & { _cachedAt: number };
if (Date.now() - cached._cachedAt > CACHE_TTL_MS) return null;
delete (cached as unknown as Record<string, unknown>)._cachedAt;
return cached;
} catch {
return null;
}
}
function writeCache(key: string, data: FlowAnalysis): void {
try {
mkdirSync(CACHE_DIR, { recursive: true });
writeFileSync(cachePath(key), JSON.stringify({ ...data, _cachedAt: Date.now() }));
} catch {
// Cache write failure is non-fatal
}
}
// ---------------------------------------------------------------------------
// Fetch helpers
// ---------------------------------------------------------------------------
let hiroApiKey: string | undefined;
function hiroHeaders(): Record<string, string> {
const h: Record<string, string> = { Accept: "application/json" };
if (hiroApiKey) h["x-hiro-api-key"] = hiroApiKey;
return h;
}
async function fetchJson<T>(url: string, headers?: Record<string, string>): Promise<T> {
const res = await fetch(url, {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
headers: { Accept: "application/json", ...headers },
});
if (res.status === 429) {
const rateLimitErr = new Error(
`Rate limited by ${new URL(url).hostname}. Use --hiro-api-key for elevated limits.`
);
(rateLimitErr as Error & { statusCode: number }).statusCode = 429;
throw rateLimitErr;
}
if (!res.ok) throw new Error(`API ${res.status} ${res.statusText}: ${url}`);
return res.json() as Promise<T>;
}
async function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
// ---------------------------------------------------------------------------
// Clarity repr parsing
// ---------------------------------------------------------------------------
function extractReprField(repr: string, field: string): string | null {
// Match (field value) or (field "value")
const patterns = [
new RegExp(`\\(${field}\\s+"([^"]*)"\\)`), // string: (field "value")
new RegExp(`\\(${field}\\s+'([A-Z0-9]+[^)]*?)\\)`), // principal: (field 'SP...)
new RegExp(`\\(${field}\\s+u(\\d+)\\)`), // uint: (field u123)
new RegExp(`\\(${field}\\s+(-?\\d+)\\)`), // int: (field -123)
];
for (const pat of patterns) {
const m = repr.match(pat);
if (m) return m[1];
}
return null;
}
function parseSwapEvent(repr: string): SwapBinHop | null {
const action = extractReprField(repr, "action");
if (!action || !action.startsWith("swap-")) return null;
const caller = extractReprField(repr, "caller");
const activeBinIdStr = extractReprField(repr, "active-bin-id");
const binIdStr = extractReprField(repr, "bin-id");
const binPriceStr = extractReprField(repr, "bin-price");
const dxStr = extractReprField(repr, "dx");
const dyStr = extractReprField(repr, "dy");
if (!caller || !dxStr || !dyStr) return null;
return {
action,
caller,
activeBinId: activeBinIdStr ? parseInt(activeBinIdStr, 10) : 0,
binId: binIdStr ? parseInt(binIdStr, 10) : 0,
binPrice: binPriceStr ? parseInt(binPriceStr, 10) : 0,
dx: BigInt(dxStr),
dy: BigInt(dyStr),
};
}
// ---------------------------------------------------------------------------
// Data collection
// ---------------------------------------------------------------------------
interface FetchSwapResult {
txs: HiroTx[];
totalFetched: number;
}
async function fetchSwapTransactions(
poolContract: string,
targetSwapCount: number,
windowSeconds?: number
): Promise<FetchSwapResult> {
const swapTxs: HiroTx[] = [];
let offset = 0;
let totalFetched = 0;
const maxPages = 20; // safety limit
const now = Math.floor(Date.now() / 1000);
for (let page = 0; page < maxPages; page++) {
const url = `${HIRO_API}/extended/v1/address/${poolContract}/transactions?limit=${TX_PAGE_SIZE}&offset=${offset}`;
const data = await fetchJson<{ results: HiroTx[]; total: number }>(url, hiroHeaders());
for (const tx of data.results) {
if (tx.tx_status !== "success") continue;
if (tx.tx_type !== "contract_call") continue;
if (!tx.contract_call) continue;
totalFetched++;
const fn = tx.contract_call.function_name;
if (!SWAP_FUNCTIONS.includes(fn)) continue;
// Window filter
if (windowSeconds && (now - tx.block_time) > windowSeconds) {
return { txs: swapTxs, totalFetched }; // Past the window — we're done
}
swapTxs.push(tx);
if (swapTxs.length >= targetSwapCount) return { txs: swapTxs, totalFetched };
}
// No more results
if (data.results.length < TX_PAGE_SIZE) break;
offset += TX_PAGE_SIZE;
}
return { txs: swapTxs, totalFetched };
}
async function fetchTxEvents(txId: string): Promise<SwapBinHop[]> {
const url = `${HIRO_API}/extended/v1/tx/events?tx_id=${txId}&limit=96`;
const data = await fetchJson<{
events: Array<{
event_type: string;
contract_log?: {
contract_id: string;
value: { repr: string };
};
}>;
}>(url, hiroHeaders());
const hops: SwapBinHop[] = [];
for (const ev of data.events) {
if (ev.event_type !== "smart_contract_log") continue;
if (!ev.contract_log?.contract_id?.includes("dlmm-core")) continue;
const repr = ev.contract_log.value?.repr;
if (!repr) continue;
const hop = parseSwapEvent(repr);
if (hop) hops.push(hop);
}
return hops;
}
async function enrichSwaps(txs: HiroTx[]): Promise<SwapRecord[]> {
const records: SwapRecord[] = [];
const errors: string[] = [];
// Batch fetch events
for (let i = 0; i < txs.length; i += BATCH_SIZE) {
const batch = txs.slice(i, i + BATCH_SIZE);
const results = await Promise.allSettled(
batch.map((tx) => fetchTxEvents(tx.tx_id))
);
for (let j = 0; j < batch.length; j++) {
const tx = batch[j];
const result = results[j];
if (result.status === "rejected") {
errors.push(tx.tx_id);
continue;
}
const hops = result.value;
if (hops.length === 0) {
// Infer direction from function name alone
const fn = tx.contract_call!.function_name;
let direction: SwapRecord["direction"] = "unknown";
if (fn.includes("x-for-y")) direction = "buy-y";
else if (fn.includes("y-for-x")) direction = "buy-x";
records.push({
txId: tx.tx_id,
sender: tx.sender_address,
blockTime: tx.block_time,
blockHeight: tx.block_height,
direction,
isLiquidation: tx.sender_address.startsWith(LIQUIDATOR_ADDRESS),
functionName: fn,
hops: [],
totalDx: 0n,
totalDy: 0n,
activeBinStart: 0,
activeBinEnd: 0,
});
continue;
}
const totalDx = hops.reduce((s, h) => s + h.dx, 0n);
const totalDy = hops.reduce((s, h) => s + h.dy, 0n);
const action = hops[0].action;
let direction: SwapRecord["direction"] = "unknown";
if (action === "swap-x-for-y") direction = "buy-y";
else if (action === "swap-y-for-x") direction = "buy-x";
// Bin movement: first hop's activeBinId vs last hop's
const activeBinStart = hops[0].activeBinId;
const activeBinEnd = hops[hops.length - 1].activeBinId;
records.push({
txId: tx.tx_id,
sender: tx.sender_address,
blockTime: tx.block_time,
blockHeight: tx.block_height,
direction,
isLiquidation: tx.sender_address.startsWith(LIQUIDATOR_ADDRESS),
functionName: tx.contract_call!.function_name,
hops,
totalDx,
totalDy,
activeBinStart,
activeBinEnd,
});
}
// Rate limit delay between batches
if (i + BATCH_SIZE < txs.length) {
await sleep(BATCH_DELAY_MS);
}
}
if (errors.length > 0) {
process.stderr.write(
`Warning: failed to fetch events for ${errors.length} txs\n`
);
}
return records;
}
// ---------------------------------------------------------------------------
// Metrics computation
// ---------------------------------------------------------------------------
function computeDirectionBias(swaps: SwapRecord[]): { value: number; label: string } {
if (swaps.length === 0) return { value: 0, label: "No data" };
// Volume-weighted direction bias
let buyXVolume = 0n;
let buyYVolume = 0n;
for (const s of swaps) {
const vol = s.totalDx + s.totalDy; // combined as proxy
if (s.direction === "buy-x") buyXVolume += vol;
else if (s.direction === "buy-y") buyYVolume += vol;
}
const total = buyXVolume + buyYVolume;
if (total === 0n) {
// Fall back to count-based
let buyX = 0, buyY = 0;
for (const s of swaps) {
if (s.direction === "buy-x") buyX++;
else if (s.direction === "buy-y") buyY++;
}
const t = buyX + buyY;
if (t === 0) return { value: 0, label: "No directional swaps" };
const bias = (buyX - buyY) / t;
return { value: Math.round(bias * 1000) / 1000, label: biasLabel(bias) };
}
const bias = Number(buyXVolume - buyYVolume) / Number(total);
return { value: Math.round(bias * 1000) / 1000, label: biasLabel(bias) };
}
function biasLabel(bias: number): string {
if (bias > 0.5) return "Strong buy-X pressure";
if (bias > 0.2) return "Moderate buy-X pressure";
if (bias > 0.05) return "Slight buy-X pressure";
if (bias > -0.05) return "Balanced";
if (bias > -0.2) return "Slight sell-X pressure";
if (bias > -0.5) return "Moderate sell-X pressure";
return "Strong sell-X pressure";
}
function computeFlowToxicity(swaps: SwapRecord[]): { value: number; label: string } {
if (swaps.length < 3) return { value: 0, label: "Insufficient data" };
// Toxicity = ratio of swaps that continue the same direction as the previous swap
// High toxicity = informed flow (adverse selection for LPs)
let sameDirection = 0;
let comparisons = 0;
for (let i = 1; i < swaps.length; i++) {
if (swaps[i].direction === "unknown" || swaps[i - 1].direction === "unknown") continue;
comparisons++;
if (swaps[i].direction === swaps[i - 1].direction) sameDirection++;
}
if (comparisons === 0) return { value: 0, label: "No directional data" };
const toxicity = sameDirection / comparisons;
const rounded = Math.round(toxicity * 1000) / 1000;
let label: string;
if (toxicity > 0.75) label = "Toxic — heavy informed flow, LPs getting picked off";
else if (toxicity > 0.6) label = "Elevated — directional momentum present";
else if (toxicity > 0.45) label = "Normal — mixed flow, healthy for LPs";
else label = "Low — mean-reverting flow, favorable for LPs";
return { value: rounded, label };
}
function computeBinVelocity(swaps: SwapRecord[]): { value: number; label: string } {
if (swaps.length < 2) return { value: 0, label: "Insufficient data" };
// Count distinct active bin changes across consecutive swaps
const swapsWithBins = swaps.filter((s) => s.hops.length > 0);
if (swapsWithBins.length < 2) return { value: 0, label: "No bin data" };
let binChanges = 0;
for (let i = 1; i < swapsWithBins.length; i++) {
const prevBin = swapsWithBins[i - 1].activeBinEnd || swapsWithBins[i - 1].activeBinStart;
const currBin = swapsWithBins[i].activeBinStart;
if (prevBin !== 0 && currBin !== 0 && prevBin !== currBin) {
binChanges += Math.abs(currBin - prevBin);
}
}
// Time span in hours
const earliest = swapsWithBins[swapsWithBins.length - 1].blockTime;
const latest = swapsWithBins[0].blockTime;
const hours = Math.max((latest - earliest) / 3600, 0.01);
const velocity = binChanges / hours;
const rounded = Math.round(velocity * 100) / 100;
let label: string;
if (velocity > 50) label = "Extreme — price whipsawing, narrow ranges will get shredded";
else if (velocity > 20) label = "High — active price discovery, widen your range";
else if (velocity > 5) label = "Moderate — normal volatility";
else label = "Low — price stable, tight ranges viable";
return { value: rounded, label };
}
function computeWhaleConcentration(swaps: SwapRecord[]): { value: number; label: string } {
if (swaps.length === 0) return { value: 0, label: "No data" };
// Herfindahl index on swap volume per address
const volumeByAddress: Record<string, bigint> = {};
let totalVolume = 0n;
for (const s of swaps) {
const vol = s.totalDx + s.totalDy;
const addr = s.sender;
volumeByAddress[addr] = (volumeByAddress[addr] ?? 0n) + vol;
totalVolume += vol;
}
if (totalVolume === 0n) {
// Fall back to count-based HHI
const countByAddress: Record<string, number> = {};
for (const s of swaps) {
countByAddress[s.sender] = (countByAddress[s.sender] ?? 0) + 1;
}
const total = swaps.length;
let hhi = 0;
for (const count of Object.values(countByAddress)) {
const share = count / total;
hhi += share * share;
}
return { value: Math.round(hhi * 1000) / 1000, label: hhiLabel(hhi) };
}
let hhi = 0;
for (const vol of Object.values(volumeByAddress)) {
const share = Number(vol) / Number(totalVolume);
hhi += share * share;
}
return { value: Math.round(hhi * 1000) / 1000, label: hhiLabel(hhi) };
}
function hhiLabel(hhi: number): string {
if (hhi > 0.5) return "Monopolistic — single actor dominates flow";
if (hhi > 0.25) return "Concentrated — few actors drive most volume";
if (hhi > 0.1) return "Moderate — some concentration";
return "Dispersed — healthy mix of participants";
}
function computeLiquidationPressure(swaps: SwapRecord[]): { value: number; label: string } {
if (swaps.length === 0) return { value: 0, label: "No data" };
let liqVolume = 0n;
let totalVolume = 0n;
let liqCount = 0;
for (const s of swaps) {
const vol = s.totalDx + s.totalDy;
totalVolume += vol;
if (s.isLiquidation) {
liqVolume += vol;
liqCount++;
}
}
if (totalVolume === 0n) {
// Count-based fallback
const ratio = liqCount / swaps.length;
return { value: Math.round(ratio * 1000) / 1000, label: liqLabel(ratio, liqCount) };
}
const ratio = Number(liqVolume) / Number(totalVolume);
return { value: Math.round(ratio * 1000) / 1000, label: liqLabel(ratio, liqCount) };
}
function liqLabel(ratio: number, count: number): string {
if (count === 0) return "None — no liquidation flow detected";
if (ratio > 0.3) return `Heavy — ${count} liquidations, ${(ratio * 100).toFixed(1)}% of volume`;
if (ratio > 0.1) return `Moderate — ${count} liquidations present`;
return `Low — ${count} liquidation(s), minimal impact`;
}
function computeBotFlowRatio(swaps: SwapRecord[], actors: ActorProfile[]): { value: number; label: string } {
const botAddresses = new Set(
actors.filter((a) => a.label === "bot" || a.label === "router").map((a) => a.address)
);
let botVolume = 0n;
let totalVolume = 0n;
for (const s of swaps) {
const vol = s.totalDx + s.totalDy;
totalVolume += vol;
if (botAddresses.has(s.sender)) botVolume += vol;
}
if (totalVolume === 0n) {
let botCount = 0;
for (const s of swaps) {
if (botAddresses.has(s.sender)) botCount++;
}
const ratio = swaps.length > 0 ? botCount / swaps.length : 0;
return { value: Math.round(ratio * 1000) / 1000, label: botLabel(ratio) };
}
const ratio = Number(botVolume) / Number(totalVolume);
return { value: Math.round(ratio * 1000) / 1000, label: botLabel(ratio) };
}
function botLabel(ratio: number): string {
if (ratio > 0.8) return "Bot-dominated — organic flow nearly absent";
if (ratio > 0.5) return "Bot-heavy — majority of flow is automated";
if (ratio > 0.2) return "Mixed — bots present but organic flow significant";
return "Organic-dominant — mostly human/natural flow";
}
// ---------------------------------------------------------------------------
// Actor classification
// ---------------------------------------------------------------------------
function classifyActors(swaps: SwapRecord[]): ActorProfile[] {
const actorMap: Record<string, {
count: number;
volumeX: bigint;
volumeY: bigint;
isLiquidator: boolean;
times: number[];
}> = {};
for (const s of swaps) {
if (!actorMap[s.sender]) {
actorMap[s.sender] = { count: 0, volumeX: 0n, volumeY: 0n, isLiquidator: false, times: [] };
}
const a = actorMap[s.sender];
a.count++;
a.volumeX += s.totalDx;
a.volumeY += s.totalDy;
a.times.push(s.blockTime);
if (s.isLiquidation) a.isLiquidator = true;
}
const timeSpanHours = swaps.length > 1
? Math.max((swaps[0].blockTime - swaps[swaps.length - 1].blockTime) / 3600, 0.01)
: 1;
const profiles: ActorProfile[] = [];
for (const [address, data] of Object.entries(actorMap)) {
const avgSwapsPerHour = data.count / timeSpanHours;
let label: ActorProfile["label"];
if (data.isLiquidator) {
label = "liquidator";
} else if (avgSwapsPerHour > 10 || data.count > swaps.length * 0.3) {
label = "bot";
} else if (avgSwapsPerHour > 3) {
label = "router";
} else {
label = "organic";
}
profiles.push({
address,
swapCount: data.count,
totalVolumeX: data.volumeX,
totalVolumeY: data.volumeY,
label,
avgSwapsPerHour: Math.round(avgSwapsPerHour * 100) / 100,
});
}
// Sort by swap count descending
profiles.sort((a, b) => b.swapCount - a.swapCount);
return profiles;
}
// ---------------------------------------------------------------------------
// Verdict generation
// ---------------------------------------------------------------------------
function generateVerdict(
metrics: FlowMetrics,
swaps: SwapRecord[],
binStep: number
): FlowVerdict {
// Score components (0-100 scale, higher = safer for LPs)
let score = 100;
// Direction bias penalty: extreme bias = danger
const biasPenalty = Math.abs(metrics.directionBias) * 30;
score -= biasPenalty;
// Toxicity penalty: high toxicity = informed flow hurting LPs
const toxicityPenalty = metrics.flowToxicity * 30;
score -= toxicityPenalty;
// Bin velocity penalty: high velocity = narrow ranges get destroyed
const velocityPenalty = Math.min(20, metrics.binVelocity * 0.4);
score -= velocityPenalty;
// Whale concentration penalty: concentrated = manipulable
const whalePenalty = Math.max(0, (metrics.whaleConcentration - 0.15) * 30);
score -= whalePenalty;
// Liquidation bonus: some liquidation flow is actually good for LPs (volume + fees)
// But heavy liquidation means underlying assets are stressed
const liqPenalty = metrics.liquidationPressure > 0.2
? (metrics.liquidationPressure - 0.2) * 25
: -metrics.liquidationPressure * 5; // slight bonus
score -= liqPenalty;
// Bot penalty: bot-dominated flow is only harmful when toxic.
// Mean-reverting arb bots in low-toxicity pools are LP-friendly; informed bots in high-toxicity pools amplify adverse selection.
// Scale the base penalty by (0.5 + toxicity): half weight at toxicity=0, 1.5x at toxicity=1.
const botPenaltyBase = Math.max(0, (metrics.botFlowRatio - 0.5) * 20);
const botPenalty = botPenaltyBase * (0.5 + metrics.flowToxicity);
score -= botPenalty;
score = Math.max(0, Math.min(100, Math.round(score)));
let lpSafety: FlowVerdict["lpSafety"];
if (score >= 65) lpSafety = "safe";
else if (score >= 40) lpSafety = "caution";
else lpSafety = "danger";
// Build reasoning
const reasons: string[] = [];
if (Math.abs(metrics.directionBias) > 0.3) {
reasons.push(`Strong directional pressure (${metrics.directionBias > 0 ? "buying" : "selling"} X)`);
}
if (metrics.flowToxicity > 0.6) {
reasons.push("Elevated flow toxicity — informed traders may be front-running LPs");
}
if (metrics.binVelocity > 20) {
reasons.push("High bin velocity — price moving fast, narrow positions at risk");
}
if (metrics.whaleConcentration > 0.25) {
reasons.push("Concentrated flow — few actors dominating volume");
}
if (metrics.liquidationPressure > 0.2) {
reasons.push("Significant liquidation flow — collateral stress in lending markets");
}
if (metrics.botFlowRatio > 0.7) {
reasons.push("Bot-dominated — organic trading minimal");
}
if (reasons.length === 0) {
reasons.push("Flow conditions normal across all metrics");
}
// Range lifespan estimate
let rangeLifespanHours: number | null = null;
if (metrics.binVelocity > 0) {
// Estimate: how long until a ±radius position goes out of range
const radius = Math.max(5, Math.round(50 / binStep));
rangeLifespanHours = Math.round((radius * 2) / metrics.binVelocity * 10) / 10;
if (rangeLifespanHours > 720) rangeLifespanHours = null; // >30 days = effectively infinite
}
// Recommendation
let recommendation: string;
if (lpSafety === "safe") {
recommendation = "Conditions favorable for LPs. Standard range width appropriate.";
} else if (lpSafety === "caution") {
recommendation = rangeLifespanHours && rangeLifespanHours < 24
? `Consider widening range — current velocity exhausts ±${Math.max(5, Math.round(50 / binStep))}-bin position in ~${rangeLifespanHours}h.`
: "Monitor flow direction. Consider asymmetric range if bias persists.";
} else {
recommendation = "Unfavorable conditions for passive LPs. Consider reducing exposure or waiting for flow normalization.";
}
return {
lpSafety,
score,
reasoning: reasons.join(". ") + ".",
recommendation,
rangeLifespanHours,
};
}
// ---------------------------------------------------------------------------
// Pool info helpers
// ---------------------------------------------------------------------------
interface PoolInfo {
poolId: string;
pair: string;
binStep: number;
tokenXSymbol: string;
tokenYSymbol: string;
tokenXDecimals: number;
tokenYDecimals: number;
}
async function getPoolInfo(poolId: string): Promise<PoolInfo> {
const [poolList, detail] = await Promise.all([
fetchJson<{ pools: Array<{ pool_id: string; bin_step: number }> }>(`${BITFLOW_QUOTES_API}/pools`),
fetchJson<{
tokens: {
tokenX: { symbol: string; decimals: number };
tokenY: { symbol: string; decimals: number };
};
}>(`${BITFLOW_APP_API}/pools/${poolId}`),
]);
const pool = poolList.pools.find((p) => p.pool_id === poolId);
if (!pool) throw new Error(`Pool ${poolId} not found in Bitflow API`);
return {
poolId,
pair: `${detail.tokens.tokenX.symbol}/${detail.tokens.tokenY.symbol}`,
binStep: pool.bin_step,
tokenXSymbol: detail.tokens.tokenX.symbol,
tokenYSymbol: detail.tokens.tokenY.symbol,
tokenXDecimals: detail.tokens.tokenX.decimals,
tokenYDecimals: detail.tokens.tokenY.decimals,
};
}
// ---------------------------------------------------------------------------
// Duration parsing
// ---------------------------------------------------------------------------
function parseDuration(input: string): number {
const m = input.match(/^(\d+)\s*(h|hr|hrs|hour|hours|d|day|days|m|min|mins|minutes?)$/i);
if (!m) throw new Error(`Invalid duration: "${input}". Use e.g. 24h, 1d, 30m.`);
const n = parseInt(m[1], 10);
const unit = m[2].toLowerCase();
if (unit.startsWith("h")) return n * 3600;
if (unit.startsWith("d")) return n * 86400;
if (unit.startsWith("m")) return n * 60;
throw new Error(`Unknown time unit: ${unit}`);
}
// ---------------------------------------------------------------------------
// Main analysis
// ---------------------------------------------------------------------------
export async function analyzePool(
poolId: string,
swapCount: number,
windowSeconds?: number,
skipCache = false
): Promise<FlowAnalysis> {
const contract = POOL_CONTRACTS[poolId];
if (!contract) throw new Error(`Unknown pool: ${poolId}. Valid: ${Object.keys(POOL_CONTRACTS).join(", ")}`);
// Check cache (5-min TTL — avoids redundant Hiro crawls for repeated calls)
const key = cacheKey(poolId, swapCount, windowSeconds);
if (!skipCache) {
const cached = readCache(key);
if (cached) return cached;
}
const poolInfo = await getPoolInfo(poolId);
// Fetch swap transactions — catch 429 and return partial results instead of crashing
let fetchResult: FetchSwapResult = { txs: [], totalFetched: 0 };
let isPartial = false;
let partialReason: string | undefined;
try {
fetchResult = await fetchSwapTransactions(contract, swapCount, windowSeconds);
} catch (e) {
if (isRateLimitError(e)) {
fetchResult = { txs: [], totalFetched: 0 };
isPartial = true;
partialReason = "hiro_rate_limited";
} else {
throw e;
}
}
const { txs, totalFetched } = fetchResult;
if (txs.length === 0 && !isPartial) {
throw new Error(`No swap transactions found for ${poolId}${windowSeconds ? ` in the specified window` : ""}`);
}
// Coverage rate: what fraction of fetched contract_call txs matched SWAP_FUNCTIONS
const coverage_rate = totalFetched > 0 ? Math.round((txs.length / totalFetched) * 10000) / 10000 : null;
const coverage_warning = coverage_rate !== null && coverage_rate < 1.0;
// Enrich with event data — catch 429 mid-enrich and return partial results
let swaps: SwapRecord[];
try {
swaps = await enrichSwaps(txs);
} catch (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
// outside of the batch — treat as partial
isPartial = true;
partialReason = "hiro_rate_limited";
swaps = [];
} else {
throw e;
}
}
if (swaps.length === 0 && !isPartial) {
throw new Error(`Failed to parse any swap data for ${poolId}`);
}
if (swaps.length === 0 && isPartial) {
// Return a minimal partial result with no metrics
const partialResult: FlowAnalysis = {
status: "success",
network: "mainnet",
timestamp: new Date().toISOString(),
poolId,
pair: poolInfo.pair,
swapsAnalyzed: 0,
timeSpanHours: 0,
coverage_rate,
coverage_warning,
partial: true,
partial_reason: partialReason,
metrics: {
directionBias: 0,
directionBiasLabel: "Partial data — rate limited",
flowToxicity: 0,
flowToxicityLabel: "Partial data — rate limited",
binVelocity: 0,
binVelocityLabel: "Partial data — rate limited",
whaleConcentration: 0,
whaleConcentrationLabel: "Partial data — rate limited",
liquidationPressure: 0,
liquidationPressureLabel: "Partial data — rate limited",
botFlowRatio: 0,
botFlowRatioLabel: "Partial data — rate limited",
},
verdict: {
lpSafety: "caution",
score: 0,
reasoning: "Incomplete data due to Hiro API rate limiting.",
recommendation: "Retry with --hiro-api-key or wait for rate limit reset.",
rangeLifespanHours: null,
},
topActors: [],
};
return partialResult;
}
// Classify actors
const actors = classifyActors(swaps);
// Compute metrics
const dirBias = computeDirectionBias(swaps);
const toxicity = computeFlowToxicity(swaps);
const velocity = computeBinVelocity(swaps);
const whale = computeWhaleConcentration(swaps);
const liq = computeLiquidationPressure(swaps);
const botFlow = computeBotFlowRatio(swaps, actors);
const metrics: FlowMetrics = {
directionBias: dirBias.value,
directionBiasLabel: dirBias.label,
flowToxicity: toxicity.value,
flowToxicityLabel: toxicity.label,
binVelocity: velocity.value,
binVelocityLabel: velocity.label,
whaleConcentration: whale.value,
whaleConcentrationLabel: whale.label,
liquidationPressure: liq.value,
liquidationPressureLabel: liq.label,
botFlowRatio: botFlow.value,
botFlowRatioLabel: botFlow.label,
};
// Generate verdict
const verdict = generateVerdict(metrics, swaps, poolInfo.binStep);
// Time span
const earliest = swaps[swaps.length - 1].blockTime;
const latest = swaps[0].blockTime;
const timeSpanHours = Math.round(((latest - earliest) / 3600) * 10) / 10;
// Top actors (top 5 by volume share)
const totalVol = swaps.reduce((s, sw) => s + sw.totalDx + sw.totalDy, 0n);
const topActors = actors.slice(0, 5).map((a) => ({
address: a.address,
swapCount: a.swapCount,
volumeShare: totalVol > 0n
? Math.round(Number(a.totalVolumeX + a.totalVolumeY) / Number(totalVol) * 1000) / 10
: Math.round((a.swapCount / swaps.length) * 1000) / 10,
label: a.label,
}));
const result: FlowAnalysis = {
status: "success",
network: "mainnet",
timestamp: new Date().toISOString(),
poolId,
pair: poolInfo.pair,
swapsAnalyzed: swaps.length,
timeSpanHours,
coverage_rate,
coverage_warning,
...(isPartial ? { partial: true as const, partial_reason: partialReason } : {}),
metrics,
verdict,
topActors,
};
writeCache(key, result);
return result;
}
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
const program = new Command();
program
.name("hodlmm-flow")
.description("Swap flow intelligence for Bitflow HODLMM concentrated liquidity pools");
program
.command("doctor")
.description("Check API connectivity")
.action(async () => {
try {
const checks: Record<string, string> = {};
// Hiro API
try {
await fetchJson<unknown>(`${HIRO_API}/v2/info`);
checks["hiro-api"] = "ok";
} catch (e) {
checks["hiro-api"] = `fail: ${e instanceof Error ? e.message : String(e)}`;
}
// Hiro transactions API (specific to our use case)
try {
const contract = POOL_CONTRACTS.dlmm_3;
await fetchJson<unknown>(
`${HIRO_API}/extended/v1/address/${contract}/transactions?limit=1`,
hiroHeaders()
);
checks["hiro-transactions"] = "ok";
} catch (e) {
checks["hiro-transactions"] = `fail: ${e instanceof Error ? e.message : String(e)}`;
}
// Hiro events API
try {
// Use a known tx to test events endpoint
const contract = POOL_CONTRACTS.dlmm_3;
const txData = await fetchJson<{ results: HiroTx[] }>(
`${HIRO_API}/extended/v1/address/${contract}/transactions?limit=1`,
hiroHeaders()
);
if (txData.results.length > 0) {
await fetchJson<unknown>(
`${HIRO_API}/extended/v1/tx/events?tx_id=${txData.results[0].tx_id}&limit=1`,
hiroHeaders()
);
checks["hiro-events"] = "ok";
} else {
checks["hiro-events"] = "skip: no txs to test";
}
} catch (e) {
checks["hiro-events"] = `fail: ${e instanceof Error ? e.message : String(e)}`;
}
// Bitflow quotes API
try {
await fetchJson<unknown>(`${BITFLOW_QUOTES_API}/pools`);
checks["bitflow-quotes"] = "ok";
} catch (e) {
checks["bitflow-quotes"] = `fail: ${e instanceof Error ? e.message : String(e)}`;
}
// Bitflow app API
try {
await fetchJson<unknown>(`${BITFLOW_APP_API}/pools/dlmm_3`);
checks["bitflow-app"] = "ok";
} catch (e) {
checks["bitflow-app"] = `fail: ${e instanceof Error ? e.message : String(e)}`;
}
const allOk = Object.values(checks).every((v) => v === "ok");
printJson({
status: allOk ? "success" : "degraded",
result: allOk ? "All APIs reachable" : "Some APIs unreachable",
checks,
pools: Object.keys(POOL_CONTRACTS).length,
note: "Flow analysis requires Hiro transactions + events APIs. Free tier supports ~100 swaps per run. Use --hiro-api-key for higher throughput.",
});
} catch (e) {
handleError(e);
}
});
program
.command("flow")
.description("Analyze swap flow for a HODLMM pool")
.option("--pool-id <id>", "Pool ID (e.g. dlmm_3)")
.option("--window <duration>", "Time window (e.g. 24h, 7d)")
.option("--swaps <count>", "Number of swaps to analyze", String(DEFAULT_SWAP_COUNT))
.option("--all", "Analyze all primary pools")
.option("--hiro-api-key <key>", "Hiro API key for elevated rate limits")
.option("--no-cache", "Bypass cache and force fresh analysis")
.action(async (opts) => {
try {
if (opts.hiroApiKey) hiroApiKey = opts.hiroApiKey;
const skipCache = opts.cache === false;
const swapCount = parseInt(opts.swaps, 10) || DEFAULT_SWAP_COUNT;
const windowSeconds = opts.window ? parseDuration(opts.window) : undefined;
if (opts.all) {
// Analyze all primary pools
const results: FlowAnalysis[] = [];
const errors: Array<{ poolId: string; error: string }> = [];
for (const poolId of PRIMARY_POOLS) {
try {
const analysis = await analyzePool(poolId, swapCount, windowSeconds, skipCache);
results.push(analysis);
} catch (e) {
errors.push({
poolId,
error: e instanceof Error ? e.message : String(e),
});
}
}
// Summary
const avgScore = results.length > 0
? Math.round(results.reduce((s, r) => s + r.verdict.score, 0) / results.length)
: 0;
printJson({
status: "success",
network: "mainnet",
timestamp: new Date().toISOString(),
mode: "protocol-wide",
poolsAnalyzed: results.length,
poolsFailed: errors.length,
protocolSafetyScore: avgScore,
pools: results.map((r) => ({
poolId: r.poolId,
pair: r.pair,
swapsAnalyzed: r.swapsAnalyzed,
timeSpanHours: r.timeSpanHours,
safetyScore: r.verdict.score,
lpSafety: r.verdict.lpSafety,
directionBias: r.metrics.directionBias,
flowToxicity: r.metrics.flowToxicity,
binVelocity: r.metrics.binVelocity,
topActor: r.topActors[0]
? `${r.topActors[0].address.slice(0, 8)}... (${r.topActors[0].label}, ${r.topActors[0].volumeShare}%)`
: "none",
})),
errors: errors.length > 0 ? errors : undefined,
});
} else {
// Single pool
const poolId = opts.poolId;
if (!poolId) {
printJson({ error: "Specify --pool-id <id> or use --all. Valid pools: " + Object.keys(POOL_CONTRACTS).join(", ") });
process.exit(1);
}
const analysis = await analyzePool(poolId, swapCount, windowSeconds, skipCache);
printJson(analysis);
}
} catch (e) {
handleError(e);
}
});
program
.command("install-packs")
.description("No-op: registry compatibility. This skill has no additional packs to install.")
.action(() => {
printJson({
status: "success",
result: "No packs to install — hodlmm-flow has no external dependencies beyond bun + node_modules.",
packs: [],
});
});
if (import.meta.main) {
program.parse();
}
Related skills
FAQ
What metrics does hodlmm-flow compute?
Direction bias, flow toxicity, bin velocity, whale concentration, liquidation pressure, and bot/organic classification.
Does it require a wallet?
No. It is read-only and safe to call from any agent without authentication.
Can it analyze all pools at once?
Yes. The flow --all command gives a protocol-wide summary across all 8 HODLMM pools.