Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions hodlmm-move-liquidity/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ metadata:

## What it does

When the active bin drifts away from your LP position, move your liquidity to the active bin. One atomic transaction via the Bitflow DLMM liquidity router's `move-relative-liquidity-multi` function: withdraw from old bins and deposit into the active bin in a single on-chain call. No intermediate state, no nonce sequencing, no partial execution risk.
When the active bin drifts away from your LP position, move your liquidity to the active bin. One atomic transaction via the Bitflow DLMM liquidity router's `move-liquidity-multi` function: withdraw from old bins and deposit into the active bin in a single on-chain call. No intermediate state, no nonce sequencing, no partial execution risk.

The active bin is where all trades flow and fees accrue. Capital in any other bin earns zero. This skill concentrates your liquidity where it earns.

Expand All @@ -29,7 +29,7 @@ This skill closes the loop. The `run` command moves liquidity on demand. The `au

## Safety notes

- **Writes to chain.** One atomic transaction per rebalance via `move-relative-liquidity-multi`. Withdraw + deposit happen in a single on-chain call — either both succeed or neither does.
- **Writes to chain.** One atomic transaction per rebalance via `move-liquidity-multi`. Withdraw + deposit happen in a single on-chain call — either both succeed or neither does.
- **Moves funds.** Liquidity is removed from old bins and placed in new bins. No tokens leave the LP's wallet — they pass through the DLMM liquidity router contract.
- **Mainnet only.** All contract addresses are mainnet Stacks.
- **`--confirm` required for `run`.** Without it, `run` outputs a dry-run preview with full plan details. No transaction is broadcast. The `auto` command executes directly (operator opts in by starting it).
Expand Down Expand Up @@ -235,11 +235,17 @@ All commands emit JSON to stdout.
## Known constraints

- Requires `@stacks/transactions` and `@stacks/wallet-sdk` to be installed in the runtime environment.
- Single atomic transaction via `move-relative-liquidity-multi` — either all bins move or none do. No partial execution risk.
- Single atomic transaction via `move-liquidity-multi` — either all bins move or none do. No partial execution risk.
- Liquidity is distributed across ±spread bins around the active bin (default ±5). The DLMM bin invariant requires bins below active to hold only Y token and bins above active to hold only X token — source bins below active map to destination offsets [-spread, 0] and source bins above active map to [0, +spread].

## Origin

Winner of AIBTC x Bitflow Skills Pay the Bills competition.
Original author: @cliqueengagements
Competition PR: https://github.com/BitflowFinance/bff-skills/pull/231

## Doc-truth + safety update (2026-07-15 field audit F-2/F-4/F-8)

- The code calls `move-liquidity-multi` (absolute signed bin IDs), not the relative variant. Earlier versions of this document named the wrong function.
- **Honest protection statement:** the code currently sends `min-dlp = 1`, which the contract only checks as `> 0` — it bounds nothing. The live contract bounds are the 5% liquidity-fee caps, which bind only when the target is the active bin. Output protection rests on the caller's off-chain gates. A price-aware `min-dlp` (95% of the contract-computed expected DLP) plus Deny-mode sender post-conditions is the planned fix.
- **New u5001 prevention gate:** before signing, the skill re-reads the live active bin and refuses on plan staleness (`STALE_ACTIVE_BIN`) or side-illegality (`ILLEGAL_MOVE_GEOMETRY`) — the true errors beneath router `u5001` batch masking are `u1020`/`u1021` side-asserts (X only at/above active, Y only at/below) and the `u1024` empty-bin dust floor. Illegal geometry means the position needs withdraw->swap->redeposit, not a native move.
59 changes: 54 additions & 5 deletions hodlmm-move-liquidity/hodlmm-move-liquidity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,15 +375,56 @@ async function executeMove(
} = await import("@stacks/transactions" as string);
const { STACKS_MAINNET } = await import("@stacks/network" as string);

// ── u5001 prevention gate (2026-07-15 field audit F-8) ────────────────────
// dlmm-core move-liquidity enforces side-asserts against the LIVE active bin
// at execution (contract :1970-1971): X-bearing funds may only land at/above
// it (err u1020), Y-bearing only at/below (err u1021). A plan built against
// a stale active bin violates these on-chain; the router's batch fold then
// MASKS the true error as u5001 (ERR_NO_RESULT_DATA — every later fold
// element overwrites the real code) and the fee is burned. Confirmed live
// 2026-07-14 on dlmm_1 (0.1 STX burned; dry-run did not catch it).
// Re-read the active bin immediately before signing and refuse on any drift
// or side-illegality.
const freshBins = await fetchPoolBins(pool.pool_id);
const freshActive = freshBins.active_bin_id;
if (freshActive !== activeBin) {
throw new Error(
`STALE_ACTIVE_BIN: plan was built for active bin ${activeBin} but the live active bin is ${freshActive}. ` +
`Re-plan against fresh state (u5001-class abort prevented; no fee spent).`
);
}
const activeSignedFresh = freshActive - CENTER_BIN_ID;
for (const m of moves) {
const toSigned = activeSignedFresh + m.activeBinOffset;
const fromSigned = m.fromBinId;
// From-bin side determines what the withdrawn funds contain:
// above active = X only, below = Y only, equal = mixed (both constraints).
const carriesX = fromSigned >= activeSignedFresh;
const carriesY = fromSigned <= activeSignedFresh;
if (carriesX && toSigned < activeSignedFresh) {
throw new Error(
`ILLEGAL_MOVE_GEOMETRY: X-bearing funds from bin ${fromSigned} cannot land below the active bin ` +
`(${toSigned} < ${activeSignedFresh}; would abort u1020, masked as u5001). Use withdraw->swap->redeposit instead.`
);
}
if (carriesY && toSigned > activeSignedFresh) {
throw new Error(
`ILLEGAL_MOVE_GEOMETRY: Y-bearing funds from bin ${fromSigned} cannot land above the active bin ` +
`(${toSigned} > ${activeSignedFresh}; would abort u1021, masked as u5001). Use withdraw->swap->redeposit instead.`
);
}
}

const [poolAddr, poolName] = pool.pool_contract.split(".");
const [xAddr, xName] = pool.token_x.split(".");
const [yAddr, yName] = pool.token_y.split(".");

// Route to move-liquidity-multi (absolute to-bin-id, list length 220) instead of
// move-relative-liquidity-multi (relative offset, list length 208). Our positions
// routinely carry 209–221 bins after prior rebalances; the relative variant's
// 208-cap overflows Clarity parse → BadFunctionArgument. The non-relative variant
// fits our size exactly and takes absolute signed bin IDs (bin - CENTER_BIN_ID).
// Routes to move-liquidity-multi (absolute signed bin IDs = bin - CENTER_BIN_ID).
// CORRECTION (2026-07-15 field audit F-8): the earlier rationale here — relative-
// variant list cap 208 vs 220 — is contradicted by dlmm-liquidity-router-v-1-1
// source: ALL batch entrypoints take (list 350 ...), and move-relative-liquidity-
// multi exists. The absolute variant remains a valid choice (explicit destinations
// are easier to legality-check pre-flight, see the u5001 gate above).
const activeSigned = activeBin - CENTER_BIN_ID;
const moveList = moves.map((m) => {
const amt = BigInt(m.amount);
Expand All @@ -398,6 +439,14 @@ async function executeMove(
// the value-conservation work regardless of min-dlp=1n.
// Follow-up (v2): price-aware min-dlp = 95% × (price_from / price_to) × amount
// to keep per-bin slippage protection while surviving cross-bin conversions.
// PRIORITIZED FOLLOW-UP (2026-07-15 field audit F-2 CRITICAL): min-dlp = 1
// is the weakest value that passes contract validation ((> min-dlp u0)) and
// bounds NOTHING. The correct price-aware bound is computable from direct
// contract reads (withdrawn x/y pro-rata from from-bin, V = bin-price(to)*x
// + y*1e8, expected DLP = V*shares_to/V_bin_to funded / sqrti(V) empty;
// min-dlp = 95% of expected). Deny-mode sender PCs are ALSO expressible for
// this strict entrypoint and should be added. Until then, output protection
// rests on the caller's off-chain gates and the u5001 legality gate above.
const minDlp = 1n;
const maxFee = amt * 5n / 100n;
return tupleCV({
Expand Down
Loading