Skip to main content

fedimint_wallet_client/
api.rs

1use std::future::ready;
2
3use bitcoin::{Address, Amount};
4use fedimint_api_client::api::{
5    FederationApiExt, FederationError, FederationGeneralError, FederationResult,
6    IModuleFederationApi,
7};
8use fedimint_api_client::query::{FilterMapThreshold, ThresholdAgreement};
9use fedimint_core::envs::BitcoinRpcConfig;
10use fedimint_core::module::{ApiAuth, ApiRequestErased, ModuleConsensusVersion};
11use fedimint_core::task::{MaybeSend, MaybeSync};
12use fedimint_core::{NumPeersExt, PeerId, apply, async_trait_maybe_send};
13use fedimint_wallet_common::endpoint_constants::{
14    ACTIVATE_CONSENSUS_VERSION_VOTING_ENDPOINT, BITCOIN_KIND_ENDPOINT, BITCOIN_RPC_CONFIG_ENDPOINT,
15    BLOCK_COUNT_ENDPOINT, BLOCK_COUNT_LOCAL_ENDPOINT, MODULE_CONSENSUS_VERSION_ENDPOINT,
16    PEG_OUT_FEES_ENDPOINT, RECOVERY_COUNT_ENDPOINT, RECOVERY_SLICE_ENDPOINT,
17    UTXO_CONFIRMED_ENDPOINT, WALLET_SUMMARY_ENDPOINT,
18};
19use fedimint_wallet_common::{PegOutFees, RecoveryItem, WalletSummary};
20
21#[apply(async_trait_maybe_send!)]
22pub trait WalletFederationApi {
23    async fn module_consensus_version(&self) -> FederationResult<ModuleConsensusVersion>;
24
25    async fn fetch_consensus_block_count(&self) -> FederationResult<u64>;
26
27    async fn fetch_peg_out_fees(
28        &self,
29        address: &Address,
30        amount: Amount,
31    ) -> FederationResult<Option<PegOutFees>>;
32
33    async fn fetch_bitcoin_rpc_kind(&self, peer_id: PeerId) -> FederationResult<String>;
34
35    async fn fetch_bitcoin_rpc_config(&self, auth: ApiAuth) -> FederationResult<BitcoinRpcConfig>;
36
37    async fn fetch_wallet_summary(&self) -> FederationResult<WalletSummary>;
38
39    async fn fetch_block_count_local(&self) -> FederationResult<u32>;
40
41    async fn is_utxo_confirmed(&self, outpoint: bitcoin::OutPoint) -> FederationResult<bool>;
42
43    async fn activate_consensus_version_voting(&self, auth: ApiAuth) -> FederationResult<()>;
44
45    /// Returns the total number of recovery items stored on the federation
46    async fn fetch_recovery_count(&self) -> FederationResult<u64>;
47
48    /// Fetches recovery items in the range `[start, end)` via consensus.
49    ///
50    /// Retries each peer with backoff until a threshold of peers agree, so a
51    /// transient transport failure on one peer (on top of a peer that is
52    /// already offline) does not abort the whole recovery.
53    async fn fetch_recovery_slice(&self, start: u64, end: u64) -> Vec<RecoveryItem>;
54}
55
56#[apply(async_trait_maybe_send!)]
57impl<T: ?Sized> WalletFederationApi for T
58where
59    T: IModuleFederationApi + MaybeSend + MaybeSync + 'static,
60{
61    async fn module_consensus_version(&self) -> FederationResult<ModuleConsensusVersion> {
62        let response = self
63            .request_current_consensus(
64                MODULE_CONSENSUS_VERSION_ENDPOINT.to_string(),
65                ApiRequestErased::default(),
66            )
67            .await;
68
69        if let Err(e) = &response
70            && e.any_peer_error_method_not_found()
71        {
72            return Ok(ModuleConsensusVersion::new(2, 0));
73        }
74
75        response
76    }
77
78    async fn is_utxo_confirmed(&self, outpoint: bitcoin::OutPoint) -> FederationResult<bool> {
79        let res = self
80            .request_current_consensus(
81                UTXO_CONFIRMED_ENDPOINT.to_string(),
82                ApiRequestErased::new(outpoint),
83            )
84            .await;
85
86        if let Err(e) = &res
87            && e.any_peer_error_method_not_found()
88        {
89            return Ok(false);
90        }
91
92        res
93    }
94
95    async fn fetch_consensus_block_count(&self) -> FederationResult<u64> {
96        self.request_current_consensus(
97            BLOCK_COUNT_ENDPOINT.to_string(),
98            ApiRequestErased::default(),
99        )
100        .await
101    }
102
103    async fn fetch_block_count_local(&self) -> FederationResult<u32> {
104        let filter_map = |_peer: PeerId, block_count: Option<u32>| ready(Ok(block_count));
105
106        let block_count_responses = self
107            .request_with_strategy(
108                FilterMapThreshold::<Option<u32>, Option<u32>>::new(
109                    filter_map,
110                    self.all_peers().to_num_peers().threshold().into(),
111                ),
112                BLOCK_COUNT_LOCAL_ENDPOINT.to_string(),
113                ApiRequestErased::default(),
114            )
115            .await?;
116
117        let mut response: Vec<u32> = block_count_responses.into_values().flatten().collect();
118
119        if response.is_empty() {
120            return Err(FederationError::general(
121                BLOCK_COUNT_LOCAL_ENDPOINT.to_string(),
122                ApiRequestErased::default(),
123                FederationGeneralError::ThresholdFailed {
124                    message: "No valid block counts received".to_string(),
125                },
126            ));
127        }
128
129        response.sort_unstable();
130        let final_block_count = response[response.len() / 2];
131
132        Ok(final_block_count)
133    }
134
135    async fn fetch_peg_out_fees(
136        &self,
137        address: &Address,
138        amount: Amount,
139    ) -> FederationResult<Option<PegOutFees>> {
140        let params = ApiRequestErased::new((address, amount.to_sat()));
141
142        // Deliberately not `request_current_consensus`. The quote is a pure
143        // function of the ordered consensus log, so peers in sync always agree;
144        // one that has fallen behind never converges, and `ThresholdConsensus`
145        // would re-request it forever while logging nothing at all.
146        let quotes = match self
147            .request_with_strategy(
148                ThresholdAgreement::new(self.all_peers().to_num_peers()),
149                PEG_OUT_FEES_ENDPOINT.to_string(),
150                params.clone(),
151            )
152            .await?
153        {
154            Ok(fees) => return Ok(fees),
155            Err(quotes) => quotes,
156        };
157
158        let detail = quotes
159            .iter()
160            .map(|(peer, quote)| match quote {
161                Some(fees) => format!(
162                    "peer {peer}: {} sats/kvb, weight {}",
163                    fees.fee_rate.sats_per_kvb, fees.total_weight
164                ),
165                None => format!("peer {peer}: no quote"),
166            })
167            .collect::<Vec<_>>()
168            .join("; ");
169
170        Err(FederationError::general(
171            PEG_OUT_FEES_ENDPOINT.to_string(),
172            params,
173            FederationGeneralError::ThresholdFailed {
174                message: format!(
175                    "Guardians disagree on peg-out fees ({detail}). The quote is consensus \
176                     state, so a guardian returning a different value has diverged - most \
177                     often because its Bitcoin backend is lagging. The same rate validates \
178                     the peg-out, so it cannot be accepted until the federation agrees."
179                ),
180            },
181        ))
182    }
183
184    async fn fetch_bitcoin_rpc_kind(&self, peer_id: PeerId) -> FederationResult<String> {
185        self.request_single_peer_federation(
186            BITCOIN_KIND_ENDPOINT.to_string(),
187            ApiRequestErased::default(),
188            peer_id,
189        )
190        .await
191    }
192
193    async fn fetch_bitcoin_rpc_config(&self, auth: ApiAuth) -> FederationResult<BitcoinRpcConfig> {
194        self.request_admin(
195            BITCOIN_RPC_CONFIG_ENDPOINT,
196            ApiRequestErased::default(),
197            auth,
198        )
199        .await
200    }
201
202    async fn fetch_wallet_summary(&self) -> FederationResult<WalletSummary> {
203        self.request_current_consensus(
204            WALLET_SUMMARY_ENDPOINT.to_string(),
205            ApiRequestErased::default(),
206        )
207        .await
208    }
209
210    async fn activate_consensus_version_voting(&self, auth: ApiAuth) -> FederationResult<()> {
211        self.request_admin(
212            ACTIVATE_CONSENSUS_VERSION_VOTING_ENDPOINT,
213            ApiRequestErased::default(),
214            auth,
215        )
216        .await
217    }
218
219    async fn fetch_recovery_count(&self) -> FederationResult<u64> {
220        self.request_current_consensus::<u64>(
221            RECOVERY_COUNT_ENDPOINT.to_string(),
222            ApiRequestErased::default(),
223        )
224        .await
225    }
226
227    async fn fetch_recovery_slice(&self, start: u64, end: u64) -> Vec<RecoveryItem> {
228        self.request_current_consensus_retry::<Vec<RecoveryItem>>(
229            RECOVERY_SLICE_ENDPOINT.to_string(),
230            ApiRequestErased::new((start, end)),
231        )
232        .await
233    }
234}