gateway_tests/
refused_payments.rs1use 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 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 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 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}