Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -2128,6 +2128,9 @@
"lnurlWithdrawFailed": "Withdraw failed",
"@lnurlWithdrawFailed": {},

"lnurlWithdrawStillPending": "Still waiting for payment. It will appear in your balance if it arrives.",
"@lnurlWithdrawStillPending": {},

"lnurlWithdrawCallbackError": "Service rejected the request: {reason}",
"@lnurlWithdrawCallbackError": {
"placeholders": {
Expand Down
1 change: 1 addition & 0 deletions lib/l10n/app_es.arb
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,7 @@
"lnurlWithdrawRequesting": "Solicitando retiro…",
"lnurlWithdrawWaiting": "Esperando pago…",
"lnurlWithdrawFailed": "Retiro fallido",
"lnurlWithdrawStillPending": "Seguimos esperando el pago. Aparecerá en tu saldo si llega.",
"lnurlWithdrawCallbackError": "El servicio rechazó la solicitud: {reason}",
"lnurlWithdrawFixedAmount": "Recibirás {amount}",
"lnurlWithdrawAmountRange": "Elige el monto ({min} – {max})",
Expand Down
22 changes: 14 additions & 8 deletions lib/lnurl_withdraw.dart
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ class _LnurlWithdrawWaitingState extends State<LnurlWithdrawWaiting> {
await awaitReceive(
federationId: widget.fed.federationId,
operationId: opId,
).timeout(const Duration(minutes: 5));
).timeout(const Duration(minutes: 1));
if (!mounted) return;
await Navigator.push(
context,
Expand All @@ -101,11 +101,14 @@ class _LnurlWithdrawWaitingState extends State<LnurlWithdrawWaiting> {
);
await Future.delayed(const Duration(seconds: 4));
} on TimeoutException {
AppLogger.instance.error(
'LNURLw await_receive timed out after 5 minutes',
);
// Not a failure: the receive keeps running in Rust, so a late payment
// still lands in the balance.
AppLogger.instance.error('LNURLw await_receive timed out after 1 minute');
if (!mounted) return;
_showError();
_showToast(
context.l10n.lnurlWithdrawStillPending,
const Icon(Icons.schedule),
);
} catch (e) {
AppLogger.instance.error('LNURLw await_receive failed: $e');
if (!mounted) return;
Expand All @@ -117,12 +120,15 @@ class _LnurlWithdrawWaitingState extends State<LnurlWithdrawWaiting> {
}
}

void _showError() {
void _showError() =>
_showToast(context.l10n.lnurlWithdrawFailed, const Icon(Icons.error));

void _showToast(String message, Icon icon) {
ToastService().show(
message: context.l10n.lnurlWithdrawFailed,
message: message,
duration: const Duration(seconds: 5),
onTap: () {},
icon: const Icon(Icons.error),
icon: icon,
);
}

Expand Down
71 changes: 56 additions & 15 deletions rust/ecashapp/src/lnurl_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -307,22 +307,28 @@ pub(crate) async fn submit_withdraw_invoice(
invoice: &str,
) -> anyhow::Result<()> {
let callback_url = build_withdraw_callback_url(callback, k1, invoice);
let resp: serde_json::Value = crate::net::http_client()
.get(&callback_url)
.send()
.await?
.json()
.await?;

if resp.get("status").and_then(|s| s.as_str()) == Some("ERROR") {
let reason = resp
.get("reason")
.and_then(|r| r.as_str())
.unwrap_or("unknown error");
bail!("LNURLw service rejected the request: {reason}");
}
let resp = fetch_json(&callback_url).await?;
check_withdraw_callback_response(&resp)
}

Ok(())
/// LUD-03: only an explicit `{"status":"OK"}` means the service took the invoice.
///
/// Anything else — `{}`, a proxy's `{"detail": ...}`, a rate limiter's body — must
/// fail here, or the caller waits out its receive timeout for a payment nobody
/// sent. Errors and non-2xx statuses are already rejected by [`fetch_json`].
fn check_withdraw_callback_response(resp: &serde_json::Value) -> anyhow::Result<()> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably implement this in rust, not dart

let status = resp.get("status").and_then(|s| s.as_str());
if status.is_some_and(|s| s.eq_ignore_ascii_case("ok")) {
return Ok(());
}

// serde_json escapes control characters, so the excerpt cannot forge log lines.
let body = resp.to_string();
let excerpt = match body.char_indices().nth(REASON_MAX_LEN) {
Some((cutoff, _)) => format!("{}\u{2026}", &body[..cutoff]),
None => body,
};
bail!("LNURLw service did not confirm the withdraw: {excerpt}");
}

/// LUD-03: append k1 and pr to the callback URL, respecting existing query params.
Expand Down Expand Up @@ -430,6 +436,41 @@ mod tests {

// --- build_withdraw_callback_url ---

// --- check_withdraw_callback_response ---

#[test]
fn test_callback_response_ok_is_accepted() {
assert!(check_withdraw_callback_response(&json!({"status": "OK"})).is_ok());
assert!(check_withdraw_callback_response(&json!({"status": "ok"})).is_ok());
}

#[test]
fn test_callback_response_without_ok_status_is_rejected() {
for resp in [
json!({}),
json!({"detail": "Not Found"}),
json!({"error": "Rate limit exceeded"}),
json!({"status": "PENDING"}),
json!({"status": 1}),
] {
let err = check_withdraw_callback_response(&resp).unwrap_err();
assert!(
err.to_string().contains("did not confirm"),
"{resp} should be rejected, got: {err}"
);
}
}

#[test]
fn test_callback_response_excerpt_is_capped() {
let resp = json!({"detail": "x".repeat(1000)});
let err = check_withdraw_callback_response(&resp)
.unwrap_err()
.to_string();
assert!(err.ends_with('\u{2026}'));
assert!(err.len() < 300);
}

#[test]
fn test_callback_url_no_existing_params() {
let url =
Expand Down
Loading