Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
140 commits
Select commit Hold shift + click to select a range
ab1b09d
feat: add spark via breez sdk
ovitrif Nov 5, 2026
4632e70
feat: spark chain service on bitkit electrs
ovitrif Nov 5, 2026
4e22b9c
chore: bump spark sdk to 0.28.0
ovitrif Nov 5, 2026
2f483bd
chore: merge master
ovitrif Nov 6, 2026
9b76a13
fix: harden spark backup, wipe, sends and withdrawals
ovitrif Nov 6, 2026
b29e9e5
fix: settle spark payments, deposits and fundings
ovitrif Nov 6, 2026
07440f6
fix: settle each lightning send from its own rail
ovitrif Nov 6, 2026
2aba121
fix: receive amountless invoices on spark
ovitrif Nov 6, 2026
d4a0dd1
fix: auto-pay allowances over spark without a node
ovitrif Nov 6, 2026
125e7c1
fix: keep the receive rule for plain invoices
ovitrif Nov 6, 2026
321412b
fix: check a paykit payment is still allowed right before it sends
ovitrif Nov 6, 2026
d6afbaa
Merge remote-tracking branch 'origin/master' into feat/breez-spark-li…
ovitrif Nov 9, 2026
7083719
fix: never pay an invoice on both rails
ovitrif Nov 9, 2026
891e355
fix: redo spark's exit backup only when it connects
ovitrif Nov 9, 2026
d93b131
fix: cap spark send fees at the node's default limit
ovitrif Nov 9, 2026
c85259f
fix: refuse a node retry while spark is still sending
ovitrif Nov 9, 2026
bf734b5
fix: hold spark's receive refresh until a restore reads its backup
ovitrif Nov 9, 2026
4e59bce
fix: retain spark send-all funding receipts
ovitrif Nov 9, 2026
83356a2
fix: resolve payment pending after a spark funding recovery
ovitrif Nov 9, 2026
23d9b7c
fix: pay channel-only savings swaps from the node
ovitrif Nov 9, 2026
85986cd
fix: fit spark's funds in transfer steps above the buttons
ovitrif Nov 9, 2026
d5297dd
fix: open the hardware transfer from funding when spark is ready
ovitrif Nov 9, 2026
136566a
fix: hold node sends while spark's history is unreadable
ovitrif Nov 9, 2026
1a8dff0
fix: back up spark's exit data once per connection
ovitrif Nov 9, 2026
384bf12
fix: leave the funding flow after a spark funding recovery
ovitrif Nov 9, 2026
de16298
test: let the amount change journey keep two inputs at the shown fee
ovitrif Nov 9, 2026
4e894e4
fix: release spark send records spark reports failed
ovitrif Nov 9, 2026
19e15b2
fix: keep the speed fee on one line in spending confirm
ovitrif Nov 9, 2026
09eac5b
fix: release spark sends that failed while the app was closed
ovitrif Nov 9, 2026
9f44c5b
fix: leave spending confirm once a kept spark funding completes
ovitrif Nov 9, 2026
8dee167
fix: keep a spark send held while its retry is live
ovitrif Nov 9, 2026
fa02b81
fix: keep the funding screen's claim speed on a recovered funding
ovitrif Nov 9, 2026
439d163
fix: read spark's sends in quickpay's payment lookup
ovitrif Nov 9, 2026
fd854d3
fix: keep a payment's note when spark replays its activity
ovitrif Nov 9, 2026
a88a943
fix: never downgrade a paid activity on a spark replay
ovitrif Nov 9, 2026
aa61e5b
fix: let the open funding screen own a kept spark funding
ovitrif Nov 9, 2026
aaafd92
fix: show the later speed when spark adds a transfer at 3 confirmations
ovitrif Nov 9, 2026
767f0eb
fix: refuse a sub-sat withdrawal spark can't invoice exactly
ovitrif Nov 9, 2026
2de7b6b
Merge remote-tracking branch 'origin/master' into feat/breez-spark-li…
ovitrif Nov 9, 2026
a180df6
fix: reconcile quickpay again once spark syncs
ovitrif Nov 9, 2026
984a21b
fix: keep a kept spark funding owned until the funding screen closes
ovitrif Nov 9, 2026
7b7bdc0
fix: hold a spark retry that starts during the activity sync
ovitrif Nov 9, 2026
665641e
fix: drop the empty super call in spark funding
ovitrif Nov 9, 2026
f811bca
fix: keep a pending spark retry over the channel's older failure
ovitrif Nov 9, 2026
ecf9e53
fix: hold node retries until spark's history has synced
ovitrif Nov 9, 2026
d31e9f0
fix: post raw transactions as text/plain through the spark regtest proxy
ovitrif Nov 9, 2026
3f6c517
fix: keep a spark retry pending on spark's record, not write times
ovitrif Nov 9, 2026
192b71e
fix: back up spark exit data that changed while backups were stopped
ovitrif Nov 9, 2026
b774fcb
Merge remote-tracking branch 'origin/master' into feat/breez-spark-li…
ovitrif Nov 9, 2026
c5d5b46
fix: hold on-chain spark funding off a chain spark doesn't watch
ovitrif Nov 9, 2026
9164635
test: cover private invoice rotation for spark receipts
ovitrif Nov 9, 2026
d9d0e21
test: name the unit test the amount change journey relies on
ovitrif Nov 9, 2026
f6bd0e0
test: pin which rails a unified invoice offers
ovitrif Nov 9, 2026
327ad44
fix: refresh the receive invoice once a restore ends
ovitrif Nov 9, 2026
6baceed
fix: spark transfer, send and claim edge cases
ovitrif Nov 10, 2026
8afe67c
fix: earlier node sends and early-claim cap clearing
ovitrif Nov 10, 2026
8590ce0
fix: recheck spark before the savings swipe commits
ovitrif Nov 10, 2026
99b81bc
fix: keep pending send hashes past the cap
ovitrif Nov 10, 2026
a836440
fix: bound stored send hashes
ovitrif Nov 10, 2026
9349700
fix: refuse a spark funding that changed after approval
ovitrif Nov 10, 2026
14eac0a
fix: retry spark activity that failed to save
ovitrif Nov 10, 2026
fc4d936
fix: keep spark activity details when its read fails
ovitrif Nov 10, 2026
309b186
fix: keep paid send guards until their invoices expire
ovitrif Nov 10, 2026
9e53798
fix: withdraw spark to savings at the confirmed fee
ovitrif Nov 10, 2026
9b555f5
fix: drop node send hashes listed only as failed
ovitrif Nov 10, 2026
a8c7e86
fix: trim send hashes by invoice expiry on both rails
ovitrif Nov 10, 2026
02b16e9
fix: claim an older spark funding only at its own approval
ovitrif Nov 10, 2026
95f1c9a
fix: restore the spending amount screen layout
ovitrif Nov 10, 2026
73806c5
fix: restore the setting up screen illustration and steps
ovitrif Nov 10, 2026
1583dc9
fix: align the spending speed picker with the fee rows
ovitrif Nov 10, 2026
0b423bf
fix: match the savings intro to figma
ovitrif Nov 10, 2026
fb9bd11
fix: add the menu icon to savings availability
ovitrif Nov 10, 2026
1dda91a
fix: match the savings confirm screen to figma and ios
ovitrif Nov 10, 2026
cb34f64
fix: show the spark failure on the savings progress screen
ovitrif Nov 10, 2026
acdd57d
fix: put the lightning connections buttons side by side
ovitrif Nov 10, 2026
74b5e4e
fix: capitalise the spending wallet transfer button
ovitrif Nov 10, 2026
13d6c33
fix: match the spending receive onboarding to figma and ios
ovitrif Nov 10, 2026
97951b8
fix: use the master copy for the transfer minimum toast
ovitrif Nov 10, 2026
f3c47be
fix: end a restore whose gate lapses by its timeout
ovitrif Nov 10, 2026
df7c061
chore: move the spark chain service kdoc back to its function
ovitrif Nov 10, 2026
a87c393
fix: leave a spark balance too small to move out of savings
ovitrif Nov 10, 2026
e5a85a8
fix: trim unlisted expired send hashes and keep spark retries
ovitrif Nov 10, 2026
282360e
fix: keep the spark balance shown after a failed withdrawal
ovitrif Nov 10, 2026
e6c7395
fix: list a spark deposit claimed early only once
ovitrif Nov 10, 2026
93a045d
fix: bind a fixed spark funding to its confirmed fee
ovitrif Nov 10, 2026
81e1f8f
fix: clear the kept spark balance once spark syncs
ovitrif Nov 10, 2026
41a6352
fix: keep a node retry's hold through an older listing
ovitrif Nov 10, 2026
bc2513f
fix: fit the spending amount number pad to the screen
ovitrif Nov 10, 2026
918279a
fix: size the funds in transfer rings and arrows like ios
ovitrif Nov 10, 2026
18cad43
fix: size the funds moved check to the screen width
ovitrif Nov 10, 2026
fb3de0b
fix: use the ios copy for the repriced instant transfer
ovitrif Nov 10, 2026
63e668d
fix: mark to spending as an estimate at both speeds
ovitrif Nov 10, 2026
d5d3489
fix: keep the savings confirm piggybank at full size
ovitrif Nov 10, 2026
fee40c7
fix: size the receive on spending card to figma
ovitrif Nov 10, 2026
f7bb9c4
fix: drop a pending transfer quote when the amount screen leaves
ovitrif Nov 10, 2026
7c8b0ee
fix: name the new amount when a max transfer changes at swipe
ovitrif Nov 10, 2026
ebb1e18
fix: keep a send that ends during a listing held
ovitrif Nov 10, 2026
ec59119
fix: keep a send whose invoice never expires held
ovitrif Nov 10, 2026
c4b60bc
docs: say a fixed spark funding's fee is bound too
ovitrif Nov 10, 2026
3006dba
fix: hide only the claimed output of a deposit tx
ovitrif Nov 10, 2026
8694e2f
fix: sync balances when the read spark balance recovers
ovitrif Nov 10, 2026
bdf3584
fix: treat spark's below-minimum quote refusal as too small
ovitrif Nov 10, 2026
77d5aff
test: run the amount change journey on the local spark regtest
ovitrif Nov 10, 2026
66131bf
fix: admit one rail's send of an invoice at a time
ovitrif Nov 10, 2026
8ad1b5f
fix: keep an uncertain spark send pending until spark syncs
ovitrif Nov 10, 2026
5234dc1
fix: hold the node retry while a spark send is uncertain
ovitrif Nov 10, 2026
d4277a6
fix: keep the instant claim follower after a failed early claim
ovitrif Nov 10, 2026
cc8e1c1
fix: drop the spark send hash when its deadline can't be set
ovitrif Nov 10, 2026
413ec0a
docs: move the expired-invoice comment onto its check
ovitrif Nov 10, 2026
c383c69
fix: resolve a re-paid invoice the node already paid
ovitrif Nov 10, 2026
b87908e
fix: skip node failure events for a spark send
ovitrif Nov 10, 2026
25e4dd9
fix: clear the cached spark withdrawal address on a new transfer
ovitrif Nov 10, 2026
4f437dd
fix: re-quote the spark withdrawal fee on a new transfer
ovitrif Nov 10, 2026
3392b9e
fix: clear the spark withdrawal fee after a refusal
ovitrif Nov 10, 2026
9b245b0
fix: finish channel closes after leaving the withdrawal progress
ovitrif Nov 10, 2026
12e3b61
fix: show the recovery for a spark funding with an unknown outcome
ovitrif Nov 10, 2026
2c12444
fix: follow a kept funding that completes off the confirm screen
ovitrif Nov 10, 2026
2ac83db
fix: fail quickpay on a spark refusal
ovitrif Nov 10, 2026
4ae1618
fix: keep the transfer flag on a restored spark funding
ovitrif Nov 10, 2026
477e9a0
chore: merge master into feat/breez-spark-lightning
ovitrif Nov 10, 2026
c11e5ca
fix: keep an uncertain spark send pending past an older failed row
ovitrif Nov 10, 2026
990d76d
fix: settle an uncertain spark send on spark's next sync
ovitrif Nov 10, 2026
cc942e7
fix: record the spark rail when its send is admitted
ovitrif Nov 10, 2026
d33ff51
fix: fail a spark send while the node can't confirm its earlier send
ovitrif Nov 10, 2026
b1595fe
fix: keep each savings transfer's own channels to close
ovitrif Nov 10, 2026
b01dee5
test: pin the amount change journey to bitkit-docker's spark regtest …
ovitrif Nov 10, 2026
3435dc8
fix: keep a missing spark send pending until its invoice expires
ovitrif Nov 10, 2026
4a29667
fix: send spark's savings withdrawal once per transfer
ovitrif Nov 10, 2026
52a3e63
fix: fund a new spark transfer amount after a followed funding
ovitrif Nov 10, 2026
166d1c0
fix: hold an unlisted spark send after a restart until its invoice ex…
ovitrif Nov 10, 2026
1182bb2
chore: drop a stale spark kdoc
ovitrif Nov 10, 2026
50bc48b
fix: count spark as synced only when its wallet sync succeeded
ovitrif Nov 10, 2026
7962bd4
fix: count spark as synced only once a wallet sync succeeded
ovitrif Nov 10, 2026
7b247f3
fix: hold a spark send spark may still resume in its send lookup
ovitrif Nov 10, 2026
515a606
fix: withdraw spark to savings only when both read one chain
ovitrif Nov 10, 2026
568e277
fix: hold a spark send spark may resume while it can't list
ovitrif Nov 10, 2026
b3298a0
fix: let spark wallets pay each other's invoices as transfers on regtest
ovitrif Nov 11, 2026
5953744
fix: pay between spark wallets over lightning on regtest
ovitrif Nov 11, 2026
6cdf592
fix: keep an unresolved spark retry held across restarts
ovitrif Nov 11, 2026
4f10f8d
fix: hold a spark send cut off mid-call and read it as pending
ovitrif Nov 11, 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
2 changes: 2 additions & 0 deletions .github/workflows/release-internal.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ jobs:
KEYSTORE_PASSWORD: ${{ secrets.INTERNAL_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.INTERNAL_KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.INTERNAL_KEY_PASSWORD }}
# Optional: without it the build passes and Spark (Lightning without channels) stays off.
BREEZ_API_KEY: ${{ secrets.BREEZ_API_KEY }}
run: |
set -euo pipefail
./gradlew assembleMainnetRelease --no-daemon --stacktrace
Expand Down
10 changes: 10 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ jobs:
printf '%s' "$BITKIT_KEYSTORE_BASE64" | base64 --decode > "$keystore_path"
echo "KEYSTORE_FILE=$keystore_path" >> "$GITHUB_ENV"
- name: Check Breez API key
env:
BREEZ_API_KEY: ${{ secrets.BREEZ_API_KEY }}
run: |
if [ -z "${BREEZ_API_KEY// /}" ]; then
echo "::error::BREEZ_API_KEY is missing from the release environment; a release without it ships with Spark (Lightning without channels) off."
exit 1
fi
- name: Build release artifacts
env:
GPR_USER: ${{ secrets.GPR_USER || github.actor }}
Expand All @@ -74,6 +83,7 @@ jobs:
KEYSTORE_PASSWORD: ${{ secrets.BITKIT_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.BITKIT_KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.BITKIT_KEY_PASSWORD }}
BREEZ_API_KEY: ${{ secrets.BREEZ_API_KEY }}
run: |
set -euo pipefail
./gradlew assembleMainnetRelease bundleMainnetRelease --no-daemon --stacktrace
Expand Down
3 changes: 3 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,8 @@ androidComponents {
"USDT_RPC_URL",
localProp("USDT_RPC_URL").orElse("https://bitkit.to/v1/usdt/chain-rpc").stringField(),
)
buildConfigFields.put("BREEZ_API_KEY", localProp("BREEZ_API_KEY").orElse("").stringField())
buildConfigFields.put("ESPLORA_URL", localProp("ESPLORA_URL").orElse("").stringField())
buildConfigFields.put("E2E", e2eEnv.booleanField())
buildConfigFields.put("E2E_BACKEND", e2eBackendEnv.stringField())
buildConfigFields.put("E2E_LOCAL_HOST", e2eLocalHostEnv.stringField())
Expand Down Expand Up @@ -566,6 +568,7 @@ dependencies {
implementation(libs.bitkit.core)
}
implementation(libs.paykit)
implementation(libs.breez.sdk.spark) { exclude(group = "net.java.dev.jna", module = "jna") }
implementation(libs.vss.client)
if (localBitkitCoreAar == null) nativeDebugSymbols(libs.bitkit.core.nativeDebugSymbolsArtifact())
nativeDebugSymbols(libs.ldk.node.android.nativeDebugSymbolsArtifact())
Expand Down
154 changes: 154 additions & 0 deletions app/src/main/java/to/bitkit/data/CacheStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,22 @@ import to.bitkit.models.WalletScope
import to.bitkit.utils.Logger
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Clock

/**
* A listing drops a rail's send hashes it shows failed only, or paid once the invoice has expired, so the other rail
* cannot pay it again, and ones it doesn't show once the invoice expired [UNLISTED_SEND_SETTLE_SECS] ago. Paid or
* unlisted sends with no known expiry (kept before expiries were, or whose invoice can't be parsed) are dropped oldest
* first only past this many hashes. A send that may be pending always stays.
*/
internal const val MAX_SPARK_SEND_HASHES = 100

/**
* How long past its invoice's expiry a send no listing shows may still be pending: no send starts after the expiry,
* and one settles within its HTLC timeout, which LDK caps at 1008 blocks (about a week) and Spark's routes keep
* shorter, so twice that cap leaves room for slow blocks.
*/
internal const val UNLISTED_SEND_SETTLE_SECS = 14L * 24 * 60 * 60

private val Context.appCacheDataStore: DataStore<AppCacheData> by dataStore(
fileName = "app_cache.json",
Expand All @@ -30,6 +46,7 @@ private val Context.appCacheDataStore: DataStore<AppCacheData> by dataStore(
@Singleton
class CacheStore internal constructor(
private val store: DataStore<AppCacheData>,
private val clock: Clock = Clock.System,
) {
@Inject
constructor(@ApplicationContext context: Context) : this(context.appCacheDataStore)
Expand Down Expand Up @@ -136,6 +153,125 @@ class CacheStore internal constructor(
store.updateData { it.copy(pendingLightningMessages = it.pendingLightningMessages - paymentHash) }
}

/** Keeps [paymentHash] with its invoice's expiry in epoch seconds, [expiresAt], when known. */
suspend fun addSparkSendHash(paymentHash: String, expiresAt: Long? = null) {
val hash = paymentHash.lowercase()
store.updateData {
it.copy(
sparkSendHashes = it.sparkSendHashes - hash + hash,
sparkSendExpiries = it.sparkSendExpiries.with(hash, expiresAt),
)
}
}

suspend fun removeSparkSendHash(paymentHash: String) {
val hash = paymentHash.lowercase()
if (hash !in store.data.first().sparkSendHashes) return
store.updateData {
it.copy(sparkSendHashes = it.sparkSendHashes - hash, sparkSendExpiries = it.sparkSendExpiries - hash)
}
}

/** Keeps Spark's send of [paymentHash] as one whose outcome Spark didn't report, since [failedAt] epoch seconds. */
suspend fun addSparkUncertainSend(paymentHash: String, failedAt: Long) {
val hash = paymentHash.lowercase()
store.updateData { it.copy(sparkUncertainSends = it.sparkUncertainSends + (hash to failedAt)) }
}

suspend fun removeSparkUncertainSend(paymentHash: String) {
val hash = paymentHash.lowercase()
if (hash !in store.data.first().sparkUncertainSends) return
store.updateData { it.copy(sparkUncertainSends = it.sparkUncertainSends - hash) }
}

/** Keeps [paymentHash] with its invoice's expiry in epoch seconds, [expiresAt], when known. */
suspend fun addLdkSendHash(paymentHash: String, expiresAt: Long? = null) {
val hash = paymentHash.lowercase()
store.updateData {
it.copy(
ldkSendHashes = it.ldkSendHashes - hash + hash,
ldkSendExpiries = it.ldkSendExpiries.with(hash, expiresAt),
)
}
}

private fun Map<String, Long>.with(hash: String, expiresAt: Long?) =
if (expiresAt == null) this else this + (hash to expiresAt)

/** Keeps [paymentHashes], the node's pending and paid sends, once, ahead of the hashes kept since. */
suspend fun seedLdkSendHashes(paymentHashes: List<String>) {
if (store.data.first().ldkSendHashesSeeded) return
store.updateData { data ->
if (data.ldkSendHashesSeeded) return@updateData data
val seeded = paymentHashes.map { it.lowercase() }.distinct() - data.ldkSendHashes.toSet()
data.copy(
ldkSendHashes = seeded + data.ldkSendHashes,
ldkSendHashesSeeded = true,
)
}
}

/**
* Drops the node's sends a listing showed as [failed] only, or as [paid] with an expired invoice, ones it didn't
* show ([listed] is every hash it shows) whose invoice expired past [UNLISTED_SEND_SETTLE_SECS], and past the cap
* the oldest paid or unlisted ones with no known expiry. A send that may be pending ([live]) always stays.
*/
suspend fun trimLdkSendHashes(paid: Set<String>, failed: Set<String>, listed: Set<String>, live: Set<String>) {
store.updateData {
val (hashes, expiries) =
withoutSettled(it.ldkSendHashes, it.ldkSendExpiries, SendListing(paid, failed, listed, live))
it.copy(ldkSendHashes = hashes, ldkSendExpiries = expiries)
}
}

/** Drops Spark's sends as [trimLdkSendHashes] drops the node's. */
suspend fun trimSparkSendHashes(paid: Set<String>, failed: Set<String>, listed: Set<String>, live: Set<String>) {
store.updateData {
val (hashes, expiries) =
withoutSettled(it.sparkSendHashes, it.sparkSendExpiries, SendListing(paid, failed, listed, live))
it.copy(sparkSendHashes = hashes, sparkSendExpiries = expiries)
}
}

private class SendListing(paid: Set<String>, failed: Set<String>, listed: Set<String>, live: Set<String>) {
val paid = paid.lowercased()
val failed = failed.lowercased()
val listed = listed.lowercased()
val live = live.lowercased()

private fun Set<String>.lowercased() = mapTo(mutableSetOf()) { it.lowercase() }
}

private fun withoutSettled(
hashes: List<String>,
expiries: Map<String, Long>,
listing: SendListing,
): Pair<List<String>, Map<String, Long>> {
val now = clock.now().epochSeconds

// Compared against now minus the window, as an invoice that never expires is kept at Long.MAX_VALUE.
fun isExpired(hash: String, settleSecs: Long = 0) = expiries[hash]?.let { it <= now - settleSecs } == true
fun isSettled(hash: String) = hash !in listing.live && (
hash in listing.failed ||
hash in listing.paid && isExpired(hash) ||
hash !in listing.listed && isExpired(hash, UNLISTED_SEND_SETTLE_SECS)
)
fun isAged(hash: String) =
hash !in listing.live && hash !in expiries && (hash in listing.paid || hash !in listing.listed)
val unsettled = hashes.filterNot(::isSettled)
var excess = unsettled.size - MAX_SPARK_SEND_HASHES
val kept = unsettled.filterNot { hash -> (excess > 0 && isAged(hash)).also { if (it) excess-- } }
return kept to expiries.filterKeys { it in kept }
}

suspend fun removeLdkSendHash(paymentHash: String) {
val hash = paymentHash.lowercase()
if (hash !in store.data.first().ldkSendHashes) return
store.updateData {
it.copy(ldkSendHashes = it.ldkSendHashes - hash, ldkSendExpiries = it.ldkSendExpiries - hash)
}
}

suspend fun setBackgroundReceive(details: NewTransactionSheetDetails) = store.updateData {
it.copy(backgroundReceive = details)
}
Expand Down Expand Up @@ -169,6 +305,24 @@ 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(),
/** 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. */
val sparkSendExpiries: Map<String, Long> = mapOf(),
/**
* Spark's sends whose outcome it didn't report, by hash, with when they failed in epoch seconds, so a restart
* never reads a retry's earlier failed attempt as its outcome.
*/
val sparkUncertainSends: Map<String, Long> = mapOf(),
/** Hashes of the Lightning sends the node started, so Spark holds off while the node can't list them. */
val ldkSendHashes: List<String> = listOf(),
/** Invoice expiry in epoch seconds of the [ldkSendHashes] that know it. */
val ldkSendExpiries: Map<String, Long> = mapOf(),
/**
* Set once the node's own list has filled [ldkSendHashes], as node sends made before hashes were kept carry none
* there. Until then an unlistable node holds every Spark send.
*/
val ldkSendHashesSeeded: Boolean = false,
) {
fun isContactDetached(activityId: String, walletId: String): Boolean =
scopedActivityId(walletId, activityId) in detachedActivityContacts
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/to/bitkit/data/SettingsStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,9 @@ data class SettingsData(
val selectedCurrency: String = "USD",
val defaultTransactionSpeed: TransactionSpeed = TransactionSpeed.default(),
val showEmptyBalanceView: Boolean = true,
val hasSeenSpendingIntro: Boolean = false,
val hasSeenWidgetsIntro: Boolean = false,
val hasSeenTransferIntro: Boolean = false,
val hasSeenSavingsIntro: Boolean = false,
val hasSeenShopIntro: Boolean = false,
val hasSeenProfileIntro: Boolean = false,
Expand Down
37 changes: 35 additions & 2 deletions app/src/main/java/to/bitkit/di/RepoModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import to.bitkit.repositories.LightningRepo
import to.bitkit.repositories.QuickPayInvoiceParser
import to.bitkit.repositories.QuickPayPaymentLookup
import to.bitkit.repositories.QuickPayReconcileRow
import to.bitkit.repositories.SparkRepo
import javax.inject.Named
import breez_sdk_spark.PaymentStatus as SparkPaymentStatus

@Module
@InstallIn(SingletonComponent::class)
Expand All @@ -33,8 +35,39 @@ abstract class RepoModule {
runCatching { Bolt11Invoice.fromStr(bolt11).paymentHash() }.getOrNull()
}

/**
* Payments of both rails, one row per invoice for a payment either rail made, so an older failed channel
* attempt never settles one a Spark retry still holds or paid. Null while either rail can't list them.
*/
@Provides
fun provideQuickPayPaymentLookup(lightningRepo: LightningRepo): QuickPayPaymentLookup =
QuickPayPaymentLookup { lightningRepo.listPaymentsOrNull()?.map { QuickPayReconcileRow(it) } }
fun provideQuickPayPaymentLookup(lightningRepo: LightningRepo, sparkRepo: SparkRepo): QuickPayPaymentLookup =
QuickPayPaymentLookup {
val node = lightningRepo.listPaymentsOrNull()?.map { QuickPayReconcileRow(it) }
?: return@QuickPayPaymentLookup null
val spark = sparkRepo.lightningSendStatuses() ?: return@QuickPayPaymentLookup null
Comment thread
ovitrif marked this conversation as resolved.
Resolved
val sparkRows = spark.map { (hash, status) ->
QuickPayReconcileRow(
paymentId = hash,
invoicePaymentHash = hash,
isOutboundBolt11 = true,
status = when (status) {
SparkPaymentStatus.COMPLETED -> QuickPayReconcileRow.Status.SUCCEEDED
SparkPaymentStatus.PENDING -> QuickPayReconcileRow.Status.PENDING
SparkPaymentStatus.FAILED -> QuickPayReconcileRow.Status.FAILED
},
)
}
val (sends, others) = (node + sparkRows).partition { it.isOutboundBolt11 }
others + sends.groupBy { it.invoicePaymentHash }.values.map { rows ->
rows.maxBy { SETTLING_STATUSES.indexOf(it.status) }
}
}

/** The order in which an outcome settles a payment both rails attempted, the last one winning. */
private val SETTLING_STATUSES = listOf(
QuickPayReconcileRow.Status.FAILED,
QuickPayReconcileRow.Status.PENDING,
QuickPayReconcileRow.Status.SUCCEEDED,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import to.bitkit.models.NewTransactionSheetType
import to.bitkit.models.msatCeilOf
import to.bitkit.repositories.ActivityRepo
import to.bitkit.repositories.BackupRepo
import to.bitkit.repositories.SparkRepo
import to.bitkit.services.MigrationService
import to.bitkit.utils.Logger
import javax.inject.Inject
Expand All @@ -35,6 +36,7 @@ class NotifyPaymentReceivedHandler @Inject constructor(
private val settingsStore: SettingsStore,
private val clock: Clock,
private val receivedNotificationContent: ReceivedNotificationContent,
private val sparkRepo: SparkRepo,
) {
private val presentationClaimsLock = Any()
private val presentationClaims = mutableSetOf<String>()
Expand Down Expand Up @@ -124,6 +126,11 @@ class NotifyPaymentReceivedHandler @Inject constructor(
if (command.details.amountSats <= 0) return false
}

if (sparkRepo.markWithdrawalAsTransfer(command.txid)) {
Logger.debug("Skipping onchain receive '${command.txid}' from a Spark withdrawal", context = TAG)
return false
}

if (settings.pendingRestoreActivitySeen) {
Logger.debug("Skipping onchain receive '${command.txid}' until the first sync after restore", context = TAG)
return false
Expand Down
32 changes: 32 additions & 0 deletions app/src/main/java/to/bitkit/env/Env.kt
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ internal object Env {
).filter { it.first in enabled }.map { it.second }
}

val breezApiKey: String? get() = BuildConfig.BREEZ_API_KEY.takeIf { it.isNotBlank() }

/**
* Esplora endpoint for the node's chain source on regtest builds, set to put the wallet on Spark's regtest chain
* through `scripts/spark-regtest-esplora.py`; Electrum is used when unset.
*/
val regtestEsploraUrl: String?
get() = BuildConfig.ESPLORA_URL.takeIf { it.isNotBlank() && network == Network.REGTEST }

val isDebug = BuildConfig.DEBUG
const val isE2eTest = BuildConfig.E2E
val e2eBackend = BuildConfig.E2E_BACKEND.lowercase()
Expand Down Expand Up @@ -120,6 +129,22 @@ internal object Env {
Network.REGTEST -> "https://mempool.bitkit.stag0.blocktank.to/"
}

/**
* Whether the node's chain is the one Spark watches, so an on-chain funding of Spark from Savings or a hardware
* wallet reaches it. On regtest Spark runs on Lightspark's own chain, which the node joins only through the
* Spark regtest Esplora proxy.
*/
fun sparkSharesNodeChain(network: Network, regtestEsploraUrl: String?): Boolean =
network != Network.REGTEST || regtestEsploraUrl != null

val sparkSharesNodeChain: Boolean get() = sparkSharesNodeChain(network, regtestEsploraUrl)

/** Esplora REST that Spark reads the chain from; null keeps the SDK's default, on staging and regtest. */
fun sparkChainServiceUrl(network: Network): String? = when (network) {
Network.BITCOIN -> bitkitEsploraUrl
else -> null
}

val blocktankBaseUrl
get() = when (network) {
Network.BITCOIN -> "https://api1.blocktank.to"
Expand All @@ -143,6 +168,7 @@ internal object Env {
const val chatwootUrl = "https://synonym.to/api/chatwoot"
const val newsBaseUrl = "https://feeds.synonym.to/news-feed/api"
const val mempoolBaseUrl = "https://mempool.space/api"
const val bitkitEsploraUrl = "https://bitkit.to/electrs"
const val pricesWidgetBaseUrl = "https://feeds.synonym.to/price-feed/api"

const val APP_STORE_URL = "https://apps.apple.com/app/bitkit-wallet/id6502440655"
Expand Down Expand Up @@ -226,6 +252,12 @@ internal object Env {

fun ldkStoragePath(walletIndex: Int) = storagePathOf(walletIndex, network.name.lowercase(), "ldk")

fun sparkStoragePath(walletIndex: Int) = storagePathOf(walletIndex, network.name.lowercase(), "spark")

/** A restored Spark exit data backup waiting to be imported once Spark connects; outside Spark's storage. */
fun sparkExitRestorePath(walletIndex: Int) =
Path(storagePathOf(walletIndex, network.name.lowercase(), "spark-restore"), "exit-state.json").toString()

fun bitkitCoreStoragePath(walletIndex: Int): String {
return storagePathOf(walletIndex, network.name.lowercase(), "core")
}
Expand Down
Loading
Loading