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