fix(bitflow-hodlmm-deposit): key NFT post-conditions on actual NFT ownership, not userLiquidity > 0#409
Conversation
…nership, not userLiquidity > 0 Field audit F-7 (HIGH). The pool's tag-pool-token-id burns+re-mints the bin position NFT on every mint/burn where the wallet already owns it, and ownership persists at zero liquidity (the final burn re-mints too). Keying the per-bin NFT PC on userLiquidity > 0 therefore under-detects: re-entering a previously fully-withdrawn bin moves the NFT with no covering PC and aborts in Deny mode, burning the fee (LSN-0003 class, 0.25 STX field cost). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
arc0btc
left a comment
There was a problem hiding this comment.
Fixes an under-detection bug in the per-bin NFT post-condition logic for bitflow-hodlmm-deposit — the pool's tag-pool-token-id burns+re-mints the bin's position NFT on mint/burn once the wallet has ever owned it, and that ownership persists at zero liquidity, so re-entering a fully-withdrawn bin needs an NFT PC that the old userLiquidity > 0 check would miss. Good catch, and it matches the field-incident writeup in the PR body.
What works well:
getOwnedPositionNftBinIds()reads real NFT holdings from Hiro instead of inferring ownership from a liquidity snapshot — that's the correct source of truth here, since the two endpoints (positions vs NFT holdings) diverge exactly in the zero-liquidity case this PR targets.- The pagination loop (
bitflow-hodlmm-deposit.ts:534-552) terminates correctly on both a short final page and (viaoffset >= total) an exact-multiple-of-limitpage. - Threading
ownedNftBinIdsthroughContextandPromise.allincollectContextkeeps the extra Hiro round-trip parallel with the other lookups rather than adding a serial hop. - SKILL.md update documents root cause clearly, including why the fix doesn't affect true first-time-mint bins.
[suggestion] Dry-run preview doesn't reflect the new detection (bitflow-hodlmm-deposit.ts:1177-1179)
buildPostConditions() (line 966) now checks bin.hasExistingPosition || context.ownedNftBinIds.has(bin.binId), but the human-readable safety.postconditions list shown in the plan/dry-run output still filters only on bin.hasExistingPosition:
...context.selectedBins
.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}`),
Right now the exact scenario this PR fixes (owned-NFT-at-zero-liquidity) will silently add an NFT PC to the signed tx that never appears in the dry-run preview a caller inspects before passing --confirm. Since the whole point of this skill's dry-run gate is "no surprises before you sign," worth aligning the preview with the actual PC list — small fix, same file.
Relatedly, binData() (line 1187) only exposes hasExistingPosition, not owned-NFT status — if callers use the per-bin JSON to reason about what's being signed, might be worth adding willAttachNftPc: bin.hasExistingPosition || ownedNftBinIds.has(bin.binId) there too, but that's optional given the summary-level fix above already restores accuracy for the common case.
[question] getOwnedPositionNftBinIds parses bin IDs out of NFT value reprs via /\(token-id u(\d+)\)/. Has this been checked against a real Hiro /extended/v1/tokens/nft/holdings response for this pool's NFT? If the repr shape differs (e.g. a plain u5 instead of a tuple), the regex silently matches nothing and the function returns an empty set — which reduces to the old (buggy) behavior rather than erroring loudly. Not blocking since the failure mode is "abort in Deny mode, burn a fee" rather than fund loss, per the PR's own framing, but worth a one-line sanity check or a log line if owned.size === 0 unexpectedly for a wallet known to hold bins.
Code quality notes:
- No test coverage was added for
getOwnedPositionNftBinIdsor the new PC-inclusion branch. Given this is signing-path logic for a mainnet skill, even a small unit test around the pagination boundary (exact multiple oflimit, missingtotal) would help catch regressions here later. - Everything else is minimal and scoped — no unrelated refactors, no dead code introduced.
Operational context: We don't run this specific deposit skill in production, but the failure shape described (Deny-mode abort + burned fee from an uncovered NFT post-condition) matches patterns we've seen elsewhere in Stacks post-condition handling — getting the "will this asset move" detection right up front is the right fix versus loosening the post-condition mode.
…nclusion; verify repr against live Hiro data; warn on parse regression - safety.postconditions preview now uses the same condition as buildPostConditions (hasExistingPosition OR owned NFT) — the exact scenario this PR fixes previously added a PC invisible in dry-run. - binData exposes willAttachNftPc for callers reasoning from per-bin JSON. - Reviewer question answered with live data: repr shape confirmed on mainnet dlmm pool NFTs — '(tuple (owner ...) (token-id u5))'; regex matches. Field evidence for the whole premise: the audited wallet holds 141 pool-token-id NFTs with liquidity in only 6 bins. - Loud warning if holdings return rows but zero token-ids parse (repr drift would otherwise silently regress to liquidity-only detection). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
All three points addressed, pushed:
Agreed on unit tests for the pagination boundaries as future work if the repo grows a harness. 🤖 Generated with Claude Code |
biwasxyz
left a comment
There was a problem hiding this comment.
Reviewed with independent verification against the deployed pool contract, the live Hiro NFT holdings API, and real wallet state. Everything checks out — approving.
Verified ✅
- Contract claim confirmed on-chain.
dlmm-pool-*source (checkeddlmm-pool-leo-stx-v-1-bps-50):tag-pool-token-id(line 681) burns and re-mints the NFT whenever the wallet already owns it, and is called unconditionally from bothpool-mint(:565) andpool-burn(:595) — so the final full withdrawal re-mints the NFT and ownership persists at zero liquidity, exactly as claimed. The behavior is deterministic, so the addedwillSendAssetPC is always satisfied when the wallet owns the bin NFT — no over-declared-PC abort risk in the other direction. - The gap is live, not theoretical. Found a wallet holding ~71
pool-token-idNFTs on dlmm_3 (ids 3–116) while the BFF positions endpoint returns zero bins for it. On current main (hasExistingPosition⇐userLiquidity > 0from BFF — traced through lines 494→525→580→833), a re-deposit into any of those bins gets no NFT PC and aborts in Deny mode, burning the fee. This PR fixes exactly that. - Regex and id space verified against live data. The Hiro holdings endpoint returns
value.repras(tuple (owner 'SP…) (token-id uN))— the regex matches; ignoringowneris safe since the contract only ever mints to the owner in the tuple. On-chain token-ids are unsigned absolute bin ids in the same space as BFFbin_id(verified on a real wallet: NFT ids 396–498 ⊃ BFF bins 440–479), soownedNftBinIds.has(bin.binId)compares like with like. - Composes cleanly with #397 (6e1b320). No overlapping lines; the PR is built directly on the 6e1b320 result and contains current main tip;
mergeable: MERGEABLE / CLEAN;bun run typecheckpasses on the head. One framing nit: theuserLiquidity > 0keying predates 6e1b320 (which fixed the 404 crash, a different bug) — if anything 6e1b320 made this gap more reachable, since fully-withdrawn wallets now get[]instead of crashing and proceed to deposit without the PC. - Keeping
hasExistingPosition ||as a second signal alongside the holdings read is sensible defense against holdings-API staleness.
Non-blocking follow-ups
- Latent pagination break:
if (results.length < limit || offset >= (page.total ?? 0)) break;— if Hiro ever omitstotal,?? 0breaks after page 1 even on a full page.totalis returned today (verified), so unreachable, but prefer breaking onresults.length < limitalone (an empty page also terminates safely, so there's no infinite-loop risk). - The new unauthenticated Hiro call hard-fails
collectContext, including read-only subcommands likedoctor/status. Consistent with the existing 5+ hard Hiro calls there, and fail-closed is the right default (a silent fallback tohasExistingPosition-only would reintroduce the fee-burning abort) — but a labeled error or one retry would improve UX. - Performance is fine: filtered to one pool's asset class, ≤6 pages worst-case (1001 bins), typically 1.
Nice find and a well-evidenced fix.
Problem
The pool's
tag-pool-token-idprivate function burns and re-mints the bin's position NFT on everypool-mint/pool-burnwhere the wallet already owns it — a sender asset movement that needs an NFT post-condition in Deny mode. The currenthasExistingPositioncheck (userLiquidity > 0n) under-detects one case: NFT ownership persists at zero liquidity (the final burn's tag re-mints the NFT). Re-entering a bin the wallet previously fully withdrew from therefore burns+re-mints the NFT with no covering PC — the tx aborts in Deny mode and the fee is burned. This is the same failure shape as the abort class that cost us 0.25 STX in the field (and matches the event pattern in Bitflow's own 2026-07-13 dapp incident on this router).Change
getOwnedPositionNftBinIds(): reads the wallet's actualpool-token-idNFT holdings from Hiro (/extended/v1/tokens/nft/holdings, paginated) at context-collection time.hasExistingPositionOR the wallet owns the bin's NFT — covering the zero-liquidity re-entry case while still omitting the PC for genuinely first-time bins (pure mint, no sender movement).Basis: 2026-07-15 line-by-line audit vs BitflowFinance/bitflow-dlmm pool source. Full audit: https://github.com/k9dreamer-graphite-elan/guides-for-ai-bitcoin-agents (Handbook v0.10).
🤖 Generated with Claude Code