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#[derive(Debug, Subcommand)]
28pub enum DutchCmd {
29 #[clap(display_order = 1000, name = "gradual")]
31 DutchAuctionGradualSchedule {
32 #[clap(long, display_order = 100, default_value = "0")]
34 source: u32,
35 #[clap(long, display_order = 200)]
37 input: String,
38 #[clap(long, display_order = 400)]
42 max_output: String,
43 #[clap(long, display_order = 500)]
47 min_output: String,
48 #[clap(arg_enum, long, display_order = 600, name = "duration")]
50 recipe: gda::GdaRecipe,
51 #[clap(long, display_order = 700)]
53 yes: bool,
54 #[clap(short, long, default_value_t, display_order = 1000)]
56 fee_tier: FeeTier,
57 #[clap(long, hide = true)]
58 debug: bool,
60 },
61 #[clap(display_order = 100, name = "schedule")]
63 DutchAuctionSchedule {
64 #[clap(long, display_order = 100, default_value = "0")]
66 source: u32,
67 #[clap(long, display_order = 200)]
69 input: String,
70 #[clap(long, display_order = 400)]
74 max_output: String,
75 #[clap(long, display_order = 500)]
79 min_output: String,
80 #[clap(long, display_order = 600)]
84 start_height: u64,
85 #[clap(long, display_order = 700)]
90 end_height: u64,
91 #[clap(long, display_order = 800)]
95 step_count: u64,
96 #[clap(short, long, default_value_t, display_order = 1000)]
98 fee_tier: FeeTier,
99 },
100 #[clap(display_order = 300, name = "end")]
102 DutchAuctionEnd {
103 #[clap(long, display_order = 100, default_value = "0")]
105 source: u32,
106 #[clap(long, display_order = 150)]
108 all: bool,
109 #[clap(display_order = 200)]
111 auction_ids: Vec<AuctionId>,
112 #[clap(long, default_value = "20", display_order = 250)]
114 batch: u8,
115 #[clap(short, long, default_value_t, display_order = 300)]
117 fee_tier: FeeTier,
118 },
119 #[clap(display_order = 200, name = "withdraw")]
121 DutchAuctionWithdraw {
122 #[clap(long, display_order = 100, default_value = "0")]
124 source: u32,
125 #[clap(long, display_order = 150)]
127 all: bool,
128 #[clap(display_order = 200)]
130 auction_ids: Vec<AuctionId>,
131 #[clap(long, default_value = "20", display_order = 250)]
133 batch: u8,
134 #[clap(short, long, default_value_t, display_order = 600)]
136 fee_tier: FeeTier,
137 },
138}
139
140impl DutchCmd {
141 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 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 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}
465async 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 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 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}