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