fedimint_lnv2_client/
send_sm.rs

1use anyhow::ensure;
2use bitcoin::hashes::sha256;
3use fedimint_client_module::DynGlobalClientContext;
4use fedimint_client_module::sm::{ClientSMDatabaseTransaction, State, StateTransition};
5use fedimint_client_module::transaction::{ClientInput, ClientInputBundle};
6use fedimint_core::config::FederationId;
7use fedimint_core::core::OperationId;
8use fedimint_core::encoding::{Decodable, Encodable};
9use fedimint_core::util::SafeUrl;
10use fedimint_core::util::backoff_util::api_networking_backoff;
11use fedimint_core::{OutPoint, TransactionId, crit, secp256k1, util};
12use fedimint_lnv2_common::contracts::OutgoingContract;
13use fedimint_lnv2_common::{LightningInput, LightningInputV0, OutgoingWitness};
14use fedimint_logging::LOG_CLIENT_MODULE_LNV2;
15use futures::future::pending;
16use secp256k1::Keypair;
17use secp256k1::schnorr::Signature;
18use tracing::instrument;
19
20use crate::api::LightningFederationApi;
21use crate::{LightningClientContext, LightningInvoice};
22
23#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
24pub struct SendStateMachine {
25    pub common: SendSMCommon,
26    pub state: SendSMState,
27}
28
29impl SendStateMachine {
30    pub fn update(&self, state: SendSMState) -> Self {
31        Self {
32            common: self.common.clone(),
33            state,
34        }
35    }
36}
37
38#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
39pub struct SendSMCommon {
40    pub operation_id: OperationId,
41    pub outpoint: OutPoint,
42    pub contract: OutgoingContract,
43    pub gateway_api: Option<SafeUrl>,
44    pub invoice: Option<LightningInvoice>,
45    pub refund_keypair: Keypair,
46}
47
48#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
49pub enum SendSMState {
50    Funding,
51    Funded,
52    Rejected(String),
53    Success([u8; 32]),
54    Refunding(Vec<OutPoint>),
55}
56
57#[cfg_attr(doc, aquamarine::aquamarine)]
58/// State machine that requests the lightning gateway to pay an invoice on
59/// behalf of a federation client.
60///
61/// ```mermaid
62/// graph LR
63/// classDef virtual fill:#fff,stroke-dasharray: 5 5
64///
65///     Funding -- funding tx is rejected --> Rejected
66///     Funding -- funding tx is accepted --> Funded
67///     Funded -- post invoice returns preimage  --> Success
68///     Funded -- post invoice returns forfeit tx --> Refunding
69///     Funded -- await_preimage returns preimage --> Success
70///     Funded -- await_preimage expires --> Refunding
71/// ```
72impl State for SendStateMachine {
73    type ModuleContext = LightningClientContext;
74
75    fn transitions(
76        &self,
77        context: &Self::ModuleContext,
78        global_context: &DynGlobalClientContext,
79    ) -> Vec<StateTransition<Self>> {
80        let gc_pay = global_context.clone();
81        let gc_preimage = global_context.clone();
82
83        match &self.state {
84            SendSMState::Funding => {
85                vec![StateTransition::new(
86                    Self::await_funding(global_context.clone(), self.common.outpoint.txid),
87                    |_, error, old_state| {
88                        Box::pin(async move { Self::transition_funding(error, &old_state) })
89                    },
90                )]
91            }
92            SendSMState::Funded => {
93                vec![
94                    StateTransition::new(
95                        Self::gateway_send_payment(
96                            self.common.gateway_api.clone().unwrap(),
97                            context.federation_id,
98                            self.common.outpoint,
99                            self.common.contract.clone(),
100                            self.common.invoice.clone().unwrap(),
101                            self.common.refund_keypair,
102                            context.clone(),
103                        ),
104                        move |dbtx, response, old_state| {
105                            Box::pin(Self::transition_gateway_send_payment(
106                                gc_pay.clone(),
107                                dbtx,
108                                response,
109                                old_state,
110                            ))
111                        },
112                    ),
113                    StateTransition::new(
114                        Self::await_preimage(
115                            self.common.outpoint,
116                            self.common.contract.clone(),
117                            gc_preimage.clone(),
118                        ),
119                        move |dbtx, preimage, old_state| {
120                            Box::pin(Self::transition_preimage(
121                                dbtx,
122                                gc_preimage.clone(),
123                                old_state,
124                                preimage,
125                            ))
126                        },
127                    ),
128                ]
129            }
130            SendSMState::Refunding(..) | SendSMState::Success(..) | SendSMState::Rejected(..) => {
131                vec![]
132            }
133        }
134    }
135
136    fn operation_id(&self) -> OperationId {
137        self.common.operation_id
138    }
139}
140
141impl SendStateMachine {
142    async fn await_funding(
143        global_context: DynGlobalClientContext,
144        txid: TransactionId,
145    ) -> Result<(), String> {
146        global_context.await_tx_accepted(txid).await
147    }
148
149    fn transition_funding(
150        result: Result<(), String>,
151        old_state: &SendStateMachine,
152    ) -> SendStateMachine {
153        old_state.update(match result {
154            Ok(()) => SendSMState::Funded,
155            Err(error) => SendSMState::Rejected(error),
156        })
157    }
158
159    #[instrument(target = LOG_CLIENT_MODULE_LNV2, skip(refund_keypair, context))]
160    async fn gateway_send_payment(
161        gateway_api: SafeUrl,
162        federation_id: FederationId,
163        outpoint: OutPoint,
164        contract: OutgoingContract,
165        invoice: LightningInvoice,
166        refund_keypair: Keypair,
167        context: LightningClientContext,
168    ) -> Result<[u8; 32], Signature> {
169        util::retry("gateway-send-payment", api_networking_backoff(), || async {
170            let payment_result = context
171                .gateway_conn
172                .send_payment(
173                    gateway_api.clone(),
174                    federation_id,
175                    outpoint,
176                    contract.clone(),
177                    invoice.clone(),
178                    refund_keypair.sign_schnorr(secp256k1::Message::from_digest(
179                        *invoice.consensus_hash::<sha256::Hash>().as_ref(),
180                    )),
181                )
182                .await?;
183
184            ensure!(
185                contract.verify_gateway_response(&payment_result),
186                "Invalid gateway response: {payment_result:?}"
187            );
188
189            Ok(payment_result)
190        })
191        .await
192        .expect("Number of retries has no limit")
193    }
194
195    async fn transition_gateway_send_payment(
196        global_context: DynGlobalClientContext,
197        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
198        gateway_response: Result<[u8; 32], Signature>,
199        old_state: SendStateMachine,
200    ) -> SendStateMachine {
201        match gateway_response {
202            Ok(preimage) => old_state.update(SendSMState::Success(preimage)),
203            Err(signature) => {
204                let client_input = ClientInput::<LightningInput> {
205                    input: LightningInput::V0(LightningInputV0::Outgoing(
206                        old_state.common.outpoint,
207                        OutgoingWitness::Cancel(signature),
208                    )),
209                    amount: old_state.common.contract.amount,
210                    keys: vec![old_state.common.refund_keypair],
211                };
212
213                let change_range = global_context
214                    .claim_inputs(
215                        dbtx,
216                        // The input of the refund tx is managed by this state machine
217                        ClientInputBundle::new_no_sm(vec![client_input]),
218                    )
219                    .await
220                    .expect("Cannot claim input, additional funding needed");
221
222                old_state.update(SendSMState::Refunding(change_range.into_iter().collect()))
223            }
224        }
225    }
226
227    #[instrument(target = LOG_CLIENT_MODULE_LNV2, skip(global_context))]
228    async fn await_preimage(
229        outpoint: OutPoint,
230        contract: OutgoingContract,
231        global_context: DynGlobalClientContext,
232    ) -> Option<[u8; 32]> {
233        let preimage = global_context
234            .module_api()
235            .await_preimage(outpoint, contract.expiration)
236            .await?;
237
238        if contract.verify_preimage(&preimage) {
239            return Some(preimage);
240        }
241
242        crit!(target: LOG_CLIENT_MODULE_LNV2, "Federation returned invalid preimage {:?}", preimage);
243
244        pending().await
245    }
246
247    async fn transition_preimage(
248        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
249        global_context: DynGlobalClientContext,
250        old_state: SendStateMachine,
251        preimage: Option<[u8; 32]>,
252    ) -> SendStateMachine {
253        if let Some(preimage) = preimage {
254            return old_state.update(SendSMState::Success(preimage));
255        }
256
257        let client_input = ClientInput::<LightningInput> {
258            input: LightningInput::V0(LightningInputV0::Outgoing(
259                old_state.common.outpoint,
260                OutgoingWitness::Refund,
261            )),
262            amount: old_state.common.contract.amount,
263            keys: vec![old_state.common.refund_keypair],
264        };
265
266        let change_range = global_context
267            .claim_inputs(dbtx, ClientInputBundle::new_no_sm(vec![client_input]))
268            .await
269            .expect("Cannot claim input, additional funding needed");
270
271        old_state.update(SendSMState::Refunding(change_range.into_iter().collect()))
272    }
273}