Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -26,8 +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 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 `$`, 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`:
Expand Down Expand Up @@ -75,6 +87,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. 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
Expand Down
55 changes: 55 additions & 0 deletions puzzles/actions/cancel_action.rue
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
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, so it's
// kept to a single byte.
inline const CANCEL_MESSAGE: Bytes = "c";

struct Solution {
...unlocker_solution: Any,
}

fn main(
UNLOCKER: fn(Truth: Bid, ...solution: Any) -> List<NftCondition>,
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)),
},

// Announce the end of the auction, the same as the end action does
CreateCoinAnnouncement { message: "$" },
],
}
}
1 change: 1 addition & 0 deletions puzzles/actions/cancel_action.rue.hash
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3711cd50a0332a4b8f46bb2f1eb66d9c1d1ed658d104b94a4cf6e424906c782e
9 changes: 9 additions & 0 deletions puzzles/actions/cancel_action.rue.hex
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
ff02ffff01ff02ffff03ffff22ffff20ff82016f80ffff20ff8202ef8080ffff
01ff04ffff04ff4fffff04ff80ffff04ffff0101ff8201ef808080ffff04ffff
04ffff0143ffff04ffff0117ffff04ffff0163ffff04ff0bff8080808080ffff
04ffff04ffff0142ffff04ffff0117ffff04ffff02ff02ffff04ff02ffff04ff
ff0101ffff02ff05ffff04ff8201efff3f8080808080ffff04ff17ff80808080
80ffff01ffff3cff248080808080ffff01ff088080ff0180ffff04ffff01ff02
ffff03ffff07ff0380ffff01ff0bffff0102ffff02ff02ffff04ff02ff058080
ffff02ff02ffff04ff02ff07808080ffff01ff0bffff0101ff038080ff0180ff
018080
2 changes: 2 additions & 0 deletions src/actions.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
mod bid;
mod cancel;
mod end;

pub use bid::*;
pub use cancel::*;
pub use end::*;
33 changes: 33 additions & 0 deletions src/actions/cancel.rs
Original file line number Diff line number Diff line change
@@ -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"c";

/// 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<U = NodePtr> {
pub unlocker: U,
pub payout_puzzle_hash: Bytes32,
pub nft_coin_id: Bytes32,
}

impl<U> CancelActionArgs<U> {
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<U> = CANCEL_ACTION,
"puzzles/actions/cancel_action.rue"
);
39 changes: 34 additions & 5 deletions src/auction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuctionInfo>;
Expand Down Expand Up @@ -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<Spend, DriverError>;

/// 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<Spend, DriverError>;

/// 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<Conditions, DriverError>;

/// 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<Nft, DriverError>;

/// Runs the action puzzles to determine the next state and the reserve coin's conditions, exactly
Expand Down Expand Up @@ -87,6 +99,22 @@ impl AuctionExt for Auction {
Ok(Spend::new(puzzle, solution))
}

fn spend_cancel_action(&self, ctx: &mut SpendContext, nft: &Nft) -> Result<Spend, DriverError> {
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<Conditions, DriverError> {
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<Nft, DriverError> {
let unlocker = self.info.nft_unlocker(ctx)?;
let unlocker_solution = ctx.alloc(&NftUnlockerSolution::new(
Expand Down Expand Up @@ -133,8 +161,9 @@ impl AuctionExt for Auction {
}
}

// Only the end action pays the royalty. A bid refund can also be sent to the settlement
// puzzle hash, if that's the puzzle hash the bidder chose.
// 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 {
if let Ok(create_coin) = ctx.extract::<CreateCoin<NodePtr>>(condition)
Expand Down
34 changes: 30 additions & 4 deletions src/info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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<NodePtr, DriverError> {
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 {
Expand Down
Loading
Loading