Skip to main content

fedimint_gwv2_client/
receive_sm.rs

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