Skip to main content

fedimint_ln_client/
api.rs

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