diff --git a/bitflow/bitflow.ts b/bitflow/bitflow.ts index a5685e7f..5babd015 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"; @@ -58,7 +59,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"); } @@ -74,7 +75,7 @@ function normalizeRelativeWithdrawalPositions(rawPositions: unknown): HodlmmRela const minXAmount = value.minXAmount ?? value.min_x_amount ?? 0; const minYAmount = value.minYAmount ?? value.min_y_amount ?? 0; - if (typeof activeBinOffset !== "number") { + if (activeBinOffset === undefined || typeof activeBinOffset !== "number") { throw new Error(`positions[${index}].activeBinOffset must be a number`); } @@ -91,6 +92,39 @@ function normalizeRelativeWithdrawalPositions(rawPositions: unknown): HodlmmRela }); } +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"); @@ -314,7 +348,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 +360,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 +439,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 +627,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 +722,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 +773,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 +950,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 +1035,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 +1064,88 @@ 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 = normalizeAbsoluteWithdrawalPositions( + parseJsonOption(opts.positions, "--positions") + ); + const resolvedFee = await resolveFee(opts.fee, NETWORK, "contract_call"); + const result = await bitflowService.withdrawHodlmmLiquidity({ + account, + poolId: opts.poolId, + positions, + allowFallback: opts.allowFallback, + fee: resolvedFee, + poolContract: opts.poolContract, + xTokenContract: opts.xTokenContract, + yTokenContract: opts.yTokenContract, + }); + + printJson({ + success: true, + network: NETWORK, + txid: result.txid, + poolId: opts.poolId, + explorerUrl: getExplorerTxUrl(result.txid, NETWORK), + }); + } catch (error) { + handleError(error); + } + } + ); + // --------------------------------------------------------------------------- // get-keeper-contract // --------------------------------------------------------------------------- @@ -1038,7 +1154,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 +1252,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/hodlmm-arb-executor/hodlmm-arb-executor.ts b/hodlmm-arb-executor/hodlmm-arb-executor.ts index 06f8a9c2..37af2eea 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 { @@ -243,7 +244,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); @@ -275,12 +277,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 +309,27 @@ 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); + if (!poolData) { + // 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`); + } + // 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), 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 +351,12 @@ 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; + // XYK fee is a fixed protocol parameter (30 bps). Only DLMM fees are variable. + // 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. // stxPerBtc = btcUsd / stxUsd — latency between publishes creates noise. @@ -368,15 +402,15 @@ function buildEntryCommands(oracle: OraclePrices, activeBinId: number, satsCappe pool_id: DLMM_POOL_ID, bins: JSON.stringify([ { - activeBinOffset: 1, // one bin above active = pricing at premium - xAmount: String(satsCapped), - yAmount: "0", // one-sided sBTC deposit above active bin + activeBinOffset: -1, + xAmount: "0", + yAmount: String(satsCapped), }, ]), active_bin_tolerance: JSON.stringify({ expectedBinId: activeBinId, maxDeviation: "2" }), slippage_tolerance: "1.5", }, - 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 below active)`, postConditions: [ `FT debit sBTC eq ${satsCapped} sats`, `LP tokens credited for pool ${DLMM_POOL_ID}`, @@ -390,7 +424,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; @@ -491,8 +525,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 +807,7 @@ program state.openPosition = { entryTimestamp: state.lastExecutionAt, entrySpreadPct: signal.grossSpreadPct, - entryBinId: dlmm.activeBinId + 1, // LP deposited at activeBinOffset: +1 + entryBinId: dlmm.activeBinId - 1, satsSent: satsCapped, estimatedEntryUsd: round((satsCapped / 1e8) * oracle.btcUsd, 2), }; diff --git a/src/lib/services/bitflow.service.ts b/src/lib/services/bitflow.service.ts index dc05c28d..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, @@ -262,6 +263,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 +1061,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 +1177,86 @@ 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 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, + 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.Deny, + ...(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 +1358,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 = []; }