Skip to content

cancel_offers: build the batch as one spend so no fee coin is picked twice - #894

Open
awizardxch wants to merge 1 commit into
xch-dev:mainfrom
awizardxch:fix/cancel-offers-one-spend
Open

awizardxch wants to merge 1 commit into
xch-dev:mainfrom
awizardxch:fix/cancel-offers-one-spend

Conversation

@awizardxch

Copy link
Copy Markdown
Contributor

Problem

cancel_offers cancels each offer in its own SpendContext and then joins the coin spends into one bundle. When an offer holds no XCH of its own (a CAT or NFT offer), its cancel needs an XCH coin for the fee, and each cancel selects one on its own. Nothing marks a coin as used between them, so two cancels can pick the same fee coin. The joined bundle then spends it twice and the node refuses it (DOUBLE_SPEND / MEMPOOL_CONFLICT).

This is one of the failures described in #780 ("Sage's internal coin selector can pick the same fee coin for two different transactions"). Within a single cancel_offers call it happens without any concurrency: a wallet with one spare XCH coin and two CAT offers is enough.

Fix

Wallet::cancel_offers builds every cancellation in one spend: one cancellable coin per offer, plus a single fee action. Selection inside one spend already skips coins it has picked (select_spends_excluding), so the fee coins are selected once. Wallet::cancel_offer is now the one-offer case of the same code.

The fee stays per offer: the endpoint passes fee × offer count, matching the UI's "Network Fee (per offer)" label and the amount users pay today. The multiplication is checked for overflow.

Test

test_cancel_offers_share_one_fee_coin sets up a wallet with one XCH coin and two CAT-only offers.

  • Cancelling them one by one and joining the results (the old behavior) spends that XCH coin twice.
  • cancel_offers spends it once. The bundle confirms on the simulator, both CAT coins come back, and the XCH balance drops by the fee exactly once.

cargo test -p sage-wallet offer: 22 passed. cargo fmt --check and cargo clippy -p sage-wallet -p sage --all-targets are clean.

🤖 Generated with Claude Code

…twice

cancel_offers cancelled each offer in its own SpendContext and joined the
results. When an offer holds no XCH of its own (a CAT or NFT offer), each
cancel selected an XCH fee coin independently, so two cancels could pick the
same coin and the joined bundle spent it twice (MEMPOOL_CONFLICT / DOUBLE_SPEND).

The wallet now cancels every offer in one spend: one coin per offer, and the
fee coins selected once. The fee stays per offer (fee x count), as the UI
labels it; cancel_offer is the one-offer case of the same code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant