fedimint_walletv2_client/
api.rs1#[cfg(test)]
2mod tests;
3
4use std::collections::BTreeMap;
5
6use fedimint_api_client::api::{
7 FederationApiExt, FederationError, FederationGeneralError, FederationResult,
8 IModuleFederationApi,
9};
10use fedimint_api_client::query::ThresholdAgreement;
11use fedimint_core::module::ApiRequestErased;
12use fedimint_core::task::{MaybeSend, MaybeSync};
13use fedimint_core::{NumPeersExt, OutPoint, PeerId, apply, async_trait_maybe_send};
14use fedimint_walletv2_common::endpoint_constants::{
15 CONSENSUS_BLOCK_COUNT_ENDPOINT, CONSENSUS_FEERATE_ENDPOINT, FEDERATION_WALLET_ENDPOINT,
16 OUTPUT_INFO_SLICE_ENDPOINT, PENDING_OUTPUTS_ENDPOINT, PENDING_TRANSACTION_CHAIN_ENDPOINT,
17 RECEIVE_FEE_ENDPOINT, SEND_FEE_ENDPOINT, TRANSACTION_CHAIN_ENDPOINT, TRANSACTION_ID_ENDPOINT,
18};
19use fedimint_walletv2_common::{
20 FederationWallet, MempoolVisibility, OutputInfo, PendingOutput, PendingOutputs, TxInfo,
21};
22
23fn describe_fee_quotes(quotes: &BTreeMap<PeerId, Option<bitcoin::Amount>>) -> String {
25 quotes
26 .iter()
27 .map(|(peer, fee)| match fee {
28 Some(fee) => format!("peer {peer}: {} sats", fee.to_sat()),
29 None => format!("peer {peer}: no quote"),
30 })
31 .collect::<Vec<_>>()
32 .join("; ")
33}
34
35#[apply(async_trait_maybe_send!)]
36pub trait WalletFederationApi {
37 async fn consensus_block_count(&self) -> FederationResult<u64>;
38
39 async fn consensus_feerate(&self) -> FederationResult<Option<u64>>;
40
41 async fn federation_wallet(&self) -> FederationResult<Option<FederationWallet>>;
42
43 async fn send_fee(&self) -> FederationResult<Option<bitcoin::Amount>>;
44
45 async fn receive_fee(&self) -> FederationResult<Option<bitcoin::Amount>>;
46
47 async fn pending_tx_chain(&self) -> FederationResult<Vec<TxInfo>>;
48
49 async fn tx_chain(&self) -> FederationResult<Vec<TxInfo>>;
50
51 async fn output_info_slice(
52 &self,
53 start_index: u64,
54 end_index: u64,
55 ) -> FederationResult<Vec<OutputInfo>>;
56
57 async fn tx_id(&self, outpoint: OutPoint) -> Option<bitcoin::Txid>;
58
59 async fn pending_outputs(&self) -> PendingOutputs;
72}
73
74#[apply(async_trait_maybe_send!)]
75impl<T: ?Sized> WalletFederationApi for T
76where
77 T: IModuleFederationApi + MaybeSend + MaybeSync + 'static,
78{
79 async fn consensus_block_count(&self) -> FederationResult<u64> {
80 self.request_current_consensus(
81 CONSENSUS_BLOCK_COUNT_ENDPOINT.to_string(),
82 ApiRequestErased::new(()),
83 )
84 .await
85 }
86
87 async fn consensus_feerate(&self) -> FederationResult<Option<u64>> {
88 self.request_current_consensus(
89 CONSENSUS_FEERATE_ENDPOINT.to_string(),
90 ApiRequestErased::new(()),
91 )
92 .await
93 }
94
95 async fn federation_wallet(&self) -> FederationResult<Option<FederationWallet>> {
96 self.request_current_consensus(
97 FEDERATION_WALLET_ENDPOINT.to_string(),
98 ApiRequestErased::new(()),
99 )
100 .await
101 }
102
103 async fn send_fee(&self) -> FederationResult<Option<bitcoin::Amount>> {
104 let quotes = match self
111 .request_with_strategy(
112 ThresholdAgreement::new(self.all_peers().to_num_peers()),
113 SEND_FEE_ENDPOINT.to_string(),
114 ApiRequestErased::new(()),
115 )
116 .await?
117 {
118 Ok(fee) => return Ok(fee),
119 Err(quotes) => quotes,
120 };
121
122 Err(FederationError::general(
123 SEND_FEE_ENDPOINT.to_string(),
124 ApiRequestErased::new(()),
125 FederationGeneralError::ThresholdFailed {
126 message: format!(
127 "Guardians disagree on the onchain send fee ({}). The fee is \
128 consensus state, so a guardian returning a different value has \
129 diverged - because its Bitcoin backend is lagging, or because its \
130 view of the pending transaction stack differs. The same fee \
131 validates the send, so it cannot be accepted until they agree.",
132 describe_fee_quotes("es)
133 ),
134 },
135 ))
136 }
137
138 async fn receive_fee(&self) -> FederationResult<Option<bitcoin::Amount>> {
139 let quotes = match self
145 .request_with_strategy(
146 ThresholdAgreement::new(self.all_peers().to_num_peers()),
147 RECEIVE_FEE_ENDPOINT.to_string(),
148 ApiRequestErased::new(()),
149 )
150 .await?
151 {
152 Ok(fee) => return Ok(fee),
153 Err(quotes) => quotes,
154 };
155
156 Err(FederationError::general(
157 RECEIVE_FEE_ENDPOINT.to_string(),
158 ApiRequestErased::new(()),
159 FederationGeneralError::ThresholdFailed {
160 message: format!(
161 "Guardians disagree on the onchain receive fee ({}). The fee is \
162 consensus state, so a guardian returning a different value has \
163 diverged - because its Bitcoin backend is lagging, or because its \
164 view of the pending transaction stack differs. The same fee \
165 validates the claim, so it cannot be accepted until they agree.",
166 describe_fee_quotes("es)
167 ),
168 },
169 ))
170 }
171
172 async fn pending_tx_chain(&self) -> FederationResult<Vec<TxInfo>> {
173 self.request_current_consensus(
174 PENDING_TRANSACTION_CHAIN_ENDPOINT.to_string(),
175 ApiRequestErased::new(()),
176 )
177 .await
178 }
179
180 async fn tx_chain(&self) -> FederationResult<Vec<TxInfo>> {
181 self.request_current_consensus(
182 TRANSACTION_CHAIN_ENDPOINT.to_string(),
183 ApiRequestErased::new(()),
184 )
185 .await
186 }
187
188 async fn output_info_slice(
189 &self,
190 start_index: u64,
191 end_index: u64,
192 ) -> FederationResult<Vec<OutputInfo>> {
193 self.request_current_consensus(
194 OUTPUT_INFO_SLICE_ENDPOINT.to_string(),
195 ApiRequestErased::new((start_index, end_index)),
196 )
197 .await
198 }
199
200 async fn tx_id(&self, outpoint: OutPoint) -> Option<bitcoin::Txid> {
201 self.request_current_consensus_retry(
202 TRANSACTION_ID_ENDPOINT.to_string(),
203 ApiRequestErased::new(outpoint),
204 )
205 .await
206 }
207
208 async fn pending_outputs(&self) -> PendingOutputs {
209 let responses = futures::future::join_all(self.all_peers().iter().map(|peer| async move {
210 self.request_single_peer::<PendingOutputs>(
211 PENDING_OUTPUTS_ENDPOINT.to_string(),
212 ApiRequestErased::new(()),
213 *peer,
214 )
215 .await
216 }))
217 .await;
218
219 let responses: Vec<PendingOutputs> = responses.into_iter().flatten().collect();
220
221 merge_pending_outputs(responses, self.all_peers().to_num_peers().threshold())
222 }
223}
224
225fn merge_pending_outputs(responses: Vec<PendingOutputs>, threshold: usize) -> PendingOutputs {
232 let mut counts: Vec<u64> = responses.iter().map(|r| r.block_count).collect();
242
243 counts.sort_unstable();
244 counts.reverse();
245
246 let block_count = counts
247 .get(threshold - 1)
248 .or_else(|| counts.first())
249 .copied()
250 .unwrap_or(0);
251
252 let mut mempool_visibility = MempoolVisibility::Unavailable;
253 let mut outputs: BTreeMap<bitcoin::OutPoint, PendingOutput> = BTreeMap::new();
254
255 for response in responses {
256 mempool_visibility = mempool_visibility.max(response.mempool_visibility);
259
260 for output in response.outputs {
261 outputs
262 .entry(output.outpoint)
263 .and_modify(|existing| {
264 existing.height = match (existing.height, output.height) {
265 (None, height) | (height, None) => height,
269 (Some(existing), Some(new)) => Some(existing.max(new)),
274 };
275 })
276 .or_insert(output);
277 }
278 }
279
280 PendingOutputs {
281 block_count,
282 outputs: outputs.into_values().collect(),
283 mempool_visibility,
284 }
285}