Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
4 changes: 2 additions & 2 deletions hodlmm-move-liquidity/AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ description: "Autonomous agent behavior for HODLMM Move-Liquidity & Auto-Rebalan
- **Respect the 4-hour cooldown.** Do not attempt to bypass cooldown by modifying the state file. If cooldown is active, inform the operator and provide the remaining wait time.
- **Do not move in-range positions.** If the position is already in the active bin range, report `IN_RANGE` and take no action. Moving an in-range position wastes gas for zero benefit.
- **Gas budget: 0.05 STX** estimated for one atomic transaction. If STX balance is below 1 STX, refuse to execute.
- **Atomic execution.** Uses `move-relative-liquidity-multi` — withdraw from old bins and deposit into new bins in a single on-chain call. Either all bins move or none do.
- **Contract-level slippage protection.** Each move requires ≥95% DLP shares back (`min-dlp`) and caps liquidity fees at 5% (`max-x-liquidity-fee`, `max-y-liquidity-fee`). The transaction reverts on-chain if either bound is violated.
- **Atomic execution.** Uses `move-liquidity-multi` — withdraw from old bins and deposit into new bins in a single on-chain call. Either all bins move or none do.
- **Contract-level protection status (honest).** The code sends `min-dlp = 1` — enforced by the contract but vacuous at 1. The live bounds are the 5% liquidity-fee caps, which bind only when the target is the active bin. Do not treat this skill as carrying output-floor protection; real safety comes from the off-chain gates and the pre-sign staleness/side-legality gate. Price-aware `min-dlp` + Deny-mode sender post-conditions are planned.

## On error

Expand Down
14 changes: 10 additions & 4 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,11 +29,11 @@ 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).
- **postConditionMode: Allow** — HODLMM operations mint and burn DLP tokens in the same transaction, which cannot be expressed as sender-side post-conditions. Contract-level slippage protection compensates: each move requires ≥95% DLP shares back (`min-dlp`) and caps liquidity fees at 5% of the amount (`max-x-liquidity-fee`, `max-y-liquidity-fee`). If the contract violates either bound, the transaction reverts on-chain. Additional safety: `--confirm` gate, cooldown, in-range check, and gas check.
- **postConditionMode: Allow** — the current implementation broadcasts without sender-side post-conditions. HONEST STATUS: the code sends `min-dlp = 1`, which the contract does enforce (`>= dlp-post-fees min-dlp`) but which is vacuous at 1 — any nonzero mint passes. The live bounds are the 5% liquidity-fee caps (`max-x-liquidity-fee`/`max-y-liquidity-fee`), which bind only when the target is the active bin. Real protection today comes from the off-chain gates (`--confirm`, cooldown, in-range check, gas check, and the pre-sign staleness/side-legality gate). Deny-mode sender post-conditions ARE expressible for this strict entrypoint and are planned together with a price-aware `min-dlp`.
- **4-hour cooldown** between moves on the same pool, enforced in code and persisted to disk.

## Commands
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`; the contract enforces `>= dlp-post-fees min-dlp` (u1024) in addition to `> 0` (u1027), so the bound exists but is vacuous at 1 — any nonzero mint passes. 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.
75 changes: 69 additions & 6 deletions hodlmm-move-liquidity/hodlmm-move-liquidity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*
* When the active bin drifts away from your LP position, this skill moves
* liquidity from old bins to bins centered on the current active bin.
* One atomic transaction via move-relative-liquidity-multi.
* One atomic transaction via move-liquidity-multi (absolute signed bin IDs).
*
* Commands:
* doctor — check APIs, wallet, pool access
Expand Down Expand Up @@ -375,15 +375,68 @@ async function executeMove(
} = await import("@stacks/transactions" as string);
const { STACKS_MAINNET } = await import("@stacks/network" as string);

// ── u5001 risk-reduction 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);
if (!freshBins.bins.length) {
throw new Error(
"DEGRADED_BINS_READ: fresh pool-bins read returned no bins — cannot verify the live active bin. Refusing to sign against unverifiable state."
);
}
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 risk avoided pre-broadcast; no fee spent). Note the check reads the indexer and the tx still sits in the mempool until anchored — this narrows the drift window, it cannot eliminate it.`
);
}
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).
// List caps verified against the DEPLOYED router via /v2/contracts/source
// (2026-07-15): move-liquidity-multi takes (list 220 ...), the relative
// variant (list 208 ...) — the deployed contract is authoritative and
// DIFFERS from the GitHub repo source (which shows 350s); an earlier
// draft of this comment trusted the repo and was wrong. So the 208-cap
// overflow rationale for large positions stands, and the absolute variant
// additionally allows explicit-destination legality checks pre-flight (see
// the u5001 gate above). Trade-off, stated honestly: move-relative-
// liquidity-multi computes to-bin-id from the live active bin ON-CHAIN,
// making it structurally more TOCTOU-robust on the destination side; we
// accept that risk in exchange for pre-flight checkability + the 220 cap,
// with the staleness gate above narrowing (not eliminating) the window.
const activeSigned = activeBin - CENTER_BIN_ID;
const moveList = moves.map((m) => {
const amt = BigInt(m.amount);
Expand All @@ -398,6 +451,16 @@ 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): the contract
// enforces BOTH (> min-dlp u0) (u1027) AND (>= dlp-post-fees min-dlp)
// (u1024) — min-dlp IS enforced against minted DLP, but min-dlp = 1 makes
// that enforced bound VACUOUS (any nonzero mint passes). 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