1use std::collections::BTreeMap;
2use std::str::FromStr;
3use std::time::Duration;
4use std::{ffi, iter};
5
6use anyhow::{Context, bail};
7use clap::{Parser, Subcommand};
8use fedimint_core::encoding::{Decodable, Encodable};
9use fedimint_core::module::registry::ModuleDecoderRegistry;
10use fedimint_core::util::FmtCompact as _;
11use fedimint_core::{Amount, PeerId, TieredMulti};
12use futures::StreamExt;
13use futures::future::join_all;
14use serde::Serialize;
15use serde_json::json;
16use tracing::{info, warn};
17
18use crate::api::MintFederationApi;
19use crate::{
20 BlindNonce, MintClientModule, Nonce, OOBNotes, ReissueExternalNotesState,
21 SelectNotesWithAtleastAmount, SelectNotesWithExactAmount,
22};
23
24#[derive(Parser, Serialize)]
25enum Opts {
26 Reissue { notes: OOBNotes },
28 Spend {
30 amount: Amount,
32 #[clap(long)]
35 allow_overpay: bool,
36 #[clap(long, default_value_t = 60 * 60 * 24 * 7)]
39 timeout: u64,
40 #[clap(long)]
43 include_invite: bool,
44 },
45 Split { oob_notes: OOBNotes },
48 Combine {
50 #[clap(required = true)]
51 oob_notes: Vec<OOBNotes>,
52 },
53 Validate {
56 #[clap(long)]
59 online: bool,
60 oob_notes: OOBNotes,
62 },
63 Dev {
65 #[clap(subcommand)]
66 command: DevOpts,
67 },
68}
69
70#[derive(Subcommand, Serialize)]
71enum DevOpts {
72 CheckNonce {
78 nonce: String,
80 },
81 CheckBlindNonce {
87 blind_nonce: String,
89 },
90}
91
92#[derive(Serialize)]
94#[serde(untagged)]
95enum PeerCheckResult {
96 Answer(bool),
97 Error(String),
98}
99
100async fn check_nonce_spent(
105 mint: &MintClientModule,
106 nonce: Nonce,
107) -> BTreeMap<PeerId, PeerCheckResult> {
108 let api = mint.client_ctx.module_api();
109
110 join_all(api.all_peers().iter().map(|&peer| {
111 let api = &api;
112 async move {
113 let result = match api.check_note_spent_single_peer(peer, nonce).await {
114 Ok(spent) => PeerCheckResult::Answer(spent),
115 Err(e) => PeerCheckResult::Error(format!("error: {}", e.fmt_compact())),
116 };
117 (peer, result)
118 }
119 }))
120 .await
121 .into_iter()
122 .collect()
123}
124
125async fn check_blind_nonce_used(
127 mint: &MintClientModule,
128 blind_nonce: BlindNonce,
129) -> BTreeMap<PeerId, PeerCheckResult> {
130 let api = mint.client_ctx.module_api();
131
132 join_all(api.all_peers().iter().map(|&peer| {
133 let api = &api;
134 async move {
135 let result = match api
136 .check_blind_nonce_used_single_peer(peer, blind_nonce)
137 .await
138 {
139 Ok(used) => PeerCheckResult::Answer(used),
140 Err(e) => PeerCheckResult::Error(format!("error: {}", e.fmt_compact())),
141 };
142 (peer, result)
143 }
144 }))
145 .await
146 .into_iter()
147 .collect()
148}
149
150async fn check_nonce(mint: &MintClientModule, nonce: &str) -> anyhow::Result<serde_json::Value> {
151 if let Ok(nonce) = Nonce::consensus_decode_hex(nonce, &ModuleDecoderRegistry::default()) {
152 return Ok(json!({
153 "nonce": nonce.consensus_encode_to_hex(),
154 "spent": check_nonce_spent(mint, nonce).await,
155 }));
156 }
157
158 let oob_notes = OOBNotes::from_str(nonce)
159 .context("Argument is neither a hex-encoded nonce nor an e-cash notes string")?;
160
161 let mut nonces = Vec::new();
162 for (amount, note) in oob_notes.notes().iter_items() {
163 let nonce = note.nonce();
164 nonces.push(json!({
165 "nonce": nonce.consensus_encode_to_hex(),
166 "amount_msat": amount.msats,
167 "spent": check_nonce_spent(mint, nonce).await,
168 }));
169 }
170
171 Ok(json!({ "nonces": nonces }))
172}
173
174async fn check_blind_nonce(
175 mint: &MintClientModule,
176 blind_nonce: &str,
177) -> anyhow::Result<serde_json::Value> {
178 let blind_nonce =
179 BlindNonce::consensus_decode_hex(blind_nonce, &ModuleDecoderRegistry::default())
180 .context("Argument is not a hex-encoded blind nonce")?;
181
182 Ok(json!({
183 "blind_nonce": blind_nonce.consensus_encode_to_hex(),
184 "issued": check_blind_nonce_used(mint, blind_nonce).await,
185 }))
186}
187
188async fn spend(
189 mint: &MintClientModule,
190 amount: Amount,
191 allow_overpay: bool,
192 timeout: u64,
193 include_invite: bool,
194) -> anyhow::Result<serde_json::Value> {
195 warn!(
196 "The client will try to double-spend these notes after the timeout to reclaim \
197 any unclaimed e-cash."
198 );
199
200 let timeout = Duration::from_secs(timeout);
201 let (operation, notes) = if allow_overpay {
202 let (operation, notes) = mint
203 .spend_notes_with_selector(
204 &SelectNotesWithAtleastAmount,
205 amount,
206 Some(timeout),
207 include_invite,
208 (),
209 )
210 .await?;
211
212 let overspend_amount = notes.total_amount().saturating_sub(amount);
213 if overspend_amount != Amount::ZERO {
214 warn!("Selected notes {overspend_amount} worth more than requested");
215 }
216
217 (operation, notes)
218 } else {
219 mint.spend_notes_with_selector(
220 &SelectNotesWithExactAmount,
221 amount,
222 Some(timeout),
223 include_invite,
224 (),
225 )
226 .await?
227 };
228 info!("Spend e-cash operation: {}", operation.fmt_short());
229
230 Ok(json!({ "notes": notes }))
231}
232
233fn split(oob_notes: &OOBNotes) -> serde_json::Value {
234 let federation = oob_notes.federation_id_prefix();
235 let notes = oob_notes
236 .notes()
237 .iter()
238 .map(|(amount, notes)| {
239 let notes = notes
240 .iter()
241 .map(|note| {
242 OOBNotes::new(
243 federation,
244 TieredMulti::new(vec![(amount, vec![*note])].into_iter().collect()),
245 )
246 })
247 .collect::<Vec<_>>();
248 (amount, notes)
249 })
250 .collect::<BTreeMap<_, _>>();
251
252 json!({ "notes": notes })
253}
254
255fn combine(oob_notes: &[OOBNotes]) -> anyhow::Result<serde_json::Value> {
256 let federation_id_prefix = {
257 let mut prefixes = oob_notes.iter().map(OOBNotes::federation_id_prefix);
258 let first = prefixes
259 .next()
260 .expect("At least one e-cash notes string expected");
261 for prefix in prefixes {
262 if prefix != first {
263 bail!("Trying to combine e-cash from different federations: {first} and {prefix}");
264 }
265 }
266 first
267 };
268
269 let combined_notes = oob_notes
270 .iter()
271 .flat_map(|notes| notes.notes().iter_items().map(|(amt, note)| (amt, *note)))
272 .collect();
273
274 let combined_oob_notes = OOBNotes::new(federation_id_prefix, combined_notes);
275
276 Ok(json!({ "notes": combined_oob_notes }))
277}
278
279pub(crate) async fn handle_cli_command(
280 mint: &MintClientModule,
281 args: &[ffi::OsString],
282) -> anyhow::Result<serde_json::Value> {
283 let opts = Opts::parse_from(iter::once(&ffi::OsString::from("mint")).chain(args.iter()));
284
285 match opts {
286 Opts::Reissue { notes } => {
287 let amount = notes.total_amount();
288
289 let operation_id = mint.reissue_external_notes(notes, ()).await?;
290
291 let mut updates = mint
292 .subscribe_reissue_external_notes(operation_id)
293 .await
294 .unwrap()
295 .into_stream();
296
297 while let Some(update) = updates.next().await {
298 if let ReissueExternalNotesState::Failed(e) = update {
299 bail!("Reissue failed: {e}");
300 }
301 }
302
303 Ok(serde_json::to_value(amount).expect("JSON serialization failed"))
304 }
305 Opts::Spend {
306 amount,
307 allow_overpay,
308 timeout,
309 include_invite,
310 } => spend(mint, amount, allow_overpay, timeout, include_invite).await,
311 Opts::Split { oob_notes } => Ok(split(&oob_notes)),
312 Opts::Combine { oob_notes } => combine(&oob_notes),
313 Opts::Validate { oob_notes, online } => {
314 let amount = mint.validate_notes(&oob_notes)?;
315
316 if online {
317 let any_spent = mint.check_note_spent(&oob_notes).await?;
318 Ok(json!({
319 "any_spent": any_spent,
320 "amount_msat": amount,
321 }))
322 } else {
323 Ok(json!({ "amount_msat": amount }))
324 }
325 }
326 Opts::Dev { command } => match command {
327 DevOpts::CheckNonce { nonce } => check_nonce(mint, &nonce).await,
328 DevOpts::CheckBlindNonce { blind_nonce } => check_blind_nonce(mint, &blind_nonce).await,
329 },
330 }
331}
332
333#[cfg(test)]
334mod tests {
335 use bls12_381::G1Affine;
336 use tbs::BlindedMessage;
337
338 use super::*;
339
340 #[test]
343 fn nonce_hex_round_trip() {
344 let nonce_hex = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
345 let nonce = Nonce::consensus_decode_hex(nonce_hex, &ModuleDecoderRegistry::default())
346 .expect("Valid compressed public key");
347
348 assert_eq!(nonce.consensus_encode_to_hex(), nonce_hex);
349 }
350
351 #[test]
353 fn blind_nonce_hex_round_trip() {
354 let blind_nonce = BlindNonce(BlindedMessage(G1Affine::generator()));
355 let blind_nonce_hex = blind_nonce.consensus_encode_to_hex();
356
357 assert_eq!(blind_nonce_hex.len(), 96);
358 assert_eq!(
359 BlindNonce::consensus_decode_hex(&blind_nonce_hex, &ModuleDecoderRegistry::default())
360 .expect("Valid compressed G1 point"),
361 blind_nonce
362 );
363 }
364}