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)]
59impl 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 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}