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        /// Identifiers of the auctions to end, if `--all` is not set.
110        #[clap(display_order = 200)]
111        auction_ids: Vec<AuctionId>,
112        /// Maximum number of auctions to process in a single transaction.
113        #[clap(long, default_value = "20", display_order = 250)]
114        batch: u8,
115        /// The selected fee tier to multiply the fee amount by.
116        #[clap(short, long, default_value_t, display_order = 300)]
117        fee_tier: FeeTier,
118    },
119    /// Withdraw a Dutch auction, and claim its reserves.
120    #[clap(display_order = 200, name = "withdraw")]
121    DutchAuctionWithdraw {
122        /// Source account withdrawing from the auction.
123        #[clap(long, display_order = 100, default_value = "0")]
124        source: u32,
125        /// If set, withdraws all auctions owned by the specified account.
126        #[clap(long, display_order = 150)]
127        all: bool,
128        /// Identifiers of the auctions to withdraw, if `--all` is not set.
129        #[clap(display_order = 200)]
130        auction_ids: Vec<AuctionId>,
131        /// Maximum number of auctions to process in a single transaction.
132        #[clap(long, default_value = "20", display_order = 250)]
133        batch: u8,
134        /// The selected fee tier to multiply the fee amount by.
135        #[clap(short, long, default_value_t, display_order = 600)]
136        fee_tier: FeeTier,
137    },
138}
139
140impl DutchCmd {
141    /// Process the command by performing the appropriate action.
142    pub async fn exec(&self, app: &mut App) -> anyhow::Result<()> {
143        let gas_prices = app
144            .view
145            .as_mut()
146            .context("view service must be initialized")?
147            .gas_prices(GasPricesRequest {})
148            .await?
149            .into_inner()
150            .gas_prices
151            .expect("gas prices must be available")
152            .try_into()?;
153
154        match self {
155            DutchCmd::DutchAuctionSchedule {
156                source,
157                input,
158                max_output,
159                min_output,
160                start_height,
161                end_height,
162                step_count,
163                fee_tier,
164            } => {
165                let mut nonce = [0u8; 32];
166                OsRng.fill_bytes(&mut nonce);
167
168                let input = input.parse::<Value>()?;
169                let max_output = max_output.parse::<Value>()?;
170                let min_output = min_output.parse::<Value>()?;
171                let output_id = max_output.asset_id;
172
173                let plan = Planner::new(OsRng)
174                    .set_gas_prices(gas_prices)
175                    .set_fee_tier((*fee_tier).into())
176                    .dutch_auction_schedule(DutchAuctionDescription {
177                        input,
178                        output_id,
179                        max_output: max_output.amount,
180                        min_output: min_output.amount,
181                        start_height: *start_height,
182                        end_height: *end_height,
183                        step_count: *step_count,
184                        nonce,
185                    })
186                    .plan(
187                        app.view
188                            .as_mut()
189                            .context("view service must be initialized")?,
190                        AddressIndex::new(*source),
191                    )
192                    .await
193                    .context("can't build auction schedule transaction")?;
194                app.build_and_submit_transaction(plan).await?;
195                Ok(())
196            }
197            DutchCmd::DutchAuctionEnd {
198                all,
199                auction_ids,
200                source,
201                batch,
202                fee_tier,
203            } => {
204                let auction_ids = match (all, auction_ids.is_empty()) {
205                    (true, _) => auctions_to_end(app.view(), *source).await?,
206                    (false, false) => auction_ids.to_owned(),
207                    (false, true) => {
208                        bail!("auction_ids are required when --all is not set")
209                    }
210                };
211
212                if auction_ids.is_empty() {
213                    println!("no active auctions to end");
214                    return Ok(());
215                }
216
217                // Process auctions in batches
218                let batches = auction_ids.chunks(*batch as usize);
219                let num_batches = &batches.len();
220                for (batch_num, auction_batch) in batches.enumerate() {
221                    println!(
222                        "processing batch {} of {}, starting with {}",
223                        batch_num + 1,
224                        num_batches,
225                        batch_num * *batch as usize
226                    );
227
228                    if auction_batch.is_empty() {
229                        continue;
230                    }
231
232                    let mut planner = Planner::new(OsRng);
233                    planner
234                        .set_gas_prices(gas_prices)
235                        .set_fee_tier((*fee_tier).into());
236
237                    for auction_id in auction_batch {
238                        planner.dutch_auction_end(*auction_id);
239                    }
240
241                    let plan = planner
242                        .plan(
243                            app.view
244                                .as_mut()
245                                .context("view service must be initialized")?,
246                            AddressIndex::new(*source),
247                        )
248                        .await
249                        .context("can't build auction end transaction")?;
250                    app.build_and_submit_transaction(plan).await?;
251                }
252                Ok(())
253            }
254            DutchCmd::DutchAuctionWithdraw {
255                all,
256                source,
257                auction_ids,
258                batch,
259                fee_tier,
260            } => {
261                let auctions = match (all, auction_ids.is_empty()) {
262                    (true, _) => auctions_to_withdraw(app.view(), *source).await?,
263                    (false, false) => {
264                        let all = auctions_to_withdraw(app.view(), *source).await?;
265                        let mut selected_auctions = Vec::new();
266
267                        for auction_id in auction_ids {
268                            let auction = all
269                                .iter()
270                                .find(|a| a.description.id() == *auction_id)
271                                .ok_or_else(|| {
272                                    anyhow!(
273                                        "auction id {} is unknown from the view service!",
274                                        auction_id
275                                    )
276                                })?
277                                .to_owned();
278                            selected_auctions.push(auction);
279                        }
280
281                        selected_auctions
282                    }
283                    (false, true) => {
284                        bail!("auction_ids are required when --all is not set")
285                    }
286                };
287
288                if auctions.is_empty() {
289                    println!("no ended auctions to withdraw");
290                    return Ok(());
291                }
292
293                let batches = auctions.chunks(*batch as usize);
294                let num_batches = &batches.len();
295                // Process auctions in batches
296                for (batch_num, auction_batch) in batches.enumerate() {
297                    println!(
298                        "processing batch {} of {}, starting with {}",
299                        batch_num + 1,
300                        num_batches,
301                        batch_num * *batch as usize
302                    );
303                    if auction_batch.is_empty() {
304                        continue;
305                    }
306
307                    let mut planner = Planner::new(OsRng);
308                    planner
309                        .set_gas_prices(gas_prices)
310                        .set_fee_tier((*fee_tier).into());
311
312                    for auction in auction_batch {
313                        planner.dutch_auction_withdraw(auction);
314                    }
315
316                    let plan = planner
317                        .plan(
318                            app.view
319                                .as_mut()
320                                .context("view service must be initialized")?,
321                            AddressIndex::new(*source),
322                        )
323                        .await
324                        .context("can't build auction withdrawal transaction")?;
325                    app.build_and_submit_transaction(plan).await?;
326                }
327                Ok(())
328            }
329            DutchCmd::DutchAuctionGradualSchedule {
330                source,
331                input: input_str,
332                max_output: max_output_str,
333                min_output: min_output_str,
334                recipe: duration,
335                yes,
336                fee_tier,
337                debug,
338            } => {
339                println!("Gradual dutch auction prototype");
340
341                let input = input_str.parse::<Value>()?;
342                let max_output = max_output_str.parse::<Value>()?;
343                let min_output = min_output_str.parse::<Value>()?;
344
345                let asset_cache = app.view().assets().await?;
346                let current_height = app.view().status().await?.full_sync_height;
347
348                let gda = gda::GradualAuction::new(
349                    input,
350                    max_output,
351                    min_output,
352                    duration.clone(),
353                    current_height,
354                );
355
356                let auction_descriptions = gda.generate_auctions();
357
358                let input_fmt = input.format(&asset_cache);
359                let max_output_fmt = max_output.format(&asset_cache);
360                let min_output_fmt = min_output.format(&asset_cache);
361
362                println!("total to auction: {input_fmt}");
363                println!("start price: {max_output_fmt}");
364                println!("end price: {min_output_fmt}");
365                display_auction_description(&asset_cache, auction_descriptions.clone());
366
367                let mut planner = Planner::new(OsRng);
368                planner
369                    .set_gas_prices(gas_prices)
370                    .set_fee_tier((*fee_tier).into());
371
372                for description in &auction_descriptions {
373                    planner.dutch_auction_schedule(description.clone());
374                }
375
376                if *debug {
377                    let debug_data_path = Path::new("gda-debug-definition-data.json");
378                    let auction_data_path = Path::new("gda-debug-auction-data.json");
379
380                    let gda_debug_data = serde_json::to_string(&gda)?;
381                    std::fs::write(debug_data_path, gda_debug_data)?;
382
383                    let gda_auction_data = serde_json::to_string(
384                        &auction_descriptions
385                            .clone()
386                            .into_iter()
387                            .map(Into::<debug::DebugDescription>::into)
388                            .collect::<Vec<_>>(),
389                    )?;
390                    std::fs::write(auction_data_path, gda_auction_data)?;
391                    tracing::debug!(?debug_data_path, ?auction_data_path, "wrote debug data");
392                    return Ok(());
393                }
394
395                let plan = planner
396                    .plan(
397                        app.view
398                            .as_mut()
399                            .context("view service must be initialized")?,
400                        AddressIndex::new(*source),
401                    )
402                    .await
403                    .context("can't build send transaction")?;
404
405                let tx = app.build_transaction(plan.clone()).await?;
406                let fee_fmt = tx
407                    .transaction_body
408                    .transaction_parameters
409                    .fee
410                    .0
411                    .format(&asset_cache);
412
413                println!("Total fee: {fee_fmt}");
414
415                if !yes {
416                    Confirm::new()
417                        .with_prompt("Do you wish to proceed")
418                        .interact()?;
419                }
420                app.build_and_submit_transaction(plan).await?;
421
422                Ok(())
423            }
424        }
425    }
426}
427
428async fn all_dutch_auction_states(
429    view_client: &mut impl ViewClient,
430    source: impl Into<AddressIndex>,
431) -> Result<Vec<(AuctionId, DutchAuction, u64)>> {
432    fetch_dutch_auction_states(view_client, source, true).await
433}
434
435async fn active_dutch_auction_states(
436    view_client: &mut impl ViewClient,
437    source: impl Into<AddressIndex>,
438) -> Result<Vec<(AuctionId, DutchAuction, u64)>> {
439    fetch_dutch_auction_states(view_client, source, false).await
440}
441
442async fn fetch_dutch_auction_states(
443    view_client: &mut impl ViewClient,
444    source: impl Into<AddressIndex>,
445    include_inactive: bool,
446) -> Result<Vec<(AuctionId, DutchAuction, u64)>> {
447    let auctions = view_client
448        .auctions(Some(source.into()), include_inactive, true)
449        .await?
450        .into_iter()
451        .filter_map(|(id, _, local_seq, state, _)| {
452            if let Some(state) = state {
453                if let Ok(da) = DutchAuction::decode(state.value) {
454                    Some((id, da, local_seq))
455                } else {
456                    None
457                }
458            } else {
459                None
460            }
461        })
462        .collect();
463    Ok(auctions)
464}
465/// Return all the auctions that need to be ended, based on our local view of the chain state.
466async fn auctions_to_end(view_client: &mut impl ViewClient, source: u32) -> Result<Vec<AuctionId>> {
467    let auctions = active_dutch_auction_states(view_client, source).await?;
468
469    let auction_ids = auctions
470        .into_iter()
471        .filter_map(|(id, _auction, local_seq)| {
472            // We want to end auctions that we track as "opened" (local_seq == 0)
473            // so that we can close them, or catch-up with the chain state if they are already closed.
474            if local_seq == 0 {
475                Some(id)
476            } else {
477                None
478            }
479        })
480        .collect();
481
482    Ok(auction_ids)
483}
484
485async fn auctions_to_withdraw(
486    view_client: &mut impl ViewClient,
487    source: u32,
488) -> Result<Vec<DutchAuction>> {
489    let auctions = all_dutch_auction_states(view_client, source).await?;
490
491    let auction_ids = auctions
492        .into_iter()
493        .filter_map(|(_, auction, local_seq)| {
494            // We want to end auctions that we track as "closed" (local_seq == 1)
495            // so that we can close them, or catch-up with the chain state if they are already closed.
496            if local_seq == 1 {
497                Some(auction)
498            } else {
499                None
500            }
501        })
502        .collect();
503
504    Ok(auction_ids)
505}
506
507fn display_auction_description(asset_cache: &Cache, auctions: Vec<DutchAuctionDescription>) {
508    let mut tally_max_output = Amount::zero();
509    let mut tally_min_output = Amount::zero();
510    let mut tally_input = Amount::zero();
511    let input_id = auctions[0].input.asset_id;
512    let output_id = auctions[0].output_id;
513
514    let mut table = comfy_table::Table::new();
515    table.load_preset(presets::NOTHING);
516
517    table.set_header(vec![
518        "start",
519        "",
520        "end",
521        "lot",
522        "start price for the lot",
523        "reserve price for the lot",
524    ]);
525
526    for auction in auctions {
527        let start_height = auction.start_height;
528        let end_height = auction.end_height;
529        let input_chunk = Value {
530            asset_id: auction.input.asset_id,
531            amount: Amount::from(auction.input.amount.value()),
532        };
533
534        let max_price = Value {
535            asset_id: auction.output_id,
536            amount: Amount::from(auction.max_output.value()),
537        };
538
539        let min_price = Value {
540            asset_id: auction.output_id,
541            amount: Amount::from(auction.min_output.value()),
542        };
543
544        let max_price_fmt = max_price.format(&asset_cache);
545        let min_price_fmt = min_price.format(&asset_cache);
546
547        let input_chunk_fmt = input_chunk.format(&asset_cache);
548
549        tally_input += input_chunk.amount;
550        tally_max_output += max_price.amount;
551        tally_min_output += min_price.amount;
552
553        table.add_row(vec![
554            format!("{start_height}"),
555            "--------->".to_string(),
556            format!("{end_height}"),
557            input_chunk_fmt,
558            max_price_fmt,
559            min_price_fmt,
560        ]);
561    }
562
563    println!("{}", table);
564
565    let tally_input_fmt = Value {
566        asset_id: input_id,
567        amount: tally_input,
568    }
569    .format(&asset_cache);
570
571    let tally_output_max_fmt = Value {
572        asset_id: output_id,
573        amount: tally_max_output,
574    }
575    .format(&asset_cache);
576
577    let tally_output_min_fmt = Value {
578        asset_id: output_id,
579        amount: tally_min_output,
580    }
581    .format(&asset_cache);
582
583    println!("Total auctioned: {tally_input_fmt}");
584    println!("Total max output: {tally_output_max_fmt}");
585    println!("Total min output: {tally_output_min_fmt}");
586}