fix unaccepted hold invoices in the accepted handler

holdinvoice_accepted_handler waited on the hold plugin track stream and
sent a HoldInvoiceAccepted notification afterwards, without checking the
reason the stream ended. When an invoice was cancelled, the stream just closes and the handler still sent
a spurious accepted notification.

Only send the notification if the stream actually reported the invoice
as accepted.

The hold plugin has no expired state: an invoice that expires without
ever being accepted stays Unpaid and the track stream never ends, so the
handler previously hung forever, holding a task and a gRPC stream open.

Pass the invoice's expiry into the handler and time out the track stream
at that point (capped), so it gives up on expired invoices and never
sends a spurious accepted notification. When re-spawning pending
invoices at startup, derive the expiry from the decoded bolt11.

Add test_hold_invoice_expiry which creates an invoice with a short
expiry and asserts the handler gives up without sending the notification.
This commit is contained in:
daywalker90 2026-08-09 14:24:42 +02:00
parent 29c1ace33c
commit b31f42691f
4 changed files with 182 additions and 8 deletions

View file

@ -10,7 +10,13 @@ use cln_plugin::{
Plugin,
options::{ConfigOption, DefaultBooleanConfigOption, StringArrayConfigOption},
};
use cln_rpc::{ClnRpc, model::requests::ListdatastoreRequest};
use cln_rpc::{
ClnRpc,
model::{
requests::{DecodeRequest, ListdatastoreRequest},
responses::DecodeType,
},
};
use nostr::nips::nip47;
use nwc::run_nwc;
use nwc_notifications::{payment_received_handler, payment_sent_handler};
@ -225,8 +231,47 @@ async fn load_pending_hold_invoices(plugin: Plugin<PluginState>) -> Result<(), a
.into_inner()
.invoices;
let mut rpc = plugin.state().rpc_lock.lock().await;
for invoice in invoices {
if invoice.state() == InvoiceState::Accepted || invoice.state() == InvoiceState::Unpaid {
// Bound the accepted handler by the invoice's expiry. The hold
// plugin has no expired state, so without this it would wait
// forever on an invoice that expires while Unpaid.
let invoice_decoded = rpc
.call_typed(&DecodeRequest {
string: invoice.invoice.clone(),
})
.await?;
if !invoice_decoded.valid {
log::warn!(
"Skipping hold invoice {}, could not decode it",
hex::encode(&invoice.payment_hash)
);
continue;
}
let (created_at, expiry) = match invoice_decoded.item_type {
DecodeType::BOLT12_INVOICE => (
invoice_decoded.invoice_created_at,
invoice_decoded.invoice_relative_expiry.map(u64::from),
),
DecodeType::BOLT11_INVOICE => {
(invoice_decoded.created_at, invoice_decoded.expiry)
}
_ => continue,
};
let (Some(created_at), Some(expiry)) = (created_at, expiry) else {
log::warn!(
"Skipping hold invoice {}: missing creation time or expiry",
hex::encode(&invoice.payment_hash)
);
continue;
};
let expires_at = created_at.saturating_add(expiry);
log::debug!(
"Starting holdinvoice accepted handler for {}",
hex::encode(&invoice.payment_hash)
@ -234,6 +279,7 @@ async fn load_pending_hold_invoices(plugin: Plugin<PluginState>) -> Result<(), a
tokio::spawn(holdinvoice_accepted_handler(
plugin.clone(),
invoice.payment_hash,
expires_at,
));
}
}

View file

@ -107,6 +107,8 @@ async fn make_hold_invoice(
})?
.into_inner();
let expires_at = Timestamp::now() + expiry;
let response = nip47::MakeHoldInvoiceResponse {
invoice: Some(holdinvoice.bolt11),
transaction_type: nip47::TransactionType::Incoming,
@ -114,12 +116,16 @@ async fn make_hold_invoice(
description_hash: params.description_hash,
amount: params.amount,
created_at: Timestamp::now(),
expires_at: Timestamp::now() + expiry,
expires_at,
metadata: None,
payment_hash: params.payment_hash,
};
tokio::spawn(holdinvoice_accepted_handler(plugin, payment_hash));
tokio::spawn(holdinvoice_accepted_handler(
plugin,
payment_hash,
expires_at.as_secs(),
));
Ok(response)
}

View file

@ -28,6 +28,11 @@ use crate::{
structs::{NOT_INV_ERR, PluginState, WalletService},
};
/// Upper bound on how long a `holdinvoice_accepted_handler` waits for an
/// invoice to be accepted, so a client cannot pin a task and a gRPC stream
/// open forever with an absurd expiry.
const MAX_HOLD_WAIT_SECS: u64 = 24 * 60 * 60;
pub async fn payment_received_handler(
plugin: Plugin<PluginState>,
args: serde_json::Value,
@ -396,6 +401,7 @@ async fn send_notification(
pub async fn holdinvoice_accepted_handler(
plugin: Plugin<PluginState>,
payment_hash: Vec<u8>,
expires_at: u64,
) -> Result<(), anyhow::Error> {
let mut hold_client = plugin.state().hold_client.lock().clone().unwrap();
@ -404,13 +410,51 @@ pub async fn holdinvoice_accepted_handler(
};
let mut track_stream = hold_client.track(track_request).await?.into_inner();
while let Some(response) = track_stream.message().await? {
log::debug!("Invoice status: {}", response.state().as_str_name());
if response.state() == InvoiceState::Accepted {
break;
// The hold plugin has no expired state: an invoice that expires without
// being accepted stays in the Unpaid state and the track stream never ends
// on its own. Bound the wait by the invoice's expiry (capped) so we do not
// hold this task and gRPC stream open forever.
let wait_duration = expires_at
.saturating_sub(Timestamp::now().as_secs())
.min(MAX_HOLD_WAIT_SECS);
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(wait_duration);
let mut accepted = false;
loop {
match tokio::time::timeout_at(deadline, track_stream.message()).await {
Err(_elapsed) => {
log::debug!(
"Hold invoice {} expired before being accepted",
hex::encode(&payment_hash)
);
break;
}
Ok(Err(e)) => return Err(e.into()),
// The stream ended without an Accepted state: the invoice was
// cancelled before it ever got accepted.
Ok(Ok(None)) => break,
Ok(Ok(Some(response))) => {
log::debug!("Invoice status: {}", response.state().as_str_name());
match response.state() {
InvoiceState::Accepted => {
accepted = true;
break;
}
InvoiceState::Paid | InvoiceState::Cancelled => break,
InvoiceState::Unpaid => (),
}
}
}
}
if !accepted {
log::debug!(
"Hold invoice {} was not accepted, skipping notification",
hex::encode(&payment_hash)
);
return Ok(());
}
let list_request = ListRequest {
constraint: Some(Constraint::PaymentHash(payment_hash.clone())),
};

View file

@ -1294,9 +1294,10 @@ async def test_hold_invoice(
"log-level": "debug",
"plugin": get_plugin,
"important-plugin": get_hold,
"hold-grpc-port": node_factory.get_unused_port(),
"nip47-relays": url,
"may_reconnect": True,
"broken_log": r"Relay receiver exited with error",
"broken_log": r"Relay receiver exited with error|Connection failed",
},
],
)
@ -1824,3 +1825,80 @@ async def test_hold_invoice(
):
hold_events.append(content)
assert len(hold_events) == 1
@pytest.mark.asyncio
async def test_hold_invoice_expiry(
node_factory,
get_plugin, # noqa: F811
get_hold, # noqa: F811
nostr_relay,
):
url = nostr_relay
l2 = node_factory.get_node(
options={
"log-level": "debug",
"plugin": get_plugin,
"important-plugin": get_hold,
"hold-grpc-port": node_factory.get_unused_port(),
"nip47-relays": url,
},
broken_log=r"Relay receiver exited with error|Connection failed",
)
uri_res = l2.rpc.call("nip47-create", ["test1", 3010])
uri_str = uri_res["uri"]
client_pubkey = PublicKey.parse(uri_res["clientkey_public"])
LOGGER.info(uri_str)
uri = NostrWalletConnectUri.parse(uri_str)
preimage = secrets.token_hex(32)
payment_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest()
LOGGER.info(f"preimage: {preimage}")
LOGGER.info(f"payment_hash: {payment_hash}")
content = {
"method": "make_hold_invoice",
"params": {
"amount": 5000,
"payment_hash": payment_hash,
"expiry": 5,
},
}
content = json.dumps(content)
keys = Keys(uri.secret())
encrypted_content = keys.nip04_encrypt(uri.public_key(), content)
event = (
await EventBuilder(Kind(23194), encrypted_content)
.tags([Tag.public_key(uri.public_key())])
.finalize_async(keys)
)
client = Client()
relay_url = RelayUrl.parse(url)
await client.add_relay(relay_url)
await client.connect()
await fetch_info_event(client, uri)
(responses1, _res) = await fetch_event_responses(
client, client_pubkey, 23195, client.send_event(event), 1
)
error_events = []
success_events = []
for event in responses1:
LOGGER.info(event)
content = keys.nip04_decrypt(uri.public_key(), event.content())
content = json.loads(content)
LOGGER.info(content)
if "result" in content and content["result"] is not None:
success_events.append(content)
if "error" in content and content["error"] is not None:
error_events.append(content)
assert len(success_events) == 1
assert len(error_events) == 0
# The hold plugin has no expired state: an invoice that expires without
# ever being accepted (and without being cancelled) stays `Unpaid` and the
# track stream never ends on its own. The handler must give up at the
# invoice's expiry instead of hanging, and must not send a spurious
# accepted notification.
l2.daemon.wait_for_log("was not accepted, skipping notification", timeout=20)