From eb0610825e8541b545015f18cf36489af1c11428 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Thu, 3 Sep 2026 19:03:50 +0200 Subject: [PATCH 1/4] fix(auction): release a sharded listing Hold when realm-core refuses it A sharded listing first moves the item and deposit into a source-shard Hold, then asks realm-core to commit the Auction. When realm-core refused the commit, nothing gave the Hold back: every retry replayed the same Hold and refused again, and the seller lost the item and the deposit. The shard now has `gw_auction_release_listing_hold`. It mails the held item and deposit back to the seller and deletes the Hold. A Hold that already has a receipt is never released. The gateway calls it only on a module Refusal with no realm receipt visible; a timeout or transport failure keeps the Hold for the existing idempotent replay. No schema change. Co-Authored-By: Claude Fable 5.1 --- docs/schema.md | 2 + ...gw_auction_release_listing_hold_reducer.rs | 77 +++++++++++ gateway/src/stdb/bindings/mod.rs | 14 ++ gateway/src/stdb/reducers.rs | 30 +++- module/src/auction.rs | 130 ++++++++++++++++++ 5 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 gateway/src/stdb/bindings/gw_auction_release_listing_hold_reducer.rs diff --git a/docs/schema.md b/docs/schema.md index 9219e7abd..33c2358c0 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -276,6 +276,8 @@ house and economic policy through its imported faction. `game_auction` is the pu its item columns are the complete item-instance snapshot while no inventory row exists, and its house and rate columns preserve the listing-time policy. Private `game_auction_hold` is the source-shard value fence; private `game_auction_operation_receipt` makes listing retries idempotent after that Hold is deleted. +When realm-core refuses a held listing, `gw_auction_release_listing_hold` mails the held item and +deposit back to the seller and deletes the Hold; a Hold with a receipt is never released. Private `game_auction_bid_hold` fences a bidder's complete offer and retains the terminal source outcome, normalized accepted price, and any purse-overflow refund awaiting relay; private `game_auction_bid_decision` is realm-core's serialized, replay-safe decision and exact-once diff --git a/gateway/src/stdb/bindings/gw_auction_release_listing_hold_reducer.rs b/gateway/src/stdb/bindings/gw_auction_release_listing_hold_reducer.rs new file mode 100644 index 000000000..c6c29f4c6 --- /dev/null +++ b/gateway/src/stdb/bindings/gw_auction_release_listing_hold_reducer.rs @@ -0,0 +1,77 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct GwAuctionReleaseListingHoldArgs { + pub operation_id: u64, + pub seller_guid: u64, +} + +impl From for super::Reducer { + fn from(args: GwAuctionReleaseListingHoldArgs) -> Self { + Self::GwAuctionReleaseListingHold { + operation_id: args.operation_id, + seller_guid: args.seller_guid, + } + } +} + +impl __sdk::InModule for GwAuctionReleaseListingHoldArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `gw_auction_release_listing_hold`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait gw_auction_release_listing_hold { + /// Request that the remote module invoke the reducer `gw_auction_release_listing_hold` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`gw_auction_release_listing_hold:gw_auction_release_listing_hold_then`] to run a callback after the reducer completes. + fn gw_auction_release_listing_hold(&self, operation_id: u64, seller_guid: u64) -> __sdk::Result<()> { + self.gw_auction_release_listing_hold_then(operation_id, seller_guid, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `gw_auction_release_listing_hold` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn gw_auction_release_listing_hold_then( + &self, + operation_id: u64, + seller_guid: u64, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl gw_auction_release_listing_hold for super::RemoteReducers { + fn gw_auction_release_listing_hold_then( + &self, + operation_id: u64, + seller_guid: u64, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp.invoke_reducer_with_callback( + GwAuctionReleaseListingHoldArgs { + operation_id, + seller_guid, + }, + callback, + ) + } +} diff --git a/gateway/src/stdb/bindings/mod.rs b/gateway/src/stdb/bindings/mod.rs index d45cc7d8f..fb10d533f 100644 --- a/gateway/src/stdb/bindings/mod.rs +++ b/gateway/src/stdb/bindings/mod.rs @@ -599,6 +599,7 @@ pub mod gw_auction_finish_bid_reducer; pub mod gw_auction_hold_bid_reducer; pub mod gw_auction_hold_listing_reducer; pub mod gw_auction_list_local_reducer; +pub mod gw_auction_release_listing_hold_reducer; pub mod gw_auto_bank_item_reducer; pub mod gw_begin_trade_reducer; pub mod gw_bind_home_reducer; @@ -1560,6 +1561,7 @@ pub use gw_auction_finish_bid_reducer::gw_auction_finish_bid; pub use gw_auction_hold_bid_reducer::gw_auction_hold_bid; pub use gw_auction_hold_listing_reducer::gw_auction_hold_listing; pub use gw_auction_list_local_reducer::gw_auction_list_local; +pub use gw_auction_release_listing_hold_reducer::gw_auction_release_listing_hold; pub use gw_auto_bank_item_reducer::gw_auto_bank_item; pub use gw_begin_trade_reducer::gw_begin_trade; pub use gw_bind_home_reducer::gw_bind_home; @@ -2782,6 +2784,10 @@ pub enum Reducer { buyout: u32, duration_minutes: u32, }, + GwAuctionReleaseListingHold { + operation_id: u64, + seller_guid: u64, + }, GwAutoBankItem { actor_guid: u64, slot: u8, @@ -3738,6 +3744,7 @@ impl __sdk::Reducer for Reducer { Reducer::GwAuctionHoldBid { .. } => "gw_auction_hold_bid", Reducer::GwAuctionHoldListing { .. } => "gw_auction_hold_listing", Reducer::GwAuctionListLocal { .. } => "gw_auction_list_local", + Reducer::GwAuctionReleaseListingHold { .. } => "gw_auction_release_listing_hold", Reducer::GwAutoBankItem { .. } => "gw_auto_bank_item", Reducer::GwBeginTrade { .. } => "gw_begin_trade", Reducer::GwBindHome { .. } => "gw_bind_home", @@ -5427,6 +5434,13 @@ Reducer::DebugVerifyRangedLethalDamageFloorFixture{ start_bid: start_bid.clone(), buyout: buyout.clone(), duration_minutes: duration_minutes.clone(), +}), + Reducer::GwAuctionReleaseListingHold{ + operation_id, + seller_guid, +} => __sats::bsatn::to_vec(&gw_auction_release_listing_hold_reducer::GwAuctionReleaseListingHoldArgs { + operation_id: operation_id.clone(), + seller_guid: seller_guid.clone(), }), Reducer::GwAutoBankItem{ actor_guid, diff --git a/gateway/src/stdb/reducers.rs b/gateway/src/stdb/reducers.rs index b5d67c17e..429550492 100644 --- a/gateway/src/stdb/reducers.rs +++ b/gateway/src/stdb/reducers.rs @@ -291,7 +291,16 @@ impl Coordinator { self.auction_hold_listing(operation_id, request)?; let hold = self.wait_for_auction_hold(operation_id)?; let realm = self.realm_core()?; - realm.auction_commit_listing(&hold)?; + if let Err(error) = realm.auction_commit_listing(&hold) { + // Only a Refusal proves realm-core took nothing. A timeout or transport failure + // leaves the Hold for the next replay, which commits idempotently. + if reducer_refusal_reason(&error).is_some() + && !realm.auction_receipt_is_visible(operation_id) + { + self.auction_release_listing_hold(&hold)?; + } + return Err(error); + } let receipt = realm.wait_for_auction_receipt(operation_id)?; self.auction_confirm_listing(operation_id, receipt.auction_id)?; self.wait_for_auction_receipt(operation_id)?; @@ -383,6 +392,25 @@ impl Coordinator { ) } + fn auction_release_listing_hold(&self, hold: &AuctionHold) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "gw_auction_release_listing_hold", + gw_auction_release_listing_hold_then(hold.operation_id, hold.seller_guid) + ) + } + + fn auction_receipt_is_visible(&self, operation_id: u64) -> bool { + self.0 + .coord() + .conn + .db + .game_auction_operation_receipt() + .operation_id() + .find(&operation_id) + .is_some() + } + fn matching_auction_hold( &self, request: crate::world::CreateAuctionRequest, diff --git a/module/src/auction.rs b/module/src/auction.rs index 97280ec77..894f6cd71 100644 --- a/module/src/auction.rs +++ b/module/src/auction.rs @@ -937,6 +937,7 @@ trait HoldSink: ListingSource { fn commit_hold(&mut self, hold: ListingHold); fn confirm_hold(&mut self, receipt: ListingReceipt); fn delete_hold(&mut self, operation_id: u64); + fn release_hold(&mut self, operation_id: u64, mail: AuctionMail); } trait MarketSink { @@ -1013,6 +1014,38 @@ fn settle_listing(sink: &mut S, operation_id: u64) -> Result<(), Li } } +/// Mail that returns a refused listing's item and deposit to the seller. Mail is the ordinary +/// return path for listed value: it needs no bag slot and reaches an offline seller. +fn listing_release_mail(listing: &PreparedListing) -> AuctionMail { + AuctionMail { + recipient_guid: listing.request.seller_guid, + sender_guid: 0, + subject: "Auction listing refused", + money: listing.deposit, + item: listing.snapshot, + } +} + +/// Give a Hold's value back after realm-core refused its listing. A Hold with a matching receipt +/// backs a live Auction and is never released; a missing Hold is a replay. +fn release_listing( + sink: &mut S, + operation_id: u64, + seller_guid: u64, +) -> Result<(), ListingRefusal> { + if sink.receipt(operation_id).is_some() { + return Err(ListingRefusal::InvalidTerms); + } + let Some(hold) = sink.hold(operation_id) else { + return Ok(()); + }; + if hold.listing.request.seller_guid != seller_guid { + return Err(ListingRefusal::InvalidTerms); + } + sink.release_hold(operation_id, listing_release_mail(&hold.listing)); + Ok(()) +} + #[derive(Clone, Debug, PartialEq, Eq)] struct ActiveAuction { id: u32, @@ -1317,6 +1350,11 @@ impl HoldSink for CtxSource<'_> { .operation_id() .delete(operation_id); } + + fn release_hold(&mut self, operation_id: u64, mail: AuctionMail) { + insert_auction_mail(self.ctx, mail); + self.delete_hold(operation_id); + } } struct CtxMarket<'a> { @@ -2000,6 +2038,19 @@ pub fn realm_auction_settle_listing(ctx: &ReducerContext, operation_id: u64) -> .map_err(|refusal| tagged(refusal, "listing Hold is not confirmed")) } +/// Sharded listing abort: after realm-core refuses phase 2, mail the held item and deposit back +/// to the seller and delete the Hold. Refused once the Hold has a receipt. +#[reducer] +pub fn gw_auction_release_listing_hold( + ctx: &ReducerContext, + operation_id: u64, + seller_guid: u64, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + release_listing(&mut CtxSource { ctx }, operation_id, seller_guid) + .map_err(|refusal| tagged(refusal, "listing Hold is confirmed")) +} + /// Single-database bid: full-offer Hold, realm decision, Auction update or buyout settlement, /// ordinary mail, and terminal source outcome commit atomically. #[reducer] @@ -2907,6 +2958,7 @@ mod tests { now_micros: i64, hold: Option, receipt: Option, + returned_mail: Vec, } impl ListingSource for FakeSource { @@ -2957,6 +3009,11 @@ mod tests { self.hold = None; } } + + fn release_hold(&mut self, operation_id: u64, mail: AuctionMail) { + self.returned_mail.push(mail); + self.delete_hold(operation_id); + } } #[derive(Clone)] @@ -2993,6 +3050,7 @@ mod tests { now_micros: 1_000, hold: None, receipt: None, + returned_mail: Vec::new(), } } @@ -3200,6 +3258,78 @@ mod tests { assert_eq!(market.auction_count, 1); } + #[test] + fn realm_refusal_releases_the_hold_and_mails_the_item_and_deposit_back() { + let mut source = source(); + let market = market(); + let request = request(); + fence_listing(&mut source, request).unwrap(); + assert_eq!(source.money, Some(40)); + assert!(source.item.is_none()); + + // Realm-core refused phase 2 (for example, its item catalogue lacks the template), so + // the market never took the listing and the source Hold is the only copy of the value. + assert!(market.receipt.is_none()); + assert_eq!( + release_listing(&mut source, request.operation_id, request.seller_guid + 1), + Err(ListingRefusal::InvalidTerms), + "a wrong seller never releases another character's Hold" + ); + assert!(source.hold.is_some()); + assert_eq!( + release_listing(&mut source, request.operation_id, request.seller_guid), + Ok(()) + ); + assert!(source.hold.is_none()); + assert_eq!( + source.returned_mail, + vec![AuctionMail { + recipient_guid: 7, + sender_guid: 0, + subject: "Auction listing refused", + money: 10, + item: item(23).snapshot, + }] + ); + + assert_eq!( + release_listing(&mut source, request.operation_id, request.seller_guid), + Ok(()), + "a replayed release finds no Hold and returns nothing twice" + ); + assert_eq!(source.returned_mail.len(), 1); + assert_eq!(market.auction_count, 0); + } + + #[test] + fn release_refuses_a_hold_that_backs_a_live_auction() { + let mut source = source(); + let mut market = market(); + let request = request(); + fence_listing(&mut source, request).unwrap(); + let hold = source.hold(request.operation_id).unwrap(); + let auction_id = commit_held_listing(&mut market, hold.listing.clone()).unwrap(); + confirm_listing( + &mut source, + ListingReceipt { + listing: hold.listing, + auction_id, + }, + ) + .unwrap(); + + assert_eq!( + release_listing(&mut source, request.operation_id, request.seller_guid), + Err(ListingRefusal::InvalidTerms) + ); + assert!(source.hold.is_some()); + assert!(source.returned_mail.is_empty()); + + assert_eq!(drive_sharded(&mut source, &mut market, request), Ok(41)); + assert!(source.hold.is_none()); + assert_eq!(market.auction_count, 1); + } + #[derive(Clone)] struct FakeExpiry { auction: Option, From 8e3f72c1d99114b393d724efc8edbe20fbc9625d Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Thu, 3 Sep 2026 21:24:07 +0200 Subject: [PATCH 2/4] fix(auction): refund refused listings on realm-core --- docs/schema.md | 6 +- gateway/src/stdb/bindings/mod.rs | 59 ++++ .../realm_auction_refund_listing_reducer.rs | 160 +++++++++++ gateway/src/stdb/reducers.rs | 58 +++- module/src/auction.rs | 258 +++++++++++++++--- 5 files changed, 503 insertions(+), 38 deletions(-) create mode 100644 gateway/src/stdb/bindings/realm_auction_refund_listing_reducer.rs diff --git a/docs/schema.md b/docs/schema.md index 33c2358c0..a2c81c3bf 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -276,8 +276,10 @@ house and economic policy through its imported faction. `game_auction` is the pu its item columns are the complete item-instance snapshot while no inventory row exists, and its house and rate columns preserve the listing-time policy. Private `game_auction_hold` is the source-shard value fence; private `game_auction_operation_receipt` makes listing retries idempotent after that Hold is deleted. -When realm-core refuses a held listing, `gw_auction_release_listing_hold` mails the held item and -deposit back to the seller and deletes the Hold; a Hold with a receipt is never released. +When realm-core refuses a held listing, it records a payload-matching `auction_id == 0` receipt and +mails the held item and deposit back to the seller in one transaction. Only then does +`gw_auction_release_listing_hold` delete the source Hold; a Hold with an active receipt is never +released. Private `game_auction_bid_hold` fences a bidder's complete offer and retains the terminal source outcome, normalized accepted price, and any purse-overflow refund awaiting relay; private `game_auction_bid_decision` is realm-core's serialized, replay-safe decision and exact-once diff --git a/gateway/src/stdb/bindings/mod.rs b/gateway/src/stdb/bindings/mod.rs index fb10d533f..40e61ab7b 100644 --- a/gateway/src/stdb/bindings/mod.rs +++ b/gateway/src/stdb/bindings/mod.rs @@ -786,6 +786,7 @@ pub mod realm_auction_commit_listing_reducer; pub mod realm_auction_confirm_listing_reducer; pub mod realm_auction_decide_bid_reducer; pub mod realm_auction_refund_bid_reducer; +pub mod realm_auction_refund_listing_reducer; pub mod realm_auction_settle_listing_reducer; pub mod realm_group_op_reducer; pub mod realm_loot_op_reducer; @@ -1748,6 +1749,7 @@ pub use realm_auction_commit_listing_reducer::realm_auction_commit_listing; pub use realm_auction_confirm_listing_reducer::realm_auction_confirm_listing; pub use realm_auction_decide_bid_reducer::realm_auction_decide_bid; pub use realm_auction_refund_bid_reducer::realm_auction_refund_bid; +pub use realm_auction_refund_listing_reducer::realm_auction_refund_listing; pub use realm_auction_settle_listing_reducer::realm_auction_settle_listing; pub use realm_group_op_reducer::realm_group_op; pub use realm_loot_op_reducer::realm_loot_op; @@ -3264,6 +3266,25 @@ pub enum Reducer { offer: u32, deferred_refund: u32, }, + RealmAuctionRefundListing { + operation_id: u64, + seller_guid: u64, + item_guid: u64, + item_entry: u32, + item_stack_count: u32, + item_durability: u32, + item_enchant_id: u32, + item_soulbound: bool, + house: u32, + deposit_rate: u32, + consignment_rate: u32, + start_bid: u32, + buyout: u32, + duration_minutes: u32, + deposit: u32, + created_micros: i64, + expires_micros: i64, + }, RealmAuctionSettleListing { operation_id: u64, }, @@ -3860,6 +3881,7 @@ impl __sdk::Reducer for Reducer { Reducer::RealmAuctionConfirmListing { .. } => "realm_auction_confirm_listing", Reducer::RealmAuctionDecideBid { .. } => "realm_auction_decide_bid", Reducer::RealmAuctionRefundBid { .. } => "realm_auction_refund_bid", + Reducer::RealmAuctionRefundListing { .. } => "realm_auction_refund_listing", Reducer::RealmAuctionSettleListing { .. } => "realm_auction_settle_listing", Reducer::RealmGroupOp { .. } => "realm_group_op", Reducer::RealmLootOp { .. } => "realm_loot_op", @@ -6284,6 +6306,43 @@ Reducer::PrepareVmapNavCoverage{ house: house.clone(), offer: offer.clone(), deferred_refund: deferred_refund.clone(), +}), + Reducer::RealmAuctionRefundListing{ + operation_id, + seller_guid, + item_guid, + item_entry, + item_stack_count, + item_durability, + item_enchant_id, + item_soulbound, + house, + deposit_rate, + consignment_rate, + start_bid, + buyout, + duration_minutes, + deposit, + created_micros, + expires_micros, +} => __sats::bsatn::to_vec(&realm_auction_refund_listing_reducer::RealmAuctionRefundListingArgs { + operation_id: operation_id.clone(), + seller_guid: seller_guid.clone(), + item_guid: item_guid.clone(), + item_entry: item_entry.clone(), + item_stack_count: item_stack_count.clone(), + item_durability: item_durability.clone(), + item_enchant_id: item_enchant_id.clone(), + item_soulbound: item_soulbound.clone(), + house: house.clone(), + deposit_rate: deposit_rate.clone(), + consignment_rate: consignment_rate.clone(), + start_bid: start_bid.clone(), + buyout: buyout.clone(), + duration_minutes: duration_minutes.clone(), + deposit: deposit.clone(), + created_micros: created_micros.clone(), + expires_micros: expires_micros.clone(), }), Reducer::RealmAuctionSettleListing{ operation_id, diff --git a/gateway/src/stdb/bindings/realm_auction_refund_listing_reducer.rs b/gateway/src/stdb/bindings/realm_auction_refund_listing_reducer.rs new file mode 100644 index 000000000..dcd4c5bb4 --- /dev/null +++ b/gateway/src/stdb/bindings/realm_auction_refund_listing_reducer.rs @@ -0,0 +1,160 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct RealmAuctionRefundListingArgs { + pub operation_id: u64, + pub seller_guid: u64, + pub item_guid: u64, + pub item_entry: u32, + pub item_stack_count: u32, + pub item_durability: u32, + pub item_enchant_id: u32, + pub item_soulbound: bool, + pub house: u32, + pub deposit_rate: u32, + pub consignment_rate: u32, + pub start_bid: u32, + pub buyout: u32, + pub duration_minutes: u32, + pub deposit: u32, + pub created_micros: i64, + pub expires_micros: i64, +} + +impl From for super::Reducer { + fn from(args: RealmAuctionRefundListingArgs) -> Self { + Self::RealmAuctionRefundListing { + operation_id: args.operation_id, + seller_guid: args.seller_guid, + item_guid: args.item_guid, + item_entry: args.item_entry, + item_stack_count: args.item_stack_count, + item_durability: args.item_durability, + item_enchant_id: args.item_enchant_id, + item_soulbound: args.item_soulbound, + house: args.house, + deposit_rate: args.deposit_rate, + consignment_rate: args.consignment_rate, + start_bid: args.start_bid, + buyout: args.buyout, + duration_minutes: args.duration_minutes, + deposit: args.deposit, + created_micros: args.created_micros, + expires_micros: args.expires_micros, + } + } +} + +impl __sdk::InModule for RealmAuctionRefundListingArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +pub trait realm_auction_refund_listing { + fn realm_auction_refund_listing( + &self, + operation_id: u64, + seller_guid: u64, + item_guid: u64, + item_entry: u32, + item_stack_count: u32, + item_durability: u32, + item_enchant_id: u32, + item_soulbound: bool, + house: u32, + deposit_rate: u32, + consignment_rate: u32, + start_bid: u32, + buyout: u32, + duration_minutes: u32, + deposit: u32, + created_micros: i64, + expires_micros: i64, + ) -> __sdk::Result<()> { + self.realm_auction_refund_listing_then( + operation_id, seller_guid, item_guid, item_entry, item_stack_count, item_durability, + item_enchant_id, item_soulbound, house, deposit_rate, consignment_rate, start_bid, + buyout, duration_minutes, deposit, created_micros, expires_micros, |_, _| {}, + ) + } + + #[allow(clippy::too_many_arguments)] + fn realm_auction_refund_listing_then( + &self, + operation_id: u64, + seller_guid: u64, + item_guid: u64, + item_entry: u32, + item_stack_count: u32, + item_durability: u32, + item_enchant_id: u32, + item_soulbound: bool, + house: u32, + deposit_rate: u32, + consignment_rate: u32, + start_bid: u32, + buyout: u32, + duration_minutes: u32, + deposit: u32, + created_micros: i64, + expires_micros: i64, + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl realm_auction_refund_listing for super::RemoteReducers { + #[allow(clippy::too_many_arguments)] + fn realm_auction_refund_listing_then( + &self, + operation_id: u64, + seller_guid: u64, + item_guid: u64, + item_entry: u32, + item_stack_count: u32, + item_durability: u32, + item_enchant_id: u32, + item_soulbound: bool, + house: u32, + deposit_rate: u32, + consignment_rate: u32, + start_bid: u32, + buyout: u32, + duration_minutes: u32, + deposit: u32, + created_micros: i64, + expires_micros: i64, + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp.invoke_reducer_with_callback( + RealmAuctionRefundListingArgs { + operation_id, + seller_guid, + item_guid, + item_entry, + item_stack_count, + item_durability, + item_enchant_id, + item_soulbound, + house, + deposit_rate, + consignment_rate, + start_bid, + buyout, + duration_minutes, + deposit, + created_micros, + expires_micros, + }, + callback, + ) + } +} diff --git a/gateway/src/stdb/reducers.rs b/gateway/src/stdb/reducers.rs index 429550492..4b696ae0b 100644 --- a/gateway/src/stdb/reducers.rs +++ b/gateway/src/stdb/reducers.rs @@ -297,6 +297,11 @@ impl Coordinator { if reducer_refusal_reason(&error).is_some() && !realm.auction_receipt_is_visible(operation_id) { + // Mail is authoritative on Realm-core. Commit its idempotent refund receipt and + // the exact returned value there before deleting the source Hold. If either call + // is interrupted, the Hold remains recovery evidence and the next replay resumes + // from the same operation id. + realm.auction_refund_listing(&hold)?; self.auction_release_listing_hold(&hold)?; } return Err(error); @@ -392,6 +397,32 @@ impl Coordinator { ) } + fn auction_refund_listing(&self, hold: &AuctionHold) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "realm_auction_refund_listing", + realm_auction_refund_listing_then( + hold.operation_id, + hold.seller_guid, + hold.item_guid, + hold.item_entry, + hold.item_stack_count, + hold.item_durability, + hold.item_enchant_id, + hold.item_soulbound, + hold.house, + hold.deposit_rate, + hold.consignment_rate, + hold.start_bid, + hold.buyout, + hold.duration_minutes, + hold.deposit, + hold.created_micros, + hold.expires_micros + ) + ) + } + fn auction_release_listing_hold(&self, hold: &AuctionHold) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, @@ -408,7 +439,7 @@ impl Coordinator { .game_auction_operation_receipt() .operation_id() .find(&operation_id) - .is_some() + .is_some_and(|receipt| receipt.auction_id != 0) } fn matching_auction_hold( @@ -481,6 +512,7 @@ impl Coordinator { .game_auction_operation_receipt() .operation_id() .find(&operation_id) + .filter(|receipt| receipt.auction_id != 0) }) } @@ -3232,6 +3264,30 @@ mod auction_reducer_tests { )); } + #[test] + fn refused_listing_refund_commits_on_realm_core_before_the_home_hold_is_deleted() { + let drive = crate::test_scan::code_of( + include_str!("reducers.rs"), + "fn drive_sharded_auction_listing(", + ); + let refund = "realm.auction_refund_listing(&hold)?;"; + let release = "self.auction_release_listing_hold(&hold)?;"; + let refund_at = drive + .find(refund) + .expect("the Realm-core handle must commit the refused listing Mail"); + let release_at = drive + .find(release) + .expect("the Home Shard must delete the Hold after that commit"); + assert!( + refund_at < release_at, + "a source-Hold delete before Realm-core Mail commit loses the only listing value" + ); + assert!( + !drive.contains("self.auction_refund_listing(&hold)?;"), + "the Home Shard does not own Mail in a sharded realm" + ); + } + #[test] fn normalized_buyout_price_drives_the_exact_success_command_value() { let hold = AuctionBidHold { diff --git a/module/src/auction.rs b/module/src/auction.rs index 894f6cd71..e7566fbf0 100644 --- a/module/src/auction.rs +++ b/module/src/auction.rs @@ -55,9 +55,10 @@ pub struct Auction { pub consignment_rate: u32, } -/// Source-shard value reserved by a sharded listing operation. A matching operation receipt makes +/// Source-shard value reserved by a sharded listing operation. An active operation receipt makes /// the row recovery evidence rather than spendable value; it is deleted only after that evidence -/// is durably copied back to the source. +/// is durably copied back to the source. A refused listing instead keeps this Hold until +/// Realm-core durably commits its refund Mail and zero-id receipt. #[table( accessor = game_auction_hold, index(accessor = by_seller, btree(columns = [seller_guid])) @@ -84,7 +85,8 @@ pub struct AuctionHold { } /// Durable idempotency receipt. The full listing payload makes identical replay distinguishable -/// from conflicting reuse even after the source Hold has been deleted. +/// from conflicting reuse even after the source Hold has been deleted. Realm-core alone uses +/// `auction_id == 0` as a refused-listing refund receipt; that sentinel never reaches the source. #[table( accessor = game_auction_operation_receipt, index(accessor = by_actor, btree(columns = [actor_guid])) @@ -357,6 +359,12 @@ struct ListingHold { listing: PreparedListing, } +#[derive(Clone, Debug, PartialEq, Eq)] +struct ListingRefund { + listing: PreparedListing, + mail: AuctionMail, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum OperationMatch { Replay(u32), @@ -879,7 +887,7 @@ trait LocalListingSink: ListingSource { fn operation_match(sink: &S, request: ListingRequest) -> OperationMatch { match sink.receipt(request.operation_id) { None => OperationMatch::Fresh, - Some(receipt) if receipt.listing.request == request => { + Some(receipt) if receipt.auction_id != 0 && receipt.listing.request == request => { OperationMatch::Replay(receipt.auction_id) } Some(_) => OperationMatch::Conflict, @@ -937,7 +945,11 @@ trait HoldSink: ListingSource { fn commit_hold(&mut self, hold: ListingHold); fn confirm_hold(&mut self, receipt: ListingReceipt); fn delete_hold(&mut self, operation_id: u64); - fn release_hold(&mut self, operation_id: u64, mail: AuctionMail); +} + +trait ListingRefundSink { + fn refund(&self, operation_id: u64) -> Result, ListingRefusal>; + fn commit_refund(&mut self, refund: ListingRefund); } trait MarketSink { @@ -947,7 +959,7 @@ trait MarketSink { fn fence_listing(sink: &mut S, request: ListingRequest) -> Result<(), ListingRefusal> { if let Some(receipt) = sink.receipt(request.operation_id) { - return if receipt.listing.request == request { + return if receipt.auction_id != 0 && receipt.listing.request == request { Ok(()) } else { Err(ListingRefusal::InvalidTerms) @@ -971,7 +983,11 @@ fn commit_held_listing( ) -> Result { if let Some(receipt) = sink.receipt(listing.request.operation_id) { return if receipt.listing == listing { - Ok(receipt.auction_id) + if receipt.auction_id == 0 { + Err(ListingRefusal::InvalidTerms) + } else { + Ok(receipt.auction_id) + } } else { Err(ListingRefusal::InvalidTerms) }; @@ -983,6 +999,9 @@ fn confirm_listing( sink: &mut S, receipt: ListingReceipt, ) -> Result<(), ListingRefusal> { + if receipt.auction_id == 0 { + return Err(ListingRefusal::InvalidTerms); + } if let Some(existing) = sink.receipt(receipt.listing.request.operation_id) { return if existing == receipt { Ok(()) @@ -1004,6 +1023,9 @@ fn settle_listing(sink: &mut S, operation_id: u64) -> Result<(), Li let receipt = sink .receipt(operation_id) .ok_or(ListingRefusal::InvalidTerms)?; + if receipt.auction_id == 0 { + return Err(ListingRefusal::InvalidTerms); + } match sink.hold(operation_id) { None => Ok(()), Some(hold) if hold.listing == receipt.listing => { @@ -1026,6 +1048,27 @@ fn listing_release_mail(listing: &PreparedListing) -> AuctionMail { } } +fn listing_refund(listing: &PreparedListing) -> ListingRefund { + ListingRefund { + listing: listing.clone(), + mail: listing_release_mail(listing), + } +} + +fn refund_listing( + sink: &mut S, + refund: ListingRefund, +) -> Result<(), ListingRefusal> { + match sink.refund(refund.listing.request.operation_id)? { + Some(existing) if existing == refund => Ok(()), + Some(_) => Err(ListingRefusal::InvalidTerms), + None => { + sink.commit_refund(refund); + Ok(()) + } + } +} + /// Give a Hold's value back after realm-core refused its listing. A Hold with a matching receipt /// backs a live Auction and is never released; a missing Hold is a replay. fn release_listing( @@ -1042,7 +1085,7 @@ fn release_listing( if hold.listing.request.seller_guid != seller_guid { return Err(ListingRefusal::InvalidTerms); } - sink.release_hold(operation_id, listing_release_mail(&hold.listing)); + sink.delete_hold(operation_id); Ok(()) } @@ -1350,17 +1393,42 @@ impl HoldSink for CtxSource<'_> { .operation_id() .delete(operation_id); } - - fn release_hold(&mut self, operation_id: u64, mail: AuctionMail) { - insert_auction_mail(self.ctx, mail); - self.delete_hold(operation_id); - } } struct CtxMarket<'a> { ctx: &'a ReducerContext, } +struct CtxListingRefund<'a> { + ctx: &'a ReducerContext, +} + +impl ListingRefundSink for CtxListingRefund<'_> { + fn refund(&self, operation_id: u64) -> Result, ListingRefusal> { + let row = self + .ctx + .db + .game_auction_operation_receipt() + .operation_id() + .find(operation_id); + match row { + None => Ok(None), + Some(row) if row.auction_id == 0 => { + Ok(Some(listing_refund(&listing_from_receipt(row).listing))) + } + Some(_) => Err(ListingRefusal::InvalidTerms), + } + } + + fn commit_refund(&mut self, refund: ListingRefund) { + insert_auction_mail(self.ctx, refund.mail.clone()); + self.ctx + .db + .game_auction_operation_receipt() + .insert(receipt_from_listing(refund.listing, 0)); + } +} + impl MarketSink for CtxMarket<'_> { fn receipt(&self, operation_id: u64) -> Option { self.ctx @@ -2038,8 +2106,59 @@ pub fn realm_auction_settle_listing(ctx: &ReducerContext, operation_id: u64) -> .map_err(|refusal| tagged(refusal, "listing Hold is not confirmed")) } +/// Sharded listing abort phase 3: Realm-core commits the seller's exact return Mail and durable +/// refund receipt together. Replays with the same payload do not create a second Mail. +#[reducer] +#[allow(clippy::too_many_arguments)] // The persisted refund receipt's value columns. +pub fn realm_auction_refund_listing( + ctx: &ReducerContext, + operation_id: u64, + seller_guid: u64, + item_guid: u64, + item_entry: u32, + item_stack_count: u32, + item_durability: u32, + item_enchant_id: u32, + item_soulbound: bool, + house: u32, + deposit_rate: u32, + consignment_rate: u32, + start_bid: u32, + buyout: u32, + duration_minutes: u32, + deposit: u32, + created_micros: i64, + expires_micros: i64, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + refund_listing( + &mut CtxListingRefund { ctx }, + listing_refund(&listing_from_hold(AuctionHold { + operation_id, + seller_guid, + item_guid, + item_entry, + item_stack_count, + item_durability, + item_enchant_id, + item_soulbound, + house, + deposit_rate, + consignment_rate, + start_bid, + buyout, + duration_minutes, + deposit, + created_micros, + expires_micros, + })), + ) + .map_err(|refusal| tagged(refusal, "listing refund conflict")) +} + /// Sharded listing abort: after realm-core refuses phase 2, mail the held item and deposit back -/// to the seller and delete the Hold. Refused once the Hold has a receipt. +/// to the seller and delete the Hold. The gateway calls this only after Realm-core has committed +/// the matching refund receipt and Mail. Refused once the Hold has a receipt. #[reducer] pub fn gw_auction_release_listing_hold( ctx: &ReducerContext, @@ -2958,7 +3077,6 @@ mod tests { now_micros: i64, hold: Option, receipt: Option, - returned_mail: Vec, } impl ListingSource for FakeSource { @@ -3009,10 +3127,26 @@ mod tests { self.hold = None; } } + } + + #[derive(Clone, Default)] + struct FakeRefundCore { + refund: Option, + mails: Vec, + } - fn release_hold(&mut self, operation_id: u64, mail: AuctionMail) { - self.returned_mail.push(mail); - self.delete_hold(operation_id); + impl ListingRefundSink for FakeRefundCore { + fn refund(&self, operation_id: u64) -> Result, ListingRefusal> { + Ok(self + .refund + .as_ref() + .filter(|refund| refund.listing.request.operation_id == operation_id) + .cloned()) + } + + fn commit_refund(&mut self, refund: ListingRefund) { + self.mails.push(refund.mail.clone()); + self.refund = Some(refund); } } @@ -3050,7 +3184,6 @@ mod tests { now_micros: 1_000, hold: None, receipt: None, - returned_mail: Vec::new(), } } @@ -3261,7 +3394,7 @@ mod tests { #[test] fn realm_refusal_releases_the_hold_and_mails_the_item_and_deposit_back() { let mut source = source(); - let market = market(); + let mut realm = FakeRefundCore::default(); let request = request(); fence_listing(&mut source, request).unwrap(); assert_eq!(source.money, Some(40)); @@ -3269,7 +3402,19 @@ mod tests { // Realm-core refused phase 2 (for example, its item catalogue lacks the template), so // the market never took the listing and the source Hold is the only copy of the value. - assert!(market.receipt.is_none()); + // The refund Mail and its receipt are written on Realm-core before its source Hold moves. + let refund = listing_refund(&source.hold(request.operation_id).unwrap().listing); + assert_eq!(refund_listing(&mut realm, refund.clone()), Ok(())); + assert_eq!(realm.mails, vec![refund.mail]); + assert!(source.hold.is_some()); + let mut changed_refund = refund.clone(); + changed_refund.listing.snapshot.durability += 1; + assert_eq!( + refund_listing(&mut realm, changed_refund), + Err(ListingRefusal::InvalidTerms), + "the durable refund receipt refuses conflicting operation-id reuse" + ); + assert_eq!(realm.mails.len(), 1); assert_eq!( release_listing(&mut source, request.operation_id, request.seller_guid + 1), Err(ListingRefusal::InvalidTerms), @@ -3281,24 +3426,69 @@ mod tests { Ok(()) ); assert!(source.hold.is_none()); - assert_eq!( - source.returned_mail, - vec![AuctionMail { - recipient_guid: 7, - sender_guid: 0, - subject: "Auction listing refused", - money: 10, - item: item(23).snapshot, - }] - ); + // A gateway crash after Realm-core commits but before source cleanup replays one Mail. + assert_eq!(refund_listing(&mut realm, refund), Ok(())); + assert_eq!(realm.mails.len(), 1); assert_eq!( release_listing(&mut source, request.operation_id, request.seller_guid), Ok(()), "a replayed release finds no Hold and returns nothing twice" ); - assert_eq!(source.returned_mail.len(), 1); - assert_eq!(market.auction_count, 0); + assert_eq!(realm.mails.len(), 1); + } + + #[test] + fn refund_receipt_replays_as_a_refusal_and_never_confirms_the_source_hold() { + let mut source = source(); + let mut realm = market(); + let request = request(); + fence_listing(&mut source, request).unwrap(); + let listing = source.hold(request.operation_id).unwrap().listing; + realm.receipt = Some(ListingReceipt { + listing: listing.clone(), + auction_id: 0, + }); + + assert_eq!( + commit_held_listing(&mut realm, listing.clone()), + Err(ListingRefusal::InvalidTerms), + "a Realm-core refund receipt is not an Auction" + ); + let mut local = local(); + local.committed = Some((listing.clone(), 0)); + assert_eq!( + operation_match(&local, listing.request), + OperationMatch::Conflict, + "a sentinel is never a local-listing replay" + ); + source.receipt = Some(ListingReceipt { + listing: listing.clone(), + auction_id: 0, + }); + assert_eq!( + fence_listing(&mut source, listing.request), + Err(ListingRefusal::InvalidTerms), + "a misrouted sentinel must fail closed on the source" + ); + assert_eq!( + confirm_listing( + &mut source, + ListingReceipt { + listing, + auction_id: 0, + }, + ), + Err(ListingRefusal::InvalidTerms), + "the sentinel must never cross back to the source" + ); + assert_eq!( + settle_listing(&mut source, request.operation_id), + Err(ListingRefusal::InvalidTerms), + "source cleanup may not treat a sentinel as receipt evidence" + ); + assert!(source.hold.is_some()); + assert_eq!(source.receipt.as_ref().map(|receipt| receipt.auction_id), Some(0)); } #[test] @@ -3323,8 +3513,6 @@ mod tests { Err(ListingRefusal::InvalidTerms) ); assert!(source.hold.is_some()); - assert!(source.returned_mail.is_empty()); - assert_eq!(drive_sharded(&mut source, &mut market, request), Ok(41)); assert!(source.hold.is_none()); assert_eq!(market.auction_count, 1); From ce76c7476c5a99cbace41b5574b232342bc61c83 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Thu, 3 Sep 2026 21:28:11 +0200 Subject: [PATCH 3/4] test(auction): pin refund reducer binding shape --- .../realm_auction_refund_listing_reducer.rs | 40 ++++++++++++++++--- gateway/src/stdb/reducers.rs | 12 ++++++ 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/gateway/src/stdb/bindings/realm_auction_refund_listing_reducer.rs b/gateway/src/stdb/bindings/realm_auction_refund_listing_reducer.rs index dcd4c5bb4..b914302aa 100644 --- a/gateway/src/stdb/bindings/realm_auction_refund_listing_reducer.rs +++ b/gateway/src/stdb/bindings/realm_auction_refund_listing_reducer.rs @@ -55,7 +55,16 @@ impl __sdk::InModule for RealmAuctionRefundListingArgs { } #[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `realm_auction_refund_listing`. +/// +/// Implemented for [`super::RemoteReducers`]. pub trait realm_auction_refund_listing { + /// Request that the remote module invoke the reducer `realm_auction_refund_listing` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`realm_auction_refund_listing:realm_auction_refund_listing_then`] to run a callback after the reducer completes. fn realm_auction_refund_listing( &self, operation_id: u64, @@ -77,13 +86,33 @@ pub trait realm_auction_refund_listing { expires_micros: i64, ) -> __sdk::Result<()> { self.realm_auction_refund_listing_then( - operation_id, seller_guid, item_guid, item_entry, item_stack_count, item_durability, - item_enchant_id, item_soulbound, house, deposit_rate, consignment_rate, start_bid, - buyout, duration_minutes, deposit, created_micros, expires_micros, |_, _| {}, + operation_id, + seller_guid, + item_guid, + item_entry, + item_stack_count, + item_durability, + item_enchant_id, + item_soulbound, + house, + deposit_rate, + consignment_rate, + start_bid, + buyout, + duration_minutes, + deposit, + created_micros, + expires_micros, + |_, _| {}, ) } - #[allow(clippy::too_many_arguments)] + /// Request that the remote module invoke the reducer `realm_auction_refund_listing` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. fn realm_auction_refund_listing_then( &self, operation_id: u64, @@ -103,6 +132,7 @@ pub trait realm_auction_refund_listing { deposit: u32, created_micros: i64, expires_micros: i64, + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + Send + 'static, @@ -110,7 +140,6 @@ pub trait realm_auction_refund_listing { } impl realm_auction_refund_listing for super::RemoteReducers { - #[allow(clippy::too_many_arguments)] fn realm_auction_refund_listing_then( &self, operation_id: u64, @@ -130,6 +159,7 @@ impl realm_auction_refund_listing for super::RemoteReducers { deposit: u32, created_micros: i64, expires_micros: i64, + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + Send + 'static, diff --git a/gateway/src/stdb/reducers.rs b/gateway/src/stdb/reducers.rs index 4b696ae0b..7b98fbee6 100644 --- a/gateway/src/stdb/reducers.rs +++ b/gateway/src/stdb/reducers.rs @@ -3288,6 +3288,18 @@ mod auction_reducer_tests { ); } + #[test] + fn refused_listing_binding_matches_the_generated_commit_listing_shape() { + let expected = include_str!("bindings/realm_auction_commit_listing_reducer.rs") + .replace("RealmAuctionCommitListing", "RealmAuctionRefundListing") + .replace("realm_auction_commit_listing", "realm_auction_refund_listing"); + assert_eq!( + include_str!("bindings/realm_auction_refund_listing_reducer.rs"), + expected, + "the hand-added reducer binding must remain generator-identical" + ); + } + #[test] fn normalized_buyout_price_drives_the_exact_success_command_value() { let hold = AuctionBidHold { From af20bbc2f4533b5b78dbce7e25d1db4ccf9e9193 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Thu, 3 Sep 2026 21:37:38 +0200 Subject: [PATCH 4/4] fix(auction): remove redundant refund mail clone --- module/src/auction.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/src/auction.rs b/module/src/auction.rs index e7566fbf0..174f34bd8 100644 --- a/module/src/auction.rs +++ b/module/src/auction.rs @@ -1421,7 +1421,7 @@ impl ListingRefundSink for CtxListingRefund<'_> { } fn commit_refund(&mut self, refund: ListingRefund) { - insert_auction_mail(self.ctx, refund.mail.clone()); + insert_auction_mail(self.ctx, refund.mail); self.ctx .db .game_auction_operation_receipt()