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
15 changes: 15 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,19 @@ android {
manifestPlaceholders["app_icon"] = "@mipmap/ic_launcher_testnet"
manifestPlaceholders["app_icon_round"] = "@mipmap/ic_launcher_testnet_round"
}
// Signet is the only test network with a Second-hosted Ark server, so this
// flavor exists to exercise the bark spending backend. It deliberately
// reuses the testnet applicationId (and therefore icons) so the checked-in
// google-services.json resolves without a signet Firebase client; the
// trade-off is that signet and tnet cannot be installed side by side.
create("signet") {
dimension = "network"
applicationIdSuffix = ".tnet"
buildConfigField("String", "NETWORK", "\"SIGNET\"")
resValue("string", "app_name", "Bitkit Signet")
manifestPlaceholders["app_icon"] = "@mipmap/ic_launcher_testnet"
manifestPlaceholders["app_icon_round"] = "@mipmap/ic_launcher_testnet_round"
}
}

signingConfigs {
Expand Down Expand Up @@ -385,6 +398,8 @@ dependencies {
implementation(libs.bitkit.core)
implementation(libs.paykit)
implementation(libs.vss.client)
// bark declares jna 5.15.0; keep the single app-wide jna aar declared above
implementation(libs.bark) { exclude(group = "net.java.dev.jna", module = "jna") }
nativeDebugSymbols(libs.bitkit.core.nativeDebugSymbolsArtifact())
nativeDebugSymbols(libs.ldk.node.android.nativeDebugSymbolsArtifact())
nativeDebugSymbols(libs.paykit.nativeDebugSymbolsArtifact())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import to.bitkit.domain.commands.NotifyPendingPaymentResolvedHandler
import to.bitkit.ext.activityManager
import to.bitkit.models.NewTransactionSheetDetails
import to.bitkit.models.NotificationDetails
import to.bitkit.repositories.BarkRepo
import to.bitkit.repositories.LightningRepo
import to.bitkit.repositories.WalletRepo
import to.bitkit.services.NodeEventHandler
Expand All @@ -58,6 +59,9 @@ class LightningNodeService : Service() {
@Inject
lateinit var lightningRepo: LightningRepo

@Inject
lateinit var barkRepo: BarkRepo

@Inject
lateinit var walletRepo: WalletRepo

Expand Down Expand Up @@ -98,6 +102,7 @@ class LightningNodeService : Service() {
).onSuccess {
walletRepo.setWalletExistsState()
walletRepo.refreshBip21()
barkRepo.startIfEnabled()
walletRepo.syncBalances()
}
}
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/to/bitkit/async/ServiceQueue.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import java.util.concurrent.ThreadFactory
import kotlin.coroutines.CoroutineContext

enum class ServiceQueue {
LDK, CORE, FOREX, LOG, MIGRATION;
LDK, ARK, CORE, FOREX, LOG, MIGRATION;

private val scope by lazy { CoroutineScope(newSingleThreadDispatcher(name) + SupervisorJob()) }

Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/to/bitkit/data/CacheStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.serialization.Serializable
import to.bitkit.data.dto.PendingBarkBoard
import to.bitkit.data.dto.PendingBoostActivity
import to.bitkit.data.serializers.AppCacheSerializer
import to.bitkit.ext.scopedActivityId
Expand Down Expand Up @@ -164,6 +165,7 @@ data class AppCacheData(
val backgroundReceive: NewTransactionSheetDetails? = null,
val addressSearchLastUsedReceiveIndexes: Map<String, Int> = mapOf(),
val addressSearchLastUsedChangeIndexes: Map<String, Int> = mapOf(),
val pendingBarkBoard: PendingBarkBoard? = null,
) {
fun isActivityDeleted(activityId: String, walletId: String): Boolean =
scopedActivityId(walletId, activityId) in deletedActivities ||
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/java/to/bitkit/data/SettingsStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -157,8 +157,12 @@ data class SettingsData(
val selectedAddressType: String = DEFAULT_ADDRESS_TYPE_STRING,
val addressTypesToMonitor: List<String> = listOf(DEFAULT_ADDRESS_TYPE_STRING),
val pendingRestoreAddressTypePrune: Boolean = false,
val spendingBackend: SpendingBackend = SpendingBackend.LDK,
)

/** Which backend provides the spending balance. Savings stays on ldk-node either way. */
enum class SpendingBackend { LDK, BARK }

fun SettingsData.resetPin() = this.copy(
isPinEnabled = false,
isPinForPaymentsEnabled = false,
Expand Down
18 changes: 18 additions & 0 deletions app/src/main/java/to/bitkit/data/dto/PendingBarkBoard.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package to.bitkit.data.dto

import kotlinx.serialization.Serializable

/**
* A savings -> spending transfer in bark mode, which takes two on-chain steps: an ldk-node send into
* bark's own on-chain wallet, then a board of that amount onto Ark once it has confirmed.
*
* Persisted so the second step survives the app being killed between them; without it the sats
* would sit in bark's on-chain wallet, invisible in both balances.
*/
@Serializable
data class PendingBarkBoard(
/** Txid of the ldk-node send that funds bark's on-chain wallet. */
val fundingTxId: String,
val amountSats: ULong,
val createdAtMillis: Long,
)
38 changes: 34 additions & 4 deletions app/src/main/java/to/bitkit/env/Env.kt
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ internal object Env {
if (isLocalE2eBackend) ElectrumServers.REGTEST.LOCAL else ElectrumServers.REGTEST.STAG
}
Network.TESTNET -> ElectrumServers.TESTNET
else -> TODO("${network.name} network not implemented")
Network.SIGNET -> ElectrumServers.SIGNET
}
}

Expand All @@ -88,6 +88,28 @@ internal object Env {
else -> null
}

/**
* Second's Ark server, used by the bark spending backend. Only mainnet and signet are hosted;
* there is no public regtest or testnet Ark server.
*/
val arkServerUrl
get() = when (network) {
Network.BITCOIN -> "https://ark.second.tech"
Network.SIGNET -> "https://ark.signet.2nd.dev"
else -> null
}

/** Esplora chain source for bark. Must stay on the same chain as [electrumServerUrl]. */
val arkEsploraUrl
get() = when (network) {
Network.BITCOIN -> "https://mempool.second.tech/api"
Network.SIGNET -> "https://esplora.signet.2nd.dev"
else -> null
}

/** Whether the bark spending backend can be offered on this build's network. */
val isArkSupported get() = arkServerUrl != null

val vssStoreIdPrefix get() = "bitkit_v1_${network.name.lowercase()}"

val vssServerUrl
Expand All @@ -105,7 +127,7 @@ internal object Env {
val blockExplorerUrl
get() = when (network) {
Network.BITCOIN -> "https://mempool.space"
Network.SIGNET -> "https://mutinynet.com"
Network.SIGNET -> "https://mempool.space/signet"
Network.TESTNET -> "https://mempool.space/testnet"
Network.REGTEST -> "https://mempool.bitkit.stag0.blocktank.to/"
}
Expand Down Expand Up @@ -187,8 +209,8 @@ internal object Env {
val isE2eLocal = isE2eTest && e2eBackend == "local"
return when (network) {
BitkitCoreNetwork.BITCOIN -> ElectrumServers.MAINNET.ESPLORA
BitkitCoreNetwork.TESTNET, BitkitCoreNetwork.TESTNET4, BitkitCoreNetwork.SIGNET ->
ElectrumServers.TESTNET
BitkitCoreNetwork.SIGNET -> ElectrumServers.SIGNET
BitkitCoreNetwork.TESTNET, BitkitCoreNetwork.TESTNET4 -> ElectrumServers.TESTNET
BitkitCoreNetwork.REGTEST ->
if (isE2eLocal) ElectrumServers.REGTEST.LOCAL else ElectrumServers.REGTEST.STAG
}
Expand Down Expand Up @@ -218,6 +240,8 @@ internal object Env {
return storagePathOf(walletIndex, network.name.lowercase(), "core")
}

fun arkStoragePath(walletIndex: Int) = storagePathOf(walletIndex, network.name.lowercase(), "ark")

/**
* Generates the storage path for a specified wallet index, network, and directory.
*
Expand Down Expand Up @@ -289,4 +313,10 @@ private object ElectrumServers {
}

const val TESTNET = "ssl://electrum.blockstream.info:60002"

/**
* Public signet Electrum. Must stay on the same chain as [Env.arkEsploraUrl]: Second's Ark
* signet is the standard public signet, not a custom one such as mutinynet.
*/
const val SIGNET = "ssl://mempool.space:60602"
}
54 changes: 54 additions & 0 deletions app/src/main/java/to/bitkit/ext/BarkMovements.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package to.bitkit.ext

import com.synonym.bitkitcore.Activity
import com.synonym.bitkitcore.LightningActivity
import com.synonym.bitkitcore.PaymentState
import com.synonym.bitkitcore.PaymentType
import to.bitkit.models.WalletScope
import uniffi.bark.Movement
import java.time.Instant
import kotlin.math.absoluteValue

/** Prefix keeping bark movement ids from colliding with ldk-node payment ids in the same store. */
const val BARK_ACTIVITY_ID_PREFIX = "bark:"

/**
* Maps a bark [Movement] onto the bitkit-core [Activity] model so Ark payments show up in the
* existing activity list, detail screen, tags and contacts with no UI changes.
*
* bark reports balance deltas as signed sats: negative is outgoing. The FFI `Movement` carries no
* preimage, so [LightningActivity.preimage] stays null.
*/
fun Movement.toActivity(walletId: String = WalletScope.default): Activity {
val createdAtSecs = parseBarkTimestamp(createdAt)
val updatedAtSecs = parseBarkTimestamp(updatedAt)

return Activity.Lightning(
LightningActivity.create(
walletId = walletId,
id = "$BARK_ACTIVITY_ID_PREFIX$id",
txType = if (intendedBalanceSats < 0) PaymentType.SENT else PaymentType.RECEIVED,
status = status.toPaymentState(),
value = effectiveBalanceSats.absoluteValue.toULong(),
fee = offchainFeeSats,
invoice = lightningInvoice ?: lightningOffer ?: sentToAddresses.firstOrNull().orEmpty(),
message = "$subsystemName/$subsystemKind",
timestamp = createdAtSecs,
createdAt = createdAtSecs,
updatedAt = updatedAtSecs,
)
)
}

private fun String.toPaymentState(): PaymentState = when (this) {
"successful" -> PaymentState.SUCCEEDED
"failed", "canceled" -> PaymentState.FAILED
else -> PaymentState.PENDING
}

/**
* bark emits RFC 3339 timestamps. A malformed value must not drop the whole activity, so it falls
* back to the epoch and the movement still shows up.
*/
private fun parseBarkTimestamp(value: String): ULong =
runCatching { Instant.parse(value).epochSecond.toULong() }.getOrDefault(0uL)
35 changes: 32 additions & 3 deletions app/src/main/java/to/bitkit/repositories/ActivityRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,15 @@ import to.bitkit.ext.nowMillis
import to.bitkit.ext.nowTimestamp
import to.bitkit.ext.rawId
import to.bitkit.ext.runSuspendCatching
import to.bitkit.ext.toActivity
import to.bitkit.ext.walletId
import to.bitkit.models.ActivityBackupV1
import to.bitkit.models.PubkyPublicKeyFormat
import to.bitkit.models.WalletScope
import to.bitkit.services.CoreService
import to.bitkit.utils.AppError
import to.bitkit.utils.Logger
import uniffi.bark.Movement
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Clock
Expand All @@ -66,6 +68,7 @@ class ActivityRepo @Inject constructor(
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val coreService: CoreService,
private val lightningRepo: LightningRepo,
private val barkRepo: BarkRepo,
private val blocktankRepo: BlocktankRepo,
private val cacheStore: CacheStore,
private val transferRepo: TransferRepo,
Expand Down Expand Up @@ -99,9 +102,20 @@ class ActivityRepo @Inject constructor(

isSyncingLdkNodePayments.update { true }

lightningRepo.getPayments().mapCatching { payments ->
Logger.debug("Got payments with success, syncing activities", context = TAG)
syncLdkNodePayments(payments).getOrThrow()
// Onchain activity is unchanged in either mode; only the offchain source differs.
val offchainSync = if (barkRepo.isEnabledNow()) {
barkRepo.history().mapCatching { movements ->
Logger.debug("Got Ark movements with success, syncing activities", context = TAG)
syncBarkMovements(movements).getOrThrow()
}
} else {
lightningRepo.getPayments().mapCatching { payments ->
Logger.debug("Got payments with success, syncing activities", context = TAG)
syncLdkNodePayments(payments).getOrThrow()
}
}

offchainSync.mapCatching {
boostPendingActivities()
transferRepo.syncTransferStates().getOrThrow()
}.onSuccess {
Expand Down Expand Up @@ -134,6 +148,21 @@ class ActivityRepo @Inject constructor(
}
}

/**
* Syncs bark [Movement]s to `bitkit-core` [Activity] items. bitkit-core only knows how to map
* ldk-node payments, so Ark movements are mapped here and inserted through the normal path.
*/
suspend fun syncBarkMovements(movements: List<Movement>): Result<Unit> = withContext(bgDispatcher) {
runSuspendCatching {
movements.forEach { movement ->
insertActivity(movement.toActivity())
}
notifyActivitiesChanged()
}.onFailure {
Logger.error("Error syncing Ark movements:", it, context = TAG)
}
}

private suspend fun findChannelsForPayments(
payments: List<PaymentDetails>,
): Map<String, String> = withContext(bgDispatcher) {
Expand Down
Loading
Loading