Skip to content
Merged
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
local.properties
# AI
.ai
.codex/
.claude/worktrees
*.local.*
!*.local.template*
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ data class PrivatePaykitCacheData(
val contacts: Map<String, PrivatePaykitContactCacheData> = emptyMap(),
val cleanupPending: Boolean = false,
val deletedContactCleanupPendingPublicKeys: Set<String> = emptySet(),
val profileRecoveryPending: Boolean = false,
)

@Serializable
Expand All @@ -77,6 +78,7 @@ data class PrivatePaykitContactCacheData(
val mainRecoveryAttemptId: String? = null,
val responderRecoveryAttemptId: String? = null,
val lastCompletedRecoveryAttemptId: String? = null,
val awaitingRecoveredRemoteEndpoints: Boolean = false,
val linkFailureCount: Int = 0,
)

Expand Down
3 changes: 3 additions & 0 deletions app/src/main/java/to/bitkit/data/SettingsStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ data class SettingsData(
val hasSeenContactsIntro: Boolean = false,
val hasConfirmedPublicPaykitEndpoints: Boolean = false,
val sharesPublicPaykitEndpoints: Boolean = false,
val sharesPrivatePaykitEndpoints: Boolean = false,
val publicPaykitLightningEnabled: Boolean = true,
val publicPaykitOnchainEnabled: Boolean = true,
val publicPaykitBolt11: String = "",
val publicPaykitBolt11PaymentHash: String = "",
val publicPaykitBolt11ExpiresAtMillis: Long = 0,
Expand Down
1 change: 1 addition & 0 deletions app/src/main/java/to/bitkit/models/BackupPayloads.kt
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ data class PrivatePaykitContactLinkBackupV1(
val recoveryStartedAt: Long? = null,
val mainRecoveryAttemptId: String? = null,
val responderRecoveryAttemptId: String? = null,
val awaitingRecoveredRemoteEndpoints: Boolean = false,
)

@Serializable
Expand Down
34 changes: 32 additions & 2 deletions app/src/main/java/to/bitkit/repositories/ActivityRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -392,9 +392,39 @@ class ActivityRepo @Inject constructor(
}
}

suspend fun clearContact(
forPaymentId: String,
syncLdkPayments: Boolean = true,
): Result<Unit> = withContext(ioDispatcher) {
runCatching {
if (syncLdkPayments) {
lightningRepo.getPayments().onSuccess {
syncLdkNodePayments(it).getOrThrow()
}.getOrThrow()
}

val activity = findActivityForPaymentId(forPaymentId, syncLdkPayments)
if (activity == null) {
Logger.warn(
"Skipped clearing contact for payment '$forPaymentId' because activity was not found",
context = TAG,
)
return@runCatching
}
if (activity.contact() == null) return@runCatching

val updatedAt = nowTimestamp().epochSecond.toULong()
val updatedActivity = activity.withContact(null, updatedAt)
updateActivity(updatedActivity.rawId(), updatedActivity).getOrThrow()
updateReplacementContactIfNeeded(updatedActivity, null, updatedAt)
}.onFailure {
Logger.error("Failed to clear contact for payment '$forPaymentId'", it, context = TAG)
}
}

private suspend fun updateReplacementContactIfNeeded(
activity: Activity,
normalizedKey: String,
normalizedKey: String?,
updatedAt: ULong,
) {
if (activity !is Activity.Onchain || activity.v1.doesExist || activity.v1.txType != PaymentType.SENT) return
Expand Down Expand Up @@ -422,7 +452,7 @@ class ActivityRepo @Inject constructor(
coreService.activity.getActivity(forPaymentId)
?: getOnchainActivityByTxId(forPaymentId)?.let { Activity.Onchain(it) }

private fun Activity.withContact(normalizedKey: String, updatedAt: ULong): Activity = when (this) {
private fun Activity.withContact(normalizedKey: String?, updatedAt: ULong): Activity = when (this) {
is Activity.Lightning -> Activity.Lightning(v1.copy(contact = normalizedKey, updatedAt = updatedAt))
is Activity.Onchain -> Activity.Onchain(v1.copy(contact = normalizedKey, updatedAt = updatedAt))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ internal object PrivatePaykitErrorClassifier {

private fun isNoiseStateFailure(reason: String): Boolean {
val lowercasedReason = reason.lowercase()
return listOf("decrypt", "decryption", "cipher", "noise state", "counter", "invalid tag", "bad mac")
return listOf("decrypt", "decryption", "cipher", "invalid tag", "bad mac")
.any { it in lowercasedReason }
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import to.bitkit.utils.AppError
sealed class PrivatePaykitError(message: String, cause: Throwable? = null) : AppError(message, cause) {
data object PrivateUnavailable : PrivatePaykitError("Private Paykit is not available")
data object PayloadTooLarge : PrivatePaykitError("Private Paykit payload is too large")
data object RouteHintsUnavailable : PrivatePaykitError("Reachable private Lightning endpoint is not available yet")
data object SnapshotRecipientMismatch : PrivatePaykitError("Private Paykit snapshot recipient mismatch")
data object StaleLinkState : PrivatePaykitError("Private Paykit link state changed")
class StatePersistenceFailed(cause: Throwable) : PrivatePaykitError("Failed to persist private Paykit state", cause)
Expand Down Expand Up @@ -43,12 +44,14 @@ internal data class PrivatePaykitState(
fun cacheState(
cleanupPending: Boolean,
deletedContactCleanupPendingPublicKeys: Set<String>,
profileRecoveryPending: Boolean,
) = PrivatePaykitCacheData(
contacts = contacts.mapNotNull { (publicKey, contactState) ->
(publicKey to contactState.cacheState()).takeIf { contactState.hasCacheState }
}.toMap(),
cleanupPending = cleanupPending,
deletedContactCleanupPendingPublicKeys = deletedContactCleanupPendingPublicKeys,
profileRecoveryPending = profileRecoveryPending,
)
}

Expand All @@ -65,6 +68,7 @@ internal data class ContactState(
var mainRecoveryAttemptId: String? = null,
var responderRecoveryAttemptId: String? = null,
var lastCompletedRecoveryAttemptId: String? = null,
var awaitingRecoveredRemoteEndpoints: Boolean = false,
var linkFailureCount: Int = 0,
) {
constructor(cache: PrivatePaykitContactCacheData) : this(
Expand All @@ -78,6 +82,7 @@ internal data class ContactState(
mainRecoveryAttemptId = cache.mainRecoveryAttemptId,
responderRecoveryAttemptId = cache.responderRecoveryAttemptId,
lastCompletedRecoveryAttemptId = cache.lastCompletedRecoveryAttemptId,
awaitingRecoveredRemoteEndpoints = cache.awaitingRecoveredRemoteEndpoints,
linkFailureCount = cache.linkFailureCount,
)

Expand All @@ -103,6 +108,7 @@ internal data class ContactState(
mainRecoveryAttemptId != null ||
responderRecoveryAttemptId != null ||
lastCompletedRecoveryAttemptId != null ||
awaitingRecoveredRemoteEndpoints ||
linkFailureCount != 0

fun cacheState() = PrivatePaykitContactCacheData(
Expand All @@ -116,6 +122,7 @@ internal data class ContactState(
mainRecoveryAttemptId = mainRecoveryAttemptId,
responderRecoveryAttemptId = responderRecoveryAttemptId,
lastCompletedRecoveryAttemptId = lastCompletedRecoveryAttemptId,
awaitingRecoveredRemoteEndpoints = awaitingRecoveredRemoteEndpoints,
linkFailureCount = linkFailureCount,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import java.security.MessageDigest
internal object PrivatePaykitPayloads {
private const val MAX_NOISE_PAYLOAD_BYTES = 1000
private const val PRIVATE_ENDPOINT_REMOVAL_PAYLOAD = """{"value":""}"""
private const val PRIVATE_PAYMENTS_ENVELOPE_KIND = "paykit.private_payments"
private const val PRIVATE_PAYMENTS_REFERENCE_PLACEHOLDER = "550e8400-e29b-41d4-a716-446655440000"

private val noisePayloadJson = Json(json) {
prettyPrint = false
Expand Down Expand Up @@ -51,10 +53,24 @@ internal object PrivatePaykitPayloads {

private fun isNoisePayloadWithinLimit(entries: List<StoredPaymentEntry>): Boolean {
val payload = entries.associate { it.methodId to it.endpointData }
return noisePayloadJson.encodeToString(payload).encodeToByteArray().size <= MAX_NOISE_PAYLOAD_BYTES
val envelope = PrivatePaymentsEnvelope(
version = 1,
kind = PRIVATE_PAYMENTS_ENVELOPE_KIND,
reference = PRIVATE_PAYMENTS_REFERENCE_PLACEHOLDER,
entries = payload,
)
return noisePayloadJson.encodeToString(envelope).encodeToByteArray().size <= MAX_NOISE_PAYLOAD_BYTES
}
}

@kotlinx.serialization.Serializable
private data class PrivatePaymentsEnvelope(
val version: Int,
val kind: String,
val reference: String,
val entries: Map<String, String>,
)

internal data class PrivatePaykitPayloadSelection(
val entries: List<StoredPaymentEntry>,
val droppedLightning: Boolean = false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,14 @@ internal class PrivatePaykitRecoveryStore(
return true
}

return purgePrivatePaymentStorage(reason)
}

suspend fun purgePrivatePaymentOutboxForProfileRecovery(reason: String): Boolean =
purgePrivatePaymentStorage(reason)

@Suppress("ReturnCount")
private suspend fun purgePrivatePaymentStorage(reason: String): Boolean {
val sessionSecret = keychain.loadString(Keychain.Key.PAYKIT_SESSION.name) ?: return false
if (sessionSecret.isBlank()) return false
val rootPath = PRIVATE_STORAGE_ROOT_PATH.removeSuffix("/")
Expand Down
Loading
Loading