Skip to content
Open
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
408 changes: 408 additions & 0 deletions app/src/main/java/to/bitkit/data/WatchOnlyAccountStore.kt

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions app/src/main/java/to/bitkit/data/keychain/Keychain.kt
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ class Keychain @Inject constructor(
PIN,
PIN_ATTEMPTS_REMAINING,
PAYKIT_SESSION,
PAYKIT_RECEIVER_NOISE_SECRET_KEY,
PAYKIT_SDK_STATE,
PUBKY_SECRET_KEY,
}
Expand Down
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 @@ -35,6 +35,7 @@ import to.bitkit.async.appScope
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 @@ -90,6 +91,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 @@ -262,6 +265,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 @@ -544,11 +558,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 @@ -630,6 +647,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
7 changes: 6 additions & 1 deletion 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 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
8 changes: 4 additions & 4 deletions app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -614,7 +614,7 @@ class PrivatePaykitRepo @Inject constructor(
}

private suspend fun prepareRelevantPrivateLinksIfAvailable(publicKeys: Collection<String>, reason: String) {
if (!hasLocalSecretKeyForCurrentProfile()) return
if (!hasLiveSessionForCurrentProfile()) return

val retryKeys = mutableListOf<PrivateMessageDrainRetryKey>()
for (publicKey in publicKeys) {
Expand Down Expand Up @@ -1232,16 +1232,16 @@ class PrivatePaykitRepo @Inject constructor(
private suspend fun canPublishPrivateEndpoints(): Boolean {
val settings = settingsStore.data.first()
return settings.sharesPrivatePaykitEndpoints &&
hasLocalSecretKeyForCurrentProfile() &&
hasLiveSessionForCurrentProfile() &&
App.currentActivity?.value != null &&
walletRepo.walletExists() &&
lightningRepo.lightningState.value.nodeLifecycleState.isRunning()
}

private suspend fun hasLocalSecretKeyForCurrentProfile(): Boolean = runSuspendCatching {
private suspend fun hasLiveSessionForCurrentProfile(): Boolean = runSuspendCatching {
pubkyService.currentPublicKey() ?: return@runSuspendCatching false
val status = paykitSdkService.identityStatus() ?: return@runSuspendCatching false
status.privateLinkCapable
status.liveSessionAvailable
}.getOrDefault(false)

private suspend fun isContactSharingCleanupPending(): Boolean =
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 @@ -40,6 +40,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 @@ -897,9 +899,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 @@ -912,6 +919,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