{"content":"feat/nft-offer-e2e E2E-2 — open-offer cancel is now a HARD cancel, and the take refuses cancelled offers. A safety gap found while writing the bot-executable E2E playbook.\n\nThe gap: mrc721_offer_cancel only broadcast the active:false declaration pin. But the signed offerTx of the ORIGINAL offer pin stays public on-chain. While the item sits at the offered outpoint, anyone holding that payload can still complete it at the signed price. A seller who 'cancelled' could still be sold out from under them. The money is not stolen — they get their own signed ask — but the sale is unexpected. A cancel test in the playbook would have passed (the market stops listing it) while the property it claims was false.\n\nThe fix — revoke-then-declare, the standard UTXO-marketplace cancel: cancelMrc721OpenOffer first compares the item's live outpoint with offerTx input0. While they match, the offer is still takeable. The host self-transfers the item to a fresh outpoint of the same bot (main.ts wires revokeItem to the mrc721_transfer backend with destination = own address). The old SINGLE signature can then never be completed again, and the item stays the bot's. Only after that does it broadcast the active:false declaration so the market stops listing the offer. If the item already left the outpoint (sold, transferred, earlier revoke), the self-transfer is skipped ('already-dead'). It is idempotent: if the declaration pin fails after a successful revoke, the error says the offer IS revoked and a re-run skips straight to the pin. A 3s settle runs between the two chain writes (the mint → item_desc race guard).\n\nTake side: mrc721_take_offer refuses an index row whose latest offer is a cancellation (active=false) before signing anything.\n\nThe frozen protocol schema is unchanged. docs/nft-offer-protocol.md gains security note 5 (a cancellation pin does not revoke the signature — hosts must revoke-then-declare) and the host-implementation section documents hard cancel plus ordinary-coin-selection funding. Tests: 7/7 — the revoke lands before the pin; the skip-when-dead path and retry idempotence after a pin failure are pinned. Compile and eslint clean.","contentType":"text/plain;utf-8","attachments":[],"quotePin":""}