🚨 ALL CI CHECKS MUST PASS
Your PR will not be reviewed or merged until every CI job is green. No exceptions.
Run all four locally before you push:
cargo fmt --all -- --check # Formatting
cargo clippy --all-targets -- -D warnings # Clippy — warnings are errors
cargo test # Tests
./scripts/build_wasm.sh # Build Optimized WASM
A red build is the single most common reason work stalls on this repo. If CI fails and you are stuck, say so in the PR — do not push a failing build and go quiet.
Also required: put Closes #<this issue number> in your PR description. Without it, GrantFox cannot link your PR to this issue.
What needs to be done
Cancellation is a first-class outcome in this contract — there are three ways to reach it — but it is recorded nowhere. EscrowStatus::Cancelled is never assigned, TotalCancelledCount is never incremented, and mutually-agreed cancellations are booked as refunds. Make cancellation observable in state, in analytics, and in events.
Why it matters
EscrowStatus declares six variants, and Cancelled is one of them. It is part of the public contract ABI: any client, indexer or dashboard written against the interface can legitimately expect to observe it. Nothing in the contract ever sets it.
$ grep -n "EscrowStatus::Cancelled" contracts/marketx/src/lib.rs
(no matches)
Trace what the three cancellation paths actually do:
| Path |
What happens |
Cancelled set? |
Counter |
accept_cancellation (lib.rs:1613) |
calls refund_buyer |
❌ sets Refunded |
TotalRefundedCount +1 |
propose_cancellation (lib.rs:1566) |
records a proposer only |
❌ |
none |
cancel_unfunded (lib.rs:1780) |
deletes the escrow record |
❌ |
none |
refund_buyer (lib.rs:333-345) is the shared tail of the accept path:
escrow.status = EscrowStatus::Refunded;
escrow.cancellation_proposer = None;
Self::add_i128(env, DataKey::TotalRefundedAmount, escrow.amount);
Self::add_u32(env, DataKey::TotalRefundedCount);
Four consequences, in descending order of how much they hurt:
1. A public getter that can only ever return zero. get_total_cancelled_count() (lib.rs:876) reads DataKey::TotalCancelledCount. That key is initialised to 0 in initialize() and never written again — the only add_u32 call sites in the file are for TotalRefundedCount (twice), TotalReleasedCount and TotalDisputedCount. It is an API that reports a number which is always wrong, in the same family as the misleading event fixed in #246 and #259.
2. analytics_summary() is wrong too. GlobalDisputeAnalytics exposes cancelled_count (types.rs:624), so the aggregate report inherits the permanent zero. failure_rate_bps sits in the same struct, computed from counters that are themselves conflated.
3. Refunds and cancellations are conflated. A dispute-driven refund and a mutually-agreed cancellation are different business events with different implications, and both land in TotalRefundedCount. Anyone using these counters to reason about dispute rates is reading an inflated figure, and there is no way to separate the two after the fact.
4. cancel_unfunded leaves almost no trace. It removes the escrow and its hash index and emits EscrowExpiredEvent, which describes expiry rather than cancellation, and increments nothing. Once the record is gone the event is the only evidence it ever existed.
Separately, and worth fixing in the same pass: TotalReleasedAmount is never initialised. initialize() (lib.rs:478-505) sets seven counters and omits it, even though it is written in six places elsewhere. It survives today only because add_i128 falls back to unwrap_or(0), which means the omission is invisible until someone reads the key directly before the first release.
Technical context
contracts/marketx/src/lib.rs:333-345 — refund_buyer, the shared tail
contracts/marketx/src/lib.rs:1566, :1613, :1780 — the three cancellation entry points
contracts/marketx/src/lib.rs:876 — get_total_cancelled_count
contracts/marketx/src/lib.rs:478-505 — counter initialisation
contracts/marketx/src/types.rs:234-241 — EscrowStatus
contracts/marketx/src/types.rs:619-626 — GlobalDisputeAnalytics
contracts/marketx/src/events.rs — event definitions; a cancellation event may need adding
A judgement call to make explicitly, and state in the PR: accept_cancellation currently reaches a terminal state through refund_buyer, which both moves the money and books the counters. Splitting cancellation out means either giving it its own tail that transfers funds and records Cancelled, or parameterising refund_buyer with the terminal status to apply. Either is fine; decide and say which and why. Do not duplicate the transfer logic.
Do not silently change existing counter semantics. TotalRefundedCount currently includes cancellations. If your change removes them from that count, say so in the PR — anyone reading those numbers today will see a step change.
Acceptance criteria
Out of scope
- Adding new cancellation entry points or changing who may cancel
- Reworking
failure_rate_bps beyond keeping it consistent with corrected counters
- Backfilling analytics for escrows already settled on-chain — call it out in the PR as a migration consideration, but do not attempt it here
- Renaming or removing any existing public function
Getting started
cargo test # 120 tests currently pass
grep -n "EscrowStatus::Cancelled" contracts/marketx/src/lib.rs # currently silent
grep -n "add_u32(" contracts/marketx/src/lib.rs # four sites, none for Cancelled
Read refund_buyer first. It is small, and the whole issue follows from what it does and does not record.
What needs to be done
Cancellation is a first-class outcome in this contract — there are three ways to reach it — but it is recorded nowhere.
EscrowStatus::Cancelledis never assigned,TotalCancelledCountis never incremented, and mutually-agreed cancellations are booked as refunds. Make cancellation observable in state, in analytics, and in events.Why it matters
EscrowStatusdeclares six variants, andCancelledis one of them. It is part of the public contract ABI: any client, indexer or dashboard written against the interface can legitimately expect to observe it. Nothing in the contract ever sets it.Trace what the three cancellation paths actually do:
Cancelledset?accept_cancellation(lib.rs:1613)refund_buyerRefundedTotalRefundedCount+1propose_cancellation(lib.rs:1566)cancel_unfunded(lib.rs:1780)refund_buyer(lib.rs:333-345) is the shared tail of the accept path:Four consequences, in descending order of how much they hurt:
1. A public getter that can only ever return zero.
get_total_cancelled_count()(lib.rs:876) readsDataKey::TotalCancelledCount. That key is initialised to0ininitialize()and never written again — the onlyadd_u32call sites in the file are forTotalRefundedCount(twice),TotalReleasedCountandTotalDisputedCount. It is an API that reports a number which is always wrong, in the same family as the misleading event fixed in #246 and #259.2.
analytics_summary()is wrong too.GlobalDisputeAnalyticsexposescancelled_count(types.rs:624), so the aggregate report inherits the permanent zero.failure_rate_bpssits in the same struct, computed from counters that are themselves conflated.3. Refunds and cancellations are conflated. A dispute-driven refund and a mutually-agreed cancellation are different business events with different implications, and both land in
TotalRefundedCount. Anyone using these counters to reason about dispute rates is reading an inflated figure, and there is no way to separate the two after the fact.4.
cancel_unfundedleaves almost no trace. It removes the escrow and its hash index and emitsEscrowExpiredEvent, which describes expiry rather than cancellation, and increments nothing. Once the record is gone the event is the only evidence it ever existed.Separately, and worth fixing in the same pass:
TotalReleasedAmountis never initialised.initialize()(lib.rs:478-505) sets seven counters and omits it, even though it is written in six places elsewhere. It survives today only becauseadd_i128falls back tounwrap_or(0), which means the omission is invisible until someone reads the key directly before the first release.Technical context
contracts/marketx/src/lib.rs:333-345—refund_buyer, the shared tailcontracts/marketx/src/lib.rs:1566,:1613,:1780— the three cancellation entry pointscontracts/marketx/src/lib.rs:876—get_total_cancelled_countcontracts/marketx/src/lib.rs:478-505— counter initialisationcontracts/marketx/src/types.rs:234-241—EscrowStatuscontracts/marketx/src/types.rs:619-626—GlobalDisputeAnalyticscontracts/marketx/src/events.rs— event definitions; a cancellation event may need addingA judgement call to make explicitly, and state in the PR:
accept_cancellationcurrently reaches a terminal state throughrefund_buyer, which both moves the money and books the counters. Splitting cancellation out means either giving it its own tail that transfers funds and recordsCancelled, or parameterisingrefund_buyerwith the terminal status to apply. Either is fine; decide and say which and why. Do not duplicate the transfer logic.Do not silently change existing counter semantics.
TotalRefundedCountcurrently includes cancellations. If your change removes them from that count, say so in the PR — anyone reading those numbers today will see a step change.Acceptance criteria
accept_cancellationresults inEscrowStatus::Cancelled, notRefundedTotalCancelledCountis incremented on every path that cancels an escrowget_total_cancelled_count()andanalytics_summary().cancelled_countreturn non-zero after a cancellationTotalRefundedCount/TotalRefundedAmount, or the PR explains why they should remaincancel_unfundedrecords the cancellation before removing the escrow, and emits an event that names cancellation rather than expiryTotalReleasedAmountis initialised ininitialize()alongside its siblingscancel_unfundedasserting the counter and event; a dispute refund asserting it still counts as a refund and not as a cancellation;analytics_summaryasserting the fields move independentlygrep -n "EscrowStatus::Cancelled" contracts/marketx/src/lib.rsreturns at least one assignmentOut of scope
failure_rate_bpsbeyond keeping it consistent with corrected countersGetting started
Read
refund_buyerfirst. It is small, and the whole issue follows from what it does and does not record.