Skip to content
146 changes: 131 additions & 15 deletions bitflow/bitflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
}
Expand All @@ -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`);
}

Expand All @@ -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<string, unknown>;
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");
Expand Down Expand Up @@ -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");

Expand All @@ -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 <contractId>",
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 <contractId>",
Expand Down Expand Up @@ -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 <tokenId>",
Expand Down Expand Up @@ -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 <tokenId>",
Expand Down Expand Up @@ -916,8 +950,8 @@ program
);
const activeBinTolerance = opts.activeBinTolerance
? normalizeActiveBinTolerance(
parseJsonOption<unknown>(opts.activeBinTolerance, "--active-bin-tolerance")
)
parseJsonOption<unknown>(opts.activeBinTolerance, "--active-bin-tolerance")
)
: undefined;
const resolvedFee = await resolveFee(opts.fee, NETWORK, "contract_call");
const result = await bitflowService.addHodlmmLiquiditySimple({
Expand Down Expand Up @@ -1001,7 +1035,7 @@ program

const bitflowService = getBitflowService(NETWORK);
const account = await getWriteAccount(opts.walletPassword);
const positions = normalizeRelativeWithdrawalPositions(
const positions = normalizeWithdrawalPositions(
parseJsonOption<unknown>(opts.positions, "--positions")
);
const resolvedFee = await resolveFee(opts.fee, NETWORK, "contract_call");
Expand Down Expand Up @@ -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 <poolId>", "HODLMM pool ID (e.g. dlmm_6)")
.requiredOption(
"--positions <json>",
"JSON array of positions to withdraw, e.g. '[{\"binId\":258,\"amount\":\"392854\",\"minXAmount\":\"0\",\"minYAmount\":\"0\"}]'"
)
.option(
"--pool-contract <contractId>",
"Override pool contract identifier if needed"
)
.option(
"--x-token-contract <contractId>",
"Override token X contract identifier if needed"
)
.option(
"--y-token-contract <contractId>",
"Override token Y contract identifier if needed"
)
.option("--allow-fallback", "Enable on-chain fallback when reading pool/bin metadata")
.option(
"--fee <value>",
"Optional STX fee: 'low' | 'medium' | 'high' preset or micro-STX amount"
)
.option(
"--wallet-password <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<unknown>(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
// ---------------------------------------------------------------------------
Expand All @@ -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 <stacksAddress>",
Expand Down Expand Up @@ -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,
});

Expand Down
56 changes: 40 additions & 16 deletions hodlmm-arb-executor/hodlmm-arb-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -88,6 +87,8 @@ interface DlmmData {
activeBinId: number;
totalBins: number;
source: "bitflow-api" | "unavailable";
xFeeBps: number;
yFeeBps: number;
}

interface McpCommand {
Expand Down Expand Up @@ -243,7 +244,8 @@ async function fetchXykReserves(oracle: OraclePrices): Promise<XykReserves> {
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);
Expand Down Expand Up @@ -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<DlmmData> {
try {
const bins = await fetchJson<HodlmmBinsResponse>(
`${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<HodlmmBinsResponse>(
`${BITFLOW_QUOTES_API}/bins/${DLMM_POOL_ID}`,
BITFLOW_API_KEY ? { headers: { "x-api-key": BITFLOW_API_KEY } } : undefined
),
fetchJson<HodlmmPoolResponse>(
`${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);
Expand All @@ -293,14 +309,20 @@ async function fetchDlmmBins(): Promise<DlmmData> {
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 };
}
}

Expand All @@ -322,7 +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);
const estFee = (FEE_BPS.xyk + FEE_BPS.dlmm) / 100;
// XYK fee is a fixed protocol parameter (30 bps). Only DLMM fees are variable.
const dlmmFeeTotal = 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.
Expand Down Expand Up @@ -368,15 +392,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}`,
Expand All @@ -390,7 +414,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;
Expand Down Expand Up @@ -491,8 +515,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) {
Expand Down Expand Up @@ -773,7 +797,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),
};
Expand Down
Loading
Loading