Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
f00fb41
feat: pay contacts via spark
ovitrif Nov 5, 2026
ba97f20
chore: merge feat/breez-spark-lightning
ovitrif Nov 5, 2026
648403b
chore: merge feat/breez-spark-lightning
ovitrif Nov 5, 2026
2541718
chore: merge feat/breez-spark-lightning
ovitrif Nov 6, 2026
abe553d
chore: merge feat/breez-spark-lightning
ovitrif Nov 6, 2026
125feaa
chore: merge feat/breez-spark-lightning
ovitrif Nov 6, 2026
6c86082
chore: merge feat/breez-spark-lightning
ovitrif Nov 6, 2026
735b5a9
feat: route paykit payments with bitkit-core spark.2
ovitrif Nov 6, 2026
2c2d29f
chore: merge feat/breez-spark-lightning
ovitrif Nov 6, 2026
26d976e
fix: record spark withdrawals to contacts
ovitrif Nov 6, 2026
1cbb4b6
fix: offer spark when savings cannot pay a contact
ovitrif Nov 6, 2026
a7fcf69
chore: merge feat/breez-spark-lightning
ovitrif Nov 6, 2026
bd755b8
feat: route every contact payment through the paykit router
ovitrif Nov 6, 2026
a792f94
fix: show the app's copy when no route pays a contact
ovitrif Nov 6, 2026
b37ea91
fix: tell an unpayable contact from a short balance
ovitrif Nov 6, 2026
47bf675
chore: merge feat/breez-spark-lightning
ovitrif Nov 6, 2026
25b40e0
chore: move to bitkit-core 0.8.0-spark.3
ovitrif Nov 6, 2026
964218c
chore: translate the no-route copy
ovitrif Nov 7, 2026
47a1484
feat: pay payment requests from spark through the router
ovitrif Nov 7, 2026
c106fb0
fix: keep a spark withdrawal proof through backup
ovitrif Nov 7, 2026
d49fc3f
fix: keep contact withdrawals out of transfers to savings
ovitrif Nov 7, 2026
154f273
fix: tie spark withdrawals to their attempt and confirmed fee
ovitrif Nov 7, 2026
12ae219
fix: pay requests savings cannot cover from spark
ovitrif Nov 7, 2026
32d546d
test: keep the spark leg for onchain and usdt contacts
ovitrif Nov 7, 2026
adabea8
fix: keep an uncertain spark send in flight
ovitrif Nov 7, 2026
a800647
fix: settle the withdrawal journal only after a fresh sync
ovitrif Nov 7, 2026
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 app/src/main/java/to/bitkit/data/CacheStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import to.bitkit.models.FxRate
import to.bitkit.models.NewTransactionSheetDetails
import to.bitkit.models.QuickPayLedger
import to.bitkit.models.WalletScope
import to.bitkit.repositories.PendingSparkWithdrawal
import to.bitkit.utils.Logger
import javax.inject.Inject
import javax.inject.Singleton
Expand Down Expand Up @@ -169,6 +170,8 @@ data class AppCacheData(
val quickPayLedger: QuickPayLedger? = null,
/** LNURL-pay comments by payment hash, kept until the sent payment's activity stores them. */
val pendingLightningMessages: Map<String, String> = mapOf(),
/** A Spark withdrawal to a Paykit contact that started, kept until Spark's payment list settles it. */
val pendingSparkWithdrawal: PendingSparkWithdrawal? = null,
) {
fun isContactDetached(activityId: String, walletId: String): Boolean =
scopedActivityId(walletId, activityId) in detachedActivityContacts
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ data class PaykitPaymentStateBackup(
val privatePaymentListVersion: ULong? = null,
val previousPrivatePaymentListVersion: ULong? = null,
val allowanceId: String? = null,
val sparkWithdrawal: Boolean = false,
) {
init {
require(
Expand Down Expand Up @@ -98,6 +99,7 @@ data class PaykitPaymentStateBackup(
privatePaymentListVersion = proof.privatePaymentListVersion,
previousPrivatePaymentListVersion = proof.previousPrivatePaymentListVersion,
allowanceId = proof.allowanceId,
sparkWithdrawal = proof.sparkWithdrawal,
)

fun restored() = PendingPaykitPaymentProof(
Expand All @@ -124,6 +126,7 @@ data class PaykitPaymentStateBackup(
privatePaymentListVersion = privatePaymentListVersion,
previousPrivatePaymentListVersion = previousPrivatePaymentListVersion,
allowanceId = allowanceId,
sparkWithdrawal = sparkWithdrawal,
)
}
}
10 changes: 10 additions & 0 deletions app/src/main/java/to/bitkit/repositories/LightningRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2331,6 +2331,16 @@ class LightningRepo @Inject constructor(
}
}

/** What became of a Spark withdrawal attempt of exactly [amountSats] to [address], from Spark's payment list. */
suspend fun sparkWithdrawalOutcome(
address: String,
amountSats: ULong,
earlierTxids: Set<String>,
): SparkWithdrawalOutcome = sparkRepo.withdrawalOutcome(address, amountSats, earlierTxids)

/** Txids of the Spark withdrawals to [address] so far, which a new attempt to it never counts as its own. */
suspend fun sparkWithdrawalTxidsTo(address: String): Set<String> = sparkRepo.withdrawalTxidsTo(address)

/** Whether Spark alone can pay [amountSats], which it does whenever the node cannot. */
fun canSendWithSpark(amountSats: ULong): Boolean = sparkRepo.canSend(amountSats)

Expand Down
130 changes: 129 additions & 1 deletion app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ data class PendingPaykitPaymentProof(
val previousPrivatePaymentListVersion: ULong? = null,
/** Set only for an automatic Allowance payment, from its submitted attempt. Manual payments omit it. */
val allowanceId: String? = null,
/** An on-chain payment sent as a Spark withdrawal, settled from Spark's payment list instead of the node's. */
val sparkWithdrawal: Boolean = false,
)

data class PaykitOnchainPaymentProofResolution(
Expand Down Expand Up @@ -297,6 +299,48 @@ class PaykitPaymentProofRepo @Inject constructor(
privatePaymentListVersion: ULong? = null,
signedTx: HwFundingSignedTx? = null,
previousPrivatePaymentListVersion: ULong? = null,
): Result<Unit> = markOnchainStarted(
request = request,
address = address,
amountSats = amountSats,
walletId = walletId,
privatePaymentListVersion = privatePaymentListVersion,
signedTx = signedTx,
previousPrivatePaymentListVersion = previousPrivatePaymentListVersion,
sparkWithdrawal = false,
)

/** Marks the on-chain proof of [request] started for a Spark withdrawal of [amountSats] to [address]. */
suspend fun markSparkOnchainPaymentStarted(
request: PaykitPaymentRequest,
address: String,
amountSats: ULong,
privatePaymentListVersion: ULong? = null,
previousPrivatePaymentListVersion: ULong? = null,
earlierWithdrawalTxids: Set<String> = emptySet(),
): Result<Unit> = markOnchainStarted(
request = request,
address = address,
amountSats = amountSats,
walletId = WalletScope.default,
privatePaymentListVersion = privatePaymentListVersion,
signedTx = null,
previousPrivatePaymentListVersion = previousPrivatePaymentListVersion,
sparkWithdrawal = true,
earlierWithdrawalTxids = earlierWithdrawalTxids,
)

@Suppress("LongParameterList")
private suspend fun markOnchainStarted(
request: PaykitPaymentRequest,
address: String,
amountSats: ULong,
walletId: String,
privatePaymentListVersion: ULong?,
signedTx: HwFundingSignedTx?,
previousPrivatePaymentListVersion: ULong?,
sparkWithdrawal: Boolean,
earlierWithdrawalTxids: Set<String> = emptySet(),
): Result<Unit> = withContext(ioDispatcher) {
runSuspendCatching {
val identity = currentIdentity() ?: throw PaykitPaymentRequestError.RequestUnavailable
Expand Down Expand Up @@ -336,12 +380,90 @@ class PaykitPaymentProofRepo @Inject constructor(
hardwareFeeRate = signedTx?.feeRate,
hardwareTotalSpent = signedTx?.totalSpent,
hardwareDispatchAttempted = signedTx?.let { false },
sparkWithdrawal = sparkWithdrawal,
onchainMatchingTransactionIdsBeforeAttempt = if (sparkWithdrawal) {
earlierWithdrawalTxids
} else {
proofs[index].onchainMatchingTransactionIdsBeforeAttempt
},
)
persist(proofs)
}
}.onFailure { Logger.warn("Failed to mark a Paykit on-chain payment as started", it, context = TAG) }
}

/** Completes the started proof of [request] paid by the Spark withdrawal [txid] and delivers it. */
suspend fun completeSparkOnchainPayment(request: PaykitPaymentRequest, txid: String): Boolean =
withContext(ioDispatcher) {
if (!txid.isHex(HASH_BYTE_COUNT)) return@withContext false
val identity = currentIdentity() ?: return@withContext false
runSuspendCatching {
operationMutex.withLock {
val proof = loadProofs().lastOrNull {
PubkyPublicKeyFormat.matches(it.identity, identity) && it.requestId == request.id &&
it.sparkWithdrawal && it.paymentStarted && it.proofData == null
} ?: return@withLock false
completeSparkOnchainProofLocked(proof, txid)
}
}.onFailure {
Logger.warn("Failed to complete a Paykit proof for a Spark withdrawal", it, context = TAG)
}.getOrDefault(false)
}

/**
* Releases the started Spark proof of [request] when Spark shows its withdrawal never sent, so the request can be
* paid again; returns whether it did.
*/
suspend fun releaseUnsentSparkOnchainProof(request: PaykitPaymentRequest): Boolean = withContext(ioDispatcher) {
val identity = currentIdentity() ?: return@withContext false
runSuspendCatching {
operationMutex.withLock {
val proof = loadProofs().lastOrNull {
PubkyPublicKeyFormat.matches(it.identity, identity) && it.requestId == request.id &&
it.sparkWithdrawal && it.paymentStarted && it.proofData == null
} ?: return@withLock false
reconcileSparkOnchainProof(proof)
loadProofs().none { it == proof }
}
}.onFailure {
Logger.warn("Failed to release an unsent Spark withdrawal proof", it, context = TAG)
}.getOrDefault(false)
}

/** Settles a started Spark proof from Spark's list: completed by its own withdrawal, removed when it never sent. */
private suspend fun reconcileSparkOnchainProof(proof: PendingPaykitPaymentProof): Boolean {
if (!proof.paymentStarted || proof.proofData != null) return false
val address = proof.onchainAddress ?: return false
val amount = proof.onchainAmountSats ?: return false
val outcome = lightningRepo.sparkWithdrawalOutcome(
address,
amount,
proof.onchainMatchingTransactionIdsBeforeAttempt,
)
return when (outcome) {
is SparkWithdrawalOutcome.Sent -> completeSparkOnchainProofLocked(proof, outcome.txid)
SparkWithdrawalOutcome.Unsent -> {
Logger.info("Released a Paykit proof whose Spark withdrawal never sent", context = TAG)
removeProofsLocked { it == proof }
false
}
SparkWithdrawalOutcome.Unknown -> false
}
}

private suspend fun completeSparkOnchainProofLocked(proof: PendingPaykitPaymentProof, txid: String): Boolean {
val proofs = loadProofs().toMutableList()
val index = proofs.indexOf(proof)
if (index < 0) return false
val completed = proof.copy(
paymentIdentifier = txid.lowercase(),
proofData = txid.lowercase(),
onchainAcceptanceVerified = true,
)
proofs[index] = completed
return persistAndSubmit(listOf(completed), proofs)
}

suspend fun completeLightningPayment(paymentHash: String, preimage: String?) = withContext(ioDispatcher) {
if (preimage == null) return@withContext
if (!preimage.matchesPaymentHash(paymentHash)) {
Expand Down Expand Up @@ -1182,7 +1304,10 @@ class PaykitPaymentProofRepo @Inject constructor(
}.getOrElse { return@withContext }
if (snapshot.isEmpty()) return@withContext
finishDeniedHardwarePreparations(snapshot)
snapshot.filter { it.kind == PaykitPaymentProofKind.Onchain && it.onchainWalletId == WalletScope.default }
snapshot.filter {
it.kind == PaykitPaymentProofKind.Onchain && it.onchainWalletId == WalletScope.default &&
!it.sparkWithdrawal
}
.forEach { proof ->
val txid = proof.proofData ?: attempt?.txid
val validCompletedProof = proof.paymentStarted && proof.proofData?.isHex(HASH_BYTE_COUNT) == true &&
Expand Down Expand Up @@ -1250,6 +1375,9 @@ class PaykitPaymentProofRepo @Inject constructor(
activityReady: Boolean,
): Boolean {
if (proof.hardwareDispatchDenied) return false
if (proof.sparkWithdrawal) {
return if (proof.proofData != null) submitReady(proof) else reconcileSparkOnchainProof(proof)
}
if (proof.kind == PaykitPaymentProofKind.Onchain && proof.onchainWalletId == WalletScope.default &&
!activityReady
) {
Expand Down
86 changes: 86 additions & 0 deletions app/src/main/java/to/bitkit/repositories/PaykitRouteRepo.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package to.bitkit.repositories

import com.synonym.bitkitcore.PaykitEndpoint
import com.synonym.bitkitcore.PaykitRoute
import com.synonym.bitkitcore.PaykitRouteRequest
import com.synonym.bitkitcore.ServiceRouteLeg
import com.synonym.bitkitcore.paykitRoutePayment
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import to.bitkit.di.IoDispatcher
import to.bitkit.env.Env
import to.bitkit.ext.runSuspendCatching
import to.bitkit.models.toCoreNetwork
import to.bitkit.utils.AppError
import to.bitkit.utils.Logger
import javax.inject.Inject
import javax.inject.Singleton

/**
* Picks how to pay a Paykit contact through bitkit-core's Paykit router: directly from a balance the wallet holds,
* or through a Spark withdrawal to the contact's on-chain endpoint when no balance reaches the contact. The router
* makes no network calls; the app quotes each Spark leg and runs the one the router returns.
*/
@Singleton
class PaykitRouteRepo @Inject constructor(
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val sparkRepo: SparkRepo,
) {
companion object {
private const val TAG = "PaykitRouteRepo"
}

/** Whether Spark holds a balance it can send, so a Spark leg may reach a contact no other balance reaches. */
val canRouteThroughSpark: Boolean
get() = sparkRepo.state.value.let { it.isReady && it.sendableSats > 0uL }

suspend fun route(
amountSats: ULong,
endpoints: List<PaykitEndpoint>,
lightningSpendableSats: ULong,
onchainSpendableSats: ULong,
quoteServiceLegs: Boolean,
): Result<PaykitRoute> = withContext(ioDispatcher) {
runSuspendCatching {
paykitRoutePayment(
request = PaykitRouteRequest(
amountSat = amountSats,
contactEndpoints = endpoints,
lightningSpendableSat = lightningSpendableSats,
onchainSpendableSat = onchainSpendableSats,
serviceLegs = if (quoteServiceLegs) serviceLegs(endpoints, amountSats) else emptyList(),
),
network = Env.network.toCoreNetwork(),
)
}
}

/** Spark withdrawals to the contact's on-chain endpoints, each quoted with its fee. */
private suspend fun serviceLegs(endpoints: List<PaykitEndpoint>, amountSats: ULong): List<ServiceRouteLeg> =
endpoints.filter { MethodId.fromRawValue(it.methodId)?.isOnchain == true }.mapNotNull { endpoint ->
sparkRepo.withdrawalRouteLeg(endpoint, amountSats)
.onFailure { Logger.warn("No Spark route to '${endpoint.methodId}'", it, context = TAG) }
.getOrNull()
}

/**
* Runs a service leg the router picked to the [contact] and returns the txid that pays them; [onBeforeSend] runs
* once the leg is prepared, right before it sends.
*/
suspend fun payServiceLeg(
leg: ServiceRouteLeg,
contact: String?,
tags: List<String> = emptyList(),
onBeforeSend: suspend () -> Boolean = { true },
): Result<String> = when (leg.service) {
SparkRepo.ROUTE_SERVICE -> sparkRepo.withdraw(
address = leg.endpoint.value,
amountSats = leg.deliveredSat,
contact = contact,
maxDebitSats = leg.debitSat,
tags = tags,
onBeforeSend = onBeforeSend,
)
else -> Result.failure(AppError("Unsupported route service '${leg.service}'"))
}
}
Loading
Loading