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 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 out of band notes
27    Reissue { notes: OOBNotes },
28    /// Prepare notes to send to a third party as a payment
29    Spend {
30        /// The amount of e-cash to spend
31        amount: Amount,
32        /// If the exact amount cannot be represented, return e-cash of a higher
33        /// value instead of failing
34        #[clap(long)]
35        allow_overpay: bool,
36        /// After how many seconds we will try to reclaim the e-cash if it
37        /// hasn't been redeemed by the recipient. Defaults to one week.
38        #[clap(long, default_value_t = 60 * 60 * 24 * 7)]
39        timeout: u64,
40        /// If the necessary information to join the federation the e-cash
41        /// belongs to should be included in the serialized notes
42        #[clap(long)]
43        include_invite: bool,
44    },
45    /// Splits a string containing multiple e-cash notes (e.g. from the `spend`
46    /// command) into ones that contain exactly one.
47    Split { oob_notes: OOBNotes },
48    /// Combines two or more serialized e-cash notes strings
49    Combine {
50        #[clap(required = true)]
51        oob_notes: Vec<OOBNotes>,
52    },
53    /// Verifies the signatures of e-cash notes, if the online flag is specified
54    /// it also checks with the mint if the notes were already spent
55    Validate {
56        /// Whether to check with the mint if the notes were already spent
57        /// (CAUTION: this hurts privacy)
58        #[clap(long)]
59        online: bool,
60        /// E-Cash note to validate
61        oob_notes: OOBNotes,
62    },
63    /// Debugging commands querying the federation directly
64    Dev {
65        #[clap(subcommand)]
66        command: DevOpts,
67    },
68}
69
70#[derive(Subcommand, Serialize)]
71enum DevOpts {
72    /// Ask every guardian if a note's nonce has already been spent
73    ///
74    /// Accepts either a hex-encoded nonce (33 byte compressed secp256k1 public
75    /// key) or an out-of-band e-cash notes string, in which case every nonce it
76    /// contains is checked.
77    CheckNonce {
78        /// Hex-encoded nonce or e-cash notes string
79        nonce: String,
80    },
81    /// Ask every guardian if e-cash has already been issued for a blind nonce
82    ///
83    /// Accepts a hex-encoded blind nonce (48 byte compressed BLS12-381 G1
84    /// point). Note that the human-readable form logged for a blind nonce is a
85    /// SHA256 digest of it and can not be used here.
86    CheckBlindNonce {
87        /// Hex-encoded blind nonce
88        blind_nonce: String,
89    },
90}
91
92/// A single guardian's answer to a nonce or blind nonce query
93#[derive(Serialize)]
94#[serde(untagged)]
95enum PeerCheckResult {
96    Answer(bool),
97    Error(String),
98}
99
100/// Asks every guardian if `nonce` was already spent.
101///
102/// Peers that fail to answer are reported as errors instead of failing the
103/// whole query, since seeing the remaining guardians' answers is the point.
104async 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
125/// Asks every guardian if e-cash was already issued for `blind_nonce`.
126async 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    /// The hex `dev check-nonce` accepts is the plain compressed public key, so
341    /// it matches what the JSON representation of a nonce shows.
342    #[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    /// Same for `dev check-blind-nonce` and the compressed G1 point.
352    #[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}