Skip to content
Merged
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: 4 additions & 0 deletions bitflow-hodlmm-deposit/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,7 @@ Fatal error:
Winner of AIBTC x Bitflow Skills Pay the Bills competition.
Original author: @macbotmini-eng
Competition PR: https://github.com/BitflowFinance/bff-skills/pull/556

## Post-condition correctness update (2026-07-15 field audit F-7)

Per-bin position-NFT post-conditions are keyed on **actual NFT ownership** (Hiro NFT holdings), not only `userLiquidity > 0`. Root cause: the pool's `tag-pool-token-id` burns and re-mints the bin NFT on every mint/burn where the wallet already owns it — and ownership **persists at zero liquidity**, so re-entering a previously fully-withdrawn bin moves the NFT even though the positions endpoint shows no liquidity. Without the PC, such a deposit aborts in Deny mode and burns the fee.
55 changes: 50 additions & 5 deletions bitflow-hodlmm-deposit/bitflow-hodlmm-deposit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ interface Context {
poolInterface: ContractInterface;
xAsset: TokenAsset;
yAsset: TokenAsset;
ownedNftBinIds: Set<number>;
}

interface SharedOptions {
Expand Down Expand Up @@ -530,6 +531,43 @@ async function getBalances(wallet: string): Promise<HiroBalancesResponse> {
return fetchJson<HiroBalancesResponse>(`${HIRO_API}/extended/v1/address/${wallet}/balances`);
}

// 2026-07-15 field audit F-7: the pool's tag-pool-token-id burns AND re-mints the
// bin's position NFT on every pool-mint/pool-burn IF the wallet already owns it —
// a sender asset movement that needs an NFT post-condition in Deny mode. For a
// first-time bin only a mint occurs, so a declared "will send NFT" PC there aborts
// the tx. Crucially, NFT ownership PERSISTS AT ZERO LIQUIDITY (tag re-mints on the
// final burn too), so `userLiquidity > 0` under-detects: re-entering a previously
// fully-withdrawn bin still moves the NFT and would abort in Deny mode without a
// PC. Read actual NFT holdings instead.
async function getOwnedPositionNftBinIds(wallet: string, poolContract: string): Promise<Set<number>> {
const owned = new Set<number>();
const assetId = `${poolContract}::${DLP_TOKEN_ID_ASSET_NAME}`;
let offset = 0;
const limit = 200;
for (;;) {
const page = await fetchJson<{ total?: number; results?: Array<{ value?: { repr?: string } }> }>(
`${HIRO_API}/extended/v1/tokens/nft/holdings?principal=${wallet}&asset_identifiers=${encodeURIComponent(assetId)}&limit=${limit}&offset=${offset}`
);
const results = Array.isArray(page.results) ? page.results : [];
for (const item of results) {
// repr shape verified against live Hiro data 2026-07-15 for dlmm pool
// NFTs: "(tuple (owner 'SP...) (token-id u5))". If the shape ever
// changes, the loud warning below prevents silently regressing to the
// old under-detection behavior.
const match = /\(token-id u(\d+)\)/.exec(item.value?.repr ?? "");
if (match) owned.add(Number(match[1]));
}
offset += results.length;
if (results.length < limit || offset >= (page.total ?? 0)) break;
}
if (owned.size === 0 && offset > 0) {
console.error(
`WARNING: ${offset} ${assetId} holdings returned but no token-ids parsed — repr shape may have changed; NFT PC detection degraded to liquidity-only.`
);
}
return owned;
}

async function getPendingTransactions(wallet: string): Promise<HiroMempoolResponse> {
return fetchJson<HiroMempoolResponse>(`${HIRO_API}/extended/v1/tx/mempool?sender_address=${wallet}&limit=20`);
}
Expand Down Expand Up @@ -929,8 +967,12 @@ function buildPostConditions(context: Context) {
}
}

// F-7: key the per-bin NFT PC on actual NFT OWNERSHIP (Hiro NFT holdings),
// not on userLiquidity > 0 — the position NFT persists at zero liquidity, so
// re-entering a previously fully-withdrawn bin still burns+re-mints the NFT
// and needs the PC in Deny mode.
for (const bin of context.selectedBins) {
if (bin.hasExistingPosition) {
if (bin.hasExistingPosition || context.ownedNftBinIds.has(bin.binId)) {
postConditions.push(Pc.principal(context.wallet)
.willSendAsset()
.nft(
Expand Down Expand Up @@ -967,12 +1009,13 @@ async function collectContext(opts: SharedOptions, requirePlan = true): Promise<
getContract(HODLMM_LIQUIDITY_ROUTER),
]);

const [poolContract, routerInterface, poolInterface, xInterface, yInterface] = await Promise.all([
const [poolContract, routerInterface, poolInterface, xInterface, yInterface, ownedNftBinIds] = await Promise.all([
getContract(pool.poolContract),
getContractInterface(HODLMM_LIQUIDITY_ROUTER),
getContractInterface(pool.poolContract),
getContractInterface(pool.tokenX.contract),
getContractInterface(pool.tokenY.contract),
getOwnedPositionNftBinIds(wallet, pool.poolContract),
]);

if (!pool.status) {
Expand Down Expand Up @@ -1087,6 +1130,7 @@ async function collectContext(opts: SharedOptions, requirePlan = true): Promise<
poolInterface,
xAsset,
yAsset,
ownedNftBinIds,
};
}

Expand Down Expand Up @@ -1114,7 +1158,7 @@ function contextData(context: Context): JsonMap {
},
activeBin: context.bins.active_bin_id,
selection: context.selection,
selectedBins: context.selectedBins.map(binData),
selectedBins: context.selectedBins.map((bin) => binData(bin, context.ownedNftBinIds)),
skippedBins: context.skippedBins,
totals: context.totals,
tokens: {
Expand All @@ -1140,7 +1184,7 @@ function contextData(context: Context): JsonMap {
context.totals.xAmount > 0n ? `wallet sends <= ${context.totals.xAmount} ${context.xAsset.symbol}` : null,
context.totals.yAmount > 0n ? `wallet sends <= ${context.totals.yAmount} ${context.yAsset.symbol}` : null,
...context.selectedBins
.filter((bin) => bin.hasExistingPosition)
.filter((bin) => bin.hasExistingPosition || context.ownedNftBinIds.has(bin.binId))
.map((bin) => `wallet sends existing ${context.pool.poolContract}::${DLP_TOKEN_ID_ASSET_NAME} token-id ${bin.binId}`),
].filter(Boolean),
dlpNote: "Minimum DLP and max liquidity fee bounds are enforced by router arguments.",
Expand All @@ -1149,7 +1193,7 @@ function contextData(context: Context): JsonMap {
};
}

function binData(bin: DepositCandidate) {
function binData(bin: DepositCandidate, ownedNftBinIds?: Set<number>) {
return {
index: bin.index,
binId: bin.binId,
Expand All @@ -1165,6 +1209,7 @@ function binData(bin: DepositCandidate) {
maxXLiquidityFee: bin.maxXLiquidityFee,
maxYLiquidityFee: bin.maxYLiquidityFee,
hasExistingPosition: bin.hasExistingPosition,
willAttachNftPc: bin.hasExistingPosition || (ownedNftBinIds?.has(bin.binId) ?? false),
};
}

Expand Down
Loading