[AIBTC Skills Comp Day 29] hodlmm-dca — Recurring DCA into HODLMM DLMM Pools#12
[AIBTC Skills Comp Day 29] hodlmm-dca — Recurring DCA into HODLMM DLMM Pools#12sonic-mast wants to merge 5 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces the hodlmm-dca skill, which enables automated dollar-cost averaging into Bitflow HODLMM pools. The implementation includes a CLI for managing DCA plans, executing swaps, and generating LP deposit commands. Feedback focuses on correcting the bin_price calculation formula and addressing hardcoded assumptions that STX is always the input and token_y is always the output. These assumptions currently prevent the skill from working correctly with pools where STX is the second token in the pair, leading to incorrect decimal conversions and bin distributions. Additionally, a suggestion was made to use asynchronous imports for the Bitflow SDK to maintain consistency and avoid blocking the event loop.
| const binOffset = i - binSpread; | ||
| const binId = activeBin + binOffset; | ||
| // Token_y-side bins (for acquired token_y from swap) | ||
| return `{bin_id: ${binId}, amount_x: 0, amount_y: ${amountPerBin}}`; |
There was a problem hiding this comment.
This logic hardcodes the acquired liquidity into amount_y. If the swap resulted in token_x (which occurs if STX is the token_y in the pool pair), the liquidity should be added to amount_x instead. The function should be updated to dynamically assign the amount to the correct token side based on the swap result to ensure the generated MCP command is valid for all pool configurations.
| timestamp: new Date().toISOString(), | ||
| pool_id: plan.pool_id, | ||
| active_bin: pool.active_bin, | ||
| bin_price: String(pool.active_bin * pool.bin_step), |
There was a problem hiding this comment.
The calculation for bin_price as pool.active_bin * pool.bin_step is incorrect for a DLMM pool. The price in a DLMM bin is determined by the formula (1 + bin_step / 10000) ^ bin_id. Multiplying the bin ID by the step does not yield a valid price representation.
| bin_price: String(pool.active_bin * pool.bin_step), | |
| bin_price: Math.pow(1 + pool.bin_step / 10000, pool.active_bin).toString(), |
| bin_price: String(pool.active_bin * pool.bin_step), | ||
| stx_amount: plan.stx_per_run, | ||
| token_in: "STX", | ||
| token_out: pool.token_y_symbol, |
There was a problem hiding this comment.
The code assumes that the DCA target is always token_y and the input is always STX. If a pool has STX as token_y (e.g., a USDA/STX pool), the skill will attempt to swap STX for STX, which will fail. The logic should dynamically determine which token in the pair is NOT STX and target that for the swap and subsequent LP deployment.
| const amountOutMicro = Math.floor( | ||
| swapResult.amountOut * Math.pow(10, pool.token_y_decimals) | ||
| ); |
There was a problem hiding this comment.
| slippagePct: number; | ||
| dryRun: boolean; | ||
| }): Promise<{ txId: string; explorerUrl: string; amountOut: number }> { | ||
| const { BitflowSDK } = require("@bitflowlabs/core-sdk"); |
There was a problem hiding this comment.
Using require inside an async function is synchronous and blocks the event loop. Since this is a Bun environment and other dependencies are imported dynamically using await import, this should also use await import for consistency and to avoid blocking the execution thread.
| const { BitflowSDK } = require("@bitflowlabs/core-sdk"); | |
| const { BitflowSDK } = await import("@bitflowlabs/core-sdk"); |
| plan.run_count += 1; | ||
| if (!isDryRun) plan.total_deployed += plan.stx_per_run; | ||
| plan.next_run_at = new Date( | ||
| Date.now() + plan.interval_hours * 3_600_000 | ||
| ).toISOString(); | ||
| plan.consecutive_failures = 0; |
There was a problem hiding this comment.
🔴 Dry-run mutates plan state: increments run_count, advances frequency gate, and persists to disk
When run is invoked without --confirm (dry-run mode), lines 809–816 unconditionally increment plan.run_count, advance plan.next_run_at by interval_hours, reset plan.consecutive_failures, save the plan, and append to history. Only total_deployed is guarded by if (!isDryRun) on line 810.
This means a dry-run preview will: (1) close the frequency gate, blocking a subsequent real run --confirm call for the full interval duration; (2) count toward max_runs, potentially marking the plan as completed before any real swap occurs; and (3) pollute history with dry-run entries that affect run_count tracking.
Was this helpful? React with 👍 or 👎 to provide feedback.
| let stxAddress = process.env.STX_ADDRESS ?? ""; | ||
| let stxPrivateKey = ""; |
There was a problem hiding this comment.
🚩 Dry-run passes empty senderAddress to BitflowSDK prepareSwap
When --confirm is not provided, stxAddress defaults to process.env.STX_ADDRESS ?? "" at line 663 and stxPrivateKey stays "". Both are passed to executeSwap, which calls sdk.prepareSwap(swapExecutionData, opts.senderAddress, ...) at line 279-283 before the dry-run early return at line 285. If STX_ADDRESS is unset, the SDK receives an empty string as the sender address. Whether this causes a failure depends on the BitflowSDK implementation — it may or may not validate the address during prepareSwap. If it does fail, the error would be caught by the try/catch at line 774, incrementing consecutive_failures and potentially auto-pausing the plan. This is somewhat mitigated by the fact that the broader dry-run state mutation bug (BUG-0002) should be fixed first, but worth keeping in mind.
Was this helpful? React with 👍 or 👎 to provide feedback.
…nce API shape, require→import
- bin_price: use DLMM formula (1 + bin_step/10000)^active_bin, not active_bin*bin_step
- resolveSwapTarget(): dynamically determine non-STX token (STX can be token_x or token_y)
- buildDepositCmd: pass isTargetTokenX flag so amount_x/amount_y are set on correct side
- fetchStxBalance: Hiro API returns flat {balance} not nested {stx:{balance}}; add fallback
- executeSwap: require() → await import() to avoid blocking event loop in async context
- fetchPools: switch to quotes API (active_bin field) with KNOWN_SYMBOLS contract lookup
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| function resolveSwapTarget(pool: PoolMeta): { | ||
| targetSymbol: string; | ||
| targetDecimals: number; | ||
| isTargetTokenX: boolean; // true = acquired token goes into amount_x bins | ||
| } { | ||
| const isStxY = pool.token_y_symbol.toUpperCase() === "STX"; | ||
| if (isStxY) { | ||
| return { | ||
| targetSymbol: pool.token_x_symbol, | ||
| targetDecimals: pool.token_x_decimals, | ||
| isTargetTokenX: true, | ||
| }; | ||
| } | ||
| return { | ||
| targetSymbol: pool.token_y_symbol, | ||
| targetDecimals: pool.token_y_decimals, | ||
| isTargetTokenX: false, | ||
| }; | ||
| } |
There was a problem hiding this comment.
🚩 resolveSwapTarget assumes STX is always one of the pool tokens
The resolveSwapTarget function at line 241 checks if token_y_symbol is "STX" and, if not, assumes STX must be token_x — returning token_y as the swap target. However, if a pool has neither token as STX (e.g., a sBTC/USDCx pool), the function silently returns token_y as the target with isTargetTokenX: false. The swap itself would still go through BitflowSDK (which routes STX → target via intermediate pools), but the LP deposit command would place tokens only on the Y side, which may not match the pool structure. In practice, this is unlikely to cause issues because the DCA skill description says it's for STX-based DCA, and all common HODLMM pools include STX. However, there's no explicit validation at setup time that the chosen pool actually contains STX as one of its tokens.
Was this helpful? React with 👍 or 👎 to provide feedback.
| const swapTarget = resolveSwapTarget(pool); | ||
|
|
||
| // DLMM bin price: (1 + bin_step / 10000) ^ active_bin | ||
| const binPrice = Math.pow(1 + pool.bin_step / 10000, pool.active_bin).toFixed(8); |
There was a problem hiding this comment.
🚩 Bin price formula may not match Bitflow HODLMM convention
Line 753 computes binPrice = Math.pow(1 + pool.bin_step / 10000, pool.active_bin). Standard DLMM implementations (e.g. Trader Joe) use a base offset for bin IDs (e.g., binId - 8388608). If Bitflow's HODLMM uses a similar offset convention, this formula would produce wildly incorrect prices for typical bin IDs in the hundreds. The bin_price value is stored in history entries and used in the deposit command context but doesn't gate any safety-critical decision. Since the actual swap uses BitflowSDK's quote engine (not this price), the financial risk is limited to displaying misleading price info in history. Without access to the Bitflow HODLMM specification, I can't confirm whether the formula is correct.
Was this helpful? React with 👍 or 👎 to provide feedback.
|
Superseded by new Day 30 submission. Upstream PR BitflowFinance#544 remains open for judging. |
|
Superseded by new Day 30 submission. Upstream PR BitflowFinance#544 remains open for judging. |
Skill Submission
Skill name: hodlmm-dca
Category: Trading / LP
HODLMM integration? Yes — uses Bitflow HODLMM DLMM pool API (active bin, bin step, pool state) and outputs
bitflow_hodlmm_add_liquidityMCP command for LP deploymentWhat it does
Recurring Dollar Cost Averaging directly into Bitflow HODLMM DLMM pools. Traditional DCA buys tokens; hodlmm-dca buys LP positions:
setup— configure pool, STX per run, interval, bin spreadrun— check frequency gate; swap fixed STX via BitflowSDK at current active-bin price; outputbitflow_hodlmm_add_liquiditycommand for LP stepstatus / history— track cumulative positions, next run timecancel— stop the planOn-chain proof
Executes swaps via BitflowSDK (
@bitflowlabs/core-sdk) usingPostConditionMode.Deny. Full execution requires--confirm+ wallet unlock.Why HODLMM-specific DCA beats token DCA
Registry compatibility checklist
metadata:nested frontmatteruser-invocableis"false"metadata.authoris"sonic-mast"--confirmrequired for all write operationsReview outcomes (5 fix rounds)
(1 + bin_step/10000)^active_binresolveSwapTarget()handles both STX-as-tokenX and STX-as-tokenY poolsbuildDepositCmdusesisTargetTokenXflag, not hardcoded amount_yswapTarget.targetDecimalsfor micro-unit conversion(v: string) => parseInt(v, 10)on all optionsSafety model
Built by Sonic Mast — @sonic-mast