Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
6624dd3
refactor: integrate paykit sdk
ben-kaufman Jun 24, 2026
fce3d67
fix: preserve paykit cancellation
ben-kaufman Jun 24, 2026
8202a59
fix: preserve paykit cancellation
ben-kaufman Jun 24, 2026
8c9127d
fix: harden paykit restore
ben-kaufman Jun 24, 2026
af688f2
Merge branch 'master' into codex/paykit-sdk-native-integration
ovitrif Jun 25, 2026
55013f6
fix: refresh paykit address
ben-kaufman Jun 30, 2026
0415486
Merge remote-tracking branch 'origin/master' into codex/paykit-sdk-na…
ben-kaufman Jun 30, 2026
4545caf
fix: back up pubky profile changes
ben-kaufman Jul 1, 2026
73921e9
Merge branch 'master' into codex/paykit-sdk-native-integration
ben-kaufman Jul 1, 2026
e769e0b
fix: mark backup dirty when clearing stale pubky secret
ben-kaufman Jul 1, 2026
7838f0d
fix: disable ring import
ben-kaufman Jul 1, 2026
82bb55c
fix: harden paykit sdk rc23
ben-kaufman Jul 2, 2026
e9aaa18
Merge branch 'master' into codex/paykit-sdk-native-integration
ben-kaufman Jul 2, 2026
14dd28d
fix: address paykit review issues
ben-kaufman Jul 2, 2026
60ea272
fix: restore pubky ring import option
ben-kaufman Jul 2, 2026
dde8fda
Merge branch 'master' into codex/paykit-sdk-native-integration
ben-kaufman Jul 2, 2026
48cefbc
fix: harden paykit recovery on rc29
ben-kaufman Jul 5, 2026
d7afddb
chore: update paykit to rc30
ben-kaufman Jul 6, 2026
da8cf50
chore: update paykit to rc31
ben-kaufman Jul 6, 2026
6f0ab97
Merge branch 'master' into codex/paykit-sdk-native-integration
ben-kaufman Jul 6, 2026
ad61c8d
fix: allow paykit public fallback
ben-kaufman Jul 6, 2026
bba4a6c
fix: wait for pubky ring callback
ben-kaufman Jul 6, 2026
3799f9f
Merge branch 'master' into codex/paykit-sdk-native-integration
ben-kaufman Jul 7, 2026
0c0dd99
fix: handle canceled ring auth
ben-kaufman Jul 8, 2026
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
11 changes: 1 addition & 10 deletions app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt
Original file line number Diff line number Diff line change
Expand Up @@ -63,23 +63,14 @@ data class PrivatePaykitCacheData(
val contacts: Map<String, PrivatePaykitContactCacheData> = emptyMap(),
val cleanupPending: Boolean = false,
val deletedContactCleanupPendingPublicKeys: Set<String> = emptySet(),
val profileRecoveryPending: Boolean = false,
)

@Serializable
data class PrivatePaykitContactCacheData(
val remoteEndpoints: List<PrivatePaykitStoredPaymentEntryData> = emptyList(),
val localInvoice: PrivatePaykitStoredInvoiceData? = null,
val receivedInvoicePaymentHashes: List<String> = emptyList(),
val lastLocalPayloadHash: String? = null,
val linkCompletedAt: Long? = null,
val handshakeUpdatedAt: Long? = null,
val recoveryStartedAt: Long? = null,
val mainRecoveryAttemptId: String? = null,
val responderRecoveryAttemptId: String? = null,
val lastCompletedRecoveryAttemptId: String? = null,
val awaitingRecoveredRemoteEndpoints: Boolean = false,
val linkFailureCount: Int = 0,
val hasPublishedPrivatePaymentList: Boolean = false,
)

@Serializable
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/to/bitkit/data/PubkyStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.serialization.Serializable
import to.bitkit.data.serializers.PubkyStoreSerializer
import to.bitkit.models.PubkyProfileData
import javax.inject.Inject
import javax.inject.Singleton

Expand Down Expand Up @@ -36,4 +37,5 @@ class PubkyStore @Inject constructor(
data class PubkyStoreData(
val cachedName: String? = null,
val cachedImageUri: String? = null,
val contactProfileOverrides: Map<String, PubkyProfileData> = emptyMap(),
)
70 changes: 64 additions & 6 deletions app/src/main/java/to/bitkit/data/keychain/Keychain.kt
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,54 @@ class Keychain @Inject constructor(
}
}

internal fun <T> accessBlocking(block: BlockingAccess.() -> T): T =
BlockingAccess().block()

internal inner class BlockingAccess {
@Suppress("TooGenericExceptionCaught")
fun load(key: String): ByteArray? {
try {
return blockingSnapshot[key.indexedBlocking]?.fromBase64()?.let {
keyStore.decrypt(it)
}
} catch (c: CancellationException) {
throw c
} catch (t: Throwable) {
emitLoadDiagnosticsOnce(key, t)
throw KeychainError.FailedToLoad(key, cause = t)
}
}

@Suppress("TooGenericExceptionCaught")
fun upsert(key: String, value: ByteArray) {
try {
val encryptedValue = keyStore.encrypt(value)
runBlocking {
keychain.edit { it[key.indexedBlocking] = encryptedValue.toBase64() }
}
} catch (c: CancellationException) {
throw c
} catch (t: Throwable) {
throw KeychainError.FailedToSave(key, cause = t)
}
Logger.info("Upserted value for key '$key'", context = TAG)
}

@Suppress("TooGenericExceptionCaught")
fun delete(key: String) {
try {
runBlocking {
keychain.edit { it.remove(key.indexedBlocking) }
}
} catch (c: CancellationException) {
throw c
} catch (t: Throwable) {
throw KeychainError.FailedToDelete(key, cause = t)
}
Logger.debug("Deleted value for key '$key'", context = TAG)
}
}

suspend fun saveString(key: String, value: String) = save(key, value.toByteArray())

@Suppress("TooGenericExceptionCaught", "ThrowsCount")
Expand All @@ -80,10 +128,9 @@ class Keychain @Inject constructor(
} catch (t: Throwable) {
throw KeychainError.FailedToSave(key, cause = t)
}
Logger.info("Saved to keychain: $key")
Logger.info("Saved value for key '$key'", context = TAG)
}

/** Inserts or replaces a string value associated with a given key in the keychain. */
@Suppress("TooGenericExceptionCaught")
suspend fun upsertString(key: String, value: String) {
try {
Expand All @@ -94,7 +141,7 @@ class Keychain @Inject constructor(
} catch (t: Throwable) {
throw KeychainError.FailedToSave(key, cause = t)
}
Logger.info("Upsert in keychain: $key")
Logger.info("Upserted value for key '$key'", context = TAG)
}

@Suppress("TooGenericExceptionCaught")
Expand All @@ -106,7 +153,7 @@ class Keychain @Inject constructor(
} catch (t: Throwable) {
throw KeychainError.FailedToDelete(key, cause = t)
}
Logger.debug("Deleted from keychain: $key")
Logger.debug("Deleted value for key '$key'", context = TAG)
}

fun exists(key: String): Boolean {
Expand All @@ -119,7 +166,7 @@ class Keychain @Inject constructor(
keyStore.resetEncryptionKey()
val count = keys.size

Logger.info("Reset keychain encryption key and deleted all '$count' entries")
Logger.info("Reset keychain encryption key and deleted all '$count' entries", context = TAG)
}

private val String.indexed: Preferences.Key<String>
Expand All @@ -128,6 +175,17 @@ class Keychain @Inject constructor(
return "${this}_$walletIndex".let(::stringPreferencesKey)
}

private val blockingSnapshot: Preferences
get() = runBlocking { keychain.data.first() }

private val String.indexedBlocking: Preferences.Key<String>
get() {
val walletIndex = runBlocking {
db.configDao().getAll().first()
}.firstOrNull()?.walletIndex ?: 0
return "${this}_$walletIndex".let(::stringPreferencesKey)
}

fun pinAttemptsRemaining(): Flow<Int?> {
return keychain.data
.map { it[Key.PIN_ATTEMPTS_REMAINING.name.indexed] }
Expand Down Expand Up @@ -174,7 +232,7 @@ class Keychain @Inject constructor(
PIN,
PIN_ATTEMPTS_REMAINING,
PAYKIT_SESSION,
PRIVATE_PAYKIT_SECRET_STATE,
PAYKIT_SDK_STATE,
PUBKY_SECRET_KEY,
}
}
Expand Down
24 changes: 0 additions & 24 deletions app/src/main/java/to/bitkit/env/Env.kt
Original file line number Diff line number Diff line change
Expand Up @@ -158,21 +158,6 @@ internal object Env {
const val BITREFILL_APP = "Bitkit"
const val BITREFILL_REF = "AL6dyZYt"

private val pubkyDomain: String
get() = when (network) {
Network.BITCOIN -> "bitkit.to"
else -> "staging.bitkit.to"
}

val pubkyCapabilities: String
get() {
val prefix = when (network) {
Network.BITCOIN -> ""
else -> "staging."
}
return "/pub/$pubkyDomain/:rw,/pub/${prefix}pubky.app/:r,/pub/paykit/v0/:rw"
}

val homegateUrl: String
get() {
if (isLocalE2eBackend) {
Expand All @@ -185,15 +170,6 @@ internal object Env {
}
}

val profilePath: String
get() = "/pub/$pubkyDomain/profile.json"

val contactsBasePath: String
get() = "/pub/$pubkyDomain/contacts/"

val blobsBasePath: String
get() = "/pub/$pubkyDomain/blobs/"

val rnBackupServerHost: String
get() = when (network) {
Network.BITCOIN -> "https://blocktank.synonym.to/backups-ldk"
Expand Down
17 changes: 2 additions & 15 deletions app/src/main/java/to/bitkit/models/BackupPayloads.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,7 @@ data class WalletBackupV1(
val createdAt: Long,
val transfers: List<TransferEntity>,
val privatePaykitHighestReservedReceiveIndexByAddressType: Map<String, Int>? = null,
val privatePaykitContactLinks: Map<String, PrivatePaykitContactLinkBackupV1>? = null,
)

@Serializable
data class PrivatePaykitContactLinkBackupV1(
val publicKey: String,
val linkSnapshotHex: String? = null,
val handshakeSnapshotHex: String? = null,
val remoteEndpoints: Map<String, String> = emptyMap(),
val linkCompletedAt: Long? = null,
val handshakeUpdatedAt: Long? = null,
val recoveryStartedAt: Long? = null,
val mainRecoveryAttemptId: String? = null,
val responderRecoveryAttemptId: String? = null,
val awaitingRecoveredRemoteEndpoints: Boolean = false,
val paykitSdkBackupState: String? = null,
)

@Serializable
Expand All @@ -44,6 +30,7 @@ data class MetadataBackupV1(
val tagMetadata: List<PreActivityMetadata>,
val cache: AppCacheData,
val pubkySession: PubkySessionBackupV1? = null,
val pubkyContactProfileOverrides: Map<String, PubkyProfileData>? = null,
)

@Serializable
Expand Down
45 changes: 36 additions & 9 deletions app/src/main/java/to/bitkit/models/PubkyProfile.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import androidx.compose.runtime.Stable
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import to.bitkit.ext.ellipsisMiddle
import com.synonym.bitkitcore.PubkyProfile as CorePubkyProfile
import com.synonym.paykit.PaykitProfile as SdkPaykitProfile
import com.synonym.paykit.PubkyProfile as SdkPubkyProfile

private val pubkyProfileJson = Json { ignoreUnknownKeys = true }

@Immutable
data class PubkyProfileLink(val label: String, val url: String)
Expand All @@ -23,18 +26,21 @@ data class PubkyProfile(
companion object {
private const val TRUNCATED_PK_LENGTH = 11

fun fromFfi(publicKey: String, ffiProfile: CorePubkyProfile): PubkyProfile {
fun fromPubkyProfile(publicKey: String, sdkProfile: SdkPubkyProfile): PubkyProfile {
return PubkyProfile(
publicKey = publicKey,
name = ffiProfile.name,
bio = ffiProfile.bio ?: "",
imageUrl = ffiProfile.image,
links = ffiProfile.links.orEmpty().map { PubkyProfileLink(label = it.title, url = it.url) },
name = sdkProfile.name,
bio = sdkProfile.bio ?: "",
imageUrl = sdkProfile.image,
links = sdkProfile.links.map { PubkyProfileLink(label = it.title, url = it.url) },
tags = emptyList(),
status = ffiProfile.status,
status = sdkProfile.status,
)
}

fun fromPaykitProfile(publicKey: String, profile: SdkPaykitProfile): PubkyProfile =
PubkyProfileData.fromPaykitProfile(profile).toPubkyProfile(publicKey)

fun placeholder(publicKey: String) = PubkyProfile(
publicKey = publicKey,
name = publicKey.ellipsisMiddle(TRUNCATED_PK_LENGTH),
Expand Down Expand Up @@ -63,6 +69,10 @@ data class PubkyProfile(
val truncatedPublicKey: String
get() = publicKey.ellipsisMiddle(TRUNCATED_PK_LENGTH)

fun withNameFallback(fallbackName: String?): PubkyProfile {
return if (name.isBlank() && !fallbackName.isNullOrBlank()) copy(name = fallbackName) else this
}

fun toProfileData() = PubkyProfileData(
name = name,
bio = bio,
Expand All @@ -85,11 +95,28 @@ data class PubkyProfileData(
) {
companion object {
fun decode(json: String): PubkyProfileData =
Json { ignoreUnknownKeys = true }.decodeFromString(json)
pubkyProfileJson.decodeFromString(json)

fun fromPaykitProfile(profile: SdkPaykitProfile): PubkyProfileData {
val extra = profile.extraJson?.let { runCatching { decode(it) }.getOrNull() }
return PubkyProfileData(
name = profile.displayName ?: extra?.name.orEmpty(),
bio = extra?.bio.orEmpty(),
image = profile.imageUri ?: extra?.image,
links = extra?.links.orEmpty(),
tags = extra?.tags.orEmpty(),
)
}
}

fun encode(): ByteArray =
Json.encodeToString(this).toByteArray(Charsets.UTF_8)
pubkyProfileJson.encodeToString(this).toByteArray(Charsets.UTF_8)

fun toPaykitProfile() = SdkPaykitProfile(
displayName = name,
imageUri = image,
extraJson = encode().toString(Charsets.UTF_8),
)

fun toPubkyProfile(publicKey: String) = PubkyProfile(
publicKey = publicKey,
Expand Down
14 changes: 2 additions & 12 deletions app/src/main/java/to/bitkit/models/PubkyPublicKeyFormat.kt
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
package to.bitkit.models

import com.synonym.paykit.PaykitPublicKeys
import to.bitkit.ext.ellipsisMiddle
import java.util.Locale

object PubkyPublicKeyFormat {
private const val pubkyPrefix = "pubky"
private const val rawKeyLength = 52
private const val redactedLength = 16
const val maximumInputLength = 57

private val zBase32Regex = Regex("^[ybndrfg8ejkmcpqxot1uwisza345h769]+$")

fun bounded(input: String): String {
return input
.trim()
Expand All @@ -19,14 +16,7 @@ object PubkyPublicKeyFormat {
}

fun normalized(input: String): String? {
val normalizedInput = input.trim().lowercase(Locale.US)
val rawKey = normalizedInput.removePrefix(pubkyPrefix)

if (rawKey.length != rawKeyLength || !zBase32Regex.matches(rawKey)) {
return null
}

return "$pubkyPrefix$rawKey"
return runCatching { PaykitPublicKeys.normalize(bounded(input)) }.getOrNull()
}

fun matches(lhs: String?, rhs: String?): Boolean {
Expand Down
Loading
Loading