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 funding_txid: TransactionId,
42 pub gateway_api: SafeUrl,
43 pub contract: OutgoingContract,
44 pub invoice: 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)]
58impl 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.funding_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(),
97 context.federation_id,
98 self.common.contract.clone(),
99 self.common.invoice.clone(),
100 self.common.refund_keypair,
101 context.clone(),
102 ),
103 move |dbtx, response, old_state| {
104 Box::pin(Self::transition_gateway_send_payment(
105 gc_pay.clone(),
106 dbtx,
107 response,
108 old_state,
109 ))
110 },
111 ),
112 StateTransition::new(
113 Self::await_preimage(self.common.contract.clone(), gc_preimage.clone()),
114 move |dbtx, preimage, old_state| {
115 Box::pin(Self::transition_preimage(
116 dbtx,
117 gc_preimage.clone(),
118 old_state,
119 preimage,
120 ))
121 },
122 ),
123 ]
124 }
125 SendSMState::Refunding(..) | SendSMState::Success(..) | SendSMState::Rejected(..) => {
126 vec![]
127 }
128 }
129 }
130
131 fn operation_id(&self) -> OperationId {
132 self.common.operation_id
133 }
134}
135
136impl SendStateMachine {
137 async fn await_funding(
138 global_context: DynGlobalClientContext,
139 txid: TransactionId,
140 ) -> Result<(), String> {
141 global_context.await_tx_accepted(txid).await
142 }
143
144 fn transition_funding(
145 result: Result<(), String>,
146 old_state: &SendStateMachine,
147 ) -> SendStateMachine {
148 old_state.update(match result {
149 Ok(()) => SendSMState::Funded,
150 Err(error) => SendSMState::Rejected(error),
151 })
152 }
153
154 #[instrument(target = LOG_CLIENT_MODULE_LNV2, skip(refund_keypair, context))]
155 async fn gateway_send_payment(
156 gateway_api: SafeUrl,
157 federation_id: FederationId,
158 contract: OutgoingContract,
159 invoice: LightningInvoice,
160 refund_keypair: Keypair,
161 context: LightningClientContext,
162 ) -> Result<[u8; 32], Signature> {
163 util::retry("gateway-send-payment", api_networking_backoff(), || async {
164 let payment_result = context
165 .gateway_conn
166 .send_payment(
167 gateway_api.clone(),
168 federation_id,
169 contract.clone(),
170 invoice.clone(),
171 refund_keypair.sign_schnorr(secp256k1::Message::from_digest(
172 *invoice.consensus_hash::<sha256::Hash>().as_ref(),
173 )),
174 )
175 .await?;
176
177 ensure!(
178 contract.verify_gateway_response(&payment_result),
179 "Invalid gateway response: {payment_result:?}"
180 );
181
182 Ok(payment_result)
183 })
184 .await
185 .expect("Number of retries has no limit")
186 }
187
188 async fn transition_gateway_send_payment(
189 global_context: DynGlobalClientContext,
190 dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
191 gateway_response: Result<[u8; 32], Signature>,
192 old_state: SendStateMachine,
193 ) -> SendStateMachine {
194 match gateway_response {
195 Ok(preimage) => old_state.update(SendSMState::Success(preimage)),
196 Err(signature) => {
197 let client_input = ClientInput::<LightningInput> {
198 input: LightningInput::V0(LightningInputV0::Outgoing(
199 old_state.common.contract.contract_id(),
200 OutgoingWitness::Cancel(signature),
201 )),
202 amount: old_state.common.contract.amount,
203 keys: vec![old_state.common.refund_keypair],
204 };
205
206 let change_range = global_context
207 .claim_inputs(
208 dbtx,
209 ClientInputBundle::new_no_sm(vec![client_input]),
211 )
212 .await
213 .expect("Cannot claim input, additional funding needed");
214
215 old_state.update(SendSMState::Refunding(change_range.into_iter().collect()))
216 }
217 }
218 }
219
220 #[instrument(target = LOG_CLIENT_MODULE_LNV2, skip(global_context))]
221 async fn await_preimage(
222 contract: OutgoingContract,
223 global_context: DynGlobalClientContext,
224 ) -> Option<[u8; 32]> {
225 let preimage = global_context
226 .module_api()
227 .await_preimage(&contract.contract_id(), contract.expiration)
228 .await?;
229
230 if contract.verify_preimage(&preimage) {
231 return Some(preimage);
232 }
233
234 crit!(target: LOG_CLIENT_MODULE_LNV2, "Federation returned invalid preimage {:?}", preimage);
235
236 pending().await
237 }
238
239 async fn transition_preimage(
240 dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
241 global_context: DynGlobalClientContext,
242 old_state: SendStateMachine,
243 preimage: Option<[u8; 32]>,
244 ) -> SendStateMachine {
245 if let Some(preimage) = preimage {
246 return old_state.update(SendSMState::Success(preimage));
247 }
248
249 let client_input = ClientInput::<LightningInput> {
250 input: LightningInput::V0(LightningInputV0::Outgoing(
251 old_state.common.contract.contract_id(),
252 OutgoingWitness::Refund,
253 )),
254 amount: old_state.common.contract.amount,
255 keys: vec![old_state.common.refund_keypair],
256 };
257
258 let change_range = global_context
259 .claim_inputs(dbtx, ClientInputBundle::new_no_sm(vec![client_input]))
260 .await
261 .expect("Cannot claim input, additional funding needed");
262
263 old_state.update(SendSMState::Refunding(change_range.into_iter().collect()))
264 }
265}