From 3e898b035c4118a53f77957b8c842c67f6e6a53b Mon Sep 17 00:00:00 2001 From: Rigidity Date: Tue, 27 Oct 2026 18:43:21 -0400 Subject: [PATCH 1/3] Add seller cancel action before the first bid --- README.md | 11 ++ puzzles/actions/cancel_action.rue | 51 +++++++++ puzzles/actions/cancel_action.rue.hash | 1 + puzzles/actions/cancel_action.rue.hex | 9 ++ src/actions.rs | 2 + src/actions/cancel.rs | 33 ++++++ src/auction.rs | 37 ++++++- src/info.rs | 34 +++++- tests/cancel.rs | 139 +++++++++++++++++++++++++ tests/common/mod.rs | 47 +++++++++ tests/puzzles.rs | 1 + 11 files changed, 357 insertions(+), 8 deletions(-) create mode 100644 puzzles/actions/cancel_action.rue create mode 100644 puzzles/actions/cancel_action.rue.hash create mode 100644 puzzles/actions/cancel_action.rue.hex create mode 100644 src/actions/cancel.rs create mode 100644 tests/cancel.rs diff --git a/README.md b/README.md index 280f814..bbf7d24 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ The life cycle of an auction is as follows: 2. Until the auction is ended, anyone can place a bid as long as their bid is strictly higher than the current winning bid and meets certain requirements which can be specified by the auctioneer. The bidder locks up their bid plus the buyer's premium and NFT royalty, and the previous bidder is refunded everything they locked up. Multiple bids can be placed in a single spend. 3. When the auction is ended, the NFT is sent to the highest bidder, the auctioneer receives the final bid amount minus the commission, and the buyer's premium, commission, and NFT royalty are paid out. If there were no bids, the NFT is sent back to the auctioneer. An auction can only be ended once, and no bids can be placed after it's ended. +Until the first bid is placed, the auctioneer can also cancel the auction at any time, which sends the NFT back to them just like ending it with no bids. Once there's a bid, the auctioneer is committed and the auction can only be ended. A cancelled auction is ended, so it can't be bid on, ended, or cancelled again. + ## Customization The following parameters can be customized by the auctioneer: @@ -26,8 +28,16 @@ These can also be customized, but they may be enforced to be certain values by t - The buyer's premium - The commission +The auctioneer can always cancel the auction before the first bid, regardless of these settings. The cancel is authorized by the payout address, so whoever controls it can cancel. + Both the buyer's premium and commission are percentages, and are sent to a single address. If these need to be split between multiple addresses, you should use something external to do so such as the [royalty split](https://splitxch.com/) primitive. +## Cancelling + +The cancel action asserts that the auction hasn't ended and has no bids, and has no timing restrictions. It must be authorized by a coin at the payout address, which sends the message `cancel` to the current auction coin with the `SENDER_PUZZLE | RECEIVER_COIN` mode (`0x17`). Since the message is sent to a specific auction coin, it can't be reused once the auction has been spent. `AuctionExt::cancel_authorization` returns the condition that coin must output, and `AuctionExt::spend_cancel_action` builds the action, which must be spent along with `AuctionExt::unlock_nft` in the same spend bundle. + +A cancel spends the auction coin, so it conflicts with a pending first bid in the mempool, and whichever confirms first wins. The auctioneer can't replace a pending bid with a cancel, since the bidder's coin asserts the bid's announcement. + ## Fees and Royalties All fees are specified in basis points (1/100th of a percent), and are rounded down. For a winning bid of `bid`: @@ -75,6 +85,7 @@ Near the end time, this means that a bid landing in the last block can't be answ ## Trust Assumptions - **Revocable CATs:** The issuer of a revocable CAT can spend any coin of that CAT, including the reserve. If they revoke the reserve during an auction, they take the current winning bid, and the auction can never be ended, so the NFT is locked forever. Wallets should make it clear that the issuer is trusted when an auction's reserve is a revocable CAT. +- **Payout address:** The payout address authorizes cancelling the auction before the first bid. If it's a puzzle that others can spend (or send messages from), they can cancel the auction too, which only returns the NFT to the payout address. - **Grace period flag:** Each bidder's coin asserts an announcement of their bid (its amount and puzzle hash), but not whether it was placed using the grace period. Anyone relaying a pending bid can change that flag, which only changes when the bid is allowed to land. The bidder still pays exactly what they bid. ## Development diff --git a/puzzles/actions/cancel_action.rue b/puzzles/actions/cancel_action.rue new file mode 100644 index 0000000..d4bb98a --- /dev/null +++ b/puzzles/actions/cancel_action.rue @@ -0,0 +1,51 @@ +import super::shared::*; + +// The message the payout puzzle must send to the auction coin to authorize a cancel. Since the +// message is sent to a specific auction coin, it doesn't need to commit to anything else. +inline const CANCEL_MESSAGE: Bytes = "cancel"; + +struct Solution { + ...unlocker_solution: Any, +} + +fn main( + UNLOCKER: fn(Truth: Bid, ...solution: Any) -> List, + PAYOUT_PUZZLE_HASH: Bytes32, + NFT_COIN_ID: Bytes32, + Truth: AuctionStateTruth, + ...{ unlocker_solution }: Solution, +) -> Output { + // The seller can only cancel before the first bid, since bidders lock up funds expecting the + // auction to run until its end. With no bids, the reserve is empty and there's nothing to pay. + assert !Truth.state.ended & (Truth.state.winning_bid.amount == 0); + + // With no bids, the winning bid is the initial one, which sends the NFT back to the seller + let unlock_conditions = UNLOCKER(Truth.state.winning_bid, ...unlocker_solution); + + Output { + new_truth: AuctionStateTruth { + state: AuctionState { + reserve_amount: 0, + ended: true, + winning_bid: Truth.state.winning_bid, + }, + ephemeral_state: Truth.ephemeral_state, + }, + conditions: [ + // Only the seller can cancel, by spending a coin at the payout address which sends the + // cancel message to this auction coin + ReceiveMessage { + mode: SENDER_PUZZLE | RECEIVER_COIN, + message: CANCEL_MESSAGE, + sender: [PAYOUT_PUZZLE_HASH], + }, + + // Authorize the NFT to be sent back to the seller + SendMessage { + mode: SENDER_PUZZLE | RECEIVER_COIN, + receiver: [NFT_COIN_ID], + message: tree_hash((1, unlock_conditions)), + }, + ], + } +} diff --git a/puzzles/actions/cancel_action.rue.hash b/puzzles/actions/cancel_action.rue.hash new file mode 100644 index 0000000..b6b4de1 --- /dev/null +++ b/puzzles/actions/cancel_action.rue.hash @@ -0,0 +1 @@ +4032a7a843ba2651f89fc53cac412028a730fdf92588b270cb6ffd973ed74e9e diff --git a/puzzles/actions/cancel_action.rue.hex b/puzzles/actions/cancel_action.rue.hex new file mode 100644 index 0000000..8b0fc6f --- /dev/null +++ b/puzzles/actions/cancel_action.rue.hex @@ -0,0 +1,9 @@ +ff02ffff01ff02ffff03ffff22ffff20ff82016f80ffff20ff8202ef8080ffff +01ff04ffff04ff4fffff04ff80ffff04ffff0101ff8201ef808080ffff04ffff +04ffff0143ffff04ffff0117ffff04ffff018663616e63656cffff04ff0bff80 +80808080ffff04ffff04ffff0142ffff04ffff0117ffff04ffff02ff02ffff04 +ff02ffff04ffff0101ffff02ff05ffff04ff8201efff3f8080808080ffff04ff +17ff8080808080ff80808080ffff01ff088080ff0180ffff04ffff01ff02ffff +03ffff07ff0380ffff01ff0bffff0102ffff02ff02ffff04ff02ff058080ffff +02ff02ffff04ff02ff07808080ffff01ff0bffff0101ff038080ff0180ff0180 +80 diff --git a/src/actions.rs b/src/actions.rs index a3cfa16..0d37f59 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -1,5 +1,7 @@ mod bid; +mod cancel; mod end; pub use bid::*; +pub use cancel::*; pub use end::*; diff --git a/src/actions/cancel.rs b/src/actions/cancel.rs new file mode 100644 index 0000000..78beb66 --- /dev/null +++ b/src/actions/cancel.rs @@ -0,0 +1,33 @@ +use chia_wallet_sdk::prelude::*; + +use crate::include_puzzle; + +/// The message the auction's payout puzzle must send to the auction coin to authorize a cancel. +pub const CANCEL_MESSAGE: &[u8] = b"cancel"; + +/// The message mode used to authorize a cancel, which commits to the sender's puzzle hash and the +/// receiving auction coin. +pub const CANCEL_MESSAGE_MODE: u8 = 0b010_111; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ToClvm, FromClvm)] +#[clvm(curry)] +pub struct CancelActionArgs { + pub unlocker: U, + pub payout_puzzle_hash: Bytes32, + pub nft_coin_id: Bytes32, +} + +impl CancelActionArgs { + pub fn new(unlocker: U, payout_puzzle_hash: Bytes32, nft_coin_id: Bytes32) -> Self { + Self { + unlocker, + payout_puzzle_hash, + nft_coin_id, + } + } +} + +include_puzzle!( + CancelActionArgs = CANCEL_ACTION, + "puzzles/actions/cancel_action.rue" +); diff --git a/src/auction.rs b/src/auction.rs index f898a61..09b61b9 100644 --- a/src/auction.rs +++ b/src/auction.rs @@ -15,7 +15,7 @@ use chia_wallet_sdk::{ use crate::{ AUCTION_SINGLETON_AMOUNT, AuctionInfo, AuctionReserve, AuctionState, Bid, BidActionSolution, - NftUnlockerSolution, spend_auction_lock, + CANCEL_MESSAGE, CANCEL_MESSAGE_MODE, NftUnlockerSolution, spend_auction_lock, }; pub type Auction = Singleton; @@ -43,8 +43,20 @@ pub trait AuctionExt: Sized { /// [`unlock_nft`](AuctionExt::unlock_nft) in the same spend bundle. fn spend_end_action(&self, ctx: &mut SpendContext, nft: &Nft) -> Result; - /// Spends the locked NFT to the winning bidder. This must be included in the same spend bundle as - /// the end action, and `self` must be the auction before it's ended. + /// Cancels the auction before the first bid, sending the NFT back to the payout address. `nft` is + /// the locked NFT, which must be unlocked with [`unlock_nft`](AuctionExt::unlock_nft) in the same + /// spend bundle. A coin at the payout address must also output the conditions from + /// [`cancel_authorization`](AuctionExt::cancel_authorization) in the same spend bundle. + fn spend_cancel_action(&self, ctx: &mut SpendContext, nft: &Nft) -> Result; + + /// The conditions that a coin at the auction's payout address must output to authorize + /// cancelling this auction coin. They only authorize a cancel of this exact coin, so they can't + /// be reused once the auction has been spent. + fn cancel_authorization(&self, ctx: &mut SpendContext) -> Result; + + /// Spends the locked NFT to the winning bidder, or back to the payout address if there are no + /// bids. This must be included in the same spend bundle as the end or cancel action, and `self` + /// must be the auction before it's ended or cancelled. fn unlock_nft(&self, ctx: &mut SpendContext, nft: &Nft) -> Result; /// Runs the action puzzles to determine the next state and the reserve coin's conditions, exactly @@ -87,6 +99,22 @@ impl AuctionExt for Auction { Ok(Spend::new(puzzle, solution)) } + fn spend_cancel_action(&self, ctx: &mut SpendContext, nft: &Nft) -> Result { + let puzzle = self.info.cancel_action(ctx)?; + // Like the end action, the cancel action passes its solution to the unlocker + let solution = ctx.alloc(&clvm_list!(nft.coin.amount))?; + Ok(Spend::new(puzzle, solution)) + } + + fn cancel_authorization(&self, ctx: &mut SpendContext) -> Result { + let receiver = ctx.alloc(&self.coin.coin_id())?; + Ok(Conditions::new().send_message( + CANCEL_MESSAGE_MODE, + CANCEL_MESSAGE.to_vec().into(), + vec![receiver], + )) + } + fn unlock_nft(&self, ctx: &mut SpendContext, nft: &Nft) -> Result { let unlocker = self.info.nft_unlocker(ctx)?; let unlocker_solution = ctx.alloc(&NftUnlockerSolution::new( @@ -133,7 +161,8 @@ impl AuctionExt for Auction { } } - // Only the end action pays the royalty. A bid refund can also be sent to the settlement + // Only the end action pays the royalty, and the cancel action never creates any reserve + // conditions. A bid refund can also be sent to the settlement // puzzle hash, if that's the puzzle hash the bidder chose. if new_state.ended && !state.ended { for &condition in &action_reserve_conditions { diff --git a/src/info.rs b/src/info.rs index 22741e7..a37130a 100644 --- a/src/info.rs +++ b/src/info.rs @@ -9,8 +9,8 @@ use chia_wallet_sdk::{ use crate::{ AuctionMemo, AuctionReserve, AuctionSettings, AuctionState, BidActionArgs, BidVerifier, - EndActionArgs, FlatBidVerifierArgs, NftUnlockerArgs, PercentBidVerifierArgs, ReserveMemo, - auction_lock_p2_puzzle_hash, calculate_bps_payment, + CancelActionArgs, EndActionArgs, FlatBidVerifierArgs, NftUnlockerArgs, PercentBidVerifierArgs, + ReserveMemo, auction_lock_p2_puzzle_hash, calculate_bps_payment, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -182,8 +182,34 @@ impl AuctionInfo { .into() } - pub fn merkle_leaves(&self) -> [Bytes32; 2] { - [self.bid_action_hash(), self.end_action_hash()] + /// Cancels the auction before the first bid, which must be authorized by the payout puzzle. See + /// [`AuctionExt::spend_cancel_action`](crate::AuctionExt::spend_cancel_action). + pub fn cancel_action(&self, ctx: &mut SpendContext) -> Result { + let unlocker = self.nft_unlocker(ctx)?; + + ctx.curry(CancelActionArgs::new( + unlocker, + self.settings.payments.payout_puzzle_hash, + self.nft_coin_id, + )) + } + + pub fn cancel_action_hash(&self) -> Bytes32 { + CancelActionArgs::new( + self.nft_unlocker_args().curry_tree_hash(), + self.settings.payments.payout_puzzle_hash, + self.nft_coin_id, + ) + .curry_tree_hash() + .into() + } + + pub fn merkle_leaves(&self) -> [Bytes32; 3] { + [ + self.bid_action_hash(), + self.end_action_hash(), + self.cancel_action_hash(), + ] } pub fn merkle_tree(&self) -> MerkleTree { diff --git a/tests/cancel.rs b/tests/cancel.rs new file mode 100644 index 0000000..bbf02ed --- /dev/null +++ b/tests/cancel.rs @@ -0,0 +1,139 @@ +mod common; + +use anyhow::{Result, anyhow}; +use auction::*; +use chia_wallet_sdk::prelude::*; +use common::*; + +#[test] +fn cancel_returns_nft_to_seller() -> Result<()> { + for reserve in [ReserveKind::Xch, ReserveKind::Cat] { + let mut h = Harness::new(Config { + reserve, + ..Config::default() + })?; + + let reserve_before = h.auction.info.reserve; + let nft = h.cancel()?; + + h.assert_unspent(nft.coin); + assert_eq!(nft.info.p2_puzzle_hash, h.seller.puzzle_hash); + assert_eq!(nft.info.current_owner, None); + + assert!(h.auction.info.state.ended); + assert_eq!(h.auction.info.state.reserve_amount, 0); + assert_eq!( + h.auction.info.state.winning_bid, + Bid::new(0, h.seller.puzzle_hash) + ); + + // Nothing is paid out, and the reserve is recreated empty + h.assert_missing(h.reserve_output(reserve_before, h.seller.puzzle_hash, 0)); + let child_reserve = h.auction.info.reserve.coin(); + assert_eq!( + child_reserve.parent_coin_info, + reserve_before.coin().coin_id() + ); + assert_eq!(child_reserve.amount, 0); + h.assert_unspent(child_reserve); + + // A cancelled auction can't be bid on, ended, or cancelled again + assert_fails_with(h.bid(100), "raise"); + h.set_time(END_TIME)?; + assert_fails_with(h.end(), "raise"); + assert_fails_with(h.cancel(), "raise"); + } + + Ok(()) +} + +#[test] +fn cancel_requires_the_sellers_message() -> Result<()> { + let mut h = Harness::new(Config::default())?; + + assert_fails_with(h.cancel_authorized_by(None), "MessageNotSentOrReceived"); + + // A message from any puzzle other than the payout puzzle doesn't authorize the cancel + let stranger = h.sim.bls(0); + assert_fails_with( + h.cancel_authorized_by(Some(stranger)), + "MessageNotSentOrReceived", + ); + + // The auction is untouched, so the seller can still cancel it + let nft = h.cancel()?; + assert_eq!(nft.info.p2_puzzle_hash, h.seller.puzzle_hash); + + Ok(()) +} + +#[test] +fn cannot_cancel_after_a_bid() -> Result<()> { + let mut h = Harness::new(Config::default())?; + let winner = h.bid(100)?; + + assert_fails_with(h.cancel(), "raise"); + + // The bid is still live and wins the auction + h.set_time(END_TIME)?; + let nft = h.end()?; + assert_eq!(nft.info.p2_puzzle_hash, winner.bid.puzzle_hash); + + Ok(()) +} + +#[test] +fn cannot_cancel_after_the_auction_ended() -> Result<()> { + let mut h = Harness::new(Config::default())?; + h.set_time(END_TIME)?; + let nft = h.end()?; + + assert_fails_with(h.cancel(), "raise"); + h.assert_unspent(nft.coin); + + Ok(()) +} + +#[test] +fn cancel_has_no_timing_restrictions() -> Result<()> { + // Well before the end time, and with a grace period that would otherwise delay the end + let mut h = Harness::new(Config { + timings: Timings::new(END_TIME, 500), + ..Config::default() + })?; + assert!(h.sim.next_timestamp() < END_TIME); + + let nft = h.cancel()?; + assert_eq!(nft.info.p2_puzzle_hash, h.seller.puzzle_hash); + + Ok(()) +} + +#[test] +fn parse_child_follows_a_cancel() -> Result<()> { + for reserve in [ReserveKind::Xch, ReserveKind::Cat] { + let mut h = Harness::new(Config { + reserve, + ..Config::default() + })?; + + let before = h.auction; + let _ = h.cancel()?; + + // The harness already compares the parsed child to the spent one, but check it explicitly + let coin_spend = h + .sim + .coin_spend(before.coin.coin_id()) + .ok_or(anyhow!("missing auction spend"))?; + let solution = coin_spend.solution.to_clvm(&mut h.ctx)?; + let parsed = before.parse_child(&mut h.ctx, solution)?; + + assert_eq!(parsed, h.auction); + assert!(parsed.info.state.ended); + assert_eq!(parsed.info.state.reserve_amount, 0); + h.assert_unspent(parsed.coin); + h.assert_unspent(parsed.info.reserve.coin()); + } + + Ok(()) +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index faf5738..982752b 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -354,6 +354,53 @@ impl Harness { Ok(nft) } + /// Cancels the auction, authorized by a coin at the seller's payout address, and unlocks the NFT + /// back to the seller. + pub fn cancel(&mut self) -> Result { + let authorizer = self.seller_coin(); + self.cancel_authorized_by(Some(authorizer)) + } + + /// Like [`Harness::cancel`], but the cancel message is sent by `authorizer`'s coin instead, or not + /// at all. + pub fn cancel_authorized_by(&mut self, authorizer: Option) -> Result { + let result = self.try_cancel(authorizer); + if result.is_err() { + self.ctx.take(); + } + result + } + + fn try_cancel(&mut self, authorizer: Option) -> Result { + let mut keys = Vec::new(); + + if let Some(authorizer) = authorizer { + let conditions = self.auction.cancel_authorization(&mut self.ctx)?; + StandardLayer::new(authorizer.pk).spend(&mut self.ctx, authorizer.coin, conditions)?; + keys.push(authorizer.sk); + } + + let cancel_action = self.auction.spend_cancel_action(&mut self.ctx, &self.nft)?; + let nft = self.auction.unlock_nft(&mut self.ctx, &self.nft)?; + let auction = self + .auction + .spend(&mut self.ctx, vec![cancel_action], vec![])?; + let coin_spends = self.ctx.take(); + self.sim.spend_coins(coin_spends.clone(), &keys)?; + self.confirmed(&coin_spends, auction)?; + self.nft = nft; + Ok(nft) + } + + /// A new empty coin owned by the seller, at the auction's payout address. + pub fn seller_coin(&mut self) -> BlsPairWithCoin { + let coin = self.sim.new_coin(self.seller.puzzle_hash, 0); + BlsPairWithCoin { + coin, + ..self.seller.clone() + } + } + /// Creates an empty coin at the auction lock, which can be spent as the reserve in place of the /// real one while the auction's reserve amount is 0. pub fn decoy_reserve(&mut self) -> Result { diff --git a/tests/puzzles.rs b/tests/puzzles.rs index ed95b4c..b3bab1a 100644 --- a/tests/puzzles.rs +++ b/tests/puzzles.rs @@ -17,6 +17,7 @@ fn assert_mod(name: &str) { fn embedded_puzzles_match_their_hashes() { assert_mod::>("bid_action"); assert_mod::>("end_action"); + assert_mod::>("cancel_action"); assert_mod::("nft_unlocker"); assert_mod::("flat_bid_verifier"); assert_mod::("percent_bid_verifier"); From f8be4234cd5eec20932d6717af06cbf00b075873 Mon Sep 17 00:00:00 2001 From: Rigidity Date: Tue, 27 Oct 2026 18:54:15 -0400 Subject: [PATCH 2/3] Address review: shorter cancel message, end announcement, on-chain tests --- README.md | 4 +- puzzles/actions/cancel_action.rue | 8 +- puzzles/actions/cancel_action.rue.hash | 2 +- puzzles/actions/cancel_action.rue.hex | 14 +- src/actions/cancel.rs | 2 +- src/auction.rs | 4 +- tests/cancel.rs | 170 ++++++++++++++++++++++++- tests/common/mod.rs | 156 +++++++++++++++++++++-- 8 files changed, 333 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index bbf7d24..e415c97 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Both the buyer's premium and commission are percentages, and are sent to a singl ## Cancelling -The cancel action asserts that the auction hasn't ended and has no bids, and has no timing restrictions. It must be authorized by a coin at the payout address, which sends the message `cancel` to the current auction coin with the `SENDER_PUZZLE | RECEIVER_COIN` mode (`0x17`). Since the message is sent to a specific auction coin, it can't be reused once the auction has been spent. `AuctionExt::cancel_authorization` returns the condition that coin must output, and `AuctionExt::spend_cancel_action` builds the action, which must be spent along with `AuctionExt::unlock_nft` in the same spend bundle. +The cancel action asserts that the auction hasn't ended and has no bids, and has no timing restrictions. It must be authorized by a coin at the payout address, which sends the message `c` to the current auction coin with the `SENDER_PUZZLE | RECEIVER_COIN` mode (`0x17`). Since the message is sent to a specific auction coin, it can't be reused once the auction has been spent. `AuctionExt::cancel_authorization` returns the condition that coin must output, and `AuctionExt::spend_cancel_action` builds the action, which must be spent along with `AuctionExt::unlock_nft` in the same spend bundle. Like the end action, the cancel action creates the coin announcement `$`, so a cancelled auction looks the same as one that ended with no bids. A cancel spends the auction coin, so it conflicts with a pending first bid in the mempool, and whichever confirms first wins. The auctioneer can't replace a pending bid with a cancel, since the bidder's coin asserts the bid's announcement. @@ -85,7 +85,7 @@ Near the end time, this means that a bid landing in the last block can't be answ ## Trust Assumptions - **Revocable CATs:** The issuer of a revocable CAT can spend any coin of that CAT, including the reserve. If they revoke the reserve during an auction, they take the current winning bid, and the auction can never be ended, so the NFT is locked forever. Wallets should make it clear that the issuer is trusted when an auction's reserve is a revocable CAT. -- **Payout address:** The payout address authorizes cancelling the auction before the first bid. If it's a puzzle that others can spend (or send messages from), they can cancel the auction too, which only returns the NFT to the payout address. +- **Payout address:** The payout address authorizes cancelling the auction before the first bid. If it's a puzzle that others can spend (or send messages from), they can cancel the auction too, which only returns the NFT to the payout address. If it's a puzzle that can't send messages, such as a payment split, the auction can't be cancelled at all, and can only be ended. - **Grace period flag:** Each bidder's coin asserts an announcement of their bid (its amount and puzzle hash), but not whether it was placed using the grace period. Anyone relaying a pending bid can change that flag, which only changes when the bid is allowed to land. The bidder still pays exactly what they bid. ## Development diff --git a/puzzles/actions/cancel_action.rue b/puzzles/actions/cancel_action.rue index d4bb98a..54a5fe2 100644 --- a/puzzles/actions/cancel_action.rue +++ b/puzzles/actions/cancel_action.rue @@ -1,8 +1,9 @@ import super::shared::*; // The message the payout puzzle must send to the auction coin to authorize a cancel. Since the -// message is sent to a specific auction coin, it doesn't need to commit to anything else. -inline const CANCEL_MESSAGE: Bytes = "cancel"; +// message is sent to a specific auction coin, it doesn't need to commit to anything else, so it's +// kept to a single byte. +inline const CANCEL_MESSAGE: Bytes = "c"; struct Solution { ...unlocker_solution: Any, @@ -46,6 +47,9 @@ fn main( receiver: [NFT_COIN_ID], message: tree_hash((1, unlock_conditions)), }, + + // Announce the end of the auction, the same as the end action does + CreateCoinAnnouncement { message: "$" }, ], } } diff --git a/puzzles/actions/cancel_action.rue.hash b/puzzles/actions/cancel_action.rue.hash index b6b4de1..49b56f4 100644 --- a/puzzles/actions/cancel_action.rue.hash +++ b/puzzles/actions/cancel_action.rue.hash @@ -1 +1 @@ -4032a7a843ba2651f89fc53cac412028a730fdf92588b270cb6ffd973ed74e9e +3711cd50a0332a4b8f46bb2f1eb66d9c1d1ed658d104b94a4cf6e424906c782e diff --git a/puzzles/actions/cancel_action.rue.hex b/puzzles/actions/cancel_action.rue.hex index 8b0fc6f..08dec8e 100644 --- a/puzzles/actions/cancel_action.rue.hex +++ b/puzzles/actions/cancel_action.rue.hex @@ -1,9 +1,9 @@ ff02ffff01ff02ffff03ffff22ffff20ff82016f80ffff20ff8202ef8080ffff 01ff04ffff04ff4fffff04ff80ffff04ffff0101ff8201ef808080ffff04ffff -04ffff0143ffff04ffff0117ffff04ffff018663616e63656cffff04ff0bff80 -80808080ffff04ffff04ffff0142ffff04ffff0117ffff04ffff02ff02ffff04 -ff02ffff04ffff0101ffff02ff05ffff04ff8201efff3f8080808080ffff04ff -17ff8080808080ff80808080ffff01ff088080ff0180ffff04ffff01ff02ffff -03ffff07ff0380ffff01ff0bffff0102ffff02ff02ffff04ff02ff058080ffff -02ff02ffff04ff02ff07808080ffff01ff0bffff0101ff038080ff0180ff0180 -80 +04ffff0143ffff04ffff0117ffff04ffff0163ffff04ff0bff8080808080ffff +04ffff04ffff0142ffff04ffff0117ffff04ffff02ff02ffff04ff02ffff04ff +ff0101ffff02ff05ffff04ff8201efff3f8080808080ffff04ff17ff80808080 +80ffff01ffff3cff248080808080ffff01ff088080ff0180ffff04ffff01ff02 +ffff03ffff07ff0380ffff01ff0bffff0102ffff02ff02ffff04ff02ff058080 +ffff02ff02ffff04ff02ff07808080ffff01ff0bffff0101ff038080ff0180ff +018080 diff --git a/src/actions/cancel.rs b/src/actions/cancel.rs index 78beb66..7fedda0 100644 --- a/src/actions/cancel.rs +++ b/src/actions/cancel.rs @@ -3,7 +3,7 @@ use chia_wallet_sdk::prelude::*; use crate::include_puzzle; /// The message the auction's payout puzzle must send to the auction coin to authorize a cancel. -pub const CANCEL_MESSAGE: &[u8] = b"cancel"; +pub const CANCEL_MESSAGE: &[u8] = b"c"; /// The message mode used to authorize a cancel, which commits to the sender's puzzle hash and the /// receiving auction coin. diff --git a/src/auction.rs b/src/auction.rs index 09b61b9..cb47e07 100644 --- a/src/auction.rs +++ b/src/auction.rs @@ -162,8 +162,8 @@ impl AuctionExt for Auction { } // Only the end action pays the royalty, and the cancel action never creates any reserve - // conditions. A bid refund can also be sent to the settlement - // puzzle hash, if that's the puzzle hash the bidder chose. + // conditions. A bid refund can also be sent to the settlement puzzle hash, if that's the + // puzzle hash the bidder chose. if new_state.ended && !state.ended { for &condition in &action_reserve_conditions { if let Ok(create_coin) = ctx.extract::>(condition) diff --git a/tests/cancel.rs b/tests/cancel.rs index bbf02ed..fc71257 100644 --- a/tests/cancel.rs +++ b/tests/cancel.rs @@ -7,7 +7,11 @@ use common::*; #[test] fn cancel_returns_nft_to_seller() -> Result<()> { - for reserve in [ReserveKind::Xch, ReserveKind::Cat] { + for reserve in [ + ReserveKind::Xch, + ReserveKind::Cat, + ReserveKind::RevocableCat, + ] { let mut h = Harness::new(Config { reserve, ..Config::default() @@ -111,7 +115,11 @@ fn cancel_has_no_timing_restrictions() -> Result<()> { #[test] fn parse_child_follows_a_cancel() -> Result<()> { - for reserve in [ReserveKind::Xch, ReserveKind::Cat] { + for reserve in [ + ReserveKind::Xch, + ReserveKind::Cat, + ReserveKind::RevocableCat, + ] { let mut h = Harness::new(Config { reserve, ..Config::default() @@ -137,3 +145,161 @@ fn parse_child_follows_a_cancel() -> Result<()> { Ok(()) } + +/// The error for a spend bundle where the auction's puzzle raises during consensus validation. +const PUZZLE_RAISED: &str = "GeneratorRuntimeError"; + +/// Authorizes a cancel from a new seller coin, and returns the cancel action spend along with the +/// key that must sign the authorization. +fn authorized_cancel(h: &mut Harness) -> Result<(Spend, SecretKey)> { + let authorizer = h.seller_coin(); + let conditions = h.auction.cancel_authorization(&mut h.ctx)?; + StandardLayer::new(authorizer.pk).spend(&mut h.ctx, authorizer.coin, conditions)?; + let cancel = h.auction.spend_cancel_action(&mut h.ctx, &h.nft)?; + Ok((cancel, authorizer.sk)) +} + +#[test] +fn cancel_rejects_other_authorizations() -> Result<()> { + let mut h = Harness::new(Config::default())?; + let auction_coin = h.auction.coin; + + let wrong_authorizations: [fn(&mut Harness, Coin) -> Result; 4] = [ + // Sent to the auction's puzzle hash rather than its coin + |h, auction_coin| { + let receiver = h.ctx.alloc(&auction_coin.puzzle_hash)?; + Ok(Conditions::new().send_message( + 0b010_010, + CANCEL_MESSAGE.to_vec().into(), + vec![receiver], + )) + }, + // Sent to a different coin + |h, _| { + let receiver = h.ctx.alloc(&h.nft.coin.coin_id())?; + Ok(Conditions::new().send_message( + CANCEL_MESSAGE_MODE, + CANCEL_MESSAGE.to_vec().into(), + vec![receiver], + )) + }, + // Committing to the sender's coin rather than its puzzle + |h, auction_coin| { + let receiver = h.ctx.alloc(&auction_coin.coin_id())?; + Ok(Conditions::new().send_message( + 0b111_111, + CANCEL_MESSAGE.to_vec().into(), + vec![receiver], + )) + }, + // The wrong message + |h, auction_coin| { + let receiver = h.ctx.alloc(&auction_coin.coin_id())?; + Ok(Conditions::new().send_message( + CANCEL_MESSAGE_MODE, + b"cancel".to_vec().into(), + vec![receiver], + )) + }, + ]; + + for wrong_authorization in wrong_authorizations { + let authorizer = h.seller_coin(); + let conditions = wrong_authorization(&mut h, auction_coin)?; + assert_fails_with( + h.cancel_with_authorization(Some((authorizer, conditions))), + "MessageNotSentOrReceived", + ); + } + + let nft = h.cancel()?; + assert_eq!(nft.info.p2_puzzle_hash, h.seller.puzzle_hash); + + Ok(()) +} + +#[test] +fn unchecked_cancel_without_bids_succeeds() -> Result<()> { + // Makes sure that the unchecked spends below are only rejected because of the cancel action + let mut h = Harness::new(Config::default())?; + let (cancel, key) = authorized_cancel(&mut h)?; + let nft = h.auction.unlock_nft(&mut h.ctx, &h.nft)?; + h.spend_unchecked(vec![cancel], 0, &[key])?; + + h.assert_unspent(nft.coin); + assert_eq!(nft.info.p2_puzzle_hash, h.seller.puzzle_hash); + assert!(h.auction.info.state.ended); + + Ok(()) +} + +#[test] +fn cancel_after_a_bid_is_rejected_on_chain() -> Result<()> { + let mut h = Harness::new(Config::default())?; + let winner = h.bid(100)?; + + let (cancel, key) = authorized_cancel(&mut h)?; + let _ = h.auction.unlock_nft(&mut h.ctx, &h.nft)?; + let result = h.spend_unchecked(vec![cancel], 0, &[key]); + assert_fails_with(result, PUZZLE_RAISED); + + assert_eq!(h.auction.info.state.winning_bid, winner.bid); + Ok(()) +} + +#[test] +fn cancel_after_the_end_is_rejected_on_chain() -> Result<()> { + let mut h = Harness::new(Config::default())?; + h.set_time(END_TIME)?; + let _ = h.end()?; + + // The NFT has already been unlocked, so only the cancel is attempted + let (cancel, key) = authorized_cancel(&mut h)?; + let result = h.spend_unchecked(vec![cancel], 0, &[key]); + assert_fails_with(result, PUZZLE_RAISED); + + Ok(()) +} + +#[test] +fn cancel_cannot_be_combined_with_other_actions() -> Result<()> { + #[derive(Debug, Clone, Copy)] + enum Other { + Bid, + End, + } + + for (other, cancel_first) in [ + (Other::Bid, false), + (Other::Bid, true), + (Other::End, false), + (Other::End, true), + ] { + let mut h = Harness::new(Config::default())?; + + // The bid is never funded, since the spend is rejected before its announcement matters + let other_spend = match other { + Other::Bid => { + let bid = Bid::new(100, Bytes32::new([5; 32])); + h.auction.spend_bid_action(&mut h.ctx, bid, false)? + } + Other::End => { + h.set_time(END_TIME)?; + h.auction.spend_end_action(&mut h.ctx, &h.nft)? + } + }; + + let (cancel, key) = authorized_cancel(&mut h)?; + let _ = h.auction.unlock_nft(&mut h.ctx, &h.nft)?; + + let actions = if cancel_first { + vec![cancel, other_spend] + } else { + vec![other_spend, cancel] + }; + let result = h.spend_unchecked(actions, 0, &[key]); + assert_fails_with(result, PUZZLE_RAISED); + } + + Ok(()) +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 982752b..5ad316d 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -5,15 +5,25 @@ use std::fmt::Debug; use anyhow::{Result, anyhow}; use auction::*; use chia_wallet_sdk::{ - chia::consensus::{ - consensus_constants::TEST_CONSTANTS, - spendbundle_conditions::get_conditions_from_spendbundle, + chia::{ + consensus::{ + consensus_constants::TEST_CONSTANTS, + spendbundle_conditions::get_conditions_from_spendbundle, + }, + puzzle_types::singleton::SingletonSolution, }, - driver::IntermediateLauncher, + clvm_traits::clvm_quote, + clvmr::serde::node_from_bytes, + driver::{ActionLayer, ActionLayerSolution, Finalizer, IntermediateLauncher, SingletonLayer}, prelude::*, puzzles::SETTLEMENT_PAYMENT_HASH, - test::BlsPairWithCoin, - types::announcement_id, + test::{BlsPairWithCoin, sign_transaction}, + types::{ + announcement_id, + puzzles::{ + RESERVE_FINALIZER_DEFAULT_RESERVE_AMOUNT_FROM_STATE_PROGRAM, ReserveFinalizerSolution, + }, + }, }; pub const PREMIUM_PUZZLE_HASH: Bytes32 = Bytes32::new([1; 32]); @@ -364,18 +374,33 @@ impl Harness { /// Like [`Harness::cancel`], but the cancel message is sent by `authorizer`'s coin instead, or not /// at all. pub fn cancel_authorized_by(&mut self, authorizer: Option) -> Result { - let result = self.try_cancel(authorizer); + let authorization = match authorizer { + Some(authorizer) => { + let conditions = self.auction.cancel_authorization(&mut self.ctx)?; + Some((authorizer, conditions)) + } + None => None, + }; + self.cancel_with_authorization(authorization) + } + + /// Like [`Harness::cancel`], but the given coin outputs the given conditions in place of the + /// cancel authorization. + pub fn cancel_with_authorization( + &mut self, + authorization: Option<(BlsPairWithCoin, Conditions)>, + ) -> Result { + let result = self.try_cancel(authorization); if result.is_err() { self.ctx.take(); } result } - fn try_cancel(&mut self, authorizer: Option) -> Result { + fn try_cancel(&mut self, authorization: Option<(BlsPairWithCoin, Conditions)>) -> Result { let mut keys = Vec::new(); - if let Some(authorizer) = authorizer { - let conditions = self.auction.cancel_authorization(&mut self.ctx)?; + if let Some((authorizer, conditions)) = authorization { StandardLayer::new(authorizer.pk).spend(&mut self.ctx, authorizer.coin, conditions)?; keys.push(authorizer.sk); } @@ -392,6 +417,117 @@ impl Harness { Ok(nft) } + /// Spends the auction with `action_spends` like [`AuctionExt::spend`], along with any spends + /// already in the context, but without running the actions in the driver first. This makes sure + /// that invalid actions are rejected on chain, and not just by the driver. Only XCH reserves are + /// supported, and the reserve is recreated with `reserve_amount`. + pub fn spend_unchecked( + &mut self, + action_spends: Vec, + reserve_amount: u64, + keys: &[SecretKey], + ) -> Result<()> { + let result = self.try_spend_unchecked(action_spends, reserve_amount, keys); + if result.is_err() { + self.ctx.take(); + } + result + } + + fn try_spend_unchecked( + &mut self, + action_spends: Vec, + reserve_amount: u64, + keys: &[SecretKey], + ) -> Result<()> { + let info = self.auction.info; + let AuctionReserve::Xch(reserve) = info.reserve else { + return Err(anyhow!("only XCH reserves are supported")); + }; + + let reserve_amount_from_state_program = node_from_bytes( + &mut self.ctx, + &RESERVE_FINALIZER_DEFAULT_RESERVE_AMOUNT_FROM_STATE_PROGRAM, + )?; + + let action_layer = ActionLayer::new( + info.merkle_tree().root(), + info.state, + Finalizer::Reserve { + reserve_full_puzzle_hash: reserve.puzzle_hash, + reserve_inner_puzzle_hash: info.reserve.p2_puzzle_hash(), + reserve_amount_from_state_program, + hint: info.launcher_id, + }, + ); + + let action_spend_hashes = action_spends + .iter() + .map(|spend| self.ctx.tree_hash(spend.puzzle).into()) + .collect::>(); + let proofs = action_layer + .get_proofs(&info.merkle_leaves(), &action_spend_hashes) + .ok_or(anyhow!("invalid merkle proof"))?; + + let finalizer_solution = self.ctx.alloc(&ReserveFinalizerSolution { + reserve_parent_id: reserve.parent_coin_info, + })?; + let inner_spend = action_layer.construct_spend( + &mut self.ctx, + ActionLayerSolution { + proofs, + action_spends, + finalizer_solution, + }, + )?; + let coin_spend = SingletonLayer::new(info.launcher_id, inner_spend.puzzle) + .construct_coin_spend( + &mut self.ctx, + self.auction.coin, + SingletonSolution { + lineage_proof: self.auction.proof, + amount: self.auction.coin.amount, + inner_solution: inner_spend.solution, + }, + )?; + self.ctx.insert(coin_spend); + + let p2_puzzle_hash = info.reserve.p2_puzzle_hash(); + let hint = self.ctx.hint(p2_puzzle_hash)?; + let new_reserve = CreateCoin::new(p2_puzzle_hash, reserve_amount, hint); + let delegated_puzzle = self.ctx.alloc(&clvm_quote!(vec![new_reserve]))?; + let reserve_spend = spend_auction_lock( + &mut self.ctx, + info.launcher_id, + info.inner_puzzle_hash(), + Spend::new(delegated_puzzle, NodePtr::NIL), + )?; + self.ctx.spend(reserve, reserve_spend)?; + + // The simulator's signer runs every puzzle, so the auction's spend is left out of signing (it + // doesn't need a signature) to make sure it's only ever run by consensus validation + let coin_spends = self.ctx.take(); + let auction_coin_id = self.auction.coin.coin_id(); + let signed = coin_spends + .iter() + .filter(|coin_spend| coin_spend.coin.coin_id() != auction_coin_id) + .cloned() + .collect::>(); + let signature = sign_transaction(&signed, keys)?; + self.sim + .new_transaction(SpendBundle::new(coin_spends.clone(), signature))?; + assert_mempool_valid(&coin_spends)?; + + let solution = self + .sim + .coin_spend(self.auction.coin.coin_id()) + .ok_or(anyhow!("missing auction spend"))? + .solution + .to_clvm(&mut self.ctx)?; + self.auction = self.auction.parse_child(&mut self.ctx, solution)?; + Ok(()) + } + /// A new empty coin owned by the seller, at the auction's payout address. pub fn seller_coin(&mut self) -> BlsPairWithCoin { let coin = self.sim.new_coin(self.seller.puzzle_hash, 0); From 130090833b98fc570d20c89876d7f505f47d0262 Mon Sep 17 00:00:00 2001 From: Rigidity Date: Tue, 27 Oct 2026 19:11:14 -0400 Subject: [PATCH 3/3] Address second review: reserve caveat, royalty-0 and reserve tests --- README.md | 6 +++-- tests/cancel.rs | 69 ++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 66 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index e415c97..3a5b121 100644 --- a/README.md +++ b/README.md @@ -28,16 +28,18 @@ These can also be customized, but they may be enforced to be certain values by t - The buyer's premium - The commission -The auctioneer can always cancel the auction before the first bid, regardless of these settings. The cancel is authorized by the payout address, so whoever controls it can cancel. +The auctioneer can cancel the auction before the first bid, regardless of these settings. The cancel is authorized by the payout address, so whoever controls it can cancel. Both the buyer's premium and commission are percentages, and are sent to a single address. If these need to be split between multiple addresses, you should use something external to do so such as the [royalty split](https://splitxch.com/) primitive. ## Cancelling -The cancel action asserts that the auction hasn't ended and has no bids, and has no timing restrictions. It must be authorized by a coin at the payout address, which sends the message `c` to the current auction coin with the `SENDER_PUZZLE | RECEIVER_COIN` mode (`0x17`). Since the message is sent to a specific auction coin, it can't be reused once the auction has been spent. `AuctionExt::cancel_authorization` returns the condition that coin must output, and `AuctionExt::spend_cancel_action` builds the action, which must be spent along with `AuctionExt::unlock_nft` in the same spend bundle. Like the end action, the cancel action creates the coin announcement `$`, so a cancelled auction looks the same as one that ended with no bids. +The cancel action asserts that the auction hasn't ended and has no bids, and has no timing restrictions. It must be authorized by a coin at the payout address, which sends the message `c` to the current auction coin with the `SENDER_PUZZLE | RECEIVER_COIN` mode (`0x17`). Since the message is sent to a specific auction coin, it can't be reused once the auction has been spent. `AuctionExt::cancel_authorization` returns the condition that coin must output, and `AuctionExt::spend_cancel_action` builds the action, which must be spent along with `AuctionExt::unlock_nft` in the same spend bundle. Like the end action, the cancel action creates the coin announcement `$`, and leaves the auction in the same state as ending it with no bids. The spend itself still reveals that it was cancelled. A cancel spends the auction coin, so it conflicts with a pending first bid in the mempool, and whichever confirms first wins. The auctioneer can't replace a pending bid with a cancel, since the bidder's coin asserts the bid's announcement. +Like ending an auction, a cancel must spend an empty coin at the lock address as the reserve. Any such coin works (see [Discovery](#discovery)), so if the launcher memo's reserve was never created, or a revocable CAT's issuer revoked it, the auctioneer has to create a new one first, and set `AuctionInfo::reserve` to it before spending the auction. For CAT reserves, this means the auctioneer needs to hold some of that CAT, since the new reserve must be a coin of the same asset. + ## Fees and Royalties All fees are specified in basis points (1/100th of a percent), and are rounded down. For a winning bid of `bid`: diff --git a/tests/cancel.rs b/tests/cancel.rs index fc71257..a781f0f 100644 --- a/tests/cancel.rs +++ b/tests/cancel.rs @@ -235,15 +235,25 @@ fn unchecked_cancel_without_bids_succeeds() -> Result<()> { #[test] fn cancel_after_a_bid_is_rejected_on_chain() -> Result<()> { - let mut h = Harness::new(Config::default())?; - let winner = h.bid(100)?; + // Without a royalty, the NFT's transfer doesn't assert a royalty payment, so nothing but the + // cancel action's own check stops a cancel from sending the NFT to the bidder and burning the + // reserve as fees + for royalty_bps in [0, 500] { + let mut h = Harness::new(Config { + royalty_bps, + ..Config::default() + })?; + let winner = h.bid(100)?; - let (cancel, key) = authorized_cancel(&mut h)?; - let _ = h.auction.unlock_nft(&mut h.ctx, &h.nft)?; - let result = h.spend_unchecked(vec![cancel], 0, &[key]); - assert_fails_with(result, PUZZLE_RAISED); + let (cancel, key) = authorized_cancel(&mut h)?; + let _ = h.auction.unlock_nft(&mut h.ctx, &h.nft)?; + let result = h.spend_unchecked(vec![cancel], 0, &[key]); + assert_fails_with(result, PUZZLE_RAISED); + + assert_eq!(h.auction.info.state.winning_bid, winner.bid); + h.assert_unspent(h.auction.info.reserve.coin()); + } - assert_eq!(h.auction.info.state.winning_bid, winner.bid); Ok(()) } @@ -303,3 +313,48 @@ fn cancel_cannot_be_combined_with_other_actions() -> Result<()> { Ok(()) } + +#[test] +fn two_cancels_in_one_spend_are_rejected() -> Result<()> { + let mut h = Harness::new(Config::default())?; + + // Each cancel has its own authorization, so only the second cancel's check can reject it + let (first, first_key) = authorized_cancel(&mut h)?; + let (second, second_key) = authorized_cancel(&mut h)?; + let _ = h.auction.unlock_nft(&mut h.ctx, &h.nft)?; + let result = h.spend_unchecked(vec![first, second], 0, &[first_key, second_key]); + assert_fails_with(result, PUZZLE_RAISED); + + Ok(()) +} + +#[test] +fn cancel_can_spend_a_stand_in_reserve() -> Result<()> { + // If the launcher's reserve is missing, the seller can create an empty coin at the lock address + // and spend it as the reserve instead. For CAT reserves, this needs a coin of the same CAT. + for reserve in [ + ReserveKind::Xch, + ReserveKind::Cat, + ReserveKind::RevocableCat, + ] { + let mut h = Harness::new(Config { + reserve, + ..Config::default() + })?; + + let tracked = h.auction.info.reserve; + let stand_in = h.decoy_reserve()?; + h.auction.info.reserve = stand_in; + + // The harness checks that parse_child follows the stand-in reserve + let nft = h.cancel()?; + assert_eq!(nft.info.p2_puzzle_hash, h.seller.puzzle_hash); + + h.assert_unspent(tracked.coin()); + let child_reserve = h.auction.info.reserve.coin(); + assert_eq!(child_reserve.parent_coin_info, stand_in.coin().coin_id()); + h.assert_unspent(child_reserve); + } + + Ok(()) +}