Skip to main content

fedimint_walletv2_client/
api.rs

1#[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
23/// Renders each peer's fee answer so a divergence error names the odd one out.
24fn 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    /// Fetches the guardians' local views of the receive outputs they can see
60    /// but the federation has not recorded yet - mempool candidates plus the
61    /// recent-block window - merged into a single view.
62    ///
63    /// Entries are retained for a short while past finality, so an outpoint
64    /// appearing here does not mean the deposit is still unclaimed.
65    ///
66    /// This is advisory data used to display peg-in progress. It is
67    /// deliberately not requested via threshold consensus: guardians observe
68    /// the chain tip independently and will legitimately disagree by a block,
69    /// so a consensus request would simply never resolve. Peers that error,
70    /// including guardians too old to know the endpoint, are skipped.
71    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        // Deliberately not `request_current_consensus`; see the same reasoning in
105        // wallet v1's `fetch_peg_out_fees`. walletv2 is if anything more exposed:
106        // this single amount folds together the consensus feerate, a floor that
107        // doubles with every pending federation transaction, and the fees already
108        // paid by that pending stack, so any one of them diverging is enough to
109        // stop a threshold ever agreeing and hang the caller forever.
110        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(&quotes)
133                ),
134            },
135        ))
136    }
137
138    async fn receive_fee(&self) -> FederationResult<Option<bitcoin::Amount>> {
139        // Same reasoning as `send_fee`. The caller here is the background
140        // output-scanner, which loops with a `warn!` and a sleep, so returning
141        // an error is how this waits for consensus *visibly*: it keeps retrying
142        // for as long as the divergence lasts and resumes on its own, instead of
143        // wedging the scanner inside a request that never returns.
144        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(&quotes)
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
225/// Combines the guardians' independent pending views into one, given the
226/// responses of the peers that answered and the federation's threshold.
227///
228/// Kept free of any networking so the merge rules can be tested against
229/// synthetic responses: every integration test shares one bitcoin backend, so
230/// none of them ever sees guardians disagree.
231fn merge_pending_outputs(responses: Vec<PendingOutputs>, threshold: usize) -> PendingOutputs {
232    // Heights are absolute, so confirmations can be computed against any
233    // guardian's tip. Which tip to trust is the question: taking the highest
234    // would let a single peer inflate everyone's progress, so pick the same way
235    // the module itself picks a block count, the highest value a threshold of
236    // peers have reached.
237    //
238    // Falling back to the highest reported tip keeps progress working on a
239    // federation where fewer than a threshold of guardians are upgraded far
240    // enough to serve this endpoint at all.
241    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        // One guardian that inspected its mempool is enough to report unmined
257        // peg-ins for the whole federation, so the best view wins.
258        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                        // A guardian that has seen the transaction mined is
266                        // ahead of one that still has it in its mempool, so a
267                        // known height always wins over none.
268                        (None, height) | (height, None) => height,
269                        // Guardians can disagree on the height of an outpoint
270                        // across a reorg. Keep the highest, the shallower of
271                        // the two blocks, which yields the lower confirmation
272                        // count, so progress is never overstated.
273                        (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}