Skip to main content

gateway_tests/
refused_payments.rs

1use std::time::Duration;
2
3use anyhow::{Context, bail, ensure};
4use devimint::devfed::DevJitFed;
5use devimint::federation::Client;
6use devimint::{Gatewayd, cmd};
7use fedimint_core::Amount;
8use fedimint_core::bitcoin::hashes::{Hash as _, sha256};
9use fedimint_core::secp256k1::{Secp256k1, SecretKey};
10use fedimint_ln_client::{
11    LightningOperationMeta, LightningOperationMetaPay, LightningOperationMetaVariant,
12    LightningPaymentOutcome,
13};
14use fedimint_ln_common::contracts::{ContractId, FundedContract};
15use fedimint_ln_common::federation_endpoint_constants::{ACCOUNT_ENDPOINT, BLOCK_COUNT_ENDPOINT};
16use fedimint_ln_common::{ContractAccount, KIND};
17use fedimint_logging::LOG_TEST;
18use lightning_invoice::{Bolt11Invoice, Currency, InvoiceBuilder, PaymentSecret};
19use tokio::time::timeout;
20use tracing::info;
21
22const PAYMENT_AMOUNT_MSAT: u64 = 100_000;
23const REFUND_TIMEOUT: Duration = Duration::from_secs(60);
24
25pub(super) async fn refund_test() -> anyhow::Result<()> {
26    Box::pin(
27        devimint::run_devfed_test().call(|dev_fed, _process_mgr| async move {
28            let fed = dev_fed.fed().await?;
29            let gateway = dev_fed.gw_lnd_registered().await?;
30            let client = fed.new_joined_client("refused-payment-refund").await?;
31            fed.pegin_client(100_000, &client).await?;
32            let initial_balance = client.balance().await?;
33
34            // An unreachable recipient makes the gateway fail before any HTLC can settle.
35            let invoice = unreachable_invoice()?;
36            let first = timeout(REFUND_TIMEOUT, pay(&client, gateway, &invoice))
37                .await
38                .context("first payment did not fail and refund before its timelock")??;
39            ensure!(matches!(first, LightningPaymentOutcome::Failure { .. }));
40            let first_payments = payments(&client).await?;
41            ensure!(first_payments.len() == 1, "expected one funded payment");
42            let first_contract = account(&client, first_payments[0].contract_id).await?;
43            ensure!(
44                first_contract.amount == Amount::ZERO,
45                "first refund was not spent"
46            );
47            ensure!(
48                outgoing_cancelled(&first_contract)?,
49                "first contract was not cancelled"
50            );
51            ensure!(
52                client.balance().await? == initial_balance,
53                "first refund did not restore ecash"
54            );
55
56            // The first refund must complete before this client creates a new contract.
57            let retry = timeout(REFUND_TIMEOUT, pay(&client, gateway, &invoice)).await;
58            let all_payments = payments(&client).await?;
59            ensure!(all_payments.len() == 2, "retry did not fund a new contract");
60            ensure!(all_payments[0].contract_id != all_payments[1].contract_id);
61            ensure!(all_payments.iter().all(|p| p.invoice == invoice));
62            let retry_payment = all_payments
63                .iter()
64                .find(|p| p.contract_id != first_payments[0].contract_id)
65                .context("missing retry contract")?;
66            let retry_contract = account(&client, retry_payment.contract_id).await?;
67            let block_count = cmd!(client, "dev", "api", "--module", KIND, BLOCK_COUNT_ENDPOINT)
68                .out_json()
69                .await?["value"]
70                .as_u64()
71                .context("missing federation block count")?;
72            let FundedContract::Outgoing(outgoing) = &retry_contract.contract else {
73                bail!("retry did not fund an outgoing contract");
74            };
75            ensure!(
76                block_count < u64::from(outgoing.timelock),
77                "refund reached the timelock"
78            );
79
80            if retry.is_err() {
81                ensure!(retry_contract.amount > Amount::ZERO);
82                ensure!(!outgoing.cancelled);
83                bail!(
84                    "same-invoice retry remains funded and uncancelled before refund height {}",
85                    outgoing.timelock
86                );
87            }
88            let retry = retry.context("retry timed out")??;
89            ensure!(matches!(retry, LightningPaymentOutcome::Failure { .. }));
90            ensure!(outgoing.cancelled, "refused retry was not cancelled");
91            ensure!(
92                retry_contract.amount == Amount::ZERO,
93                "refused retry was not refunded"
94            );
95            ensure!(
96                client.balance().await? == initial_balance,
97                "retry refund did not restore ecash"
98            );
99
100            refund_paid_invoice(&dev_fed, &client, gateway).await?;
101            info!(target: LOG_TEST, "refused same-invoice contract refunded before its timelock");
102            Ok(())
103        }),
104    )
105    .await
106}
107
108async fn refund_paid_invoice(
109    dev_fed: &DevJitFed,
110    payer: &Client,
111    gateway: &Gatewayd,
112) -> anyhow::Result<()> {
113    let fed = dev_fed.fed().await?;
114    let recipient = dev_fed.gw_ldk_connected().await?;
115    let invoice = recipient
116        .client()
117        .create_invoice(PAYMENT_AMOUNT_MSAT)
118        .await?;
119    let outcome = timeout(REFUND_TIMEOUT, pay(payer, gateway, &invoice))
120        .await
121        .context("fresh invoice payment timed out")??;
122    ensure!(matches!(outcome, LightningPaymentOutcome::Success { .. }));
123    let federation_id = fed.calculate_federation_id().clone();
124    let gateway_balance = gateway
125        .client()
126        .ecash_balance(federation_id.clone())
127        .await?;
128
129    // A separate client has no local record of this invoice's successful payment.
130    let retry_client = fed.new_joined_client("paid-invoice-refund").await?;
131    fed.pegin_client(100_000, &retry_client).await?;
132    let initial_balance = retry_client.balance().await?;
133    let outcome = timeout(REFUND_TIMEOUT, pay(&retry_client, gateway, &invoice))
134        .await
135        .context("paid-invoice retry did not refund promptly")??;
136    ensure!(matches!(outcome, LightningPaymentOutcome::Failure { .. }));
137    let operations = payments(&retry_client).await?;
138    ensure!(operations.len() == 1);
139    let contract = account(&retry_client, operations[0].contract_id).await?;
140    ensure!(outgoing_cancelled(&contract)?);
141    ensure!(contract.amount == Amount::ZERO);
142    ensure!(retry_client.balance().await? == initial_balance);
143    ensure!(
144        gateway.client().ecash_balance(federation_id).await? == gateway_balance,
145        "refused contract must not pay the gateway twice"
146    );
147    Ok(())
148}
149
150fn unreachable_invoice() -> anyhow::Result<Bolt11Invoice> {
151    let secret = SecretKey::from_slice(&[1; 32])?;
152    let secp = Secp256k1::new();
153    Ok(InvoiceBuilder::new(Currency::Regtest)
154        .description("unreachable recipient".to_owned())
155        .payment_hash(sha256::Hash::hash(b"refused-payment-refund"))
156        .current_timestamp()
157        .min_final_cltv_expiry_delta(144)
158        .payment_secret(PaymentSecret([2; 32]))
159        .amount_milli_satoshis(PAYMENT_AMOUNT_MSAT)
160        .build_signed(|message| secp.sign_ecdsa_recoverable(message, &secret))?)
161}
162
163async fn pay(
164    client: &Client,
165    gateway: &Gatewayd,
166    invoice: &Bolt11Invoice,
167) -> anyhow::Result<LightningPaymentOutcome> {
168    let value = cmd!(
169        client,
170        "module",
171        KIND,
172        "pay",
173        invoice,
174        "--gateway-id",
175        &gateway.gateway_id
176    )
177    .kill_on_drop(true)
178    .out_json()
179    .await?;
180    Ok(serde_json::from_value(value)?)
181}
182
183async fn payments(client: &Client) -> anyhow::Result<Vec<LightningOperationMetaPay>> {
184    let value = cmd!(client, "list-operations", "--limit", "100")
185        .out_json()
186        .await?;
187    let mut payments = Vec::new();
188    for operation in value["operations"]
189        .as_array()
190        .context("missing operations")?
191    {
192        if operation["operation_kind"].as_str() != Some(KIND.as_str()) {
193            continue;
194        }
195        let meta: LightningOperationMeta =
196            serde_json::from_value(operation["operation_meta"].clone())?;
197        if let LightningOperationMetaVariant::Pay(payment) = meta.variant {
198            payments.push(payment);
199        }
200    }
201    Ok(payments)
202}
203
204async fn account(client: &Client, contract_id: ContractId) -> anyhow::Result<ContractAccount> {
205    let value = cmd!(
206        client,
207        "dev",
208        "api",
209        "--module",
210        KIND,
211        ACCOUNT_ENDPOINT,
212        serde_json::to_string(&contract_id)?
213    )
214    .out_json()
215    .await?;
216    serde_json::from_value::<Option<ContractAccount>>(value["value"].clone())?
217        .context("funded contract is missing")
218}
219
220fn outgoing_cancelled(account: &ContractAccount) -> anyhow::Result<bool> {
221    match &account.contract {
222        FundedContract::Outgoing(contract) => Ok(contract.cancelled),
223        FundedContract::Incoming(_) => bail!("expected an outgoing contract"),
224    }
225}