Skip to main content

fedimint_ln_client/
api.rs

1use std::collections::{BTreeMap, HashMap};
2use std::convert::identity;
3use std::time::Duration;
4
5use anyhow::anyhow;
6use bitcoin::hashes::sha256::{self, Hash as Sha256Hash};
7use fedimint_api_client::api::{
8    FederationApiExt, FederationResult, IModuleFederationApi, ServerError,
9};
10use fedimint_api_client::query::FilterMapThreshold;
11use fedimint_core::module::{ApiRequestErased, ModuleConsensusVersion};
12use fedimint_core::secp256k1::PublicKey;
13use fedimint_core::task::{MaybeSend, MaybeSync, timeout};
14use fedimint_core::{NumPeersExt, PeerId, apply, async_trait_maybe_send};
15use fedimint_ln_common::contracts::incoming::{IncomingContractAccount, IncomingContractOffer};
16use fedimint_ln_common::contracts::{ContractId, DecryptedPreimageStatus, Preimage};
17use fedimint_ln_common::federation_endpoint_constants::{
18    ACCOUNT_ENDPOINT, AWAIT_ACCOUNT_ENDPOINT, AWAIT_BLOCK_HEIGHT_ENDPOINT, AWAIT_OFFER_ENDPOINT,
19    AWAIT_OUTGOING_CONTRACT_CANCELLED_ENDPOINT, AWAIT_PREIMAGE_DECRYPTION, BLOCK_COUNT_ENDPOINT,
20    GET_DECRYPTED_PREIMAGE_STATUS, LIST_GATEWAYS_ENDPOINT, MODULE_CONSENSUS_VERSION_ENDPOINT,
21    OFFER_ENDPOINT, REGISTER_GATEWAY_ENDPOINT, REMOVE_GATEWAY_CHALLENGE_ENDPOINT,
22    REMOVE_GATEWAY_ENDPOINT,
23};
24use fedimint_ln_common::{
25    ContractAccount, FederationPublicKey, LightningGateway, LightningGatewayAnnouncement,
26    RemoveGatewayRequest,
27};
28use itertools::Itertools;
29use tracing::{info, warn};
30
31#[apply(async_trait_maybe_send!)]
32pub trait LnFederationApi {
33    /// The module consensus version the federation currently runs on, which is
34    /// the version its peers have voted in, not the one their binaries
35    /// support.
36    ///
37    /// Federations predating the endpoint report [`MODULE_CONSENSUS_VERSION`]
38    /// 2.0, the version before voting existed.
39    ///
40    /// [`MODULE_CONSENSUS_VERSION`]: fedimint_ln_common::MODULE_CONSENSUS_VERSION
41    async fn module_consensus_version(&self) -> FederationResult<ModuleConsensusVersion>;
42
43    async fn fetch_consensus_block_count(&self) -> FederationResult<Option<u64>>;
44
45    async fn fetch_contract(
46        &self,
47        contract: ContractId,
48    ) -> FederationResult<Option<ContractAccount>>;
49
50    async fn await_contract(&self, contract: ContractId) -> ContractAccount;
51
52    async fn wait_block_height(&self, block_height: u64);
53
54    async fn wait_outgoing_contract_cancelled(
55        &self,
56        contract: ContractId,
57    ) -> FederationResult<ContractAccount>;
58
59    async fn get_decrypted_preimage_status(
60        &self,
61        contract: ContractId,
62    ) -> FederationResult<(IncomingContractAccount, DecryptedPreimageStatus)>;
63
64    async fn wait_preimage_decrypted(
65        &self,
66        contract: ContractId,
67    ) -> FederationResult<(IncomingContractAccount, Option<Preimage>)>;
68
69    async fn fetch_offer(
70        &self,
71        payment_hash: Sha256Hash,
72    ) -> FederationResult<IncomingContractOffer>;
73
74    /// `federation_public_key` is needed to check registration proofs, which
75    /// has to happen before signed announcements are preferred over unsigned
76    /// ones — see
77    /// [`LightningGatewayAnnouncement::registration_proof_is_valid`].
78    async fn fetch_gateways(
79        &self,
80        federation_public_key: FederationPublicKey,
81    ) -> FederationResult<Vec<LightningGatewayAnnouncement>>;
82
83    async fn register_gateway(
84        &self,
85        gateway: &LightningGatewayAnnouncement,
86    ) -> FederationResult<()>;
87
88    /// Retrieves the map of gateway remove challenges from the server. Each
89    /// challenge needs to be signed by the gateway's private key in order
90    /// for the registration record to be removed.
91    async fn get_remove_gateway_challenge(
92        &self,
93        gateway_id: PublicKey,
94    ) -> BTreeMap<PeerId, Option<sha256::Hash>>;
95
96    /// Removes the gateway's registration record. First checks the provided
97    /// signature to verify the gateway authorized the removal of the
98    /// registration.
99    async fn remove_gateway(&self, remove_gateway_request: RemoveGatewayRequest);
100
101    async fn offer_exists(&self, payment_hash: Sha256Hash) -> FederationResult<bool>;
102}
103
104#[apply(async_trait_maybe_send!)]
105impl<T: ?Sized> LnFederationApi for T
106where
107    T: IModuleFederationApi + MaybeSend + MaybeSync + 'static,
108{
109    async fn module_consensus_version(&self) -> FederationResult<ModuleConsensusVersion> {
110        let response = self
111            .request_current_consensus(
112                MODULE_CONSENSUS_VERSION_ENDPOINT.to_string(),
113                ApiRequestErased::default(),
114            )
115            .await;
116
117        if let Err(e) = &response
118            && e.any_peer_error_method_not_found()
119        {
120            return Ok(ModuleConsensusVersion::new(2, 0));
121        }
122
123        response
124    }
125
126    async fn fetch_consensus_block_count(&self) -> FederationResult<Option<u64>> {
127        self.request_current_consensus(
128            BLOCK_COUNT_ENDPOINT.to_string(),
129            ApiRequestErased::default(),
130        )
131        .await
132    }
133
134    async fn fetch_contract(
135        &self,
136        contract: ContractId,
137    ) -> FederationResult<Option<ContractAccount>> {
138        self.request_current_consensus(
139            ACCOUNT_ENDPOINT.to_string(),
140            ApiRequestErased::new(contract),
141        )
142        .await
143    }
144
145    async fn await_contract(&self, contract: ContractId) -> ContractAccount {
146        self.request_current_consensus_retry(
147            AWAIT_ACCOUNT_ENDPOINT.to_string(),
148            ApiRequestErased::new(contract),
149        )
150        .await
151    }
152
153    async fn wait_block_height(&self, block_height: u64) {
154        self.request_current_consensus_retry::<()>(
155            AWAIT_BLOCK_HEIGHT_ENDPOINT.to_string(),
156            ApiRequestErased::new(block_height),
157        )
158        .await;
159    }
160
161    async fn wait_outgoing_contract_cancelled(
162        &self,
163        contract: ContractId,
164    ) -> FederationResult<ContractAccount> {
165        self.request_current_consensus(
166            AWAIT_OUTGOING_CONTRACT_CANCELLED_ENDPOINT.to_string(),
167            ApiRequestErased::new(contract),
168        )
169        .await
170    }
171
172    async fn get_decrypted_preimage_status(
173        &self,
174        contract: ContractId,
175    ) -> FederationResult<(IncomingContractAccount, DecryptedPreimageStatus)> {
176        self.request_current_consensus(
177            GET_DECRYPTED_PREIMAGE_STATUS.to_string(),
178            ApiRequestErased::new(contract),
179        )
180        .await
181    }
182
183    async fn wait_preimage_decrypted(
184        &self,
185        contract: ContractId,
186    ) -> FederationResult<(IncomingContractAccount, Option<Preimage>)> {
187        self.request_current_consensus(
188            AWAIT_PREIMAGE_DECRYPTION.to_string(),
189            ApiRequestErased::new(contract),
190        )
191        .await
192    }
193
194    async fn fetch_offer(
195        &self,
196        payment_hash: Sha256Hash,
197    ) -> FederationResult<IncomingContractOffer> {
198        self.request_current_consensus(
199            AWAIT_OFFER_ENDPOINT.to_string(),
200            ApiRequestErased::new(payment_hash),
201        )
202        .await
203    }
204
205    /// There is no consensus within Fedimint on the gateways, each guardian
206    /// might be aware of different ones, so we just return the union of all
207    /// responses and allow client selection.
208    async fn fetch_gateways(
209        &self,
210        federation_public_key: FederationPublicKey,
211    ) -> FederationResult<Vec<LightningGatewayAnnouncement>> {
212        let gateway_announcements = self
213            .request_with_strategy(
214                FilterMapThreshold::new(
215                    |_, gateways| Ok(gateways),
216                    self.all_peers().to_num_peers(),
217                ),
218                LIST_GATEWAYS_ENDPOINT.to_string(),
219                ApiRequestErased::default(),
220            )
221            .await?;
222
223        // Filter out duplicate gateways so that we don't have to deal with
224        // multiple guardians having different TTLs for the same gateway.
225        Ok(filter_duplicate_gateways(
226            &gateway_announcements,
227            federation_public_key,
228        ))
229    }
230
231    async fn register_gateway(
232        &self,
233        gateway: &LightningGatewayAnnouncement,
234    ) -> FederationResult<()> {
235        self.request_current_consensus(
236            REGISTER_GATEWAY_ENDPOINT.to_string(),
237            ApiRequestErased::new(gateway),
238        )
239        .await
240    }
241
242    async fn get_remove_gateway_challenge(
243        &self,
244        gateway_id: PublicKey,
245    ) -> BTreeMap<PeerId, Option<sha256::Hash>> {
246        let mut responses = BTreeMap::new();
247
248        for peer in self.all_peers() {
249            // Only wait a second since removing a gateway is "best effort"
250            if let Ok(response) = timeout(
251                Duration::from_secs(1),
252                self.request_single_peer::<Option<sha256::Hash>>(
253                    REMOVE_GATEWAY_CHALLENGE_ENDPOINT.to_string(),
254                    ApiRequestErased::new(gateway_id),
255                    *peer,
256                ),
257            )
258            .await
259            .map_err(|e| ServerError::Transport(anyhow!("Request timed out: {e}")))
260            .and_then(identity)
261            {
262                responses.insert(*peer, response);
263            }
264        }
265
266        responses
267    }
268
269    async fn remove_gateway(&self, remove_gateway_request: RemoveGatewayRequest) {
270        let gateway_id = remove_gateway_request.gateway_id;
271
272        for peer in self.all_peers() {
273            // Only wait a second since removing a gateway is "best effort"
274            if let Ok(response) = timeout(
275                Duration::from_secs(1),
276                self.request_single_peer::<bool>(
277                    REMOVE_GATEWAY_ENDPOINT.to_string(),
278                    ApiRequestErased::new(remove_gateway_request.clone()),
279                    *peer,
280                ),
281            )
282            .await
283            .map_err(|e| ServerError::Transport(anyhow!("Request timed out: {e}")))
284            .and_then(identity)
285            {
286                if response {
287                    info!("Successfully removed {gateway_id} gateway from peer: {peer}",);
288                } else {
289                    warn!("Unable to remove gateway {gateway_id} registration from peer: {peer}");
290                }
291            }
292        }
293    }
294
295    async fn offer_exists(&self, payment_hash: Sha256Hash) -> FederationResult<bool> {
296        Ok(self
297            .request_current_consensus::<Option<IncomingContractOffer>>(
298                OFFER_ENDPOINT.to_string(),
299                ApiRequestErased::new(payment_hash),
300            )
301            .await?
302            .is_some())
303    }
304}
305
306/// Filter out duplicate gateways. This is necessary because different guardians
307/// may have different TTLs for the same gateway, so two
308/// `LightningGatewayAnnouncement`s representing the same gateway registration
309/// may not be equal.
310fn filter_duplicate_gateways(
311    gateways: &BTreeMap<PeerId, Vec<LightningGatewayAnnouncement>>,
312    federation_public_key: FederationPublicKey,
313) -> Vec<LightningGatewayAnnouncement> {
314    let gateways_by_gateway_id = gateways
315        .values()
316        .flatten()
317        // Drop forged proofs here, before the preference below acts on them.
318        // Otherwise one peer could attach a garbage signature to another
319        // gateway's id, evict every honest unsigned announcement for it, and
320        // have its own forgery discarded later — removing the gateway entirely.
321        .filter(|announcement| announcement.registration_proof_is_valid(federation_public_key))
322        .cloned()
323        .map(|announcement| (announcement.info.gateway_id, announcement))
324        .into_group_map();
325
326    // For each gateway, we may have multiple announcements with different settings
327    // and/or TTLs. We want to filter out duplicates in a way that doesn't allow a
328    // malicious guardian to override the caller's view of the gateways by
329    // returning a gateway with a shorter TTL. Instead, if we receive multiple
330    // announcements for the same gateway ID, we only filter out announcements
331    // that have the same settings, keeping the one with the longest TTL.
332    gateways_by_gateway_id
333        .into_values()
334        .flat_map(|mut announcements| {
335            // Guardians that predate registration proofs cannot reject an
336            // unsigned registration overwriting a signed one, so while a
337            // federation is mid-upgrade its peers can disagree about a gateway.
338            // Trust the peers that have a proof: only the gateway itself can
339            // produce one, whereas anyone at all can publish an unsigned
340            // announcement.
341            if announcements.iter().any(|ann| ann.auth.is_some()) {
342                announcements.retain(|ann| ann.auth.is_some());
343            }
344
345            let mut gateways: HashMap<LightningGateway, LightningGatewayAnnouncement> =
346                HashMap::new();
347            for announcement in announcements {
348                gateways
349                    .entry(announcement.info.clone())
350                    .and_modify(|existing| {
351                        // Only replace if the TTL is longer than the one we already have
352                        if announcement.ttl > existing.ttl {
353                            existing.ttl = announcement.ttl;
354                        }
355                        // Keep the freshest proof, so a replayed older one cannot
356                        // displace it.
357                        let nonce = |ann: &LightningGatewayAnnouncement| {
358                            ann.auth.as_ref().map_or(0, |auth| auth.nonce)
359                        };
360                        if nonce(&announcement) > nonce(existing) {
361                            existing.auth.clone_from(&announcement.auth);
362                        }
363                    })
364                    .or_insert(LightningGatewayAnnouncement {
365                        vetted: false,
366                        ..announcement
367                    });
368            }
369
370            gateways.into_values()
371        })
372        .collect()
373}