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
110 changes: 110 additions & 0 deletions app/src/androidTest/java/to/bitkit/services/SparkExitRegtestTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package to.bitkit.services

import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import breez_sdk_spark.ConnectRequest
import breez_sdk_spark.CpfpFundingKind
import breez_sdk_spark.ExitLeafSelection
import breez_sdk_spark.PrepareRecoverFundsRequest
import breez_sdk_spark.Seed
import breez_sdk_spark.connect
import breez_sdk_spark.defaultConfig
import breez_sdk_spark.singleKeyCpfpSigner
import io.ktor.client.HttpClient
import kotlinx.coroutines.runBlocking
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.lightningdevkit.ldknode.Network
import to.bitkit.repositories.EsploraSparkExitChain
import to.bitkit.repositories.SparkExitFundingKey
import to.bitkit.repositories.SparkExitQuote
import to.bitkit.repositories.SparkExitSweep
import to.bitkit.test.annotations.DeviceIntegration
import to.bitkit.utils.SignedTransactionId
import java.io.File
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import breez_sdk_spark.Network as SparkNetwork

/**
* Runs the exit's SDK, key and chain calls on a device against Spark's hosted regtest, for a new wallet with nothing
* to exit. It needs the network, so it only runs with the instrumentation argument `sparkExitRegtest true`.
*/
@RunWith(AndroidJUnit4::class)
@DeviceIntegration
class SparkExitRegtestTest {
private val onchain = OnchainService()

@Before
fun requireOptIn() {
val isOptedIn = InstrumentationRegistry.getArguments().getString("sparkExitRegtest") == "true"
assumeTrue("Pass -e sparkExitRegtest true to run against Spark's regtest", isOptedIn)
}

@Test
fun quotesAnEmptyWalletAndReadsItsFundingAddress(): Unit = runBlocking {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val storage = File(context.cacheDir, "spark-exit-regtest-${System.nanoTime()}").apply { mkdirs() }
val mnemonic = onchain.generateMnemonic()
val config = defaultConfig(SparkNetwork.REGTEST).apply {
leafOptimizationConfig = leafOptimizationConfig.copy(autoEnabled = false)
}
val sdk = connect(ConnectRequest(config, Seed.Mnemonic(mnemonic, null), storage.path))
try {
val path = SparkExitFundingKey.path(0, Network.REGTEST)
val address = onchain.deriveBitcoinAddress(mnemonic, path, Network.REGTEST, null)
val again = onchain.deriveBitcoinAddress(mnemonic, path, Network.REGTEST, null)
val secret = SparkExitFundingKey.secretKey(onchain.derivePrivateKey(mnemonic, path, Network.REGTEST, null))
singleKeyCpfpSigner(secret)
assertEquals("m/84'/1'/7797'/0/0", path)
assertTrue(address.address.startsWith("bcrt1q"), address.address)
assertEquals(66, address.publicKey.length)
assertEquals(32, secret.size)
assertEquals(address, again)

val feeRate = maxOf(1uL, sdk.recommendedFees().fastestFee)
val prepared = BreezSparkExitRecovering(sdk).prepare(
PrepareRecoverFundsRequest(feeRate, CpfpFundingKind.P2wpkh, address.address, ExitLeafSelection.All),
)
assertTrue(prepared.leaves.isEmpty())
assertFalse(SparkExitQuote(prepared).isWorthExiting)

val chain = EsploraSparkExitChain(EsploraSparkExitChain.defaultHosts("regtest"), HttpClient())
assertTrue(chain.tipHeight() > 0u)
assertTrue(chain.utxos(address.address).isEmpty())
} finally {
sdk.disconnect()
storage.deleteRecursively()
}
}

@Test
fun signsALeftoverSweepWithTheFundingKey(): Unit = runBlocking {
val mnemonic = onchain.generateMnemonic()
val path = SparkExitFundingKey.path(0, Network.REGTEST)
val address = onchain.deriveBitcoinAddress(mnemonic, path, Network.REGTEST, null)
val secret = SparkExitFundingKey.secretKey(onchain.derivePrivateKey(mnemonic, path, Network.REGTEST, null))
val plan = requireNotNull(
SparkExitSweep.plan(
listOf(
SparkExitSweep.Input("11".repeat(32), 0u, 3_000uL),
SparkExitSweep.Input("22".repeat(32), 2u, 2_035uL),
),
destination = "bcrt1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqc8gma6",
feeRateSatPerVbyte = 2uL,
),
)

val signed = singleKeyCpfpSigner(secret).signPsbt(SparkExitSweep.psbt(plan, address.address))
val hex = SparkExitSweep.signedTxHex(plan, signed)

// Two inputs, each a P2WPKH witness of a DER signature and the funding key.
val witness = Regex("02(4[4-9])30[0-9a-f]+?01(21${address.publicKey})").findAll(hex).toList()
assertEquals(2, witness.size, hex)
assertTrue(hex.startsWith("020000000001"), hex)
assertEquals(64, SignedTransactionId.fromHex(hex).length)
}
}
9 changes: 9 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,15 @@
</intent-filter>
</receiver>

<receiver
android:name=".repositories.SparkExitReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>

<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/java/to/bitkit/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import to.bitkit.appwidget.AppWidgetRefreshReason
import to.bitkit.appwidget.AppWidgetRefreshScheduler
import to.bitkit.env.Env
import to.bitkit.repositories.HwWalletRepo
import to.bitkit.repositories.SparkExitWork
import to.bitkit.services.BluetoothInit
import to.bitkit.services.PubkyAuthHandlerRegistrar
import to.bitkit.utils.Crypto
Expand All @@ -42,6 +43,9 @@ internal open class App : Application(), Configuration.Provider {
@Inject
lateinit var subscriptionClockOffsetSync: SubscriptionClockOffsetSync

@Inject
lateinit var sparkExitWork: SparkExitWork

/** Resolved only once the process changes foreground state, so startup does not build the wallet graph. */
@Inject
lateinit var hwWalletRepo: Lazy<HwWalletRepo>
Expand All @@ -60,6 +64,7 @@ internal open class App : Application(), Configuration.Provider {
SingletonImageLoader.setSafe { imageLoader }
currentActivity = CurrentActivity().also { registerActivityLifecycleCallbacks(it) }
appWidgetRefreshScheduler.ensureScheduled(AppWidgetRefreshReason.APP_START)
sparkExitWork.ensureScheduled()
// Initialize btleplug for Bluetooth support (required before any BLE usage)
BluetoothInit.ensureInitialized()
pubkyAuthHandlerRegistrar.start()
Expand Down
11 changes: 11 additions & 0 deletions app/src/main/java/to/bitkit/data/CacheStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,17 @@ 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(),
/**
* When a Spark leaf optimization last ran or was cut short, in epoch milliseconds; cleared once it completed or
* an exit quote waited out the counter-swap grace. Kept across processes, since a swap the app's close cut short
* leaves value in flight.
*/
val sparkOptimizationActivityAt: Long? = null,
/**
* The funding key index the next Spark emergency exit takes, per `{network}/wallet{n}`, so each exit pays its fees
* from an address of its own.
*/
val sparkExitNextFundingIndexes: Map<String, Int> = mapOf(),
) {
fun isContactDetached(activityId: String, walletId: String): Boolean =
scopedActivityId(walletId, activityId) in detachedActivityContacts
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/java/to/bitkit/data/dao/TransferDao.kt
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ interface TransferDao {
@Query("UPDATE transfers SET isSettled = 1, settledAt = :settledAt WHERE id = :id")
suspend fun markSettled(id: String, settledAt: Long)

@Query("UPDATE transfers SET claimableAtHeight = :claimableAtHeight WHERE id = :id")
suspend fun updateClaimableAtHeight(id: String, claimableAtHeight: Int?)

@Query("DELETE FROM transfers WHERE isSettled = 1 AND settledAt < :expirationTimestamp")
suspend fun deleteOldSettled(expirationTimestamp: Long)
}
6 changes: 6 additions & 0 deletions app/src/main/java/to/bitkit/env/Env.kt
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,12 @@ internal object Env {
fun sparkExitRestorePath(walletIndex: Int) =
Path(storagePathOf(walletIndex, network.name.lowercase(), "spark-restore"), "exit-state.json").toString()

/** The running Spark emergency exit, if any: its signed recovery and how far it got. */
fun sparkExitRecordPath(walletIndex: Int): String {
require(::appStoragePath.isInitialized) { "App storage path should be 'context.filesDir.absolutePath'." }
return Path(appStoragePath, network.name.lowercase(), "wallet$walletIndex", "spark-exit.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/ext/Context.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import android.content.ContextWrapper
import android.content.Intent
import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.hardware.usb.UsbManager
import android.net.ConnectivityManager
import android.os.PowerManager
import android.provider.Settings
import android.text.format.DateFormat
Expand Down Expand Up @@ -41,6 +42,9 @@ val Context.activityManager: ActivityManager
val Context.alarmManager: AlarmManager
get() = getSystemService(Context.ALARM_SERVICE) as AlarmManager

val Context.connectivityManager: ConnectivityManager
get() = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager

val Context.usbManager: UsbManager
get() = getSystemService(Context.USB_SERVICE) as UsbManager

Expand Down
7 changes: 5 additions & 2 deletions app/src/main/java/to/bitkit/models/TransferType.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ enum class TransferType {
TO_SAVINGS,
MANUAL_SETUP,
FORCE_CLOSE,
COOP_CLOSE;
COOP_CLOSE,

fun isToSavings() = this in listOf(TO_SAVINGS, COOP_CLOSE, FORCE_CLOSE)
/** The Spark balance leaving on-chain without the Spark operators. */
SPARK_EXIT;

fun isToSavings() = this in listOf(TO_SAVINGS, COOP_CLOSE, FORCE_CLOSE, SPARK_EXIT)
fun isToSpending() = this in listOf(TO_SPENDING, MANUAL_SETUP)
}
Loading
Loading