Skip to main content

fedimint_wallet_client/
cli.rs

1use std::str::FromStr as _;
2use std::{ffi, iter};
3
4use bitcoin::address::NetworkUnchecked;
5use clap::Parser;
6use fedimint_api_client::api::FederationError;
7use fedimint_client_module::error::{ModuleLookupError, TransactionSubmitError};
8use fedimint_core::BitcoinAmountOrAll;
9use fedimint_core::core::OperationId;
10use fedimint_core::encoding::Encodable;
11use futures::StreamExt;
12use serde::Serialize;
13use tracing::{debug, info};
14
15use super::WalletClientModule;
16use crate::api::WalletFederationApi;
17use crate::client_db::TweakIdx;
18use crate::{
19    DepositAddressError, MaxWithdrawableAmountError, PegInError, SubscribeWithdrawError,
20    WithdrawFeesError, WithdrawState,
21};
22
23#[derive(Parser, Serialize)]
24enum Opts {
25    /// Await a deposit on a given deposit address
26    AwaitDeposit {
27        addr: Option<String>,
28        #[arg(long)]
29        operation_id: Option<OperationId>,
30        #[arg(long)]
31        tweak_idx: Option<TweakIdx>,
32        /// Await more than just one deposit
33        #[arg(long, default_value = "1")]
34        num: usize,
35    },
36    GetConsensusBlockCount,
37    /// Returns the Bitcoin RPC kind
38    GetBitcoinRpcKind {
39        peer_id: u16,
40    },
41    /// Returns the Bitcoin RPC kind and URL, if authenticated
42    GetBitcoinRpcConfig,
43
44    NewDepositAddress,
45    /// Withdraw funds from the federation
46    Withdraw {
47        #[clap(long)]
48        amount: BitcoinAmountOrAll,
49        #[clap(long)]
50        address: bitcoin::Address<NetworkUnchecked>,
51    },
52    /// Trigger wallet address check (in the background)
53    RecheckDepositAddress {
54        addr: Option<bitcoin::Address<NetworkUnchecked>>,
55        #[arg(long)]
56        operation_id: Option<OperationId>,
57        #[arg(long)]
58        tweak_idx: Option<TweakIdx>,
59    },
60}
61
62async fn await_deposit(
63    module: &WalletClientModule,
64    addr: Option<String>,
65    operation_id: Option<OperationId>,
66    tweak_idx: Option<TweakIdx>,
67    num: usize,
68) -> Result<(), CliCommandError> {
69    if u32::from(addr.is_some())
70        + u32::from(operation_id.is_some())
71        + u32::from(tweak_idx.is_some())
72        != 1
73    {
74        return Err(CliCommandError::SelectorCount);
75    }
76    if let Some(tweak_idx) = tweak_idx {
77        module.await_num_deposits(tweak_idx, num).await?;
78    } else if let Some(operation_id) = operation_id {
79        module
80            .await_num_deposits_by_operation_id(operation_id, num)
81            .await?;
82    } else if let Some(addr) = addr {
83        if addr.len() == 64 {
84            eprintln!(
85                "Interpreting addr as an operation_id for backward compatibility. \
86                Use `--operation-id` from now on."
87            );
88            let operation_id = OperationId::from_str(&addr)?;
89            module
90                .await_num_deposits_by_operation_id(operation_id, num)
91                .await?;
92        } else {
93            let addr = bitcoin::Address::from_str(&addr)?;
94            module.await_num_deposits_by_address(addr, num).await?;
95        }
96    } else {
97        unreachable!()
98    }
99    Ok(())
100}
101
102async fn withdraw(
103    module: &WalletClientModule,
104    amount: BitcoinAmountOrAll,
105    address: bitcoin::Address<NetworkUnchecked>,
106) -> Result<serde_json::Value, CliCommandError> {
107    let address = address.require_network(module.get_network())?;
108    let (amount, fees) = match amount {
109        // The on-chain fee is only part of the cost of withdrawing everything:
110        // funding the peg-out output also incurs the federation's per-note
111        // fees. The returned fees are quoted at the returned amount, so they
112        // must be used together.
113        BitcoinAmountOrAll::All => {
114            let balance = module.client_ctx.get_balance_for_btc().await?;
115            module.max_withdrawable_amount(&address, balance).await?
116        }
117        BitcoinAmountOrAll::Amount(amount) => {
118            (amount, module.get_withdraw_fees(&address, amount).await?)
119        }
120    };
121    let absolute_fees = fees.amount();
122
123    info!("Attempting withdraw with fees: {fees:?}");
124
125    let operation_id = module.withdraw(&address, amount, fees, ()).await?;
126
127    let mut updates = module
128        .subscribe_withdraw_updates(operation_id)
129        .await?
130        .into_stream();
131
132    while let Some(update) = updates.next().await {
133        debug!(?update, "Withdraw state update");
134
135        match update {
136            WithdrawState::Succeeded(txid) => {
137                return Ok(serde_json::json!({
138                    "txid": txid.consensus_encode_to_hex(),
139                    "fees_sat": absolute_fees.to_sat(),
140                }));
141            }
142            WithdrawState::Failed(e) => {
143                return Err(CliCommandError::WithdrawFailed(e));
144            }
145            WithdrawState::Created => {}
146        }
147    }
148
149    unreachable!("Update stream ended without outcome");
150}
151
152pub(crate) async fn handle_cli_command(
153    module: &WalletClientModule,
154    args: &[ffi::OsString],
155) -> Result<serde_json::Value, CliCommandError> {
156    let opts = Opts::parse_from(iter::once(&ffi::OsString::from("wallet")).chain(args.iter()));
157
158    let res = match opts {
159        Opts::AwaitDeposit {
160            operation_id,
161            num,
162            addr,
163            tweak_idx,
164        } => {
165            await_deposit(module, addr, operation_id, tweak_idx, num).await?;
166            serde_json::Value::Bool(true)
167        }
168        Opts::GetBitcoinRpcKind { peer_id } => {
169            let kind = module
170                .module_api
171                .fetch_bitcoin_rpc_kind(peer_id.into())
172                .await?;
173            serde_json::to_value(kind).expect("JSON serialization failed")
174        }
175        Opts::GetBitcoinRpcConfig => {
176            let auth = module
177                .admin_auth
178                .clone()
179                .ok_or(CliCommandError::AdminAuthNotSet)?;
180            serde_json::to_value(module.module_api.fetch_bitcoin_rpc_config(auth).await?)
181                .expect("JSON serialization failed")
182        }
183        Opts::GetConsensusBlockCount => {
184            serde_json::to_value(module.module_api.fetch_consensus_block_count().await?)
185                .expect("JSON serialization failed")
186        }
187        Opts::RecheckDepositAddress {
188            addr,
189            operation_id,
190            tweak_idx,
191        } => {
192            if u32::from(addr.is_some())
193                + u32::from(operation_id.is_some())
194                + u32::from(tweak_idx.is_some())
195                != 1
196            {
197                return Err(CliCommandError::SelectorCount);
198            }
199            if let Some(tweak_idx) = tweak_idx {
200                module.recheck_pegin_address(tweak_idx).await?;
201            } else if let Some(operation_id) = operation_id {
202                module.recheck_pegin_address_by_op_id(operation_id).await?;
203            } else if let Some(addr) = addr {
204                module.recheck_pegin_address_by_address(addr).await?;
205            } else {
206                unreachable!()
207            }
208            serde_json::Value::Bool(true)
209        }
210        Opts::NewDepositAddress => {
211            let deposit_address = module.allocate_deposit_address_expert_only(()).await?;
212            serde_json::json! {
213                {
214                    "address": deposit_address.address,
215                    "operation_id": deposit_address.operation_id,
216                    "tweak_idx": deposit_address.tweak_idx.0
217                }
218            }
219        }
220        Opts::Withdraw { amount, address } => return withdraw(module, amount, address).await,
221    };
222
223    Ok(res)
224}
225
226/// A failure of a `wallet` module command.
227#[derive(Debug, thiserror::Error)]
228pub(crate) enum CliCommandError {
229    /// Not exactly one of the address, operation id and tweak index was
230    /// given.
231    #[error("One and only one of the selector arguments must be set")]
232    SelectorCount,
233
234    /// The deposits could not be awaited or rechecked.
235    #[error(transparent)]
236    PegIn(#[from] PegInError),
237
238    /// The argument is not a valid operation id.
239    #[error(transparent)]
240    OperationId(#[from] fedimint_core::hex::FromHexError),
241
242    /// The address is not valid, or not for the federation's network.
243    #[error(transparent)]
244    Address(#[from] bitcoin::address::ParseError),
245
246    /// The client's bitcoin balance could not be read.
247    #[error(transparent)]
248    Balance(#[from] ModuleLookupError),
249
250    /// The largest withdrawable amount could not be computed.
251    #[error(transparent)]
252    MaxWithdrawable(#[from] MaxWithdrawableAmountError),
253
254    /// The withdrawal fees could not be quoted.
255    #[error(transparent)]
256    WithdrawFees(#[from] WithdrawFeesError),
257
258    /// The withdrawal transaction could not be submitted.
259    #[error(transparent)]
260    Withdraw(#[from] TransactionSubmitError),
261
262    /// The withdrawal's updates could not be followed.
263    #[error(transparent)]
264    SubscribeWithdraw(#[from] SubscribeWithdrawError),
265
266    /// The withdrawal failed.
267    #[error("Withdraw failed: {0}")]
268    WithdrawFailed(String),
269
270    /// The federation did not serve the request.
271    #[error(transparent)]
272    Federation(#[from] FederationError),
273
274    /// The client has no admin credentials, which reading the Bitcoin RPC
275    /// config needs.
276    #[error("Admin auth not set")]
277    AdminAuthNotSet,
278
279    /// A deposit address could not be allocated.
280    #[error(transparent)]
281    DepositAddress(#[from] DepositAddressError),
282}