Skip to main content

fedimint_gwv2_client/
receive_sm.rs

1use core::fmt;
2use std::collections::BTreeMap;
3use std::sync::Arc;
4
5use fedimint_api_client::api::{FederationApiExt, ServerError};
6use fedimint_api_client::query::FilterMapThreshold;
7use fedimint_client_module::DynGlobalClientContext;
8use fedimint_client_module::sm::{ClientSMDatabaseTransaction, State, StateTransition};
9use fedimint_client_module::transaction::{ClientInput, ClientInputBundle};
10use fedimint_core::core::OperationId;
11use fedimint_core::encoding::{Decodable, Encodable};
12use fedimint_core::module::{Amounts, ApiRequestErased};
13use fedimint_core::secp256k1::Keypair;
14use fedimint_core::util::FmtCompact;
15use fedimint_core::{NumPeersExt, OutPoint, PeerId, runtime};
16use fedimint_lnv2_common::contracts::IncomingContract;
17use fedimint_lnv2_common::endpoint_constants::DECRYPTION_KEY_SHARE_ENDPOINT;
18use fedimint_lnv2_common::{LightningInput, LightningInputV0};
19use fedimint_logging::LOG_CLIENT_MODULE_GW;
20use serde::{Deserialize, Serialize};
21use tpe::{
22    AggregateDecryptionKey, AggregatePublicKey, DecryptionKeyShare, PublicKeyShare,
23    aggregate_dk_shares,
24};
25use tracing::warn;
26
27use super::events::{IncomingPaymentFailed, IncomingPaymentSucceeded};
28use crate::GatewayClientContextV2;
29
30#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
31pub struct ReceiveStateMachine {
32    pub common: ReceiveSMCommon,
33    pub state: ReceiveSMState,
34}
35
36impl ReceiveStateMachine {
37    pub fn update(&self, state: ReceiveSMState) -> Self {
38        Self {
39            common: self.common.clone(),
40            state,
41        }
42    }
43}
44
45impl fmt::Display for ReceiveStateMachine {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        write!(
48            f,
49            "Receive State Machine Operation ID: {:?} State: {}",
50            self.common.operation_id, self.state
51        )
52    }
53}
54
55#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
56pub struct ReceiveSMCommon {
57    pub operation_id: OperationId,
58    pub contract: IncomingContract,
59    pub outpoint: OutPoint,
60    pub refund_keypair: Keypair,
61}
62
63#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
64pub enum ReceiveSMState {
65    Funding,
66    Rejected(String),
67    Success([u8; 32]),
68    Failure,
69    Refunding(Vec<OutPoint>),
70}
71
72impl fmt::Display for ReceiveSMState {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        match self {
75            ReceiveSMState::Funding => write!(f, "Funding"),
76            ReceiveSMState::Rejected(_) => write!(f, "Rejected"),
77            ReceiveSMState::Success(_) => write!(f, "Success"),
78            ReceiveSMState::Failure => write!(f, "Failure"),
79            ReceiveSMState::Refunding(_) => write!(f, "Refunding"),
80        }
81    }
82}
83
84/// What the funding trigger resolves to.
85///
86/// The shares are verified and aggregated in the trigger, off the async
87/// workers, so the transition only records the outcome.
88#[derive(Debug, Serialize, Deserialize)]
89pub enum DecryptionOutcome {
90    /// The funding transaction was rejected.
91    Rejected(String),
92    /// The shares aggregate to a key the config's aggregate key does not
93    /// verify, so the config's public keys are inconsistent.
94    InconsistentKeys,
95    /// The aggregate decryption key, and the preimage if the ciphertext holds
96    /// one that matches the payment image.
97    Decrypted(AggregateDecryptionKey, Option<[u8; 32]>),
98}
99
100/// State machine that handles the relay of an incoming Lightning payment.
101///
102/// ```mermaid
103/// graph LR
104/// classDef virtual fill:#fff,stroke-dasharray: 5 5
105///
106///     Funding -- funding transaction is rejected --> Rejected
107///     Funding -- aggregated decryption key is invalid --> Failure
108///     Funding -- decrypted preimage is valid --> Success
109///     Funding -- decrypted preimage is invalid --> Refunding
110/// ```
111impl State for ReceiveStateMachine {
112    type ModuleContext = GatewayClientContextV2;
113
114    fn transitions(
115        &self,
116        context: &Self::ModuleContext,
117        global_context: &DynGlobalClientContext,
118    ) -> Vec<StateTransition<Self>> {
119        let gc = global_context.clone();
120        let tpe_agg_pk = context.tpe_agg_pk;
121        let gateway_context_ready = context.clone();
122
123        match &self.state {
124            ReceiveSMState::Funding => {
125                vec![StateTransition::new(
126                    Self::await_decryption_shares(
127                        global_context.clone(),
128                        context.tpe_pks.clone(),
129                        tpe_agg_pk,
130                        self.common.outpoint,
131                        self.common.contract.clone(),
132                    ),
133                    move |dbtx, outcome, old_state| {
134                        Box::pin(Self::transition_decryption_shares(
135                            dbtx,
136                            outcome,
137                            old_state,
138                            gc.clone(),
139                            gateway_context_ready.clone(),
140                        ))
141                    },
142                )]
143            }
144            ReceiveSMState::Success(..)
145            | ReceiveSMState::Rejected(..)
146            | ReceiveSMState::Refunding(..)
147            | ReceiveSMState::Failure => {
148                vec![]
149            }
150        }
151    }
152
153    fn operation_id(&self) -> OperationId {
154        self.common.operation_id
155    }
156}
157
158impl ReceiveStateMachine {
159    async fn await_decryption_shares(
160        global_context: DynGlobalClientContext,
161        tpe_pks: BTreeMap<PeerId, PublicKeyShare>,
162        tpe_agg_pk: AggregatePublicKey,
163        outpoint: OutPoint,
164        contract: IncomingContract,
165    ) -> DecryptionOutcome {
166        let num_peers = global_context.api().all_peers().to_num_peers();
167        let module_api = global_context.module_api();
168        let tpe_pks = Arc::new(tpe_pks);
169        let contract = Arc::new(contract);
170
171        // The decryption key share endpoint long-polls until the share exists, which
172        // happens atomically when the funding transaction is accepted. We can therefore
173        // fire the request up front and let it overlap with awaiting transaction
174        // acceptance instead of serializing the two, saving a round trip on the happy
175        // path. Acceptance is still awaited to detect a rejected funding transaction.
176        let decryption_shares = module_api.request_with_strategy_retry(
177            FilterMapThreshold::new(
178                {
179                    let tpe_pks = tpe_pks.clone();
180                    let contract = contract.clone();
181
182                    move |peer_id, share: DecryptionKeyShare| {
183                        let tpe_pks = tpe_pks.clone();
184                        let contract = contract.clone();
185
186                        // Four pairings per share; keep them off the async workers.
187                        runtime::spawn_blocking(move || {
188                            let pk =
189                                tpe_pks
190                                    .get(&peer_id)
191                                    .ok_or(ServerError::InternalClientError(format!(
192                                        "Missing TPE PK for peer {peer_id}?!"
193                                    )))?;
194
195                            if !contract.verify_decryption_share(pk, &share) {
196                                return Err(ServerError::InvalidResponse(
197                                    "Invalid decryption share".to_string(),
198                                ));
199                            }
200
201                            Ok(share)
202                        })
203                    }
204                },
205                num_peers,
206            ),
207            DECRYPTION_KEY_SHARE_ENDPOINT.to_owned(),
208            ApiRequestErased::new(outpoint),
209        );
210
211        let decryption_shares = std::pin::pin!(decryption_shares);
212        let tx_accepted = std::pin::pin!(global_context.await_tx_accepted(outpoint.txid));
213
214        let decryption_shares = match futures::future::select(decryption_shares, tx_accepted).await
215        {
216            futures::future::Either::Left((shares, _)) => shares,
217            futures::future::Either::Right((accepted, decryption_shares)) => {
218                if let Err(error) = accepted {
219                    return DecryptionOutcome::Rejected(error);
220                }
221
222                decryption_shares.await
223            }
224        };
225
226        // Aggregating the shares and verifying the key is four more pairings.
227        runtime::spawn_blocking(move || {
228            let agg_decryption_key = aggregate_dk_shares(
229                &decryption_shares
230                    .into_iter()
231                    .map(|(peer, share)| (peer.to_usize() as u64, share))
232                    .collect(),
233            );
234
235            if !contract.verify_agg_decryption_key(&tpe_agg_pk, &agg_decryption_key) {
236                return DecryptionOutcome::InconsistentKeys;
237            }
238
239            let preimage = contract.decrypt_preimage(&agg_decryption_key);
240
241            DecryptionOutcome::Decrypted(agg_decryption_key, preimage)
242        })
243        .await
244    }
245
246    async fn transition_decryption_shares(
247        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
248        outcome: DecryptionOutcome,
249        old_state: ReceiveStateMachine,
250        global_context: DynGlobalClientContext,
251        client_ctx: GatewayClientContextV2,
252    ) -> ReceiveStateMachine {
253        let payment_image = old_state.common.contract.commitment.payment_image.clone();
254
255        let (agg_decryption_key, preimage) = match outcome {
256            DecryptionOutcome::Rejected(error) => {
257                client_ctx
258                    .module
259                    .client_ctx
260                    .log_event(
261                        &mut dbtx.module_tx(),
262                        IncomingPaymentFailed {
263                            payment_image,
264                            error: error.clone(),
265                        },
266                    )
267                    .await;
268
269                return old_state.update(ReceiveSMState::Rejected(error));
270            }
271            DecryptionOutcome::InconsistentKeys => {
272                warn!(target: LOG_CLIENT_MODULE_GW, "Failed to obtain decryption key. Client config's public keys are inconsistent");
273
274                client_ctx
275                    .module
276                    .client_ctx
277                    .log_event(
278                        &mut dbtx.module_tx(),
279                        IncomingPaymentFailed {
280                            payment_image,
281                            error: "Client config's public keys are inconsistent".to_string(),
282                        },
283                    )
284                    .await;
285
286                return old_state.update(ReceiveSMState::Failure);
287            }
288            DecryptionOutcome::Decrypted(agg_decryption_key, preimage) => {
289                (agg_decryption_key, preimage)
290            }
291        };
292
293        if let Some(preimage) = preimage {
294            client_ctx
295                .module
296                .client_ctx
297                .log_event(
298                    &mut dbtx.module_tx(),
299                    IncomingPaymentSucceeded { payment_image },
300                )
301                .await;
302
303            return old_state.update(ReceiveSMState::Success(preimage));
304        }
305
306        let client_input = ClientInput::<LightningInput> {
307            input: LightningInput::V0(LightningInputV0::Incoming(
308                old_state.common.outpoint,
309                agg_decryption_key,
310            )),
311            amounts: Amounts::new_bitcoin(old_state.common.contract.commitment.amount),
312            keys: vec![old_state.common.refund_keypair],
313        };
314
315        let outpoints = match global_context
316            .claim_inputs(
317                dbtx,
318                // The input of the refund tx is managed by this state machine
319                ClientInputBundle::new_no_sm(vec![client_input]),
320            )
321            .await
322        {
323            Ok(outpoints) => outpoints.into_iter().collect(),
324            // The contract is the refund transaction's only input, so this fails
325            // exactly when the federation's fees exceed the contract, leaving nothing
326            // to refund. The gateway keeps the lightning payment it already received
327            // for it, so the loss is bounded by the contract; a panic here would not
328            // be, because it runs in the executor and would take down every payment
329            // this gateway handles for the federation, on every restart.
330            Err(err) => {
331                warn!(
332                    target: LOG_CLIENT_MODULE_GW,
333                    err = %err.fmt_compact(),
334                    amount = %old_state.common.contract.commitment.amount,
335                    "Not refunding incoming contract, its amount does not cover the refund fee"
336                );
337
338                client_ctx
339                    .module
340                    .client_ctx
341                    .log_event(
342                        &mut dbtx.module_tx(),
343                        IncomingPaymentFailed {
344                            payment_image: old_state
345                                .common
346                                .contract
347                                .commitment
348                                .payment_image
349                                .clone(),
350                            error: "Contract does not cover the refund fee".to_string(),
351                        },
352                    )
353                    .await;
354
355                return old_state.update(ReceiveSMState::Failure);
356            }
357        };
358
359        client_ctx
360            .module
361            .client_ctx
362            .log_event(
363                &mut dbtx.module_tx(),
364                IncomingPaymentFailed {
365                    payment_image: old_state.common.contract.commitment.payment_image.clone(),
366                    error: "Failed to decrypt preimage".to_string(),
367                },
368            )
369            .await;
370
371        old_state.update(ReceiveSMState::Refunding(outpoints))
372    }
373}