Skip to main content

fedimint_walletv2_client/
api.rs

1use std::collections::BTreeMap;
2
3use anyhow::anyhow;
4use fedimint_api_client::api::{
5    FederationApiExt, FederationError, FederationResult, 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            anyhow!(
107                "Guardians disagree on the onchain send fee ({}). The fee is \
108                 consensus state, so a guardian returning a different value has \
109                 diverged - because its Bitcoin backend is lagging, or because its \
110                 view of the pending transaction stack differs. The same fee \
111                 validates the send, so it cannot be accepted until they agree.",
112                describe_fee_quotes(&quotes)
113            ),
114        ))
115    }
116
117    async fn receive_fee(&self) -> FederationResult<Option<bitcoin::Amount>> {
118        // Same reasoning as `send_fee`. The caller here is the background
119        // output-scanner, which loops with a `warn!` and a sleep, so returning
120        // an error is how this waits for consensus *visibly*: it keeps retrying
121        // for as long as the divergence lasts and resumes on its own, instead of
122        // wedging the scanner inside a request that never returns.
123        let quotes = match self
124            .request_with_strategy(
125                ThresholdAgreement::new(self.all_peers().to_num_peers()),
126                RECEIVE_FEE_ENDPOINT.to_string(),
127                ApiRequestErased::new(()),
128            )
129            .await?
130        {
131            Ok(fee) => return Ok(fee),
132            Err(quotes) => quotes,
133        };
134
135        Err(FederationError::general(
136            RECEIVE_FEE_ENDPOINT.to_string(),
137            ApiRequestErased::new(()),
138            anyhow!(
139                "Guardians disagree on the onchain receive fee ({}). The fee is \
140                 consensus state, so a guardian returning a different value has \
141                 diverged - because its Bitcoin backend is lagging, or because its \
142                 view of the pending transaction stack differs. The same fee \
143                 validates the claim, so it cannot be accepted until they agree.",
144                describe_fee_quotes(&quotes)
145            ),
146        ))
147    }
148
149    async fn pending_tx_chain(&self) -> FederationResult<Vec<TxInfo>> {
150        self.request_current_consensus(
151            PENDING_TRANSACTION_CHAIN_ENDPOINT.to_string(),
152            ApiRequestErased::new(()),
153        )
154        .await
155    }
156
157    async fn tx_chain(&self) -> FederationResult<Vec<TxInfo>> {
158        self.request_current_consensus(
159            TRANSACTION_CHAIN_ENDPOINT.to_string(),
160            ApiRequestErased::new(()),
161        )
162        .await
163    }
164
165    async fn output_info_slice(
166        &self,
167        start_index: u64,
168        end_index: u64,
169    ) -> FederationResult<Vec<OutputInfo>> {
170        self.request_current_consensus(
171            OUTPUT_INFO_SLICE_ENDPOINT.to_string(),
172            ApiRequestErased::new((start_index, end_index)),
173        )
174        .await
175    }
176
177    async fn tx_id(&self, outpoint: OutPoint) -> Option<bitcoin::Txid> {
178        self.request_current_consensus_retry(
179            TRANSACTION_ID_ENDPOINT.to_string(),
180            ApiRequestErased::new(outpoint),
181        )
182        .await
183    }
184}