Skip to content
Open
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: 2 additions & 1 deletion app/src/main/kotlin/com/wisp/app/nostr/Nip47.kt
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ object Nip47 {
object GetBalance : NwcRequest()
object GetInfo : NwcRequest()
data class PayInvoice(val invoice: String) : NwcRequest()
data class MakeInvoice(val amountMsats: Long, val description: String) : NwcRequest()
data class MakeInvoice(val amountMsats: Long, val description: String, val expiry: Int? = null) : NwcRequest()
data class ListTransactions(val limit: Int = 50, val offset: Int = 0) : NwcRequest()
}

Expand Down Expand Up @@ -172,6 +172,7 @@ object Nip47 {
put("params", buildJsonObject {
put("amount", request.amountMsats)
put("description", request.description)
request.expiry?.let { put("expiry", it) }
})
}
is NwcRequest.ListTransactions -> buildJsonObject {
Expand Down
7 changes: 5 additions & 2 deletions app/src/main/kotlin/com/wisp/app/repo/NwcRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ class NwcRepository(private val context: Context, private val relayPool: RelayPo
private val _paymentReceived = MutableSharedFlow<Long>(extraBufferCapacity = 8)
override val paymentReceived: SharedFlow<Long> = _paymentReceived

private val _transactionsChanged = MutableSharedFlow<Unit>(extraBufferCapacity = 8)
override val transactionsChanged: SharedFlow<Unit> = _transactionsChanged

private fun emitStatus(msg: String) {
Log.d(TAG, msg)
_statusLog.tryEmit(msg)
Expand Down Expand Up @@ -346,8 +349,8 @@ class NwcRepository(private val context: Context, private val relayPool: RelayPo
return result.map { (it as Nip47.NwcResponse.PayInvoiceResult).preimage }
}

override suspend fun makeInvoice(amountMsats: Long, description: String): Result<String> {
val result = sendRequest(Nip47.NwcRequest.MakeInvoice(amountMsats, description))
override suspend fun makeInvoice(amountMsats: Long, description: String, expirySecs: Int): Result<String> {
val result = sendRequest(Nip47.NwcRequest.MakeInvoice(amountMsats, description, expirySecs))
return result.map { (it as Nip47.NwcResponse.MakeInvoiceResult).invoice }
}

Expand Down
98 changes: 88 additions & 10 deletions app/src/main/kotlin/com/wisp/app/repo/SparkRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@ import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import breez_sdk_spark.CheckLightningAddressRequest
import breez_sdk_spark.ClaimDepositRequest
import breez_sdk_spark.ConnectRequest
import breez_sdk_spark.DepositInfo
import breez_sdk_spark.EventListener
import breez_sdk_spark.GetInfoRequest
import breez_sdk_spark.ListPaymentsRequest
import breez_sdk_spark.MaxFee
import breez_sdk_spark.Network
import breez_sdk_spark.PaymentDetails
import breez_sdk_spark.PaymentType
Expand Down Expand Up @@ -101,6 +104,9 @@ class SparkRepository(
private val _paymentReceived = MutableSharedFlow<Long>(extraBufferCapacity = 8)
override val paymentReceived: SharedFlow<Long> = _paymentReceived

private val _transactionsChanged = MutableSharedFlow<Unit>(extraBufferCapacity = 8)
override val transactionsChanged: SharedFlow<Unit> = _transactionsChanged

// Identity pubkey from the SDK's GetInfoResponse — exposed for the
// Wallet Info expandable in settings. Populated on first balance fetch
// after connect.
Expand Down Expand Up @@ -309,15 +315,22 @@ class SparkRepository(
is SdkEvent.PaymentSucceeded -> {
emitStatus("Payment succeeded")
refreshBalanceInternal()
_transactionsChanged.tryEmit(Unit)
if (e.payment.paymentType == PaymentType.RECEIVE) {
_paymentReceived.tryEmit(e.payment.amount.toLong() * 1000)
}
}
is SdkEvent.PaymentFailed -> {
emitStatus("Payment failed")
_transactionsChanged.tryEmit(Unit)
}
is SdkEvent.PaymentPending -> {
emitStatus("Payment pending")
refreshBalanceInternal()
_transactionsChanged.tryEmit(Unit)
}
is SdkEvent.UnclaimedDeposits -> {
claimDeposits(e.unclaimedDeposits)
}
else -> {}
}
Expand All @@ -329,6 +342,7 @@ class SparkRepository(
emitStatus("Connected to Spark")

refreshBalanceInternal()
claimPendingDeposits()
} catch (e: Exception) {
emitStatus("Connection failed: ${e.message}")
Log.e(TAG, "Spark connect failed", e)
Expand Down Expand Up @@ -467,7 +481,7 @@ class SparkRepository(

// --- Receive ---

override suspend fun makeInvoice(amountMsats: Long, description: String): Result<String> =
override suspend fun makeInvoice(amountMsats: Long, description: String, expirySecs: Int): Result<String> =
withContext(Dispatchers.IO) {
try {
val instance = sdk ?: return@withContext Result.failure(Exception("Not connected"))
Expand All @@ -477,7 +491,7 @@ class SparkRepository(
val method = ReceivePaymentMethod.Bolt11Invoice(
description = description.ifEmpty { "Wisp wallet" },
amountSats = amountSats,
expirySecs = 3600u,
expirySecs = expirySecs.toUInt(),
paymentHash = null
)
val response = instance.receivePayment(ReceivePaymentRequest(method))
Expand All @@ -502,6 +516,54 @@ class SparkRepository(
}
}

// --- On-chain deposit claiming ---

/**
* On-chain deposits sit unclaimed (and show as "Pending" in history) until
* explicitly claimed once they have enough confirmations. The SDK emits
* [SdkEvent.UnclaimedDeposits] when deposits become claimable; claim them
* automatically so they settle without user action.
*/
private suspend fun claimDeposits(deposits: List<DepositInfo>) {
val instance = sdk ?: return
var claimedAny = false
for (deposit in deposits) {
try {
instance.claimDeposit(
ClaimDepositRequest(
txid = deposit.txid,
vout = deposit.vout,
maxFee = MaxFee.NetworkRecommended(leewaySatPerVbyte = 5UL)
)
)
claimedAny = true
emitStatus("Claimed on-chain deposit")
} catch (e: Exception) {
emitStatus("Failed to claim deposit: ${e.message}")
Log.e(TAG, "claimDeposit failed for ${deposit.txid}", e)
}
}
if (claimedAny) {
refreshBalanceInternal()
_transactionsChanged.tryEmit(Unit)
}
}

/** Claim any deposits that became claimable while the app was closed. */
suspend fun claimPendingDeposits() {
withContext(Dispatchers.IO) {
try {
val instance = sdk ?: return@withContext
val response = instance.listUnclaimedDeposits(breez_sdk_spark.ListUnclaimedDepositsRequest)
if (response.deposits.isNotEmpty()) {
claimDeposits(response.deposits)
}
} catch (e: Exception) {
Log.d(TAG, "listUnclaimedDeposits failed: ${e.message}")
}
}
}

// --- Transactions ---

override suspend fun listTransactions(limit: Int, offset: Int): Result<List<WalletTransaction>> =
Expand All @@ -515,17 +577,25 @@ class SparkRepository(
))
val transactions = response.payments.map { payment ->
val lightningDetails = payment.details as? PaymentDetails.Lightning

// Prefer bolt11-decoded hash (matches ZapSender records), fall back to HTLC hash, then payment ID
val decoded = lightningDetails?.invoice?.let {
com.wisp.app.nostr.Bolt11.decode(it)
val depositDetails = payment.details as? PaymentDetails.Deposit
val withdrawDetails = payment.details as? PaymentDetails.Withdraw
val onchain = depositDetails != null || withdrawDetails != null

val paymentHash = when {
onchain -> depositDetails?.txId ?: withdrawDetails?.txId ?: payment.id
else -> {
// Prefer bolt11-decoded hash (matches ZapSender records), fall back to HTLC hash, then payment ID
val decoded = lightningDetails?.invoice?.let {
com.wisp.app.nostr.Bolt11.decode(it)
}
val htlcHash = lightningDetails?.htlcDetails?.paymentHash?.lowercase()
decoded?.paymentHash ?: htlcHash ?: payment.id
}
}
val htlcHash = lightningDetails?.htlcDetails?.paymentHash?.lowercase()
val paymentHash = decoded?.paymentHash ?: htlcHash ?: payment.id
// Prefer Spark's description, fall back to bolt11 description
// (bolt11 tag 13 may contain the kind 9734 zap request JSON)
val description = lightningDetails?.description
?: decoded?.description
?: lightningDetails?.invoice?.let { com.wisp.app.nostr.Bolt11.decode(it)?.description }

WalletTransaction(
type = when (payment.paymentType) {
Expand All @@ -537,7 +607,15 @@ class SparkRepository(
amountMsats = payment.amount.toLong() * 1000,
feeMsats = payment.fees.toLong() * 1000,
createdAt = payment.timestamp.toLong(),
settledAt = payment.timestamp.toLong()
settledAt = payment.timestamp.toLong(),
// On-chain payments made outside this app instance (another
// wallet on the same seed) aren't tracked by this SDK session,
// so PaymentStatus can stay stuck at PENDING long after the
// underlying transaction is confirmed. Since wisp doesn't
// initiate on-chain send/receive itself, don't trust that flag
// for on-chain rows — the mempool.space link lets users verify.
pending = !onchain && payment.status == breez_sdk_spark.PaymentStatus.PENDING,
isOnchain = onchain
)
}
Result.success(transactions)
Expand Down
9 changes: 7 additions & 2 deletions app/src/main/kotlin/com/wisp/app/repo/WalletProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,15 @@ interface WalletProvider {
/** Emits the amount in msats whenever an incoming payment is received. */
val paymentReceived: SharedFlow<Long>

/** Emits Unit whenever the transaction list should be refreshed. */
val transactionsChanged: SharedFlow<Unit>

fun hasConnection(): Boolean
fun connect()
fun disconnect()
suspend fun fetchBalance(): Result<Long>
suspend fun payInvoice(bolt11: String): Result<String>
suspend fun makeInvoice(amountMsats: Long, description: String): Result<String>
suspend fun makeInvoice(amountMsats: Long, description: String, expirySecs: Int = 3600): Result<String>
suspend fun listTransactions(limit: Int = 50, offset: Int = 0): Result<List<WalletTransaction>>
}

Expand All @@ -29,5 +32,7 @@ data class WalletTransaction(
val createdAt: Long,
val settledAt: Long?,
/** Pubkey of the counterparty (recipient for outgoing, sender for incoming zaps). */
val counterpartyPubkey: String? = null
val counterpartyPubkey: String? = null,
val pending: Boolean = false,
val isOnchain: Boolean = false
)
Loading