fedimint_ln_client/
api.rs

1use std::collections::{BTreeMap, HashMap};
2use std::time::Duration;
3
4use bitcoin::hashes::sha256::{self, Hash as Sha256Hash};
5use fedimint_api_client::api::{
6    FederationApiExt, FederationError, FederationResult, IModuleFederationApi,
7};
8use fedimint_api_client::query::FilterMapThreshold;
9use fedimint_core::module::ApiRequestErased;
10use fedimint_core::secp256k1::PublicKey;
11use fedimint_core::task::{MaybeSend, MaybeSync};
12use fedimint_core::{apply, async_trait_maybe_send, NumPeersExt, PeerId};
13use fedimint_ln_common::contracts::incoming::{IncomingContractAccount, IncomingContractOffer};
14use fedimint_ln_common::contracts::outgoing::OutgoingContractAccount;
15use fedimint_ln_common::contracts::{
16    ContractId, DecryptedPreimageStatus, FundedContract, Preimage,
17};
18use fedimint_ln_common::federation_endpoint_constants::{
19    ACCOUNT_ENDPOINT, AWAIT_ACCOUNT_ENDPOINT, AWAIT_BLOCK_HEIGHT_ENDPOINT, AWAIT_OFFER_ENDPOINT,
20    AWAIT_OUTGOING_CONTRACT_CANCELLED_ENDPOINT, AWAIT_PREIMAGE_DECRYPTION, BLOCK_COUNT_ENDPOINT,
21    GET_DECRYPTED_PREIMAGE_STATUS, LIST_GATEWAYS_ENDPOINT, OFFER_ENDPOINT,
22    REGISTER_GATEWAY_ENDPOINT, REMOVE_GATEWAY_CHALLENGE_ENDPOINT, REMOVE_GATEWAY_ENDPOINT,
23};
24use fedimint_ln_common::{
25    ContractAccount, LightningGateway, LightningGatewayAnnouncement, RemoveGatewayRequest,
26};
27use itertools::Itertools;
28use tracing::{info, warn};
29
30#[apply(async_trait_maybe_send!)]
31pub trait LnFederationApi {
32    async fn fetch_consensus_block_count(&self) -> FederationResult<Option<u64>>;
33
34    async fn fetch_contract(
35        &self,
36        contract: ContractId,
37    ) -> FederationResult<Option<ContractAccount>>;
38
39    async fn wait_contract(&self, contract: ContractId) -> FederationResult<ContractAccount>;
40
41    async fn wait_block_height(&self, block_height: u64) -> FederationResult<()>;
42
43    async fn wait_outgoing_contract_cancelled(
44        &self,
45        contract: ContractId,
46    ) -> FederationResult<ContractAccount>;
47
48    async fn get_decrypted_preimage_status(
49        &self,
50        contract: ContractId,
51    ) -> FederationResult<(IncomingContractAccount, DecryptedPreimageStatus)>;
52
53    async fn wait_preimage_decrypted(
54        &self,
55        contract: ContractId,
56    ) -> FederationResult<(IncomingContractAccount, Option<Preimage>)>;
57
58    async fn fetch_offer(
59        &self,
60        payment_hash: Sha256Hash,
61    ) -> FederationResult<IncomingContractOffer>;
62
63    async fn fetch_gateways(&self) -> FederationResult<Vec<LightningGatewayAnnouncement>>;
64
65    async fn register_gateway(
66        &self,
67        gateway: &LightningGatewayAnnouncement,
68    ) -> FederationResult<()>;
69
70    /// Retrieves the map of gateway remove challenges from the server. Each
71    /// challenge needs to be signed by the gateway's private key in order
72    /// for the registration record to be removed.
73    async fn get_remove_gateway_challenge(
74        &self,
75        gateway_id: PublicKey,
76    ) -> BTreeMap<PeerId, Option<sha256::Hash>>;
77
78    /// Removes the gateway's registration record. First checks the provided
79    /// signature to verify the gateway authorized the removal of the
80    /// registration.
81    async fn remove_gateway(&self, remove_gateway_request: RemoveGatewayRequest);
82
83    async fn offer_exists(&self, payment_hash: Sha256Hash) -> FederationResult<bool>;
84
85    async fn get_incoming_contract(
86        &self,
87        id: ContractId,
88    ) -> FederationResult<IncomingContractAccount>;
89
90    async fn get_outgoing_contract(
91        &self,
92        id: ContractId,
93    ) -> FederationResult<OutgoingContractAccount>;
94}
95
96#[apply(async_trait_maybe_send!)]
97impl<T: ?Sized> LnFederationApi for T
98where
99    T: IModuleFederationApi + MaybeSend + MaybeSync + 'static,
100{
101    async fn fetch_consensus_block_count(&self) -> FederationResult<Option<u64>> {
102        self.request_current_consensus(
103            BLOCK_COUNT_ENDPOINT.to_string(),
104            ApiRequestErased::default(),
105        )
106        .await
107    }
108
109    async fn fetch_contract(
110        &self,
111        contract: ContractId,
112    ) -> FederationResult<Option<ContractAccount>> {
113        self.request_current_consensus(
114            ACCOUNT_ENDPOINT.to_string(),
115            ApiRequestErased::new(contract),
116        )
117        .await
118    }
119
120    async fn wait_contract(&self, contract: ContractId) -> FederationResult<ContractAccount> {
121        self.request_current_consensus(
122            AWAIT_ACCOUNT_ENDPOINT.to_string(),
123            ApiRequestErased::new(contract),
124        )
125        .await
126    }
127
128    async fn wait_block_height(&self, block_height: u64) -> FederationResult<()> {
129        self.request_current_consensus(
130            AWAIT_BLOCK_HEIGHT_ENDPOINT.to_string(),
131            ApiRequestErased::new(block_height),
132        )
133        .await
134    }
135
136    async fn wait_outgoing_contract_cancelled(
137        &self,
138        contract: ContractId,
139    ) -> FederationResult<ContractAccount> {
140        self.request_current_consensus(
141            AWAIT_OUTGOING_CONTRACT_CANCELLED_ENDPOINT.to_string(),
142            ApiRequestErased::new(contract),
143        )
144        .await
145    }
146
147    async fn get_decrypted_preimage_status(
148        &self,
149        contract: ContractId,
150    ) -> FederationResult<(IncomingContractAccount, DecryptedPreimageStatus)> {
151        self.request_current_consensus(
152            GET_DECRYPTED_PREIMAGE_STATUS.to_string(),
153            ApiRequestErased::new(contract),
154        )
155        .await
156    }
157
158    async fn wait_preimage_decrypted(
159        &self,
160        contract: ContractId,
161    ) -> FederationResult<(IncomingContractAccount, Option<Preimage>)> {
162        self.request_current_consensus(
163            AWAIT_PREIMAGE_DECRYPTION.to_string(),
164            ApiRequestErased::new(contract),
165        )
166        .await
167    }
168
169    async fn fetch_offer(
170        &self,
171        payment_hash: Sha256Hash,
172    ) -> FederationResult<IncomingContractOffer> {
173        self.request_current_consensus(
174            AWAIT_OFFER_ENDPOINT.to_string(),
175            ApiRequestErased::new(payment_hash),
176        )
177        .await
178    }
179
180    /// There is no consensus within Fedimint on the gateways, each guardian
181    /// might be aware of different ones, so we just return the union of all
182    /// responses and allow client selection.
183    async fn fetch_gateways(&self) -> FederationResult<Vec<LightningGatewayAnnouncement>> {
184        let gateway_announcements = self
185            .request_with_strategy(
186                FilterMapThreshold::new(
187                    |_, gateways| Ok(gateways),
188                    self.all_peers().to_num_peers(),
189                ),
190                LIST_GATEWAYS_ENDPOINT.to_string(),
191                ApiRequestErased::default(),
192            )
193            .await?;
194
195        // Filter out duplicate gateways so that we don't have to deal with
196        // multiple guardians having different TTLs for the same gateway.
197        Ok(filter_duplicate_gateways(&gateway_announcements))
198    }
199
200    async fn register_gateway(
201        &self,
202        gateway: &LightningGatewayAnnouncement,
203    ) -> FederationResult<()> {
204        self.request_current_consensus(
205            REGISTER_GATEWAY_ENDPOINT.to_string(),
206            ApiRequestErased::new(gateway),
207        )
208        .await
209    }
210
211    async fn get_remove_gateway_challenge(
212        &self,
213        gateway_id: PublicKey,
214    ) -> BTreeMap<PeerId, Option<sha256::Hash>> {
215        let mut responses = BTreeMap::new();
216
217        for peer in self.all_peers() {
218            if let Ok(response) = self
219                // Only wait a second since removing a gateway is "best effort"
220                .request_single_peer_federation::<Option<sha256::Hash>>(
221                    Some(Duration::from_secs(1)),
222                    REMOVE_GATEWAY_CHALLENGE_ENDPOINT.to_string(),
223                    ApiRequestErased::new(gateway_id),
224                    *peer,
225                )
226                .await
227            {
228                responses.insert(*peer, response);
229            }
230        }
231
232        responses
233    }
234
235    async fn remove_gateway(&self, remove_gateway_request: RemoveGatewayRequest) {
236        let gateway_id = remove_gateway_request.gateway_id;
237
238        for peer in self.all_peers() {
239            if let Ok(response) = self
240                .request_single_peer_federation::<bool>(
241                    // Only wait a second since removing a gateway is "best effort"
242                    Some(Duration::from_secs(1)),
243                    REMOVE_GATEWAY_ENDPOINT.to_string(),
244                    ApiRequestErased::new(remove_gateway_request.clone()),
245                    *peer,
246                )
247                .await
248            {
249                if response {
250                    info!("Successfully removed {gateway_id} gateway from peer: {peer}",);
251                } else {
252                    warn!("Unable to remove gateway {gateway_id} registration from peer: {peer}");
253                }
254            }
255        }
256    }
257
258    async fn offer_exists(&self, payment_hash: Sha256Hash) -> FederationResult<bool> {
259        Ok(self
260            .request_current_consensus::<Option<IncomingContractOffer>>(
261                OFFER_ENDPOINT.to_string(),
262                ApiRequestErased::new(payment_hash),
263            )
264            .await?
265            .is_some())
266    }
267
268    async fn get_incoming_contract(
269        &self,
270        id: ContractId,
271    ) -> FederationResult<IncomingContractAccount> {
272        let account = self.wait_contract(id).await?;
273        match account.contract {
274            FundedContract::Incoming(c) => Ok(IncomingContractAccount {
275                amount: account.amount,
276                contract: c.contract,
277            }),
278            FundedContract::Outgoing(_) => Err(FederationError::general(
279                AWAIT_ACCOUNT_ENDPOINT,
280                id,
281                anyhow::anyhow!("WrongAccountType"),
282            )),
283        }
284    }
285
286    async fn get_outgoing_contract(
287        &self,
288        id: ContractId,
289    ) -> FederationResult<OutgoingContractAccount> {
290        let account = self.wait_contract(id).await?;
291        match account.contract {
292            FundedContract::Outgoing(c) => Ok(OutgoingContractAccount {
293                amount: account.amount,
294                contract: c,
295            }),
296            FundedContract::Incoming(_) => Err(FederationError::general(
297                AWAIT_ACCOUNT_ENDPOINT,
298                id,
299                anyhow::anyhow!("WrongAccountType"),
300            )),
301        }
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) -> Vec<LightningGatewayAnnouncement> {
312    let gateways_by_gateway_id = gateways
313        .values()
314        .flatten()
315        .cloned()
316        .map(|announcement| (announcement.info.gateway_id, announcement))
317        .into_group_map();
318
319    // For each gateway, we may have multiple announcements with different settings
320    // and/or TTLs. We want to filter out duplicates in a way that doesn't allow a
321    // malicious guardian to override the caller's view of the gateways by
322    // returning a gateway with a shorter TTL. Instead, if we receive multiple
323    // announcements for the same gateway ID, we only filter out announcements
324    // that have the same settings, keeping the one with the longest TTL.
325    gateways_by_gateway_id
326        .into_values()
327        .flat_map(|announcements| {
328            let mut gateways: HashMap<LightningGateway, Duration> = HashMap::new();
329            for announcement in announcements {
330                let ttl = announcement.ttl;
331                let gateway = announcement.info.clone();
332                // Only insert if the TTL is longer than the one we already have
333                gateways
334                    .entry(gateway)
335                    .and_modify(|t| {
336                        if ttl > *t {
337                            *t = ttl;
338                        }
339                    })
340                    .or_insert(ttl);
341            }
342
343            gateways
344                .into_iter()
345                .map(|(gateway, ttl)| LightningGatewayAnnouncement {
346                    info: gateway,
347                    ttl,
348                    vetted: false,
349                })
350        })
351        .collect()
352}