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