What happened?
A securely cancelled Offer can remain permanently recorded as CANCELLED after the block confirming the cancellation is removed by a reorg. The cancellation spend returns to the full-node mempool, but the maker's TradeRecord is not rolled back.
If a taker still has the originally signed Offer, a higher-fee acceptance can then replace the rebroadcast cancellation under the normal mempool replacement rules. The original Offer confirms, while the connected maker wallet continues to report the trade as CANCELLED.
This report is about wallet/trade-state consistency after a reorg. The exchange still follows the terms of the originally signed Offer; no signature bypass or arbitrary asset transfer is claimed.
Deterministic reproduction
The reproduction uses the real wallet simulator, TradeManager, CAT wallet, full-node mempool, block farming, and reorg path.
- Create and share a signed CAT-for-XCH Offer.
- Securely cancel it on chain using the default zero fee and no expiry condition.
- Farm a block confirming the cancellation.
- Verify that the maker's record is
CANCELLED, but confirmed_at_index == 0.
- Reorg out the cancellation block with
FullNodeSimulator.reorg_from_index_to_new_index().
- Verify that the cancellation spend returns to the full-node mempool.
- Submit the original Offer through
TradeManager.respond_to_offer() with a fee of 20,000,000 mojos.
- Verify that this higher-fee acceptance replaces the cancellation in the mempool.
- Farm a block and verify that the original Offer spend confirms.
- Query the maker's original
TradeRecord; it still reports CANCELLED with confirmed_at_index == 0.
Observed output:
POC_STEP_1 secure cancellation confirmed: stored_status=CANCELLED stored_confirmed_at_index=0
CONTROL_NO_EXPIRY reorged secure cancellation returned to mempool=True
CONTROL_NO_EXPIRY higher-fee original offer replaced cancellation in mempool=True
CONTROL_NO_EXPIRY higher-fee take confirmed while maker status remains CANCELLED
The positive sequence passed 8/8 fresh Windows executions. It also passed under Linux Docker and was reconfirmed against the latest origin/main immediately before filing this issue.
Negative controls
- With no reorg, the securely cancelled Offer remains invalid.
- After the same reorg, a zero/equal-fee acceptance is rejected with
MEMPOOL_CONFLICT, and the cancellation remains in the mempool.
- The expiry condition used in an earlier hypothesis is not required; the minimal no-expiry sequence above reproduces the bug.
Aggregate results:
Windows / Python 3.12.13:
4 passed, 12 skipped, 64 deselected
Linux Docker / Python 3.12.14:
2 passed, 6 skipped, 72 deselected
Latest origin/main / Windows / Python 3.12.13:
2 passed, 6 skipped, 72 deselected
Expected behavior
When the cancellation confirmation is rolled back, the maker's Offer record should no longer remain authoritatively CANCELLED. The wallet should restore an appropriate pending/reorged state and continue tracking the cancellation spend.
At minimum, if the original Offer subsequently confirms, the stale maker record should be updated to reflect that execution instead of remaining CANCELLED.
Actual behavior
- The cancellation spend is correctly returned to the mempool after the reorg.
- A higher-fee original Offer acceptance can replace it and confirm.
- The maker's original record remains
CANCELLED while the wallet is connected.
- The stale state also persisted across a wallet restart in the extended lifecycle test.
Root cause
-
chia/wallet/trade_manager.py, coins_of_interest_farmed() sets a confirmed cancellation without passing the block height:
await self.trade_store.set_status(trade.trade_id, TradeStatus.CANCELLED)
-
chia/wallet/trading/trade_store.py, set_status() only changes confirmed_at_index when index != 0, so the cancelled record retains confirmed_at_index == 0.
-
TradeStore.rollback_to_block() only deletes records with confirmed_at_index > block_index. A cancellation recorded at index 0 cannot be reverted by this rule.
-
WalletStateManager.reorg_rollback() rolls back several wallet stores but does not invoke TradeStore rollback.
-
TradeManager.get_coins_of_interest() excludes CANCELLED trades, and get_trades_by_coin() skips them. The original Offer's later confirmation therefore does not correct the maker's stale record.
Related issues
Version
Initially tested commit: 0b88565359087cc09655cb5ad8cdd93354c30931
Reconfirmed immediately before filing against origin/main at 9492119b2f26acb034d0d7cfc881d071aa4f904a. The positive higher-fee sequence and equal-fee negative control both passed (2 passed, 6 skipped, 72 deselected).
What platform are you using?
Windows (also reproduced in Linux Docker)
What ui mode are you using?
CLI / wallet simulator
Relevant log output
POC_STEP_1 secure cancellation confirmed: stored_status=CANCELLED stored_confirmed_at_index=0
CONTROL_NO_EXPIRY reorged secure cancellation returned to mempool=True
CONTROL_NO_EXPIRY higher-fee original offer replaced cancellation in mempool=True
CONTROL_NO_EXPIRY higher-fee take confirmed while maker status remains CANCELLED
CONTROL_EQUAL_FEE original offer rejected while reorged cancellation remains in mempool=True
What happened?
A securely cancelled Offer can remain permanently recorded as
CANCELLEDafter the block confirming the cancellation is removed by a reorg. The cancellation spend returns to the full-node mempool, but the maker'sTradeRecordis not rolled back.If a taker still has the originally signed Offer, a higher-fee acceptance can then replace the rebroadcast cancellation under the normal mempool replacement rules. The original Offer confirms, while the connected maker wallet continues to report the trade as
CANCELLED.This report is about wallet/trade-state consistency after a reorg. The exchange still follows the terms of the originally signed Offer; no signature bypass or arbitrary asset transfer is claimed.
Deterministic reproduction
The reproduction uses the real wallet simulator,
TradeManager, CAT wallet, full-node mempool, block farming, and reorg path.CANCELLED, butconfirmed_at_index == 0.FullNodeSimulator.reorg_from_index_to_new_index().TradeManager.respond_to_offer()with a fee of 20,000,000 mojos.TradeRecord; it still reportsCANCELLEDwithconfirmed_at_index == 0.Observed output:
The positive sequence passed 8/8 fresh Windows executions. It also passed under Linux Docker and was reconfirmed against the latest
origin/mainimmediately before filing this issue.Negative controls
MEMPOOL_CONFLICT, and the cancellation remains in the mempool.Aggregate results:
Expected behavior
When the cancellation confirmation is rolled back, the maker's Offer record should no longer remain authoritatively
CANCELLED. The wallet should restore an appropriate pending/reorged state and continue tracking the cancellation spend.At minimum, if the original Offer subsequently confirms, the stale maker record should be updated to reflect that execution instead of remaining
CANCELLED.Actual behavior
CANCELLEDwhile the wallet is connected.Root cause
chia/wallet/trade_manager.py,coins_of_interest_farmed()sets a confirmed cancellation without passing the block height:chia/wallet/trading/trade_store.py,set_status()only changesconfirmed_at_indexwhenindex != 0, so the cancelled record retainsconfirmed_at_index == 0.TradeStore.rollback_to_block()only deletes records withconfirmed_at_index > block_index. A cancellation recorded at index 0 cannot be reverted by this rule.WalletStateManager.reorg_rollback()rolls back several wallet stores but does not invoke TradeStore rollback.TradeManager.get_coins_of_interest()excludesCANCELLEDtrades, andget_trades_by_coin()skips them. The original Offer's later confirmation therefore does not correct the maker's stale record.Related issues
confirmed_at_indexis always empty #16817 concernsconfirmed_at_indexbeing zero for confirmed Offers generally, without this cancellation rollback lifecycle.PENDING_ACCEPT, rather than a confirmed cancellation remainingCANCELLEDafter its block is reorged out.Version
Initially tested commit:
0b88565359087cc09655cb5ad8cdd93354c30931Reconfirmed immediately before filing against
origin/mainat9492119b2f26acb034d0d7cfc881d071aa4f904a. The positive higher-fee sequence and equal-fee negative control both passed (2 passed, 6 skipped, 72 deselected).What platform are you using?
Windows (also reproduced in Linux Docker)
What ui mode are you using?
CLI / wallet simulator
Relevant log output