Skip to content
408 changes: 408 additions & 0 deletions app/src/main/java/to/bitkit/data/WatchOnlyAccountStore.kt

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package to.bitkit.data.serializers

import androidx.datastore.core.CorruptionException
import androidx.datastore.core.Serializer
import kotlinx.serialization.SerializationException
import to.bitkit.data.WatchOnlyAccountData
import to.bitkit.di.json
import java.io.InputStream
import java.io.OutputStream

object WatchOnlyAccountDataSerializer : Serializer<WatchOnlyAccountData> {
override val defaultValue = WatchOnlyAccountData()

override suspend fun readFrom(input: InputStream): WatchOnlyAccountData = try {
json.decodeFromString(input.readBytes().decodeToString())
} catch (error: SerializationException) {
throw CorruptionException("Failed to deserialize watch-only account data", error)
}

override suspend fun writeTo(t: WatchOnlyAccountData, output: OutputStream) {
output.write(json.encodeToString(t).encodeToByteArray())
}
}
3 changes: 3 additions & 0 deletions app/src/main/java/to/bitkit/models/BackupPayloads.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import to.bitkit.data.AppCacheData
import to.bitkit.data.SettingsData
import to.bitkit.data.WatchOnlyAccountAllocationState
import to.bitkit.data.WidgetsData
import to.bitkit.data.entities.TransferEntity

Expand All @@ -21,6 +22,8 @@ data class WalletBackupV1(
val transfers: List<TransferEntity>,
val privatePaykitHighestReservedReceiveIndexByAddressType: Map<String, Int>? = null,
val paykitSdkBackupState: String? = null,
val watchOnlyAccounts: List<WatchOnlyAccountRecord>? = null,
val watchOnlyAccountAllocationState: WatchOnlyAccountAllocationState? = null,
)

@Serializable
Expand Down
93 changes: 93 additions & 0 deletions app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt
Original file line number Diff line number Diff line change
@@ -1,12 +1,42 @@
package to.bitkit.models

import androidx.compose.runtime.Immutable
import to.bitkit.utils.AppError
import java.net.URI
import java.net.URLDecoder
import java.nio.charset.StandardCharsets

enum class PubkyAuthClaim(val wireValue: String) {
WATCH_ONLY_ACCOUNT_V1("watch-only-account-v1"),
;

companion object {
/** Query parameter used for Bitkit-specific Pubky auth claims. */
const val QUERY_PARAMETER = "x-bitkit-claim"

/** Capabilities required by the watch-only Paykit Server setup flow. */
const val WATCH_ONLY_ACCOUNT_CAPABILITIES = "/pub/paykit/v0/bitkit/server/:rw"

fun fromWireValue(value: String) = entries.firstOrNull { it.wireValue == value }
}
}

sealed class PubkyAuthRequestError(cause: Throwable? = null) : AppError(cause = cause) {
class InvalidUrl(cause: Throwable) : PubkyAuthRequestError(cause)
data object MissingBitkitClaim : PubkyAuthRequestError()
data object DuplicateBitkitClaim : PubkyAuthRequestError()
data class UnsupportedBitkitClaim(val value: String) : PubkyAuthRequestError()
data object InvalidBitkitClaimCapabilities : PubkyAuthRequestError()
}

@Immutable
data class PubkyAuthPermission(
val path: String,
val accessLevel: String,
) {
val displayPath: String
get() = if (path.length > 1) path.removeSuffix("/") else path

val displayAccess: String
get() = accessLevel.map { char ->
when (char) {
Expand All @@ -20,10 +50,71 @@ data class PubkyAuthPermission(
data class PubkyAuthRequest(
val rawUrl: String,
val relay: String,
val capabilities: String,
val permissions: List<PubkyAuthPermission>,
val serviceNames: List<String>,
val bitkitClaim: PubkyAuthClaim?,
) {
companion object {
fun parse(
rawUrl: String,
relay: String,
capabilities: String,
): Result<PubkyAuthRequest> = parseBitkitClaim(rawUrl, capabilities).map { bitkitClaim ->
val permissions = parseCapabilities(capabilities)
PubkyAuthRequest(
rawUrl = rawUrl,
relay = relay,
capabilities = capabilities,
permissions = permissions,
serviceNames = permissions.mapNotNull { extractServiceName(it.path) }.distinct(),
bitkitClaim = bitkitClaim,
)
}

fun parseBitkitClaim(rawUrl: String, capabilities: String): Result<PubkyAuthClaim?> =
parseBitkitClaimValues(rawUrl).fold(
onSuccess = { claimValues -> validateBitkitClaim(claimValues, capabilities) },
onFailure = { Result.failure(it) },
)

private fun parseBitkitClaimValues(rawUrl: String): Result<List<String>> = runCatching {
URI(rawUrl).rawQuery.orEmpty()
.split("&")
.filter { it.isNotEmpty() }
.map { it.split("=", limit = 2) }
.filter { decodeQueryComponent(it.first()) == PubkyAuthClaim.QUERY_PARAMETER }
.map { decodeQueryComponent(it.getOrElse(1) { "" }) }
}.fold(
onSuccess = { Result.success(it) },
onFailure = { Result.failure(PubkyAuthRequestError.InvalidUrl(it)) },
)

private fun validateBitkitClaim(
claimValues: List<String>,
capabilities: String,
): Result<PubkyAuthClaim?> = when {
claimValues.size > 1 -> Result.failure(PubkyAuthRequestError.DuplicateBitkitClaim)
claimValues.isEmpty() && capabilities == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES ->
Result.failure(PubkyAuthRequestError.MissingBitkitClaim)
claimValues.isEmpty() -> Result.success(null)
else -> validateBitkitClaimValue(claimValues.first(), capabilities)
}

private fun validateBitkitClaimValue(
claimValue: String,
capabilities: String,
): Result<PubkyAuthClaim?> {
val claim = PubkyAuthClaim.fromWireValue(claimValue)
?: return Result.failure(PubkyAuthRequestError.UnsupportedBitkitClaim(claimValue))

return if (capabilities == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES) {
Result.success(claim)
} else {
Result.failure(PubkyAuthRequestError.InvalidBitkitClaimCapabilities)
}
}

fun parseCapabilities(caps: String): List<PubkyAuthPermission> =
caps.split(",")
.filter { it.isNotBlank() }
Expand All @@ -40,5 +131,7 @@ data class PubkyAuthRequest(
val pubIndex = parts.indexOf("pub")
return if (pubIndex >= 0 && pubIndex + 1 < parts.size) parts[pubIndex + 1] else null
}

private fun decodeQueryComponent(value: String) = URLDecoder.decode(value, StandardCharsets.UTF_8.name())
}
}
49 changes: 49 additions & 0 deletions app/src/main/java/to/bitkit/models/WatchOnlyAccount.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package to.bitkit.models

import androidx.compose.runtime.Immutable
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.lightningdevkit.ldknode.Network
import to.bitkit.env.Env

const val WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX = 999
const val WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE = "nativeSegwit"
const val WATCH_ONLY_ACCOUNT_SERIALIZED_XPUB_LENGTH = 78

@Serializable
enum class WatchOnlyAccountSetupState {
@SerialName("pendingDelivery")
PendingDelivery,

@SerialName("authorizing")
Authorizing,

@SerialName("active")
Active,
}

@Serializable
@Immutable
data class WatchOnlyAccountRecord(
val id: String,
val walletIndex: Int,
val accountIndex: Int,
val addressType: String,
val xpub: String,
val requestFingerprint: String,
val createdAt: Long,
val name: String,
val isTrackingEnabled: Boolean,
val setupState: WatchOnlyAccountSetupState,
) {
val derivationPath: String
get() {
val coinType = if (Env.network == Network.BITCOIN) 0 else 1
return "m/84'/$coinType'/$accountIndex'"
}
}

data class PreparedWatchOnlyAccountClaim(
val account: WatchOnlyAccountRecord,
val payload: ByteArray,
)
22 changes: 22 additions & 0 deletions app/src/main/java/to/bitkit/repositories/BackupRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import to.bitkit.R
import to.bitkit.data.AppDb
import to.bitkit.data.CacheStore
import to.bitkit.data.SettingsStore
import to.bitkit.data.WatchOnlyAccountStore
import to.bitkit.data.WidgetsStore
import to.bitkit.data.backup.VssBackupClient
import to.bitkit.data.backup.VssBackupClientLdk
Expand Down Expand Up @@ -91,6 +92,8 @@ class BackupRepo @Inject constructor(
private val vssBackupClientLdk: VssBackupClientLdk,
private val settingsStore: SettingsStore,
private val widgetsStore: WidgetsStore,
private val watchOnlyAccountStore: WatchOnlyAccountStore,
private val watchOnlyAccountRepo: WatchOnlyAccountRepo,
private val blocktankRepo: BlocktankRepo,
private val activityRepo: ActivityRepo,
private val pubkyRepo: PubkyRepo,
Expand Down Expand Up @@ -263,6 +266,17 @@ class BackupRepo @Inject constructor(
}
dataListenerJobs.add(transfersJob)

val watchOnlyAccountsJob = scope.launch {
watchOnlyAccountStore.data
.distinctUntilChanged()
.drop(1)
.collect {
if (shouldSkipBackup()) return@collect
markBackupRequired(BackupCategory.WALLET)
}
}
dataListenerJobs.add(watchOnlyAccountsJob)

// METADATA - Observe entire CacheStore excluding backup statuses
val cacheMetadataJob = scope.launch {
cacheStore.data
Expand Down Expand Up @@ -545,11 +559,14 @@ class BackupRepo @Inject constructor(
}
.getOrThrow()

val watchOnlyAccountSnapshot = watchOnlyAccountStore.backupSnapshot()
val payload = WalletBackupV1(
createdAt = currentTimeMillis(),
transfers = transfers,
privatePaykitHighestReservedReceiveIndexByAddressType = privateReservations,
paykitSdkBackupState = paykitSdkBackupState,
watchOnlyAccounts = watchOnlyAccountSnapshot.accounts,
watchOnlyAccountAllocationState = watchOnlyAccountSnapshot.allocationState,
)

return json.encodeToString(payload).toByteArray()
Expand Down Expand Up @@ -631,6 +648,11 @@ class BackupRepo @Inject constructor(
private suspend fun restoreWalletBackup(dataBytes: ByteArray): Long {
val parsed = json.decodeFromString<WalletBackupV1>(String(dataBytes))
db.transferDao().upsert(parsed.transfers)
watchOnlyAccountRepo.restore(
parsed.watchOnlyAccounts.orEmpty(),
parsed.watchOnlyAccountAllocationState,
)
lightningService.reconcileWatchOnlyAccounts()
if (!parsed.privatePaykitHighestReservedReceiveIndexByAddressType.isNullOrEmpty()) {
cacheStore.update { it.copy(onchainAddress = "", bip21 = "") }
}
Expand Down
9 changes: 7 additions & 2 deletions app/src/main/java/to/bitkit/repositories/LightningRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import to.bitkit.env.Env
import to.bitkit.ext.getSatsPerVByteFor
import to.bitkit.ext.nowMillis
import to.bitkit.ext.nowTimestamp
import to.bitkit.ext.runSuspendCatching
import to.bitkit.ext.toPeerDetailsList
import to.bitkit.ext.totalNextOutboundHtlcLimitSats
import to.bitkit.models.ALL_ADDRESS_TYPE_STRINGS
Expand All @@ -73,6 +74,7 @@ import to.bitkit.models.NATIVE_WITNESS_TYPES
import to.bitkit.models.NodeLifecycleState
import to.bitkit.models.OpenChannelResult
import to.bitkit.models.TransactionSpeed
import to.bitkit.models.WalletScope
import to.bitkit.models.safe
import to.bitkit.models.satsToMsat
import to.bitkit.models.toAddressType
Expand All @@ -88,7 +90,6 @@ import to.bitkit.services.LnurlWithdrawResponse
import to.bitkit.services.LspNotificationsService
import to.bitkit.services.NodeEventHandler
import to.bitkit.utils.AppError
import to.bitkit.models.WalletScope
import to.bitkit.utils.Logger
import to.bitkit.utils.ServiceError
import to.bitkit.utils.UrlValidator
Expand Down Expand Up @@ -340,8 +341,12 @@ class LightningRepo @Inject constructor(
}
}

if (getStatus()?.isRunning == true) {
if (lightningService.status?.isRunning == true) {
Logger.info("LDK node already running", context = TAG)
runSuspendCatching { lightningService.reconcileWatchOnlyAccounts() }
.onFailure {
Logger.warn("Failed to reconcile Paykit Server accounts during startup", it, context = TAG)
}
_lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Running) }
lightningService.startEventListener(::onEvent).onFailure {
Logger.warn("Failed to start event listener", it, context = TAG)
Expand Down
34 changes: 31 additions & 3 deletions app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import android.graphics.BitmapFactory
import coil3.ImageLoader
import com.synonym.paykit.ContactProfileResolution
import com.synonym.paykit.PaykitProfile
import com.synonym.paykit.PubkyAuthDetails
import com.synonym.paykit.PubkyAuthCompanionClaim
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.post
Expand Down Expand Up @@ -41,6 +41,8 @@ import to.bitkit.di.IoDispatcher
import to.bitkit.env.Env
import to.bitkit.ext.runSuspendCatching
import to.bitkit.models.HomegateResponse
import to.bitkit.models.PubkyAuthClaim
import to.bitkit.models.PubkyAuthRequest
import to.bitkit.models.PubkyProfile
import to.bitkit.models.PubkyProfileData
import to.bitkit.models.PubkyProfileLink
Expand Down Expand Up @@ -898,9 +900,14 @@ class PubkyRepo @Inject constructor(
managedSecretKeyFor(publicKey) != null
}.getOrDefault(false)

suspend fun parseAuthUrl(authUrl: String): Result<PubkyAuthDetails> = runSuspendCatching {
suspend fun parseAuthUrl(authUrl: String): Result<PubkyAuthRequest> = runSuspendCatching {
withContext(ioDispatcher) {
pubkyService.parseAuthUrl(authUrl)
val details = pubkyService.parseAuthUrl(authUrl)
PubkyAuthRequest.parse(
rawUrl = authUrl,
relay = details.relayUrl.orEmpty(),
capabilities = details.capabilities.orEmpty(),
).getOrThrow()
}
}

Expand All @@ -913,6 +920,27 @@ class PubkyRepo @Inject constructor(
}
}

suspend fun approveAuthWithCompanionClaim(
authUrl: String,
unsignedPayload: ByteArray,
): Result<Unit> = runSuspendCatching {
withContext(ioDispatcher) {
val secretKeyHex = requireNotNull(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)) {
"No secret key available — use Ring to manage authorizations"
}
pubkyService.approveAuthWithCompanionClaim(
authUrl = authUrl,
expectedCapabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES,
secretKeyHex = secretKeyHex,
claim = PubkyAuthCompanionClaim(
queryParameter = PubkyAuthClaim.QUERY_PARAMETER,
claimType = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue,
unsignedPayload = unsignedPayload,
),
)
}
}

// endregion

// region Backup state
Expand Down
Loading
Loading