fix: keep amt and fa tags consistent during the taker-bond window - #928
fix: keep amt and fa tags consistent during the taker-bond window#928Forte11Cuba wants to merge 1 commit into
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (6)
Included review availability: Your plan provides up to 2 included reviews per hour; 1 remains after this review. WalkthroughThe changes reject partial range orders, keep NIP-33 range publication consistent during ChangesTaker bond recovery and order state handling
Estimated code review effort: 4 (Complex) | ~45 minutes Merge Risk: 🟠 High · up to The new stale-bond recovery can release and overwrite a taker bond after a valid payment has locked it, potentially allowing an order or trade to proceed without the intended anti-abuse bond. The cleanup transition needs an atomic state-and-age guard before this PR is ready to merge. Sequence Diagram(s)sequenceDiagram
participant Scheduler
participant BondFlow
participant BondDatabase
participant OrderStore
participant Nostr
Scheduler->>BondFlow: Run reconciliation every 300 seconds
BondFlow->>BondDatabase: Find stale WaitingTakerBond orders
BondDatabase-->>BondFlow: Return eligible orders
BondFlow->>OrderStore: Re-check bond state and age
BondFlow->>OrderStore: Release stale Requested bond
BondFlow->>OrderStore: Set order to Pending
BondFlow->>Nostr: Republish order as Pending
Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Full details: Linked Issues checkExplanation The changes satisfy issue
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
21Mill
left a comment
There was a problem hiding this comment.
Reviewed at f5387fb. Verified locally: full suite green (1250 passed / 0 failed / 2 ignored) and clippy --all-targets --all-features -- -D warnings clean, as you describe. Part 1 and part 3 look right to me; part 3 in particular closes a genuine hole — check_range_order_limits only validates when both bounds are Some, so a half-range really did slip past everything.
Part 2 has a problem that I think blocks it, and it comes from one factual premise.
1. src/app/bond/flow.rs:1307 — the stale cutoff is derived from a setting that never reaches the bond invoice.
stale_cutoff = now - hold_invoice_expiration_window - 60. But that setting is never applied to a bond hold invoice: request_taker_bond calls create_hold_invoice (flow.rs:172), which sets only hash, memo, value and cltv_expiry and leaves expiry at 0 (src/lightning/mod.rs:452), so LND falls back to its documented default — "Payment request expiry time in seconds. Default is 86400 (24 hours)", per the AddHoldInvoiceRequest proto. The bond invoice is payable for 24h, not for 360s.
Two consequences:
- With the shipped config, a taker who pays their bond invoice 7 minutes after taking finds it already cancelled and the order yanked back to
Pending— while the invoice was still open and payable at LND. - The sweep is not a fallback.
maybe_drop_waiting_taker_bondis driven by LND's cancel, which arrives at the 24h expiry — so on a healthy subscription the signal is not missed, it simply has not happened yet. This sweep becomes the primary mechanism on every take, shortening the bond window from 24h to ~6 minutes for everyone. Your regtest run reads the same way: the cancel signal had not "genuinely never arrived", it was 23h54m away.
Shortening the window may well be the right behaviour — it is what the config entry describes — but it is a live change for every take rather than the belt-and-braces recovery the doc comment describes.
2. src/app/bond/flow.rs:1339 and :1352 — the "inverse race cannot happen" premise is false, and the release has no state guard.
The doc justifies the unguarded release with "its hold invoice expired at least the grace margin ago, so LND no longer accepts payment on it". Per finding 1 that does not hold, which makes this sequence reachable:
- the sweep reads the bond via
find_active_bonds_for_orderand seesRequested; - the taker pays;
on_bond_invoice_acceptedwins itsRequested -> LockedCAS, promotes the taker context and resumes the take; - the sweep calls
release_bondwith its stale snapshot, cancelling the now-accepted HTLC.
Re-reading the state would not save it either: release_bond short-circuits only on state.is_terminal() (flow.rs:513), and Locked is not terminal. And the write is worse than the cancel — Bond::update is UPDATE bonds SET <every column> WHERE id = ? with no state predicate (src/app/bond/crud.rs:160-171), so it overwrites the concurrent Locked and restores the snapshot's locked_at. End state: a live trade backed by a bond whose sats were refunded to the taker.
Worth noting release_bond's own doc already says the real cancel comes "when the hold invoice's CLTV expires and LND auto-cancels" (flow.rs:496) — the contradiction is thirty lines up in the same file.
3. src/nip33.rs:318 — the amt fix is scoped to range orders, leaving the same contradiction for market-priced single orders.
create_amt_value returns "0" only under publishes_as_pending(order) && order.is_range_order(). A single order created at market price has amount == 0 while Pending, and take_sell / take_buy mutate it to the live quote before the republish (src/app/take_sell.rs:174-179), from the same in-memory struct. Its WaitingTakerBond event therefore carries s: pending with a concrete amt where the Pending event it replaces advertised "0" — the same self-contradicting event, just without the range beside it to make it obvious. I confirmed it against this branch:
assertion `left == right` failed
left: "199399"
right: "0"
Your test covers the fixed-price single order ("Single-amount order in WaitingTakerBond -> real amount"), which is correct — that one was never "0". The market-priced one is the gap. Gating on the persisted amount rather than on is_range_order() would cover both; note the current approach cannot, since once amount is mutated nothing on the struct distinguishes the two.
Checked and sound: the find_stale_waiting_taker_bond_orders SQL, including the bondless arm (a WaitingTakerBond order always has a taker bond row, since create_bond at flow.rs:188 precedes the status CAS at :271); child slash rows cannot pollute it (role=maker); the >= cutoff / < cutoff boundary is consistent between finder and sweep step; the scheduler gating under !is_cashu_enabled() and the resubscribe_active_bonds-before-start_scheduler ordering in main.rs.
publishes_as_pending() is the right abstraction and keeping amt byte-identical to the Pending event is the right call — the disagreement is only about how wide the predicate should be.
One cross-reference: this PR would make hold_invoice_expiration_window its first real consumer, and give it a third meaning (the taker bond window). #955 covers the setting's current state — it has no reader, and the deadline its config entry describes is enforced by expiration_seconds. Probably worth settling that before this lands on top of it.
|
I've tested this PR thoroughly on Fedora with Core Fix in
|
Closes #927
A range order being taken published a contradictory event: the range in
fanext to the taker's quote inamt, while still showings: pending. The order was fine in the DB — the event was built from the in-memory struct thattake_sell/take_buymutate before theWaitingTakerBondrepublish.Part 1 nip33:
faandamtnow share a singlepublishes_as_pending()predicate. While a range order publishes as pending,amtstays"0"— byte-identical to the Pending event it replaces, so no client changes needed.Part 2 scheduler sweep: if the LND cancel signal is missed, the order no longer sits at
WaitingTakerBondfor up to 24 h. A job runs every 300 s, releases demonstrably staleRequestedtaker bonds and drops the order back toPending. One deviation from the issue: calling onlymaybe_drop_waiting_taker_bondwould never fire (its CAS no-ops while arequestedbond exists — exactly the stranded state), so the sweep releases those bonds first via the existingrelease_bond. The per-order step re-checks bond state and age at action time, since a concurrent take can land between the scan and the release.Part 3 order creation: half-specified ranges (min xor max) are now rejected with
InvalidAmount. They used to slip past bothcheck_fiat_amountandcheck_range_order_limitsand get persisted with a fixed sats price and no fiat amount.Tested manually on regtest (Polar with a real LND node): took a range order of 5000-10000 CUP and let the bond expire without paying. The event kept
amt: "0"and the full range infaduring the whole bond window, and since LND's cancel signal genuinely never arrived, the sweep recovered the order back toPendingin about 6 minutes. Also adds 6 new unit tests; full suite passes (1250 tests) and clippy is clean.Summary by CodeRabbit
New Features
Bug Fixes