diff --git a/bitflow-hodlmm-deposit/SKILL.md b/bitflow-hodlmm-deposit/SKILL.md index 8271092..bd67a87 100644 --- a/bitflow-hodlmm-deposit/SKILL.md +++ b/bitflow-hodlmm-deposit/SKILL.md @@ -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. diff --git a/bitflow-hodlmm-deposit/bitflow-hodlmm-deposit.ts b/bitflow-hodlmm-deposit/bitflow-hodlmm-deposit.ts index abf1fdb..d1fb5e3 100644 --- a/bitflow-hodlmm-deposit/bitflow-hodlmm-deposit.ts +++ b/bitflow-hodlmm-deposit/bitflow-hodlmm-deposit.ts @@ -252,6 +252,7 @@ interface Context { poolInterface: ContractInterface; xAsset: TokenAsset; yAsset: TokenAsset; + ownedNftBinIds: Set; } interface SharedOptions { @@ -530,6 +531,43 @@ async function getBalances(wallet: string): Promise { return fetchJson(`${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> { + const owned = new Set(); + 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 { return fetchJson(`${HIRO_API}/extended/v1/tx/mempool?sender_address=${wallet}&limit=20`); } @@ -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( @@ -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) { @@ -1087,6 +1130,7 @@ async function collectContext(opts: SharedOptions, requirePlan = true): Promise< poolInterface, xAsset, yAsset, + ownedNftBinIds, }; } @@ -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: { @@ -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.", @@ -1149,7 +1193,7 @@ function contextData(context: Context): JsonMap { }; } -function binData(bin: DepositCandidate) { +function binData(bin: DepositCandidate, ownedNftBinIds?: Set) { return { index: bin.index, binId: bin.binId, @@ -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), }; }