Skip to main content

fedimint_wallet_client/
api.rs

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