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