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 @@ -357,6 +357,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 @@ -564,6 +566,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
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
const val isGeoblockingEnabled = BuildConfig.GEO
Expand Down Expand Up @@ -219,6 +227,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 @@ -38,6 +38,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 @@ -86,3 +86,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,
)
39 changes: 39 additions & 0 deletions app/src/main/java/to/bitkit/repositories/BackupRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,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 @@ -62,6 +64,7 @@ import to.bitkit.models.BlocktankBackupV1
import to.bitkit.models.MetadataBackupV1
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 @@ -128,6 +131,7 @@ class BackupRepo @Inject constructor(
private val onchainSendAttemptStore: OnchainSendAttemptStore,
private val transferRepo: Provider<TransferRepo>,
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 @@ -405,6 +409,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 @@ -543,6 +561,8 @@ class BackupRepo @Inject constructor(

val upload = if (category == BackupCategory.WALLET) {
uploadWalletBackup(null)
} else if (category == BackupCategory.SPARK_EXIT) {
uploadSparkExitBackup()
} else {
runSuspendCatching {
vssBackupClient.putObject(key = category.name, data = getBackupDataBytes(category)).getOrThrow()
Expand Down Expand Up @@ -572,6 +592,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(usdt: String? = null): Result<Unit> =
uploadWalletBackup(usdt).onFailure { markBackupRequired(BackupCategory.WALLET) }

Expand Down Expand Up @@ -672,6 +701,11 @@ class BackupRepo @Inject constructor(
}

BackupCategory.LIGHTNING_CONNECTIONS -> throw NotImplementedError("LIGHTNING backup is managed by ldk-node")

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 @@ -843,6 +877,11 @@ class BackupRepo @Inject constructor(
if (restored.needsRewrite) categoriesNeedingRewrite += BackupCategory.ACTIVITY
restored.createdAt
}
performRestore(BackupCategory.SPARK_EXIT) { dataBytes ->
val parsed = json.decodeFromString<SparkExitBackupV1>(String(dataBytes))
sparkRepo.restoreExitBackup(parsed, backupWalletIndex()).getOrThrow()
parsed.createdAt
}

Logger.info("Full restore success", context = TAG)
}.onSuccess {
Expand Down
Loading
Loading