pcli/command/tx/auction/
dutch.rs

1use std::path::Path;
2
3use crate::command::tx::FeeTier;
4use crate::App;
5use anyhow::Result;
6use anyhow::{anyhow, bail, Context};
7use clap::Subcommand;
8use comfy_table::presets;
9use dialoguer::Confirm;
10use penumbra_sdk_asset::{asset::Cache, Value};
11use penumbra_sdk_auction::auction::{
12    dutch::DutchAuction, dutch::DutchAuctionDescription, AuctionId,
13};
14use penumbra_sdk_keys::keys::AddressIndex;
15use penumbra_sdk_num::Amount;
16use penumbra_sdk_proto::{view::v1::GasPricesRequest, DomainType};
17use penumbra_sdk_view::ViewClient;
18use penumbra_sdk_wallet::plan::Planner;
19use rand::RngCore;
20use rand_core::OsRng;
21use serde_json;
22
23mod debug;
24pub mod gda;
25
26/// Commands related to Dutch auctions
27#[derive(Debug, Subcommand)]
28pub enum DutchCmd {
29    /// Schedule a gradual dutch auction, a prototype for penumbra developers.
30    #[clap(display_order = 1000, name = "gradual")]
31    DutchAuctionGradualSchedule {
32        /// Source account initiating the auction.
33        #[clap(long, display_order = 100, default_value = "0")]
34        source: u32,
35        /// The value the seller wishes to auction.
36        #[clap(long, display_order = 200)]
37        input: String,
38        /// The maximum output the seller can receive.
39        ///
40        /// This implicitly defines the starting price for the auction.
41        #[clap(long, display_order = 400)]
42        max_output: String,
43        /// The minimum output the seller is willing to receive.
44        ///
45        /// This implicitly defines the ending price for the auction.
46        #[clap(long, display_order = 500)]
47        min_output: String,
48        /// The duration for the auction
49        #[clap(arg_enum, long, display_order = 600, name = "duration")]
50        recipe: gda::GdaRecipe,
51        /// Skip asking for confirmation, pay any fees, and execute the transaction.
52        #[clap(long, display_order = 700)]
53        yes: bool,
54        /// The selected fee tier to multiply the fee amount by.
55        #[clap(short, long, default_value_t, display_order = 1000)]
56        fee_tier: FeeTier,
57        #[clap(long, hide = true)]
58        // Use to produce a debug file for numerical analysis.
59        debug: bool,
60    },
61    /// Schedule a Dutch auction, a tool to help accomplish price discovery.
62    #[clap(display_order = 100, name = "schedule")]
63    DutchAuctionSchedule {
64        /// Source account initiating the auction.
65        #[clap(long, display_order = 100, default_value = "0")]
66        source: u32,
67        /// The value the seller wishes to auction.
68        #[clap(long, display_order = 200)]
69        input: String,
70        /// The maximum output the seller can receive.
71        ///
72        /// This implicitly defines the starting price for the auction.
73        #[clap(long, display_order = 400)]
74        max_output: String,
75        /// The minimum output the seller is willing to receive.
76        ///
77        /// This implicitly defines the ending price for the auction.
78        #[clap(long, display_order = 500)]
79        min_output: String,
80        /// The block height at which the auction begins.
81        ///
82        /// This allows the seller to schedule an auction at a future time.
83        #[clap(long, display_order = 600)]
84        start_height: u64,
85        /// The block height at which the auction ends.
86        ///
87        /// Together with `start_height`, `max_output`, and `min_output`,
88        /// this implicitly defines the speed of the auction.
89        #[clap(long, display_order = 700)]
90        end_height: u64,
91        /// The number of discrete price steps to use for the auction.
92        ///
93        /// `end_height - start_height` must be a multiple of `step_count`.
94        #[clap(long, display_order = 800)]
95        step_count: u64,
96        /// The selected fee tier to multiply the fee amount by.
97        #[clap(short, long, default_value_t, display_order = 1000)]
98        fee_tier: FeeTier,
99    },
100    /// Terminate a Dutch auction.
101    #[clap(display_order = 300, name = "end")]
102    DutchAuctionEnd {
103        /// Source account terminating the auction.
104        #[clap(long, display_order = 100, default_value = "0")]
105        source: u32,
106        /// If set, ends all auctions owned by the specified account.
107        #[clap(long, display_order = 150)]
108        all: bool,
109        /// Identifier of the auction to end, if `--all` is not set.
110        #[clap(display_order = 200)]
111        auction_id: Option<String>,
112        /// The selected fee tier to multiply the fee amount by.
113        #[clap(short, long, default_value_t, display_order = 300)]
114        fee_tier: FeeTier,
115    },
116    /// Withdraw a Dutch auction, and claim its reserves.
117    #[clap(display_order = 200, name = "withdraw")]
118    DutchAuctionWithdraw {
119        /// Source account withdrawing from the auction.
120        #[clap(long, display_order = 100, default_value = "0")]
121        source: u32,
122        /// If set, withdraws all auctions owned by the specified account.
123        #[clap(long, display_order = 150)]
124        all: bool,
125        /// Identifier of the auction to withdraw from, if `--all` is not set.
126        #[clap(display_order = 200)]
127        auction_id: Option<String>,
128        /// The selected fee tier to multiply the fee amount by.
129        #[clap(short, long, default_value_t, display_order = 600)]
130        fee_tier: FeeTier,
131    },
132}
133
134impl DutchCmd {
135    /// Process the command by performing the appropriate action.
136    pub async fn exec(&self, app: &mut App) -> anyhow::Result<()> {
137        let gas_prices = app
138            .view
139            .as_mut()
140            .context("view service must be initialized")?
141            .gas_prices(GasPricesRequest {})
142            .await?
143            .into_inner()
144            .gas_prices
145            .expect("gas prices must be available")
146            .try_into()?;
147
148        match self {
149            DutchCmd::DutchAuctionSchedule {
150                source,
151                input,
152                max_output,
153                min_output,
154                start_height,
155                end_height,
156                step_count,
157                fee_tier,
158            } => {
159                let mut nonce = [0u8; 32];
160                OsRng.fill_bytes(&mut nonce);
161
162                let input = input.parse::<Value>()?;
163                let max_output = max_output.parse::<Value>()?;
164                let min_output = min_output.parse::<Value>()?;
165                let output_id = max_output.asset_id;
166
167                let plan = Planner::new(OsRng)
168                    .set_gas_prices(gas_prices)
169                    .set_fee_tier((*fee_tier).into())
170                    .dutch_auction_schedule(DutchAuctionDescription {
171                        input,
172                        output_id,
173                        max_output: max_output.amount,
174                        min_output: min_output.amount,
175                        start_height: *start_height,
176                        end_height: *end_height,
177                        step_count: *step_count,
178                        nonce,
179                    })
180                    .plan(
181                        app.view
182                            .as_mut()
183                            .context("view service must be initialized")?,
184                        AddressIndex::new(*source),
185                    )
186                    .await
187                    .context("can't build auction schedule transaction")?;
188                app.build_and_submit_transaction(plan).await?;
189                Ok(())
190            }
191            DutchCmd::DutchAuctionEnd {
192                all,
193                auction_id,
194                source,
195                fee_tier,
196            } => {
197                let auction_ids = match (all, auction_id) {
198                    (true, _) => auctions_to_end(app.view(), *source).await?,
199                    (false, Some(auction_id)) => {
200                        let auction_id = auction_id.parse::<AuctionId>()?;
201                        vec![auction_id]
202                    }
203                    (false, None) => {
204                        bail!("auction_id is required when --all is not set")
205                    }
206                };
207
208                let mut planner = Planner::new(OsRng);
209
210                planner
211                    .set_gas_prices(gas_prices)
212                    .set_fee_tier((*fee_tier).into());
213
214                for auction_id in auction_ids {
215                    planner.dutch_auction_end(auction_id);
216                }
217
218                let plan = planner
219                    .plan(
220                        app.view
221                            .as_mut()
222                            .context("view service must be initialized")?,
223                        AddressIndex::new(*source),
224                    )
225                    .await
226                    .context("can't build auction end transaction")?;
227                app.build_and_submit_transaction(plan).await?;
228                Ok(())
229            }
230            DutchCmd::DutchAuctionWithdraw {
231                all,
232                source,
233                auction_id,
234                fee_tier,
235            } => {
236                let auctions = match (all, auction_id) {
237                    (true, _) => auctions_to_withdraw(app.view(), *source).await?,
238                    (false, Some(auction_id)) => {
239                        let auction_id = auction_id.parse::<AuctionId>()?;
240
241                        let all = auctions_to_withdraw(app.view(), *source).await?;
242                        vec![all
243                            .into_iter()
244                            .find(|a| a.description.id() == auction_id)
245                            .ok_or_else(|| {
246                                anyhow!("the auction id is unknown from the view service!")
247                            })?]
248                    }
249                    (false, None) => {
250                        bail!("auction_id is required when --all is not set")
251                    }
252                };
253
254                let mut planner = Planner::new(OsRng);
255
256                planner
257                    .set_gas_prices(gas_prices)
258                    .set_fee_tier((*fee_tier).into());
259
260                for auction in &auctions {
261                    planner.dutch_auction_withdraw(auction);
262                }
263
264                let plan = planner
265                    .plan(
266                        app.view
267                            .as_mut()
268                            .context("view service must be initialized")?,
269                        AddressIndex::new(*source),
270                    )
271                    .await
272                    .context("can't build auction withdrawal transaction")?;
273                app.build_and_submit_transaction(plan).await?;
274                Ok(())
275            }
276            DutchCmd::DutchAuctionGradualSchedule {
277                source,
278                input: input_str,
279                max_output: max_output_str,
280                min_output: min_output_str,
281                recipe: duration,
282                yes,
283                fee_tier,
284                debug,
285            } => {
286                println!("Gradual dutch auction prototype");
287
288                let input = input_str.parse::<Value>()?;
289                let max_output = max_output_str.parse::<Value>()?;
290                let min_output = min_output_str.parse::<Value>()?;
291
292                let asset_cache = app.view().assets().await?;
293                let current_height = app.view().status().await?.full_sync_height;
294
295                let gda = gda::GradualAuction::new(
296                    input,
297                    max_output,
298                    min_output,
299                    duration.clone(),
300                    current_height,
301                );
302
303                let auction_descriptions = gda.generate_auctions();
304
305                let input_fmt = input.format(&asset_cache);
306                let max_output_fmt = max_output.format(&asset_cache);
307                let min_output_fmt = min_output.format(&asset_cache);
308
309                println!("total to auction: {input_fmt}");
310                println!("start price: {max_output_fmt}");
311                println!("end price: {min_output_fmt}");
312                display_auction_description(&asset_cache, auction_descriptions.clone());
313
314                let mut planner = Planner::new(OsRng);
315                planner
316                    .set_gas_prices(gas_prices)
317                    .set_fee_tier((*fee_tier).into());
318
319                for description in &auction_descriptions {
320                    planner.dutch_auction_schedule(description.clone());
321                }
322
323                if *debug {
324                    let debug_data_path = Path::new("gda-debug-definition-data.json");
325                    let auction_data_path = Path::new("gda-debug-auction-data.json");
326
327                    let gda_debug_data = serde_json::to_string(&gda)?;
328                    std::fs::write(debug_data_path, gda_debug_data)?;
329
330                    let gda_auction_data = serde_json::to_string(
331                        &auction_descriptions
332                            .clone()
333                            .into_iter()
334                            .map(Into::<debug::DebugDescription>::into)
335                            .collect::<Vec<_>>(),
336                    )?;
337                    std::fs::write(auction_data_path, gda_auction_data)?;
338                    tracing::debug!(?debug_data_path, ?auction_data_path, "wrote debug data");
339                    return Ok(());
340                }
341
342                let plan = planner
343                    .plan(
344                        app.view
345                            .as_mut()
346                            .context("view service must be initialized")?,
347                        AddressIndex::new(*source),
348                    )
349                    .await
350                    .context("can't build send transaction")?;
351
352                let tx = app.build_transaction(plan.clone()).await?;
353                let fee_fmt = tx
354                    .transaction_body
355                    .transaction_parameters
356                    .fee
357                    .0
358                    .format(&asset_cache);
359
360                println!("Total fee: {fee_fmt}");
361
362                if !yes {
363                    Confirm::new()
364                        .with_prompt("Do you wish to proceed")
365                        .interact()?;
366                }
367                app.build_and_submit_transaction(plan).await?;
368
369                Ok(())
370            }
371        }
372    }
373}
374
375async fn all_dutch_auction_states(
376    view_client: &mut impl ViewClient,
377    source: impl Into<AddressIndex>,
378) -> Result<Vec<(AuctionId, DutchAuction, u64)>> {
379    fetch_dutch_auction_states(view_client, source, true).await
380}
381
382async fn active_dutch_auction_states(
383    view_client: &mut impl ViewClient,
384    source: impl Into<AddressIndex>,
385) -> Result<Vec<(AuctionId, DutchAuction, u64)>> {
386    fetch_dutch_auction_states(view_client, source, false).await
387}
388
389async fn fetch_dutch_auction_states(
390    view_client: &mut impl ViewClient,
391    source: impl Into<AddressIndex>,
392    include_inactive: bool,
393) -> Result<Vec<(AuctionId, DutchAuction, u64)>> {
394    let auctions = view_client
395        .auctions(Some(source.into()), include_inactive, true)
396        .await?
397        .into_iter()
398        .filter_map(|(id, _, local_seq, state, _)| {
399            if let Some(state) = state {
400                if let Ok(da) = DutchAuction::decode(state.value) {
401                    Some((id, da, local_seq))
402                } else {
403                    None
404                }
405            } else {
406                None
407            }
408        })
409        .collect();
410    Ok(auctions)
411}
412/// Return all the auctions that need to be ended, based on our local view of the chain state.
413async fn auctions_to_end(view_client: &mut impl ViewClient, source: u32) -> Result<Vec<AuctionId>> {
414    let auctions = active_dutch_auction_states(view_client, source).await?;
415
416    let auction_ids = auctions
417        .into_iter()
418        .filter_map(|(id, _auction, local_seq)| {
419            // We want to end auctions that we track as "opened" (local_seq == 0)
420            // so that we can close them, or catch-up with the chain state if they are already closed.
421            if local_seq == 0 {
422                Some(id)
423            } else {
424                None
425            }
426        })
427        .collect();
428
429    Ok(auction_ids)
430}
431
432async fn auctions_to_withdraw(
433    view_client: &mut impl ViewClient,
434    source: u32,
435) -> Result<Vec<DutchAuction>> {
436    let auctions = all_dutch_auction_states(view_client, source).await?;
437
438    let auction_ids = auctions
439        .into_iter()
440        .filter_map(|(_, auction, local_seq)| {
441            // We want to end auctions that we track as "closed" (local_seq == 1)
442            // so that we can close them, or catch-up with the chain state if they are already closed.
443            if local_seq == 1 {
444                Some(auction)
445            } else {
446                None
447            }
448        })
449        .collect();
450
451    Ok(auction_ids)
452}
453
454fn display_auction_description(asset_cache: &Cache, auctions: Vec<DutchAuctionDescription>) {
455    let mut tally_max_output = Amount::zero();
456    let mut tally_min_output = Amount::zero();
457    let mut tally_input = Amount::zero();
458    let input_id = auctions[0].input.asset_id;
459    let output_id = auctions[0].output_id;
460
461    let mut table = comfy_table::Table::new();
462    table.load_preset(presets::NOTHING);
463
464    table.set_header(vec![
465        "start",
466        "",
467        "end",
468        "lot",
469        "start price for the lot",
470        "reserve price for the lot",
471    ]);
472
473    for auction in auctions {
474        let start_height = auction.start_height;
475        let end_height = auction.end_height;
476        let input_chunk = Value {
477            asset_id: auction.input.asset_id,
478            amount: Amount::from(auction.input.amount.value()),
479        };
480
481        let max_price = Value {
482            asset_id: auction.output_id,
483            amount: Amount::from(auction.max_output.value()),
484        };
485
486        let min_price = Value {
487            asset_id: auction.output_id,
488            amount: Amount::from(auction.min_output.value()),
489        };
490
491        let max_price_fmt = max_price.format(&asset_cache);
492        let min_price_fmt = min_price.format(&asset_cache);
493
494        let input_chunk_fmt = input_chunk.format(&asset_cache);
495
496        tally_input += input_chunk.amount;
497        tally_max_output += max_price.amount;
498        tally_min_output += min_price.amount;
499
500        table.add_row(vec![
501            format!("{start_height}"),
502            "--------->".to_string(),
503            format!("{end_height}"),
504            input_chunk_fmt,
505            max_price_fmt,
506            min_price_fmt,
507        ]);
508    }
509
510    println!("{}", table);
511
512    let tally_input_fmt = Value {
513        asset_id: input_id,
514        amount: tally_input,
515    }
516    .format(&asset_cache);
517
518    let tally_output_max_fmt = Value {
519        asset_id: output_id,
520        amount: tally_max_output,
521    }
522    .format(&asset_cache);
523
524    let tally_output_min_fmt = Value {
525        asset_id: output_id,
526        amount: tally_min_output,
527    }
528    .format(&asset_cache);
529
530    println!("Total auctioned: {tally_input_fmt}");
531    println!("Total max output: {tally_output_max_fmt}");
532    println!("Total min output: {tally_output_min_fmt}");
533}