Skip to main content

fedimint_mint_client/
cli.rs

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 out of band notes
29    Reissue { notes: OOBNotes },
30    /// Prepare notes to send to a third party as a payment
31    Spend {
32        /// The amount of e-cash to spend
33        amount: Amount,
34        /// If the exact amount cannot be represented, return e-cash of a higher
35        /// value instead of failing
36        #[clap(long)]
37        allow_overpay: bool,
38        /// After how many seconds we will try to reclaim the e-cash if it
39        /// hasn't been redeemed by the recipient. Defaults to one week.
40        #[clap(long, default_value_t = 60 * 60 * 24 * 7)]
41        timeout: u64,
42        /// If the necessary information to join the federation the e-cash
43        /// belongs to should be included in the serialized notes
44        #[clap(long)]
45        include_invite: bool,
46    },
47    /// Splits a string containing multiple e-cash notes (e.g. from the `spend`
48    /// command) into ones that contain exactly one.
49    Split { oob_notes: OOBNotes },
50    /// Combines two or more serialized e-cash notes strings
51    Combine {
52        #[clap(required = true)]
53        oob_notes: Vec<OOBNotes>,
54    },
55    /// Verifies the signatures of e-cash notes, if the online flag is specified
56    /// it also checks with the mint if the notes were already spent
57    Validate {
58        /// Whether to check with the mint if the notes were already spent
59        /// (CAUTION: this hurts privacy)
60        #[clap(long)]
61        online: bool,
62        /// E-Cash note to validate
63        oob_notes: OOBNotes,
64    },
65    /// Debugging commands querying the federation directly
66    Dev {
67        #[clap(subcommand)]
68        command: DevOpts,
69    },
70}
71
72#[derive(Subcommand, Serialize)]
73enum DevOpts {
74    /// Ask every guardian if a note's nonce has already been spent
75    ///
76    /// Accepts either a hex-encoded nonce (33 byte compressed secp256k1 public
77    /// key) or an out-of-band e-cash notes string, in which case every nonce it
78    /// contains is checked.
79    CheckNonce {
80        /// Hex-encoded nonce or e-cash notes string
81        nonce: String,
82    },
83    /// Ask every guardian if e-cash has already been issued for a blind nonce
84    ///
85    /// Accepts a hex-encoded blind nonce (48 byte compressed BLS12-381 G1
86    /// point). Note that the human-readable form logged for a blind nonce is a
87    /// SHA256 digest of it and can not be used here.
88    CheckBlindNonce {
89        /// Hex-encoded blind nonce
90        blind_nonce: String,
91    },
92}
93
94/// A single guardian's answer to a nonce or blind nonce query
95#[derive(Serialize)]
96#[serde(untagged)]
97enum PeerCheckResult {
98    Answer(bool),
99    Error(String),
100}
101
102/// Asks every guardian if `nonce` was already spent.
103///
104/// Peers that fail to answer are reported as errors instead of failing the
105/// whole query, since seeing the remaining guardians' answers is the point.
106async 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
127/// Asks every guardian if e-cash was already issued for `blind_nonce`.
128async 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/// A failure of a `mint` module command.
341#[derive(Debug, thiserror::Error)]
342pub(crate) enum CliCommandError {
343    /// The notes could not be reissued.
344    #[error(transparent)]
345    Reissue(#[from] ReissueExternalNotesError),
346
347    /// The reissue transaction failed.
348    #[error("Reissue failed: {0}")]
349    ReissueFailed(String),
350
351    /// The notes to spend could not be selected or prepared.
352    #[error(transparent)]
353    Spend(#[from] SpendOOBError),
354
355    /// Notes of different federations were given to combine.
356    #[error("Trying to combine e-cash from different federations: {first} and {other}")]
357    MixedFederations {
358        first: FederationIdPrefix,
359        other: FederationIdPrefix,
360    },
361
362    /// The notes to validate are invalid.
363    #[error(transparent)]
364    Validate(#[from] ValidateNotesError),
365
366    /// The federation could not say whether the notes are spent.
367    #[error(transparent)]
368    Federation(#[from] FederationError),
369
370    /// The argument of `dev check-nonce` is neither a nonce nor e-cash.
371    #[error("Argument is neither a hex-encoded nonce nor an e-cash notes string")]
372    InvalidNonceArgument(#[source] OOBNotesParseError),
373
374    /// The argument of `dev check-blind-nonce` is not a blind nonce.
375    #[error("Argument is not a hex-encoded blind nonce")]
376    InvalidBlindNonce(#[source] DecodeError),
377}
378
379#[cfg(test)]
380mod tests;