Skip to main content

fedimint_walletv2_client/
api.rs

1use std::collections::BTreeMap;
2
3use fedimint_api_client::api::{
4    FederationApiExt, FederationError, FederationGeneralError, FederationResult,
5    IModuleFederationApi,
6};
7use fedimint_api_client::query::ThresholdAgreement;
8use fedimint_core::module::ApiRequestErased;
9use fedimint_core::task::{MaybeSend, MaybeSync};
10use fedimint_core::{NumPeersExt, OutPoint, PeerId, apply, async_trait_maybe_send};
11use fedimint_walletv2_common::endpoint_constants::{
12    CONSENSUS_BLOCK_COUNT_ENDPOINT, CONSENSUS_FEERATE_ENDPOINT, FEDERATION_WALLET_ENDPOINT,
13    OUTPUT_INFO_SLICE_ENDPOINT, PENDING_TRANSACTION_CHAIN_ENDPOINT, RECEIVE_FEE_ENDPOINT,
14    SEND_FEE_ENDPOINT, TRANSACTION_CHAIN_ENDPOINT, TRANSACTION_ID_ENDPOINT,
15};
16use fedimint_walletv2_common::{FederationWallet, OutputInfo, TxInfo};
17
18/// Renders each peer's fee answer so a divergence error names the odd one out.
19fn describe_fee_quotes(quotes: &BTreeMap<PeerId, Option<bitcoin::Amount>>) -> String {
20    quotes
21        .iter()
22        .map(|(peer, fee)| match fee {
23            Some(fee) => format!("peer {peer}: {} sats", fee.to_sat()),
24            None => format!("peer {peer}: no quote"),
25        })
26        .collect::<Vec<_>>()
27        .join("; ")
28}
29
30#[apply(async_trait_maybe_send!)]
31pub trait WalletFederationApi {
32    async fn consensus_block_count(&self) -> FederationResult<u64>;
33
34    async fn consensus_feerate(&self) -> FederationResult<Option<u64>>;
35
36    async fn federation_wallet(&self) -> FederationResult<Option<FederationWallet>>;
37
38    async fn send_fee(&self) -> FederationResult<Option<bitcoin::Amount>>;
39
40    async fn receive_fee(&self) -> FederationResult<Option<bitcoin::Amount>>;
41
42    async fn pending_tx_chain(&self) -> FederationResult<Vec<TxInfo>>;
43
44    async fn tx_chain(&self) -> FederationResult<Vec<TxInfo>>;
45
46    async fn output_info_slice(
47        &self,
48        start_index: u64,
49        end_index: u64,
50    ) -> FederationResult<Vec<OutputInfo>>;
51
52    async fn tx_id(&self, outpoint: OutPoint) -> Option<bitcoin::Txid>;
53}
54
55#[apply(async_trait_maybe_send!)]
56impl<T: ?Sized> WalletFederationApi for T
57where
58    T: IModuleFederationApi + MaybeSend + MaybeSync + 'static,
59{
60    async fn consensus_block_count(&self) -> FederationResult<u64> {
61        self.request_current_consensus(
62            CONSENSUS_BLOCK_COUNT_ENDPOINT.to_string(),
63            ApiRequestErased::new(()),
64        )
65        .await
66    }
67
68    async fn consensus_feerate(&self) -> FederationResult<Option<u64>> {
69        self.request_current_consensus(
70            CONSENSUS_FEERATE_ENDPOINT.to_string(),
71            ApiRequestErased::new(()),
72        )
73        .await
74    }
75
76    async fn federation_wallet(&self) -> FederationResult<Option<FederationWallet>> {
77        self.request_current_consensus(
78            FEDERATION_WALLET_ENDPOINT.to_string(),
79            ApiRequestErased::new(()),
80        )
81        .await
82    }
83
84    async fn send_fee(&self) -> FederationResult<Option<bitcoin::Amount>> {
85        // Deliberately not `request_current_consensus`; see the same reasoning in
86        // wallet v1's `fetch_peg_out_fees`. walletv2 is if anything more exposed:
87        // this single amount folds together the consensus feerate, a floor that
88        // doubles with every pending federation transaction, and the fees already
89        // paid by that pending stack, so any one of them diverging is enough to
90        // stop a threshold ever agreeing and hang the caller forever.
91        let quotes = match self
92            .request_with_strategy(
93                ThresholdAgreement::new(self.all_peers().to_num_peers()),
94                SEND_FEE_ENDPOINT.to_string(),
95                ApiRequestErased::new(()),
96            )
97            .await?
98        {
99            Ok(fee) => return Ok(fee),
100            Err(quotes) => quotes,
101        };
102
103        Err(FederationError::general(
104            SEND_FEE_ENDPOINT.to_string(),
105            ApiRequestErased::new(()),
106            FederationGeneralError::ThresholdFailed {
107                message: format!(
108                    "Guardians disagree on the onchain send fee ({}). The fee is \
109                     consensus state, so a guardian returning a different value has \
110                     diverged - because its Bitcoin backend is lagging, or because its \
111                     view of the pending transaction stack differs. The same fee \
112                     validates the send, so it cannot be accepted until they agree.",
113                    describe_fee_quotes(&quotes)
114                ),
115            },
116        ))
117    }
118
119    async fn receive_fee(&self) -> FederationResult<Option<bitcoin::Amount>> {
120        // Same reasoning as `send_fee`. The caller here is the background
121        // output-scanner, which loops with a `warn!` and a sleep, so returning
122        // an error is how this waits for consensus *visibly*: it keeps retrying
123        // for as long as the divergence lasts and resumes on its own, instead of
124        // wedging the scanner inside a request that never returns.
125        let quotes = match self
126            .request_with_strategy(
127                ThresholdAgreement::new(self.all_peers().to_num_peers()),
128                RECEIVE_FEE_ENDPOINT.to_string(),
129                ApiRequestErased::new(()),
130            )
131            .await?
132        {
133            Ok(fee) => return Ok(fee),
134            Err(quotes) => quotes,
135        };
136
137        Err(FederationError::general(
138            RECEIVE_FEE_ENDPOINT.to_string(),
139            ApiRequestErased::new(()),
140            FederationGeneralError::ThresholdFailed {
141                message: format!(
142                    "Guardians disagree on the onchain receive fee ({}). The fee is \
143                     consensus state, so a guardian returning a different value has \
144                     diverged - because its Bitcoin backend is lagging, or because its \
145                     view of the pending transaction stack differs. The same fee \
146                     validates the claim, so it cannot be accepted until they agree.",
147                    describe_fee_quotes(&quotes)
148                ),
149            },
150        ))
151    }
152
153    async fn pending_tx_chain(&self) -> FederationResult<Vec<TxInfo>> {
154        self.request_current_consensus(
155            PENDING_TRANSACTION_CHAIN_ENDPOINT.to_string(),
156            ApiRequestErased::new(()),
157        )
158        .await
159    }
160
161    async fn tx_chain(&self) -> FederationResult<Vec<TxInfo>> {
162        self.request_current_consensus(
163            TRANSACTION_CHAIN_ENDPOINT.to_string(),
164            ApiRequestErased::new(()),
165        )
166        .await
167    }
168
169    async fn output_info_slice(
170        &self,
171        start_index: u64,
172        end_index: u64,
173    ) -> FederationResult<Vec<OutputInfo>> {
174        self.request_current_consensus(
175            OUTPUT_INFO_SLICE_ENDPOINT.to_string(),
176            ApiRequestErased::new((start_index, end_index)),
177        )
178        .await
179    }
180
181    async fn tx_id(&self, outpoint: OutPoint) -> Option<bitcoin::Txid> {
182        self.request_current_consensus_retry(
183            TRANSACTION_ID_ENDPOINT.to_string(),
184            ApiRequestErased::new(outpoint),
185        )
186        .await
187    }
188}