Skip to main content

fedimint_wallet_client/
api.rs

1use bitcoin::{Address, Amount};
2use fedimint_api_client::api::{
3    FederationApiExt, FederationError, FederationGeneralError, FederationResult,
4    IModuleFederationApi, ServerResult,
5};
6use fedimint_api_client::query::{FilterMapThreshold, ThresholdAgreement};
7use fedimint_core::envs::BitcoinRpcConfig;
8use fedimint_core::module::{ApiAuth, ApiRequestErased, ModuleConsensusVersion};
9use fedimint_core::task::{MaybeSend, MaybeSync};
10use fedimint_core::{NumPeersExt, PeerId, apply, async_trait_maybe_send};
11use fedimint_wallet_common::endpoint_constants::{
12    ACTIVATE_CONSENSUS_VERSION_VOTING_ENDPOINT, BITCOIN_KIND_ENDPOINT, BITCOIN_RPC_CONFIG_ENDPOINT,
13    BLOCK_COUNT_ENDPOINT, BLOCK_COUNT_LOCAL_ENDPOINT, MODULE_CONSENSUS_VERSION_ENDPOINT,
14    PEG_OUT_FEES_ENDPOINT, RECOVERY_COUNT_ENDPOINT, RECOVERY_SLICE_ENDPOINT,
15    UTXO_CONFIRMED_ENDPOINT, WALLET_SUMMARY_ENDPOINT,
16};
17use fedimint_wallet_common::{PegOutFees, RecoveryItem, WalletSummary};
18
19#[apply(async_trait_maybe_send!)]
20pub trait WalletFederationApi {
21    async fn module_consensus_version(&self) -> FederationResult<ModuleConsensusVersion>;
22
23    async fn fetch_consensus_block_count(&self) -> FederationResult<u64>;
24
25    async fn fetch_peg_out_fees(
26        &self,
27        address: &Address,
28        amount: Amount,
29    ) -> FederationResult<Option<PegOutFees>>;
30
31    async fn fetch_bitcoin_rpc_kind(&self, peer_id: PeerId) -> FederationResult<String>;
32
33    async fn fetch_bitcoin_rpc_config(&self, auth: ApiAuth) -> FederationResult<BitcoinRpcConfig>;
34
35    async fn fetch_wallet_summary(&self) -> FederationResult<WalletSummary>;
36
37    async fn fetch_block_count_local(&self) -> FederationResult<u32>;
38
39    async fn is_utxo_confirmed(&self, outpoint: bitcoin::OutPoint) -> FederationResult<bool>;
40
41    async fn activate_consensus_version_voting(&self, auth: ApiAuth) -> FederationResult<()>;
42
43    /// Returns the total number of recovery items stored on the federation
44    async fn fetch_recovery_count(&self) -> FederationResult<u64>;
45
46    /// Fetches recovery items in the range `[start, end)` via consensus.
47    ///
48    /// Retries each peer with backoff until a threshold of peers agree, so a
49    /// transient transport failure on one peer (on top of a peer that is
50    /// already offline) does not abort the whole recovery.
51    async fn fetch_recovery_slice(&self, start: u64, end: u64) -> Vec<RecoveryItem>;
52}
53
54#[apply(async_trait_maybe_send!)]
55impl<T: ?Sized> WalletFederationApi for T
56where
57    T: IModuleFederationApi + MaybeSend + MaybeSync + 'static,
58{
59    async fn module_consensus_version(&self) -> FederationResult<ModuleConsensusVersion> {
60        let response = self
61            .request_current_consensus(
62                MODULE_CONSENSUS_VERSION_ENDPOINT.to_string(),
63                ApiRequestErased::default(),
64            )
65            .await;
66
67        if let Err(e) = &response
68            && e.any_peer_error_method_not_found()
69        {
70            return Ok(ModuleConsensusVersion::new(2, 0));
71        }
72
73        response
74    }
75
76    async fn is_utxo_confirmed(&self, outpoint: bitcoin::OutPoint) -> FederationResult<bool> {
77        let res = self
78            .request_current_consensus(
79                UTXO_CONFIRMED_ENDPOINT.to_string(),
80                ApiRequestErased::new(outpoint),
81            )
82            .await;
83
84        if let Err(e) = &res
85            && e.any_peer_error_method_not_found()
86        {
87            return Ok(false);
88        }
89
90        res
91    }
92
93    async fn fetch_consensus_block_count(&self) -> FederationResult<u64> {
94        self.request_current_consensus(
95            BLOCK_COUNT_ENDPOINT.to_string(),
96            ApiRequestErased::default(),
97        )
98        .await
99    }
100
101    async fn fetch_block_count_local(&self) -> FederationResult<u32> {
102        let filter_map = |_peer: PeerId, block_count: Option<u32>| -> ServerResult<Option<u32>> {
103            Ok(block_count)
104        };
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}