Skip to main content

fedimint_lnv2_client/
receive_sm.rs

1use fedimint_client_module::DynGlobalClientContext;
2use fedimint_client_module::sm::{ClientSMDatabaseTransaction, State, StateTransition};
3use fedimint_client_module::transaction::{ClientInput, ClientInputBundle};
4use fedimint_core::core::OperationId;
5use fedimint_core::encoding::{Decodable, Encodable};
6use fedimint_core::module::Amounts;
7use fedimint_core::secp256k1::Keypair;
8use fedimint_core::util::FmtCompactAnyhow;
9use fedimint_core::{Amount, OutPoint};
10use fedimint_lnv2_common::contracts::{IncomingContract, fee_from_expiration};
11use fedimint_lnv2_common::{LightningInput, LightningInputV0};
12use fedimint_logging::LOG_CLIENT_MODULE_LNV2;
13use tpe::AggregateDecryptionKey;
14use tracing::{instrument, warn};
15
16use crate::api::LightningFederationApi;
17use crate::events::ReceivePaymentEvent;
18use crate::{LightningClientContext, LightningOperationMeta};
19
20#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
21pub struct ReceiveStateMachine {
22    pub common: ReceiveSMCommon,
23    pub state: ReceiveSMState,
24}
25
26impl ReceiveStateMachine {
27    pub fn update(&self, state: ReceiveSMState) -> Self {
28        Self {
29            common: self.common.clone(),
30            state,
31        }
32    }
33}
34
35#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
36pub struct ReceiveSMCommon {
37    pub operation_id: OperationId,
38    pub contract: IncomingContract,
39    pub claim_keypair: Keypair,
40    pub agg_decryption_key: AggregateDecryptionKey,
41}
42
43#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
44pub enum ReceiveSMState {
45    Pending,
46    Claiming(Vec<OutPoint>),
47    Expired,
48    /// Claiming the contract costs more in federation fees than the contract is
49    /// worth, so there is nothing to recover. Terminal: the verdict follows
50    /// from the contract amount and the federation's fee consensus, so waiting
51    /// does not change it.
52    Uneconomical,
53}
54
55#[cfg_attr(doc, aquamarine::aquamarine)]
56/// State machine that waits on the receipt of a Lightning payment.
57///
58/// ```mermaid
59/// graph LR
60/// classDef virtual fill:#fff,stroke-dasharray: 5 5
61///
62///     Pending -- incoming contract is confirmed --> Claiming
63///     Pending -- decryption contract expires --> Expired
64///     Pending -- claim fee exceeds the contract --> Uneconomical
65/// ```
66impl State for ReceiveStateMachine {
67    type ModuleContext = LightningClientContext;
68
69    fn transitions(
70        &self,
71        context: &Self::ModuleContext,
72        global_context: &DynGlobalClientContext,
73    ) -> Vec<StateTransition<Self>> {
74        let gc = global_context.clone();
75        let ctx = context.clone();
76
77        match &self.state {
78            ReceiveSMState::Pending => {
79                vec![StateTransition::new(
80                    Self::await_incoming_contract(self.common.contract.clone(), gc.clone()),
81                    move |dbtx, contract_confirmed, old_state| {
82                        Box::pin(Self::transition_incoming_contract(
83                            dbtx,
84                            old_state,
85                            ctx.clone(),
86                            gc.clone(),
87                            contract_confirmed,
88                        ))
89                    },
90                )]
91            }
92            ReceiveSMState::Claiming(..)
93            | ReceiveSMState::Expired
94            | ReceiveSMState::Uneconomical => {
95                vec![]
96            }
97        }
98    }
99
100    fn operation_id(&self) -> OperationId {
101        self.common.operation_id
102    }
103}
104
105impl ReceiveStateMachine {
106    #[instrument(target = LOG_CLIENT_MODULE_LNV2, skip(global_context))]
107    async fn await_incoming_contract(
108        contract: IncomingContract,
109        global_context: DynGlobalClientContext,
110    ) -> Option<OutPoint> {
111        global_context
112            .module_api()
113            .await_incoming_contract(
114                &contract.contract_id(),
115                contract.commitment.expiration_or_fee,
116            )
117            .await
118    }
119
120    async fn transition_incoming_contract(
121        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
122        old_state: ReceiveStateMachine,
123        context: LightningClientContext,
124        global_context: DynGlobalClientContext,
125        outpoint: Option<OutPoint>,
126    ) -> ReceiveStateMachine {
127        let Some(outpoint) = outpoint else {
128            return old_state.update(ReceiveSMState::Expired);
129        };
130
131        let client_input = ClientInput::<LightningInput> {
132            input: LightningInput::V0(LightningInputV0::Incoming(
133                outpoint,
134                old_state.common.agg_decryption_key,
135            )),
136            amounts: Amounts::new_bitcoin(old_state.common.contract.commitment.amount),
137            keys: vec![old_state.common.claim_keypair],
138        };
139
140        let change_range = match global_context
141            .claim_inputs(dbtx, ClientInputBundle::new_no_sm(vec![client_input]))
142            .await
143        {
144            Ok(change_range) => change_range,
145            // The contract is the transaction's only input, so the primary module has
146            // to top the transaction up exactly when the federation's fees exceed the
147            // contract - and a wallet with nothing in it cannot top it up at all.
148            // Anyone can address an incoming contract to a published lnurl key, so
149            // this has to end in a state rather than a panic: the executor polls this
150            // transition, a panic in it takes the whole client down, and because
151            // nothing commits it would do so again on every restart.
152            Err(err) => {
153                warn!(
154                    target: LOG_CLIENT_MODULE_LNV2,
155                    err = %err.fmt_compact_anyhow(),
156                    amount = %old_state.common.contract.commitment.amount,
157                    "Not claiming incoming contract, its amount does not cover the claim fee"
158                );
159
160                return old_state.update(ReceiveSMState::Uneconomical);
161            }
162        };
163
164        // The event reports the invoice amount and the gateway fee separately.
165        // Manual receives carry the invoice in their operation meta, so the fee
166        // is the difference between invoice and contract amount. Lnurl receives
167        // do not have an invoice on the client, so the fee is recovered from the
168        // fee-encoded contract expiration set by the recurring daemon instead.
169        let fee = match context
170            .client_ctx
171            .get_operation(old_state.common.operation_id)
172            .await
173            .map(|operation| operation.meta::<LightningOperationMeta>())
174        {
175            // A receive operation meta is only recorded for manually created
176            // invoices; lnurl receives have no invoice on the client (and no
177            // operation), so the fee is recovered from the fee-encoded expiration.
178            Ok(LightningOperationMeta::Receive(meta)) => meta.gateway_fee(),
179            _ => Amount::from_msats(fee_from_expiration(
180                old_state.common.contract.commitment.expiration_or_fee,
181            )),
182        };
183
184        // Log event when receive completes successfully
185        context
186            .client_ctx
187            .log_event(
188                &mut dbtx.module_tx(),
189                ReceivePaymentEvent {
190                    operation_id: old_state.common.operation_id,
191                    amount: old_state.common.contract.commitment.amount + fee,
192                    fee,
193                },
194            )
195            .await;
196
197        old_state.update(ReceiveSMState::Claiming(change_range.into_iter().collect()))
198    }
199}