Skip to content

Commit

Permalink
Prevent missing notifications due to concurrent SDK instances (#697)
Browse files Browse the repository at this point in the history
* Prevent missing notifications due to concurrent SDK instances

* Expose swap claim tx id and use it to derive WaitingConfirmation in plugins

* Use id type as get payment request variant
  • Loading branch information
danielgranhao authored Feb 3, 2025
1 parent c42bebb commit 0c9d2b8
Show file tree
Hide file tree
Showing 16 changed files with 558 additions and 140 deletions.
31 changes: 25 additions & 6 deletions cli/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use std::sync::Arc;
use std::thread;
use std::time::Duration;

use anyhow::{anyhow, Result};
use anyhow::{anyhow, bail, Result};
use breez_sdk_liquid::prelude::*;
use clap::{arg, ArgAction, Parser};
use qrcode_rs::render::unicode;
Expand Down Expand Up @@ -156,9 +156,14 @@ pub(crate) enum Command {
sort_ascending: Option<bool>,
},
/// Retrieve a payment
#[command(group = clap::ArgGroup::new("payment_identifiers").args(&["payment_hash", "swap_id"]).required(true))]
GetPayment {
/// Lightning payment hash
payment_hash: String,
#[arg(long, short = 'p')]
payment_hash: Option<String>,
/// Swap ID or its hash
#[arg(long, short = 's')]
swap_id: Option<String>,
},
/// Get and potentially accept proposed fees for WaitingFeeAcceptance Payment
ReviewPaymentProposedFees { swap_id: String },
Expand Down Expand Up @@ -572,10 +577,24 @@ pub(crate) async fn handle_command(
.await?;
command_result!(payments)
}
Command::GetPayment { payment_hash } => {
let maybe_payment = sdk
.get_payment(&GetPaymentRequest::Lightning { payment_hash })
.await?;
Command::GetPayment {
payment_hash,
swap_id,
} => {
if payment_hash.is_none() && swap_id.is_none() {
bail!("No payment identifiers provided.");
}

let maybe_payment = if let Some(payment_hash) = payment_hash {
sdk.get_payment(&GetPaymentRequest::PaymentHash { payment_hash })
.await?
} else if let Some(swap_id) = swap_id {
sdk.get_payment(&GetPaymentRequest::SwapId { swap_id })
.await?
} else {
None
};

match maybe_payment {
Some(payment) => command_result!(payment),
None => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package breez_sdk_liquid_notification.job

import android.content.Context
import breez_sdk_liquid.BindingLiquidSdk
import breez_sdk_liquid.GetPaymentRequest
import breez_sdk_liquid.Payment
import breez_sdk_liquid.PaymentDetails
import breez_sdk_liquid.PaymentState
import breez_sdk_liquid.PaymentType
import breez_sdk_liquid.SdkEvent
import breez_sdk_liquid_notification.Constants.DEFAULT_PAYMENT_RECEIVED_NOTIFICATION_TEXT
Expand All @@ -27,6 +29,11 @@ import breez_sdk_liquid_notification.NotificationHelper.Companion.notifyChannel
import breez_sdk_liquid_notification.ResourceHelper.Companion.getString
import breez_sdk_liquid_notification.SdkForegroundService
import breez_sdk_liquid_notification.ServiceLogger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.security.MessageDigest
Expand All @@ -42,9 +49,12 @@ class SwapUpdatedJob(
private val fgService: SdkForegroundService,
private val payload: String,
private val logger: ServiceLogger,
private val scope: CoroutineScope = CoroutineScope(Dispatchers.Default)
) : Job {
private var swapIdHash: String? = null
private var notified: Boolean = false
private var pollingJob: kotlinx.coroutines.Job? = null
private val pollingInterval: Long = 5000

companion object {
private const val TAG = "SwapUpdatedJob"
Expand All @@ -54,11 +64,62 @@ class SwapUpdatedJob(
try {
val request = Json.decodeFromString(SwapUpdatedRequest.serializer(), payload)
this.swapIdHash = request.id
startPolling(liquidSDK)
} catch (e: Exception) {
logger.log(TAG, "Failed to decode payload: ${e.message}", "WARN")
}
}

private fun startPolling(liquidSDK: BindingLiquidSdk) {
pollingJob = scope.launch {
while (isActive) {
try {
if (swapIdHash == null) {
stopPolling(Exception("Missing swap ID"))
return@launch
}

liquidSDK.getPayment(GetPaymentRequest.SwapId(swapIdHash!!))?.let { payment ->
when (payment.status) {
PaymentState.COMPLETE -> {
onEvent(SdkEvent.PaymentSucceeded(payment))
stopPolling()
return@launch
}
PaymentState.WAITING_FEE_ACCEPTANCE -> {
onEvent(SdkEvent.PaymentWaitingFeeAcceptance(payment))
stopPolling()
return@launch
}
PaymentState.PENDING -> {
if (paymentClaimIsBroadcasted(payment.details)) {
onEvent(SdkEvent.PaymentWaitingConfirmation(payment))
stopPolling()
return@launch
}
}
else -> { }
}
}
delay(pollingInterval)
} catch (e: Exception) {
stopPolling(e)
return@launch
}
}
}
}

private fun stopPolling(error: Exception? = null) {
pollingJob?.cancel()
pollingJob = null

error?.let {
logger.log(TAG, "Polling stopped with error: ${it.message}", "ERROR")
onShutdown()
}
}

override fun onEvent(e: SdkEvent) {
when (e) {
is SdkEvent.PaymentWaitingConfirmation -> handlePaymentSuccess(e.details)
Expand Down Expand Up @@ -89,6 +150,14 @@ class SwapUpdatedJob(
}
}

private fun paymentClaimIsBroadcasted(details: PaymentDetails?): Boolean {
return when (details) {
is PaymentDetails.Bitcoin -> details.claimTxId != null
is PaymentDetails.Lightning -> details.claimTxId != null
else -> false
}
}

private fun handlePaymentSuccess(payment: Payment) {
val swapId = getSwapId(payment.details)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,17 @@ typedef struct wire_cst_fetch_payment_proposed_fees_request {
struct wire_cst_list_prim_u_8_strict *swap_id;
} wire_cst_fetch_payment_proposed_fees_request;

typedef struct wire_cst_GetPaymentRequest_Lightning {
typedef struct wire_cst_GetPaymentRequest_PaymentHash {
struct wire_cst_list_prim_u_8_strict *payment_hash;
} wire_cst_GetPaymentRequest_Lightning;
} wire_cst_GetPaymentRequest_PaymentHash;

typedef struct wire_cst_GetPaymentRequest_SwapId {
struct wire_cst_list_prim_u_8_strict *swap_id;
} wire_cst_GetPaymentRequest_SwapId;

typedef union GetPaymentRequestKind {
struct wire_cst_GetPaymentRequest_Lightning Lightning;
struct wire_cst_GetPaymentRequest_PaymentHash PaymentHash;
struct wire_cst_GetPaymentRequest_SwapId SwapId;
} GetPaymentRequestKind;

typedef struct wire_cst_get_payment_request {
Expand Down Expand Up @@ -522,6 +527,7 @@ typedef struct wire_cst_PaymentDetails_Lightning {
struct wire_cst_list_prim_u_8_strict *payment_hash;
struct wire_cst_list_prim_u_8_strict *destination_pubkey;
struct wire_cst_ln_url_info *lnurl_info;
struct wire_cst_list_prim_u_8_strict *claim_tx_id;
struct wire_cst_list_prim_u_8_strict *refund_tx_id;
uint64_t *refund_tx_amount_sat;
} wire_cst_PaymentDetails_Lightning;
Expand All @@ -545,6 +551,7 @@ typedef struct wire_cst_PaymentDetails_Bitcoin {
bool auto_accepted_fees;
uint32_t *liquid_expiration_blockheight;
uint32_t *bitcoin_expiration_blockheight;
struct wire_cst_list_prim_u_8_strict *claim_tx_id;
struct wire_cst_list_prim_u_8_strict *refund_tx_id;
uint64_t *refund_tx_amount_sat;
} wire_cst_PaymentDetails_Bitcoin;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@ struct SwapUpdatedRequest: Codable {
class SwapUpdatedTask : TaskProtocol {
fileprivate let TAG = "SwapUpdatedTask"

private let pollingInterval: TimeInterval = 5.0

internal var payload: String
internal var contentHandler: ((UNNotificationContent) -> Void)?
internal var bestAttemptContent: UNMutableNotificationContent?
internal var logger: ServiceLogger
internal var request: SwapUpdatedRequest? = nil
internal var notified: Bool = false
private var pollingTimer: Timer?

init(payload: String, logger: ServiceLogger, contentHandler: ((UNNotificationContent) -> Void)? = nil, bestAttemptContent: UNMutableNotificationContent? = nil) {
self.payload = payload
Expand All @@ -31,6 +34,52 @@ class SwapUpdatedTask : TaskProtocol {
self.onShutdown()
throw e
}

startPolling(liquidSDK: liquidSDK)
}

func startPolling(liquidSDK: BindingLiquidSdk) {
pollingTimer = Timer.scheduledTimer(withTimeInterval: pollingInterval, repeats: true) { [weak self] _ in
guard let self = self else { return }
do {
guard let request = self.request else {
self.stopPolling(withError: NSError(domain: "SwapUpdatedTask", code: -1, userInfo: [NSLocalizedDescriptionKey: "Missing swap updated request"]))
return
}

if let payment = try liquidSDK.getPayment(req: .swapId(swapId: request.id)) {
switch payment.status {
case .complete:
onEvent(e: SdkEvent.paymentSucceeded(details: payment))
self.stopPolling()
case .waitingFeeAcceptance:
onEvent(e: SdkEvent.paymentWaitingFeeAcceptance(details: payment))
self.stopPolling()
case .pending:
if paymentClaimIsBroadcasted(details: payment.details) {
onEvent(e: SdkEvent.paymentWaitingConfirmation(details: payment))
self.stopPolling()
}
default:
break
}
}
} catch {
self.stopPolling(withError: error)
}
}

pollingTimer?.fire()
}

private func stopPolling(withError error: Error? = nil) {
pollingTimer?.invalidate()
pollingTimer = nil

if let error = error {
logger.log(tag: TAG, line: "Polling stopped with error: \(error)", level: "ERROR")
onShutdown()
}
}

public func onEvent(e: SdkEvent) {
Expand Down Expand Up @@ -59,9 +108,9 @@ class SwapUpdatedTask : TaskProtocol {
func getSwapId(details: PaymentDetails?) -> String? {
if let details = details {
switch details {
case let .bitcoin(swapId, _, _, _, _, _, _):
case let .bitcoin(swapId, _, _, _, _, _, _, _):
return swapId
case let .lightning(swapId, _, _, _, _, _, _, _, _, _, _):
case let .lightning(swapId, _, _, _, _, _, _, _, _, _, _, _):
return swapId
default:
break
Expand All @@ -70,6 +119,17 @@ class SwapUpdatedTask : TaskProtocol {
return nil
}

func paymentClaimIsBroadcasted(details: PaymentDetails) -> Bool {
switch details {
case let .bitcoin(_, _, _, _, _, claimTxId, _, _):
return claimTxId != nil
case let .lightning( _, _, _, _, _, _, _, _, _, claimTxId, _, _):
return claimTxId != nil
default:
return false
}
}

func onShutdown() {
let notificationTitle = ResourceHelper.shared.getString(key: Constants.SWAP_CONFIRMED_NOTIFICATION_FAILURE_TITLE, fallback: Constants.DEFAULT_SWAP_CONFIRMED_NOTIFICATION_FAILURE_TITLE)
let notificationBody = ResourceHelper.shared.getString(key: Constants.SWAP_CONFIRMED_NOTIFICATION_FAILURE_TEXT, fallback: Constants.DEFAULT_SWAP_CONFIRMED_NOTIFICATION_FAILURE_TEXT)
Expand Down
7 changes: 4 additions & 3 deletions lib/bindings/src/breez_sdk_liquid.udl
Original file line number Diff line number Diff line change
Expand Up @@ -569,7 +569,8 @@ interface ListPaymentDetails {

[Enum]
interface GetPaymentRequest {
Lightning(string payment_hash);
PaymentHash(string payment_hash);
SwapId(string swap_id);
};

dictionary FetchPaymentProposedFeesRequest {
Expand Down Expand Up @@ -605,9 +606,9 @@ dictionary AssetInfo {

[Enum]
interface PaymentDetails {
Lightning(string swap_id, string description, u32 liquid_expiration_blockheight, string? preimage, string? invoice, string? bolt12_offer, string? payment_hash, string? destination_pubkey, LnUrlInfo? lnurl_info, string? refund_tx_id, u64? refund_tx_amount_sat);
Lightning(string swap_id, string description, u32 liquid_expiration_blockheight, string? preimage, string? invoice, string? bolt12_offer, string? payment_hash, string? destination_pubkey, LnUrlInfo? lnurl_info, string? claim_tx_id, string? refund_tx_id, u64? refund_tx_amount_sat);
Liquid(string asset_id, string destination, string description, AssetInfo? asset_info);
Bitcoin(string swap_id, string description, boolean auto_accepted_fees, u32? bitcoin_expiration_blockheight, u32? liquid_expiration_blockheight, string? refund_tx_id, u64? refund_tx_amount_sat);
Bitcoin(string swap_id, string description, boolean auto_accepted_fees, u32? bitcoin_expiration_blockheight, u32? liquid_expiration_blockheight, string? claim_tx_id, string? refund_tx_id, u64? refund_tx_amount_sat);
};

dictionary Payment {
Expand Down
Loading

0 comments on commit 0c9d2b8

Please sign in to comment.