Skip to content
Draft
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
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
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
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
14 changes: 14 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,14 @@ 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 @@ -226,6 +234,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
4 changes: 4 additions & 0 deletions app/src/main/java/to/bitkit/models/BackupCategory.kt
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ enum class BackupCategory(
icon = R.drawable.ic_tag,
title = R.string.settings__backup__category_tags,
),
SPARK_EXIT(
icon = R.drawable.ic_lightning,
title = R.string.settings__backup__category_spark_exit,
),
// Descoped in v1, will return in v2:
// PROFILE(
// icon = R.drawable.ic_user,
Expand Down
12 changes: 12 additions & 0 deletions app/src/main/java/to/bitkit/models/BackupPayloads.kt
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,15 @@ data class WidgetsBackupV1(
val createdAt: Long,
val widgets: WidgetsData,
)

/**
* The Spark wallet's unilateral exit data, as the SDK exports it, kept so a wallet restored on another device can
* still exit on-chain while the Spark operators are unreachable.
*/
@Serializable
data class SparkExitBackupV1(
val version: Int = 1,
val createdAt: Long,
val network: String,
val exitState: String,
)
38 changes: 38 additions & 0 deletions app/src/main/java/to/bitkit/repositories/BackupRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
Expand Down Expand Up @@ -69,6 +71,7 @@ import to.bitkit.models.MetadataBackupV1
import to.bitkit.models.PaykitAllowanceStateBackup
import to.bitkit.models.PaykitPaymentStateBackup
import to.bitkit.models.SettingsBackupV1
import to.bitkit.models.SparkExitBackupV1
import to.bitkit.models.Toast
import to.bitkit.models.WalletBackupV1
import to.bitkit.models.WalletScope
Expand Down Expand Up @@ -137,6 +140,7 @@ class BackupRepo @Inject constructor(
private val keychain: Keychain,
private val onchainSendAttemptStore: OnchainSendAttemptStore,
private val lightningRepo: Provider<LightningRepo>,
private val sparkRepo: SparkRepo,
private val vssStoreIdProvider: VssStoreIdProvider,
private val preActivityMetadataRepo: PreActivityMetadataRepo,
private val lightningService: LightningService,
Expand Down Expand Up @@ -435,6 +439,20 @@ class BackupRepo @Inject constructor(
// payment and sync traffic does not re-upload the whole metadata envelope.
dataListenerJobs.add(observeBackupChanges(activityRepo.activityTagsChanged, BackupCategory.METADATA))

// SPARK_EXIT - back up once Spark is ready, which replays when observing starts after it connected,
// and again whenever Spark announces its unilateral exit data changed
dataListenerJobs.add(
scope.launch {
merge(
sparkRepo.state.map { it.isReady }.distinctUntilChanged().filter { it },
sparkRepo.exitStateChanged,
).collect {
if (shouldSkipBackup()) return@collect
markBackupRequired(BackupCategory.SPARK_EXIT)
}
}
)

// LIGHTNING_CONNECTIONS - Only display sync timestamp, ldk-node manages its own backups
@OptIn(FlowPreview::class)
val lightningConnectionsJob = scope.launch {
Expand Down Expand Up @@ -581,6 +599,7 @@ class BackupRepo @Inject constructor(
val upload = when (category) {
BackupCategory.WALLET -> uploadWalletBackup()
BackupCategory.USDT_RECOVERY -> uploadUsdtRecoveryBackup()
BackupCategory.SPARK_EXIT -> uploadSparkExitBackup()
else -> runSuspendCatching {
vssBackupClient.putObject(key = category.name, data = getBackupDataBytes(category)).getOrThrow()
}
Expand Down Expand Up @@ -619,6 +638,15 @@ class BackupRepo @Inject constructor(
}
}

/** Nothing to back up until Spark connects, which marks [BackupCategory.SPARK_EXIT] required again. */
private suspend fun uploadSparkExitBackup(): Result<Unit> = runSuspendCatching {
val backup = sparkRepo.exitBackup().getOrThrow() ?: return@runSuspendCatching
vssBackupClient.putObject(
key = BackupCategory.SPARK_EXIT.name,
data = json.encodeToString(backup).toByteArray(),
).getOrThrow()
}

suspend fun persistWalletBackup(): Result<Unit> =
withContext(ioDispatcher) {
walletBackupMutex.withLock { uploadWalletBackup() }
Expand Down Expand Up @@ -752,6 +780,10 @@ class BackupRepo @Inject constructor(

BackupCategory.LIGHTNING_CONNECTIONS -> throw NotImplementedError("LIGHTNING backup is managed by ldk-node")
BackupCategory.USDT_RECOVERY -> throw UsdtException.BackupUnavailable()
BackupCategory.SPARK_EXIT -> {
val backup = checkNotNull(sparkRepo.exitBackup().getOrThrow()) { "Spark is not connected" }
json.encodeToString(backup).toByteArray()
}
}

private suspend fun getMetadataBackupDataBytes(): ByteArray = withContext(ioDispatcher) {
Expand Down Expand Up @@ -955,6 +987,12 @@ class BackupRepo @Inject constructor(
}
// SETTINGS is optional; preserving explicit local corrections is required.
connectionSession.replay()
// Before the slower categories, so the exit data is staged before Spark connects and syncs.
performRestore(BackupCategory.SPARK_EXIT) { dataBytes ->
val parsed = json.decodeFromString<SparkExitBackupV1>(String(dataBytes))
sparkRepo.restoreExitBackup(parsed, backupWalletIndex()).getOrThrow()
parsed.createdAt
}
val retainedWalletBackup = keychain.loadString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name)
if (retainedWalletBackup.isNullOrEmpty()) {
var didRestoreWalletBackup = false
Expand Down
Loading
Loading