From 93705762c4ffeb76b7b44680eb03f4e1b52c7194 Mon Sep 17 00:00:00 2001 From: Peter Date: Tue, 21 Apr 2026 20:30:00 +0100 Subject: [PATCH 1/8] fix(hodlmm): swap DLMM bin arguments and read live variable fees for spread check per audit --- hodlmm-arb-executor/hodlmm-arb-executor.ts | 52 +++++++++++++++++----- 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/hodlmm-arb-executor/hodlmm-arb-executor.ts b/hodlmm-arb-executor/hodlmm-arb-executor.ts index 06f8a9c2..65c69e45 100644 --- a/hodlmm-arb-executor/hodlmm-arb-executor.ts +++ b/hodlmm-arb-executor/hodlmm-arb-executor.ts @@ -45,7 +45,6 @@ const TOKEN_SBTC = "token-sbtc"; // Fee estimates (bps) const FEE_BPS = { xyk: 30, // 0.30% Bitflow XYK fee - dlmm: 25, // 0.25% HODLMM fee (variable, typical) }; // Safety limits — HARD CAPS enforced in code, not just documentation @@ -88,6 +87,8 @@ interface DlmmData { activeBinId: number; totalBins: number; source: "bitflow-api" | "unavailable"; + xFeeBps: number; + yFeeBps: number; } interface McpCommand { @@ -275,12 +276,26 @@ interface HodlmmBinsResponse { bins: HodlmmBin[]; } +interface HodlmmPoolResponse { + pools: Array<{ + pool_id: string; + x_total_fee_bps: string; + y_total_fee_bps: string; + }>; +} + async function fetchDlmmBins(): Promise { try { - const bins = await fetchJson( - `${BITFLOW_QUOTES_API}/bins/${DLMM_POOL_ID}`, - BITFLOW_API_KEY ? { headers: { "x-api-key": BITFLOW_API_KEY } } : undefined - ); + const [bins, pools] = await Promise.all([ + fetchJson( + `${BITFLOW_QUOTES_API}/bins/${DLMM_POOL_ID}`, + BITFLOW_API_KEY ? { headers: { "x-api-key": BITFLOW_API_KEY } } : undefined + ), + fetchJson( + `${BITFLOW_QUOTES_API}/pools?amm_type=dlmm`, + BITFLOW_API_KEY ? { headers: { "x-api-key": BITFLOW_API_KEY } } : undefined + ), + ]); const activeBinId = bins.active_bin_id ?? 0; const activeBin = bins.bins?.find((b) => b.bin_id === activeBinId); @@ -293,14 +308,20 @@ async function fetchDlmmBins(): Promise { const rawPrice = activeBin?.price ? Number(activeBin.price) : 0; const stxPerBtc = rawPrice * 10; + const poolData = pools.pools?.find(p => p.pool_id === DLMM_POOL_ID); + const xFeeBps = poolData?.x_total_fee_bps ? Number(poolData.x_total_fee_bps) : 0; + const yFeeBps = poolData?.y_total_fee_bps ? Number(poolData.y_total_fee_bps) : 0; + return { stxPerBtc: round(stxPerBtc, 2), activeBinId, totalBins: bins.bins?.length ?? 0, source: stxPerBtc > 0 ? "bitflow-api" : "unavailable", + xFeeBps, + yFeeBps, }; } catch { - return { stxPerBtc: 0, activeBinId: 0, totalBins: 0, source: "unavailable" }; + return { stxPerBtc: 0, activeBinId: 0, totalBins: 0, source: "unavailable", xFeeBps: 0, yFeeBps: 0 }; } } @@ -322,7 +343,8 @@ function analyzeSpread(oracle: OraclePrices, xyk: XykReserves, dlmm: DlmmData): if (dlmm.source === "unavailable" || dlmm.stxPerBtc === 0) return null; const grossSpread = Math.abs(((xyk.stxPerBtc - dlmm.stxPerBtc) / dlmm.stxPerBtc) * 100); - const estFee = (FEE_BPS.xyk + FEE_BPS.dlmm) / 100; + const dlmmFeeTotal = (dlmm.xFeeBps + dlmm.yFeeBps) / 100; + const estFee = (FEE_BPS.xyk / 100) + dlmmFeeTotal; const netSpread = grossSpread - estFee; // Confidence buffer: STX feed uncertainty as % of price. // stxPerBtc = btcUsd / stxUsd — latency between publishes creates noise. @@ -368,15 +390,25 @@ function buildEntryCommands(oracle: OraclePrices, activeBinId: number, satsCappe pool_id: DLMM_POOL_ID, bins: JSON.stringify([ { +<<<<<<< HEAD activeBinOffset: 1, // one bin above active = pricing at premium xAmount: String(satsCapped), yAmount: "0", // one-sided sBTC deposit above active bin +======= + activeBinOffset: 1, // offset: 1 (requested by maintainers for Y-side logic fix) + xAmount: "0", + yAmount: String(satsCapped), +>>>>>>> f1fcd6f (fix(hodlmm): swap DLMM bin arguments and read live variable fees for spread check per audit) }, ]), active_bin_tolerance: JSON.stringify({ expectedBinId: activeBinId, maxDeviation: "2" }), slippage_tolerance: "1.5", }, +<<<<<<< HEAD description: `Add ${sbtcAmount} sBTC to DLMM pool ${DLMM_POOL_ID} bin +1 (LP entry at premium)`, +======= + description: `Add ${sbtcAmount} sBTC to DLMM pool ${DLMM_POOL_ID} bin 1 (Y-side LP entry)`, +>>>>>>> f1fcd6f (fix(hodlmm): swap DLMM bin arguments and read live variable fees for spread check per audit) postConditions: [ `FT debit sBTC eq ${satsCapped} sats`, `LP tokens credited for pool ${DLMM_POOL_ID}`, @@ -491,8 +523,8 @@ program status: dlmm.source === "unavailable" ? (!BITFLOW_API_KEY ? "warn" : "error") : "ok", detail: dlmm.source === "unavailable" ? (!BITFLOW_API_KEY - ? "BITFLOW_API_KEY env var not set — set it to enable DLMM spread detection" - : "HODLMM API unreachable — execute requires DLMM data") + ? "BITFLOW_API_KEY env var not set — set it to enable DLMM spread detection" + : "HODLMM API unreachable — execute requires DLMM data") : `${dlmm.stxPerBtc} STX/BTC | active bin ${dlmm.activeBinId} | ${dlmm.totalBins} bins | oracle implied ${oracleImplied} STX/BTC`, }); } catch (e) { @@ -773,7 +805,7 @@ program state.openPosition = { entryTimestamp: state.lastExecutionAt, entrySpreadPct: signal.grossSpreadPct, - entryBinId: dlmm.activeBinId + 1, // LP deposited at activeBinOffset: +1 + satsSent: satsCapped, estimatedEntryUsd: round((satsCapped / 1e8) * oracle.btcUsd, 2), }; From 46d44980327e8b946e683bb600520c458face052 Mon Sep 17 00:00:00 2001 From: Peter Date: Wed, 22 Apr 2026 01:01:43 +0100 Subject: [PATCH 2/8] feat(bitflow): add absolute bin target support for hodlmm withdrawals --- bitflow/bitflow.ts | 119 ++++++++++++++++++++++++---- src/lib/services/bitflow.service.ts | 97 ++++++++++++++++++++--- 2 files changed, 189 insertions(+), 27 deletions(-) diff --git a/bitflow/bitflow.ts b/bitflow/bitflow.ts index a5685e7f..362f5853 100644 --- a/bitflow/bitflow.ts +++ b/bitflow/bitflow.ts @@ -58,7 +58,7 @@ function normalizeRelativeLiquidityBins(rawBins: unknown): HodlmmRelativeLiquidi }); } -function normalizeRelativeWithdrawalPositions(rawPositions: unknown): HodlmmRelativeWithdrawalInput[] { +function normalizeWithdrawalPositions(rawPositions: unknown): HodlmmRelativeWithdrawalInput[] { if (!Array.isArray(rawPositions) || rawPositions.length === 0) { throw new Error("--positions must be a non-empty JSON array"); } @@ -70,12 +70,13 @@ function normalizeRelativeWithdrawalPositions(rawPositions: unknown): HodlmmRela const value = position as Record; const activeBinOffset = value.activeBinOffset ?? value.active_bin_offset; + const binId = value.binId ?? value.bin_id; const amount = value.amount; const minXAmount = value.minXAmount ?? value.min_x_amount ?? 0; const minYAmount = value.minYAmount ?? value.min_y_amount ?? 0; - if (typeof activeBinOffset !== "number") { - throw new Error(`positions[${index}].activeBinOffset must be a number`); + if (activeBinOffset === undefined && binId === undefined) { + throw new Error(`positions[${index}].activeBinOffset or binId is required`); } if (amount === undefined || amount === null) { @@ -83,7 +84,8 @@ function normalizeRelativeWithdrawalPositions(rawPositions: unknown): HodlmmRela } return { - activeBinOffset, + activeBinOffset: activeBinOffset as number, + binId: binId as number, amount: String(amount), minXAmount: String(minXAmount), minYAmount: String(minYAmount), @@ -314,7 +316,7 @@ program .name("bitflow") .description( "Bitflow DEX: token swaps, market data, routing, and Keeper automation on Stacks. Mainnet-only. " + - "No API key required — uses public endpoints (500 req/min)." + "No API key required — uses public endpoints (500 req/min)." ) .version("0.1.0"); @@ -326,7 +328,7 @@ program .command("get-ticker") .description( "Get market ticker data from Bitflow DEX. Returns price, volume, and liquidity data for all trading pairs. " + - "No API key required. Mainnet-only." + "No API key required. Mainnet-only." ) .option( "--base-currency ", @@ -405,7 +407,7 @@ program .command("get-tokens") .description( "Get all available tokens for swapping on Bitflow. " + - "No API key required — uses public endpoints (500 req/min). Mainnet-only." + "No API key required — uses public endpoints (500 req/min). Mainnet-only." ) .action(async () => { try { @@ -593,7 +595,7 @@ program .command("get-swap-targets") .description( "Get possible swap target tokens for a given input token on Bitflow. " + - "Returns all tokens that can be received when swapping from the specified token. Mainnet-only." + "Returns all tokens that can be received when swapping from the specified token. Mainnet-only." ) .requiredOption( "--token-id ", @@ -688,7 +690,7 @@ program .command("get-routes") .description( "Get all possible swap routes between two tokens on Bitflow. " + - "Includes multi-hop routes through intermediate tokens. Mainnet-only." + "Includes multi-hop routes through intermediate tokens. Mainnet-only." ) .requiredOption( "--token-x ", @@ -739,7 +741,7 @@ program .command("swap") .description( "Execute a token swap on Bitflow DEX. Automatically finds the best route across all Bitflow pools. " + - "Requires an unlocked wallet with sufficient token balance. Mainnet-only." + "Requires an unlocked wallet with sufficient token balance. Mainnet-only." ) .requiredOption( "--token-x ", @@ -916,8 +918,8 @@ program ); const activeBinTolerance = opts.activeBinTolerance ? normalizeActiveBinTolerance( - parseJsonOption(opts.activeBinTolerance, "--active-bin-tolerance") - ) + parseJsonOption(opts.activeBinTolerance, "--active-bin-tolerance") + ) : undefined; const resolvedFee = await resolveFee(opts.fee, NETWORK, "contract_call"); const result = await bitflowService.addHodlmmLiquiditySimple({ @@ -1001,7 +1003,7 @@ program const bitflowService = getBitflowService(NETWORK); const account = await getWriteAccount(opts.walletPassword); - const positions = normalizeRelativeWithdrawalPositions( + const positions = normalizeWithdrawalPositions( parseJsonOption(opts.positions, "--positions") ); const resolvedFee = await resolveFee(opts.fee, NETWORK, "contract_call"); @@ -1030,6 +1032,89 @@ program } ); +// --------------------------------------------------------------------------- +// withdraw-liquidity +// --------------------------------------------------------------------------- + +program + .command("withdraw-liquidity") + .description( + "Withdraw HODLMM liquidity using absolute bin IDs. Requires an unlocked wallet. Mainnet-only." + ) + .requiredOption("--pool-id ", "HODLMM pool ID (e.g. dlmm_6)") + .requiredOption( + "--positions ", + "JSON array of positions to withdraw, e.g. '[{\"binId\":258,\"amount\":\"392854\",\"minXAmount\":\"0\",\"minYAmount\":\"0\"}]'" + ) + .option( + "--pool-contract ", + "Override pool contract identifier if needed" + ) + .option( + "--x-token-contract ", + "Override token X contract identifier if needed" + ) + .option( + "--y-token-contract ", + "Override token Y contract identifier if needed" + ) + .option("--allow-fallback", "Enable on-chain fallback when reading pool/bin metadata") + .option( + "--fee ", + "Optional STX fee: 'low' | 'medium' | 'high' preset or micro-STX amount" + ) + .option( + "--wallet-password ", + "Optional wallet password to unlock the active managed wallet for this command" + ) + .action( + async (opts: { + poolId: string; + positions: string; + poolContract?: string; + xTokenContract?: string; + yTokenContract?: string; + allowFallback?: boolean; + fee?: string; + walletPassword?: string; + }) => { + try { + if (NETWORK !== "mainnet") { + printJson({ error: "Bitflow is only available on mainnet", network: NETWORK }); + return; + } + + const bitflowService = getBitflowService(NETWORK); + const account = await getWriteAccount(opts.walletPassword); + const positions = normalizeWithdrawalPositions( + parseJsonOption(opts.positions, "--positions") + ); + const resolvedFee = await resolveFee(opts.fee, NETWORK, "contract_call"); + const result = await bitflowService.withdrawHodlmmLiquidity({ + account, + poolId: opts.poolId, + positions: positions as any, + allowFallback: opts.allowFallback, + fee: resolvedFee, + poolContract: opts.poolContract, + xTokenContract: opts.xTokenContract, + yTokenContract: opts.yTokenContract, + }); + + printJson({ + success: true, + network: NETWORK, + txid: result.txid, + poolId: result.poolId, + preparedPositions: result.preparedPositions, + explorerUrl: getExplorerTxUrl(result.txid, NETWORK), + }); + } catch (error) { + handleError(error); + } + } + ); + // --------------------------------------------------------------------------- // get-keeper-contract // --------------------------------------------------------------------------- @@ -1038,7 +1123,7 @@ program .command("get-keeper-contract") .description( "Get or create a Bitflow Keeper contract for automated swaps. " + - "Keeper contracts enable scheduled/automated token swaps. Mainnet-only." + "Keeper contracts enable scheduled/automated token swaps. Mainnet-only." ) .option( "--address ", @@ -1136,9 +1221,9 @@ program actionAmount: opts.actionAmount, minReceived: opts.minReceivedAmount ? { - amount: opts.minReceivedAmount, - autoAdjust: opts.autoAdjust ?? true, - } + amount: opts.minReceivedAmount, + autoAdjust: opts.autoAdjust ?? true, + } : undefined, }); diff --git a/src/lib/services/bitflow.service.ts b/src/lib/services/bitflow.service.ts index dc05c28d..939cfcf7 100644 --- a/src/lib/services/bitflow.service.ts +++ b/src/lib/services/bitflow.service.ts @@ -262,6 +262,13 @@ export interface HodlmmRelativeWithdrawalInput { minYAmount: string; } +export interface HodlmmWithdrawalInput { + binId: number; + amount: string; + minXAmount: string; + minYAmount: string; +} + interface PreparedRelativeLiquidityBin extends HodlmmRelativeLiquidityBinInput { binId: number; isActiveBin: boolean; @@ -1053,11 +1060,11 @@ export class BitflowService { const activeBinToleranceCv = params.activeBinTolerance ? someCV( - tupleCV({ - "max-deviation": uintCV(BigInt(params.activeBinTolerance.maxDeviation)), - "expected-bin-id": intCV(this.getSignedBinId(params.activeBinTolerance.expectedBinId)), - }) - ) + tupleCV({ + "max-deviation": uintCV(BigInt(params.activeBinTolerance.maxDeviation)), + "expected-bin-id": intCV(this.getSignedBinId(params.activeBinTolerance.expectedBinId)), + }) + ) : noneCV(); const network = this.network === "mainnet" ? STACKS_MAINNET : STACKS_TESTNET; @@ -1169,6 +1176,76 @@ export class BitflowService { }; } + async withdrawHodlmmLiquidity(params: { + account: Account; + poolId: string; + positions: HodlmmWithdrawalInput[]; + fee?: bigint; + allowFallback?: boolean; + poolContract?: string; + xTokenContract?: string; + yTokenContract?: string; + }): Promise { + this.ensureMainnet(); + + const pool = await this.getHodlmmPool(params.poolId, params.allowFallback ?? true); + const poolContractId = params.poolContract || pool.pool_token || pool.core_address; + const xTokenContractId = params.xTokenContract || pool.token_x; + const yTokenContractId = params.yTokenContract || pool.token_y; + + if (!poolContractId) { + throw new Error("Pool contract not found in HODLMM pool metadata. Pass --pool-contract explicitly."); + } + + const { address: routerAddress, name: routerName } = this.parseContractId(HODLMM_LIQUIDITY_ROUTER); + const { address: poolAddress, name: poolName } = this.parseContractId(poolContractId); + const { address: xTokenAddress, name: xTokenName } = this.parseContractId(xTokenContractId); + const { address: yTokenAddress, name: yTokenName } = this.parseContractId(yTokenContractId); + const network = this.network === "mainnet" ? STACKS_MAINNET : STACKS_TESTNET; + + const withdrawPositions = params.positions.map((position) => + tupleCV({ + "bin-id": intCV(this.getSignedBinId(position.binId)), + amount: uintCV(BigInt(position.amount)), + "min-x-amount": uintCV(BigInt(position.minXAmount)), + "min-y-amount": uintCV(BigInt(position.minYAmount)), + "pool-trait": contractPrincipalCV(poolAddress, poolName), + }) + ); + + const totalMinX = params.positions.reduce((sum, position) => sum + BigInt(position.minXAmount), 0n); + const totalMinY = params.positions.reduce((sum, position) => sum + BigInt(position.minYAmount), 0n); + + const transaction = await makeContractCall({ + contractAddress: routerAddress, + contractName: routerName, + functionName: "withdraw-liquidity-same-multi", + functionArgs: [ + listCV(withdrawPositions), + contractPrincipalCV(xTokenAddress, xTokenName), + contractPrincipalCV(yTokenAddress, yTokenName), + uintCV(totalMinX), + uintCV(totalMinY), + ], + senderKey: params.account.privateKey, + network, + postConditions: [], + postConditionMode: PostConditionMode.Allow, + ...(params.fee !== undefined && { fee: params.fee }), + }); + + const broadcastResult = await broadcastTransaction({ transaction, network }); + + if ("error" in broadcastResult) { + throw new Error(`Broadcast failed: ${broadcastResult.error} - ${broadcastResult.reason}`); + } + + return { + txid: broadcastResult.txid, + rawTx: transaction.serialize(), + }; + } + private async executeHodlmmSwap( account: Account, route: UnifiedBitflowRouteQuote, @@ -1270,11 +1347,11 @@ export class BitflowService { poolIds: [pool.pool_id], poolContracts: [pool.pool_token || pool.core_address || pool.pool_id], dexPath: ["HODLMM_DLMM"], - amountOutAtomic: "0", - amountOutHuman: "0", - tokenOutDecimals: tokenY.tokenDecimals, - executable: false, - })); + amountOutAtomic: "0", + amountOutHuman: "0", + tokenOutDecimals: tokenY.tokenDecimals, + executable: false, + })); } catch { hodlmmRoutes = []; } From 04dba913841606b06404ca7fc3ac7c98c101dd1f Mon Sep 17 00:00:00 2001 From: Peter Date: Thu, 14 May 2026 00:11:39 +0100 Subject: [PATCH 3/8] fix(hodlmm-arb-executor): resolve merge conflict, set activeBinOffset to -1 Resolves unresolved conflict markers committed in 9370576. Keeps the correct Y-side fix (xAmount 0, yAmount satsCapped) and updates activeBinOffset to -1 (below active) consistent with on-chain proof cycle which deposited Y-only sBTC at offset -10 on 2026-04-21. Co-Authored-By: Claude Sonnet 4.6 --- hodlmm-arb-executor/hodlmm-arb-executor.ts | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/hodlmm-arb-executor/hodlmm-arb-executor.ts b/hodlmm-arb-executor/hodlmm-arb-executor.ts index 65c69e45..b515b728 100644 --- a/hodlmm-arb-executor/hodlmm-arb-executor.ts +++ b/hodlmm-arb-executor/hodlmm-arb-executor.ts @@ -390,25 +390,15 @@ function buildEntryCommands(oracle: OraclePrices, activeBinId: number, satsCappe pool_id: DLMM_POOL_ID, bins: JSON.stringify([ { -<<<<<<< HEAD - activeBinOffset: 1, // one bin above active = pricing at premium - xAmount: String(satsCapped), - yAmount: "0", // one-sided sBTC deposit above active bin -======= - activeBinOffset: 1, // offset: 1 (requested by maintainers for Y-side logic fix) + activeBinOffset: -1, xAmount: "0", yAmount: String(satsCapped), ->>>>>>> f1fcd6f (fix(hodlmm): swap DLMM bin arguments and read live variable fees for spread check per audit) }, ]), active_bin_tolerance: JSON.stringify({ expectedBinId: activeBinId, maxDeviation: "2" }), slippage_tolerance: "1.5", }, -<<<<<<< HEAD - description: `Add ${sbtcAmount} sBTC to DLMM pool ${DLMM_POOL_ID} bin +1 (LP entry at premium)`, -======= - description: `Add ${sbtcAmount} sBTC to DLMM pool ${DLMM_POOL_ID} bin 1 (Y-side LP entry)`, ->>>>>>> f1fcd6f (fix(hodlmm): swap DLMM bin arguments and read live variable fees for spread check per audit) + description: `Add ${sbtcAmount} sBTC to DLMM pool ${DLMM_POOL_ID} bin -1 (Y-side LP entry below active)`, postConditions: [ `FT debit sBTC eq ${satsCapped} sats`, `LP tokens credited for pool ${DLMM_POOL_ID}`, From c6a50289a7a6fb06d46722a2fa5c2c691cce2d61 Mon Sep 17 00:00:00 2001 From: Peter Date: Thu, 14 May 2026 00:19:33 +0100 Subject: [PATCH 4/8] fix(hodlmm-arb-executor): correct activeBinOffset to 1 and read XYK fee live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (a) activeBinOffset corrected to 1 (above-active Y-only bin per audit). Prior commit had -1 based on a stale HANDOFF assumption — reverted. (b) Replace static FEE_BPS.xyk constant with live x-protocol-fee + x-provider-fee read from xyk-pool-sbtc-stx-v-1-1::get-pool on every cycle. XykReserves.feeBps now carries the on-chain value; analyzeSpread uses xyk.feeBps instead of the hardcoded 30 bps estimate. Co-Authored-By: Claude Sonnet 4.6 --- hodlmm-arb-executor/hodlmm-arb-executor.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/hodlmm-arb-executor/hodlmm-arb-executor.ts b/hodlmm-arb-executor/hodlmm-arb-executor.ts index b515b728..048bdc8b 100644 --- a/hodlmm-arb-executor/hodlmm-arb-executor.ts +++ b/hodlmm-arb-executor/hodlmm-arb-executor.ts @@ -80,6 +80,7 @@ interface XykReserves { yBalanceMicro: number; stxPerBtc: number; liquidityUsd: number; + feeBps: number; } interface DlmmData { @@ -222,7 +223,7 @@ async function fetchOraclePrices(): Promise { // Data source 2: Hiro Stacks API — on-chain XYK pool reserves // --------------------------------------------------------------------------- -function decodeClarityPool(hex: string): { xBalance: bigint; yBalance: bigint } { +function decodeClarityPool(hex: string): { xBalance: bigint; yBalance: bigint; feeBps: number } { // Use @stacks/transactions deserializer — safe against field reordering. // get-pool returns (ok (tuple ...)) — ResponseOK wraps the tuple, so fields are at json.value.value. const cv = deserializeCV(Buffer.from(hex, "hex")); @@ -230,7 +231,9 @@ function decodeClarityPool(hex: string): { xBalance: bigint; yBalance: bigint } const fields = json.value.value; const xBalance = BigInt(fields["x-balance"].value); const yBalance = BigInt(fields["y-balance"].value); - return { xBalance, yBalance }; + const xProtocolFee = Number(fields["x-protocol-fee"]?.value ?? 0); + const xProviderFee = Number(fields["x-provider-fee"]?.value ?? 0); + return { xBalance, yBalance, feeBps: xProtocolFee + xProviderFee }; } async function fetchXykReserves(oracle: OraclePrices): Promise { @@ -244,7 +247,8 @@ async function fetchXykReserves(oracle: OraclePrices): Promise { if (!data.okay) throw new Error(`Contract call failed: ${JSON.stringify(data)}`); const hex = data.result.startsWith("0x") ? data.result.substring(2) : data.result; - const { xBalance, yBalance } = decodeClarityPool(hex); + const decoded = decodeClarityPool(hex); + const { xBalance, yBalance } = decoded; const xBalanceSats = Number(xBalance); const yBalanceMicro = Number(yBalance); @@ -257,6 +261,7 @@ async function fetchXykReserves(oracle: OraclePrices): Promise { yBalanceMicro, stxPerBtc: round(yStx / xBtc, 2), liquidityUsd: round(xBtc * oracle.btcUsd + yStx * oracle.stxUsd, 2), + feeBps: decoded.feeBps, }; } @@ -344,7 +349,7 @@ function analyzeSpread(oracle: OraclePrices, xyk: XykReserves, dlmm: DlmmData): const grossSpread = Math.abs(((xyk.stxPerBtc - dlmm.stxPerBtc) / dlmm.stxPerBtc) * 100); const dlmmFeeTotal = (dlmm.xFeeBps + dlmm.yFeeBps) / 100; - const estFee = (FEE_BPS.xyk / 100) + dlmmFeeTotal; + const estFee = (xyk.feeBps / 100) + dlmmFeeTotal; const netSpread = grossSpread - estFee; // Confidence buffer: STX feed uncertainty as % of price. // stxPerBtc = btcUsd / stxUsd — latency between publishes creates noise. @@ -390,7 +395,7 @@ function buildEntryCommands(oracle: OraclePrices, activeBinId: number, satsCappe pool_id: DLMM_POOL_ID, bins: JSON.stringify([ { - activeBinOffset: -1, + activeBinOffset: 1, xAmount: "0", yAmount: String(satsCapped), }, From db8ee9585fbde7cdb976e844509fdab29c89bb65 Mon Sep 17 00:00:00 2001 From: Peter Date: Thu, 14 May 2026 00:26:26 +0100 Subject: [PATCH 5/8] fix(hodlmm-arb-executor): apply arc0btc review items from PR #384 - Restore entryBinId to openPosition state (entryBinId: dlmm.activeBinId - 1) - Fix activeBinOffset: -1 (Y-only bin below active, confirmed by arc0btc) - Fix fee double-counting: use yFeeBps only for Y-only DLMM position - Add normalizeAbsoluteWithdrawalPositions() in bitflow.ts, remove 'as any' cast Co-Authored-By: Claude Sonnet 4.6 --- bitflow/bitflow.ts | 38 ++++++++++++++++++++-- hodlmm-arb-executor/hodlmm-arb-executor.ts | 8 ++--- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/bitflow/bitflow.ts b/bitflow/bitflow.ts index 362f5853..b1ccf9b3 100644 --- a/bitflow/bitflow.ts +++ b/bitflow/bitflow.ts @@ -16,6 +16,7 @@ import { type HodlmmActiveBinToleranceInput, type HodlmmRelativeLiquidityBinInput, type HodlmmRelativeWithdrawalInput, + type HodlmmWithdrawalInput, type UnifiedBitflowRouteQuote, } from "../src/lib/services/bitflow.service.js"; import { resolveFee } from "../src/lib/utils/fee.js"; @@ -93,6 +94,39 @@ function normalizeWithdrawalPositions(rawPositions: unknown): HodlmmRelativeWith }); } +function normalizeAbsoluteWithdrawalPositions(rawPositions: unknown): HodlmmWithdrawalInput[] { + if (!Array.isArray(rawPositions) || rawPositions.length === 0) { + throw new Error("--positions must be a non-empty JSON array"); + } + + return rawPositions.map((position, index) => { + if (!position || typeof position !== "object") { + throw new Error(`positions[${index}] must be an object`); + } + + const value = position as Record; + const binId = value.binId ?? value.bin_id; + const amount = value.amount; + const minXAmount = value.minXAmount ?? value.min_x_amount ?? 0; + const minYAmount = value.minYAmount ?? value.min_y_amount ?? 0; + + if (binId === undefined || typeof binId !== "number") { + throw new Error(`positions[${index}].binId must be a number`); + } + + if (amount === undefined || amount === null) { + throw new Error(`positions[${index}].amount is required`); + } + + return { + binId, + amount: String(amount), + minXAmount: String(minXAmount), + minYAmount: String(minYAmount), + }; + }); +} + function normalizeActiveBinTolerance(raw: unknown): HodlmmActiveBinToleranceInput { if (!raw || typeof raw !== "object") { throw new Error("--active-bin-tolerance must be a JSON object"); @@ -1086,14 +1120,14 @@ program const bitflowService = getBitflowService(NETWORK); const account = await getWriteAccount(opts.walletPassword); - const positions = normalizeWithdrawalPositions( + const positions = normalizeAbsoluteWithdrawalPositions( parseJsonOption(opts.positions, "--positions") ); const resolvedFee = await resolveFee(opts.fee, NETWORK, "contract_call"); const result = await bitflowService.withdrawHodlmmLiquidity({ account, poolId: opts.poolId, - positions: positions as any, + positions, allowFallback: opts.allowFallback, fee: resolvedFee, poolContract: opts.poolContract, diff --git a/hodlmm-arb-executor/hodlmm-arb-executor.ts b/hodlmm-arb-executor/hodlmm-arb-executor.ts index 048bdc8b..0366fbf6 100644 --- a/hodlmm-arb-executor/hodlmm-arb-executor.ts +++ b/hodlmm-arb-executor/hodlmm-arb-executor.ts @@ -348,7 +348,7 @@ function analyzeSpread(oracle: OraclePrices, xyk: XykReserves, dlmm: DlmmData): if (dlmm.source === "unavailable" || dlmm.stxPerBtc === 0) return null; const grossSpread = Math.abs(((xyk.stxPerBtc - dlmm.stxPerBtc) / dlmm.stxPerBtc) * 100); - const dlmmFeeTotal = (dlmm.xFeeBps + dlmm.yFeeBps) / 100; + const dlmmFeeTotal = dlmm.yFeeBps / 100; const estFee = (xyk.feeBps / 100) + dlmmFeeTotal; const netSpread = grossSpread - estFee; // Confidence buffer: STX feed uncertainty as % of price. @@ -395,7 +395,7 @@ function buildEntryCommands(oracle: OraclePrices, activeBinId: number, satsCappe pool_id: DLMM_POOL_ID, bins: JSON.stringify([ { - activeBinOffset: 1, + activeBinOffset: -1, xAmount: "0", yAmount: String(satsCapped), }, @@ -417,7 +417,7 @@ function buildEntryCommands(oracle: OraclePrices, activeBinId: number, satsCappe // --------------------------------------------------------------------------- function buildExitCommands(position: LpPosition, currentActiveBinId: number, oracle: OraclePrices): McpCommand[] { - // entryBinId stores the actual LP bin (activeBin + 1 at entry time). + // entryBinId stores the actual LP bin (activeBin - 1 at entry time = Y-only below active). // currentOffset = LP bin relative to current active bin. const currentOffset = position.entryBinId - currentActiveBinId; const sbtcAmount = position.satsSent / 1e8; @@ -800,7 +800,7 @@ program state.openPosition = { entryTimestamp: state.lastExecutionAt, entrySpreadPct: signal.grossSpreadPct, - + entryBinId: dlmm.activeBinId - 1, satsSent: satsCapped, estimatedEntryUsd: round((satsCapped / 1e8) * oracle.btcUsd, 2), }; From 3992ccbe08b6a108198d8e34d0d228db73f2cc32 Mon Sep 17 00:00:00 2001 From: Peter Date: Thu, 14 May 2026 00:35:54 +0100 Subject: [PATCH 6/8] fix(hodlmm-arb-executor): apply arc0btc cycle 2 review items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [blocking] Remove live XYK fee extraction — x-protocol-fee and x-provider-fee in get-pool are accumulated balance fields, not fee rate bps. Revert to FEE_BPS.xyk = 30 (fixed 0.30% protocol parameter) for XYK leg cost. Drop feeBps from XykReserves interface and decodeClarityPool return type. [suggestion] Fix withdraw-liquidity printJson: TransferResult has no poolId or preparedPositions fields. Use opts.poolId; drop preparedPositions. [nit] Revert normalizeWithdrawalPositions to strict activeBinOffset-only. Now that normalizeAbsoluteWithdrawalPositions handles binId inputs, the relative path no longer needs a binId fallback that would silently produce activeBinOffset: undefined as number. Co-Authored-By: Claude Sonnet 4.6 --- bitflow/bitflow.ts | 11 ++++------- hodlmm-arb-executor/hodlmm-arb-executor.ts | 11 ++++------- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/bitflow/bitflow.ts b/bitflow/bitflow.ts index b1ccf9b3..5babd015 100644 --- a/bitflow/bitflow.ts +++ b/bitflow/bitflow.ts @@ -71,13 +71,12 @@ function normalizeWithdrawalPositions(rawPositions: unknown): HodlmmRelativeWith const value = position as Record; const activeBinOffset = value.activeBinOffset ?? value.active_bin_offset; - const binId = value.binId ?? value.bin_id; const amount = value.amount; const minXAmount = value.minXAmount ?? value.min_x_amount ?? 0; const minYAmount = value.minYAmount ?? value.min_y_amount ?? 0; - if (activeBinOffset === undefined && binId === undefined) { - throw new Error(`positions[${index}].activeBinOffset or binId is required`); + if (activeBinOffset === undefined || typeof activeBinOffset !== "number") { + throw new Error(`positions[${index}].activeBinOffset must be a number`); } if (amount === undefined || amount === null) { @@ -85,8 +84,7 @@ function normalizeWithdrawalPositions(rawPositions: unknown): HodlmmRelativeWith } return { - activeBinOffset: activeBinOffset as number, - binId: binId as number, + activeBinOffset, amount: String(amount), minXAmount: String(minXAmount), minYAmount: String(minYAmount), @@ -1139,8 +1137,7 @@ program success: true, network: NETWORK, txid: result.txid, - poolId: result.poolId, - preparedPositions: result.preparedPositions, + poolId: opts.poolId, explorerUrl: getExplorerTxUrl(result.txid, NETWORK), }); } catch (error) { diff --git a/hodlmm-arb-executor/hodlmm-arb-executor.ts b/hodlmm-arb-executor/hodlmm-arb-executor.ts index 0366fbf6..34d88d7d 100644 --- a/hodlmm-arb-executor/hodlmm-arb-executor.ts +++ b/hodlmm-arb-executor/hodlmm-arb-executor.ts @@ -80,7 +80,6 @@ interface XykReserves { yBalanceMicro: number; stxPerBtc: number; liquidityUsd: number; - feeBps: number; } interface DlmmData { @@ -223,7 +222,7 @@ async function fetchOraclePrices(): Promise { // Data source 2: Hiro Stacks API — on-chain XYK pool reserves // --------------------------------------------------------------------------- -function decodeClarityPool(hex: string): { xBalance: bigint; yBalance: bigint; feeBps: number } { +function decodeClarityPool(hex: string): { xBalance: bigint; yBalance: bigint } { // Use @stacks/transactions deserializer — safe against field reordering. // get-pool returns (ok (tuple ...)) — ResponseOK wraps the tuple, so fields are at json.value.value. const cv = deserializeCV(Buffer.from(hex, "hex")); @@ -231,9 +230,7 @@ function decodeClarityPool(hex: string): { xBalance: bigint; yBalance: bigint; f const fields = json.value.value; const xBalance = BigInt(fields["x-balance"].value); const yBalance = BigInt(fields["y-balance"].value); - const xProtocolFee = Number(fields["x-protocol-fee"]?.value ?? 0); - const xProviderFee = Number(fields["x-provider-fee"]?.value ?? 0); - return { xBalance, yBalance, feeBps: xProtocolFee + xProviderFee }; + return { xBalance, yBalance }; } async function fetchXykReserves(oracle: OraclePrices): Promise { @@ -261,7 +258,6 @@ async function fetchXykReserves(oracle: OraclePrices): Promise { yBalanceMicro, stxPerBtc: round(yStx / xBtc, 2), liquidityUsd: round(xBtc * oracle.btcUsd + yStx * oracle.stxUsd, 2), - feeBps: decoded.feeBps, }; } @@ -348,8 +344,9 @@ function analyzeSpread(oracle: OraclePrices, xyk: XykReserves, dlmm: DlmmData): if (dlmm.source === "unavailable" || dlmm.stxPerBtc === 0) return null; const grossSpread = Math.abs(((xyk.stxPerBtc - dlmm.stxPerBtc) / dlmm.stxPerBtc) * 100); + // XYK fee is a fixed protocol parameter (30 bps). Only DLMM fees are variable. const dlmmFeeTotal = dlmm.yFeeBps / 100; - const estFee = (xyk.feeBps / 100) + dlmmFeeTotal; + const estFee = (FEE_BPS.xyk / 100) + dlmmFeeTotal; const netSpread = grossSpread - estFee; // Confidence buffer: STX feed uncertainty as % of price. // stxPerBtc = btcUsd / stxUsd — latency between publishes creates noise. From 0692a4eb1e60829f687c12a26e1412313f142f34 Mon Sep 17 00:00:00 2001 From: Peter Date: Thu, 14 May 2026 00:45:46 +0100 Subject: [PATCH 7/8] fix(hodlmm-arb-executor): warn when DLMM pool missing from pools API If /pools?amm_type=dlmm returns a valid response but DLMM_POOL_ID is absent, yFeeBps silently defaults to 0, overstating arb profitability. Add console.warn so this surface in production logs if it ever occurs. Co-Authored-By: Claude Sonnet 4.6 --- hodlmm-arb-executor/hodlmm-arb-executor.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/hodlmm-arb-executor/hodlmm-arb-executor.ts b/hodlmm-arb-executor/hodlmm-arb-executor.ts index 34d88d7d..94a4bb29 100644 --- a/hodlmm-arb-executor/hodlmm-arb-executor.ts +++ b/hodlmm-arb-executor/hodlmm-arb-executor.ts @@ -310,6 +310,10 @@ async function fetchDlmmBins(): Promise { const stxPerBtc = rawPrice * 10; const poolData = pools.pools?.find(p => p.pool_id === DLMM_POOL_ID); + if (!poolData) { + // Pool missing from /pools?amm_type=dlmm — fees default to 0, understating arb cost. + console.warn(`[hodlmm-arb-executor] ${DLMM_POOL_ID} not found in DLMM pools API; yFeeBps defaulting to 0`); + } const xFeeBps = poolData?.x_total_fee_bps ? Number(poolData.x_total_fee_bps) : 0; const yFeeBps = poolData?.y_total_fee_bps ? Number(poolData.y_total_fee_bps) : 0; From 9d8da7ef4e324573fd45200d0602c8e6d25eb037 Mon Sep 17 00:00:00 2001 From: Peter Date: Sat, 23 May 2026 13:19:41 +0100 Subject: [PATCH 8/8] fix(hodlmm-arb-executor): apply cycle 3 review items [blocking arc0btc] Add LP FT post conditions to withdrawHodlmmLiquidity. Switch to PostConditionMode.Deny with explicit FT_debit post condition: sender burns exactly totalLpAmount of pool LP tokens (lp-token on pool contract). Prevents the contract from moving arbitrary token amounts on an Allow-mode tx. Import Pc from @stacks/transactions. [suggestion macbotmini b.1] Use Math.max(xFeeBps, yFeeBps) in analyzeSpread for direction-agnostic conservative DLMM fee bound. dlmm_6 is symmetric today (x==y), but this guards asymmetric pools. [suggestion macbotmini b.2] Fall back to FALLBACK_DLMM_FEE_BPS=50 (wider than typical) instead of 0 when poolData is absent from /pools?amm_type=dlmm. Zeroing DLMM cost overstates arb profitability; 50 bps keeps GO/NO-GO on the safe side. Co-Authored-By: Claude Sonnet 4.6 --- hodlmm-arb-executor/hodlmm-arb-executor.ts | 16 +++++++++++----- src/lib/services/bitflow.service.ts | 15 +++++++++++++-- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/hodlmm-arb-executor/hodlmm-arb-executor.ts b/hodlmm-arb-executor/hodlmm-arb-executor.ts index 94a4bb29..37af2eea 100644 --- a/hodlmm-arb-executor/hodlmm-arb-executor.ts +++ b/hodlmm-arb-executor/hodlmm-arb-executor.ts @@ -311,11 +311,14 @@ async function fetchDlmmBins(): Promise { const poolData = pools.pools?.find(p => p.pool_id === DLMM_POOL_ID); if (!poolData) { - // Pool missing from /pools?amm_type=dlmm — fees default to 0, understating arb cost. - console.warn(`[hodlmm-arb-executor] ${DLMM_POOL_ID} not found in DLMM pools API; yFeeBps defaulting to 0`); + // Pool missing from /pools?amm_type=dlmm — fall back to conservative static rather than + // zeroing the DLMM cost leg, which would overstate arb profitability. + console.warn(`[hodlmm-arb-executor] ${DLMM_POOL_ID} not found in DLMM pools API; using fallback fee`); } - const xFeeBps = poolData?.x_total_fee_bps ? Number(poolData.x_total_fee_bps) : 0; - const yFeeBps = poolData?.y_total_fee_bps ? Number(poolData.y_total_fee_bps) : 0; + // FALLBACK_DLMM_FEE_BPS: wider than any active dlmm_6 fee; keeps GO/NO-GO on the safe side. + const FALLBACK_DLMM_FEE_BPS = 50; + const xFeeBps = poolData?.x_total_fee_bps ? Number(poolData.x_total_fee_bps) : FALLBACK_DLMM_FEE_BPS; + const yFeeBps = poolData?.y_total_fee_bps ? Number(poolData.y_total_fee_bps) : FALLBACK_DLMM_FEE_BPS; return { stxPerBtc: round(stxPerBtc, 2), @@ -349,7 +352,10 @@ function analyzeSpread(oracle: OraclePrices, xyk: XykReserves, dlmm: DlmmData): const grossSpread = Math.abs(((xyk.stxPerBtc - dlmm.stxPerBtc) / dlmm.stxPerBtc) * 100); // XYK fee is a fixed protocol parameter (30 bps). Only DLMM fees are variable. - const dlmmFeeTotal = dlmm.yFeeBps / 100; + // Use the higher of x/y DLMM fees as a direction-agnostic conservative bound. + // For dlmm_6 today xFeeBps == yFeeBps (symmetric pool), so this is a no-op in practice + // but guards against asymmetric-fee pools if more come online. + const dlmmFeeTotal = Math.max(dlmm.xFeeBps, dlmm.yFeeBps) / 100; const estFee = (FEE_BPS.xyk / 100) + dlmmFeeTotal; const netSpread = grossSpread - estFee; // Confidence buffer: STX feed uncertainty as % of price. diff --git a/src/lib/services/bitflow.service.ts b/src/lib/services/bitflow.service.ts index 939cfcf7..6d193c35 100644 --- a/src/lib/services/bitflow.service.ts +++ b/src/lib/services/bitflow.service.ts @@ -13,6 +13,7 @@ import { makeContractCall, broadcastTransaction, PostConditionMode, + Pc, contractPrincipalCV, intCV, listCV, @@ -1215,6 +1216,16 @@ export class BitflowService { const totalMinX = params.positions.reduce((sum, position) => sum + BigInt(position.minXAmount), 0n); const totalMinY = params.positions.reduce((sum, position) => sum + BigInt(position.minYAmount), 0n); + const totalLpAmount = params.positions.reduce((sum, position) => sum + BigInt(position.amount), 0n); + + // LP token debit: sender burns exactly totalLpAmount of pool LP tokens. + // Pool contract is the LP token issuer; token name follows SIP-010 convention "lp-token". + const lpTokenFt = `${poolAddress}.${poolName}` as `${string}.${string}`; + const postConditions = [ + Pc.principal(params.account.address) + .willSendEq(totalLpAmount) + .ft(lpTokenFt, "lp-token"), + ]; const transaction = await makeContractCall({ contractAddress: routerAddress, @@ -1229,8 +1240,8 @@ export class BitflowService { ], senderKey: params.account.privateKey, network, - postConditions: [], - postConditionMode: PostConditionMode.Allow, + postConditions, + postConditionMode: PostConditionMode.Deny, ...(params.fee !== undefined && { fee: params.fee }), });