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