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::FmtCompact;
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/// State machine that waits on the receipt of a Lightning payment.
56///
57/// ```mermaid
58/// graph LR
59/// classDef virtual fill:#fff,stroke-dasharray: 5 5
60///
61///     Pending -- incoming contract is confirmed --> Claiming
62///     Pending -- decryption contract expires --> Expired
63///     Pending -- claim fee exceeds the contract --> Uneconomical
64/// ```
65impl State for ReceiveStateMachine {
66    type ModuleContext = LightningClientContext;
67
68    fn transitions(
69        &self,
70        context: &Self::ModuleContext,
71        global_context: &DynGlobalClientContext,
72    ) -> Vec<StateTransition<Self>> {
73        let gc = global_context.clone();
74        let ctx = context.clone();
75
76        match &self.state {
77            ReceiveSMState::Pending => {
78                vec![StateTransition::new(
79                    Self::await_incoming_contract(self.common.contract.clone(), gc.clone()),
80                    move |dbtx, contract_confirmed, old_state| {
81                        Box::pin(Self::transition_incoming_contract(
82                            dbtx,
83                            old_state,
84                            ctx.clone(),
85                            gc.clone(),
86                            contract_confirmed,
87                        ))
88                    },
89                )]
90            }
91            ReceiveSMState::Claiming(..)
92            | ReceiveSMState::Expired
93            | ReceiveSMState::Uneconomical => {
94                vec![]
95            }
96        }
97    }
98
99    fn operation_id(&self) -> OperationId {
100        self.common.operation_id
101    }
102}
103
104impl ReceiveStateMachine {
105    #[instrument(target = LOG_CLIENT_MODULE_LNV2, skip(global_context))]
106    async fn await_incoming_contract(
107        contract: IncomingContract,
108        global_context: DynGlobalClientContext,
109    ) -> Option<OutPoint> {
110        global_context
111            .module_api()
112            .await_incoming_contract(
113                &contract.contract_id(),
114                contract.commitment.expiration_or_fee,
115            )
116            .await
117    }
118
119    async fn transition_incoming_contract(
120        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
121        old_state: ReceiveStateMachine,
122        context: LightningClientContext,
123        global_context: DynGlobalClientContext,
124        outpoint: Option<OutPoint>,
125    ) -> ReceiveStateMachine {
126        let Some(outpoint) = outpoint else {
127            return old_state.update(ReceiveSMState::Expired);
128        };
129
130        let client_input = ClientInput::<LightningInput> {
131            input: LightningInput::V0(LightningInputV0::Incoming(
132                outpoint,
133                old_state.common.agg_decryption_key,
134            )),
135            amounts: Amounts::new_bitcoin(old_state.common.contract.commitment.amount),
136            keys: vec![old_state.common.claim_keypair],
137        };
138
139        let change_range = match global_context
140            .claim_inputs(dbtx, ClientInputBundle::new_no_sm(vec![client_input]))
141            .await
142        {
143            Ok(change_range) => change_range,
144            // The contract is the transaction's only input, so the primary module has
145            // to top the transaction up exactly when the federation's fees exceed the
146            // contract - and a wallet with nothing in it cannot top it up at all.
147            // Anyone can address an incoming contract to a published lnurl key, so
148            // this has to end in a state rather than a panic: the executor polls this
149            // transition, a panic in it takes the whole client down, and because
150            // nothing commits it would do so again on every restart.
151            Err(err) => {
152                warn!(
153                    target: LOG_CLIENT_MODULE_LNV2,
154                    err = %err.fmt_compact(),
155                    amount = %old_state.common.contract.commitment.amount,
156                    "Not claiming incoming contract, its amount does not cover the claim fee"
157                );
158
159                return old_state.update(ReceiveSMState::Uneconomical);
160            }
161        };
162
163        // The event reports the invoice amount and the gateway fee separately.
164        // Manual receives carry the invoice in their operation meta, so the fee
165        // is the difference between invoice and contract amount. Lnurl receives
166        // do not have an invoice on the client, so the fee is recovered from the
167        // fee-encoded contract expiration set by the recurring daemon instead.
168        let fee = match context
169            .client_ctx
170            .get_operation(old_state.common.operation_id)
171            .await
172            .map(|operation| operation.meta::<LightningOperationMeta>())
173        {
174            // A receive operation meta is only recorded for manually created
175            // invoices; lnurl receives have no invoice on the client (and no
176            // operation), so the fee is recovered from the fee-encoded expiration.
177            Ok(LightningOperationMeta::Receive(meta)) => meta.gateway_fee(),
178            _ => Amount::from_msats(fee_from_expiration(
179                old_state.common.contract.commitment.expiration_or_fee,
180            )),
181        };
182
183        // Log event when receive completes successfully
184        context
185            .client_ctx
186            .log_event(
187                &mut dbtx.module_tx(),
188                ReceivePaymentEvent {
189                    operation_id: old_state.common.operation_id,
190                    amount: old_state.common.contract.commitment.amount + fee,
191                    fee,
192                },
193            )
194            .await;
195
196        old_state.update(ReceiveSMState::Claiming(change_range.into_iter().collect()))
197    }
198}