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)]
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.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 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}