Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
121 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
49439d2
Merge remote-tracking branch 'origin/feat/breez-spark-lightning' into…
ovitrif Nov 9, 2026
634d66d
Merge remote-tracking branch 'origin/feat/breez-spark-lightning' into…
ovitrif Nov 9, 2026
127c617
fix: block the swipe when a source switch finds no route
ovitrif Nov 9, 2026
30ca31d
fix: release the private list of an unsent spark withdrawal
ovitrif Nov 9, 2026
951c0a6
fix: recheck a spark-routed request's amount before confirm
ovitrif Nov 9, 2026
72ee2a5
fix: keep a listed spark withdrawal's journal until it saves
ovitrif Nov 9, 2026
9b5b1c4
Merge remote-tracking branch 'origin/feat/breez-spark-lightning' into…
ovitrif Nov 9, 2026
f354717
Merge remote-tracking branch 'origin/feat/breez-spark-lightning' into…
ovitrif Nov 9, 2026
5020923
fix: keep a starting spark withdrawal's proof from reconcile
ovitrif Nov 9, 2026
8c601a0
fix: keep an unlisted spark withdrawal open for a grace period
ovitrif Nov 9, 2026
3543bc8
fix: reroute every source switch after spark finds no route
ovitrif Nov 9, 2026
a7bef98
Merge remote-tracking branch 'origin/feat/breez-spark-lightning' into…
ovitrif Nov 9, 2026
590f925
fix: settle a spark send's allowance attempt on reconcile
ovitrif Nov 9, 2026
1e3aa5d
Merge remote-tracking branch 'origin/feat/breez-spark-lightning' into…
ovitrif Nov 9, 2026
b22c249
fix: resolve a pending spark send once reconcile finds it sent
ovitrif Nov 9, 2026
03f22e6
fix: fail a pending spark send once reconcile finds it unsent
ovitrif Nov 9, 2026
20ac89e
fix: end a spark withdrawal spark returns as failed as a refusal
ovitrif Nov 9, 2026
d79cced
fix: drop a paykit route that returns after its payment changed
ovitrif Nov 9, 2026
4439a24
fix: settle an uncertain spark send to a contact from its journal
ovitrif Nov 9, 2026
5584e40
fix: settle an expired spark withdrawal journal on each sync
ovitrif Nov 9, 2026
b905364
Merge remote-tracking branch 'origin/feat/breez-spark-lightning' into…
ovitrif Nov 9, 2026
0c74a5b
fix: keep an unselected lnurl's limits off a spark leg
ovitrif Nov 9, 2026
4bd0732
fix: keep a spark withdrawal journal until spark lists it
ovitrif Nov 9, 2026
370653f
Merge remote-tracking branch 'origin/feat/breez-spark-lightning' into…
ovitrif Nov 9, 2026
cac9d12
Merge remote-tracking branch 'origin/feat/breez-spark-lightning' into…
ovitrif Nov 9, 2026
55e4991
fix: drop a late spark settlement once its send sheet closed
ovitrif Nov 9, 2026
31298c4
fix: register an uncertain spark send before reading its outcome
ovitrif Nov 9, 2026
2b34a65
fix: open success for a request's spark send spark already lists
ovitrif Nov 9, 2026
6d38782
Merge remote-tracking branch 'origin/feat/breez-spark-lightning' into…
ovitrif Nov 9, 2026
bc0db90
Merge remote-tracking branch 'origin/feat/breez-spark-lightning' into…
ovitrif Nov 9, 2026
9ab62cf
fix: clear a spark withdrawal journal only while it is still open
ovitrif Nov 9, 2026
3a77a65
chore: log private payment list publication
ovitrif Nov 9, 2026
e329c25
fix: keep a dismissed payment request from popping up again
ovitrif Nov 10, 2026
2d13bba
Merge remote-tracking branch 'origin/feat/breez-spark-lightning' into…
ovitrif Nov 10, 2026
de768f8
fix: announce a spark withdrawal's settlement only when its journal c…
ovitrif Nov 10, 2026
5a98c0a
Merge remote-tracking branch 'origin/feat/breez-spark-lightning' into…
ovitrif Nov 10, 2026
038668a
Merge remote-tracking branch 'origin/feat/breez-spark-lightning' into…
ovitrif Nov 10, 2026
7521d5f
fix: check the quote deadline right before a spark withdrawal sends
ovitrif Nov 10, 2026
d15dc9f
fix: release a spark proof refused at its deadline at once
ovitrif Nov 10, 2026
2bd07b7
chore: merge feat/breez-spark-lightning into feat/paykit-spark-route
ovitrif Nov 10, 2026
13d95cb
fix: keep a spark withdrawal journal until a sync reaches spark
ovitrif Nov 10, 2026
4369e44
fix: price a spark-funded onchain request at its onchain rate
ovitrif Nov 10, 2026
33db490
fix: keep a failed spark withdrawal open after a sync that never reac…
ovitrif Nov 10, 2026
fb33851
chore: merge feat/breez-spark-lightning into feat/paykit-spark-route
ovitrif Nov 10, 2026
ee69de3
chore: merge feat/breez-spark-lightning into feat/paykit-spark-route
ovitrif Nov 10, 2026
81fdf50
fix: keep an accepted spark withdrawal sent after a restart
ovitrif Nov 10, 2026
0f5d7e5
fix: drop a paykit route quote once its amount or source changed
ovitrif Nov 10, 2026
49e1aea
chore: merge feat/breez-spark-lightning into feat/paykit-spark-route
ovitrif Nov 11, 2026
5bac80a
fix: restore the endpoint a paykit reroute picks
ovitrif Nov 11, 2026
2674c84
fix: weigh a spark withdrawal leg against the whole balance
ovitrif Nov 11, 2026
352a129
fix: keep a cached spark withdrawal from settling a retry
ovitrif Nov 11, 2026
47296c6
fix: check a spark withdrawal's deadline after its journal
ovitrif Nov 11, 2026
62c6424
fix: keep a spark withdrawal's journal until its tags save
ovitrif Nov 11, 2026
eee7d23
refactor: drop a private list release that never runs
ovitrif Nov 11, 2026
6056afe
refactor: hold paykit route endpoints in an immutable list
ovitrif Nov 11, 2026
82c446d
fix: hide boost on a pending spark withdrawal
ovitrif Nov 11, 2026
cf8840e
fix: settle a deleted spark withdrawal it cannot tag
ovitrif Nov 11, 2026
801b660
fix: keep a spark send pending when its outcome read fails
ovitrif Nov 11, 2026
ed3bffc
fix: validate a spark withdrawal against its debit and spark's balance
ovitrif Nov 11, 2026
bc76a61
Merge remote-tracking branch 'origin/master' into HEAD
ovitrif Nov 11, 2026
7ff99a2
fix: check a rerouted lnurl's minimum before confirm
ovitrif Nov 11, 2026
c73f0cd
fix: check a rerouted lnurl's maximum before confirm
ovitrif Nov 11, 2026
4642457
fix: offer the spark leg when lightning pays the lnurl minimum
ovitrif Nov 11, 2026
218000b
fix: skip the spark quote when lightning pays a contact
ovitrif Nov 11, 2026
3d58693
fix: route mixed bitcoin contacts through spark
ovitrif Nov 11, 2026
db16625
fix: hold a pending spark withdrawal until spark completes it
ovitrif Nov 11, 2026
76ed400
fix: close a spark withdrawal spark refuses before sending at once
ovitrif Nov 11, 2026
b6dba15
fix: refuse a second spark withdrawal while one prepares
ovitrif Nov 11, 2026
a4a35f6
fix: keep boost on the savings receipt of a spark withdrawal
ovitrif Nov 11, 2026
bd4b727
fix: keep an lnurl its limits refuse out of the contact route
ovitrif Nov 11, 2026
5a168c5
fix: show a short balance when the spark fee does not fit
ovitrif Nov 11, 2026
7374cec
fix: keep confirm closed for a request past the lnurl maximum
ovitrif Nov 11, 2026
37979f8
Merge remote-tracking branch 'origin/master' into HEAD
ovitrif Nov 11, 2026
5042f41
fix: let savings pay a contact below its lnurl minimum
ovitrif Nov 11, 2026
eb6e8e1
fix: leave an open contact withdrawal off spark's transfers to savings
ovitrif Nov 11, 2026
6370c37
fix: close a contact withdrawal spark refuses for a fee rise at once
ovitrif Nov 11, 2026
d1b899a
fix: skip the lnurl invoice fetch for an on-chain contact payment
ovitrif Nov 11, 2026
a5022fe
fix: refuse a spark contact leg while an earlier withdrawal settles
ovitrif Nov 11, 2026
315cd03
fix: confirm an on-chain contact payment with its on-chain details
ovitrif Nov 11, 2026
b43fae9
fix: drop a spark leg's fee when the router picks the lnurl
ovitrif Nov 11, 2026
ba94a58
fix: show a spark leg's address as its confirm recipient
ovitrif Nov 11, 2026
f2654b0
Merge remote-tracking branch 'origin/master' into HEAD
ovitrif Nov 11, 2026
1f1a42e
fix: route paykit payments with bitkit-core rc14
ovitrif Nov 11, 2026
37276e7
fix: show tags and hide the lnurl comment on a spark leg
ovitrif Nov 11, 2026
3815386
fix: drop the spark leg fee on a direct bolt11 pick
ovitrif Nov 11, 2026
f9ad001
fix: confirm a spark withdrawal from the chain
ovitrif Nov 11, 2026
aa09b77
fix: keep a returned spark withdrawal in flight until it is recorded
ovitrif Nov 11, 2026
e0221f9
fix: match a spark withdrawal by its idempotency key
ovitrif Nov 11, 2026
c220ce5
chore: merge master into the paykit spark route branch
ovitrif Nov 11, 2026
b7c748f
test: stub spark sends with their idempotency key
ovitrif Nov 12, 2026
8cb1a6d
fix: confirm a spark payout the contact already spent
ovitrif Nov 12, 2026
82ef38a
docs: move the spark withdrawal journal kdoc onto clearJournal
ovitrif Nov 12, 2026
3ab29cd
test: pass the payout chain to the spark repo in lightning repo tests
ovitrif Nov 12, 2026
2511a60
fix: show a spark leg's fee on request confirm
ovitrif Nov 12, 2026
b5fa153
fix: confirm a spark payout when electrum fails, keeping its seen time
ovitrif Nov 12, 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
6 changes: 6 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,8 @@ 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.repositories.SentSparkWithdrawal
import to.bitkit.utils.Logger
import javax.inject.Inject
import javax.inject.Singleton
Expand Down Expand Up @@ -334,6 +336,10 @@ 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,
/** The latest Spark withdrawals to Paykit contacts that settled as sent, so a restart never reads one as unsent. */
val sentSparkWithdrawals: List<SentSparkWithdrawal> = listOf(),
/** Hashes of the latest Lightning sends Spark started, so the node holds off while Spark can't list them. */
val sparkSendHashes: List<String> = listOf(),
/** Invoice expiry in epoch seconds of the [sparkSendHashes] that know it. */
Expand Down
54 changes: 54 additions & 0 deletions app/src/main/java/to/bitkit/data/SparkPayoutChain.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package to.bitkit.data

import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import io.ktor.http.isSuccess
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.lightningdevkit.ldknode.Network
import to.bitkit.env.Env
import to.bitkit.ext.runSuspendCatching
import to.bitkit.utils.Logger
import javax.inject.Inject
import javax.inject.Singleton

/**
* Whether a Spark withdrawal's payout transaction is mined, asked of the Esplora chain Spark reads. Unlike the payout
* address's unspent outputs, the transaction's status still answers once the contact has spent the payout.
*/
@Singleton
class SparkPayoutChain @Inject constructor(
private val client: HttpClient,
) {
companion object {
private const val TAG = "SparkPayoutChain"
private const val SPARK_REGTEST_ESPLORA_URL = "https://regtest-mempool.us-west-2.sparkinfra.net/api"
private val json = Json { ignoreUnknownKeys = true }

/** The Esplora to ask on [network]: the one Spark reads there, or null where the app knows none. */
fun esploraUrl(network: Network): String? = when (network) {
Network.BITCOIN -> Env.bitkitEsploraUrl
Network.REGTEST -> SPARK_REGTEST_ESPLORA_URL
else -> null
}
}

/** Whether [txid] is in a block; null when this network has no Esplora to ask or the lookup failed. */
suspend fun isMined(txid: String): Boolean? {
val baseUrl = esploraUrl(Env.network) ?: return null
return runSuspendCatching {
val response = client.get("$baseUrl/tx/$txid/status")
// Spark can list a payout before it is broadcast, and the chain then knows no such transaction yet.
if (response.status == HttpStatusCode.NotFound) return@runSuspendCatching false
check(response.status.isSuccess()) { "status ${response.status.value}" }
json.parseToJsonElement(response.bodyAsText()).jsonObject.getValue("confirmed").jsonPrimitive.boolean
}.getOrElse {
Logger.warn("Failed to look up Spark payout transaction '$txid'", it, context = TAG)
null
}
}
}
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,
)
}
}
16 changes: 16 additions & 0 deletions app/src/main/java/to/bitkit/repositories/LightningRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,9 @@ class LightningRepo @Inject constructor(
/** Whether Spark synced its payments since it connected, so its payment list is complete. */
val isSparkSynced: Flow<Boolean> get() = sparkRepo.state.map { it.hasSynced }.distinctUntilChanged()

/** How each journaled Spark contact withdrawal closed. */
val sparkWithdrawalSettlements: Flow<SparkWithdrawalSettlement> get() = sparkRepo.withdrawalSettlements

private val scope = appScope(bgDispatcher, TAG)

private val _eventHandlers = ConcurrentHashMap.newKeySet<NodeEventHandler>()
Expand Down Expand Up @@ -2610,9 +2613,22 @@ 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)

/** Whether Spark's whole balance covers a withdrawal debiting [debitSats], its fees included. */
fun canWithdrawWithSpark(debitSats: ULong): Boolean = sparkRepo.canWithdraw(debitSats)

fun canSend(amountSats: ULong): Boolean {
val state = _lightningState.value
if (sparkRepo.canSend(amountSats)) return true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1113,6 +1113,27 @@ class PaykitAllowanceExecutor @Inject constructor(
}
}

/**
* Records a manual on-chain send of [requestId] that Spark's list later shows sent as succeeded with its
* [transactionId], so an attempt left unknown no longer holds the request. Throws when an outcome could not be
* recorded, so the proof that found it stays for another pass.
*/
suspend fun onchainSendSettled(payerIdentity: String?, requestId: PaykitPaymentRequestId, transactionId: String) {
val identity = activeIdentity?.takeIf { PubkyPublicKeyFormat.matches(it, payerIdentity) } ?: return
withContext(ioDispatcher) {
settleMutex.withLock {
val attempts = localState(identity).journal.filter {
it.requestId == requestId && !it.isAutomatic && it.paymentHash == null &&
it.stage in SETTLEABLE_STAGES
}
for (attempt in attempts) {
updateJournalEntry(attempt.attemptId, identity) { it.copy(transactionId = transactionId) }
record(attempt.attemptId, PaymentOutcome.SUCCEEDED, identity)
}
}
}
}

// endregion

// region Helpers
Expand Down
176 changes: 175 additions & 1 deletion app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,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 All @@ -120,6 +122,12 @@ data class PaykitOnchainPaymentProofResolution(
val amountSats: ULong? = null,
)

/** A request whose uncertain Spark withdrawal Spark showed never sent, so its pending payment can fail. */
data class PaykitSparkWithdrawalRelease(
val identity: String,
val requestId: PaykitPaymentRequestId,
)

@Singleton
class PaykitPaymentProofRepo @Inject constructor(
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
Expand Down Expand Up @@ -186,6 +194,8 @@ class PaykitPaymentProofRepo @Inject constructor(
}
private val _onchainPaymentResolutions = MutableStateFlow<List<PaykitOnchainPaymentProofResolution>>(emptyList())
val onchainPaymentResolutions = _onchainPaymentResolutions.asStateFlow()
private val _sparkWithdrawalReleases = MutableStateFlow<List<PaykitSparkWithdrawalRelease>>(emptyList())
val sparkWithdrawalReleases = _sparkWithdrawalReleases.asStateFlow()

suspend fun prepare(
request: PaykitPaymentRequest,
Expand Down Expand Up @@ -308,6 +318,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 @@ -347,12 +399,124 @@ 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)
}

/**
* Releases the started Spark proof of [request] for a withdrawal Spark refused before its journal was written, so
* nothing left the wallet and no listing is needed; returns whether it did.
*/
suspend fun releaseRefusedSparkOnchainProof(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
releaseUnsentSparkOnchainProofLocked(proof)
true
}
}.onFailure {
Logger.warn("Failed to release a refused 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,
)
// The request's allowance attempt is settled first: a failed ledger write keeps the proof for the next pass.
return when (outcome) {
is SparkWithdrawalOutcome.Sent -> {
allowanceExecutor.get().onchainSendSettled(proof.identity, proof.requestId, outcome.txid)
completeSparkOnchainProofLocked(proof, outcome.txid)
}
SparkWithdrawalOutcome.Unsent -> {
Comment thread
ovitrif marked this conversation as resolved.
Resolved
releaseUnsentSparkOnchainProofLocked(proof)
false
}
SparkWithdrawalOutcome.Unknown -> false
}
}

private suspend fun releaseUnsentSparkOnchainProofLocked(proof: PendingPaykitPaymentProof) {
allowanceExecutor.get().onchainSendReleased(proof.identity, proof.requestId)
Logger.info("Released a Paykit proof whose Spark withdrawal never sent", context = TAG)
releaseDeniedPrivateConsumption(proof)
removeProofsLocked { it == proof }
// The pending screen of an uncertain Spark send waits on this to fail it.
val release = PaykitSparkWithdrawalRelease(proof.identity, proof.requestId)
_sparkWithdrawalReleases.update { if (release in it) it else it + release }
}

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
// The pending screen of an uncertain Spark send waits on this resolution.
return persistAndSubmit(listOf(completed), proofs).also { publishOnchainResolution(completed, txid) }
}

suspend fun completeLightningPayment(paymentHash: String, preimage: String?) = withContext(ioDispatcher) {
if (preimage == null) return@withContext
if (!preimage.matchesPaymentHash(paymentHash)) {
Expand Down Expand Up @@ -1193,7 +1357,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 @@ -1261,6 +1428,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 Expand Up @@ -1459,6 +1629,10 @@ class PaykitPaymentProofRepo @Inject constructor(
}
}

fun consumeSparkWithdrawalRelease(release: PaykitSparkWithdrawalRelease) {
_sparkWithdrawalReleases.update { it - release }
}

fun consumeOnchainPaymentResolution(resolution: PaykitOnchainPaymentProofResolution) {
_onchainPaymentResolutions.update { it - resolution }
}
Expand Down
Loading
Loading