From 8bd35049fe7a9a7278dd9dd3fc469f0ffe8dbab6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 24 Sep 2026 16:48:43 +0200 Subject: [PATCH 01/51] chore: build paykit from the local allowances stack --- app/src/main/java/to/bitkit/services/PaykitSdkService.kt | 3 ++- settings.gradle.kts | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index c2a5fde8fa..560d6a6391 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -780,6 +780,7 @@ class PaykitSdkService @Inject constructor( paymentEndpointIdentifier: String, proofJson: String, billingPeriod: PaykitBillingPeriod? = null, + allowanceId: String? = null, ): PaymentRequestRecord { isSetup.await() return operationMutex.withLock { @@ -791,7 +792,7 @@ class PaykitSdkService @Inject constructor( PaymentProofSubmission( billingPeriod = billingPeriod?.sdkValue, paymentEndpointIdentifier = paymentEndpointIdentifier, - allowanceId = null, + allowanceId = allowanceId, conversionQuoteId = null, proof = PrivateJsonObject(proofJson), ), diff --git a/settings.gradle.kts b/settings.gradle.kts index 9790eeb9eb..d017b43bb1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -36,6 +36,11 @@ plugins { dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { + // Allowances demo: Paykit 0.1.0-rc56 built locally from pubky/paykit-rs 6561359 (#161 merged), only for this branch. + exclusiveContent { + forRepository { maven { url = uri(rootDir.resolve("../maven")) } } + filter { includeModule("com.synonym", "paykit-android") } + } mavenLocal() google() mavenCentral() From df25bea12ef069011bc3ff56b092c6b63d1c0d16 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 24 Sep 2026 16:48:43 +0200 Subject: [PATCH 02/51] chore: let an explicit e2e homegate url win on every backend --- app/build.gradle.kts | 2 ++ app/src/main/java/to/bitkit/env/Env.kt | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 955d5e477d..bfbce68237 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -80,6 +80,7 @@ val e2eLocalHostEnv = providers.environmentVariable("E2E_LOCAL_HOST").orElse("10 val e2eHomegateUrlEnv = providers.environmentVariable("E2E_HOMEGATE_URL") .orElse(e2eLocalHostEnv.map { "http://$it:6288" }) val e2eHomeserverPubkyEnv = providers.environmentVariable("E2E_HOMESERVER_PUBKY").orElse("") +val e2eHomegateOverrideEnv = providers.environmentVariable("E2E_HOMEGATE_URL").orElse("") val geoEnv = envFlag("GEO", default = true) val paykitUiDisabledEnv = envFlag("PAYKIT_UI_DISABLED", default = false) val trezorBridgeEnv = localProp("TREZOR_BRIDGE").map { it.toBoolean().toString() }.orElse("false") @@ -325,6 +326,7 @@ androidComponents { buildConfigFields.put("E2E_LOCAL_HOST", e2eLocalHostEnv.stringField()) buildConfigFields.put("E2E_HOMEGATE_URL", e2eHomegateUrlEnv.stringField()) buildConfigFields.put("E2E_HOMESERVER_PUBKY", e2eHomeserverPubkyEnv.stringField()) + buildConfigFields.put("E2E_HOMEGATE_OVERRIDE", e2eHomegateOverrideEnv.stringField()) buildConfigFields.put("TREZOR_BRIDGE", trezorBridgeEnv.booleanField()) buildConfigFields.put("TREZOR_BRIDGE_URL", trezorBridgeUrlEnv.stringField()) buildConfigFields.put("GEO", geoEnv.booleanField()) diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index dd5d4ab8a7..83e8360cc0 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -169,6 +169,10 @@ internal object Env { val homegateUrl: String get() { + // An explicit E2E_HOMEGATE_URL wins on every backend, as on iOS: demos run their own homegate. + if (isE2eTest && BuildConfig.E2E_HOMEGATE_OVERRIDE.isNotBlank()) { + return BuildConfig.E2E_HOMEGATE_OVERRIDE + } if (isLocalE2eBackend) { return e2eHomegateUrl } From eed528053de033cca2a05f49a1c9ac843870b214 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 24 Sep 2026 17:01:52 +0200 Subject: [PATCH 03/51] feat: add the allowance model, sdk calls and repo contract --- .../java/to/bitkit/data/keychain/Keychain.kt | 1 + .../to/bitkit/repositories/PaykitAllowance.kt | 267 ++++++++++++++++++ .../repositories/PaykitAllowanceRepo.kt | 77 +++++ .../to/bitkit/services/PaykitSdkService.kt | 198 +++++++++++++ app/src/main/java/to/bitkit/ui/ContentView.kt | 4 +- .../java/to/bitkit/ui/components/SheetHost.kt | 7 + 6 files changed, 553 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/to/bitkit/repositories/PaykitAllowance.kt create mode 100644 app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt diff --git a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt index 2a41579d54..2520d7642b 100644 --- a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt +++ b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt @@ -237,6 +237,7 @@ class Keychain @Inject constructor( PAYKIT_PENDING_BACKUP_RESTORE, PAYKIT_PENDING_PAYMENT_PROOFS, PAYKIT_PRESENTED_PAYMENT_REQUESTS, + PAYKIT_ALLOWANCE_STATE, PUBKY_SECRET_KEY, SHARED_PUBKY_SOURCE, } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowance.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowance.kt new file mode 100644 index 0000000000..6fca0eeca3 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowance.kt @@ -0,0 +1,267 @@ +package to.bitkit.repositories + +import androidx.compose.runtime.Immutable +import com.synonym.paykit.AllowanceAccountingHistory +import com.synonym.paykit.AllowanceAmountRange +import com.synonym.paykit.AllowanceLifecycleState +import com.synonym.paykit.AllowanceLocalRole +import com.synonym.paykit.AllowancePeriod +import com.synonym.paykit.AllowancePeriodLimit +import com.synonym.paykit.AllowanceRecord +import com.synonym.paykit.AllowanceTerms +import com.synonym.paykit.PaymentExecutionMode +import com.synonym.paykit.PaymentExecutionStatus +import kotlinx.serialization.Serializable +import java.math.BigDecimal +import java.time.ZoneOffset +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit +import kotlin.time.Instant +import kotlin.time.toJavaInstant +import kotlin.time.toKotlinInstant + +/** + * An Allowance between this wallet and one contact link, built from the SDK record. + * Eligibility always runs on real time. + */ +@Immutable +data class PaykitAllowance( + val id: Id, + val role: Role, + val lifecycleState: AllowanceLifecycleState, + val isProposedByMe: Boolean, + val perPaymentMaxSats: ULong?, + val monthlyLimitSats: ULong?, + val monthlyAnchor: Instant?, + val activeFrom: Instant? = null, + val expiresAt: Instant? = null, + val allowedPaymentEndpointIdentifiers: List? = null, + val lastEventAt: Instant? = null, +) { + @Serializable + data class Id( + val counterparty: String, + val counterpartyReceiverPath: String, + val allowanceId: String, + ) + + @Serializable + enum class Role { ALLOWER, ALLOWEE } + + enum class Status { + /** Sent by this wallet; the other side has not answered yet. */ + AWAITING_ANSWER, + + /** Received; this wallet must accept or decline. */ + AWAITING_MY_ANSWER, + ACTIVE, + NOT_YET_ACTIVE, + EXPIRED, + DECLINED, + ENDED, + CONFLICTED, + } + + val counterparty: String get() = id.counterparty + val counterpartyReceiverPath: String get() = id.counterpartyReceiverPath + val allowanceId: String get() = id.allowanceId + + /** The payer side: this wallet pays the counterparty's requests automatically. */ + val isAllower: Boolean get() = role == Role.ALLOWER + + val canEnd: Boolean + get() = when (lifecycleState) { + AllowanceLifecycleState.ACCEPTED -> true + AllowanceLifecycleState.PROPOSED -> isProposedByMe + else -> false + } + + val isAnswerable: Boolean get() = lifecycleState == AllowanceLifecycleState.PROPOSED && !isProposedByMe + + fun status(now: Instant): Status = when (lifecycleState) { + AllowanceLifecycleState.PROPOSED -> if (isProposedByMe) Status.AWAITING_ANSWER else Status.AWAITING_MY_ANSWER + AllowanceLifecycleState.ACCEPTED -> when { + expiresAt != null && now >= expiresAt -> Status.EXPIRED + activeFrom != null && now < activeFrom -> Status.NOT_YET_ACTIVE + else -> Status.ACTIVE + } + AllowanceLifecycleState.REJECTED -> Status.DECLINED + AllowanceLifecycleState.ENDED -> Status.ENDED + AllowanceLifecycleState.CONFLICTED, AllowanceLifecycleState.UNKNOWN -> Status.CONFLICTED + } + + companion object { + fun from(record: AllowanceRecord): PaykitAllowance? { + val role = when (record.localRole) { + AllowanceLocalRole.ALLOWER -> Role.ALLOWER + AllowanceLocalRole.ALLOWEE -> Role.ALLOWEE + else -> return null + } + val terms = record.terms ?: return null + if (terms.asset() != PaykitIssuerInterop.BITCOIN_ASSET) return null + val monthly = terms.periodLimits().firstOrNull { isMonthly(it.period()) } + return PaykitAllowance( + id = Id(record.counterparty, record.counterpartyReceiverPath, record.allowanceId), + role = role, + lifecycleState = record.state, + isProposedByMe = record.proposalOutboundMessageId != null, + perPaymentMaxSats = terms.perPaymentAmount()?.let { satsFromBitcoinAmount(it.maximum()) }, + monthlyLimitSats = monthly?.amountLimit()?.let(::satsFromBitcoinAmount), + monthlyAnchor = monthly?.period()?.anchor()?.let(PaykitAllowanceTime::parse), + activeFrom = terms.activeFrom()?.let(PaykitAllowanceTime::parse), + expiresAt = terms.expiresAt()?.let(PaykitAllowanceTime::parse), + allowedPaymentEndpointIdentifiers = terms.allowedPaymentEndpointIdentifiers(), + lastEventAt = record.lastEventAt?.let(PaykitAllowanceTime::parse), + ) + } + + fun isMonthly(period: AllowancePeriod): Boolean = + period.kind() == "anchored" && period.every() == 1uL && period.unit() == "month" + + /** BTC decimal string to sats; unlike [toPaykitSats] a zero amount is valid here. */ + fun satsFromBitcoinAmount(amount: String): ULong? { + if (amount.split('.').all { part -> part.all { it == '0' } }) return 0uL + return amount.toPaykitSats() + } + } +} + +/** One grant as the user sees it: the same limits proposed on each of a contact's supported links. */ +@Immutable +data class PaykitAllowanceEntry( + val id: String, + val allowances: List, + val limits: PaykitAllowanceLimits?, +) { + val primary: PaykitAllowance + get() = allowances.firstOrNull { it.lifecycleState == AllowanceLifecycleState.ACCEPTED } ?: allowances.first() + val counterparty: String get() = primary.counterparty + val role: PaykitAllowance.Role get() = primary.role + val perPaymentMaxSats: ULong? get() = primary.perPaymentMaxSats + val monthlyLimitSats: ULong? get() = primary.monthlyLimitSats + val canEnd: Boolean get() = allowances.any { it.canEnd } + val isAnswerable: Boolean get() = allowances.any { it.isAnswerable } + + fun status(now: Instant): PaykitAllowance.Status = primary.status(now) +} + +/** Limits picked in USD on the Set Allowance sheet, converted once to whole-sat BTC terms. */ +@Serializable +@Immutable +data class PaykitAllowanceLimits( + val perPaymentUsd: Int, + val monthlyUsd: Int, + val perPaymentSats: ULong, + val monthlySats: ULong, +) { + /** Terms Bitkit proposes: per-payment range 0...max, an anchored UTC calendar month, and the endpoints Bitkit pays. */ + fun terms(monthAnchor: Instant, allowedPaymentEndpointIdentifiers: List): AllowanceTerms { + val perPayment = AllowanceAmountRange(minimum = "0", maximum = perPaymentSats.toBitcoinDecimal()) + val month = AllowancePeriod( + kind = "anchored", + every = 1uL, + unit = "month", + anchor = PaykitAllowanceTime.format(monthAnchor), + ) + val monthly = AllowancePeriodLimit( + amountLimit = monthlySats.toBitcoinDecimal(), + paymentCountLimit = null, + period = month, + ) + return AllowanceTerms( + asset = PaykitIssuerInterop.BITCOIN_ASSET, + perPaymentAmount = perPayment, + periodLimits = listOf(monthly), + lifetimeAmountLimit = null, + activeFrom = null, + expiresAt = null, + allowedPaymentEndpointIdentifiers = allowedPaymentEndpointIdentifiers, + ) + } + + companion object { + /** Slider stops on the Set Allowance sheet, in whole US dollars. */ + val PER_PAYMENT_STOPS_USD = listOf(1, 5, 10, 20, 50) + + /** Slider stops on the Set Allowance sheet, in whole US dollars. */ + val MONTHLY_STOPS_USD = listOf(10, 50, 100, 200, 500) + } +} + +internal fun ULong.toBitcoinDecimal(): String = + BigDecimal(toString()).movePointLeft(8).stripTrailingZeros().toPlainString() + +object PaykitAllowanceTime { + private val utc = ZoneOffset.UTC + + fun format(instant: Instant): String = + DateTimeFormatter.ISO_INSTANT.format(instant.toJavaInstant().truncatedTo(ChronoUnit.MILLIS)) + + fun parse(value: String): Instant? = runCatching { Instant.parse(value) }.getOrNull() + + /** First instant of the UTC calendar month that contains [instant]. */ + fun monthStart(containing: Instant): Instant = ZonedDateTime.ofInstant(containing.toJavaInstant(), utc) + .withDayOfMonth(1) + .truncatedTo(ChronoUnit.DAYS) + .toInstant() + .toKotlinInstant() + + /** + * The anchored monthly window `[start, end)` that contains [instant]. Anchors on a day that a short month lacks + * clamp to that month's last day, as the spec's anchored-period arithmetic does. + */ + fun monthlyWindow(anchor: Instant, containing: Instant): Pair { + val anchorTime = ZonedDateTime.ofInstant(anchor.toJavaInstant(), utc) + val dateTime = ZonedDateTime.ofInstant(containing.toJavaInstant(), utc) + // Every boundary counts from the original anchor, so a clamped February never shortens later months. + val boundary = { index: Long -> anchorTime.plusMonths(index).toInstant().toKotlinInstant() } + var index = (dateTime.year - anchorTime.year) * 12L + dateTime.monthValue - anchorTime.monthValue + while (boundary(index) > containing) index-- + while (boundary(index + 1) <= containing) index++ + return boundary(index) to boundary(index + 1) + } +} + +/** + * Wallet-side capacity preflight. The SDK checks capacity only after automatic Acceptance, so Bitkit sums this + * Allowance's live automatic attempts in the current month first and keeps an over-cap request on the manual flow. + */ +object PaykitAllowanceCapacity { + data class Attempt( + val allowanceId: String, + val amountSats: ULong, + val admittedAt: Instant, + val isLive: Boolean, + ) + + fun usedSats(allowanceId: String, attempts: List, anchor: Instant, now: Instant): ULong { + val (start, end) = PaykitAllowanceTime.monthlyWindow(anchor, now) + return attempts + .filter { it.allowanceId == allowanceId && it.isLive && it.admittedAt >= start && it.admittedAt < end } + .fold(0uL) { total, attempt -> total + attempt.amountSats } + } + + fun fits(amountSats: ULong, allowance: PaykitAllowance, attempts: List, now: Instant): Boolean { + val perPaymentMax = allowance.perPaymentMaxSats + if (perPaymentMax != null && amountSats > perPaymentMax) return false + val monthlyLimit = allowance.monthlyLimitSats ?: return true + val anchor = allowance.monthlyAnchor ?: return true + return usedSats(allowance.allowanceId, attempts, anchor, now) + amountSats <= monthlyLimit + } + + fun attempts(history: AllowanceAccountingHistory): List = history.occurrences + .flatMap { it.attempts } + .mapNotNull { attempt -> + if (attempt.mode != PaymentExecutionMode.AUTOMATIC) return@mapNotNull null + val allowanceId = attempt.allowanceId ?: return@mapNotNull null + val admittedAt = PaykitAllowanceTime.parse(attempt.admittedAt) ?: return@mapNotNull null + val amountSats = PaykitAllowance.satsFromBitcoinAmount(attempt.amount.value()) ?: return@mapNotNull null + Attempt( + allowanceId = allowanceId, + amountSats = amountSats, + admittedAt = admittedAt, + isLive = attempt.status != PaymentExecutionStatus.FAILED, + ) + } +} diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt new file mode 100644 index 0000000000..0a38837efa --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt @@ -0,0 +1,77 @@ +package to.bitkit.repositories + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +// CONTRACT (allowances port): the public API below is fixed; bodies are filled by the core worker. + +sealed interface PaykitAllowanceEvent { + data class PaidAutomatically(val counterparty: String, val amountSats: ULong, val paymentId: String) : PaykitAllowanceEvent + data class LimitReached(val counterparty: String, val amountSats: ULong) : PaykitAllowanceEvent + data object LedgerChanged : PaykitAllowanceEvent +} + +enum class PaykitAllowanceAutoPayResult { NOT_COVERED, MANUAL, STARTED, COMPLETED } + +@Singleton +class PaykitAllowanceRepo @Inject constructor() { + private val _entries = MutableStateFlow>(emptyList()) + + /** Grants grouped across a contact's links, newest first. */ + val entries: StateFlow> = _entries.asStateFlow() + + private val _autoPaidSats = MutableStateFlow>(emptyMap()) + + /** Sats paid automatically per entry id, for "Paid so far". */ + val autoPaidSats: StateFlow> = _autoPaidSats.asStateFlow() + + private val _events = MutableSharedFlow(extraBufferCapacity = 16) + val events: SharedFlow = _events.asSharedFlow() + + fun entry(id: String): PaykitAllowanceEntry? = _entries.value.firstOrNull { it.id == id } + + suspend fun activate(identity: String?): Unit = TODO() + + fun deactivate(): Unit = TODO() + + suspend fun refresh(): Result = TODO() + + suspend fun propose(contactPublicKey: String, limits: PaykitAllowanceLimits): Result = TODO() + + suspend fun accept(entryId: String): Result = TODO() + + suspend fun reject(entryId: String): Result = TODO() + + suspend fun end(entryId: String): Result = TODO() + + fun proposalForPresentation(): PaykitAllowanceEntry? = TODO() + + suspend fun markProposalPresented(entryId: String): Unit = TODO() + + /** Pays covered incoming requests headlessly; returns true when any request was handled. */ + suspend fun processIncomingRequests(requests: List): Boolean = TODO() + + /** True while a request is covered by an active allowance or is being paid automatically: keep it off the Send sheet. */ + suspend fun isAutomaticallyHandling(request: PaykitPaymentRequest): Boolean = TODO() + + /** Request ids paid automatically, for the "Auto-paid" tag in payment history. */ + fun isAutoPaid(id: PaykitPaymentRequestId): Boolean = TODO() + + /** Reports a manual payment of a request to the allowance ledger; returns the attempt id or null when no ledger applies. */ + suspend fun beginManualPayment(request: PaykitPaymentRequest, paymentEndpointIdentifier: String): Result = TODO() + + suspend fun manualLightningPaymentSent(attemptId: String, paymentHash: String): Unit = TODO() + + /** [succeeded] null = outcome unknown (pending). */ + suspend fun finishManualPayment(attemptId: String, succeeded: Boolean?, transactionId: String? = null): Unit = TODO() + + suspend fun lightningPaymentSettled(paymentHash: String, succeeded: Boolean): Unit = TODO() + + suspend fun recover(): Unit = TODO() +} diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 560d6a6391..4730859faa 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -2,6 +2,15 @@ package to.bitkit.services import android.content.Context import com.synonym.bitkitcore.mnemonicToSeed +import com.synonym.paykit.AllowanceAccountingReconciliation +import com.synonym.paykit.AllowanceAccountingState +import com.synonym.paykit.AllowanceAssociationRecord +import com.synonym.paykit.AllowanceCandidate +import com.synonym.paykit.AllowanceFilter +import com.synonym.paykit.AllowanceLocalRole +import com.synonym.paykit.AllowanceRecord +import com.synonym.paykit.AllowanceSelectionInput +import com.synonym.paykit.AllowanceTerms import com.synonym.paykit.ContactProfileResolution import com.synonym.paykit.ContactRecord import com.synonym.paykit.ContactUpdate @@ -11,6 +20,7 @@ import com.synonym.paykit.IdentityStatus import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState import com.synonym.paykit.OutboundPrivateCounterpartySendReport +import com.synonym.paykit.OutboundPrivateSendReport import com.synonym.paykit.PaykitAndroid import com.synonym.paykit.PaykitException import com.synonym.paykit.PaykitProfile @@ -20,6 +30,12 @@ import com.synonym.paykit.PaykitReceiverMarker import com.synonym.paykit.PaykitSdk import com.synonym.paykit.PaykitSdkDefaults import com.synonym.paykit.PaymentAmountContext +import com.synonym.paykit.PaymentAttemptDecision +import com.synonym.paykit.PaymentAttemptRecord +import com.synonym.paykit.PaymentExecutionChecks +import com.synonym.paykit.PaymentOccurrence +import com.synonym.paykit.PaymentOccurrenceRecord +import com.synonym.paykit.PaymentOutcomeReport import com.synonym.paykit.PaymentPayload import com.synonym.paykit.PaymentProofSubmission import com.synonym.paykit.PaymentReference @@ -27,6 +43,7 @@ import com.synonym.paykit.PaymentRequestAmount import com.synonym.paykit.PaymentRequestFilter import com.synonym.paykit.PaymentRequestRecord import com.synonym.paykit.PaymentRequestRecurrence +import com.synonym.paykit.PaymentRequestScope import com.synonym.paykit.PaymentRequestTerms import com.synonym.paykit.PaymentTarget import com.synonym.paykit.PrivateContactPaymentResolution @@ -42,6 +59,7 @@ import com.synonym.paykit.PrivateReceivingDetail import com.synonym.paykit.PrivateReceivingDetailReservationResponse import com.synonym.paykit.PrivateReceivingDetailReservationResponseKind import com.synonym.paykit.PrivateStreamCounterpartyIntakeReport +import com.synonym.paykit.PrivateStreamIntakeReport import com.synonym.paykit.PubkyAuthCompanionClaim import com.synonym.paykit.PubkyClientConfig import com.synonym.paykit.PubkyLocalSecretKey @@ -801,6 +819,186 @@ class PaykitSdkService @Inject constructor( } } + suspend fun listAllowances(filter: AllowanceFilter): List { + isSetup.await() + return operationMutex.withLock { + handle().listAllowances(filter) + } + } + + suspend fun proposeAllowance( + counterparty: String, + counterpartyReceiverPath: String, + localRole: AllowanceLocalRole, + terms: AllowanceTerms, + ): AllowanceRecord { + isSetup.await() + return operationMutex.withLock { + withStateRevisionTracking { handle -> + handle.proposeAllowance(counterparty, counterpartyReceiverPath, localRole, terms) + } + } + } + + suspend fun acceptAllowance( + counterparty: String, + counterpartyReceiverPath: String, + allowanceId: String, + ): AllowanceRecord { + isSetup.await() + return operationMutex.withLock { + withStateRevisionTracking { handle -> + handle.acceptAllowance(counterparty, counterpartyReceiverPath, allowanceId) + } + } + } + + suspend fun rejectAllowance( + counterparty: String, + counterpartyReceiverPath: String, + allowanceId: String, + ): AllowanceRecord { + isSetup.await() + return operationMutex.withLock { + withStateRevisionTracking { handle -> + handle.rejectAllowance(counterparty, counterpartyReceiverPath, allowanceId) + } + } + } + + suspend fun endAllowance( + counterparty: String, + counterpartyReceiverPath: String, + allowanceId: String, + ): AllowanceRecord { + isSetup.await() + return operationMutex.withLock { + withStateRevisionTracking { handle -> + handle.endAllowance(counterparty, counterpartyReceiverPath, allowanceId) + } + } + } + + suspend fun receivePrivateMessages( + counterparty: String, + counterpartyReceiverPath: String, + ): PrivateStreamIntakeReport { + isSetup.await() + return operationMutex.withLock { + withStateRevisionTracking { handle -> + handle.receivePrivateMessages(counterparty, counterpartyReceiverPath) + } + } + } + + suspend fun processOutboundPrivateMessages( + counterparty: String, + counterpartyReceiverPath: String, + ): OutboundPrivateSendReport { + isSetup.await() + return operationMutex.withLock { + withStateRevisionTracking { handle -> + handle.processOutboundPrivateMessages(counterparty, counterpartyReceiverPath) + } + } + } + + suspend fun allowanceAccountingState(): AllowanceAccountingState? { + isSetup.await() + return operationMutex.withLock { + handle().allowanceAccountingState() + } + } + + suspend fun reconcileAllowanceAccounting( + reconciliation: AllowanceAccountingReconciliation, + ): AllowanceAccountingState { + isSetup.await() + return operationMutex.withLock { + withStateRevisionTracking { handle -> + handle.reconcileAllowanceAccounting(reconciliation) + } + } + } + + suspend fun evaluateAllowanceCandidates( + scope: PaymentRequestScope, + trustedTime: String, + ): List { + isSetup.await() + return operationMutex.withLock { + handle().evaluateAllowanceCandidates(scope, trustedTime) + } + } + + suspend fun acceptPaymentRequestAutomatically( + scope: PaymentRequestScope, + selection: AllowanceSelectionInput, + checks: PaymentExecutionChecks, + ): AllowanceAssociationRecord { + isSetup.await() + return operationMutex.withLock { + withStateRevisionTracking { handle -> + handle.acceptPaymentRequestAutomatically(scope, selection, checks) + } + } + } + + suspend fun reserveAutomaticPayment( + occurrence: PaymentOccurrence, + expectedAssociationRevision: ULong, + checks: PaymentExecutionChecks, + ): PaymentAttemptDecision { + isSetup.await() + return operationMutex.withLock { + withStateRevisionTracking { handle -> + handle.reserveAutomaticPayment(occurrence, expectedAssociationRevision, checks) + } + } + } + + suspend fun reserveManualPayment( + occurrence: PaymentOccurrence, + checks: PaymentExecutionChecks, + ): PaymentAttemptDecision { + isSetup.await() + return operationMutex.withLock { + withStateRevisionTracking { handle -> + handle.reserveManualPayment(occurrence, checks) + } + } + } + + suspend fun beginPaymentExecution( + attemptId: String, + checks: PaymentExecutionChecks, + ): PaymentAttemptDecision { + isSetup.await() + return operationMutex.withLock { + withStateRevisionTracking { handle -> + handle.beginPaymentExecution(attemptId, checks) + } + } + } + + suspend fun recordPaymentOutcome(report: PaymentOutcomeReport): PaymentAttemptRecord { + isSetup.await() + return operationMutex.withLock { + withStateRevisionTracking { handle -> + handle.recordPaymentOutcome(report) + } + } + } + + suspend fun markPaymentManualOnly(occurrence: PaymentOccurrence): PaymentOccurrenceRecord { + isSetup.await() + return operationMutex.withLock { + withStateRevisionTracking { handle -> + handle.markPaymentManualOnly(occurrence) + } + } + } + suspend fun rejectPaymentRequest( counterparty: String, counterpartyReceiverPath: String, diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index c72fc20716..9cbf4956b7 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -526,7 +526,7 @@ fun ContentView( is Sheet.Widgets -> Colors.Gray7 // Meet the top of the subscription sheets' own gradient, so the grabber strip // does not sit a shade darker than the content right below it. - is Sheet.Subscription -> Colors.Gray6 + is Sheet.Subscription, is Sheet.Allowance -> Colors.Gray6 else -> DefaultSheetContainerColor }, sheets = { @@ -578,6 +578,8 @@ fun ContentView( is Sheet.Subscription -> SubscriptionSheet(appViewModel, sheet.route) + is Sheet.Allowance -> Unit // CONTRACT: UI worker renders AllowanceSheet(sheet.route) here + is Sheet.ActivityDateRangeSelector -> DateRangeSelectorSheet() is Sheet.ActivityTagSelector -> TagSelectorSheet() is Sheet.Pin -> PinSheet(sheet, appViewModel) diff --git a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt index 7f284dd301..5241ac01b7 100644 --- a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt +++ b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt @@ -54,6 +54,12 @@ enum class SheetHandlePlacement { ContentOverlay, } +sealed interface AllowanceRoute { + data object Set : AllowanceRoute + data class Review(val entryId: String) : AllowanceRoute + data class Details(val entryId: String) : AllowanceRoute +} + sealed interface SubscriptionRoute { data class Review(val id: PaykitSubscriptionId) : SubscriptionRoute data class Success(val id: PaykitSubscriptionId) : SubscriptionRoute @@ -75,6 +81,7 @@ sealed interface Sheet { data object PaymentRequests : Sheet data object CreateSubscription : Sheet data class Subscription(val route: SubscriptionRoute) : Sheet + data class Allowance(val route: AllowanceRoute) : Sheet data class Pin(val route: PinRoute = PinRoute.Prompt()) : Sheet data object ChangePin : Sheet data object DisablePin : Sheet From 1efc74969ff33d3fb7c9497f50e73fa9e5658b14 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 24 Sep 2026 17:11:51 +0200 Subject: [PATCH 04/51] feat: keep rc55 paykit state readable across the allowance sdk --- .../to/bitkit/services/PaykitSdkService.kt | 151 +++++++++-- .../services/PaykitSdkStateLayoutTest.kt | 252 ++++++++++++++++++ 2 files changed, 383 insertions(+), 20 deletions(-) create mode 100644 app/src/test/java/to/bitkit/services/PaykitSdkStateLayoutTest.kt diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 4730859faa..39dfdc04d5 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -255,6 +255,7 @@ class PaykitSdkService @Inject constructor( PaykitAndroid.initializeOrThrow(context) launch { republishIdentityIfNeeded() } operationMutex.withLock { + stateStore.resolveLegacyLayoutIfNeeded(::paykitProbeSdk) var handle = handle() try { handle.initialize() @@ -1356,40 +1357,150 @@ internal fun validatedApprovalClientId(requestClientId: String, approvedClientId return requestClientId } -private class PaykitSdkStateBlobStore( +/** + * Paykit #161 added `allowance_accounting` as the first field of the SDK state without bumping the state blob version, + * so the two layouts differ by one Option tag right after the version byte. Bitkit keeps the rc55 layout on disk while + * accounting is empty, so an rc55 build can still read the wallet, and records the stored layout in the revision. + */ +internal enum class PaykitSdkStateLayout(private val suffix: String) { + RC55("rc55"), + ALLOWANCES("alw"), + ; + + companion object { + /** Postcard encoding of state blob version 1, the first byte of every SDK state blob. */ + private const val VERSION_BYTE: Byte = 0x01 + + /** Postcard tag of an empty `allowance_accounting` Option in the #161 layout. */ + private const val NONE_TAG: Byte = 0x00 + + fun stored(sdkBytes: ByteArray): Pair { + val isEmptyAccounting = sdkBytes.size >= 2 && sdkBytes[0] == VERSION_BYTE && sdkBytes[1] == NONE_TAG + if (!isEmptyAccounting) return ALLOWANCES to sdkBytes + return RC55 to byteArrayOf(VERSION_BYTE) + sdkBytes.copyOfRange(2, sdkBytes.size) + } + + fun fromRevision(revision: String): PaykitSdkStateLayout? = + entries.firstOrNull { revision.endsWith(".${it.suffix}") } + } + + fun sdkBytes(storedBytes: ByteArray): ByteArray { + if (this != RC55 || storedBytes.firstOrNull() != VERSION_BYTE) return storedBytes + return byteArrayOf(VERSION_BYTE, NONE_TAG) + storedBytes.copyOfRange(1, storedBytes.size) + } + + fun revision(base: String) = "$base.$suffix" +} + +internal class PaykitSdkStateBlobStore( private val keychain: Keychain, + private val decodeSnapshot: (ByteArray) -> SdkStateBlobSnapshot = ::decodeSdkStateBlobSnapshot, + private val encodeSnapshot: (SdkStateBlobSnapshot) -> ByteArray = ::encodeSdkStateBlobSnapshot, + private val newBlob: (ByteArray) -> SdkStateBlob = ::SdkStateBlob, ) : SdkStateBlobStore { + companion object { + private const val TAG = "PaykitSdkStateBlobStore" + } + private val lock = Any() override fun loadStateBlob(): SdkStateBlobSnapshot? = synchronized(lock) { - val data = keychain.accessBlocking { - load(Keychain.Key.PAYKIT_SDK_STATE.name) - } ?: return@synchronized null - decodeSdkStateBlobSnapshot(data) + val snapshot = loadSnapshot() ?: return@synchronized null + val layout = PaykitSdkStateLayout.fromRevision(snapshot.revision) ?: return@synchronized snapshot + snapshot.copy(blob = newBlob(layout.sdkBytes(snapshot.blob.exportBytes()))) } override fun saveStateBlobAtomically( blob: SdkStateBlob, expectedRevision: String?, ): String = synchronized(lock) { - val currentRevision = keychain.accessBlocking { - load(Keychain.Key.PAYKIT_SDK_STATE.name) - } - ?.let { decodeSdkStateBlobSnapshot(it).revision } - if (currentRevision != expectedRevision) { - throw PaykitException.Storage( - code = "revision_conflict", - context = "SDK state revision changed", - ) - } + if (loadSnapshot()?.revision != expectedRevision) throw revisionConflict() - val nextRevision = UUID.randomUUID().toString() - val snapshot = SdkStateBlobSnapshot(blob = blob, revision = nextRevision) - keychain.accessBlocking { - upsert(Keychain.Key.PAYKIT_SDK_STATE.name, encodeSdkStateBlobSnapshot(snapshot)) - } + val (layout, bytes) = PaykitSdkStateLayout.stored(blob.exportBytes()) + val nextRevision = layout.revision(UUID.randomUUID().toString()) + saveSnapshot(SdkStateBlobSnapshot(blob = newBlob(bytes), revision = nextRevision)) nextRevision } + + /** + * Records the layout of a state blob saved before Bitkit marked layouts, which may come from an rc55 build. The SDK + * itself tells which layout decodes: a throwaway instance reads each candidate from memory. + */ + suspend fun resolveLegacyLayoutIfNeeded(probeSdk: (ByteArray) -> PaykitSdk) { + val legacy = unmarkedSnapshot() ?: return + val bytes = legacy.blob.exportBytes() + val decoded = listOf(PaykitSdkStateLayout.ALLOWANCES, PaykitSdkStateLayout.RC55).mapNotNull { layout -> + runSuspendCatching { + probeSdk(layout.sdkBytes(bytes)).use { + it.allowanceAccountingState() + layout to (runSuspendCatching { it.identityStatus()?.publicKey }.getOrNull() != null) + } + }.getOrNull() + } + // Postcard ignores trailing bytes, so a wrong layout can occasionally parse; the one holding the identity wins. + val chosen = decoded.firstOrNull { it.second }?.first ?: decoded.firstOrNull()?.first + if (chosen == null) { + Logger.error("Found no known layout for the stored Paykit state", context = TAG) + return + } + runCatching { markLayout(chosen, legacy.revision) } + .onSuccess { + Logger.info( + "Resolved the stored Paykit state layout as '$chosen' ('${decoded.size}' candidates decoded)", + context = TAG, + ) + } + .onFailure { Logger.warn("Failed to record the Paykit state layout", it, context = TAG) } + } + + private fun unmarkedSnapshot(): SdkStateBlobSnapshot? = synchronized(lock) { + runCatching { loadSnapshot() } + .onFailure { Logger.warn("Failed to read the stored Paykit state layout", it, context = TAG) } + .getOrNull() + ?.takeIf { PaykitSdkStateLayout.fromRevision(it.revision) == null } + } + + private fun markLayout(layout: PaykitSdkStateLayout, expectedRevision: String) = synchronized(lock) { + val snapshot = loadSnapshot() ?: return@synchronized + if (snapshot.revision != expectedRevision) throw revisionConflict() + saveSnapshot(snapshot.copy(revision = layout.revision(snapshot.revision))) + } + + private fun loadSnapshot(): SdkStateBlobSnapshot? = + keychain.accessBlocking { load(Keychain.Key.PAYKIT_SDK_STATE.name) }?.let(decodeSnapshot) + + private fun saveSnapshot(snapshot: SdkStateBlobSnapshot) { + val data = encodeSnapshot(snapshot) + keychain.accessBlocking { upsert(Keychain.Key.PAYKIT_SDK_STATE.name, data) } + } + + private fun revisionConflict() = PaykitException.Storage( + code = "revision_conflict", + context = "SDK state revision changed", + ) +} + +private fun paykitProbeSdk(sdkBytes: ByteArray) = PaykitSdk( + stateStore = PaykitSdkProbeStateStore(sdkBytes), + sessionProvider = PaykitSdkProbeSessionProvider, + config = paykitSdkConfig(), +) + +private class PaykitSdkProbeStateStore( + private val sdkBytes: ByteArray, +) : SdkStateBlobStore { + override fun loadStateBlob() = SdkStateBlobSnapshot(blob = SdkStateBlob(sdkBytes), revision = "probe") + + override fun saveStateBlobAtomically(blob: SdkStateBlob, expectedRevision: String?): String = + throw PaykitException.Storage(code = "read_only", context = "Paykit state layout probe is read-only") +} + +private object PaykitSdkProbeSessionProvider : SdkPubkySessionProvider { + override fun loadSessionAccess(): PubkySessionAccess? = null + + override fun publicStorageAvailable() = false + + override fun clearSessionAccess() = Unit } internal class PaykitSdkSessionProvider( diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkStateLayoutTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkStateLayoutTest.kt new file mode 100644 index 0000000000..5a05a180e7 --- /dev/null +++ b/app/src/test/java/to/bitkit/services/PaykitSdkStateLayoutTest.kt @@ -0,0 +1,252 @@ +package to.bitkit.services + +import com.synonym.paykit.IdentityStatus +import com.synonym.paykit.PaykitException +import com.synonym.paykit.PaykitSdk +import com.synonym.paykit.SdkStateBlob +import com.synonym.paykit.SdkStateBlobSnapshot +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.data.keychain.Keychain +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PaykitSdkStateLayoutTest : BaseUnitTest() { + private companion object { + val STATE_KEY = Keychain.Key.PAYKIT_SDK_STATE.name + } + + private val rc55Bytes = byteArrayOf(1, 7, 8) + private val emptyAccountingBytes = byteArrayOf(1, 0, 7, 8) + private val accountingBytes = byteArrayOf(1, 1, 9) + + private val keychain = mock() + private val blocking = mock() + private var storedData: ByteArray? = null + + @Before + fun setUp() { + whenever(keychain.accessBlocking(any())).doAnswer { + it.getArgument Any?>(0).invoke(blocking) + } + whenever(blocking.load(STATE_KEY)).doAnswer { storedData } + doAnswer { storedData = it.getArgument(1) }.whenever(blocking).upsert(eq(STATE_KEY), any()) + } + + @Test + fun `rc55 layout gains the empty accounting tag and loses it again`() { + val sdkBytes = PaykitSdkStateLayout.RC55.sdkBytes(rc55Bytes) + val (layout, storedBytes) = PaykitSdkStateLayout.stored(sdkBytes) + + assertContentEquals(emptyAccountingBytes, sdkBytes) + assertEquals(PaykitSdkStateLayout.RC55, layout) + assertContentEquals(rc55Bytes, storedBytes) + } + + @Test + fun `state with accounting keeps the allowances layout`() { + val (layout, storedBytes) = PaykitSdkStateLayout.stored(accountingBytes) + + assertEquals(PaykitSdkStateLayout.ALLOWANCES, layout) + assertContentEquals(accountingBytes, storedBytes) + assertContentEquals(accountingBytes, PaykitSdkStateLayout.ALLOWANCES.sdkBytes(accountingBytes)) + } + + @Test + fun `revision suffix names the stored layout`() { + assertEquals("id.rc55", PaykitSdkStateLayout.RC55.revision("id")) + assertEquals("id.alw", PaykitSdkStateLayout.ALLOWANCES.revision("id")) + assertEquals(PaykitSdkStateLayout.RC55, PaykitSdkStateLayout.fromRevision("id.rc55")) + assertEquals(PaykitSdkStateLayout.ALLOWANCES, PaykitSdkStateLayout.fromRevision("id.alw")) + assertNull(PaykitSdkStateLayout.fromRevision("3f1c0e9a-7b8d-4c2e-9f10-2a6b5c4d3e21")) + } + + @Test + fun `rc55 blob loads in the allowances layout`() { + storeSnapshot(rc55Bytes, "r1.rc55") + + val snapshot = assertNotNull(store().loadStateBlob()) + + assertContentEquals(emptyAccountingBytes, snapshot.blob.exportBytes()) + assertEquals("r1.rc55", snapshot.revision) + } + + @Test + fun `unmarked blob loads unchanged`() { + storeSnapshot(rc55Bytes, "legacy") + + val snapshot = assertNotNull(store().loadStateBlob()) + + assertContentEquals(rc55Bytes, snapshot.blob.exportBytes()) + assertEquals("legacy", snapshot.revision) + } + + @Test + fun `save without accounting stores the rc55 layout`() { + val store = store() + + val revision = store.saveStateBlobAtomically(blob(emptyAccountingBytes), expectedRevision = null) + + val stored = storedSnapshot() + assertTrue(revision.endsWith(".rc55")) + assertEquals(revision, stored.revision) + assertContentEquals(rc55Bytes, stored.blob.exportBytes()) + assertContentEquals(emptyAccountingBytes, store.loadStateBlob()?.blob?.exportBytes()) + } + + @Test + fun `save with accounting stores the allowances layout`() { + storeSnapshot(rc55Bytes, "r1.rc55") + val store = store() + + val revision = store.saveStateBlobAtomically(blob(accountingBytes), expectedRevision = "r1.rc55") + + val stored = storedSnapshot() + assertTrue(revision.endsWith(".alw")) + assertEquals(revision, stored.revision) + assertContentEquals(accountingBytes, stored.blob.exportBytes()) + assertContentEquals(accountingBytes, store.loadStateBlob()?.blob?.exportBytes()) + } + + @Test + fun `save rejects a stale revision`() { + storeSnapshot(rc55Bytes, "r1.rc55") + val store = store() + val loadedRevision = assertNotNull(store.loadStateBlob()).revision + val nextRevision = store.saveStateBlobAtomically(blob(accountingBytes), loadedRevision) + + for (staleRevision in listOf(loadedRevision, null)) { + val error = assertFailsWith { + store.saveStateBlobAtomically(blob(emptyAccountingBytes), staleRevision) + } + assertEquals("revision_conflict", error.code) + } + assertEquals(nextRevision, storedSnapshot().revision) + assertContentEquals(accountingBytes, storedSnapshot().blob.exportBytes()) + } + + @Test + fun `unmarked rc55 blob resolves to the rc55 layout`() = test { + storeSnapshot(rc55Bytes, "legacy") + val store = store() + val probes = mutableListOf() + + store.resolveLegacyLayoutIfNeeded { + probe(decodes = it.contentEquals(emptyAccountingBytes), hasIdentity = true).also(probes::add) + } + + val stored = storedSnapshot() + assertEquals("legacy.rc55", stored.revision) + assertContentEquals(rc55Bytes, stored.blob.exportBytes()) + assertContentEquals(emptyAccountingBytes, store.loadStateBlob()?.blob?.exportBytes()) + assertEquals(2, probes.size) + probes.forEach { verify(it).close() } + } + + @Test + fun `unmarked allowances blob resolves to the allowances layout`() = test { + storeSnapshot(accountingBytes, "legacy") + val store = store() + + store.resolveLegacyLayoutIfNeeded { probe(decodes = it.contentEquals(accountingBytes), hasIdentity = true) } + + val stored = storedSnapshot() + assertEquals("legacy.alw", stored.revision) + assertContentEquals(accountingBytes, stored.blob.exportBytes()) + assertContentEquals(accountingBytes, store.loadStateBlob()?.blob?.exportBytes()) + } + + @Test + fun `identity decides when both layouts decode`() = test { + val cases = listOf( + true to "legacy.rc55", + false to "legacy.alw", + ) + for ((rc55HasIdentity, expectedRevision) in cases) { + storeSnapshot(rc55Bytes, "legacy") + + store().resolveLegacyLayoutIfNeeded { + val isRc55Candidate = it.contentEquals(emptyAccountingBytes) + probe(decodes = true, hasIdentity = isRc55Candidate == rc55HasIdentity) + } + + assertEquals(expectedRevision, storedSnapshot().revision) + } + } + + @Test + fun `undecodable or marked blobs keep their revision`() = test { + storeSnapshot(rc55Bytes, "legacy") + store().resolveLegacyLayoutIfNeeded { probe(decodes = false, hasIdentity = false) } + assertEquals("legacy", storedSnapshot().revision) + + storeSnapshot(rc55Bytes, "r1.alw") + var probeCount = 0 + store().resolveLegacyLayoutIfNeeded { + probeCount++ + probe(decodes = true, hasIdentity = true) + } + assertEquals("r1.alw", storedSnapshot().revision) + assertEquals(0, probeCount) + } + + @Test + fun `resolution keeps a state saved while probing`() = test { + storeSnapshot(rc55Bytes, "legacy") + + store().resolveLegacyLayoutIfNeeded { + storeSnapshot(accountingBytes, "concurrent.alw") + probe(decodes = true, hasIdentity = true) + } + + val stored = storedSnapshot() + assertEquals("concurrent.alw", stored.revision) + assertContentEquals(accountingBytes, stored.blob.exportBytes()) + } + + private fun store() = PaykitSdkStateBlobStore(keychain, ::decode, ::encode, ::blob) + + private fun probe(decodes: Boolean, hasIdentity: Boolean): PaykitSdk { + val sdk = mock() + if (decodes) { + whenever { sdk.allowanceAccountingState() }.thenReturn(null) + } else { + whenever { sdk.allowanceAccountingState() } + .thenThrow(PaykitException.Storage("decode", "decode SDK state blob")) + } + val publicKey = if (hasIdentity) "pubky_test" else null + whenever { sdk.identityStatus() }.thenReturn(IdentityStatus(publicKey, liveSessionAvailable = false)) + return sdk + } + + private fun storeSnapshot(bytes: ByteArray, revision: String) { + storedData = encode(SdkStateBlobSnapshot(blob(bytes), revision)) + } + + private fun storedSnapshot() = decode(checkNotNull(storedData)) + + private fun blob(bytes: ByteArray): SdkStateBlob = mock { on { exportBytes() } doReturn bytes } + + private fun encode(snapshot: SdkStateBlobSnapshot) = + "${snapshot.revision}\n".encodeToByteArray() + snapshot.blob.exportBytes() + + private fun decode(data: ByteArray): SdkStateBlobSnapshot { + val separator = data.indexOf('\n'.code.toByte()) + return SdkStateBlobSnapshot( + blob = blob(data.copyOfRange(separator + 1, data.size)), + revision = data.copyOf(separator).decodeToString(), + ) + } +} From 4d5c4e66a6c899b8e7e385bb2d91477df97927ed Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 24 Sep 2026 17:49:32 +0200 Subject: [PATCH 05/51] feat: pay covered paykit requests through allowances --- .../to/bitkit/repositories/PaykitAllowance.kt | 17 +- .../repositories/PaykitAllowanceExecutor.kt | 761 +++++++++++++++++ .../repositories/PaykitAllowanceRepo.kt | 492 ++++++++++- .../repositories/PaykitAllowanceStore.kt | 92 ++ .../repositories/PaykitPaymentProofRepo.kt | 13 +- .../bitkit/repositories/PrivatePaykitRepo.kt | 72 ++ .../bitkit/repositories/PublicPaykitRepo.kt | 8 + .../PaykitAllowanceExecutorTest.kt | 798 ++++++++++++++++++ .../repositories/PaykitAllowanceRepoTest.kt | 500 +++++++++++ .../repositories/PaykitAllowanceTest.kt | 690 +++++++++++++++ .../PaykitPaymentProofRepoTest.kt | 105 ++- .../PrivatePaykitAllowancePaymentTest.kt | 246 ++++++ 12 files changed, 3735 insertions(+), 59 deletions(-) create mode 100644 app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt create mode 100644 app/src/main/java/to/bitkit/repositories/PaykitAllowanceStore.kt create mode 100644 app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt create mode 100644 app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt create mode 100644 app/src/test/java/to/bitkit/repositories/PaykitAllowanceTest.kt create mode 100644 app/src/test/java/to/bitkit/repositories/PrivatePaykitAllowancePaymentTest.kt diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowance.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowance.kt index 6fca0eeca3..82ddae7538 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowance.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowance.kt @@ -12,6 +12,8 @@ import com.synonym.paykit.AllowanceTerms import com.synonym.paykit.PaymentExecutionMode import com.synonym.paykit.PaymentExecutionStatus import kotlinx.serialization.Serializable +import to.bitkit.models.safe +import to.bitkit.services.PaykitReceiverPaths import java.math.BigDecimal import java.time.ZoneOffset import java.time.ZonedDateTime @@ -134,8 +136,13 @@ data class PaykitAllowanceEntry( val allowances: List, val limits: PaykitAllowanceLimits?, ) { + /** An accepted link before any other, and the contact's wallet link before their server link. */ val primary: PaykitAllowance - get() = allowances.firstOrNull { it.lifecycleState == AllowanceLifecycleState.ACCEPTED } ?: allowances.first() + get() = allowances.minBy { + val acceptedRank = if (it.lifecycleState == AllowanceLifecycleState.ACCEPTED) 0 else 2 + val walletRank = if (it.counterpartyReceiverPath == PaykitReceiverPaths.WALLET) 0 else 1 + acceptedRank + walletRank + } val counterparty: String get() = primary.counterparty val role: PaykitAllowance.Role get() = primary.role val perPaymentMaxSats: ULong? get() = primary.perPaymentMaxSats @@ -155,7 +162,9 @@ data class PaykitAllowanceLimits( val perPaymentSats: ULong, val monthlySats: ULong, ) { - /** Terms Bitkit proposes: per-payment range 0...max, an anchored UTC calendar month, and the endpoints Bitkit pays. */ + /** + * Terms Bitkit proposes: per-payment range 0...max, an anchored UTC calendar month, and the endpoints Bitkit pays. + */ fun terms(monthAnchor: Instant, allowedPaymentEndpointIdentifiers: List): AllowanceTerms { val perPayment = AllowanceAmountRange(minimum = "0", maximum = perPaymentSats.toBitcoinDecimal()) val month = AllowancePeriod( @@ -239,7 +248,7 @@ object PaykitAllowanceCapacity { val (start, end) = PaykitAllowanceTime.monthlyWindow(anchor, now) return attempts .filter { it.allowanceId == allowanceId && it.isLive && it.admittedAt >= start && it.admittedAt < end } - .fold(0uL) { total, attempt -> total + attempt.amountSats } + .fold(0uL) { total, attempt -> total.safe() + attempt.amountSats.safe() } } fun fits(amountSats: ULong, allowance: PaykitAllowance, attempts: List, now: Instant): Boolean { @@ -247,7 +256,7 @@ object PaykitAllowanceCapacity { if (perPaymentMax != null && amountSats > perPaymentMax) return false val monthlyLimit = allowance.monthlyLimitSats ?: return true val anchor = allowance.monthlyAnchor ?: return true - return usedSats(allowance.allowanceId, attempts, anchor, now) + amountSats <= monthlyLimit + return usedSats(allowance.allowanceId, attempts, anchor, now).safe() + amountSats.safe() <= monthlyLimit } fun attempts(history: AllowanceAccountingHistory): List = history.occurrences diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt new file mode 100644 index 0000000000..450df5cd34 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt @@ -0,0 +1,761 @@ +package to.bitkit.repositories + +import com.synonym.paykit.AccountingAmount +import com.synonym.paykit.AllowanceAccountingBlock +import com.synonym.paykit.AllowanceAccountingHistory +import com.synonym.paykit.AllowanceAccountingReconciliation +import com.synonym.paykit.AllowanceAccountingState +import com.synonym.paykit.AllowanceLifecycleState +import com.synonym.paykit.AllowanceSelectionInput +import com.synonym.paykit.PaymentAttemptDecision +import com.synonym.paykit.PaymentAttemptRecord +import com.synonym.paykit.PaymentExecutionChecks +import com.synonym.paykit.PaymentExecutionStatus +import com.synonym.paykit.PaymentOccurrence +import com.synonym.paykit.PaymentOutcome +import com.synonym.paykit.PaymentOutcomeReport +import com.synonym.paykit.PaymentRequestLifecycleState +import com.synonym.paykit.PaymentRequestScope +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.lightningdevkit.ldknode.NodeException +import org.lightningdevkit.ldknode.PaymentDirection +import org.lightningdevkit.ldknode.PaymentStatus +import to.bitkit.di.IoDispatcher +import to.bitkit.ext.runSuspendCatching +import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.models.TransactionSpeed +import to.bitkit.repositories.PaykitAllowanceLocalState.JournalEntry +import to.bitkit.repositories.PaykitAllowanceLocalState.Stage +import to.bitkit.services.PaykitSdkService +import to.bitkit.utils.AppError +import to.bitkit.utils.Logger +import to.bitkit.utils.ServiceError +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Clock +import kotlin.time.Instant + +/** An on-chain send failed; [isDefinitelyNotBroadcast] is true only when the transaction surely never left. */ +class PaykitAllowanceOnchainSendError( + val isDefinitelyNotBroadcast: Boolean, + cause: Throwable, +) : AppError(cause) + +/** The side effects of paying one request, kept apart so the admission logic is testable without a node. */ +@Singleton +@Suppress("TooManyFunctions") +class PaykitAllowancePayer @Inject constructor( + private val privatePaykitRepo: PrivatePaykitRepo, + private val paymentProofRepo: PaykitPaymentProofRepo, + private val lightningRepo: LightningRepo, +) { + suspend fun resolve( + request: PaykitPaymentRequest, + eligibleIdentifiers: List, + ): Result = privatePaykitRepo.resolveAllowancePayment(request, eligibleIdentifiers) + + suspend fun consumePaymentList(publicKey: String, context: PrivatePaykitPaymentContext): Result = + privatePaykitRepo.consumePrivatePaymentList(publicKey, context) + + suspend fun prepareProof( + request: PaykitPaymentRequest, + paymentEndpointIdentifier: String, + allowanceId: String?, + ): Result { + val kind = PaykitPaymentProofKind.fromPaymentEndpointIdentifier(paymentEndpointIdentifier) + ?: return Result.failure(PaykitPaymentRequestError.RequestUnavailable) + return paymentProofRepo.prepare(request, paymentEndpointIdentifier, kind, allowanceId) + } + + suspend fun associateLightningPayment( + request: PaykitPaymentRequest, + paymentHash: String, + paymentEndpointIdentifier: String, + ): Result = paymentProofRepo.associateLightningPayment(request, paymentHash, paymentEndpointIdentifier) + + suspend fun markOnchainPaymentStarted(request: PaykitPaymentRequest, address: String): Result = + paymentProofRepo.markOnchainPaymentStarted(request, address) + + suspend fun payLightning(bolt11: String, sats: ULong?): Result = lightningRepo.payInvoice(bolt11, sats) + + /** Sends at the medium fee rate. A failure is a [PaykitAllowanceOnchainSendError]. */ + suspend fun payOnchain(address: String, sats: ULong): Result { + var sendAttempted = false + var broadcastTxid: String? = null + val result = lightningRepo.sendOnChain( + address = address, + sats = sats, + speed = TransactionSpeed.Medium, + beforeSendAttempt = { sendAttempted = true }, + onBroadcast = { broadcastTxid = it }, + ) + broadcastTxid?.let { return Result.success(it) } + val error = result.exceptionOrNull() ?: return result + return Result.failure( + PaykitAllowanceOnchainSendError( + isDefinitelyNotBroadcast = !sendAttempted || error.isDefiniteOnchainPreBroadcastFailure(), + cause = error, + ), + ) + } + + suspend fun completeOnchainPayment( + request: PaykitPaymentRequest, + txid: String, + paymentEndpointIdentifier: String, + ) { + paymentProofRepo.completeOnchainPayment(request, txid, paymentEndpointIdentifier) + } + + /** Clears the proof when [error] proves the payment never left; returns whether it did. */ + suspend fun failLightningPayment(paymentHash: String, error: Throwable): Boolean = + paymentProofRepo.failLightningPayment(paymentHash, error) + + suspend fun failOnchainPayment(request: PaykitPaymentRequest) = paymentProofRepo.failOnchainPayment(request) + + suspend fun cancelProofPreparation(request: PaykitPaymentRequest) = paymentProofRepo.cancelPreparation(request) + + /** The node's status for an outbound payment, or null when the node does not know it (or is not running). */ + suspend fun lightningPaymentStatus(paymentHash: String): PaymentStatus? = + runSuspendCatching { lightningRepo.listPaymentsOrNull() } + .getOrNull() + ?.firstOrNull { it.direction == PaymentDirection.OUTBOUND && it.id.equals(paymentHash, ignoreCase = true) } + ?.status +} + +/** + * Runs Allowance admission for incoming requests through the SDK: evaluate, capacity preflight, automatic + * Acceptance, reserve, begin, pay, record the outcome. Every attempt is journaled before its handoff, so a restart + * resolves it from the node instead of paying again. + */ +@Singleton +@Suppress("TooManyFunctions") +class PaykitAllowanceExecutor @Inject constructor( + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val paykitSdkService: PaykitSdkService, + private val store: PaykitAllowanceStore, + private val payer: PaykitAllowancePayer, + private val clock: Clock, +) { + companion object { + private const val TAG = "PaykitAllowanceExecutor" + private const val MAX_JOURNAL_ENTRIES = 200 + private val SETTLEABLE_STAGES = setOf(Stage.SUBMITTED, Stage.SENDING, Stage.SENT, Stage.UNKNOWN) + } + + private var accountingAmount: (String, String) -> AccountingAmount = ::AccountingAmount + private val stateMutex = Mutex() + private val settleMutex = Mutex() + private val inFlightRequestIds = ConcurrentHashMap.newKeySet() + private val liveAttemptIds = ConcurrentHashMap.newKeySet() + private val _events = MutableSharedFlow(extraBufferCapacity = 16) + val events: SharedFlow = _events.asSharedFlow() + + @Volatile + var activeIdentity: String? = null + private set + + internal constructor( + ioDispatcher: CoroutineDispatcher, + paykitSdkService: PaykitSdkService, + store: PaykitAllowanceStore, + payer: PaykitAllowancePayer, + clock: Clock, + accountingAmount: (String, String) -> AccountingAmount, + ) : this(ioDispatcher, paykitSdkService, store, payer, clock) { + this.accountingAmount = accountingAmount + } + + fun activate(identity: String?) = run { activeIdentity = identity } + + // region Local state + + fun localState(identity: String): PaykitAllowanceLocalState = store.load(identity) + + suspend fun updateLocalState( + identity: String, + change: (PaykitAllowanceLocalState) -> PaykitAllowanceLocalState, + ): PaykitAllowanceLocalState = stateMutex.withLock { + val updated = change(localState(identity)) + runSuspendCatching { store.save(identity, updated) } + .onFailure { Logger.warn("Failed to save Paykit allowance state", it, context = TAG) } + updated + } + + fun isHandling(requestId: PaykitPaymentRequestId): Boolean = requestId in inFlightRequestIds + + /** + * Trusted time for eligibility: the real clock, never earlier than the last value given to the SDK, because the + * SDK refuses a watermark that moves backwards. + */ + suspend fun trustedTime(identity: String): String { + var millis = clock.now().toEpochMilliseconds() + updateLocalState(identity) { state -> + state.lastTrustedTimeMillis?.let { millis = maxOf(millis, it) } + state.copy(lastTrustedTimeMillis = millis) + } + return PaykitAllowanceTime.format(Instant.fromEpochMilliseconds(millis)) + } + + fun succeededAutomaticPayments(identity: String): List = + localState(identity).journal.filter { it.isAutomatic && it.stage == Stage.SUCCEEDED } + + // endregion + + // region Ledger + + /** + * Brings the SDK ledger to a reconciled state. A wallet with no ledger attests an empty history: it has never paid + * through an Allowance. After a restore, outcomes come from the journal and the node. + */ + suspend fun ensureReconciled(identity: String): AllowanceAccountingState = withContext(ioDispatcher) { + val current = paykitSdkService.allowanceAccountingState() + if (current != null && !current.requiresReconciliation) return@withContext current + + val history = current?.history ?: AllowanceAccountingHistory(emptyList(), emptyList(), emptyList()) + val outcomes = history.occurrences.flatMap { it.attempts }.mapNotNull { attempt -> + verifiedOutcome(attempt, identity)?.let { PaymentOutcomeReport(attempt.attemptId, it) } + } + val reconciled = paykitSdkService.reconcileAllowanceAccounting( + AllowanceAccountingReconciliation( + expectedRevision = current?.revision, + history = history, + outcomes = outcomes, + trustedTime = trustedTime(identity), + ), + ) + Logger.info("Reconciled Paykit allowance accounting with '${outcomes.size}' verified outcomes", context = TAG) + reconciled + } + + /** + * Resolves attempts a crash or kill left open. Prepared attempts never got a handoff and are released; submitted + * ones are settled from the journal and the node. Nothing is paid again, and attempts this process is still + * executing are left alone. + */ + suspend fun recover(identity: String) { + withContext(ioDispatcher) { recoverOpenAttempts(identity) } + } + + private suspend fun recoverOpenAttempts(identity: String) { + runSuspendCatching { + // No ledger means nothing was ever admitted. Creating one here would also end the rc55 storage layout. + paykitSdkService.allowanceAccountingState() ?: return@runSuspendCatching + val state = ensureReconciled(identity) + for (attempt in state.history.occurrences.flatMap { it.attempts }) { + if (attempt.attemptId in liveAttemptIds) continue + when (attempt.status) { + PaymentExecutionStatus.PREPARED -> { + if (attempt.epoch == state.epoch) record(attempt.attemptId, PaymentOutcome.FAILED, identity) + } + PaymentExecutionStatus.SUBMITTED, PaymentExecutionStatus.UNKNOWN -> { + val outcome = verifiedOutcome(attempt, identity) + ?: PaymentOutcome.UNKNOWN.takeIf { attempt.status == PaymentExecutionStatus.SUBMITTED } + outcome?.let { record(attempt.attemptId, it, identity) } + } + PaymentExecutionStatus.SUCCEEDED, PaymentExecutionStatus.FAILED -> Unit + } + } + }.onFailure { Logger.warn("Failed to recover Paykit allowance attempts", it, context = TAG) } + } + + /** Settles journaled Lightning attempts whose outcome the node already knows, for events missed while inactive. */ + suspend fun settleOpenLightningAttempts(): Unit = withContext(ioDispatcher) { + val identity = activeIdentity ?: return@withContext + val paymentHashes = localState(identity).journal + .filter { it.stage in SETTLEABLE_STAGES } + .mapNotNull { it.paymentHash } + .distinct() + for (paymentHash in paymentHashes) { + when (payer.lightningPaymentStatus(paymentHash)) { + PaymentStatus.SUCCEEDED -> lightningPaymentSettled(paymentHash, succeeded = true) + PaymentStatus.FAILED -> lightningPaymentSettled(paymentHash, succeeded = false) + PaymentStatus.PENDING, null -> Unit + } + } + } + + private suspend fun verifiedOutcome(attempt: PaymentAttemptRecord, identity: String): PaymentOutcome? = + when (attempt.status) { + PaymentExecutionStatus.PREPARED -> PaymentOutcome.FAILED + PaymentExecutionStatus.SUCCEEDED, PaymentExecutionStatus.FAILED -> null + PaymentExecutionStatus.SUBMITTED, PaymentExecutionStatus.UNKNOWN -> localState(identity).journal + .firstOrNull { it.attemptId == attempt.attemptId } + ?.let { journalOutcome(it) } + } + + private suspend fun journalOutcome(entry: JournalEntry): PaymentOutcome? = when (entry.stage) { + // The journal is written before the node call, so no payment left this wallet. + Stage.PREPARED, Stage.SUBMITTED, Stage.FAILED -> PaymentOutcome.FAILED + Stage.SUCCEEDED -> PaymentOutcome.SUCCEEDED + Stage.SENDING, Stage.SENT, Stage.UNKNOWN -> { + val paymentHash = entry.paymentHash + if (paymentHash == null) { + PaymentOutcome.SUCCEEDED.takeIf { entry.transactionId != null } + } else { + when (payer.lightningPaymentStatus(paymentHash)) { + PaymentStatus.SUCCEEDED -> PaymentOutcome.SUCCEEDED + PaymentStatus.FAILED -> PaymentOutcome.FAILED + PaymentStatus.PENDING, null -> null + } + } + } + } + + private suspend fun record(attemptId: String, outcome: PaymentOutcome, identity: String) { + paykitSdkService.recordPaymentOutcome(PaymentOutcomeReport(attemptId, outcome)) + val stage = when (outcome) { + PaymentOutcome.SUCCEEDED -> Stage.SUCCEEDED + PaymentOutcome.FAILED -> Stage.FAILED + PaymentOutcome.UNKNOWN -> Stage.UNKNOWN + } + updateJournalEntry(attemptId, identity) { it.copy(stage = stage) } + _events.tryEmit(PaykitAllowanceEvent.LedgerChanged) + } + + private suspend fun automaticAttempts(): List = + runSuspendCatching { paykitSdkService.allowanceAccountingState() } + .getOrNull() + ?.let { PaykitAllowanceCapacity.attempts(it.history) } + .orEmpty() + + // endregion + + // region Automatic payment + + suspend fun autoPay( + request: PaykitPaymentRequest, + allowances: List, + identity: String, + ): PaykitAllowanceAutoPayResult = withContext(ioDispatcher) { + val isCovered = request.direction == PaykitPaymentRequestDirection.Incoming && + request.billingPeriod == null && + request.lifecycleState == PaymentRequestLifecycleState.PROPOSED && + allowances.any { + it.isAllower && + it.lifecycleState == AllowanceLifecycleState.ACCEPTED && + PubkyPublicKeyFormat.matches(it.counterparty, request.counterparty) && + it.counterpartyReceiverPath == request.counterpartyReceiverPath + } + if (!isCovered || !inFlightRequestIds.add(request.id)) { + return@withContext PaykitAllowanceAutoPayResult.NOT_COVERED + } + + try { + runSuspendCatching { + ensureReconciled(identity) + admitAndPay(request, allowances, identity) + }.getOrElse { + Logger.warn("Kept an incoming request on the manual flow after an allowance error", it, context = TAG) + PaykitAllowanceAutoPayResult.MANUAL + } + } finally { + inFlightRequestIds.remove(request.id) + } + } + + @Suppress("ReturnCount", "LongMethod") + private suspend fun admitAndPay( + request: PaykitPaymentRequest, + allowances: List, + identity: String, + ): PaykitAllowanceAutoPayResult { + val scope = request.scope() + val selectionTime = trustedTime(identity) + val candidates = paykitSdkService.evaluateAllowanceCandidates(scope, selectionTime) + val candidate = candidates.firstOrNull { it.blocked == null } + val allowance = candidate?.let { eligible -> allowances.firstOrNull { it.allowanceId == eligible.allowanceId } } + if (candidate == null || allowance == null) { + val reasons = candidates.mapNotNull { it.blocked } + Logger.info("Kept an incoming request manual: no eligible allowance, blocked by '$reasons'", context = TAG) + return PaykitAllowanceAutoPayResult.MANUAL + } + + if (!PaykitAllowanceCapacity.fits(request.amountSats, allowance, automaticAttempts(), clock.now())) { + Logger.info("Kept an incoming request manual: the allowance limit is reached", context = TAG) + notifyLimitReached(request, identity) + return PaykitAllowanceAutoPayResult.MANUAL + } + + val payment = payer.resolve(request, candidate.eligiblePaymentEndpointIdentifiers).getOrThrow() + if (payment == null) { + Logger.info("Kept an incoming request manual: no payable private endpoint", context = TAG) + return PaykitAllowanceAutoPayResult.MANUAL + } + + val endpointIdentifier = payment.endpoint.methodId.rawValue + val association = paykitSdkService.acceptPaymentRequestAutomatically( + scope = scope, + selection = AllowanceSelectionInput( + allowanceId = candidate.allowanceId, + expectedRevision = null, + trustedTime = selectionTime, + ), + checks = checks(request, endpointIdentifier, selectionTime), + ) + sendQueuedMessages(request) + + val occurrence = PaymentOccurrence(scope, billingPeriod = null) + val reservation = paykitSdkService.reserveAutomaticPayment( + occurrence = occurrence, + expectedAssociationRevision = association.revisions.lastOrNull()?.revision ?: 1uL, + checks = checks(request, endpointIdentifier, trustedTime(identity)), + ) + val prepared = (reservation as? PaymentAttemptDecision.Ready)?.attempt + if (prepared == null) { + val reason = (reservation as? PaymentAttemptDecision.Blocked)?.reason + Logger.info("Kept an incoming request manual: the reservation is blocked by '$reason'", context = TAG) + runSuspendCatching { paykitSdkService.markPaymentManualOnly(occurrence) } + .onFailure { Logger.warn("Failed to mark an allowance payment manual only", it, context = TAG) } + notifyLimitReached(request, identity) + return PaykitAllowanceAutoPayResult.MANUAL + } + + liveAttemptIds.add(prepared.attemptId) + try { + return executeAutomaticAttempt(request, payment, prepared, identity) + } finally { + liveAttemptIds.remove(prepared.attemptId) + } + } + + private suspend fun executeAutomaticAttempt( + request: PaykitPaymentRequest, + payment: PrivatePaykitAllowancePayment, + prepared: PaymentAttemptRecord, + identity: String, + ): PaykitAllowanceAutoPayResult { + val endpointIdentifier = payment.endpoint.methodId.rawValue + journal( + JournalEntry( + attemptId = prepared.attemptId, + isAutomatic = true, + requestId = request.id, + allowanceId = prepared.allowanceId, + amountSats = request.amountSats, + paymentEndpointIdentifier = endpointIdentifier, + paymentHash = payment.lightningPaymentHash, + onchainAddress = payment.endpoint.value.takeIf { payment.endpoint.methodId.isOnchain }, + stage = Stage.PREPARED, + createdAtMillis = clock.now().toEpochMilliseconds(), + ), + identity, + ) + + // Begin fetches nothing, so pull the link first: an End or a cancellation must be seen before the handoff. + runSuspendCatching { + paykitSdkService.receivePrivateMessages(request.counterparty, request.counterpartyReceiverPath) + }.onFailure { Logger.warn("Failed to receive private messages before an allowance payment", it, context = TAG) } + val handoff = paykitSdkService.beginPaymentExecution( + attemptId = prepared.attemptId, + checks = checks(request, endpointIdentifier, trustedTime(identity)), + ) + val submitted = (handoff as? PaymentAttemptDecision.Ready)?.attempt + if (submitted == null) { + val reason = (handoff as? PaymentAttemptDecision.Blocked)?.reason + Logger.info("Kept an incoming request manual: the allowance handoff is blocked by '$reason'", context = TAG) + record(prepared.attemptId, PaymentOutcome.FAILED, identity) + return PaykitAllowanceAutoPayResult.MANUAL + } + setStage(Stage.SUBMITTED, submitted.attemptId, identity) + + runSuspendCatching { + payer.consumePaymentList(request.counterparty, payment.context).getOrThrow() + payer.prepareProof(request, endpointIdentifier, submitted.allowanceId).getOrThrow() + }.exceptionOrNull()?.let { + payer.cancelProofPreparation(request) + record(submitted.attemptId, PaymentOutcome.FAILED, identity) + throw it + } + + val paymentHash = payment.lightningPaymentHash + ?: return payOnchain(request, payment, submitted.attemptId, identity) + return payLightning(request, payment, paymentHash, submitted.attemptId, identity) + } + + private suspend fun payLightning( + request: PaykitPaymentRequest, + payment: PrivatePaykitAllowancePayment, + paymentHash: String, + attemptId: String, + identity: String, + ): PaykitAllowanceAutoPayResult { + payer.associateLightningPayment(request, paymentHash, payment.endpoint.methodId.rawValue).onFailure { + payer.cancelProofPreparation(request) + record(attemptId, PaymentOutcome.FAILED, identity) + throw it + } + + setStage(Stage.SENDING, attemptId, identity) + val sats = request.amountSats.takeUnless { payment.lightningInvoiceHasAmount } + payer.payLightning(payment.endpoint.value, sats).onFailure { + // Only an error that proves the payment never left releases the reservation; anything else stays open. + val neverSent = payer.failLightningPayment(paymentHash, it) + record(attemptId, if (neverSent) PaymentOutcome.FAILED else PaymentOutcome.UNKNOWN, identity) + throw it + } + // The node's payment event can settle the attempt before the send call returns; keep that outcome. + updateJournalEntry(attemptId, identity) { if (it.stage == Stage.SENDING) it.copy(stage = Stage.SENT) else it } + Logger.info("Handed an allowance payment to the node", context = TAG) + return PaykitAllowanceAutoPayResult.STARTED + } + + private suspend fun payOnchain( + request: PaykitPaymentRequest, + payment: PrivatePaykitAllowancePayment, + attemptId: String, + identity: String, + ): PaykitAllowanceAutoPayResult { + val address = payment.endpoint.value + payer.markOnchainPaymentStarted(request, address).onFailure { + payer.cancelProofPreparation(request) + record(attemptId, PaymentOutcome.FAILED, identity) + throw it + } + + setStage(Stage.SENDING, attemptId, identity) + val txid = payer.payOnchain(address, request.amountSats).getOrElse { + if ((it as? PaykitAllowanceOnchainSendError)?.isDefinitelyNotBroadcast == true) { + payer.failOnchainPayment(request) + record(attemptId, PaymentOutcome.FAILED, identity) + } else { + record(attemptId, PaymentOutcome.UNKNOWN, identity) + } + throw it + } + + updateJournalEntry(attemptId, identity) { it.copy(transactionId = txid) } + payer.completeOnchainPayment(request, txid, payment.endpoint.methodId.rawValue) + record(attemptId, PaymentOutcome.SUCCEEDED, identity) + _events.tryEmit(PaykitAllowanceEvent.PaidAutomatically(request.counterparty, request.amountSats, txid)) + Logger.info("Paid an allowance payment on-chain", context = TAG) + return PaykitAllowanceAutoPayResult.COMPLETED + } + + /** Called for every settled outbound Lightning payment; only journaled ones are Allowance work. */ + suspend fun lightningPaymentSettled(paymentHash: String, succeeded: Boolean): Unit = withContext(ioDispatcher) { + settleMutex.withLock { + val identity = activeIdentity ?: return@withLock + val entries = localState(identity).journal.filter { + it.paymentHash.equals(paymentHash, ignoreCase = true) && it.stage in SETTLEABLE_STAGES + } + for (entry in entries) { + runSuspendCatching { + val outcome = if (succeeded) PaymentOutcome.SUCCEEDED else PaymentOutcome.FAILED + record(entry.attemptId, outcome, identity) + if (succeeded && entry.isAutomatic) { + _events.tryEmit( + PaykitAllowanceEvent.PaidAutomatically( + counterparty = entry.requestId.counterparty, + amountSats = entry.amountSats, + paymentId = paymentHash.lowercase(), + ), + ) + } + }.onFailure { Logger.warn("Failed to record an allowance payment outcome", it, context = TAG) } + } + } + } + + // endregion + + // region Manual payments + + /** + * Reports a user-approved payment of an incoming request to the shared ledger before it leaves the wallet. + * Fails with [PaykitAllowanceError.PaymentAlreadyRecorded] when another live attempt exists for the request, so + * the same request is never paid twice. Any other problem leaves the payment unreported and returns null. + */ + suspend fun beginManualPayment( + request: PaykitPaymentRequest, + paymentEndpointIdentifier: String, + ): Result = withContext(ioDispatcher) { + val identity = activeIdentity + if ( + identity == null || + request.billingPeriod != null || + request.direction != PaykitPaymentRequestDirection.Incoming + ) { + return@withContext Result.success(null) + } + val result = runSuspendCatching { reportManualPayment(request, paymentEndpointIdentifier, identity) } + val error = result.exceptionOrNull() ?: return@withContext result + if (error is PaykitAllowanceError.PaymentAlreadyRecorded) return@withContext result + Logger.warn("Failed to report a manual payment to the allowance ledger", error, context = TAG) + Result.success(null) + } + + private suspend fun reportManualPayment( + request: PaykitPaymentRequest, + paymentEndpointIdentifier: String, + identity: String, + ): String? { + ensureReconciled(identity) + val decision = paykitSdkService.reserveManualPayment( + occurrence = PaymentOccurrence(request.scope(), billingPeriod = null), + checks = checks(request, paymentEndpointIdentifier, trustedTime(identity)), + ) + val prepared = when (decision) { + is PaymentAttemptDecision.Ready -> decision.attempt + is PaymentAttemptDecision.Blocked if decision.reason == AllowanceAccountingBlock.PaymentAlreadyRecorded -> + throw PaykitAllowanceError.PaymentAlreadyRecorded + is PaymentAttemptDecision.Blocked -> { + Logger.info("Skipped reporting a manual payment: blocked by '${decision.reason}'", context = TAG) + return null + } + } + // Recovery leaves the attempt alone until finishManualPayment reports its outcome. + liveAttemptIds.add(prepared.attemptId) + val attemptId = runSuspendCatching { + beginManualAttempt(request, paymentEndpointIdentifier, prepared, identity) + } + if (attemptId.getOrNull() == null) liveAttemptIds.remove(prepared.attemptId) + return attemptId.getOrThrow() + } + + private suspend fun beginManualAttempt( + request: PaykitPaymentRequest, + paymentEndpointIdentifier: String, + prepared: PaymentAttemptRecord, + identity: String, + ): String? { + journal( + JournalEntry( + attemptId = prepared.attemptId, + isAutomatic = false, + requestId = request.id, + allowanceId = null, + amountSats = request.amountSats, + paymentEndpointIdentifier = paymentEndpointIdentifier, + stage = Stage.PREPARED, + createdAtMillis = clock.now().toEpochMilliseconds(), + ), + identity, + ) + val handoff = paykitSdkService.beginPaymentExecution( + attemptId = prepared.attemptId, + checks = checks(request, paymentEndpointIdentifier, trustedTime(identity)), + ) + if (handoff !is PaymentAttemptDecision.Ready) { + record(prepared.attemptId, PaymentOutcome.FAILED, identity) + return null + } + setStage(Stage.SUBMITTED, prepared.attemptId, identity) + Logger.info("Reported a manual payment to the allowance ledger", context = TAG) + return prepared.attemptId + } + + suspend fun manualLightningPaymentSent(attemptId: String, paymentHash: String) { + val identity = activeIdentity ?: return + withContext(ioDispatcher) { + updateJournalEntry(attemptId, identity) { + it.copy(paymentHash = paymentHash.lowercase(), stage = Stage.SENT) + } + } + } + + suspend fun finishManualPayment( + attemptId: String, + outcome: PaymentOutcome, + transactionId: String? = null, + ) { + liveAttemptIds.remove(attemptId) + val identity = activeIdentity ?: return + withContext(ioDispatcher) { + settleMutex.withLock { + // The node's payment events may have settled a Lightning attempt already. + val stage = localState(identity).journal.firstOrNull { it.attemptId == attemptId }?.stage + if (stage == Stage.SUCCEEDED || stage == Stage.FAILED) return@withLock + transactionId?.let { txid -> updateJournalEntry(attemptId, identity) { it.copy(transactionId = txid) } } + runSuspendCatching { record(attemptId, outcome, identity) } + .onFailure { Logger.warn("Failed to record a manual payment outcome", it, context = TAG) } + } + } + } + + // endregion + + // region Helpers + + private fun checks( + request: PaykitPaymentRequest, + endpointIdentifier: String, + trustedTime: String, + ) = PaymentExecutionChecks( + trustedTime = trustedTime, + paymentEndpointIdentifier = endpointIdentifier, + actualAmount = accountingAmount(request.amountValue, PaykitIssuerInterop.BITCOIN_ASSET), + endpointCurrent = true, + localEnabled = true, + recurrenceEligible = true, + ) + + private suspend fun sendQueuedMessages(request: PaykitPaymentRequest) { + runSuspendCatching { + paykitSdkService.processOutboundPrivateMessages(request.counterparty, request.counterpartyReceiverPath) + }.onFailure { Logger.warn("Failed to send the automatic acceptance right away", it, context = TAG) } + } + + private suspend fun journal(entry: JournalEntry, identity: String) { + updateLocalState(identity) { state -> + state.copy( + journal = (state.journal.filterNot { it.attemptId == entry.attemptId } + entry) + .takeLast(MAX_JOURNAL_ENTRIES), + ) + } + } + + private suspend fun setStage(stage: Stage, attemptId: String, identity: String) { + updateJournalEntry(attemptId, identity) { it.copy(stage = stage) } + } + + private suspend fun updateJournalEntry( + attemptId: String, + identity: String, + change: (JournalEntry) -> JournalEntry, + ) { + updateLocalState(identity) { state -> + state.copy(journal = state.journal.map { if (it.attemptId == attemptId) change(it) else it }) + } + } + + private suspend fun notifyLimitReached(request: PaykitPaymentRequest, identity: String) { + var isNew = false + updateLocalState(identity) { state -> + isNew = request.paymentRequestId !in state.notifiedRequestIds + state.copy(notifiedRequestIds = state.notifiedRequestIds + request.paymentRequestId) + } + if (isNew) _events.tryEmit(PaykitAllowanceEvent.LimitReached(request.counterparty, request.amountSats)) + } + + // endregion +} + +private fun PaykitPaymentRequest.scope() = + PaymentRequestScope(counterparty, counterpartyReceiverPath, paymentRequestId) + +private fun Throwable.isDefiniteOnchainPreBroadcastFailure(): Boolean = + generateSequence(this as Throwable?) { it.cause } + .any { + it is ServiceError.NodeNotSetup || + it is ServiceError.NodeNotStarted || + it is NodeException.NotRunning || + it is NodeException.OnchainTxCreationFailed || + it is NodeException.OnchainTxSigningFailed || + it is NodeException.WalletOperationFailed || + it is NodeException.PersistenceFailed || + it is NodeException.InvalidAddress || + it is NodeException.InvalidAmount || + it is NodeException.InvalidNetwork || + it is NodeException.InvalidFeeRate || + it is NodeException.InsufficientFunds || + it is NodeException.CoinSelectionFailed || + it is NodeException.NoSpendableOutputs + } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt index 0a38837efa..a73663ac00 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt @@ -1,26 +1,123 @@ package to.bitkit.repositories -import kotlinx.coroutines.flow.MutableSharedFlow +import com.synonym.paykit.AllowanceFilter +import com.synonym.paykit.AllowanceHistoryStatus +import com.synonym.paykit.AllowanceLocalRole +import com.synonym.paykit.AllowanceRecord +import com.synonym.paykit.AllowanceTerms +import com.synonym.paykit.LinkedPeerState +import com.synonym.paykit.PaymentOutcome +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.lightningdevkit.ldknode.Event +import org.lightningdevkit.ldknode.Network +import to.bitkit.async.appScope +import to.bitkit.di.IoDispatcher +import to.bitkit.env.Env +import to.bitkit.ext.runSuspendCatching +import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.models.safe +import to.bitkit.services.PaykitReceiverPaths +import to.bitkit.services.PaykitSdkService +import to.bitkit.utils.AppError +import to.bitkit.utils.Logger +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject import javax.inject.Singleton - -// CONTRACT (allowances port): the public API below is fixed; bodies are filled by the core worker. +import kotlin.time.Clock +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant sealed interface PaykitAllowanceEvent { - data class PaidAutomatically(val counterparty: String, val amountSats: ULong, val paymentId: String) : PaykitAllowanceEvent + data class PaidAutomatically( + val counterparty: String, + val amountSats: ULong, + val paymentId: String, + ) : PaykitAllowanceEvent + data class LimitReached(val counterparty: String, val amountSats: ULong) : PaykitAllowanceEvent + data object LedgerChanged : PaykitAllowanceEvent } -enum class PaykitAllowanceAutoPayResult { NOT_COVERED, MANUAL, STARTED, COMPLETED } +enum class PaykitAllowanceAutoPayResult { + /** No accepted Allowance covers the request's link, or the request is already being paid. */ + NOT_COVERED, + + /** An Allowance exists but this request stays on the manual flow (over a limit, ended, no payable endpoint). */ + MANUAL, + + /** The Lightning payment was handed to the node; the outcome arrives through the node's payment events. */ + STARTED, + + /** The on-chain payment was broadcast and recorded. */ + COMPLETED, +} + +sealed class PaykitAllowanceError(message: String) : AppError(message) { + data object ContactNotLinked : PaykitAllowanceError("The contact has no linked Paykit receiver") + data object Unavailable : PaykitAllowanceError("The allowance is unavailable") + data object PaymentAlreadyRecorded : PaykitAllowanceError("A payment for this request is already in progress") +} +/** + * Allowances for the active identity. One user-facing entry groups the same grant across a contact's links (their + * wallet, and their Paykit Server folder), and covered incoming requests are paid headlessly through + * [PaykitAllowanceExecutor]. The repository also settles its own Lightning attempts from the node's payment events + * and attributes automatic payments to the contact's activity. + */ @Singleton -class PaykitAllowanceRepo @Inject constructor() { +@Suppress("TooManyFunctions", "LongParameterList") +class PaykitAllowanceRepo @Inject constructor( + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val paykitSdkService: PaykitSdkService, + private val executor: PaykitAllowanceExecutor, + private val lightningRepo: LightningRepo, + private val activityRepo: ActivityRepo, + private val clock: Clock, +) { + companion object { + private const val TAG = "PaykitAllowanceRepo" + + /** A request created up to this long before its Allowance was accepted still counts as created after it. */ + val ACCEPTANCE_CLOCK_TOLERANCE = 30.seconds + + /** Endpoints Bitkit pays automatically: bolt11 and every on-chain method of the network. */ + fun allowedPaymentEndpointIdentifiers(network: Network = Env.network): List = + (listOf(MethodId.Bolt11) + MethodId.entries.filter { it.isOnchain }).map { it.rawValueForNetwork(network) } + + /** The supported receiver paths among [paths], the wallet link first. */ + fun orderedReceiverPaths(paths: Collection): List = + PaykitReceiverPaths.ordered.filter { it in paths } + } + + private val scope = appScope(ioDispatcher, TAG) + private val refreshMutex = Mutex() + private val publishLock = Any() + private val isProcessingRequests = AtomicBoolean(false) + private val manualRequestSignatures = ConcurrentHashMap() + private val _allowances = MutableStateFlow>(emptyList()) + private val _localState = MutableStateFlow(PaykitAllowanceLocalState()) + private val _autoPaidRequestIds = MutableStateFlow>(emptySet()) + private var allowanceTerms: (PaykitAllowanceLimits, Instant, List) -> AllowanceTerms = + { limits, monthAnchor, endpoints -> limits.terms(monthAnchor, endpoints) } + + @Volatile + private var activeIdentity: String? = null + private val _entries = MutableStateFlow>(emptyList()) /** Grants grouped across a contact's links, newest first. */ @@ -31,47 +128,386 @@ class PaykitAllowanceRepo @Inject constructor() { /** Sats paid automatically per entry id, for "Paid so far". */ val autoPaidSats: StateFlow> = _autoPaidSats.asStateFlow() - private val _events = MutableSharedFlow(extraBufferCapacity = 16) - val events: SharedFlow = _events.asSharedFlow() + val events: SharedFlow = executor.events + + internal constructor( + ioDispatcher: CoroutineDispatcher, + paykitSdkService: PaykitSdkService, + executor: PaykitAllowanceExecutor, + lightningRepo: LightningRepo, + activityRepo: ActivityRepo, + clock: Clock, + allowanceTerms: (PaykitAllowanceLimits, Instant, List) -> AllowanceTerms, + ) : this(ioDispatcher, paykitSdkService, executor, lightningRepo, activityRepo, clock) { + this.allowanceTerms = allowanceTerms + } + + init { + scope.launch { executor.events.collect { onAllowanceEvent(it) } } + scope.launch { lightningRepo.nodeEvents.collect { onNodeEvent(it) } } + scope.launch { + lightningRepo.lightningState + .map { it.nodeLifecycleState.isRunning() } + .distinctUntilChanged() + .filter { it } + .collect { executor.settleOpenLightningAttempts() } + } + } fun entry(id: String): PaykitAllowanceEntry? = _entries.value.firstOrNull { it.id == id } - suspend fun activate(identity: String?): Unit = TODO() + // region Lifecycle + + suspend fun activate(identity: String?) { + val normalizedIdentity = identity?.let(PubkyPublicKeyFormat::normalized) + if (normalizedIdentity == null) { + deactivate() + return + } + withContext(ioDispatcher) { + val identityChanged = activeIdentity != normalizedIdentity + activeIdentity = normalizedIdentity + executor.activate(normalizedIdentity) + if (identityChanged) { + manualRequestSignatures.clear() + executor.recover(normalizedIdentity) + } + refresh() + } + } + + fun deactivate() { + activeIdentity = null + executor.activate(null) + manualRequestSignatures.clear() + publish(emptyList(), PaykitAllowanceLocalState()) + } - fun deactivate(): Unit = TODO() + suspend fun refresh(): Result = withContext(ioDispatcher) { + val identity = activeIdentity ?: return@withContext Result.success(Unit) + refreshMutex.withLock { + val listing = runSuspendCatching { + paykitSdkService.listAllowances( + AllowanceFilter( + counterparty = null, + counterpartyReceiverPath = null, + localRole = null, + states = emptyList(), + ), + ) + .filter { + it.historyStatus == AllowanceHistoryStatus.CONSISTENT || + it.historyStatus == AllowanceHistoryStatus.UNRESOLVED_REFERENCES + } + .mapNotNull { PaykitAllowance.from(it) } + }.onFailure { Logger.warn("Failed to list Paykit allowances", it, context = TAG) } + if (activeIdentity == identity) { + publish(listing.getOrDefault(_allowances.value), executor.localState(identity)) + } + listing.map {} + } + } - suspend fun refresh(): Result = TODO() + /** Proposes the same terms on every supported linked receiver path of the contact and records one entry. */ + suspend fun propose(contactPublicKey: String, limits: PaykitAllowanceLimits): Result = + withContext(ioDispatcher) { + runSuspendCatching { + val identity = activeIdentity ?: throw PaykitAllowanceError.Unavailable + val contactKey = PubkyPublicKeyFormat.normalized(contactPublicKey) + ?: throw PaykitAllowanceError.ContactNotLinked + val linkedPaths = paykitSdkService.linkedPeers() + .filter { + PubkyPublicKeyFormat.matches(it.counterparty, contactKey) && it.state == LinkedPeerState.LINKED + } + .map { it.counterpartyReceiverPath } + val receiverPaths = orderedReceiverPaths(linkedPaths) + if (receiverPaths.isEmpty()) throw PaykitAllowanceError.ContactNotLinked - suspend fun propose(contactPublicKey: String, limits: PaykitAllowanceLimits): Result = TODO() + val terms = allowanceTerms( + limits, + PaykitAllowanceTime.monthStart(clock.now()), + allowedPaymentEndpointIdentifiers(), + ) + val allowanceIds = proposeOnLinks(contactKey, receiverPaths, terms) + val group = PaykitAllowanceLocalState.Group( + id = UUID.randomUUID().toString(), + counterparty = contactKey, + limits = limits, + allowanceIds = allowanceIds, + createdAtMillis = clock.now().toEpochMilliseconds(), + ) + executor.updateLocalState(identity) { it.copy(groups = it.groups + group) } + Logger.info("Proposed an allowance on '${allowanceIds.size}' links", context = TAG) + refresh() + Unit + } + } - suspend fun accept(entryId: String): Result = TODO() + suspend fun accept(entryId: String): Result = respond(entryId) { + paykitSdkService.acceptAllowance(it.counterparty, it.counterpartyReceiverPath, it.allowanceId) + } - suspend fun reject(entryId: String): Result = TODO() + suspend fun reject(entryId: String): Result = respond(entryId) { + paykitSdkService.rejectAllowance(it.counterparty, it.counterpartyReceiverPath, it.allowanceId) + } - suspend fun end(entryId: String): Result = TODO() + suspend fun end(entryId: String): Result = withContext(ioDispatcher) { + runSuspendCatching { + val entry = entry(entryId) ?: throw PaykitAllowanceError.Unavailable + val endable = entry.allowances.filter { it.canEnd } + if (endable.isEmpty()) throw PaykitAllowanceError.Unavailable + for (allowance in endable) { + paykitSdkService.endAllowance( + allowance.counterparty, + allowance.counterpartyReceiverPath, + allowance.allowanceId, + ) + sendQueuedMessages(allowance.counterparty, allowance.counterpartyReceiverPath) + } + refresh() + Unit + } + } - fun proposalForPresentation(): PaykitAllowanceEntry? = TODO() + private suspend fun respond( + entryId: String, + response: suspend (PaykitAllowance) -> AllowanceRecord, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + val identity = activeIdentity ?: throw PaykitAllowanceError.Unavailable + val entry = entry(entryId) ?: throw PaykitAllowanceError.Unavailable + val answerable = entry.allowances.filter { it.isAnswerable } + if (answerable.isEmpty()) throw PaykitAllowanceError.Unavailable + for (allowance in answerable) { + response(allowance) + sendQueuedMessages(allowance.counterparty, allowance.counterpartyReceiverPath) + } + val ids = entry.allowances.map { it.allowanceId } + executor.updateLocalState(identity) { it.copy(presentedProposalIds = it.presentedProposalIds + ids) } + refresh() + Unit + } + } - suspend fun markProposalPresented(entryId: String): Unit = TODO() + private suspend fun proposeOnLinks( + contactKey: String, + receiverPaths: List, + terms: AllowanceTerms, + ): List { + val allowanceIds = mutableListOf() + var firstError: Throwable? = null + for (receiverPath in receiverPaths) { + runSuspendCatching { + paykitSdkService.proposeAllowance(contactKey, receiverPath, AllowanceLocalRole.ALLOWER, terms) + }.onSuccess { + allowanceIds += it.allowanceId + sendQueuedMessages(contactKey, receiverPath) + }.onFailure { + if (receiverPath == PaykitReceiverPaths.WALLET) throw it + if (firstError == null) firstError = it + Logger.warn("Failed to propose an allowance on the secondary link '$receiverPath'", it, context = TAG) + } + } + if (allowanceIds.isEmpty()) throw firstError ?: PaykitAllowanceError.Unavailable + return allowanceIds + } + + private suspend fun sendQueuedMessages(counterparty: String, receiverPath: String) { + runSuspendCatching { paykitSdkService.processOutboundPrivateMessages(counterparty, receiverPath) } + .onFailure { + Logger.warn("Failed to send allowance messages on '$receiverPath' right away", it, context = TAG) + } + } + + // endregion + + // region Presentation + + /** The first received proposal that still needs an answer and was not shown yet. */ + fun proposalForPresentation(): PaykitAllowanceEntry? { + val presented = _localState.value.presentedProposalIds + return _entries.value.firstOrNull { entry -> + entry.isAnswerable && entry.allowances.none { it.allowanceId in presented } + } + } + + suspend fun markProposalPresented(entryId: String) { + val identity = activeIdentity ?: return + val ids = entry(entryId)?.allowances?.map { it.allowanceId } ?: return + val state = withContext(ioDispatcher) { + executor.updateLocalState(identity) { it.copy(presentedProposalIds = it.presentedProposalIds + ids) } + } + if (activeIdentity == identity) publish(_allowances.value, state) + } + + // endregion + + // region Automatic payments + + /** + * Whether an active Allowance this wallet granted covers the request's exact link. A request created before the + * Allowance was accepted stays manual: it may already have been shown to the user for a decision. + */ + fun coversRequest(request: PaykitPaymentRequest): Boolean { + val now = clock.now() + return _allowances.value.any { allowance -> + allowance.isAllower && + allowance.status(now) == PaykitAllowance.Status.ACTIVE && + PubkyPublicKeyFormat.matches(allowance.counterparty, request.counterparty) && + allowance.counterpartyReceiverPath == request.counterpartyReceiverPath && + isCreatedAfterAcceptance(request, allowance) + } + } /** Pays covered incoming requests headlessly; returns true when any request was handled. */ - suspend fun processIncomingRequests(requests: List): Boolean = TODO() + suspend fun processIncomingRequests(requests: List): Boolean = withContext(ioDispatcher) { + val identity = activeIdentity ?: return@withContext false + val signature = allowancesSignature() + val covered = requests.filter { + it.requiresAcceptance && coversRequest(it) && manualRequestSignatures[it.id] != signature + } + if (covered.isEmpty() || !isProcessingRequests.compareAndSet(false, true)) return@withContext false - /** True while a request is covered by an active allowance or is being paid automatically: keep it off the Send sheet. */ - suspend fun isAutomaticallyHandling(request: PaykitPaymentRequest): Boolean = TODO() + try { + payCovered(covered, identity, signature) + } finally { + isProcessingRequests.set(false) + } + } + + /** + * True while a request is covered by an active allowance or is being paid automatically: keep it off the Send + * sheet. A covered request counts until processIncomingRequests has found it manual. + */ + suspend fun isAutomaticallyHandling(request: PaykitPaymentRequest): Boolean { + if (executor.isHandling(request.id)) return true + return request.requiresAcceptance && + coversRequest(request) && + manualRequestSignatures[request.id] != allowancesSignature() + } /** Request ids paid automatically, for the "Auto-paid" tag in payment history. */ - fun isAutoPaid(id: PaykitPaymentRequestId): Boolean = TODO() + fun isAutoPaid(id: PaykitPaymentRequestId): Boolean = id in _autoPaidRequestIds.value + + private suspend fun payCovered( + covered: List, + identity: String, + signature: Int, + ): Boolean { + var handledAny = false + for (request in covered) { + val result = executor.autoPay(request, _allowances.value, identity) + Logger.info("Decided '$result' for an incoming request covered by an allowance", context = TAG) + when (result) { + PaykitAllowanceAutoPayResult.STARTED, PaykitAllowanceAutoPayResult.COMPLETED -> handledAny = true + PaykitAllowanceAutoPayResult.MANUAL -> manualRequestSignatures[request.id] = signature + PaykitAllowanceAutoPayResult.NOT_COVERED -> Unit + } + } + if (handledAny) refresh() + return handledAny + } - /** Reports a manual payment of a request to the allowance ledger; returns the attempt id or null when no ledger applies. */ - suspend fun beginManualPayment(request: PaykitPaymentRequest, paymentEndpointIdentifier: String): Result = TODO() + private fun isCreatedAfterAcceptance(request: PaykitPaymentRequest, allowance: PaykitAllowance): Boolean { + val acceptedAt = allowance.lastEventAt ?: return true + val createdAt = request.createdAt ?: return true + return createdAt >= acceptedAt - ACCEPTANCE_CLOCK_TOLERANCE + } - suspend fun manualLightningPaymentSent(attemptId: String, paymentHash: String): Unit = TODO() + private fun allowancesSignature(): Int { + val lifecycle = _allowances.value.map { it.allowanceId to it.lifecycleState } + val autoPaidTotal = _autoPaidSats.value.values.fold(0uL) { total, sats -> total.safe() + sats.safe() } + return listOf(lifecycle, autoPaidTotal).hashCode() + } + + // endregion + + // region Ledger + + /** + * Reports a manual payment of a request to the allowance ledger; returns the attempt id or null when no ledger + * applies. Fails with [PaykitAllowanceError.PaymentAlreadyRecorded] while another attempt for the request is live. + */ + suspend fun beginManualPayment(request: PaykitPaymentRequest, paymentEndpointIdentifier: String): Result = + executor.beginManualPayment(request, paymentEndpointIdentifier) + + suspend fun manualLightningPaymentSent(attemptId: String, paymentHash: String) = + executor.manualLightningPaymentSent(attemptId, paymentHash) /** [succeeded] null = outcome unknown (pending). */ - suspend fun finishManualPayment(attemptId: String, succeeded: Boolean?, transactionId: String? = null): Unit = TODO() + suspend fun finishManualPayment(attemptId: String, succeeded: Boolean?, transactionId: String? = null) { + val outcome = when (succeeded) { + true -> PaymentOutcome.SUCCEEDED + false -> PaymentOutcome.FAILED + null -> PaymentOutcome.UNKNOWN + } + executor.finishManualPayment(attemptId, outcome, transactionId) + } + + /** The repository already settles from the node's payment events; extra calls for the same hash are no-ops. */ + suspend fun lightningPaymentSettled(paymentHash: String, succeeded: Boolean) = + executor.lightningPaymentSettled(paymentHash, succeeded) + + suspend fun recover() { + val identity = activeIdentity ?: return + executor.recover(identity) + } + + private suspend fun onNodeEvent(event: Event) { + when (event) { + is Event.PaymentSuccessful -> executor.lightningPaymentSettled(event.paymentHash, succeeded = true) + is Event.PaymentFailed -> (event.paymentHash ?: event.paymentId)?.let { + executor.lightningPaymentSettled(it, succeeded = false) + } + else -> Unit + } + } + + private suspend fun onAllowanceEvent(event: PaykitAllowanceEvent) { + when (event) { + is PaykitAllowanceEvent.PaidAutomatically -> { + activityRepo.setContact(event.counterparty, event.paymentId) + reloadLocalState() + } + PaykitAllowanceEvent.LedgerChanged -> reloadLocalState() + is PaykitAllowanceEvent.LimitReached -> Unit + } + } + + private fun reloadLocalState() { + val identity = activeIdentity ?: return + publish(_allowances.value, executor.localState(identity)) + } - suspend fun lightningPaymentSettled(paymentHash: String, succeeded: Boolean): Unit = TODO() + // endregion - suspend fun recover(): Unit = TODO() + private fun publish( + allowances: List, + state: PaykitAllowanceLocalState, + ) = synchronized(publishLock) { + val entries = allowances + .groupBy { state.group(containing = it.allowanceId)?.id ?: it.allowanceId } + .map { (id, members) -> + PaykitAllowanceEntry( + id = id, + allowances = members, + limits = state.groups.firstOrNull { it.id == id }?.limits, + ) + } + val paid = state.journal.filter { it.isAutomatic && it.stage == PaykitAllowanceLocalState.Stage.SUCCEEDED } + val paidByAllowance = paid.groupBy { it.allowanceId }.mapValues { (_, payments) -> + payments.fold(0uL) { total, payment -> total.safe() + payment.amountSats.safe() } + } + _allowances.update { allowances } + _localState.update { state } + _autoPaidRequestIds.update { paid.mapTo(mutableSetOf()) { it.requestId } } + _entries.update { entries } + _autoPaidSats.update { + entries.associate { entry -> + entry.id to entry.allowances.fold(0uL) { total, allowance -> + total.safe() + (paidByAllowance[allowance.allowanceId] ?: 0uL).safe() + } + } + } + } } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceStore.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceStore.kt new file mode 100644 index 0000000000..fe485e6639 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceStore.kt @@ -0,0 +1,92 @@ +package to.bitkit.repositories + +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import to.bitkit.data.keychain.Keychain +import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.utils.Logger +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Local Allowance state kept per identity: USD labels, the grouping of one grant across a contact's links, and the + * execution journal that restart recovery reads. The SDK ledger stays authoritative for admission. + */ +@Serializable +data class PaykitAllowanceLocalState( + val groups: List = emptyList(), + val journal: List = emptyList(), + val presentedProposalIds: Set = emptySet(), + val notifiedRequestIds: Set = emptySet(), + val lastTrustedTimeMillis: Long? = null, +) { + @Serializable + data class Group( + val id: String, + val counterparty: String, + val limits: PaykitAllowanceLimits, + val allowanceIds: List, + val createdAtMillis: Long, + ) + + @Serializable + enum class Stage { PREPARED, SUBMITTED, SENDING, SENT, SUCCEEDED, FAILED, UNKNOWN } + + @Serializable + data class JournalEntry( + val attemptId: String, + val isAutomatic: Boolean, + val requestId: PaykitPaymentRequestId, + val allowanceId: String?, + val amountSats: ULong, + val paymentEndpointIdentifier: String, + val paymentHash: String? = null, + val onchainAddress: String? = null, + val transactionId: String? = null, + val stage: Stage, + val createdAtMillis: Long, + ) + + fun group(containing: String): Group? = groups.firstOrNull { containing in it.allowanceIds } +} + +@Singleton +class PaykitAllowanceStore @Inject constructor( + private val keychain: Keychain, +) { + companion object { + private const val TAG = "PaykitAllowanceStore" + private val KEY = Keychain.Key.PAYKIT_ALLOWANCE_STATE.name + private val storeJson = Json { ignoreUnknownKeys = true } + } + + @Serializable + private data class Stored( + val statesByIdentity: Map = emptyMap(), + ) + + fun load(identity: String): PaykitAllowanceLocalState = + runCatching { loadAll().statesByIdentity[storageKey(identity)] } + .onFailure { Logger.warn("Failed to load Paykit allowance state", it, context = TAG) } + .getOrNull() + ?: PaykitAllowanceLocalState() + + suspend fun save(identity: String, state: PaykitAllowanceLocalState) { + val stored = loadAll() + val updated = stored.copy(statesByIdentity = stored.statesByIdentity + (storageKey(identity) to state)) + keychain.upsertString(KEY, storeJson.encodeToString(updated)) + } + + private fun loadAll(): Stored { + val value = keychain.loadString(KEY) ?: return Stored() + return runCatching { storeJson.decodeFromString(value) } + .getOrElse { + Logger.warn("Discarded corrupt Paykit allowance state", it, context = TAG) + Stored() + } + } + + private fun storageKey(identity: String): String = PubkyPublicKeyFormat.normalized(identity) ?: identity +} diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index 7520856c17..3a3776d690 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -68,6 +68,8 @@ data class PendingPaykitPaymentProof( val onchainAmountSats: ULong? = null, val onchainWalletId: String = WalletScope.default, val onchainMatchingTransactionIdsBeforeAttempt: Set = emptySet(), + /** Set only for an automatic Allowance payment, from its submitted attempt. Manual payments omit it. */ + val allowanceId: String? = null, ) data class PaykitOnchainPaymentProofResolution( @@ -137,10 +139,18 @@ class PaykitPaymentProofRepo @Inject constructor( request: PaykitPaymentRequest, paymentEndpointIdentifier: String, kind: PaykitPaymentProofKind, + ): Result = prepare(request, paymentEndpointIdentifier, kind, allowanceId = null) + + /** [allowanceId] is set only for an automatic Allowance payment; the submitted proof carries it. */ + suspend fun prepare( + request: PaykitPaymentRequest, + paymentEndpointIdentifier: String, + kind: PaykitPaymentProofKind, + allowanceId: String?, ): Result = withContext(ioDispatcher) { runSuspendCatching { operationMutex.withLock { - val proof = pendingProof(request, paymentEndpointIdentifier, kind) + val proof = pendingProof(request, paymentEndpointIdentifier, kind).copy(allowanceId = allowanceId) val currentProofs = loadProofs() if (currentProofs.any { it.isStartedFor(proof.identity, request.id) }) { throw PaykitPaymentRequestError.OperationInProgress @@ -533,6 +543,7 @@ class PaykitPaymentProofRepo @Inject constructor( paymentEndpointIdentifier = proof.paymentEndpointIdentifier, proofJson = proofJson, billingPeriod = proof.billingPeriod, + allowanceId = proof.allowanceId, ) Logger.info("Queued a Paykit payment proof for private delivery", context = TAG) runSuspendCatching { paykitSdkService.processPendingPrivateMessages() } diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index f59a22e68e..698e9f8dfb 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -387,6 +387,78 @@ class PrivatePaykitRepo @Inject constructor( result } + /** + * Resolves the payee's current private endpoint for an automatic Allowance payment. Only endpoints that the + * Allowance and the request both accept qualify, and only ones this wallet can pay without asking: a bolt11 + * invoice for exactly the requested amount (or amount-less), or an unused on-chain address. Public endpoints + * are never used. Returns null when nothing qualifies. + */ + suspend fun resolveAllowancePayment( + request: PaykitPaymentRequest, + eligibleIdentifiers: List, + ): Result = withContext(serializedDispatcher) { + runSuspendCatching { + if (request.isExpired(clock.now())) return@runSuspendCatching null + val publicKey = normalizedPublicKey(request.counterparty) ?: return@runSuspendCatching null + val consumedVersion = ensureState().contacts[publicKey] + ?.consumedPrivatePaymentListVersionsByReceiverPath + ?.get(request.counterpartyReceiverPath) + val prepared = paykitSdkService.prepareAndResolvePrivateContactPayment( + counterparty = publicKey, + receiverPath = request.counterpartyReceiverPath, + afterPrivatePaymentListVersion = consumedVersion, + amount = PaymentAmountContext(request.amountValue, PaykitIssuerInterop.BITCOIN_ASSET), + ) + val paymentListVersion = prepared.resolution.privatePaymentListVersion + ?: return@runSuspendCatching null + + val eligible = eligibleIdentifiers.toSet() intersect request.acceptedPaymentEndpointIdentifiers.toSet() + val candidates = prepared.resolution.payableEndpoints + .mapNotNull { PublicPaykitRepo.parseEndpoint(it.identifier, it.payload) } + .filter { + it.methodId.rawValue in eligible && (it.methodId == MethodId.Bolt11 || it.methodId.isOnchain) + } + allowancePayment( + request = request, + payable = privatePayableEndpoints(candidates, publicKey), + context = PrivatePaykitPaymentContext(request.counterpartyReceiverPath, paymentListVersion), + ) + } + } + + private suspend fun allowancePayment( + request: PaykitPaymentRequest, + payable: List, + context: PrivatePaykitPaymentContext, + ): PrivatePaykitAllowancePayment? = PublicPaykitRepo.payablePreferenceOrder + .mapNotNull { methodId -> payable.firstOrNull { it.methodId == methodId } } + .firstNotNullOfOrNull { allowancePayment(request, it, context) } + + private suspend fun allowancePayment( + request: PaykitPaymentRequest, + endpoint: Endpoint, + context: PrivatePaykitPaymentContext, + ): PrivatePaykitAllowancePayment? { + if (endpoint.methodId != MethodId.Bolt11) { + return PrivatePaykitAllowancePayment( + endpoint = endpoint, + context = context, + lightningPaymentHash = null, + lightningInvoiceHasAmount = false, + ) + } + val invoice = runSuspendCatching { (coreService.decode(endpoint.value) as? Scanner.Lightning)?.invoice } + .getOrNull() + ?: return null + if (!request.acceptsLightningInvoiceAmountSats(invoice.amountSatoshis)) return null + return PrivatePaykitAllowancePayment( + endpoint = endpoint, + context = context, + lightningPaymentHash = invoice.paymentHash.toHex().lowercase(), + lightningInvoiceHasAmount = invoice.amountSatoshis != 0uL, + ) + } + suspend fun consumePrivatePaymentList( publicKey: String, context: PrivatePaykitPaymentContext, diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index b55fff7326..c7e1f86ed7 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -86,6 +86,14 @@ data class PrivatePaykitPaymentContext( val paymentListVersion: ULong, ) +/** The payee's current private endpoint for an automatic Allowance payment. */ +data class PrivatePaykitAllowancePayment( + val endpoint: Endpoint, + val context: PrivatePaykitPaymentContext, + val lightningPaymentHash: String?, + val lightningInvoiceHasAmount: Boolean, +) + @OptIn(ExperimentalTime::class) @Suppress("LongParameterList") @Singleton diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt new file mode 100644 index 0000000000..6d364491f1 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt @@ -0,0 +1,798 @@ +package to.bitkit.repositories + +import com.synonym.paykit.AllowanceAccountingBlock +import com.synonym.paykit.AllowanceAccountingReconciliation +import com.synonym.paykit.AllowanceAccountingState +import com.synonym.paykit.AllowanceAssociationRecord +import com.synonym.paykit.AllowanceAssociationRevision +import com.synonym.paykit.AllowanceCandidate +import com.synonym.paykit.AllowanceLifecycleState +import com.synonym.paykit.AllowanceSelectionInput +import com.synonym.paykit.OutboundPrivateSendReport +import com.synonym.paykit.PaymentAttemptDecision +import com.synonym.paykit.PaymentDisposition +import com.synonym.paykit.PaymentExecutionChecks +import com.synonym.paykit.PaymentExecutionMode +import com.synonym.paykit.PaymentExecutionStatus +import com.synonym.paykit.PaymentOccurrence +import com.synonym.paykit.PaymentOccurrenceKey +import com.synonym.paykit.PaymentOccurrenceRecord +import com.synonym.paykit.PaymentOutcome +import com.synonym.paykit.PaymentOutcomeReport +import com.synonym.paykit.PaymentRequestScope +import com.synonym.paykit.PrivateStreamIntakeReport +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import org.junit.Before +import org.junit.Test +import org.lightningdevkit.ldknode.PaymentStatus +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.eq +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.isNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever +import to.bitkit.data.keychain.Keychain +import to.bitkit.repositories.PaykitAllowanceFixtures.WALLET_ALLOWANCE_ID +import to.bitkit.repositories.PaykitAllowanceLocalState.JournalEntry +import to.bitkit.repositories.PaykitAllowanceLocalState.Stage +import to.bitkit.services.PaykitReceiverPaths +import to.bitkit.services.PaykitSdkService +import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AppError +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Instant + +@Suppress("LargeClass") +class PaykitAllowanceExecutorTest : BaseUnitTest() { + companion object { + private const val AUTOMATIC_ATTEMPT_ID = "attempt-1" + private const val MANUAL_ATTEMPT_ID = "manual-1" + private const val INVOICE = "lnbcrt10u1allowancetestinvoice" + private const val ONCHAIN_ADDRESS = "bcrt1qallowancetestaddress" + private val PAYMENT_HASH = "ab".repeat(32) + private val TXID = "c".repeat(64) + } + + private val fixtures = PaykitAllowanceFixtures + private val identity = fixtures.identityKey + private val sdk = mock() + private val payer = mock() + private val keychain = mock() + private var storedState: String? = null + private var now = fixtures.now + private val clock = object : Clock { + override fun now(): Instant = this@PaykitAllowanceExecutorTest.now + } + + private var accountingState: AllowanceAccountingState? = fixtures.accountingState() + private var candidates = listOf(candidate()) + private var automaticReservation: PaymentAttemptDecision? = null + private var manualReservation: PaymentAttemptDecision? = null + private var beginDecision: PaymentAttemptDecision? = null + private val evaluatedTrustedTimes = mutableListOf() + private val selections = mutableListOf() + private val acceptedChecks = mutableListOf() + private val reservedAssociationRevisions = mutableListOf() + private val recordedOutcomes = mutableListOf() + private val reconciliations = mutableListOf() + private val nodeStatuses = mutableMapOf() + private val events = mutableListOf() + private lateinit var sut: PaykitAllowanceExecutor + + @Before + fun setUp() = test { + stubLedger() + stubAttempts() + stubPayer() + val amount = fixtures.accountingAmount("0.00001") + sut = PaykitAllowanceExecutor( + ioDispatcher = testDispatcher, + paykitSdkService = sdk, + store = PaykitAllowanceStore(keychain), + payer = payer, + clock = clock, + accountingAmount = { _, _ -> amount }, + ) + } + + private suspend fun stubLedger() { + whenever(keychain.loadString(any())).thenAnswer { storedState } + whenever(keychain.upsertString(any(), any())).doSuspendableAnswer { storedState = it.getArgument(1) } + + whenever(sdk.allowanceAccountingState()).thenAnswer { accountingState } + whenever(sdk.reconcileAllowanceAccounting(any())).doSuspendableAnswer { + val reconciliation = it.getArgument(0) + reconciliations += reconciliation + AllowanceAccountingState( + revision = (reconciliation.expectedRevision ?: 0uL) + 1uL, + epoch = accountingState?.epoch ?: "epoch-1", + requiresReconciliation = false, + history = reconciliation.history, + ).also { reconciled -> accountingState = reconciled } + } + whenever(sdk.evaluateAllowanceCandidates(any(), any())).doSuspendableAnswer { + evaluatedTrustedTimes += it.getArgument(1) + candidates + } + whenever(sdk.acceptPaymentRequestAutomatically(any(), any(), any())).doSuspendableAnswer { + val selection = it.getArgument(1) + selections += selection + acceptedChecks += it.getArgument(2) + AllowanceAssociationRecord( + request = fixtures.accountingScope(it.getArgument(0).paymentRequestId), + revisions = listOf( + AllowanceAssociationRevision( + revision = 1uL, + allowanceId = selection.allowanceId, + effectiveFrom = null, + authorizationId = null, + authorizedAt = selection.trustedTime, + ), + ), + ) + } + whenever(sdk.processOutboundPrivateMessages(any(), any())) + .thenReturn(OutboundPrivateSendReport(emptyList(), emptyList(), emptyList(), emptyList(), emptyList())) + whenever(sdk.receivePrivateMessages(any(), any())) + .thenReturn(PrivateStreamIntakeReport(null, emptyList(), emptyList())) + } + + private suspend fun stubAttempts() { + val preparedAutomatic = PaymentAttemptDecision.Ready( + fixtures.attemptRecord(AUTOMATIC_ATTEMPT_ID, status = PaymentExecutionStatus.PREPARED), + ) + val submittedAutomatic = PaymentAttemptDecision.Ready( + fixtures.attemptRecord(AUTOMATIC_ATTEMPT_ID, status = PaymentExecutionStatus.SUBMITTED), + ) + val preparedManual = PaymentAttemptDecision.Ready( + fixtures.attemptRecord( + MANUAL_ATTEMPT_ID, + mode = PaymentExecutionMode.MANUAL, + allowanceId = null, + status = PaymentExecutionStatus.PREPARED, + ), + ) + val submittedManual = PaymentAttemptDecision.Ready( + fixtures.attemptRecord( + MANUAL_ATTEMPT_ID, + mode = PaymentExecutionMode.MANUAL, + allowanceId = null, + status = PaymentExecutionStatus.SUBMITTED, + ), + ) + val recordedAttempt = fixtures.attemptRecord(AUTOMATIC_ATTEMPT_ID, status = PaymentExecutionStatus.SUCCEEDED) + + whenever(sdk.reserveAutomaticPayment(any(), any(), any())).doSuspendableAnswer { + reservedAssociationRevisions += (it.arguments[1] as Long).toULong() + automaticReservation ?: preparedAutomatic + } + whenever(sdk.reserveManualPayment(any(), any())).doSuspendableAnswer { manualReservation ?: preparedManual } + whenever(sdk.beginPaymentExecution(any(), any())).doSuspendableAnswer { + beginDecision ?: if (it.getArgument(0) == MANUAL_ATTEMPT_ID) submittedManual else submittedAutomatic + } + whenever(sdk.recordPaymentOutcome(any())).doSuspendableAnswer { + recordedOutcomes += it.getArgument(0) + recordedAttempt + } + whenever(sdk.markPaymentManualOnly(any())).doSuspendableAnswer { + PaymentOccurrenceRecord( + key = PaymentOccurrenceKey(fixtures.accountingScope("manual-only"), billingPeriod = null), + disposition = PaymentDisposition.ManualOnly, + allowanceId = null, + associationRevision = null, + attempts = emptyList(), + ) + } + } + + private suspend fun stubPayer() { + whenever(payer.resolve(any(), any())).thenReturn(Result.success(lightningPayment())) + whenever(payer.consumePaymentList(any(), any())).thenReturn(Result.success(Unit)) + whenever(payer.prepareProof(any(), any(), anyOrNull())).thenReturn(Result.success(Unit)) + whenever(payer.associateLightningPayment(any(), any(), any())).thenReturn(Result.success(Unit)) + whenever(payer.markOnchainPaymentStarted(any(), any())).thenReturn(Result.success(Unit)) + whenever(payer.payLightning(any(), anyOrNull())).thenReturn(Result.success(PAYMENT_HASH)) + whenever(payer.payOnchain(any(), any())).thenReturn(Result.success(TXID)) + whenever(payer.failLightningPayment(any(), any())).thenReturn(true) + whenever(payer.lightningPaymentStatus(any())).thenAnswer { nodeStatuses[it.getArgument(0)] } + } + + // region Admission + + @Test + fun `uncovered request never reaches the SDK`() = test { + val request = fixtures.paymentRequest() + val uncovering = listOf( + emptyList(), + listOf(fixtures.allowance(role = PaykitAllowance.Role.ALLOWEE)), + listOf(fixtures.allowance(state = AllowanceLifecycleState.PROPOSED)), + listOf(fixtures.allowance(state = AllowanceLifecycleState.ENDED)), + listOf(fixtures.allowance(receiverPath = PaykitReceiverPaths.SERVER)), + listOf(fixtures.allowance(counterparty = fixtures.otherCounterpartyKey)), + ) + + for (allowances in uncovering) { + assertEquals(PaykitAllowanceAutoPayResult.NOT_COVERED, sut.autoPay(request, allowances, identity)) + } + verifyNoInteractions(sdk, payer, keychain) + } + + @Test + fun `covered lightning request runs admission in order and hands off to the node`() = test { + val request = fixtures.paymentRequest() + + val result = sut.autoPay(request, listOf(fixtures.allowance()), identity) + + assertEquals(PaykitAllowanceAutoPayResult.STARTED, result) + val order = inOrder(sdk, payer) + order.verify(sdk).evaluateAllowanceCandidates(any(), any()) + order.verify(payer).resolve(eq(request), eq(listOf(fixtures.lightningIdentifier))) + order.verify(sdk).acceptPaymentRequestAutomatically(any(), any(), any()) + order.verify(sdk).reserveAutomaticPayment(any(), any(), any()) + order.verify(sdk).receivePrivateMessages(request.counterparty, request.counterpartyReceiverPath) + order.verify(sdk).beginPaymentExecution(eq(AUTOMATIC_ATTEMPT_ID), any()) + order.verify(payer).consumePaymentList(request.counterparty, lightningPayment().context) + order.verify(payer).prepareProof(request, fixtures.lightningIdentifier, WALLET_ALLOWANCE_ID) + order.verify(payer).associateLightningPayment(request, PAYMENT_HASH, fixtures.lightningIdentifier) + order.verify(payer).payLightning(eq(INVOICE), isNull()) + verify(sdk, never()).recordPaymentOutcome(any()) + assertEquals(listOf(PaykitAllowanceTime.format(fixtures.now)), evaluatedTrustedTimes) + assertEquals(listOf(WALLET_ALLOWANCE_ID), selections.map { it.allowanceId }) + assertEquals(listOf(fixtures.lightningIdentifier), acceptedChecks.map { it.paymentEndpointIdentifier }) + assertEquals(listOf(1uL), reservedAssociationRevisions) + + val entry = sut.localState(identity).journal.single() + assertEquals(AUTOMATIC_ATTEMPT_ID, entry.attemptId) + assertEquals(Stage.SENT, entry.stage) + assertTrue(entry.isAutomatic) + assertEquals(request.id, entry.requestId) + assertEquals(WALLET_ALLOWANCE_ID, entry.allowanceId) + assertEquals(1_000uL, entry.amountSats) + assertEquals(PAYMENT_HASH, entry.paymentHash) + assertEquals(fixtures.lightningIdentifier, entry.paymentEndpointIdentifier) + assertFalse(sut.isHandling(request.id)) + } + + @Test + fun `node settlement that arrives before the send call returns is kept`() = test { + sut.activate(identity) + whenever(payer.payLightning(any(), anyOrNull())).doSuspendableAnswer { + sut.lightningPaymentSettled(PAYMENT_HASH, succeeded = true) + Result.success(PAYMENT_HASH) + } + + val result = sut.autoPay(fixtures.paymentRequest(), listOf(fixtures.allowance()), identity) + + assertEquals(PaykitAllowanceAutoPayResult.STARTED, result) + assertEquals(Stage.SUCCEEDED, sut.localState(identity).journal.single().stage) + assertEquals(listOf(PaymentOutcomeReport(AUTOMATIC_ATTEMPT_ID, PaymentOutcome.SUCCEEDED)), recordedOutcomes) + } + + @Test + fun `amount-less invoice is paid exactly the requested amount`() = test { + whenever(payer.resolve(any(), any())) + .thenReturn(Result.success(lightningPayment().copy(lightningInvoiceHasAmount = false))) + + sut.autoPay(fixtures.paymentRequest(), listOf(fixtures.allowance()), identity) + + verify(payer).payLightning(eq(INVOICE), eq(1_000uL)) + } + + @Test + fun `blocked candidate stays manual without acceptance`() = test { + candidates = listOf(candidate(blocked = AllowanceAccountingBlock.SharedRule("amount_outside_range"))) + + val result = sut.autoPay(fixtures.paymentRequest(), listOf(fixtures.allowance()), identity) + + assertEquals(PaykitAllowanceAutoPayResult.MANUAL, result) + verify(sdk).evaluateAllowanceCandidates(any(), any()) + verify(sdk, never()).acceptPaymentRequestAutomatically(any(), any(), any()) + verify(sdk, never()).reserveAutomaticPayment(any(), any(), any()) + verifyNoInteractions(payer) + } + + @Test + fun `over the monthly cap stays manual and notifies once`() = test { + collectEvents() + accountingState = stateNearTheMonthlyCap() + val request = fixtures.paymentRequest() + + val first = sut.autoPay(request, listOf(fixtures.allowance()), identity) + val second = sut.autoPay(request, listOf(fixtures.allowance()), identity) + val limitReached: PaykitAllowanceEvent = PaykitAllowanceEvent.LimitReached(fixtures.counterpartyKey, 1_000uL) + + assertEquals(PaykitAllowanceAutoPayResult.MANUAL, first) + assertEquals(PaykitAllowanceAutoPayResult.MANUAL, second) + verify(sdk, never()).acceptPaymentRequestAutomatically(any(), any(), any()) + verify(sdk, never()).reserveAutomaticPayment(any(), any(), any()) + verifyNoInteractions(payer) + assertEquals(listOf(limitReached), events) + } + + @Test + fun `blocked reservation marks the payment manual only`() = test { + collectEvents() + automaticReservation = PaymentAttemptDecision.Blocked( + AllowanceAccountingBlock.SharedRule("period_amount_limit"), + ) + val request = fixtures.paymentRequest() + + val result = sut.autoPay(request, listOf(fixtures.allowance()), identity) + + assertEquals(PaykitAllowanceAutoPayResult.MANUAL, result) + val limitReached: PaykitAllowanceEvent = PaykitAllowanceEvent.LimitReached(fixtures.counterpartyKey, 1_000uL) + val scope = PaymentRequestScope( + counterparty = request.counterparty, + counterpartyReceiverPath = request.counterpartyReceiverPath, + paymentRequestId = request.paymentRequestId, + ) + verify(sdk).markPaymentManualOnly(PaymentOccurrence(scope, billingPeriod = null)) + verify(sdk, never()).beginPaymentExecution(any(), any()) + verify(payer, never()).payLightning(any(), anyOrNull()) + assertEquals(emptyList(), sut.localState(identity).journal) + assertEquals(listOf(limitReached), events) + } + + @Test + fun `blocked begin records a failed outcome`() = test { + beginDecision = PaymentAttemptDecision.Blocked(AllowanceAccountingBlock.ManualOnly) + + val result = sut.autoPay(fixtures.paymentRequest(), listOf(fixtures.allowance()), identity) + + assertEquals(PaykitAllowanceAutoPayResult.MANUAL, result) + assertEquals(listOf(PaymentOutcomeReport(AUTOMATIC_ATTEMPT_ID, PaymentOutcome.FAILED)), recordedOutcomes) + assertEquals(listOf(Stage.FAILED), sut.localState(identity).journal.map { it.stage }) + verify(payer, never()).consumePaymentList(any(), any()) + verify(payer, never()).prepareProof(any(), any(), anyOrNull()) + verify(payer, never()).payLightning(any(), anyOrNull()) + } + + @Test + fun `failed proof preparation releases the attempt before any payment`() = test { + whenever(payer.prepareProof(any(), any(), anyOrNull())) + .thenReturn(Result.failure(PaykitPaymentRequestError.RequestUnavailable)) + val request = fixtures.paymentRequest() + + val result = sut.autoPay(request, listOf(fixtures.allowance()), identity) + + assertEquals(PaykitAllowanceAutoPayResult.MANUAL, result) + verify(payer).cancelProofPreparation(request) + assertEquals(listOf(PaymentOutcomeReport(AUTOMATIC_ATTEMPT_ID, PaymentOutcome.FAILED)), recordedOutcomes) + verify(payer, never()).payLightning(any(), anyOrNull()) + } + + @Test + fun `lightning send failure releases only a payment that never left`() = test { + whenever(payer.payLightning(any(), anyOrNull())).thenReturn(Result.failure(TestAllowanceError("send"))) + whenever(payer.failLightningPayment(any(), any())).thenReturn(true, false) + + val first = sut.autoPay(fixtures.paymentRequest(id = "request-1"), listOf(fixtures.allowance()), identity) + val second = sut.autoPay(fixtures.paymentRequest(id = "request-2"), listOf(fixtures.allowance()), identity) + + assertEquals(PaykitAllowanceAutoPayResult.MANUAL, first) + assertEquals(PaykitAllowanceAutoPayResult.MANUAL, second) + assertEquals( + listOf(PaymentOutcome.FAILED, PaymentOutcome.UNKNOWN), + recordedOutcomes.map { it.outcome }, + ) + assertEquals(Stage.UNKNOWN, sut.localState(identity).journal.single().stage) + } + + @Test + fun `covered on-chain request completes and reports the payment`() = test { + collectEvents() + whenever(payer.resolve(any(), any())).thenReturn(Result.success(onchainPayment())) + val request = fixtures.paymentRequest() + + val result = sut.autoPay(request, listOf(fixtures.allowance()), identity) + + assertEquals(PaykitAllowanceAutoPayResult.COMPLETED, result) + val order = inOrder(payer, sdk) + order.verify(payer).prepareProof(request, fixtures.onchainIdentifier, WALLET_ALLOWANCE_ID) + order.verify(payer).markOnchainPaymentStarted(request, ONCHAIN_ADDRESS) + order.verify(payer).payOnchain(eq(ONCHAIN_ADDRESS), any()) + order.verify(payer).completeOnchainPayment(request, TXID, fixtures.onchainIdentifier) + order.verify(sdk).recordPaymentOutcome(PaymentOutcomeReport(AUTOMATIC_ATTEMPT_ID, PaymentOutcome.SUCCEEDED)) + val entry = sut.localState(identity).journal.single() + assertEquals(Stage.SUCCEEDED, entry.stage) + assertEquals(TXID, entry.transactionId) + assertEquals(ONCHAIN_ADDRESS, entry.onchainAddress) + assertNull(entry.paymentHash) + assertTrue(PaykitAllowanceEvent.PaidAutomatically(fixtures.counterpartyKey, 1_000uL, TXID) in events) + assertEquals(listOf(AUTOMATIC_ATTEMPT_ID), sut.succeededAutomaticPayments(identity).map { it.attemptId }) + } + + @Test + fun `on-chain send failure keeps the attempt reserved unless nothing was broadcast`() = test { + whenever(payer.resolve(any(), any())).thenReturn(Result.success(onchainPayment())) + whenever(payer.payOnchain(any(), any())).thenReturn( + Result.failure(PaykitAllowanceOnchainSendError(true, TestAllowanceError("funds"))), + Result.failure(PaykitAllowanceOnchainSendError(false, TestAllowanceError("timeout"))), + ) + val definite = fixtures.paymentRequest(id = "request-1") + val uncertain = fixtures.paymentRequest(id = "request-2") + + sut.autoPay(definite, listOf(fixtures.allowance()), identity) + sut.autoPay(uncertain, listOf(fixtures.allowance()), identity) + + assertEquals(listOf(PaymentOutcome.FAILED, PaymentOutcome.UNKNOWN), recordedOutcomes.map { it.outcome }) + verify(payer).failOnchainPayment(definite) + verify(payer, never()).failOnchainPayment(uncertain) + verify(payer, never()).completeOnchainPayment(any(), any(), any()) + } + + // endregion + + // region Settlement and recovery + + @Test + fun `lightning settlement records success for the journaled attempt`() = test { + collectEvents() + val request = fixtures.paymentRequest() + seedJournal(journalEntry("attempt-1", request, Stage.SENT, paymentHash = PAYMENT_HASH)) + sut.activate(identity) + + sut.lightningPaymentSettled("f".repeat(64), succeeded = true) + assertEquals(emptyList(), recordedOutcomes) + + sut.lightningPaymentSettled(PAYMENT_HASH.uppercase(), succeeded = true) + sut.lightningPaymentSettled(PAYMENT_HASH, succeeded = true) + + assertEquals(listOf(PaymentOutcomeReport("attempt-1", PaymentOutcome.SUCCEEDED)), recordedOutcomes) + assertEquals(listOf(Stage.SUCCEEDED), sut.localState(identity).journal.map { it.stage }) + assertEquals(listOf("attempt-1"), sut.succeededAutomaticPayments(identity).map { it.attemptId }) + assertEquals( + listOf(PaykitAllowanceEvent.PaidAutomatically(fixtures.counterpartyKey, 1_000uL, PAYMENT_HASH)), + events.filterIsInstance(), + ) + verify(payer, never()).payLightning(any(), anyOrNull()) + verify(payer, never()).payOnchain(any(), any()) + } + + @Test + fun `open lightning attempts settle from the node's payment status`() = test { + val paidHash = "1".repeat(64) + val pendingHash = "2".repeat(64) + val request = fixtures.paymentRequest() + seedJournal( + journalEntry("paid", request, Stage.UNKNOWN, paymentHash = paidHash), + journalEntry("pending", request, Stage.SENT, paymentHash = pendingHash), + ) + nodeStatuses[paidHash] = PaymentStatus.SUCCEEDED + nodeStatuses[pendingHash] = PaymentStatus.PENDING + sut.activate(identity) + + sut.settleOpenLightningAttempts() + + assertEquals(listOf(PaymentOutcomeReport("paid", PaymentOutcome.SUCCEEDED)), recordedOutcomes) + } + + @Test + fun `recovery resolves open attempts without paying again`() = test { + val request = fixtures.paymentRequest() + val paidHash = "1".repeat(64) + val pendingHash = "2".repeat(64) + accountingState = fixtures.accountingState( + revision = 4uL, + occurrences = listOf( + occurrence("prepared", PaymentExecutionStatus.PREPARED), + occurrence("old-epoch", PaymentExecutionStatus.PREPARED, epoch = "epoch-0"), + occurrence("never-sent", PaymentExecutionStatus.SUBMITTED), + occurrence("sent-paid", PaymentExecutionStatus.SUBMITTED), + occurrence("sent-pending", PaymentExecutionStatus.SUBMITTED), + occurrence("done", PaymentExecutionStatus.SUCCEEDED), + ), + ) + seedJournal( + journalEntry("prepared", request, Stage.PREPARED), + journalEntry("never-sent", request, Stage.SUBMITTED), + journalEntry("sent-paid", request, Stage.SENT, paymentHash = paidHash), + journalEntry("sent-pending", request, Stage.SENT, paymentHash = pendingHash), + ) + nodeStatuses[paidHash] = PaymentStatus.SUCCEEDED + nodeStatuses[pendingHash] = PaymentStatus.PENDING + + sut.recover(identity) + + val expected = mapOf( + "prepared" to PaymentOutcome.FAILED, + "never-sent" to PaymentOutcome.FAILED, + "sent-paid" to PaymentOutcome.SUCCEEDED, + "sent-pending" to PaymentOutcome.UNKNOWN, + ) + assertEquals(expected, recordedOutcomes.associate { it.attemptId to it.outcome }) + assertEquals(4, recordedOutcomes.size) + assertEquals( + mapOf( + "prepared" to Stage.FAILED, + "never-sent" to Stage.FAILED, + "sent-paid" to Stage.SUCCEEDED, + "sent-pending" to Stage.UNKNOWN, + ), + sut.localState(identity).journal.associate { it.attemptId to it.stage }, + ) + verify(payer, never()).payLightning(any(), anyOrNull()) + verify(payer, never()).payOnchain(any(), any()) + verify(payer, never()).resolve(any(), any()) + assertEquals(emptyList(), reconciliations) + } + + @Test + fun `recovery leaves a wallet without a ledger untouched`() = test { + accountingState = null + + sut.recover(identity) + + verify(sdk).allowanceAccountingState() + verify(sdk, never()).reconcileAllowanceAccounting(any()) + verify(sdk, never()).recordPaymentOutcome(any()) + } + + @Test + fun `recovery leaves a manual payment in progress alone`() = test { + sut.activate(identity) + val attemptId = sut.beginManualPayment(fixtures.paymentRequest(), fixtures.lightningIdentifier).getOrThrow() + accountingState = fixtures.accountingState( + occurrences = listOf(occurrence(MANUAL_ATTEMPT_ID, PaymentExecutionStatus.SUBMITTED)), + ) + + sut.recover(identity) + assertEquals(emptyList(), recordedOutcomes) + + sut.finishManualPayment(checkNotNull(attemptId), PaymentOutcome.SUCCEEDED) + assertEquals(listOf(PaymentOutcomeReport(MANUAL_ATTEMPT_ID, PaymentOutcome.SUCCEEDED)), recordedOutcomes) + } + + // endregion + + // region Manual payments + + @Test + fun `manual payment fails when the request is already recorded`() = test { + sut.activate(identity) + manualReservation = PaymentAttemptDecision.Blocked(AllowanceAccountingBlock.PaymentAlreadyRecorded) + + val result = sut.beginManualPayment(fixtures.paymentRequest(), fixtures.lightningIdentifier) + + assertIs(result.exceptionOrNull()) + verify(sdk, never()).beginPaymentExecution(any(), any()) + } + + @Test + fun `manual payment is journaled and submitted when ready`() = test { + sut.activate(identity) + val request = fixtures.paymentRequest() + + val attemptId = sut.beginManualPayment(request, fixtures.lightningIdentifier).getOrThrow() + + assertEquals(MANUAL_ATTEMPT_ID, attemptId) + val entry = sut.localState(identity).journal.single() + assertEquals(Stage.SUBMITTED, entry.stage) + assertFalse(entry.isAutomatic) + assertNull(entry.allowanceId) + + sut.manualLightningPaymentSent(MANUAL_ATTEMPT_ID, PAYMENT_HASH.uppercase()) + assertEquals(PAYMENT_HASH, sut.localState(identity).journal.single().paymentHash) + assertEquals(Stage.SENT, sut.localState(identity).journal.single().stage) + + manualReservation = PaymentAttemptDecision.Blocked(AllowanceAccountingBlock.ManualOnly) + assertNull(sut.beginManualPayment(request, fixtures.lightningIdentifier).getOrThrow()) + } + + @Test + fun `manual lightning attempt settled by the node is recorded once and never counts as automatic`() = test { + collectEvents() + sut.activate(identity) + val attemptId = checkNotNull( + sut.beginManualPayment(fixtures.paymentRequest(), fixtures.lightningIdentifier).getOrThrow(), + ) + sut.manualLightningPaymentSent(attemptId, PAYMENT_HASH) + + sut.lightningPaymentSettled(PAYMENT_HASH, succeeded = true) + sut.finishManualPayment(attemptId, PaymentOutcome.SUCCEEDED) + + assertEquals(listOf(PaymentOutcomeReport(MANUAL_ATTEMPT_ID, PaymentOutcome.SUCCEEDED)), recordedOutcomes) + assertEquals(emptyList(), events.filterIsInstance()) + assertEquals(emptyList(), sut.succeededAutomaticPayments(identity)) + } + + @Test + fun `manual payment is not reported without an identity or for an outgoing request`() = test { + assertNull(sut.beginManualPayment(fixtures.paymentRequest(), fixtures.lightningIdentifier).getOrThrow()) + + sut.activate(identity) + val outgoing = fixtures.paymentRequest().copy(direction = PaykitPaymentRequestDirection.Outgoing) + assertNull(sut.beginManualPayment(outgoing, fixtures.lightningIdentifier).getOrThrow()) + verifyNoInteractions(sdk) + } + + // endregion + + // region Trusted time and reconciliation + + @Test + fun `trusted time never moves backwards`() = test { + val start = fixtures.now + + val first = sut.trustedTime(identity) + now = start - 1.hours + val afterClockMovedBack = sut.trustedTime(identity) + now = start + 1.minutes + val afterClockMovedForward = sut.trustedTime(identity) + + assertEquals(PaykitAllowanceTime.format(start), first) + assertEquals(PaykitAllowanceTime.format(start), afterClockMovedBack) + assertEquals(PaykitAllowanceTime.format(start + 1.minutes), afterClockMovedForward) + assertEquals((start + 1.minutes).toEpochMilliseconds(), sut.localState(identity).lastTrustedTimeMillis) + } + + @Test + fun `missing ledger is reconciled with an empty history`() = test { + accountingState = null + + val reconciled = sut.ensureReconciled(identity) + sut.ensureReconciled(identity) + + val reconciliation = reconciliations.single() + assertNull(reconciliation.expectedRevision) + assertTrue(reconciliation.history.associations.isEmpty()) + assertTrue(reconciliation.history.occurrences.isEmpty()) + assertTrue(reconciliation.history.watermarks.isEmpty()) + assertEquals(emptyList(), reconciliation.outcomes) + assertEquals(PaykitAllowanceTime.format(fixtures.now), reconciliation.trustedTime) + assertFalse(reconciled.requiresReconciliation) + } + + @Test + fun `ledger that requires reconciliation is reconciled at its revision`() = test { + val request = fixtures.paymentRequest() + val paidHash = "3".repeat(64) + accountingState = fixtures.accountingState( + revision = 7uL, + requiresReconciliation = true, + occurrences = listOf( + occurrence("prepared", PaymentExecutionStatus.PREPARED), + occurrence("sent-paid", PaymentExecutionStatus.SUBMITTED), + ), + ) + seedJournal(journalEntry("sent-paid", request, Stage.SENT, paymentHash = paidHash)) + nodeStatuses[paidHash] = PaymentStatus.SUCCEEDED + + sut.ensureReconciled(identity) + + val reconciliation = reconciliations.single() + assertEquals(7uL, reconciliation.expectedRevision) + assertEquals( + listOf("prepared", "sent-paid"), + reconciliation.history.occurrences.flatMap { it.attempts }.map { it.attemptId }, + ) + assertEquals( + listOf( + PaymentOutcomeReport("prepared", PaymentOutcome.FAILED), + PaymentOutcomeReport("sent-paid", PaymentOutcome.SUCCEEDED), + ), + reconciliation.outcomes, + ) + verify(payer, never()).payLightning(any(), anyOrNull()) + verify(payer, never()).payOnchain(any(), any()) + } + + @Test + fun `admission uses the injected clock for the monthly window`() = test { + accountingState = stateNearTheMonthlyCap() + now = Instant.parse("2026-10-02T12:00:00Z") + + val result = sut.autoPay(fixtures.paymentRequest(), listOf(fixtures.allowance()), identity) + + assertEquals(PaykitAllowanceAutoPayResult.STARTED, result, "September's attempts must not count in October") + assertEquals(listOf(PaykitAllowanceTime.format(now)), evaluatedTrustedTimes) + } + + // endregion + + // region Helpers + + private fun TestScope.collectEvents() { + backgroundScope.launch { sut.events.collect { events += it } } + } + + private fun candidate(blocked: AllowanceAccountingBlock? = null) = AllowanceCandidate( + allowanceId = WALLET_ALLOWANCE_ID, + eligiblePaymentEndpointIdentifiers = listOf(PaykitAllowanceFixtures.lightningIdentifier), + blocked = blocked, + ) + + private fun lightningPayment() = PrivatePaykitAllowancePayment( + endpoint = Endpoint( + methodId = MethodId.Bolt11, + value = INVOICE, + rawPayload = """{"value":"$INVOICE"}""", + ), + context = PrivatePaykitPaymentContext(PaykitReceiverPaths.WALLET, 3uL), + lightningPaymentHash = PAYMENT_HASH, + lightningInvoiceHasAmount = true, + ) + + private fun onchainPayment() = PrivatePaykitAllowancePayment( + endpoint = Endpoint( + methodId = MethodId.P2wpkh, + value = ONCHAIN_ADDRESS, + rawPayload = """{"value":"$ONCHAIN_ADDRESS"}""", + ), + context = PrivatePaykitPaymentContext(PaykitReceiverPaths.WALLET, 3uL), + lightningPaymentHash = null, + lightningInvoiceHasAmount = false, + ) + + /** Three succeeded automatic payments this month total 49,500 sats of the 50,000 sat cap. */ + private fun stateNearTheMonthlyCap() = fixtures.accountingState( + occurrences = listOf( + fixtures.occurrence( + "paid-1", + listOf(paidAttempt("paid-1", "0.0002", "2026-09-05T10:00:00Z")), + ), + fixtures.occurrence( + "paid-2", + listOf(paidAttempt("paid-2", "0.0002", "2026-09-12T10:00:00Z")), + ), + fixtures.occurrence( + "paid-3", + listOf(paidAttempt("paid-3", "0.000095", "2026-09-20T10:00:00Z")), + ), + ), + ) + + private fun paidAttempt(id: String, amount: String, admittedAt: String) = fixtures.attemptRecord( + id = id, + amount = amount, + admittedAt = admittedAt, + status = PaymentExecutionStatus.SUCCEEDED, + ) + + private fun occurrence( + attemptId: String, + status: PaymentExecutionStatus, + epoch: String = "epoch-1", + ) = fixtures.occurrence( + requestId = "req-$attemptId", + attempts = listOf(fixtures.attemptRecord(id = attemptId, status = status, epoch = epoch)), + ) + + private fun journalEntry( + attemptId: String, + request: PaykitPaymentRequest, + stage: Stage, + paymentHash: String? = null, + ) = JournalEntry( + attemptId = attemptId, + isAutomatic = true, + requestId = request.id, + allowanceId = WALLET_ALLOWANCE_ID, + amountSats = request.amountSats, + paymentEndpointIdentifier = fixtures.lightningIdentifier, + paymentHash = paymentHash, + stage = stage, + createdAtMillis = fixtures.now.toEpochMilliseconds(), + ) + + private suspend fun seedJournal(vararg entries: JournalEntry) { + sut.updateLocalState(identity) { it.copy(journal = entries.toList()) } + } + + // endregion +} + +private class TestAllowanceError(message: String) : AppError(message) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt new file mode 100644 index 0000000000..d00148cd36 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt @@ -0,0 +1,500 @@ +package to.bitkit.repositories + +import com.synonym.paykit.AllowanceHistoryStatus +import com.synonym.paykit.AllowanceLifecycleState +import com.synonym.paykit.AllowanceLocalRole +import com.synonym.paykit.AllowanceRecord +import com.synonym.paykit.LinkedPeerRecord +import com.synonym.paykit.LinkedPeerState +import com.synonym.paykit.OutboundPrivateSendReport +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import org.junit.Before +import org.junit.Test +import org.lightningdevkit.ldknode.Event +import org.lightningdevkit.ldknode.PaymentFailureReason +import org.mockito.kotlin.any +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.models.NodeLifecycleState +import to.bitkit.repositories.PaykitAllowanceFixtures.SERVER_ALLOWANCE_ID +import to.bitkit.repositories.PaykitAllowanceFixtures.WALLET_ALLOWANCE_ID +import to.bitkit.repositories.PaykitAllowanceLocalState.Stage +import to.bitkit.services.PaykitReceiverPaths +import to.bitkit.services.PaykitSdkService +import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AppError +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Duration.Companion.days +import kotlin.time.Instant + +class PaykitAllowanceRepoTest : BaseUnitTest() { + companion object { + private val PAYMENT_HASH = "ab".repeat(32) + private val TXID = "c".repeat(64) + } + + private val fixtures = PaykitAllowanceFixtures + private val identity = fixtures.identityKey + private val sdk = mock() + private val executor = mock() + private val lightningRepo = mock() + private val activityRepo = mock() + private val executorEvents = MutableSharedFlow(extraBufferCapacity = 8) + private val nodeEvents = MutableSharedFlow(extraBufferCapacity = 8) + private val lightningState = MutableStateFlow(LightningState()) + private val terms = fixtures.terms() + private val clock = object : Clock { + override fun now(): Instant = PaykitAllowanceFixtures.now + } + private val proposedTerms = mutableListOf>>() + private var localState = PaykitAllowanceLocalState() + private var records = listOf() + private lateinit var sut: PaykitAllowanceRepo + + @Before + fun setUp() = test { + whenever(executor.events).thenReturn(executorEvents) + whenever(executor.localState(any())).thenAnswer { localState } + whenever(executor.updateLocalState(any(), any())).doSuspendableAnswer { + val change = it.getArgument<(PaykitAllowanceLocalState) -> PaykitAllowanceLocalState>(1) + localState = change(localState) + localState + } + whenever(executor.autoPay(any(), any(), any())).thenReturn(PaykitAllowanceAutoPayResult.STARTED) + whenever(executor.isHandling(any())).thenReturn(false) + whenever(lightningRepo.nodeEvents).thenReturn(nodeEvents) + whenever(lightningRepo.lightningState).thenReturn(lightningState) + whenever(activityRepo.setContact(any(), any(), any(), any())).thenReturn(Result.success(Unit)) + whenever(sdk.listAllowances(any())).thenAnswer { records } + whenever(sdk.linkedPeers()).thenReturn(emptyList()) + whenever(sdk.processOutboundPrivateMessages(any(), any())) + .thenReturn(OutboundPrivateSendReport(emptyList(), emptyList(), emptyList(), emptyList(), emptyList())) + + sut = PaykitAllowanceRepo( + ioDispatcher = testDispatcher, + paykitSdkService = sdk, + executor = executor, + lightningRepo = lightningRepo, + activityRepo = activityRepo, + clock = clock, + allowanceTerms = { _, monthAnchor, endpoints -> + proposedTerms += monthAnchor to endpoints + terms + }, + ) + } + + // region Entries + + @Test + fun `entries group one grant across links with the wallet link as primary`() = test { + records = listOf( + fixtures.record(allowanceId = SERVER_ALLOWANCE_ID, receiverPath = PaykitReceiverPaths.SERVER), + fixtures.record(allowanceId = WALLET_ALLOWANCE_ID), + fixtures.record(allowanceId = "allowance-other", counterparty = fixtures.otherCounterpartyKey), + fixtures.record(allowanceId = "allowance-invalid", historyStatus = AllowanceHistoryStatus.INVALID), + ) + localState = PaykitAllowanceLocalState(groups = listOf(group(WALLET_ALLOWANCE_ID, SERVER_ALLOWANCE_ID))) + + sut.activate(identity) + + val entries = sut.entries.value + assertEquals(listOf("group-1", "allowance-other"), entries.map { it.id }) + val grouped = entries.first() + assertEquals(listOf(SERVER_ALLOWANCE_ID, WALLET_ALLOWANCE_ID), grouped.allowances.map { it.allowanceId }) + assertEquals(WALLET_ALLOWANCE_ID, grouped.primary.allowanceId) + assertEquals(fixtures.limits, grouped.limits) + assertEquals(fixtures.counterpartyKey, grouped.counterparty) + assertEquals(PaykitAllowance.Role.ALLOWER, grouped.role) + assertEquals(5_000uL, grouped.perPaymentMaxSats) + assertEquals(50_000uL, grouped.monthlyLimitSats) + assertEquals(PaykitAllowance.Status.ACTIVE, grouped.status(fixtures.now)) + assertNull(entries.last().limits) + assertEquals(WALLET_ALLOWANCE_ID, sut.entry("group-1")?.primary?.allowanceId) + } + + @Test + fun `activation recovers once per identity and deactivation clears entries`() = test { + records = listOf(fixtures.record()) + + sut.activate(identity) + sut.activate(identity) + + verify(executor, times(1)).recover(identity) + verify(executor, times(2)).activate(identity) + assertEquals(1, sut.entries.value.size) + + sut.deactivate() + + verify(executor).activate(null) + assertEquals(emptyList(), sut.entries.value) + assertTrue(sut.refresh().isSuccess) + verify(sdk, times(2)).listAllowances(any()) + } + + @Test + fun `auto-paid sats and requests come from succeeded automatic payments`() = test { + records = listOf( + fixtures.record(allowanceId = SERVER_ALLOWANCE_ID, receiverPath = PaykitReceiverPaths.SERVER), + fixtures.record(allowanceId = WALLET_ALLOWANCE_ID), + ) + val paid = fixtures.paymentRequest(id = "paid") + val failed = fixtures.paymentRequest(id = "failed") + val serverPaid = fixtures.paymentRequest(id = "server") + localState = PaykitAllowanceLocalState( + groups = listOf(group(WALLET_ALLOWANCE_ID, SERVER_ALLOWANCE_ID)), + journal = listOf( + journalEntry("a", paid, WALLET_ALLOWANCE_ID, 1_000uL, Stage.SUCCEEDED), + journalEntry("b", serverPaid, SERVER_ALLOWANCE_ID, 2_000uL, Stage.SUCCEEDED), + journalEntry("c", failed, WALLET_ALLOWANCE_ID, 5_000uL, Stage.FAILED), + journalEntry("d", fixtures.paymentRequest(id = "manual"), null, 7_000uL, Stage.SUCCEEDED, false), + ), + ) + + sut.activate(identity) + + assertEquals(mapOf("group-1" to 3_000uL), sut.autoPaidSats.value) + assertTrue(sut.isAutoPaid(paid.id)) + assertFalse(sut.isAutoPaid(failed.id)) + assertFalse(sut.isAutoPaid(fixtures.paymentRequest(id = "manual").id)) + } + + // endregion + + // region Coverage + + @Test + fun `coverage needs an active allower allowance on the request's exact link`() = test { + records = listOf(fixtures.record(terms = fixtures.terms(expiresAt = (fixtures.now + 30.days).toString()))) + + sut.activate(identity) + + assertTrue(sut.coversRequest(fixtures.paymentRequest())) + assertFalse(sut.coversRequest(fixtures.paymentRequest(counterparty = fixtures.otherCounterpartyKey))) + assertFalse(sut.coversRequest(fixtures.paymentRequest(receiverPath = PaykitReceiverPaths.SERVER))) + + records = listOf( + fixtures.record(terms = fixtures.terms(expiresAt = "2026-09-20T00:00:00Z")), + fixtures.record(allowanceId = "allowee", localRole = AllowanceLocalRole.ALLOWEE, proposedByMe = false), + fixtures.record(allowanceId = "proposed", state = AllowanceLifecycleState.PROPOSED), + ) + sut.refresh() + + assertFalse(sut.coversRequest(fixtures.paymentRequest())) + } + + @Test + fun `requests created before the allowance was accepted stay manual`() = test { + records = listOf(fixtures.record(lastEventAt = "2026-09-24T11:30:00Z")) + + sut.activate(identity) + + assertFalse(sut.coversRequest(fixtures.paymentRequest(createdAt = "2026-09-24T11:00:00Z"))) + assertTrue(sut.coversRequest(fixtures.paymentRequest(createdAt = "2026-09-24T11:29:40Z")), "Within tolerance") + assertTrue(sut.coversRequest(fixtures.paymentRequest(createdAt = "2026-09-24T11:45:00Z"))) + } + + // endregion + + // region Lifecycle + + @Test + fun `propose sends the same terms on every supported linked path and records one entry`() = test { + whenever(sdk.linkedPeers()).thenReturn( + listOf( + linkedPeer(fixtures.counterpartyKey, PaykitReceiverPaths.SERVER), + linkedPeer(fixtures.counterpartyKey, "a/other"), + linkedPeer(fixtures.counterpartyKey, PaykitReceiverPaths.WALLET), + linkedPeer(fixtures.otherCounterpartyKey, PaykitReceiverPaths.WALLET), + linkedPeer(fixtures.counterpartyKey, PaykitReceiverPaths.WALLET, LinkedPeerState.LINKING), + ), + ) + whenever(sdk.proposeAllowance(any(), any(), any(), any())).doSuspendableAnswer { + val receiverPath = it.getArgument(1) + val isWallet = receiverPath == PaykitReceiverPaths.WALLET + val allowanceId = if (isWallet) WALLET_ALLOWANCE_ID else SERVER_ALLOWANCE_ID + fixtures.record( + allowanceId = allowanceId, + receiverPath = receiverPath, + state = AllowanceLifecycleState.PROPOSED, + terms = terms, + ).also { record -> records = records + record } + } + sut.activate(identity) + + val result = sut.propose(fixtures.counterpartyKey, fixtures.limits) + + assertTrue(result.isSuccess, result.exceptionOrNull().toString()) + val allower = AllowanceLocalRole.ALLOWER + verify(sdk).proposeAllowance(fixtures.counterpartyKey, PaykitReceiverPaths.WALLET, allower, terms) + verify(sdk).proposeAllowance(fixtures.counterpartyKey, PaykitReceiverPaths.SERVER, allower, terms) + verify(sdk, never()).proposeAllowance(any(), eq("a/other"), any(), any()) + verify(sdk).processOutboundPrivateMessages(fixtures.counterpartyKey, PaykitReceiverPaths.WALLET) + verify(sdk).processOutboundPrivateMessages(fixtures.counterpartyKey, PaykitReceiverPaths.SERVER) + assertEquals( + listOf(fixtures.septemberAnchor to PaykitAllowanceRepo.allowedPaymentEndpointIdentifiers()), + proposedTerms, + ) + val group = localState.groups.single() + assertEquals(listOf(WALLET_ALLOWANCE_ID, SERVER_ALLOWANCE_ID), group.allowanceIds) + assertEquals(fixtures.limits, group.limits) + assertEquals(fixtures.counterpartyKey, group.counterparty) + val entry = sut.entries.value.single() + assertEquals(group.id, entry.id) + assertEquals(fixtures.limits, entry.limits) + assertEquals(PaykitAllowance.Status.AWAITING_ANSWER, entry.status(fixtures.now)) + } + + @Test + fun `propose keeps the wallet proposal when the server link fails`() = test { + whenever(sdk.linkedPeers()).thenReturn( + listOf( + linkedPeer(fixtures.counterpartyKey, PaykitReceiverPaths.WALLET), + linkedPeer(fixtures.counterpartyKey, PaykitReceiverPaths.SERVER), + ), + ) + whenever(sdk.proposeAllowance(any(), any(), any(), any())).doSuspendableAnswer { + if (it.getArgument(1) == PaykitReceiverPaths.SERVER) throw TestRepoError("server link") + fixtures.record(state = AllowanceLifecycleState.PROPOSED, terms = terms) + } + sut.activate(identity) + + assertTrue(sut.propose(fixtures.counterpartyKey, fixtures.limits).isSuccess) + + assertEquals(listOf(WALLET_ALLOWANCE_ID), localState.groups.single().allowanceIds) + } + + @Test + fun `propose fails when the contact has no linked receiver`() = test { + whenever(sdk.linkedPeers()) + .thenReturn( + listOf(linkedPeer(fixtures.counterpartyKey, PaykitReceiverPaths.WALLET, LinkedPeerState.LINKING)), + ) + sut.activate(identity) + + val result = sut.propose(fixtures.counterpartyKey, fixtures.limits) + + assertIs(result.exceptionOrNull()) + verify(sdk, never()).proposeAllowance(any(), any(), any(), any()) + assertTrue(localState.groups.isEmpty()) + } + + @Test + fun `received proposal is presented once and accepting answers it`() = test { + val proposalRecord = receivedProposal() + records = listOf(proposalRecord) + whenever(sdk.acceptAllowance(any(), any(), any())).thenReturn(proposalRecord) + sut.activate(identity) + + val proposal = checkNotNull(sut.proposalForPresentation()) + assertEquals(WALLET_ALLOWANCE_ID, proposal.id) + sut.markProposalPresented(proposal.id) + assertNull(sut.proposalForPresentation()) + assertEquals(setOf(WALLET_ALLOWANCE_ID), localState.presentedProposalIds) + + assertTrue(sut.accept(proposal.id).isSuccess) + + verify(sdk).acceptAllowance(fixtures.counterpartyKey, PaykitReceiverPaths.WALLET, WALLET_ALLOWANCE_ID) + verify(sdk).processOutboundPrivateMessages(fixtures.counterpartyKey, PaykitReceiverPaths.WALLET) + } + + @Test + fun `answering fails when nothing in the entry awaits an answer`() = test { + records = listOf(fixtures.record()) + sut.activate(identity) + + assertIs(sut.reject(WALLET_ALLOWANCE_ID).exceptionOrNull()) + assertIs(sut.accept("missing").exceptionOrNull()) + verify(sdk, never()).rejectAllowance(any(), any(), any()) + } + + @Test + fun `ending an entry ends every endable allowance`() = test { + records = listOf( + fixtures.record(allowanceId = SERVER_ALLOWANCE_ID, receiverPath = PaykitReceiverPaths.SERVER), + fixtures.record(allowanceId = WALLET_ALLOWANCE_ID), + ) + localState = PaykitAllowanceLocalState(groups = listOf(group(WALLET_ALLOWANCE_ID, SERVER_ALLOWANCE_ID))) + whenever(sdk.endAllowance(any(), any(), any())).thenReturn(records.last()) + sut.activate(identity) + + assertTrue(sut.end("group-1").isSuccess) + + verify(sdk).endAllowance(fixtures.counterpartyKey, PaykitReceiverPaths.WALLET, WALLET_ALLOWANCE_ID) + verify(sdk).endAllowance(fixtures.counterpartyKey, PaykitReceiverPaths.SERVER, SERVER_ALLOWANCE_ID) + } + + // endregion + + // region Automatic payments + + @Test + fun `covered request stays off the Send sheet until it is found manual`() = test { + records = listOf(fixtures.record()) + whenever(executor.autoPay(any(), any(), any())).thenReturn(PaykitAllowanceAutoPayResult.MANUAL) + sut.activate(identity) + val request = fixtures.paymentRequest() + + assertTrue(sut.isAutomaticallyHandling(request)) + assertFalse(sut.isAutomaticallyHandling(fixtures.paymentRequest(counterparty = fixtures.otherCounterpartyKey))) + + assertFalse(sut.processIncomingRequests(listOf(request))) + assertFalse(sut.isAutomaticallyHandling(request)) + assertFalse(sut.processIncomingRequests(listOf(request))) + verify(executor, times(1)).autoPay(any(), any(), any()) + + records = listOf(fixtures.record(), fixtures.record(allowanceId = SERVER_ALLOWANCE_ID)) + sut.refresh() + sut.processIncomingRequests(listOf(request)) + verify(executor, times(2)).autoPay(any(), any(), any()) + } + + @Test + fun `request being paid stays off the Send sheet`() = test { + sut.activate(identity) + val request = fixtures.paymentRequest() + whenever(executor.isHandling(request.id)).thenReturn(true) + + assertTrue(sut.isAutomaticallyHandling(request)) + } + + @Test + fun `started payment is reported and refreshes the allowances`() = test { + records = listOf(fixtures.record()) + sut.activate(identity) + val uncovered = fixtures.paymentRequest(id = "other", counterparty = fixtures.otherCounterpartyKey) + + val handled = sut.processIncomingRequests(listOf(fixtures.paymentRequest(), uncovered)) + + assertTrue(handled) + verify(executor).autoPay(eq(fixtures.paymentRequest()), eq(sut.entries.value.single().allowances), eq(identity)) + verify(executor, never()).autoPay(eq(uncovered), any(), any()) + verify(sdk, times(2)).listAllowances(any()) + } + + @Test + fun `nothing is processed without an identity`() = test { + assertFalse(sut.processIncomingRequests(listOf(fixtures.paymentRequest()))) + verify(executor, never()).autoPay(any(), any(), any()) + } + + // endregion + + // region Events + + @Test + fun `node payment events settle allowance attempts`() = test { + nodeEvents.emit( + Event.PaymentSuccessful( + paymentId = "payment-id", + paymentHash = PAYMENT_HASH, + paymentPreimage = "00".repeat(32), + feePaidMsat = 10uL, + ), + ) + nodeEvents.emit( + Event.PaymentFailed( + paymentId = PAYMENT_HASH, + paymentHash = null, + reason = PaymentFailureReason.RETRIES_EXHAUSTED, + ), + ) + + verify(executor).lightningPaymentSettled(PAYMENT_HASH, true) + verify(executor).lightningPaymentSettled(PAYMENT_HASH, false) + } + + @Test + fun `automatic payment is attributed to the contact's activity`() = test { + records = listOf(fixtures.record()) + sut.activate(identity) + localState = PaykitAllowanceLocalState( + journal = listOf( + journalEntry("a", fixtures.paymentRequest(), WALLET_ALLOWANCE_ID, 1_000uL, Stage.SUCCEEDED), + ), + ) + + executorEvents.emit(PaykitAllowanceEvent.PaidAutomatically(fixtures.counterpartyKey, 1_000uL, TXID)) + + verify(activityRepo).setContact(eq(fixtures.counterpartyKey), eq(TXID), any(), any()) + assertEquals(mapOf(WALLET_ALLOWANCE_ID to 1_000uL), sut.autoPaidSats.value) + } + + @Test + fun `running node settles open lightning attempts`() = test { + lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Running) } + + verify(executor).settleOpenLightningAttempts() + } + + // endregion + + // region Helpers + + private fun group(vararg allowanceIds: String) = PaykitAllowanceLocalState.Group( + id = "group-1", + counterparty = fixtures.counterpartyKey, + limits = fixtures.limits, + allowanceIds = allowanceIds.toList(), + createdAtMillis = fixtures.now.toEpochMilliseconds(), + ) + + private fun receivedProposal() = fixtures.record( + localRole = AllowanceLocalRole.ALLOWEE, + state = AllowanceLifecycleState.PROPOSED, + proposedByMe = false, + ) + + @Suppress("LongParameterList") + private fun journalEntry( + attemptId: String, + request: PaykitPaymentRequest, + allowanceId: String?, + amountSats: ULong, + stage: Stage, + isAutomatic: Boolean = true, + ) = PaykitAllowanceLocalState.JournalEntry( + attemptId = attemptId, + isAutomatic = isAutomatic, + requestId = request.id, + allowanceId = allowanceId, + amountSats = amountSats, + paymentEndpointIdentifier = fixtures.lightningIdentifier, + stage = stage, + createdAtMillis = fixtures.now.toEpochMilliseconds(), + ) + + private fun linkedPeer( + publicKey: String, + receiverPath: String, + state: LinkedPeerState = LinkedPeerState.LINKED, + ) = LinkedPeerRecord( + counterparty = publicKey, + counterpartyReceiverPath = receiverPath, + state = state, + lastSyncAt = null, + lastPrivateReceiveAt = null, + failureCount = 0u, + localRecoveryAttemptId = null, + localRecoveryMarkerCreatedAt = null, + localRecoveryMarkerLastError = null, + remoteRecoveryAttemptId = null, + remoteRecoveryMarkerObservedAt = null, + ) + + // endregion +} + +private class TestRepoError(message: String) : AppError(message) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceTest.kt new file mode 100644 index 0000000000..faa65324ad --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceTest.kt @@ -0,0 +1,690 @@ +package to.bitkit.repositories + +import com.synonym.paykit.AccountingAmount +import com.synonym.paykit.AllowanceAccountingHistory +import com.synonym.paykit.AllowanceAccountingState +import com.synonym.paykit.AllowanceAmountRange +import com.synonym.paykit.AllowanceHistoryStatus +import com.synonym.paykit.AllowanceLifecycleState +import com.synonym.paykit.AllowanceLocalRole +import com.synonym.paykit.AllowancePeriod +import com.synonym.paykit.AllowancePeriodLimit +import com.synonym.paykit.AllowanceRecord +import com.synonym.paykit.AllowanceTerms +import com.synonym.paykit.PaymentAccountingScope +import com.synonym.paykit.PaymentAttemptRecord +import com.synonym.paykit.PaymentDisposition +import com.synonym.paykit.PaymentExecutionMode +import com.synonym.paykit.PaymentExecutionStatus +import com.synonym.paykit.PaymentOccurrenceKey +import com.synonym.paykit.PaymentOccurrenceRecord +import org.junit.Test +import org.lightningdevkit.ldknode.Network +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import to.bitkit.data.keychain.Keychain +import to.bitkit.services.PaykitReceiverPaths +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +class PaykitAllowanceTest : BaseUnitTest() { + private val fixtures = PaykitAllowanceFixtures + + // region Records + + @Test + fun `record reads back sats, anchor, role and allowlist`() { + val allowlist = listOf(fixtures.lightningIdentifier, fixtures.onchainIdentifier) + val record = fixtures.record( + state = AllowanceLifecycleState.PROPOSED, + terms = fixtures.terms(allowedPaymentEndpointIdentifiers = allowlist), + proposedByMe = true, + ) + + val allowance = checkNotNull(PaykitAllowance.from(record)) + + assertEquals(fixtures.counterpartyKey, allowance.counterparty) + assertEquals(PaykitReceiverPaths.WALLET, allowance.counterpartyReceiverPath) + assertEquals(PaykitAllowanceFixtures.WALLET_ALLOWANCE_ID, allowance.allowanceId) + assertEquals(PaykitAllowance.Role.ALLOWER, allowance.role) + assertTrue(allowance.isAllower) + assertTrue(allowance.isProposedByMe) + assertEquals(AllowanceLifecycleState.PROPOSED, allowance.lifecycleState) + assertEquals(5_000uL, allowance.perPaymentMaxSats) + assertEquals(50_000uL, allowance.monthlyLimitSats) + assertEquals(fixtures.septemberAnchor, allowance.monthlyAnchor) + assertNull(allowance.activeFrom) + assertNull(allowance.expiresAt) + assertEquals(allowlist, allowance.allowedPaymentEndpointIdentifiers) + assertEquals(Instant.parse("2026-09-02T10:00:00Z"), allowance.lastEventAt) + + val received = checkNotNull( + PaykitAllowance.from( + fixtures.record( + localRole = AllowanceLocalRole.ALLOWEE, + state = AllowanceLifecycleState.PROPOSED, + proposedByMe = false, + ), + ), + ) + assertEquals(PaykitAllowance.Role.ALLOWEE, received.role) + assertFalse(received.isAllower) + assertFalse(received.isProposedByMe) + assertTrue(received.isAnswerable) + } + + @Test + fun `record without usable role, terms or asset is skipped`() { + assertNull(PaykitAllowance.from(fixtures.record(localRole = null))) + assertNull(PaykitAllowance.from(fixtures.record(localRole = AllowanceLocalRole.UNKNOWN))) + assertNull(PaykitAllowance.from(fixtures.record(terms = null))) + assertNull(PaykitAllowance.from(fixtures.record(terms = fixtures.terms(asset = "usd")))) + } + + @Test + fun `status maps every lifecycle state`() { + fun status( + state: AllowanceLifecycleState, + proposedByMe: Boolean = true, + terms: AllowanceTerms = fixtures.terms(), + now: Instant = fixtures.now, + ): PaykitAllowance.Status { + val record = fixtures.record(state = state, terms = terms, proposedByMe = proposedByMe) + return checkNotNull(PaykitAllowance.from(record)).status(now) + } + + assertEquals(PaykitAllowance.Status.AWAITING_ANSWER, status(AllowanceLifecycleState.PROPOSED)) + assertEquals( + PaykitAllowance.Status.AWAITING_MY_ANSWER, + status(AllowanceLifecycleState.PROPOSED, proposedByMe = false), + ) + assertEquals(PaykitAllowance.Status.ACTIVE, status(AllowanceLifecycleState.ACCEPTED)) + assertEquals(PaykitAllowance.Status.DECLINED, status(AllowanceLifecycleState.REJECTED)) + assertEquals(PaykitAllowance.Status.ENDED, status(AllowanceLifecycleState.ENDED)) + assertEquals(PaykitAllowance.Status.CONFLICTED, status(AllowanceLifecycleState.CONFLICTED)) + assertEquals(PaykitAllowance.Status.CONFLICTED, status(AllowanceLifecycleState.UNKNOWN)) + + val expiry = Instant.parse("2026-09-20T00:00:00Z") + val expiring = fixtures.terms(expiresAt = "2026-09-20T00:00:00Z") + val accepted = AllowanceLifecycleState.ACCEPTED + assertEquals(PaykitAllowance.Status.ACTIVE, status(accepted, terms = expiring, now = expiry - 1.seconds)) + assertEquals(PaykitAllowance.Status.EXPIRED, status(accepted, terms = expiring, now = expiry)) + assertEquals(PaykitAllowance.Status.EXPIRED, status(accepted, terms = expiring, now = fixtures.now)) + + val start = Instant.parse("2026-10-01T00:00:00Z") + val scheduled = fixtures.terms(activeFrom = "2026-10-01T00:00:00Z") + assertEquals(PaykitAllowance.Status.NOT_YET_ACTIVE, status(accepted, terms = scheduled, now = fixtures.now)) + assertEquals(PaykitAllowance.Status.ACTIVE, status(accepted, terms = scheduled, now = start)) + } + + @Test + fun `sats from bitcoin amount reads the zero minimum`() { + assertEquals(0uL, PaykitAllowance.satsFromBitcoinAmount("0")) + assertEquals(0uL, PaykitAllowance.satsFromBitcoinAmount("0.00000000")) + assertEquals(5_000uL, PaykitAllowance.satsFromBitcoinAmount("0.00005")) + assertEquals(50_000uL, PaykitAllowance.satsFromBitcoinAmount("0.0005")) + assertNull(PaykitAllowance.satsFromBitcoinAmount("abc")) + } + + @Test + fun `bitcoin decimal and trusted time round trip`() { + assertEquals("0.00005", 5_000uL.toBitcoinDecimal()) + assertEquals("0.0005", 50_000uL.toBitcoinDecimal()) + assertEquals("2026-09-24T12:00:00Z", PaykitAllowanceTime.format(fixtures.now)) + val withMillis = Instant.parse("2026-09-24T12:00:00.123456Z") + assertEquals("2026-09-24T12:00:00.123Z", PaykitAllowanceTime.format(withMillis)) + assertEquals(Instant.parse("2026-09-24T12:00:00.123Z"), PaykitAllowanceTime.parse("2026-09-24T12:00:00.123Z")) + assertNull(PaykitAllowanceTime.parse("yesterday")) + } + + // endregion + + // region Monthly window + + @Test + fun `month start uses the UTC calendar month`() { + assertEquals(fixtures.septemberAnchor, PaykitAllowanceTime.monthStart(fixtures.now)) + assertEquals( + fixtures.septemberAnchor, + PaykitAllowanceTime.monthStart(Instant.parse("2026-10-01T01:00:00+02:00")), + ) + assertEquals( + Instant.parse("2026-10-01T00:00:00Z"), + PaykitAllowanceTime.monthStart(Instant.parse("2026-09-30T23:30:00-02:00")), + ) + } + + @Test + fun `monthly window from a first of month anchor`() { + val cases = listOf( + Triple("2026-09-01T00:00:00Z", "2026-09-01T00:00:00Z", "2026-10-01T00:00:00Z"), + Triple("2026-09-24T12:00:00Z", "2026-09-01T00:00:00Z", "2026-10-01T00:00:00Z"), + Triple("2026-09-30T23:59:59Z", "2026-09-01T00:00:00Z", "2026-10-01T00:00:00Z"), + Triple("2026-10-01T00:00:00Z", "2026-10-01T00:00:00Z", "2026-11-01T00:00:00Z"), + Triple("2026-12-31T23:59:59Z", "2026-12-01T00:00:00Z", "2027-01-01T00:00:00Z"), + Triple("2027-02-10T08:00:00Z", "2027-02-01T00:00:00Z", "2027-03-01T00:00:00Z"), + Triple("2026-08-31T23:59:59Z", "2026-08-01T00:00:00Z", "2026-09-01T00:00:00Z"), + Triple("2026-07-15T12:00:00Z", "2026-07-01T00:00:00Z", "2026-08-01T00:00:00Z"), + ) + + for ((date, start, end) in cases) { + val window = PaykitAllowanceTime.monthlyWindow(fixtures.septemberAnchor, Instant.parse(date)) + assertEquals(Instant.parse(start) to Instant.parse(end), window, "window for $date") + } + } + + @Test + fun `monthly window clamps a January 31 anchor in February`() { + val anchor = Instant.parse("2026-01-31T12:30:00Z") + + val midFebruary = PaykitAllowanceTime.monthlyWindow(anchor, Instant.parse("2026-02-15T00:00:00Z")) + assertEquals(anchor, midFebruary.first) + assertEquals(Instant.parse("2026-02-28T12:30:00Z"), midFebruary.second) + + val lateFebruary = PaykitAllowanceTime.monthlyWindow(anchor, Instant.parse("2026-02-28T12:30:00Z")) + assertEquals(Instant.parse("2026-02-28T12:30:00Z"), lateFebruary.first) + } + + /** + * Vectors from paykit-lib `test_anchored_months_clamp_from_original_anchor`: every boundary is counted from the + * original anchor, so the window after a clamped February still ends on March 31. + */ + @Test + fun `monthly window after a clamped February matches paykit anchored arithmetic`() { + val anchor = Instant.parse("2026-01-31T12:30:00Z") + val vectors = listOf( + Triple("2026-02-28T12:30:00Z", "2026-02-28T12:30:00Z", "2026-03-31T12:30:00Z"), + Triple("2026-03-30T12:30:00Z", "2026-02-28T12:30:00Z", "2026-03-31T12:30:00Z"), + Triple("2025-12-01T00:00:00Z", "2025-11-30T12:30:00Z", "2025-12-31T12:30:00Z"), + ) + for ((date, start, end) in vectors) { + val window = PaykitAllowanceTime.monthlyWindow(anchor, Instant.parse(date)) + assertEquals(Instant.parse(start) to Instant.parse(end), window, "window for $date") + } + + val beforeMarchAnchor = PaykitAllowanceTime.monthlyWindow( + Instant.parse("2026-03-31T00:00:00Z"), + Instant.parse("2026-01-15T00:00:00Z"), + ) + assertEquals(Instant.parse("2025-12-31T00:00:00Z"), beforeMarchAnchor.first) + assertEquals(Instant.parse("2026-01-31T00:00:00Z"), beforeMarchAnchor.second) + + val allowance = fixtures.allowance(monthlyAnchor = anchor) + val lateMarchAttempt = fixtures.capacityAttempt(sats = 50_000uL, at = "2026-03-29T09:00:00Z") + assertFalse( + PaykitAllowanceCapacity.fits( + 1_000uL, + allowance, + listOf(lateMarchAttempt), + Instant.parse("2026-03-30T12:30:00Z"), + ), + "A March 29 attempt at the cap must count on March 30", + ) + } + + // endregion + + // region Capacity + + @Test + fun `capacity fits under the cap`() { + val attempts = listOf(fixtures.capacityAttempt(sats = 20_000uL, at = "2026-09-05T10:00:00Z")) + + assertEquals(20_000uL, fixtures.usedSats(attempts)) + assertTrue(PaykitAllowanceCapacity.fits(5_000uL, fixtures.allowance(), attempts, fixtures.now)) + } + + @Test + fun `capacity fits exactly at the cap and rejects one sat over`() { + val attempts = listOf( + fixtures.capacityAttempt(sats = 20_000uL, at = "2026-09-05T10:00:00Z"), + fixtures.capacityAttempt(sats = 25_000uL, at = "2026-09-12T10:00:00Z"), + ) + + assertTrue(PaykitAllowanceCapacity.fits(5_000uL, fixtures.allowance(), attempts, fixtures.now)) + val noPerPaymentMaximum = fixtures.allowance(perPaymentMaxSats = null) + assertFalse(PaykitAllowanceCapacity.fits(5_001uL, noPerPaymentMaximum, attempts, fixtures.now)) + } + + @Test + fun `capacity rejects over the per payment maximum`() { + val allowance = fixtures.allowance() + + assertTrue(PaykitAllowanceCapacity.fits(5_000uL, allowance, emptyList(), fixtures.now)) + assertFalse(PaykitAllowanceCapacity.fits(5_001uL, allowance, emptyList(), fixtures.now)) + } + + @Test + fun `capacity ignores failed attempts, previous months and other allowances`() { + val attempts = listOf( + fixtures.capacityAttempt(sats = 45_000uL, at = "2026-09-05T10:00:00Z", isLive = false), + fixtures.capacityAttempt(sats = 50_000uL, at = "2026-08-31T23:59:59Z"), + fixtures.capacityAttempt( + allowanceId = PaykitAllowanceFixtures.SERVER_ALLOWANCE_ID, + sats = 50_000uL, + at = "2026-09-10T10:00:00Z", + ), + fixtures.capacityAttempt(sats = 1_000uL, at = "2026-09-01T00:00:00Z"), + fixtures.capacityAttempt(sats = 10_000uL, at = "2026-09-12T10:00:00Z"), + ) + + assertEquals(11_000uL, fixtures.usedSats(attempts)) + assertTrue(PaykitAllowanceCapacity.fits(5_000uL, fixtures.allowance(), attempts, fixtures.now)) + } + + @Test + fun `capacity without a monthly limit checks only the per payment maximum`() { + val allowance = fixtures.allowance(monthlyLimitSats = null) + val attempts = listOf(fixtures.capacityAttempt(sats = 1_000_000uL, at = "2026-09-05T10:00:00Z")) + + assertTrue(PaykitAllowanceCapacity.fits(5_000uL, allowance, attempts, fixtures.now)) + } + + @Test + fun `attempts from history keep automatic allowance attempts`() { + val history = AllowanceAccountingHistory( + associations = emptyList(), + occurrences = listOf( + fixtures.occurrence( + requestId = "req-1", + attempts = listOf( + fixtures.attemptRecord( + id = "failed", + amount = "0.0001", + admittedAt = "2026-09-02T10:00:00Z", + status = PaymentExecutionStatus.FAILED, + ), + fixtures.attemptRecord( + id = "paid", + amount = "0.0001", + admittedAt = "2026-09-03T10:00:00Z", + status = PaymentExecutionStatus.SUCCEEDED, + ), + ), + ), + fixtures.occurrence( + requestId = "req-2", + attempts = listOf( + fixtures.attemptRecord( + id = "manual", + mode = PaymentExecutionMode.MANUAL, + allowanceId = null, + status = PaymentExecutionStatus.SUCCEEDED, + ), + fixtures.attemptRecord( + id = "unattributed", + allowanceId = null, + status = PaymentExecutionStatus.SUCCEEDED, + ), + fixtures.attemptRecord( + id = "open", + amount = "0.00002", + admittedAt = "2026-09-05T10:00:00Z", + status = PaymentExecutionStatus.SUBMITTED, + ), + ), + ), + ), + watermarks = emptyList(), + ) + + val attempts = PaykitAllowanceCapacity.attempts(history) + + val walletId = PaykitAllowanceFixtures.WALLET_ALLOWANCE_ID + assertEquals( + listOf( + PaykitAllowanceCapacity.Attempt(walletId, 10_000uL, Instant.parse("2026-09-02T10:00:00Z"), false), + PaykitAllowanceCapacity.Attempt(walletId, 10_000uL, Instant.parse("2026-09-03T10:00:00Z"), true), + PaykitAllowanceCapacity.Attempt(walletId, 2_000uL, Instant.parse("2026-09-05T10:00:00Z"), true), + ), + attempts, + ) + } + + @Test + fun `status and capacity use the given time`() { + val allowance = fixtures.allowance(expiresAt = fixtures.now + 30.days) + val attempts = listOf(fixtures.capacityAttempt(sats = 50_000uL, at = "2026-09-10T10:00:00Z")) + + assertEquals(PaykitAllowance.Status.ACTIVE, allowance.status(fixtures.now)) + assertEquals(PaykitAllowance.Status.EXPIRED, allowance.status(fixtures.now + 30.days)) + assertFalse(PaykitAllowanceCapacity.fits(1uL, allowance, attempts, fixtures.now)) + assertTrue(PaykitAllowanceCapacity.fits(1uL, allowance, attempts, Instant.parse("2026-10-01T00:00:00Z"))) + } + + // endregion + + // region Grouping and terms + + @Test + fun `ordered receiver paths keep supported links with the wallet link first`() { + assertEquals( + listOf(PaykitReceiverPaths.WALLET, PaykitReceiverPaths.SERVER), + PaykitAllowanceRepo.orderedReceiverPaths( + listOf(PaykitReceiverPaths.SERVER, "a/other", PaykitReceiverPaths.WALLET, PaykitReceiverPaths.SERVER), + ), + ) + assertEquals( + listOf(PaykitReceiverPaths.SERVER), + PaykitAllowanceRepo.orderedReceiverPaths(listOf(PaykitReceiverPaths.SERVER)), + ) + assertEquals(emptyList(), PaykitAllowanceRepo.orderedReceiverPaths(emptyList())) + } + + @Test + fun `entry primary prefers an accepted link, then the wallet link`() { + val server = fixtures.allowance( + allowanceId = PaykitAllowanceFixtures.SERVER_ALLOWANCE_ID, + receiverPath = PaykitReceiverPaths.SERVER, + perPaymentMaxSats = 1uL, + ) + val wallet = fixtures.allowance() + + val entry = PaykitAllowanceEntry(id = "group", allowances = listOf(server, wallet), limits = fixtures.limits) + assertEquals(PaykitAllowanceFixtures.WALLET_ALLOWANCE_ID, entry.primary.allowanceId) + assertEquals(5_000uL, entry.perPaymentMaxSats) + + val serverOnly = PaykitAllowanceEntry(id = "server", allowances = listOf(server), limits = null) + assertEquals(PaykitAllowanceFixtures.SERVER_ALLOWANCE_ID, serverOnly.primary.allowanceId) + + val walletDeclined = wallet.copy(lifecycleState = AllowanceLifecycleState.REJECTED) + val acceptedOnServer = PaykitAllowanceEntry(id = "g", listOf(walletDeclined, server), limits = null) + assertEquals(PaykitAllowanceFixtures.SERVER_ALLOWANCE_ID, acceptedOnServer.primary.allowanceId) + + val bothProposed = listOf( + server.copy(lifecycleState = AllowanceLifecycleState.PROPOSED), + wallet.copy(lifecycleState = AllowanceLifecycleState.PROPOSED), + ) + val proposed = PaykitAllowanceEntry(id = "p", allowances = bothProposed, limits = null) + assertEquals(PaykitAllowanceFixtures.WALLET_ALLOWANCE_ID, proposed.primary.allowanceId) + } + + @Test + fun `allowed endpoints are bolt11 and the network's on-chain methods`() { + assertEquals( + listOf( + "btc-lightning-bolt11", + "btc-regtest-p2tr", + "btc-regtest-p2wpkh", + "btc-regtest-p2sh", + "btc-regtest-p2pkh", + ), + PaykitAllowanceRepo.allowedPaymentEndpointIdentifiers(Network.REGTEST), + ) + } + + // endregion + + // region Store + + @Test + fun `store keeps one state per identity and survives a corrupt value`() = test { + val keychain = mock() + var stored: String? = null + whenever(keychain.loadString(any())).thenAnswer { stored } + whenever(keychain.upsertString(any(), any())).doSuspendableAnswer { stored = it.getArgument(1) } + val store = PaykitAllowanceStore(keychain) + val entry = PaykitAllowanceLocalState.JournalEntry( + attemptId = "attempt-1", + isAutomatic = true, + requestId = fixtures.paymentRequest().id, + allowanceId = PaykitAllowanceFixtures.WALLET_ALLOWANCE_ID, + amountSats = 1_000uL, + paymentEndpointIdentifier = fixtures.lightningIdentifier, + paymentHash = "ab".repeat(32), + stage = PaykitAllowanceLocalState.Stage.SENT, + createdAtMillis = fixtures.now.toEpochMilliseconds(), + ) + val group = PaykitAllowanceLocalState.Group( + id = "group-1", + counterparty = fixtures.counterpartyKey, + limits = fixtures.limits, + allowanceIds = listOf(PaykitAllowanceFixtures.WALLET_ALLOWANCE_ID), + createdAtMillis = 1L, + ) + val state = PaykitAllowanceLocalState( + groups = listOf(group), + journal = listOf(entry), + presentedProposalIds = setOf("proposal"), + notifiedRequestIds = setOf("request"), + lastTrustedTimeMillis = 42L, + ) + + store.save(fixtures.identityKey, state) + + assertEquals(state, store.load(fixtures.identityKey)) + assertEquals(PaykitAllowanceLocalState(), store.load(fixtures.otherCounterpartyKey)) + val loaded = store.load(fixtures.identityKey) + assertEquals(group, loaded.group(containing = PaykitAllowanceFixtures.WALLET_ALLOWANCE_ID)) + + stored = "{not json" + assertEquals(PaykitAllowanceLocalState(), store.load(fixtures.identityKey)) + } + + // endregion +} + +/** Shared builders for the Allowance suites. */ +internal object PaykitAllowanceFixtures { + const val WALLET_ALLOWANCE_ID = "allowance-wallet" + const val SERVER_ALLOWANCE_ID = "allowance-server" + val identityKey = "pubky" + "z".repeat(52) + val counterpartyKey = "pubky" + "y".repeat(52) + val otherCounterpartyKey = "pubky" + "x".repeat(52) + val lightningIdentifier: String get() = MethodId.Bolt11.rawValue + val onchainIdentifier: String get() = MethodId.P2wpkh.rawValue + val now: Instant = Instant.parse("2026-09-24T12:00:00Z") + val septemberAnchor: Instant = Instant.parse("2026-09-01T00:00:00Z") + val limits = PaykitAllowanceLimits( + perPaymentUsd = 5, + monthlyUsd = 50, + perPaymentSats = 5_000uL, + monthlySats = 50_000uL, + ) + + @Suppress("LongParameterList") + fun terms( + perPaymentMaximum: String? = "0.00005", + monthlyLimit: String? = "0.0005", + anchor: String? = "2026-09-01T00:00:00Z", + activeFrom: String? = null, + expiresAt: String? = null, + asset: String = PaykitIssuerInterop.BITCOIN_ASSET, + allowedPaymentEndpointIdentifiers: List? = listOf(lightningIdentifier), + ): AllowanceTerms { + val termsAsset = asset + val termsActiveFrom = activeFrom + val termsExpiresAt = expiresAt + val allowlist = allowedPaymentEndpointIdentifiers + val periodAnchor = anchor + val range = perPaymentMaximum?.let { max -> + mock { + on { minimum() } doReturn "0" + on { maximum() } doReturn max + } + } + val monthlyPeriod = mock { + on { kind() } doReturn "anchored" + on { every() } doReturn 1uL + on { unit() } doReturn "month" + on { anchor() } doReturn periodAnchor + } + val periodLimit = mock { + on { amountLimit() } doReturn monthlyLimit + on { paymentCountLimit() } doReturn null + on { period() } doReturn monthlyPeriod + } + return mock { + on { asset() } doReturn termsAsset + on { perPaymentAmount() } doReturn range + on { periodLimits() } doReturn listOf(periodLimit) + on { lifetimeAmountLimit() } doReturn null + on { activeFrom() } doReturn termsActiveFrom + on { expiresAt() } doReturn termsExpiresAt + on { allowedPaymentEndpointIdentifiers() } doReturn allowlist + } + } + + @Suppress("LongParameterList") + fun record( + allowanceId: String = WALLET_ALLOWANCE_ID, + counterparty: String = counterpartyKey, + receiverPath: String = PaykitReceiverPaths.WALLET, + localRole: AllowanceLocalRole? = AllowanceLocalRole.ALLOWER, + state: AllowanceLifecycleState = AllowanceLifecycleState.ACCEPTED, + historyStatus: AllowanceHistoryStatus = AllowanceHistoryStatus.CONSISTENT, + terms: AllowanceTerms? = terms(), + proposedByMe: Boolean = true, + lastEventAt: String? = "2026-09-02T10:00:00Z", + ) = AllowanceRecord( + counterparty = counterparty, + counterpartyReceiverPath = receiverPath, + allowanceId = allowanceId, + localRole = localRole, + state = state, + historyStatus = historyStatus, + proposalEventId = "proposal-$allowanceId", + terms = terms, + proposalStreamItemId = if (proposedByMe) null else 1uL, + proposalOutboundMessageId = if (proposedByMe) 1uL else null, + proposalOutboundStatus = null, + acceptanceEventId = null, + acceptanceOutboundStatus = null, + rejectionEventId = null, + rejectionOutboundStatus = null, + endEventId = null, + endOutboundStatus = null, + pendingCausalEventIds = emptyList(), + conflictEventIds = emptyList(), + lastStreamItemId = null, + lastOutboundMessageId = null, + lastOutboundStatus = null, + lastEventAt = lastEventAt, + invalidReason = null, + ) + + @Suppress("LongParameterList") + fun allowance( + allowanceId: String = WALLET_ALLOWANCE_ID, + counterparty: String = counterpartyKey, + receiverPath: String = PaykitReceiverPaths.WALLET, + role: PaykitAllowance.Role = PaykitAllowance.Role.ALLOWER, + state: AllowanceLifecycleState = AllowanceLifecycleState.ACCEPTED, + perPaymentMaxSats: ULong? = 5_000uL, + monthlyLimitSats: ULong? = 50_000uL, + monthlyAnchor: Instant? = septemberAnchor, + expiresAt: Instant? = null, + lastEventAt: Instant? = null, + ) = PaykitAllowance( + id = PaykitAllowance.Id(counterparty, receiverPath, allowanceId), + role = role, + lifecycleState = state, + isProposedByMe = role == PaykitAllowance.Role.ALLOWER, + perPaymentMaxSats = perPaymentMaxSats, + monthlyLimitSats = monthlyLimitSats, + monthlyAnchor = monthlyAnchor, + expiresAt = expiresAt, + allowedPaymentEndpointIdentifiers = listOf(lightningIdentifier), + lastEventAt = lastEventAt, + ) + + fun capacityAttempt( + allowanceId: String = WALLET_ALLOWANCE_ID, + sats: ULong, + at: String, + isLive: Boolean = true, + ) = PaykitAllowanceCapacity.Attempt(allowanceId, sats, Instant.parse(at), isLive) + + fun usedSats(attempts: List): ULong = + PaykitAllowanceCapacity.usedSats(WALLET_ALLOWANCE_ID, attempts, septemberAnchor, now) + + fun accountingAmount(amount: String, currency: String = PaykitIssuerInterop.BITCOIN_ASSET): AccountingAmount = + mock { + on { value() } doReturn amount + on { asset() } doReturn currency + } + + @Suppress("LongParameterList") + fun attemptRecord( + id: String, + mode: PaymentExecutionMode = PaymentExecutionMode.AUTOMATIC, + allowanceId: String? = WALLET_ALLOWANCE_ID, + amount: String = "0.00001", + admittedAt: String = "2026-09-24T12:00:00Z", + status: PaymentExecutionStatus, + epoch: String = "epoch-1", + ) = PaymentAttemptRecord( + attemptId = id, + mode = mode, + allowanceId = allowanceId, + associationRevision = allowanceId?.let { 1uL }, + amount = accountingAmount(amount), + admittedAt = admittedAt, + status = status, + epoch = epoch, + ) + + fun accountingScope(paymentRequestId: String) = PaymentAccountingScope( + localPublicKey = identityKey, + localReceiverPath = PaykitReceiverPaths.WALLET, + counterparty = counterpartyKey, + counterpartyReceiverPath = PaykitReceiverPaths.WALLET, + paymentRequestId = paymentRequestId, + ) + + fun occurrence( + requestId: String, + attempts: List, + allowanceId: String? = WALLET_ALLOWANCE_ID, + ) = PaymentOccurrenceRecord( + key = PaymentOccurrenceKey(request = accountingScope(requestId), billingPeriod = null), + disposition = PaymentDisposition.Automatic, + allowanceId = allowanceId, + associationRevision = allowanceId?.let { 1uL }, + attempts = attempts, + ) + + fun accountingState( + revision: ULong = 1uL, + epoch: String = "epoch-1", + requiresReconciliation: Boolean = false, + occurrences: List = emptyList(), + ) = AllowanceAccountingState( + revision = revision, + epoch = epoch, + requiresReconciliation = requiresReconciliation, + history = AllowanceAccountingHistory( + associations = emptyList(), + occurrences = occurrences, + watermarks = emptyList(), + ), + ) + + @Suppress("LongParameterList") + fun paymentRequest( + id: String = "550e8400-e29b-41d4-a716-446655440001", + counterparty: String = counterpartyKey, + receiverPath: String = PaykitReceiverPaths.WALLET, + amountValue: String = "0.00001", + amountSats: ULong = 1_000uL, + createdAt: String = "2026-09-24T11:00:00Z", + ) = PaykitPaymentRequest( + paymentRequestId = id, + counterparty = counterparty, + counterpartyReceiverPath = receiverPath, + amountValue = amountValue, + amountSats = amountSats, + createdAt = Instant.parse(createdAt), + expiresAt = null, + acceptedPaymentEndpointIdentifiers = listOf(lightningIdentifier), + ) +} diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 9079e92502..b2b4396aa5 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -50,6 +50,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { private const val PAYMENT_REQUEST_ID = "550e8400-e29b-41d4-a716-446655440000" private const val PAYMENT_HASH = "66687aadf862bd776c8fc18b8e9f8e20089714856ee233b3902a591d0d5f2925" private const val ONCHAIN_ADDRESS = "bcrt1qpaymentproof" + private const val ALLOWANCE_ID = "4f1c2d3e-5a6b-4c7d-8e9f-0a1b2c3d4e5f" private val PREIMAGE = "00".repeat(32) } @@ -118,7 +119,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val record = paymentRequestRecord() val request = paymentRequest(MethodId.Bolt11.rawValue) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) .thenThrow(IllegalStateException("temporary failure")) .thenReturn(record) val firstRepo = paymentProofRepo() @@ -140,6 +141,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentEndpointIdentifier = endpointCaptor.capture(), proofJson = proofCaptor.capture(), billingPeriod = isNull(), + allowanceId = isNull(), ) assertEquals(MethodId.Bolt11.rawValue, endpointCaptor.lastValue) assertEquals( @@ -163,7 +165,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentRequestRecord(paymentRequestId = secondPaymentRequestId), ), ) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) .thenThrow(IllegalStateException("temporary failure")) .thenReturn(paymentRequestRecord(paymentRequestId = secondPaymentRequestId)) @@ -177,6 +179,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentEndpointIdentifier = any(), proofJson = any(), billingPeriod = isNull(), + allowanceId = isNull(), ) assertEquals(listOf(PAYMENT_REQUEST_ID, secondPaymentRequestId), paymentRequestIdCaptor.allValues) assertEquals(listOf(PAYMENT_REQUEST_ID), storedProofs.map { it.requestId.paymentRequestId }) @@ -197,7 +200,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { } whenever(lightningRepo.getPayments()).thenReturn(Result.success(listOf(payment))) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) + .thenReturn(record) val firstRepo = paymentProofRepo() firstRepo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() @@ -215,6 +219,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentEndpointIdentifier = any(), proofJson = proofCaptor.capture(), billingPeriod = isNull(), + allowanceId = isNull(), ) assertEquals( """{"data":"$PREIMAGE","type":"${PaykitPaymentProofKind.Lightning.type}"}""", @@ -223,18 +228,45 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(storedProofs.isEmpty()) } + @Test + fun `allowance proof is submitted with its allowance id`() = test { + val record = paymentRequestRecord() + val request = paymentRequest(MethodId.Bolt11.rawValue) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), any())) + .thenReturn(record) + val sut = paymentProofRepo() + + sut.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning, ALLOWANCE_ID).getOrThrow() + sut.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() + assertEquals(ALLOWANCE_ID, storedProofs.single().allowanceId) + sut.completeLightningPayment(PAYMENT_HASH, PREIMAGE) + + verify(paykitSdkService).submitPaymentProof( + counterparty = any(), + counterpartyReceiverPath = any(), + paymentRequestId = any(), + paymentEndpointIdentifier = eq(MethodId.Bolt11.rawValue), + proofJson = any(), + billingPeriod = isNull(), + allowanceId = eq(ALLOWANCE_ID), + ) + assertTrue(storedProofs.isEmpty()) + } + @Test fun `lightning proof completes without prepared proof`() = test { val record = paymentRequestRecord() val request = paymentRequest(MethodId.Bolt11.rawValue) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) + .thenReturn(record) val repo = paymentProofRepo() repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() repo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) - verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) assertTrue(storedProofs.isEmpty()) } @@ -248,7 +280,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.completeLightningPayment(PAYMENT_HASH, "01".repeat(32)) assertNull(storedProofs.single().proofData) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) } @Test @@ -271,7 +303,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) assertTrue(storedProofs.isEmpty()) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) } @Test @@ -284,7 +316,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.failLightningPayment(PAYMENT_HASH) assertTrue(storedProofs.isEmpty()) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) } @Test @@ -298,7 +330,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) whenever(lightningRepo.getPayments()).thenReturn(Result.success(emptyList())) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) + .thenReturn(record) for (error in errors) { val request = paymentRequest(MethodId.Bolt11.rawValue) val repo = paymentProofRepo() @@ -319,7 +352,15 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { restartedRepo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) assertTrue(storedProofs.isEmpty()) } - verify(paykitSdkService, times(errors.size)).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService, times(errors.size)).submitPaymentProof( + any(), + any(), + any(), + any(), + any(), + isNull(), + isNull(), + ) } @Test @@ -351,7 +392,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.P2wpkh.rawValue) val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) + .thenReturn(record) val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() @@ -368,6 +410,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { endpointCaptor.capture(), proofCaptor.capture(), isNull(), + isNull(), ) assertEquals(MethodId.P2wpkh.rawValue, endpointCaptor.firstValue) assertEquals( @@ -433,12 +476,13 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(endpoint) val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) + .thenReturn(record) val repo = paymentProofRepo() repo.completeOnchainPayment(request, txid, endpoint) - verify(paykitSdkService).submitPaymentProof(any(), any(), any(), eq(endpoint), any(), isNull()) + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), eq(endpoint), any(), isNull(), isNull()) assertTrue(storedProofs.isEmpty()) } @@ -451,7 +495,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.Bolt11.rawValue, billingPeriod = period) val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), any())).thenReturn(record) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull())) + .thenReturn(record) val repo = paymentProofRepo() repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() @@ -466,6 +511,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentEndpointIdentifier = any(), proofJson = any(), billingPeriod = periodCaptor.capture(), + allowanceId = isNull(), ) assertEquals(period, periodCaptor.firstValue) } @@ -490,14 +536,15 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val record = paymentRequestRecord(listOf(existingProof)) val request = paymentRequest(MethodId.Bolt11.rawValue, billingPeriod = currentPeriod) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), any())).thenReturn(record) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull())) + .thenReturn(record) val repo = paymentProofRepo() repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() repo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) - verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), any()) + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) } @Test @@ -538,7 +585,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.P2wpkh.rawValue) val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) + .thenReturn(record) val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() @@ -546,7 +594,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { shouldFailNextSave = true repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue) - verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) assertTrue(storedProofs.isEmpty()) } @@ -556,7 +604,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.P2wpkh.rawValue) val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) .thenThrow(IllegalStateException("transient submission failure")) val repo = paymentProofRepo() @@ -566,7 +614,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue) assertEquals(txid, storedProofs.single().proofData) - verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) } @Test @@ -575,7 +623,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.P2wpkh.rawValue) val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) + .thenReturn(record) val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() @@ -583,7 +632,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { shouldFailProofRemoval = true repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue) - verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) assertEquals(txid, storedProofs.single().proofData) } @@ -594,7 +643,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(endpoint) val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) + .thenReturn(record) val repo = paymentProofRepo() repo.prepare(request, endpoint, PaykitPaymentProofKind.Onchain).getOrThrow() @@ -611,6 +661,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentEndpointIdentifier = endpointCaptor.capture(), proofJson = proofCaptor.capture(), billingPeriod = isNull(), + allowanceId = isNull(), ) assertEquals(endpoint, endpointCaptor.firstValue) assertEquals( @@ -626,7 +677,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val record = paymentRequestRecord() val request = paymentRequest(MethodId.P2wpkh.rawValue) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) + .thenReturn(record) whenever( onchainPaymentLookup.transactionId( ONCHAIN_ADDRESS, @@ -650,6 +702,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentEndpointIdentifier = eq(MethodId.P2wpkh.rawValue), proofJson = proofCaptor.capture(), billingPeriod = isNull(), + allowanceId = isNull(), ) assertTrue(proofCaptor.firstValue.contains(txid)) } @@ -665,7 +718,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentRequestRecord(paymentRequestId = secondPaymentRequestId), ), ) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) .thenReturn(paymentRequestRecord()) whenever(onchainPaymentLookup.transactionId(any(), any(), any(), any())) .thenReturn("ab".repeat(32), "cd".repeat(32)) @@ -706,7 +759,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(setOf(oldTransactionId), storedProofs.single().onchainMatchingTransactionIdsBeforeAttempt) assertNull(storedProofs.single().proofData) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) } @Test diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAllowancePaymentTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAllowancePaymentTest.kt new file mode 100644 index 0000000000..2e4ae32b6b --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAllowancePaymentTest.kt @@ -0,0 +1,246 @@ +package to.bitkit.repositories + +import com.synonym.bitkitcore.LightningInvoice +import com.synonym.bitkitcore.NetworkType +import com.synonym.bitkitcore.Scanner +import com.synonym.paykit.LinkedPeerState +import com.synonym.paykit.PaymentAmountContext +import com.synonym.paykit.PrivatePaymentResolutionState +import com.synonym.paykit.PrivatePaymentResolutionStatus +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.data.PrivatePaykitCacheData +import to.bitkit.data.PrivatePaykitCacheStore +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore +import to.bitkit.ext.toHex +import to.bitkit.models.NodeLifecycleState +import to.bitkit.services.CoreService +import to.bitkit.services.PaykitPreparedPrivateContactPayment +import to.bitkit.services.PaykitPrivateContactPaymentResolution +import to.bitkit.services.PaykitReceiverPaths +import to.bitkit.services.PaykitResolvedPaymentEndpoint +import to.bitkit.services.PaykitSdkService +import to.bitkit.services.PubkyService +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Clock +import kotlin.time.Instant + +class PrivatePaykitAllowancePaymentTest : BaseUnitTest(StandardTestDispatcher()) { + companion object { + private const val CONTACT_KEY = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + private const val PRIVATE_ADDRESS = "bcrt1qs04g2ka4pr9s3mv73nu32tvfy7r3cxd27wkyu8" + private const val PRIVATE_BOLT11 = "lnbcrt1private" + private const val PRIVATE_LNURL = "lnurl1private" + private const val NOW_SECONDS = 1_700_000_000L + private val PAYMENT_HASH = byteArrayOf(9, 9, 9) + } + + private val paykitSdkService = mock() + private val cacheStore = mock() + private val settingsStore = mock() + private val lightningRepo = mock() + private val publicPaykitRepo = mock() + private val coreService = mock() + private val clock = mock() + private lateinit var sut: PrivatePaykitRepo + + @Before + fun setUp() = test { + whenever(cacheStore.data).thenReturn(MutableStateFlow(PrivatePaykitCacheData())) + whenever(settingsStore.data).thenReturn(MutableStateFlow(SettingsData())) + whenever(lightningRepo.lightningState) + .thenReturn(MutableStateFlow(LightningState(nodeLifecycleState = NodeLifecycleState.Running))) + whenever(lightningRepo.getPayments()).thenReturn(Result.success(emptyList())) + whenever(clock.now()).thenReturn(Instant.fromEpochSeconds(NOW_SECONDS)) + whenever(publicPaykitRepo.payableEndpoints(any())).thenAnswer { it.getArgument>(0) } + whenever(coreService.isAddressUsed(any())).thenReturn(false) + PublicPaykitRepo.lightningRouteHintsValidator = { true } + + sut = PrivatePaykitRepo( + ioDispatcher = testDispatcher, + paykitSdkService = paykitSdkService, + pubkyService = mock(), + cacheStore = cacheStore, + settingsStore = settingsStore, + addressReservationRepo = mock(), + lightningRepo = lightningRepo, + walletRepo = mock(), + publicPaykitRepo = publicPaykitRepo, + coreService = coreService, + clock = clock, + ) + } + + @After + fun tearDown() { + PublicPaykitRepo.lightningRouteHintsValidator = null + } + + @Test + fun `allowance payment prefers an exact bolt11 invoice among accepted private endpoints`() = test { + stubResolution( + resolvedEndpoint(MethodId.P2wpkh, PRIVATE_ADDRESS), + resolvedEndpoint(MethodId.Bolt11, PRIVATE_BOLT11), + ) + stubInvoice(amountSats = 2_500uL) + val request = paymentRequest() + + val payment = sut.resolveAllowancePayment(request, eligible(MethodId.Bolt11, MethodId.P2wpkh)).getOrThrow() + + val invoiceEndpoint = PublicPaykitRepo.parseEndpoint(MethodId.Bolt11.rawValue, payload(PRIVATE_BOLT11)) + assertEquals( + PrivatePaykitAllowancePayment( + endpoint = checkNotNull(invoiceEndpoint), + context = PrivatePaykitPaymentContext(PaykitReceiverPaths.SERVER, 7uL), + lightningPaymentHash = PAYMENT_HASH.toHex(), + lightningInvoiceHasAmount = true, + ), + payment, + ) + val amountCaptor = argumentCaptor() + verify(paykitSdkService).prepareAndResolvePrivateContactPayment( + eq(CONTACT_KEY), + eq(PaykitReceiverPaths.SERVER), + eq(null), + amountCaptor.capture(), + ) + assertEquals(PaymentAmountContext("0.000025", "btc"), amountCaptor.firstValue) + } + + @Test + fun `allowance payment skips an invoice for another amount and falls back to on-chain`() = test { + stubResolution( + resolvedEndpoint(MethodId.Bolt11, PRIVATE_BOLT11), + resolvedEndpoint(MethodId.P2wpkh, PRIVATE_ADDRESS), + ) + stubInvoice(amountSats = 1_000uL) + + val payment = sut.resolveAllowancePayment(paymentRequest(), eligible(MethodId.Bolt11, MethodId.P2wpkh)) + .getOrThrow() + + assertEquals(MethodId.P2wpkh, payment?.endpoint?.methodId) + assertEquals(PRIVATE_ADDRESS, payment?.endpoint?.value) + assertNull(payment?.lightningPaymentHash) + } + + @Test + fun `amount-less invoice qualifies for the requested amount`() = test { + stubResolution(resolvedEndpoint(MethodId.Bolt11, PRIVATE_BOLT11)) + stubInvoice(amountSats = 0uL) + + val payment = sut.resolveAllowancePayment(paymentRequest(), eligible(MethodId.Bolt11)).getOrThrow() + + assertEquals(PRIVATE_BOLT11, payment?.endpoint?.value) + assertEquals(false, payment?.lightningInvoiceHasAmount) + } + + @Test + fun `allowance payment uses only endpoints the allowance and the request both accept`() = test { + stubResolution( + resolvedEndpoint(MethodId.Bolt11, PRIVATE_BOLT11), + resolvedEndpoint(MethodId.P2wpkh, PRIVATE_ADDRESS), + ) + stubInvoice(amountSats = 2_500uL) + val request = paymentRequest(accepted = eligible(MethodId.P2wpkh)) + + val payment = sut.resolveAllowancePayment(request, eligible(MethodId.Bolt11, MethodId.P2wpkh)).getOrThrow() + val none = sut.resolveAllowancePayment(request, eligible(MethodId.Bolt11)).getOrThrow() + + assertEquals(MethodId.P2wpkh, payment?.endpoint?.methodId) + assertNull(none) + } + + @Test + fun `lnurl and used addresses never qualify`() = test { + stubResolution( + resolvedEndpoint(MethodId.Lnurl, PRIVATE_LNURL), + resolvedEndpoint(MethodId.P2wpkh, PRIVATE_ADDRESS), + ) + whenever(coreService.isAddressUsed(PRIVATE_ADDRESS)).thenReturn(true) + val request = paymentRequest(accepted = eligible(MethodId.Lnurl, MethodId.P2wpkh)) + + assertNull(sut.resolveAllowancePayment(request, eligible(MethodId.Lnurl, MethodId.P2wpkh)).getOrThrow()) + } + + @Test + fun `allowance payment needs a private payment list`() = test { + stubResolution(resolvedEndpoint(MethodId.P2wpkh, PRIVATE_ADDRESS), version = null) + + assertNull(sut.resolveAllowancePayment(paymentRequest(), eligible(MethodId.P2wpkh)).getOrThrow()) + } + + @Test + fun `expired request is never resolved`() = test { + val expired = paymentRequest().copy(expiresAt = Instant.fromEpochSeconds(NOW_SECONDS - 1)) + + assertNull(sut.resolveAllowancePayment(expired, eligible(MethodId.Bolt11)).getOrThrow()) + verify(paykitSdkService, never()).prepareAndResolvePrivateContactPayment(any(), any(), anyOrNull(), anyOrNull()) + } + + private suspend fun stubResolution(vararg endpoints: PaykitResolvedPaymentEndpoint, version: ULong? = 7uL) { + whenever(paykitSdkService.prepareAndResolvePrivateContactPayment(any(), any(), anyOrNull(), anyOrNull())) + .thenReturn( + PaykitPreparedPrivateContactPayment( + resolution = PaykitPrivateContactPaymentResolution( + status = PrivatePaymentResolutionStatus.PAYABLE, + state = PrivatePaymentResolutionState.AVAILABLE, + privatePaymentListVersion = version, + payableEndpoints = endpoints.toList(), + ), + linkState = LinkedPeerState.LINKED, + ), + ) + } + + private suspend fun stubInvoice(amountSats: ULong) { + whenever(coreService.decode(PRIVATE_BOLT11)).thenReturn( + Scanner.Lightning( + LightningInvoice( + bolt11 = PRIVATE_BOLT11, + paymentHash = PAYMENT_HASH, + amountSatoshis = amountSats, + timestampSeconds = NOW_SECONDS.toULong(), + expirySeconds = 86_400uL, + isExpired = false, + description = "", + networkType = NetworkType.REGTEST, + payeeNodeId = null, + ), + ), + ) + } + + private fun eligible(vararg methods: MethodId) = methods.map { it.rawValue } + + private fun payload(value: String) = PublicPaykitRepo.serializePayload(value) + + private fun resolvedEndpoint(methodId: MethodId, value: String) = PaykitResolvedPaymentEndpoint( + identifier = methodId.rawValue, + payload = payload(value), + ) + + private fun paymentRequest(accepted: List = eligible(MethodId.Bolt11, MethodId.P2wpkh)) = + PaykitPaymentRequest( + paymentRequestId = "request-id", + counterparty = CONTACT_KEY, + counterpartyReceiverPath = PaykitReceiverPaths.SERVER, + amountValue = "0.000025", + amountSats = 2_500uL, + expiresAt = Instant.fromEpochSeconds(NOW_SECONDS + 60), + acceptedPaymentEndpointIdentifiers = accepted, + ) +} From feeb0b6120be783bf138ca31c5ed2f0b7b8365b5 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 24 Sep 2026 17:49:32 +0200 Subject: [PATCH 06/51] feat: add the allowances tab and allowance sheets --- app/src/main/java/to/bitkit/ui/ContentView.kt | 3 +- .../java/to/bitkit/ui/components/Slider.kt | 31 +- .../paymentrequests/PaymentRequestsScreen.kt | 18 +- .../screens/subscriptions/AllowanceSheet.kt | 588 ++++++++++++++++++ .../screens/subscriptions/AllowancesScreen.kt | 285 +++++++++ .../subscriptions/AllowancesViewModel.kt | 348 +++++++++++ .../subscriptions/SubscriptionsScreen.kt | 23 +- app/src/main/res/values/strings.xml | 47 ++ .../subscriptions/AllowancesViewModelTest.kt | 230 +++++++ docs/screens-map.md | 1 + 10 files changed, 1563 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowanceSheet.kt create mode 100644 app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowancesScreen.kt create mode 100644 app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowancesViewModel.kt create mode 100644 app/src/test/java/to/bitkit/ui/screens/subscriptions/AllowancesViewModelTest.kt diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 9cbf4956b7..dc0820e382 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -121,6 +121,7 @@ import to.bitkit.ui.screens.settings.VssDebugScreen import to.bitkit.ui.screens.shop.ShopIntroScreen import to.bitkit.ui.screens.shop.shopDiscover.ShopDiscoverScreen import to.bitkit.ui.screens.shop.shopWebView.ShopWebViewScreen +import to.bitkit.ui.screens.subscriptions.AllowanceSheet import to.bitkit.ui.screens.subscriptions.CreateSubscriptionSheet import to.bitkit.ui.screens.subscriptions.SubscriptionDetailScreen import to.bitkit.ui.screens.subscriptions.SubscriptionSheet @@ -578,7 +579,7 @@ fun ContentView( is Sheet.Subscription -> SubscriptionSheet(appViewModel, sheet.route) - is Sheet.Allowance -> Unit // CONTRACT: UI worker renders AllowanceSheet(sheet.route) here + is Sheet.Allowance -> AllowanceSheet(appViewModel, sheet.route) is Sheet.ActivityDateRangeSelector -> DateRangeSelectorSheet() is Sheet.ActivityTagSelector -> TagSelectorSheet() diff --git a/app/src/main/java/to/bitkit/ui/components/Slider.kt b/app/src/main/java/to/bitkit/ui/components/Slider.kt index b27c197ff3..54aa1e8731 100644 --- a/app/src/main/java/to/bitkit/ui/components/Slider.kt +++ b/app/src/main/java/to/bitkit/ui/components/Slider.kt @@ -13,7 +13,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.systemGestureExclusion import androidx.compose.runtime.Composable @@ -32,12 +32,14 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.ProgressBarRangeInfo import androidx.compose.ui.semantics.progressBarRangeInfo import androidx.compose.ui.semantics.semantics @@ -51,6 +53,7 @@ import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.launch +import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import kotlin.math.abs @@ -73,6 +76,9 @@ fun Slider( onValueChange: (Int) -> Unit, modifier: Modifier = Modifier, formatLabel: (Int) -> String = { "$$it" }, + activeColor: Color = Colors.Green, + trackColor: Color = Colors.Green32, + stopTestTag: ((index: Int) -> String)? = null, ) { val density = LocalDensity.current val coroutineScope = rememberCoroutineScope() @@ -173,7 +179,7 @@ fun Slider( val cornerRadius = density.run { 3.dp.toPx() } drawRoundRect( - color = Colors.Green32, + color = trackColor, topLeft = Offset(0f, trackY - trackHeight / 2), size = Size(size.width, trackHeight), cornerRadius = CornerRadius(cornerRadius), @@ -181,7 +187,7 @@ fun Slider( if (knobX > 0f) { drawRoundRect( - color = Colors.Green, + color = activeColor, topLeft = Offset(0f, trackY - trackHeight / 2), size = Size(knobX, trackHeight), cornerRadius = CornerRadius(cornerRadius), @@ -249,7 +255,7 @@ fun Slider( modifier = Modifier .size(KNOB_SIZE_DP.dp) .clip(CircleShape) - .background(Colors.Green) + .background(activeColor) ) { Box( modifier = Modifier @@ -267,6 +273,8 @@ fun Slider( StepSliderLabels( steps = steps, formatLabel = formatLabel, + stopTestTag = stopTestTag, + onStepClick = { onValueChange(steps[it]) }, ) }.first().measure(Constraints.fixedWidth(width)) @@ -303,17 +311,28 @@ private fun Modifier.stepSliderSemantics( private fun StepSliderLabels( steps: ImmutableList, formatLabel: (Int) -> String, + stopTestTag: ((index: Int) -> String)?, + onStepClick: (index: Int) -> Unit, modifier: Modifier = Modifier, ) { Layout( modifier = modifier, content = { - steps.forEach { step -> + steps.forEachIndexed { index, step -> Caption13Up( text = formatLabel(step), color = Colors.White64, textAlign = TextAlign.Center, - modifier = Modifier.width(KNOB_SIZE_DP.dp) + maxLines = 1, + modifier = Modifier + .widthIn(min = KNOB_SIZE_DP.dp) + .then( + stopTestTag?.let { tag -> + Modifier + .clickableAlpha { onStepClick(index) } + .testTag(tag(index)) + } ?: Modifier + ) ) } }, diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt index 160b708161..d475362188 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt @@ -196,6 +196,7 @@ fun PaymentRequestsScreen( onDetails: (PaykitPaymentRequestId) -> Unit, showsNavigationBar: Boolean = true, topPadding: Dp = 0.dp, + autoPaidRequestIds: ImmutableSet = persistentSetOf(), ) { val pending by appViewModel.pendingPaymentRequests.collectAsStateWithLifecycle() val history by appViewModel.paymentRequestHistory.collectAsStateWithLifecycle() @@ -210,6 +211,7 @@ fun PaymentRequestsScreen( contacts = contacts.toImmutableList(), subscriptions = subscriptions.toImmutableList(), dismissingRequestIds = dismissingRequestIds.toImmutableSet(), + autoPaidRequestIds = autoPaidRequestIds, canRequestPayment = targets.isNotEmpty(), onBack = onBack, onRequestPayment = onRequestPayment, @@ -237,6 +239,7 @@ internal fun PaymentRequestsContent( onDetails: (PaykitPaymentRequestId) -> Unit, showsNavigationBar: Boolean = true, topPadding: Dp = 0.dp, + autoPaidRequestIds: ImmutableSet = persistentSetOf(), ) { val sections = paymentRequestSections(requests, pending, Clock.System.now()) val density = LocalDensity.current @@ -329,9 +332,12 @@ internal fun PaymentRequestsContent( PaymentRequestCard( request = request, contact = contacts.contactFor(request), - compactSubtitle = subscriptions.nameFor(request) - ?: request.note?.takeIf(String::isNotBlank) - ?: paymentRequestDate(request), + compactSubtitle = paymentRequestHistorySubtitle( + subtitle = subscriptions.nameFor(request) + ?: request.note?.takeIf(String::isNotBlank) + ?: paymentRequestDate(request), + isAutoPaid = request.id in autoPaidRequestIds, + ), showSignedAmount = true, onClick = { onDetails(request.id) }, ) @@ -466,6 +472,12 @@ private fun PaykitPaymentRequest.historyPeriod( } } +@Composable +private fun paymentRequestHistorySubtitle(subtitle: String, isAutoPaid: Boolean): String { + if (!isAutoPaid) return subtitle + return "$subtitle · ${stringResource(R.string.subscriptions__allowance_auto_paid)}" +} + @Composable internal fun paymentRequestDate(request: PaykitPaymentRequest): String = request.createdAt?.let { uiDateText(it.epochSeconds.toULong(), UiDateStyle.DATE) diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowanceSheet.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowanceSheet.kt new file mode 100644 index 0000000000..f8e48e3751 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowanceSheet.kt @@ -0,0 +1,588 @@ +package to.bitkit.ui.screens.subscriptions + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.delay +import to.bitkit.R +import to.bitkit.models.PubkyProfile +import to.bitkit.repositories.PaykitAllowance +import to.bitkit.repositories.PaykitAllowanceLimits +import to.bitkit.ui.components.AllowanceRoute +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BodyMSB +import to.bitkit.ui.components.BodyS +import to.bitkit.ui.components.BodySSB +import to.bitkit.ui.components.BottomSheetPreview +import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.Display +import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.MoneyCaptionB +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.PubkyContactAvatar +import to.bitkit.ui.components.PubkyContactRow +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.Slider +import to.bitkit.ui.components.SwipeToConfirm +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.shared.util.gradientBackground +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.withAccent +import to.bitkit.viewmodels.AppViewModel +import kotlin.time.Duration.Companion.seconds + +/** Delay before a shown proposal counts as seen: another sheet's dismissal can close this one right after it opens. */ +private val PROPOSAL_SEEN_DELAY = 1.seconds + +private const val DEFAULT_PER_PAYMENT_INDEX = 1 +private const val DEFAULT_MONTHLY_INDEX = 2 + +@Composable +fun AllowanceSheet( + appViewModel: AppViewModel, + route: AllowanceRoute, + viewModel: AllowancesViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + if (!uiState.isLoaded) { + Box( + modifier = Modifier + .fillMaxWidth() + .sheetHeight() + .gradientBackground(startColor = Colors.Gray6, endColor = Colors.Black) + ) + return + } + + when (route) { + AllowanceRoute.Set -> AllowanceSetFlow( + uiState = uiState, + onSave = { contact, perPaymentUsd, monthlyUsd -> + viewModel.propose(contact, perPaymentUsd, monthlyUsd, onSuccess = appViewModel::hideSheet) + }, + ) + + is AllowanceRoute.Review -> { + val allowance = uiState.allowances.firstOrNull { it.id == route.entryId } + if (allowance == null) { + AllowanceUnavailableContent(onClose = appViewModel::hideSheet, modifier = Modifier.sheetHeight()) + } else { + LaunchedEffect(route.entryId) { + delay(PROPOSAL_SEEN_DELAY) + viewModel.markProposalPresented(route.entryId) + } + AllowanceReviewContent( + allowance = allowance, + isWorking = uiState.isWorking, + onClickDecline = { viewModel.decline(allowance.id, onSuccess = appViewModel::hideSheet) }, + onClickAccept = { viewModel.accept(allowance.id, onSuccess = appViewModel::hideSheet) }, + modifier = Modifier.sheetHeight() + ) + } + } + + is AllowanceRoute.Details -> { + val allowance = uiState.allowances.firstOrNull { it.id == route.entryId } + if (allowance == null) { + AllowanceUnavailableContent(onClose = appViewModel::hideSheet, modifier = Modifier.sheetHeight()) + } else { + AllowanceDetailContent( + allowance = allowance, + isWorking = uiState.isWorking, + onEnd = { viewModel.end(allowance.id, onSuccess = appViewModel::hideSheet) }, + modifier = Modifier.sheetHeight() + ) + } + } + } +} + +@Composable +private fun AllowanceSetFlow( + uiState: AllowancesUiState, + onSave: (contact: PubkyProfile, perPaymentUsd: Int, monthlyUsd: Int) -> Unit, +) { + var contactKey by rememberSaveable { mutableStateOf(null) } + val contact = uiState.contacts.firstOrNull { it.publicKey == contactKey } + + if (contact == null) { + AllowanceContactContent( + contacts = uiState.contacts, + onClickContact = { contactKey = it.publicKey }, + modifier = Modifier.sheetHeight() + ) + } else { + SetAllowanceContent( + contact = contact, + isWorking = uiState.isWorking, + onBack = { contactKey = null }, + onClickSave = { perPaymentUsd, monthlyUsd -> onSave(contact, perPaymentUsd, monthlyUsd) }, + modifier = Modifier.sheetHeight() + ) + } +} + +@Composable +private fun AllowanceContactContent( + contacts: ImmutableList, + onClickContact: (PubkyProfile) -> Unit, + modifier: Modifier = Modifier, +) { + AllowanceSheetColumn(modifier = modifier) { + SheetTopBar(titleText = stringResource(R.string.subscriptions__allowance_choose_contact)) + if (contacts.isEmpty()) { + VerticalSpacer(16.dp) + BodyM( + text = stringResource(R.string.subscriptions__allowance_no_contacts), + color = Colors.White64, + ) + FillHeight() + } else { + LazyColumn(modifier = Modifier.weight(1f)) { + items(contacts, key = { it.publicKey }) { contact -> + PubkyContactRow( + profile = contact, + onClick = { onClickContact(contact) }, + modifier = Modifier.testTag("AllowanceContact-${contact.name}") + ) + HorizontalDivider() + } + } + } + } +} + +@Composable +private fun SetAllowanceContent( + contact: PubkyProfile, + isWorking: Boolean, + onBack: () -> Unit, + onClickSave: (perPaymentUsd: Int, monthlyUsd: Int) -> Unit, + modifier: Modifier = Modifier, +) { + val perPaymentStops = remember { PaykitAllowanceLimits.PER_PAYMENT_STOPS_USD.toImmutableList() } + val monthlyStops = remember { PaykitAllowanceLimits.MONTHLY_STOPS_USD.toImmutableList() } + var perPaymentUsd by rememberSaveable { mutableIntStateOf(perPaymentStops[DEFAULT_PER_PAYMENT_INDEX]) } + var monthlyUsd by rememberSaveable { mutableIntStateOf(monthlyStops[DEFAULT_MONTHLY_INDEX]) } + + AllowanceSheetColumn(modifier = modifier.testTag("SetAllowance")) { + SheetTopBar(titleText = stringResource(R.string.subscriptions__allowance_set_title), onBack = onBack) + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + ) { + AllowanceCounterpartyCard(counterparty = contact) + VerticalSpacer(16.dp) + BodyM( + text = stringResource(R.string.subscriptions__allowance_set_explanation) + .replace("{name}", contact.name), + color = Colors.White64, + ) + VerticalSpacer(16.dp) + AllowanceLimitSlider( + title = stringResource(R.string.subscriptions__allowance_payment_limit), + value = perPaymentUsd, + stops = perPaymentStops, + onValueChange = { perPaymentUsd = it }, + testTag = "AllowancePerPayment", + ) + VerticalSpacer(16.dp) + HorizontalDivider() + AllowanceLimitSlider( + title = stringResource(R.string.subscriptions__allowance_monthly_allowance), + value = monthlyUsd, + stops = monthlyStops, + onValueChange = { monthlyUsd = it }, + testTag = "AllowanceMonthly", + ) + VerticalSpacer(16.dp) + HorizontalDivider() + VerticalSpacer(16.dp) + BodyS( + text = stringResource(R.string.subscriptions__allowance_set_summary) + .replace("{perPayment}", AllowanceAmountText.short(perPaymentUsd)) + .replace("{monthly}", AllowanceAmountText.short(monthlyUsd)), + color = Colors.White64, + modifier = Modifier.testTag("AllowanceSummary") + ) + VerticalSpacer(16.dp) + } + PrimaryButton( + text = stringResource(R.string.subscriptions__allowance_save), + onClick = { onClickSave(perPaymentUsd, monthlyUsd) }, + isLoading = isWorking, + modifier = Modifier.testTag("AllowanceSave") + ) + VerticalSpacer(16.dp) + } +} + +@Composable +private fun AllowanceLimitSlider( + title: String, + value: Int, + stops: ImmutableList, + onValueChange: (Int) -> Unit, + testTag: String, +) { + Caption13Up( + text = title, + color = Colors.White64, + modifier = Modifier.padding(vertical = 16.dp) + ) + Slider( + value = value, + steps = stops, + onValueChange = onValueChange, + formatLabel = AllowanceAmountText::short, + activeColor = Colors.Purple, + trackColor = Colors.Purple32, + stopTestTag = { "${testTag}Stop-$it" }, + modifier = Modifier.testTag(testTag) + ) +} + +@Composable +private fun AllowanceReviewContent( + allowance: AllowanceUi, + isWorking: Boolean, + onClickDecline: () -> Unit, + onClickAccept: () -> Unit, + modifier: Modifier = Modifier, +) { + AllowanceSheetColumn(modifier = modifier.testTag("AllowanceReview")) { + SheetTopBar(titleText = stringResource(R.string.subscriptions__allowance_title)) + VerticalSpacer(16.dp) + Display(text = allowance.reviewHeadline.withAccent(accentColor = Colors.Purple)) + VerticalSpacer(16.dp) + AllowanceCounterpartyCard(counterparty = allowance.counterparty, isCard = true) + VerticalSpacer(24.dp) + AllowanceLimitsGrid(allowance = allowance) + VerticalSpacer(24.dp) + BodyM(text = allowance.reviewExplanation, color = Colors.White64) + FillHeight() + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + SecondaryButton( + text = stringResource(R.string.subscriptions__allowance_decline), + onClick = onClickDecline, + enabled = !isWorking, + modifier = Modifier + .weight(1f) + .testTag("AllowanceDecline") + ) + PrimaryButton( + text = stringResource(R.string.subscriptions__allowance_accept), + onClick = onClickAccept, + isLoading = isWorking, + modifier = Modifier + .weight(1f) + .testTag("AllowanceAccept") + ) + } + VerticalSpacer(16.dp) + } +} + +@Composable +private fun AllowanceDetailContent( + allowance: AllowanceUi, + isWorking: Boolean, + onEnd: () -> Unit, + modifier: Modifier = Modifier, +) { + AllowanceSheetColumn(modifier = modifier.testTag("AllowanceDetail")) { + SheetTopBar(titleText = stringResource(R.string.subscriptions__allowance_title)) + AllowanceCounterpartyCard(counterparty = allowance.counterparty, isCard = true) + VerticalSpacer(24.dp) + AllowanceLimitsGrid(allowance = allowance) + VerticalSpacer(24.dp) + Caption13Up(text = stringResource(R.string.subscriptions__allowance_paid_so_far), color = Colors.White64) + VerticalSpacer(8.dp) + BodySSB(text = allowance.paidSoFar, modifier = Modifier.testTag("AllowancePaidSoFar")) + VerticalSpacer(24.dp) + BodyM( + text = allowance.explanation, + color = Colors.White64, + modifier = Modifier.testTag("AllowanceDetailStatus") + ) + FillHeight() + if (allowance.canEnd) { + SwipeToConfirm( + text = stringResource( + if (allowance.isProposal) { + R.string.subscriptions__allowance_swipe_withdraw + } else { + R.string.subscriptions__allowance_swipe_end + } + ), + color = Colors.Purple, + loading = isWorking, + onConfirm = onEnd, + ) + VerticalSpacer(16.dp) + } + } +} + +@Composable +private fun AllowanceUnavailableContent( + onClose: () -> Unit, + modifier: Modifier = Modifier, +) { + AllowanceSheetColumn(modifier = modifier) { + SheetTopBar(titleText = stringResource(R.string.subscriptions__allowance_title)) + FillHeight() + BodyM(text = stringResource(R.string.subscriptions__allowance_error_unavailable), color = Colors.White64) + FillHeight() + PrimaryButton(text = stringResource(R.string.common__close), onClick = onClose) + VerticalSpacer(16.dp) + } +} + +@Composable +private fun AllowanceSheetColumn( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Column( + modifier = modifier + .fillMaxSize() + .gradientBackground(startColor = Colors.Gray6, endColor = Colors.Black) + .navigationBarsPadding() + .padding(horizontal = 16.dp), + content = content, + ) +} + +@Composable +private fun AllowanceCounterpartyCard( + counterparty: PubkyProfile, + isCard: Boolean = false, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .then( + if (isCard) { + Modifier + .clip(RoundedCornerShape(16.dp)) + .background(Colors.Gray6) + .padding(16.dp) + } else { + Modifier + } + ) + .testTag("AllowanceCounterparty") + ) { + PubkyContactAvatar(profile = counterparty, size = 48.dp) + Column( + modifier = Modifier + .padding(start = 16.dp) + .weight(1f) + ) { + Caption13Up( + text = counterparty.truncatedPublicKey, + color = Colors.White64, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + BodyMSB( + text = counterparty.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun AllowanceLimitsGrid(allowance: AllowanceUi) { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth()) { + AllowanceLimitCell( + title = stringResource(R.string.subscriptions__allowance_per_payment), + upTo = allowance.perPaymentUpTo, + sats = allowance.perPaymentSats, + testTag = "AllowancePerPaymentValue", + modifier = Modifier.weight(1f) + ) + AllowanceLimitCell( + title = stringResource(R.string.subscriptions__allowance_each_month), + upTo = allowance.monthlyUpTo, + sats = allowance.monthlySats, + testTag = "AllowanceMonthlyValue", + modifier = Modifier.weight(1f) + ) + } +} + +@Composable +private fun AllowanceLimitCell( + title: String, + upTo: String, + sats: Long?, + testTag: String, + modifier: Modifier = Modifier, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier) { + Caption13Up(text = title, color = Colors.White64) + BodySSB(text = upTo, modifier = Modifier.testTag(testTag)) + sats?.let { MoneyCaptionB(sats = it, color = Colors.White64, symbol = true) } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + BottomSheetPreview { + AllowanceContactContent( + contacts = persistentListOf( + previewAllowance(name = "Leo").counterparty, + PubkyProfile.forDisplay( + publicKey = "pubkyqzx4bxnsmp6n1u3y3uyt6hbjmaqwzs5tbaxkh7wwcbzjb8sccq3o", + name = "Mia", + imageUrl = null, + ), + ), + onClickContact = {}, + modifier = Modifier.sheetHeight() + ) + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview2() { + AppThemeSurface { + BottomSheetPreview { + AllowanceContactContent( + contacts = persistentListOf(), + onClickContact = {}, + modifier = Modifier.sheetHeight() + ) + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview3() { + AppThemeSurface { + BottomSheetPreview { + SetAllowanceContent( + contact = previewAllowance(name = "Leo").counterparty, + isWorking = false, + onBack = {}, + onClickSave = { _, _ -> }, + modifier = Modifier.sheetHeight() + ) + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview4() { + AppThemeSurface { + BottomSheetPreview { + AllowanceReviewContent( + allowance = previewAllowance(name = "Ana").copy( + status = PaykitAllowance.Status.AWAITING_MY_ANSWER, + subtitle = "Waiting for your answer", + isAnswerable = true, + isProposal = true, + ), + isWorking = false, + onClickDecline = {}, + onClickAccept = {}, + modifier = Modifier.sheetHeight() + ) + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview5() { + AppThemeSurface { + BottomSheetPreview { + AllowanceDetailContent( + allowance = previewAllowance(), + isWorking = false, + onEnd = {}, + modifier = Modifier.sheetHeight() + ) + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview6() { + AppThemeSurface { + BottomSheetPreview { + AllowanceDetailContent( + allowance = previewAllowance().copy( + status = PaykitAllowance.Status.ENDED, + subtitle = "Ended · $2.00 paid automatically", + explanation = "This allowance has ended. Every request asks again.", + canEnd = false, + ), + isWorking = false, + onEnd = {}, + modifier = Modifier.sheetHeight() + ) + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview7() { + AppThemeSurface { + BottomSheetPreview { + AllowanceUnavailableContent(onClose = {}, modifier = Modifier.sheetHeight()) + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowancesScreen.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowancesScreen.kt new file mode 100644 index 0000000000..824b95c1f2 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowancesScreen.kt @@ -0,0 +1,285 @@ +package to.bitkit.ui.screens.subscriptions + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.collections.immutable.persistentListOf +import to.bitkit.R +import to.bitkit.models.PubkyProfile +import to.bitkit.models.USD_SYMBOL +import to.bitkit.repositories.PaykitAllowance +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BodyMSB +import to.bitkit.ui.components.CaptionB +import to.bitkit.ui.components.Display +import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.HorizontalSpacer +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.PubkyContactAvatar +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.withAccent + +/** Row opacity for an allowance that no longer pays: ended, declined, expired or in conflict. */ +private const val INACTIVE_ROW_ALPHA = 0.64f + +/** The Allowances tab on the Subscriptions screen: the empty state or the allowance list, plus Add Allowance. */ +@Composable +fun AllowancesScreen( + topPadding: Dp, + onClickAdd: () -> Unit, + onClickAllowance: (AllowanceUi) -> Unit, + modifier: Modifier = Modifier, + viewModel: AllowancesViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { viewModel.refresh() } + + AllowancesContent( + uiState = uiState, + topPadding = topPadding, + onClickAdd = onClickAdd, + onClickAllowance = onClickAllowance, + modifier = modifier + ) +} + +@Composable +private fun AllowancesContent( + uiState: AllowancesUiState, + topPadding: Dp, + onClickAdd: () -> Unit, + onClickAllowance: (AllowanceUi) -> Unit, + modifier: Modifier = Modifier, +) { + val density = LocalDensity.current + var footerHeight by remember { mutableStateOf(0.dp) } + + Box(modifier = modifier.fillMaxSize()) { + when { + !uiState.isLoaded -> Unit + uiState.allowances.isEmpty() -> AllowancesEmptyState( + modifier = Modifier + .fillMaxSize() + .padding(top = topPadding, bottom = footerHeight) + ) + else -> LazyColumn( + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + top = topPadding + 32.dp, + bottom = footerHeight + 16.dp, + ), + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxSize() + ) { + items(uiState.allowances, key = { it.id }) { allowance -> + AllowanceRow( + allowance = allowance, + onClick = { onClickAllowance(allowance) }, + ) + } + } + } + + Column( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .onSizeChanged { footerHeight = with(density) { it.height.toDp() } } + .navigationBarsPadding() + ) { + VerticalSpacer(16.dp) + PrimaryButton( + text = stringResource(R.string.subscriptions__allowance_add), + onClick = onClickAdd, + modifier = Modifier + .padding(horizontal = 16.dp) + .testTag("AllowanceAdd") + ) + VerticalSpacer(16.dp) + } + } +} + +@Composable +private fun AllowancesEmptyState(modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 32.dp) + .testTag("AllowancesEmpty") + ) { + FillHeight() + Image( + painter = painterResource(R.drawable.group), + contentDescription = null, + modifier = Modifier + .size(256.dp) + .align(Alignment.CenterHorizontally) + ) + VerticalSpacer(32.dp) + Display( + text = stringResource(R.string.subscriptions__allowances_empty_headline) + .withAccent(accentColor = Colors.Purple), + ) + VerticalSpacer(8.dp) + BodyM(text = stringResource(R.string.subscriptions__allowances_empty_description), color = Colors.White64) + } +} + +@Composable +private fun AllowanceRow( + allowance: AllowanceUi, + onClick: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .alpha(if (allowance.isInactive) INACTIVE_ROW_ALPHA else 1f) + .clip(RoundedCornerShape(16.dp)) + .background(Colors.Gray6) + .clickableAlpha(onClick = onClick) + .padding(16.dp) + .testTag("AllowanceRow-${allowance.id}") + ) { + PubkyContactAvatar(profile = allowance.counterparty, size = 40.dp) + Column( + modifier = Modifier + .padding(start = 16.dp) + .weight(1f) + ) { + BodyMSB( + text = allowance.counterparty.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + CaptionB( + text = allowance.subtitle, + color = Colors.White64, + maxLines = 1, + modifier = Modifier.testTag("AllowanceRowStatus") + ) + } + HorizontalSpacer(8.dp) + Column(horizontalAlignment = Alignment.End) { + AllowanceUsd(amount = allowance.monthlyAmount) + CaptionB( + text = stringResource(R.string.subscriptions__allowance_monthly_limit), + color = Colors.White64, + maxLines = 1, + ) + } + } +} + +/** A dollar amount with a dimmed currency symbol, as the allowance rows show limits. */ +@Composable +internal fun AllowanceUsd(amount: String, modifier: Modifier = Modifier) { + BodyMSB( + text = "$USD_SYMBOL $amount".withAccent(accentColor = Colors.White64), + modifier = modifier + ) +} + +internal fun previewAllowance(id: String = "allowance-1", name: String = "Leo") = AllowanceUi( + id = id, + counterparty = PubkyProfile.forDisplay( + publicKey = "pubky8pinxcsrt5ufsyu3n1pzkq5e3bdi7gbq67pcu7tsnjj65ntx1xy", + name = name, + imageUrl = null, + ), + status = PaykitAllowance.Status.ACTIVE, + subtitle = "Active · $5 a payment", + explanation = "Requests within these limits are paid automatically. Anything above them asks you first.", + reviewHeadline = "Ana offers\nan allowance", + reviewExplanation = "Ana's wallet will pay your requests within these limits without asking each time. " + + "Either of you can end it.", + monthlyAmount = "50.00", + perPaymentUpTo = "Up to $5.00", + monthlyUpTo = "Up to $50.00", + perPaymentSats = 4_512L, + monthlySats = 45_120L, + paidSoFar = "$2.00", + isAnswerable = false, + canEnd = true, + isProposal = false, +) + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + AllowancesContent( + uiState = AllowancesUiState(isLoaded = true), + topPadding = 0.dp, + onClickAdd = {}, + onClickAllowance = {}, + ) + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview2() { + AppThemeSurface { + AllowancesContent( + uiState = AllowancesUiState( + isLoaded = true, + allowances = persistentListOf( + previewAllowance(), + previewAllowance(id = "allowance-2", name = "Mia").copy( + status = PaykitAllowance.Status.AWAITING_ANSWER, + subtitle = "Waiting for an answer", + isProposal = true, + ), + previewAllowance(id = "allowance-3", name = "John Carvalho").copy( + status = PaykitAllowance.Status.ENDED, + subtitle = "Ended · $2.00 paid automatically", + canEnd = false, + ), + ), + ), + topPadding = 0.dp, + onClickAdd = {}, + onClickAllowance = {}, + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowancesViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowancesViewModel.kt new file mode 100644 index 0000000000..9bd02e469b --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowancesViewModel.kt @@ -0,0 +1,348 @@ +@file:OptIn(ExperimentalTime::class) + +package to.bitkit.ui.screens.subscriptions + +import android.content.Context +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.synonym.paykit.AllowanceLifecycleState +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableSet +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import to.bitkit.R +import to.bitkit.models.PubkyProfile +import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.models.Toast +import to.bitkit.models.USD +import to.bitkit.models.USD_SYMBOL +import to.bitkit.repositories.CurrencyRepo +import to.bitkit.repositories.PaykitAllowance +import to.bitkit.repositories.PaykitAllowanceEntry +import to.bitkit.repositories.PaykitAllowanceError +import to.bitkit.repositories.PaykitAllowanceLimits +import to.bitkit.repositories.PaykitAllowanceRepo +import to.bitkit.repositories.PaykitPaymentRequestId +import to.bitkit.repositories.PaykitPaymentRequestRepo +import to.bitkit.repositories.PubkyRepo +import to.bitkit.ui.shared.toast.ToastEventBus +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.DecimalFormat +import java.text.DecimalFormatSymbols +import java.util.Locale +import javax.inject.Inject +import kotlin.time.Clock +import kotlin.time.Duration.Companion.minutes +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +@HiltViewModel +class AllowancesViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val allowanceRepo: PaykitAllowanceRepo, + private val paymentRequestRepo: PaykitPaymentRequestRepo, + private val pubkyRepo: PubkyRepo, + private val currencyRepo: CurrencyRepo, + private val clock: Clock, +) : ViewModel() { + companion object { + /** How often time-based statuses (not active yet, expired) are re-evaluated while the UI is visible. */ + private val STATUS_REFRESH_INTERVAL = 1.minutes + + /** Keeps the state alive across short configuration changes. */ + private const val STOP_TIMEOUT_MS = 5_000L + } + + private val isWorking = MutableStateFlow(false) + + private val now: Flow = flow { + while (true) { + emit(clock.now()) + delay(STATUS_REFRESH_INTERVAL) + } + } + + private val allowances: Flow> = combine( + allowanceRepo.entries, + allowanceRepo.autoPaidSats, + pubkyRepo.contacts, + currencyRepo.currencyState, + now, + ) { entries, autoPaidSats, contacts, _, now -> + entries.map { it.toUi(contacts, autoPaidSats[it.id] ?: 0uL, now) }.toImmutableList() + } + + private val contactChoices: Flow> = combine( + pubkyRepo.contacts, + paymentRequestRepo.eligibleTargets, + ) { contacts, targets -> + contacts.filter { contact -> + targets.any { PubkyPublicKeyFormat.matches(it.publicKey, contact.publicKey) } + }.toImmutableList() + } + + // A fresh subscriber starts from the unloaded state, so a sheet never renders a stale entry list. + val uiState: StateFlow = combine( + allowances, + contactChoices, + isWorking, + ) { allowances, contacts, isWorking -> + AllowancesUiState( + isLoaded = true, + allowances = allowances, + contacts = contacts, + isWorking = isWorking, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS, replayExpirationMillis = 0), + initialValue = AllowancesUiState(), + ) + + /** Payment request ids paid by an allowance, for the "Auto-paid" suffix in payment history. */ + val autoPaidRequestIds: StateFlow> = combine( + paymentRequestRepo.paymentRequestHistory, + allowanceRepo.autoPaidSats, + ) { history, _ -> + history.map { it.id }.filter(allowanceRepo::isAutoPaid).toImmutableSet() + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), persistentSetOf()) + + fun refresh() { + viewModelScope.launch { allowanceRepo.refresh() } + } + + fun markProposalPresented(entryId: String) { + viewModelScope.launch { allowanceRepo.markProposalPresented(entryId) } + } + + fun propose(contact: PubkyProfile, perPaymentUsd: Int, monthlyUsd: Int, onSuccess: () -> Unit) { + val limits = limitsInSats(perPaymentUsd, monthlyUsd) + if (limits == null) { + viewModelScope.launch { toastError(context.getString(R.string.subscriptions__allowance_error_unavailable)) } + return + } + runAction(onSuccess) { allowanceRepo.propose(contact.publicKey, limits) } + } + + fun accept(entryId: String, onSuccess: () -> Unit) = runAction(onSuccess) { allowanceRepo.accept(entryId) } + + fun decline(entryId: String, onSuccess: () -> Unit) = runAction(onSuccess) { allowanceRepo.reject(entryId) } + + fun end(entryId: String, onSuccess: () -> Unit) = runAction(onSuccess) { allowanceRepo.end(entryId) } + + private fun runAction(onSuccess: () -> Unit, action: suspend () -> Result) { + if (isWorking.value) return + isWorking.update { true } + viewModelScope.launch { + action() + .onSuccess { onSuccess() } + .onFailure { toastError(errorDescription(it)) } + isWorking.update { false } + } + } + + private fun errorDescription(error: Throwable): String = when (error) { + PaykitAllowanceError.ContactNotLinked -> context.getString(R.string.subscriptions__allowance_error_not_linked) + PaykitAllowanceError.Unavailable -> context.getString(R.string.subscriptions__allowance_error_unavailable) + else -> error.message?.takeIf(String::isNotBlank) ?: context.getString(R.string.common__error_body) + } + + private suspend fun toastError(description: String) = ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.common__error), + description = description, + ) + + private fun limitsInSats(perPaymentUsd: Int, monthlyUsd: Int): PaykitAllowanceLimits? { + val perPaymentSats = currencyRepo.convertFiatToSats(BigDecimal(perPaymentUsd), USD).getOrNull() ?: return null + val monthlySats = currencyRepo.convertFiatToSats(BigDecimal(monthlyUsd), USD).getOrNull() ?: return null + return PaykitAllowanceLimits( + perPaymentUsd = perPaymentUsd, + monthlyUsd = monthlyUsd, + perPaymentSats = perPaymentSats, + monthlySats = monthlySats, + ) + } + + private fun PaykitAllowanceEntry.toUi(contacts: List, paidSats: ULong, now: Instant): AllowanceUi { + val profile = contacts.firstOrNull { PubkyPublicKeyFormat.matches(it.publicKey, counterparty) } + ?: PubkyProfile.placeholder(counterparty) + val status = status(now) + val isAllowee = role == PaykitAllowance.Role.ALLOWEE + return AllowanceUi( + id = id, + counterparty = profile, + status = status, + subtitle = subtitle(status, paidSats), + explanation = explanation(status), + reviewHeadline = context.getString( + if (isAllowee) { + R.string.subscriptions__allowance_offer_headline + } else { + R.string.subscriptions__allowance_request_headline + } + ).replace("{name}", profile.name), + reviewExplanation = context.getString( + if (isAllowee) { + R.string.subscriptions__allowance_offer_explanation + } else { + R.string.subscriptions__allowance_request_explanation + } + ).replace("{name}", profile.name), + monthlyAmount = limitAmount(limits?.monthlyUsd, monthlyLimitSats), + perPaymentUpTo = upTo(limits?.perPaymentUsd, perPaymentMaxSats), + monthlyUpTo = upTo(limits?.monthlyUsd, monthlyLimitSats), + perPaymentSats = perPaymentMaxSats?.toDisplaySats(), + monthlySats = monthlyLimitSats?.toDisplaySats(), + paidSoFar = USD_SYMBOL + fiatValue(paidSats), + isAnswerable = primary.isAnswerable, + canEnd = canEnd, + isProposal = primary.lifecycleState == AllowanceLifecycleState.PROPOSED, + ) + } + + private fun PaykitAllowanceEntry.subtitle(status: PaykitAllowance.Status, paidSats: ULong): String = when (status) { + PaykitAllowance.Status.ACTIVE -> listOfNotNull( + context.getString(R.string.subscriptions__allowance_status_active), + perPaymentShort(), + ).joinToString(" · ") + PaykitAllowance.Status.ENDED -> { + val ended = context.getString(R.string.subscriptions__allowance_status_ended) + if (paidSats == 0uL) { + ended + } else { + val paid = context.getString(R.string.subscriptions__allowance_paid_automatically) + .replace("{amount}", USD_SYMBOL + fiatValue(paidSats)) + "$ended · $paid" + } + } + else -> statusText(status) + } + + private fun PaykitAllowanceEntry.explanation(status: PaykitAllowance.Status): String = when (status) { + PaykitAllowance.Status.ACTIVE -> context.getString( + if (role == PaykitAllowance.Role.ALLOWER) { + R.string.subscriptions__allowance_detail_active_allower + } else { + R.string.subscriptions__allowance_detail_active_allowee + } + ) + PaykitAllowance.Status.ENDED -> context.getString(R.string.subscriptions__allowance_detail_ended) + else -> statusText(status) + } + + private fun statusText(status: PaykitAllowance.Status): String = context.getString( + when (status) { + PaykitAllowance.Status.ACTIVE -> R.string.subscriptions__allowance_status_active + PaykitAllowance.Status.AWAITING_ANSWER -> R.string.subscriptions__allowance_status_waiting + PaykitAllowance.Status.AWAITING_MY_ANSWER -> R.string.subscriptions__allowance_status_needs_answer + PaykitAllowance.Status.NOT_YET_ACTIVE -> R.string.subscriptions__allowance_status_scheduled + PaykitAllowance.Status.EXPIRED -> R.string.subscriptions__allowance_status_expired + PaykitAllowance.Status.DECLINED -> R.string.subscriptions__allowance_status_declined + PaykitAllowance.Status.ENDED -> R.string.subscriptions__allowance_status_ended + PaykitAllowance.Status.CONFLICTED -> R.string.subscriptions__allowance_status_conflicted + } + ) + + private fun PaykitAllowanceEntry.perPaymentShort(): String? { + val amount = limits?.perPaymentUsd?.let(AllowanceAmountText::short) + ?: perPaymentMaxSats?.let { USD_SYMBOL + limitValue(it) } + ?: return null + return context.getString(R.string.subscriptions__allowance_per_payment_short).replace("{amount}", amount) + } + + private fun upTo(usd: Int?, sats: ULong?): String { + val amount = usd?.let { USD_SYMBOL + AllowanceAmountText.formatted(BigDecimal(it)) } + ?: sats?.let { USD_SYMBOL + limitValue(it) } + ?: AllowanceAmountText.UNKNOWN + return context.getString(R.string.subscriptions__allowance_up_to).replace("{amount}", amount) + } + + private fun limitAmount(usd: Int?, sats: ULong?): String = + usd?.let { AllowanceAmountText.formatted(BigDecimal(it)) } + ?: sats?.let(::limitValue) + ?: AllowanceAmountText.UNKNOWN + + private fun fiatValue(sats: ULong): String = usdValue(sats)?.let(AllowanceAmountText::formatted) + ?: AllowanceAmountText.UNKNOWN + + private fun limitValue(sats: ULong): String = usdValue(sats)?.let(AllowanceAmountText::limit) + ?: AllowanceAmountText.UNKNOWN + + private fun usdValue(sats: ULong): BigDecimal? = + currencyRepo.convertSatsToFiat(sats.toDisplaySats(), USD).getOrNull()?.value +} + +@Stable +data class AllowancesUiState( + val isLoaded: Boolean = false, + val allowances: ImmutableList = persistentListOf(), + val contacts: ImmutableList = persistentListOf(), + val isWorking: Boolean = false, +) + +/** One allowance entry as the UI shows it, with every amount already converted to US dollars. */ +@Stable +data class AllowanceUi( + val id: String, + val counterparty: PubkyProfile, + val status: PaykitAllowance.Status, + val subtitle: String, + val explanation: String, + val reviewHeadline: String, + val reviewExplanation: String, + val monthlyAmount: String, + val perPaymentUpTo: String, + val monthlyUpTo: String, + val perPaymentSats: Long?, + val monthlySats: Long?, + val paidSoFar: String, + val isAnswerable: Boolean, + val canEnd: Boolean, + val isProposal: Boolean, +) { + val isInactive: Boolean + get() = when (status) { + PaykitAllowance.Status.ENDED, + PaykitAllowance.Status.DECLINED, + PaykitAllowance.Status.EXPIRED, + PaykitAllowance.Status.CONFLICTED, + -> true + else -> false + } +} + +/** US dollar amounts as iOS shows them: grouped, en_US, with limits set in whole dollars rounded back to the dollar. */ +internal object AllowanceAmountText { + /** Shown when an amount cannot be converted, e.g. before exchange rates load. */ + const val UNKNOWN = "—" + + fun formatted(usd: BigDecimal): String = DecimalFormat("#,##0.00", DecimalFormatSymbols(Locale.US)).format(usd) + + fun short(usd: Int): String = USD_SYMBOL + DecimalFormat("#,##0", DecimalFormatSymbols(Locale.US)).format(usd) + + /** + * A limit set in whole dollars on the other wallet, shown back from its BTC terms at today's rate: + * rounded to the dollar so a small rate move does not turn $5 into $4.99. + */ + fun limit(usd: BigDecimal): String = + formatted(if (usd >= BigDecimal.ONE) usd.setScale(0, RoundingMode.HALF_UP) else usd) +} + +private fun ULong.toDisplaySats(): Long = coerceAtMost(Long.MAX_VALUE.toULong()).toLong() diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt index 12a688b986..dcdfbc81b5 100644 --- a/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt @@ -48,6 +48,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.airbnb.lottie.compose.LottieAnimation import com.airbnb.lottie.compose.LottieCompositionSpec @@ -71,6 +72,7 @@ import to.bitkit.repositories.PaykitPaymentRequestId import to.bitkit.repositories.PaykitRecurrenceUnit import to.bitkit.repositories.PaykitSubscription import to.bitkit.repositories.PaykitSubscriptionId +import to.bitkit.ui.components.AllowanceRoute import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyMSB import to.bitkit.ui.components.BodyS @@ -119,6 +121,7 @@ fun SubscriptionsScreen( onDetails: (PaykitSubscriptionId) -> Unit, onPaymentRequestDetails: (PaykitPaymentRequestId) -> Unit, showPayments: Boolean = false, + allowancesViewModel: AllowancesViewModel = hiltViewModel(), ) { val subscriptions by appViewModel.subscriptions.collectAsStateWithLifecycle() val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() @@ -142,6 +145,7 @@ fun SubscriptionsScreen( }, onCreateSubscription = onCreateSubscription, paymentsContent = { topPadding -> + val autoPaidRequestIds by allowancesViewModel.autoPaidRequestIds.collectAsStateWithLifecycle() PaymentRequestsScreen( appViewModel = appViewModel, onBack = onBack, @@ -149,6 +153,18 @@ fun SubscriptionsScreen( onDetails = onPaymentRequestDetails, showsNavigationBar = false, topPadding = topPadding, + autoPaidRequestIds = autoPaidRequestIds, + ) + }, + allowancesContent = { topPadding -> + AllowancesScreen( + topPadding = topPadding, + onClickAdd = { appViewModel.showSheet(Sheet.Allowance(AllowanceRoute.Set)) }, + onClickAllowance = { + val route = if (it.isAnswerable) AllowanceRoute.Review(it.id) else AllowanceRoute.Details(it.id) + appViewModel.showSheet(Sheet.Allowance(route)) + }, + viewModel = allowancesViewModel, ) }, ) @@ -166,6 +182,7 @@ internal fun SubscriptionsContent( onSubscription: (PaykitSubscription) -> Unit, onCreateSubscription: () -> Unit, paymentsContent: @Composable (topPadding: Dp) -> Unit, + allowancesContent: @Composable (topPadding: Dp) -> Unit = {}, ) { val proposals = subscriptions.filter { it.isPayer && it.isProposalVisible(now) } val active = subscriptions.filter { it.isPayer && it.isActive(now) } @@ -194,6 +211,8 @@ internal fun SubscriptionsContent( ) { if (selectedTab == SubscriptionTab.Payments) { paymentsContent(headerHeight) + } else if (selectedTab == SubscriptionTab.Allowances) { + allowancesContent(headerHeight) } else if (!hasVisibleSubscriptions) { SubscriptionEmptyState( Modifier @@ -321,7 +340,7 @@ private fun SubscriptionTabs( onTabChange: (SubscriptionTab) -> Unit, ) { CustomTabRowWithSpacing( - tabs = persistentListOf(SubscriptionTab.Overview, SubscriptionTab.Payments), + tabs = persistentListOf(SubscriptionTab.Overview, SubscriptionTab.Allowances, SubscriptionTab.Payments), currentTabIndex = selectedTab.ordinal, selectedColor = Colors.White, onTabChange = onTabChange, @@ -332,12 +351,14 @@ private fun SubscriptionTabs( internal enum class SubscriptionTab : TabItem { Overview, + Allowances, Payments; override val uiText: String @Composable get() = stringResource( when (this) { Overview -> R.string.subscriptions__overview + Allowances -> R.string.subscriptions__allowances Payments -> R.string.subscriptions__payments } ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d39957fed1..f92330814d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1074,6 +1074,53 @@ Profile Create Profile Active + Accept + Add Allowance + Auto-paid + Choose Contact + Decline + Your requests within these limits are paid automatically. + Requests within these limits are paid automatically. Anything above them asks you first. + This allowance has ended. Every request asks again. + Each month + This contact is not connected yet. Try again in a moment. + This allowance is not available right now. + Auto-pay sent {amount} to {name} + Payment Executed + A {amount} request from {name} is above your allowance. Review it to pay. + Limit Reached + Monthly allowance + Monthly limit + Add a contact first. Allowances cover payment requests from your contacts. + {name}\'s wallet will pay your requests within these limits without asking each time. Either of you can end it. + an allowance]]> + {amount} paid automatically + Paid automatically + This request is already being paid. + Payment limit + Per payment + {amount} a payment + Your wallet will pay {name}\'s requests within these limits without asking you each time. Either of you can end it. + an allowance]]> + Save Allowance + Automatically approve payment requests from {name} below these limits: + Requests up to {perPayment} will be paid automatically, up to {monthly} a month, without asking you each time. + Set Allowance + Active + Needs attention + Declined + Ended + Expired + Waiting for your answer + Not active yet + Waiting for an answer + Swipe To End Allowance + Swipe To Withdraw + Allowance + Up to {amount} + Allowances + You can set spending limits to automatically fulfill payment requests from trusted peers. + allowances]]> Cancel Cancel Subscription Choose Recipient diff --git a/app/src/test/java/to/bitkit/ui/screens/subscriptions/AllowancesViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/subscriptions/AllowancesViewModelTest.kt new file mode 100644 index 0000000000..7b44377a1f --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/subscriptions/AllowancesViewModelTest.kt @@ -0,0 +1,230 @@ +@file:OptIn(ExperimentalTime::class) + +package to.bitkit.ui.screens.subscriptions + +import android.content.Context +import com.synonym.paykit.AllowanceLifecycleState +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.R +import to.bitkit.models.ConvertedAmount +import to.bitkit.models.PubkyProfile +import to.bitkit.models.USD +import to.bitkit.repositories.CurrencyRepo +import to.bitkit.repositories.CurrencyState +import to.bitkit.repositories.PaykitAllowance +import to.bitkit.repositories.PaykitAllowanceEntry +import to.bitkit.repositories.PaykitAllowanceError +import to.bitkit.repositories.PaykitAllowanceLimits +import to.bitkit.repositories.PaykitAllowanceRepo +import to.bitkit.repositories.PaykitPaymentRequest +import to.bitkit.repositories.PaykitPaymentRequestRepo +import to.bitkit.repositories.PaykitPaymentRequestTarget +import to.bitkit.repositories.PubkyRepo +import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.ServiceError +import java.math.BigDecimal +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +@OptIn(ExperimentalCoroutinesApi::class) +class AllowancesViewModelTest : BaseUnitTest() { + companion object { + private const val LEO_KEY = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + private const val MIA_KEY = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + + /** Sats per US dollar in these tests, so 4 990 sats is $4.99. */ + private val SATS_PER_USD = BigDecimal(1_000) + } + + private val context: Context = mock() + private val allowanceRepo: PaykitAllowanceRepo = mock() + private val paymentRequestRepo: PaykitPaymentRequestRepo = mock() + private val pubkyRepo: PubkyRepo = mock() + private val currencyRepo: CurrencyRepo = mock() + private val clock = object : Clock { + override fun now() = Instant.parse("2027-01-15T08:00:00Z") + } + + private val entries = MutableStateFlow>(emptyList()) + private val autoPaidSats = MutableStateFlow>(emptyMap()) + private val contacts = MutableStateFlow(listOf(profile(LEO_KEY, "Leo"), profile(MIA_KEY, "Mia"))) + private val targets = MutableStateFlow>(emptyList()) + + private lateinit var sut: AllowancesViewModel + + @Before + fun setUp() { + whenever(allowanceRepo.entries).thenReturn(entries) + whenever(allowanceRepo.autoPaidSats).thenReturn(autoPaidSats) + whenever(pubkyRepo.contacts).thenReturn(contacts) + whenever(paymentRequestRepo.eligibleTargets).thenReturn(targets) + whenever(paymentRequestRepo.paymentRequestHistory) + .thenReturn(MutableStateFlow(emptyList())) + whenever(currencyRepo.currencyState).thenReturn(MutableStateFlow(CurrencyState())) + whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())).thenAnswer { + val sats = it.getArgument(0) + ConvertedAmount( + value = BigDecimal(sats).divide(SATS_PER_USD), + formatted = "", + symbol = "$", + currency = USD, + flag = "", + sats = sats, + ) + } + whenever(currencyRepo.convertFiatToSats(any(), anyOrNull())).thenAnswer { + it.getArgument(0).multiply(SATS_PER_USD).toLong().toULong() + } + whenever(context.getString(any())).thenReturn("") + mapOf( + R.string.subscriptions__allowance_status_active to "Active", + R.string.subscriptions__allowance_status_ended to "Ended", + R.string.subscriptions__allowance_status_waiting to "Waiting for an answer", + R.string.subscriptions__allowance_per_payment_short to "{amount} a payment", + R.string.subscriptions__allowance_paid_automatically to "{amount} paid automatically", + R.string.subscriptions__allowance_up_to to "Up to {amount}", + R.string.subscriptions__allowance_error_not_linked to "This contact is not connected yet.", + R.string.common__error to "Error", + ).forEach { (id, text) -> whenever(context.getString(id)).thenReturn(text) } + sut = AllowancesViewModel( + context = context, + allowanceRepo = allowanceRepo, + paymentRequestRepo = paymentRequestRepo, + pubkyRepo = pubkyRepo, + currencyRepo = currencyRepo, + clock = clock, + ) + } + + @Test + fun `limits known only in sats round back to whole dollars`() = test { + entries.value = listOf(entry(perPaymentSats = 4_990uL, monthlySats = 49_900uL, limits = null)) + + val allowance = loadedState().allowances.single() + + assertEquals("Active · $5.00 a payment", allowance.subtitle) + assertEquals("Up to $5.00", allowance.perPaymentUpTo) + assertEquals("50.00", allowance.monthlyAmount) + } + + @Test + fun `limits picked on this wallet show the chosen dollar stops`() = test { + val limits = PaykitAllowanceLimits( + perPaymentUsd = 5, + monthlyUsd = 50, + perPaymentSats = 4_900uL, + monthlySats = 49_000uL, + ) + entries.value = listOf(entry(perPaymentSats = 4_900uL, monthlySats = 49_000uL, limits = limits)) + + val allowance = loadedState().allowances.single() + + assertEquals("Active · $5 a payment", allowance.subtitle) + assertEquals("50.00", allowance.monthlyAmount) + } + + @Test + fun `an ended allowance shows what it paid automatically`() = test { + entries.value = listOf(entry(state = AllowanceLifecycleState.ENDED)) + autoPaidSats.value = mapOf("entry-1" to 2_000uL) + + val allowance = loadedState().allowances.single() + + assertEquals("Ended · $2.00 paid automatically", allowance.subtitle) + assertEquals("$2.00", allowance.paidSoFar) + assertTrue(allowance.isInactive) + } + + @Test + fun `contact picker lists only contacts that can receive payment requests`() = test { + targets.value = listOf(PaykitPaymentRequestTarget(LEO_KEY, "bitkit/wallet")) + + assertEquals(listOf("Leo"), loadedState().contacts.map { it.name }) + } + + @Test + fun `saving converts the chosen dollar stops to sats once`() = test { + val limits = PaykitAllowanceLimits( + perPaymentUsd = 5, + monthlyUsd = 100, + perPaymentSats = 5_000uL, + monthlySats = 100_000uL, + ) + whenever(allowanceRepo.propose(LEO_KEY, limits)).thenReturn(Result.success(Unit)) + var saved = false + + sut.propose(profile(LEO_KEY, "Leo"), perPaymentUsd = 5, monthlyUsd = 100) { saved = true } + + verify(allowanceRepo).propose(LEO_KEY, limits) + assertTrue(saved) + } + + @Test + fun `saving without a dollar rate proposes nothing`() = test { + whenever(currencyRepo.convertFiatToSats(any(), anyOrNull())) + .thenReturn(Result.failure(ServiceError.CurrencyRateUnavailable())) + + sut.propose(profile(LEO_KEY, "Leo"), perPaymentUsd = 5, monthlyUsd = 100) {} + + verify(allowanceRepo, never()).propose(any(), any()) + } + + @Test + fun `a failed save keeps the sheet open and frees the button`() = test { + whenever(allowanceRepo.propose(any(), any())).thenReturn(Result.failure(PaykitAllowanceError.ContactNotLinked)) + var saved = false + + sut.propose(profile(LEO_KEY, "Leo"), perPaymentUsd = 5, monthlyUsd = 100) { saved = true } + + assertEquals(false, saved) + assertEquals(false, loadedState().isWorking) + } + + private fun TestScope.loadedState(): AllowancesUiState { + backgroundScope.launch { sut.uiState.collect {} } + runCurrent() + return sut.uiState.value.also { assertTrue(it.isLoaded) } + } + + private fun entry( + state: AllowanceLifecycleState = AllowanceLifecycleState.ACCEPTED, + perPaymentSats: ULong = 5_000uL, + monthlySats: ULong = 50_000uL, + limits: PaykitAllowanceLimits? = null, + ) = PaykitAllowanceEntry( + id = "entry-1", + allowances = listOf( + PaykitAllowance( + id = PaykitAllowance.Id(LEO_KEY, "bitkit/wallet", "allowance-1"), + role = PaykitAllowance.Role.ALLOWER, + lifecycleState = state, + isProposedByMe = true, + perPaymentMaxSats = perPaymentSats, + monthlyLimitSats = monthlySats, + monthlyAnchor = null, + ), + ), + limits = limits, + ) + + private fun profile(publicKey: String, name: String) = PubkyProfile.forDisplay( + publicKey = publicKey, + name = name, + imageUrl = null, + ) +} diff --git a/docs/screens-map.md b/docs/screens-map.md index bac060af52..960b08ceda 100644 --- a/docs/screens-map.md +++ b/docs/screens-map.md @@ -121,6 +121,7 @@ Frame names are stable across handoff iterations; node ids are not, so the map l | Android | Figma | | - | - | +| AllowancesScreen.kt | `todo` | | CreateSubscriptionScreen.kt | Subscriptions › Create Subscription / Choose Subscription Recipient / Sent Subscription Proposal | | SubscriptionsScreen.kt | Subscriptions › Subscriptions Intro / Subscriptions overview | From f13ccf3e8f798e31abb41841f26deedadcb373c2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 24 Sep 2026 17:49:45 +0200 Subject: [PATCH 07/51] feat: run allowances from the app lifecycle and send flow --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 98 ++++++++++++++++++- .../viewmodels/AppViewModelSendFlowTest.kt | 8 ++ 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index a66e2fe255..3db7282462 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -161,6 +161,9 @@ import to.bitkit.repositories.PaykitPaymentRequestDiagnostics import to.bitkit.repositories.PaykitPaymentRequestDraft import to.bitkit.repositories.PaykitPaymentRequestError import to.bitkit.repositories.PaykitPaymentRequestId +import to.bitkit.repositories.PaykitAllowanceError +import to.bitkit.repositories.PaykitAllowanceEvent +import to.bitkit.repositories.PaykitAllowanceRepo import to.bitkit.repositories.PaykitPaymentRequestRepo import to.bitkit.repositories.PaykitPaymentRequestTarget import to.bitkit.repositories.PaykitSubscription @@ -189,6 +192,7 @@ import to.bitkit.services.MigrationService import to.bitkit.services.NodeServiceFgState import to.bitkit.services.PubkyService import to.bitkit.ui.Routes +import to.bitkit.ui.components.AllowanceRoute import to.bitkit.ui.components.Sheet import to.bitkit.ui.components.SubscriptionRoute import to.bitkit.ui.shared.toast.ToastEventBus @@ -257,6 +261,7 @@ class AppViewModel @Inject constructor( private val publicPaykitRepo: PublicPaykitRepo, private val privatePaykitRepo: PrivatePaykitRepo, private val paykitPaymentRequestRepo: PaykitPaymentRequestRepo, + private val paykitAllowanceRepo: PaykitAllowanceRepo, private val paykitPaymentProofRepo: PaykitPaymentProofRepo, private val paykitPaymentRequestDiagnostics: PaykitPaymentRequestDiagnostics, private val refreshContactPaykitReceivers: RefreshContactPaykitReceiversUseCase, @@ -563,6 +568,7 @@ class AppViewModel @Inject constructor( observeInitialPaykitLinkBursts() observeIncomingPaykitPaymentRequests() observePaykitOnchainPaymentResolution() + observePaykitAllowanceEvents() observeSendEvents() viewModelScope.launch { checkCriticalAppUpdate() @@ -712,6 +718,7 @@ class AppViewModel @Inject constructor( preserveRequestedPaymentRequest = paymentRequestIdentity == null, ) paymentRequestIdentity = null + paykitAllowanceRepo.deactivate() try { paykitPaymentRequestRepo.clear() } finally { @@ -733,6 +740,7 @@ class AppViewModel @Inject constructor( isPaymentRequestIdentityActivating = true try { paykitPaymentRequestRepo.activate(state.publicKey) + paykitAllowanceRepo.activate(state.publicKey) if (!PubkyPublicKeyFormat.matches(pubkyRepo.publicKey.value, state.publicKey)) return paymentRequestIdentity = state.publicKey refreshPrivateOnlyPaykitReceiverMarker("contact sync") @@ -870,10 +878,54 @@ class AppViewModel @Inject constructor( if (!isPaykitEnabled.value || pubkyRepo.publicKey.value == null || !walletRepo.walletExists()) return if (refreshMaintenance) paykitPaymentProofRepo.reconcile() paykitPaymentRequestRepo.refresh().onSuccess { + paykitAllowanceRepo.refresh() + if (paykitAllowanceRepo.processIncomingRequests(paykitPaymentRequestRepo.pendingRequests.value)) { + paykitPaymentRequestRepo.refresh() + } presentNextIncomingPaykitPaymentRequest() } } + private fun observePaykitAllowanceEvents() { + viewModelScope.launch { + paykitAllowanceRepo.events.collect(::handlePaykitAllowanceEvent) + } + } + + private fun handlePaykitAllowanceEvent(event: PaykitAllowanceEvent) { + when (event) { + is PaykitAllowanceEvent.PaidAutomatically -> toast( + type = Toast.ToastType.LIGHTNING, + title = context.getString(R.string.subscriptions__allowance_executed_title), + description = context.getString(R.string.subscriptions__allowance_executed_description) + .replace("{amount}", allowanceFiatAmount(event.amountSats)) + .replace("{name}", allowanceContactName(event.counterparty)), + testTag = "AllowancePaidToast", + ) + + is PaykitAllowanceEvent.LimitReached -> toast( + type = Toast.ToastType.WARNING, + title = context.getString(R.string.subscriptions__allowance_limit_title), + description = context.getString(R.string.subscriptions__allowance_limit_description) + .replace("{amount}", allowanceFiatAmount(event.amountSats)) + .replace("{name}", allowanceContactName(event.counterparty)), + testTag = "AllowanceLimitToast", + ) + + PaykitAllowanceEvent.LedgerChanged -> Unit + } + } + + private fun allowanceFiatAmount(sats: ULong): String = + currencyRepo.convertSatsToFiat(sats.toLong()).getOrNull()?.let { "${it.symbol}${it.formatted}" } + ?: "$sats sats" + + private fun allowanceContactName(publicKey: String): String = + pubkyRepo.contacts.value.firstOrNull { PubkyPublicKeyFormat.matches(it.publicKey, publicKey) } + ?.name + ?.takeIf { it.isNotBlank() } + ?: PubkyPublicKeyFormat.display(publicKey) + private suspend fun refreshPaymentRequestTargets(force: Boolean = false) { if (!isPaykitEnabled.value || pubkyRepo.publicKey.value == null || !walletRepo.walletExists()) return paykitPaymentRequestRepo.refreshEligibleTargets( @@ -1000,12 +1052,19 @@ class AppViewModel @Inject constructor( private suspend fun presentNextIncomingPaykitPaymentRequest() { if (isPresentingPaymentRequest || isPaymentRequestPresentationBlocked()) return if (requestedPaymentRequestId == null) { + paykitAllowanceRepo.proposalForPresentation()?.let { + showSheet(Sheet.Allowance(AllowanceRoute.Review(it.id))) + return + } paykitPaymentRequestRepo.automaticSubscriptionProposals().firstOrNull()?.let { showSheet(Sheet.Subscription(SubscriptionRoute.Review(it.id))) return } } - val requests = paymentRequestsForPresentation() ?: return + val requests = paymentRequestsForPresentation() + ?.filterNot { paykitAllowanceRepo.isAutomaticallyHandling(it) } + ?.takeIf { it.isNotEmpty() } + ?: return val generation = paymentRequestPresentationGeneration isPresentingPaymentRequest = true activePaymentRequestPresentationGeneration = generation @@ -4041,27 +4100,55 @@ class AppViewModel @Inject constructor( } } + val allowanceAttemptId = beginManualAllowancePayment(preparedPaymentProofRequest).getOrElse { + cancelPaymentProofPreparation(preparedPaymentProofRequest) + handlePaymentPreparationFailure(it, contactPaymentContext) + return + } + when (_sendUiState.value.payMethod) { SendMethod.ONCHAIN -> proceedWithOnchainPayment( incomingPaymentRequest, preparedPaymentProofRequest, contactPaymentContext, amount, + allowanceAttemptId, ) SendMethod.LIGHTNING -> proceedWithLightningPayment( incomingPaymentRequest, preparedPaymentProofRequest, amount, + allowanceAttemptId, ) } } + /** + * Reports a manual payment of an incoming request to the allowance ledger, so an allowance never pays the same + * request again. Only a payment the ledger already holds stops this one; any other ledger failure is logged. + */ + private suspend fun beginManualAllowancePayment(request: PaykitPaymentRequest?): Result { + if (request == null) return Result.success(null) + return paykitAllowanceRepo.beginManualPayment(request, paymentProofPreparation().endpointIdentifier) + .recoverCatching { + if (it is PaykitAllowanceError.PaymentAlreadyRecorded) throw it + Logger.warn("Failed to report a manual payment to the allowance ledger", it, context = TAG) + null + } + } + + private fun finishManualAllowancePayment(attemptId: String?, succeeded: Boolean?, transactionId: String? = null) { + if (attemptId == null) return + viewModelScope.launch { paykitAllowanceRepo.finishManualPayment(attemptId, succeeded, transactionId) } + } + private suspend fun proceedWithOnchainPayment( incomingPaymentRequest: PaykitPaymentRequest?, preparedPaymentProofRequest: PaykitPaymentRequest?, contactPaymentContext: ContactPaymentContext?, amount: ULong, + allowanceAttemptId: String?, ) { val address = _sendUiState.value.address val tags = _sendUiState.value.selectedTags @@ -4080,6 +4167,7 @@ class AppViewModel @Inject constructor( onBroadcast = { txId -> proofRequest = null completeOnchainPaymentProofInBackground(incomingPaymentRequest, txId) + finishManualAllowancePayment(allowanceAttemptId, succeeded = true, transactionId = txId) }, ).onSuccess { txId -> Logger.info("Onchain send result txid: $txId", context = TAG) @@ -4102,6 +4190,7 @@ class AppViewModel @Inject constructor( incomingPaymentRequest = incomingPaymentRequest, preparedPaymentProofRequest = proofRequest, contactPaymentContext = contactPaymentContext, + allowanceAttemptId = allowanceAttemptId, ) } } @@ -4112,10 +4201,12 @@ class AppViewModel @Inject constructor( incomingPaymentRequest: PaykitPaymentRequest?, preparedPaymentProofRequest: PaykitPaymentRequest?, contactPaymentContext: ContactPaymentContext?, + allowanceAttemptId: String? = null, ) { val amount = _sendUiState.value.amount if (paymentStarted && !error.isDefiniteOnchainPreBroadcastFailure()) { Logger.warn("On-chain payment outcome is uncertain after send started", error, context = TAG) + finishManualAllowancePayment(allowanceAttemptId, succeeded = null) uncertainOnchainPaymentRequestId = incomingPaymentRequest?.id paykitPaymentProofRepo.onchainPaymentResolutions.value .firstOrNull { it.requestId == incomingPaymentRequest?.id } @@ -4133,6 +4224,7 @@ class AppViewModel @Inject constructor( if (paymentStarted) { incomingPaymentRequest?.let { paykitPaymentProofRepo.failOnchainPayment(it) } } + finishManualAllowancePayment(allowanceAttemptId, succeeded = false) cancelPaymentProofPreparation(preparedPaymentProofRequest) Logger.error("Error sending onchain payment", error, context = TAG) if (contactPaymentContext?.isInitialSubscriptionPayment == true) { @@ -4151,6 +4243,7 @@ class AppViewModel @Inject constructor( incomingPaymentRequest: PaykitPaymentRequest?, preparedPaymentProofRequest: PaykitPaymentRequest?, amount: ULong, + allowanceAttemptId: String?, ) { val decodedInvoice = requireNotNull(_sendUiState.value.decodedInvoice) val paymentAmount = if (decodedInvoice.amountSatoshis > 0uL) null else amount @@ -4177,6 +4270,8 @@ class AppViewModel @Inject constructor( } val lnurlComment = savePendingLnurlComment(decodedInvoice, paymentHash) + // The node's payment events settle this attempt by hash, like an automatic one. + allowanceAttemptId?.let { paykitAllowanceRepo.manualLightningPaymentSent(it, paymentHash) } sendLightning(decodedInvoice.bolt11, paymentAmount).onSuccess { actualPaymentHash -> proofRequest = null @@ -4203,6 +4298,7 @@ class AppViewModel @Inject constructor( return@onFailure } cancelPaymentProofPreparation(proofRequest) + finishManualAllowancePayment(allowanceAttemptId, succeeded = false) createdMetadataPaymentId?.let { preActivityMetadataRepo.deletePreActivityMetadata(it) } lnurlComment?.let { activityRepo.clearPendingLightningMessage(paymentHash) } Logger.error("Error sending lightning payment", error, context = TAG) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 8e06bb881b..e355374265 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -133,6 +133,7 @@ import to.bitkit.repositories.PaykitPaymentRequestDiagnostics import to.bitkit.repositories.PaykitPaymentRequestDraft import to.bitkit.repositories.PaykitPaymentRequestError import to.bitkit.repositories.PaykitPaymentRequestId +import to.bitkit.repositories.PaykitAllowanceRepo import to.bitkit.repositories.PaykitPaymentRequestRepo import to.bitkit.repositories.PaykitPaymentRequestTarget import to.bitkit.repositories.PaykitRecurrenceUnit @@ -232,6 +233,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val publicPaykitRepo = mock() private val privatePaykitRepo = mock() private val paykitPaymentRequestRepo = mock() + private val paykitAllowanceRepo = mock() private val paykitPaymentProofRepo = mock() private val paykitPaymentRequestDiagnostics = mock() private val samRockRepo = mock() @@ -389,6 +391,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitPaymentRequestRepo.isExpired(any())).thenReturn(false) whenever(paykitPaymentRequestRepo.isProcessing(any())).thenReturn(false) whenever(paykitPaymentProofRepo.onchainPaymentResolutions).thenReturn(onchainPaymentResolutions) + whenever(paykitAllowanceRepo.events).thenReturn(MutableSharedFlow()) + whenever { paykitAllowanceRepo.refresh() }.thenReturn(Result.success(Unit)) + whenever { paykitAllowanceRepo.beginManualPayment(any(), any()) }.thenReturn(Result.success(null)) + whenever { paykitAllowanceRepo.processIncomingRequests(any()) }.thenReturn(false) + whenever { paykitAllowanceRepo.isAutomaticallyHandling(any()) }.thenReturn(false) whenever { paykitPaymentProofRepo.prepare(any(), any(), any()) }.thenReturn(Result.success(Unit)) whenever { paykitPaymentProofRepo.associateLightningPayment(any(), any(), any()) @@ -485,6 +492,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { publicPaykitRepo = publicPaykitRepo, privatePaykitRepo = privatePaykitRepo, paykitPaymentRequestRepo = paykitPaymentRequestRepo, + paykitAllowanceRepo = paykitAllowanceRepo, paykitPaymentProofRepo = paykitPaymentProofRepo, paykitPaymentRequestDiagnostics = paykitPaymentRequestDiagnostics, refreshContactPaykitReceivers = refreshContactPaykitReceivers, From 7bf7640294af0b79d2b2391cdb1febb944ee7354 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 24 Sep 2026 18:27:43 +0200 Subject: [PATCH 08/51] fix: wait for the payee's next payment list instead of asking the payer --- .../repositories/PaykitAllowanceExecutor.kt | 6 +++++- .../repositories/PaykitAllowanceRepo.kt | 6 +++++- .../bitkit/repositories/PrivatePaykitRepo.kt | 15 +++++++++++-- .../PaykitAllowanceExecutorTest.kt | 10 +++++++++ .../PrivatePaykitAllowancePaymentTest.kt | 21 +++++++++++++++++-- 5 files changed, 52 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt index 450df5cd34..7fcc7b96b3 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt @@ -384,7 +384,11 @@ class PaykitAllowanceExecutor @Inject constructor( return PaykitAllowanceAutoPayResult.MANUAL } - val payment = payer.resolve(request, candidate.eligiblePaymentEndpointIdentifiers).getOrThrow() + val payment = payer.resolve(request, candidate.eligiblePaymentEndpointIdentifiers).getOrElse { + if (it !is PaykitAllowanceError.PaymentListPending) throw it + Logger.info("Deferred an incoming request until the payee publishes a new payment list", context = TAG) + return PaykitAllowanceAutoPayResult.DEFERRED + } if (payment == null) { Logger.info("Kept an incoming request manual: no payable private endpoint", context = TAG) return PaykitAllowanceAutoPayResult.MANUAL diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt index a73663ac00..707746f8c6 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt @@ -65,12 +65,16 @@ enum class PaykitAllowanceAutoPayResult { /** The on-chain payment was broadcast and recorded. */ COMPLETED, + + /** The payee has not published a payment list newer than the one last paid; the next refresh tries again. */ + DEFERRED, } sealed class PaykitAllowanceError(message: String) : AppError(message) { data object ContactNotLinked : PaykitAllowanceError("The contact has no linked Paykit receiver") data object Unavailable : PaykitAllowanceError("The allowance is unavailable") data object PaymentAlreadyRecorded : PaykitAllowanceError("A payment for this request is already in progress") + data object PaymentListPending : PaykitAllowanceError("The payee has not published a new payment list yet") } /** @@ -401,7 +405,7 @@ class PaykitAllowanceRepo @Inject constructor( when (result) { PaykitAllowanceAutoPayResult.STARTED, PaykitAllowanceAutoPayResult.COMPLETED -> handledAny = true PaykitAllowanceAutoPayResult.MANUAL -> manualRequestSignatures[request.id] = signature - PaykitAllowanceAutoPayResult.NOT_COVERED -> Unit + PaykitAllowanceAutoPayResult.NOT_COVERED, PaykitAllowanceAutoPayResult.DEFERRED -> Unit } } if (handledAny) refresh() diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 698e9f8dfb..d6c1518c4d 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -409,8 +409,19 @@ class PrivatePaykitRepo @Inject constructor( afterPrivatePaymentListVersion = consumedVersion, amount = PaymentAmountContext(request.amountValue, PaykitIssuerInterop.BITCOIN_ASSET), ) - val paymentListVersion = prepared.resolution.privatePaymentListVersion - ?: return@runSuspendCatching null + val resolution = prepared.resolution + if ( + resolution.state == PrivatePaymentResolutionState.RECOVERY_PENDING || + resolution.status == PrivatePaymentResolutionStatus.WAITING_FOR_UPDATED_PAYMENT_LIST + ) { + // The last list was already paid from; a new one arrives once the payee sees that payment settle. + schedulePendingPrivateMessageDrainRetries( + reason = "allowance payment", + retryKeys = listOf(PrivateMessageDrainRetryKey(publicKey, request.counterpartyReceiverPath)), + ) + throw PaykitAllowanceError.PaymentListPending + } + val paymentListVersion = resolution.privatePaymentListVersion ?: return@runSuspendCatching null val eligible = eligibleIdentifiers.toSet() intersect request.acceptedPaymentEndpointIdentifiers.toSet() val candidates = prepared.resolution.payableEndpoints diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt index 6d364491f1..1acae70fbe 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt @@ -291,6 +291,16 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { verify(payer).payLightning(eq(INVOICE), eq(1_000uL)) } + @Test + fun `request waits without acceptance while the payee's payment list is pending`() = test { + whenever(payer.resolve(any(), any())).thenReturn(Result.failure(PaykitAllowanceError.PaymentListPending)) + + val result = sut.autoPay(fixtures.paymentRequest(), listOf(fixtures.allowance()), identity) + + assertEquals(PaykitAllowanceAutoPayResult.DEFERRED, result) + verify(sdk, never()).acceptPaymentRequestAutomatically(any(), any(), any()) + } + @Test fun `blocked candidate stays manual without acceptance`() = test { candidates = listOf(candidate(blocked = AllowanceAccountingBlock.SharedRule("amount_outside_range"))) diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAllowancePaymentTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAllowancePaymentTest.kt index 2e4ae32b6b..a84eb156a7 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAllowancePaymentTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAllowancePaymentTest.kt @@ -183,6 +183,19 @@ class PrivatePaykitAllowancePaymentTest : BaseUnitTest(StandardTestDispatcher()) assertNull(sut.resolveAllowancePayment(paymentRequest(), eligible(MethodId.P2wpkh)).getOrThrow()) } + @Test + fun `allowance payment waits for a payment list newer than the one already paid`() = test { + stubResolution( + resolvedEndpoint(MethodId.Bolt11, PRIVATE_BOLT11), + version = null, + status = PrivatePaymentResolutionStatus.WAITING_FOR_UPDATED_PAYMENT_LIST, + ) + + val result = sut.resolveAllowancePayment(paymentRequest(), eligible(MethodId.Bolt11)) + + assertEquals(PaykitAllowanceError.PaymentListPending, result.exceptionOrNull()) + } + @Test fun `expired request is never resolved`() = test { val expired = paymentRequest().copy(expiresAt = Instant.fromEpochSeconds(NOW_SECONDS - 1)) @@ -191,12 +204,16 @@ class PrivatePaykitAllowancePaymentTest : BaseUnitTest(StandardTestDispatcher()) verify(paykitSdkService, never()).prepareAndResolvePrivateContactPayment(any(), any(), anyOrNull(), anyOrNull()) } - private suspend fun stubResolution(vararg endpoints: PaykitResolvedPaymentEndpoint, version: ULong? = 7uL) { + private suspend fun stubResolution( + vararg endpoints: PaykitResolvedPaymentEndpoint, + version: ULong? = 7uL, + status: PrivatePaymentResolutionStatus = PrivatePaymentResolutionStatus.PAYABLE, + ) { whenever(paykitSdkService.prepareAndResolvePrivateContactPayment(any(), any(), anyOrNull(), anyOrNull())) .thenReturn( PaykitPreparedPrivateContactPayment( resolution = PaykitPrivateContactPaymentResolution( - status = PrivatePaymentResolutionStatus.PAYABLE, + status = status, state = PrivatePaymentResolutionState.AVAILABLE, privatePaymentListVersion = version, payableEndpoints = endpoints.toList(), From 3d7e2aff1305623eaa435a308146451866e9cf32 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 24 Sep 2026 18:31:53 +0200 Subject: [PATCH 09/51] fix: hold automatic payments until the node's channels are usable --- .../repositories/PaykitAllowanceRepo.kt | 12 +++++++++- .../repositories/PaykitAllowanceRepoTest.kt | 22 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt index 707746f8c6..f51e2e4e15 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt @@ -370,7 +370,8 @@ class PaykitAllowanceRepo @Inject constructor( val covered = requests.filter { it.requiresAcceptance && coversRequest(it) && manualRequestSignatures[it.id] != signature } - if (covered.isEmpty() || !isProcessingRequests.compareAndSet(false, true)) return@withContext false + if (covered.isEmpty() || !canPayNow()) return@withContext false + if (!isProcessingRequests.compareAndSet(false, true)) return@withContext false try { payCovered(covered, identity, signature) @@ -390,6 +391,15 @@ class PaykitAllowanceRepo @Inject constructor( manualRequestSignatures[request.id] != allowancesSignature() } + /** + * A node that just started lists its channels before they reconnect, and a payment sent then fails with no route. + * Covered requests wait for the next refresh instead of falling back to the manual flow. + */ + private fun canPayNow(): Boolean { + val state = lightningRepo.lightningState.value + return state.nodeLifecycleState.isRunning() && (state.channels.isEmpty() || state.channels.any { it.isUsable }) + } + /** Request ids paid automatically, for the "Auto-paid" tag in payment history. */ fun isAutoPaid(id: PaykitPaymentRequestId): Boolean = id in _autoPaidRequestIds.value diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt index d00148cd36..a50f024a97 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt @@ -1,5 +1,8 @@ package to.bitkit.repositories +import org.mockito.kotlin.doReturn +import kotlinx.collections.immutable.persistentListOf +import org.lightningdevkit.ldknode.ChannelDetails import com.synonym.paykit.AllowanceHistoryStatus import com.synonym.paykit.AllowanceLifecycleState import com.synonym.paykit.AllowanceLocalRole @@ -342,6 +345,7 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { @Test fun `covered request stays off the Send sheet until it is found manual`() = test { + nodeReady() records = listOf(fixtures.record()) whenever(executor.autoPay(any(), any(), any())).thenReturn(PaykitAllowanceAutoPayResult.MANUAL) sut.activate(identity) @@ -372,6 +376,7 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { @Test fun `started payment is reported and refreshes the allowances`() = test { + nodeReady() records = listOf(fixtures.record()) sut.activate(identity) val uncovered = fixtures.paymentRequest(id = "other", counterparty = fixtures.otherCounterpartyKey) @@ -384,6 +389,21 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { verify(sdk, times(2)).listAllowances(any()) } + @Test + fun `covered request waits while the node's channels reconnect`() = test { + records = listOf(fixtures.record()) + sut.activate(identity) + val reconnecting = mock { on { isUsable } doReturn false } + lightningState.update { + it.copy(nodeLifecycleState = NodeLifecycleState.Running, channels = persistentListOf(reconnecting)) + } + val request = fixtures.paymentRequest() + + assertFalse(sut.processIncomingRequests(listOf(request))) + assertTrue(sut.isAutomaticallyHandling(request)) + verify(executor, never()).autoPay(any(), any(), any()) + } + @Test fun `nothing is processed without an identity`() = test { assertFalse(sut.processIncomingRequests(listOf(fixtures.paymentRequest()))) @@ -443,6 +463,8 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { // region Helpers + private fun nodeReady() = lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Running) } + private fun group(vararg allowanceIds: String) = PaykitAllowanceLocalState.Group( id = "group-1", counterparty = fixtures.counterpartyKey, From 8ac84fc172b4df3f088457a368596f06940253ca Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 24 Sep 2026 18:56:41 +0200 Subject: [PATCH 10/51] fix: notify allowance payments and limits over the request sheet --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 3db7282462..229a40de2a 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -191,10 +191,12 @@ import to.bitkit.services.CoreService import to.bitkit.services.MigrationService import to.bitkit.services.NodeServiceFgState import to.bitkit.services.PubkyService +import to.bitkit.ui.ID_NOTIFICATION_SKIPPED import to.bitkit.ui.Routes import to.bitkit.ui.components.AllowanceRoute import to.bitkit.ui.components.Sheet import to.bitkit.ui.components.SubscriptionRoute +import to.bitkit.ui.pushNotification import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.ui.shared.toast.ToastQueueManager import to.bitkit.ui.sheets.SendRoute @@ -894,7 +896,7 @@ class AppViewModel @Inject constructor( private fun handlePaykitAllowanceEvent(event: PaykitAllowanceEvent) { when (event) { - is PaykitAllowanceEvent.PaidAutomatically -> toast( + is PaykitAllowanceEvent.PaidAutomatically -> notifyAllowanceEvent( type = Toast.ToastType.LIGHTNING, title = context.getString(R.string.subscriptions__allowance_executed_title), description = context.getString(R.string.subscriptions__allowance_executed_description) @@ -903,7 +905,7 @@ class AppViewModel @Inject constructor( testTag = "AllowancePaidToast", ) - is PaykitAllowanceEvent.LimitReached -> toast( + is PaykitAllowanceEvent.LimitReached -> notifyAllowanceEvent( type = Toast.ToastType.WARNING, title = context.getString(R.string.subscriptions__allowance_limit_title), description = context.getString(R.string.subscriptions__allowance_limit_description) @@ -916,6 +918,15 @@ class AppViewModel @Inject constructor( } } + /** + * Allowance events post a system notification when allowed, as on iOS: the request sheet that opens right after a + * limit is reached would cover an in-app toast. Without the permission they fall back to a toast. + */ + private fun notifyAllowanceEvent(type: Toast.ToastType, title: String, description: String, testTag: String) { + if (context.pushNotification(title, description) != ID_NOTIFICATION_SKIPPED) return + toast(type = type, title = title, description = description, testTag = testTag) + } + private fun allowanceFiatAmount(sats: ULong): String = currencyRepo.convertSatsToFiat(sats.toLong()).getOrNull()?.let { "${it.symbol}${it.formatted}" } ?: "$sats sats" From e0995512392293c472633342f35787453ed43761 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 25 Sep 2026 00:19:09 +0200 Subject: [PATCH 11/51] docs: add the allowance journeys --- journeys/allowances/README.md | 51 +++++++++++++++++++ journeys/allowances/above-limit-asks.xml | 19 +++++++ journeys/allowances/auto-pay-under-limit.xml | 22 ++++++++ journeys/allowances/end-stops-auto-pay.xml | 26 ++++++++++ journeys/allowances/monthly-cap-reached.xml | 22 ++++++++ .../allowances/restart-never-pays-twice.xml | 21 ++++++++ journeys/allowances/set-and-accept.xml | 26 ++++++++++ 7 files changed, 187 insertions(+) create mode 100644 journeys/allowances/README.md create mode 100644 journeys/allowances/above-limit-asks.xml create mode 100644 journeys/allowances/auto-pay-under-limit.xml create mode 100644 journeys/allowances/end-stops-auto-pay.xml create mode 100644 journeys/allowances/monthly-cap-reached.xml create mode 100644 journeys/allowances/restart-never-pays-twice.xml create mode 100644 journeys/allowances/set-and-accept.xml diff --git a/journeys/allowances/README.md b/journeys/allowances/README.md new file mode 100644 index 0000000000..da9ccc442f --- /dev/null +++ b/journeys/allowances/README.md @@ -0,0 +1,51 @@ +# Allowances journeys + +Cover the Paykit allowance lifecycle between two Bitkit instances: the payer sets an allowance for a +contact, the payee accepts it, requests within the limits are paid without asking, a request above a +limit falls back to the normal Payment Request sheet, and either side ends it. The restart journey +pins the one rule that must never break: a payment interrupted mid-flight is never paid twice. + +## Setup + +Run Bitkit against regtest with Paykit UI enabled on two instances that have each other saved as +contacts and linked on receiver path `bitkit/wallet`, exactly as for +[`../subscriptions/README.md`](../subscriptions/README.md). One instance plays the payer (the one +that sets the allowance), the other the payee (the one that sends requests). Automatic payments go +over Lightning only, so the payer needs a spending balance above 50,000 sats with a usable channel, +and the payee needs receiving capacity; fund both through the staging LSP. + +Grant the payer notification permission first (`adb shell pm grant to.bitkit.dev +android.permission.POST_NOTIFICATIONS`): the "Payment Executed" and "Limit Reached" events post a +system notification when they can, and fall back to a toast that `android layout` cannot see. + +The journeys set $5 a payment and $50 a month, the second stop on each slider, and the cap journey +sets $5 a payment and $10 a month, the first monthly stop. Requests are one-time Payment Requests +created from the Payments tab of the payee, in the fiat unit. Dollar amounts on screen are converted +at the current rate, so a sats amount next to them will differ between runs. + +## Reference evidence + +The source run drove every journey on 2026-09-24 across two Android 16 emulators against the +staging regtest LSP, with Paykit built from pubky/paykit-rs#161. A $2 and a $4 request were paid +about two seconds after arriving, a $20 request asked, the third $4 request on a $10 monthly cap +raised Limit Reached, ending the allowance from either side stopped automatic payments, and a payer +killed right after handing the payment to the node never paid twice after relaunch. + +## Identifiers used + +- Tab: `Tab-allowances`; empty state `AllowancesEmpty`; footer button `AllowanceAdd` +- Contact picker: `AllowanceContact-` +- Set sheet: `SetAllowance`, sliders `AllowancePerPayment` and `AllowanceMonthly` with stops + `AllowancePerPaymentStop-` and `AllowanceMonthlyStop-`, `AllowanceSummary`, + `AllowanceSave` +- List row: `AllowanceRow-` with its status line `AllowanceRowStatus` +- Review sheet (payee): `AllowanceReview`, `AllowanceCounterparty`, `AllowancePerPaymentValue`, + `AllowanceMonthlyValue`, `AllowanceAccept`, `AllowanceDecline` +- Detail sheet: `AllowanceDetail`, `AllowancePaidSoFar`, `AllowanceDetailStatus` +- Payments tab: `Tab-payments`, `PaymentRequestCreate`, `PaymentRequestRow-` + whose subtitle ends with "· Auto-paid" for an automatic payment +- Incoming request: `PaymentRequestsBell`, `PaymentRequestsSheet` + +The review sheet on the payee opens on its own about a second after the proposal arrives; no tap is +needed to reach it. `android layout` can omit test tags applied to plain `Box` and `Column` +containers; use the raw UI Automator hierarchy when a documented container tag is missing. diff --git a/journeys/allowances/above-limit-asks.xml b/journeys/allowances/above-limit-asks.xml new file mode 100644 index 0000000000..598cfe4932 --- /dev/null +++ b/journeys/allowances/above-limit-asks.xml @@ -0,0 +1,19 @@ + + + A request above the per-payment limit is never paid automatically: it arrives as an ordinary + Payment Request that the payer pays by swiping, and a manual payment does not count toward the + amount paid automatically. Requires the Active $5 a payment allowance from set-and-accept.xml + and a payer balance above the request. + + + Launch the E2E Bitkit app on both instances with the $5 a payment, $50 a month allowance Active and at least one automatic payment made, per auto-pay-under-limit.xml + On the payee instance, open Subscriptions, the Payments tab (testTag "Tab-payments"), tap Request Payment (testTag "PaymentRequestCreate") and send the payer a one-time Payment Request for $20 with the note "Artpack" + Switch to the payer instance + Verify the Payment Request sheet (testTag "PaymentRequestsSheet") opens for $20.00 from the payee, or the bell (testTag "PaymentRequestsBell") shows one pending request, and that nothing was sent automatically + Verify no "Payment Executed" notification was posted for this request + Pay it from the sheet (testTag "PaymentRequestPay-<paymentRequestId>") and complete Swipe To Pay + Verify Bitcoin Sent shows about $20.00 + Open Subscriptions, tap the Payments tab and verify the "Artpack" row is listed without "· Auto-paid" in its subtitle + Tap the Allowances tab, tap the payee's row and verify PAID AUTOMATICALLY (testTag "AllowancePaidSoFar") still shows only the automatic payments, not the $20 + + diff --git a/journeys/allowances/auto-pay-under-limit.xml b/journeys/allowances/auto-pay-under-limit.xml new file mode 100644 index 0000000000..e539f7e258 --- /dev/null +++ b/journeys/allowances/auto-pay-under-limit.xml @@ -0,0 +1,22 @@ + + + A request within both limits is paid without the payer seeing a sheet. The payer only gets a + "Payment Executed" notification, the payee receives the payment, and the payer's Payments tab and + allowance detail both account for it. Requires the Active allowance from set-and-accept.xml and + notification permission granted on the payer. + + + Launch the E2E Bitkit app on both instances with the $5 a payment, $50 a month allowance from set-and-accept.xml Active, and the payer's notification permission granted + On the payee instance, open the drawer menu, tap Subscriptions, tap the Payments tab (testTag "Tab-payments") and tap Request Payment (testTag "PaymentRequestCreate") + Send the payer a one-time Payment Request for $2 with the note "Devlog" + Switch to the payer instance, with Bitkit in the foreground on the home screen + Verify that within about five seconds no Payment Request sheet opens and the bell (testTag "PaymentRequestsBell") shows no pending request + Verify a "Payment Executed" notification with the text "Auto-pay sent $2.00 to <payee>" is posted, reading it back with `adb shell dumpsys notification --noredact`; without notification permission it is a toast that only a screenshot catches + Switch to the payee instance and verify the payment arrives, either as the Received Instant Bitcoin sheet or as a $2.00 "Received from <payer>" row in Activity + Switch to the payer instance, open the drawer menu, tap Subscriptions and tap the Payments tab + Verify the "Devlog" row (testTag "PaymentRequestRow-<paymentRequestId>") is listed with a subtitle ending in "· Auto-paid" + Tap the Allowances tab, then tap the payee's row + Verify the detail sheet (testTag "AllowanceDetail") shows PAID AUTOMATICALLY "$2.00" (testTag "AllowancePaidSoFar") and the explanation "Requests within these limits are paid automatically. Anything above them asks you first." (testTag "AllowanceDetailStatus") + Open Activity from the home screen and verify the newest row reads "Sent to <payee>" for $2.00 + + diff --git a/journeys/allowances/end-stops-auto-pay.xml b/journeys/allowances/end-stops-auto-pay.xml new file mode 100644 index 0000000000..ab8d7a761b --- /dev/null +++ b/journeys/allowances/end-stops-auto-pay.xml @@ -0,0 +1,26 @@ + + + Either side can end an allowance from its detail sheet, and from then on every request asks + again. The first half ends it on the payer; the second half sets a fresh allowance and ends it + on the payee. In both cases the payer's row keeps the amount that was paid automatically, and the + next request waits in the bell as an ordinary Payment Request. + + + Launch the E2E Bitkit app on both instances with an Active allowance that has paid at least one request automatically, per auto-pay-under-limit.xml + On the payer instance, open the drawer menu, tap Subscriptions, tap the Allowances tab and tap the payee's row + Verify the detail sheet (testTag "AllowanceDetail") ends with a swipe control reading "Swipe To End Allowance" + Swipe the control to the end + Verify the sheet dismisses and the row's status line (testTag "AllowanceRowStatus") reads "Ended · " followed by the amount paid automatically, such as "Ended · $2.00 paid automatically", with the row dimmed + Tap the row and verify the detail explanation (testTag "AllowanceDetailStatus") reads "This allowance has ended. Every request asks again." with no swipe control + On the payee instance, send the payer a one-time Payment Request for $2 with the note "AfterEnd" + On the payer instance, verify it is not paid: no "Payment Executed" notification, and the request waits in the bell (testTag "PaymentRequestsBell") as an ordinary Payment Request + Dismiss that request + On the payee instance, open Subscriptions and the Allowances tab, and verify the payer's row reads "Ended" + Set and accept a fresh $5 a payment, $50 a month allowance between the same two instances, per set-and-accept.xml + On the payee instance, tap the Active row, verify the detail ends with "Swipe To End Allowance", and swipe it to the end + Verify the payee's row reads "Ended" + On the payer instance, verify its row for that allowance reads "Ended · $0.00 paid automatically" + On the payee instance, send the payer a one-time Payment Request for $2 with the note "AfterPayeeEnd" + On the payer instance, verify it is not paid and waits in the bell as an ordinary Payment Request, then dismiss it + + diff --git a/journeys/allowances/monthly-cap-reached.xml b/journeys/allowances/monthly-cap-reached.xml new file mode 100644 index 0000000000..4f6cd032c2 --- /dev/null +++ b/journeys/allowances/monthly-cap-reached.xml @@ -0,0 +1,22 @@ + + + Requests keep paying themselves until the month's allowance is used up; the first request that + would exceed it is left to the payer with a "Limit Reached" notification and the ordinary Payment + Request sheet. The journey uses a $5 a payment, $10 a month allowance so two $4 requests fit and + the third does not. The second request can take a few extra seconds: after a payment the payee + publishes a new private payment list, and the payer waits for it rather than asking. + + + Launch the E2E Bitkit app on both instances with no Active allowance between them and the payer's notification permission granted + On the payer instance, set an allowance for the payee as in set-and-accept.xml, but with the $5 stop (testTag "AllowancePerPaymentStop-1") and the $10 stop (testTag "AllowanceMonthlyStop-0"), and have the payee accept it + Verify the payer's row reads "Active · $5 a payment" with "$ 10.00" over "Monthly limit" + On the payee instance, send the payer a one-time Payment Request for $4 with the note "Cap1" + On the payer instance, verify it is paid without a sheet and a "Payment Executed" notification reads "Auto-pay sent $4.00 to <payee>" + On the payee instance, wait for the payment to arrive, then send a second $4 request with the note "Cap2" + On the payer instance, verify it is paid without a sheet within about fifteen seconds, and a second "Payment Executed" notification is posted + On the payee instance, send a third $4 request with the note "Cap3" + On the payer instance, verify a "Limit Reached" notification reads "A $4.00 request from <payee> is above your allowance. Review it to pay." and the Payment Request sheet opens for $4.00 + Dismiss the request (testTag "PaymentRequestDismiss-<paymentRequestId>") + Open Subscriptions, tap the Allowances tab, tap the payee's row and verify PAID AUTOMATICALLY (testTag "AllowancePaidSoFar") reads "$8.00" + + diff --git a/journeys/allowances/restart-never-pays-twice.xml b/journeys/allowances/restart-never-pays-twice.xml new file mode 100644 index 0000000000..21426038f4 --- /dev/null +++ b/journeys/allowances/restart-never-pays-twice.xml @@ -0,0 +1,21 @@ + + + Killing the payer while an automatic payment is in flight must never lead to a second payment + after relaunch. The app records the request as taken before it hands the payment to the node, so + on relaunch it either sees the node finish the first attempt or hands the request back to the + payer as an ordinary Payment Request; it never starts a new automatic attempt. The kill has to + land within about two seconds of the request arriving, which the app log marks with "Handed an + allowance payment to the node". + + + Launch the E2E Bitkit app on both instances with the $5 a payment, $50 a month allowance Active, per set-and-accept.xml + On the payer instance, start tailing the app log for "Handed an allowance payment to the node" so the kill can follow it at once + On the payee instance, send the payer a one-time Payment Request for $2 with the note "Devlog3" + As soon as the log line appears on the payer, run `adb shell am force-stop to.bitkit.dev` + Relaunch the payer with `adb shell am start -n to.bitkit.dev/to.bitkit.ui.MainActivity` and wait for the node to come back up + Verify no "Payment Executed" notification is posted for a second attempt after the relaunch + Open the drawer menu, tap Subscriptions and tap the Payments tab; verify "Devlog3" is listed exactly once + On the payee instance, verify at most one $2.00 payment arrives for "Devlog3" + If the kill landed before the node took the payment: on the payer, verify "Devlog3" comes back as an ordinary Payment Request in the bell (testTag "PaymentRequestsBell") rather than being paid automatically, and dismiss it + + diff --git a/journeys/allowances/set-and-accept.xml b/journeys/allowances/set-and-accept.xml new file mode 100644 index 0000000000..60c12949e9 --- /dev/null +++ b/journeys/allowances/set-and-accept.xml @@ -0,0 +1,26 @@ + + + The payer sets an allowance for a contact from the Allowances tab, and the payee sees the offer + open by itself and accepts it. Until the payee answers, the payer's row reads "Waiting for an + answer"; after it, both sides show the allowance as Active. The other allowance journeys start + from the Active state this one ends in. + + + Launch the E2E Bitkit app with Paykit UI enabled on both instances, each with the other saved as a linked contact, the payer holding a spendable balance above 50,000 sats with a usable Lightning channel + On the payer instance, open the drawer menu and tap Subscriptions + Tap the Allowances tab (testTag "Tab-allowances") + Verify the empty state (testTag "AllowancesEmpty") reads "Set up allowances" over the group illustration, with the footer button "Add Allowance" + Tap Add Allowance (testTag "AllowanceAdd") + Verify the Choose Contact sheet lists the payee and tap it (testTag "AllowanceContact-<displayName>") + Verify the Set Allowance sheet (testTag "SetAllowance") shows the payee's card, a PAYMENT LIMIT slider (testTag "AllowancePerPayment") and a MONTHLY ALLOWANCE slider (testTag "AllowanceMonthly") + Tap the $5 stop of the payment limit slider (testTag "AllowancePerPaymentStop-1") and the $50 stop of the monthly slider (testTag "AllowanceMonthlyStop-1") + Verify the summary (testTag "AllowanceSummary") reads "Requests up to $5 will be paid automatically, up to $50 a month, without asking you each time." + Tap Save Allowance (testTag "AllowanceSave") + Verify the sheet dismisses and the list shows one row for the payee (testTag "AllowanceRow-<allowanceId>") whose status line (testTag "AllowanceRowStatus") reads "Waiting for an answer", with "$ 50.00" over "Monthly limit" on the right + Switch to the payee instance and bring Bitkit to the foreground + Verify the Allowance review sheet (testTag "AllowanceReview") opens on its own within a few seconds, headed "<payer> offers an allowance", with the payer's contact card (testTag "AllowanceCounterparty"), PER PAYMENT "Up to $5.00" (testTag "AllowancePerPaymentValue") and EACH MONTH "Up to $50.00" (testTag "AllowanceMonthlyValue") + Tap Accept (testTag "AllowanceAccept") + Verify the sheet dismisses; open the drawer menu, tap Subscriptions, tap the Allowances tab and verify the payer's row reads "Active" followed by the per-payment limit + Switch back to the payer instance and verify its row now reads "Active · $5 a payment" + + From faca50b233ef85b1834f611392113cd3aab50f34 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 25 Sep 2026 00:25:44 +0200 Subject: [PATCH 12/51] chore: add changelog fragment --- changelog.d/next/1340.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/next/1340.added.md diff --git a/changelog.d/next/1340.added.md b/changelog.d/next/1340.added.md new file mode 100644 index 0000000000..6e89aeb684 --- /dev/null +++ b/changelog.d/next/1340.added.md @@ -0,0 +1 @@ +Allowances: set a per-payment and a monthly limit for a Paykit contact so their payment requests within the limits are paid automatically. From 4dc9055f77e111e13674dac2c7dcbedcd5ebad2e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 25 Sep 2026 06:16:58 +0200 Subject: [PATCH 13/51] fix: extend an allowance to a contact link that links after the grant --- .../repositories/PaykitAllowanceRepo.kt | 91 ++++++++++++++++--- .../repositories/PaykitAllowanceRepoTest.kt | 59 ++++++++++++ 2 files changed, 135 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt index f51e2e4e15..34e1a82982 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt @@ -103,6 +103,13 @@ class PaykitAllowanceRepo @Inject constructor( fun allowedPaymentEndpointIdentifiers(network: Network = Env.network): List = (listOf(MethodId.Bolt11) + MethodId.entries.filter { it.isOnchain }).map { it.rawValueForNetwork(network) } + /** A grant in one of these states still covers the contact, so links that appear later join it. */ + private val EXTENDABLE_STATUSES = setOf( + PaykitAllowance.Status.ACTIVE, + PaykitAllowance.Status.NOT_YET_ACTIVE, + PaykitAllowance.Status.AWAITING_ANSWER, + ) + /** The supported receiver paths among [paths], the wallet link first. */ fun orderedReceiverPaths(paths: Collection): List = PaykitReceiverPaths.ordered.filter { it in paths } @@ -190,21 +197,8 @@ class PaykitAllowanceRepo @Inject constructor( suspend fun refresh(): Result = withContext(ioDispatcher) { val identity = activeIdentity ?: return@withContext Result.success(Unit) refreshMutex.withLock { - val listing = runSuspendCatching { - paykitSdkService.listAllowances( - AllowanceFilter( - counterparty = null, - counterpartyReceiverPath = null, - localRole = null, - states = emptyList(), - ), - ) - .filter { - it.historyStatus == AllowanceHistoryStatus.CONSISTENT || - it.historyStatus == AllowanceHistoryStatus.UNRESOLVED_REFERENCES - } - .mapNotNull { PaykitAllowance.from(it) } - }.onFailure { Logger.warn("Failed to list Paykit allowances", it, context = TAG) } + var listing = listAllowances() + if (listing.getOrNull()?.let { extendToNewLinks(identity, it) } == true) listing = listAllowances() if (activeIdentity == identity) { publish(listing.getOrDefault(_allowances.value), executor.localState(identity)) } @@ -212,6 +206,73 @@ class PaykitAllowanceRepo @Inject constructor( } } + private suspend fun listAllowances(): Result> = runSuspendCatching { + paykitSdkService.listAllowances( + AllowanceFilter( + counterparty = null, + counterpartyReceiverPath = null, + localRole = null, + states = emptyList(), + ), + ) + .filter { + it.historyStatus == AllowanceHistoryStatus.CONSISTENT || + it.historyStatus == AllowanceHistoryStatus.UNRESOLVED_REFERENCES + } + .mapNotNull { PaykitAllowance.from(it) } + }.onFailure { Logger.warn("Failed to list Paykit allowances", it, context = TAG) } + + /** + * A grant covers the contact's links that were linked when it was made. When another supported link of the + * contact links later (typically their Paykit Server folder), proposes the grant's terms there and adds it to the + * grant, as a grant made now would. Returns true when any proposal was made. + */ + private suspend fun extendToNewLinks(identity: String, allowances: List): Boolean { + val now = clock.now() + val liveGroups = executor.localState(identity).groups.mapNotNull { group -> + val members = allowances.filter { it.allowanceId in group.allowanceIds } + val isLive = members.size == group.allowanceIds.size && + members.any { it.isAllower && it.status(now) in EXTENDABLE_STATUSES } + if (isLive) group to members else null + } + if (liveGroups.isEmpty()) return false + val linkedPeers = runSuspendCatching { paykitSdkService.linkedPeers() } + .onFailure { Logger.warn("Failed to list linked peers for allowances", it, context = TAG) } + .getOrNull() + ?.filter { it.state == LinkedPeerState.LINKED } + ?: return false + + var proposed = false + for ((group, members) in liveGroups) { + val coveredPaths = members.map { it.counterpartyReceiverPath }.toSet() + val linkedPaths = linkedPeers + .filter { PubkyPublicKeyFormat.matches(it.counterparty, group.counterparty) } + .map { it.counterpartyReceiverPath } + val newPaths = orderedReceiverPaths(linkedPaths).filterNot { it in coveredPaths } + if (newPaths.isEmpty()) continue + + val terms = allowanceTerms( + group.limits, + members.firstNotNullOfOrNull { it.monthlyAnchor } ?: PaykitAllowanceTime.monthStart(now), + allowedPaymentEndpointIdentifiers(), + ) + runSuspendCatching { proposeOnLinks(group.counterparty, newPaths, terms) } + .onSuccess { allowanceIds -> + executor.updateLocalState(identity) { state -> + state.copy( + groups = state.groups.map { + if (it.id == group.id) it.copy(allowanceIds = it.allowanceIds + allowanceIds) else it + }, + ) + } + Logger.info("Extended an allowance to '${allowanceIds.size}' newly linked links", context = TAG) + proposed = true + } + .onFailure { Logger.warn("Failed to extend an allowance to a newly linked link", it, context = TAG) } + } + return proposed + } + /** Proposes the same terms on every supported linked receiver path of the contact and records one entry. */ suspend fun propose(contactPublicKey: String, limits: PaykitAllowanceLimits): Result = withContext(ioDispatcher) { diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt index a50f024a97..9687c73f5b 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt @@ -294,6 +294,65 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { assertTrue(localState.groups.isEmpty()) } + @Test + fun `refresh extends a grant to a contact link that linked after it`() = test { + records = listOf(fixtures.record(allowanceId = WALLET_ALLOWANCE_ID)) + localState = PaykitAllowanceLocalState(groups = listOf(group(WALLET_ALLOWANCE_ID))) + whenever(sdk.linkedPeers()).thenReturn( + listOf( + linkedPeer(fixtures.counterpartyKey, PaykitReceiverPaths.WALLET), + linkedPeer(fixtures.counterpartyKey, PaykitReceiverPaths.SERVER), + ), + ) + whenever(sdk.proposeAllowance(any(), any(), any(), any())).doSuspendableAnswer { + fixtures.record( + allowanceId = SERVER_ALLOWANCE_ID, + receiverPath = PaykitReceiverPaths.SERVER, + state = AllowanceLifecycleState.PROPOSED, + terms = terms, + ).also { record -> records = records + record } + } + + sut.activate(identity) + sut.refresh() + + val allower = AllowanceLocalRole.ALLOWER + verify(sdk).proposeAllowance(fixtures.counterpartyKey, PaykitReceiverPaths.SERVER, allower, terms) + verify(sdk, never()).proposeAllowance(any(), eq(PaykitReceiverPaths.WALLET), any(), any()) + verify(sdk).processOutboundPrivateMessages(fixtures.counterpartyKey, PaykitReceiverPaths.SERVER) + assertEquals( + listOf(fixtures.septemberAnchor to PaykitAllowanceRepo.allowedPaymentEndpointIdentifiers()), + proposedTerms, + ) + assertEquals(listOf(WALLET_ALLOWANCE_ID, SERVER_ALLOWANCE_ID), localState.groups.single().allowanceIds) + val entry = sut.entries.value.single() + assertEquals("group-1", entry.id) + assertEquals(setOf(WALLET_ALLOWANCE_ID, SERVER_ALLOWANCE_ID), entry.allowances.map { it.allowanceId }.toSet()) + assertEquals(PaykitAllowance.Status.ACTIVE, entry.status(fixtures.now)) + } + + @Test + fun `refresh does not extend an ended grant or one that covers every linked path`() = test { + whenever(sdk.linkedPeers()).thenReturn( + listOf( + linkedPeer(fixtures.counterpartyKey, PaykitReceiverPaths.WALLET), + linkedPeer(fixtures.counterpartyKey, PaykitReceiverPaths.SERVER), + ), + ) + records = listOf(fixtures.record(allowanceId = WALLET_ALLOWANCE_ID, state = AllowanceLifecycleState.ENDED)) + localState = PaykitAllowanceLocalState(groups = listOf(group(WALLET_ALLOWANCE_ID))) + sut.activate(identity) + + records = listOf( + fixtures.record(allowanceId = WALLET_ALLOWANCE_ID), + fixtures.record(allowanceId = SERVER_ALLOWANCE_ID, receiverPath = PaykitReceiverPaths.SERVER), + ) + localState = PaykitAllowanceLocalState(groups = listOf(group(WALLET_ALLOWANCE_ID, SERVER_ALLOWANCE_ID))) + sut.refresh() + + verify(sdk, never()).proposeAllowance(any(), any(), any(), any()) + } + @Test fun `received proposal is presented once and accepting answers it`() = test { val proposalRecord = receivedProposal() From 145330cead20517571cbfce7cda7171a1454004c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 26 Sep 2026 09:36:51 +0200 Subject: [PATCH 14/51] fix: finish an allowance payment once it starts --- .../repositories/PaykitAllowanceExecutor.kt | 4 +++- .../PaykitAllowanceExecutorTest.kt | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt index 7fcc7b96b3..67cc7091f2 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt @@ -17,6 +17,7 @@ import com.synonym.paykit.PaymentOutcomeReport import com.synonym.paykit.PaymentRequestLifecycleState import com.synonym.paykit.PaymentRequestScope import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow @@ -351,7 +352,8 @@ class PaykitAllowanceExecutor @Inject constructor( try { runSuspendCatching { ensureReconciled(identity) - admitAndPay(request, allowances, identity) + // Survive a cancelled caller: stopping after the acceptance would leave the request unpaid. + withContext(NonCancellable) { admitAndPay(request, allowances, identity) } }.getOrElse { Logger.warn("Kept an incoming request on the manual flow after an allowance error", it, context = TAG) PaykitAllowanceAutoPayResult.MANUAL diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt index 1acae70fbe..b3a9bce714 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt @@ -21,6 +21,7 @@ import com.synonym.paykit.PaymentOutcome import com.synonym.paykit.PaymentOutcomeReport import com.synonym.paykit.PaymentRequestScope import com.synonym.paykit.PrivateStreamIntakeReport +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.launch import kotlinx.coroutines.test.TestScope import org.junit.Before @@ -444,6 +445,24 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { verify(payer, never()).completeOnchainPayment(any(), any(), any()) } + @Test + fun `on-chain payment completes when its caller is cancelled during the send`() = test { + whenever(payer.resolve(any(), any())).thenReturn(Result.success(onchainPayment())) + val sendResult = CompletableDeferred>() + whenever(payer.payOnchain(any(), any())).doSuspendableAnswer { sendResult.await() } + val request = fixtures.paymentRequest() + + val caller = launch { sut.autoPay(request, listOf(fixtures.allowance()), identity) } + assertEquals(Stage.SENDING, sut.localState(identity).journal.single().stage) + caller.cancel() + sendResult.complete(Result.success(TXID)) + caller.join() + + verify(payer).completeOnchainPayment(request, TXID, fixtures.onchainIdentifier) + assertEquals(listOf(PaymentOutcomeReport(AUTOMATIC_ATTEMPT_ID, PaymentOutcome.SUCCEEDED)), recordedOutcomes) + assertEquals(Stage.SUCCEEDED, sut.localState(identity).journal.single().stage) + } + // endregion // region Settlement and recovery From d701ee557505b1fd9c239a6fd0e25be62facbb97 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 30 Sep 2026 22:53:41 -0500 Subject: [PATCH 15/51] feat: share paykit state across apps --- .../CreatePaymentRequestScreenTest.kt | 2 - .../PaymentRequestsScreenTest.kt | 2 - .../CreateSubscriptionScreenTest.kt | 5 +- .../to/bitkit/data/PrivatePaykitStores.kt | 6 +- .../java/to/bitkit/data/keychain/Keychain.kt | 4 +- .../bitkit/models/PaykitPaymentStateBackup.kt | 3 + .../to/bitkit/models/PubkyAuthClaimCodec.kt | 47 + .../java/to/bitkit/models/PubkyAuthRequest.kt | 54 +- .../to/bitkit/repositories/ActivityRepo.kt | 6 + .../ContactPaymentSettingsRepo.kt | 13 +- .../repositories/PaykitPaymentProofRepo.kt | 33 +- .../repositories/PaykitPaymentRequestRepo.kt | 94 +- .../PaykitReceivedPaymentContacts.kt | 90 ++ .../bitkit/repositories/PaykitSubscription.kt | 7 +- ...PaykitSubscriptionNotificationScheduler.kt | 10 +- .../PrivatePaykitAddressReservationRepo.kt | 43 +- .../PrivatePaykitContactResolver.kt | 21 +- .../repositories/PrivatePaykitModels.kt | 26 +- .../bitkit/repositories/PrivatePaykitRepo.kt | 541 ++++------- .../java/to/bitkit/repositories/PubkyRepo.kt | 43 +- .../bitkit/repositories/PublicPaykitRepo.kt | 33 +- .../repositories/WatchOnlyAccountRepo.kt | 3 +- .../java/to/bitkit/services/CoreService.kt | 62 +- .../to/bitkit/services/PaykitSdkService.kt | 557 ++++-------- .../java/to/bitkit/services/PubkyService.kt | 19 +- app/src/main/java/to/bitkit/ui/ContentView.kt | 7 - .../main/java/to/bitkit/ui/MainActivity.kt | 2 - .../main/java/to/bitkit/ui/Notifications.kt | 1 - .../screens/contacts/AddContactViewModel.kt | 6 +- .../ui/screens/contacts/ContactsViewModel.kt | 6 +- .../CreatePaymentRequestScreen.kt | 4 +- .../paymentrequests/PaymentRequestsScreen.kt | 3 +- .../screens/profile/PubkyAuthApprovalSheet.kt | 26 +- .../profile/PubkyAuthApprovalViewModel.kt | 38 +- .../screens/wallets/receive/ReceiveSheet.kt | 6 +- ....kt => RefreshContactPaykitLinkUseCase.kt} | 9 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 100 +- .../to/bitkit/viewmodels/SettingsViewModel.kt | 14 +- app/src/main/res/values/strings.xml | 2 + .../models/PaykitPaymentStateBackupTest.kt | 2 +- .../to/bitkit/models/PubkyAuthRequestTest.kt | 150 ++- .../bitkit/repositories/ActivityRepoTest.kt | 17 + .../ContactPaymentSettingsRepoTest.kt | 14 +- .../repositories/PaykitIssuerInteropTest.kt | 6 +- .../PaykitPaymentProofRepoTest.kt | 138 +-- ...ykitPaymentRequestPresentationStoreTest.kt | 12 +- ...aykitPaymentRequestRepoSubscriptionTest.kt | 134 +-- .../PaykitPaymentRequestRepoTest.kt | 385 ++++---- .../PaykitReceivedPaymentContactsTest.kt | 224 +++++ ...itSubscriptionNotificationSchedulerTest.kt | 6 +- .../repositories/PaykitSubscriptionTest.kt | 4 +- ...PrivatePaykitAddressReservationRepoTest.kt | 17 - .../PrivatePaykitContactResolverTest.kt | 56 +- .../repositories/PrivatePaykitRepoTest.kt | 854 +++++------------- .../to/bitkit/repositories/PubkyRepoTest.kt | 115 +-- .../repositories/PublicPaykitRepoTest.kt | 73 +- .../WatchOnlyAccountClaimCodecTest.kt | 102 ++- .../repositories/WatchOnlyAccountRepoTest.kt | 41 + .../ActivityServicePaykitContactsTest.kt | 165 ++++ .../bitkit/services/PaykitSdkServiceTest.kt | 337 +++---- .../services/PaykitSdkServiceWipeTest.kt | 128 +-- .../contacts/AddContactViewModelTest.kt | 10 +- .../contacts/ContactDetailViewModelTest.kt | 2 +- .../PaymentRequestPresentationTest.kt | 1 - .../profile/PubkyAuthApprovalViewModelTest.kt | 198 +++- .../subscriptions/SubscriptionsScreenTest.kt | 1 - ...=> RefreshContactPaykitLinkUseCaseTest.kt} | 23 +- .../viewmodels/AppViewModelSendFlowTest.kt | 322 ++++--- .../viewmodels/SettingsViewModelTest.kt | 9 +- .../resources/bitkit-combined-claim-v1.json | 10 + .../next/paykit-shared-runtime.changed.md | 1 + docs/paykit-issuer-interoperability.md | 2 +- docs/pubky-auth-companion-claims.md | 60 ++ docs/pubky.md | 15 +- docs/watch-only-account-claim-v1.md | 52 -- gradle/libs.versions.toml | 2 +- journeys/README.md | 2 +- journeys/payment-requests/README.md | 8 +- .../contact-request-or-pay.xml | 2 +- .../delete-and-readd-contact.xml | 2 +- .../issuer-interoperability.xml | 4 +- .../payment-deadline-history.xml | 2 +- journeys/payment-requests/request-summary.xml | 2 +- journeys/pubky-marketplace/README.md | 105 +-- .../paykit-only-approval.xml | 19 + .../pubky-marketplace/paykit-reconnect.xml | 18 + journeys/pubky-marketplace/wallet-leg.xml | 10 +- journeys/subscriptions/README.md | 2 +- journeys/subscriptions/create-and-propose.xml | 2 +- settings.gradle.kts | 4 +- 90 files changed, 3076 insertions(+), 2742 deletions(-) create mode 100644 app/src/main/java/to/bitkit/models/PubkyAuthClaimCodec.kt create mode 100644 app/src/main/java/to/bitkit/repositories/PaykitReceivedPaymentContacts.kt rename app/src/main/java/to/bitkit/usecases/{RefreshContactPaykitReceiversUseCase.kt => RefreshContactPaykitLinkUseCase.kt} (75%) create mode 100644 app/src/test/java/to/bitkit/repositories/PaykitReceivedPaymentContactsTest.kt create mode 100644 app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt rename app/src/test/java/to/bitkit/usecases/{RefreshContactPaykitReceiversUseCaseTest.kt => RefreshContactPaykitLinkUseCaseTest.kt} (68%) create mode 100644 app/src/test/resources/bitkit-combined-claim-v1.json create mode 100644 changelog.d/next/paykit-shared-runtime.changed.md create mode 100644 docs/pubky-auth-companion-claims.md delete mode 100644 docs/watch-only-account-claim-v1.md create mode 100644 journeys/pubky-marketplace/paykit-only-approval.xml create mode 100644 journeys/pubky-marketplace/paykit-reconnect.xml diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreenTest.kt index 69456ee0c2..ef1fca7fc3 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreenTest.kt @@ -163,13 +163,11 @@ class CreatePaymentRequestScreenTest { private val target = PaykitPaymentRequestTarget( publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", - receiverPath = "bitkit/wallet", ) private val request = PaykitPaymentRequest( paymentRequestId = "payment-request", counterparty = target.publicKey, - counterpartyReceiverPath = target.receiverPath, amountValue = "0.00025", amountSats = draft.amountSats, note = draft.note, diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt index de6f261ce4..c8ede596a7 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt @@ -289,7 +289,6 @@ class PaymentRequestsScreenTest { private fun request(id: String = "request") = PaykitPaymentRequest( paymentRequestId = id, counterparty = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", - counterpartyReceiverPath = "bitkit/wallet", amountValue = "0.00025", amountSats = 25_000uL, note = "Dinner", @@ -301,7 +300,6 @@ class PaymentRequestsScreenTest { private fun subscription(note: String) = PaykitSubscription( paymentRequestId = "subscription", counterparty = request().counterparty, - counterpartyReceiverPath = "bitkit/wallet", amountValue = "0.00025", amountSats = 25_000uL, note = note, diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreenTest.kt index 1cd6a6efaf..cf2aa3c9d5 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreenTest.kt @@ -93,7 +93,7 @@ class CreateSubscriptionScreenTest { @Test fun recipientAllowsExactlyOneSelectionAndChangesExpiry() { - val second = PaykitPaymentRequestTarget("pubky" + "z".repeat(52), "bitkit/wallet") + val second = PaykitPaymentRequestTarget("pubky" + "z".repeat(52)) var selected by mutableStateOf(null) var expiration by mutableStateOf(PaymentRequestExpiration.Week) var proposedTo: PaykitPaymentRequestTarget? = null @@ -158,13 +158,12 @@ class CreateSubscriptionScreenTest { composeTestRule.onNodeWithText("OK").assertIsDisplayed() } - private val target = PaykitPaymentRequestTarget("pubky" + "y".repeat(52), "bitkit/wallet") + private val target = PaykitPaymentRequestTarget("pubky" + "y".repeat(52)) private val contact = PubkyProfile.forDisplay(target.publicKey, "Anna", null) private val startsAt = Instant.parse("2027-01-15T08:00:00Z") private val subscription = PaykitSubscription( paymentRequestId = "creator-proposal", counterparty = target.publicKey, - counterpartyReceiverPath = target.receiverPath, amountValue = "0.00001", amountSats = 1000uL, note = "Support", diff --git a/app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt b/app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt index 3519b2186d..8aabaa02bc 100644 --- a/app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt +++ b/app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt @@ -68,10 +68,10 @@ data class PrivatePaykitCacheData( @Serializable data class PrivatePaykitContactCacheData( val remoteEndpoints: List = emptyList(), - val consumedPrivatePaymentListVersionsByReceiverPath: Map = emptyMap(), - val localInvoicesByReceiverPath: Map = emptyMap(), + val consumedPrivatePaymentListVersion: ULong? = null, + val localInvoice: PrivatePaykitStoredInvoiceData? = null, val receivedInvoicePaymentHashes: List = emptyList(), - val publishedPrivatePaymentReceiverPaths: Set = emptySet(), + val hasPublishedPrivatePaymentList: Boolean = false, ) @Serializable diff --git a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt index 2a41579d54..481fa0af58 100644 --- a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt +++ b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt @@ -232,8 +232,8 @@ class Keychain @Inject constructor( PIN, PIN_ATTEMPTS_REMAINING, PAYKIT_SESSION, - PAYKIT_RECEIVER_NOISE_SECRET_KEY, - PAYKIT_SDK_STATE, + PAYKIT_KEY_GENERATION, + PAYKIT_RECOVERY_BACKUP, PAYKIT_PENDING_BACKUP_RESTORE, PAYKIT_PENDING_PAYMENT_PROOFS, PAYKIT_PRESENTED_PAYMENT_REQUESTS, diff --git a/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt b/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt index f5301f8419..b9429d238a 100644 --- a/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt +++ b/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt @@ -41,6 +41,7 @@ data class PaykitPaymentStateBackup( val identity: String, val requestId: PaykitPaymentRequestId, val paymentEndpointIdentifier: String, + val paymentAppId: String, val kind: String, val paymentStarted: Boolean, val paymentIdentifier: String? = null, @@ -55,6 +56,7 @@ data class PaykitPaymentStateBackup( identity = proof.identity, requestId = proof.requestId, paymentEndpointIdentifier = proof.paymentEndpointIdentifier, + paymentAppId = proof.paymentAppId, kind = proof.kind.type, paymentStarted = proof.paymentStarted, paymentIdentifier = proof.paymentIdentifier, @@ -70,6 +72,7 @@ data class PaykitPaymentStateBackup( identity = identity, requestId = requestId.copy(billingPeriodStartsAt = billingPeriod?.startsAt?.toString()), paymentEndpointIdentifier = paymentEndpointIdentifier, + paymentAppId = paymentAppId, kind = requireNotNull(PaykitPaymentProofKind.entries.find { it.type == kind }), paymentStarted = paymentStarted, paymentIdentifier = paymentIdentifier, diff --git a/app/src/main/java/to/bitkit/models/PubkyAuthClaimCodec.kt b/app/src/main/java/to/bitkit/models/PubkyAuthClaimCodec.kt new file mode 100644 index 0000000000..7fe7b28ab3 --- /dev/null +++ b/app/src/main/java/to/bitkit/models/PubkyAuthClaimCodec.kt @@ -0,0 +1,47 @@ +package to.bitkit.models + +import java.nio.ByteBuffer + +object PubkyAuthClaimCodec { + /** Size of the versioned account metadata and serialized extended public key. */ + const val WATCH_ONLY_PAYLOAD_LENGTH = 84 + + /** Size of the version, generation, and Paykit identity secret. */ + const val PAYKIT_PAYLOAD_LENGTH = 41 + + /** Size of account metadata followed by the generation and Paykit identity secret. */ + const val COMBINED_PAYLOAD_LENGTH = 124 + + fun validateAccountPayload(claim: PubkyAuthClaim, accountPayload: ByteArray) { + if (!claim.includesWatchOnlyAccount) { + require(accountPayload.isEmpty()) { "Paykit-only approval cannot include an account" } + return + } + require(accountPayload.size == WATCH_ONLY_PAYLOAD_LENGTH) { "Invalid watch-only payload length" } + require(accountPayload[0] == 1.toByte() && accountPayload[5] == 0.toByte()) { + "Unsupported watch-only payload version or address type" + } + } + + fun encode( + claim: PubkyAuthClaim, + accountPayload: ByteArray, + generation: ULong? = null, + secret: ByteArray? = null, + ): ByteArray { + validateAccountPayload(claim, accountPayload) + if (!claim.includesPaykitAccess) { + require(generation == null && secret == null) { "Watch-only approval cannot include a Paykit key" } + return accountPayload.copyOf() + } + require(generation != null && generation > 0uL) { "Invalid Paykit key generation" } + require(secret?.size == 32) { "Invalid Paykit identity secret length" } + val prefix = if (claim.includesWatchOnlyAccount) accountPayload else byteArrayOf(1) + val length = if (claim.includesWatchOnlyAccount) COMBINED_PAYLOAD_LENGTH else PAYKIT_PAYLOAD_LENGTH + return ByteBuffer.allocate(length) + .put(prefix) + .putLong(generation.toLong()) + .put(secret) + .array() + } +} diff --git a/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt b/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt index 14e1f70d2c..05a6673a2d 100644 --- a/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt +++ b/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt @@ -1,39 +1,51 @@ package to.bitkit.models import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList import to.bitkit.utils.AppError import java.net.URI import java.net.URLDecoder import java.net.URLEncoder import java.nio.charset.StandardCharsets -enum class PubkyAuthClaim(val wireValue: String) { - WATCH_ONLY_ACCOUNT_V1("watch-only-account-v1"), - ; +@Immutable +@JvmInline +value class PubkyAuthClaim private constructor(val items: ImmutableList) { + constructor(vararg items: Item) : this(items.sortedBy { it.ordinal }.toImmutableList()) + + init { + require(items.isNotEmpty() && items.distinct().size == items.size) + } + + enum class Item(val wireValue: String) { + PAYKIT_ACCESS_V1("paykit-access-v1"), + WATCH_ONLY_ACCOUNT_V1("watch-only-account-v1"), + } + + val wireValue: String get() = items.joinToString(".") { it.wireValue } + val includesWatchOnlyAccount: Boolean get() = Item.WATCH_ONLY_ACCOUNT_V1 in items + val includesPaykitAccess: Boolean get() = Item.PAYKIT_ACCESS_V1 in items companion object { /** Query parameter used for Bitkit-specific Pubky auth claims. */ const val QUERY_PARAMETER = "x-bitkit-claim" - /** Both public and private Paykit Server capabilities required by the watch-only setup flow. */ - const val WATCH_ONLY_ACCOUNT_CAPABILITIES = - "/pub/paykit/v0/bitkit/server/:rw,/pub/paykit/v0/private/bitkit/server/:rw" - - private val watchOnlyAccountCapabilitySet = WATCH_ONLY_ACCOUNT_CAPABILITIES.split(",").toSet() + /** Exact Pubky storage scope required by Bitkit companion claims. */ + const val REQUIRED_CAPABILITIES = "/pub/paykit/:rw" - /** - * Matches exactly the required public and private capabilities regardless of ordering or surrounding spaces. - */ - fun matchesWatchOnlyAccountCapabilities(capabilities: String) = - capabilitySet(capabilities) == watchOnlyAccountCapabilitySet + /** Matches the required storage scope, allowing surrounding whitespace but no duplicate capabilities. */ + fun matchesRequiredCapabilities(capabilities: String) = + capabilities.trim() == REQUIRED_CAPABILITIES - private fun capabilitySet(capabilities: String): Set? { - val entries = capabilities.split(",").map { it.trim() } - if (entries.any { it.isEmpty() }) return null - return entries.toSet() + /** Preserves the received item order because the SDK signs and routes using this exact string. */ + fun fromWireValue(value: String): PubkyAuthClaim? { + val items = value.split(".").map { token -> + Item.entries.firstOrNull { it.wireValue == token } ?: return null + } + if (items.distinct().size != items.size) return null + return PubkyAuthClaim(items.toImmutableList()) } - - fun fromWireValue(value: String) = entries.firstOrNull { it.wireValue == value } } } @@ -189,8 +201,6 @@ data class PubkyAuthRequest( capabilities: String, ): Result = when { claimValues.size > 1 -> Result.failure(PubkyAuthRequestError.DuplicateBitkitClaim) - claimValues.isEmpty() && PubkyAuthClaim.matchesWatchOnlyAccountCapabilities(capabilities) -> - Result.failure(PubkyAuthRequestError.MissingBitkitClaim) claimValues.isEmpty() -> Result.success(null) else -> validateBitkitClaimValue(claimValues.first(), capabilities) } @@ -202,7 +212,7 @@ data class PubkyAuthRequest( val claim = PubkyAuthClaim.fromWireValue(claimValue) ?: return Result.failure(PubkyAuthRequestError.UnsupportedBitkitClaim(claimValue)) - return if (PubkyAuthClaim.matchesWatchOnlyAccountCapabilities(capabilities)) { + return if (PubkyAuthClaim.matchesRequiredCapabilities(capabilities)) { Result.success(claim) } else { Result.failure(PubkyAuthRequestError.InvalidBitkitClaimCapabilities) diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 4119de267f..20ee50dbff 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -105,6 +105,12 @@ class ActivityRepo @Inject constructor( Logger.debug("Activity state reset", context = TAG) } + suspend fun backfillPaykitContacts(): Result = withContext(bgDispatcher) { + runSuspendCatching { + if (coreService.activity.backfillPaykitContacts()) notifyActivitiesChanged() + }.onFailure { Logger.warn("Failed to backfill Paykit activity contacts", it, context = TAG) } + } + suspend fun syncActivities(): Result = withContext(bgDispatcher) { Logger.debug("syncActivities called", context = TAG) diff --git a/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt b/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt index ec02d0eded..e21e6faa0d 100644 --- a/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt @@ -67,6 +67,10 @@ class ContactPaymentSettingsRepo @Inject constructor( ) } }.onFailure(error::addSuppressed) + if (!previous.sharesPrivatePaykitEndpoints) { + privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contacts) + .onFailure(error::addSuppressed) + } publicPaykitRepo.syncPublishedEndpoints(publish = previous.sharesPublicPaykitEndpoints) .onFailure { error.addSuppressed(it) @@ -76,9 +80,6 @@ class ContactPaymentSettingsRepo @Inject constructor( privatePaykitRepo.enableSharingAndPrepareSavedContacts( publicKeys = contacts, ).onFailure(error::addSuppressed) - } else { - privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contacts) - .onFailure(error::addSuppressed) } } @@ -100,12 +101,12 @@ class ContactPaymentSettingsRepo @Inject constructor( var publicCleanupError: Throwable? = null var privateCleanupError: Throwable? = null - publicPaykitRepo.syncPublishedEndpoints(publish = false) - .onFailure { publicCleanupError = it } - privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contacts) .onFailure { privateCleanupError = it } + publicPaykitRepo.syncPublishedEndpoints(publish = false) + .onFailure { publicCleanupError = it } + publicCleanupError?.let { error -> runSuspendCatching { settingsStore.update { settings -> diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index 7520856c17..a46552f1e4 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -4,6 +4,7 @@ import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.ActivityFilter import com.synonym.bitkitcore.PaymentType import com.synonym.paykit.BillingPeriod +import com.synonym.paykit.PubkyIdentityCapability import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -59,6 +60,7 @@ data class PendingPaykitPaymentProof( val identity: String, val requestId: PaykitPaymentRequestId, val paymentEndpointIdentifier: String, + val paymentAppId: String, val kind: PaykitPaymentProofKind, val paymentStarted: Boolean = false, val paymentIdentifier: String? = null, @@ -136,11 +138,12 @@ class PaykitPaymentProofRepo @Inject constructor( suspend fun prepare( request: PaykitPaymentRequest, paymentEndpointIdentifier: String, + paymentAppId: String, kind: PaykitPaymentProofKind, ): Result = withContext(ioDispatcher) { runSuspendCatching { operationMutex.withLock { - val proof = pendingProof(request, paymentEndpointIdentifier, kind) + val proof = pendingProof(request, paymentEndpointIdentifier, paymentAppId, kind) val currentProofs = loadProofs() if (currentProofs.any { it.isStartedFor(proof.identity, request.id) }) { throw PaykitPaymentRequestError.OperationInProgress @@ -157,6 +160,7 @@ class PaykitPaymentProofRepo @Inject constructor( request: PaykitPaymentRequest, paymentHash: String, paymentEndpointIdentifier: String, + paymentAppId: String, ): Result = withContext(ioDispatcher) { runSuspendCatching { if (!paymentHash.isHex(HASH_BYTE_COUNT)) throw PaykitPaymentRequestError.RequestUnavailable @@ -177,7 +181,7 @@ class PaykitPaymentProofRepo @Inject constructor( paymentIdentifier = paymentHash.lowercase(), ) } else { - pendingProof(request, paymentEndpointIdentifier, PaykitPaymentProofKind.Lightning) + pendingProof(request, paymentEndpointIdentifier, paymentAppId, PaykitPaymentProofKind.Lightning) .copy( paymentStarted = true, paymentIdentifier = paymentHash.lowercase(), @@ -262,6 +266,7 @@ class PaykitPaymentProofRepo @Inject constructor( request: PaykitPaymentRequest, txid: String, paymentEndpointIdentifier: String, + paymentAppId: String, ) = withContext(ioDispatcher) { if (!txid.isHex(HASH_BYTE_COUNT)) { Logger.warn("Ignored a Paykit on-chain proof with an invalid transaction id", context = TAG) @@ -270,7 +275,7 @@ class PaykitPaymentProofRepo @Inject constructor( val identity = currentIdentity() ?: return@withContext val fallbackProof = runSuspendCatching { - pendingProof(request, paymentEndpointIdentifier, PaykitPaymentProofKind.Onchain) + pendingProof(request, paymentEndpointIdentifier, paymentAppId, PaykitPaymentProofKind.Onchain) }.getOrNull() operationMutex.withLock { val completion = runSuspendCatching { @@ -289,7 +294,7 @@ class PaykitPaymentProofRepo @Inject constructor( proofData = txid.lowercase(), ) } else { - pendingProof(request, paymentEndpointIdentifier, PaykitPaymentProofKind.Onchain).copy( + pendingProof(request, paymentEndpointIdentifier, paymentAppId, PaykitPaymentProofKind.Onchain).copy( paymentStarted = true, paymentIdentifier = txid.lowercase(), proofData = txid.lowercase(), @@ -367,8 +372,7 @@ class PaykitPaymentProofRepo @Inject constructor( PubkyPublicKeyFormat.matches(it.identity, identity) && it.requestId.billingPeriodStartsAt != null && it.requestId.paymentRequestId == subscriptionId.paymentRequestId && - it.requestId.counterparty == subscriptionId.counterparty && - it.requestId.counterpartyReceiverPath == subscriptionId.counterpartyReceiverPath + it.requestId.counterparty == subscriptionId.counterparty } val protectedRequestIds = proofs.filter(belongsToSubscription) .filter { it.paymentStarted || it.paymentIdentifier != null || it.proofData != null } @@ -396,7 +400,9 @@ class PaykitPaymentProofRepo @Inject constructor( return@runSuspendCatching } val identityStatus = paykitSdkService.identityStatus() - if (identityStatus?.liveSessionAvailable != true) return@runSuspendCatching + if (identityStatus?.capability != PubkyIdentityCapability.PRIVATE_LINK_CAPABLE) { + return@runSuspendCatching + } val publicKey = identityStatus.publicKey ?: return@runSuspendCatching val identity = PubkyPublicKeyFormat.normalized(publicKey) ?: return@runSuspendCatching val proofs = storedProofs.filter { PubkyPublicKeyFormat.matches(it.identity, identity) } @@ -508,7 +514,7 @@ class PaykitPaymentProofRepo @Inject constructor( val proofData = proof.proofData ?: return false val identityStatus = paykitSdkService.identityStatus() if ( - identityStatus?.liveSessionAvailable != true || + identityStatus?.capability != PubkyIdentityCapability.PRIVATE_LINK_CAPABLE || !PubkyPublicKeyFormat.matches(identityStatus.publicKey, proof.identity) ) { return false @@ -516,21 +522,21 @@ class PaykitPaymentProofRepo @Inject constructor( val record = paykitSdkService.paymentRequests().firstOrNull { it.paymentRequestId == proof.requestId.paymentRequestId && - PubkyPublicKeyFormat.matches(it.counterparty, proof.requestId.counterparty) && - it.counterpartyReceiverPath == proof.requestId.counterpartyReceiverPath + PubkyPublicKeyFormat.matches(it.counterparty, proof.requestId.counterparty) } ?: return false val proofJson = proofJson(proof.kind, proofData) val alreadyQueued = record.paymentProofs.any { it.billingPeriod.matches(proof.billingPeriod) && it.paymentEndpointIdentifier == proof.paymentEndpointIdentifier && + it.paymentAppId == proof.paymentAppId && it.proof.exportText().proofValues() == proofJson.proofValues() } if (!alreadyQueued) { paykitSdkService.submitPaymentProof( counterparty = proof.requestId.counterparty, - counterpartyReceiverPath = proof.requestId.counterpartyReceiverPath, paymentRequestId = proof.requestId.paymentRequestId, paymentEndpointIdentifier = proof.paymentEndpointIdentifier, + paymentAppId = proof.paymentAppId, proofJson = proofJson, billingPeriod = proof.billingPeriod, ) @@ -622,9 +628,11 @@ class PaykitPaymentProofRepo @Inject constructor( private suspend fun pendingProof( request: PaykitPaymentRequest, paymentEndpointIdentifier: String, + paymentAppId: String, kind: PaykitPaymentProofKind, ): PendingPaykitPaymentProof { if ( + paymentAppId.isBlank() || paymentEndpointIdentifier !in request.acceptedPaymentEndpointIdentifiers || !endpointSupports(paymentEndpointIdentifier, kind) ) { @@ -632,13 +640,14 @@ class PaykitPaymentProofRepo @Inject constructor( } val identityStatus = paykitSdkService.identityStatus() val identity = identityStatus?.publicKey?.let { PubkyPublicKeyFormat.normalized(it) } - if (identityStatus?.liveSessionAvailable != true || identity == null) { + if (identityStatus?.capability != PubkyIdentityCapability.PRIVATE_LINK_CAPABLE || identity == null) { throw PaykitPaymentRequestError.RequestUnavailable } return PendingPaykitPaymentProof( identity = identity, requestId = request.id, paymentEndpointIdentifier = paymentEndpointIdentifier, + paymentAppId = paymentAppId, kind = kind, billingPeriod = request.billingPeriod, ) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 8c7f905305..eb81148f86 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -11,6 +11,7 @@ import com.synonym.paykit.PaymentRequestRecord import com.synonym.paykit.PaymentRequestTerms import com.synonym.paykit.PrivateJsonObject import com.synonym.paykit.PrivateStreamCounterpartyIntakeReport +import com.synonym.paykit.PubkyIdentityCapability import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -45,8 +46,8 @@ import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.satsToMsat import to.bitkit.services.PaykitPaymentRequestProposalTerms import to.bitkit.services.PaykitPaymentRequestRecurrenceTerms -import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PaykitSdkService +import to.bitkit.services.isBitkitPaymentRequest import to.bitkit.utils.AppError import to.bitkit.utils.Logger import to.bitkit.utils.SubscriptionIcon @@ -65,14 +66,12 @@ import kotlin.time.Instant data class PaykitPaymentRequestId( val paymentRequestId: String, val counterparty: String, - val counterpartyReceiverPath: String, val billingPeriodStartsAt: String? = null, ) data class PaykitPaymentRequest( val paymentRequestId: String, val counterparty: String, - val counterpartyReceiverPath: String, val amountValue: String, val amountSats: ULong, val note: String? = null, @@ -109,7 +108,6 @@ data class PaykitPaymentRequest( get() = PaykitPaymentRequestId( paymentRequestId, counterparty, - counterpartyReceiverPath, billingPeriod?.startsAt?.toString(), ) @@ -130,8 +128,7 @@ data class PaykitPaymentRequest( fun belongsTo(subscription: PaykitSubscription): Boolean = billingPeriod != null && paymentRequestId == subscription.paymentRequestId && - counterparty == subscription.counterparty && - counterpartyReceiverPath == subscription.counterpartyReceiverPath + counterparty == subscription.counterparty } internal sealed interface PaykitPaymentRequestParseResult { @@ -197,7 +194,6 @@ enum class PaykitPaymentRequestDirection { Incoming, Outgoing } data class PaykitPaymentRequestTarget( val publicKey: String, - val receiverPath: String, ) data class PaykitPaymentRequestDraft( @@ -229,7 +225,7 @@ data class PaykitSubscriptionCreation( private data class PaykitPaymentRequestTargetContext( val savedPublicKeys: List, - val linkedReceiverPaths: Map>, + val linkedPublicKeys: Set, ) private data class PaykitPaymentRequestTargetDiscovery( @@ -293,6 +289,19 @@ class PaykitPaymentRequestRepo @Inject constructor( @Volatile private var activeIdentity: String? = null + @Volatile + private var receivedContacts: ReceivedContactsSnapshot? = null + + internal val receivedPaymentContacts: PaykitReceivedPaymentContacts + get() = receivedContacts?.takeIf { isCurrentState(it.generation, it.identity) }?.contacts + ?: PaykitReceivedPaymentContacts.Empty + + private data class ReceivedContactsSnapshot( + val generation: Long, + val identity: String, + val contacts: PaykitReceivedPaymentContacts, + ) + @Volatile private var presentedRequestIds = emptySet() @@ -561,7 +570,6 @@ class PaykitPaymentRequestRepo @Inject constructor( ) val record = paykitSdkService.proposePaymentRequest( counterparty = target.publicKey, - counterpartyReceiverPath = target.receiverPath, proposal = proposal, expectedIdentity = expectedIdentity, ) @@ -635,7 +643,6 @@ class PaykitPaymentRequestRepo @Inject constructor( PaykitSubscriptionProposal.validate(proposal) val record = paykitSdkService.proposePaymentRequest( counterparty = target.publicKey, - counterpartyReceiverPath = target.receiverPath, proposal = proposal, expectedIdentity = expectedIdentity, ) @@ -730,7 +737,6 @@ class PaykitPaymentRequestRepo @Inject constructor( val messageId = record.proposalOutboundMessageId ?: return false return reports.any { PubkyPublicKeyFormat.matches(it.counterparty, record.counterparty) && - it.counterpartyReceiverPath == record.counterpartyReceiverPath && messageId in it.report?.sent.orEmpty() } } @@ -770,8 +776,7 @@ class PaykitPaymentRequestRepo @Inject constructor( if ( paykitSdkService.linkedPeers().any { it.state == LinkedPeerState.BLOCKED && - PubkyPublicKeyFormat.matches(it.counterparty, request.counterparty) && - it.counterpartyReceiverPath == request.counterpartyReceiverPath + PubkyPublicKeyFormat.matches(it.counterparty, request.counterparty) } ) { throw PaykitPaymentRequestError.RequestUnavailable @@ -779,6 +784,13 @@ class PaykitPaymentRequestRepo @Inject constructor( } } + suspend fun claimForPayment(request: PaykitPaymentRequest): Result = withContext(ioDispatcher) { + runSuspendCatching { + paykitSdkService.claimPaymentRequestForExecution(request.counterparty, request.paymentRequestId) + Unit + } + } + suspend fun accept(request: PaykitPaymentRequest): Result = withContext(ioDispatcher) { runSuspendCatching { if (!request.requiresAcceptance) { @@ -793,7 +805,6 @@ class PaykitPaymentRequestRepo @Inject constructor( ensurePaymentAllowed(it).getOrThrow() paykitSdkService.acceptPaymentRequest( counterparty = it.counterparty, - counterpartyReceiverPath = it.counterpartyReceiverPath, paymentRequestId = it.paymentRequestId, ) }.getOrThrow() @@ -809,7 +820,6 @@ class PaykitPaymentRequestRepo @Inject constructor( ) { paykitSdkService.rejectPaymentRequest( counterparty = it.counterparty, - counterpartyReceiverPath = it.counterpartyReceiverPath, paymentRequestId = it.paymentRequestId, ) }.onFailure { @@ -829,7 +839,6 @@ class PaykitPaymentRequestRepo @Inject constructor( return updateRequest(request, PaymentRequestLifecycleState.CANCELED) { paykitSdkService.cancelPaymentRequest( counterparty = it.counterparty, - counterpartyReceiverPath = it.counterpartyReceiverPath, paymentRequestId = it.paymentRequestId, ) } @@ -847,7 +856,6 @@ class PaykitPaymentRequestRepo @Inject constructor( ?: throw PaykitPaymentRequestError.RequestUnavailable val record = paykitSdkService.acceptPaymentRequest( current.counterparty, - current.counterpartyReceiverPath, current.paymentRequestId, ) processPendingMessages() @@ -875,7 +883,7 @@ class PaykitPaymentRequestRepo @Inject constructor( throw PaykitPaymentRequestError.OperationInProgress } } - paykitSdkService.cancelPaymentRequest(it.counterparty, it.counterpartyReceiverPath, it.paymentRequestId) + paykitSdkService.cancelPaymentRequest(it.counterparty, it.paymentRequestId) } fun isPending(request: PaykitPaymentRequest): Boolean = @@ -910,12 +918,14 @@ class PaykitPaymentRequestRepo @Inject constructor( processPendingMessages() paykitSdkService.receivePrivateMessagesFromLinkedPeers().also(::logIntakeFailures) val now = clock.now() - val records = paykitSdkService.paymentRequests() + receivedContacts = null + val allRecords = paykitSdkService.allPaymentRequests(expectedIdentity) + val contacts = PaykitReceivedPaymentContacts.from(allRecords, Env.network) + val records = allRecords.filter(::isBitkitPaymentRequest) val blockedPeers = paykitSdkService.linkedPeers().filter { it.state == LinkedPeerState.BLOCKED } val availableRecords = records.filterNot { record -> blockedPeers.any { - PubkyPublicKeyFormat.matches(it.counterparty, record.counterparty) && - it.counterpartyReceiverPath == record.counterpartyReceiverPath + PubkyPublicKeyFormat.matches(it.counterparty, record.counterparty) } } val locallyCompletedProofKinds = expectedIdentity @@ -930,8 +940,7 @@ class PaykitPaymentRequestRepo @Inject constructor( val blockedSubscriptionIds = allSubscriptions.mapNotNull { subscription -> subscription.id.takeIf { blockedPeers.any { - PubkyPublicKeyFormat.matches(it.counterparty, subscription.counterparty) && - it.counterpartyReceiverPath == subscription.counterpartyReceiverPath + PubkyPublicKeyFormat.matches(it.counterparty, subscription.counterparty) } } }.toSet() @@ -1014,6 +1023,7 @@ class PaykitPaymentRequestRepo @Inject constructor( val history = (recurringHistory + oneTimeHistory) .sortedByDescending { it.createdAt } if (!isCurrentState(generation, expectedIdentity) || expectedIdentity == null) return + receivedContacts = ReceivedContactsSnapshot(generation, expectedIdentity, contacts) val subscriptionStateChanged = subscriptionAcceptedAt != updatedSubscriptionAcceptedAt || dismissedSubscriptionPaymentIds != updatedDismissedPaymentIds @@ -1057,24 +1067,19 @@ class PaykitPaymentRequestRepo @Inject constructor( val identityStatus = paykitSdkService.identityStatus() if ( savedKeys.isEmpty() || - identityStatus?.liveSessionAvailable != true || + identityStatus?.capability != PubkyIdentityCapability.PRIVATE_LINK_CAPABLE || !PubkyPublicKeyFormat.matches(identityStatus.publicKey, expectedIdentity) ) { return null } - val linkedPaths = mutableMapOf>() - paykitSdkService.linkedPeers() + val linkedPublicKeys = paykitSdkService.linkedPeers() .filter { it.state == LinkedPeerState.LINKED } - .forEach { peer -> - val publicKey = PubkyPublicKeyFormat.normalized(peer.counterparty) ?: return@forEach - if (peer.counterpartyReceiverPath in PaykitReceiverPaths.supported) { - linkedPaths.getOrPut(publicKey, ::mutableSetOf) += peer.counterpartyReceiverPath - } - } + .mapNotNull { PubkyPublicKeyFormat.normalized(it.counterparty) } + .toSet() return PaykitPaymentRequestTargetContext( savedPublicKeys = savedKeys, - linkedReceiverPaths = linkedPaths.mapValues { it.value.toSet() }, + linkedPublicKeys = linkedPublicKeys, ) } @@ -1085,9 +1090,9 @@ class PaykitPaymentRequestRepo @Inject constructor( var isComplete = true val failedPublicKeys = mutableSetOf() val targets = context.savedPublicKeys.mapNotNull { publicKey -> - val linked = context.linkedReceiverPaths[publicKey] ?: return@mapNotNull null + if (publicKey !in context.linkedPublicKeys) return@mapNotNull null val lookup = withTimeoutOrNull(TARGET_DISCOVERY_TIMEOUT) { - runSuspendCatching { paykitSdkService.paymentRequestReceiverPaths(publicKey) } + runSuspendCatching { paykitSdkService.canReceivePaymentRequests(publicKey) } } if (lookup == null) { isComplete = false @@ -1096,7 +1101,7 @@ class PaykitPaymentRequestRepo @Inject constructor( "Timed out inspecting payment request support for '${PubkyPublicKeyFormat.redacted(publicKey)}'", context = TAG, ) - return@mapNotNull previousTargets[publicKey]?.takeIf { it.receiverPath in linked } + return@mapNotNull previousTargets[publicKey] } val capable = lookup .onFailure { @@ -1109,14 +1114,13 @@ class PaykitPaymentRequestRepo @Inject constructor( ) } .getOrElse { - return@mapNotNull previousTargets[publicKey]?.takeIf { it.receiverPath in linked } + return@mapNotNull previousTargets[publicKey] } - val receiverPath = PaykitReceiverPaths.ordered.firstOrNull { it in linked && it in capable } - ?: run { - isComplete = false - return@mapNotNull null - } - PaykitPaymentRequestTarget(publicKey, receiverPath) + if (!capable) { + isComplete = false + return@mapNotNull null + } + PaykitPaymentRequestTarget(publicKey) } return PaykitPaymentRequestTargetDiscovery( targets = targets, @@ -1326,6 +1330,7 @@ class PaykitPaymentRequestRepo @Inject constructor( } private fun clearStateLocked() { + receivedContacts = null expirationJob?.cancel() expirationJob = null _pendingRequests.update { emptyList() } @@ -1472,7 +1477,6 @@ private fun PaymentRequestRecord.toPaykitPaymentRequest( ) = PaykitPaymentRequest( paymentRequestId = paymentRequestId, counterparty = counterparty, - counterpartyReceiverPath = counterpartyReceiverPath, amountValue = parsedTerms.terms.amount.value, amountSats = parsedTerms.amountSats, note = parsedTerms.terms.metadata.note(), @@ -1524,14 +1528,12 @@ private fun PaymentRequestRecord.toCreatedPaykitPaymentRequest( val wasSent = proposalOutboundMessageId?.let { messageId -> reports.any { report -> PubkyPublicKeyFormat.matches(report.counterparty, counterparty) && - report.counterpartyReceiverPath == counterpartyReceiverPath && messageId in report.report?.sent.orEmpty() } } == true return PaykitPaymentRequest( paymentRequestId = paymentRequestId, counterparty = target.publicKey, - counterpartyReceiverPath = target.receiverPath, amountValue = draft.amountSats.toBitcoinAmount(), amountSats = draft.amountSats, note = draft.note.takeIf(String::isNotBlank), diff --git a/app/src/main/java/to/bitkit/repositories/PaykitReceivedPaymentContacts.kt b/app/src/main/java/to/bitkit/repositories/PaykitReceivedPaymentContacts.kt new file mode 100644 index 0000000000..1487aeb5e6 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PaykitReceivedPaymentContacts.kt @@ -0,0 +1,90 @@ +package to.bitkit.repositories + +import com.synonym.bitkitcore.validateBitcoinAddress +import com.synonym.paykit.PaymentRequestLifecycleState +import com.synonym.paykit.PaymentRequestLocalRole +import com.synonym.paykit.PaymentRequestRecord +import org.lightningdevkit.ldknode.Bolt11Invoice +import org.lightningdevkit.ldknode.Currency +import org.lightningdevkit.ldknode.Network +import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.models.toLdkNetwork + +internal class PaykitReceivedPaymentContacts private constructor( + private val addresses: Map>, + private val paymentHashes: Map>, +) { + fun contactsForAddresses(values: Collection): Set = + values.flatMapTo(mutableSetOf()) { addresses[it].orEmpty() } + + fun contactsForPaymentHash(value: String): Set = paymentHashes[value.lowercase()].orEmpty() + + companion object { + val Empty = PaykitReceivedPaymentContacts(emptyMap(), emptyMap()) + + fun from( + records: List, + network: Network, + parseInvoice: (String) -> Bolt11Invoice = Bolt11Invoice::fromStr, + ): PaykitReceivedPaymentContacts { + val addresses = mutableMapOf>() + val hashes = mutableMapOf>() + for (record in records) { + val contact = validCounterparty(record) ?: continue + val terms = checkNotNull(record.terms) + // Only immutable request destinations identify a payer; proofs and current lists do not. + val acceptedEndpoints = terms.paymentEndpoints.orEmpty() + .filterKeys(terms.acceptedPaymentEndpointIdentifiers::contains) + for ((identifier, payload) in acceptedEndpoints) { + val endpoint = PublicPaykitRepo.parseEndpoint(identifier, payload, network) ?: continue + val value = endpoint.value + runCatching { + when { + endpoint.methodId.isOnchain && isValidAddress(value, network) -> { + addresses.getOrPut(value) { mutableSetOf() }.add(contact) + } + endpoint.methodId == MethodId.Bolt11 -> { + val invoice = parseInvoice(value) + if (invoice.currency() == currency(network)) { + hashes.getOrPut(invoice.paymentHash()) { mutableSetOf() }.add(contact) + } + } + } + } + } + } + return if (addresses.isEmpty() && hashes.isEmpty()) { + Empty + } else { + PaykitReceivedPaymentContacts(addresses, hashes) + } + } + + private fun validCounterparty(record: PaymentRequestRecord): String? { + if (record.localRole != PaymentRequestLocalRole.PAYEE || record.invalidReason != null) return null + if (record.state == PaymentRequestLifecycleState.INVALID_CONFLICT || + record.state == PaymentRequestLifecycleState.UNKNOWN + ) { + return null + } + if (record.terms?.amount?.asset != PaykitIssuerInterop.BITCOIN_ASSET) return null + if (record.counterparty.trim().length > PubkyPublicKeyFormat.maximumInputLength) return null + return PubkyPublicKeyFormat.normalized(record.counterparty) + } + + private fun isValidAddress(value: String, network: Network): Boolean { + val addressNetwork = validateBitcoinAddress(value).network.toLdkNetwork() + if (addressNetwork == network) return true + if (addressNetwork != Network.TESTNET) return false + return network == Network.SIGNET || + (network == Network.REGTEST && value.firstOrNull() in listOf('2', 'm', 'n')) + } + + private fun currency(network: Network): Currency = when (network) { + Network.BITCOIN -> Currency.BITCOIN + Network.TESTNET -> Currency.BITCOIN_TESTNET + Network.SIGNET -> Currency.SIGNET + Network.REGTEST -> Currency.REGTEST + } + } +} diff --git a/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt b/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt index 6e123cc896..10617aff53 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt @@ -168,13 +168,11 @@ enum class PaykitSubscriptionRole { Payer, Payee } data class PaykitSubscriptionId( val paymentRequestId: String, val counterparty: String, - val counterpartyReceiverPath: String, ) data class PaykitSubscription( val paymentRequestId: String, val counterparty: String, - val counterpartyReceiverPath: String, val amountValue: String, val amountSats: ULong, val note: String?, @@ -191,7 +189,7 @@ data class PaykitSubscription( val hasPaymentDeadline: Boolean = false, ) { val id: PaykitSubscriptionId - get() = PaykitSubscriptionId(paymentRequestId, counterparty, counterpartyReceiverPath) + get() = PaykitSubscriptionId(paymentRequestId, counterparty) val isPayer: Boolean get() = role == PaykitSubscriptionRole.Payer @@ -244,7 +242,6 @@ data class PaykitSubscription( PaykitPaymentRequest( paymentRequestId = paymentRequestId, counterparty = counterparty, - counterpartyReceiverPath = counterpartyReceiverPath, amountValue = amountValue, amountSats = amountSats, note = note, @@ -271,7 +268,6 @@ data class PaykitSubscription( PaykitPaymentRequest( paymentRequestId = paymentRequestId, counterparty = counterparty, - counterpartyReceiverPath = counterpartyReceiverPath, amountValue = amountValue, amountSats = amountSats, note = note, @@ -327,7 +323,6 @@ internal fun PaymentRequestRecord.toPaykitSubscription( return PaykitSubscription( paymentRequestId = paymentRequestId, counterparty = counterparty, - counterpartyReceiverPath = counterpartyReceiverPath, amountValue = requestTerms.amount.value, amountSats = amountSats, note = requestTerms.metadata.note()?.take(256), diff --git a/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt b/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt index b268e4e334..effd1280c7 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt @@ -20,7 +20,6 @@ import to.bitkit.R import to.bitkit.ext.runSuspendCatching import to.bitkit.ui.EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT import to.bitkit.ui.EXTRA_PAYKIT_COUNTERPARTY -import to.bitkit.ui.EXTRA_PAYKIT_COUNTERPARTY_RECEIVER_PATH import to.bitkit.ui.EXTRA_PAYKIT_PAYER_IDENTITY import to.bitkit.ui.EXTRA_PAYKIT_PAYMENT_REQUEST_ID import to.bitkit.ui.EXTRA_PAYKIT_SUBSCRIPTION_PAYMENT_DUE @@ -82,7 +81,7 @@ class PaykitSubscriptionNotificationScheduler @Inject constructor( .take(MAX_NOTIFICATIONS) .associate { (subscription, period) -> val workName = "$WORK_PREFIX$payerIdentity|${subscription.counterparty}|" + - "${subscription.counterpartyReceiverPath}|${subscription.paymentRequestId}|${period.startsAt}" + "${subscription.paymentRequestId}|${period.startsAt}" val delay = (period.startsAt - now).inWholeMilliseconds.coerceAtLeast(0) val work = OneTimeWorkRequestBuilder() .setInitialDelay(delay, TimeUnit.MILLISECONDS) @@ -91,7 +90,6 @@ class PaykitSubscriptionNotificationScheduler @Inject constructor( EXTRA_PAYKIT_PAYMENT_REQUEST_ID to subscription.paymentRequestId, EXTRA_PAYKIT_PAYER_IDENTITY to payerIdentity, EXTRA_PAYKIT_COUNTERPARTY to subscription.counterparty, - EXTRA_PAYKIT_COUNTERPARTY_RECEIVER_PATH to subscription.counterpartyReceiverPath, EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT to period.startsAt.toString(), ) ) @@ -101,7 +99,7 @@ class PaykitSubscriptionNotificationScheduler @Inject constructor( } val pendingWorkNames = pendingRequestIds.mapNotNullTo(mutableSetOf()) { requestId -> requestId.billingPeriodStartsAt?.let { - "$WORK_PREFIX$payerIdentity|${requestId.counterparty}|${requestId.counterpartyReceiverPath}|" + + "$WORK_PREFIX$payerIdentity|${requestId.counterparty}|" + "${requestId.paymentRequestId}|$it" } } @@ -170,10 +168,6 @@ class PaykitSubscriptionNotificationWorker @AssistedInject constructor( putString(EXTRA_PAYKIT_PAYER_IDENTITY, inputData.getString(EXTRA_PAYKIT_PAYER_IDENTITY)) putString(EXTRA_PAYKIT_PAYMENT_REQUEST_ID, inputData.getString(EXTRA_PAYKIT_PAYMENT_REQUEST_ID)) putString(EXTRA_PAYKIT_COUNTERPARTY, inputData.getString(EXTRA_PAYKIT_COUNTERPARTY)) - putString( - EXTRA_PAYKIT_COUNTERPARTY_RECEIVER_PATH, - inputData.getString(EXTRA_PAYKIT_COUNTERPARTY_RECEIVER_PATH), - ) putString( EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT, inputData.getString(EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT), diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt index 4b889a8ee6..e57115f1fc 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt @@ -22,7 +22,6 @@ import to.bitkit.models.addressTypeFromAddress import to.bitkit.models.toAddressType import to.bitkit.models.toSettingsString import to.bitkit.services.CoreService -import to.bitkit.services.PaykitReceiverPaths import to.bitkit.utils.AppError import to.bitkit.utils.Logger import javax.inject.Inject @@ -34,20 +33,6 @@ sealed class PrivatePaykitAddressReservationError(message: String) : AppError(me ) } -internal data class ContactAssignmentKey( - val publicKey: String, - val receiverPath: String, -) { - fun encoded(): String = - if (receiverPath == PaykitReceiverPaths.WALLET) publicKey else "$publicKey$SEPARATOR$receiverPath" - - companion object { - private const val SEPARATOR = '#' - - fun publicKeyOf(encoded: String): String = encoded.substringBefore(SEPARATOR) - } -} - @Singleton @Suppress("TooManyFunctions") class PrivatePaykitAddressReservationRepo @Inject constructor( @@ -98,10 +83,9 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( suspend fun currentOrRotatedAddress( publicKey: String, - receiverPath: String, ): Result = withContext(ioDispatcher) { runSuspendCatching { - val assignmentKey = contactAssignmentKey(publicKey, receiverPath) + val assignmentKey = normalizedPublicKey(publicKey) val current = locked { it.contactAssignments[assignmentKey] } if (current != null && isAddressTypeMonitored(current.addressType)) { val address = resolvedAddress(current).getOrThrow() @@ -170,7 +154,7 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( assignments.firstOrNull { (_, assignment) -> assignment.addressType == addressType && (assignment.address == address || resolvedAddress(assignment).getOrNull() == address) - }?.first?.publicKeyFromAssignmentKey() + }?.first } suspend fun currentContactPublicKeyForReservedAddress(address: String): String? = withContext(ioDispatcher) { @@ -180,7 +164,7 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( assignments.firstOrNull { (_, assignment) -> assignment.addressType == addressType && (assignment.address == address || resolvedAddress(assignment).getOrNull() == address) - }?.first?.publicKeyFromAssignmentKey() + }?.first } suspend fun contactsWithUsedReservedAddresses(): List = withContext(ioDispatcher) { @@ -191,13 +175,13 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( .onFailure { Logger.warn( "Failed to check private Paykit address usage for " + - "'${redacted(publicKey.publicKeyFromAssignmentKey())}'", + "'${redacted(publicKey)}'", it, context = TAG, ) } .getOrDefault(false) - publicKey.publicKeyFromAssignmentKey().takeIf { isUsed } + publicKey.takeIf { isUsed } }.distinct() } @@ -211,18 +195,18 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( val normalizedKey = normalizedPublicKey(publicKey) locked { current -> val hadAssignment = current.contactAssignments.keys.any { - it.publicKeyFromAssignmentKey() == normalizedKey + it == normalizedKey } val hadHistory = current.contactAssignmentHistory.keys.any { - it.publicKeyFromAssignmentKey() == normalizedKey + it == normalizedKey } if (!hadAssignment && !hadHistory) return@locked val next = current.copy( contactAssignments = current.contactAssignments.filterKeys { - it.publicKeyFromAssignmentKey() != normalizedKey + it != normalizedKey }, contactAssignmentHistory = current.contactAssignmentHistory.filterKeys { - it.publicKeyFromAssignmentKey() != normalizedKey + it != normalizedKey }, ) ledger = next @@ -236,10 +220,10 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( locked { current -> val next = current.copy( contactAssignments = current.contactAssignments.filterKeys { - it.publicKeyFromAssignmentKey() in savedKeys + it in savedKeys }, contactAssignmentHistory = current.contactAssignmentHistory.filterKeys { - it.publicKeyFromAssignmentKey() in savedKeys + it in savedKeys }, ) if (next == current) return@locked @@ -392,11 +376,6 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( private fun normalizedPublicKeyOrNull(publicKey: String): String? = PubkyPublicKeyFormat.normalized(publicKey) - private fun contactAssignmentKey(publicKey: String, receiverPath: String): String = - ContactAssignmentKey(normalizedPublicKey(publicKey), receiverPath).encoded() - - private fun String.publicKeyFromAssignmentKey(): String = ContactAssignmentKey.publicKeyOf(this) - private fun redacted(publicKey: String): String = PubkyPublicKeyFormat.redacted(publicKey) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt index ebb38c4c7f..b302089f40 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext import to.bitkit.data.PrivatePaykitCacheStore import to.bitkit.di.IoDispatcher +import to.bitkit.models.PubkyPublicKeyFormat import javax.inject.Inject import javax.inject.Provider import javax.inject.Singleton @@ -14,24 +15,34 @@ class PrivatePaykitContactResolver @Inject constructor( @IoDispatcher private val ioDispatcher: CoroutineDispatcher, private val cacheStore: PrivatePaykitCacheStore, private val addressReservationRepo: Provider, + private val paymentRequestRepo: Provider, ) { + internal val receivedPaymentContacts: PaykitReceivedPaymentContacts + get() = paymentRequestRepo.get().receivedPaymentContacts + suspend fun contactPublicKeyForPrivateInvoicePaymentHash(paymentHash: String): String? = withContext(ioDispatcher) { if (paymentHash.isBlank()) return@withContext null - cacheStore.data.first().contacts.firstNotNullOfOrNull { (publicKey, contactState) -> + val shared = receivedPaymentContacts + val contacts = cacheStore.data.first().contacts.mapNotNull { (publicKey, contactState) -> publicKey.takeIf { - contactState.localInvoicesByReceiverPath.values.any { invoice -> - invoice.paymentHash == paymentHash - } || + contactState.localInvoice?.paymentHash == paymentHash || paymentHash in contactState.receivedInvoicePaymentHashes } } + if (receivedPaymentContacts !== shared) return@withContext null + (contacts + shared.contactsForPaymentHash(paymentHash)) + .mapNotNull(PubkyPublicKeyFormat::normalized).distinct().singleOrNull() } suspend fun contactPublicKeyForPrivateOnchainAddresses(addresses: Collection): String? = withContext(ioDispatcher) { - addresses.firstNotNullOfOrNull { + val shared = receivedPaymentContacts + val contacts = addresses.mapNotNull { addressReservationRepo.get().contactPublicKeyForReservedAddress(it) } + if (receivedPaymentContacts !== shared) return@withContext null + (contacts + shared.contactsForAddresses(addresses)) + .mapNotNull(PubkyPublicKeyFormat::normalized).distinct().singleOrNull() } } diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt index 785f26ead8..49984ab725 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt @@ -35,36 +35,36 @@ internal data class PrivatePaykitState( internal data class ContactState( var remoteEndpoints: List = emptyList(), - var consumedPrivatePaymentListVersionsByReceiverPath: Map = emptyMap(), - var localInvoicesByReceiverPath: Map = emptyMap(), + var consumedPrivatePaymentListVersion: ULong? = null, + var localInvoice: StoredInvoice? = null, var receivedInvoicePaymentHashes: List = emptyList(), - var publishedPrivatePaymentReceiverPaths: Set = emptySet(), + var hasPublishedPrivatePaymentList: Boolean = false, ) { constructor(cache: PrivatePaykitContactCacheData) : this( remoteEndpoints = cache.remoteEndpoints.map { StoredPaymentEntry(it.methodId, it.endpointData) }, - consumedPrivatePaymentListVersionsByReceiverPath = cache.consumedPrivatePaymentListVersionsByReceiverPath, - localInvoicesByReceiverPath = cache.localInvoicesByReceiverPath.mapValues { (_, invoice) -> + consumedPrivatePaymentListVersion = cache.consumedPrivatePaymentListVersion, + localInvoice = cache.localInvoice?.let { invoice -> StoredInvoice(invoice.bolt11, invoice.paymentHash, invoice.expiresAt) }, receivedInvoicePaymentHashes = cache.receivedInvoicePaymentHashes, - publishedPrivatePaymentReceiverPaths = cache.publishedPrivatePaymentReceiverPaths, + hasPublishedPrivatePaymentList = cache.hasPublishedPrivatePaymentList, ) val hasCacheState: Boolean - get() = publishedPrivatePaymentReceiverPaths.isNotEmpty() || + get() = hasPublishedPrivatePaymentList || remoteEndpoints.isNotEmpty() || - consumedPrivatePaymentListVersionsByReceiverPath.isNotEmpty() || - localInvoicesByReceiverPath.isNotEmpty() || + (consumedPrivatePaymentListVersion != null) || + (localInvoice != null) || receivedInvoicePaymentHashes.isNotEmpty() fun cacheState() = PrivatePaykitContactCacheData( remoteEndpoints = remoteEndpoints.map { PrivatePaykitStoredPaymentEntryData(it.methodId, it.endpointData) }, - consumedPrivatePaymentListVersionsByReceiverPath = consumedPrivatePaymentListVersionsByReceiverPath, - localInvoicesByReceiverPath = localInvoicesByReceiverPath.mapValues { (_, invoice) -> + consumedPrivatePaymentListVersion = consumedPrivatePaymentListVersion, + localInvoice = localInvoice?.let { invoice -> PrivatePaykitStoredInvoiceData(invoice.bolt11, invoice.paymentHash, invoice.expiresAt) }, receivedInvoicePaymentHashes = receivedInvoicePaymentHashes, - publishedPrivatePaymentReceiverPaths = publishedPrivatePaymentReceiverPaths, + hasPublishedPrivatePaymentList = hasPublishedPrivatePaymentList, ) } @@ -76,7 +76,7 @@ internal data class StoredPaymentEntry( @Serializable internal data class PrivatePaykitBackup( val sdkState: String, - val consumedPrivatePaymentListVersions: Map>, + val consumedPrivatePaymentListVersions: Map, ) internal data class StoredInvoice( diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 9096c1003c..c3880e9885 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -2,6 +2,7 @@ package to.bitkit.repositories import com.synonym.bitkitcore.Scanner import com.synonym.paykit.LinkedPeerState +import com.synonym.paykit.PaykitException import com.synonym.paykit.PaymentAmountContext import com.synonym.paykit.PrivatePaymentEndpointReservationInput import com.synonym.paykit.PrivatePaymentListDeliveryReport @@ -43,7 +44,6 @@ import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.services.CoreService import to.bitkit.services.PaykitPreparedPrivateContactPayment import to.bitkit.services.PaykitPrivateContactPaymentResolution -import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PaykitSdkService import to.bitkit.services.PubkyService import to.bitkit.utils.Logger @@ -104,7 +104,7 @@ class PrivatePaykitRepo @Inject constructor( private val knownSavedContactKeys = mutableSetOf() private var state: PrivatePaykitState? = null private val pendingMessageDrainRetryLock = Any() - private val pendingMessageDrainRetryKeys = mutableSetOf() + private val pendingMessageDrainRetryKeys = mutableSetOf() private var pendingMessageDrainRetryJob: Job? = null private var pendingMessageDrainRetryGeneration = 0 private val _initialLinkBurstStarted = MutableSharedFlow(extraBufferCapacity = 1) @@ -116,12 +116,12 @@ class PrivatePaykitRepo @Inject constructor( private data class PrivatePublicationPreparation( val updates: List, - val linkRetryKeys: List, + val linkRetryKeys: List, val firstError: Throwable?, ) private data class PrivateEndpointCleanupPreparation( - val clearedRetryKeys: List, + val clearedRetryKeys: List, val failedPublicKeys: Set, val firstError: Throwable?, ) @@ -131,20 +131,10 @@ class PrivatePaykitRepo @Inject constructor( val invalidKeys: Set, ) - private data class LinkedReceiverPathsSnapshot( - val pathsByPublicKey: Map>, - val error: Throwable?, - ) - private data class PublishedEndpointCleanupState( val remoteEndpoints: List, - val localInvoicesByReceiverPath: Map, - val publishedPrivatePaymentReceiverPaths: Set, - ) - - private data class PrivateMessageDrainRetryKey( - val publicKey: String, - val receiverPath: String, + val localInvoice: StoredInvoice?, + val hasPublishedPrivatePaymentList: Boolean, ) private val _backupStateVersion = MutableStateFlow(0L) @@ -270,6 +260,9 @@ class PrivatePaykitRepo @Inject constructor( removePublishedEndpoints().getOrThrow() clearUnsavedContactState(savedPublicKeys).getOrThrow() updateContactSharingCleanupPending(false) + publicPaykitRepo.syncPaykitApp().onFailure { + updateContactSharingCleanupPending(true) + }.getOrThrow() } retryPendingDeletedContactEndpointRemoval(savedPublicKeys).getOrThrow() }.onFailure { @@ -309,6 +302,7 @@ class PrivatePaykitRepo @Inject constructor( suspend fun disableSharingAndPruneUnsavedContactState(savedPublicKeys: Collection): Result = withContext(serializedDispatcher) { runSuspendCatching { + updateContactSharingCleanupPending(true) val removalError = removePublishedEndpoints().exceptionOrNull() if (removalError != null) { updateContactSharingCleanupPending(true) @@ -322,6 +316,9 @@ class PrivatePaykitRepo @Inject constructor( clearUnsavedContactState(savedPublicKeys).getOrThrow() updateContactSharingCleanupPending(false) + publicPaykitRepo.syncPaykitApp().onFailure { + updateContactSharingCleanupPending(true) + }.getOrThrow() } } @@ -333,14 +330,15 @@ class PrivatePaykitRepo @Inject constructor( } suspend fun removePublishedEndpointsForCleanup(context: String): Result = withContext(serializedDispatcher) { - removePublishedEndpoints() - .onSuccess { - updateContactSharingCleanupPending(false) - } - .onFailure { - updateContactSharingCleanupPending(true) - Logger.warn("Failed to remove private Paykit endpoints during '$context'", it, context = TAG) - } + runSuspendCatching { + updateContactSharingCleanupPending(true) + removePublishedEndpoints().getOrThrow() + updateContactSharingCleanupPending(false) + publicPaykitRepo.syncPaykitApp().getOrThrow() + }.onFailure { + updateContactSharingCleanupPending(true) + Logger.warn("Failed to remove private Paykit endpoints during '$context'", it, context = TAG) + } } suspend fun closeAndClear(): Result = withContext(serializedDispatcher) { @@ -394,19 +392,18 @@ class PrivatePaykitRepo @Inject constructor( ): Result = withContext(serializedDispatcher) { runSuspendCatching { val normalizedKey = normalizedPublicKey(publicKey) ?: throw PrivatePaykitError.InvalidPublicKey + val paymentListVersion = context.paymentListVersion ?: return@runSuspendCatching val contactState = ensureState().contacts.getOrPut(normalizedKey) { ContactState() } - val consumedVersion = contactState.consumedPrivatePaymentListVersionsByReceiverPath[context.receiverPath] - if (consumedVersion != null && context.paymentListVersion <= consumedVersion) { + val consumedVersion = contactState.consumedPrivatePaymentListVersion + if (consumedVersion != null && paymentListVersion <= consumedVersion) { throw PrivatePaykitError.PaymentListAlreadyConsumed } - contactState.consumedPrivatePaymentListVersionsByReceiverPath = - contactState.consumedPrivatePaymentListVersionsByReceiverPath + - (context.receiverPath to context.paymentListVersion) + contactState.consumedPrivatePaymentListVersion = paymentListVersion contactState.remoteEndpoints = emptyList() persistState(markWalletBackup = true) Logger.info( - "Consumed private Paykit payment list version ${context.paymentListVersion} " + + "Consumed private Paykit payment list version $paymentListVersion " + "for '${redacted(normalizedKey)}'", context = TAG, ) @@ -421,15 +418,15 @@ class PrivatePaykitRepo @Inject constructor( ): Result = withContext(serializedDispatcher) { runSuspendCatching { val normalizedKey = normalizedPublicKey(publicKey) ?: throw PrivatePaykitError.InvalidPublicKey + val paymentListVersion = context.paymentListVersion ?: return@runSuspendCatching val contactState = ensureState().contacts[normalizedKey] ?: return@runSuspendCatching - val consumedVersion = contactState.consumedPrivatePaymentListVersionsByReceiverPath[context.receiverPath] - if (consumedVersion != context.paymentListVersion) return@runSuspendCatching + val consumedVersion = contactState.consumedPrivatePaymentListVersion + if (consumedVersion != paymentListVersion) return@runSuspendCatching - contactState.consumedPrivatePaymentListVersionsByReceiverPath = - contactState.consumedPrivatePaymentListVersionsByReceiverPath - context.receiverPath + contactState.consumedPrivatePaymentListVersion = null persistState(markWalletBackup = true) Logger.info( - "Released private Paykit payment list version '${context.paymentListVersion}' " + + "Released private Paykit payment list version '$paymentListVersion' " + "for '${redacted(normalizedKey)}'", context = TAG, ) @@ -477,7 +474,7 @@ class PrivatePaykitRepo @Inject constructor( val matches = buildList { ensureState().contacts.forEach { (publicKey, contactState) -> if (publicKey !in knownSavedContactKeys) return@forEach - contactState.localInvoicesByReceiverPath.values.forEach { invoice -> + contactState.localInvoice?.let { invoice -> if (invoice.paymentHash in paymentHashes) add(publicKey to invoice.paymentHash) } } @@ -515,7 +512,7 @@ class PrivatePaykitRepo @Inject constructor( if (paymentHash.isBlank()) return@withContext null ensureState().contacts.firstNotNullOfOrNull { (publicKey, contactState) -> publicKey.takeIf { - contactState.localInvoices().any { invoice -> invoice.paymentHash == paymentHash } || + contactState.localInvoice?.paymentHash == paymentHash || paymentHash in contactState.receivedInvoicePaymentHashes } } @@ -537,9 +534,7 @@ class PrivatePaykitRepo @Inject constructor( sdkState = paykitSdkService.exportBackupState(), consumedPrivatePaymentListVersions = ensureState().contacts .mapNotNull { (publicKey, contactState) -> - contactState.consumedPrivatePaymentListVersionsByReceiverPath - .takeIf { it.isNotEmpty() } - ?.let { publicKey to it } + contactState.consumedPrivatePaymentListVersion?.let { publicKey to it } }.toMap(), ) ) @@ -549,17 +544,17 @@ class PrivatePaykitRepo @Inject constructor( suspend fun restoreBackup(backup: String?): Result = withContext(serializedDispatcher) { runSuspendCatching { + val decoded = backup?.let { json.decodeFromString(it) } + decoded?.let { paykitSdkService.retainRecoveryBackup(it.sdkState) } clearPendingMessageDrainRetries() state = PrivatePaykitState() knownSavedContactKeys.clear() if (backup == null) { paykitSdkService.clearState() } else { - val decoded = json.decodeFromString(backup) - paykitSdkService.restoreBackupState(decoded.sdkState) - decoded.consumedPrivatePaymentListVersions.forEach { (publicKey, versions) -> + requireNotNull(decoded).consumedPrivatePaymentListVersions.forEach { (publicKey, versions) -> ensureState().contacts.getOrPut(publicKey) { ContactState() } - .consumedPrivatePaymentListVersionsByReceiverPath = versions + .consumedPrivatePaymentListVersion = versions } } persistState(preserveCleanupMarkers = false) @@ -573,44 +568,38 @@ class PrivatePaykitRepo @Inject constructor( ): Result = withContext(serializedDispatcher) { runSuspendCatching { - val receiverPath = paymentRequest?.counterpartyReceiverPath ?: PaykitReceiverPaths.WALLET - val consumedVersion = ensureState().contacts[publicKey] - ?.consumedPrivatePaymentListVersionsByReceiverPath - ?.get(receiverPath) + val consumedVersion = ensureState().contacts[publicKey]?.consumedPrivatePaymentListVersion val amount = paymentRequest?.let { PaymentAmountContext(it.amountValue, PaykitIssuerInterop.BITCOIN_ASSET) } val prepared = runSuspendCatching { preparePrivateContactPayment( publicKey = publicKey, - receiverPath = receiverPath, consumedVersion = consumedVersion, amount = amount, - allowPublicResolution = paymentRequest == null, + paymentRequest = paymentRequest, ) }.getOrElse { if (paymentRequest == null || !it.isPaykitRecoveryRequired()) throw it if (paymentRequest.isExpired(clock.now())) throw PaykitPaymentRequestError.RequestExpired - return@runSuspendCatching privateLinkPendingResult(publicKey, receiverPath) + return@runSuspendCatching privateLinkPendingResult(publicKey) } ?: return@runSuspendCatching publicPaykitRepo.beginPayment(publicKey).getOrThrow() val resolution = prepared.resolution - val linkState = currentLinkState(publicKey, receiverPath, prepared.linkState) + val linkState = currentLinkState(publicKey, prepared.linkState) if (paymentRequest == null && canUsePublicPayment(linkState, resolution.status, resolution.state)) { return@runSuspendCatching publicPaykitRepo.beginPayment(publicKey).getOrThrow() } val result = unresolvedPrivateLinkResult( publicKey = publicKey, - receiverPath = receiverPath, paymentRequest = paymentRequest, resolution = resolution, linkState = linkState, ) ?: privatePaymentResult( publicKey = publicKey, - receiverPath = receiverPath, resolution = resolution, consumedVersion = consumedVersion, - acceptedEndpointIdentifiers = paymentRequest?.acceptedPaymentEndpointIdentifiers?.toSet(), + paymentRequest = paymentRequest, ) if (paymentRequest?.isExpired(clock.now()) == true) { throw PaykitPaymentRequestError.RequestExpired @@ -648,22 +637,28 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun preparePrivateContactPayment( publicKey: String, - receiverPath: String, consumedVersion: ULong?, amount: PaymentAmountContext?, - allowPublicResolution: Boolean, + paymentRequest: PaykitPaymentRequest?, ): PaykitPreparedPrivateContactPayment? { val result = runSuspendCatching { - paykitSdkService.prepareAndResolvePrivateContactPayment( - counterparty = publicKey, - receiverPath = receiverPath, - afterPrivatePaymentListVersion = consumedVersion, - amount = amount, - ) + if (paymentRequest != null) { + paykitSdkService.prepareAndResolvePrivatePaymentRequest( + counterparty = publicKey, + paymentRequestId = paymentRequest.paymentRequestId, + afterPrivatePaymentListVersion = consumedVersion, + ) + } else { + paykitSdkService.prepareAndResolvePrivateContactPayment( + counterparty = publicKey, + afterPrivatePaymentListVersion = consumedVersion, + amount = amount, + ) + } } val error = result.exceptionOrNull() ?: return result.getOrThrow() - if (!allowPublicResolution) throw error - if (!canUsePublicPayment(currentLinkState(publicKey, receiverPath))) throw error + if (paymentRequest != null) throw error + if (!canUsePublicPayment(currentLinkState(publicKey))) throw error Logger.warn( "Using public Paykit resolution for '${redacted(publicKey)}'", @@ -675,36 +670,42 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun privatePaymentResult( publicKey: String, - receiverPath: String, resolution: PaykitPrivateContactPaymentResolution, consumedVersion: ULong?, - acceptedEndpointIdentifiers: Set? = null, + paymentRequest: PaykitPaymentRequest?, ): PublicPaykitPaymentResult { val privateEndpoints = resolution.payableEndpoints - .mapNotNull { PublicPaykitRepo.parseEndpoint(it.identifier, it.payload) } - cacheResolvedPrivateEndpoints(publicKey, privateEndpoints) + .mapNotNull { PublicPaykitRepo.parseEndpoint(it.identifier, it.payload)?.copy(appId = it.appId) } + if (resolution.privatePaymentListVersion != null) { + cacheResolvedPrivateEndpoints(publicKey, privateEndpoints) + } + val acceptedEndpointIdentifiers = paymentRequest?.acceptedPaymentEndpointIdentifiers?.toSet() val acceptedEndpoints = privateEndpoints.filter { acceptedEndpointIdentifiers?.contains(it.methodId.rawValue) ?: true } val privatePayable = privatePayableEndpoints(acceptedEndpoints, publicKey) val paymentListVersion = resolution.privatePaymentListVersion - if (privatePayable.isNotEmpty() && paymentListVersion != null) { + if (privatePayable.isNotEmpty() && (paymentListVersion != null || paymentRequest != null)) { Logger.info( "Opened private Paykit payment for '${redacted(publicKey)}' using payment list version " + - "$paymentListVersion after ${consumedVersion ?: "none"}", + "${paymentListVersion ?: "none"} after ${consumedVersion ?: "none"}", context = TAG, ) return PublicPaykitPaymentResult.Opened( paymentRequest = PublicPaykitRepo.paymentRequest(privatePayable), - privatePaymentContext = PrivatePaykitPaymentContext(receiverPath, paymentListVersion), + privatePaymentContext = PrivatePaykitPaymentContext( + paymentAppsByEndpoint = privatePayable.distinctBy { it.methodId } + .associate { it.methodId.rawValue to requireNotNull(it.appId) }, + paymentListVersion = paymentListVersion, + ), ) } if (resolution.status == PrivatePaymentResolutionStatus.WAITING_FOR_UPDATED_PAYMENT_LIST) { schedulePendingPrivateMessageDrainRetries( reason = "payment recovery", - retryKeys = listOf(PrivateMessageDrainRetryKey(publicKey, receiverPath)), + retryKeys = listOf(publicKey), ) Logger.info( "Waiting for a private Paykit payment list newer than ${consumedVersion ?: "none"} " + @@ -723,27 +724,25 @@ class PrivatePaykitRepo @Inject constructor( private fun unresolvedPrivateLinkResult( publicKey: String, - receiverPath: String, paymentRequest: PaykitPaymentRequest?, resolution: PaykitPrivateContactPaymentResolution, linkState: LinkedPeerState?, ): PublicPaykitPaymentResult? = when { resolution.state == PrivatePaymentResolutionState.RECOVERY_PENDING -> - privateLinkPendingResult(publicKey, receiverPath) + privateLinkPendingResult(publicKey) paymentRequest == null -> null linkState == LinkedPeerState.LINKING || linkState == LinkedPeerState.RECOVERY_REQUIRED -> - privateLinkPendingResult(publicKey, receiverPath) + privateLinkPendingResult(publicKey) linkState != LinkedPeerState.LINKED -> PublicPaykitPaymentResult.NoEndpoint else -> null } private fun privateLinkPendingResult( publicKey: String, - receiverPath: String, ): PublicPaykitPaymentResult { schedulePendingPrivateMessageDrainRetries( reason = "payment link recovery", - retryKeys = listOf(PrivateMessageDrainRetryKey(publicKey, receiverPath)), + retryKeys = listOf(publicKey), ) Logger.info( "Waiting for private Paykit link recovery for '${redacted(publicKey)}'", @@ -754,10 +753,9 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun currentLinkState( publicKey: String, - receiverPath: String, preparedState: LinkedPeerState? = null, ): LinkedPeerState? = preparedState ?: paykitSdkService.linkedPeers().firstOrNull { - PubkyPublicKeyFormat.matches(it.counterparty, publicKey) && it.counterpartyReceiverPath == receiverPath + PubkyPublicKeyFormat.matches(it.counterparty, publicKey) }?.state private fun canUsePublicPayment( @@ -838,113 +836,31 @@ class PrivatePaykitRepo @Inject constructor( forceRefreshLightning: Boolean, ): PrivatePublicationPreparation { var firstError: Throwable? = null - var receiverPathSelectionError: Throwable? = null - var hasPublicationUpdate = false val updates = mutableListOf() - val linkRetryKeys = mutableListOf() - val linkedReceiverPathsSnapshot = linkedReceiverPathsSnapshot(reason) - firstError = linkedReceiverPathsSnapshot.error - - for (publicKey in publicKeys) { - val receiverPaths = runSuspendCatching { receiverPathsForSavedContact(publicKey) } + val linkRetryKeys = mutableListOf() + for (publicKey in publicKeys.distinct()) { + val link = runSuspendCatching { paykitSdkService.ensureLinkWithPeer(publicKey) } + .onFailure { Logger.warn("Failed to prepare private Paykit link during '$reason'", it, context = TAG) } + if (link.exceptionOrNull() is PaykitException.NotFound) continue + linkRetryKeys += publicKey + runSuspendCatching { privatePaymentListUpdate(publicKey, forceRefreshLightning) } + .onSuccess { updates += it } .onFailure { - firstError = firstError ?: it - Logger.warn( - "Failed to read saved Paykit receivers for '${redacted(publicKey)}' during '$reason'", - it, - context = TAG, - ) - }.getOrNull() ?: continue - val receiverPathSelection = paykitSdkService.privateReceiverPathSelection(publicKey, receiverPaths) - val linkableReceiverPaths = receiverPathSelection.linkableReceiverPaths - val publicationReceiverPaths = receiverPathSelection.publishableReceiverPaths - receiverPathSelection.error?.let { - logPrivateReceiverPathSelectionFailure(publicKey, reason, it) - receiverPathSelectionError = receiverPathSelectionError ?: it - } - val cleanupReceiverPaths = receiverPathsForPrivateEndpointCleanup( - publicKey = publicKey, - excludedReceiverPaths = publicationReceiverPaths + receiverPathSelection.cleanupProtectedReceiverPaths, - linkedReceiverPaths = linkedReceiverPathsSnapshot.pathsByPublicKey[publicKey].orEmpty(), - ) - - linkRetryKeys += preparePrivateLinks(publicKey, linkableReceiverPaths + cleanupReceiverPaths, reason) - - cleanupReceiverPaths.forEach { receiverPath -> - updates += PrivatePaymentListReservationUpdateInput( - counterparty = publicKey, - counterpartyReceiverPath = receiverPath, - reservations = emptyList(), - ) - } - - publicationReceiverPaths.forEach { receiverPath -> - runSuspendCatching { - privatePaymentListUpdate(publicKey, receiverPath, forceRefreshLightning) - }.onSuccess { - updates += it - hasPublicationUpdate = true - }.onFailure { firstError = firstError ?: it logPrivatePublicationPreparationFailure(publicKey, reason, it) } - } } - - if (!hasPublicationUpdate) firstError = firstError ?: receiverPathSelectionError - return PrivatePublicationPreparation(updates, linkRetryKeys.distinct(), firstError) + return PrivatePublicationPreparation(updates, linkRetryKeys, firstError) } private suspend fun prepareRelevantPrivateLinksIfAvailable(publicKeys: Collection, reason: String) { if (!hasPrivatePaymentAccessForCurrentProfile()) return - - val retryKeys = mutableListOf() - for (publicKey in publicKeys) { - val receiverPaths = runSuspendCatching { receiverPathsForSavedContact(publicKey) } - .onFailure { - Logger.warn( - "Failed to read saved Paykit receivers for '${redacted(publicKey)}' during '$reason'", - it, - context = TAG, - ) - }.getOrNull() ?: continue - val selection = paykitSdkService.privateReceiverPathSelection(publicKey, receiverPaths) - selection.error?.let { - Logger.warn( - "Failed to inspect private Paykit receiver markers for '${redacted(publicKey)}' during '$reason'", - it, - context = TAG, - ) - } - retryKeys += selection.linkableReceiverPaths.map { PrivateMessageDrainRetryKey(publicKey, it) } - } - - drainAndSchedulePrivateLinkRetries(reason, retryKeys.distinct()) - } - - private suspend fun preparePrivateLinks( - publicKey: String, - receiverPaths: Collection, - reason: String, - ): List { - val retryKeys = mutableListOf() - for (receiverPath in receiverPaths.distinct()) { - runSuspendCatching { paykitSdkService.ensureLinkWithPeer(publicKey, receiverPath) }.onFailure { - Logger.warn( - "Failed to prepare private Paykit link for '${redacted(publicKey)}' during '$reason'", - it, - context = TAG, - ) - } - retryKeys += PrivateMessageDrainRetryKey(publicKey, receiverPath) - } - - return retryKeys + drainAndSchedulePrivateLinkRetries(reason, publicKeys.distinct()) } private suspend fun drainAndSchedulePrivateLinkRetries( reason: String, - retryKeys: Collection, + retryKeys: Collection, ) { if (retryKeys.isEmpty()) return @@ -957,15 +873,13 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun privatePaymentListUpdate( publicKey: String, - receiverPath: String, forceRefreshLightning: Boolean, ): PrivatePaymentListReservationUpdateInput { - val endpoints = buildLocalEndpoints(publicKey, receiverPath, forceRefreshLightning).getOrThrow() + val endpoints = buildLocalEndpoints(publicKey, forceRefreshLightning).getOrThrow() if (endpoints.isEmpty()) throw PrivatePaykitError.PrivateUnavailable return PrivatePaymentListReservationUpdateInput( counterparty = publicKey, - counterpartyReceiverPath = receiverPath, - reservations = endpoints.map { endpoint -> privateReservation(publicKey, receiverPath, endpoint) }, + reservations = endpoints.map { endpoint -> privateReservation(publicKey, endpoint) }, ) } @@ -988,18 +902,6 @@ class PrivatePaykitRepo @Inject constructor( } } - private fun logPrivateReceiverPathSelectionFailure( - publicKey: String, - reason: String, - error: Throwable, - ) { - Logger.warn( - "Failed to inspect private Paykit receiver markers for '${redacted(publicKey)}' during '$reason'", - error, - context = TAG, - ) - } - private suspend fun applyPrivatePaymentListDeliveryReport( report: PrivatePaymentListDeliveryReport, reason: String, @@ -1007,14 +909,14 @@ class PrivatePaykitRepo @Inject constructor( report.failedToQueue.forEach { Logger.warn( "Failed to queue private Paykit endpoints for '${redacted(it.counterparty)}' during '$reason': " + - (it.error ?: "unknown error"), + (it.error?.redactedContext() ?: "unknown error"), context = TAG, ) } report.failedToDeliver.forEach { Logger.warn( "Failed to deliver private Paykit endpoints for '${redacted(it.counterparty)}' during '$reason': " + - it.error, + it.error.redactedContext(), context = TAG, ) } @@ -1022,14 +924,13 @@ class PrivatePaykitRepo @Inject constructor( var didUpdateCache = false for (change in report.queued) { val publicKey = normalizedPublicKey(change.counterparty) ?: continue - recordPublishedPrivatePaymentListCache(publicKey, change.counterpartyReceiverPath) + recordPublishedPrivatePaymentListCache(publicKey) didUpdateCache = true } for (change in report.cleared) { didUpdateCache = clearPublishedPrivatePaymentListCache( counterparty = change.counterparty, - receiverPath = change.counterpartyReceiverPath, ) || didUpdateCache } @@ -1044,32 +945,29 @@ class PrivatePaykitRepo @Inject constructor( private fun privatePaymentListDeliveryRetryKeys( report: PrivatePaymentListDeliveryReport, - ): List { - val changes = report.queued.map { it.counterparty to it.counterpartyReceiverPath } + - report.cleared.map { it.counterparty to it.counterpartyReceiverPath } + - report.failedToDeliver.map { it.counterparty to it.counterpartyReceiverPath } - - return changes - .mapNotNull { (counterparty, receiverPath) -> - normalizedPublicKey(counterparty)?.let { PrivateMessageDrainRetryKey(it, receiverPath) } - } + ): List { + return ( + report.queued.map { it.counterparty } + + report.cleared.map { it.counterparty } + + report.failedToDeliver.map { it.counterparty } + ) + .mapNotNull(::normalizedPublicKey) .distinct() } private suspend fun drainPendingPrivateMessages( reason: String, - advancingLinksFor: List = emptyList(), + advancingLinksFor: List = emptyList(), ) { runSuspendCatching { advancingLinksFor.distinct().forEach { retryKey -> runSuspendCatching { paykitSdkService.ensureLinkWithPeer( - counterparty = retryKey.publicKey, - receiverPath = retryKey.receiverPath, + counterparty = retryKey, ) }.onFailure { Logger.warn( - "Failed to advance private Paykit link for '${redacted(retryKey.publicKey)}' during '$reason'", + "Failed to advance private Paykit link for '${redacted(retryKey)}' during '$reason'", it, context = TAG, ) @@ -1086,7 +984,7 @@ class PrivatePaykitRepo @Inject constructor( private fun schedulePendingPrivateMessageDrainRetries( reason: String, - retryKeys: Collection, + retryKeys: Collection, ) { val retryKeys = retryKeys.toSet() if (retryKeys.isEmpty()) return @@ -1135,7 +1033,7 @@ class PrivatePaykitRepo @Inject constructor( } } - private suspend fun updatePendingMessageDrainRetryKeys(retryKeys: Collection) { + private suspend fun updatePendingMessageDrainRetryKeys(retryKeys: Collection) { val remainingKeys = pendingPrivateMessageDrainKeys(retryKeys) synchronized(pendingMessageDrainRetryLock) { pendingMessageDrainRetryKeys.removeAll(retryKeys.toSet()) @@ -1144,8 +1042,8 @@ class PrivatePaykitRepo @Inject constructor( } private suspend fun pendingPrivateMessageDrainKeys( - retryKeys: Collection, - ): Set { + retryKeys: Collection, + ): Set { val retryKeys = retryKeys.toSet() if (retryKeys.isEmpty()) return emptySet() @@ -1156,7 +1054,7 @@ class PrivatePaykitRepo @Inject constructor( } .mapNotNull { peer -> normalizedPublicKey(peer.counterparty)?.let { publicKey -> - PrivateMessageDrainRetryKey(publicKey, peer.counterpartyReceiverPath) to peer.state + publicKey to peer.state } } .toMap() @@ -1165,11 +1063,7 @@ class PrivatePaykitRepo @Inject constructor( Logger.warn("Failed to inspect pending private Paykit messages", it, context = TAG) return retryKeys } - .mapNotNull { receiver -> - normalizedPublicKey(receiver.counterparty)?.let { - PrivateMessageDrainRetryKey(it, receiver.counterpartyReceiverPath) - } - } + .mapNotNull(::normalizedPublicKey) .toSet() return retryKeys.filterTo(mutableSetOf()) { retryKey -> @@ -1190,21 +1084,18 @@ class PrivatePaykitRepo @Inject constructor( } } - private suspend fun recordPublishedPrivatePaymentListCache(publicKey: String, receiverPath: String) { + private suspend fun recordPublishedPrivatePaymentListCache(publicKey: String) { val contactState = ensureState().contacts.getOrPut(publicKey) { ContactState() } - contactState.publishedPrivatePaymentReceiverPaths = - (contactState.publishedPrivatePaymentReceiverPaths + receiverPath).toSortedSet() + contactState.hasPublishedPrivatePaymentList = true } private suspend fun clearPublishedPrivatePaymentListCache( counterparty: String, - receiverPath: String, ): Boolean { val publicKey = normalizedPublicKey(counterparty) ?: return false ensureState().contacts[publicKey]?.let { contactState -> - contactState.publishedPrivatePaymentReceiverPaths = - contactState.publishedPrivatePaymentReceiverPaths.filterTo(mutableSetOf()) { it != receiverPath } - contactState.localInvoicesByReceiverPath = contactState.localInvoicesByReceiverPath - receiverPath + contactState.hasPublishedPrivatePaymentList = false + contactState.localInvoice = null if (!contactState.hasCacheState) { state?.contacts?.remove(publicKey) } @@ -1214,7 +1105,6 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun buildLocalEndpoints( publicKey: String, - receiverPath: String, forceRefreshLightning: Boolean = false, ): Result> = withContext(serializedDispatcher) { runSuspendCatching { @@ -1223,7 +1113,6 @@ class PrivatePaykitRepo @Inject constructor( if (PublicPaykitRepo.isOnchainPaymentOptionEnabled(settings)) { val reservedAddress = addressReservationRepo.currentOrRotatedAddress( publicKey, - receiverPath, ).getOrThrow() walletRepo.refreshReusableReceiveAddressIfReserved().getOrThrow() endpoints += Endpoint( @@ -1236,7 +1125,6 @@ class PrivatePaykitRepo @Inject constructor( if (PublicPaykitRepo.isLightningPaymentOptionEnabled(settings) && lightningRepo.canReceive()) { currentOrRotatedInvoice( publicKey, - receiverPath, forceRefresh = forceRefreshLightning, ).onSuccess { invoice -> endpoints += Endpoint( @@ -1259,18 +1147,17 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun currentOrRotatedInvoice( publicKey: String, - receiverPath: String, forceRefresh: Boolean = false, ): Result = withContext(serializedDispatcher) { runSuspendCatching { - if (!forceRefresh) reusablePrivateInvoice(publicKey, receiverPath)?.let { return@runSuspendCatching it } + if (!forceRefresh) reusablePrivateInvoice(publicKey)?.let { return@runSuspendCatching it } val bolt11 = lightningRepo.createInvoice( amountSats = null, description = "", expirySeconds = privateInvoiceExpiry.inWholeSeconds.toUInt(), ).getOrThrow() - if (!forceRefresh) reusablePrivateInvoice(publicKey, receiverPath)?.let { return@runSuspendCatching it } + if (!forceRefresh) reusablePrivateInvoice(publicKey)?.let { return@runSuspendCatching it } val decoded = (coreService.decode(bolt11) as? Scanner.Lightning)?.invoice ?: throw PublicPaykitError.InvalidPayload @@ -1283,7 +1170,7 @@ class PrivatePaykitRepo @Inject constructor( paymentHash = decoded.paymentHash.toHex(), expiresAt = expiresAt, ) - setLocalInvoice(publicKey, receiverPath, invoice) + setLocalInvoice(publicKey, invoice) persistState() invoice } @@ -1291,9 +1178,8 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun reusablePrivateInvoice( publicKey: String, - receiverPath: String, ): StoredInvoice? { - val invoice = localInvoice(publicKey, receiverPath) ?: return null + val invoice = localInvoice(publicKey) ?: return null val refreshAt = clock.now().epochSeconds + invoiceRefreshBuffer.inWholeSeconds val decoded = (coreService.decode(invoice.bolt11) as? Scanner.Lightning)?.invoice ?: return null val isReusable = invoice.expiresAt > refreshAt && @@ -1306,31 +1192,28 @@ class PrivatePaykitRepo @Inject constructor( private fun privateReservation( publicKey: String, - receiverPath: String, endpoint: Endpoint, ): PrivatePaymentEndpointReservationInput { val contactState = state?.contacts?.get(publicKey) val attribution = if (endpoint.methodId == MethodId.Bolt11) { - val paymentHash = localInvoice(publicKey, receiverPath)?.takeIf { it.bolt11 == endpoint.value }?.paymentHash + val paymentHash = localInvoice(publicKey)?.takeIf { it.bolt11 == endpoint.value }?.paymentHash mapOf( "type" to "private_paykit", "counterparty" to publicKey, - "receiver_path" to receiverPath, ) + listOfNotNull(paymentHash?.let { "payment_hash" to it }).toMap() } else { mapOf( "type" to "private_paykit", "counterparty" to publicKey, - "receiver_path" to receiverPath, ) } val expiresAt = contactState - ?.let { localInvoice(publicKey, receiverPath) } + ?.let { localInvoice(publicKey) } ?.takeIf { endpoint.methodId == MethodId.Bolt11 && it.bolt11 == endpoint.value } ?.let { Instant.ofEpochSecond(it.expiresAt).toString() } return PrivatePaymentEndpointReservationInput( - reservationId = privateReservationId(publicKey, receiverPath, endpoint), + reservationId = privateReservationId(publicKey, endpoint), identifier = endpoint.methodId.rawValue, payload = endpoint.rawPayload, expiresAt = expiresAt, @@ -1338,12 +1221,12 @@ class PrivatePaykitRepo @Inject constructor( ) } - private fun privateReservationId(publicKey: String, receiverPath: String, endpoint: Endpoint): String { + private fun privateReservationId(publicKey: String, endpoint: Endpoint): String { val payloadHashPrefix = MessageDigest.getInstance("SHA-256") .digest(endpoint.rawPayload.toByteArray(Charsets.UTF_8)) .copyOfRange(0, 8) .toHex() - return "$publicKey:$receiverPath:${endpoint.methodId.rawValue}:$payloadHashPrefix" + return "$publicKey:${endpoint.methodId.rawValue}:$payloadHashPrefix" } private suspend fun cacheResolvedPrivateEndpoints(publicKey: String, endpoints: List) { @@ -1353,10 +1236,13 @@ class PrivatePaykitRepo @Inject constructor( } private suspend fun removePublishedEndpoints(): Result = withContext(serializedDispatcher) { - publicationMutex.withLock { - val keys = (knownSavedContactKeys + ensureState().contacts.keys + pendingDeletedContactCleanupPublicKeys()) - .distinct() - removePublishedEndpointsLocked(keys) + runSuspendCatching { + publicationMutex.withLock { + val keys = ( + knownSavedContactKeys + ensureState().contacts.keys + pendingDeletedContactCleanupPublicKeys() + ).distinct() + removePublishedEndpointsLocked(keys).getOrThrow() + } } } @@ -1378,9 +1264,9 @@ class PrivatePaykitRepo @Inject constructor( if (normalizedKeys.isEmpty()) return@runSuspendCatching ensureState() + publicPaykitRepo.syncPaykitApp(privateSharingEnabled = true).getOrThrow() val cleanupStateByPublicKey = normalizedKeys.associateWith(::publishedEndpointCleanupState) - val linkedReceiverPathsSnapshot = linkedReceiverPathsSnapshot("private endpoint cleanup") - val preparation = clearPrivatePaymentLists(normalizedKeys, linkedReceiverPathsSnapshot) + val preparation = clearPrivatePaymentLists(normalizedKeys) val failedPublicKeys = preparation.failedPublicKeys.toMutableSet() var firstError = preparation.firstError @@ -1391,7 +1277,7 @@ class PrivatePaykitRepo @Inject constructor( ) val pendingRetryKeys = pendingPrivateMessageDrainKeys(preparation.clearedRetryKeys) if (pendingRetryKeys.isNotEmpty()) { - failedPublicKeys += pendingRetryKeys.map { it.publicKey } + failedPublicKeys += pendingRetryKeys firstError = firstError ?: PrivatePaykitError.PrivateUnavailable } } @@ -1409,41 +1295,39 @@ class PrivatePaykitRepo @Inject constructor( clearPublishedEndpointCache(normalizedKeys.filterNot { it in failedPublicKeys }) firstError?.let { throw it } + publicPaykitRepo.syncPaykitApp().getOrThrow() } private suspend fun clearPrivatePaymentLists( publicKeys: Collection, - linkedReceiverPathsSnapshot: LinkedReceiverPathsSnapshot, ): PrivateEndpointCleanupPreparation { - val failedPublicKeys = if (linkedReceiverPathsSnapshot.error == null) { - mutableSetOf() - } else { - publicKeys.toMutableSet() - } - val clearedRetryKeys = mutableListOf() - var firstError = linkedReceiverPathsSnapshot.error - + val linkedPublicKeys = paykitSdkService.linkedPeers() + .filter { it.state != LinkedPeerState.NOT_LINKED } + .mapNotNull { normalizedPublicKey(it.counterparty) } + .toSet() + val failedPublicKeys = mutableSetOf() + val clearedRetryKeys = mutableListOf() + var firstError: Throwable? = null publicKeys.forEach { publicKey -> - receiverPathsForCleanup( - publicKey = publicKey, - linkedReceiverPaths = linkedReceiverPathsSnapshot.pathsByPublicKey[publicKey].orEmpty(), - ).forEach { receiverPath -> - runSuspendCatching { - val report = paykitSdkService.clearPrivatePaymentList(publicKey, receiverPath) - ?: return@runSuspendCatching false - if (report.failedToQueue.isNotEmpty() || report.failedToDeliver.isNotEmpty()) { - throw PrivatePaykitError.PrivateUnavailable - } - true - }.onSuccess { - if (it) clearedRetryKeys += PrivateMessageDrainRetryKey(publicKey, receiverPath) - }.onFailure { - failedPublicKeys += publicKey - firstError = firstError ?: it + if (publicKey !in linkedPublicKeys && + state?.contacts?.get(publicKey)?.hasPublishedPrivatePaymentList != true + ) { + return@forEach + } + runSuspendCatching { + val report = paykitSdkService.clearPrivatePaymentList(publicKey) + ?: return@runSuspendCatching false + if (report.failedToQueue.isNotEmpty() || report.failedToDeliver.isNotEmpty()) { + throw PrivatePaykitError.PrivateUnavailable } + true + }.onSuccess { + if (it) clearedRetryKeys += publicKey + }.onFailure { + failedPublicKeys += publicKey + firstError = firstError ?: it } } - return PrivateEndpointCleanupPreparation(clearedRetryKeys, failedPublicKeys, firstError) } @@ -1453,8 +1337,8 @@ class PrivatePaykitRepo @Inject constructor( publicKeys.forEach { publicKey -> state?.contacts?.get(publicKey)?.let { contactState -> contactState.remoteEndpoints = emptyList() - contactState.localInvoicesByReceiverPath = emptyMap() - contactState.publishedPrivatePaymentReceiverPaths = emptySet() + contactState.localInvoice = null + contactState.hasPublishedPrivatePaymentList = false if (!contactState.hasCacheState) { state?.contacts?.remove(publicKey) } @@ -1484,42 +1368,11 @@ class PrivatePaykitRepo @Inject constructor( val contactState = state?.contacts?.get(publicKey) return PublishedEndpointCleanupState( remoteEndpoints = contactState?.remoteEndpoints.orEmpty(), - localInvoicesByReceiverPath = contactState?.localInvoicesByReceiverPath.orEmpty(), - publishedPrivatePaymentReceiverPaths = contactState?.publishedPrivatePaymentReceiverPaths.orEmpty(), + localInvoice = contactState?.localInvoice, + hasPublishedPrivatePaymentList = contactState?.hasPublishedPrivatePaymentList == true, ) } - private suspend fun receiverPathsForSavedContact(publicKey: String): List { - val record = paykitSdkService.contactRecord(publicKey) - val savedPaths = supportedReceiverPaths(record?.receiverPaths.orEmpty()) - - return runSuspendCatching { - val discoveredPaths = pubkyService.discoverRelevantReceiverPaths(publicKey) - val currentRecord = paykitSdkService.contactRecord(publicKey) - ?: return@runSuspendCatching savedPaths - val currentSavedPaths = supportedReceiverPaths(currentRecord.receiverPaths) - val mergedPaths = supportedReceiverPaths(currentSavedPaths + discoveredPaths) - if (mergedPaths == currentSavedPaths) return@runSuspendCatching currentSavedPaths - - val updatedRecord = pubkyService.saveContact(publicKey, currentRecord.label, mergedPaths) - _initialLinkBurstStarted.tryEmit(Unit) - Logger.info("Discovered new Paykit receiver paths for '${redacted(publicKey)}'", context = TAG) - supportedReceiverPaths(updatedRecord.receiverPaths) - }.getOrElse { - if (it is CancellationException) throw it - Logger.warn( - "Failed to refresh Paykit receiver paths for '${redacted(publicKey)}'; using saved paths", - it, - context = TAG, - ) - savedPaths - } - } - - private fun supportedReceiverPaths(receiverPaths: Collection): List = - PaykitReceiverPaths.supported.filter { it in receiverPaths } - .ifEmpty { listOf(PaykitReceiverPaths.WALLET) } - private suspend fun refreshSavedContactEndpointsDuringInitialLinkBurst( publicKeys: Collection, reason: String, @@ -1541,57 +1394,6 @@ class PrivatePaykitRepo @Inject constructor( } } - private fun receiverPathsForPrivateEndpointCleanup( - publicKey: String, - excludedReceiverPaths: List, - linkedReceiverPaths: Collection, - ): List { - val publishedPaths = publishedPrivatePaymentReceiverPaths(publicKey) - return (publishedPaths + linkedReceiverPaths) - .filter { it in PaykitReceiverPaths.supported } - .filterNot { it in excludedReceiverPaths } - .distinct() - .sorted() - } - - private fun receiverPathsForCleanup( - publicKey: String, - linkedReceiverPaths: Collection, - ): List { - return (linkedReceiverPaths + publishedPrivatePaymentReceiverPaths(publicKey)) - .filter { it in PaykitReceiverPaths.supported } - .distinct() - .sorted() - } - - private suspend fun linkedReceiverPathsByPublicKey(): Map> { - val linkedPaths = mutableMapOf>() - paykitSdkService.linkedPeers().forEach { peer -> - val publicKey = normalizedPublicKey(peer.counterparty) ?: return@forEach - if (peer.counterpartyReceiverPath in PaykitReceiverPaths.supported) { - linkedPaths.getOrPut(publicKey, ::mutableSetOf) += peer.counterpartyReceiverPath - } - } - return linkedPaths - } - - private suspend fun linkedReceiverPathsSnapshot(reason: String): LinkedReceiverPathsSnapshot { - repeat(2) { attempt -> - val result = runSuspendCatching { linkedReceiverPathsByPublicKey() } - result.getOrNull()?.let { return LinkedReceiverPathsSnapshot(it, null) } - val error = result.exceptionOrNull() ?: PrivatePaykitError.PrivateUnavailable - val suffix = if (attempt == 0) "; retrying once" else " after retry" - Logger.warn( - "Failed to inspect private Paykit links during '$reason'$suffix", - error, - context = TAG, - ) - if (attempt == 1) return LinkedReceiverPathsSnapshot(emptyMap(), error) - } - - return LinkedReceiverPathsSnapshot(emptyMap(), PrivatePaykitError.PrivateUnavailable) - } - private fun normalizedPublicKeyBatch(publicKeys: Collection): NormalizedPublicKeyBatch { val invalidKeys = mutableSetOf() val normalizedKeys = publicKeys.mapNotNull { publicKey -> @@ -1603,11 +1405,6 @@ class PrivatePaykitRepo @Inject constructor( return NormalizedPublicKeyBatch(normalizedKeys, invalidKeys) } - private fun publishedPrivatePaymentReceiverPaths(publicKey: String): List { - val contactState = state?.contacts?.get(publicKey) ?: return emptyList() - return contactState.publishedPrivatePaymentReceiverPaths.toList() - } - private suspend fun clearUnsavedContactState(savedPublicKeys: Collection): Result = withContext(serializedDispatcher) { runSuspendCatching { @@ -1715,7 +1512,7 @@ class PrivatePaykitRepo @Inject constructor( cacheStore.data.first().cleanupPending private suspend fun hasPublishedPrivateEndpoints(): Boolean = - ensureState().contacts.values.any { it.publishedPrivatePaymentReceiverPaths.isNotEmpty() } + ensureState().contacts.values.any { it.hasPublishedPrivatePaymentList } private suspend fun updateContactSharingCleanupPending(isPending: Boolean) { cacheStore.update { it.copy(cleanupPending = isPending) } @@ -1766,7 +1563,7 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun settledPrivateInvoicePaymentHashes(): List { val settled = receivedSettledPaymentHashes() return ensureState().contacts.values - .flatMap { it.localInvoices() } + .mapNotNull { it.localInvoice } .map { it.paymentHash } .filter(settled::contains) } @@ -1815,18 +1612,14 @@ class PrivatePaykitRepo @Inject constructor( persistState() } - private fun localInvoice(publicKey: String, receiverPath: String): StoredInvoice? { + private fun localInvoice(publicKey: String): StoredInvoice? { val contactState = state?.contacts?.get(publicKey) ?: return null - return contactState.localInvoicesByReceiverPath[receiverPath] + return contactState.localInvoice } - private suspend fun setLocalInvoice(publicKey: String, receiverPath: String, invoice: StoredInvoice) { + private suspend fun setLocalInvoice(publicKey: String, invoice: StoredInvoice) { val contactState = ensureState().contacts.getOrPut(publicKey) { ContactState() } - contactState.localInvoicesByReceiverPath = contactState.localInvoicesByReceiverPath + (receiverPath to invoice) - } - - private fun ContactState.localInvoices(): List { - return localInvoicesByReceiverPath.values.toList() + contactState.localInvoice = invoice } private fun rememberSavedContacts(publicKeys: Collection, replacing: Boolean): List { diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index bfc254b8e7..efef728bbf 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -3,8 +3,8 @@ package to.bitkit.repositories import android.graphics.Bitmap import android.graphics.BitmapFactory import coil3.ImageLoader -import com.synonym.paykit.ContactProfileResolution import com.synonym.paykit.PaykitProfile +import com.synonym.paykit.ProfileResolution import com.synonym.paykit.PubkyAuthCompanionClaim import io.ktor.client.HttpClient import io.ktor.client.call.body @@ -46,13 +46,13 @@ import to.bitkit.ext.runSuspendCatching import to.bitkit.models.HomegateResponse import to.bitkit.models.PubkyAuthClaim import to.bitkit.models.PubkyAuthRequest +import to.bitkit.models.PubkyAuthRequestError import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyProfileData import to.bitkit.models.PubkyProfileLink import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.PubkySessionBackupKind import to.bitkit.models.PubkySessionBackupV1 -import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PubkyService import to.bitkit.utils.AppError import to.bitkit.utils.Logger @@ -883,7 +883,6 @@ class PubkyRepo @Inject constructor( pubkyService.saveContact( prefixedKey, profile.name, - relevantReceiverPaths(prefixedKey), restorePrivateConnection = true, ) updateContacts { current -> @@ -895,16 +894,6 @@ class PubkyRepo @Inject constructor( } } - suspend fun refreshContactReceiverPaths(publicKey: String): Result = runSuspendCatching { - withContext(ioDispatcher) { - val prefixedKey = requireAddableContactPublicKey(publicKey = publicKey, allowExisting = true) - val contact = _contacts.value.firstOrNull { PubkyPublicKeyFormat.matches(it.publicKey, prefixedKey) } - ?: return@withContext - pubkyService.saveContact(prefixedKey, contact.name, relevantReceiverPaths(prefixedKey)) - Logger.info("Refreshed contact receiver paths for '${redacted(prefixedKey)}'", context = TAG) - } - } - @Suppress("LongParameterList") suspend fun updateContact( publicKey: String, @@ -962,7 +951,6 @@ class PubkyRepo @Inject constructor( pubkyService.saveContact( prefixedKey, profile.name, - relevantReceiverPaths(prefixedKey), restorePrivateConnection = true, ) profile @@ -1121,17 +1109,21 @@ class PubkyRepo @Inject constructor( unsignedPayload: ByteArray, ): Result = runSuspendCatching { withContext(ioDispatcher) { + val claim = PubkyAuthRequest.parseBitkitClaim( + authUrl, + PubkyAuthClaim.REQUIRED_CAPABILITIES, + ).getOrThrow() ?: throw PubkyAuthRequestError.MissingBitkitClaim val secretKeyHex = requireNotNull(activeSecretKeyHex()) { "No secret key available — use Ring to manage authorizations" } pubkyService.approveAuthWithCompanionClaim( authUrl = authUrl, - expectedCapabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES, + expectedCapabilities = PubkyAuthClaim.REQUIRED_CAPABILITIES, approvedClientId = approvedClientId, secretKeyHex = secretKeyHex, claim = PubkyAuthCompanionClaim( queryParameter = PubkyAuthClaim.QUERY_PARAMETER, - claimType = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue, + claimType = claim.wireValue, unsignedPayload = unsignedPayload, ), ) @@ -1335,7 +1327,7 @@ class PubkyRepo @Inject constructor( } } - private fun profileFromResolution(resolution: ContactProfileResolution): PubkyProfile { + private fun profileFromResolution(resolution: ProfileResolution): PubkyProfile { val prefixedKey = resolution.publicKey.ensurePubkyPrefix() resolution.paykitProfile?.let { return PubkyProfile.fromPaykitProfile(prefixedKey, it) @@ -1350,14 +1342,6 @@ class PubkyRepo @Inject constructor( ) } - private suspend fun relevantReceiverPaths(publicKey: String): List = - runSuspendCatching { - pubkyService.discoverRelevantReceiverPaths(publicKey) - }.onFailure { - Logger.warn("Failed to discover Paykit receivers for '${redacted(publicKey)}'", it, context = TAG) - }.getOrNull() - ?: listOf(PaykitReceiverPaths.WALLET) - private suspend fun upsertContactProfileOverride(profile: PubkyProfile) { val prefixedKey = profile.publicKey.ensurePubkyPrefix() val ownerPublicKey = requireNotNull(_publicKey.value) { "Pubky identity unavailable" } @@ -1502,20 +1486,11 @@ class PubkyRepo @Inject constructor( settingsStore.setPubkyProfileSetupPending(false) } - private fun requireAddableContactPublicKey(publicKey: String, allowExisting: Boolean = false): String { - val prefixedKey = PubkyPublicKeyFormat.normalized(publicKey) - return requireValidAddableContactPublicKey(prefixedKey, allowExisting) - } - private fun requireCanonicalAddableContactPublicKey( publicKey: String, allowExisting: Boolean = false, ): String { val prefixedKey = PubkyPublicKeyFormat.canonicalized(publicKey) - return requireValidAddableContactPublicKey(prefixedKey, allowExisting) - } - - private fun requireValidAddableContactPublicKey(prefixedKey: String?, allowExisting: Boolean): String { contactValidationError(prefixedKey, allowExisting)?.let { throw it } return checkNotNull(prefixedKey) { "Normalized pubky key is required" } } diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index 342ab08650..60a1df90fb 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.lightningdevkit.ldknode.Bolt11Invoice import org.lightningdevkit.ldknode.Network +import to.bitkit.data.PrivatePaykitCacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher @@ -19,7 +20,6 @@ import to.bitkit.ext.toHex import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.toLdkNetwork import to.bitkit.services.CoreService -import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PaykitSdkService import to.bitkit.utils.AppError import to.bitkit.utils.NetworkValidationHelper @@ -84,8 +84,8 @@ internal val PublicPaykitPaymentResult.incomingPaymentRequestFailureReason: } data class PrivatePaykitPaymentContext( - val receiverPath: String, - val paymentListVersion: ULong, + val paymentAppsByEndpoint: Map, + val paymentListVersion: ULong?, ) @OptIn(ExperimentalTime::class) @@ -99,6 +99,7 @@ class PublicPaykitRepo @Inject constructor( private val coreService: CoreService, private val paykitSdkService: PaykitSdkService, private val settingsStore: SettingsStore, + private val privatePaykitCacheStore: PrivatePaykitCacheStore, private val clock: Clock, ) { companion object { @@ -212,18 +213,18 @@ class PublicPaykitRepo @Inject constructor( runSuspendCatching { if (!publish) { val endpointError = runSuspendCatching { removePublishedEndpoints() }.exceptionOrNull() - val markerError = syncLocalReceiverMarker(publicSharingEnabled = false).exceptionOrNull() + val appError = syncPaykitApp().exceptionOrNull() if (endpointError != null) { - markerError?.let(endpointError::addSuppressed) + appError?.let(endpointError::addSuppressed) throw endpointError } - markerError?.let { throw it } + appError?.let { throw it } settingsStore.update { it.copy(publicPaykitCleanupPending = false) } return@runSuspendCatching } val desired = buildWalletEndpoints(refresh = true) - syncLocalReceiverMarker(publicSharingEnabled = true).getOrThrow() + syncPaykitApp().getOrThrow() applyPublishedEndpoints(desired) settingsStore.update { it.copy(publicPaykitCleanupPending = false) } } @@ -239,7 +240,7 @@ class PublicPaykitRepo @Inject constructor( forceRefreshLightning = forceRefreshLightning, requireEndpoint = requireEndpoint, ) - syncLocalReceiverMarker(publicSharingEnabled = true).getOrThrow() + syncPaykitApp().getOrThrow() applyPublishedEndpoints(desired) settingsStore.update { it.copy(publicPaykitCleanupPending = false) } } @@ -262,25 +263,22 @@ class PublicPaykitRepo @Inject constructor( val normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) ?: publicKey paykitSdkService.resolvePublicContactPayment( counterparty = normalizedKey, - receiverPath = PaykitReceiverPaths.WALLET, ).payableEndpoints - .mapNotNull { parseEndpoint(it.identifier, it.payload) } - .associateBy { it.methodId } - .values + .mapNotNull { parseEndpoint(it.identifier, it.payload)?.copy(appId = it.appId) } .sortedBy { endpoint -> payablePreferenceOrder.indexOf(endpoint.methodId) } } } - suspend fun syncLocalReceiverMarker( - publicSharingEnabled: Boolean? = null, + suspend fun syncPaykitApp( privateSharingEnabled: Boolean? = null, ): Result = withContext(ioDispatcher) { runSuspendCatching { val settings = settingsStore.data.first() - val publicSharing = publicSharingEnabled ?: settings.sharesPublicPaykitEndpoints val privateSharing = privateSharingEnabled ?: settings.sharesPrivatePaykitEndpoints - paykitSdkService.syncLocalReceiverMarker( - isDiscoverable = publicSharing || privateSharing, + val privateCache = privatePaykitCacheStore.data.first() + paykitSdkService.syncPaykitApp( + privatePaymentsEnabled = privateSharing || privateCache.cleanupPending || + privateCache.deletedContactCleanupPendingPublicKeys.isNotEmpty(), ) } } @@ -447,6 +445,7 @@ data class Endpoint( val min: String? = null, val max: String? = null, val rawPayload: String, + val appId: String? = null, ) { val paymentRequest: String get() = value diff --git a/app/src/main/java/to/bitkit/repositories/WatchOnlyAccountRepo.kt b/app/src/main/java/to/bitkit/repositories/WatchOnlyAccountRepo.kt index 671dfc25ab..36bdc5b643 100644 --- a/app/src/main/java/to/bitkit/repositories/WatchOnlyAccountRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WatchOnlyAccountRepo.kt @@ -15,6 +15,7 @@ import to.bitkit.ext.nowMillis import to.bitkit.ext.runSuspendCatching import to.bitkit.models.PreparedWatchOnlyAccountClaim import to.bitkit.models.PubkyAuthClaim +import to.bitkit.models.PubkyAuthClaimCodec import to.bitkit.models.WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX import to.bitkit.models.WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE import to.bitkit.models.WATCH_ONLY_ACCOUNT_SERIALIZED_XPUB_LENGTH @@ -328,7 +329,7 @@ object WatchOnlyAccountClaimCodec { const val VERSION: Byte = 1 const val NATIVE_SEGWIT_ADDRESS_TYPE: Byte = 0 const val SERIALIZED_XPUB_LENGTH = WATCH_ONLY_ACCOUNT_SERIALIZED_XPUB_LENGTH - const val PAYLOAD_LENGTH = 1 + 4 + 1 + SERIALIZED_XPUB_LENGTH + const val PAYLOAD_LENGTH = PubkyAuthClaimCodec.WATCH_ONLY_PAYLOAD_LENGTH fun encode( account: WatchOnlyAccountRecord, diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 9d4ab907c8..bc798bdc73 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -100,6 +100,7 @@ import to.bitkit.models.msatFloorOf import to.bitkit.models.toAddressType import to.bitkit.models.toCoreNetwork import to.bitkit.models.toSettingsString +import to.bitkit.repositories.PaykitReceivedPaymentContacts import to.bitkit.repositories.PrivatePaykitContactResolver import to.bitkit.utils.AppError import to.bitkit.utils.Logger @@ -567,6 +568,53 @@ class ActivityService( updateActivity(activityId = id, activity = activity) } + suspend fun backfillPaykitContacts(): Boolean = ServiceQueue.CORE.background { + val resolver = privatePaykitContactResolver.get() + val contacts = resolver.receivedPaymentContacts + if (contacts === PaykitReceivedPaymentContacts.Empty) return@background false + val activities = getActivities( + walletId = null, + filter = ActivityFilter.ALL, + txType = PaymentType.RECEIVED, + tags = null, + search = null, + minDate = null, + maxDate = null, + limit = null, + sortDirection = null, + ) + var changed = false + for (activity in activities) { + if (resolver.receivedPaymentContacts !== contacts) break + val contact = when (activity) { + is Activity.Lightning -> receivedPaykitContact(activity.v1, contacts) + is Activity.Onchain -> receivedPaykitContact(activity.v1, contacts) + } + if (contact != null && resolver.receivedPaymentContacts === contacts) { + val updated = when (activity) { + is Activity.Lightning -> Activity.Lightning(activity.v1.copy(contact = contact)) + is Activity.Onchain -> Activity.Onchain(activity.v1.copy(contact = contact)) + } + updateActivity(activityId = activity.rawId(), activity = updated) + changed = true + } + } + changed + } + + private fun receivedPaykitContact(row: LightningActivity, contacts: PaykitReceivedPaymentContacts): String? { + if (row.contact != null || row.txType != PaymentType.RECEIVED || row.status == PaymentState.FAILED) return null + return contacts.contactsForPaymentHash(row.id).singleOrNull() + } + + private fun receivedPaykitContact(row: OnchainActivity, contacts: PaykitReceivedPaymentContacts): String? { + if (row.contact != null || row.txType != PaymentType.RECEIVED) return null + val details = getBitkitCoreTransactionDetails(walletId = row.walletId, txId = row.txId) ?: return null + if (details.outputs.isEmpty()) return null + val addresses = listOfNotNull(row.address) + details.outputs.mapNotNull { it.scriptpubkeyAddress } + return contacts.contactsForAddresses(addresses).singleOrNull() + } + suspend fun delete(id: String, walletId: String = defaultWalletId): Boolean = ServiceQueue.CORE.background { deleteActivityById(walletId = walletId, activityId = id) } @@ -784,7 +832,9 @@ class ActivityService( val contact = existingActivity ?.takeIf { it is Activity.Lightning } ?.let { (it as Activity.Lightning).v1.contact } - ?: privatePaykitContactPublicKeyForReceivedInvoicePaymentHash(payment.id, payment.direction) + ?: payment.takeUnless { it.status == PaymentStatus.FAILED }?.let { + privatePaykitContactPublicKeyForReceivedInvoicePaymentHash(it.id, it.direction) + } val ln = if (existingActivity is Activity.Lightning) { existingActivity.v1.withPaymentUpdate( @@ -1202,8 +1252,14 @@ class ActivityService( val resolvedAddress = resolveAddressForInboundPayment(kind, payment, transactionDetails) val existingContact = existingOnchainActivity?.v1?.contact - val contact = existingContact ?: if (payment.direction == PaymentDirection.INBOUND) { - resolvedAddress?.let { privatePaykitContactPublicKeyForReservedAddress(it) } + val contact = existingContact ?: if ( + payment.direction == PaymentDirection.INBOUND && payment.status != PaymentStatus.FAILED && + !transactionDetails?.outputs.isNullOrEmpty() + ) { + privatePaykitContactResolver.get().contactPublicKeyForPrivateOnchainAddresses( + listOfNotNull(resolvedAddress) + + transactionDetails.outputs.mapNotNull { it.scriptpubkeyAddress }, + ) } else { null } diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 59bada6697..5d91bce963 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -1,22 +1,19 @@ package to.bitkit.services import android.content.Context -import com.synonym.bitkitcore.mnemonicToSeed -import com.synonym.paykit.ContactProfileResolution import com.synonym.paykit.ContactRecord import com.synonym.paykit.ContactUpdate -import com.synonym.paykit.CounterpartyReceiver import com.synonym.paykit.EndpointSyncReport import com.synonym.paykit.IdentityStatus import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState import com.synonym.paykit.OutboundPrivateCounterpartySendReport import com.synonym.paykit.PaykitAndroid +import com.synonym.paykit.PaykitAppCapabilities import com.synonym.paykit.PaykitException +import com.synonym.paykit.PaykitIdentitySecretKey import com.synonym.paykit.PaykitProfile import com.synonym.paykit.PaykitProfileRecord -import com.synonym.paykit.PaykitReceiverCapabilities -import com.synonym.paykit.PaykitReceiverMarker import com.synonym.paykit.PaykitSdk import com.synonym.paykit.PaykitSdkDefaults import com.synonym.paykit.PaymentAmountContext @@ -26,6 +23,7 @@ import com.synonym.paykit.PaymentReference import com.synonym.paykit.PaymentRequestAmount import com.synonym.paykit.PaymentRequestFilter import com.synonym.paykit.PaymentRequestLifecycleState +import com.synonym.paykit.PaymentRequestLocalRole import com.synonym.paykit.PaymentRequestRecord import com.synonym.paykit.PaymentRequestRecurrence import com.synonym.paykit.PaymentRequestTerms @@ -43,8 +41,10 @@ import com.synonym.paykit.PrivateReceivingDetail import com.synonym.paykit.PrivateReceivingDetailReservationResponse import com.synonym.paykit.PrivateReceivingDetailReservationResponseKind import com.synonym.paykit.PrivateStreamCounterpartyIntakeReport +import com.synonym.paykit.ProfileResolution import com.synonym.paykit.PubkyAuthCompanionClaim import com.synonym.paykit.PubkyClientConfig +import com.synonym.paykit.PubkyIdentityCapability import com.synonym.paykit.PubkyLocalSecretKey import com.synonym.paykit.PubkyProfile import com.synonym.paykit.PubkySessionAccess @@ -54,16 +54,10 @@ import com.synonym.paykit.PublicContactPaymentResolution import com.synonym.paykit.PublicPaymentEndpointCandidate import com.synonym.paykit.PublicPaymentEndpointSelectionRequest import com.synonym.paykit.PublicReceivingDetail -import com.synonym.paykit.ReceiverNoiseSecretKey import com.synonym.paykit.SdkPaymentAdapter import com.synonym.paykit.SdkPubkySessionProvider -import com.synonym.paykit.SdkStateBlob -import com.synonym.paykit.SdkStateBlobSnapshot -import com.synonym.paykit.SdkStateBlobStore -import com.synonym.paykit.decodeSdkStateBlobSnapshot import com.synonym.paykit.defaultConfig import com.synonym.paykit.defaultPubkyClientConfig -import com.synonym.paykit.encodeSdkStateBlobSnapshot import com.synonym.paykit.parsePubkyAuthUrl import com.synonym.paykit.pubkyPublicKeyFromSecret import com.synonym.paykit.pubkySecretKeyFromBip39Mnemonic @@ -97,6 +91,9 @@ import to.bitkit.ext.fromHex import to.bitkit.ext.nowMillis import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.toHex +import to.bitkit.models.PubkyAuthClaim +import to.bitkit.models.PubkyAuthClaimCodec +import to.bitkit.models.PubkyAuthRequest import to.bitkit.models.PubkyAuthRequestError import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.repositories.Endpoint @@ -106,10 +103,6 @@ import to.bitkit.repositories.PubkyContactError import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.utils.AppError import to.bitkit.utils.Logger -import java.security.MessageDigest -import java.util.UUID -import javax.crypto.Mac -import javax.crypto.spec.SecretKeySpec import javax.inject.Inject import javax.inject.Singleton import kotlin.coroutines.cancellation.CancellationException @@ -134,6 +127,7 @@ data class PaykitPublicContactPaymentResolution( ) data class PaykitResolvedPaymentEndpoint( + val appId: String, val identifier: String, val payload: String, ) @@ -155,22 +149,6 @@ data class PaykitPaymentRequestRecurrenceTerms( val endsAt: String? = null, ) -data class PaykitPrivateReceiverPathSelection( - val linkableReceiverPaths: List, - val publishableReceiverPaths: List, - val cleanupProtectedReceiverPaths: List, - val error: Throwable?, -) - -internal object PaykitReceiverPaths { - const val WALLET = "bitkit/wallet" - const val SERVER = "bitkit/server" - - /** Current Bitkit flows only route its own receivers; cross-wallet routing can broaden this allowlist. */ - val ordered = listOf(WALLET, SERVER) - val supported = ordered.toSet() -} - @Singleton @Suppress("TooManyFunctions", "LargeClass") class PaykitSdkService @Inject constructor( @@ -180,7 +158,6 @@ class PaykitSdkService @Inject constructor( sharedPubky: SharedPubkyClient, @IoDispatcher ioDispatcher: CoroutineDispatcher, ) : BaseCoroutineScope(ioDispatcher, TAG) { - private val stateStore = PaykitSdkStateBlobStore(keychain) private val sessionProvider = PaykitSdkSessionProvider(keychain, sharedPubky) private val paymentAdapter = PaykitSdkPaymentAdapter() private val pubkyClientConfig by lazy { paykitPubkyClientConfig() } @@ -205,8 +182,7 @@ class PaykitSdkService @Inject constructor( private val _backupStateVersion = MutableStateFlow(0L) val backupStateVersion: StateFlow = _backupStateVersion.asStateFlow() private var sdkFactory: () -> PaykitSdk = { - PaykitSdk.withPaymentAdapterAndPubkyClientConfig( - stateStore = stateStore, + PaykitSdk.withPaymentAdapterAndPubkySharedStateAndClientConfig( sessionProvider = sessionProvider, paymentAdapter = paymentAdapter, config = paykitSdkConfig(), @@ -247,6 +223,7 @@ class PaykitSdkService @Inject constructor( platformInitializer() launch { republishIdentityIfNeeded() } operationLock.withLock { + refreshPaykitKey() var handle = handle() try { handle.initialize() @@ -267,7 +244,7 @@ class PaykitSdkService @Inject constructor( sessionProvider.resumeStoredSessionAccess() } } - publishReceiverMarkerIfLiveSessionAvailable(handle) + publishAppIfLiveSessionAvailable(handle) } isSetup.complete(Unit) } catch (t: Throwable) { @@ -324,33 +301,32 @@ class PaykitSdkService @Inject constructor( suspend fun currentPublicKey(): String? { isSetup.await() return operationLock.withLock { + refreshPaykitKey() val handle = handle() handle.identityStatus()?.publicKey?.let { return@withLock it } - handle.initialize().identity.publicKey + handle.initialize().publicKey } } suspend fun hasPrivatePaymentAccess(): Boolean { isSetup.await() return operationLock.withLock { - handle().identityStatus()?.liveSessionAvailable == true + refreshPaykitKey() + handle().identityStatus()?.capability == PubkyIdentityCapability.PRIVATE_LINK_CAPABLE } } suspend fun importSession(secret: String): PubkySessionBootstrapResult { isSetup.await() return operationLock.withLock { - val previousPublicKey = currentSdkStatePublicKeyLocked() val result = bootstrap().importSession( sessionSecret = secret, localSecretKey = sessionProvider.loadLocalSecretKey(), - receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey(), - requiredCapabilities = requiredSessionCapabilities(paykitSdkConfig()), + requiredCapabilities = requiredSessionCapabilities(), ) activateBootstrapResult( result = result, - previousPublicKey = previousPublicKey, ) notifyBackupStateChanged() @@ -365,10 +341,8 @@ class PaykitSdkService @Inject constructor( ): PubkySessionBootstrapResult { isSetup.await() return operationLock.withLock { - val previousPublicKey = currentSdkStatePublicKeyLocked() val result = bootstrap().signUp( localSecretKey = localSecretKey(secretKeyHex), - receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey(), homeserverPublicKey = homeserverPublicKey, signupCode = signupCode, requiredCapabilities = requiredCapabilities(), @@ -376,7 +350,6 @@ class PaykitSdkService @Inject constructor( activateBootstrapResult( result = result, - previousPublicKey = previousPublicKey, ) notifyBackupStateChanged() @@ -393,7 +366,6 @@ class PaykitSdkService @Inject constructor( return operationLock.withLock { bootstrap().signUp( localSecretKey = localSecretKey(secretKeyHex), - receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey(), homeserverPublicKey = homeserverPublicKey, signupCode = signupCode, requiredCapabilities = requiredCapabilities(), @@ -404,13 +376,10 @@ class PaykitSdkService @Inject constructor( suspend fun activateRegisteredIdentity(result: PubkySessionBootstrapResult) { isSetup.await() return operationLock.withLock { - val previousPublicKey = currentSdkStatePublicKeyLocked() - var activated = false try { activateBootstrapResult( result = result, - previousPublicKey = previousPublicKey, ) activated = true } finally { @@ -424,16 +393,13 @@ class PaykitSdkService @Inject constructor( suspend fun signIn(secretKeyHex: String): PubkySessionBootstrapResult { isSetup.await() return operationLock.withLock { - val previousPublicKey = currentSdkStatePublicKeyLocked() val result = bootstrap().signIn( localSecretKey = localSecretKey(secretKeyHex), - receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey(), requiredCapabilities = requiredCapabilities(), ) activateBootstrapResult( result = result, - previousPublicKey = previousPublicKey, ) notifyBackupStateChanged() @@ -466,11 +432,27 @@ class PaykitSdkService @Inject constructor( ) { isSetup.await() return operationLock.withLock { + val requestedClaim = PubkyAuthRequest.parseBitkitClaim(authUrl, expectedCapabilities).getOrThrow() + ?: throw PubkyAuthRequestError.MissingBitkitClaim + require( + claim.queryParameter == PubkyAuthClaim.QUERY_PARAMETER && claim.claimType == requestedClaim.wireValue + ) { + "Companion claim does not match the authorization request" + } + PubkyAuthClaimCodec.validateAccountPayload(requestedClaim, claim.unsignedPayload) + val paykitKey = if (requestedClaim.includesPaykitAccess) paykitKey(localSecretKey(secretKeyHex)) else null approvalBootstrap(authUrl, approvedClientId).approveAuthWithCompanionClaim( authUrl = authUrl, expectedCapabilities = expectedCapabilities, localSecretKey = localSecretKey(secretKeyHex), - claim = claim, + claim = claim.copy( + unsignedPayload = PubkyAuthClaimCodec.encode( + claim = requestedClaim, + accountPayload = claim.unsignedPayload, + generation = paykitKey?.keyGeneration(), + secret = paykitKey?.exportBytes(), + ), + ), ) } } @@ -485,7 +467,11 @@ class PaykitSdkService @Inject constructor( suspend fun publishPaykitProfile(profile: PaykitProfile): PaykitProfileRecord { isSetup.await() return operationLock.withLock { - handle().publishPaykitProfile(profile).also { + refreshPaykitKey() + val handle = handle() + val publicKey = requireNotNull(handle.identityStatus()?.publicKey) + val current = handle.fetchPaykitProfile(publicKey) + handle.publishPaykitProfile(profile, current?.revision).also { notifyBackupStateChanged() } } @@ -497,7 +483,7 @@ class PaykitSdkService @Inject constructor( if (expectedIdentity != null) { val identityStatus = handle().identityStatus() check( - identityStatus?.liveSessionAvailable == true && + identityStatus?.capability == PubkyIdentityCapability.PRIVATE_LINK_CAPABLE && PubkyPublicKeyFormat.matches(identityStatus.publicKey, expectedIdentity) ) { "Paykit identity changed before uploading the subscription icon" } } @@ -510,7 +496,10 @@ class PaykitSdkService @Inject constructor( suspend fun deletePaykitProfile() { isSetup.await() operationLock.withLock { - handle().deletePaykitProfile() + refreshPaykitKey() + val handle = handle() + val publicKey = requireNotNull(handle.identityStatus()?.publicKey) + handle.fetchPaykitProfile(publicKey)?.let { handle.deletePaykitProfile(it.revision) } notifyBackupStateChanged() } } @@ -525,13 +514,14 @@ class PaykitSdkService @Inject constructor( suspend fun fetchPubkyFollows(publicKey: String): List { isSetup.await() return operationLock.withLock { - handle().fetchPubkyFollows(publicKey) + handle().fetchPubkyFollows(publicKey, maxEntries = 1000u) } } suspend fun contactRecords(): List { isSetup.await() return operationLock.withLock { + refreshPaykitKey() handle().contactRecords() } } @@ -539,6 +529,7 @@ class PaykitSdkService @Inject constructor( suspend fun contactRecord(publicKey: String): ContactRecord? { isSetup.await() return operationLock.withLock { + refreshPaykitKey() handle().contactRecord(publicKey) } } @@ -546,7 +537,6 @@ class PaykitSdkService @Inject constructor( suspend fun saveContact( publicKey: String, label: String?, - receiverPaths: List? = null, restorePrivateConnection: Boolean = false, ): ContactRecord { isSetup.await() @@ -554,9 +544,7 @@ class PaykitSdkService @Inject constructor( withStateRevisionTracking { handle -> val existing = handle.contactRecord(publicKey) check(restorePrivateConnection || existing != null) { "Contact no longer exists" } - val existingPaths = existing?.receiverPaths.orEmpty() - val contactPaths = mergedReceiverPaths(existingPaths + receiverPaths.orEmpty()) - val update = ContactUpdate(publicKey, contactPaths, label) + val update = ContactUpdate(publicKey, label) if (!restorePrivateConnection) return@withStateRevisionTracking handle.saveContact(update) val blockedPeers = handle.linkedPeers().filter { it.state == LinkedPeerState.BLOCKED && PubkyPublicKeyFormat.matches(it.counterparty, publicKey) @@ -570,12 +558,9 @@ class PaykitSdkService @Inject constructor( isSetup.await() return operationLock.withLock { withStateRevisionTracking { handle -> - val record = handle.contactRecord(publicKey) val peers = handle.linkedPeers().filter { PubkyPublicKeyFormat.matches(it.counterparty, publicKey) } - val receiverPaths = - (record?.receiverPaths.orEmpty() + peers.map { it.counterpartyReceiverPath }).distinct() val now = nowMillis() val hasActiveSubscription = handle.paymentRequests().any { val endsAt = it.terms?.recurrence?.endsAt?.let { timestamp -> @@ -590,7 +575,6 @@ class PaykitSdkService @Inject constructor( runSuspendCatching { val report = handle.clearPrivatePaymentListAndProcessOutbound( publicKey, - peer.counterpartyReceiverPath, ) if (report.failedToQueue.isNotEmpty() || report.failedToDeliver.isNotEmpty()) { Logger.warn("Failed to withdraw private endpoints before contact deletion", context = TAG) @@ -599,7 +583,7 @@ class PaykitSdkService @Inject constructor( Logger.warn("Failed to withdraw private endpoints before contact deletion", it, context = TAG) } } - receiverPaths.forEach { handle.blockPeer(publicKey, it) } + peers.forEach { handle.blockPeer(publicKey) } handle.removeContact(publicKey) } } @@ -608,76 +592,24 @@ class PaykitSdkService @Inject constructor( suspend fun resolveContactProfile( publicKey: String, allowPubkyProfileFallback: Boolean, - ): ContactProfileResolution? { - isSetup.await() - return operationLock.withLock { - handle().resolveContactProfile(publicKey, PaykitReceiverPaths.WALLET, allowPubkyProfileFallback) - } - } - - suspend fun discoverRelevantReceiverPaths(publicKey: String): List { - isSetup.await() - return operationLock.withLock { - val handle = handle() - val discovered = handle.paykitReceiverPaths(publicKey) - .filter { it in PaykitReceiverPaths.supported } - .filter { - it == PaykitReceiverPaths.WALLET || - handle.paykitReceiverMarker(publicKey, it)?.requiresPrivateLink() == true - } - mergedReceiverPaths(discovered) - } - } - - suspend fun privateReceiverPathSelection( - publicKey: String, - savedReceiverPaths: List, - ): PaykitPrivateReceiverPathSelection { + ): ProfileResolution? { isSetup.await() return operationLock.withLock { - val handle = handle() - val linkable = mutableListOf() - val publishable = mutableListOf() - val cleanupProtected = mutableListOf() - var firstError: Throwable? = null - - mergedReceiverPaths(savedReceiverPaths).forEach { receiverPath -> - runSuspendCatching { handle.paykitReceiverMarker(publicKey, receiverPath) } - .onSuccess { marker -> - if (marker?.requiresPrivateLink() == true) { - linkable += receiverPath - } - if (marker.canReceivePrivatePaymentDetails()) { - publishable += receiverPath - } - } - .onFailure { - cleanupProtected += receiverPath - firstError = firstError ?: it - } - } - - PaykitPrivateReceiverPathSelection( - linkableReceiverPaths = linkable, - publishableReceiverPaths = publishable, - cleanupProtectedReceiverPaths = cleanupProtected, - error = firstError, - ) + handle().resolveProfile(publicKey, allowPubkyProfileFallback) } } - suspend fun syncLocalReceiverMarker( - isDiscoverable: Boolean, - ) { + suspend fun syncPaykitApp(privatePaymentsEnabled: Boolean) { isSetup.await() operationLock.withLock { withStateRevisionTracking { handle -> - if (!isDiscoverable) { - handle.removePaykitReceiverMarker() - return@withStateRevisionTracking - } - - handle.publishPaykitReceiverMarker(receiverCapabilities(handle)) + val capabilities = appCapabilities(handle) + handle.publishPaykitApp( + displayName = "Bitkit", + capabilities = capabilities.copy( + privatePayments = capabilities.privatePayments && privatePaymentsEnabled + ), + ) } } } @@ -691,7 +623,7 @@ class PaykitSdkService @Inject constructor( } } - fun requiredCapabilities(): String = requiredSessionCapabilities(paykitSdkConfig()) + fun requiredCapabilities(): String = requiredSessionCapabilities() suspend fun syncPrivatePaymentListsWithReservations( updates: List, @@ -710,20 +642,18 @@ class PaykitSdkService @Inject constructor( suspend fun ensureLinkWithPeer( counterparty: String, - receiverPath: String, maxAdvanceSteps: UInt = 8u, ) = run { isSetup.await() operationLock.withLock { withStateRevisionTracking { handle -> - handle.ensureLinkWithPeer(counterparty, receiverPath, maxAdvanceSteps) + handle.ensureLinkWithPeer(counterparty, maxAdvanceSteps) } } } suspend fun clearPrivatePaymentList( counterparty: String, - receiverPath: String, ): PrivatePaymentListDeliveryReport? { isSetup.await() return operationLock.withLock { @@ -731,13 +661,12 @@ class PaykitSdkService @Inject constructor( if ( handle.linkedPeers().any { it.state == LinkedPeerState.BLOCKED && - PubkyPublicKeyFormat.matches(it.counterparty, counterparty) && - it.counterpartyReceiverPath == receiverPath + PubkyPublicKeyFormat.matches(it.counterparty, counterparty) } ) { return@withStateRevisionTracking null } - handle.clearPrivatePaymentListAndProcessOutbound(counterparty, receiverPath) + handle.clearPrivatePaymentListAndProcessOutbound(counterparty) } } } @@ -760,13 +689,21 @@ class PaykitSdkService @Inject constructor( } } - suspend fun paymentRequests(): List { + suspend fun paymentRequests(): List = allPaymentRequests().filter(::isBitkitPaymentRequest) + + suspend fun allPaymentRequests(expectedIdentity: String? = null): List { isSetup.await() return operationLock.withLock { - handle().listPaymentRequests( + refreshPaykitKey() + val handle = handle() + if (expectedIdentity != null) { + check(PubkyPublicKeyFormat.matches(handle.identityStatus()?.publicKey, expectedIdentity)) { + "Payment Request identity changed" + } + } + handle.listPaymentRequests( PaymentRequestFilter( counterparty = null, - counterpartyReceiverPath = null, localRole = null, states = emptyList(), recurring = null, @@ -779,23 +716,22 @@ class PaykitSdkService @Inject constructor( suspend fun identityStatus(): IdentityStatus? { isSetup.await() return operationLock.withLock { + refreshPaykitKey() handle().identityStatus() } } - suspend fun paymentRequestReceiverPaths(publicKey: String): List { + suspend fun canReceivePaymentRequests(publicKey: String): Boolean { isSetup.await() return operationLock.withLock { - val handle = handle() - handle.paykitReceiverPaths(publicKey) - .filter { it in PaykitReceiverPaths.supported } - .filter { handle.paykitReceiverMarker(publicKey, it)?.capabilities?.paymentRequests == true } + handle().paykitAppRegistry(publicKey)?.apps?.any { + it.capabilities.paymentRequests && it.capabilities.outgoingPayments + } == true } } suspend fun proposePaymentRequest( counterparty: String, - counterpartyReceiverPath: String, proposal: PaykitPaymentRequestProposalTerms, expectedIdentity: String, ): PaymentRequestRecord { @@ -804,7 +740,7 @@ class PaykitSdkService @Inject constructor( withStateRevisionTracking { handle -> val identityStatus = handle.identityStatus() check( - identityStatus?.liveSessionAvailable == true && + identityStatus?.capability == PubkyIdentityCapability.PRIVATE_LINK_CAPABLE && PubkyPublicKeyFormat.matches(identityStatus.publicKey, expectedIdentity) ) { "Paykit identity changed before proposing the payment request" } val terms = PaymentRequestTerms( @@ -821,33 +757,42 @@ class PaykitSdkService @Inject constructor( ) }, acceptedPaymentEndpointIdentifiers = proposal.acceptedPaymentEndpointIdentifiers, + paymentEndpoints = null, + requiredAppId = "bitkit", conversion = null, paymentDeadline = null, metadata = PrivateJsonObject(proposal.metadataJson), ) - handle.proposePaymentRequest(counterparty, counterpartyReceiverPath, terms) + handle.proposePaymentRequest(counterparty, terms) } } } suspend fun acceptPaymentRequest( counterparty: String, - counterpartyReceiverPath: String, paymentRequestId: String, ): PaymentRequestRecord { isSetup.await() return operationLock.withLock { withStateRevisionTracking { handle -> - handle.acceptPaymentRequest(counterparty, counterpartyReceiverPath, paymentRequestId) + handle.claimPaymentRequestForExecution(counterparty, paymentRequestId) + handle.acceptPaymentRequest(counterparty, paymentRequestId) } } } + suspend fun claimPaymentRequestForExecution(counterparty: String, paymentRequestId: String): PaymentRequestRecord { + isSetup.await() + return operationLock.withLock { + withStateRevisionTracking { it.claimPaymentRequestForExecution(counterparty, paymentRequestId) } + } + } + @Suppress("LongParameterList") suspend fun submitPaymentProof( counterparty: String, - counterpartyReceiverPath: String, paymentRequestId: String, + paymentAppId: String, paymentEndpointIdentifier: String, proofJson: String, billingPeriod: PaykitBillingPeriod? = null, @@ -857,10 +802,10 @@ class PaykitSdkService @Inject constructor( withStateRevisionTracking { handle -> handle.submitPaymentProof( counterparty, - counterpartyReceiverPath, paymentRequestId, PaymentProofSubmission( billingPeriod = billingPeriod?.sdkValue, + paymentAppId = paymentAppId, paymentEndpointIdentifier = paymentEndpointIdentifier, allowanceId = null, conversionQuoteId = null, @@ -873,28 +818,26 @@ class PaykitSdkService @Inject constructor( suspend fun rejectPaymentRequest( counterparty: String, - counterpartyReceiverPath: String, paymentRequestId: String, reason: String? = null, ): PaymentRequestRecord { isSetup.await() return operationLock.withLock { withStateRevisionTracking { handle -> - handle.rejectPaymentRequest(counterparty, counterpartyReceiverPath, paymentRequestId, reason) + handle.rejectPaymentRequest(counterparty, paymentRequestId, reason) } } } suspend fun cancelPaymentRequest( counterparty: String, - counterpartyReceiverPath: String, paymentRequestId: String, reason: String? = null, ): PaymentRequestRecord { isSetup.await() return operationLock.withLock { withStateRevisionTracking { handle -> - handle.cancelPaymentRequest(counterparty, counterpartyReceiverPath, paymentRequestId, reason) + handle.cancelPaymentRequest(counterparty, paymentRequestId, reason) } } } @@ -902,20 +845,21 @@ class PaykitSdkService @Inject constructor( suspend fun linkedPeers(): List { isSetup.await() return operationLock.withLock { + refreshPaykitKey() handle().linkedPeers() } } - suspend fun pendingOutboundPrivateCounterparties(): List { + suspend fun pendingOutboundPrivateCounterparties(): List { isSetup.await() return operationLock.withLock { + refreshPaykitKey() handle().pendingOutboundPrivateCounterparties() } } suspend fun prepareAndResolvePrivateContactPayment( counterparty: String, - receiverPath: String, afterPrivatePaymentListVersion: ULong?, amount: PaymentAmountContext? = null, ): PaykitPreparedPrivateContactPayment { @@ -924,7 +868,6 @@ class PaykitSdkService @Inject constructor( withStateRevisionTracking { handle -> handle.prepareAndResolvePrivateContactPayment( counterparty = counterparty, - counterpartyReceiverPath = receiverPath, amount = amount, afterPrivatePaymentListVersion = afterPrivatePaymentListVersion, maxAdvanceSteps = 8u, @@ -937,13 +880,34 @@ class PaykitSdkService @Inject constructor( ) } + suspend fun prepareAndResolvePrivatePaymentRequest( + counterparty: String, + paymentRequestId: String, + afterPrivatePaymentListVersion: ULong?, + ): PaykitPreparedPrivateContactPayment { + isSetup.await() + val prepared = operationLock.withLock { + withStateRevisionTracking { + it.prepareAndResolvePrivatePaymentRequest( + counterparty, + paymentRequestId, + afterPrivatePaymentListVersion, + 8u, + ) + } + } + return PaykitPreparedPrivateContactPayment( + prepared.resolution.toPaykitPrivateContactPaymentResolution(), + prepared.linkReport?.state, + ) + } + suspend fun resolvePublicContactPayment( counterparty: String, - receiverPath: String, ): PaykitPublicContactPaymentResolution { isSetup.await() val resolution = operationLock.withLock { - handle().resolvePublicContactPayment(counterparty, receiverPath, amount = null) + handle().resolvePublicContactPayment(counterparty, amount = null) } return resolution.toPaykitPublicContactPaymentResolution() } @@ -955,6 +919,7 @@ class PaykitSdkService @Inject constructor( privatePaymentListVersion = privatePaymentListVersion, payableEndpoints = payableEndpoints.map { PaykitResolvedPaymentEndpoint( + appId = it.appId, identifier = it.identifier, payload = it.target.payload.exportText(), ) @@ -965,6 +930,7 @@ class PaykitSdkService @Inject constructor( PaykitPublicContactPaymentResolution( payableEndpoints = payableEndpoints.map { PaykitResolvedPaymentEndpoint( + appId = it.appId, identifier = it.identifier, payload = it.target.payload.exportText(), ) @@ -974,18 +940,13 @@ class PaykitSdkService @Inject constructor( suspend fun exportBackupState(): String { isSetup.await() return operationLock.withLock { + refreshPaykitKey() handle().exportBackupString() } } - suspend fun restoreBackupState(backup: String) { - isSetup.await() - operationLock.withLock { - withStateRevisionTracking { handle -> - handle.restoreBackupString(backup) - } - resetRuntime() - } + suspend fun retainRecoveryBackup(backup: String) { + keychain.upsertString(Keychain.Key.PAYKIT_RECOVERY_BACKUP.name, backup) } suspend fun signOut() { @@ -1004,9 +965,8 @@ class PaykitSdkService @Inject constructor( isSetup.await() operationLock.withLock { try { - withStateRevisionTracking { handle -> - handle.forgetSessionAccess() - } + handle().forgetSessionAccess() + notifyBackupStateChanged() } finally { resetRuntime() } @@ -1030,28 +990,33 @@ class PaykitSdkService @Inject constructor( suspend fun clearState() { operationLock.withLock { - clearStateLocked() + resetRuntime() + notifyBackupStateChanged() } } - private suspend fun clearStateLocked() { - keychain.delete(Keychain.Key.PAYKIT_SDK_STATE.name) - resetRuntime() - notifyBackupStateChanged() + private suspend fun refreshPaykitKey() { + val root = sessionProvider.loadLocalSecretKey() ?: return + sessionProvider.setPaykitIdentitySecretKey(paykitKey(root)) } - private suspend fun currentSdkStatePublicKeyLocked(): String? { - sessionProvider.suspendStoredSessionAccess() - return try { - handle().identityStatus()?.publicKey - } finally { - sessionProvider.resumeStoredSessionAccess() - } + suspend fun paykitKeyForAuthorization(secretKeyHex: String): PaykitIdentitySecretKey { + isSetup.await() + return operationLock.withLock { paykitKey(localSecretKey(secretKeyHex)) } + } + + private suspend fun paykitKey(root: PubkyLocalSecretKey): PaykitIdentitySecretKey { + val publicKey = pubkyPublicKeyFromSecret(root) + val generation = handle().paykitAppRegistry(publicKey)?.keyGeneration ?: 1uL + val storageKey = "${Keychain.Key.PAYKIT_KEY_GENERATION.name}:$publicKey" + val saved = keychain.loadString(storageKey)?.toULong() + check(generation >= (saved ?: 1uL)) { "The Paykit App Registry has an older key generation" } + if (saved != generation) keychain.upsertString(storageKey, generation.toString()) + return root.derivePaykitIdentitySecretKey(generation) } private suspend fun persistSessionAccess(access: PubkySessionAccess) { keychain.upsertString(Keychain.Key.PAYKIT_SESSION.name, access.exportSessionSecret()) - sessionProvider.persistReceiverNoiseSecretKey(access.exportReceiverNoiseSecretKey()) val localSecret = access.exportLocalSecretKey() if (localSecret != null && sessionProvider.adoptedPubky() == null) { keychain.upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, secretKeyHex(localSecret)) @@ -1062,48 +1027,43 @@ class PaykitSdkService @Inject constructor( private suspend fun activateBootstrapResult( result: PubkySessionBootstrapResult, - previousPublicKey: String?, ) { persistSessionAccess(result.sessionAccess) sessionProvider.setLiveSessionAccess(result.sessionAccess) - val cachedOwner = pubkyStore.data.first().ownerPublicKey - val previousOwner = cachedOwner ?: previousPublicKey + val previousOwner = pubkyStore.data.first().ownerPublicKey if (previousOwner != null && !PubkyPublicKeyFormat.matches(previousOwner, result.publicKey)) { pubkyStore.reset() } - if (!PubkyPublicKeyFormat.matches(previousPublicKey, result.publicKey)) { - keychain.delete(Keychain.Key.PAYKIT_SDK_STATE.name) - } resetRuntime() + refreshPaykitKey() val handle = handle() handle.initialize() - publishReceiverMarkerIfLiveSessionAvailable(handle) + publishAppIfLiveSessionAvailable(handle) republishIdentityIfNeeded(publicKey = result.publicKey) } private suspend fun clearRegisteredIdentityActivationLocked() = withContext(NonCancellable) { runSuspendCatching { sessionProvider.clearSessionAccess() } .onFailure { Logger.warn("Failed to clear incomplete Pubky signup session", it, context = TAG) } - runSuspendCatching { keychain.delete(Keychain.Key.PAYKIT_SDK_STATE.name) } - .onFailure { Logger.warn("Failed to clear incomplete Pubky signup state", it, context = TAG) } resetRuntime() notifyBackupStateChanged() } - private suspend fun publishReceiverMarkerIfLiveSessionAvailable(handle: PaykitSdk) { + private suspend fun publishAppIfLiveSessionAvailable(handle: PaykitSdk) { runSuspendCatching { - val capabilities = receiverCapabilities(handle) + val capabilities = appCapabilities(handle) if (capabilities.privatePayments) { - handle.publishPaykitReceiverMarker(capabilities) + handle.publishPaykitApp("Bitkit", capabilities) } }.onFailure { - Logger.warn("Failed to publish Paykit receiver marker", it, context = TAG) + Logger.warn("Failed to publish Paykit app", it, context = TAG) } } - private suspend fun receiverCapabilities(handle: PaykitSdk): PaykitReceiverCapabilities { - val hasPrivatePaymentAccess = handle.identityStatus()?.liveSessionAvailable == true - return PaykitReceiverCapabilities( + private suspend fun appCapabilities(handle: PaykitSdk): PaykitAppCapabilities { + val hasPrivatePaymentAccess = + handle.identityStatus()?.capability == PubkyIdentityCapability.PRIVATE_LINK_CAPABLE + return PaykitAppCapabilities( privatePayments = hasPrivatePaymentAccess, paymentRequests = hasPrivatePaymentAccess, receipts = false, @@ -1123,7 +1083,7 @@ class PaykitSdkService @Inject constructor( var failure: Throwable? = null return try { runSuspendCatching { - blockedPeers.forEach { handle.unblockPeer(it.counterparty, it.counterpartyReceiverPath) } + blockedPeers.forEach { handle.unblockPeer(it.counterparty) } handle.saveContact(update) }.onFailure { failure = it }.getOrThrow() } catch (error: CancellationException) { @@ -1134,7 +1094,7 @@ class PaykitSdkService @Inject constructor( withContext(NonCancellable) { blockedPeers.forEach { peer -> runSuspendCatching { - handle.blockPeer(peer.counterparty, peer.counterpartyReceiverPath) + handle.blockPeer(peer.counterparty) }.onFailure(restorationError::addSuppressed) } } @@ -1143,6 +1103,7 @@ class PaykitSdkService @Inject constructor( } private suspend fun withStateRevisionTracking(block: suspend (PaykitSdk) -> T): T { + refreshPaykitKey() val handle = handle() return withPaykitBackupStateTracking( readRevision = { handle.backupStateRevision() }, @@ -1171,18 +1132,6 @@ class PaykitSdkService @Inject constructor( sdk = null } - private fun mergedReceiverPaths(paths: List): List { - return (listOf(PaykitReceiverPaths.WALLET) + paths) - .filter { it in PaykitReceiverPaths.supported } - .distinct() - } - - private fun PaykitReceiverMarker.requiresPrivateLink(): Boolean = - capabilities.privatePayments || capabilities.paymentRequests || capabilities.receipts - - private fun PaykitReceiverMarker?.canReceivePrivatePaymentDetails(): Boolean = - this?.capabilities?.let { it.privatePayments && it.outgoingPayments } == true - companion object { private const val TAG = "PaykitSdkService" @@ -1215,6 +1164,16 @@ class PaykitSdkService @Inject constructor( } } +internal fun isBitkitPaymentRequest(record: PaymentRequestRecord): Boolean = when (record.localRole) { + PaymentRequestLocalRole.PAYEE -> record.proposalAppId == "bitkit" + PaymentRequestLocalRole.PAYER -> record.executionClaimAppId?.let { it == "bitkit" } ?: when (record.state) { + PaymentRequestLifecycleState.ACTIVE_RECURRING -> true + PaymentRequestLifecycleState.ACCEPTED if record.paymentProofs.isEmpty() -> true + else -> record.payerAppId == null || record.payerAppId == "bitkit" + } + else -> false +} + internal suspend fun withPaykitBackupStateTracking( readRevision: suspend () -> String, onChange: () -> Unit, @@ -1235,18 +1194,11 @@ internal suspend fun withPaykitBackupStateTracking( internal object BitkitPaykitSdkConfig { val clientId: String - get() = profileNamespace - val profileNamespace: String get() = if (Env.network == Network.BITCOIN) "bitkit.to" else "staging.bitkit.to" - val endpointManagementScope = PaykitSdkDefaults.DEFAULT_ENDPOINT_MANAGEMENT_SCOPE - val encryptedLinkRecoveryMarkers = PaykitSdkDefaults.DEFAULT_ENCRYPTED_LINK_RECOVERY_MARKER_POLICY val publicContactSharing = PaykitSdkDefaults.DEFAULT_PUBLIC_CONTACT_SHARING_POLICY } -internal fun paykitSdkConfig() = defaultConfig(PaykitReceiverPaths.WALLET).copy( - profileNamespace = BitkitPaykitSdkConfig.profileNamespace, - endpointManagementScope = BitkitPaykitSdkConfig.endpointManagementScope, - encryptedLinkRecoveryMarkers = BitkitPaykitSdkConfig.encryptedLinkRecoveryMarkers, +internal fun paykitSdkConfig() = defaultConfig("bitkit").copy( publicContactSharing = BitkitPaykitSdkConfig.publicContactSharing, ) @@ -1270,61 +1222,23 @@ internal fun validatedApprovalClientId(requestClientId: String, approvedClientId return requestClientId } -private class PaykitSdkStateBlobStore( - private val keychain: Keychain, -) : SdkStateBlobStore { - private val lock = Any() - - override fun loadStateBlob(): SdkStateBlobSnapshot? = paykitStorageCallback("state_load_failed") { - synchronized(lock) { - val data = keychain.accessBlocking { - load(Keychain.Key.PAYKIT_SDK_STATE.name) - } ?: return@synchronized null - decodeSdkStateBlobSnapshot(data) - } - } - - override fun saveStateBlobAtomically( - blob: SdkStateBlob, - expectedRevision: String?, - ): String = paykitStorageCallback("state_save_failed") { - synchronized(lock) { - val currentRevision = keychain.accessBlocking { - load(Keychain.Key.PAYKIT_SDK_STATE.name) - } - ?.let { decodeSdkStateBlobSnapshot(it).revision } - if (currentRevision != expectedRevision) { - throw PaykitException.Storage( - code = "revision_conflict", - context = "SDK state revision changed", - ) - } - - val nextRevision = UUID.randomUUID().toString() - val snapshot = SdkStateBlobSnapshot(blob = blob, revision = nextRevision) - keychain.accessBlocking { - upsert(Keychain.Key.PAYKIT_SDK_STATE.name, encodeSdkStateBlobSnapshot(snapshot)) - } - nextRevision - } - } -} - internal class PaykitSdkSessionProvider( private val keychain: Keychain, private val sharedPubky: SharedPubkyClient, ) : SdkPubkySessionProvider { private val lock = Any() - private val receiverNoiseKeyStore = PaykitReceiverNoiseKeyStore(keychain) + private var paykitIdentitySecretKey: PaykitIdentitySecretKey? = null private var liveSessionAccess: PubkySessionAccess? = null private var isStoredSessionAccessSuspended = false fun setLiveSessionAccess(access: PubkySessionAccess) = synchronized(lock) { liveSessionAccess = access + paykitIdentitySecretKey = access.exportPaykitIdentitySecretKey() } fun clearLiveSessionAccess() = synchronized(lock) { liveSessionAccess = null + paykitIdentitySecretKey = null } override fun loadSessionAccess(): PubkySessionAccess? = paykitStorageCallback("session_load_failed") { @@ -1342,8 +1256,8 @@ internal class PaykitSdkSessionProvider( clientId = BitkitPaykitSdkConfig.clientId, sessionSecret = sessionSecret, localSecretKey = loadLocalSecretKey(), - receiverNoiseSecretKey = loadOrDeriveReceiverNoiseSecretKey(), - ) + paykitIdentitySecretKey = paykitIdentitySecretKey, + ).also { liveSessionAccess = it } } } @@ -1388,48 +1302,12 @@ internal class PaykitSdkSessionProvider( return PaykitSdkService.localSecretKey(secretKeyHex) } - fun loadOrDeriveReceiverNoiseSecretKey(): ReceiverNoiseSecretKey = - receiverNoiseKeyStore.loadOrDerive() - - fun persistReceiverNoiseSecretKey(key: ReceiverNoiseSecretKey) { - receiverNoiseKeyStore.persist(key) - } -} - -internal object PaykitReceiverNoiseKeyDerivation { - private const val DOMAIN = "bitkit/paykit/receiver-noise-key" - private const val VERSION = "v1" - - fun deriveFromWalletSeed( - mnemonic: String, - passphrase: String?, - network: String, - receiverPath: String, - ): ByteArray { - val seed = mnemonicToSeed(mnemonic, passphrase?.takeIf { it.isNotEmpty() }) - return try { - derive(seed, network, receiverPath) - } finally { - seed.fill(0) - } - } - - fun derive(seed: ByteArray, network: String, receiverPath: String): ByteArray { - val salt = MessageDigest.getInstance("SHA-256").digest(DOMAIN.encodeToByteArray()) - val prk = hmacSha256(key = salt, data = seed) - return try { - val info = "$VERSION\u0000$network\u0000$receiverPath".encodeToByteArray() + byteArrayOf(0x01) - hmacSha256(key = prk, data = info) - } finally { - prk.fill(0) + fun setPaykitIdentitySecretKey(key: PaykitIdentitySecretKey) = synchronized(lock) { + if ((paykitIdentitySecretKey?.keyGeneration() ?: 1uL) != key.keyGeneration()) { + liveSessionAccess = null } + paykitIdentitySecretKey = key } - - private fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray = - Mac.getInstance("HmacSHA256").run { - init(SecretKeySpec(key, "HmacSHA256")) - doFinal(data) - } } internal fun clearPubkySessionCredentials(deleteKeychainValue: (String) -> Unit) { @@ -1439,88 +1317,15 @@ internal fun clearPubkySessionCredentials(deleteKeychainValue: (String) -> Unit) localSecretResult.getOrThrow() } -internal class PaykitReceiverNoiseKeyStore( - private val loadBytes: () -> ByteArray?, - private val upsertBytes: (ByteArray) -> Unit, - private val deriveBytes: () -> ByteArray, -) { - constructor(keychain: Keychain) : this( - loadBytes = { - keychain.accessBlocking { - load(Keychain.Key.PAYKIT_RECEIVER_NOISE_SECRET_KEY.name) - } - }, - upsertBytes = { bytes -> - keychain.accessBlocking { - upsert(Keychain.Key.PAYKIT_RECEIVER_NOISE_SECRET_KEY.name, bytes) - } - }, - deriveBytes = { - val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) - ?: throw AppError("Mnemonic not found while deriving the Paykit receiver Noise key") - val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) - PaykitReceiverNoiseKeyDerivation.deriveFromWalletSeed( - mnemonic = mnemonic, - passphrase = passphrase, - network = Env.network.name.lowercase(), - receiverPath = PaykitReceiverPaths.WALLET, - ) - }, - ) - - @Synchronized - fun loadOrDerive(): ReceiverNoiseSecretKey = - ReceiverNoiseSecretKey(validatedKeyBytes().copyOf()) - - @Synchronized - fun persist(key: ReceiverNoiseSecretKey) { - persistBytes(key.exportBytes()) - } - - @Synchronized - internal fun loadOrDeriveBytes(): ByteArray = - validatedKeyBytes().copyOf() - - @Synchronized - internal fun persistBytes(bytes: ByteArray) { - if (!validatedKeyBytes().contentEquals(bytes)) { - throw AppError("Paykit receiver Noise key changed unexpectedly") - } - } - - private fun validatedKeyBytes(): ByteArray { - loadBytes()?.let { - checkKeyLength(it, "Stored Paykit receiver Noise key is invalid") - return it - } - - val derivedBytes = deriveBytes() - checkKeyLength(derivedBytes, "Derived Paykit receiver Noise key is invalid") - upsertBytes(derivedBytes.copyOf()) - - return derivedBytes - } - - private fun checkKeyLength(bytes: ByteArray, message: String) { - if (bytes.size != RECEIVER_NOISE_KEY_LENGTH) throw AppError(message) - } - - private companion object { - const val RECEIVER_NOISE_KEY_LENGTH = 32 - } -} - class PaykitSdkPaymentAdapter : SdkPaymentAdapter { override fun currentPublicReceivingDetails(): List = emptyList() override fun currentPrivateReceivingDetails( counterparty: String, - counterpartyReceiverPath: String, ): List = emptyList() override fun reservePrivateReceivingDetails( counterparty: String, - counterpartyReceiverPath: String, ): PrivateReceivingDetailReservationResponse = PrivateReceivingDetailReservationResponse( kind = PrivateReceivingDetailReservationResponseKind.USE_CURRENT_RECEIVING_DETAILS, diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt index 8cb0667ccc..d5a04d4766 100644 --- a/app/src/main/java/to/bitkit/services/PubkyService.kt +++ b/app/src/main/java/to/bitkit/services/PubkyService.kt @@ -1,10 +1,10 @@ package to.bitkit.services import com.synonym.bitkitcore.approvePubkyAuth -import com.synonym.paykit.ContactProfileResolution import com.synonym.paykit.ContactRecord import com.synonym.paykit.PaykitProfile import com.synonym.paykit.PaykitPublicKeys +import com.synonym.paykit.ProfileResolution import com.synonym.paykit.PubkyAuthCompanionClaim import com.synonym.paykit.PubkySessionBootstrapResult import kotlinx.coroutines.withTimeoutOrNull @@ -61,11 +61,11 @@ class PubkyService @Inject constructor( val report = paykitSdkService.syncPublicEndpoints(emptyList()) if (report.failed.isNotEmpty()) throw AppError("Failed to remove Paykit payment endpoints") }.exceptionOrNull() - val markerError = runSuspendCatching { - paykitSdkService.syncLocalReceiverMarker(isDiscoverable = false) + val appError = runSuspendCatching { + paykitSdkService.syncPaykitApp(privatePaymentsEnabled = false) }.exceptionOrNull() - val cleanupError = endpointError ?: markerError - if (endpointError != null && markerError != null) endpointError.addSuppressed(markerError) + val cleanupError = endpointError ?: appError + if (endpointError != null && appError != null) endpointError.addSuppressed(appError) cleanupError?.let { throw it } } @@ -201,10 +201,9 @@ class PubkyService @Inject constructor( suspend fun saveContact( publicKey: String, label: String?, - receiverPaths: List? = null, restorePrivateConnection: Boolean = false, ): ContactRecord = ServiceQueue.CORE.background { - paykitSdkService.saveContact(publicKey, label, receiverPaths, restorePrivateConnection) + paykitSdkService.saveContact(publicKey, label, restorePrivateConnection) } suspend fun removeContact(publicKey: String): ContactRecord? = ServiceQueue.CORE.background { @@ -214,14 +213,10 @@ class PubkyService @Inject constructor( suspend fun resolveContactProfile( publicKey: String, allowPubkyProfileFallback: Boolean, - ): ContactProfileResolution? = ServiceQueue.CORE.background { + ): ProfileResolution? = ServiceQueue.CORE.background { paykitSdkService.resolveContactProfile(publicKey, allowPubkyProfileFallback) } - suspend fun discoverRelevantReceiverPaths(publicKey: String): List = ServiceQueue.CORE.background { - paykitSdkService.discoverRelevantReceiverPaths(publicKey) - } - // endregion } diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index c72fc20716..1c4ec4e126 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -852,7 +852,6 @@ private fun RootNavHost( Routes.SubscriptionDetail( paymentRequestId = it.paymentRequestId, counterparty = it.counterparty, - counterpartyReceiverPath = it.counterpartyReceiverPath, ) ) }, @@ -869,7 +868,6 @@ private fun RootNavHost( id = PaykitSubscriptionId( paymentRequestId = route.paymentRequestId, counterparty = route.counterparty, - counterpartyReceiverPath = route.counterpartyReceiverPath, ), onBack = { navController.popBackStack() }, ) @@ -1424,7 +1422,6 @@ private fun NavGraphBuilder.contacts( Sheet.Receive( route = ReceiveRoute.PaymentRequestAmount( publicKey = it.publicKey, - receiverPath = it.receiverPath, ) ) ) @@ -2173,14 +2170,12 @@ fun NavController.navigateToLanguageSettings() = navigateTo(Routes.LanguageSetti private fun PaykitPaymentRequestId.toRoute() = Routes.PaymentRequestDetails( paymentRequestId = paymentRequestId, counterparty = counterparty, - counterpartyReceiverPath = counterpartyReceiverPath, billingPeriodStartsAt = billingPeriodStartsAt, ) private fun Routes.PaymentRequestDetails.toId() = PaykitPaymentRequestId( paymentRequestId = paymentRequestId, counterparty = counterparty, - counterpartyReceiverPath = counterpartyReceiverPath, billingPeriodStartsAt = billingPeriodStartsAt, ) @@ -2508,14 +2503,12 @@ sealed interface Routes { data class SubscriptionDetail( val paymentRequestId: String, val counterparty: String, - val counterpartyReceiverPath: String, ) : Routes.InternalOnly @Serializable data class PaymentRequestDetails( val paymentRequestId: String, val counterparty: String, - val counterpartyReceiverPath: String, val billingPeriodStartsAt: String? = null, ) : Routes.InternalOnly diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 037b5d21b3..0bf8778c73 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -307,13 +307,11 @@ class MainActivity : FragmentActivity() { private fun Intent.paykitPaymentRequestId(): PaykitPaymentRequestId? { val requestId = getStringExtra(EXTRA_PAYKIT_PAYMENT_REQUEST_ID) ?: return null val counterparty = getStringExtra(EXTRA_PAYKIT_COUNTERPARTY) ?: return null - val receiverPath = getStringExtra(EXTRA_PAYKIT_COUNTERPARTY_RECEIVER_PATH) ?: return null val billingPeriodStartsAt = getStringExtra(EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT) ?: return null return PaykitPaymentRequestId( paymentRequestId = requestId, counterparty = counterparty, - counterpartyReceiverPath = receiverPath, billingPeriodStartsAt = billingPeriodStartsAt, ) } diff --git a/app/src/main/java/to/bitkit/ui/Notifications.kt b/app/src/main/java/to/bitkit/ui/Notifications.kt index 1e8d7b9668..f04ae82195 100644 --- a/app/src/main/java/to/bitkit/ui/Notifications.kt +++ b/app/src/main/java/to/bitkit/ui/Notifications.kt @@ -30,7 +30,6 @@ const val EXTRA_PAYKIT_SUBSCRIPTION_PAYMENT_DUE = "paykit_subscription_payment_d const val EXTRA_PAYKIT_PAYER_IDENTITY = "paykit_payer_identity" const val EXTRA_PAYKIT_PAYMENT_REQUEST_ID = "paykit_payment_request_id" const val EXTRA_PAYKIT_COUNTERPARTY = "paykit_counterparty" -const val EXTRA_PAYKIT_COUNTERPARTY_RECEIVER_PATH = "paykit_counterparty_receiver_path" const val EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT = "paykit_billing_period_starts_at" val Context.CHANNEL_MAIN get() = getString(R.string.app_notifications_channel_id) diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt index bb4e3920a9..9ecf84fd70 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt @@ -23,7 +23,7 @@ import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitPaymentResult import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.ui.shared.toast.ToastEventBus -import to.bitkit.usecases.RefreshContactPaykitReceiversUseCase +import to.bitkit.usecases.RefreshContactPaykitLinkUseCase import to.bitkit.utils.Logger import javax.inject.Inject @@ -32,7 +32,7 @@ class AddContactViewModel @Inject constructor( @ApplicationContext private val context: Context, private val pubkyRepo: PubkyRepo, private val publicPaykitRepo: PublicPaykitRepo, - private val refreshContactPaykitReceivers: RefreshContactPaykitReceiversUseCase, + private val refreshContactPaykitLink: RefreshContactPaykitLinkUseCase, savedStateHandle: SavedStateHandle, ) : ViewModel() { @@ -96,7 +96,7 @@ class AddContactViewModel @Inject constructor( ) } if (error == PubkyContactError.AlreadyExists) { - refreshContactPaykitReceivers(publicKey) + refreshContactPaykitLink(publicKey) } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsViewModel.kt index cfd1cb7439..f4e6283161 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsViewModel.kt @@ -16,13 +16,13 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.models.PubkyProfile import to.bitkit.repositories.PubkyRepo -import to.bitkit.usecases.RefreshContactPaykitReceiversUseCase +import to.bitkit.usecases.RefreshContactPaykitLinkUseCase import javax.inject.Inject @HiltViewModel class ContactsViewModel @Inject constructor( private val pubkyRepo: PubkyRepo, - private val refreshContactPaykitReceivers: RefreshContactPaykitReceiversUseCase, + private val refreshContactPaykitLink: RefreshContactPaykitLinkUseCase, ) : ViewModel() { private val _searchText = MutableStateFlow("") @@ -70,7 +70,7 @@ class ContactsViewModel @Inject constructor( } fun refreshExistingContact(publicKey: String) { - viewModelScope.launch { refreshContactPaykitReceivers(publicKey) } + viewModelScope.launch { refreshContactPaykitLink(publicKey) } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt index 1bb53ef3b7..2b927c88fc 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt @@ -481,7 +481,7 @@ internal fun PaymentRequestRecipientContent( } items( items = recipients, - key = { (target, _) -> "${target.publicKey}|${target.receiverPath}" }, + key = { (target, _) -> target.publicKey }, ) { (target, contact) -> PubkyContactRow( profile = contact, @@ -592,13 +592,11 @@ private val previewDraft = PaykitPaymentRequestDraft( private val previewTarget = PaykitPaymentRequestTarget( publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", - receiverPath = "bitkit/wallet", ) private val previewCreatedRequest = PaykitPaymentRequest( paymentRequestId = "payment-request", counterparty = previewTarget.publicKey, - counterpartyReceiverPath = previewTarget.receiverPath, amountValue = "0.00025", amountSats = previewDraft.amountSats, note = previewDraft.note, diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt index b5a744f931..90a5523e75 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt @@ -665,7 +665,7 @@ private fun List.nameFor(request: PaykitPaymentRequest): Str } private val PaykitPaymentRequest.lazyListKey: String - get() = "$paymentRequestId|$counterparty|$counterpartyReceiverPath|${billingPeriod?.startsAt ?: ""}" + get() = "$paymentRequestId|$counterparty|${billingPeriod?.startsAt ?: ""}" internal val PaykitPaymentRequest.paymentRailIconColor get() = if (paymentProofKind == PaykitPaymentProofKind.Lightning) Colors.Purple else Colors.Brand @@ -692,7 +692,6 @@ private fun PaymentRailIcon(request: PaykitPaymentRequest, paymentWasSent: Boole private val previewRequest = PaykitPaymentRequest( paymentRequestId = "payment-request", counterparty = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", - counterpartyReceiverPath = "bitkit/wallet", amountValue = "0.00025", amountSats = 25_000uL, note = "Dinner", diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalSheet.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalSheet.kt index 42bffae193..9d8c31c4eb 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalSheet.kt @@ -11,8 +11,10 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.runtime.Composable @@ -38,6 +40,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import to.bitkit.R import to.bitkit.models.PubkyAuthClaim +import to.bitkit.models.PubkyAuthClaim.Item import to.bitkit.models.PubkyAuthPermission import to.bitkit.models.PubkyProfile import to.bitkit.ui.appViewModel @@ -265,7 +268,7 @@ private fun approvalBackAction( onCancel: () -> Unit, onDismiss: () -> Unit, ): (() -> Unit)? = when (approvalState) { - ApprovalState.Authorize if bitkitClaim == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1 -> onBackToWatchOnly + ApprovalState.Authorize if bitkitClaim?.includesWatchOnlyAccount == true -> onBackToWatchOnly ApprovalState.Authorize, ApprovalState.Authenticating, ApprovalState.Authorizing -> onCancel ApprovalState.Success -> onDismiss else -> null @@ -384,7 +387,7 @@ private fun ColumnScope.AuthorizingContent( private fun ColumnScope.ApprovalDetails( uiState: PubkyAuthApprovalUiState, ) { - Column(modifier = Modifier.weight(1f)) { + Column(modifier = Modifier.weight(1f).verticalScroll(rememberScrollState())) { VerticalSpacer(26.dp) if (uiState.homeserverPublicKey != null) { @@ -410,7 +413,11 @@ private fun ColumnScope.ApprovalDetails( if (uiState.permissions.isNotEmpty()) { PermissionsSection(permissions = uiState.permissions) } - FillHeight(min = 32.dp) + if (uiState.bitkitClaim?.includesPaykitAccess == true) { + VerticalSpacer(16.dp) + PaykitAccessSection() + } + VerticalSpacer(32.dp) TrustWarning() VerticalSpacer(16.dp) @@ -432,6 +439,15 @@ private fun ColumnScope.ApprovalDetails( } } +@Composable +private fun PaykitAccessSection() { + Column(modifier = Modifier.testTag("PubkyAuthPaykitAccess")) { + BodyMSB(text = stringResource(R.string.profile__auth_approval_paykit_access_title)) + VerticalSpacer(8.dp) + BodyM(text = stringResource(R.string.profile__auth_approval_paykit_access_description), color = Colors.White64) + } +} + @Composable private fun ColumnScope.SuccessContent( uiState: PubkyAuthApprovalUiState, @@ -592,7 +608,7 @@ private fun WatchOnlyConsentPreview() { uiState = PubkyAuthApprovalUiState( state = ApprovalState.WatchOnlyConsent, serviceName = "paykit", - bitkitClaim = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, + bitkitClaim = PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1), ), isCurrentRequest = true, onAuthorize = {}, @@ -619,7 +635,7 @@ private fun AuthorizePreview() { PubkyAuthPermission(path = "/pub/pubky.app/", accessLevel = "rw"), PubkyAuthPermission(path = "/pub/paykit/v0/", accessLevel = "rw"), ), - bitkitClaim = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, + bitkitClaim = PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1), profile = PubkyProfile( publicKey = "pk8e3qm5f4kgczagxhertyuiop1gxag", name = "Satoshi Nakamoto", diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt index 9d6eeb3157..0cb1229438 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt @@ -21,6 +21,7 @@ import to.bitkit.ext.runSuspendCatching import to.bitkit.models.PubkyAuthClaim import to.bitkit.models.PubkyAuthPermission import to.bitkit.models.PubkyAuthRequest +import to.bitkit.models.PubkyAuthRequestError import to.bitkit.models.PubkyProfile import to.bitkit.models.Toast import to.bitkit.models.WatchOnlyAccountSetupState @@ -76,16 +77,9 @@ class PubkyAuthApprovalViewModel @Inject constructor( if (_uiState.value.authUrl != authUrl) return@launch val unknownService = context.getString(R.string.profile__auth_approval_service_unknown) val serviceName = request.serviceNames.firstOrNull() ?: unknownService - val profile = pubkyRepo.profile.value ?: pubkyRepo.publicKey.value?.let { publicKey -> - PubkyProfile.forDisplay( - publicKey = publicKey, - name = pubkyRepo.displayName.value, - imageUrl = pubkyRepo.displayImageUri.value, - ) - } _uiState.update { it.copy( - state = if (request.bitkitClaim == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1) { + state = if (request.bitkitClaim?.includesWatchOnlyAccount == true) { ApprovalState.WatchOnlyConsent } else { ApprovalState.Authorize @@ -95,7 +89,7 @@ class PubkyAuthApprovalViewModel @Inject constructor( serviceName = serviceName, permissions = request.permissions.toImmutableList(), bitkitClaim = request.bitkitClaim, - profile = profile, + profile = approvalProfile(), ) } } @@ -125,7 +119,7 @@ class PubkyAuthApprovalViewModel @Inject constructor( if ( state.authUrl == authUrl && state.state == ApprovalState.Authorize && - state.bitkitClaim == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1 + state.bitkitClaim?.includesWatchOnlyAccount == true ) { state.copy(state = ApprovalState.WatchOnlyConsent) } else { @@ -170,6 +164,12 @@ class PubkyAuthApprovalViewModel @Inject constructor( } val approvalState = _uiState.value if (approvalState.authUrl != authUrl) return + if (request.bitkitClaim != approvalState.bitkitClaim || request.clientId != approvalState.clientId || + request.permissions != approvalState.permissions + ) { + handleApprovalFailure(PubkyAuthRequestError.RequesterChanged, authUrl) + return + } if (!approveRequest(request, authUrl)) return Logger.info("Auth approved for '${request.serviceNames.firstOrNull().orEmpty()}'", context = TAG) @@ -202,7 +202,7 @@ class PubkyAuthApprovalViewModel @Inject constructor( authUrl: String, ): Boolean { val preparedClaim = runSuspendCatching { - if (request.bitkitClaim == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1) { + if (request.bitkitClaim?.includesWatchOnlyAccount == true) { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, defaultWatchOnlyAccountName(request)) } else { null @@ -226,9 +226,11 @@ class PubkyAuthApprovalViewModel @Inject constructor( } } - val approvalResult = preparedClaim?.let { - pubkyRepo.approveAuthWithCompanionClaim(authUrl, request.clientId, it.payload) - } ?: pubkyRepo.approveAuth(authUrl, request.capabilities, request.clientId) + val approvalResult = if (request.bitkitClaim != null) { + pubkyRepo.approveAuthWithCompanionClaim(authUrl, request.clientId, preparedClaim?.payload ?: byteArrayOf()) + } else { + pubkyRepo.approveAuth(authUrl, request.capabilities, request.clientId) + } if (approvalResult.isFailure) { val approvalError = checkNotNull(approvalResult.exceptionOrNull()) { "Authorization failed" } preparedClaim?.let { claim -> @@ -320,6 +322,14 @@ class PubkyAuthApprovalViewModel @Inject constructor( return context.getString(R.string.profile__auth_approval_watch_only_account_default_name, serviceName) } + private fun approvalProfile() = pubkyRepo.profile.value ?: pubkyRepo.publicKey.value?.let { publicKey -> + PubkyProfile.forDisplay( + publicKey = publicKey, + name = pubkyRepo.displayName.value, + imageUrl = pubkyRepo.displayImageUri.value, + ) + } + fun dismiss() { viewModelScope.launch { _effects.emit(PubkyAuthApprovalEffect.Dismiss) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt index b4036546fa..c40a2dd900 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt @@ -110,8 +110,7 @@ fun ReceiveSheet( mutableStateOf( (startRoute as? ReceiveRoute.PaymentRequestAmount)?.let { val publicKey = it.publicKey ?: return@let null - val receiverPath = it.receiverPath ?: return@let null - PaykitPaymentRequestTarget(publicKey, receiverPath) + PaykitPaymentRequestTarget(publicKey) } ) } @@ -195,7 +194,7 @@ fun ReceiveSheet( composableWithDefaultTransitions { backStackEntry -> val route = backStackEntry.toRoute() val routeTarget = route.publicKey?.let { publicKey -> - route.receiverPath?.let { receiverPath -> PaykitPaymentRequestTarget(publicKey, receiverPath) } + PaykitPaymentRequestTarget(publicKey) } val contact = (routeTarget ?: selectedPaymentRequestTarget)?.let { target -> paymentRequestContacts.firstOrNull { @@ -541,7 +540,6 @@ sealed interface ReceiveRoute { @Serializable data class PaymentRequestAmount( val publicKey: String? = null, - val receiverPath: String? = null, ) : InternalOnly @Serializable diff --git a/app/src/main/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCase.kt b/app/src/main/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCase.kt similarity index 75% rename from app/src/main/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCase.kt rename to app/src/main/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCase.kt index be69e7623f..830993fc54 100644 --- a/app/src/main/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCase.kt @@ -10,24 +10,23 @@ import to.bitkit.repositories.PubkyRepo import to.bitkit.utils.Logger import javax.inject.Inject -class RefreshContactPaykitReceiversUseCase @Inject constructor( +class RefreshContactPaykitLinkUseCase @Inject constructor( @IoDispatcher private val ioDispatcher: CoroutineDispatcher, private val pubkyRepo: PubkyRepo, private val privatePaykitRepo: PrivatePaykitRepo, ) { companion object { - private const val TAG = "RefreshContactPaykitReceiversUseCase" + private const val TAG = "RefreshContactPaykitLinkUseCase" } suspend operator fun invoke(publicKey: String): Result = withContext(ioDispatcher) { runSuspendCatching { - pubkyRepo.refreshContactReceiverPaths(publicKey).getOrThrow() val savedPublicKeys = (pubkyRepo.contacts.value.map { it.publicKey } + publicKey).distinct() privatePaykitRepo.refreshSavedContactEndpoints(publicKey, savedPublicKeys).getOrThrow() - privatePaykitRepo.startInitialLinkBurst(savedPublicKeys, "contact receiver refresh") + privatePaykitRepo.startInitialLinkBurst(savedPublicKeys, "contact link refresh") }.onFailure { Logger.warn( - "Failed to refresh Paykit receivers for '${PubkyPublicKeyFormat.redacted(publicKey)}'", + "Failed to refresh the Paykit link for '${PubkyPublicKeyFormat.redacted(publicKey)}'", it, context = TAG, ) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 473605d250..0401ded9b7 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -199,7 +199,7 @@ import to.bitkit.ui.theme.TRANSITION_SCREEN_MS import to.bitkit.ui.utils.ScreenDeepLinks import to.bitkit.ui.utils.localizedPubkyAuthMessage import to.bitkit.usecases.FormatMoneyValue -import to.bitkit.usecases.RefreshContactPaykitReceiversUseCase +import to.bitkit.usecases.RefreshContactPaykitLinkUseCase import to.bitkit.utils.AppError import to.bitkit.utils.Bip21Utils import to.bitkit.utils.Logger @@ -259,7 +259,7 @@ class AppViewModel @Inject constructor( private val paykitPaymentRequestRepo: PaykitPaymentRequestRepo, private val paykitPaymentProofRepo: PaykitPaymentProofRepo, private val paykitPaymentRequestDiagnostics: PaykitPaymentRequestDiagnostics, - private val refreshContactPaykitReceivers: RefreshContactPaykitReceiversUseCase, + private val refreshContactPaykitLink: RefreshContactPaykitLinkUseCase, private val samRockRepo: SamRockRepo, private val appUpdateSheet: AppUpdateTimedSheet, private val backupSheet: BackupTimedSheet, @@ -741,7 +741,7 @@ class AppViewModel @Inject constructor( paykitPaymentRequestRepo.activate(state.publicKey) if (!PubkyPublicKeyFormat.matches(pubkyRepo.publicKey.value, state.publicKey)) return paymentRequestIdentity = state.publicKey - refreshPrivateOnlyPaykitReceiverMarker("contact sync") + refreshPrivateOnlyPaykitApp("contact sync") if (!state.contactsLoaded) return val removedKeys = lastPrivatePaykitContactKeys - state.contactKeys @@ -782,7 +782,7 @@ class AppViewModel @Inject constructor( if (!isPaykitEnabled.value) return - refreshPrivateOnlyPaykitReceiverMarker(reason) + refreshPrivateOnlyPaykitApp(reason) privatePaykitRepo.reconcileReservedReceiveIndexes() .onFailure { Logger.warn("Failed to reconcile private Paykit receive indexes for '$reason'", it, context = TAG) @@ -876,6 +876,7 @@ class AppViewModel @Inject constructor( if (!isPaykitEnabled.value || pubkyRepo.publicKey.value == null || !walletRepo.walletExists()) return if (refreshMaintenance) paykitPaymentProofRepo.reconcile() paykitPaymentRequestRepo.refresh().onSuccess { + activityRepo.backfillPaykitContacts() presentNextIncomingPaykitPaymentRequest() } } @@ -1340,18 +1341,23 @@ class AppViewModel @Inject constructor( return shouldHideRequestSendSheet } - private suspend fun refreshPrivateOnlyPaykitReceiverMarker(reason: String) { + private suspend fun refreshPrivateOnlyPaykitApp(reason: String) { val settings = settingsStore.data.first() if (!settings.sharesPrivatePaykitEndpoints || settings.sharesPublicPaykitEndpoints) return if (pubkyRepo.publicKey.value == null) return - publicPaykitRepo.syncLocalReceiverMarker() + publicPaykitRepo.syncPaykitApp() .onFailure { - Logger.warn("Failed to refresh private Paykit receiver marker for '$reason'", it, context = TAG) + Logger.warn("Failed to refresh private Paykit app registration for '$reason'", it, context = TAG) } } private suspend fun retryPendingPaykitEndpointRemoval(contactKeys: Collection, reason: String) { + privatePaykitRepo.retryPendingEndpointRemoval(contactKeys) + .onFailure { + Logger.warn("Failed to retry private Paykit endpoint removal for '$reason'", it, context = TAG) + } + val settings = settingsStore.data.first() if (settings.publicPaykitCleanupPending) { val reconciliationResult = if (settings.sharesPublicPaykitEndpoints) { @@ -1367,11 +1373,6 @@ class AppViewModel @Inject constructor( Logger.warn("Failed to reconcile public Paykit state for '$reason'", it, context = TAG) } } - - privatePaykitRepo.retryPendingEndpointRemoval(contactKeys) - .onFailure { - Logger.warn("Failed to retry private Paykit endpoint removal for '$reason'", it, context = TAG) - } } @Suppress("CyclomaticComplexMethod") @@ -3053,7 +3054,7 @@ class AppViewModel @Inject constructor( if (currentSheet.value is Sheet.Send) hideSheet() mainScreenEffect(MainScreenEffect.Navigate(route)) if (route is Routes.ContactDetail) { - refreshContactPaykitReceivers(route.publicKey) + refreshContactPaykitLink(route.publicKey) } return@withContext } @@ -4031,12 +4032,19 @@ class AppViewModel @Inject constructor( val incomingPaymentRequest = contactPaymentContext?.incomingPaymentRequest val proofPreparation = preparePaymentProof(incomingPaymentRequest) - if (proofPreparation.exceptionOrNull() is PaykitPaymentRequestError.OperationInProgress) { - handlePaymentPreparationFailure(PaykitPaymentRequestError.OperationInProgress, contactPaymentContext) + proofPreparation.exceptionOrNull()?.let { + handlePaymentPreparationFailure(it, contactPaymentContext) return } val preparedPaymentProofRequest = proofPreparation.getOrNull() + contactPaymentContext?.incomingPaymentRequest?.let { request -> + paykitPaymentRequestRepo.claimForPayment(request).onFailure { + cancelPaymentProofPreparation(preparedPaymentProofRequest) + handlePaymentPreparationFailure(it, contactPaymentContext) + return + } + } consumePrivatePaymentListIfNeeded(contactPaymentContext).onFailure { cancelPaymentProofPreparation(preparedPaymentProofRequest) handlePaymentPreparationFailure(it, contactPaymentContext) @@ -4111,6 +4119,7 @@ class AppViewModel @Inject constructor( ) { val address = _sendUiState.value.address val tags = _sendUiState.value.selectedTags + val proofPreparation = incomingPaymentRequest?.let { paymentProofPreparation(contactPaymentContext) } var proofRequest = preparedPaymentProofRequest var paymentProofStarted = false var sendAttempted = false @@ -4128,7 +4137,7 @@ class AppViewModel @Inject constructor( }, onBroadcast = { txId -> proofRequest = null - completeOnchainPaymentProofInBackground(incomingPaymentRequest, txId) + completeOnchainPaymentProofInBackground(incomingPaymentRequest, txId, proofPreparation) }, ).onSuccess { txId -> Logger.info("Onchain send result txid: $txId", context = TAG) @@ -4218,7 +4227,9 @@ class AppViewModel @Inject constructor( val paymentHash = decodedInvoice.paymentHash.toHex() associateLightningPaymentProof(incomingPaymentRequest, paymentHash).onFailure { cancelPaymentProofPreparation(proofRequest) - proofRequest = null + releasePrivatePaymentListIfNeeded(contactPaymentContext) + handlePaymentPreparationFailure(it, contactPaymentContext) + return } val tags = _sendUiState.value.selectedTags @@ -4314,10 +4325,17 @@ class AppViewModel @Inject constructor( else -> paykitPaymentProofRepo.failLightningPayment(paymentHash, error) } + @Suppress("ReturnCount") private suspend fun prepareContactPayment(contactPaymentContext: ContactPaymentContext?): Boolean { if (isPreparedContactPayment(contactPaymentContext)) return true if (!validateIncomingPaymentRequest(contactPaymentContext)) return false + contactPaymentContext?.incomingPaymentRequest?.let { request -> + paykitPaymentRequestRepo.claimForPayment(request).onFailure { + handlePaymentPreparationFailure(it, contactPaymentContext) + return false + } + } consumePrivatePaymentListIfNeeded(contactPaymentContext).onFailure { handlePaymentPreparationFailure(it, contactPaymentContext) return false @@ -4341,10 +4359,11 @@ class AppViewModel @Inject constructor( private suspend fun preparePaymentProof(request: PaykitPaymentRequest?): Result { if (request == null) return Result.success(null) - val preparation = paymentProofPreparation() + val preparation = runCatching { paymentProofPreparation() }.getOrElse { return Result.failure(it) } return paykitPaymentProofRepo.prepare( request = request, paymentEndpointIdentifier = preparation.endpointIdentifier, + paymentAppId = preparation.appId, kind = preparation.kind, ).map { request } } @@ -4352,23 +4371,30 @@ class AppViewModel @Inject constructor( private suspend fun associateLightningPaymentProof( request: PaykitPaymentRequest?, paymentHash: String, - ): Result = request?.let { + ): Result = runSuspendCatching { + if (request == null) return@runSuspendCatching + val preparation = paymentProofPreparation() paykitPaymentProofRepo.associateLightningPayment( - request = it, + request = request, paymentHash = paymentHash, - paymentEndpointIdentifier = paymentProofPreparation().endpointIdentifier, - ) + paymentEndpointIdentifier = preparation.endpointIdentifier, + paymentAppId = preparation.appId, + ).getOrThrow() } - ?: Result.success(Unit) - private fun completeOnchainPaymentProofInBackground(request: PaykitPaymentRequest?, txId: String) { + private fun completeOnchainPaymentProofInBackground( + request: PaykitPaymentRequest?, + txId: String, + preparation: PaymentProofPreparation?, + ) { val paymentRequest = request ?: return - val endpointIdentifier = paymentProofPreparation().endpointIdentifier + if (preparation == null) return viewModelScope.launch { paykitPaymentProofRepo.completeOnchainPayment( request = paymentRequest, txid = txId, - paymentEndpointIdentifier = endpointIdentifier, + paymentEndpointIdentifier = preparation.endpointIdentifier, + paymentAppId = preparation.appId, ) if (paymentRequest.billingPeriod != null) refreshIncomingPaykitPaymentRequests() } @@ -4385,7 +4411,11 @@ class AppViewModel @Inject constructor( request?.let { paykitPaymentProofRepo.cancelPreparation(it) } } - private fun paymentProofPreparation(): PaymentProofPreparation { + private fun paymentProofPreparation( + contactPaymentContext: ContactPaymentContext? = synchronized(contactPaymentContextLock) { + activeContactPaymentContext + }, + ): PaymentProofPreparation { val methodId = when (_sendUiState.value.payMethod) { SendMethod.ONCHAIN -> PublicPaykitRepo.onchainMethodId(_sendUiState.value.address) SendMethod.LIGHTNING -> if (_sendUiState.value.lnurl is LnurlParams.LnurlPay) { @@ -4394,8 +4424,11 @@ class AppViewModel @Inject constructor( MethodId.Bolt11 } } + val appId = contactPaymentContext?.privatePaymentContext?.paymentAppsByEndpoint?.get(methodId.rawValue) + ?: throw PaykitPaymentRequestError.RequestUnavailable return PaymentProofPreparation( endpointIdentifier = methodId.rawValue, + appId = appId, kind = if (methodId.isOnchain) PaykitPaymentProofKind.Onchain else PaykitPaymentProofKind.Lightning, ) } @@ -5240,8 +5273,8 @@ class AppViewModel @Inject constructor( val incomingPaymentRequest = contactPaymentContext?.incomingPaymentRequest val proofPreparation = preparePaymentProof(incomingPaymentRequest) - if (proofPreparation.exceptionOrNull() is PaykitPaymentRequestError.OperationInProgress) { - handlePaymentPreparationFailure(PaykitPaymentRequestError.OperationInProgress, contactPaymentContext) + proofPreparation.exceptionOrNull()?.let { + handlePaymentPreparationFailure(it, contactPaymentContext) return false } val preparedPaymentProofRequest = proofPreparation.getOrNull() @@ -5283,10 +5316,12 @@ class AppViewModel @Inject constructor( } fun completeHardwareContactPayment(txId: String) { - val incomingPaymentRequest = synchronized(contactPaymentContextLock) { - activeContactPaymentContext?.incomingPaymentRequest + val context = synchronized(contactPaymentContextLock) { + preparedContactPaymentContext } - completeOnchainPaymentProofInBackground(incomingPaymentRequest, txId) + val request = context?.incomingPaymentRequest + val preparation = request?.let { paymentProofPreparation(context) } + completeOnchainPaymentProofInBackground(request, txId, preparation) } fun onHardwareSignCancelled() { @@ -6052,6 +6087,7 @@ data class ContactPaymentContext( private data class PaymentProofPreparation( val endpointIdentifier: String, + val appId: String, val kind: PaykitPaymentProofKind, ) diff --git a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt index f2cb0a95d4..63eb2b2556 100644 --- a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt @@ -276,6 +276,11 @@ class SettingsViewModel @Inject constructor( private suspend fun removePaykitEndpoints(hadPublicPaykitState: Boolean, hadPrivatePaykitState: Boolean) { val contacts = pubkyRepo.contacts.value.map { it.publicKey } + privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contacts) + .onFailure { + Logger.warn("Failed to remove private Paykit endpoints after disabling Paykit UI", it, context = TAG) + } + if (hadPublicPaykitState) { publicPaykitRepo.syncPublishedEndpoints(publish = false) .onSuccess { @@ -286,17 +291,12 @@ class SettingsViewModel @Inject constructor( Logger.warn("Failed to remove public Paykit endpoints after disabling Paykit UI", it, context = TAG) } } else if (hadPrivatePaykitState) { - publicPaykitRepo.syncLocalReceiverMarker(publicSharingEnabled = false, privateSharingEnabled = false) + publicPaykitRepo.syncPaykitApp(privateSharingEnabled = false) .onFailure { settingsStore.update { settings -> settings.copy(publicPaykitCleanupPending = true) } - Logger.warn("Failed to remove Paykit receiver marker after disabling Paykit UI", it, context = TAG) + Logger.warn("Failed to update Paykit app capabilities after disabling Paykit UI", it, context = TAG) } } - - privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contacts) - .onFailure { - Logger.warn("Failed to remove private Paykit endpoints after disabling Paykit UI", it, context = TAG) - } } val isPinEnabled = settingsStore.data.map { it.isPinEnabled } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 17d09e7795..c5544089f2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -640,6 +640,8 @@ Authorize Authorizing… OK + Read and manage your private Paykit data, and send Paykit messages on your behalf. Your Pubky identity secret and wallet spending keys are not shared. + Paykit access Requested permissions Requester ID: %1$s A service is requesting permission to access and edit your <accent>%1$s</accent> data. diff --git a/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt b/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt index 912a5174ac..edc194cd23 100644 --- a/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt +++ b/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt @@ -18,7 +18,7 @@ class PaykitPaymentStateBackupTest { fun `payment backup accepts shared wire format and retains pending payment`() { WalletScope.pushTestOverride("wallet0").use { val fixture = """ - {"subscriptions":{"alice":{"acceptances":[{"id":{"paymentRequestId":"request","counterparty":"bob","counterpartyReceiverPath":"bitkit/server"},"acceptedAt":"2026-09-24T10:00:00.123Z"}],"presentedProposalIds":[]}},"pendingProofs":[{"identity":"alice","requestId":{"paymentRequestId":"request","counterparty":"bob","counterpartyReceiverPath":"bitkit/server","billingPeriodStartsAt":"2026-09-24T10:00:00.100Z"},"paymentEndpointIdentifier":"bitcoin-onchain","kind":"bitcoin-onchain-txid","paymentStarted":true,"billingPeriod":{"startsAt":"2026-09-24T10:00:00.100Z","endsAt":"2026-09-25T10:00:00.100Z"},"onchainMatchingTransactionIdsBeforeAttempt":[]}]} + {"subscriptions":{"alice":{"acceptances":[{"id":{"paymentRequestId":"request","counterparty":"bob"},"acceptedAt":"2026-09-24T10:00:00.123Z"}],"presentedProposalIds":[]}},"pendingProofs":[{"identity":"alice","requestId":{"paymentRequestId":"request","counterparty":"bob","billingPeriodStartsAt":"2026-09-24T10:00:00.100Z"},"paymentAppId":"bitkit","paymentEndpointIdentifier":"bitcoin-onchain","kind":"bitcoin-onchain-txid","paymentStarted":true,"billingPeriod":{"startsAt":"2026-09-24T10:00:00.100Z","endsAt":"2026-09-25T10:00:00.100Z"},"onchainMatchingTransactionIdsBeforeAttempt":[]}]} """.trimIndent() val backup = Json.decodeFromString(fixture) val restored = backup.pendingProofs.single().restored() diff --git a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt index 38afcab01f..32f92a9362 100644 --- a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt +++ b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt @@ -1,8 +1,10 @@ package to.bitkit.models +import to.bitkit.models.PubkyAuthClaim.Item import java.net.URLEncoder import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertNull @@ -10,6 +12,92 @@ import kotlin.test.assertTrue class PubkyAuthRequestTest { + @Test + fun `parse preserves each companion selection and received item order`() { + val expected = mapOf( + "watch-only-account-v1" to listOf(Item.WATCH_ONLY_ACCOUNT_V1), + "paykit-access-v1" to listOf(Item.PAYKIT_ACCESS_V1), + "paykit-access-v1.watch-only-account-v1" to listOf(Item.PAYKIT_ACCESS_V1, Item.WATCH_ONLY_ACCOUNT_V1), + "watch-only-account-v1.paykit-access-v1" to listOf(Item.WATCH_ONLY_ACCOUNT_V1, Item.PAYKIT_ACCESS_V1), + ) + for ((wireValue, items) in expected) { + val claim = requireNotNull( + PubkyAuthRequest.parseBitkitClaim( + authUrl("/pub/paykit/:rw", wireValue), + "/pub/paykit/:rw", + ).getOrThrow(), + ) + assertEquals(items, claim.items) + assertEquals(wireValue, claim.wireValue) + assertEquals(Item.PAYKIT_ACCESS_V1 in items, claim.includesPaykitAccess) + assertEquals(Item.WATCH_ONLY_ACCOUNT_V1 in items, claim.includesWatchOnlyAccount) + } + } + + @Test + fun `constructor copies items in canonical order and rejects empty or duplicate selections`() { + val items = arrayOf(Item.WATCH_ONLY_ACCOUNT_V1, Item.PAYKIT_ACCESS_V1) + val claim = PubkyAuthClaim(*items) + items[0] = Item.PAYKIT_ACCESS_V1 + + assertEquals(listOf(Item.PAYKIT_ACCESS_V1, Item.WATCH_ONLY_ACCOUNT_V1), claim.items) + assertEquals("paykit-access-v1.watch-only-account-v1", claim.wireValue) + assertFailsWith { PubkyAuthClaim() } + for (item in Item.entries) { + assertFailsWith { PubkyAuthClaim(item, item) } + } + } + + @Test + fun `parse rejects empty duplicate unknown and combined identifiers`() { + val invalid = listOf( + "", ".", ".paykit-access-v1", "paykit-access-v1.", + "paykit-access-v1..watch-only-account-v1", + "paykit-access-v1.paykit-access-v1", "watch-only-account-v1.watch-only-account-v1", + "paykit-access-v1.watch-only-account-v1.paykit-access-v1", + "unknown-v1", "paykit-access-v1.unknown-v1", "unknown-v1.watch-only-account-v1", + "paykit-access-and-watch-only-account-v1", "PAYKIT-ACCESS-V1", "paykit-access-v1%20", + ) + for (wireValue in invalid) { + assertIs( + PubkyAuthRequest.parseBitkitClaim(authUrl("/pub/paykit/:rw", wireValue), "/pub/paykit/:rw") + .exceptionOrNull(), + wireValue, + ) + } + } + + @Test + fun `parse rejects duplicate mixed or encoded companion parameters`() { + for (claim in companionSelections()) { + for (other in companionSelections()) { + val url = authUrl("/pub/paykit/:rw", claim.wireValue) + "&x-bitkit-%63laim=${other.wireValue}" + assertIs( + PubkyAuthRequest.parseBitkitClaim(url, "/pub/paykit/:rw").exceptionOrNull(), + ) + } + } + } + + @Test + fun `all companion claims require the exact Paykit scope`() { + val invalidCapabilities = listOf( + "/pub/paykit/:r", + "/pub/paykit/v0/:rw", + "/pub/:rw", + "/pub/paykit/:rw,/pub/other/:rw", + "/pub/paykit/:rw,/pub/paykit/:rw", + ) + for (claim in companionSelections()) { + for (capabilities in invalidCapabilities) { + assertIs( + PubkyAuthRequest.parseBitkitClaim(authUrl(capabilities, claim.wireValue), capabilities) + .exceptionOrNull(), + ) + } + } + } + @Test fun `parse authorized signup preserves registration and authorization details`() { listOf("pubkyring", "pubkyauth").forEach { scheme -> @@ -57,35 +145,22 @@ class PubkyAuthRequestTest { @Test fun `parse recognizes watch-only account claim`() { - val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + val capabilities = PubkyAuthClaim.REQUIRED_CAPABILITIES val request = PubkyAuthRequest.parse( - rawUrl = authUrl(capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue), + rawUrl = authUrl(capabilities, PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1).wireValue), clientId = "paykit.test", relay = "https://httprelay.pubky.app/inbox/", capabilities = capabilities, ).getOrThrow() - assertEquals(PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, request.bitkitClaim) + assertEquals(PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1), request.bitkitClaim) } @Test - fun `parse recognizes watch-only account claim with reordered capabilities`() { - val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES.split(",").reversed().joinToString(",") - val request = PubkyAuthRequest.parse( - rawUrl = authUrl(capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue), - clientId = "paykit.test", - relay = "https://httprelay.pubky.app/inbox/", - capabilities = capabilities, - ).getOrThrow() - - assertEquals(PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, request.bitkitClaim) - } - - @Test - fun `matcher recognizes watch-only account claim with capability whitespace`() { - val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES.replace(",", " , ") + fun `matcher recognizes the Paykit scope with surrounding whitespace`() { + val capabilities = " ${PubkyAuthClaim.REQUIRED_CAPABILITIES} " - assertTrue(PubkyAuthClaim.matchesWatchOnlyAccountCapabilities(capabilities)) + assertTrue(PubkyAuthClaim.matchesRequiredCapabilities(capabilities)) } @Test @@ -134,8 +209,8 @@ class PubkyAuthRequestTest { } @Test - fun `parse rejects watch-only capability without Bitkit claim`() { - val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + fun `parse permits Paykit authorization without a companion claim`() { + val capabilities = PubkyAuthClaim.REQUIRED_CAPABILITIES val result = PubkyAuthRequest.parse( rawUrl = authUrl(capabilities), clientId = "paykit.test", @@ -143,17 +218,17 @@ class PubkyAuthRequestTest { capabilities = capabilities, ) - assertIs(result.exceptionOrNull()) + assertEquals(null, result.getOrThrow().bitkitClaim) } @Test fun `parse rejects duplicate Bitkit claim`() { - val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + val capabilities = PubkyAuthClaim.REQUIRED_CAPABILITIES val result = PubkyAuthRequest.parse( rawUrl = authUrl( capabilities, - PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue, - PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue, + PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1).wireValue, + PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1).wireValue, ), clientId = "paykit.test", relay = "https://httprelay.pubky.app/inbox/", @@ -165,7 +240,7 @@ class PubkyAuthRequestTest { @Test fun `parse rejects unknown Bitkit claim`() { - val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + val capabilities = PubkyAuthClaim.REQUIRED_CAPABILITIES val result = PubkyAuthRequest.parse( rawUrl = authUrl(capabilities, "unknown-v1"), clientId = "paykit.test", @@ -181,7 +256,7 @@ class PubkyAuthRequestTest { fun `parse rejects watch-only claim with other capabilities`() { val capabilities = "/pub/paykit/v0/:rw" val result = PubkyAuthRequest.parse( - rawUrl = authUrl(capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue), + rawUrl = authUrl(capabilities, PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1).wireValue), clientId = "paykit.test", relay = "https://httprelay.pubky.app/inbox/", capabilities = capabilities, @@ -191,10 +266,10 @@ class PubkyAuthRequestTest { } @Test - fun `parse rejects watch-only claim without private capability`() { - val capabilities = "/pub/paykit/v0/bitkit/server/:rw" + fun `parse rejects watch-only claim without write capability`() { + val capabilities = "/pub/paykit/:r" val result = PubkyAuthRequest.parse( - rawUrl = authUrl(capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue), + rawUrl = authUrl(capabilities, PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1).wireValue), clientId = "paykit.test", relay = "https://httprelay.pubky.app/inbox/", capabilities = capabilities, @@ -205,9 +280,9 @@ class PubkyAuthRequestTest { @Test fun `parse rejects watch-only claim with empty capability`() { - val capabilities = "${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}," + val capabilities = "${PubkyAuthClaim.REQUIRED_CAPABILITIES}," val result = PubkyAuthRequest.parse( - rawUrl = authUrl(capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue), + rawUrl = authUrl(capabilities, PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1).wireValue), clientId = "paykit.test", relay = "https://httprelay.pubky.app/inbox/", capabilities = capabilities, @@ -272,8 +347,8 @@ class PubkyAuthRequestTest { @Test fun `displayPath removes capability separator`() { - val perm = PubkyAuthPermission(path = "/pub/paykit/v0/bitkit/server/", accessLevel = "rw") - assertEquals("/pub/paykit/v0/bitkit/server", perm.displayPath) + val perm = PubkyAuthPermission(path = "/pub/paykit/", accessLevel = "rw") + assertEquals("/pub/paykit", perm.displayPath) } @Test @@ -303,6 +378,13 @@ class PubkyAuthRequestTest { ) } + private fun companionSelections() = listOf( + PubkyAuthClaim(Item.PAYKIT_ACCESS_V1), + PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1), + PubkyAuthClaim(Item.PAYKIT_ACCESS_V1, Item.WATCH_ONLY_ACCOUNT_V1), + requireNotNull(PubkyAuthClaim.fromWireValue("watch-only-account-v1.paykit-access-v1")), + ) + private fun authUrl(capabilities: String, vararg claimValues: String): String { val claims = claimValues.joinToString(separator = "") { "&${PubkyAuthClaim.QUERY_PARAMETER}=$it" diff --git a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt index 61b8b3e102..7db4f233e2 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -173,6 +173,23 @@ class ActivityRepoTest : BaseUnitTest() { wheneverBlocking { coreService.activity.allPossibleTags() }.thenReturn(emptyList()) } + @Test + fun `Paykit backfill notifies activity observers only after a changed row`() = test { + whenever(coreService.activity.backfillPaykitContacts()).thenReturn(false, true) + + sut.backfillPaykitContacts().getOrThrow() + assertEquals(0L, sut.activitiesChanged.value) + sut.backfillPaykitContacts().getOrThrow() + assertTrue(sut.activitiesChanged.value > 0L) + assertEquals(0L, sut.activityTagsChanged.value) + } + + @Test + fun `Paykit backfill preserves cancellation`() = test { + whenever(coreService.activity.backfillPaykitContacts()).thenThrow(CancellationException("canceled")) + assertFailsWith { sut.backfillPaykitContacts() } + } + @Test fun `syncActivities success flow`() = test { val payments = listOf(testPaymentDetails) diff --git a/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt index a386543212..b9efee5774 100644 --- a/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt @@ -7,6 +7,7 @@ import org.junit.Before import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -43,7 +44,7 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { Unit } whenever { publicPaykitRepo.syncPublishedEndpoints(any()) }.thenReturn(Result.success(Unit)) - whenever { publicPaykitRepo.syncLocalReceiverMarker(anyOrNull(), anyOrNull()) } + whenever { publicPaykitRepo.syncPaykitApp(anyOrNull()) } .thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.enableSharingAndPrepareSavedContacts(any>()) } .thenReturn(Result.success(Unit)) @@ -105,7 +106,10 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { assertTrue(result.isFailure) assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) - verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) + inOrder(privatePaykitRepo, publicPaykitRepo) { + verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) + verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) + } } @Test @@ -134,8 +138,10 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) - verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) - verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) + inOrder(privatePaykitRepo, publicPaykitRepo) { + verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) + verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) + } } @Test diff --git a/app/src/test/java/to/bitkit/repositories/PaykitIssuerInteropTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitIssuerInteropTest.kt index 9bafd350c0..a5febcda89 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitIssuerInteropTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitIssuerInteropTest.kt @@ -114,7 +114,6 @@ class PaykitIssuerInteropTest { endpointIdentifiers: List, ) = PaymentRequestRecord( counterparty = "pubkyissuerfixture", - counterpartyReceiverPath = "bitkit/server", paymentRequestId = "71300000-0000-4000-8000-000000000001", localRole = PaymentRequestLocalRole.PAYER, state = PaymentRequestLifecycleState.PROPOSED, @@ -131,6 +130,8 @@ class PaykitIssuerInteropTest { conversion = null, paymentDeadline = null, metadata = METADATA, + paymentEndpoints = null, + requiredAppId = "bitkit", ), acceptedEventId = null, acceptedOutboundStatus = null, @@ -145,6 +146,9 @@ class PaykitIssuerInteropTest { lastOutboundStatus = null, lastEventAt = NOW.toString(), invalidReason = null, + proposalAppId = "bitkit", + payerAppId = null, + executionClaimAppId = null, ) } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 9079e92502..c91bd32910 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -31,7 +31,6 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.models.NodeLifecycleState import to.bitkit.models.WalletScope -import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError @@ -69,7 +68,9 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { shouldFailNextSave = false shouldFailProofRemoval = false whenever(store.hasPendingProofs()).thenReturn(true) - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, com.synonym.paykit.PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.processPendingPrivateMessages()).thenReturn(emptyList()) whenever(onchainPaymentLookup.existingTransactionIds(any(), any(), any())).thenReturn(emptySet()) whenever(store.load()).thenAnswer { @@ -114,7 +115,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `completed lightning proof retries after repository restart`() = test { + fun `completed lightning proof retains the payment app when retrying after repository restart`() = test { val record = paymentRequestRecord() val request = paymentRequest(MethodId.Bolt11.rawValue) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) @@ -123,8 +124,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { .thenReturn(record) val firstRepo = paymentProofRepo() - firstRepo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - firstRepo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() + firstRepo.prepare(request, MethodId.Bolt11.rawValue, "merchant", PaykitPaymentProofKind.Lightning).getOrThrow() + firstRepo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "merchant").getOrThrow() firstRepo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) assertEquals(PREIMAGE, storedProofs.single().proofData) @@ -135,8 +136,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val proofCaptor = argumentCaptor() verify(paykitSdkService, times(2)).submitPaymentProof( counterparty = any(), - counterpartyReceiverPath = any(), paymentRequestId = any(), + paymentAppId = eq("merchant"), paymentEndpointIdentifier = endpointCaptor.capture(), proofJson = proofCaptor.capture(), billingPeriod = isNull(), @@ -172,8 +173,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val paymentRequestIdCaptor = argumentCaptor() verify(paykitSdkService, times(2)).submitPaymentProof( counterparty = any(), - counterpartyReceiverPath = any(), paymentRequestId = paymentRequestIdCaptor.capture(), + paymentAppId = eq("bitkit"), paymentEndpointIdentifier = any(), proofJson = any(), billingPeriod = isNull(), @@ -200,8 +201,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) val firstRepo = paymentProofRepo() - firstRepo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - firstRepo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() + firstRepo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() + firstRepo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() assertNull(storedProofs.single().proofData) paymentProofRepo().reconcile() @@ -210,8 +211,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val proofCaptor = argumentCaptor() verify(paykitSdkService).submitPaymentProof( counterparty = any(), - counterpartyReceiverPath = any(), paymentRequestId = any(), + paymentAppId = eq("bitkit"), paymentEndpointIdentifier = any(), proofJson = proofCaptor.capture(), billingPeriod = isNull(), @@ -231,7 +232,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) val repo = paymentProofRepo() - repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() repo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) @@ -243,8 +244,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.Bolt11.rawValue) val repo = paymentProofRepo() - repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() repo.completeLightningPayment(PAYMENT_HASH, "01".repeat(32)) assertNull(storedProofs.single().proofData) @@ -259,6 +260,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val existingProof = mock { on { billingPeriod } doReturn null on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue + on { paymentAppId } doReturn "bitkit" on { proof } doReturn existingProofJson } val record = paymentRequestRecord(listOf(existingProof)) @@ -266,8 +268,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) val repo = paymentProofRepo() - repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() repo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) assertTrue(storedProofs.isEmpty()) @@ -279,8 +281,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.Bolt11.rawValue) val repo = paymentProofRepo() - repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() repo.failLightningPayment(PAYMENT_HASH) assertTrue(storedProofs.isEmpty()) @@ -302,8 +304,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { for (error in errors) { val request = paymentRequest(MethodId.Bolt11.rawValue) val repo = paymentProofRepo() - repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() val failed = repo.failLightningPayment(PAYMENT_HASH, error) repo.cancelPreparation(request) @@ -313,7 +315,12 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertFalse(failed) assertTrue(storedProofs.single().paymentStarted) assertEquals(PAYMENT_HASH, storedProofs.single().paymentIdentifier) - val retry = restartedRepo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning) + val retry = restartedRepo.prepare( + request, + MethodId.Bolt11.rawValue, + "bitkit", + PaykitPaymentProofKind.Lightning + ) assertTrue(retry.exceptionOrNull() is PaykitPaymentRequestError.OperationInProgress) restartedRepo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) @@ -337,8 +344,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { for (error in errors) { val request = paymentRequest(MethodId.Bolt11.rawValue) val repo = paymentProofRepo() - repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() assertTrue(repo.failLightningPayment(PAYMENT_HASH, error)) assertTrue(storedProofs.isEmpty()) @@ -354,10 +361,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) val repo = paymentProofRepo() - repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() assertTrue(storedProofs.single().paymentStarted) - repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue) + repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue, "bitkit") val endpointCaptor = argumentCaptor() val proofCaptor = argumentCaptor() @@ -382,7 +389,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.P2wpkh.rawValue) val repo = paymentProofRepo() - repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() repo.cancelPreparation(request) @@ -394,7 +401,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.P2wpkh.rawValue) val repo = paymentProofRepo() - repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() repo.failOnchainPayment(request) @@ -405,7 +412,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `onchain failure clears started proof without a live identity`() = test { val request = paymentRequest(MethodId.P2wpkh.rawValue) val repo = paymentProofRepo() - repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() whenever(paykitSdkService.identityStatus()).thenReturn(null) @@ -418,7 +425,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `cancel preparation clears proof without a live identity`() = test { val request = paymentRequest(MethodId.Bolt11.rawValue) val repo = paymentProofRepo() - repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() whenever(paykitSdkService.identityStatus()).thenReturn(null) repo.cancelPreparation(request) @@ -436,7 +443,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) val repo = paymentProofRepo() - repo.completeOnchainPayment(request, txid, endpoint) + repo.completeOnchainPayment(request, txid, endpoint, "bitkit") verify(paykitSdkService).submitPaymentProof(any(), any(), any(), eq(endpoint), any(), isNull()) assertTrue(storedProofs.isEmpty()) @@ -454,15 +461,15 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), any())).thenReturn(record) val repo = paymentProofRepo() - repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() repo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) val periodCaptor = argumentCaptor() verify(paykitSdkService).submitPaymentProof( counterparty = any(), - counterpartyReceiverPath = any(), paymentRequestId = any(), + paymentAppId = eq("bitkit"), paymentEndpointIdentifier = any(), proofJson = any(), billingPeriod = periodCaptor.capture(), @@ -485,6 +492,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { endsAt = "2027-02-01T08:00:00.000Z", ) on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue + on { paymentAppId } doReturn "bitkit" on { proof } doReturn existingProofJson } val record = paymentRequestRecord(listOf(existingProof)) @@ -493,8 +501,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), any())).thenReturn(record) val repo = paymentProofRepo() - repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() repo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), any()) @@ -505,15 +513,15 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.Bolt11.rawValue) val repo = paymentProofRepo() - repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() - val retry = repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning) + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() + val retry = repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning) assertTrue(retry.exceptionOrNull() is PaykitPaymentRequestError.OperationInProgress) assertEquals(PAYMENT_HASH, storedProofs.single().paymentIdentifier) repo.failLightningPayment(PAYMENT_HASH) - repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() assertEquals(1, storedProofs.size) } @@ -524,9 +532,9 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val secondRequest = paymentRequest(MethodId.Bolt11.rawValue, secondRequestId) val repo = paymentProofRepo() - repo.prepare(firstRequest, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() + repo.prepare(firstRequest, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() storedProofs = emptyList() - repo.prepare(secondRequest, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() + repo.prepare(secondRequest, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() assertEquals(1, storedProofs.size) assertEquals(secondRequestId, storedProofs.single().requestId.paymentRequestId) @@ -541,10 +549,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) val repo = paymentProofRepo() - repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() shouldFailNextSave = true - repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue) + repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue, "bitkit") verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) assertTrue(storedProofs.isEmpty()) @@ -560,10 +568,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { .thenThrow(IllegalStateException("transient submission failure")) val repo = paymentProofRepo() - repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() shouldFailNextSave = true - repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue) + repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue, "bitkit") assertEquals(txid, storedProofs.single().proofData) verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) @@ -578,10 +586,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) val repo = paymentProofRepo() - repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() shouldFailProofRemoval = true - repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue) + repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue, "bitkit") verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) assertEquals(txid, storedProofs.single().proofData) @@ -597,17 +605,17 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) val repo = paymentProofRepo() - repo.prepare(request, endpoint, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.prepare(request, endpoint, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() shouldFailNextLoad = true - repo.completeOnchainPayment(request, txid, endpoint) + repo.completeOnchainPayment(request, txid, endpoint, "bitkit") val endpointCaptor = argumentCaptor() val proofCaptor = argumentCaptor() verify(paykitSdkService).submitPaymentProof( counterparty = any(), - counterpartyReceiverPath = any(), paymentRequestId = any(), + paymentAppId = eq("bitkit"), paymentEndpointIdentifier = endpointCaptor.capture(), proofJson = proofCaptor.capture(), billingPeriod = isNull(), @@ -637,7 +645,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ).thenReturn(txid) val repo = paymentProofRepo() - repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() repo.reconcile() @@ -645,8 +653,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val proofCaptor = argumentCaptor() verify(paykitSdkService).submitPaymentProof( counterparty = eq(request.counterparty), - counterpartyReceiverPath = eq(request.counterpartyReceiverPath), paymentRequestId = eq(request.paymentRequestId), + paymentAppId = eq("bitkit"), paymentEndpointIdentifier = eq(MethodId.P2wpkh.rawValue), proofJson = proofCaptor.capture(), billingPeriod = isNull(), @@ -670,9 +678,9 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(onchainPaymentLookup.transactionId(any(), any(), any(), any())) .thenReturn("ab".repeat(32), "cd".repeat(32)) val repo = paymentProofRepo() - repo.prepare(firstRequest, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.prepare(firstRequest, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(firstRequest, ONCHAIN_ADDRESS).getOrThrow() - repo.prepare(secondRequest, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.prepare(secondRequest, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(secondRequest, ONCHAIN_ADDRESS).getOrThrow() repo.reconcile() @@ -700,7 +708,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ).thenReturn(null) val repo = paymentProofRepo() - repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() repo.reconcile() @@ -717,11 +725,12 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { requestId = request.id, paymentEndpointIdentifier = MethodId.Bolt11.rawValue, kind = PaykitPaymentProofKind.Lightning, + paymentAppId = "bitkit", ) storedProofs = listOf(otherIdentityProof) val repo = paymentProofRepo() - repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() repo.cancelPreparation(request) assertEquals(listOf(otherIdentityProof), storedProofs) @@ -735,11 +744,11 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) val request = paymentRequest(MethodId.Bolt11.rawValue, billingPeriod = period) val repo = paymentProofRepo() - repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() val protectedRequestIds = repo.protectedRequestIdsForSubscriptionCancellation( LOCAL_IDENTITY, - PaykitSubscriptionId(PAYMENT_REQUEST_ID, COUNTERPARTY, PaykitReceiverPaths.WALLET), + PaykitSubscriptionId(PAYMENT_REQUEST_ID, COUNTERPARTY), ).getOrThrow() assertTrue(protectedRequestIds.isEmpty()) @@ -754,12 +763,12 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) val request = paymentRequest(MethodId.Bolt11.rawValue, billingPeriod = period) val repo = paymentProofRepo() - repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() val protectedRequestIds = repo.protectedRequestIdsForSubscriptionCancellation( LOCAL_IDENTITY, - PaykitSubscriptionId(PAYMENT_REQUEST_ID, COUNTERPARTY, PaykitReceiverPaths.WALLET), + PaykitSubscriptionId(PAYMENT_REQUEST_ID, COUNTERPARTY), ).getOrThrow() assertEquals(setOf(request.id), protectedRequestIds) @@ -781,7 +790,6 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) = PaykitPaymentRequest( paymentRequestId = paymentRequestId, counterparty = COUNTERPARTY, - counterpartyReceiverPath = PaykitReceiverPaths.WALLET, amountValue = "0.00001", amountSats = 1_000uL, expiresAt = null, @@ -793,7 +801,6 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { identity = LOCAL_IDENTITY, requestId = PaykitPaymentRequestId( counterparty = COUNTERPARTY, - counterpartyReceiverPath = PaykitReceiverPaths.WALLET, paymentRequestId = paymentRequestId, ), paymentEndpointIdentifier = MethodId.Bolt11.rawValue, @@ -801,6 +808,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentStarted = true, paymentIdentifier = PAYMENT_HASH, proofData = PREIMAGE, + paymentAppId = "bitkit", ) private fun paymentRequestRecord( @@ -808,7 +816,6 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentRequestId: String = PAYMENT_REQUEST_ID, ) = PaymentRequestRecord( counterparty = COUNTERPARTY, - counterpartyReceiverPath = PaykitReceiverPaths.WALLET, paymentRequestId = paymentRequestId, localRole = PaymentRequestLocalRole.PAYER, state = PaymentRequestLifecycleState.PROPOSED, @@ -825,6 +832,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { conversion = null, paymentDeadline = null, metadata = mock(), + paymentEndpoints = null, + requiredAppId = "bitkit", ), acceptedEventId = null, acceptedOutboundStatus = null, @@ -839,5 +848,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { lastOutboundStatus = null, lastEventAt = "2027-01-15T08:00:00Z", invalidReason = null, + proposalAppId = "bitkit", + payerAppId = null, + executionClaimAppId = null, ) } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt index 4b4310e50f..d16a5ea6c6 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt @@ -40,7 +40,7 @@ class PaykitPaymentRequestPresentationStoreTest : BaseUnitTest() { Unit } val sut = PaykitPaymentRequestPresentationStore(keychain) - val requestId = PaykitPaymentRequestId("request", COUNTERPARTY, "bitkit/server") + val requestId = PaykitPaymentRequestId("request", COUNTERPARTY) val loadError = assertFailsWith { sut.load(IDENTITY) } val saveError = assertFailsWith { sut.save(IDENTITY, setOf(requestId)) } @@ -63,8 +63,8 @@ class PaykitPaymentRequestPresentationStoreTest : BaseUnitTest() { Unit } val sut = PaykitPaymentRequestPresentationStore(keychain) - val requestId = PaykitPaymentRequestId("request", COUNTERPARTY, "bitkit/server") - val subscriptionId = PaykitSubscriptionId("subscription", COUNTERPARTY, "bitkit/server") + val requestId = PaykitPaymentRequestId("request", COUNTERPARTY) + val subscriptionId = PaykitSubscriptionId("subscription", COUNTERPARTY) val dismissedOnly = PaykitSubscriptionPresentationState(dismissedPaymentIds = setOf(requestId)) val accepted = dismissedOnly.copy( acceptedAt = mapOf(subscriptionId to Instant.parse("2026-09-24T08:00:00.123Z")), @@ -102,8 +102,8 @@ class PaykitPaymentRequestPresentationStoreTest : BaseUnitTest() { Unit } val sut = PaykitPaymentRequestPresentationStore(keychain) - val millisecondId = PaykitSubscriptionId("millisecond", COUNTERPARTY, "bitkit/server") - val nanosecondId = PaykitSubscriptionId("nanosecond", COUNTERPARTY, "bitkit/server") + val millisecondId = PaykitSubscriptionId("millisecond", COUNTERPARTY) + val nanosecondId = PaykitSubscriptionId("nanosecond", COUNTERPARTY) val acceptedAt = mapOf( millisecondId to Instant.parse("2026-09-24T10:00:00.123Z"), nanosecondId to Instant.parse("2026-09-24T10:00:00.123456789Z"), @@ -139,7 +139,7 @@ class PaykitPaymentRequestPresentationStoreTest : BaseUnitTest() { fun `invalid subscription timestamp identifies its preserved key`() = test { val keychain = mock() val value = """ - {"subscriptionStatesByIdentity":{"$IDENTITY":{"acceptances":[{"id":{"paymentRequestId":"subscription","counterparty":"$COUNTERPARTY","counterpartyReceiverPath":"bitkit/server"},"acceptedAt":"not-a-timestamp"}]}}} + {"subscriptionStatesByIdentity":{"$IDENTITY":{"acceptances":[{"id":{"paymentRequestId":"subscription","counterparty":"$COUNTERPARTY"},"acceptedAt":"not-a-timestamp"}]}}} """.trimIndent() whenever(keychain.loadString(KEY)).thenReturn(value) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt index 7efb36af63..98a383ac66 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt @@ -16,6 +16,7 @@ import com.synonym.paykit.PaymentRequestRecord import com.synonym.paykit.PaymentRequestRecurrence import com.synonym.paykit.PaymentRequestTerms import com.synonym.paykit.PrivateJsonObject +import com.synonym.paykit.PubkyIdentityCapability import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async @@ -41,7 +42,6 @@ import org.mockito.kotlin.whenever import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.services.PaykitPaymentRequestProposalTerms -import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals @@ -88,7 +88,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat schedulerOriginMillis = testDispatcher.scheduler.currentTime whenever(paykitSdkService.processPendingPrivateMessages()).thenReturn(emptyList()) whenever(paykitSdkService.receivePrivateMessagesFromLinkedPeers()).thenReturn(emptyList()) - whenever(paykitSdkService.paymentRequests()).thenReturn(emptyList()) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(emptyList()) whenever(paykitSdkService.linkedPeers()).thenReturn(emptyList()) whenever(settingsStore.isPaykitEnabled).thenReturn(flowOf(true)) whenever(settingsStore.data).thenReturn(flowOf(SettingsData(sharesPrivatePaykitEndpoints = true))) @@ -127,7 +127,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat val metadata = mock { on { exportText() } doReturn metadataText } - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord( id = "recurring", @@ -166,7 +166,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat val metadata = mock { on { exportText() } doReturn metadataText } - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord( role = PaymentRequestLocalRole.PAYEE, @@ -219,17 +219,17 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat @Test fun `creator proposal sends recurring terms and stays queued until delivery`() = test { - val target = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + val target = PaykitPaymentRequestTarget(COUNTERPARTY) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), - ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).thenReturn( - listOf(PaykitReceiverPaths.SERVER), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.proposePaymentRequest(any(), any(), any(), eq(LOCAL_IDENTITY))) + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).thenReturn(true) + whenever(paykitSdkService.proposePaymentRequest(any(), any(), eq(LOCAL_IDENTITY))) .thenAnswer { invocation -> - val proposal = invocation.getArgument(2) + val proposal = invocation.getArgument(1) paymentRequestRecord( role = PaymentRequestLocalRole.PAYEE, expiresAt = proposal.proposalExpiresAt, @@ -259,7 +259,6 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat verifyBlocking(paykitSdkService) { proposePaymentRequest( eq(COUNTERPARTY), - eq(PaykitReceiverPaths.SERVER), captured.capture(), eq(LOCAL_IDENTITY), ) @@ -282,13 +281,15 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat @Test fun `oversized creator proposal is rejected before icon upload or enqueue`() = test { - val target = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + val target = PaykitPaymentRequestTarget(COUNTERPARTY) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)) - .thenReturn(listOf(PaykitReceiverPaths.SERVER)) + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)) + .thenReturn(true) listOf(null, byteArrayOf(0, 1, 2)).forEach { icon -> val result = sut.proposeSubscription( @@ -307,7 +308,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat } verifyBlocking(paykitSdkService, never()) { uploadProfileAvatar(any(), any(), anyOrNull()) } - verifyBlocking(paykitSdkService, never()) { proposePaymentRequest(any(), any(), any(), any()) } + verifyBlocking(paykitSdkService, never()) { proposePaymentRequest(any(), any(), any()) } assertTrue(sut.subscriptions.value.isEmpty()) assertFalse(sut.isCreatingRequest.value) } @@ -319,11 +320,10 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat role = PaymentRequestLocalRole.PAYEE, state = PaymentRequestLifecycleState.CANCELED, ) - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(proposed), listOf(canceled)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(proposed), listOf(canceled)) whenever( paykitSdkService.cancelPaymentRequest( COUNTERPARTY, - PaykitReceiverPaths.SERVER, PAYMENT_REQUEST_ID, ) ).thenReturn(canceled) @@ -335,7 +335,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat protectedRequestIdsForSubscriptionCancellation(any(), any()) } verifyBlocking(paykitSdkService) { - cancelPaymentRequest(COUNTERPARTY, PaykitReceiverPaths.SERVER, PAYMENT_REQUEST_ID) + cancelPaymentRequest(COUNTERPARTY, PAYMENT_REQUEST_ID) } assertEquals(PaymentRequestLifecycleState.CANCELED, sut.subscriptions.value.single().lifecycleState) assertFalse(sut.subscriptions.value.single().isCreatedVisible(clock.now())) @@ -358,9 +358,9 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat state = PaymentRequestLifecycleState.CANCELED, paymentProofs = listOf(proof), ) - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(active), listOf(canceled)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(active), listOf(canceled)) whenever( - paykitSdkService.cancelPaymentRequest(COUNTERPARTY, PaykitReceiverPaths.SERVER, PAYMENT_REQUEST_ID) + paykitSdkService.cancelPaymentRequest(COUNTERPARTY, PAYMENT_REQUEST_ID) ).thenReturn(canceled) sut.refresh().getOrThrow() val subscription = sut.subscriptions.value.single() @@ -383,7 +383,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat @Test fun `refresh does not report subscription proposals as one time parse failures`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(paymentRequestRecord())) sut.refresh().getOrThrow() @@ -401,7 +401,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat anchor = "2027-01-01T08:00:00Z", endsAt = null, ) - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf(paymentRequestRecord(recurrence = unsupportedRecurrence)), ) @@ -419,20 +419,21 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat fun `accepting subscription returns current period and preserves payment targets`() = test { val proposal = paymentRequestRecord() val active = paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING) - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(proposal), listOf(active)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(proposal), listOf(active)) whenever( paykitSdkService.acceptPaymentRequest( COUNTERPARTY, - PaykitReceiverPaths.SERVER, PAYMENT_REQUEST_ID, ) ).thenReturn(active) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)) - .thenReturn(listOf(PaykitReceiverPaths.SERVER)) - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)) + .thenReturn(true) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) sut.refresh().getOrThrow() sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() @@ -453,7 +454,9 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat fun `accepting subscription rejects terms changed after review`() = test { val reviewedRecord = paymentRequestRecord() val changedRecord = paymentRequestRecord(amount = "0.002") - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(reviewedRecord), listOf(changedRecord)) + whenever( + paykitSdkService.allPaymentRequests(anyOrNull()) + ).thenReturn(listOf(reviewedRecord), listOf(changedRecord)) sut.refresh().getOrThrow() val reviewedSubscription = sut.subscriptions.value.single() sut.refresh().getOrThrow() @@ -461,20 +464,19 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat val result = sut.accept(reviewedSubscription) assertTrue(result.exceptionOrNull() is PaykitPaymentRequestError.RequestUnavailable) - verifyBlocking(paykitSdkService, never()) { acceptPaymentRequest(any(), any(), any()) } + verifyBlocking(paykitSdkService, never()) { acceptPaymentRequest(any(), any()) } } @Test fun `accepted subscription stays successful when its immediate refresh fails`() = test { val proposal = paymentRequestRecord() val active = paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING) - whenever(paykitSdkService.paymentRequests()) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())) .thenReturn(listOf(proposal)) .thenThrow(IllegalStateException("refresh failed")) whenever( paykitSdkService.acceptPaymentRequest( COUNTERPARTY, - PaykitReceiverPaths.SERVER, PAYMENT_REQUEST_ID, ) ).thenReturn(active) @@ -489,7 +491,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat @Test fun `dismissed subscription period stays out of queue after refresh`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf(paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING)), ) sut.refresh().getOrThrow() @@ -508,7 +510,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat @Test fun `failed dismissal persistence keeps subscription payment in queue`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf(paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING)), ) sut.refresh().getOrThrow() @@ -535,7 +537,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat resumeRefresh.await() emptyList() } - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf(paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING)), ) whenever(presentationStore.load(SECOND_IDENTITY)).thenReturn(emptySet()) @@ -559,12 +561,11 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat val requestId = PaykitPaymentRequestId( paymentRequestId = PAYMENT_REQUEST_ID, counterparty = COUNTERPARTY, - counterpartyReceiverPath = PaykitReceiverPaths.SERVER, billingPeriodStartsAt = "2027-01-01T08:00:00Z", ) whenever(paymentProofStore.completedRequestProofKindsAwaitingSubmission(LOCAL_IDENTITY)) .thenReturn(mapOf(requestId to PaykitPaymentProofKind.Onchain)) - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord( state = PaymentRequestLifecycleState.ACTIVE_RECURRING, @@ -593,7 +594,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat ) on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue } - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord( state = PaymentRequestLifecycleState.ACTIVE_RECURRING, @@ -618,7 +619,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat ) on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue } - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord( state = PaymentRequestLifecycleState.CANCELED, @@ -627,7 +628,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat ), ) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED)), ) sut.refresh().getOrThrow() @@ -650,7 +651,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat ) on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue } - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord( role = PaymentRequestLocalRole.PAYEE, @@ -660,7 +661,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat ), ) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED)), ) sut.refresh().getOrThrow() @@ -683,7 +684,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat ) on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue } - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord( state = PaymentRequestLifecycleState.ACTIVE_RECURRING, @@ -693,7 +694,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat ), ) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED)), ) sut.refresh().getOrThrow() @@ -712,7 +713,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat on { billingPeriod } doReturn BillingPeriod("2027-01-01T08:00:00Z", "2027-02-01T08:00:00Z") on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue } - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf(PaymentRequestLocalRole.PAYER, PaymentRequestLocalRole.PAYEE).map { role -> paymentRequestRecord( id = role.name, @@ -744,11 +745,10 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat val requestId = PaykitPaymentRequestId( paymentRequestId = PAYMENT_REQUEST_ID, counterparty = COUNTERPARTY, - counterpartyReceiverPath = PaykitReceiverPaths.SERVER, billingPeriodStartsAt = "2027-01-01T08:00:00Z", ) whenever(paymentProofStore.inFlightRequestIds(LOCAL_IDENTITY)).thenReturn(setOf(requestId)) - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf(paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING)), ) @@ -763,20 +763,19 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat val requestId = PaykitPaymentRequestId( paymentRequestId = PAYMENT_REQUEST_ID, counterparty = COUNTERPARTY, - counterpartyReceiverPath = PaykitReceiverPaths.SERVER, billingPeriodStartsAt = "2027-01-01T08:00:00Z", ) val active = paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING) whenever(paymentProofRepo.protectedRequestIdsForSubscriptionCancellation(eq(LOCAL_IDENTITY), any())) .thenReturn(Result.success(setOf(requestId))) - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(active)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(active)) sut.refresh().getOrThrow() val result = sut.cancel(sut.subscriptions.value.single()) assertTrue(result.exceptionOrNull() is PaykitPaymentRequestError.OperationInProgress) assertEquals(1, sut.subscriptions.value.size) - verifyBlocking(paykitSdkService, never()) { cancelPaymentRequest(any(), any(), any(), anyOrNull()) } + verifyBlocking(paykitSdkService, never()) { cancelPaymentRequest(any(), any(), anyOrNull()) } } @Test @@ -789,11 +788,10 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat state = PaymentRequestLifecycleState.CANCELED, paymentDeadline = PaymentDeadline.PeriodStart(3600uL), ) - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(active), emptyList()) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(active), emptyList()) whenever( paykitSdkService.cancelPaymentRequest( COUNTERPARTY, - PaykitReceiverPaths.SERVER, PAYMENT_REQUEST_ID, ) ).thenReturn(canceled) @@ -802,13 +800,13 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat sut.cancel(sut.subscriptions.value.single()).getOrThrow() verifyBlocking(paykitSdkService) { - cancelPaymentRequest(COUNTERPARTY, PaykitReceiverPaths.SERVER, PAYMENT_REQUEST_ID) + cancelPaymentRequest(COUNTERPARTY, PAYMENT_REQUEST_ID) } } @Test fun `malformed expiry is rejected and unsupported payment details disable acceptance`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord(id = "malformed", expiresAt = "not-a-timestamp"), paymentRequestRecord(id = "deadline", paymentDeadline = PaymentDeadline.PeriodStart(3600uL)), @@ -825,12 +823,12 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat val deadlineSubscription = subscriptions.first { it.paymentRequestId == "deadline" } assertEquals(null, deadlineSubscription.paymentDueOnAcceptance(clock.now())) assertTrue(sut.accept(deadlineSubscription).exceptionOrNull() is PaykitPaymentRequestError.RequestUnavailable) - verifyBlocking(paykitSdkService, never()) { acceptPaymentRequest(any(), any(), any()) } + verifyBlocking(paykitSdkService, never()) { acceptPaymentRequest(any(), any()) } } @Test fun `presented subscription stays available without auto presenting after reactivation`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf(paymentRequestRecord(id = "subscription")), ) sut.refresh().getOrThrow() @@ -855,7 +853,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat @Test fun `subscription proposal moves to expired at its deadline`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf(paymentRequestRecord(expiresAt = clock.now().plus(10.seconds).toString())), ) sut.refresh().getOrThrow() @@ -876,7 +874,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat anchor = "2027-01-01T08:00:00Z", endsAt = clock.now().plus(10.seconds).toString(), ) - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf(paymentRequestRecord(recurrence = endingRecurrence)), ) sut.refresh().getOrThrow() @@ -890,7 +888,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat @Test fun `ended subscription keeps its unpaid period available`() = test { - val subscriptionId = PaykitSubscriptionId(PAYMENT_REQUEST_ID, COUNTERPARTY, PaykitReceiverPaths.SERVER) + val subscriptionId = PaykitSubscriptionId(PAYMENT_REQUEST_ID, COUNTERPARTY) val endingRecurrence = PaymentRequestRecurrence( every = 1u, unit = "month", @@ -904,7 +902,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat acceptedAt = mapOf(subscriptionId to Instant.parse("2027-01-01T08:00:00Z")), ), ) - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord( state = PaymentRequestLifecycleState.ACTIVE_RECURRING, @@ -937,7 +935,6 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat paymentProofs: List = emptyList(), ) = PaymentRequestRecord( counterparty = COUNTERPARTY, - counterpartyReceiverPath = PaykitReceiverPaths.SERVER, paymentRequestId = id, localRole = role, state = state, @@ -954,6 +951,8 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat conversion = null, paymentDeadline = paymentDeadline, metadata = metadata, + paymentEndpoints = null, + requiredAppId = "bitkit", ), acceptedEventId = null, acceptedOutboundStatus = null, @@ -968,14 +967,15 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat lastOutboundStatus = null, lastEventAt = clock.now().toString(), invalidReason = null, + proposalAppId = "bitkit", + payerAppId = null, + executionClaimAppId = null, ) private fun linkedPeer( publicKey: String, state: LinkedPeerState, - receiverPath: String, ) = LinkedPeerRecord( counterparty = publicKey, - counterpartyReceiverPath = receiverPath, state = state, lastSyncAt = null, lastPrivateReceiveAt = null, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index c198abd9da..9549766b94 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -2,6 +2,10 @@ package to.bitkit.repositories +import com.synonym.bitkitcore.AddressType +import com.synonym.bitkitcore.NetworkType +import com.synonym.bitkitcore.ValidationResult +import com.synonym.bitkitcore.validateBitcoinAddress import com.synonym.paykit.IdentityStatus import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState @@ -17,6 +21,7 @@ import com.synonym.paykit.PaymentRequestTerms import com.synonym.paykit.PrivateJsonObject import com.synonym.paykit.PrivateOperationError import com.synonym.paykit.PrivateStreamCounterpartyIntakeReport +import com.synonym.paykit.PubkyIdentityCapability import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async @@ -27,7 +32,9 @@ import kotlinx.coroutines.test.runCurrent import org.junit.After import org.junit.Before import org.junit.Test +import org.mockito.Mockito.mockStatic import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.doReturn @@ -41,8 +48,8 @@ import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore +import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.services.PaykitPaymentRequestProposalTerms -import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals @@ -91,7 +98,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { schedulerOriginMillis = testDispatcher.scheduler.currentTime whenever(paykitSdkService.processPendingPrivateMessages()).thenReturn(emptyList()) whenever(paykitSdkService.receivePrivateMessagesFromLinkedPeers()).thenReturn(emptyList()) - whenever(paykitSdkService.paymentRequests()).thenReturn(emptyList()) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(emptyList()) whenever(paykitSdkService.linkedPeers()).thenReturn(emptyList()) whenever(settingsStore.isPaykitEnabled).thenReturn(flowOf(true)) whenever(settingsStore.data).thenReturn(flowOf(SettingsData(sharesPrivatePaykitEndpoints = true))) @@ -122,18 +129,52 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.clear() } + @Test + fun `shared app destinations stay out of request UI and clear on identity switch`() = test { + val address = PaykitReceivedPaymentContactsTest.ADDRESS + val record = paymentRequestRecord(role = PaymentRequestLocalRole.PAYEE).let { + it.copy( + proposalAppId = "marketplace", + terms = it.terms!!.copy( + acceptedPaymentEndpointIdentifiers = listOf(MethodId.P2wpkh.rawValue), + paymentEndpoints = mapOf( + MethodId.P2wpkh.rawValue to PaykitReceivedPaymentContactsTest.payload(address) + ), + ) + ) + } + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) + mockStatic(Class.forName("com.synonym.bitkitcore.Bitkitcore_androidKt")).use { native -> + native.`when` { validateBitcoinAddress(address) } + .thenReturn(ValidationResult(address, NetworkType.REGTEST, AddressType.P2WPKH)) + sut.refresh().getOrThrow() + } + assertEquals( + setOf(PubkyPublicKeyFormat.normalized(COUNTERPARTY)), + sut.receivedPaymentContacts.contactsForAddresses(listOf(address)) + ) + assertTrue(sut.pendingRequests.value.isEmpty()) + assertTrue(sut.paymentRequestHistory.value.isEmpty()) + verify(paykitSdkService).allPaymentRequests(PubkyPublicKeyFormat.normalized(LOCAL_IDENTITY)) + + sut.activate(SECOND_IDENTITY) + assertTrue(sut.receivedPaymentContacts.contactsForAddresses(listOf(address)).isEmpty()) + sut.clear() + assertTrue(sut.receivedPaymentContacts.contactsForAddresses(listOf(address)).isEmpty()) + } + @Test fun `blocking peer hides requests from an earlier snapshot`() = test { val record = paymentRequestRecord() - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) sut.refresh().getOrThrow() assertEquals(1, sut.pendingRequests.value.size) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED, record.counterpartyReceiverPath)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED)), ) sut.refresh().getOrThrow() assertTrue(sut.pendingRequests.value.isEmpty()) - whenever(paykitSdkService.paymentRequests()).thenReturn(emptyList()) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(emptyList()) sut.refresh().getOrThrow() assertTrue(sut.paymentRequestHistory.value.isEmpty()) } @@ -141,11 +182,11 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `blocking an already presented accepted request prevents payment`() = test { val record = paymentRequestRecord(state = PaymentRequestLifecycleState.ACCEPTED) - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) sut.refresh().getOrThrow() val request = sut.pendingRequests.value.single() whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED, record.counterpartyReceiverPath)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED)), ) assertEquals(PaykitPaymentRequestError.RequestUnavailable, sut.accept(request).exceptionOrNull()) } @@ -153,7 +194,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `accepted request checks blocking after waiting for synchronization`() = test { val record = paymentRequestRecord(state = PaymentRequestLifecycleState.ACCEPTED) - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) sut.refresh().getOrThrow() val request = sut.pendingRequests.value.single() val refreshPaused = CompletableDeferred() @@ -166,7 +207,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { resumeRefresh.await() emptyList() } else if (blocked) { - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED, record.counterpartyReceiverPath)) + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED)) } else { emptyList() } @@ -185,7 +226,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `refresh maps actionable bitcoin request`() = test { val record = paymentRequestRecord(expiresAt = clock.now().plus(60.seconds).toString()) - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) sut.refresh().getOrThrow() @@ -200,12 +241,11 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { val error = mock { on { redactedContext() } doReturn "transport failure" } - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) whenever(paykitSdkService.receivePrivateMessagesFromLinkedPeers()).thenReturn( listOf( PrivateStreamCounterpartyIntakeReport( counterparty = COUNTERPARTY, - counterpartyReceiverPath = PaykitReceiverPaths.WALLET, report = null, error = error, ), @@ -254,7 +294,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { asset = "BTC", counterparty = "secret", ) - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) sut.refresh().getOrThrow() @@ -262,22 +302,22 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `refresh logs unknown local role as unsupported_local_role`() = test { + fun `refresh excludes unknown local roles at the app boundary`() = test { val record = paymentRequestRecord( role = PaymentRequestLocalRole.UNKNOWN, counterparty = "secret", ) - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) sut.refresh().getOrThrow() - verify(diagnostics).logParseRejection("secret", PaykitPaymentRequest.ParseFailure.UnsupportedLocalRole) + verify(diagnostics, never()).logParseRejection(any(), any()) assertTrue(sut.pendingRequests.value.isEmpty()) } @Test fun `refresh does not log outgoing payee requests`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf(paymentRequestRecord(role = PaymentRequestLocalRole.PAYEE, counterparty = "secret")), ) @@ -288,7 +328,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `refresh does not log expired requests`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf(paymentRequestRecord(expiresAt = clock.now().toString())), ) @@ -299,7 +339,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `refresh rejects amounts outside the app payment range`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord(id = "millisatoshi-safe-max", amount = "184467440.73709551"), paymentRequestRecord(id = "millisatoshi-overflow", amount = "184467440.73709552"), @@ -320,7 +360,6 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = PaykitPaymentRequest( paymentRequestId = PAYMENT_REQUEST_ID, counterparty = COUNTERPARTY, - counterpartyReceiverPath = PaykitReceiverPaths.SERVER, amountValue = "0.000025", amountSats = 2_500uL, expiresAt = null, @@ -335,7 +374,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `refresh drops expired unsupported and non payer requests`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord(expiresAt = clock.now().toString()), paymentRequestRecord(id = "unsupported", endpoints = listOf("btc-unsupported-method")), @@ -350,7 +389,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `refresh keeps one time bitcoin lifecycle history`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord(id = "incoming"), paymentRequestRecord(id = "accepted", state = PaymentRequestLifecycleState.ACCEPTED), @@ -405,11 +444,10 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { val requestId = PaykitPaymentRequestId( paymentRequestId = PAYMENT_REQUEST_ID, counterparty = COUNTERPARTY, - counterpartyReceiverPath = PaykitReceiverPaths.SERVER, ) whenever(paymentProofStore.completedRequestProofKindsAwaitingSubmission(LOCAL_IDENTITY)) .thenReturn(mapOf(requestId to PaykitPaymentProofKind.Onchain)) - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) sut.refresh().getOrThrow() @@ -423,7 +461,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { val proof = mock { on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue } - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord( state = PaymentRequestLifecycleState.PROOF_SUBMITTED, @@ -439,7 +477,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `pending request is removed exactly when it expires`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf(paymentRequestRecord(expiresAt = clock.now().plus(10.seconds).toString())), ) sut.refresh().getOrThrow() @@ -459,7 +497,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `outgoing request moves to expired history exactly when it expires`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord( role = PaymentRequestLocalRole.PAYEE, @@ -484,11 +522,10 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `accept removes current request and delivers queued response`() = test { val record = paymentRequestRecord() - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) whenever( paykitSdkService.acceptPaymentRequest( COUNTERPARTY, - PaykitReceiverPaths.SERVER, PAYMENT_REQUEST_ID, ), ).thenReturn(record) @@ -507,11 +544,10 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { val record = paymentRequestRecord() val deliveryStarted = CompletableDeferred() val finishDelivery = CompletableDeferred() - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) whenever( paykitSdkService.rejectPaymentRequest( COUNTERPARTY, - PaykitReceiverPaths.SERVER, PAYMENT_REQUEST_ID, ), ).thenReturn(record) @@ -538,7 +574,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `surfaced request stays pending and is excluded from automatic presentation`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(paymentRequestRecord())) sut.refresh().getOrThrow() val request = sut.pendingRequests.value.single() @@ -551,7 +587,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `switching identity clears request state and restores only that identity suppression`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(paymentRequestRecord())) sut.refresh().getOrThrow() val request = sut.pendingRequests.value.single() sut.markPresented(request) @@ -573,7 +609,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { resumeRefresh.await() emptyList() } - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(paymentRequestRecord())) whenever(presentationStore.load(SECOND_IDENTITY)).thenReturn(emptySet()) val refresh = async { sut.refresh() } @@ -593,18 +629,19 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `proposal uses exact linked capable path and canonical bitcoin terms`() = test { - val target = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + val target = PaykitPaymentRequestTarget(COUNTERPARTY) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).thenReturn( - listOf(PaykitReceiverPaths.SERVER), + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).thenReturn( + true, ) whenever( paykitSdkService.proposePaymentRequest( eq(COUNTERPARTY), - eq(PaykitReceiverPaths.SERVER), any(), eq(LOCAL_IDENTITY), ), @@ -612,7 +649,6 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentRequestRecord( role = PaymentRequestLocalRole.PAYEE, counterparty = COUNTERPARTY, - receiverPath = PaykitReceiverPaths.SERVER, ), ) val expiry = clock.now().plus(60.seconds) @@ -628,7 +664,6 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { verifyBlocking(paykitSdkService) { proposePaymentRequest( eq(COUNTERPARTY), - eq(PaykitReceiverPaths.SERVER), proposal.capture(), eq(LOCAL_IDENTITY), ) @@ -646,27 +681,28 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `proposal revalidates only the selected saved contact`() = test { - val target = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) + val target = PaykitPaymentRequestTarget(COUNTERPARTY) val stalledDiscovery = CompletableDeferred() - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( listOf( - linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER), - linkedPeer(SECOND_IDENTITY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER), + linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED), + linkedPeer(SECOND_IDENTITY, LinkedPeerState.LINKED), ), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).thenReturn( - listOf(PaykitReceiverPaths.SERVER), + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).thenReturn( + true, ) - whenever(paykitSdkService.paymentRequestReceiverPaths(SECOND_IDENTITY)).doSuspendableAnswer { + whenever(paykitSdkService.canReceivePaymentRequests(SECOND_IDENTITY)).doSuspendableAnswer { stalledDiscovery.await() - listOf(PaykitReceiverPaths.SERVER) + true } - whenever(paykitSdkService.proposePaymentRequest(any(), any(), any(), eq(LOCAL_IDENTITY))).thenReturn( + whenever(paykitSdkService.proposePaymentRequest(any(), any(), eq(LOCAL_IDENTITY))).thenReturn( paymentRequestRecord( role = PaymentRequestLocalRole.PAYEE, counterparty = COUNTERPARTY, - receiverPath = PaykitReceiverPaths.SERVER, ), ) @@ -681,22 +717,24 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(proposal.isCompleted) proposal.await().getOrThrow() - verifyBlocking(paykitSdkService, never()) { paymentRequestReceiverPaths(SECOND_IDENTITY) } + verifyBlocking(paykitSdkService, never()) { canReceivePaymentRequests(SECOND_IDENTITY) } } @Test fun `identity switch keeps a committed proposal out of the replacement identity state`() = test { - val target = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) + val target = PaykitPaymentRequestTarget(COUNTERPARTY) val proposalStarted = CompletableDeferred() val finishProposal = CompletableDeferred() - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).thenReturn( - listOf(PaykitReceiverPaths.SERVER), + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).thenReturn( + true, ) - whenever(paykitSdkService.proposePaymentRequest(any(), any(), any(), eq(LOCAL_IDENTITY))).doSuspendableAnswer { + whenever(paykitSdkService.proposePaymentRequest(any(), any(), eq(LOCAL_IDENTITY))).doSuspendableAnswer { proposalStarted.complete(Unit) finishProposal.await() paymentRequestRecord(role = PaymentRequestLocalRole.PAYEE) @@ -725,64 +763,72 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `incoming refresh does not wait for recipient discovery`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(paymentRequestRecord())) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) sut.refresh().getOrThrow() assertEquals(1, sut.pendingRequests.value.size) - verifyBlocking(paykitSdkService, never()) { paymentRequestReceiverPaths(any()) } + verifyBlocking(paykitSdkService, never()) { canReceivePaymentRequests(any()) } } @Test fun `recipient discovery reuses unchanged link state`() = test { - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).thenReturn( - listOf(PaykitReceiverPaths.SERVER), + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).thenReturn( + true, ) sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() assertEquals( - listOf(PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER)), + listOf(PaykitPaymentRequestTarget(COUNTERPARTY)), sut.eligibleTargets.value, ) - verifyBlocking(paykitSdkService, times(1)) { paymentRequestReceiverPaths(COUNTERPARTY) } + verifyBlocking(paykitSdkService, times(1)) { canReceivePaymentRequests(COUNTERPARTY) } } @Test fun `single recipient refresh adds a newly eligible contact`() = test { - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).thenReturn( - listOf(PaykitReceiverPaths.SERVER), + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).thenReturn( + true, ) val target = sut.refreshEligibleTarget(COUNTERPARTY).getOrThrow().target - val expected = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) + val expected = PaykitPaymentRequestTarget(COUNTERPARTY) assertEquals(expected, target) assertEquals(listOf(expected), sut.eligibleTargets.value) } @Test fun `single recipient refresh removes a contact that is no longer linked`() = test { - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), emptyList(), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).thenReturn( - listOf(PaykitReceiverPaths.SERVER), + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).thenReturn( + true, ) sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() @@ -794,12 +840,14 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `single recipient refresh removes a contact that stopped accepting requests`() = test { - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)) - .thenReturn(listOf(PaykitReceiverPaths.SERVER), emptyList()) + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)) + .thenReturn(true, false) sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() val target = sut.refreshEligibleTarget(COUNTERPARTY).getOrThrow().target @@ -810,18 +858,20 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `single recipient refresh keeps a known target while capability lookup fails`() = test { - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)) - .thenReturn(listOf(PaykitReceiverPaths.SERVER)) + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)) + .thenReturn(true) .thenThrow(IllegalStateException("marker unavailable")) sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() val check = sut.refreshEligibleTarget(COUNTERPARTY).getOrThrow() - val expected = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) + val expected = PaykitPaymentRequestTarget(COUNTERPARTY) assertEquals(expected, check.target) assertFalse(check.isComplete) assertEquals(listOf(expected), sut.eligibleTargets.value) @@ -832,18 +882,20 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { val fullLookupStarted = CompletableDeferred() val releaseFullLookup = CompletableDeferred() var lookups = 0 - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).doSuspendableAnswer { + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).doSuspendableAnswer { lookups += 1 when (lookups) { - 1 -> listOf(PaykitReceiverPaths.SERVER) + 1 -> true 2 -> { fullLookupStarted.complete(Unit) releaseFullLookup.await() - emptyList() + false } else -> error("marker unavailable") } @@ -865,20 +917,22 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { val fullLinkLookupStarted = CompletableDeferred() val releaseFullLinkLookup = CompletableDeferred() var linkLookups = 0 - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).doSuspendableAnswer { linkLookups += 1 if (linkLookups > 1) { return@doSuspendableAnswer listOf( - linkedPeer(SECOND_IDENTITY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER), + linkedPeer(SECOND_IDENTITY, LinkedPeerState.LINKED), ) } fullLinkLookupStarted.complete(Unit) releaseFullLinkLookup.await() error("linked peers unavailable") } - whenever(paykitSdkService.paymentRequestReceiverPaths(SECOND_IDENTITY)) - .thenReturn(listOf(PaykitReceiverPaths.SERVER)) + whenever(paykitSdkService.canReceivePaymentRequests(SECOND_IDENTITY)) + .thenReturn(true) val fullRefresh = async { sut.refreshEligibleTargets(listOf(COUNTERPARTY)) } fullLinkLookupStarted.await() @@ -887,7 +941,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(fullRefresh.await().isFailure) assertEquals( - listOf(PaykitPaymentRequestTarget(SECOND_IDENTITY, PaykitReceiverPaths.SERVER)), + listOf(PaykitPaymentRequestTarget(SECOND_IDENTITY)), sut.eligibleTargets.value, ) } @@ -897,16 +951,18 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { val fullLookupStarted = CompletableDeferred() val releaseFullLookup = CompletableDeferred() var lookups = 0 - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).doSuspendableAnswer { + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).doSuspendableAnswer { lookups += 1 - if (lookups > 1) return@doSuspendableAnswer emptyList() + if (lookups > 1) return@doSuspendableAnswer false fullLookupStarted.complete(Unit) releaseFullLookup.await() - listOf(PaykitReceiverPaths.SERVER) + true } val fullRefresh = async { sut.refreshEligibleTargets(listOf(COUNTERPARTY)) } @@ -924,16 +980,18 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { val singleLookupStarted = CompletableDeferred() val releaseSingleLookup = CompletableDeferred() var lookups = 0 - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).doSuspendableAnswer { + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).doSuspendableAnswer { lookups += 1 - if (lookups > 1) return@doSuspendableAnswer listOf(PaykitReceiverPaths.SERVER) + if (lookups > 1) return@doSuspendableAnswer true singleLookupStarted.complete(Unit) releaseSingleLookup.await() - emptyList() + false } val singleRefresh = async { sut.refreshEligibleTarget(COUNTERPARTY) } @@ -942,7 +1000,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { releaseSingleLookup.complete(Unit) val target = singleRefresh.await().getOrThrow().target - val expected = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) + val expected = PaykitPaymentRequestTarget(COUNTERPARTY) assertEquals(expected, target) assertEquals(listOf(expected), sut.eligibleTargets.value) } @@ -952,22 +1010,24 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { val fullLookupStarted = CompletableDeferred() val releaseFullLookup = CompletableDeferred() var counterpartyLookups = 0 - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( listOf( - linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER), - linkedPeer(SECOND_IDENTITY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER), + linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED), + linkedPeer(SECOND_IDENTITY, LinkedPeerState.LINKED), ), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).doSuspendableAnswer { + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).doSuspendableAnswer { counterpartyLookups += 1 - if (counterpartyLookups > 1) return@doSuspendableAnswer emptyList() + if (counterpartyLookups > 1) return@doSuspendableAnswer false fullLookupStarted.complete(Unit) releaseFullLookup.await() - listOf(PaykitReceiverPaths.SERVER) + true } - whenever(paykitSdkService.paymentRequestReceiverPaths(SECOND_IDENTITY)) - .thenReturn(listOf(PaykitReceiverPaths.SERVER)) + whenever(paykitSdkService.canReceivePaymentRequests(SECOND_IDENTITY)) + .thenReturn(true) val fullRefresh = async { sut.refreshEligibleTargets(listOf(COUNTERPARTY, SECOND_IDENTITY)) } fullLookupStarted.await() @@ -976,19 +1036,21 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { fullRefresh.await().getOrThrow() assertEquals( - listOf(PaykitPaymentRequestTarget(SECOND_IDENTITY, PaykitReceiverPaths.SERVER)), + listOf(PaykitPaymentRequestTarget(SECOND_IDENTITY)), sut.eligibleTargets.value, ) } @Test fun `failed recipient discovery drops contacts that are no longer saved`() = test { - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()) - .thenReturn(listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER))) + .thenReturn(listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED))) .thenThrow(IllegalStateException("linked peers unavailable")) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).thenReturn( - listOf(PaykitReceiverPaths.SERVER), + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).thenReturn( + true, ) sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() @@ -1000,55 +1062,61 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `recipient discovery retries capabilities that are not published yet`() = test { - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)) - .thenReturn(emptyList(), listOf(PaykitReceiverPaths.SERVER)) + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)) + .thenReturn(false, true) sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() assertEquals( - listOf(PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER)), + listOf(PaykitPaymentRequestTarget(COUNTERPARTY)), sut.eligibleTargets.value, ) - verifyBlocking(paykitSdkService, times(2)) { paymentRequestReceiverPaths(COUNTERPARTY) } + verifyBlocking(paykitSdkService, times(2)) { canReceivePaymentRequests(COUNTERPARTY) } } @Test fun `recipient discovery retains a known target while capability refresh fails`() = test { - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)) - .thenReturn(listOf(PaykitReceiverPaths.SERVER)) + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)) + .thenReturn(true) .thenThrow(IllegalStateException("marker unavailable")) sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() sut.refreshEligibleTargets(listOf(COUNTERPARTY), force = true).getOrThrow() assertEquals( - listOf(PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER)), + listOf(PaykitPaymentRequestTarget(COUNTERPARTY)), sut.eligibleTargets.value, ) - verifyBlocking(paykitSdkService, times(2)) { paymentRequestReceiverPaths(COUNTERPARTY) } + verifyBlocking(paykitSdkService, times(2)) { canReceivePaymentRequests(COUNTERPARTY) } } @Test fun `recipient discovery bounds a stalled capability lookup`() = test { val discoveryStarted = CompletableDeferred() val stalledDiscovery = CompletableDeferred() - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).doSuspendableAnswer { + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).doSuspendableAnswer { discoveryStarted.complete(Unit) stalledDiscovery.await() - listOf(PaykitReceiverPaths.SERVER) + true } val targetRefresh = async { sut.refreshEligibleTargets(listOf(COUNTERPARTY)) } @@ -1064,39 +1132,43 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `outgoing requests require private payment publication`() = test { whenever(settingsStore.data).thenReturn(flowOf(SettingsData(sharesPrivatePaykitEndpoints = false))) - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).thenReturn( - listOf(PaykitReceiverPaths.SERVER), + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).thenReturn( + true, ) sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() assertTrue(sut.eligibleTargets.value.isEmpty()) - verifyBlocking(paykitSdkService, never()) { proposePaymentRequest(any(), any(), any(), any()) } + verifyBlocking(paykitSdkService, never()) { proposePaymentRequest(any(), any(), any()) } } @Test fun `outgoing requests require the active SDK identity`() = test { - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(SECOND_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(SECOND_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).thenReturn( - listOf(PaykitReceiverPaths.SERVER), + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).thenReturn( + true, ) sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() assertTrue(sut.eligibleTargets.value.isEmpty()) - verifyBlocking(paykitSdkService, never()) { proposePaymentRequest(any(), any(), any(), any()) } + verifyBlocking(paykitSdkService, never()) { proposePaymentRequest(any(), any(), any()) } } @Test fun `expired draft is rejected before proposal is queued`() = test { - val target = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) + val target = PaykitPaymentRequestTarget(COUNTERPARTY) assertFailsWith { sut.propose( @@ -1105,13 +1177,13 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { savedPublicKeys = listOf(COUNTERPARTY), ).getOrThrow() } - verifyBlocking(paykitSdkService, never()) { proposePaymentRequest(any(), any(), any(), any()) } + verifyBlocking(paykitSdkService, never()) { proposePaymentRequest(any(), any(), any()) } } @Test fun `expired request cannot be accepted`() = test { val record = paymentRequestRecord(expiresAt = clock.now().plus(1.seconds).toString()) - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) sut.refresh().getOrThrow() val request = sut.pendingRequests.value.single() advanceTimeBy(1_000) @@ -1120,14 +1192,14 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.accept(request).getOrThrow() } verifyBlocking(paykitSdkService, never()) { - acceptPaymentRequest(COUNTERPARTY, PaykitReceiverPaths.SERVER, PAYMENT_REQUEST_ID) + acceptPaymentRequest(COUNTERPARTY, PAYMENT_REQUEST_ID) } } @Test fun `expired request is no longer pending before the expiration job runs`() = test { val record = paymentRequestRecord(expiresAt = clock.now().plus(1.seconds).toString()) - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) sut.refresh().getOrThrow() val request = sut.pendingRequests.value.single() advanceTimeBy(1_000) @@ -1146,13 +1218,11 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentDeadline: PaymentDeadline? = null, endpoints: List = listOf(MethodId.Bolt11.rawValue), counterparty: String = COUNTERPARTY, - receiverPath: String = PaykitReceiverPaths.SERVER, recurrence: PaymentRequestRecurrence? = null, metadata: PrivateJsonObject = METADATA, paymentProofs: List = emptyList(), ) = PaymentRequestRecord( counterparty = counterparty, - counterpartyReceiverPath = receiverPath, paymentRequestId = id, localRole = role, state = state, @@ -1169,6 +1239,8 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { conversion = null, paymentDeadline = paymentDeadline, metadata = metadata, + paymentEndpoints = null, + requiredAppId = "bitkit", ), acceptedEventId = null, acceptedOutboundStatus = null, @@ -1183,15 +1255,16 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { lastOutboundStatus = null, lastEventAt = clock.now().toString(), invalidReason = null, + proposalAppId = "bitkit", + payerAppId = null, + executionClaimAppId = null, ) private fun linkedPeer( publicKey: String, state: LinkedPeerState, - receiverPath: String, ) = LinkedPeerRecord( counterparty = publicKey, - counterpartyReceiverPath = receiverPath, state = state, lastSyncAt = null, lastPrivateReceiveAt = null, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitReceivedPaymentContactsTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitReceivedPaymentContactsTest.kt new file mode 100644 index 0000000000..ecf380e884 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PaykitReceivedPaymentContactsTest.kt @@ -0,0 +1,224 @@ +package to.bitkit.repositories + +import com.synonym.bitkitcore.AddressType +import com.synonym.bitkitcore.NetworkType +import com.synonym.bitkitcore.ValidationResult +import com.synonym.bitkitcore.validateBitcoinAddress +import com.synonym.paykit.PaymentProofRecord +import com.synonym.paykit.PaymentRequestAmount +import com.synonym.paykit.PaymentRequestLifecycleState +import com.synonym.paykit.PaymentRequestLocalRole +import com.synonym.paykit.PaymentRequestRecord +import com.synonym.paykit.PaymentRequestTerms +import com.synonym.paykit.PrivateJsonObject +import org.junit.Test +import org.lightningdevkit.ldknode.Bolt11Invoice +import org.lightningdevkit.ldknode.Currency +import org.lightningdevkit.ldknode.Network +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PaykitReceivedPaymentContactsTest { + @Test + fun `shared app immutable address attributes received payment`() = withDecoder { + val contacts = index(receivedRequest()) + assertEquals(setOf(BUYER), contacts.contactsForAddresses(listOf(ADDRESS))) + assertTrue(contacts.contactsForAddresses(listOf(OTHER_ADDRESS)).isEmpty()) + } + + @Test + fun `terminal request states retain exact destination attribution`() = withDecoder { + for (state in PaymentRequestLifecycleState.entries.filterNot { + it == PaymentRequestLifecycleState.INVALID_CONFLICT || it == PaymentRequestLifecycleState.UNKNOWN + }) { + assertEquals(setOf(BUYER), index(receivedRequest(state = state)).contactsForAddresses(listOf(ADDRESS))) + } + } + + @Test + fun `payer unknown role and invalid records cannot attribute`() = withDecoder { + val invalidRecords = listOf( + receivedRequest(role = PaymentRequestLocalRole.PAYER), + receivedRequest(role = null), + receivedRequest(state = PaymentRequestLifecycleState.INVALID_CONFLICT), + receivedRequest(state = PaymentRequestLifecycleState.UNKNOWN), + receivedRequest(invalidReason = "conflict"), + receivedRequest(counterparty = "invalid"), + receivedRequest(counterparty = BUYER + "excess"), + receivedRequest(asset = "usd"), + receivedRequest(terms = null), + ) + assertTrue(index(*invalidRecords.toTypedArray()).contactsForAddresses(listOf(ADDRESS)).isEmpty()) + } + + @Test + fun `missing unaccepted malformed and wrong network endpoints cannot attribute`() = withDecoder { + val records = listOf( + receivedRequest(endpoints = null), + receivedRequest(endpoints = emptyMap()), + receivedRequest(accepted = emptyList()), + receivedRequest(endpoints = mapOf(METHOD to "not json")), + receivedRequest(endpoints = mapOf(METHOD to "{\"value\":\"\"}")), + receivedRequest(endpoints = mapOf(METHOD to payload("malformed"))), + receivedRequest(endpoints = mapOf("btc-bitcoin-p2wpkh" to payload(ADDRESS))), + receivedRequest(endpoints = mapOf(METHOD to payload(MAINNET_ADDRESS))), + receivedRequest(endpoints = mapOf(METHOD to payload(TESTNET_ADDRESS))), + ) + assertTrue( + index(*records.toTypedArray()) + .contactsForAddresses(listOf(ADDRESS, MAINNET_ADDRESS, TESTNET_ADDRESS)).isEmpty() + ) + } + + @Test + fun `signet accepts testnet address encoding only with a signet endpoint`() = withDecoder { + val identifier = "btc-signet-p2wpkh" + val record = receivedRequest( + accepted = listOf(identifier), + endpoints = mapOf(identifier to payload(TESTNET_ADDRESS)) + ) + val contacts = PaykitReceivedPaymentContacts.from(listOf(record), Network.SIGNET) + assertEquals(setOf(BUYER), contacts.contactsForAddresses(listOf(TESTNET_ADDRESS))) + assertTrue(index(record).contactsForAddresses(listOf(TESTNET_ADDRESS)).isEmpty()) + } + + @Test + fun `regtest accepts network ambiguous legacy address encodings`() = withDecoder { + for (address in LEGACY_ADDRESSES) { + val identifier = if (address.startsWith("2")) "btc-regtest-p2sh" else "btc-regtest-p2pkh" + val record = receivedRequest( + accepted = listOf(identifier), + endpoints = mapOf(identifier to payload(address)) + ) + assertEquals(setOf(BUYER), index(record).contactsForAddresses(listOf(address))) + } + } + + @Test + fun `normalized duplicate counterparties remain unique`() = withDecoder { + val contacts = index(receivedRequest(), receivedRequest(counterparty = BUYER.removePrefix("pubky").uppercase())) + assertEquals(setOf(BUYER), contacts.contactsForAddresses(listOf(ADDRESS, ADDRESS))) + } + + @Test + fun `same destination shared by different counterparties stays ambiguous`() = withDecoder { + val contacts = index(receivedRequest(), receivedRequest(counterparty = OTHER_BUYER)) + assertEquals(setOf(BUYER, OTHER_BUYER), contacts.contactsForAddresses(listOf(ADDRESS))) + } + + @Test + fun `all output addresses contribute to ambiguity`() = withDecoder { + val contacts = index( + receivedRequest(), + receivedRequest(counterparty = OTHER_BUYER, endpoints = mapOf(METHOD to payload(OTHER_ADDRESS))), + ) + assertEquals(setOf(BUYER, OTHER_BUYER), contacts.contactsForAddresses(listOf(ADDRESS, OTHER_ADDRESS))) + } + + @Test + fun `validated expired bolt11 matches exact payment hash`() = withDecoder { + val contacts = index(receivedRequest(endpoints = mapOf(BOLT11_METHOD to payload(INVOICE)))) + assertEquals(setOf(BUYER), contacts.contactsForPaymentHash(HASH.uppercase())) + assertTrue(contacts.contactsForPaymentHash("cd".repeat(32)).isEmpty()) + } + + @Test + fun `malformed or wrong network bolt11 cannot attribute`() = withDecoder { + val contacts = index( + receivedRequest(endpoints = mapOf(BOLT11_METHOD to payload("invalid-invoice"))), + receivedRequest(endpoints = mapOf(BOLT11_METHOD to payload(MAINNET_INVOICE))), + ) + assertTrue(contacts.contactsForPaymentHash(HASH).isEmpty()) + } + + @Test + fun `proof hash alone cannot attribute without immutable request endpoints`() = withDecoder { + val record = receivedRequest(endpoints = null) + val proof = mock { + on { exportText() }.thenReturn("{\"payment_hash\":\"$HASH\",\"txid\":\"$HASH\"}") + } + val proofRecord = mock { on { this.proof }.thenReturn(proof) } + whenever(record.paymentProofs).thenReturn(listOf(proofRecord)) + + assertTrue(index(record).contactsForPaymentHash(HASH).isEmpty()) + assertTrue(index(record).contactsForAddresses(listOf(ADDRESS)).isEmpty()) + } + + @Test + fun `same invoice hash across contacts stays ambiguous`() = withDecoder { + val contacts = index( + receivedRequest(endpoints = mapOf(BOLT11_METHOD to payload(INVOICE))), + receivedRequest(counterparty = OTHER_BUYER, endpoints = mapOf(BOLT11_METHOD to payload(INVOICE))), + ) + assertEquals(setOf(BUYER, OTHER_BUYER), contacts.contactsForPaymentHash(HASH)) + } + + private fun index(vararg records: PaymentRequestRecord) = + PaykitReceivedPaymentContacts.from(records.toList(), Network.REGTEST) { value -> + require(value == INVOICE || value == MAINNET_INVOICE) + mock { + on { currency() }.thenReturn(if (value == INVOICE) Currency.REGTEST else Currency.BITCOIN) + on { paymentHash() }.thenReturn(HASH) + } + } + + private fun withDecoder(block: () -> Unit) { + mockStatic(Class.forName("com.synonym.bitkitcore.Bitkitcore_androidKt")).use { native -> + for (address in listOf(ADDRESS, OTHER_ADDRESS, MAINNET_ADDRESS, TESTNET_ADDRESS) + LEGACY_ADDRESSES) { + val network = when (address) { + MAINNET_ADDRESS -> NetworkType.BITCOIN + TESTNET_ADDRESS, in LEGACY_ADDRESSES -> NetworkType.TESTNET + else -> NetworkType.REGTEST + } + native.`when` { validateBitcoinAddress(address) }.thenReturn( + ValidationResult(address, network, AddressType.UNKNOWN), + ) + } + block() + } + } + + companion object { + const val BUYER = "pubky7don8zi885feihpjsyx7t53srod6z1n4xjiyaaxucpqarm6sh85o" + const val OTHER_BUYER = "pubkya3mduedw686dysw8ndr5c1dyry5h8k6i8hzbbmx9gf9k43zq4s9o" + const val ADDRESS = "bcrt1qfn50lqawrce0evh66qrnlt8j447lwmeyqp5gmd" + const val OTHER_ADDRESS = "bcrt1qpsps9chsjnnd3veems9phzlvw42em682rsj8hh" + const val MAINNET_ADDRESS = "bc1qexample" + const val TESTNET_ADDRESS = "tb1qexample" + val LEGACY_ADDRESSES = listOf("mlegacy", "nlegacy", "2legacy") + const val METHOD = "btc-regtest-p2wpkh" + const val BOLT11_METHOD = "btc-lightning-bolt11" + const val INVOICE = "lnbcrt1example" + const val MAINNET_INVOICE = "lnbc1example" + val HASH = "ab".repeat(32) + + fun payload(value: String) = "{\"value\":\"$value\"}" + + @Suppress("LongParameterList") + fun receivedRequest( + counterparty: String = BUYER, + role: PaymentRequestLocalRole? = PaymentRequestLocalRole.PAYEE, + state: PaymentRequestLifecycleState = PaymentRequestLifecycleState.PROOF_SUBMITTED, + invalidReason: String? = null, + asset: String = "btc", + endpoints: Map? = mapOf(METHOD to payload(ADDRESS)), + accepted: List = listOf(METHOD, BOLT11_METHOD), + terms: PaymentRequestTerms? = PaymentRequestTerms( + amount = PaymentRequestAmount("0.00015", asset), paymentReference = mock(), + proposalExpiresAt = null, recurrence = null, acceptedPaymentEndpointIdentifiers = accepted, + paymentEndpoints = endpoints, requiredAppId = "marketplace", conversion = null, + paymentDeadline = null, metadata = mock(), + ), + ): PaymentRequestRecord = mock { + on { this.counterparty }.thenReturn(counterparty) + on { this.localRole }.thenReturn(role) + on { this.state }.thenReturn(state) + on { this.invalidReason }.thenReturn(invalidReason) + on { this.terms }.thenReturn(terms) + on { proposalAppId }.thenReturn("marketplace") + } + } +} diff --git a/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionNotificationSchedulerTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionNotificationSchedulerTest.kt index b1641949a0..1ea3cafa95 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionNotificationSchedulerTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionNotificationSchedulerTest.kt @@ -27,7 +27,6 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import to.bitkit.ui.EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT import to.bitkit.ui.EXTRA_PAYKIT_COUNTERPARTY -import to.bitkit.ui.EXTRA_PAYKIT_COUNTERPARTY_RECEIVER_PATH import to.bitkit.ui.EXTRA_PAYKIT_PAYER_IDENTITY import to.bitkit.ui.EXTRA_PAYKIT_PAYMENT_REQUEST_ID import kotlin.test.assertEquals @@ -43,11 +42,10 @@ class PaykitSubscriptionNotificationSchedulerTest { const val COUNTERPARTY = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" const val PAYER_IDENTITY = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" const val PAYMENT_REQUEST_ID = "request-id" - const val RECEIVER_PATH = "bitkit/server" const val WORK_TAG = "paykit-subscriptions" val NOW = Instant.parse("2027-01-02T08:00:00Z") val NEXT_PERIOD_START = Instant.parse("2027-01-08T08:00:00Z") - val WORK_NAME = "paykit-subscription-$PAYER_IDENTITY|$COUNTERPARTY|$RECEIVER_PATH|" + + val WORK_NAME = "paykit-subscription-$PAYER_IDENTITY|$COUNTERPARTY|" + "$PAYMENT_REQUEST_ID|$NEXT_PERIOD_START" } @@ -88,7 +86,6 @@ class PaykitSubscriptionNotificationSchedulerTest { assertEquals(PAYMENT_REQUEST_ID, request.workSpec.input.getString(EXTRA_PAYKIT_PAYMENT_REQUEST_ID)) assertEquals(PAYER_IDENTITY, request.workSpec.input.getString(EXTRA_PAYKIT_PAYER_IDENTITY)) assertEquals(COUNTERPARTY, request.workSpec.input.getString(EXTRA_PAYKIT_COUNTERPARTY)) - assertEquals(RECEIVER_PATH, request.workSpec.input.getString(EXTRA_PAYKIT_COUNTERPARTY_RECEIVER_PATH)) assertEquals( NEXT_PERIOD_START.toString(), request.workSpec.input.getString(EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT), @@ -168,7 +165,6 @@ class PaykitSubscriptionNotificationSchedulerTest { private fun subscription() = PaykitSubscription( paymentRequestId = PAYMENT_REQUEST_ID, counterparty = COUNTERPARTY, - counterpartyReceiverPath = RECEIVER_PATH, amountValue = "0.00025", amountSats = 25_000uL, note = "Weekly coffee", diff --git a/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionTest.kt index dc9d822b82..7751a3f744 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionTest.kt @@ -168,7 +168,7 @@ class PaykitSubscriptionTest { } @Test - fun `subscription payment matching includes counterparty and receiver path`() { + fun `subscription payment matching includes counterparty`() { val recurrence = PaykitSubscriptionRecurrence( every = 1, unit = PaykitRecurrenceUnit.Month, @@ -179,7 +179,6 @@ class PaykitSubscriptionTest { val subscription = PaykitSubscription( paymentRequestId = "shared", counterparty = "counterparty-a", - counterpartyReceiverPath = "bitkit/server", amountValue = "0.001", amountSats = 100_000uL, note = null, @@ -198,6 +197,5 @@ class PaykitSubscriptionTest { assertTrue(request.belongsTo(subscription)) assertFalse(request.copy(counterparty = "counterparty-b").belongsTo(subscription)) - assertFalse(request.copy(counterpartyReceiverPath = "bitkit/wallet").belongsTo(subscription)) } } diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt index b39eb66159..478721d46d 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt @@ -17,7 +17,6 @@ import to.bitkit.data.SettingsStore import to.bitkit.models.NodeLifecycleState import to.bitkit.services.AddressDerivationInfo import to.bitkit.services.CoreService -import to.bitkit.services.PaykitReceiverPaths import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -60,22 +59,6 @@ class PrivatePaykitAddressReservationRepoTest : BaseUnitTest() { ) } - @Test - fun `wallet assignment key keeps bare public key`() { - val key = ContactAssignmentKey(CONTACT_KEY, PaykitReceiverPaths.WALLET) - - assertEquals(CONTACT_KEY, key.encoded()) - assertEquals(CONTACT_KEY, ContactAssignmentKey.publicKeyOf(key.encoded())) - } - - @Test - fun `server assignment key includes receiver path`() { - val key = ContactAssignmentKey(CONTACT_KEY, PaykitReceiverPaths.SERVER) - - assertEquals("$CONTACT_KEY#${PaykitReceiverPaths.SERVER}", key.encoded()) - assertEquals(CONTACT_KEY, ContactAssignmentKey.publicKeyOf(key.encoded())) - } - @Test fun `contactsWithUsedReservedAddresses treats positive ldk address balance as used`() = test { reservationData.value = PrivatePaykitReservationData( diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt index dcb74d2530..a9bacffdc8 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt @@ -9,6 +9,7 @@ import to.bitkit.data.PrivatePaykitCacheData import to.bitkit.data.PrivatePaykitCacheStore import to.bitkit.data.PrivatePaykitContactCacheData import to.bitkit.data.PrivatePaykitStoredInvoiceData +import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.test.BaseUnitTest import javax.inject.Provider import kotlin.test.assertEquals @@ -23,6 +24,7 @@ class PrivatePaykitContactResolverTest : BaseUnitTest() { private val cacheStore = mock() private val addressReservationRepo = mock() + private val paymentRequestRepo = mock() private val cacheData = MutableStateFlow(PrivatePaykitCacheData()) private lateinit var sut: PrivatePaykitContactResolver @@ -30,24 +32,60 @@ class PrivatePaykitContactResolverTest : BaseUnitTest() { @Before fun setUp() { whenever(cacheStore.data).thenReturn(cacheData) + whenever(paymentRequestRepo.receivedPaymentContacts).thenReturn(PaykitReceivedPaymentContacts.Empty) sut = PrivatePaykitContactResolver( ioDispatcher = testDispatcher, cacheStore = cacheStore, addressReservationRepo = Provider { addressReservationRepo }, + paymentRequestRepo = Provider { paymentRequestRepo }, ) } + @Test + fun `shared request invoice hash resolves without local invoice reservations`() = test { + val shared = mock() + whenever(shared.contactsForPaymentHash(PAYMENT_HASH)).thenReturn(setOf(CONTACT_KEY)) + whenever(paymentRequestRepo.receivedPaymentContacts).thenReturn(shared) + + assertEquals( + PubkyPublicKeyFormat.normalized(CONTACT_KEY), + sut.contactPublicKeyForPrivateInvoicePaymentHash(PAYMENT_HASH) + ) + } + + @Test + fun `conflicting local reservation and shared request stay unattributed`() = test { + val shared = mock() + whenever(shared.contactsForAddresses(listOf(PRIVATE_ADDRESS))) + .thenReturn(setOf(PaykitReceivedPaymentContactsTest.BUYER)) + whenever(paymentRequestRepo.receivedPaymentContacts).thenReturn(shared) + whenever(addressReservationRepo.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS)).thenReturn(CONTACT_KEY) + + assertNull(sut.contactPublicKeyForPrivateOnchainAddresses(listOf(PRIVATE_ADDRESS))) + } + + @Test + fun `identity changes while resolving discard old shared request matches`() = test { + val shared = mock() + whenever(shared.contactsForAddresses(listOf(PRIVATE_ADDRESS))).thenReturn(setOf(CONTACT_KEY)) + whenever(paymentRequestRepo.receivedPaymentContacts).thenReturn(shared) + whenever(addressReservationRepo.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS)).thenAnswer { + whenever(paymentRequestRepo.receivedPaymentContacts).thenReturn(PaykitReceivedPaymentContacts.Empty) + null + } + + assertNull(sut.contactPublicKeyForPrivateOnchainAddresses(listOf(PRIVATE_ADDRESS))) + } + @Test fun `contactPublicKeyForPrivateInvoicePaymentHash resolves current local invoice`() = test { cacheData.value = PrivatePaykitCacheData( contacts = mapOf( CONTACT_KEY to PrivatePaykitContactCacheData( - localInvoicesByReceiverPath = mapOf( - "bitkit/wallet" to PrivatePaykitStoredInvoiceData( - bolt11 = "lnbcrt1private", - paymentHash = PAYMENT_HASH, - expiresAt = 1_700_000_000L, - ), + localInvoice = PrivatePaykitStoredInvoiceData( + bolt11 = "lnbcrt1private", + paymentHash = PAYMENT_HASH, + expiresAt = 1_700_000_000L, ), ), ), @@ -55,7 +93,7 @@ class PrivatePaykitContactResolverTest : BaseUnitTest() { val result = sut.contactPublicKeyForPrivateInvoicePaymentHash(PAYMENT_HASH) - assertEquals(CONTACT_KEY, result) + assertEquals(PubkyPublicKeyFormat.normalized(CONTACT_KEY), result) } @Test @@ -70,7 +108,7 @@ class PrivatePaykitContactResolverTest : BaseUnitTest() { val result = sut.contactPublicKeyForPrivateInvoicePaymentHash(PAYMENT_HASH) - assertEquals(CONTACT_KEY, result) + assertEquals(PubkyPublicKeyFormat.normalized(CONTACT_KEY), result) } @Test @@ -87,6 +125,6 @@ class PrivatePaykitContactResolverTest : BaseUnitTest() { val result = sut.contactPublicKeyForPrivateOnchainAddresses(listOf(PRIVATE_ADDRESS)) - assertEquals(CONTACT_KEY, result) + assertEquals(PubkyPublicKeyFormat.normalized(CONTACT_KEY), result) } } diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 543d6b622d..4496ad1a53 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -5,11 +5,9 @@ import com.synonym.bitkitcore.LightningInvoice import com.synonym.bitkitcore.NetworkType import com.synonym.bitkitcore.Scanner import com.synonym.paykit.ContactRecord -import com.synonym.paykit.CounterpartyReceiver import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState import com.synonym.paykit.PaykitException -import com.synonym.paykit.PaymentAmountContext import com.synonym.paykit.PrivatePaymentListDeliveryReport import com.synonym.paykit.PrivatePaymentListReservationUpdateInput import com.synonym.paykit.PrivatePaymentListSyncChange @@ -28,15 +26,11 @@ import kotlinx.coroutines.test.runCurrent import org.junit.After import org.junit.Before import org.junit.Test -import org.lightningdevkit.ldknode.PaymentDetails -import org.lightningdevkit.ldknode.PaymentDirection -import org.lightningdevkit.ldknode.PaymentKind -import org.lightningdevkit.ldknode.PaymentStatus import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.atLeast import org.mockito.kotlin.clearInvocations -import org.mockito.kotlin.doReturn import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -56,7 +50,6 @@ import to.bitkit.models.NodeLifecycleState import to.bitkit.services.CoreService import to.bitkit.services.PaykitPreparedPrivateContactPayment import to.bitkit.services.PaykitPrivateContactPaymentResolution -import to.bitkit.services.PaykitPrivateReceiverPathSelection import to.bitkit.services.PaykitResolvedPaymentEndpoint import to.bitkit.services.PaykitSdkService import to.bitkit.services.PubkyService @@ -83,11 +76,8 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { private const val OTHER_PRIVATE_ADDRESS = "bcrt1q9x0pz2tqf8clz0lq6m9wj8t47zffnrdz2tkt6v" private const val PRIVATE_BOLT11 = "lnbcrt1private" private const val SERVER_PRIVATE_BOLT11 = "lnbcrt1serverprivate" - private const val ROTATED_PRIVATE_BOLT11 = "lnbcrt1rotatedprivate" private const val PRIVATE_BOLT11_EXPIRY_SECONDS = 86_400u private const val NOW_SECONDS = 1_700_000_000L - private const val WALLET_RECEIVER_PATH = "bitkit/wallet" - private const val SERVER_RECEIVER_PATH = "bitkit/server" } private val paykitSdkService = mock() @@ -126,25 +116,21 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(lightningRepo.lightningState).thenReturn(lightningState) whenever(clock.now()).thenReturn(Instant.fromEpochSeconds(NOW_SECONDS)) whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) - whenever { pubkyService.discoverRelevantReceiverPaths(any()) } - .thenReturn(listOf(WALLET_RECEIVER_PATH)) whenever(paykitSdkService.hasPrivatePaymentAccess()).thenReturn(true) whenever(walletRepo.walletExists()).thenReturn(true) whenever { walletRepo.refreshReusableReceiveAddressIfReserved() }.thenReturn(Result.success(Unit)) whenever { addressReservationRepo.reconcileReservedIndexesWithLdk() }.thenReturn(Result.success(Unit)) - whenever { addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY, WALLET_RECEIVER_PATH) } + whenever { addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY) } .thenReturn(Result.success(PRIVATE_ADDRESS)) - whenever { paykitSdkService.privateReceiverPathSelection(any(), any()) }.thenAnswer { - privateReceiverPathSelection(it.getArgument(1)) - } whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) } .thenReturn(privateListDeliveryReport(queuedCounterparties = listOf(CONTACT_KEY))) whenever { paykitSdkService.linkedPeers() }.thenReturn(emptyList()) whenever { paykitSdkService.pendingOutboundPrivateCounterparties() }.thenReturn(emptyList()) - whenever { paykitSdkService.clearPrivatePaymentList(any(), any()) }.thenReturn(privateListDeliveryReport()) + whenever { paykitSdkService.clearPrivatePaymentList(any()) }.thenReturn(privateListDeliveryReport()) whenever { publicPaykitRepo.beginPayment(any()) } .thenReturn(Result.success(PublicPaykitPaymentResult.Opened("bitcoin:bcrt1qpublic"))) whenever { publicPaykitRepo.payableEndpoints(any()) }.thenAnswer { it.getArgument>(0) } + whenever { publicPaykitRepo.syncPaykitApp(anyOrNull()) }.thenReturn(Result.success(Unit)) whenever(lightningRepo.getPayments()).thenReturn(Result.success(emptyList())) PublicPaykitRepo.lightningRouteHintsValidator = { true } @@ -179,16 +165,15 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(PublicPaykitRepo.serializePayload(PRIVATE_ADDRESS), reservation.payload) assertTrue( reservation.reservationId.startsWith( - "$CONTACT_KEY:$WALLET_RECEIVER_PATH:${MethodId.P2wpkh.rawValue}:", + "$CONTACT_KEY:${MethodId.P2wpkh.rawValue}:", ), ) assertTrue(reservation.reservationId.length <= 128) assertEquals("private_paykit", reservation.attribution["type"]) assertEquals(CONTACT_KEY, reservation.attribution["counterparty"]) - assertEquals(WALLET_RECEIVER_PATH, reservation.attribution["receiver_path"]) assertEquals( - setOf(WALLET_RECEIVER_PATH), - cacheData.value.contacts.getValue(CONTACT_KEY).publishedPrivatePaymentReceiverPaths, + true, + cacheData.value.contacts.getValue(CONTACT_KEY).hasPublishedPrivatePaymentList, ) } @@ -200,168 +185,16 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `prepareSavedContacts publishes distinct private reservations for eligible receiver paths`() = test { - settingsData.value = SettingsData( - sharesPrivatePaykitEndpoints = true, - publicPaykitLightningEnabled = false, - publicPaykitOnchainEnabled = true, - ) - whenever { paykitSdkService.contactRecord(CONTACT_KEY) } - .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) - whenever { addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY, SERVER_RECEIVER_PATH) } - .thenReturn(Result.success(OTHER_PRIVATE_ADDRESS)) - whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) }.thenAnswer { - privateListDeliveryReportForUpdates(it.getArgument(0)) - } - - val result = sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) - - assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - val captor = argumentCaptor>() - verifyBlocking(paykitSdkService) { syncPrivatePaymentListsWithReservations(captor.capture(), eq(false)) } - - assertEquals( - listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH), - captor.firstValue.map { it.counterpartyReceiverPath }, - ) - assertEquals( - listOf(PRIVATE_ADDRESS, OTHER_PRIVATE_ADDRESS).map(PublicPaykitRepo::serializePayload), - captor.firstValue.map { it.reservations.single().payload }, - ) - assertEquals(2, captor.firstValue.map { it.reservations.single().reservationId }.distinct().size) - assertEquals( - setOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH), - cacheData.value.contacts.getValue(CONTACT_KEY).publishedPrivatePaymentReceiverPaths, - ) - verifyBlocking(paykitSdkService, atLeast(1)) { ensureLinkWithPeer(CONTACT_KEY, WALLET_RECEIVER_PATH) } - verifyBlocking(paykitSdkService, atLeast(1)) { ensureLinkWithPeer(CONTACT_KEY, SERVER_RECEIVER_PATH) } - } - - @Test - fun `prepareSavedContacts skips public-only receiver paths`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) - whenever { paykitSdkService.contactRecord(CONTACT_KEY) } - .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) - whenever { paykitSdkService.privateReceiverPathSelection(eq(CONTACT_KEY), any()) } - .thenReturn(privateReceiverPathSelection(emptyList())) - - val result = sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) - - assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - verifyBlocking(paykitSdkService, never()) { ensureLinkWithPeer(any(), any(), any()) } - verifyBlocking(paykitSdkService, never()) { syncPrivatePaymentListsWithReservations(any(), any()) } - - assertTrue(sut.removePublishedEndpointsForCleanup("test").isSuccess) - verifyBlocking(paykitSdkService, never()) { clearPrivatePaymentList(any(), any()) } - } - - @Test - fun `prepareSavedContacts links server receiver without publishing payment details`() = test { - settingsData.value = SettingsData( - sharesPrivatePaykitEndpoints = true, - publicPaykitLightningEnabled = false, - publicPaykitOnchainEnabled = true, - ) - whenever { paykitSdkService.contactRecord(CONTACT_KEY) } - .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) - whenever { paykitSdkService.privateReceiverPathSelection(eq(CONTACT_KEY), any()) } - .thenReturn( - privateReceiverPathSelection( - linkableReceiverPaths = listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH), - publishableReceiverPaths = listOf(WALLET_RECEIVER_PATH), - ), - ) - whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) }.thenAnswer { - privateListDeliveryReportForUpdates(it.getArgument(0)) - } - - val result = sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) - - assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - verifyBlocking(paykitSdkService, atLeast(1)) { ensureLinkWithPeer(CONTACT_KEY, SERVER_RECEIVER_PATH) } - val captor = argumentCaptor>() - verifyBlocking(paykitSdkService) { syncPrivatePaymentListsWithReservations(captor.capture(), eq(false)) } - assertEquals(listOf(WALLET_RECEIVER_PATH), captor.firstValue.map { it.counterpartyReceiverPath }) - } - - @Test - fun `prepareSavedContacts links relevant receivers when endpoint sharing is disabled`() = test { + fun `prepareSavedContacts links contacts when endpoint sharing is disabled`() = test { settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) whenever { paykitSdkService.contactRecord(CONTACT_KEY) } - .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) - whenever { paykitSdkService.privateReceiverPathSelection(eq(CONTACT_KEY), any()) } - .thenReturn( - privateReceiverPathSelection( - linkableReceiverPaths = listOf(SERVER_RECEIVER_PATH), - publishableReceiverPaths = emptyList(), - ), - ) - + .thenReturn(contactRecord(CONTACT_KEY)) val result = sut.prepareSavedContacts(listOf(CONTACT_KEY)) assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - verifyBlocking(paykitSdkService) { ensureLinkWithPeer(CONTACT_KEY, SERVER_RECEIVER_PATH) } + verifyBlocking(paykitSdkService) { ensureLinkWithPeer(CONTACT_KEY) } verifyBlocking(paykitSdkService, never()) { syncPrivatePaymentListsWithReservations(any(), any()) } - verify(addressReservationRepo, never()).currentOrRotatedAddress(any(), any()) - } - - @Test - fun `initial link burst discovers a server receiver published after the contact was saved`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) - whenever { paykitSdkService.contactRecord(CONTACT_KEY) } - .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH))) - whenever { pubkyService.discoverRelevantReceiverPaths(CONTACT_KEY) } - .thenReturn(listOf(WALLET_RECEIVER_PATH)) - .thenReturn(listOf(WALLET_RECEIVER_PATH)) - .thenReturn(listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH)) - whenever { - pubkyService.saveContact( - CONTACT_KEY, - null, - listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH), - ) - }.thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) - - assertTrue(sut.prepareSavedContacts(listOf(CONTACT_KEY)).isSuccess) - clearInvocations(paykitSdkService, pubkyService) - - sut.startInitialLinkBurst(listOf(CONTACT_KEY), "test") - runCurrent() - advanceTimeBy(2_000) - runCurrent() - - verifyBlocking(pubkyService) { - saveContact( - CONTACT_KEY, - null, - listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH), - ) - } - verifyBlocking(paykitSdkService) { ensureLinkWithPeer(CONTACT_KEY, SERVER_RECEIVER_PATH) } - verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } - sut.closeAndClear() - } - - @Test - fun `initial link burst does not recreate a contact deleted during discovery`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) - whenever(paykitSdkService.contactRecord(CONTACT_KEY)) - .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH)), null) - whenever { pubkyService.discoverRelevantReceiverPaths(CONTACT_KEY) } - .thenReturn(listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH)) - - sut.startInitialLinkBurst(listOf(CONTACT_KEY), "test") - runCurrent() - - verify(paykitSdkService, times(2)).contactRecord(CONTACT_KEY) - verifyBlocking(pubkyService, never()) { - saveContact( - CONTACT_KEY, - null, - listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH), - ) - } - sut.closeAndClear() + verify(addressReservationRepo, never()).currentOrRotatedAddress(any()) } @Test @@ -370,16 +203,16 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.startInitialLinkBurst(listOf(CONTACT_KEY), "test") runCurrent() - clearInvocations(pubkyService) + clearInvocations(paykitSdkService) sut.startInitialLinkBurst(listOf(OTHER_CONTACT_KEY), "test") runCurrent() - clearInvocations(pubkyService) + clearInvocations(paykitSdkService) advanceTimeBy(2_000) runCurrent() - verifyBlocking(pubkyService) { discoverRelevantReceiverPaths(OTHER_CONTACT_KEY) } - verifyBlocking(pubkyService, never()) { discoverRelevantReceiverPaths(CONTACT_KEY) } + verifyBlocking(paykitSdkService) { ensureLinkWithPeer(OTHER_CONTACT_KEY) } + verifyBlocking(paykitSdkService, never()) { ensureLinkWithPeer(CONTACT_KEY) } sut.closeAndClear() } @@ -389,142 +222,16 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.startInitialLinkBurst(listOf(CONTACT_KEY), "test") runCurrent() - clearInvocations(pubkyService) + clearInvocations(paykitSdkService) sut.startInitialLinkBurst(emptyList(), "test") advanceTimeBy(30_000) runCurrent() - verifyBlocking(pubkyService, never()) { discoverRelevantReceiverPaths(any()) } + verifyBlocking(paykitSdkService, never()) { ensureLinkWithPeer(any(), any()) } sut.closeAndClear() } - @Test - fun `prepareSavedContacts clears receiver paths that are no longer eligible`() = test { - settingsData.value = SettingsData( - sharesPrivatePaykitEndpoints = true, - publicPaykitLightningEnabled = false, - publicPaykitOnchainEnabled = true, - ) - whenever { paykitSdkService.contactRecord(CONTACT_KEY) } - .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) - whenever { addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY, SERVER_RECEIVER_PATH) } - .thenReturn(Result.success(OTHER_PRIVATE_ADDRESS)) - whenever { paykitSdkService.privateReceiverPathSelection(eq(CONTACT_KEY), any()) } - .thenReturn(privateReceiverPathSelection(listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) - .thenReturn(privateReceiverPathSelection(listOf(WALLET_RECEIVER_PATH))) - whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) }.thenAnswer { - privateListDeliveryReportForUpdates(it.getArgument(0)) - } - - sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) - clearInvocations(paykitSdkService) - val result = sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) - - assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - val captor = argumentCaptor>() - verifyBlocking(paykitSdkService) { syncPrivatePaymentListsWithReservations(captor.capture(), eq(false)) } - val updatesByPath = captor.firstValue.associateBy { it.counterpartyReceiverPath } - assertEquals(1, updatesByPath.getValue(WALLET_RECEIVER_PATH).reservations.size) - assertTrue(updatesByPath.getValue(SERVER_RECEIVER_PATH).reservations.isEmpty()) - assertEquals( - setOf(WALLET_RECEIVER_PATH), - cacheData.value.contacts.getValue(CONTACT_KEY).publishedPrivatePaymentReceiverPaths, - ) - } - - @Test - fun `prepareSavedContacts preserves receiver paths when marker lookup fails`() = test { - settingsData.value = SettingsData( - sharesPrivatePaykitEndpoints = true, - publicPaykitLightningEnabled = false, - publicPaykitOnchainEnabled = true, - ) - whenever { paykitSdkService.contactRecord(CONTACT_KEY) } - .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) - whenever { addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY, SERVER_RECEIVER_PATH) } - .thenReturn(Result.success(OTHER_PRIVATE_ADDRESS)) - whenever { paykitSdkService.privateReceiverPathSelection(eq(CONTACT_KEY), any()) } - .thenReturn(privateReceiverPathSelection(listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) - .thenReturn( - privateReceiverPathSelection( - publishableReceiverPaths = listOf(WALLET_RECEIVER_PATH), - cleanupProtectedReceiverPaths = listOf(SERVER_RECEIVER_PATH), - error = PrivatePaykitTestAppError("marker unavailable"), - ), - ) - whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) }.thenAnswer { - privateListDeliveryReportForUpdates(it.getArgument(0)) - } - - sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) - clearInvocations(paykitSdkService) - val result = sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) - - assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - val captor = argumentCaptor>() - verifyBlocking(paykitSdkService) { syncPrivatePaymentListsWithReservations(captor.capture(), eq(false)) } - assertEquals(listOf(WALLET_RECEIVER_PATH), captor.firstValue.map { it.counterpartyReceiverPath }) - assertEquals( - setOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH), - cacheData.value.contacts.getValue(CONTACT_KEY).publishedPrivatePaymentReceiverPaths, - ) - } - - @Test - fun `immediate preparation fails when every marker lookup fails`() = test { - settingsData.value = SettingsData( - sharesPrivatePaykitEndpoints = true, - publicPaykitLightningEnabled = false, - publicPaykitOnchainEnabled = true, - ) - whenever { paykitSdkService.privateReceiverPathSelection(eq(CONTACT_KEY), any()) } - .thenReturn( - privateReceiverPathSelection( - publishableReceiverPaths = emptyList(), - cleanupProtectedReceiverPaths = listOf(WALLET_RECEIVER_PATH), - error = PrivatePaykitTestAppError("marker unavailable"), - ), - ) - - val result = sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) - - assertTrue(result.isFailure) - verifyBlocking(paykitSdkService, never()) { syncPrivatePaymentListsWithReservations(any(), any()) } - } - - @Test - fun `immediate preparation keeps valid contacts when another marker lookup fails`() = test { - settingsData.value = SettingsData( - sharesPrivatePaykitEndpoints = true, - publicPaykitLightningEnabled = false, - publicPaykitOnchainEnabled = true, - ) - whenever { addressReservationRepo.currentOrRotatedAddress(OTHER_CONTACT_KEY, WALLET_RECEIVER_PATH) } - .thenReturn(Result.success(OTHER_PRIVATE_ADDRESS)) - whenever { paykitSdkService.privateReceiverPathSelection(eq(CONTACT_KEY), any()) } - .thenReturn( - privateReceiverPathSelection( - publishableReceiverPaths = emptyList(), - cleanupProtectedReceiverPaths = listOf(WALLET_RECEIVER_PATH), - error = PrivatePaykitTestAppError("marker unavailable"), - ), - ) - whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) }.thenAnswer { - privateListDeliveryReportForUpdates(it.getArgument(0)) - } - - val result = sut.prepareSavedContacts( - listOf(CONTACT_KEY, OTHER_CONTACT_KEY), - requireImmediatePublication = true, - ) - - assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - val captor = argumentCaptor>() - verifyBlocking(paykitSdkService) { syncPrivatePaymentListsWithReservations(captor.capture(), eq(false)) } - assertEquals(listOf(OTHER_CONTACT_KEY), captor.firstValue.map { it.counterparty }) - } - @Test fun `prepareSavedContacts succeeds when link preparation fails but SDK queues reservations`() = test { settingsData.value = SettingsData( @@ -532,7 +239,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { publicPaykitLightningEnabled = false, publicPaykitOnchainEnabled = true, ) - whenever { paykitSdkService.ensureLinkWithPeer(CONTACT_KEY, WALLET_RECEIVER_PATH) }.thenAnswer { + whenever { paykitSdkService.ensureLinkWithPeer(CONTACT_KEY) }.thenAnswer { throw PrivatePaykitTestAppError("still linking") } @@ -541,29 +248,9 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(result.isSuccess, result.exceptionOrNull().toString()) verifyBlocking(paykitSdkService) { syncPrivatePaymentListsWithReservations(any(), eq(false)) } assertEquals( - setOf(WALLET_RECEIVER_PATH), - cacheData.value.contacts.getValue(CONTACT_KEY).publishedPrivatePaymentReceiverPaths, - ) - } - - @Test - fun `prepareSavedContacts reads linked peers once for multiple contacts`() = test { - settingsData.value = SettingsData( - sharesPrivatePaykitEndpoints = true, - publicPaykitLightningEnabled = false, - publicPaykitOnchainEnabled = true, + true, + cacheData.value.contacts.getValue(CONTACT_KEY).hasPublishedPrivatePaymentList, ) - whenever { paykitSdkService.privateReceiverPathSelection(any(), any()) }.thenReturn( - privateReceiverPathSelection( - publishableReceiverPaths = emptyList(), - linkableReceiverPaths = emptyList(), - ), - ) - - val result = sut.prepareSavedContacts(listOf(CONTACT_KEY, OTHER_CONTACT_KEY)) - - assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - verifyBlocking(paykitSdkService, times(1)) { linkedPeers() } } @Test @@ -582,7 +269,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { advanceTimeBy(257_000) runCurrent() - verifyBlocking(paykitSdkService, atLeast(8)) { ensureLinkWithPeer(CONTACT_KEY, WALLET_RECEIVER_PATH) } + verifyBlocking(paykitSdkService, atLeast(8)) { ensureLinkWithPeer(CONTACT_KEY) } sut.closeAndClear() } @@ -617,49 +304,75 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `received wallet invoice rotation preserves server receiver invoice`() = test { + fun `publication skips contacts without Paykit and cleanup has nothing to withdraw`() = test { settingsData.value = SettingsData( sharesPrivatePaykitEndpoints = true, - publicPaykitLightningEnabled = true, - publicPaykitOnchainEnabled = false, - ) - whenever(lightningRepo.canReceive()).thenReturn(true) - whenever { paykitSdkService.contactRecord(CONTACT_KEY) } - .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) - whenever { - lightningRepo.createInvoice( - amountSats = null, - description = "", - expirySeconds = PRIVATE_BOLT11_EXPIRY_SECONDS, - ) - }.thenReturn( - Result.success(PRIVATE_BOLT11), - Result.success(SERVER_PRIVATE_BOLT11), - Result.success(ROTATED_PRIVATE_BOLT11), + publicPaykitLightningEnabled = false, + publicPaykitOnchainEnabled = true, ) - whenever(coreService.decode(PRIVATE_BOLT11)) - .thenReturn(Scanner.Lightning(lightningInvoice(PRIVATE_BOLT11, byteArrayOf(1, 1, 1)))) - whenever(coreService.decode(SERVER_PRIVATE_BOLT11)) - .thenReturn(Scanner.Lightning(lightningInvoice(SERVER_PRIVATE_BOLT11, byteArrayOf(2, 2, 2)))) - whenever(coreService.decode(ROTATED_PRIVATE_BOLT11)) - .thenReturn(Scanner.Lightning(lightningInvoice(ROTATED_PRIVATE_BOLT11, byteArrayOf(3, 3, 3)))) + whenever { paykitSdkService.ensureLinkWithPeer(CONTACT_KEY) } + .thenAnswer { throw PaykitException.NotFound("not_found", "No App Registry") } - sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true).getOrThrow() - val settledPayment = mock { - on { id } doReturn "010101" - on { kind } doReturn mock() - on { direction } doReturn PaymentDirection.INBOUND - on { status } doReturn PaymentStatus.SUCCEEDED - } - whenever(lightningRepo.getPayments()).thenReturn(Result.success(listOf(settledPayment))) + val publication = sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) + val cleanup = sut.disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) - val result = sut.handleReceivedPayment("010101") + assertTrue(publication.isSuccess, publication.exceptionOrNull().toString()) + assertTrue(cleanup.isSuccess, cleanup.exceptionOrNull().toString()) + verifyBlocking(addressReservationRepo, never()) { currentOrRotatedAddress(any()) } + verifyBlocking(paykitSdkService, never()) { syncPrivatePaymentListsWithReservations(any(), any()) } + verifyBlocking(paykitSdkService, never()) { clearPrivatePaymentList(any()) } + } - assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - val invoices = cacheData.value.contacts.getValue(CONTACT_KEY).localInvoicesByReceiverPath - assertEquals(ROTATED_PRIVATE_BOLT11, invoices.getValue(WALLET_RECEIVER_PATH).bolt11) - assertEquals(SERVER_PRIVATE_BOLT11, invoices.getValue(SERVER_RECEIVER_PATH).bolt11) - sut.closeAndClear() + @Test + fun `disabled cleanup retains its capability through failures and direct retries`() = test { + val publicRepo = PublicPaykitRepo( + ioDispatcher = testDispatcher, + pubkyRepo = mock(), + walletRepo = walletRepo, + lightningRepo = lightningRepo, + coreService = coreService, + paykitSdkService = paykitSdkService, + settingsStore = settingsStore, + privatePaykitCacheStore = cacheStore, + clock = clock, + ) + for (failureStage in listOf("enable capability", "withdraw", "disable capability")) { + cacheData.value = PrivatePaykitCacheData( + contacts = mapOf(CONTACT_KEY to PrivatePaykitContactCacheData(hasPublishedPrivatePaymentList = true)), + ) + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) + sut = createSut(publicRepo) + var capability = false + var failed = false + doSuspendableAnswer { + val enabled = it.getArgument(0) + if (!failed && failureStage == if (enabled) "enable capability" else "disable capability") { + failed = true + throw AppError("Registration unavailable") + } + capability = enabled + }.whenever(paykitSdkService).syncPaykitApp(any()) + doSuspendableAnswer { + assertTrue(capability, "Withdrawal requires the private capability") + if (!failed && failureStage == "withdraw") { + failed = true + throw AppError("Delivery unavailable") + } + privateListDeliveryReport() + }.whenever(paykitSdkService).clearPrivatePaymentList(CONTACT_KEY) + + sut.disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) + assertTrue(failed, failureStage) + assertTrue(cacheData.value.cleanupPending, failureStage) + publicRepo.syncPaykitApp(privateSharingEnabled = false).getOrThrow() + assertTrue(capability, failureStage) + + sut.retryPendingEndpointRemoval(listOf(CONTACT_KEY)).getOrThrow() + + assertFalse(capability, failureStage) + assertFalse(cacheData.value.cleanupPending, failureStage) + assertFalse(cacheData.value.contacts[CONTACT_KEY]?.hasPublishedPrivatePaymentList == true, failureStage) + } } @Test @@ -674,7 +387,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { val result = sut.disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) assertTrue(result.isSuccess) - verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } + verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY) } assertTrue(cacheData.value.contacts.isEmpty()) } @@ -686,14 +399,13 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { publicPaykitOnchainEnabled = true, ) sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true).getOrThrow() - whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) }.thenReturn( + whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY) }.thenReturn( privateListDeliveryReport( failedToQueue = listOf( PrivatePaymentListSyncChange( counterparty = CONTACT_KEY, - counterpartyReceiverPath = WALLET_RECEIVER_PATH, outboundMessageId = null, - error = "failed", + error = mock(), ), ), ), @@ -733,7 +445,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.retryPendingEndpointRemoval(listOf(CONTACT_KEY)).getOrThrow() - verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } + verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY) } assertFalse(cacheData.value.cleanupPending) } @@ -755,7 +467,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(result.isFailure) assertTrue(cacheData.value.cleanupPending) assertTrue( - cacheData.value.contacts.values.all { it.publishedPrivatePaymentReceiverPaths.isEmpty() }, + cacheData.value.contacts.values.all { !it.hasPublishedPrivatePaymentList }, ) } @@ -767,14 +479,13 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { publicPaykitOnchainEnabled = true, ) sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) - whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) }.thenReturn( + whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY) }.thenReturn( privateListDeliveryReport( failedToQueue = listOf( PrivatePaymentListSyncChange( counterparty = CONTACT_KEY, - counterpartyReceiverPath = WALLET_RECEIVER_PATH, outboundMessageId = null, - error = "failed", + error = mock(), ), ), ), @@ -794,123 +505,27 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { publicPaykitOnchainEnabled = true, ) sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) - whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } + whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY) } .thenReturn(privateListDeliveryReport(clearedCounterparties = listOf(CONTACT_KEY))) - val pendingReceiver = mock() - whenever(pendingReceiver.counterparty).thenReturn(CONTACT_KEY) - whenever(pendingReceiver.counterpartyReceiverPath).thenReturn(WALLET_RECEIVER_PATH) whenever { paykitSdkService.pendingOutboundPrivateCounterparties() } - .thenReturn(listOf(pendingReceiver)) + .thenReturn(listOf(CONTACT_KEY)) val result = sut.removePublishedEndpointsForCleanup("test") assertTrue(result.isFailure) assertTrue(cacheData.value.cleanupPending) assertEquals( - setOf(WALLET_RECEIVER_PATH), - cacheData.value.contacts.getValue(CONTACT_KEY).publishedPrivatePaymentReceiverPaths, + true, + cacheData.value.contacts.getValue(CONTACT_KEY).hasPublishedPrivatePaymentList, ) sut.closeAndClear() } - @Test - fun `cleanup keeps publication state when any saved receiver cleanup fails`() = test { - settingsData.value = SettingsData( - sharesPrivatePaykitEndpoints = true, - publicPaykitLightningEnabled = false, - publicPaykitOnchainEnabled = true, - ) - whenever { paykitSdkService.contactRecord(CONTACT_KEY) } - .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) - whenever { addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY, SERVER_RECEIVER_PATH) } - .thenReturn(Result.success(OTHER_PRIVATE_ADDRESS)) - whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) }.thenAnswer { - privateListDeliveryReportForUpdates(it.getArgument(0)) - } - sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) - assertEquals( - setOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH), - cacheData.value.contacts.getValue(CONTACT_KEY).publishedPrivatePaymentReceiverPaths, - ) - whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } - .thenReturn(privateListDeliveryReport(clearedCounterparties = listOf(CONTACT_KEY))) - whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY, SERVER_RECEIVER_PATH) }.thenReturn( - privateListDeliveryReport( - failedToQueue = listOf( - PrivatePaymentListSyncChange( - counterparty = CONTACT_KEY, - counterpartyReceiverPath = SERVER_RECEIVER_PATH, - outboundMessageId = null, - error = "failed", - ), - ), - ), - ) - - val result = sut.removePublishedEndpointsForCleanup("test") - - assertTrue(result.isFailure) - assertEquals(true, cacheData.value.cleanupPending) - assertEquals( - setOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH), - cacheData.value.contacts.getValue(CONTACT_KEY).publishedPrivatePaymentReceiverPaths, - ) - } - - @Test - fun `cleanup keeps pending state when linked receiver inspection fails`() = test { - settingsData.value = SettingsData( - sharesPrivatePaykitEndpoints = true, - publicPaykitLightningEnabled = false, - publicPaykitOnchainEnabled = true, - ) - sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true).getOrThrow() - whenever { paykitSdkService.linkedPeers() } - .thenThrow(IllegalStateException("link inspection failed")) - - val result = sut.removePublishedEndpointsForCleanup("test") - - assertTrue(result.isFailure) - assertTrue(cacheData.value.cleanupPending) - verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } - assertEquals( - setOf(WALLET_RECEIVER_PATH), - cacheData.value.contacts.getValue(CONTACT_KEY).publishedPrivatePaymentReceiverPaths, - ) - } - - @Test - fun `cleanup retries linked receiver inspection once for the batch`() = test { - cacheData.value = PrivatePaykitCacheData( - contacts = mapOf( - CONTACT_KEY to cachedPublishedContact(WALLET_RECEIVER_PATH), - OTHER_CONTACT_KEY to cachedPublishedContact(SERVER_RECEIVER_PATH), - ), - ) - sut = createSut() - var linkedPeerReads = 0 - whenever { paykitSdkService.linkedPeers() }.thenAnswer { - linkedPeerReads += 1 - if (linkedPeerReads <= 2) error("link inspection failed") - emptyList() - } - - val result = sut.removePublishedEndpointsForCleanup("test") - - assertTrue(result.isFailure) - verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } - verifyBlocking(paykitSdkService) { clearPrivatePaymentList(OTHER_CONTACT_KEY, SERVER_RECEIVER_PATH) } - assertTrue(CONTACT_KEY in cacheData.value.contacts) - assertTrue(OTHER_CONTACT_KEY in cacheData.value.contacts) - assertTrue(cacheData.value.cleanupPending) - assertEquals(3, linkedPeerReads) - } - @Test fun `invalid deleted contact key is dropped from cleanup state`() = test { val invalidPublicKey = "not-a-pubky" cacheData.value = PrivatePaykitCacheData( - contacts = mapOf(invalidPublicKey to cachedPublishedContact(WALLET_RECEIVER_PATH)), + contacts = mapOf(invalidPublicKey to cachedPublishedContact()), deletedContactCleanupPendingPublicKeys = setOf(invalidPublicKey), ) sut = createSut() @@ -920,50 +535,31 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(result.isSuccess, result.exceptionOrNull().toString()) assertTrue(cacheData.value.contacts.isEmpty()) assertTrue(cacheData.value.deletedContactCleanupPendingPublicKeys.isEmpty()) - verifyBlocking(paykitSdkService, never()) { clearPrivatePaymentList(any(), any()) } - } - - @Test - fun `cleanup uses linked receiver paths when contact record is gone`() = test { - settingsData.value = SettingsData( - sharesPrivatePaykitEndpoints = true, - publicPaykitLightningEnabled = false, - publicPaykitOnchainEnabled = true, - ) - whenever { paykitSdkService.contactRecord(CONTACT_KEY) }.thenReturn(null) - whenever { paykitSdkService.linkedPeers() } - .thenReturn(listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.LINKED))) - sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) - - val result = sut.removePublishedEndpointsForCleanup("test") - - assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } - verifyBlocking(paykitSdkService, never()) { clearPrivatePaymentList(CONTACT_KEY, SERVER_RECEIVER_PATH) } + verifyBlocking(paykitSdkService, never()) { clearPrivatePaymentList(any()) } } @Test fun `cleanup drains all contacts in one batch`() = test { cacheData.value = PrivatePaykitCacheData( contacts = mapOf( - CONTACT_KEY to cachedPublishedContact(WALLET_RECEIVER_PATH), - OTHER_CONTACT_KEY to cachedPublishedContact(SERVER_RECEIVER_PATH), + CONTACT_KEY to cachedPublishedContact(), + OTHER_CONTACT_KEY to cachedPublishedContact(), ), ) sut = createSut() whenever { paykitSdkService.linkedPeers() }.thenReturn( listOf( linkedPeer(CONTACT_KEY, LinkedPeerState.LINKED), - linkedPeer(OTHER_CONTACT_KEY, LinkedPeerState.LINKED, SERVER_RECEIVER_PATH), + linkedPeer(OTHER_CONTACT_KEY, LinkedPeerState.LINKED), ), ) val result = sut.removePublishedEndpointsForCleanup("test") assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } - verifyBlocking(paykitSdkService) { clearPrivatePaymentList(OTHER_CONTACT_KEY, SERVER_RECEIVER_PATH) } - verifyBlocking(paykitSdkService, times(2)) { linkedPeers() } + verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY) } + verifyBlocking(paykitSdkService) { clearPrivatePaymentList(OTHER_CONTACT_KEY) } + verifyBlocking(paykitSdkService, atLeast(1)) { linkedPeers() } verifyBlocking(paykitSdkService, times(1)) { pendingOutboundPrivateCounterparties() } verifyBlocking(paykitSdkService, times(2)) { processPendingPrivateMessages() } verifyBlocking(paykitSdkService, times(2)) { receivePrivateMessagesFromLinkedPeers() } @@ -974,8 +570,8 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `deleted contact retry cleans all pending contacts in one batch`() = test { cacheData.value = PrivatePaykitCacheData( contacts = mapOf( - CONTACT_KEY to cachedPublishedContact(WALLET_RECEIVER_PATH), - OTHER_CONTACT_KEY to cachedPublishedContact(SERVER_RECEIVER_PATH), + CONTACT_KEY to cachedPublishedContact(), + OTHER_CONTACT_KEY to cachedPublishedContact(), ), deletedContactCleanupPendingPublicKeys = setOf(CONTACT_KEY, OTHER_CONTACT_KEY), ) @@ -984,9 +580,9 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { val result = sut.retryPendingEndpointRemoval(emptyList()) assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } - verifyBlocking(paykitSdkService) { clearPrivatePaymentList(OTHER_CONTACT_KEY, SERVER_RECEIVER_PATH) } - verifyBlocking(paykitSdkService, times(2)) { linkedPeers() } + verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY) } + verifyBlocking(paykitSdkService) { clearPrivatePaymentList(OTHER_CONTACT_KEY) } + verifyBlocking(paykitSdkService, atLeast(1)) { linkedPeers() } assertTrue(cacheData.value.contacts.isEmpty()) assertTrue(cacheData.value.deletedContactCleanupPendingPublicKeys.isEmpty()) } @@ -995,13 +591,12 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `cleanup retains the batch when drain inspection fails`() = test { cacheData.value = PrivatePaykitCacheData( contacts = mapOf( - CONTACT_KEY to cachedPublishedContact(WALLET_RECEIVER_PATH), - OTHER_CONTACT_KEY to cachedPublishedContact(SERVER_RECEIVER_PATH), + CONTACT_KEY to cachedPublishedContact(), + OTHER_CONTACT_KEY to cachedPublishedContact(), ), ) sut = createSut() whenever { paykitSdkService.linkedPeers() } - .thenReturn(emptyList()) .thenThrow(IllegalStateException("drain inspection failed")) val result = sut.removePublishedEndpointsForCleanup("test") @@ -1015,23 +610,22 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `cleanup retains endpoint cache updated during remote removal`() = test { cacheData.value = PrivatePaykitCacheData( - contacts = mapOf(CONTACT_KEY to cachedPublishedContact(WALLET_RECEIVER_PATH)), + contacts = mapOf(CONTACT_KEY to cachedPublishedContact()), ) sut = createSut() val cleanupStarted = CompletableDeferred() val resumeCleanup = CompletableDeferred() - whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } + whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY) } .doSuspendableAnswer { cleanupStarted.complete(Unit) resumeCleanup.await() privateListDeliveryReport(clearedCounterparties = listOf(CONTACT_KEY)) } whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment( + paykitSdkService.prepareAndResolvePrivatePaymentRequest( eq(CONTACT_KEY), - eq(SERVER_RECEIVER_PATH), + eq("request-id"), eq(null), - any(), ) }.thenReturn(resolution(resolvedEndpoint(MethodId.P2wpkh, PRIVATE_ADDRESS), version = 7uL)) whenever(coreService.isAddressUsed(PRIVATE_ADDRESS)).thenReturn(false) @@ -1052,19 +646,18 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `cleanup isolates a failed contact while clearing successful contacts`() = test { cacheData.value = PrivatePaykitCacheData( contacts = mapOf( - CONTACT_KEY to cachedPublishedContact(WALLET_RECEIVER_PATH), - OTHER_CONTACT_KEY to cachedPublishedContact(SERVER_RECEIVER_PATH), + CONTACT_KEY to cachedPublishedContact(), + OTHER_CONTACT_KEY to cachedPublishedContact(), ), ) sut = createSut() - whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) }.thenReturn( + whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY) }.thenReturn( privateListDeliveryReport( failedToQueue = listOf( PrivatePaymentListSyncChange( counterparty = CONTACT_KEY, - counterpartyReceiverPath = WALLET_RECEIVER_PATH, outboundMessageId = null, - error = "failed", + error = mock(), ), ), ), @@ -1085,9 +678,9 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { publicPaykitLightningEnabled = false, publicPaykitOnchainEnabled = true, ) - whenever { addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY, WALLET_RECEIVER_PATH) } + whenever { addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY) } .thenReturn(Result.failure(PrivatePaykitTestAppError("address unavailable"))) - whenever { addressReservationRepo.currentOrRotatedAddress(OTHER_CONTACT_KEY, WALLET_RECEIVER_PATH) } + whenever { addressReservationRepo.currentOrRotatedAddress(OTHER_CONTACT_KEY) } .thenReturn(Result.success(OTHER_PRIVATE_ADDRESS)) whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) }.thenReturn( privateListDeliveryReport(queuedCounterparties = listOf(OTHER_CONTACT_KEY)), @@ -1097,13 +690,13 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(result.isSuccess) assertEquals( - setOf(WALLET_RECEIVER_PATH), - cacheData.value.contacts.getValue(OTHER_CONTACT_KEY).publishedPrivatePaymentReceiverPaths, + true, + cacheData.value.contacts.getValue(OTHER_CONTACT_KEY).hasPublishedPrivatePaymentList, ) } @Test - fun `prepareSavedContacts continues when another contact record cannot be read`() = test { + fun `prepareSavedContacts does not require a cached contact record`() = test { settingsData.value = SettingsData( sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false, @@ -1111,7 +704,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) whenever { paykitSdkService.contactRecord(CONTACT_KEY) } .thenThrow(IllegalStateException("contact unavailable")) - whenever { addressReservationRepo.currentOrRotatedAddress(OTHER_CONTACT_KEY, WALLET_RECEIVER_PATH) } + whenever { addressReservationRepo.currentOrRotatedAddress(OTHER_CONTACT_KEY) } .thenReturn(Result.success(OTHER_PRIVATE_ADDRESS)) whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) }.thenAnswer { privateListDeliveryReportForUpdates(it.getArgument(0)) @@ -1122,7 +715,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(result.isSuccess) val captor = argumentCaptor>() verifyBlocking(paykitSdkService) { syncPrivatePaymentListsWithReservations(captor.capture(), eq(false)) } - assertEquals(listOf(OTHER_CONTACT_KEY), captor.firstValue.map { it.counterparty }) + assertEquals(listOf(CONTACT_KEY, OTHER_CONTACT_KEY), captor.firstValue.map { it.counterparty }) } @Test @@ -1157,7 +750,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `beginSavedContactPayment uses public resolution while Noise link is not established`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, null) }.thenReturn( resolution( status = PrivatePaymentResolutionStatus.NO_ENDPOINT, @@ -1178,7 +771,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever(paykitSdkService.hasPrivatePaymentAccess()).thenReturn(false) whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, null) }.thenReturn(resolution(resolvedEndpoint(MethodId.Bolt11, PRIVATE_BOLT11), version = 7uL)) whenever(coreService.decode(PRIVATE_BOLT11)) .thenReturn(Scanner.Lightning(lightningInvoice(PRIVATE_BOLT11, byteArrayOf(9, 9, 9)))) @@ -1188,7 +781,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals( PublicPaykitPaymentResult.Opened( paymentRequest = PRIVATE_BOLT11, - privatePaymentContext = PrivatePaykitPaymentContext(WALLET_RECEIVER_PATH, 7uL), + privatePaymentContext = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL), ), result, ) @@ -1211,7 +804,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { privateListDeliveryReport(queuedCounterparties = listOf(CONTACT_KEY)) } whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, null) }.thenReturn(resolution(resolvedEndpoint(MethodId.Bolt11, PRIVATE_BOLT11), version = 7uL)) whenever(coreService.decode(PRIVATE_BOLT11)) .thenReturn(Scanner.Lightning(lightningInvoice(PRIVATE_BOLT11, byteArrayOf(9, 9, 9)))) @@ -1241,7 +834,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { privateListDeliveryReport(queuedCounterparties = listOf(CONTACT_KEY)) } whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, null) }.thenReturn(resolution(resolvedEndpoint(MethodId.Bolt11, PRIVATE_BOLT11), version = 7uL)) whenever(coreService.decode(PRIVATE_BOLT11)) .thenReturn(Scanner.Lightning(lightningInvoice(PRIVATE_BOLT11, byteArrayOf(9, 9, 9)))) @@ -1260,7 +853,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `beginSavedContactPayment opens private endpoint with its list version`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, null) }.thenReturn(resolution(resolvedEndpoint(MethodId.Bolt11, PRIVATE_BOLT11), version = 7uL)) whenever(coreService.decode(PRIVATE_BOLT11)) .thenReturn(Scanner.Lightning(lightningInvoice(PRIVATE_BOLT11, byteArrayOf(9, 9, 9)))) @@ -1270,7 +863,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals( PublicPaykitPaymentResult.Opened( paymentRequest = PRIVATE_BOLT11, - privatePaymentContext = PrivatePaykitPaymentContext(WALLET_RECEIVER_PATH, 7uL), + privatePaymentContext = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL), ), result, ) @@ -1281,7 +874,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `beginSavedContactPayment never falls back while linked recovery is pending`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, null) }.thenReturn( resolution( status = PrivatePaymentResolutionStatus.NO_ENDPOINT, @@ -1301,11 +894,10 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `beginPaymentRequest waits for a linking peer before opening cached details`() = test { val request = paymentRequest() whenever( - paykitSdkService.prepareAndResolvePrivateContactPayment( + paykitSdkService.prepareAndResolvePrivatePaymentRequest( eq(CONTACT_KEY), - eq(SERVER_RECEIVER_PATH), + eq("request-id"), eq(null), - any(), ) ).thenReturn( resolution( @@ -1329,7 +921,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals( PublicPaykitPaymentResult.Opened( paymentRequest = SERVER_PRIVATE_BOLT11, - privatePaymentContext = PrivatePaykitPaymentContext(SERVER_RECEIVER_PATH, 7uL), + privatePaymentContext = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL), ), opened, ) @@ -1340,11 +932,10 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `beginPaymentRequest rejects cached details when the peer is not linked`() = test { val request = paymentRequest() whenever( - paykitSdkService.prepareAndResolvePrivateContactPayment( + paykitSdkService.prepareAndResolvePrivatePaymentRequest( eq(CONTACT_KEY), - eq(SERVER_RECEIVER_PATH), + eq("request-id"), eq(null), - any(), ) ).thenReturn( resolution( @@ -1365,11 +956,10 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `beginPaymentRequest keeps typed recovery failures pending`() = test { val request = paymentRequest() whenever( - paykitSdkService.prepareAndResolvePrivateContactPayment( + paykitSdkService.prepareAndResolvePrivatePaymentRequest( eq(CONTACT_KEY), - eq(SERVER_RECEIVER_PATH), + eq("request-id"), eq(null), - any(), ) ).doSuspendableAnswer { throw PaykitException.RecoveryRequired("recovery_required", "Handshake is in progress") @@ -1385,7 +975,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `beginSavedContactPayment retries a newer private list without public fallback`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, null) }.thenReturn( resolution( status = PrivatePaymentResolutionStatus.WAITING_FOR_UPDATED_PAYMENT_LIST, @@ -1403,12 +993,12 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals( PublicPaykitPaymentResult.Opened( paymentRequest = PRIVATE_BOLT11, - privatePaymentContext = PrivatePaykitPaymentContext(WALLET_RECEIVER_PATH, 7uL), + privatePaymentContext = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL), ), result, ) verifyBlocking(paykitSdkService, times(2)) { - prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) + prepareAndResolvePrivateContactPayment(CONTACT_KEY, null) } verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } @@ -1417,7 +1007,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `beginSavedContactPayment only falls back after failure when Noise link is absent`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, null) }.thenThrow(IllegalStateException("private unavailable")) val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() @@ -1430,7 +1020,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `beginSavedContactPayment propagates failure when Noise link exists`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, null) }.thenThrow(IllegalStateException("private unavailable")) whenever(paykitSdkService.linkedPeers()) .thenReturn(listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.LINKED))) @@ -1443,14 +1033,14 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `consumePrivatePaymentList persists version clears list and rejects reuse`() = test { - val context = PrivatePaykitPaymentContext(WALLET_RECEIVER_PATH, 7uL) + val context = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL) sut.consumePrivatePaymentList(CONTACT_KEY, context).getOrThrow() assertEquals( 7uL, cacheData.value.contacts.getValue(CONTACT_KEY) - .consumedPrivatePaymentListVersionsByReceiverPath[WALLET_RECEIVER_PATH], + .consumedPrivatePaymentListVersion, ) assertFailsWith { sut.consumePrivatePaymentList(CONTACT_KEY, context).getOrThrow() @@ -1459,16 +1049,15 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `releasePrivatePaymentList makes matching version reusable without clearing newer consumption`() = test { - val releasedContext = PrivatePaykitPaymentContext(WALLET_RECEIVER_PATH, 7uL) - val newerContext = PrivatePaykitPaymentContext(WALLET_RECEIVER_PATH, 8uL) + val releasedContext = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL) + val newerContext = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 8uL) sut.consumePrivatePaymentList(CONTACT_KEY, releasedContext).getOrThrow() sut.releasePrivatePaymentList(CONTACT_KEY, releasedContext).getOrThrow() assertNull( cacheData.value.contacts[CONTACT_KEY] - ?.consumedPrivatePaymentListVersionsByReceiverPath - ?.get(WALLET_RECEIVER_PATH), + ?.consumedPrivatePaymentListVersion, ) sut.consumePrivatePaymentList(CONTACT_KEY, releasedContext).getOrThrow() sut.consumePrivatePaymentList(CONTACT_KEY, newerContext).getOrThrow() @@ -1478,7 +1067,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals( 8uL, cacheData.value.contacts.getValue(CONTACT_KEY) - .consumedPrivatePaymentListVersionsByReceiverPath[WALLET_RECEIVER_PATH], + .consumedPrivatePaymentListVersion, ) } @@ -1487,10 +1076,10 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.prepareSavedContacts(listOf(CONTACT_KEY)) sut.consumePrivatePaymentList( CONTACT_KEY, - PrivatePaykitPaymentContext(WALLET_RECEIVER_PATH, 7uL), + PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL), ).getOrThrow() whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, 7uL) + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, 7uL) }.thenReturn( resolution( status = PrivatePaymentResolutionStatus.WAITING_FOR_UPDATED_PAYMENT_LIST, @@ -1503,7 +1092,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() verifyBlocking(paykitSdkService, times(4)) { - prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, 7uL) + prepareAndResolvePrivateContactPayment(CONTACT_KEY, 7uL) } } @@ -1511,7 +1100,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `beginSavedContactPayment does not fall back when private resolution is cancelled`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, null) }.thenThrow(CancellationException("cancelled")) assertFailsWith { @@ -1524,11 +1113,10 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `beginPaymentRequestWaitingForUpdatedList retries a newer private list`() = test { val request = paymentRequest() whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment( + paykitSdkService.prepareAndResolvePrivatePaymentRequest( eq(CONTACT_KEY), - eq(SERVER_RECEIVER_PATH), + eq("request-id"), eq(null), - any(), ) }.thenReturn( resolution( @@ -1547,16 +1135,15 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals( PublicPaykitPaymentResult.Opened( paymentRequest = SERVER_PRIVATE_BOLT11, - privatePaymentContext = PrivatePaykitPaymentContext(SERVER_RECEIVER_PATH, 7uL), + privatePaymentContext = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL), ), result, ) verifyBlocking(paykitSdkService, times(2)) { - prepareAndResolvePrivateContactPayment( + prepareAndResolvePrivatePaymentRequest( eq(CONTACT_KEY), - eq(SERVER_RECEIVER_PATH), + eq("request-id"), eq(null), - any(), ) } } @@ -1565,11 +1152,10 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `beginPaymentRequest resolves only accepted private endpoints with the requested amount`() = test { val request = paymentRequest(acceptedEndpointIdentifiers = listOf(MethodId.Bolt11.rawValue)) whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment( + paykitSdkService.prepareAndResolvePrivatePaymentRequest( eq(CONTACT_KEY), - eq(SERVER_RECEIVER_PATH), + eq("request-id"), eq(null), - any(), ) }.thenReturn( resolution( @@ -1586,21 +1172,17 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals( PublicPaykitPaymentResult.Opened( paymentRequest = PRIVATE_BOLT11, - privatePaymentContext = PrivatePaykitPaymentContext(SERVER_RECEIVER_PATH, 7uL), + privatePaymentContext = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL), ), result, ) - val amountCaptor = argumentCaptor() verifyBlocking(paykitSdkService) { - prepareAndResolvePrivateContactPayment( + prepareAndResolvePrivatePaymentRequest( eq(CONTACT_KEY), - eq(SERVER_RECEIVER_PATH), + eq("request-id"), eq(null), - amountCaptor.capture(), ) } - assertEquals("0.000025", amountCaptor.firstValue.value) - assertEquals("btc", amountCaptor.firstValue.asset) verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } @@ -1609,11 +1191,10 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest() whenever(paykitSdkService.hasPrivatePaymentAccess()).thenReturn(false) whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment( + paykitSdkService.prepareAndResolvePrivatePaymentRequest( eq(CONTACT_KEY), - eq(SERVER_RECEIVER_PATH), + eq("request-id"), eq(null), - any(), ) }.thenReturn( resolution( @@ -1629,13 +1210,67 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals( PublicPaykitPaymentResult.Opened( paymentRequest = SERVER_PRIVATE_BOLT11, - privatePaymentContext = PrivatePaykitPaymentContext(SERVER_RECEIVER_PATH, 7uL), + privatePaymentContext = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL), ), result, ) verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } + @Test + fun `bound requests keep their own addresses after contact list updates without consuming the list`() = test { + sut.prepareSavedContacts(listOf(CONTACT_KEY)) + sut.consumePrivatePaymentList(CONTACT_KEY, PrivatePaykitPaymentContext(emptyMap(), 7uL)).getOrThrow() + whenever { + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, 7uL) + }.thenReturn(resolution(resolvedEndpoint(MethodId.P2wpkh, "latest-address"), version = 8uL)) + whenever(coreService.isAddressUsed(any())).thenReturn(false) + sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() + val cachedEndpoints = cacheData.value.contacts.getValue(CONTACT_KEY).remoteEndpoints + val addresses = mapOf("invoice-a" to PRIVATE_ADDRESS, "invoice-b" to OTHER_PRIVATE_ADDRESS) + whenever { + paykitSdkService.prepareAndResolvePrivatePaymentRequest(eq(CONTACT_KEY), any(), eq(7uL)) + }.thenAnswer { + resolution(resolvedEndpoint(MethodId.P2wpkh, addresses.getValue(it.getArgument(1))), version = null) + } + + for (id in listOf("invoice-a", "invoice-b", "invoice-a")) { + val request = paymentRequest(listOf(MethodId.P2wpkh.rawValue)).copy(paymentRequestId = id) + val result = sut.beginPaymentRequest(request).getOrThrow() + val context = PrivatePaykitPaymentContext(mapOf(MethodId.P2wpkh.rawValue to "bitkit"), null) + assertEquals(PublicPaykitPaymentResult.Opened(addresses.getValue(id), context), result) + sut.consumePrivatePaymentList(CONTACT_KEY, context).getOrThrow() + sut.releasePrivatePaymentList(CONTACT_KEY, context).getOrThrow() + assertEquals(7uL, cacheData.value.contacts.getValue(CONTACT_KEY).consumedPrivatePaymentListVersion) + assertEquals(cachedEndpoints, cacheData.value.contacts.getValue(CONTACT_KEY).remoteEndpoints) + } + verifyBlocking(paykitSdkService, times(1)) { prepareAndResolvePrivateContactPayment(CONTACT_KEY, 7uL) } + verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } + } + + @Test + fun `bound request with no payable candidate never falls back to contact or public endpoints`() = test { + whenever { + paykitSdkService.prepareAndResolvePrivatePaymentRequest(CONTACT_KEY, "request-id", null) + }.thenReturn(resolution(version = null, linkState = LinkedPeerState.LINKED)) + + assertEquals(PublicPaykitPaymentResult.NoEndpoint, sut.beginPaymentRequest(paymentRequest()).getOrThrow()) + verifyBlocking(paykitSdkService, never()) { prepareAndResolvePrivateContactPayment(any(), any(), any()) } + verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } + } + + @Test + fun `bound request keeps wallet address usage validation`() = test { + whenever { + paykitSdkService.prepareAndResolvePrivatePaymentRequest(CONTACT_KEY, "request-id", null) + }.thenReturn(resolution(resolvedEndpoint(MethodId.P2wpkh, PRIVATE_ADDRESS), version = null)) + whenever(coreService.isAddressUsed(PRIVATE_ADDRESS)).thenReturn(true) + + val request = paymentRequest(listOf(MethodId.P2wpkh.rawValue)) + assertEquals(PublicPaykitPaymentResult.NotOpened, sut.beginPaymentRequest(request).getOrThrow()) + verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } + } + @Test fun `beginPaymentRequest rechecks expiration after private resolution`() = test { val request = paymentRequest() @@ -1644,11 +1279,10 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { Instant.fromEpochSeconds(NOW_SECONDS + 61), ) whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment( + paykitSdkService.prepareAndResolvePrivatePaymentRequest( eq(CONTACT_KEY), - eq(SERVER_RECEIVER_PATH), + eq("request-id"), eq(null), - any(), ) }.thenReturn( resolution( @@ -1671,7 +1305,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.exportBackupState()).thenReturn(backup) sut.consumePrivatePaymentList( CONTACT_KEY, - PrivatePaykitPaymentContext(WALLET_RECEIVER_PATH, 7uL), + PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL), ).getOrThrow() val snapshot = sut.backupSnapshot().getOrThrow() @@ -1681,12 +1315,12 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals( 7uL, cacheData.value.contacts.getValue(CONTACT_KEY) - .consumedPrivatePaymentListVersionsByReceiverPath[WALLET_RECEIVER_PATH], + .consumedPrivatePaymentListVersion, ) - verifyBlocking(paykitSdkService) { restoreBackupState(backup) } + verifyBlocking(paykitSdkService) { retainRecoveryBackup(backup) } } - private fun createSut() = PrivatePaykitRepo( + private fun createSut(publicPaykitRepo: PublicPaykitRepo = this.publicPaykitRepo) = PrivatePaykitRepo( ioDispatcher = testDispatcher, paykitSdkService = paykitSdkService, pubkyService = pubkyService, @@ -1731,6 +1365,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { return PaykitResolvedPaymentEndpoint( identifier = methodId.rawValue, payload = PublicPaykitRepo.serializePayload(value), + appId = "bitkit", ) } @@ -1739,7 +1374,6 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) = PaykitPaymentRequest( paymentRequestId = "request-id", counterparty = CONTACT_KEY, - counterpartyReceiverPath = SERVER_RECEIVER_PATH, amountValue = "0.000025", amountSats = 2_500uL, expiresAt = Instant.fromEpochSeconds(NOW_SECONDS + 60), @@ -1754,7 +1388,6 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { queued = queuedCounterparties.map { PrivatePaymentListSyncChange( counterparty = it, - counterpartyReceiverPath = WALLET_RECEIVER_PATH, outboundMessageId = null, error = null, ) @@ -1762,7 +1395,6 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { cleared = clearedCounterparties.map { PrivatePaymentListSyncChange( counterparty = it, - counterpartyReceiverPath = WALLET_RECEIVER_PATH, outboundMessageId = null, error = null, ) @@ -1771,20 +1403,8 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { failedToDeliver = emptyList(), ) - private fun cachedPublishedContact(receiverPath: String) = PrivatePaykitContactCacheData( - publishedPrivatePaymentReceiverPaths = setOf(receiverPath), - ) - - private fun privateReceiverPathSelection( - publishableReceiverPaths: List, - linkableReceiverPaths: List = publishableReceiverPaths, - cleanupProtectedReceiverPaths: List = emptyList(), - error: Throwable? = null, - ) = PaykitPrivateReceiverPathSelection( - linkableReceiverPaths = linkableReceiverPaths, - publishableReceiverPaths = publishableReceiverPaths, - cleanupProtectedReceiverPaths = cleanupProtectedReceiverPaths, - error = error, + private fun cachedPublishedContact() = PrivatePaykitContactCacheData( + hasPublishedPrivatePaymentList = true, ) private fun privateListDeliveryReportForUpdates( @@ -1792,34 +1412,30 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) = PrivatePaymentListDeliveryReport( queued = updates .filter { it.reservations.isNotEmpty() } - .map { privateListSyncChange(it.counterparty, it.counterpartyReceiverPath) }, + .map { privateListSyncChange(it.counterparty) }, cleared = updates .filter { it.reservations.isEmpty() } - .map { privateListSyncChange(it.counterparty, it.counterpartyReceiverPath) }, + .map { privateListSyncChange(it.counterparty) }, failedToQueue = emptyList(), failedToDeliver = emptyList(), ) private fun privateListSyncChange( counterparty: String, - receiverPath: String, ) = PrivatePaymentListSyncChange( counterparty = counterparty, - counterpartyReceiverPath = receiverPath, outboundMessageId = null, error = null, ) - private fun contactRecord(publicKey: String, receiverPaths: List) = ContactRecord( + private fun contactRecord(publicKey: String) = ContactRecord( publicKey = publicKey, - receiverPaths = receiverPaths, label = null, profile = null, profileFetchedAt = null, createdAt = "2026-01-01T00:00:00Z", updatedAt = "2026-01-01T00:00:00Z", publicContactMarkerStatus = PublicationStatus.NOT_PUBLISHED, - publicContactMarkerReceiverPath = null, publicContactPublishedAt = null, publicContactRemovedAt = null, publicContactLastError = null, @@ -1828,10 +1444,8 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { private fun linkedPeer( publicKey: String, state: LinkedPeerState, - receiverPath: String = WALLET_RECEIVER_PATH, ) = LinkedPeerRecord( counterparty = publicKey, - counterpartyReceiverPath = receiverPath, state = state, lastSyncAt = null, lastPrivateReceiveAt = null, diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 667720bf73..830c6c7089 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -4,11 +4,11 @@ import app.cash.turbine.test import coil3.ImageLoader import coil3.disk.DiskCache import coil3.memory.MemoryCache -import com.synonym.paykit.ContactProfileResolution -import com.synonym.paykit.ContactProfileSource import com.synonym.paykit.ContactRecord import com.synonym.paykit.PaykitException import com.synonym.paykit.PaykitProfile +import com.synonym.paykit.ProfileResolution +import com.synonym.paykit.ProfileSource import com.synonym.paykit.PubkyAuthCompanionClaim import com.synonym.paykit.PubkySessionBootstrapResult import com.synonym.paykit.PublicationStatus @@ -54,6 +54,7 @@ import to.bitkit.data.sharedpubky.SharedPubkyClient import to.bitkit.data.sharedpubky.SharedPubkyContract import to.bitkit.ext.runSuspendCatching import to.bitkit.models.PubkyAuthClaim +import to.bitkit.models.PubkyAuthClaim.Item import to.bitkit.models.PubkyAuthRequest import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkySessionBackupKind @@ -317,27 +318,35 @@ class PubkyRepoTest : BaseUnitTest() { @Test fun `approveAuthWithCompanionClaim forwards exact claim identifiers and capability`() = test { - val authUrl = "pubkyauth://signin?x-bitkit-claim=watch-only-account-v1" val clientId = "paykit.test" val secretKey = "local_secret" - val payload = ByteArray(84) { it.toByte() } whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(secretKey) - val result = sut.approveAuthWithCompanionClaim(authUrl, clientId, payload) - - assertTrue(result.isSuccess) - verifyBlocking(pubkyService) { - approveAuthWithCompanionClaim( - authUrl = authUrl, - expectedCapabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES, - approvedClientId = clientId, - secretKeyHex = secretKey, - claim = PubkyAuthCompanionClaim( - queryParameter = PubkyAuthClaim.QUERY_PARAMETER, - claimType = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue, - unsignedPayload = payload, - ), - ) + val selections = listOf( + PubkyAuthClaim(Item.PAYKIT_ACCESS_V1), + PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1), + PubkyAuthClaim(Item.PAYKIT_ACCESS_V1, Item.WATCH_ONLY_ACCOUNT_V1), + requireNotNull(PubkyAuthClaim.fromWireValue("watch-only-account-v1.paykit-access-v1")), + ) + for (claimType in selections) { + val payload = if (claimType.includesWatchOnlyAccount) ByteArray(84).apply { this[0] = 1 } else byteArrayOf() + val authUrl = "pubkyauth://signin?x-bitkit-claim=${claimType.wireValue}" + val result = sut.approveAuthWithCompanionClaim(authUrl, clientId, payload) + + assertTrue(result.isSuccess) + verifyBlocking(pubkyService) { + approveAuthWithCompanionClaim( + authUrl = authUrl, + expectedCapabilities = PubkyAuthClaim.REQUIRED_CAPABILITIES, + approvedClientId = clientId, + secretKeyHex = secretKey, + claim = PubkyAuthCompanionClaim( + queryParameter = PubkyAuthClaim.QUERY_PARAMETER, + claimType = claimType.wireValue, + unsignedPayload = payload, + ), + ) + } } } @@ -2110,71 +2119,13 @@ class PubkyRepoTest : BaseUnitTest() { fun `addContact should canonicalize key before persistence`() = test { authenticateForTesting() val profile = PubkyProfile.placeholder(NON_CANONICAL_CONTACT_KEY_A) - whenever { pubkyService.discoverRelevantReceiverPaths(VALID_CONTACT_KEY_A) }.thenReturn(emptyList()) val result = sut.addContact(NON_CANONICAL_CONTACT_KEY_A, existingProfile = profile) assertTrue(result.isSuccess) assertEquals(VALID_CONTACT_KEY_A, sut.contacts.value.single().publicKey) verifyBlocking(pubkyService) { - saveContact(VALID_CONTACT_KEY_A, profile.name, emptyList(), restorePrivateConnection = true) - } - } - - @Test - fun `refreshContactReceiverPaths should update saved contact receiver paths`() = test { - authenticateForTesting() - val contact = PubkyProfile( - publicKey = VALID_CONTACT_KEY_B, - name = "Alice", - bio = "", - imageUrl = null, - links = emptyList(), - tags = emptyList(), - status = null, - ) - sut.addContact(VALID_CONTACT_KEY_B, existingProfile = contact) - clearInvocations(pubkyService) - whenever(pubkyService.discoverRelevantReceiverPaths(VALID_CONTACT_KEY_B)) - .thenReturn(listOf("bitkit/wallet", "bitkit/server")) - - val result = sut.refreshContactReceiverPaths(VALID_CONTACT_KEY_B) - - assertTrue(result.isSuccess) - verifyBlocking(pubkyService) { - saveContact( - VALID_CONTACT_KEY_B, - "Alice", - listOf("bitkit/wallet", "bitkit/server"), - ) - } - } - - @Test - fun `refreshContactReceiverPaths should preserve a loaded noncanonical key`() = test { - authenticateForTesting() - whenever(pubkyService.contactRecords()).thenReturn( - listOf( - createContactRecord( - publicKey = NON_CANONICAL_CONTACT_KEY_A, - profile = createPaykitProfile("Alice"), - ), - ), - ) - sut.loadContacts() - clearInvocations(pubkyService) - whenever(pubkyService.discoverRelevantReceiverPaths(NON_CANONICAL_CONTACT_KEY_A)) - .thenReturn(listOf("bitkit/wallet", "bitkit/server")) - - val result = sut.refreshContactReceiverPaths(NON_CANONICAL_CONTACT_KEY_A) - - assertTrue(result.isSuccess) - verifyBlocking(pubkyService) { - saveContact( - NON_CANONICAL_CONTACT_KEY_A, - "Alice", - listOf("bitkit/wallet", "bitkit/server"), - ) + saveContact(VALID_CONTACT_KEY_A, profile.name, restorePrivateConnection = true) } } @@ -2547,14 +2498,12 @@ class PubkyRepoTest : BaseUnitTest() { profile: PaykitProfile? = null, ) = ContactRecord( publicKey = publicKey, - receiverPaths = listOf("bitkit/wallet"), label = label, profile = profile, profileFetchedAt = null, createdAt = "2026-01-01T00:00:00Z", updatedAt = "2026-01-01T00:00:00Z", publicContactMarkerStatus = PublicationStatus.NOT_PUBLISHED, - publicContactMarkerReceiverPath = null, publicContactPublishedAt = null, publicContactRemovedAt = null, publicContactLastError = null, @@ -2564,12 +2513,12 @@ class PubkyRepoTest : BaseUnitTest() { publicKey: String, paykitProfile: PaykitProfile? = null, pubkyProfile: SdkPubkyProfile? = null, - ) = ContactProfileResolution( + ) = ProfileResolution( publicKey = publicKey, source = if (paykitProfile != null) { - ContactProfileSource.PAYKIT_PROFILE + ProfileSource.PAYKIT_PROFILE } else { - ContactProfileSource.PUBKY_PROFILE + ProfileSource.PUBKY_PROFILE }, displayName = paykitProfile?.displayName ?: pubkyProfile?.name, imageUri = paykitProfile?.imageUri ?: pubkyProfile?.image, diff --git a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt index faed638630..16207eaafe 100644 --- a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt @@ -14,13 +14,15 @@ import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever +import to.bitkit.data.PrivatePaykitCacheData +import to.bitkit.data.PrivatePaykitCacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.services.CoreService import to.bitkit.services.PaykitPublicContactPaymentResolution -import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PaykitResolvedPaymentEndpoint import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest @@ -45,11 +47,13 @@ class PublicPaykitRepoTest : BaseUnitTest() { private val coreService = mock() private val paykitSdkService = mock() private val settingsStore = mock() + private val privatePaykitCacheStore = mock() private val clock = mock() private val publicKey = MutableStateFlow("pubkyself") private val walletState = MutableStateFlow(WalletState()) private val settingsFlow = MutableStateFlow(SettingsData()) + private val privateCacheFlow = MutableStateFlow(PrivatePaykitCacheData()) private lateinit var sut: PublicPaykitRepo @@ -63,6 +67,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { whenever(pubkyRepo.publicKey).thenReturn(publicKey) whenever(walletRepo.walletState).thenReturn(walletState) whenever(settingsStore.data).thenReturn(settingsFlow) + whenever(privatePaykitCacheStore.data).thenReturn(privateCacheFlow) whenever(clock.now()).thenReturn(Instant.fromEpochMilliseconds(NOW_MILLIS)) whenever(paykitSdkService.syncPublicEndpoints(any())).thenReturn(syncReport()) whenever { walletRepo.refreshReusableReceiveAddress() }.thenReturn(Result.success(Unit)) @@ -78,6 +83,21 @@ class PublicPaykitRepoTest : BaseUnitTest() { PublicPaykitRepo.lightningRouteHintsValidator = null } + @Test + fun `private capability remains enabled until all pending cleanup is complete`() = test { + for (pending in listOf( + PrivatePaykitCacheData(cleanupPending = true), + PrivatePaykitCacheData(deletedContactCleanupPendingPublicKeys = setOf("pubkycontact")), + PrivatePaykitCacheData(), + )) { + privateCacheFlow.value = pending + sut.syncPaykitApp(privateSharingEnabled = false).getOrThrow() + } + val capabilities = argumentCaptor() + verifyBlocking(paykitSdkService, times(3)) { syncPaykitApp(capabilities.capture()) } + assertEquals(listOf(true, true, false), capabilities.allValues) + } + @Test fun `syncCurrentPublishedEndpoints configures SDK public endpoints`() = test { walletState.value = WalletState(onchainAddress = "bc1ptest") @@ -120,19 +140,19 @@ class PublicPaykitRepoTest : BaseUnitTest() { } @Test - fun `syncPublishedEndpoints does not publish endpoints when receiver marker publish fails`() = test { + fun `syncPublishedEndpoints does not publish endpoints when app registration fails`() = test { settingsFlow.value = SettingsData( publicPaykitLightningEnabled = false, publicPaykitOnchainEnabled = true, ) walletState.value = WalletState(onchainAddress = "bc1ptest") - val markerError = RuntimeException("marker failed") - whenever { paykitSdkService.syncLocalReceiverMarker(isDiscoverable = true) } - .thenThrow(markerError) + val appError = RuntimeException("app registration failed") + whenever { paykitSdkService.syncPaykitApp(privatePaymentsEnabled = false) } + .thenThrow(appError) val error = sut.syncPublishedEndpoints(publish = true).exceptionOrNull() - assertEquals(markerError, error) + assertEquals(appError, error) verifyBlocking(paykitSdkService, never()) { syncPublicEndpoints(any()) } } @@ -154,15 +174,15 @@ class PublicPaykitRepoTest : BaseUnitTest() { } @Test - fun `syncPublishedEndpoints remove keeps cleanup pending when receiver marker removal fails`() = test { + fun `syncPublishedEndpoints remove keeps cleanup pending when app capability update fails`() = test { settingsFlow.value = SettingsData(publicPaykitCleanupPending = true) - val markerError = RuntimeException("marker failed") - whenever { paykitSdkService.syncLocalReceiverMarker(isDiscoverable = false) } - .thenThrow(markerError) + val appError = RuntimeException("app registration failed") + whenever { paykitSdkService.syncPaykitApp(privatePaymentsEnabled = false) } + .thenThrow(appError) val error = sut.syncPublishedEndpoints(publish = false).exceptionOrNull() - assertEquals(markerError, error) + assertEquals(appError, error) assertTrue(settingsFlow.value.publicPaykitCleanupPending) verifyBlocking(paykitSdkService) { syncPublicEndpoints(emptyList()) } } @@ -170,14 +190,14 @@ class PublicPaykitRepoTest : BaseUnitTest() { @Test fun `syncPublishedEndpoints remove preserves both cleanup failures`() = test { val endpointError = RuntimeException("endpoint failed") - val markerError = RuntimeException("marker failed") + val appError = RuntimeException("app registration failed") whenever { paykitSdkService.syncPublicEndpoints(emptyList()) }.thenThrow(endpointError) - whenever { paykitSdkService.syncLocalReceiverMarker(isDiscoverable = false) }.thenThrow(markerError) + whenever { paykitSdkService.syncPaykitApp(privatePaymentsEnabled = false) }.thenThrow(appError) val error = sut.syncPublishedEndpoints(publish = false).exceptionOrNull() assertEquals(endpointError, error) - assertEquals(listOf(markerError), error?.suppressedExceptions) + assertEquals(listOf(appError), error?.suppressedExceptions) } @Test @@ -214,7 +234,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { @Test fun `beginPayment opens SDK resolved public endpoint`() = test { whenever { - paykitSdkService.resolvePublicContactPayment("pubkycontact", PaykitReceiverPaths.WALLET) + paykitSdkService.resolvePublicContactPayment("pubkycontact") }.thenReturn( resolution( resolvedEndpoint( @@ -231,6 +251,27 @@ class PublicPaykitRepoTest : BaseUnitTest() { assertEquals(PublicPaykitPaymentResult.Opened(PUBLIC_BOLT11), result) } + @Test + fun `beginPayment retains payable alternatives regardless of app order`() = test { + val unavailableInvoice = "lnbcrt1unavailable" + val endpoints = listOf( + resolvedEndpoint(MethodId.Bolt11, unavailableInvoice), + resolvedEndpoint(MethodId.Bolt11, PUBLIC_BOLT11).copy(appId = "another-wallet"), + ) + whenever(coreService.decode(unavailableInvoice)).thenThrow(IllegalArgumentException("Invalid invoice")) + whenever(coreService.decode(PUBLIC_BOLT11)) + .thenReturn(Scanner.Lightning(lightningInvoice(PUBLIC_BOLT11, byteArrayOf(4, 5, 6)))) + + for (ordered in listOf(endpoints, endpoints.reversed())) { + whenever { paykitSdkService.resolvePublicContactPayment("pubkycontact") } + .thenReturn(resolution(*ordered.toTypedArray())) + + val result = sut.beginPayment("pubkycontact").getOrThrow() + + assertEquals(PublicPaykitPaymentResult.Opened(PUBLIC_BOLT11), result) + } + } + @Test fun `payment launch results have reason specific incoming request failures`() { assertEquals( @@ -270,6 +311,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { coreService = coreService, paykitSdkService = paykitSdkService, settingsStore = settingsStore, + privatePaykitCacheStore = privatePaykitCacheStore, clock = clock, ) @@ -282,6 +324,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { value: String, ): PaykitResolvedPaymentEndpoint { return PaykitResolvedPaymentEndpoint( + appId = "bitkit", identifier = methodId.rawValue, payload = PublicPaykitRepo.serializePayload(value), ) diff --git a/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountClaimCodecTest.kt b/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountClaimCodecTest.kt index dfcee0ac5e..c4bb58bf0d 100644 --- a/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountClaimCodecTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountClaimCodecTest.kt @@ -1,6 +1,14 @@ package to.bitkit.repositories +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import org.junit.Test +import to.bitkit.ext.fromHex +import to.bitkit.models.PubkyAuthClaim +import to.bitkit.models.PubkyAuthClaim.Item +import to.bitkit.models.PubkyAuthClaimCodec import to.bitkit.models.WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE import to.bitkit.models.WatchOnlyAccountRecord import to.bitkit.models.WatchOnlyAccountSetupState @@ -10,9 +18,92 @@ import kotlin.test.assertEquals import kotlin.test.assertFailsWith class WatchOnlyAccountClaimCodecTest { + @Test + fun `combined companion claim matches the canonical server fixture`() { + val fixture = requireNotNull(javaClass.getResourceAsStream("/bitkit-combined-claim-v1.json")) + .bufferedReader().use { Json.parseToJsonElement(it.readText()).jsonObject } + val claimType = PubkyAuthClaim(Item.PAYKIT_ACCESS_V1, Item.WATCH_ONLY_ACCOUNT_V1) + val accountPayload = WatchOnlyAccountClaimCodec.encode( + account(fixture.getValue("account_index").jsonPrimitive.int, TESTNET_TPUB), + ) { fixture.getValue("serialized_xpub_hex").jsonPrimitive.content.fromHex() } + val payload = PubkyAuthClaimCodec.encode( + claim = claimType, + accountPayload = accountPayload, + generation = fixture.getValue("key_generation").jsonPrimitive.content.toULong(), + secret = fixture.getValue("paykit_secret_hex").jsonPrimitive.content.fromHex(), + ) + + assertEquals(fixture.getValue("query_parameter").jsonPrimitive.content, PubkyAuthClaim.QUERY_PARAMETER) + assertEquals(fixture.getValue("claim_type").jsonPrimitive.content, claimType.wireValue) + assertEquals( + fixture.getValue("capabilities").jsonPrimitive.content, + PubkyAuthClaim.REQUIRED_CAPABILITIES, + ) + assertEquals(124, payload.size) + assertContentEquals(fixture.getValue("unsigned_payload_hex").jsonPrimitive.content.fromHex(), payload) + assertContentEquals( + payload, + PubkyAuthClaimCodec.encode( + claim = requireNotNull(PubkyAuthClaim.fromWireValue("watch-only-account-v1.paykit-access-v1")), + accountPayload = accountPayload, + generation = fixture.getValue("key_generation").jsonPrimitive.content.toULong(), + secret = fixture.getValue("paykit_secret_hex").jsonPrimitive.content.fromHex(), + ), + ) + } + + @Test + fun `companion claims match shared fixed bytes`() { + val accountPayload = WatchOnlyAccountClaimCodec.encode(account(42, TESTNET_TPUB)) { + TESTNET_SERIALIZED_HEX.fromHex() + } + val fixtures = listOf( + PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1) to WATCH_ONLY_HEX, + PubkyAuthClaim(Item.PAYKIT_ACCESS_V1) to PAYKIT_HEX, + PubkyAuthClaim(Item.PAYKIT_ACCESS_V1, Item.WATCH_ONLY_ACCOUNT_V1) to COMBINED_HEX, + requireNotNull(PubkyAuthClaim.fromWireValue("watch-only-account-v1.paykit-access-v1")) to COMBINED_HEX, + ) + for ((claim, expected) in fixtures) { + val payload = PubkyAuthClaimCodec.encode( + claim = claim, + accountPayload = if (claim.includesWatchOnlyAccount) accountPayload else byteArrayOf(), + generation = if (claim.includesPaykitAccess) 3uL else null, + secret = if (claim.includesPaykitAccess) ByteArray(32) { 7 } else null, + ) + assertContentEquals(expected.fromHex(), payload, claim.wireValue) + } + } + + @Test + fun `companion claims reject mismatched account or key payloads`() { + val accountPayload = WATCH_ONLY_HEX.fromHex() + val key = ByteArray(32) { 7 } + assertFailsWith { + PubkyAuthClaimCodec.encode(PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1), accountPayload, 3uL, key) + } + for (payload in listOf(byteArrayOf(), COMBINED_HEX.fromHex(), ByteArray(84))) { + assertFailsWith { + PubkyAuthClaimCodec.encode(PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1), payload) + } + } + assertFailsWith { + PubkyAuthClaimCodec.encode(PubkyAuthClaim(Item.PAYKIT_ACCESS_V1), accountPayload, 3uL, key) + } + for (generation in listOf(null, 0uL)) { + assertFailsWith { + PubkyAuthClaimCodec.encode(PubkyAuthClaim(Item.PAYKIT_ACCESS_V1), byteArrayOf(), generation, key) + } + } + for (secret in listOf(null, ByteArray(31), ByteArray(33))) { + assertFailsWith { + PubkyAuthClaimCodec.encode(PubkyAuthClaim(Item.PAYKIT_ACCESS_V1), byteArrayOf(), 3uL, secret) + } + } + } + @Test fun `unsigned claim contains exact account metadata`() { - val rawXpub = TESTNET_SERIALIZED_HEX.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + val rawXpub = TESTNET_SERIALIZED_HEX.fromHex() val account = account(accountIndex = 42, xpub = TESTNET_TPUB) val payload = WatchOnlyAccountClaimCodec.encode(account) { xpub -> @@ -53,6 +144,15 @@ class WatchOnlyAccountClaimCodecTest { ) private companion object { + const val WATCH_ONLY_HEX = + "010000002a00043587cf03caafd489800000004b5fcc4a5fe210d9fba6616b4db1d025237dd7f035101f11f562401bc7104699" + + "02e0bf22b51a6a49e0b149b995670d0ed9bb1fd99417748bacefba88fae655572d" + const val PAYKIT_HEX = + "0100000000000000030707070707070707070707070707070707070707070707070707070707070707" + const val COMBINED_HEX = + "010000002a00043587cf03caafd489800000004b5fcc4a5fe210d9fba6616b4db1d025237dd7f035101f11f562401bc7104699" + + "02e0bf22b51a6a49e0b149b995670d0ed9bb1fd99417748bacefba88fae655572d" + + "00000000000000030707070707070707070707070707070707070707070707070707070707070707" const val TESTNET_TPUB = "tpubDDWohsp5dx2iMJ9N7iHbgAEDhH4BJB9NWW1fEW3yA3AFNDREmpzteCXNqppMLUmKFY5q5e3" + "PXtS5CuqWCQbYcGhpPqYAgQSYdwknW9J6sQv" diff --git a/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRepoTest.kt index e30318410f..697b64fbc6 100644 --- a/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRepoTest.kt @@ -41,6 +41,47 @@ import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class WatchOnlyAccountRepoTest : BaseUnitTest() { + @Test + fun `explicit watch-only request allocates a new account instead of reusing an active account`() = test { + val active = account() + val store = mock() + val lightningService = mock() + val node = mock() + whenever(store.data).thenReturn(flowOf(WatchOnlyAccountData(accounts = listOf(active)))) + whenever(store.load()).thenReturn(listOf(active)) + whenever(store.reserveAccountIndex(any(), any())).thenReturn(2) + whenever(lightningService.node).thenReturn(node) + whenever(node.exportOnchainWalletAccountXpub(AddressType.NATIVE_SEGWIT, 2u)).thenReturn(TEST_XPUB_ALTERNATE) + + val prepared = repository(store, lightningService).prepareUnsignedClaim( + "pubkyauth://signin?secret=new&x-bitkit-claim=watch-only-account-v1", + "New service account", + ) + + assertNotEquals(active.id, prepared.account.id) + assertEquals(2, prepared.account.accountIndex) + assertEquals(TEST_XPUB_ALTERNATE, prepared.account.xpub) + assertEquals(WatchOnlyAccountSetupState.PendingDelivery, prepared.account.setupState) + assertFalse(prepared.account.isTrackingEnabled) + verify(store).save(listOf(active, prepared.account)) + verify(node, never()).addOnchainWalletAccount(any(), any(), any()) + } + + @Test + fun `authorization rejects an active account before tracking`() = test { + val active = account() + val store = mock() + val lightningService = mock() + whenever(store.data).thenReturn(flowOf(WatchOnlyAccountData(accounts = listOf(active)))) + whenever(store.load()).thenReturn(listOf(active)) + + assertFailsWith { + repository(store, lightningService).beginAuthorization(active.id) + } + verify(lightningService, never()).node + verify(store, never()).update(any()) + } + @Test fun `current wallet accounts exclude records from other wallets`() = test { val currentWalletAccount = account().copy(walletIndex = 1) diff --git a/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt b/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt new file mode 100644 index 0000000000..3a3076c3be --- /dev/null +++ b/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt @@ -0,0 +1,165 @@ +package to.bitkit.services + +import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.LightningActivity +import com.synonym.bitkitcore.OnchainActivity +import com.synonym.bitkitcore.PaymentState +import com.synonym.bitkitcore.PaymentType +import com.synonym.bitkitcore.TransactionDetails +import com.synonym.bitkitcore.TxOutput +import com.synonym.bitkitcore.getActivities +import com.synonym.bitkitcore.getTransactionDetails +import com.synonym.bitkitcore.updateActivity +import org.junit.Test +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.async.ServiceQueue +import to.bitkit.ext.create +import to.bitkit.repositories.PaykitReceivedPaymentContacts +import to.bitkit.repositories.PrivatePaykitContactResolver +import to.bitkit.test.BaseUnitTest +import javax.inject.Provider +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ActivityServicePaykitContactsTest : BaseUnitTest() { + private val contacts = mock() + private val resolver = mock() + private val sut by lazy { ActivityService(mock(), mock(), mock(), mock(), Provider { resolver }) } + private var rows = listOf() + private var details: TransactionDetails? = null + private val updates = mutableListOf() + + @Test + fun `backfill matches cached outputs and preserves all other onchain metadata`() = coreTest { + val original = onchain(address = "unknown") + rows = listOf(Activity.Onchain(original)) + val outputs = listOf(output("request-address"), output("change-address")) + details = mock { on { this.outputs }.thenReturn(outputs) } + whenever(contacts.contactsForAddresses(listOf("unknown", "request-address", "change-address"))) + .thenReturn(setOf("buyer")) + + assertTrue(sut.backfillPaykitContacts()) + + assertEquals(listOf(Activity.Onchain(original.copy(contact = "buyer"))), updates) + verify(contacts).contactsForAddresses(listOf("unknown", "request-address", "change-address")) + } + + @Test + fun `backfill matches lightning hash when invoice text is missing and preserves metadata`() = coreTest { + val original = lightning() + rows = listOf(Activity.Lightning(original)) + whenever(contacts.contactsForPaymentHash(original.id)).thenReturn(setOf("buyer")) + + assertTrue(sut.backfillPaykitContacts()) + + assertEquals(listOf(Activity.Lightning(original.copy(contact = "buyer"))), updates) + } + + @Test + fun `backfill skips explicit contacts outgoing and failed received activities`() = coreTest { + rows = listOf( + Activity.Onchain(onchain().copy(contact = "explicit")), + Activity.Onchain(onchain().copy(txType = PaymentType.SENT)), + Activity.Lightning(lightning().copy(contact = "explicit")), + Activity.Lightning(lightning().copy(txType = PaymentType.SENT)), + Activity.Lightning(lightning().copy(status = PaymentState.FAILED)), + ) + whenever(contacts.contactsForPaymentHash(any())).thenReturn(setOf("buyer")) + whenever(contacts.contactsForAddresses(any())).thenReturn(setOf("buyer")) + + assertFalse(sut.backfillPaykitContacts()) + assertTrue(updates.isEmpty()) + } + + @Test + fun `backfill refuses ambiguity across stored receiving address and other outputs`() = coreTest { + rows = listOf(Activity.Onchain(onchain(address = "request-address"))) + val outputs = listOf(output("other-request-address")) + details = mock { on { this.outputs }.thenReturn(outputs) } + whenever(contacts.contactsForAddresses(listOf("request-address", "other-request-address"))) + .thenReturn(setOf("buyer", "other-buyer")) + + assertFalse(sut.backfillPaykitContacts()) + assertTrue(updates.isEmpty()) + } + + @Test + fun `backfill waits for complete cached outputs even when stored address matches`() = coreTest { + rows = listOf(Activity.Onchain(onchain(address = "request-address"))) + whenever(contacts.contactsForAddresses(listOf("request-address"))).thenReturn(setOf("buyer")) + + assertFalse(sut.backfillPaykitContacts()) + details = mock { on { outputs }.thenReturn(emptyList()) } + assertFalse(sut.backfillPaykitContacts()) + + val outputs = listOf(output("other-request-address")) + details = mock { on { this.outputs }.thenReturn(outputs) } + whenever(contacts.contactsForAddresses(listOf("request-address", "other-request-address"))) + .thenReturn(setOf("buyer", "other-buyer")) + assertFalse(sut.backfillPaykitContacts()) + assertTrue(updates.isEmpty()) + } + + @Test + fun `backfill refuses stale identity snapshot before write`() = coreTest { + rows = listOf(Activity.Lightning(lightning())) + whenever(contacts.contactsForPaymentHash(any())).thenAnswer { + whenever(resolver.receivedPaymentContacts).thenReturn(PaykitReceivedPaymentContacts.Empty) + setOf("buyer") + } + + assertFalse(sut.backfillPaykitContacts()) + assertTrue(updates.isEmpty()) + } + + @Test + fun `backfill remains idempotent after persisted activity is reopened`() = coreTest { + rows = listOf(Activity.Lightning(lightning())) + whenever(contacts.contactsForPaymentHash(any())).thenReturn(setOf("buyer")) + assertTrue(sut.backfillPaykitContacts()) + rows = updates.toList() + updates.clear() + + assertFalse(sut.backfillPaykitContacts()) + assertTrue(updates.isEmpty()) + } + + private fun coreTest(block: suspend () -> Unit) = test { + whenever(resolver.receivedPaymentContacts).thenReturn(contacts) + ServiceQueue.CORE.background { + mockStatic(Class.forName("com.synonym.bitkitcore.Bitkitcore_androidKt")).use { native -> + native.`when`> { + getActivities( + anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), + anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull() + ) + }.thenAnswer { rows } + native.`when` { getTransactionDetails(any(), any()) }.thenAnswer { details } + native.`when` { updateActivity(any(), any()) }.thenAnswer { + updates.add(it.arguments[1] as Activity) + null + } + block() + } + } + } + + private fun output(address: String): TxOutput = mock { on { scriptpubkeyAddress }.thenReturn(address) } + + private fun onchain(address: String = "address") = OnchainActivity.create( + id = "received", txId = "transaction", txType = PaymentType.RECEIVED, address = address, + value = 15_000uL, fee = 1uL, timestamp = 100uL, confirmed = true, seenAt = 101uL, + ) + + private fun lightning() = LightningActivity.create( + id = "payment-hash", txType = PaymentType.RECEIVED, status = PaymentState.SUCCEEDED, + value = 15_000uL, invoice = "No invoice", timestamp = 100uL, fee = 1uL, message = "existing note", + preimage = "preimage", seenAt = 101uL, + ) +} diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index d51feebf3c..e534c4a9ed 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -1,12 +1,11 @@ package to.bitkit.services import com.synonym.paykit.ContactRecord -import com.synonym.paykit.EncryptedLinkRecoveryMarkerPolicy -import com.synonym.paykit.EndpointManagementScope import com.synonym.paykit.IdentityStatus import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState import com.synonym.paykit.PaykitException +import com.synonym.paykit.PaykitIdentitySecretKey import com.synonym.paykit.PaykitSdk import com.synonym.paykit.PaymentRequestLifecycleState import com.synonym.paykit.PaymentRequestLocalRole @@ -14,17 +13,19 @@ import com.synonym.paykit.PaymentRequestRecord import com.synonym.paykit.PaymentRequestRecurrence import com.synonym.paykit.PaymentRequestTerms import com.synonym.paykit.PrivatePaymentListDeliveryReport +import com.synonym.paykit.PubkyAuthCompanionClaim import com.synonym.paykit.PubkyClientConfig +import com.synonym.paykit.PubkyIdentityCapability import com.synonym.paykit.PubkyLocalSecretKey import com.synonym.paykit.PubkySessionAccess import com.synonym.paykit.PubkySessionBootstrap import com.synonym.paykit.PubkySessionBootstrapResult import com.synonym.paykit.PublicContactSharingPolicy -import com.synonym.paykit.ReceiverNoiseSecretKey import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Deferred import kotlinx.coroutines.async +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.runTest @@ -37,20 +38,22 @@ import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import to.bitkit.data.PubkyStore import to.bitkit.data.PubkyStoreData import to.bitkit.data.keychain.Keychain import to.bitkit.data.keychain.KeychainError import to.bitkit.data.sharedpubky.SharedPubkyClient -import to.bitkit.ext.fromHex +import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.toHex +import to.bitkit.models.PubkyAuthClaim +import to.bitkit.models.PubkyAuthClaim.Item import to.bitkit.models.PubkyAuthRequestError import to.bitkit.models.PubkyProfileData import to.bitkit.repositories.PubkyContactError import to.bitkit.utils.AppError import kotlin.coroutines.cancellation.CancellationException -import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertNull @@ -129,6 +132,121 @@ class PaykitSdkServiceTest { assertEquals("healthy", paykitStorageCallback("state_save_failed") { "healthy" }) } + @Test + fun `payer ownership follows execution claims and released actionable work`() { + data class Case( + val state: PaymentRequestLifecycleState, + val payer: String?, + val claim: String?, + val hasProof: Boolean = false, + val visible: Boolean, + ) + val cases = listOf( + Case(PaymentRequestLifecycleState.PROPOSED, null, null, visible = true), + Case(PaymentRequestLifecycleState.ACCEPTED, "other", "bitkit", visible = true), + Case(PaymentRequestLifecycleState.ACCEPTED, "bitkit", "other", visible = false), + Case(PaymentRequestLifecycleState.ACCEPTED, "other", null, visible = true), + Case(PaymentRequestLifecycleState.ACCEPTED, "other", null, hasProof = true, visible = false), + Case(PaymentRequestLifecycleState.ACTIVE_RECURRING, "other", null, hasProof = true, visible = true), + Case(PaymentRequestLifecycleState.ACTIVE_RECURRING, "bitkit", "other", visible = false), + Case(PaymentRequestLifecycleState.PROOF_SUBMITTED, "other", "bitkit", hasProof = true, visible = true), + Case(PaymentRequestLifecycleState.PROOF_SUBMITTED, "other", null, hasProof = true, visible = false), + Case(PaymentRequestLifecycleState.CANCELED, "other", null, visible = false), + Case(PaymentRequestLifecycleState.CANCELED, "bitkit", null, visible = true), + Case(PaymentRequestLifecycleState.PROPOSAL_EXPIRED, null, null, visible = true), + ) + cases.forEach { case -> + val record = mock() + whenever(record.localRole).thenReturn(PaymentRequestLocalRole.PAYER) + whenever(record.state).thenReturn(case.state) + whenever(record.payerAppId).thenReturn(case.payer) + whenever(record.executionClaimAppId).thenReturn(case.claim) + whenever(record.paymentProofs).thenReturn(if (case.hasProof) listOf(mock()) else emptyList()) + + assertEquals(case.visible, isBitkitPaymentRequest(record), case.toString()) + } + } + + @Test + fun `all payment requests retain server payee records while payment API remains app scoped`() = runTest { + val server = mock { + on { localRole }.thenReturn(PaymentRequestLocalRole.PAYEE) + on { proposalAppId }.thenReturn("marketplace") + } + val bitkit = mock { + on { localRole }.thenReturn(PaymentRequestLocalRole.PAYEE) + on { proposalAppId }.thenReturn("bitkit") + } + val sdk = mock() + whenever( + sdk.identityStatus() + ).thenReturn(IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + whenever(sdk.listPaymentRequests(any())).thenReturn(listOf(server, bitkit)) + val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + + assertEquals(listOf(server, bitkit), service.allPaymentRequests(RING_PUBKY)) + assertEquals(listOf(bitkit), service.paymentRequests()) + } + + @Test + fun `all payment requests reject a different active identity before listing`() = runTest { + val sdk = mock() + whenever( + sdk.identityStatus() + ).thenReturn(IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + + assertFailsWith { + service.allPaymentRequests("a3mduedw686dysw8ndr5c1dyry5h8k6i8hzbbmx9gf9k43zq4s9o") + } + verify(sdk, never()).listPaymentRequests(any()) + } + + @Test + fun `companion approval rejects mismatched requests before accessing keys or transport`() = runTest { + val watchOnly = PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1) + val claim = PubkyAuthCompanionClaim( + queryParameter = PubkyAuthClaim.QUERY_PARAMETER, + claimType = watchOnly.wireValue, + unsignedPayload = ByteArray(84).apply { this[0] = 1 }, + ) + val url = "pubkyauth://signin?x-bitkit-claim=${watchOnly.wireValue}" + val invalidRequests = listOf( + "pubkyauth://signin" to claim, + "$url&x-bitkit-claim=${watchOnly.wireValue}" to claim, + "pubkyauth://signin?x-bitkit-claim=unknown" to claim, + "$url." to claim, + "$url.${watchOnly.wireValue}" to claim, + "pubkyauth://signin?x-bitkit-claim=paykit-access-and-watch-only-account-v1" to claim, + "pubkyauth://signin?x-bitkit-claim=paykit-access-v1.watch-only-account-v1" to + claim.copy(claimType = "watch-only-account-v1.paykit-access-v1"), + "pubkyauth://signin?x-bitkit-claim=watch-only-account-v1.paykit-access-v1" to + claim.copy(claimType = "paykit-access-v1.watch-only-account-v1"), + url to claim.copy(queryParameter = "other-claim"), + url to claim.copy(claimType = PubkyAuthClaim(Item.PAYKIT_ACCESS_V1).wireValue), + url to claim.copy(unsignedPayload = ByteArray(124)), + "pubkyauth://signin?x-bitkit-claim=paykit-access-v1" to + claim.copy(claimType = PubkyAuthClaim(Item.PAYKIT_ACCESS_V1).wireValue), + ) + for ((authUrl, companion) in invalidRequests) { + val keychain = mock() + val sdk = mock() + val service = PaykitSdkService(mock(), keychain, mock()) { sdk } + assertTrue( + runSuspendCatching { + service.approveAuthWithCompanionClaim( + authUrl, + PubkyAuthClaim.REQUIRED_CAPABILITIES, + "test", + "secret", + companion, + ) + }.isFailure, + ) + verifyNoInteractions(keychain, sdk) + } + } + @Test fun `registered identity activation persists credentials or clears partial activation`() = runTest { for (failure in listOf(null, "session", "secret", "initialize", "cancel")) { @@ -138,17 +256,16 @@ class PaykitSdkServiceTest { it.getArgument Any?>(0).invoke(blocking) } val bytes = ByteArray(32) { 1 } - whenever(blocking.load(Keychain.Key.PAYKIT_RECEIVER_NOISE_SECRET_KEY.name)).thenReturn(bytes) val sdk = mock() whenever(sdk.contactRecords()).thenReturn(emptyList()) val access = mock() val secret = mock() - val noise = mock() + val noise = mock() whenever(secret.exportBytes()).thenReturn(bytes) whenever(noise.exportBytes()).thenReturn(bytes) whenever(access.exportSessionSecret()).thenReturn("new-session") whenever(access.exportLocalSecretKey()).thenReturn(secret) - whenever(access.exportReceiverNoiseSecretKey()).thenReturn(noise) + whenever(access.exportPaykitIdentitySecretKey()).thenReturn(noise) val error = if (failure == "cancel") { CancellationException("cancelled") } else { @@ -168,7 +285,7 @@ class PaykitSdkServiceTest { handlesCreated++ sdk } - val result = PubkySessionBootstrapResult(access, "pubky_test") + val result = PubkySessionBootstrapResult(access, "pubky_test", PubkyIdentityCapability.PRIVATE_LINK_CAPABLE) if (failure == null) { service.activateRegisteredIdentity(result) @@ -183,7 +300,6 @@ class PaykitSdkServiceTest { assertEquals(error, thrown) verify(blocking).delete(Keychain.Key.PAYKIT_SESSION.name) verify(blocking).delete(Keychain.Key.PUBKY_SECRET_KEY.name) - verify(keychain, atLeastOnce()).delete(Keychain.Key.PAYKIT_SDK_STATE.name) val handlesBeforeReload = handlesCreated service.contactRecords() assertEquals(handlesBeforeReload + 1, handlesCreated) @@ -192,16 +308,16 @@ class PaykitSdkServiceTest { } @Test - fun `deletion blocks all known receivers before removing the contact`() = runTest { + fun `deletion blocks the identity before removing the contact`() = runTest { for (failBlock in listOf(false, true)) { val sdk = mock() whenever(sdk.paymentRequests()).thenReturn(emptyList()) - val contact = mock { on { receiverPaths } doReturn listOf(PaykitReceiverPaths.WALLET) } + val contact = mock() whenever(sdk.contactRecord(RING_PUBKY)).thenReturn(contact) - val peer = contactPeer(PaykitReceiverPaths.SERVER, LinkedPeerState.LINKED) + val peer = contactPeer(LinkedPeerState.LINKED) whenever(sdk.linkedPeers()).thenReturn(listOf(peer)) if (failBlock) { - whenever(sdk.blockPeer(RING_PUBKY, PaykitReceiverPaths.SERVER)) + whenever(sdk.blockPeer(RING_PUBKY)) .thenThrow(IllegalStateException("storage failure")) } val service = PaykitSdkService(mock(), mock(), mock()) { sdk } @@ -211,8 +327,7 @@ class PaykitSdkServiceTest { } else { service.removeContact(RING_PUBKY) inOrder(sdk) { - verify(sdk).blockPeer(RING_PUBKY, PaykitReceiverPaths.WALLET) - verify(sdk).blockPeer(RING_PUBKY, PaykitReceiverPaths.SERVER) + verify(sdk).blockPeer(RING_PUBKY) verify(sdk).removeContact(RING_PUBKY) } } @@ -239,7 +354,7 @@ class PaykitSdkServiceTest { whenever(sdk.paymentRequests()).thenReturn(listOf(request)) val service = PaykitSdkService(mock(), mock(), mock()) { sdk } assertFailsWith { service.removeContact(RING_PUBKY) } - verify(sdk, never()).blockPeer(any(), any()) + verify(sdk, never()).blockPeer(any()) verify(sdk, never()).removeContact(any()) if (endsNaturally) { whenever(recurrence.endsAt).thenReturn("2026-02-01T00:00:00Z") @@ -252,23 +367,31 @@ class PaykitSdkServiceTest { } @Test - fun `identity lookup failure preserves stored state and stops activation`() = runTest { + fun `unreadable profile cache stops activation`() = runTest { for (error in listOf( PaykitException.Identity("identity_error", "restore Pubky grant session from platform provider"), PaykitException.Storage("storage_error", "unavailable"), )) { val keychain = mock() val sdk = mock() - whenever(sdk.identityStatus()).thenThrow(error) - val service = PaykitSdkService(mock(), keychain, mock()) { sdk } + val store = mock() + whenever(store.data).thenReturn(flow { throw error }) + val access = mock() + whenever(access.exportSessionSecret()).thenReturn("new-session") + val service = PaykitSdkService(mock(), keychain, store) { sdk } val thrown = assertFailsWith { - service.activateRegisteredIdentity(PubkySessionBootstrapResult(mock(), "pubky_test")) + service.activateRegisteredIdentity( + PubkySessionBootstrapResult( + access, + "pubky_test", + PubkyIdentityCapability.PRIVATE_LINK_CAPABLE + ) + ) } assertEquals(error, thrown) - verify(keychain, never()).delete(any()) - verify(keychain, never()).upsertString(any(), any()) + verify(sdk, never()).initialize() } } @@ -278,10 +401,10 @@ class PaykitSdkServiceTest { val sdk = mock() whenever(sdk.paymentRequests()).thenReturn(emptyList()) whenever(sdk.linkedPeers()).thenReturn( - listOf(contactPeer(PaykitReceiverPaths.SERVER, LinkedPeerState.LINKED)), + listOf(contactPeer(LinkedPeerState.LINKED)), ) val withdrawal = whenever( - sdk.clearPrivatePaymentListAndProcessOutbound(RING_PUBKY, PaykitReceiverPaths.SERVER), + sdk.clearPrivatePaymentListAndProcessOutbound(RING_PUBKY), ) if (failWithdrawal) { withdrawal.thenThrow(IllegalStateException("network unavailable")) @@ -293,8 +416,8 @@ class PaykitSdkServiceTest { val service = PaykitSdkService(mock(), mock(), mock()) { sdk } service.removeContact(RING_PUBKY) inOrder(sdk) { - verify(sdk).clearPrivatePaymentListAndProcessOutbound(RING_PUBKY, PaykitReceiverPaths.SERVER) - verify(sdk).blockPeer(RING_PUBKY, PaykitReceiverPaths.SERVER) + verify(sdk).clearPrivatePaymentListAndProcessOutbound(RING_PUBKY) + verify(sdk).blockPeer(RING_PUBKY) verify(sdk).removeContact(RING_PUBKY) } } @@ -306,9 +429,10 @@ class PaykitSdkServiceTest { val differentKey = "5" + originalKey.drop(1) for ((previousKey, cachedOwner, resetFails) in identityCacheCases(originalKey, differentKey)) { val keychain = mock() - stubReceiverNoiseSecret(keychain) val sdk = mock() - whenever(sdk.identityStatus()).thenReturn(IdentityStatus(previousKey, false)) + whenever( + sdk.identityStatus() + ).thenReturn(IdentityStatus(previousKey, PubkyIdentityCapability.PUBLIC_ONLY)) val originalCache = PubkyStoreData( ownerPublicKey = cachedOwner, cachedName = "Original profile", @@ -326,13 +450,18 @@ class PaykitSdkServiceTest { val bootstrap = mock() whenever(bootstrap.republishIdentity(any())).thenReturn(true) val access = mock() - val noise = mock() + val noise = mock() whenever(noise.exportBytes()).thenReturn(ByteArray(32) { 1 }) whenever(access.exportSessionSecret()).thenReturn("new-session") - whenever(access.exportReceiverNoiseSecretKey()).thenReturn(noise) + whenever(access.exportPaykitIdentitySecretKey()).thenReturn(noise) val service = PaykitSdkService(mock(), keychain, store, { bootstrap }) { sdk } - val result = PubkySessionBootstrapResult(access, "pubky$originalKey") + val result = + PubkySessionBootstrapResult( + access, + "pubky$originalKey", + PubkyIdentityCapability.PRIVATE_LINK_CAPABLE + ) if (resetFails) { assertEquals(resetError, assertFailsWith { service.activateRegisteredIdentity(result) }) verify(sdk, never()).initialize() @@ -341,7 +470,7 @@ class PaykitSdkServiceTest { } service.activateRegisteredIdentity(result) - if (previousKey == differentKey || cachedOwner == differentKey) { + if (cachedOwner == differentKey) { assertEquals(PubkyStoreData(), cache) inOrder(store, sdk) { verify(store).reset() @@ -355,25 +484,23 @@ class PaykitSdkServiceTest { } @Test - fun `only explicit readd unblocks every saved private receiver`() = runTest { + fun `only explicit readd unblocks the contact`() = runTest { for (restoreConnection in listOf(false, true)) { val sdk = mock() whenever(sdk.saveContact(any())).thenReturn(mock()) whenever(sdk.linkedPeers()).thenReturn( listOf( - contactPeer(PaykitReceiverPaths.WALLET, LinkedPeerState.BLOCKED), - contactPeer(PaykitReceiverPaths.SERVER, LinkedPeerState.BLOCKED), + contactPeer(LinkedPeerState.BLOCKED), ), ) val service = PaykitSdkService(mock(), mock(), mock()) { sdk } if (restoreConnection) { service.saveContact(RING_PUBKY, "Contact", restorePrivateConnection = true) - verify(sdk).unblockPeer(RING_PUBKY, PaykitReceiverPaths.WALLET) - verify(sdk).unblockPeer(RING_PUBKY, PaykitReceiverPaths.SERVER) + verify(sdk).unblockPeer(RING_PUBKY) } else { assertFailsWith { service.saveContact(RING_PUBKY, "Contact") } verify(sdk, never()).saveContact(any()) - verify(sdk, never()).unblockPeer(any(), any()) + verify(sdk, never()).unblockPeer(any()) } } } @@ -383,8 +510,7 @@ class PaykitSdkServiceTest { for (failurePoint in listOf("lookup", "unblock", "save", "cancel")) { val sdk = mock() val peers = listOf( - contactPeer(PaykitReceiverPaths.WALLET, LinkedPeerState.BLOCKED), - contactPeer(PaykitReceiverPaths.SERVER, LinkedPeerState.BLOCKED), + contactPeer(LinkedPeerState.BLOCKED), ) val failure = if (failurePoint == "cancel") { CancellationException("cancelled") @@ -396,7 +522,7 @@ class PaykitSdkServiceTest { when (failurePoint) { "lookup" -> whenever(sdk.linkedPeers()).thenThrow(failure).thenReturn(peers) "unblock", "cancel" -> { - whenever(sdk.unblockPeer(RING_PUBKY, PaykitReceiverPaths.SERVER)) + whenever(sdk.unblockPeer(RING_PUBKY)) .thenThrow(failure).thenReturn(peers.last().copy(state = LinkedPeerState.NOT_LINKED)) } "save" -> whenever(sdk.saveContact(any())).thenThrow(failure).thenReturn(mock()) @@ -407,10 +533,9 @@ class PaykitSdkServiceTest { } assertSame(failure, thrown) if (failurePoint == "lookup") { - verify(sdk, never()).blockPeer(any(), any()) + verify(sdk, never()).blockPeer(any()) } else { - verify(sdk).blockPeer(RING_PUBKY, PaykitReceiverPaths.WALLET) - verify(sdk).blockPeer(RING_PUBKY, PaykitReceiverPaths.SERVER) + verify(sdk).blockPeer(RING_PUBKY) } service.saveContact(RING_PUBKY, "Contact", restorePrivateConnection = true) verify(sdk, atLeastOnce()).saveContact(any()) @@ -421,14 +546,13 @@ class PaykitSdkServiceTest { fun `private connection restoration preserves failures from rollback`() = runTest { val sdk = mock() val peers = listOf( - contactPeer(PaykitReceiverPaths.WALLET, LinkedPeerState.BLOCKED), - contactPeer(PaykitReceiverPaths.SERVER, LinkedPeerState.BLOCKED), + contactPeer(LinkedPeerState.BLOCKED), ) val restorationFailure = IllegalStateException("save failed") val rollbackFailure = IllegalStateException("block failed") whenever(sdk.linkedPeers()).thenReturn(peers) whenever(sdk.saveContact(any())).thenThrow(restorationFailure) - whenever(sdk.blockPeer(RING_PUBKY, PaykitReceiverPaths.WALLET)).thenThrow(rollbackFailure) + whenever(sdk.blockPeer(RING_PUBKY)).thenThrow(rollbackFailure) val service = PaykitSdkService(mock(), mock(), mock()) { sdk } val thrown = assertFailsWith { @@ -437,22 +561,20 @@ class PaykitSdkServiceTest { assertSame(restorationFailure, thrown) assertEquals(listOf(rollbackFailure), thrown.suppressed.toList()) - verify(sdk).blockPeer(RING_PUBKY, PaykitReceiverPaths.WALLET) - verify(sdk).blockPeer(RING_PUBKY, PaykitReceiverPaths.SERVER) + verify(sdk).blockPeer(RING_PUBKY) } @Test fun `blocked peer cleanup does not attempt network delivery`() = runTest { val sdk = mock() - whenever(sdk.linkedPeers()).thenReturn(listOf(contactPeer(PaykitReceiverPaths.SERVER, LinkedPeerState.BLOCKED))) + whenever(sdk.linkedPeers()).thenReturn(listOf(contactPeer(LinkedPeerState.BLOCKED))) val service = PaykitSdkService(mock(), mock(), mock()) { sdk } - assertNull(service.clearPrivatePaymentList(RING_PUBKY, PaykitReceiverPaths.SERVER)) - verify(sdk, never()).clearPrivatePaymentListAndProcessOutbound(any(), any()) + assertNull(service.clearPrivatePaymentList(RING_PUBKY)) + verify(sdk, never()).clearPrivatePaymentListAndProcessOutbound(any()) } - private fun contactPeer(path: String, state: LinkedPeerState) = LinkedPeerRecord( + private fun contactPeer(state: LinkedPeerState) = LinkedPeerRecord( counterparty = RING_PUBKY, - counterpartyReceiverPath = path, state = state, lastSyncAt = null, lastPrivateReceiveAt = null, @@ -471,11 +593,9 @@ class PaykitSdkServiceTest { ) @Test - fun `config scopes public endpoint sync to Bitkit managed endpoints`() { - assertEquals(BitkitPaykitSdkConfig.profileNamespace, BitkitPaykitSdkConfig.clientId) - assertEquals(EndpointManagementScope.MANAGED_ONLY, BitkitPaykitSdkConfig.endpointManagementScope) - assertEquals(PublicContactSharingPolicy.LOCAL_ONLY, BitkitPaykitSdkConfig.publicContactSharing) - assertEquals(EncryptedLinkRecoveryMarkerPolicy.ENABLED, BitkitPaykitSdkConfig.encryptedLinkRecoveryMarkers) + fun `config keeps contacts private`() { + assertEquals("staging.bitkit.to", BitkitPaykitSdkConfig.clientId) + assertEquals(PublicContactSharingPolicy.PRIVATE_ONLY, BitkitPaykitSdkConfig.publicContactSharing) } @Test @@ -512,86 +632,6 @@ class PaykitSdkServiceTest { } } - @Test - fun `receiver noise derivation matches cross platform vector`() { - val seed = ( - "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e534955" + - "31f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04" - ).fromHex() - - val key = PaykitReceiverNoiseKeyDerivation.derive( - seed = seed, - network = "bitcoin", - receiverPath = "bitkit/wallet", - ) - - assertEquals("500f4799bbb2d02103e3b74b365ddb478a3187333c053fa9eb62f4052ba6a327", key.toHex()) - } - - @Test - fun `receiver noise key is persisted and reused`() { - var persistedBytes: ByteArray? = null - val derivedBytes = ByteArray(32) { 7 } - val store = keyStore( - loadBytes = { persistedBytes }, - upsertBytes = { persistedBytes = it.copyOf() }, - deriveBytes = { derivedBytes }, - ) - - val first = store.loadOrDeriveBytes() - val second = store.loadOrDeriveBytes() - val restored = keyStore( - loadBytes = { persistedBytes }, - deriveBytes = { derivedBytes }, - ).loadOrDeriveBytes() - - assertContentEquals(first, persistedBytes) - assertContentEquals(first, second) - assertContentEquals(first, restored) - assertEquals("PAYKIT_RECEIVER_NOISE_SECRET_KEY", Keychain.Key.PAYKIT_RECEIVER_NOISE_SECRET_KEY.name) - } - - @Test - fun `receiver noise key loads for external session without wallet seed`() { - val persistedBytes = ByteArray(32) { 7 } - val store = keyStore( - loadBytes = { persistedBytes }, - deriveBytes = { throw AppError("wallet seed unavailable") }, - ) - - assertContentEquals(persistedBytes, store.loadOrDeriveBytes()) - } - - @Test - fun `receiver noise key cannot be replaced`() { - val store = keyStore( - loadBytes = { ByteArray(32) { 1 } }, - deriveBytes = { ByteArray(32) { 1 } }, - ) - - assertFailsWith { - store.persistBytes(ByteArray(32) { 2 }) - } - } - - @Test - fun `receiver noise key follows wallet replacement after keychain wipe`() { - var persistedBytes: ByteArray? = null - var derivedBytes = ByteArray(32) { 1 } - val store = keyStore( - loadBytes = { persistedBytes }, - upsertBytes = { persistedBytes = it.copyOf() }, - deriveBytes = { derivedBytes }, - ) - store.loadOrDeriveBytes() - - persistedBytes = null - derivedBytes = ByteArray(32) { 2 } - - assertContentEquals(derivedBytes, store.loadOrDeriveBytes()) - assertContentEquals(derivedBytes, persistedBytes) - } - @Test fun `external session retains private payment access`() { val keychain = mock() @@ -657,21 +697,6 @@ class PaykitSdkServiceTest { ) } - private fun keyStore( - loadBytes: () -> ByteArray?, - upsertBytes: (ByteArray) -> Unit = {}, - deriveBytes: () -> ByteArray, - ) = PaykitReceiverNoiseKeyStore(loadBytes, upsertBytes, deriveBytes) - - private fun stubReceiverNoiseSecret(keychain: Keychain) { - val blocking = mock() - whenever(keychain.accessBlocking(any())).doAnswer { - it.getArgument Any?>(0).invoke(blocking) - } - whenever(blocking.load(Keychain.Key.PAYKIT_RECEIVER_NOISE_SECRET_KEY.name)) - .thenReturn(ByteArray(32) { 1 }) - } - private fun identityCacheCases(originalKey: String, differentKey: String) = listOf( Triple(originalKey, null, false), Triple("pubky$originalKey", null, false), @@ -680,6 +705,6 @@ class PaykitSdkServiceTest { Triple(null, originalKey, false), Triple(null, differentKey, false), Triple(originalKey, differentKey, false), - Triple(differentKey, null, true), + Triple(originalKey, differentKey, true), ) } diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceWipeTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceWipeTest.kt index ea6ff80aa3..40d00af7d9 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceWipeTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceWipeTest.kt @@ -2,17 +2,10 @@ package to.bitkit.services import com.synonym.paykit.PaykitException import com.synonym.paykit.PaykitSdk -import com.synonym.paykit.PaykitSdkConfig +import com.synonym.paykit.PubkyIdentityCapability import com.synonym.paykit.PubkySessionAccess import com.synonym.paykit.PubkySessionBootstrap import com.synonym.paykit.PubkySessionBootstrapResult -import com.synonym.paykit.ReceiverNoiseSecretKey -import com.synonym.paykit.SdkStateBlob -import com.synonym.paykit.SdkStateBlobSnapshot -import com.synonym.paykit.SdkStateBlobStore -import com.synonym.paykit.decodeSdkStateBlobSnapshot -import com.synonym.paykit.defaultConfig -import com.synonym.paykit.encodeSdkStateBlobSnapshot import com.synonym.paykit.requiredSessionCapabilities import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineStart @@ -21,7 +14,6 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.Test -import org.mockito.Mockito.mockConstruction import org.mockito.Mockito.mockStatic import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull @@ -48,62 +40,6 @@ class PaykitSdkServiceWipeTest { private const val RING_PUBKY = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" } - private val sdkConfig = PaykitSdkConfig( - receiverPath = PaykitReceiverPaths.WALLET, - profileNamespace = BitkitPaykitSdkConfig.profileNamespace, - endpointManagementScope = BitkitPaykitSdkConfig.endpointManagementScope, - encryptedLinkRecoveryMarkers = BitkitPaykitSdkConfig.encryptedLinkRecoveryMarkers, - publicContactSharing = BitkitPaykitSdkConfig.publicContactSharing, - peerLinkOperationLeaseTimeoutSecs = 1uL, - outboundPrivateSendLeaseTimeoutSecs = 1uL, - outboundPrivateRetryBackoffSecs = 1uL, - ) - - @Test - fun `state storage callbacks translate keychain failures and recover on retry`() { - val keychain = mock() - val blocking = mock() - whenever(keychain.accessBlocking(any())).doAnswer { - it.getArgument Any?>(0).invoke(blocking) - } - val service = PaykitSdkService(mock(), keychain, mock()) { mock() } - val store = PaykitSdkService::class.java.getDeclaredField("stateStore") - .apply { isAccessible = true }.get(service) as SdkStateBlobStore - val key = Keychain.Key.PAYKIT_SDK_STATE.name - whenever(blocking.load(key)).thenAnswer { throw KeychainError.FailedToLoad(key) }.thenReturn(null) - - assertEquals("state_load_failed", assertFailsWith { store.loadStateBlob() }.code) - assertNull(store.loadStateBlob()) - - val blob = mock() - val encoded = byteArrayOf(1, 2, 3) - lateinit var snapshot: SdkStateBlobSnapshot - mockStatic(Class.forName("com.synonym.paykit.Paykit_androidKt")).use { native -> - native.`when` { encodeSdkStateBlobSnapshot(any()) }.thenAnswer { - snapshot = it.getArgument(0) - encoded - } - native.`when` { decodeSdkStateBlobSnapshot(encoded) }.thenAnswer { snapshot } - whenever(blocking.upsert(key, encoded)).thenAnswer { throw KeychainError.FailedToSave(key) }.thenAnswer { - whenever(blocking.load(key)).thenReturn(encoded) - } - - assertEquals( - "state_save_failed", - assertFailsWith { store.saveStateBlobAtomically(blob, null) }.code, - ) - assertNull(store.loadStateBlob()) - val revision = store.saveStateBlobAtomically(blob, null) - val restored = store.loadStateBlob() - assertSame(blob, restored?.blob) - assertEquals(revision, restored?.revision) - assertEquals( - "revision_conflict", - assertFailsWith { store.saveStateBlobAtomically(blob, null) }.code, - ) - } - } - @Test fun `session storage callbacks translate keychain failures and recover on retry`() { val keychain = mock() @@ -139,21 +75,17 @@ class PaykitSdkServiceWipeTest { @Test fun `wallet wipe drains identity bootstrap and persistence and rejects queued imports`() = runTest { val keychain = mock() - stubReceiverNoiseSecret(keychain) val sdk = mock() val bootstrap = mock() val access = mock() - val noise = mock() - whenever(noise.exportBytes()).thenReturn(ByteArray(32) { 1 }) - whenever(access.exportReceiverNoiseSecretKey()).thenReturn(noise) whenever(access.exportSessionSecret()).thenReturn("new-session") - val result = PubkySessionBootstrapResult(access, RING_PUBKY) + val result = PubkySessionBootstrapResult(access, RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE) val bootstrapStarted = CompletableDeferred() val releaseBootstrap = CompletableDeferred() val persistenceStarted = CompletableDeferred() val releasePersistence = CompletableDeferred() val events = mutableListOf() - whenever(bootstrap.importSession(eq("active"), anyOrNull(), any(), any())).doSuspendableAnswer { + whenever(bootstrap.importSession(eq("active"), anyOrNull(), any())).doSuspendableAnswer { events.add("bootstrap") bootstrapStarted.complete(Unit) releaseBootstrap.await() @@ -171,41 +103,29 @@ class PaykitSdkServiceWipeTest { val service = PaykitSdkService(mock(), keychain, store, { bootstrap }, dispatcher) { sdk } mockStatic(Class.forName("com.synonym.paykit.Paykit_androidKt")).use { native -> - native.`when` { defaultConfig(PaykitReceiverPaths.WALLET) }.thenReturn(sdkConfig) - native.`when` { requiredSessionCapabilities(any()) }.thenReturn("capabilities") - mockConstruction(ReceiverNoiseSecretKey::class.java).use { - val active = async(start = CoroutineStart.UNDISPATCHED) { service.importSession("active") } - bootstrapStarted.await() - val queued = async(start = CoroutineStart.UNDISPATCHED) { - assertFailsWith { service.importSession("queued") } - } - val wipe = async(start = CoroutineStart.UNDISPATCHED) { - service.withWalletWipe { events.add("cleanup") } - } - assertFalse(wipe.isCompleted) - assertEquals(listOf("bootstrap"), events) - - releaseBootstrap.complete(Unit) - persistenceStarted.await() - assertFalse(wipe.isCompleted) - assertEquals(listOf("bootstrap"), events) - releasePersistence.complete(Unit) - - assertSame(result, active.await()) - assertEquals("wallet_wipe_in_progress", queued.await().code) - wipe.await() - assertEquals(listOf("bootstrap", "persisted", "cleanup"), events) - verify(bootstrap, never()).importSession(eq("queued"), anyOrNull(), any(), any()) + native.`when` { requiredSessionCapabilities() }.thenReturn("capabilities") + val active = async(start = CoroutineStart.UNDISPATCHED) { service.importSession("active") } + bootstrapStarted.await() + val queued = async(start = CoroutineStart.UNDISPATCHED) { + assertFailsWith { service.importSession("queued") } } - } - } + val wipe = async(start = CoroutineStart.UNDISPATCHED) { + service.withWalletWipe { events.add("cleanup") } + } + assertFalse(wipe.isCompleted) + assertEquals(listOf("bootstrap"), events) - private fun stubReceiverNoiseSecret(keychain: Keychain) { - val blocking = mock() - whenever(keychain.accessBlocking(any())).doAnswer { - it.getArgument Any?>(0).invoke(blocking) + releaseBootstrap.complete(Unit) + persistenceStarted.await() + assertFalse(wipe.isCompleted) + assertEquals(listOf("bootstrap"), events) + releasePersistence.complete(Unit) + + assertSame(result, active.await()) + assertEquals("wallet_wipe_in_progress", queued.await().code) + wipe.await() + assertEquals(listOf("bootstrap", "persisted", "cleanup"), events) + verify(bootstrap, never()).importSession(eq("queued"), anyOrNull(), any()) } - whenever(blocking.load(Keychain.Key.PAYKIT_RECEIVER_NOISE_SECRET_KEY.name)) - .thenReturn(ByteArray(32) { 1 }) } } diff --git a/app/src/test/java/to/bitkit/ui/screens/contacts/AddContactViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/contacts/AddContactViewModelTest.kt index 6ad1eb9c06..9fa6a397b2 100644 --- a/app/src/test/java/to/bitkit/ui/screens/contacts/AddContactViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/contacts/AddContactViewModelTest.kt @@ -16,7 +16,7 @@ import to.bitkit.repositories.PubkyContactError import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.test.BaseUnitTest -import to.bitkit.usecases.RefreshContactPaykitReceiversUseCase +import to.bitkit.usecases.RefreshContactPaykitLinkUseCase import kotlin.test.assertEquals import kotlin.test.assertNull @@ -25,7 +25,7 @@ class AddContactViewModelTest : BaseUnitTest() { private val context: Context = mock() private val pubkyRepo: PubkyRepo = mock() private val publicPaykitRepo: PublicPaykitRepo = mock() - private val refreshContactPaykitReceivers = mock() + private val refreshContactPaykitLink = mock() @Test fun `self add failure should show dedicated error`() = test { @@ -58,14 +58,14 @@ class AddContactViewModelTest : BaseUnitTest() { whenever(context.getString(R.string.contacts__add_error_existing)).thenReturn("existing contact") whenever(pubkyRepo.fetchContactProfile(any())) .thenReturn(Result.failure(PubkyContactError.AlreadyExists)) - whenever { refreshContactPaykitReceivers(TEST_PUBLIC_KEY) }.thenReturn(Result.success(Unit)) + whenever { refreshContactPaykitLink(TEST_PUBLIC_KEY) }.thenReturn(Result.success(Unit)) val sut = createSut() advanceUntilIdle() assertEquals("existing contact", sut.uiState.value.error) assertNull(sut.uiState.value.fetchedProfile) - verify(refreshContactPaykitReceivers).invoke(TEST_PUBLIC_KEY) + verify(refreshContactPaykitLink).invoke(TEST_PUBLIC_KEY) } @Test @@ -103,7 +103,7 @@ class AddContactViewModelTest : BaseUnitTest() { context = context, pubkyRepo = pubkyRepo, publicPaykitRepo = publicPaykitRepo, - refreshContactPaykitReceivers = refreshContactPaykitReceivers, + refreshContactPaykitLink = refreshContactPaykitLink, savedStateHandle = SavedStateHandle(mapOf("publicKey" to publicKey)), ) } diff --git a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt index 585e559f59..0c03d9fff6 100644 --- a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt @@ -52,7 +52,7 @@ class ContactDetailViewModelTest : BaseUnitTest() { override fun now() = now } private val eligibleTargets = MutableStateFlow>(emptyList()) - private val target = PaykitPaymentRequestTarget(TEST_PUBLIC_KEY, "bitkit/wallet") + private val target = PaykitPaymentRequestTarget(TEST_PUBLIC_KEY) private val openedPayment = PublicPaykitPaymentResult.Opened( paymentRequest = "bitcoin:bcrt1qtest", privatePaymentContext = null, diff --git a/app/src/test/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestPresentationTest.kt b/app/src/test/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestPresentationTest.kt index c65284712b..29d06c1924 100644 --- a/app/src/test/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestPresentationTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestPresentationTest.kt @@ -71,7 +71,6 @@ class PaymentRequestPresentationTest { ) = PaykitPaymentRequest( paymentRequestId = "request-id", counterparty = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", - counterpartyReceiverPath = "bitkit/server", amountValue = "0.000025", amountSats = 2_500uL, expiresAt = null, diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModelTest.kt index 4a1184dddd..d25a5abecd 100644 --- a/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModelTest.kt @@ -23,10 +23,12 @@ import org.mockito.kotlin.never import org.mockito.kotlin.same import org.mockito.kotlin.times import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import to.bitkit.R import to.bitkit.models.PreparedWatchOnlyAccountClaim import to.bitkit.models.PubkyAuthClaim +import to.bitkit.models.PubkyAuthClaim.Item import to.bitkit.models.PubkyAuthPermission import to.bitkit.models.PubkyAuthRequest import to.bitkit.models.PubkyProfile @@ -41,6 +43,7 @@ import to.bitkit.utils.AppError import kotlin.test.assertEquals @OptIn(ExperimentalCoroutinesApi::class) +@Suppress("LargeClass") class PubkyAuthApprovalViewModelTest : BaseUnitTest() { private val clientId = "paykit.test" private val context: Context = mock() @@ -72,6 +75,135 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { assertEquals(ApprovalState.Loading, sut.uiState.value.state) } + @Test + fun `Paykit-only reconnect never prepares or tracks a wallet account`() = test { + val capabilities = PubkyAuthClaim.REQUIRED_CAPABILITIES + val authUrl = "pubkyauth://signin?x-bitkit-claim=paykit-access-v1" + whenever(pubkyRepo.parseAuthUrl(authUrl)).thenReturn( + Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim(Item.PAYKIT_ACCESS_V1))), + ) + whenever(pubkyRepo.approveAuthWithCompanionClaim(eq(authUrl), eq(clientId), argThat { isEmpty() })) + .thenReturn(Result.success(Unit)) + val sut = createSut() + + sut.load(authUrl) + advanceUntilIdle() + assertEquals(ApprovalState.Authorize, sut.uiState.value.state) + sut.confirmAuthorize(authUrl) + advanceUntilIdle() + + assertEquals(ApprovalState.Success, sut.uiState.value.state) + verifyBlocking(pubkyRepo) { approveAuthWithCompanionClaim(eq(authUrl), eq(clientId), argThat { isEmpty() }) } + verifyBlocking(pubkyRepo, never()) { approveAuth(any(), any(), any()) } + verifyNoInteractions(watchOnlyAccountRepo) + } + + @Test + fun `combined authorization prepares and activates a new watch-only account`() = test { + val authUrl = "pubkyauth://signin?x-bitkit-claim=paykit-access-v1.watch-only-account-v1" + val request = authRequest( + authUrl, + PubkyAuthClaim.REQUIRED_CAPABILITIES, + PubkyAuthClaim(Item.PAYKIT_ACCESS_V1, Item.WATCH_ONLY_ACCOUNT_V1), + ) + val pending = watchOnlyAccount() + val prepared = PreparedWatchOnlyAccountClaim(pending, ByteArray(84)) + whenever(pubkyRepo.parseAuthUrl(authUrl)).thenReturn(Result.success(request)) + whenever(watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server")).thenReturn(prepared) + whenever(watchOnlyAccountRepo.beginAuthorization(pending.id)).thenReturn(false) + whenever(pubkyRepo.approveAuthWithCompanionClaim(authUrl, clientId, prepared.payload)) + .thenReturn(Result.success(Unit)) + val sut = createSut() + + mockStatic(Log::class.java).use { + sut.load(authUrl) + advanceUntilIdle() + assertEquals(ApprovalState.WatchOnlyConsent, sut.uiState.value.state) + sut.approveWatchOnlyConsent(authUrl) + sut.confirmAuthorize(authUrl) + advanceUntilIdle() + } + + assertEquals(ApprovalState.Success, sut.uiState.value.state) + verifyBlocking(watchOnlyAccountRepo) { prepareUnsignedClaim(authUrl, "paykit server") } + verifyBlocking(watchOnlyAccountRepo, never()) { cancelAuthorization(any(), any()) } + verifyBlocking(watchOnlyAccountRepo) { markActive(pending.id) } + } + + @Test + fun `canceling local auth does not allocate or change a watch-only account`() = test { + val authUrl = "pubkyauth://signin?x-bitkit-claim=watch-only-account-v1" + whenever(pubkyRepo.parseAuthUrl(authUrl)).thenReturn( + Result.success( + authRequest( + authUrl, + PubkyAuthClaim.REQUIRED_CAPABILITIES, + PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1), + ), + ), + ) + val sut = createSut() + sut.load(authUrl) + advanceUntilIdle() + sut.approveWatchOnlyConsent(authUrl) + sut.requestAuthorize(authUrl) + advanceUntilIdle() + sut.cancelLocalAuth(authUrl) + sut.dismiss() + advanceUntilIdle() + + verifyNoInteractions(watchOnlyAccountRepo) + verifyBlocking(pubkyRepo, never()) { approveAuthWithCompanionClaim(any(), any(), any()) } + } + + @Test + fun `authorization rejects a claim type that differs from the displayed consent`() = test { + val authUrl = "pubkyauth://signin?x-bitkit-claim=paykit-access-v1" + val shown = authRequest( + authUrl, + PubkyAuthClaim.REQUIRED_CAPABILITIES, + PubkyAuthClaim(Item.PAYKIT_ACCESS_V1), + ) + whenever(pubkyRepo.parseAuthUrl(authUrl)).thenReturn( + Result.success(shown), + Result.success(shown.copy(bitkitClaim = PubkyAuthClaim(Item.PAYKIT_ACCESS_V1, Item.WATCH_ONLY_ACCOUNT_V1))), + ) + val sut = createSut() + mockStatic(Log::class.java).use { + sut.load(authUrl) + advanceUntilIdle() + sut.confirmAuthorize(authUrl) + advanceUntilIdle() + } + assertEquals(ApprovalState.Authorize, sut.uiState.value.state) + verifyNoInteractions(watchOnlyAccountRepo) + verifyBlocking(pubkyRepo, never()) { approveAuthWithCompanionClaim(any(), any(), any()) } + } + + @Test + fun `authorization rejects item order changed after consent`() = test { + val claim = PubkyAuthClaim(Item.PAYKIT_ACCESS_V1, Item.WATCH_ONLY_ACCOUNT_V1) + val authUrl = "pubkyauth://signin?x-bitkit-claim=${claim.wireValue}" + val shown = authRequest(authUrl, PubkyAuthClaim.REQUIRED_CAPABILITIES, claim) + whenever(pubkyRepo.parseAuthUrl(authUrl)).thenReturn( + Result.success(shown), + Result.success( + shown.copy(bitkitClaim = PubkyAuthClaim.fromWireValue("watch-only-account-v1.paykit-access-v1")), + ), + ) + val sut = createSut() + mockStatic(Log::class.java).use { + sut.load(authUrl) + advanceUntilIdle() + sut.approveWatchOnlyConsent(authUrl) + sut.confirmAuthorize(authUrl) + advanceUntilIdle() + } + assertEquals(ApprovalState.Authorize, sut.uiState.value.state) + verifyBlocking(watchOnlyAccountRepo, never()) { prepareUnsignedClaim(any(), any()) } + verifyBlocking(pubkyRepo, never()) { approveAuthWithCompanionClaim(any(), any(), any()) } + } + @Test fun `auth display public key omits pubky prefix`() { assertEquals("3rsd...w5xg", pubkyAuthDisplayPublicKey("pubky3rsd123456789w5xg")) @@ -294,13 +426,13 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { @Test fun `load exposes watch-only account claim for approval`() = test { - val authUrl = "pubkyauth://signin?caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" + val authUrl = "pubkyauth://signin?caps=${PubkyAuthClaim.REQUIRED_CAPABILITIES}" whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( Result.success( authRequest( authUrl = authUrl, - capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES, - bitkitClaim = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, + capabilities = PubkyAuthClaim.REQUIRED_CAPABILITIES, + bitkitClaim = PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1), ), ), ) @@ -310,7 +442,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { advanceUntilIdle() assertEquals(ApprovalState.WatchOnlyConsent, sut.uiState.value.state) - assertEquals(PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, sut.uiState.value.bitkitClaim) + assertEquals(PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1), sut.uiState.value.bitkitClaim) sut.confirmAuthorize(authUrl) advanceUntilIdle() @@ -331,15 +463,15 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { } @Test - fun `watch-only authorization uses combined companion approval`() = test { - val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" - val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + fun `watch-only authorization delivers the account companion claim`() = test { + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.REQUIRED_CAPABILITIES}" + val capabilities = PubkyAuthClaim.REQUIRED_CAPABILITIES val prepared = PreparedWatchOnlyAccountClaim( account = watchOnlyAccount(), payload = ByteArray(84), ) whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( - Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1)), + Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1))), ) whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) }.thenReturn(false) @@ -366,14 +498,14 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { @Test fun `duplicate confirmations start one companion authorization`() = test { - val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" - val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.REQUIRED_CAPABILITIES}" + val capabilities = PubkyAuthClaim.REQUIRED_CAPABILITIES val prepared = PreparedWatchOnlyAccountClaim( account = watchOnlyAccount(), payload = ByteArray(84), ) whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( - Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1)), + Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1))), ) whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) }.thenReturn(false) @@ -399,8 +531,8 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { @Test fun `switching requests and reopening during companion approval does not start another authorization`() = test { - val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" - val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.REQUIRED_CAPABILITIES}" + val capabilities = PubkyAuthClaim.REQUIRED_CAPABILITIES val secondAuthUrl = "pubkyauth://signin?caps=/pub/second/:rw" val secondCapabilities = "/pub/second/:rw" val prepared = PreparedWatchOnlyAccountClaim( @@ -409,7 +541,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { ) val approvalResult = CompletableDeferred>() whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( - Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1)), + Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1))), ) whenever { pubkyRepo.parseAuthUrl(secondAuthUrl) }.thenReturn( Result.success(authRequest(secondAuthUrl, secondCapabilities)), @@ -456,8 +588,8 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { @Test fun `wrapped post-delivery authorization failure keeps account authorizing for retry`() = test { - val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" - val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.REQUIRED_CAPABILITIES}" + val capabilities = PubkyAuthClaim.REQUIRED_CAPABILITIES val prepared = PreparedWatchOnlyAccountClaim( account = watchOnlyAccount(), payload = ByteArray(84), @@ -466,7 +598,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { "AuthToken delivery failed" ) whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( - Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1)), + Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1))), ) whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) }.thenReturn(false) @@ -488,14 +620,14 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { @Test fun `companion delivery failure does not approve normal auth or activate account`() = test { - val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" - val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.REQUIRED_CAPABILITIES}" + val capabilities = PubkyAuthClaim.REQUIRED_CAPABILITIES val prepared = PreparedWatchOnlyAccountClaim( account = watchOnlyAccount(), payload = ByteArray(84), ) whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( - Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1)), + Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1))), ) whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) }.thenReturn(false) @@ -518,8 +650,8 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { @Test fun `retry delivery failure keeps a previously delivered account authorizing`() = test { - val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" - val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.REQUIRED_CAPABILITIES}" + val capabilities = PubkyAuthClaim.REQUIRED_CAPABILITIES val prepared = PreparedWatchOnlyAccountClaim( account = watchOnlyAccount().copy( isTrackingEnabled = true, @@ -528,7 +660,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { payload = ByteArray(84), ) whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( - Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1)), + Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1))), ) whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) }.thenReturn(true) @@ -552,7 +684,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { @Test fun `tracking preparation failure unloads account without attempting approval`() = test { - val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.REQUIRED_CAPABILITIES}" val prepared = PreparedWatchOnlyAccountClaim( account = watchOnlyAccount(), payload = ByteArray(84), @@ -561,8 +693,8 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { Result.success( authRequest( authUrl, - PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES, - PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, + PubkyAuthClaim.REQUIRED_CAPABILITIES, + PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1), ), ), ) @@ -585,7 +717,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { @Test fun `tracking failure uses the current authorizing disposition`() = test { - val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.REQUIRED_CAPABILITIES}" val prepared = PreparedWatchOnlyAccountClaim( account = watchOnlyAccount(), payload = ByteArray(84), @@ -594,8 +726,8 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { Result.success( authRequest( authUrl, - PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES, - PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, + PubkyAuthClaim.REQUIRED_CAPABILITIES, + PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1), ), ), ) @@ -623,7 +755,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { @Test fun `retry after process restart reuses account and repeats authorization`() = test { - val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.REQUIRED_CAPABILITIES}" val prepared = PreparedWatchOnlyAccountClaim( account = watchOnlyAccount(), payload = ByteArray(84), @@ -638,8 +770,8 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { Result.success( authRequest( authUrl, - PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES, - PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, + PubkyAuthClaim.REQUIRED_CAPABILITIES, + PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1), ), ), ) @@ -694,7 +826,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { clientId = clientId, relay = "https://httprelay.pubky.app/inbox/", capabilities = capabilities, - permissions = listOf(PubkyAuthPermission(path = "/pub/paykit/v0/bitkit/server/", accessLevel = "rw")), + permissions = listOf(PubkyAuthPermission(path = "/pub/paykit/", accessLevel = "rw")), serviceNames = listOf("paykit"), bitkitClaim = bitkitClaim, homeserverPublicKey = if (PubkyAuthRequest.isSignupUrl(authUrl)) "homeserver" else null, diff --git a/app/src/test/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt b/app/src/test/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt index e741447bf8..cebdd19857 100644 --- a/app/src/test/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt @@ -176,7 +176,6 @@ class SubscriptionsScreenTest { ) = PaykitSubscription( paymentRequestId = "subscription", counterparty = "pubkypayee", - counterpartyReceiverPath = "bitkit/server", amountValue = "0.001", amountSats = amountSats, note = "Subscription", diff --git a/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCaseTest.kt similarity index 68% rename from app/src/test/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCaseTest.kt rename to app/src/test/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCaseTest.kt index 052a91a001..7470d823cb 100644 --- a/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCaseTest.kt @@ -15,7 +15,7 @@ import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals import kotlin.test.assertTrue -class RefreshContactPaykitReceiversUseCaseTest : BaseUnitTest() { +class RefreshContactPaykitLinkUseCaseTest : BaseUnitTest() { private val pubkyRepo = mock() private val privatePaykitRepo = mock() private val contactKeys = listOf("pubky-alice", "pubky-bob") @@ -32,7 +32,7 @@ class RefreshContactPaykitReceiversUseCaseTest : BaseUnitTest() { }, ) - private val sut = RefreshContactPaykitReceiversUseCase( + private val sut = RefreshContactPaykitLinkUseCase( ioDispatcher = testDispatcher, pubkyRepo = pubkyRepo, privatePaykitRepo = privatePaykitRepo, @@ -44,8 +44,7 @@ class RefreshContactPaykitReceiversUseCaseTest : BaseUnitTest() { } @Test - fun `refreshes receiver paths before publishing the contact`() = test { - whenever { pubkyRepo.refreshContactReceiverPaths(contactKeys.last()) }.thenReturn(Result.success(Unit)) + fun `refreshes contact endpoints before starting the link burst`() = test { whenever { privatePaykitRepo.refreshSavedContactEndpoints(contactKeys.last(), contactKeys) }.thenReturn(Result.success(Unit)) @@ -53,22 +52,22 @@ class RefreshContactPaykitReceiversUseCaseTest : BaseUnitTest() { val result = sut(contactKeys.last()) assertTrue(result.isSuccess) - inOrder(pubkyRepo, privatePaykitRepo).apply { - verify(pubkyRepo).refreshContactReceiverPaths(contactKeys.last()) + inOrder(privatePaykitRepo).apply { verify(privatePaykitRepo).refreshSavedContactEndpoints(contactKeys.last(), contactKeys) - verify(privatePaykitRepo).startInitialLinkBurst(contactKeys, "contact receiver refresh") + verify(privatePaykitRepo).startInitialLinkBurst(contactKeys, "contact link refresh") } } @Test - fun `stops when receiver discovery fails`() = test { - val error = IllegalStateException("Discovery failed") - whenever { pubkyRepo.refreshContactReceiverPaths(contactKeys.last()) }.thenReturn(Result.failure(error)) + fun `stops when endpoint refresh fails`() = test { + val error = IllegalStateException("Endpoint refresh failed") + whenever { + privatePaykitRepo.refreshSavedContactEndpoints(contactKeys.last(), contactKeys) + }.thenReturn(Result.failure(error)) val result = sut(contactKeys.last()) assertEquals(error, result.exceptionOrNull()) - verify(privatePaykitRepo, never()).refreshSavedContactEndpoints(contactKeys.last(), contactKeys) - verify(privatePaykitRepo, never()).startInitialLinkBurst(contactKeys, "contact receiver refresh") + verify(privatePaykitRepo, never()).startInitialLinkBurst(contactKeys, "contact link refresh") } } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index a8d49e8e82..8545e3873b 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -181,7 +181,7 @@ import to.bitkit.ui.sheets.hardware.HardwareRoute import to.bitkit.ui.theme.TRANSITION_SCREEN_MS import to.bitkit.ui.utils.ScreenDeepLinks import to.bitkit.usecases.FormatMoneyValue -import to.bitkit.usecases.RefreshContactPaykitReceiversUseCase +import to.bitkit.usecases.RefreshContactPaykitLinkUseCase import to.bitkit.utils.AppError import to.bitkit.utils.timedsheets.TimedSheetManager import java.math.BigDecimal @@ -241,7 +241,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val samRockRepo = mock() private val widgetsRepo = mock() private val formatMoneyValue = mock() - private val refreshContactPaykitReceivers = mock() + private val refreshContactPaykitLink = mock() private val clipboardManager = mock() private val toastManager = mock() private val toastState = MutableStateFlow(null) @@ -385,8 +385,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever { pubkyRepo.republishIdentityIfNeeded() }.thenReturn(Result.success(Unit)) whenever { pubkyRepo.hasIdentity() }.thenAnswer { pubkyPublicKey.value != null } whenever(pubkyRepo.contacts).thenReturn(pubkyContacts) - whenever { refreshContactPaykitReceivers(any()) }.thenReturn(Result.success(Unit)) - whenever { publicPaykitRepo.syncLocalReceiverMarker(anyOrNull(), anyOrNull()) } + whenever { refreshContactPaykitLink(any()) }.thenReturn(Result.success(Unit)) + whenever { publicPaykitRepo.syncPaykitApp(anyOrNull()) } .thenReturn(Result.success(Unit)) whenever(pubkyRepo.contactsLoadVersion).thenReturn(pubkyContactsLoadVersion) whenever(pubkyRepo.contactsLoadCompletionVersion).thenReturn(pubkyContactsLoadCompletionVersion) @@ -410,6 +410,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } whenever(paykitPaymentRequestRepo.isPending(any())).thenReturn(true) whenever { paykitPaymentRequestRepo.ensurePaymentAllowed(any()) }.thenReturn(Result.success(Unit)) + whenever { paykitPaymentRequestRepo.claimForPayment(any()) }.thenReturn(Result.success(Unit)) whenever { lightningRepo.payInvoice(any(), anyOrNull(), any()) }.doSuspendableAnswer { if (it.getArgument Boolean>(2)()) { lightningRepo.payInvoice(it.getArgument(0), it.getArgument(1)) @@ -420,9 +421,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitPaymentRequestRepo.isExpired(any())).thenReturn(false) whenever(paykitPaymentRequestRepo.isProcessing(any())).thenReturn(false) whenever(paykitPaymentProofRepo.onchainPaymentResolutions).thenReturn(onchainPaymentResolutions) - whenever { paykitPaymentProofRepo.prepare(any(), any(), any()) }.thenReturn(Result.success(Unit)) + whenever { paykitPaymentProofRepo.prepare(any(), any(), any(), any()) }.thenReturn(Result.success(Unit)) whenever { - paykitPaymentProofRepo.associateLightningPayment(any(), any(), any()) + paykitPaymentProofRepo.associateLightningPayment(any(), any(), any(), eq("bitkit")) }.thenReturn(Result.success(Unit)) whenever { paykitPaymentProofRepo.markOnchainPaymentStarted(any(), any(), any()) @@ -520,7 +521,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { paykitPaymentRequestRepo = paykitPaymentRequestRepo, paykitPaymentProofRepo = paykitPaymentProofRepo, paykitPaymentRequestDiagnostics = paykitPaymentRequestDiagnostics, - refreshContactPaykitReceivers = refreshContactPaykitReceivers, + refreshContactPaykitLink = refreshContactPaykitLink, samRockRepo = samRockRepo, appUpdateSheet = mock(), backupSheet = mock(), @@ -887,7 +888,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.setIsAuthenticated(true) val request = paymentRequest() val bolt11 = "lnbcrt1updatedpaymentrequest" - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 8uL) + val privateContext = privatePaymentContext(8uL) whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList), @@ -932,7 +933,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { Result.success( PublicPaykitPaymentResult.Opened( paymentRequest = bolt11, - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 8uL), + privatePaymentContext = privatePaymentContext(8uL), ), ), ) @@ -959,7 +960,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.setIsAuthenticated(true) val request = paymentRequest() val bolt11 = "lnbcrt1updatedmanualrequest" - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 8uL) + val privateContext = privatePaymentContext(8uL) whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList), Result.success( @@ -1041,7 +1042,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { surfacedPaykitPaymentRequestIds += request.id setActiveContactPaymentContext( publicKey = testPublicKey, - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), + privatePaymentContext = privatePaymentContext(7uL), incomingPaymentRequest = request, ) setRequestedPaymentRequestId(request.id) @@ -1105,7 +1106,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { Result.success( PublicPaykitPaymentResult.Opened( paymentRequest = bolt11, - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 8uL), + privatePaymentContext = privatePaymentContext(8uL), ), ), ) @@ -1120,7 +1121,6 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val subscriptionId = PaykitSubscriptionId( request.paymentRequestId, request.counterparty, - request.counterpartyReceiverPath, ) sut.showSheet(Sheet.Subscription(SubscriptionRoute.Review(subscriptionId))) sut.openIncomingPaymentRequest(request.id) @@ -1886,7 +1886,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { Result.success( PublicPaykitPaymentResult.Opened( paymentRequest = automaticInvoice, - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), + privatePaymentContext = privatePaymentContext(7uL), ), ) } @@ -2032,7 +2032,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { setActiveContactPaymentContext(manualContext.publicKey) PublicPaykitPaymentResult.Opened( paymentRequest = "lnbcrt1incoming", - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), + privatePaymentContext = privatePaymentContext(7uL), ) } pendingPaykitPaymentRequests.value = listOf(request) @@ -2065,7 +2065,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { Result.success( PublicPaykitPaymentResult.Opened( paymentRequest = requestInvoice, - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), + privatePaymentContext = privatePaymentContext(7uL), ), ) } @@ -2285,7 +2285,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { Result.success( PublicPaykitPaymentResult.Opened( paymentRequest = requestInvoice, - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), + privatePaymentContext = privatePaymentContext(7uL), ), ) } @@ -2424,7 +2424,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val expiredRequest = paymentRequest() val payableRequest = expiredRequest.copy(paymentRequestId = "payable-request") val bolt11 = "lnbcrt1payableafterexpired" - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) var payableAttempts = 0 whenever(privatePaykitRepo.beginPaymentRequest(expiredRequest)) .thenReturn(Result.failure(PaykitPaymentRequestError.RequestExpired)) @@ -2975,7 +2975,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() } - verify(refreshContactPaykitReceivers).invoke(testPublicKey) + verify(refreshContactPaykitLink).invoke(testPublicKey) } @Test @@ -3045,7 +3045,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { expectNoEvents() } verify(pubkyRepo, never()).loadContacts() - verify(refreshContactPaykitReceivers).invoke(testPublicKey) + verify(refreshContactPaykitLink).invoke(testPublicKey) verify(coreService, never()).decode(any()) } @@ -3069,7 +3069,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() } verify(pubkyRepo).loadContacts() - verify(refreshContactPaykitReceivers).invoke(testPublicKey) + verify(refreshContactPaykitLink).invoke(testPublicKey) verify(coreService, never()).decode(any()) } @@ -3096,7 +3096,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() } verify(pubkyRepo).loadContacts() - verify(refreshContactPaykitReceivers).invoke(testPublicKey) + verify(refreshContactPaykitLink).invoke(testPublicKey) verify(coreService, never()).decode(any()) } @@ -6070,7 +6070,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `incoming onchain payment request has a valid fixed amount`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) whenever { coreService.decode(REGTEST_ADDRESS) }.thenReturn( Scanner.OnChain( @@ -6115,7 +6115,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `outgoing payment request creation continues after its caller returns`() = test { val request = paymentRequest().copy(counterparty = "pubkyrecipient") - val target = PaykitPaymentRequestTarget(request.counterparty, request.counterpartyReceiverPath) + val target = PaykitPaymentRequestTarget(request.counterparty) val draft = PaykitPaymentRequestDraft( amountSats = request.amountSats, note = "Lunch", @@ -6142,7 +6142,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `committed outgoing request closes inactive identity flow and shows queued feedback`() = test { val request = paymentRequest().copy(counterparty = "pubkyrecipient") - val target = PaykitPaymentRequestTarget(request.counterparty, request.counterpartyReceiverPath) + val target = PaykitPaymentRequestTarget(request.counterparty) val draft = PaykitPaymentRequestDraft( amountSats = request.amountSats, note = "Lunch", @@ -6171,7 +6171,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `inactive identity completion preserves a replacement sheet`() = test { val request = paymentRequest().copy(counterparty = "pubkyrecipient") - val target = PaykitPaymentRequestTarget(request.counterparty, request.counterpartyReceiverPath) + val target = PaykitPaymentRequestTarget(request.counterparty) val draft = PaykitPaymentRequestDraft( amountSats = request.amountSats, note = "Lunch", @@ -6240,7 +6240,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `duplicate payment request confirmation submits only once`() = test { val address = "bcrt1qpaymentrequest" val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) @@ -6278,7 +6278,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { beforeSendAttempt = any(), onBroadcast = any(), ) - verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue) + verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue, "bitkit") } @Test @@ -6290,7 +6290,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.openContactPayment( paymentRequest = bolt11, publicKey = testPublicKey, - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), + privatePaymentContext = privatePaymentContext(7uL), incomingPaymentRequest = request, ) advanceUntilIdle() @@ -6326,7 +6326,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `private onchain contact payment consumes private list before send`() = test { val address = "bcrt1qprivatecontact" val contactKey = "pubkycontact" - val privateContext = PrivatePaykitPaymentContext("bitkit/wallet", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) stubSuccessfulOnchainSend(address, 1000u) whenever(privatePaykitRepo.consumePrivatePaymentList(contactKey, privateContext)) @@ -6350,7 +6350,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `incoming payment request consumes its private list before it is accepted`() = test { val address = "bcrt1qpaymentrequest" val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) @@ -6370,21 +6370,25 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() inOrder(paykitPaymentProofRepo, privatePaykitRepo, paykitPaymentRequestRepo).apply { - verify(paykitPaymentProofRepo).prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain) + verify( + paykitPaymentProofRepo + ).prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain) verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) verify(paykitPaymentRequestRepo).accept(request) } verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) - verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue) + verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue, "bitkit") } @Test - fun `proof preparation failure does not block incoming onchain payment`() = test { + fun `proof preparation failure blocks incoming onchain payment`() = test { val address = "bcrt1qpaymentrequest" val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) - whenever(paykitPaymentProofRepo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain)) + whenever( + paykitPaymentProofRepo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain) + ) .thenReturn(Result.failure(IllegalStateException("proof unavailable"))) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) @@ -6403,9 +6407,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() - verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) - verify(paykitPaymentRequestRepo).accept(request) - verify(lightningRepo).sendOnChain( + verify(privatePaykitRepo, never()).consumePrivatePaymentList(testPublicKey, privateContext) + verify(paykitPaymentRequestRepo, never()).accept(request) + verify(lightningRepo, never()).sendOnChain( address = any(), sats = any(), speed = anyOrNull(), @@ -6422,12 +6426,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `uncertain onchain outcome without prepared proof keeps private payment details consumed`() = test { + fun `execution claim failure blocks versionless request without consuming private details`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(null) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) - whenever(paykitPaymentProofRepo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain)) - .thenReturn(Result.failure(IllegalStateException("proof unavailable"))) + whenever(paykitPaymentRequestRepo.claimForPayment(request)) + .thenReturn(Result.failure(IllegalStateException("request claimed by another app"))) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) @@ -6447,29 +6451,34 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ), ) - sut.sendEffect.test { - confirmCurrentPayment() - - assertTrue(awaitItem() is SendEffect.NavigateToPending) - } + confirmCurrentPayment() + verify(privatePaykitRepo, never()).consumePrivatePaymentList(any(), any()) + verify(paykitPaymentRequestRepo, never()).accept(request) verify(paykitPaymentProofRepo, never()).failOnchainPayment(any()) - verify(paykitPaymentProofRepo, never()).cancelPreparation(any()) + verify(paykitPaymentProofRepo).cancelPreparation(request) verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) } @Test - fun `proof association failure does not block incoming lightning payment`() = test { + fun `proof association failure blocks incoming lightning payment`() = test { val request = paymentRequest() val bolt11 = "lnbcrt1paymentrequest" val paymentHash = "010203" - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) - whenever(paykitPaymentProofRepo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning)) + whenever( + paykitPaymentProofRepo.prepare( + request, + MethodId.Bolt11.rawValue, + "bitkit", + PaykitPaymentProofKind.Lightning + ) + ) .doSuspendableAnswer { setSendState(sut.sendUiState.value.copy(decodedInvoice = lightningInvoice(bolt11, request.amountSats))) Result.success(Unit) } - whenever { paykitPaymentProofRepo.associateLightningPayment(any(), any(), any()) } + whenever { paykitPaymentProofRepo.associateLightningPayment(any(), any(), any(), eq("bitkit")) } .thenReturn(Result.failure(IllegalStateException("proof unavailable"))) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) @@ -6488,10 +6497,14 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.setSendEvent(SendEvent.PayConfirmed) advanceUntilIdle() - verify(paykitPaymentProofRepo).prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning) - verify(paykitPaymentProofRepo).associateLightningPayment(request, paymentHash, MethodId.Bolt11.rawValue) + verify( + paykitPaymentProofRepo + ).prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning) + verify( + paykitPaymentProofRepo + ).associateLightningPayment(request, paymentHash, MethodId.Bolt11.rawValue, "bitkit") verify(paykitPaymentProofRepo).cancelPreparation(request) - verify(lightningRepo).payInvoice(bolt11 = bolt11, sats = null) + verify(lightningRepo, never()).payInvoice(bolt11 = bolt11, sats = null) } @Test @@ -6500,14 +6513,17 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val request = paymentRequest() val bolt11 = "lnbcrt1paymentrequest" val paymentHash = "010203" - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) whenever( - paykitPaymentProofRepo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning), + paykitPaymentProofRepo.prepare( + request, + MethodId.Bolt11.rawValue, + "bitkit", + PaykitPaymentProofKind.Lightning + ), ).doSuspendableAnswer { - setSendState( - sut.sendUiState.value.copy(decodedInvoice = lightningInvoice(bolt11, request.amountSats)), - ) + setSendState(sut.sendUiState.value.copy(decodedInvoice = lightningInvoice(bolt11, request.amountSats))) if (preparationSucceeds) { Result.success(Unit) } else { @@ -6517,7 +6533,14 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) - whenever(paykitPaymentProofRepo.associateLightningPayment(request, paymentHash, MethodId.Bolt11.rawValue)) + whenever( + paykitPaymentProofRepo.associateLightningPayment( + request, + paymentHash, + MethodId.Bolt11.rawValue, + "bitkit" + ) + ) .doSuspendableAnswer { whenever(paykitPaymentRequestRepo.ensurePaymentAllowed(request)) .thenReturn(Result.failure(PaykitPaymentRequestError.RequestUnavailable)) @@ -6534,10 +6557,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) sut.setSendEvent(SendEvent.PayConfirmed) advanceUntilIdle() - verify(paykitPaymentRequestRepo).accept(request) + verify(paykitPaymentRequestRepo, times(if (preparationSucceeds) 1 else 0)).accept(request) verify(lightningRepo, never()).payInvoice(any(), anyOrNull()) - verify(paykitPaymentProofRepo).failLightningPayment(paymentHash) - verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) + verify(paykitPaymentProofRepo, times(if (preparationSucceeds) 1 else 0)).failLightningPayment(paymentHash) + verify( + privatePaykitRepo, + times(if (preparationSucceeds) 1 else 0) + ).releasePrivatePaymentList(testPublicKey, privateContext) clearInvocations(lightningRepo, paykitPaymentProofRepo, paykitPaymentRequestRepo, privatePaykitRepo) } } @@ -6547,9 +6573,16 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val request = paymentRequest() val bolt11 = "lnbcrt1pendingrequest" val invoicePaymentHash = "010203" - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) - whenever(paykitPaymentProofRepo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning)) + whenever( + paykitPaymentProofRepo.prepare( + request, + MethodId.Bolt11.rawValue, + "bitkit", + PaykitPaymentProofKind.Lightning + ) + ) .doSuspendableAnswer { setSendState(sut.sendUiState.value.copy(decodedInvoice = lightningInvoice(bolt11, request.amountSats))) Result.success(Unit) @@ -6573,13 +6606,16 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() inOrder(paykitPaymentProofRepo, privatePaykitRepo, paykitPaymentRequestRepo, lightningRepo).apply { - verify(paykitPaymentProofRepo).prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning) + verify( + paykitPaymentProofRepo + ).prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning) verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) verify(paykitPaymentRequestRepo).accept(request) verify(paykitPaymentProofRepo).associateLightningPayment( request, invoicePaymentHash, MethodId.Bolt11.rawValue, + "bitkit", ) verify(lightningRepo).payInvoice(bolt11 = bolt11, sats = null) } @@ -6594,9 +6630,16 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val bolt11 = "lnbcrt1failedrequest" val invoicePaymentHash = "010203" val error = NodeException.PaymentSendingFailed("no route") - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) - whenever(paykitPaymentProofRepo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning)) + whenever( + paykitPaymentProofRepo.prepare( + request, + MethodId.Bolt11.rawValue, + "bitkit", + PaykitPaymentProofKind.Lightning + ) + ) .doSuspendableAnswer { setSendState(sut.sendUiState.value.copy(decodedInvoice = lightningInvoice(bolt11, request.amountSats))) Result.success(Unit) @@ -6621,13 +6664,16 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() inOrder(paykitPaymentProofRepo, privatePaykitRepo, paykitPaymentRequestRepo, lightningRepo).apply { - verify(paykitPaymentProofRepo).prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning) + verify( + paykitPaymentProofRepo + ).prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning) verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) verify(paykitPaymentRequestRepo).accept(request) verify(paykitPaymentProofRepo).associateLightningPayment( request, invoicePaymentHash, MethodId.Bolt11.rawValue, + "bitkit", ) verify(lightningRepo).payInvoice(bolt11 = bolt11, sats = null) verify(paykitPaymentProofRepo).failLightningPayment(invoicePaymentHash, error) @@ -6639,7 +6685,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `failed LNURL request callback releases preparation and retry reopens request`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) val lnurl = LnurlPayData( uri = "lnurl1failedrequest", callback = "https://example.com/callback", @@ -6707,9 +6753,16 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val bolt11 = "lnbcrt1pendingrequest" val paymentHash = "010203" val error = NodeException.PersistenceFailed("io") - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) - whenever(paykitPaymentProofRepo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning)) + whenever( + paykitPaymentProofRepo.prepare( + request, + MethodId.Bolt11.rawValue, + "bitkit", + PaykitPaymentProofKind.Lightning + ) + ) .doSuspendableAnswer { setSendState(sut.sendUiState.value.copy(decodedInvoice = lightningInvoice(bolt11, request.amountSats))) Result.success(Unit) @@ -6745,9 +6798,16 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `in flight proof blocks another payment before consuming private details`() = test { val request = paymentRequest() val bolt11 = "lnbcrt1paymentrequest" - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) - whenever(paykitPaymentProofRepo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning)) + whenever( + paykitPaymentProofRepo.prepare( + request, + MethodId.Bolt11.rawValue, + "bitkit", + PaykitPaymentProofKind.Lightning + ) + ) .thenReturn(Result.failure(PaykitPaymentRequestError.OperationInProgress)) setActiveContactPaymentContext(testPublicKey, privateContext, request) setSendState( @@ -6762,7 +6822,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.setSendEvent(SendEvent.PayConfirmed) advanceUntilIdle() - verify(paykitPaymentProofRepo).prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning) + verify( + paykitPaymentProofRepo + ).prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning) verify(privatePaykitRepo, never()).consumePrivatePaymentList(any(), any()) verify(paykitPaymentRequestRepo, never()).accept(any()) verify(lightningRepo, never()).payInvoice(any(), anyOrNull()) @@ -6772,8 +6834,10 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `in flight proof blocks switching to a hardware payment`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) - whenever(paykitPaymentProofRepo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain)) + val privateContext = privatePaymentContext(7uL) + whenever( + paykitPaymentProofRepo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain) + ) .thenReturn(Result.failure(PaykitPaymentRequestError.OperationInProgress)) setActiveContactPaymentContext(testPublicKey, privateContext, request) setSendState( @@ -6790,7 +6854,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `hardware payment request releases private details when acceptance fails`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) whenever(paykitPaymentRequestRepo.accept(request)) .thenReturn(Result.failure(IllegalStateException("accept failed"))) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) @@ -6817,7 +6881,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `hardware payment request releases private details when proof start fails`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) @@ -6849,7 +6913,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `approved hardware payment request preparation is idempotent`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) @@ -6869,7 +6933,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertTrue(sut.prepareHardwareContactPayment()) inOrder(paykitPaymentProofRepo, privatePaykitRepo, paykitPaymentRequestRepo).apply { - verify(paykitPaymentProofRepo).prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain) + verify( + paykitPaymentProofRepo + ).prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain) verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) verify(paykitPaymentRequestRepo).accept(request) verify(paykitPaymentProofRepo).markOnchainPaymentStarted( @@ -6890,7 +6956,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `hardware retry denial keeps the started proof until cancellation`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) whenever(context.getString(R.string.common__error)).thenReturn("Error") whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(paykitPaymentRequestRepo.ensurePaymentAllowed(request)) @@ -6938,7 +7004,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `cancelling hardware signing fails the started payment proof`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) @@ -6965,7 +7031,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `dismissing hardware signing fails the started payment proof`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) @@ -6992,10 +7058,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `proof preparation failure does not block hardware payment request`() = test { + fun `proof preparation failure blocks hardware payment request`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) - whenever(paykitPaymentProofRepo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain)) + val privateContext = privatePaymentContext(7uL) + whenever( + paykitPaymentProofRepo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain) + ) .thenReturn(Result.failure(IllegalStateException("proof unavailable"))) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) @@ -7011,23 +7079,23 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ), ) - assertTrue(sut.prepareHardwareContactPayment()) + assertFalse(sut.prepareHardwareContactPayment()) - verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) - verify(paykitPaymentRequestRepo).accept(request) + verify(privatePaykitRepo, never()).consumePrivatePaymentList(testPublicKey, privateContext) + verify(paykitPaymentRequestRepo, never()).accept(request) verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted(any(), any(), any()) } @Test fun `hardware payment request completes proof in background after broadcast`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) val completionStarted = CompletableDeferred() val finishCompletion = CompletableDeferred() whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) - whenever(paykitPaymentProofRepo.completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue)) + whenever(paykitPaymentProofRepo.completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue, "bitkit")) .doSuspendableAnswer { completionStarted.complete(Unit) finishCompletion.await() @@ -7052,13 +7120,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { finishCompletion.complete(Unit) advanceUntilIdle() - verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue) + verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue, "bitkit") verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) } @Test fun `private hardware contact preparation is idempotent`() = test { - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) setActiveContactPaymentContext(testPublicKey, privateContext) @@ -7078,7 +7146,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { endsAt = Instant.parse("2026-08-31T00:00:00Z"), ) ) - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) pubkyPublicKey.value = testPublicKey enablePaykitUi() balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) @@ -7100,7 +7168,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() - verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue) + verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue, "bitkit") verify(paykitPaymentRequestRepo).refresh() } @@ -7314,7 +7382,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `onchain payment failure before send attempt releases private payment details`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) @@ -7350,7 +7418,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `onchain authorization denial after proof starts releases private payment details`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) val address = "bcrt1qauthorizationdenied" balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) @@ -7389,7 +7457,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(paykitPaymentProofRepo).failOnchainPayment(request) verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) verify(paykitPaymentProofRepo).cancelPreparation(request) - verify(paykitPaymentProofRepo, never()).completeOnchainPayment(any(), any(), any()) + verify(paykitPaymentProofRepo, never()).completeOnchainPayment(any(), any(), any(), eq("bitkit")) } @Test @@ -7402,7 +7470,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { )) { clearInvocations(paykitPaymentProofRepo, privatePaykitRepo) val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) @@ -7442,7 +7510,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `uncertain onchain failure resolves the matching pending payment`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) pubkyPublicKey.value = testPublicKey runCurrent() balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) @@ -7528,6 +7596,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `unrelated uncertain onchain resolution does not hijack another send`() = test { val request = paymentRequest() + val privateContext = PrivatePaykitPaymentContext(mapOf(MethodId.P2wpkh.rawValue to "bitkit"), 7uL) pubkyPublicKey.value = testPublicKey runCurrent() val replacementRequest = paymentRequest().copy(paymentRequestId = "replacement-request") @@ -7538,7 +7607,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sats = request.amountSats, result = Result.failure(IllegalStateException("outcome unknown")), ) - setActiveContactPaymentContext(testPublicKey, incomingPaymentRequest = request) + whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) + .thenReturn(Result.success(Unit)) + setActiveContactPaymentContext(testPublicKey, privateContext, request) setSendState( SendUiState( address = "bcrt1quncertainreplacement", @@ -7583,7 +7654,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `post broadcast bookkeeping failure still completes payment proof`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) @@ -7607,7 +7678,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() - verify(paykitPaymentProofRepo).completeOnchainPayment(request, "broadcast-txid", MethodId.P2wpkh.rawValue) + verify( + paykitPaymentProofRepo + ).completeOnchainPayment(request, "broadcast-txid", MethodId.P2wpkh.rawValue, "bitkit") verify(paykitPaymentProofRepo, never()).failOnchainPayment(any()) verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) } @@ -7616,7 +7689,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `incoming payment request is not accepted when private list consumption fails`() = test { val address = "bcrt1qpaymentrequest" val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.failure(IllegalStateException("Payment list already consumed"))) @@ -7653,7 +7726,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `incoming payment request releases private details when acceptance fails`() = test { val address = "bcrt1qpaymentrequest" val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) @@ -7693,7 +7766,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `incoming payment request rejects mismatched fixed invoice amount before acceptance`() = test { val request = paymentRequest() val bolt11 = "lnbcrt1mismatchedrequest" - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) setActiveContactPaymentContext(testPublicKey, privateContext, request) setSendState( SendUiState( @@ -7718,7 +7791,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `incoming payment request rejects changed onchain amount before acceptance`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) setActiveContactPaymentContext(testPublicKey, privateContext, request) setSendState( SendUiState( @@ -7753,7 +7826,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `incoming payment request rejects changed amount for amountless invoice`() = test { val request = paymentRequest() val bolt11 = "lnbcrt1changedrequest" - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) setActiveContactPaymentContext(testPublicKey, privateContext, request) setSendState( SendUiState( @@ -7776,7 +7849,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `expired incoming payment request is not submitted`() = test { val address = "bcrt1qexpiredrequest" val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) whenever(paykitPaymentRequestRepo.isPending(request)).thenReturn(false) setActiveContactPaymentContext(testPublicKey, privateContext, request) setSendState( @@ -7830,7 +7903,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val bolt11 = "lnbcrt1privatecontact" val paymentHash = "payment_hash" val contactKey = "pubkycontact" - val privateContext = PrivatePaykitPaymentContext("bitkit/wallet", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) whenever(lightningRepo.payInvoice(bolt11 = bolt11, sats = null)).thenReturn(Result.success(paymentHash)) whenever(privatePaykitRepo.consumePrivatePaymentList(contactKey, privateContext)) @@ -7865,7 +7938,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val bolt11 = "lnbcrt1pending" val paymentHash = "pending_hash" val contactKey = "pubkycontact" - val privateContext = PrivatePaykitPaymentContext("bitkit/wallet", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) whenever(lightningRepo.payInvoice(bolt11 = bolt11, sats = null)) .thenReturn(Result.failure(PaymentPendingException(paymentHash))) @@ -7891,7 +7964,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `private lightning duplicate payment consumes private list`() = test { val bolt11 = "lnbcrt1duplicate" val contactKey = "pubkycontact" - val privateContext = PrivatePaykitPaymentContext("bitkit/wallet", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) whenever(lightningRepo.payInvoice(bolt11 = bolt11, sats = null)) .thenReturn(Result.failure(AppError("DuplicatePayment"))) @@ -8234,7 +8307,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pubkyPublicKey.value = testPublicKey advanceUntilIdle() - verify(publicPaykitRepo).syncLocalReceiverMarker() + verify(publicPaykitRepo).syncPaykitApp() verify(privatePaykitRepo, never()).prepareSavedContacts(any>(), any()) verify(privatePaykitRepo, never()).pruneUnsavedContactState(any>()) @@ -8316,7 +8389,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) - verify(publicPaykitRepo, never()).syncLocalReceiverMarker() + verify(publicPaykitRepo, never()).syncPaykitApp() assertFalse(settingsData.value.publicPaykitCleanupPending) verify(privatePaykitRepo).retryPendingEndpointRemoval(emptyList()) } @@ -8330,7 +8403,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.refreshPrivatePaykitEndpoints() advanceUntilIdle() - verify(publicPaykitRepo).syncLocalReceiverMarker() + verify(publicPaykitRepo).syncPaykitApp() } private suspend fun TestScope.confirmCurrentPayment() { @@ -8463,7 +8536,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { paymentRequest: String, privateListIndex: ULong = 7uL, ): PrivatePaykitPaymentContext { - val privateContext = PrivatePaykitPaymentContext("bitkit/server", privateListIndex) + val privateContext = privatePaymentContext(privateListIndex) whenever { privatePaykitRepo.beginPaymentRequest(request) }.thenReturn( Result.success( PublicPaykitPaymentResult.Opened( @@ -8674,10 +8747,18 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fundingBalanceSats = fundingBalanceSats, ) + private fun privatePaymentContext(version: ULong?) = PrivatePaykitPaymentContext( + paymentAppsByEndpoint = mapOf( + MethodId.P2wpkh.rawValue to "bitkit", + MethodId.Bolt11.rawValue to "bitkit", + MethodId.Lnurl.rawValue to "bitkit", + ), + paymentListVersion = version, + ) + private fun paymentRequest() = PaykitPaymentRequest( paymentRequestId = "request-id", counterparty = testPublicKey, - counterpartyReceiverPath = "bitkit/server", amountValue = "0.000025", amountSats = 2_500uL, expiresAt = null, @@ -8687,7 +8768,6 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private fun subscriptionStartingAt(startsAt: Instant) = PaykitSubscription( paymentRequestId = "subscription-id", counterparty = testPublicKey, - counterpartyReceiverPath = "bitkit/server", amountValue = "0.000025", amountSats = 2_500uL, note = "Weekly coffee", diff --git a/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt index a835d447d2..0ec7057cdf 100644 --- a/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt @@ -79,7 +79,7 @@ class SettingsViewModelTest : BaseUnitTest() { whenever(pubkyRepo.isAuthenticated).thenReturn(MutableStateFlow(false)) whenever(pubkyRepo.contacts).thenReturn(contacts) whenever { publicPaykitRepo.syncPublishedEndpoints(publish = false) }.thenReturn(Result.success(Unit)) - whenever { publicPaykitRepo.syncLocalReceiverMarker(anyOrNull(), anyOrNull()) }.thenReturn(Result.success(Unit)) + whenever { publicPaykitRepo.syncPaykitApp(anyOrNull()) }.thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>()) } .thenReturn(Result.success(Unit)) @@ -144,7 +144,7 @@ class SettingsViewModelTest : BaseUnitTest() { assertFalse(settingsData.value.publicPaykitCleanupPending) verify(publicPaykitRepo, never()).syncPublishedEndpoints(publish = false) - verify(publicPaykitRepo).syncLocalReceiverMarker(publicSharingEnabled = false, privateSharingEnabled = false) + verify(publicPaykitRepo).syncPaykitApp(privateSharingEnabled = false) verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(contacts.value.map { it.publicKey }) } @@ -152,8 +152,7 @@ class SettingsViewModelTest : BaseUnitTest() { fun `disabling Paykit with private-only state keeps cleanup pending when marker removal fails`() = test { clearInvocations(publicPaykitRepo) whenever { - publicPaykitRepo.syncLocalReceiverMarker( - publicSharingEnabled = false, + publicPaykitRepo.syncPaykitApp( privateSharingEnabled = false, ) } @@ -168,7 +167,7 @@ class SettingsViewModelTest : BaseUnitTest() { assertTrue(settingsData.value.publicPaykitCleanupPending) verify(publicPaykitRepo, never()).syncPublishedEndpoints(publish = false) - verify(publicPaykitRepo).syncLocalReceiverMarker(publicSharingEnabled = false, privateSharingEnabled = false) + verify(publicPaykitRepo).syncPaykitApp(privateSharingEnabled = false) verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(contacts.value.map { it.publicKey }) } diff --git a/app/src/test/resources/bitkit-combined-claim-v1.json b/app/src/test/resources/bitkit-combined-claim-v1.json new file mode 100644 index 0000000000..8fd50f23fb --- /dev/null +++ b/app/src/test/resources/bitkit-combined-claim-v1.json @@ -0,0 +1,10 @@ +{ + "query_parameter": "x-bitkit-claim", + "claim_type": "paykit-access-v1.watch-only-account-v1", + "capabilities": "/pub/paykit/:rw", + "account_index": 16909060, + "key_generation": 72623859790382856, + "serialized_xpub_hex": "090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909", + "paykit_secret_hex": "0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b", + "unsigned_payload_hex": "01010203040009090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090901020304050607080b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b" +} diff --git a/changelog.d/next/paykit-shared-runtime.changed.md b/changelog.d/next/paykit-shared-runtime.changed.md new file mode 100644 index 0000000000..3299757cb7 --- /dev/null +++ b/changelog.d/next/paykit-shared-runtime.changed.md @@ -0,0 +1 @@ +Share Paykit contacts and payment state with authorized apps while keeping wallet keys private, and label received payments with their Paykit payer contact. diff --git a/docs/paykit-issuer-interoperability.md b/docs/paykit-issuer-interoperability.md index 1f30e14df5..6b38bb5e25 100644 --- a/docs/paykit-issuer-interoperability.md +++ b/docs/paykit-issuer-interoperability.md @@ -74,7 +74,7 @@ After this shape check, Bitkit validates that the value is usable: an on-chain a ## Delivery prerequisites -The issuer and wallet must be linked Paykit peers on the same receiver path before Bitkit polls the request. The issuer must advertise a usable endpoint for at least one identifier retained from the request. A request that fails the request gate is not presented; a request whose endpoint cannot be resolved is deferred until usable payment details arrive. +The issuer and wallet identities must be linked Paykit peers before Bitkit polls the request. Requests may require a specific payment App; Bitkit uses the SDK's request-specific resolver and preserves the selected App in its proof. The issuer can include exact `paymentEndpoints` in the Payment Request or supply current endpoints resolved for the requested App. At least one endpoint must be usable for an identifier retained from the request; embedded endpoints do not require separate advertisement. A request that fails the request gate is not presented; a request whose endpoint cannot be resolved is deferred until usable payment details arrive. ## Contract fixtures diff --git a/docs/pubky-auth-companion-claims.md b/docs/pubky-auth-companion-claims.md new file mode 100644 index 0000000000..6a724d6685 --- /dev/null +++ b/docs/pubky-auth-companion-claims.md @@ -0,0 +1,60 @@ +# Bitkit Pubky Auth companion claims + +Bitkit can authorize a Pubky session and share Paykit access, a watch-only Bitcoin account, or both. The request explicitly selects the material to share; homeserver write permissions alone never imply key export. + +## Request + +- The Pubky Auth URL includes one `x-bitkit-claim` query parameter containing a dot-separated list of independent items: `paykit-access-v1` and `watch-only-account-v1`. Each item requests only its corresponding permission and material. +- Request builders emit Paykit first when requesting both: `x-bitkit-claim=paykit-access-v1.watch-only-account-v1`. Paykit Server requests both items for initial setup and only `paykit-access-v1` for reconnect. +- Either item order is accepted. Bitkit preserves the exact received list string as the SDK's `claim_type`; it must not be reordered before signing or relay delivery. +- The capability is `/pub/paykit/:rw`. +- Empty, unknown, or duplicate items, mismatched selections, and duplicate companion query parameters are rejected. Ordinary Pubky Auth without a companion claim shares only the session, not a Paykit access key or watch-only account. +- Explicit watch-only approval creates a fresh native-SegWit account. Account indexes begin at `1`, increase monotonically, and are never recycled. Retrying the same logical auth request reuses its incomplete account even if query parameters are reordered. +- Bitkit automatically names the account from the requesting service. The user can rename it later. The local name is not disclosed in the claim. +- Paykit-only reconnect leaves local watch-only accounts unchanged. The server retains its existing xpub, account index, and allocation state without requesting the account again; it binds the reconnect to the expected authenticated Pubky identity. +- Paykit-only approval neither allocates nor loads a Bitcoin account. It opens authorization directly, explaining private Paykit data and messages without watch-only or content-earning UI. + +## Claim payload + +The watch-only unsigned payload is exactly 84 bytes: + +| Offset | Size | Value | +| --- | ---: | --- | +| 0 | 1 | Claim version, `0x01` | +| 1 | 4 | BIP account index, unsigned big-endian | +| 5 | 1 | Address type, `0x00` for native SegWit | +| 6 | 78 | Base58Check-decoded extended public key, including its 4-byte version | + +The Paykit-only unsigned payload is exactly 41 bytes: version `1`, an 8-byte nonzero unsigned big-endian key generation, and the 32-byte Paykit access key. Requesting both items produces exactly 124 bytes: the 84-byte watch-only payload followed by that generation and key, without another version byte. This payload order is fixed regardless of the requested item order. + +Bitkit passes the exact item list as `claim_type` and the payload to Paykit's `approveAuthWithCompanionClaim` API. Paykit appends a 64-byte Ed25519 signature, encrypts the signed claim, delivers it on the companion relay channel, and only then approves normal Pubky Auth. The signed sizes are respectively 148, 105, and 188 bytes. + +For requests including Paykit access, Bitkit derives the Paykit secret from its local or shared Pubky identity secret and the current App Registry generation. A locally recorded generation prevents rollback. The recipient derives the separate Noise and shared-state encryption keys from the delegated secret. Neither the Pubky root secret nor Bitcoin spending keys are shared. Watch-only requests do not derive or send a Paykit secret. + +The signature binds the UTF-8 prefix `x-bitkit-claim|`, the exact received item list and trailing `|`, the SHA256 digest of the decoded auth request secret, and the complete unsigned claim bytes, concatenated in that order. Changing the list order changes both the signature domain and relay channel even though the unsigned payload is identical. Each selection requires its exact payload length: 84 bytes for watch-only, 41 bytes for Paykit access, and 124 bytes for both. + +`decoded_auth_request_secret` is the raw 32-byte value produced by base64url-no-pad decoding the URL's `secret` parameter, not UTF-8 text. + +The server verifies the signature with the creator's Pubky Ed25519 public key from the authenticated session. Binding the signature to the request secret prevents a valid signed claim from being moved to a different request; possession of the relay secret alone is insufficient to substitute an attacker's xpub. + +## Delivery and lifecycle + +- The normal AuthToken channel is `base_relay/{base64url_no_pad(BLAKE3(secret))}`. +- The companion channel is `base_relay/{base64url_no_pad(BLAKE3(UTF8(claim_type || "|") || secret))}`. +- Paykit encrypts the complete signed claim on the companion channel with the auth request secret using XSalsa20-Poly1305. The SDK owns transport and cryptography. +- Paykit delivers the claim before approving the normal Pubky Auth token, avoiding a session that was authorized without its required account claim. +- Bitkit persists the account before delivery and reuses the same account index and unsigned xpub payload when retrying an incomplete setup. Each attempt may create new encrypted relay messages; delivery is not guaranteed exactly once. +- Bitkit durably marks and loads a new incomplete account as authorizing before calling Paykit. Successful approval marks it active and leaves tracking enabled. An initial preparation or companion-delivery failure returns it to pending and unloads it again. +- Account registration and address revelation finish before authorization. LDK's periodic sync fetches transaction history; a full wallet sync is not a prerequisite for delivering the authorization. +- If Paykit reports that companion delivery succeeded but normal AuthToken delivery failed, Bitkit leaves the account authorizing and tracked. The same conservative state is retained if local activation persistence fails after Paykit returns success. Retrying reruns the combined Paykit approval with the same account and xpub payload; retry failures keep the account tracked so Bitkit does not lose visibility into addresses the server may already have derived. +- Disabling tracking unloads the account from LDK Node at runtime. It does not delete persisted wallet state, the xpub, or the server session. +- Enabled active or authorizing accounts are configured before LDK Node starts. Electrum full scans use a batch size of `100` and stop gap of `1000`. +- Bitkit pre-reveals external receive indexes `0...999` for each tracked account. LDK then maintains a rolling stop-gap window: the first address with transaction history must be at or below index `999`, and after activity at index `n`, the next active index must be at or below `n + 1000` so there are never `1000` consecutive inactive addresses. +- On startup and before app-driven sync, Bitkit reconciles persisted account state with LDK and restores the pre-revealed range. Accounts removed by a backup remain scheduled for unload until reconciliation succeeds, allowing transient failures to retry safely. +- Account metadata and monotonic allocation state are included in the existing encrypted wallet backup and use the same JSON field names on iOS and Android. + +## Shared fixtures and consent + +The canonical server fixture `bitkit-combined-claim-v1.json`, also included in Android test resources, defines the combined wire layout and exact bytes. + +The shared UI identifiers are `PubkyAuthPaykitAccess` for private Paykit consent and `PubkyAuthWatchOnlyConsent` for the original wallet introduction. Requested Paykit access appears only on final authorization. Journey specifications cover visible consent and cancellation separately from fixture-backed delivery and Paykit-only reconnect. diff --git a/docs/pubky.md b/docs/pubky.md index 12b81d2ebe..75be389dca 100644 --- a/docs/pubky.md +++ b/docs/pubky.md @@ -27,11 +27,22 @@ Delegates Pubky operations to `PaykitSdkService`, which uses: - **paykit-ffi** (`com.synonym:paykit-android`) — session management, auth approval, profile/contact resolution, and bounded file fetching - `fetchPubkyProfile()`, `fetchPubkyFollows()`, `resolveContactProfile()`, `fetchPubkyFileBounded()` -- **bitkit-core** (`com.synonym:bitkit-core-android`) — mnemonic-to-seed conversion for receiver noise-key derivation - - `mnemonicToSeed()` + +Paykit derives the delegated Paykit key from the active Pubky identity secret and the App Registry's current key generation. Authorized apps share encrypted Pubky-hosted Paykit state; Bitkit retains wallet-owned address reservations and pending payment proofs locally. + +The Android dependency is `com.synonym:paykit-android:0.1.0-rc57` from GitHub Packages. Paykit is excluded from Maven-local resolution. Companion authorization and key-sharing consent are described in [Pubky Auth companion claims](pubky-auth-companion-claims.md). All calls are dispatched on `ServiceQueue.CORE` (single-thread executor) to ensure serial access to the underlying Rust state. +### Received Payment Attribution + +Shared Payment Requests can identify a received payment's payer even when another authorized app +created the request. Attribution uses the request's immutable, accepted Bitcoin destinations, not +current contact endpoints or unverified payment proofs. Bitkit validates the network, checks every +known transaction output or the received Lightning payment hash, and leaves ambiguous matches +unlabelled. A successful shared-state refresh backfills only incoming activity with no contact; +existing labels and notes remain unchanged. Snapshots are scoped to the active Pubky identity. + ## Repository Layer (`PubkyRepo`) Manages session lifecycle, identity adoption, and profile data. Singleton scoped. diff --git a/docs/watch-only-account-claim-v1.md b/docs/watch-only-account-claim-v1.md deleted file mode 100644 index 2d9e9446ac..0000000000 --- a/docs/watch-only-account-claim-v1.md +++ /dev/null @@ -1,52 +0,0 @@ -# Bitkit watch-only account claim v1 - -This document records the client contract implemented by Bitkit iOS and Android for Paykit Server setup requests. - -## Request - -- The Pubky Auth URL includes `x-bitkit-claim=watch-only-account-v1`. -- The exact capabilities are `/pub/paykit/v0/bitkit/server/:rw` and `/pub/paykit/v0/private/bitkit/server/:rw`. -- Missing, unknown, mismatched, or duplicate companion-claim parameters are rejected. -- Every distinct auth request creates a fresh native-SegWit account, beginning at BIP84 account index `1`. Account indexes increase monotonically and are never reused. Retrying the same logical auth request reuses its incomplete account even if query parameters are reordered. -- Bitkit automatically names the account from the requesting service. The user can rename it later. The local name is not disclosed in the claim. - -## Claim payload - -Bitkit serializes this exact 84-byte unsigned payload: - -| Offset | Size | Value | -| --- | ---: | --- | -| 0 | 1 | Claim version, `0x01` | -| 1 | 4 | BIP account index, unsigned big-endian | -| 5 | 1 | Address type, `0x00` for native SegWit | -| 6 | 78 | Base58Check-decoded extended public key, including its 4-byte version | - -Bitkit passes the payload to Paykit's `approveAuthWithCompanionClaim` API. Paykit appends a 64-byte Ed25519 signature, encrypts the resulting 148-byte claim, delivers it on the companion relay channel, and only then approves normal Pubky Auth. - -The signature input is the byte concatenation: - -```text -UTF8("x-bitkit-claim|watch-only-account-v1|") -|| SHA256(decoded_auth_request_secret) -|| claim_bytes[0..<84] -``` - -`decoded_auth_request_secret` is the raw 32-byte value produced by base64url-no-pad decoding the URL's `secret` parameter, not UTF-8 text. - -The server verifies the signature with the creator's Pubky Ed25519 public key from the authenticated session. Binding the signature to the request secret prevents a valid signed claim from being moved to a different request; possession of the relay secret alone is insufficient to substitute an attacker's xpub. - -## Delivery and lifecycle - -- The normal AuthToken channel is `base_relay/{base64url_no_pad(BLAKE3(secret))}`. -- The companion channel is `base_relay/{base64url_no_pad(BLAKE3(ASCII("watch-only-account-v1|") || secret))}`. -- Paykit encrypts the complete 148-byte signed claim on the companion channel with the auth request secret using XSalsa20-Poly1305. -- Paykit delivers the claim before approving the normal Pubky Auth token, avoiding a session that was authorized without its required account claim. -- Bitkit persists the account before delivery and reuses the same account index and unsigned xpub payload when retrying an incomplete setup. Each attempt may create new encrypted relay messages; delivery is not guaranteed exactly once. -- Bitkit durably marks and loads an incomplete account as authorizing before calling Paykit. Successful combined approval marks it active and leaves tracking enabled. An initial preparation or companion-delivery failure returns it to pending and unloads it again. -- Account registration and address revelation finish before authorization. LDK's periodic sync fetches transaction history; a full wallet sync is not a prerequisite for delivering the authorization. -- If Paykit reports that companion delivery succeeded but normal AuthToken delivery failed, Bitkit leaves the account authorizing and tracked. The same conservative state is retained if local activation persistence fails after Paykit returns success. Retrying reruns the combined Paykit approval with the same account and xpub payload; retry failures keep the account tracked so Bitkit does not lose visibility into addresses the server may already have derived. -- Disabling tracking unloads the account from LDK Node at runtime. It does not delete persisted wallet state, the xpub, or the server session. -- Enabled active or authorizing accounts are configured before LDK Node starts. Electrum full scans use a batch size of `100` and stop gap of `1000`. -- Bitkit pre-reveals external receive indexes `0...999` for each tracked account. LDK then maintains a rolling stop-gap window: the first address with transaction history must be at or below index `999`, and after activity at index `n`, the next active index must be at or below `n + 1000` so there are never `1000` consecutive inactive addresses. -- On startup and before app-driven sync, Bitkit reconciles persisted account state with LDK and restores the pre-revealed range. Accounts removed by a backup remain scheduled for unload until reconciliation succeeds, allowing transient failures to retry safely. -- Account metadata and monotonic allocation state are included in the existing encrypted wallet backup and use the same JSON field names on iOS and Android. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c3a4f08a16..f1edd44eb0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,7 +22,7 @@ appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.5.18" } -paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc56" } +paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc58" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } diff --git a/journeys/README.md b/journeys/README.md index 8c24ba27d6..f53c2d0005 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -127,7 +127,7 @@ journey PR, which is what made this file conflict on every merge. | A hardware wallet to pair, watch and sign with | the deterministic Trezor emulator from `bitkit-docker` over the Bridge transport, with the USB attach intent injected by `adb`; USB enumeration, permission grants, the OS picker and BLE are not simulated — [hardware-wallet](hardware-wallet/README.md) | | Push notifications to a backgrounded or killed app | an FCM push from a CJIT order paid through `./lsp`, read back with `adb shell dumpsys notification` — [cjit-notifications](cjit-notifications/README.md) | | The OS notification-permission dialog | an API 33+ target, reset with `adb shell pm revoke to.bitkit.dev android.permission.POST_NOTIFICATIONS` — [notification-permission](notification-permission/README.md) | -| An incoming Payment Request from a linked issuer | the fixture issuer, saved as a contact and linked on receiver path `bitkit/server` — [payment-requests](payment-requests/README.md) | +| An incoming Payment Request from a linked issuer | the fixture issuer, saved as a contact and linked as identities — [payment-requests](payment-requests/README.md) | | Two linked Bitkit wallets for a subscription lifecycle | a second Bitkit instance linked to the first, so a proposal can be reviewed and accepted — [subscriptions](subscriptions) | | A Pubky identity and a two-wallet marketplace purchase | the integration fixture runtime: Pubky testnet, Paykit Server, regtest bitcoind and Fulcrum — [pubky-marketplace](pubky-marketplace/README.md) | | LNURL pay, withdraw, channel and auth, and Lightning Addresses | the `bitkit-docker` `lnurl-server` on local regtest, with the app started by `just run docker`, which builds with `E2E=true` and forwards its ports over `adb reverse`; it issues memo invoices, so a check that needs a description-hash invoice needs another endpoint — [lnurl](lnurl) | diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index 52cd3b856f..aba9ebf3e3 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -6,7 +6,7 @@ Cover incoming Paykit Payment Requests from a linked issuer. The issuer contract ## Setup -Run Bitkit against regtest with Paykit UI enabled. Authenticate a Pubky identity, save the fixture issuer as a contact, link it on receiver path `bitkit/server`, and give the wallet enough on-chain balance to pay 100,000 sats. The fixture issuer must be able to publish a Paykit endpoint and send a one-time Payment Request to that linked peer. The `bitkit/server` path belongs to the third-party fixture issuer; use `bitkit/wallet` when another Bitkit instance is the issuer. +Run Bitkit against regtest with Paykit UI enabled. Authenticate a Pubky identity, save and link the fixture issuer as a contact, and give the wallet enough on-chain balance to pay 100,000 sats. The fixture issuer must be able to publish a Paykit endpoint and send a one-time Payment Request to that linked peer. Its App ID is `paykit-server`; Bitkit uses `bitkit`. The accepted journey uses: @@ -18,7 +18,7 @@ The accepted journey uses: Rejected fixture shapes stay in unit tests because Bitkit intentionally does not present requests that fail the contract gate. -`request-summary.xml` uses a second Bitkit instance as the requester instead of the fixture issuer: both instances are authenticated Pubky identities, saved as each other's contacts and linked on receiver path `bitkit/wallet`, and the payer holds enough balance to pay 21,000 sats. +`request-summary.xml` uses a second Bitkit instance as the requester instead of the fixture issuer: both instances are authenticated Pubky identities, saved as each other's contacts and linked, and the payer holds enough balance to pay 21,000 sats. `contact-request-or-pay.xml` uses the same two-instance setup and starts from the payer's Contact Detail screen, opened through the `bitkit://contact` deeplink. Its timing step assumes the payer has been running for about a minute: right after launch, the Paykit session restore and link refresh hold the SDK and can push the Pay step well past the budget. @@ -59,10 +59,10 @@ That run established the issuer shapes captured by the fixture: lowercase `btc`, ## Payment deadline history -`payment-deadline-history.xml` covers rc56 requests with actual-payment deadlines. +`payment-deadline-history.xml` covers requests with actual-payment deadlines. Bitkit keeps their lifecycle and paid-period history, and subscription cancellation, but does not accept them, offer payments, or schedule payment reminders. The journey -requires a controlled rc56 peer to prepare the accepted and paid records; repository +requires a controlled shared-runtime peer to prepare the accepted and paid records; repository tests cover these states without sending funds. On Android, unpaid history rows show lifecycle labels, while paid rows show subscription names, notes, or dates. Active subscriptions are opened from Overview. The journeys record each fixture's payment diff --git a/journeys/payment-requests/contact-request-or-pay.xml b/journeys/payment-requests/contact-request-or-pay.xml index 575a09769f..11d800076a 100644 --- a/journeys/payment-requests/contact-request-or-pay.xml +++ b/journeys/payment-requests/contact-request-or-pay.xml @@ -1,6 +1,6 @@ - Verifies that Pay on a linked contact offers Request or Pay, that paying shows progress on the sheet until the amount screen opens within a few seconds, and that Request opens the Payment Request amount screen. Requires two Bitkit instances saved as each other's contacts and linked on receiver path "bitkit/wallet", with the payer funded; see README.md. Let the payer run for a minute after launch before starting, so start-up Paykit work does not dominate the timings. Open the contact with adb shell am start -a android.intent.action.VIEW -d "bitkit://contact?pubky=<requester-public-key>" to.bitkit.dev. + Verifies that Pay on a linked contact offers Request or Pay, that paying shows progress on the sheet until the amount screen opens within a few seconds, and that Request opens the Payment Request amount screen. Requires two Bitkit instances saved as each other's contacts and linked as identities, with the payer funded; see README.md. Let the payer run for a minute after launch before starting, so start-up Paykit work does not dominate the timings. Open the contact with adb shell am start -a android.intent.action.VIEW -d "bitkit://contact?pubky=<requester-public-key>" to.bitkit.dev. Open bitkit://contact?pubky=<requester-public-key> on the payer diff --git a/journeys/payment-requests/delete-and-readd-contact.xml b/journeys/payment-requests/delete-and-readd-contact.xml index e740907019..9c03487386 100644 --- a/journeys/payment-requests/delete-and-readd-contact.xml +++ b/journeys/payment-requests/delete-and-readd-contact.xml @@ -1,6 +1,6 @@ - Verifies that deleting a contact stops incoming private Payment Requests until the user explicitly adds the contact again. Requires two authenticated Bitkit instances saved as each other's contacts and linked on receiver path "bitkit/wallet". The payer must have no active subscription with the requester. No payment needs to be sent. + Verifies that deleting a contact stops incoming private Payment Requests until the user explicitly adds the contact again. Requires two authenticated Bitkit instances saved as each other's contacts and linked as identities. The payer must have no active subscription with the requester. No payment needs to be sent. On the requester, send the payer contact a Payment Request for 5,000 sats with the note "Before deletion" diff --git a/journeys/payment-requests/issuer-interoperability.xml b/journeys/payment-requests/issuer-interoperability.xml index bfe6b3ad8a..bd1e42760f 100644 --- a/journeys/payment-requests/issuer-interoperability.xml +++ b/journeys/payment-requests/issuer-interoperability.xml @@ -1,9 +1,9 @@ - Verifies that the canonical accepted regtest issuer fixture reaches Bitkit and opens the payment confirmation flow. Requires the saved and linked server fixture plus the funded regtest wallet described in README.md. The canonical fixture uses "bitkit/server"; a Bitkit app acting as issuer uses its negotiated "bitkit/wallet" path instead. Rejected shapes are covered by the shared fixture unit tests because Bitkit intentionally does not present them. + Verifies that the canonical accepted regtest issuer fixture reaches Bitkit and opens the payment confirmation flow. Requires the saved and linked server fixture plus the funded regtest wallet described in README.md. The fixture uses App ID "paykit-server"; Bitkit uses "bitkit". Rejected shapes are covered by the shared fixture unit tests because Bitkit intentionally does not present them. - Launch the E2E Bitkit app with Paykit UI enabled and the fixture issuer saved as a contact and linked on receiver path "bitkit/server" + Launch the E2E Bitkit app with Paykit UI enabled and the fixture issuer saved as a contact and linked as identities Have the issuer publish a current regtest P2WPKH address under identifier "btc-regtest-p2wpkh" with JSON payload {"value":"<current address>"} Have the issuer send proposed one-time Payment Request "71300000-0000-4000-8000-000000000001" for amount "0.001", asset "btc", and accepted identifier "btc-regtest-p2wpkh" Verify the Payment Request confirmation screen (testTag "PaymentRequestConfirm") appears with 100,000 sats diff --git a/journeys/payment-requests/payment-deadline-history.xml b/journeys/payment-requests/payment-deadline-history.xml index 1ba8b15b3d..ad08b9421b 100644 --- a/journeys/payment-requests/payment-deadline-history.xml +++ b/journeys/payment-requests/payment-deadline-history.xml @@ -1,7 +1,7 @@ Requests with actual-payment deadlines are visible but cannot be paid by this version of Bitkit. - Use a disposable Paykit test identity linked to a controlled rc56 peer. Prepare one-time BTC + Use a disposable Paykit test identity linked to a controlled shared-runtime peer. Prepare one-time BTC requests with deadlines in proposed, accepted, rejected, canceled and proof-submitted states, plus an incoming, accepted monthly BTC subscription with no end date, a period-start deadline, one paid period and one unpaid period. Acceptance and proof submission must be prepared through the controlled diff --git a/journeys/payment-requests/request-summary.xml b/journeys/payment-requests/request-summary.xml index 8259e8b5a7..220577ae34 100644 --- a/journeys/payment-requests/request-summary.xml +++ b/journeys/payment-requests/request-summary.xml @@ -1,6 +1,6 @@ - Verifies that the collapsed Payment Request confirmation shows who the request is from and what it is for, keeps the note in the details, and shows "Not specified" in For when the request has no note. Requires two Bitkit instances saved as each other's contacts and linked on receiver path "bitkit/wallet", with the payer funded to cover 21,000 sats; see README.md. + Verifies that the collapsed Payment Request confirmation shows who the request is from and what it is for, keeps the note in the details, and shows "Not specified" in For when the request has no note. Requires two Bitkit instances saved as each other's contacts and linked as identities, with the payer funded to cover 21,000 sats; see README.md. On the requester, send the payer contact a Payment Request for 21,000 sats with the note "Lunch last week" diff --git a/journeys/pubky-marketplace/README.md b/journeys/pubky-marketplace/README.md index 60e837a9ac..bbcdbffd41 100644 --- a/journeys/pubky-marketplace/README.md +++ b/journeys/pubky-marketplace/README.md @@ -5,6 +5,23 @@ watch-only account claim, a linked buyer receives the resulting Payment Request, the request on regtest through confirmation. It does not cover marketplace browsing, Locks content delivery, fiat payment, or Hypercolor. +## Companion consent and reconnect + +- `paykit-only-approval.xml` checks Paykit-only consent and cancellation without account creation. +- `paykit-reconnect.xml` checks Paykit-only reconnect consent and cancellation without changing the existing service account. + +These visible consent and cancel checks require valid auth URL fixtures but do not authorize a +network session. The reconnect journey additionally needs a known active, tracked account in the +isolated current wallet. Do not manufacture that fixture from real wallet data. + +With a live fixture, also approve each request: verify Paykit-only delivers exactly 41 unsigned +bytes and does not change account allocation or tracking. Initial combined setup delivers +124 unsigned bytes and creates a new account. Paykit-only reconnect delivers 41 unsigned bytes +with a nondecreasing Paykit generation; the server retains its xpub, account index, and allocation state. +Repeat reconnect with a rejected authorization and a cancelled local authentication prompt; +neither may change or unload the existing account. Watch-only requests deliver exactly 84 unsigned +bytes and no Paykit secret. Unknown, duplicate, mismatched, or wrong-sized claims must fail closed. + ## Required integration fixture runtime The journey needs a controlled integration fixture runtime. It must provide: @@ -12,7 +29,7 @@ The journey needs a controlled integration fixture runtime. It must provide: - A fresh Pubky testnet or isolated staging namespace reachable by both wallets. - A Paykit Server including the canonical request behavior from merged upstream [`pubky/paykit-server#2`](https://github.com/pubky/paykit-server/pull/2), plus a `/setup` flow whose - auth URL carries `x-bitkit-claim=watch-only-account-v1`. + auth URL carries `x-bitkit-claim=paykit-access-v1.watch-only-account-v1`. - A regtest bitcoind and Electrum/Fulcrum endpoint on the same chain. Configure the endpoint in both wallets before their first launch so neither wallet retains a taller foreign regtest tip. - A clean seller wallet, a separate clean funded buyer wallet, and the seller Pubky public key. @@ -33,8 +50,9 @@ adb -s reverse tcp:15412 tcp:15412 ``` After creating each wallet, choose **Create profile with Bitkit** to create a Bitkit-generated Pubky -identity in each wallet. Do not import the identity with Pubky Ring; an imported identity cannot -approve the fixture setup auth URL. +identity in each wallet, or use a Ring identity whose root secret is available to Bitkit. +The fixture must use the same shared-runtime SDK and the +[companion claim contract](../../docs/pubky-auth-companion-claims.md). The request and endpoint must satisfy the issuer contract from Android issue [#1208](https://github.com/synonymdev/bitkit-android/issues/1208): lowercase `btc`, a @@ -43,14 +61,12 @@ string `value`. The fixture must keep watch-only account material and spending a Evidence must show the claimed account xpub and account index while omitting wallet seed material and tokens. -Use the pinned +The pinned [`BitcoinErrorLog/pubky-marketplace/payments-env`](https://github.com/BitcoinErrorLog/pubky-marketplace/tree/ed03a32ecfe02deab40ad10ae1bac7fa18465c10/payments-env) -runtime as the marketplace driver and Locks harness. Fixture commit `ed03a32e` pins Paykit Server -source `867fc883` and verifies the canonical lowercase asset, network-correct endpoint identifier, -JSON value payload, and initial private-link retry behavior. Its `scripts/verify.sh` proves the -Locks, Paykit, Pubky, bitcoind, and Fulcrum protocol path. The seller wallet fills the -companion-auth role and the buyer wallet fills the reader role; the other fixture roles remain -unchanged. +runtime is a reference for the marketplace driver and Locks harness, not a shared-runtime +acceptance fixture. Use a fixture revision updated for the companion claim contract above and +record its exact revisions. The seller wallet fills the companion-auth role and the buyer wallet +fills the reader role; the other fixture roles remain unchanged. ## Required app changes @@ -90,8 +106,8 @@ Keep these artifacts at each boundary: | Boundary | Bitkit evidence | Fixture evidence | | --- | --- | --- | -| Watch-only claim | `PubkyAuthWatchOnlyConsent`, `PubkyAuthWatchOnlyApprove`, `PubkyAuthAuthorize`, and `PubkyAuthOK` snapshots | Setup completion and the claimed xpub/account index, with no spending key | -| Contact payments | `ContactPaymentsToggle` snapshots from both wallets | Public receiver markers for both wallet identities | +| Combined claim | `PubkyAuthWatchOnlyConsent`, `PubkyAuthPaykitAccess`, `PubkyAuthAuthorize`, and `PubkyAuthOK` snapshots | Setup completion and the claimed xpub/account index, with no spending key | +| Contact payments | `ContactPaymentsToggle` snapshots from both wallets | Public App Registry entries for both wallet identities | | Linked buyer | `Contact_` snapshot | Seller and buyer peer-link state | | Incoming request | `PaymentRequestsSheet` and `PaymentRequestRow-` snapshots showing seller, amount, and note when present | Delivery record and exact Payment Request id | | Payment approval | `PaymentRequestPay-`, `ReviewAmount`, and `ReviewContactRecipient` snapshots | Derived regtest address and expected amount | @@ -101,68 +117,3 @@ Keep these artifacts at each boundary: `SendSuccess` is evidence of backend acceptance, not confirmation. The fixture's chain and purchase status are the confirmation authority. `PaymentRequestPay-` is shared with the iOS counterpart supplied by [`bitkit-ios#721`](https://github.com/synonymdev/bitkit-ios/pull/721). - -## Release provenance - -The watch-only claim entered the repository in `6f3134e1`, and the incoming Payment Request surface -entered in `4ea3dd16` and `7385376d`. No shipped Android release or tag contains both surfaces as of -2026-09-02. The first intended shipped release is the open `2.6.0` milestone; record its final tag -here when it ships. - -## Acceptance run from 2026-09-02 - -The initial successful payment replay used an isolated runtime including upstream Paykit Server -merge `867fc883` and a pre-merge copy of the incoming-request swipe behavior now supplied by merged -[#1178](https://github.com/synonymdev/bitkit-android/pull/1178). The installed E2E APK had SHA-256 -`cb494d870a255b96e3e289cbe7e659aa89fff1987308502b2fd55f121a0b0c42`, used the local backend at -`10.0.2.2`, and targeted homeserver -`8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo`. A deployed seller completed the unchanged -watch-only consent, approval, authorization, companion-claim delivery, and `/setup` completion -path. The fixture verifier passed with lowercase `btc`, endpoint identifier -`btc-regtest-p2wpkh`, and a JSON string `value`. - -Fresh Android buyer `pubkycecq8ssqnfgfwifioj7djoutnupmpjgomobistnz34zd9d5yyn4o` linked seller -`pubkyhbn4tahj71yzpmtarz5amtqqf5fmicdd7rs8ao448tzaujdapfiy`; both wallets saved the reciprocal -contact and enabled contact payments. The buyer received 1,000,000 sats at -`bcrt1q6mkkp26tu8zm4g78d58uksmvv3una04dryp7s0` in transaction -`3b48b259cd9aec8817b902a44c1609738268880cb16f25179ab87dea21a81a11`, confirmed at height -16,397 in block `5ad00a6d1d9e0e5a2348a255eae5bc83d507a759a4e9f377567af92fa3bacef6`. - -The fixture delivered Locks bundle `1GC8SBDYB2HHA2E0NZ51ZVEZX4`, server invoice -`92fdee57-6a46-40f2-9714-8a0e68d7e60e`, Payment Request -`ad1a8463-59e0-4cf8-b037-99ffb9d5b6ca`, and event -`4282bad8-270d-4e5c-a5f9-bca52d1583dd`. Android displayed the incoming Seller row for 15,000 -sats with an absent note, opened the payment review, resolved -`bcrt1qkuajc36azmaf9kk9ndwy9rdttl6vdlqwtvgyg5`, preserved the amount and Seller recipient, and -enabled the confirmation slider with a 141-sat fee. - -One swipe broadcast transaction `a3e2801d8cf3afa6a461a30fa38bc46680602311a7728b97e5d88f3767f3d9d2`. -The frozen zero-confirmation boundary contained only that mempool transaction, whose output zero -paid exactly 15,000 sats to the derived address; Paykit reported -`detected/0/amount_matched=true` and Locks remained pending. The fixture then mined exactly one -block. Android synced height 16,398 and showed a confirmed Seller activity with a 15,000-sat -payment and 141-sat fee. The transaction confirmed in block -`5f2a356cace276a17e0759d8d34e7c196c852b4b299901e592552fb8f8f0bb19`, Paykit reported -`confirmed/1/amount_matched=true`, the Locks bundle completed, and the paid request remained as -history without Pay or Dismiss actions. - -The selector-specific acceptance replay used Android buyer `emulator-5560` and seller iOS simulator -`B379B7A4-715A-427F-8CB6-A6479BC73050`. It ran a pre-merge integration build containing the journey -selectors and the incoming-request swipe behavior now supplied by merged #1178. The built and -device-installed APKs both had SHA-256 -`8d62881da626a6f6eab1e243c05079305ebd3efd2f9abb820a1a6b55d6454cf4`. The retained Buyer profile -was synced at height 16,398 with 984,859 sats before the fixture created Locks bundle -`J9AKW3TNASDM6MJB0SNE08RJ0M`, server invoice `8d810705-6eeb-46f6-9c57-dd3bc4bdc0cf`, Payment -Request `b7f67854-b052-4eed-a6e7-e1ac41c31a7a`, event -`cec538cb-57f5-4c89-9d80-7584699af1ec`, and payment reference -`94f212c9-ed8c-4b85-9b0a-e9eea09f0a73`. - -Android exposed the exact `PaymentRequestRow-b7f67854-b052-4eed-a6e7-e1ac41c31a7a` and -`PaymentRequestPay-b7f67854-b052-4eed-a6e7-e1ac41c31a7a` selectors, then preserved the 15,000-sat -amount, Seller recipient, and 141-sat fee with an enabled confirmation slider. One swipe broadcast -transaction `3dacd7591e0e9d1b7eab62f814f86017c41c1a1b8dda839a79b7244d9b20f997`, whose output zero paid -exactly 15,000 sats to `bcrt1qxluk70usejytg7ehgdhpsq657eshhmr8lmkcsv`. The frozen -zero-confirmation boundary contained that single mempool transaction. The fixture mined exactly one -block, `7cabfa65673a0472d70c0b1f217e93d6114e040a342381a446f9a50987d18f30`, at height 16,399. Paykit -reported `confirmed/1/amount_matched=true`, the Locks bundle completed, Android showed the Seller -payment as confirmed, and the paid request remained in history without Pay or Dismiss actions. diff --git a/journeys/pubky-marketplace/paykit-only-approval.xml b/journeys/pubky-marketplace/paykit-only-approval.xml new file mode 100644 index 0000000000..340ee26eb4 --- /dev/null +++ b/journeys/pubky-marketplace/paykit-only-approval.xml @@ -0,0 +1,19 @@ + + + Verifies explicit Paykit-only consent and cancellation without allocating a Bitcoin account. + Precondition: an isolated wallet with a Bitkit-generated Pubky identity or an available Ring + root secret. The fixture supplies a valid fresh auth URL with exactly /pub/paykit/:rw and + x-bitkit-claim=paykit-access-v1. Consent and cancellation require no live authorization relay. + Successful delivery is a separate fixture-backed check described in the suite README. + + + Record the current wallet's watch-only account names, derivation paths, and tracking settings + Open the fixture Paykit-only auth URL in the wallet + Verify the authorization screen shows exactly /pub/paykit with READ, WRITE access + Verify Paykit access (id "PubkyAuthPaykitAccess") describes access to private Paykit data and sending Paykit messages, without wallet spending keys. + Verify no Earn introduction, watch-only consent (id "PubkyAuthWatchOnlyConsent"), or account picker is shown. + Tap Cancel on the authorization screen + Verify the authorization sheet is dismissed + Verify the current wallet's watch-only accounts and tracking settings are unchanged + + diff --git a/journeys/pubky-marketplace/paykit-reconnect.xml b/journeys/pubky-marketplace/paykit-reconnect.xml new file mode 100644 index 0000000000..82325a8e74 --- /dev/null +++ b/journeys/pubky-marketplace/paykit-reconnect.xml @@ -0,0 +1,18 @@ + + + Verifies Paykit-only reconnect consent and cancellation without a new watch-only account. + Precondition: an isolated wallet with one known service account that is active and tracked, + and a valid fresh pubkyauth://signin URL with exactly /pub/paykit/:rw and + x-bitkit-claim=paykit-access-v1. The cancel steps need no live authorization relay. + Successful reconnect is a separate fixture-backed README check. + + + Record the known service account's name, derivation path, xpub, and tracking setting + Open the fixture Paykit-only reconnect auth URL in the wallet + Verify the authorization screen is visible without watch-only consent or an account picker + Verify Paykit access (testTag "PubkyAuthPaykitAccess") describes private Paykit data and messages without sharing identity or spending keys + Tap Cancel on the authorization screen + Verify the authorization sheet is dismissed + Verify the known service account's name, derivation path, xpub, and tracking setting are unchanged and no new account was created + + diff --git a/journeys/pubky-marketplace/wallet-leg.xml b/journeys/pubky-marketplace/wallet-leg.xml index b174d4e03b..b11efee9c2 100644 --- a/journeys/pubky-marketplace/wallet-leg.xml +++ b/journeys/pubky-marketplace/wallet-leg.xml @@ -1,23 +1,24 @@ - Verifies a Bitkit seller grants a watch-only account claim and a separate linked Bitkit buyer + Verifies a Bitkit seller grants Paykit access and a watch-only account claim and a separate linked Bitkit buyer receives, approves, pays, and confirms the resulting marketplace Payment Request on regtest. Precondition: two clean wallets and the integration fixture runtime described in this suite's README. Configure the fixture Electrum endpoint and Android emulator port mappings before either wallet's first launch. After creating both wallets, create a Bitkit-generated Pubky identity - in each wallet. Pubky Ring-imported identities cannot approve the fixture setup auth URL. Start + in each wallet, or use a Ring identity whose root secret is available to Bitkit. Start with the seller wallet open and the fixture's fresh setup auth URL available. Open the fixture setup auth URL in the seller wallet Verify the watch-only consent screen (testTag "PubkyAuthWatchOnlyConsent") is visible Capture the watch-only consent evidence, then tap Approve (testTag "PubkyAuthWatchOnlyApprove") - Verify the authorization screen shows exactly /pub/paykit/v0/bitkit/server and /pub/paykit/v0/private/bitkit/server, each with READ, WRITE access + Verify the authorization screen shows exactly /pub/paykit with READ, WRITE access + Verify Paykit access (testTag "PubkyAuthPaykitAccess") describes private Paykit data and messages without sharing identity or spending keys Tap Authorize (testTag "PubkyAuthAuthorize") Verify authorization succeeds (testTag "PubkyAuthOK") Tap OK (testTag "PubkyAuthOK") - Verify the fixture completes setup with the seller account xpub and no spending authority + Verify the fixture completes setup with the seller account xpub and generation-bound Paykit key, but no Pubky root secret or spending authority Open General Settings in both wallets and verify contact payments (testTag "ContactPaymentsToggle") are enabled Open Contacts in the buyer wallet and save the fixture's seller public key Verify the seller contact (testTag "Contact_<seller-public-key>") is visible @@ -45,6 +46,7 @@ Wait for periodic synchronization to detect the payout while the seller app remains active If the seller's received-transaction sheet appears, capture it and tap its button (testTag "ReceivedTransactionButton") to dismiss it Verify the seller savings balance (testTag "ActivitySavings") increased by the fixture payout amount and the latest received activity is visible + Verify the received activity identifies the buyer contact after shared Paykit synchronization, including after reopening the app Tap the seller's latest received activity (testTag "ActivityShort-0"), then tap transaction details (testTag "ActivityTxDetails") Verify the transaction id (testTag "TXID") matches the fixture transaction Capture the final activity, transaction id, confirmation height, and completed purchase evidence diff --git a/journeys/subscriptions/README.md b/journeys/subscriptions/README.md index b8287d1873..ddda3e5f61 100644 --- a/journeys/subscriptions/README.md +++ b/journeys/subscriptions/README.md @@ -7,7 +7,7 @@ Payments tab inside Subscriptions is covered here too, because it shares the scr ## Setup Run Bitkit against regtest with Paykit UI enabled on two instances that have each other saved as -contacts and linked on receiver path `bitkit/wallet`. One instance plays the creator, the other the +contacts with an established Paykit Encrypted Link. One instance plays the creator, the other the payer. Fund the payer with enough on-chain or spending balance to cover the subscription amount when the proposal is accepted with payment due on acceptance. diff --git a/journeys/subscriptions/create-and-propose.xml b/journeys/subscriptions/create-and-propose.xml index 382dda8e6f..a548c004b6 100644 --- a/journeys/subscriptions/create-and-propose.xml +++ b/journeys/subscriptions/create-and-propose.xml @@ -5,7 +5,7 @@ the two linked Bitkit instances described in README.md. - Launch the E2E Bitkit app with Paykit UI enabled and the payer instance saved as a contact linked on receiver path "bitkit/wallet" + Launch the E2E Bitkit app with Paykit UI enabled and the payer instance saved as a contact linked as identities Open the drawer menu and tap Subscriptions Verify the Subscriptions screen (testTag "SubscriptionsScreen") appears with the Overview tab (testTag "Tab-overview") selected Tap Create (testTag "SubscriptionCreate") diff --git a/settings.gradle.kts b/settings.gradle.kts index 9790eeb9eb..b1aede9e15 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -36,7 +36,9 @@ plugins { dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { - mavenLocal() + mavenLocal { + content { excludeModule("com.synonym", "paykit-android") } + } google() mavenCentral() maven { From 2bba381eaf8ed73fc9badc0c0607e7f10a272934 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 30 Sep 2026 22:55:41 -0500 Subject: [PATCH 16/51] chore: rename changelog fragment --- .../next/{paykit-shared-runtime.changed.md => 1401.changed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{paykit-shared-runtime.changed.md => 1401.changed.md} (100%) diff --git a/changelog.d/next/paykit-shared-runtime.changed.md b/changelog.d/next/1401.changed.md similarity index 100% rename from changelog.d/next/paykit-shared-runtime.changed.md rename to changelog.d/next/1401.changed.md From 5f959d0f45fefb867600ef5938ed0cb026b043b6 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 30 Sep 2026 23:12:03 -0500 Subject: [PATCH 17/51] docs: clarify paykit integration contracts --- docs/pubky.md | 2 +- journeys/payment-requests/request-summary.xml | 2 +- journeys/pubky-marketplace/README.md | 47 +++++-------------- .../paykit-only-approval.xml | 2 +- .../pubky-marketplace/paykit-reconnect.xml | 2 +- 5 files changed, 15 insertions(+), 40 deletions(-) diff --git a/docs/pubky.md b/docs/pubky.md index 75be389dca..180a8f01e3 100644 --- a/docs/pubky.md +++ b/docs/pubky.md @@ -30,7 +30,7 @@ Delegates Pubky operations to `PaykitSdkService`, which uses: Paykit derives the delegated Paykit key from the active Pubky identity secret and the App Registry's current key generation. Authorized apps share encrypted Pubky-hosted Paykit state; Bitkit retains wallet-owned address reservations and pending payment proofs locally. -The Android dependency is `com.synonym:paykit-android:0.1.0-rc57` from GitHub Packages. Paykit is excluded from Maven-local resolution. Companion authorization and key-sharing consent are described in [Pubky Auth companion claims](pubky-auth-companion-claims.md). +The Android dependency is `com.synonym:paykit-android` from GitHub Packages, pinned in `gradle/libs.versions.toml`. Paykit is excluded from Maven-local resolution. Companion authorization and key-sharing consent are described in [Pubky Auth companion claims](pubky-auth-companion-claims.md). All calls are dispatched on `ServiceQueue.CORE` (single-thread executor) to ensure serial access to the underlying Rust state. diff --git a/journeys/payment-requests/request-summary.xml b/journeys/payment-requests/request-summary.xml index 220577ae34..eeb78a939c 100644 --- a/journeys/payment-requests/request-summary.xml +++ b/journeys/payment-requests/request-summary.xml @@ -8,7 +8,7 @@ Verify From (testTag "PaymentRequestFrom") shows the requester's contact name Verify For (testTag "PaymentRequestFor") shows "Lunch last week" Tap Show details (testTag "SendConfirmToggleDetails") - Verify From (testTag "PaymentRequestFrom") and For (testTag "PaymentRequestFor") are no longer shown, and the recipient (testTag "ReviewContactRecipient") under Contact is the requester's contact + Verify From (testTag "PaymentRequestFrom") and For (testTag "PaymentRequestFor") are hidden in details, and the recipient (testTag "ReviewContactRecipient") under Contact is the requester's contact Verify the invoice note (testTag "PaymentRequestInvoiceNote") shows "Lunch last week" Close the Payment Request confirmation screen without paying On the requester, send the payer contact a Payment Request for 5,000 sats with no note diff --git a/journeys/pubky-marketplace/README.md b/journeys/pubky-marketplace/README.md index bbcdbffd41..e5da87d9b9 100644 --- a/journeys/pubky-marketplace/README.md +++ b/journeys/pubky-marketplace/README.md @@ -1,7 +1,7 @@ # Pubky marketplace wallet leg -This suite covers the two-wallet Bitkit leg of a Pubky marketplace purchase: a seller grants a -watch-only account claim, a linked buyer receives the resulting Payment Request, and the buyer pays +This suite covers the two-wallet Bitkit leg of a Pubky marketplace purchase: a seller grants +Paykit access and a watch-only account, a linked buyer receives the Payment Request, and the buyer pays the request on regtest through confirmation. It does not cover marketplace browsing, Locks content delivery, fiat payment, or Hypercolor. @@ -27,9 +27,9 @@ bytes and no Paykit secret. Unknown, duplicate, mismatched, or wrong-sized claim The journey needs a controlled integration fixture runtime. It must provide: - A fresh Pubky testnet or isolated staging namespace reachable by both wallets. -- A Paykit Server including the canonical request behavior from merged upstream - [`pubky/paykit-server#2`](https://github.com/pubky/paykit-server/pull/2), plus a `/setup` flow whose - auth URL carries `x-bitkit-claim=paykit-access-v1.watch-only-account-v1`. +- A Paykit Server using the same shared-runtime SDK and the + [companion-claim contract](../../docs/pubky-auth-companion-claims.md), including a `/setup` auth + URL whose payload requests `x-bitkit-claim=paykit-access-v1.watch-only-account-v1`. - A regtest bitcoind and Electrum/Fulcrum endpoint on the same chain. Configure the endpoint in both wallets before their first launch so neither wallet retains a taller foreign regtest tip. - A clean seller wallet, a separate clean funded buyer wallet, and the seller Pubky public key. @@ -51,41 +51,17 @@ adb -s reverse tcp:15412 tcp:15412 After creating each wallet, choose **Create profile with Bitkit** to create a Bitkit-generated Pubky identity in each wallet, or use a Ring identity whose root secret is available to Bitkit. -The fixture must use the same shared-runtime SDK and the -[companion claim contract](../../docs/pubky-auth-companion-claims.md). -The request and endpoint must satisfy the issuer contract from Android issue -[#1208](https://github.com/synonymdev/bitkit-android/issues/1208): lowercase `btc`, a +The request and endpoint must satisfy the +[issuer contract](../../docs/paykit-issuer-interoperability.md): lowercase `btc`, a network-correct `btc-regtest-*` endpoint identifier, and a JSON endpoint payload with a non-empty string `value`. The fixture must keep watch-only account material and spending authority separate. Evidence must show the claimed account xpub and account index while omitting wallet seed material and tokens. -The pinned -[`BitcoinErrorLog/pubky-marketplace/payments-env`](https://github.com/BitcoinErrorLog/pubky-marketplace/tree/ed03a32ecfe02deab40ad10ae1bac7fa18465c10/payments-env) -runtime is a reference for the marketplace driver and Locks harness, not a shared-runtime -acceptance fixture. Use a fixture revision updated for the companion claim contract above and -record its exact revisions. The seller wallet fills the companion-auth role and the buyer wallet -fills the reader role; the other fixture roles remain unchanged. - -## Required app changes - -The full journey depends on the sibling work from the parent epic: - -- [#1208](https://github.com/synonymdev/bitkit-android/issues/1208) defines the issuer interop - contract. -- [#1209](https://github.com/synonymdev/bitkit-android/issues/1209) adds reason-specific parse - diagnostics and terminal feedback when an open-time Pay retry is exhausted. -- [#1210](https://github.com/synonymdev/bitkit-android/issues/1210) owns the approved Payment Request - intake policy. This journey does not implement or widen that policy. -- [#1211](https://github.com/synonymdev/bitkit-android/issues/1211) prevents an Electrum-rejected - broadcast from reaching `SendSuccess`. -- [#1218](https://github.com/synonymdev/bitkit-android/issues/1218) tracks the incoming on-chain - request swipe requirement. The behavior is supplied by merged - [#1178](https://github.com/synonymdev/bitkit-android/pull/1178) at `9698dea4`. - -The linked-contact prerequisite is existing Paykit behavior: the buyer must save the seller before -Bitkit's private-message poll can receive the request. The seller must also save the buyer when the +The seller wallet authorizes the server; the buyer wallet receives and pays the request. +The buyer must save the seller before Bitkit's private-message poll can receive the request. +The seller must also save the buyer when the fixture exercises bilateral private delivery. ## Periodic payout detection @@ -115,5 +91,4 @@ Keep these artifacts at each boundary: | Confirmation | Confirmed buyer activity snapshot | Transaction id at one or more confirmations and completed purchase status | `SendSuccess` is evidence of backend acceptance, not confirmation. The fixture's chain and purchase -status are the confirmation authority. `PaymentRequestPay-` is shared with the -iOS counterpart supplied by [`bitkit-ios#721`](https://github.com/synonymdev/bitkit-ios/pull/721). +status are the confirmation authority. `PaymentRequestPay-` is shared with iOS. diff --git a/journeys/pubky-marketplace/paykit-only-approval.xml b/journeys/pubky-marketplace/paykit-only-approval.xml index 340ee26eb4..06f77fa5fa 100644 --- a/journeys/pubky-marketplace/paykit-only-approval.xml +++ b/journeys/pubky-marketplace/paykit-only-approval.xml @@ -11,7 +11,7 @@ Open the fixture Paykit-only auth URL in the wallet Verify the authorization screen shows exactly /pub/paykit with READ, WRITE access Verify Paykit access (id "PubkyAuthPaykitAccess") describes access to private Paykit data and sending Paykit messages, without wallet spending keys. - Verify no Earn introduction, watch-only consent (id "PubkyAuthWatchOnlyConsent"), or account picker is shown. + Verify watch-only consent (id "PubkyAuthWatchOnlyConsent") is not shown Tap Cancel on the authorization screen Verify the authorization sheet is dismissed Verify the current wallet's watch-only accounts and tracking settings are unchanged diff --git a/journeys/pubky-marketplace/paykit-reconnect.xml b/journeys/pubky-marketplace/paykit-reconnect.xml index 82325a8e74..259599f0d5 100644 --- a/journeys/pubky-marketplace/paykit-reconnect.xml +++ b/journeys/pubky-marketplace/paykit-reconnect.xml @@ -9,7 +9,7 @@ Record the known service account's name, derivation path, xpub, and tracking setting Open the fixture Paykit-only reconnect auth URL in the wallet - Verify the authorization screen is visible without watch-only consent or an account picker + Verify the authorization screen is visible without watch-only consent Verify Paykit access (testTag "PubkyAuthPaykitAccess") describes private Paykit data and messages without sharing identity or spending keys Tap Cancel on the authorization screen Verify the authorization sheet is dismissed From 354471e7ca8ab5ba27530be7517e0065487bc1c2 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 30 Sep 2026 23:52:02 -0500 Subject: [PATCH 18/51] fix: tighten paykit attribution and cleanup reporting --- .../PrivatePaykitContactResolver.kt | 24 +- .../bitkit/repositories/PrivatePaykitRepo.kt | 19 +- .../java/to/bitkit/services/CoreService.kt | 59 +++-- .../to/bitkit/services/LightningService.kt | 24 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 8 - .../PrivatePaykitContactResolverTest.kt | 27 +- .../repositories/PrivatePaykitRepoTest.kt | 11 +- .../ActivityServicePaykitContactsTest.kt | 242 ++++++++++++++++-- .../bitkit/services/LightningServiceTest.kt | 30 +++ .../viewmodels/AppViewModelSendFlowTest.kt | 6 +- 10 files changed, 367 insertions(+), 83 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt index b302089f40..7d83815b5a 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt @@ -35,14 +35,28 @@ class PrivatePaykitContactResolver @Inject constructor( .mapNotNull(PubkyPublicKeyFormat::normalized).distinct().singleOrNull() } - suspend fun contactPublicKeyForPrivateOnchainAddresses(addresses: Collection): String? = + suspend fun contactPublicKeyForReservedAddress(address: String): String? = withContext(ioDispatcher) { + addressReservationRepo.get().contactPublicKeyForReservedAddress(address) + } + + suspend fun contactPublicKeyForPrivateOnchainAddresses( + receivingAddress: String?, + addresses: Collection, + ): String? = withContext(ioDispatcher) { + if (receivingAddress.isNullOrBlank() || receivingAddress !in addresses) return@withContext null val shared = receivedPaymentContacts - val contacts = addresses.mapNotNull { - addressReservationRepo.get().contactPublicKeyForReservedAddress(it) + val reservedContacts = addresses.distinct().associateWith { + contactPublicKeyForReservedAddress(it) } + val receivingContacts = listOfNotNull(reservedContacts[receivingAddress]) + + shared.contactsForAddresses(listOf(receivingAddress)) + val contact = receivingContacts.mapNotNull(PubkyPublicKeyFormat::normalized).distinct().singleOrNull() + ?: return@withContext null if (receivedPaymentContacts !== shared) return@withContext null - (contacts + shared.contactsForAddresses(addresses)) - .mapNotNull(PubkyPublicKeyFormat::normalized).distinct().singleOrNull() + contact.takeIf { + (reservedContacts.values.filterNotNull() + shared.contactsForAddresses(addresses)) + .mapNotNull(PubkyPublicKeyFormat::normalized).distinct().singleOrNull() == contact + } } } diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index c3880e9885..cad5b717bb 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -303,17 +303,7 @@ class PrivatePaykitRepo @Inject constructor( withContext(serializedDispatcher) { runSuspendCatching { updateContactSharingCleanupPending(true) - val removalError = removePublishedEndpoints().exceptionOrNull() - if (removalError != null) { - updateContactSharingCleanupPending(true) - Logger.warn( - "Deferred private Paykit endpoint cleanup after disable failed", - removalError, - context = TAG, - ) - return@runSuspendCatching - } - + removePublishedEndpoints().getOrThrow() clearUnsavedContactState(savedPublicKeys).getOrThrow() updateContactSharingCleanupPending(false) publicPaykitRepo.syncPaykitApp().onFailure { @@ -518,13 +508,6 @@ class PrivatePaykitRepo @Inject constructor( } } - suspend fun contactPublicKeyForPrivateOnchainAddresses(addresses: Collection): String? = - withContext(serializedDispatcher) { - addresses.firstNotNullOfOrNull { - addressReservationRepo.contactPublicKeyForReservedAddress(it) - } - } - suspend fun backupSnapshot(): Result = withContext(serializedDispatcher) { runSuspendCatching { diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index bc798bdc73..1ff13f5362 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -588,7 +588,7 @@ class ActivityService( if (resolver.receivedPaymentContacts !== contacts) break val contact = when (activity) { is Activity.Lightning -> receivedPaykitContact(activity.v1, contacts) - is Activity.Onchain -> receivedPaykitContact(activity.v1, contacts) + is Activity.Onchain -> receivedPaykitContact(activity.v1) } if (contact != null && resolver.receivedPaymentContacts === contacts) { val updated = when (activity) { @@ -607,12 +607,13 @@ class ActivityService( return contacts.contactsForPaymentHash(row.id).singleOrNull() } - private fun receivedPaykitContact(row: OnchainActivity, contacts: PaykitReceivedPaymentContacts): String? { + private suspend fun receivedPaykitContact(row: OnchainActivity): String? { if (row.contact != null || row.txType != PaymentType.RECEIVED) return null val details = getBitkitCoreTransactionDetails(walletId = row.walletId, txId = row.txId) ?: return null - if (details.outputs.isEmpty()) return null - val addresses = listOfNotNull(row.address) + details.outputs.mapNotNull { it.scriptpubkeyAddress } - return contacts.contactsForAddresses(addresses).singleOrNull() + return privatePaykitContactResolver.get().contactPublicKeyForPrivateOnchainAddresses( + receivingAddress = row.address, + addresses = details.outputs.mapNotNull { it.scriptpubkeyAddress }, + ) } suspend fun delete(id: String, walletId: String = defaultWalletId): Boolean = ServiceQueue.CORE.background { @@ -921,11 +922,10 @@ class ActivityService( private suspend fun resolveAddressForInboundPayment( kind: PaymentKind.Onchain, payment: PaymentDetails, - transactionDetails: BitkitCoreTransactionDetails? = null, + details: BitkitCoreTransactionDetails?, ): String? { if (payment.direction != PaymentDirection.INBOUND) return null - val details = transactionDetails ?: fetchTransactionDetails(kind.txid) if (details == null) { Logger.verbose( "Skipped address resolution because transaction details are unavailable for '${kind.txid}'", @@ -950,7 +950,7 @@ class ActivityService( private suspend fun findPrivateReservedAddress(details: BitkitCoreTransactionDetails): String? { for (output in details.outputs) { val address = output.scriptpubkeyAddress ?: continue - if (privatePaykitContactPublicKeyForReservedAddress(address) != null) return address + if (privatePaykitContactResolver.get().contactPublicKeyForReservedAddress(address) != null) return address } return null } @@ -977,23 +977,45 @@ class ActivityService( } } + val accounts = runSuspendCatching { lightningService.listOnchainWalletAccounts() } + .onFailure { Logger.warn("Failed to list onchain wallet accounts", it, context = TAG) } + .getOrDefault(emptyList()) + .filter { it.accountIndex != 0u } + for (isChange in listOf(false, true)) { + for (account in accounts) { + searchReceivingAddressForType( + details = details, + value = value, + currentWalletAddress = "", + addressType = account.addressType.toBitkitAddressType(), + isChange = isChange, + accountIndex = account.accountIndex, + )?.let { return it } + } + } + return null } + @Suppress("LongParameterList") private suspend fun searchReceivingAddressForType( details: BitkitCoreTransactionDetails, value: ULong, currentWalletAddress: String, addressType: AddressType, isChange: Boolean, + accountIndex: UInt = 0u, ): String? { - val addressTypeKey = addressType.toSettingsString() + val addressTypeKey = addressType.toSettingsString().let { + if (accountIndex == 0u) it else "$it:account:$accountIndex" + } val endIndex = addressSearchEndIndex(lastUsedAddressSearchIndex(addressTypeKey, isChange)) var index = 0 var currentAddressBatch: Int? = null while (index < endIndex) { - val addresses = fetchAddressSearchBatch(addressType, isChange, index, addressTypeKey) ?: return null + val addresses = fetchAddressSearchBatch(addressType, isChange, index, addressTypeKey, accountIndex) + ?: return null if ( currentWalletAddress.isNotBlank() && @@ -1021,14 +1043,16 @@ class ActivityService( isChange: Boolean, index: Int, addressTypeKey: String, + accountIndex: UInt, ): List? { val scope = if (isChange) "change" else "receive" - return runCatching { + return runSuspendCatching { lightningService.addressInfosForType( addressType = addressType, isChange = isChange, startIndex = index, count = ADDRESS_SEARCH_BATCH_SIZE, + accountIndex = accountIndex, ).map { it.address } }.onFailure { Logger.warn( @@ -1250,15 +1274,17 @@ class ActivityService( } } - val resolvedAddress = resolveAddressForInboundPayment(kind, payment, transactionDetails) + val details = transactionDetails ?: payment.takeIf { it.direction == PaymentDirection.INBOUND } + ?.let { fetchTransactionDetails(kind.txid) } + val resolvedAddress = resolveAddressForInboundPayment(kind, payment, details) val existingContact = existingOnchainActivity?.v1?.contact val contact = existingContact ?: if ( payment.direction == PaymentDirection.INBOUND && payment.status != PaymentStatus.FAILED && - !transactionDetails?.outputs.isNullOrEmpty() + !details?.outputs.isNullOrEmpty() ) { privatePaykitContactResolver.get().contactPublicKeyForPrivateOnchainAddresses( - listOfNotNull(resolvedAddress) + - transactionDetails.outputs.mapNotNull { it.scriptpubkeyAddress }, + receivingAddress = resolvedAddress, + addresses = details.outputs.mapNotNull { it.scriptpubkeyAddress }, ) } else { null @@ -1310,9 +1336,6 @@ class ActivityService( return privatePaykitContactResolver.get().contactPublicKeyForPrivateInvoicePaymentHash(paymentHash) } - private suspend fun privatePaykitContactPublicKeyForReservedAddress(address: String): String? = - privatePaykitContactResolver.get().contactPublicKeyForPrivateOnchainAddresses(listOf(address)) - // MARK: - Test Data Generation (regtest only) @Suppress("LongMethod") diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 71b75c30ef..b00abd93fd 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -37,6 +37,7 @@ import org.lightningdevkit.ldknode.KeychainKind import org.lightningdevkit.ldknode.Node import org.lightningdevkit.ldknode.NodeException import org.lightningdevkit.ldknode.NodeStatus +import org.lightningdevkit.ldknode.OnchainWalletAccount import org.lightningdevkit.ldknode.OnchainWalletAccountConfig import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PaymentId @@ -114,6 +115,13 @@ internal fun enabledOnchainWalletAccountConfigs( ) } +internal fun LdkAddressType.toBitkitAddressType(): AddressType = when (this) { + LdkAddressType.LEGACY -> AddressType.P2PKH + LdkAddressType.NESTED_SEGWIT -> AddressType.P2SH + LdkAddressType.NATIVE_SEGWIT -> AddressType.P2WPKH + LdkAddressType.TAPROOT -> AddressType.P2TR +} + private fun accountKey(addressType: LdkAddressType, accountIndex: UInt): String = "${addressType.name}:$accountIndex" @@ -679,14 +687,16 @@ class LightningService internal constructor( isChange: Boolean, startIndex: Int, count: Int, + accountIndex: UInt = 0u, ): List { val node = this.node ?: throw ServiceError.NodeNotSetup() val keychain = if (isChange) KeychainKind.INTERNAL else KeychainKind.EXTERNAL return ServiceQueue.LDK.background(ldkQueue) { node.onchainPayment() - .addressInfosForType( + .addressInfosForAccount( addressType.toLdkAddressType(), + accountIndex, keychain, startIndex.toUInt(), count.toUInt(), @@ -695,6 +705,11 @@ class LightningService internal constructor( } } + suspend fun listOnchainWalletAccounts(): List = ServiceQueue.LDK.background(ldkQueue) { + val n = node ?: throw ServiceError.NodeNotSetup() + n.listOnchainWalletAccounts() + } + suspend fun revealReceiveAddresses(toReceiveIndex: Int, forType: AddressType) { val node = this.node ?: throw ServiceError.NodeNotSetup() @@ -1330,13 +1345,6 @@ class LightningService internal constructor( n.listMonitoredAddressTypes().map { it.toBitkitAddressType() } } - private fun LdkAddressType.toBitkitAddressType(): AddressType = when (this) { - LdkAddressType.LEGACY -> AddressType.P2PKH - LdkAddressType.NESTED_SEGWIT -> AddressType.P2SH - LdkAddressType.NATIVE_SEGWIT -> AddressType.P2WPKH - LdkAddressType.TAPROOT -> AddressType.P2TR - } - private fun AddressType.toLdkAddressType(): LdkAddressType = when (this) { AddressType.P2PKH -> LdkAddressType.LEGACY AddressType.P2SH -> LdkAddressType.NESTED_SEGWIT diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 0401ded9b7..d8b8f6ab37 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1690,15 +1690,7 @@ class AppViewModel @Inject constructor( ) { closeSettledReceiveSheet(receiveSheetToClose) val addresses = event.details.outputs.mapNotNull { it.scriptpubkeyAddress } - val contactPublicKey = privatePaykitRepo.contactPublicKeyForPrivateOnchainAddresses(addresses) notifyPaymentReceived(event) - if (contactPublicKey != null) { - activityRepo.setContact( - contactPublicKey = contactPublicKey, - forPaymentId = event.txid, - syncLdkPayments = false, - ) - } privatePaykitRepo.handleOnchainActivity(addresses) .onFailure { Logger.warn("Failed to rotate private Paykit address for '${event.txid}'", it, context = TAG) diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt index a9bacffdc8..500c8252a3 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt @@ -61,7 +61,7 @@ class PrivatePaykitContactResolverTest : BaseUnitTest() { whenever(paymentRequestRepo.receivedPaymentContacts).thenReturn(shared) whenever(addressReservationRepo.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS)).thenReturn(CONTACT_KEY) - assertNull(sut.contactPublicKeyForPrivateOnchainAddresses(listOf(PRIVATE_ADDRESS))) + assertNull(sut.contactPublicKeyForPrivateOnchainAddresses(PRIVATE_ADDRESS, listOf(PRIVATE_ADDRESS))) } @Test @@ -74,7 +74,7 @@ class PrivatePaykitContactResolverTest : BaseUnitTest() { null } - assertNull(sut.contactPublicKeyForPrivateOnchainAddresses(listOf(PRIVATE_ADDRESS))) + assertNull(sut.contactPublicKeyForPrivateOnchainAddresses(PRIVATE_ADDRESS, listOf(PRIVATE_ADDRESS))) } @Test @@ -123,8 +123,29 @@ class PrivatePaykitContactResolverTest : BaseUnitTest() { whenever(addressReservationRepo.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS)) .thenReturn(CONTACT_KEY) - val result = sut.contactPublicKeyForPrivateOnchainAddresses(listOf(PRIVATE_ADDRESS)) + val result = sut.contactPublicKeyForPrivateOnchainAddresses(PRIVATE_ADDRESS, listOf(PRIVATE_ADDRESS)) assertEquals(PubkyPublicKeyFormat.normalized(CONTACT_KEY), result) } + + @Test + fun `shared request is not a local address reservation`() = test { + val shared = mock() + whenever(shared.contactsForAddresses(listOf(PRIVATE_ADDRESS))).thenReturn(setOf(CONTACT_KEY)) + whenever(paymentRequestRepo.receivedPaymentContacts).thenReturn(shared) + + assertNull(sut.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS)) + whenever(addressReservationRepo.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS)).thenReturn(CONTACT_KEY) + assertEquals(CONTACT_KEY, sut.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS)) + } + + @Test + fun `unrelated output cannot supply a contact for the receiving address`() = test { + val outputs = listOf("wallet-address", PRIVATE_ADDRESS) + whenever(addressReservationRepo.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS)).thenReturn(CONTACT_KEY) + + assertNull(sut.contactPublicKeyForPrivateOnchainAddresses("wallet-address", outputs)) + assertNull(sut.contactPublicKeyForPrivateOnchainAddresses(null, outputs)) + assertNull(sut.contactPublicKeyForPrivateOnchainAddresses(PRIVATE_ADDRESS, listOf("wallet-address"))) + } } diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 4496ad1a53..04a9fd3acf 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -361,7 +361,9 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { privateListDeliveryReport() }.whenever(paykitSdkService).clearPrivatePaymentList(CONTACT_KEY) - sut.disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) + val result = sut.disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) + + assertTrue(result.isFailure, failureStage) assertTrue(failed, failureStage) assertTrue(cacheData.value.cleanupPending, failureStage) publicRepo.syncPaykitApp(privateSharingEnabled = false).getOrThrow() @@ -392,13 +394,14 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `disable sharing defers unavailable endpoint cleanup`() = test { + fun `disable sharing reports unavailable endpoint cleanup and retains retry state`() = test { settingsData.value = SettingsData( sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false, publicPaykitOnchainEnabled = true, ) sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true).getOrThrow() + settingsData.value = settingsData.value.copy(sharesPrivatePaykitEndpoints = false) whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY) }.thenReturn( privateListDeliveryReport( failedToQueue = listOf( @@ -413,8 +416,10 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { val result = sut.disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) - assertTrue(result.isSuccess, result.exceptionOrNull().toString()) + assertEquals(PrivatePaykitError.PrivateUnavailable, result.exceptionOrNull()) assertTrue(cacheData.value.cleanupPending) + assertTrue(cacheData.value.contacts.getValue(CONTACT_KEY).hasPublishedPrivatePaymentList) + verifyBlocking(addressReservationRepo, never()) { clearContactAssignments(excludingPublicKeys = any()) } } @Test diff --git a/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt b/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt index 3a3076c3be..88f206c57d 100644 --- a/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt +++ b/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt @@ -1,6 +1,7 @@ package to.bitkit.services import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.AddressType import com.synonym.bitkitcore.LightningActivity import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentState @@ -10,44 +11,84 @@ import com.synonym.bitkitcore.TxOutput import com.synonym.bitkitcore.getActivities import com.synonym.bitkitcore.getTransactionDetails import com.synonym.bitkitcore.updateActivity +import com.synonym.bitkitcore.upsertActivity +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf import org.junit.Test +import org.lightningdevkit.ldknode.ConfirmationStatus +import org.lightningdevkit.ldknode.OnchainWalletAccount +import org.lightningdevkit.ldknode.PaymentDetails +import org.lightningdevkit.ldknode.PaymentDirection +import org.lightningdevkit.ldknode.PaymentKind +import org.lightningdevkit.ldknode.PaymentStatus import org.mockito.Mockito.mockStatic import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.async.ServiceQueue +import to.bitkit.data.AppCacheData +import to.bitkit.data.CacheStore +import to.bitkit.data.PrivatePaykitCacheData +import to.bitkit.data.PrivatePaykitCacheStore +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore import to.bitkit.ext.create +import to.bitkit.repositories.PaykitPaymentRequestRepo import to.bitkit.repositories.PaykitReceivedPaymentContacts +import to.bitkit.repositories.PaykitReceivedPaymentContactsTest.Companion.BUYER +import to.bitkit.repositories.PaykitReceivedPaymentContactsTest.Companion.OTHER_BUYER +import to.bitkit.repositories.PrivatePaykitAddressReservationRepo import to.bitkit.repositories.PrivatePaykitContactResolver import to.bitkit.test.BaseUnitTest import javax.inject.Provider import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue +import org.lightningdevkit.ldknode.AddressType as LdkAddressType +import org.lightningdevkit.ldknode.TransactionDetails as LdkTransactionDetails +import org.lightningdevkit.ldknode.TxOutput as LdkTxOutput class ActivityServicePaykitContactsTest : BaseUnitTest() { private val contacts = mock() - private val resolver = mock() - private val sut by lazy { ActivityService(mock(), mock(), mock(), mock(), Provider { resolver }) } + private val requestRepo = mock() + private val reservations = mock() + private val privateCacheStore = mock() + private val cacheStore = mock() + private val cacheData = MutableStateFlow(AppCacheData(onchainAddress = "wallet-address")) + private val settingsStore = mock() + private val lightningService = mock() + private val coreService = mock() + private val resolver by lazy { + PrivatePaykitContactResolver( + ioDispatcher = testDispatcher, + cacheStore = privateCacheStore, + addressReservationRepo = Provider { reservations }, + paymentRequestRepo = Provider { requestRepo }, + ) + } + private val sut by lazy { + ActivityService(coreService, cacheStore, lightningService, settingsStore, Provider { resolver }) + } private var rows = listOf() private var details: TransactionDetails? = null private val updates = mutableListOf() @Test - fun `backfill matches cached outputs and preserves all other onchain metadata`() = coreTest { - val original = onchain(address = "unknown") + fun `backfill matches receiving address in outputs and preserves all other onchain metadata`() = coreTest { + val original = onchain(address = "request-address") rows = listOf(Activity.Onchain(original)) val outputs = listOf(output("request-address"), output("change-address")) details = mock { on { this.outputs }.thenReturn(outputs) } - whenever(contacts.contactsForAddresses(listOf("unknown", "request-address", "change-address"))) - .thenReturn(setOf("buyer")) + sharedAddresses("request-address" to BUYER) assertTrue(sut.backfillPaykitContacts()) - assertEquals(listOf(Activity.Onchain(original.copy(contact = "buyer"))), updates) - verify(contacts).contactsForAddresses(listOf("unknown", "request-address", "change-address")) + assertEquals(listOf(Activity.Onchain(original.copy(contact = BUYER))), updates) + verify(contacts).contactsForAddresses(listOf("request-address", "change-address")) } @Test @@ -80,10 +121,9 @@ class ActivityServicePaykitContactsTest : BaseUnitTest() { @Test fun `backfill refuses ambiguity across stored receiving address and other outputs`() = coreTest { rows = listOf(Activity.Onchain(onchain(address = "request-address"))) - val outputs = listOf(output("other-request-address")) + val outputs = listOf(output("request-address"), output("other-request-address")) details = mock { on { this.outputs }.thenReturn(outputs) } - whenever(contacts.contactsForAddresses(listOf("request-address", "other-request-address"))) - .thenReturn(setOf("buyer", "other-buyer")) + sharedAddresses("request-address" to BUYER, "other-request-address" to OTHER_BUYER) assertFalse(sut.backfillPaykitContacts()) assertTrue(updates.isEmpty()) @@ -92,7 +132,7 @@ class ActivityServicePaykitContactsTest : BaseUnitTest() { @Test fun `backfill waits for complete cached outputs even when stored address matches`() = coreTest { rows = listOf(Activity.Onchain(onchain(address = "request-address"))) - whenever(contacts.contactsForAddresses(listOf("request-address"))).thenReturn(setOf("buyer")) + sharedAddresses("request-address" to BUYER) assertFalse(sut.backfillPaykitContacts()) details = mock { on { outputs }.thenReturn(emptyList()) } @@ -100,8 +140,6 @@ class ActivityServicePaykitContactsTest : BaseUnitTest() { val outputs = listOf(output("other-request-address")) details = mock { on { this.outputs }.thenReturn(outputs) } - whenever(contacts.contactsForAddresses(listOf("request-address", "other-request-address"))) - .thenReturn(setOf("buyer", "other-buyer")) assertFalse(sut.backfillPaykitContacts()) assertTrue(updates.isEmpty()) } @@ -110,7 +148,7 @@ class ActivityServicePaykitContactsTest : BaseUnitTest() { fun `backfill refuses stale identity snapshot before write`() = coreTest { rows = listOf(Activity.Lightning(lightning())) whenever(contacts.contactsForPaymentHash(any())).thenAnswer { - whenever(resolver.receivedPaymentContacts).thenReturn(PaykitReceivedPaymentContacts.Empty) + whenever(requestRepo.receivedPaymentContacts).thenReturn(PaykitReceivedPaymentContacts.Empty) setOf("buyer") } @@ -130,8 +168,169 @@ class ActivityServicePaykitContactsTest : BaseUnitTest() { assertTrue(updates.isEmpty()) } + @Test + fun `backfill rejects a request that only matches another output`() = coreTest { + for (address in listOf("wallet-address", "unknown")) { + rows = listOf(Activity.Onchain(onchain(address))) + val outputs = listOf(output("wallet-address"), output("request-address")) + details = mock { on { this.outputs }.thenReturn(outputs) } + sharedAddresses("request-address" to BUYER) + + assertFalse(sut.backfillPaykitContacts()) + } + assertTrue(updates.isEmpty()) + } + + @Test + fun `backfill includes local reservations in ambiguity checks`() = coreTest { + rows = listOf(Activity.Onchain(onchain("request-address"))) + val outputs = listOf(output("request-address"), output("reserved-address")) + details = mock { on { this.outputs }.thenReturn(outputs) } + sharedAddresses("request-address" to BUYER) + whenever(reservations.contactPublicKeyForReservedAddress("reserved-address")).thenReturn(OTHER_BUYER) + + assertFalse(sut.backfillPaykitContacts()) + assertTrue(updates.isEmpty()) + } + + @Test + fun `live received payment rejects a request matching an unrelated output`() = coreTest { + sharedAddresses("request-address" to BUYER) + + receive("wallet-address", "request-address") + + val row = (updates.single() as Activity.Onchain).v1 + assertEquals("wallet-address", row.address) + assertNull(row.contact) + } + + @Test + fun `live received payment attributes a positively matched wallet destination`() = coreTest { + sharedAddresses("wallet-address" to BUYER) + + receive("wallet-address", "unrelated-address") + + assertEquals(BUYER, (updates.single() as Activity.Onchain).v1.contact) + } + + @Test + fun `live received payment rejects conflicting request outputs`() = coreTest { + sharedAddresses("wallet-address" to BUYER, "request-address" to OTHER_BUYER) + + receive("wallet-address", "request-address") + + assertNull((updates.single() as Activity.Onchain).v1.contact) + } + + @Test + fun `shared request destination alone cannot resolve the receiving address`() = coreTest { + sharedAddresses("request-address" to BUYER) + + receive("request-address") + + val row = (updates.single() as Activity.Onchain).v1 + assertEquals("Loading...", row.address) + assertNull(row.contact) + } + + @Test + fun `local reservation can resolve and attribute a received destination`() = coreTest { + whenever(reservations.contactPublicKeyForReservedAddress("reserved-address")).thenReturn(BUYER) + + receive("reserved-address") + + val row = (updates.single() as Activity.Onchain).v1 + assertEquals("reserved-address", row.address) + assertEquals(BUYER, row.contact) + } + + @Test + fun `registered companion account resolves request destination with scoped search indexes`() = coreTest { + sharedAddresses("server-address" to BUYER) + cacheData.value = cacheData.value.copy( + addressSearchLastUsedReceiveIndexes = mapOf("nativeSegwit" to 600), + ) + whenever(lightningService.listOnchainWalletAccounts()).thenReturn( + listOf(OnchainWalletAccount(LdkAddressType.NATIVE_SEGWIT, 5u)), + ) + whenever(lightningService.addressInfosForType(AddressType.P2WPKH, false, 200, 200, 5u)) + .thenReturn(listOf(AddressDerivationInfo("server-address", 203))) + + receive("change-address", "server-address") + + val row = (updates.single() as Activity.Onchain).v1 + assertEquals("server-address", row.address) + assertEquals(BUYER, row.contact) + assertEquals( + mapOf("nativeSegwit" to 600, "nativeSegwit:account:5" to 200), + cacheData.value.addressSearchLastUsedReceiveIndexes, + ) + } + + @Test + fun `companion account search keeps its own bounded receive and change windows`() = coreTest { + cacheData.value = cacheData.value.copy( + addressSearchLastUsedReceiveIndexes = mapOf("nativeSegwit" to 600), + addressSearchLastUsedChangeIndexes = mapOf("nativeSegwit:account:5" to 200), + ) + whenever(lightningService.listOnchainWalletAccounts()).thenReturn( + listOf(OnchainWalletAccount(LdkAddressType.NATIVE_SEGWIT, 5u)), + ) + + receive("unowned-address") + + verify(lightningService).addressInfosForType(AddressType.P2WPKH, false, 800, 200, 5u) + verify(lightningService, never()).addressInfosForType(AddressType.P2WPKH, false, 1000, 200, 5u) + verify(lightningService).addressInfosForType(AddressType.P2WPKH, true, 1000, 200, 5u) + verify(lightningService, never()).addressInfosForType(AddressType.P2WPKH, true, 1200, 200, 5u) + } + + @Test + fun `payment sync uses stored outputs for receiving address attribution`() = coreTest { + val outputs = listOf(output("wallet-address"), output("unrelated-address")) + details = mock { on { this.outputs }.thenReturn(outputs) } + sharedAddresses("wallet-address" to BUYER) + + sut.syncLdkNodePaymentsToActivities(listOf(payment())) + + assertEquals(BUYER, (updates.single() as Activity.Onchain).v1.contact) + } + + private suspend fun receive(vararg addresses: String) { + whenever(lightningService.listPayments()).thenReturn(listOf(payment())) + sut.handleOnchainTransactionReceived( + "transaction", + LdkTransactionDetails( + amountSats = 15_000, + inputs = emptyList(), + outputs = addresses.mapIndexed { index, address -> + LdkTxOutput("", "", address, 15_000, index.toUInt()) + }, + ), + ) + } + + private fun payment() = PaymentDetails( + id = "received", + kind = PaymentKind.Onchain("transaction", ConfirmationStatus.Unconfirmed), + amountMsat = 15_000_000uL, + feePaidMsat = 0uL, + direction = PaymentDirection.INBOUND, + status = PaymentStatus.SUCCEEDED, + latestUpdateTimestamp = 100uL, + ) + private fun coreTest(block: suspend () -> Unit) = test { - whenever(resolver.receivedPaymentContacts).thenReturn(contacts) + whenever(requestRepo.receivedPaymentContacts).thenReturn(contacts) + whenever(privateCacheStore.data).thenReturn(flowOf(PrivatePaykitCacheData())) + whenever(cacheStore.data).thenReturn(cacheData) + whenever(cacheStore.update(any())).thenAnswer { + cacheData.value = it.getArgument<(AppCacheData) -> AppCacheData>(0)(cacheData.value) + } + whenever(settingsStore.data).thenReturn(flowOf(SettingsData())) + whenever(coreService.activity).thenReturn(mock()) + whenever(lightningService.listOnchainWalletAccounts()).thenReturn(emptyList()) + whenever(lightningService.addressInfosForType(any(), any(), any(), any(), any())).thenReturn(emptyList()) ServiceQueue.CORE.background { mockStatic(Class.forName("com.synonym.bitkitcore.Bitkitcore_androidKt")).use { native -> native.`when`> { @@ -145,11 +344,22 @@ class ActivityServicePaykitContactsTest : BaseUnitTest() { updates.add(it.arguments[1] as Activity) null } + native.`when` { upsertActivity(any()) }.thenAnswer { + updates.add(it.arguments[0] as Activity) + null + } block() } } } + private fun sharedAddresses(vararg values: Pair) { + val byAddress = values.toMap() + whenever(contacts.contactsForAddresses(any())).thenAnswer { + it.getArgument>(0).mapNotNull(byAddress::get).toSet() + } + } + private fun output(address: String): TxOutput = mock { on { scriptpubkeyAddress }.thenReturn(address) } private fun onchain(address: String = "address") = OnchainActivity.create( diff --git a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt index 14ecdf1b26..b2f0137724 100644 --- a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt @@ -15,8 +15,10 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder +import org.lightningdevkit.ldknode.AddressInfo import org.lightningdevkit.ldknode.AddressType import org.lightningdevkit.ldknode.Event +import org.lightningdevkit.ldknode.KeychainKind import org.lightningdevkit.ldknode.Node import org.lightningdevkit.ldknode.NodeException import org.lightningdevkit.ldknode.NodeStatus @@ -51,6 +53,7 @@ import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Duration.Companion.milliseconds +import com.synonym.bitkitcore.AddressType as BitkitAddressType private const val VERIFY_TIMEOUT_MS = 2_000L private const val RELEASE_GATE_PROBE_MS = 200L @@ -98,6 +101,33 @@ class LightningServiceTest : BaseUnitTest() { assertFalse(sut.canReceive()) } + @Test + fun `address derivation forwards companion account and keychain without changing account zero`() = test { + val onchain = mock() + whenever(node.onchainPayment()).thenReturn(onchain) + val address = AddressInfo(201u, "derived-address", KeychainKind.INTERNAL) + whenever(onchain.addressInfosForAccount(AddressType.NATIVE_SEGWIT, 5u, KeychainKind.INTERNAL, 200u, 200u)) + .thenReturn(listOf(address)) + whenever(onchain.addressInfosForAccount(AddressType.NATIVE_SEGWIT, 0u, KeychainKind.EXTERNAL, 0u, 200u)) + .thenReturn(listOf(address)) + + val companion = sut.addressInfosForType(BitkitAddressType.P2WPKH, true, 200, 200, accountIndex = 5u) + val primary = sut.addressInfosForType(BitkitAddressType.P2WPKH, false, 0, 200) + + assertEquals(listOf(AddressDerivationInfo("derived-address", 201)), companion) + assertEquals(companion, primary) + verify(onchain).addressInfosForAccount(AddressType.NATIVE_SEGWIT, 5u, KeychainKind.INTERNAL, 200u, 200u) + verify(onchain).addressInfosForAccount(AddressType.NATIVE_SEGWIT, 0u, KeychainKind.EXTERNAL, 0u, 200u) + } + + @Test + fun `listOnchainWalletAccounts returns registered accounts from LDK`() = test { + val accounts = listOf(OnchainWalletAccount(AddressType.NATIVE_SEGWIT, 5u)) + whenever(node.listOnchainWalletAccounts()).thenReturn(accounts) + + assertEquals(accounts, sut.listOnchainWalletAccounts()) + } + @Test fun `canReceive returns true when channel is usable`() { val usableChannel = createChannelDetails().copy( diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 8545e3873b..87aaca6495 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -465,8 +465,6 @@ class AppViewModelSendFlowTest : BaseUnitTest() { true } } - whenever { privatePaykitRepo.contactPublicKeyForPrivateOnchainAddresses(any>()) } - .thenReturn(null) whenever { privatePaykitRepo.discardRemoteLightningEndpoints(any(), any()) } .thenReturn(Result.success(Unit)) whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())) @@ -4751,11 +4749,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `received onchain payment preserves a replacement receive sheet`() = test { val processingStarted = CompletableDeferred() val resumeProcessing = CompletableDeferred() - whenever(privatePaykitRepo.contactPublicKeyForPrivateOnchainAddresses(any>())) + whenever(privatePaykitRepo.handleOnchainActivity(any>())) .doSuspendableAnswer { processingStarted.complete(Unit) resumeProcessing.await() - null + Result.success(Unit) } val settledAddress = "bcrt1qsettled" walletState.value = WalletState(onchainAddress = settledAddress) From d4655365b12a3a4c77ad6600892b7876c28b2826 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 1 Oct 2026 00:06:49 -0500 Subject: [PATCH 19/51] test: cover paykit key generation and rotation --- .../PaykitReceivedPaymentContacts.kt | 12 +- .../to/bitkit/services/PaykitSdkService.kt | 2 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 2 +- .../PaykitPaymentProofRepoTest.kt | 3 +- .../PaykitPaymentRequestRepoTest.kt | 8 +- .../PaykitReceivedPaymentContactsTest.kt | 111 ++++++++------ .../repositories/PrivatePaykitRepoTest.kt | 12 +- .../services/PaykitKeyGenerationTest.kt | 141 ++++++++++++++++++ .../RefreshContactPaykitLinkUseCaseTest.kt | 10 +- 9 files changed, 232 insertions(+), 69 deletions(-) create mode 100644 app/src/test/java/to/bitkit/services/PaykitKeyGenerationTest.kt diff --git a/app/src/main/java/to/bitkit/repositories/PaykitReceivedPaymentContacts.kt b/app/src/main/java/to/bitkit/repositories/PaykitReceivedPaymentContacts.kt index 1487aeb5e6..11584865a2 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitReceivedPaymentContacts.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitReceivedPaymentContacts.kt @@ -14,11 +14,6 @@ internal class PaykitReceivedPaymentContacts private constructor( private val addresses: Map>, private val paymentHashes: Map>, ) { - fun contactsForAddresses(values: Collection): Set = - values.flatMapTo(mutableSetOf()) { addresses[it].orEmpty() } - - fun contactsForPaymentHash(value: String): Set = paymentHashes[value.lowercase()].orEmpty() - companion object { val Empty = PaykitReceivedPaymentContacts(emptyMap(), emptyMap()) @@ -32,7 +27,7 @@ internal class PaykitReceivedPaymentContacts private constructor( for (record in records) { val contact = validCounterparty(record) ?: continue val terms = checkNotNull(record.terms) - // Only immutable request destinations identify a payer; proofs and current lists do not. + // Use immutable request destinations for attribution, not proofs or current lists. val acceptedEndpoints = terms.paymentEndpoints.orEmpty() .filterKeys(terms.acceptedPaymentEndpointIdentifiers::contains) for ((identifier, payload) in acceptedEndpoints) { @@ -87,4 +82,9 @@ internal class PaykitReceivedPaymentContacts private constructor( Network.REGTEST -> Currency.REGTEST } } + + fun contactsForAddresses(values: Collection): Set = + values.flatMapTo(mutableSetOf()) { addresses[it].orEmpty() } + + fun contactsForPaymentHash(value: String): Set = paymentHashes[value.lowercase()].orEmpty() } diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 5d91bce963..3316316e99 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -571,7 +571,7 @@ class PaykitSdkService @Inject constructor( (endsAt == null || endsAt > now) } if (hasActiveSubscription) throw PubkyContactError.ActiveSubscription - peers.filter { it.state == LinkedPeerState.LINKED }.forEach { peer -> + peers.filter { it.state == LinkedPeerState.LINKED }.forEach { runSuspendCatching { val report = handle.clearPrivatePaymentListAndProcessOutbound( publicKey, diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index d8b8f6ab37..cc225bb332 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -4351,7 +4351,7 @@ class AppViewModel @Inject constructor( private suspend fun preparePaymentProof(request: PaykitPaymentRequest?): Result { if (request == null) return Result.success(null) - val preparation = runCatching { paymentProofPreparation() }.getOrElse { return Result.failure(it) } + val preparation = runSuspendCatching { paymentProofPreparation() }.getOrElse { return Result.failure(it) } return paykitPaymentProofRepo.prepare( request = request, paymentEndpointIdentifier = preparation.endpointIdentifier, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index c91bd32910..083dd620e8 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -10,6 +10,7 @@ import com.synonym.paykit.PaymentRequestLocalRole import com.synonym.paykit.PaymentRequestRecord import com.synonym.paykit.PaymentRequestTerms import com.synonym.paykit.PrivateJsonObject +import com.synonym.paykit.PubkyIdentityCapability import kotlinx.coroutines.test.StandardTestDispatcher import org.junit.Before import org.junit.Test @@ -70,7 +71,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(store.hasPendingProofs()).thenReturn(true) whenever( paykitSdkService.identityStatus() - ).thenReturn(IdentityStatus(LOCAL_IDENTITY, com.synonym.paykit.PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.processPendingPrivateMessages()).thenReturn(emptyList()) whenever(onchainPaymentLookup.existingTransactionIds(any(), any(), any())).thenReturn(emptySet()) whenever(store.load()).thenAnswer { diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 9549766b94..2cf07a26d2 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -135,12 +135,12 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { val record = paymentRequestRecord(role = PaymentRequestLocalRole.PAYEE).let { it.copy( proposalAppId = "marketplace", - terms = it.terms!!.copy( + terms = requireNotNull(it.terms).copy( acceptedPaymentEndpointIdentifiers = listOf(MethodId.P2wpkh.rawValue), paymentEndpoints = mapOf( - MethodId.P2wpkh.rawValue to PaykitReceivedPaymentContactsTest.payload(address) + MethodId.P2wpkh.rawValue to PaykitReceivedPaymentContactsTest.payload(address), ), - ) + ), ) } whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) @@ -151,7 +151,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { } assertEquals( setOf(PubkyPublicKeyFormat.normalized(COUNTERPARTY)), - sut.receivedPaymentContacts.contactsForAddresses(listOf(address)) + sut.receivedPaymentContacts.contactsForAddresses(listOf(address)), ) assertTrue(sut.pendingRequests.value.isEmpty()) assertTrue(sut.paymentRequestHistory.value.isEmpty()) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitReceivedPaymentContactsTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitReceivedPaymentContactsTest.kt index ecf380e884..2658ab80e3 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitReceivedPaymentContactsTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitReceivedPaymentContactsTest.kt @@ -22,6 +22,70 @@ import kotlin.test.assertEquals import kotlin.test.assertTrue class PaykitReceivedPaymentContactsTest { + companion object { + /** Synthetic payer identity shared by request fixtures. */ + const val BUYER = "pubky7don8zi885feihpjsyx7t53srod6z1n4xjiyaaxucpqarm6sh85o" + + /** Second payer identity for ambiguous attribution checks. */ + const val OTHER_BUYER = "pubkya3mduedw686dysw8ndr5c1dyry5h8k6i8hzbbmx9gf9k43zq4s9o" + + /** Valid regtest receiving address used by request fixtures. */ + const val ADDRESS = "bcrt1qfn50lqawrce0evh66qrnlt8j447lwmeyqp5gmd" + + /** Distinct regtest address for unrelated-output checks. */ + const val OTHER_ADDRESS = "bcrt1qpsps9chsjnnd3veems9phzlvw42em682rsj8hh" + + /** Mainnet address sentinel accepted by the mocked decoder. */ + const val MAINNET_ADDRESS = "bc1qexample" + + /** Testnet address sentinel accepted by the mocked decoder. */ + const val TESTNET_ADDRESS = "tb1qexample" + + /** Network-ambiguous prefixes reported as testnet by the mocked decoder. */ + val LEGACY_ADDRESSES = listOf("mlegacy", "nlegacy", "2legacy") + + /** Regtest on-chain endpoint identifier for address fixtures. */ + const val METHOD = "btc-regtest-p2wpkh" + + /** Endpoint identifier for the invoice fixtures. */ + const val BOLT11_METHOD = "btc-lightning-bolt11" + + /** Regtest invoice sentinel accepted by the test invoice parser. */ + const val INVOICE = "lnbcrt1example" + + /** Mainnet invoice sentinel for wrong-network checks. */ + const val MAINNET_INVOICE = "lnbc1example" + + /** Payment hash returned by the test invoice parser. */ + val HASH = "ab".repeat(32) + + fun payload(value: String) = "{\"value\":\"$value\"}" + + @Suppress("LongParameterList") + fun receivedRequest( + counterparty: String = BUYER, + role: PaymentRequestLocalRole? = PaymentRequestLocalRole.PAYEE, + state: PaymentRequestLifecycleState = PaymentRequestLifecycleState.PROOF_SUBMITTED, + invalidReason: String? = null, + asset: String = "btc", + endpoints: Map? = mapOf(METHOD to payload(ADDRESS)), + accepted: List = listOf(METHOD, BOLT11_METHOD), + terms: PaymentRequestTerms? = PaymentRequestTerms( + amount = PaymentRequestAmount("0.00015", asset), paymentReference = mock(), + proposalExpiresAt = null, recurrence = null, acceptedPaymentEndpointIdentifiers = accepted, + paymentEndpoints = endpoints, requiredAppId = "marketplace", conversion = null, + paymentDeadline = null, metadata = mock(), + ), + ): PaymentRequestRecord = mock { + on { this.counterparty }.thenReturn(counterparty) + on { this.localRole }.thenReturn(role) + on { this.state }.thenReturn(state) + on { this.invalidReason }.thenReturn(invalidReason) + on { this.terms }.thenReturn(terms) + on { proposalAppId }.thenReturn("marketplace") + } + } + @Test fun `shared app immutable address attributes received payment`() = withDecoder { val contacts = index(receivedRequest()) @@ -69,7 +133,7 @@ class PaykitReceivedPaymentContactsTest { ) assertTrue( index(*records.toTypedArray()) - .contactsForAddresses(listOf(ADDRESS, MAINNET_ADDRESS, TESTNET_ADDRESS)).isEmpty() + .contactsForAddresses(listOf(ADDRESS, MAINNET_ADDRESS, TESTNET_ADDRESS)).isEmpty(), ) } @@ -78,7 +142,7 @@ class PaykitReceivedPaymentContactsTest { val identifier = "btc-signet-p2wpkh" val record = receivedRequest( accepted = listOf(identifier), - endpoints = mapOf(identifier to payload(TESTNET_ADDRESS)) + endpoints = mapOf(identifier to payload(TESTNET_ADDRESS)), ) val contacts = PaykitReceivedPaymentContacts.from(listOf(record), Network.SIGNET) assertEquals(setOf(BUYER), contacts.contactsForAddresses(listOf(TESTNET_ADDRESS))) @@ -91,7 +155,7 @@ class PaykitReceivedPaymentContactsTest { val identifier = if (address.startsWith("2")) "btc-regtest-p2sh" else "btc-regtest-p2pkh" val record = receivedRequest( accepted = listOf(identifier), - endpoints = mapOf(identifier to payload(address)) + endpoints = mapOf(identifier to payload(address)), ) assertEquals(setOf(BUYER), index(record).contactsForAddresses(listOf(address))) } @@ -180,45 +244,4 @@ class PaykitReceivedPaymentContactsTest { block() } } - - companion object { - const val BUYER = "pubky7don8zi885feihpjsyx7t53srod6z1n4xjiyaaxucpqarm6sh85o" - const val OTHER_BUYER = "pubkya3mduedw686dysw8ndr5c1dyry5h8k6i8hzbbmx9gf9k43zq4s9o" - const val ADDRESS = "bcrt1qfn50lqawrce0evh66qrnlt8j447lwmeyqp5gmd" - const val OTHER_ADDRESS = "bcrt1qpsps9chsjnnd3veems9phzlvw42em682rsj8hh" - const val MAINNET_ADDRESS = "bc1qexample" - const val TESTNET_ADDRESS = "tb1qexample" - val LEGACY_ADDRESSES = listOf("mlegacy", "nlegacy", "2legacy") - const val METHOD = "btc-regtest-p2wpkh" - const val BOLT11_METHOD = "btc-lightning-bolt11" - const val INVOICE = "lnbcrt1example" - const val MAINNET_INVOICE = "lnbc1example" - val HASH = "ab".repeat(32) - - fun payload(value: String) = "{\"value\":\"$value\"}" - - @Suppress("LongParameterList") - fun receivedRequest( - counterparty: String = BUYER, - role: PaymentRequestLocalRole? = PaymentRequestLocalRole.PAYEE, - state: PaymentRequestLifecycleState = PaymentRequestLifecycleState.PROOF_SUBMITTED, - invalidReason: String? = null, - asset: String = "btc", - endpoints: Map? = mapOf(METHOD to payload(ADDRESS)), - accepted: List = listOf(METHOD, BOLT11_METHOD), - terms: PaymentRequestTerms? = PaymentRequestTerms( - amount = PaymentRequestAmount("0.00015", asset), paymentReference = mock(), - proposalExpiresAt = null, recurrence = null, acceptedPaymentEndpointIdentifiers = accepted, - paymentEndpoints = endpoints, requiredAppId = "marketplace", conversion = null, - paymentDeadline = null, metadata = mock(), - ), - ): PaymentRequestRecord = mock { - on { this.counterparty }.thenReturn(counterparty) - on { this.localRole }.thenReturn(role) - on { this.state }.thenReturn(state) - on { this.invalidReason }.thenReturn(invalidReason) - on { this.terms }.thenReturn(terms) - on { proposalAppId }.thenReturn("marketplace") - } - } } diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 04a9fd3acf..5ba44c2201 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -1226,16 +1226,16 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `bound requests keep their own addresses after contact list updates without consuming the list`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) sut.consumePrivatePaymentList(CONTACT_KEY, PrivatePaykitPaymentContext(emptyMap(), 7uL)).getOrThrow() - whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, 7uL) - }.thenReturn(resolution(resolvedEndpoint(MethodId.P2wpkh, "latest-address"), version = 8uL)) + whenever( + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, 7uL), + ).thenReturn(resolution(resolvedEndpoint(MethodId.P2wpkh, "latest-address"), version = 8uL)) whenever(coreService.isAddressUsed(any())).thenReturn(false) sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() val cachedEndpoints = cacheData.value.contacts.getValue(CONTACT_KEY).remoteEndpoints val addresses = mapOf("invoice-a" to PRIVATE_ADDRESS, "invoice-b" to OTHER_PRIVATE_ADDRESS) - whenever { - paykitSdkService.prepareAndResolvePrivatePaymentRequest(eq(CONTACT_KEY), any(), eq(7uL)) - }.thenAnswer { + whenever( + paykitSdkService.prepareAndResolvePrivatePaymentRequest(eq(CONTACT_KEY), any(), eq(7uL)), + ).thenAnswer { resolution(resolvedEndpoint(MethodId.P2wpkh, addresses.getValue(it.getArgument(1))), version = null) } diff --git a/app/src/test/java/to/bitkit/services/PaykitKeyGenerationTest.kt b/app/src/test/java/to/bitkit/services/PaykitKeyGenerationTest.kt new file mode 100644 index 0000000000..6b6c51f9bf --- /dev/null +++ b/app/src/test/java/to/bitkit/services/PaykitKeyGenerationTest.kt @@ -0,0 +1,141 @@ +package to.bitkit.services + +import com.synonym.paykit.IdentityStatus +import com.synonym.paykit.PaykitAppRegistry +import com.synonym.paykit.PaykitIdentitySecretKey +import com.synonym.paykit.PaykitSdk +import com.synonym.paykit.PubkyIdentityCapability +import com.synonym.paykit.PubkyLocalSecretKey +import com.synonym.paykit.PubkySessionAccess +import com.synonym.paykit.pubkyPublicKeyFromSecret +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.mockito.Mockito.mockConstruction +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.any +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.data.keychain.Keychain +import to.bitkit.ext.toHex +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertSame + +class PaykitKeyGenerationTest { + companion object { + /** Fixture identity for generation-scoped storage keys. */ + private const val RING_PUBKY = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + } + + @Test + fun `authorization derives the registry generation and persists its identity scoped floor`() = runTest { + for (registryGeneration in listOf(null, 7uL)) { + val generation = registryGeneration ?: 1uL + val registry = registryGeneration?.let { + mock { on { keyGeneration }.thenReturn(generation) } + } + val sdk = mock() + whenever(sdk.paykitAppRegistry(RING_PUBKY)).thenReturn(registry) + val keychain = mock() + val storageKey = "${Keychain.Key.PAYKIT_KEY_GENERATION.name}:$RING_PUBKY" + whenever(keychain.loadString(storageKey)).thenReturn(null, generation.toString()) + val key = mock() + val bytes = ByteArray(32) { 1 } + val service = PaykitSdkService(mock(), keychain, mock()) { sdk } + + mockStatic(Class.forName("com.synonym.paykit.Paykit_androidKt")).use { native -> + native.`when` { pubkyPublicKeyFromSecret(any()) }.thenReturn(RING_PUBKY) + mockConstruction(PubkyLocalSecretKey::class.java) { root, context -> + assertContentEquals(bytes, context.arguments().single() as ByteArray) + whenever(root.derivePaykitIdentitySecretKey(generation)).thenReturn(key) + }.use { roots -> + repeat(2) { + assertSame(key, service.paykitKeyForAuthorization(bytes.toHex())) + } + assertEquals(2, roots.constructed().size) + roots.constructed().forEach { verify(it).derivePaykitIdentitySecretKey(generation) } + verify(keychain).upsertString(storageKey, generation.toString()) + verify(sdk, times(2)).paykitAppRegistry(RING_PUBKY) + } + } + } + } + + @Test + fun `stored root refresh rotates the session key and rejects registry rollback`() = runTest { + val initialRegistry = mock { on { keyGeneration }.thenReturn(2uL) } + val rotatedRegistry = mock { on { keyGeneration }.thenReturn(3uL) } + val sdk = mock() + whenever(sdk.paykitAppRegistry(RING_PUBKY)) + .thenReturn(initialRegistry, rotatedRegistry, initialRegistry, null) + whenever(sdk.identityStatus()) + .thenReturn(IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + val keychain = mock() + val storageKey = "${Keychain.Key.PAYKIT_KEY_GENERATION.name}:$RING_PUBKY" + whenever(keychain.loadString(storageKey)).thenReturn("2", "2", "3", "3") + val root = mock() + val initialKey = mock() + val rotatedKey = mock() + whenever(root.derivePaykitIdentitySecretKey(2uL)).thenReturn(initialKey) + whenever(root.derivePaykitIdentitySecretKey(3uL)).thenReturn(rotatedKey) + + mockStatic(Class.forName("com.synonym.paykit.Paykit_androidKt")).use { native -> + native.`when` { pubkyPublicKeyFromSecret(root) }.thenReturn(RING_PUBKY) + mockConstruction(PaykitSdkSessionProvider::class.java) { provider, _ -> + whenever(provider.loadLocalSecretKey()).thenReturn(root) + }.use { providers -> + val service = PaykitSdkService(mock(), keychain, mock()) { sdk } + val provider = providers.constructed().single() + repeat(2) { assertEquals(RING_PUBKY, service.currentPublicKey()) } + repeat(2) { + val error = assertFailsWith { service.currentPublicKey() } + assertEquals("The Paykit App Registry has an older key generation", error.message) + } + + inOrder(provider, keychain, root) { + verify(root).derivePaykitIdentitySecretKey(2uL) + verify(provider).setPaykitIdentitySecretKey(initialKey) + verify(keychain).upsertString(storageKey, "3") + verify(root).derivePaykitIdentitySecretKey(3uL) + verify(provider).setPaykitIdentitySecretKey(rotatedKey) + } + verify(root, times(2)).derivePaykitIdentitySecretKey(any()) + verify(keychain).upsertString(any(), any()) + verify(sdk, times(2)).identityStatus() + verify(provider, times(2)).setPaykitIdentitySecretKey(any()) + } + } + } + + @Test + fun `session key rotation rebuilds cached session access with the refreshed key`() { + val keychain = mock() + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("saved-session") + val initialKey = mock { on { keyGeneration() }.thenReturn(2uL) } + val rotatedKey = mock { on { keyGeneration() }.thenReturn(3uL) } + val initialAccess = mock() + whenever(initialAccess.exportSessionSecret()).thenReturn("saved-session") + whenever(initialAccess.exportPaykitIdentitySecretKey()).thenReturn(initialKey) + val provider = PaykitSdkSessionProvider(keychain, mock()) + provider.setLiveSessionAccess(initialAccess) + provider.setPaykitIdentitySecretKey(initialKey) + assertSame(initialAccess, provider.loadSessionAccess()) + + mockConstruction(PubkySessionAccess::class.java) { access, context -> + assertEquals(BitkitPaykitSdkConfig.clientId, context.arguments()[0]) + assertEquals("saved-session", context.arguments()[1]) + assertSame(rotatedKey, context.arguments()[3]) + whenever(access.exportSessionSecret()).thenReturn("saved-session") + }.use { sessions -> + provider.setPaykitIdentitySecretKey(rotatedKey) + val refreshedAccess = provider.loadSessionAccess() + assertSame(sessions.constructed().single(), refreshedAccess) + assertSame(refreshedAccess, provider.loadSessionAccess()) + assertEquals(1, sessions.constructed().size) + } + } +} diff --git a/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCaseTest.kt index 7470d823cb..86a1abf911 100644 --- a/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCaseTest.kt @@ -45,9 +45,8 @@ class RefreshContactPaykitLinkUseCaseTest : BaseUnitTest() { @Test fun `refreshes contact endpoints before starting the link burst`() = test { - whenever { - privatePaykitRepo.refreshSavedContactEndpoints(contactKeys.last(), contactKeys) - }.thenReturn(Result.success(Unit)) + whenever(privatePaykitRepo.refreshSavedContactEndpoints(contactKeys.last(), contactKeys)) + .thenReturn(Result.success(Unit)) val result = sut(contactKeys.last()) @@ -61,9 +60,8 @@ class RefreshContactPaykitLinkUseCaseTest : BaseUnitTest() { @Test fun `stops when endpoint refresh fails`() = test { val error = IllegalStateException("Endpoint refresh failed") - whenever { - privatePaykitRepo.refreshSavedContactEndpoints(contactKeys.last(), contactKeys) - }.thenReturn(Result.failure(error)) + whenever(privatePaykitRepo.refreshSavedContactEndpoints(contactKeys.last(), contactKeys)) + .thenReturn(Result.failure(error)) val result = sut(contactKeys.last()) From bca62da2d516235032cce6e17a9f915703896e23 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 1 Oct 2026 07:02:19 -0500 Subject: [PATCH 20/51] fix: guard paykit execution and cache contact backfills --- .../java/to/bitkit/data/keychain/Keychain.kt | 1 + .../PaykitPaymentRequestPresentationStore.kt | 23 ++ .../repositories/PaykitPaymentRequestRepo.kt | 61 ++++- .../PaykitReceivedPaymentContacts.kt | 3 +- .../PrivatePaykitAddressReservationRepo.kt | 10 +- .../PrivatePaykitContactResolver.kt | 6 + .../java/to/bitkit/services/CoreService.kt | 88 +++++-- ...ykitPaymentRequestPresentationStoreTest.kt | 44 ++++ ...aykitPaymentRequestRepoSubscriptionTest.kt | 30 +++ .../PaykitPaymentRequestRepoTest.kt | 221 +++++++++++++++++- .../PaykitReceivedPaymentContactsTest.kt | 11 + ...PrivatePaykitAddressReservationRepoTest.kt | 26 ++- .../ActivityServicePaykitContactsTest.kt | 119 +++++++++- .../viewmodels/AppViewModelSendFlowTest.kt | 5 + changelog.d/next/1401.changed.md | 2 +- journeys/payment-requests/README.md | 7 + .../accepted-device-ownership.xml | 21 ++ 17 files changed, 650 insertions(+), 28 deletions(-) create mode 100644 journeys/payment-requests/accepted-device-ownership.xml diff --git a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt index 481fa0af58..1a8df74c5d 100644 --- a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt +++ b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt @@ -236,6 +236,7 @@ class Keychain @Inject constructor( PAYKIT_RECOVERY_BACKUP, PAYKIT_PENDING_BACKUP_RESTORE, PAYKIT_PENDING_PAYMENT_PROOFS, + PAYKIT_ACCEPTED_PAYMENT_REQUESTS, PAYKIT_PRESENTED_PAYMENT_REQUESTS, PUBKY_SECRET_KEY, SHARED_PUBKY_SOURCE, diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt index 8ffba9fe87..abc97b191d 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt @@ -31,6 +31,7 @@ class PaykitPaymentRequestPresentationStore @Inject constructor( ) { companion object { private val KEY = Keychain.Key.PAYKIT_PRESENTED_PAYMENT_REQUESTS.name + private val ACCEPTED_KEY = Keychain.Key.PAYKIT_ACCEPTED_PAYMENT_REQUESTS.name } private val mutex = Mutex() @@ -73,6 +74,28 @@ class PaykitPaymentRequestPresentationStore @Inject constructor( } } + /** Local execution ownership is never exported or imported by wallet backups. */ + fun loadAcceptedOneTimeIds(identity: String): Set { + val normalizedIdentity = PubkyPublicKeyFormat.normalized(identity) ?: return emptySet() + return loadAcceptedOneTimeIdsByIdentity()[normalizedIdentity].orEmpty() + } + + suspend fun addAcceptedOneTimeId(identity: String, id: PaykitPaymentRequestId): Set = + mutex.withLock { + val normalizedIdentity = requireNotNull(PubkyPublicKeyFormat.normalized(identity)) + val current = loadAcceptedOneTimeIdsByIdentity() + val ids = current[normalizedIdentity].orEmpty() + id + val state = current + (normalizedIdentity to ids) + keychain.upsertString(ACCEPTED_KEY, Json.encodeToString(state)) + ids + } + + private fun loadAcceptedOneTimeIdsByIdentity(): Map> { + val value = keychain.loadString(ACCEPTED_KEY) ?: return emptyMap() + return runCatching { Json.decodeFromString>>(value) } + .getOrElse { throw PaykitPaymentStateUnreadableError(ACCEPTED_KEY, it) } + } + fun loadSubscriptionState(identity: String): PaykitSubscriptionPresentationState { val normalizedIdentity = PubkyPublicKeyFormat.normalized(identity) ?: return PaykitSubscriptionPresentationState() diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index eb81148f86..b786220988 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -296,6 +296,9 @@ class PaykitPaymentRequestRepo @Inject constructor( get() = receivedContacts?.takeIf { isCurrentState(it.generation, it.identity) }?.contacts ?: PaykitReceivedPaymentContacts.Empty + internal val receivedPaymentContactsGeneration: Long + get() = stateGeneration.get() + private data class ReceivedContactsSnapshot( val generation: Long, val identity: String, @@ -305,6 +308,12 @@ class PaykitPaymentRequestRepo @Inject constructor( @Volatile private var presentedRequestIds = emptySet() + @Volatile + private var acceptedOneTimeRequestIds = emptySet() + + @Volatile + private var activatedGeneration = -1L + @Volatile private var subscriptionAcceptedAt = emptyMap() @@ -319,10 +328,17 @@ class PaykitPaymentRequestRepo @Inject constructor( if (!PubkyPublicKeyFormat.matches(activeIdentity, normalizedIdentity)) { stateGeneration.incrementAndGet() } + val generation = stateGeneration.get() operationMutex.withLock { if (PubkyPublicKeyFormat.matches(activeIdentity, normalizedIdentity)) return@withLock clearStateLocked() activeIdentity = null + acceptedOneTimeRequestIds = emptySet() + acceptedOneTimeRequestIds = runSuspendCatching { + presentationStore.loadAcceptedOneTimeIds(normalizedIdentity) + } + .onFailure { Logger.error("Failed to restore accepted Paykit payment requests", it, context = TAG) } + .getOrElse { return@withLock } presentedRequestIds = runSuspendCatching { presentationStore.load(normalizedIdentity) } .onFailure { Logger.error("Failed to restore surfaced Paykit payment requests", it, context = TAG) } .getOrDefault(emptySet()) @@ -333,6 +349,7 @@ class PaykitPaymentRequestRepo @Inject constructor( presentedSubscriptionProposalIds = subscriptionState.presentedProposalIds dismissedSubscriptionPaymentIds = subscriptionState.dismissedPaymentIds activeIdentity = normalizedIdentity + activatedGeneration = generation } } @@ -771,8 +788,20 @@ class PaykitPaymentRequestRepo @Inject constructor( return PaykitPaymentRequestCreation(request, creatorIdentity, wasPublishedToActiveState) } - suspend fun ensurePaymentAllowed(request: PaykitPaymentRequest): Result = withContext(ioDispatcher) { + suspend fun ensurePaymentAllowed(request: PaykitPaymentRequest): Result = + ensurePaymentAllowed(request, forExecution = true) + + private suspend fun ensurePaymentAllowed( + request: PaykitPaymentRequest, + forExecution: Boolean, + ): Result = withContext(ioDispatcher) { runSuspendCatching { + val identity = activeIdentity ?: throw PaykitPaymentRequestError.RequestUnavailable + val generation = stateGeneration.get() + if (!isLocallyPayable(request)) throw PaykitPaymentRequestError.RequestUnavailable + if (forExecution && !hasCurrentExecutionContext(request)) { + throw PaykitPaymentRequestError.RequestUnavailable + } if ( paykitSdkService.linkedPeers().any { it.state == LinkedPeerState.BLOCKED && @@ -781,11 +810,13 @@ class PaykitPaymentRequestRepo @Inject constructor( ) { throw PaykitPaymentRequestError.RequestUnavailable } + if (!isCurrentState(generation, identity)) throw PaykitPaymentRequestError.RequestUnavailable } } suspend fun claimForPayment(request: PaykitPaymentRequest): Result = withContext(ioDispatcher) { runSuspendCatching { + ensurePaymentAllowed(request, forExecution = false).getOrThrow() paykitSdkService.claimPaymentRequestForExecution(request.counterparty, request.paymentRequestId) Unit } @@ -795,18 +826,23 @@ class PaykitPaymentRequestRepo @Inject constructor( runSuspendCatching { if (!request.requiresAcceptance) { operationMutex.withLock { - ensurePaymentAllowed(request).getOrThrow() + ensurePaymentAllowed(request, forExecution = false).getOrThrow() if (_pendingRequests.value.none { it.id == request.id }) { throw PaykitPaymentRequestError.RequestUnavailable } } } else { updateRequest(request, PaymentRequestLifecycleState.ACCEPTED) { - ensurePaymentAllowed(it).getOrThrow() + val identity = activeIdentity ?: throw PaykitPaymentRequestError.RequestUnavailable + val generation = stateGeneration.get() + ensurePaymentAllowed(it, forExecution = false).getOrThrow() paykitSdkService.acceptPaymentRequest( counterparty = it.counterparty, paymentRequestId = it.paymentRequestId, ) + val acceptedIds = presentationStore.addAcceptedOneTimeId(identity, it.id) + if (!isCurrentState(generation, identity)) throw PaykitPaymentRequestError.RequestUnavailable + acceptedOneTimeRequestIds = acceptedIds }.getOrThrow() } }.onFailure { @@ -904,6 +940,7 @@ class PaykitPaymentRequestRepo @Inject constructor( clearStateLocked() activeIdentity = null presentedRequestIds = emptySet() + acceptedOneTimeRequestIds = emptySet() presentedSubscriptionProposalIds = emptySet() dismissedSubscriptionPaymentIds = emptySet() } @@ -1011,7 +1048,9 @@ class PaykitPaymentRequestRepo @Inject constructor( null } } - }.filter { it.id !in locallyCompletedRequestIds && it.id !in locallyInFlightRequestIds } + }.filter { + isLocallyPayable(it) && it.id !in locallyCompletedRequestIds && it.id !in locallyInFlightRequestIds + } val incoming = (dueRequests + oneTimeIncoming).sortedBy { it.createdAt } val oneTimeHistory = records.mapNotNull { it.toPaykitPaymentRequestHistory(now) }.map { request -> val proofKind = locallyCompletedProofKinds[request.id] ?: return@map request @@ -1139,6 +1178,20 @@ class PaykitPaymentRequestRepo @Inject constructor( private suspend fun isAvailable(): Boolean = activeIdentity != null && PaykitFeatureFlags.isUiEnabled(settingsStore.isPaykitEnabled.first()) + private fun isLocallyPayable(request: PaykitPaymentRequest): Boolean = + request.billingPeriod != null || request.lifecycleState != PaymentRequestLifecycleState.ACCEPTED || + hasLocalAcceptance(request) + + private fun hasLocalAcceptance(request: PaykitPaymentRequest): Boolean = + activatedGeneration == stateGeneration.get() && request.id in acceptedOneTimeRequestIds + + private fun hasCurrentExecutionContext(request: PaykitPaymentRequest): Boolean { + if (request.billingPeriod == null) return hasLocalAcceptance(request) + return activatedGeneration == stateGeneration.get() && _subscriptions.value.any { + it.isPayer && it.lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING && request.belongsTo(it) + } + } + private suspend fun updateRequest( request: PaykitPaymentRequest, resultingState: PaymentRequestLifecycleState, diff --git a/app/src/main/java/to/bitkit/repositories/PaykitReceivedPaymentContacts.kt b/app/src/main/java/to/bitkit/repositories/PaykitReceivedPaymentContacts.kt index 11584865a2..4e5a7f5f0e 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitReceivedPaymentContacts.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitReceivedPaymentContacts.kt @@ -10,7 +10,8 @@ import org.lightningdevkit.ldknode.Network import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.toLdkNetwork -internal class PaykitReceivedPaymentContacts private constructor( +@ConsistentCopyVisibility +internal data class PaykitReceivedPaymentContacts private constructor( private val addresses: Map>, private val paymentHashes: Map>, ) { diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt index e57115f1fc..4fde83adc5 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt @@ -47,7 +47,15 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( } private val mutex = Mutex() + + @Volatile + internal var attributionVersion = 0L + private set private var ledger: PrivatePaykitReservationData? = null + set(value) { + if (field != value) attributionVersion++ + field = value + } private val _backupStateVersion = MutableStateFlow(0L) val backupStateVersion: StateFlow = _backupStateVersion.asStateFlow() @@ -153,7 +161,7 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( assignments.firstOrNull { (_, assignment) -> assignment.addressType == addressType && - (assignment.address == address || resolvedAddress(assignment).getOrNull() == address) + (assignment.address == address || resolvedAddress(assignment).getOrThrow() == address) }?.first } diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt index 7d83815b5a..47c01cdf88 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt @@ -20,6 +20,12 @@ class PrivatePaykitContactResolver @Inject constructor( internal val receivedPaymentContacts: PaykitReceivedPaymentContacts get() = paymentRequestRepo.get().receivedPaymentContacts + internal val receivedPaymentContactsGeneration: Long + get() = paymentRequestRepo.get().receivedPaymentContactsGeneration + + internal val reservationVersion: Long + get() = addressReservationRepo.get().attributionVersion + suspend fun contactPublicKeyForPrivateInvoicePaymentHash(paymentHash: String): String? = withContext(ioDispatcher) { if (paymentHash.isBlank()) return@withContext null diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 1ff13f5362..79afc00afb 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -211,6 +211,7 @@ class CoreService @Inject constructor( suspend fun wipeData(): Result = ServiceQueue.CORE.background { runCatching { + activity.invalidatePaykitContactBackfill() val result = wipeAllDatabases() Logger.info("Core DB wipe: '$result'", context = TAG) }.onFailure { @@ -372,9 +373,23 @@ class ActivityService( private val privatePaykitContactResolver: Provider, ) { private val defaultWalletId = WalletScope.default + private var paykitActivityRevision = 0L + private var lastPaykitContactBackfill: PaykitContactBackfillState? = null + + private data class PaykitContactBackfillState( + val contacts: PaykitReceivedPaymentContacts, + val generation: Long, + val activityRevision: Long, + val reservationVersion: Long, + ) + + internal suspend fun invalidatePaykitContactBackfill() = ServiceQueue.CORE.background { + paykitActivityRevision++ + } suspend fun removeAll() { ServiceQueue.CORE.background { + invalidatePaykitContactBackfill() // Get all activities and delete them one by one val activities = getActivities( walletId = null, @@ -397,18 +412,22 @@ class ActivityService( } suspend fun deleteByWalletId(walletId: String): UInt = ServiceQueue.CORE.background { + invalidatePaykitContactBackfill() deleteActivitiesByWalletId(walletId) } suspend fun insert(activity: Activity) = ServiceQueue.CORE.background { + invalidatePaykitContactBackfill() insertActivity(activity) } suspend fun upsert(activity: Activity) = ServiceQueue.CORE.background { + invalidatePaykitContactBackfill() upsertActivity(activity) } suspend fun upsertList(activities: List) = ServiceQueue.CORE.background { + invalidatePaykitContactBackfill() upsertActivities(activities) } @@ -428,6 +447,7 @@ class ActivityService( transactionDetails: List, transferChannelIdsByFundingTxId: Map, ): HwSnapshotResult = ServiceQueue.CORE.background { + invalidatePaykitContactBackfill() val existingActivities = getActivities( walletId = walletId, filter = ActivityFilter.ONCHAIN, @@ -565,13 +585,24 @@ class ActivityService( } suspend fun update(id: String, activity: Activity) = ServiceQueue.CORE.background { + invalidatePaykitContactBackfill() updateActivity(activityId = id, activity = activity) } suspend fun backfillPaykitContacts(): Boolean = ServiceQueue.CORE.background { val resolver = privatePaykitContactResolver.get() val contacts = resolver.receivedPaymentContacts - if (contacts === PaykitReceivedPaymentContacts.Empty) return@background false + if (contacts === PaykitReceivedPaymentContacts.Empty) { + lastPaykitContactBackfill = null + return@background false + } + val snapshot = PaykitContactBackfillState( + contacts = contacts, + generation = resolver.receivedPaymentContactsGeneration, + activityRevision = paykitActivityRevision, + reservationVersion = resolver.reservationVersion, + ) + if (snapshot == lastPaykitContactBackfill) return@background false val activities = getActivities( walletId = null, filter = ActivityFilter.ALL, @@ -584,13 +615,19 @@ class ActivityService( sortDirection = null, ) var changed = false + var complete = true for (activity in activities) { - if (resolver.receivedPaymentContacts !== contacts) break + if (!isCurrentPaykitContactBackfill(resolver, snapshot)) return@background changed val contact = when (activity) { is Activity.Lightning -> receivedPaykitContact(activity.v1, contacts) - is Activity.Onchain -> receivedPaykitContact(activity.v1) + is Activity.Onchain -> { + val (contact, hydrated) = receivedPaykitContact(activity.v1) + complete = complete && hydrated + contact + } } - if (contact != null && resolver.receivedPaymentContacts === contacts) { + if (!isCurrentPaykitContactBackfill(resolver, snapshot)) return@background changed + if (contact != null) { val updated = when (activity) { is Activity.Lightning -> Activity.Lightning(activity.v1.copy(contact = contact)) is Activity.Onchain -> Activity.Onchain(activity.v1.copy(contact = contact)) @@ -599,24 +636,38 @@ class ActivityService( changed = true } } + if (complete && isCurrentPaykitContactBackfill(resolver, snapshot)) { + lastPaykitContactBackfill = snapshot + } changed } + private fun isCurrentPaykitContactBackfill( + resolver: PrivatePaykitContactResolver, + snapshot: PaykitContactBackfillState, + ): Boolean = resolver.receivedPaymentContacts === snapshot.contacts && + resolver.receivedPaymentContactsGeneration == snapshot.generation && + paykitActivityRevision == snapshot.activityRevision && + resolver.reservationVersion == snapshot.reservationVersion + private fun receivedPaykitContact(row: LightningActivity, contacts: PaykitReceivedPaymentContacts): String? { if (row.contact != null || row.txType != PaymentType.RECEIVED || row.status == PaymentState.FAILED) return null return contacts.contactsForPaymentHash(row.id).singleOrNull() } - private suspend fun receivedPaykitContact(row: OnchainActivity): String? { - if (row.contact != null || row.txType != PaymentType.RECEIVED) return null - val details = getBitkitCoreTransactionDetails(walletId = row.walletId, txId = row.txId) ?: return null + private suspend fun receivedPaykitContact(row: OnchainActivity): Pair { + if (row.contact != null || row.txType != PaymentType.RECEIVED) return null to true + val details = getBitkitCoreTransactionDetails(walletId = row.walletId, txId = row.txId) + val addresses = details?.outputs?.mapNotNull { it.scriptpubkeyAddress }.orEmpty() + if (row.address !in addresses) return null to false return privatePaykitContactResolver.get().contactPublicKeyForPrivateOnchainAddresses( receivingAddress = row.address, - addresses = details.outputs.mapNotNull { it.scriptpubkeyAddress }, - ) + addresses = addresses, + ) to true } suspend fun delete(id: String, walletId: String = defaultWalletId): Boolean = ServiceQueue.CORE.background { + invalidatePaykitContactBackfill() deleteActivityById(walletId = walletId, activityId = id) } @@ -860,6 +911,7 @@ class ActivityService( ) }.withPendingMessage(pendingMessage = pendingMessage, description = kind.description) + if (existingActivity != Activity.Lightning(ln)) invalidatePaykitContactBackfill() if (getActivityById(walletId = defaultWalletId, activityId = payment.id) != null) { updateActivity(activityId = payment.id, activity = Activity.Lightning(ln)) } else { @@ -884,7 +936,7 @@ class ActivityService( as? Activity.Lightning ?: return@background val updated = existing.v1.withPendingMessage(pendingMessage = message, description = description) if (updated != existing.v1) { - updateActivity(activityId = paymentHash, activity = Activity.Lightning(updated)) + update(paymentHash, Activity.Lightning(updated)) } cacheStore.removePendingLightningMessage(paymentHash) } @@ -1317,6 +1369,7 @@ class ActivityService( return } + if (existingActivity != Activity.Onchain(onChain)) invalidatePaykitContactBackfill() if (existingActivity != null && existingActivity is Activity.Onchain) { val existingOnchain = existingActivity.v1 updateActivity(activityId = existingOnchain.id, activity = Activity.Onchain(onChain)) @@ -1413,7 +1466,7 @@ class ActivityService( } // Insert activity - insertActivity(activity) + insert(activity) // Add random tags val numTags = (0..3).random() @@ -1445,7 +1498,7 @@ class ActivityService( isTransfer = true, channelId = existing.channelId ?: channelId, ) - if (updated != existing) upsertActivity(Activity.Onchain(updated)) + if (updated != existing) upsert(Activity.Onchain(updated)) } Logger.debug("Activity already exists for txid $txid, skipping immediate creation", context = TAG) return@background @@ -1468,7 +1521,7 @@ class ActivityService( updatedAt = 0u, seenAt = now, ) - upsertActivity(Activity.Onchain(onchain)) + upsert(Activity.Onchain(onchain)) Logger.info("Created sent onchain activity for txid $txid from send result", context = TAG) }.onFailure { Logger.error("Failed to create sent onchain activity for txid $txid", it, context = TAG) @@ -1480,6 +1533,7 @@ class ActivityService( ServiceQueue.CORE.background { runCatching { val coreDetails = mapToCoreTransactionDetails(txid, details) + invalidatePaykitContactBackfill() upsertTransactionDetails(listOf(coreDetails)) val payments = lightningService.listPayments() ?: run { @@ -1511,6 +1565,7 @@ class ActivityService( ServiceQueue.CORE.background { runCatching { val coreDetails = mapToCoreTransactionDetails(txid, details) + invalidatePaykitContactBackfill() upsertTransactionDetails(listOf(coreDetails)) val payments = lightningService.listPayments() ?: run { @@ -1573,6 +1628,7 @@ class ActivityService( isBoosted = false, updatedAt = System.currentTimeMillis().toULong() / 1000u ) + paykitActivityRevision++ updateActivity(activityId = replacedActivity.id, activity = Activity.Onchain(updatedActivity)) Logger.info("Marked transaction $txid as replaced", context = TAG) } else { @@ -1633,7 +1689,7 @@ class ActivityService( contact = replacementActivity.contact ?: replacedActivity?.contact, updatedAt = System.currentTimeMillis().toULong() / 1000u, ) - updateActivity(activityId = replacementActivity.id, activity = Activity.Onchain(updatedActivity)) + update(replacementActivity.id, Activity.Onchain(updatedActivity)) if (replacedActivity != null) { copyTagsFromReplacedActivity(txid, conflictTxid, replacedActivity.id, replacementActivity.id) @@ -1677,7 +1733,7 @@ class ActivityService( updatedAt = System.currentTimeMillis().toULong() / 1000u ) - updateActivity(activityId = onchain.id, activity = Activity.Onchain(updatedActivity)) + update(onchain.id, Activity.Onchain(updatedActivity)) }.onFailure { e -> Logger.error("Error handling onchain transaction reorged for $txid", e, context = TAG) } @@ -1697,7 +1753,7 @@ class ActivityService( updatedAt = System.currentTimeMillis().toULong() / 1000u ) - updateActivity(activityId = onchain.id, activity = Activity.Onchain(updatedActivity)) + update(onchain.id, Activity.Onchain(updatedActivity)) }.onFailure { e -> Logger.error("Error handling onchain transaction evicted for $txid", e, context = TAG) } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt index d16a5ea6c6..4d454033df 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt @@ -92,6 +92,50 @@ class PaykitPaymentRequestPresentationStoreTest : BaseUnitTest() { assertEquals(2L, sut.backupStateVersion.value) } + @Test + fun `accepted one time ownership survives reopening but never enters wallet backup`() = test { + val keychain = mock() + val values = mutableMapOf() + whenever(keychain.loadString(any())).thenAnswer { values[it.getArgument(0)] } + whenever { keychain.upsertString(any(), any()) }.thenAnswer { + values[it.getArgument(0)] = it.getArgument(1) + Unit + } + val sut = PaykitPaymentRequestPresentationStore(keychain) + val requestId = PaykitPaymentRequestId("request", COUNTERPARTY) + val secondId = PaykitPaymentRequestId("second", COUNTERPARTY) + sut.addAcceptedOneTimeId(IDENTITY, requestId) + sut.addAcceptedOneTimeId(COUNTERPARTY, secondId) + sut.addAcceptedOneTimeId(IDENTITY, secondId) + sut.save(IDENTITY, setOf(requestId)) + + val reopened = PaykitPaymentRequestPresentationStore(keychain) + assertEquals(setOf(requestId, secondId), reopened.loadAcceptedOneTimeIds(IDENTITY)) + assertEquals(setOf(secondId), reopened.loadAcceptedOneTimeIds(COUNTERPARTY)) + assertEquals(emptyMap(), reopened.backupSnapshot()) + assertEquals(0L, sut.backupStateVersion.value) + reopened.restoreBackup(emptyMap()) + assertEquals(setOf(requestId, secondId), reopened.loadAcceptedOneTimeIds(IDENTITY)) + + values.clear() + reopened.restoreBackup(emptyMap()) + assertEquals(emptySet(), reopened.loadAcceptedOneTimeIds(IDENTITY)) + } + + @Test + fun `unreadable accepted one time ownership is preserved and fails closed`() = test { + val keychain = mock() + val acceptedKey = Keychain.Key.PAYKIT_ACCEPTED_PAYMENT_REQUESTS.name + whenever(keychain.loadString(acceptedKey)).thenReturn("not-json") + val sut = PaykitPaymentRequestPresentationStore(keychain) + + assertFailsWith { sut.loadAcceptedOneTimeIds(IDENTITY) } + assertFailsWith { + sut.addAcceptedOneTimeId(IDENTITY, PaykitPaymentRequestId("request", COUNTERPARTY)) + } + verify(keychain, never()).upsertString(any(), any()) + } + @Test fun `backup restore preserves precise acceptance billing boundaries`() = test { val keychain = mock() diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt index 98a383ac66..d555f9c048 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt @@ -150,6 +150,36 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat assertEquals(Instant.parse("2027-02-01T08:00:00Z"), request.billingPeriod?.endsAt) } + @Test + fun `recurring final authorization survives in flight filtering but rejects identity switches`() = test { + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( + listOf(paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING)), + ) + sut.refresh().getOrThrow() + val request = sut.pendingRequests.value.single() + sut.accept(request).getOrThrow() + sut.ensurePaymentAllowed(request).getOrThrow() + whenever(paymentProofStore.inFlightRequestIds(LOCAL_IDENTITY)).thenReturn(setOf(request.id)) + sut.refresh().getOrThrow() + assertTrue(sut.pendingRequests.value.isEmpty()) + sut.ensurePaymentAllowed(request).getOrThrow() + + val checking = CompletableDeferred() + val checked = CompletableDeferred() + whenever(paykitSdkService.linkedPeers()).doSuspendableAnswer { + checking.complete(Unit) + checked.await() + emptyList() + } + val authorization = async { sut.ensurePaymentAllowed(request) } + checking.await() + sut.activate(SECOND_IDENTITY) + checked.complete(Unit) + + assertTrue(authorization.await().isFailure) + assertTrue(sut.ensurePaymentAllowed(request).isFailure) + } + @Test fun `refresh keeps creator subscription without generating a payer payment`() = test { val metadataText = """ diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 2cf07a26d2..c1c72e3528 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -103,6 +103,10 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(settingsStore.isPaykitEnabled).thenReturn(flowOf(true)) whenever(settingsStore.data).thenReturn(flowOf(SettingsData(sharesPrivatePaykitEndpoints = true))) whenever(presentationStore.load(LOCAL_IDENTITY)).thenReturn(emptySet()) + whenever(presentationStore.loadAcceptedOneTimeIds(any())).thenReturn(emptySet()) + whenever(presentationStore.addAcceptedOneTimeId(any(), any())).thenAnswer { + setOf(it.getArgument(1)) + } whenever( presentationStore.loadSubscriptionState(any()) ).thenReturn(PaykitSubscriptionPresentationState()) @@ -181,6 +185,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `blocking an already presented accepted request prevents payment`() = test { + restoreAcceptedRequest() val record = paymentRequestRecord(state = PaymentRequestLifecycleState.ACCEPTED) whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) sut.refresh().getOrThrow() @@ -193,6 +198,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `accepted request checks blocking after waiting for synchronization`() = test { + restoreAcceptedRequest() val record = paymentRequestRecord(state = PaymentRequestLifecycleState.ACCEPTED) whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) sut.refresh().getOrThrow() @@ -419,7 +425,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.refresh().getOrThrow() - assertEquals(listOf("incoming", "accepted"), sut.pendingRequests.value.map { it.paymentRequestId }) + assertEquals(listOf("incoming"), sut.pendingRequests.value.map { it.paymentRequestId }) assertEquals( setOf( "incoming", "accepted", "rejected", "expired", "outgoing", "unsupported", @@ -537,6 +543,211 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(sut.pendingRequests.value.isEmpty()) assertEquals(PaymentRequestLifecycleState.ACCEPTED, sut.paymentRequestHistory.value.single().lifecycleState) verifyBlocking(paykitSdkService) { processPendingPrivateMessages() } + verify(presentationStore).addAcceptedOneTimeId( + LOCAL_IDENTITY, + PaykitPaymentRequestId(PAYMENT_REQUEST_ID, COUNTERPARTY), + ) + } + + @Test + fun `local acceptance survives refresh and reconnect without accepting twice`() = test { + val proposed = paymentRequestRecord() + val accepted = proposed.copy(state = PaymentRequestLifecycleState.ACCEPTED) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(proposed)) + whenever(paykitSdkService.acceptPaymentRequest(any(), any())).thenReturn(accepted) + sut.refresh().getOrThrow() + val request = sut.pendingRequests.value.single() + sut.accept(request).getOrThrow() + + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(accepted)) + sut.refresh().getOrThrow() + sut.accept(sut.pendingRequests.value.single()).getOrThrow() + whenever(presentationStore.loadAcceptedOneTimeIds(LOCAL_IDENTITY)).thenReturn(setOf(request.id)) + sut.clear() + sut.activate(LOCAL_IDENTITY) + sut.refresh().getOrThrow() + sut.accept(sut.pendingRequests.value.single()).getOrThrow() + verify(paykitSdkService, times(1)).acceptPaymentRequest(any(), any()) + sut.ensurePaymentAllowed(request).getOrThrow() + + sut.activate(SECOND_IDENTITY) + assertTrue(sut.ensurePaymentAllowed(request).isFailure) + } + + @Test + fun `final authorization rechecks identity after asynchronous peer lookup`() = test { + restoreAcceptedRequest() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(paymentRequestRecord())) + sut.refresh().getOrThrow() + val request = sut.pendingRequests.value.single() + val checking = CompletableDeferred() + val checked = CompletableDeferred() + whenever(paykitSdkService.linkedPeers()).doSuspendableAnswer { + checking.complete(Unit) + checked.await() + emptyList() + } + val authorization = async { sut.ensurePaymentAllowed(request) } + checking.await() + sut.activate(SECOND_IDENTITY) + checked.complete(Unit) + + assertTrue(authorization.await().isFailure) + } + + @Test + fun `queued identity switch invalidates ownership before acquiring the repository mutex`() = test { + restoreAcceptedRequest() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(paymentRequestRecord())) + sut.refresh().getOrThrow() + val request = sut.pendingRequests.value.single() + val refreshing = CompletableDeferred() + val refreshed = CompletableDeferred() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).doSuspendableAnswer { + refreshing.complete(Unit) + refreshed.await() + emptyList() + } + val refresh = async { sut.refresh() } + refreshing.await() + val activation = async { sut.activate(SECOND_IDENTITY) } + runCurrent() + + assertTrue(sut.ensurePaymentAllowed(request).isFailure) + refreshed.complete(Unit) + refresh.await() + activation.await() + } + + @Test + fun `unknown acceptance or failed persistence never grants local ownership`() = test { + val proposed = paymentRequestRecord() + val accepted = proposed.copy(state = PaymentRequestLifecycleState.ACCEPTED) + whenever(paykitSdkService.acceptPaymentRequest(any(), any())) + .thenThrow(IllegalStateException("unknown completion")).thenReturn(accepted) + whenever(presentationStore.addAcceptedOneTimeId(any(), any())).thenThrow(IllegalStateException("disk")) + for (sdkSucceeded in listOf(false, true)) { + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(proposed)) + sut.refresh().getOrThrow() + assertTrue(sut.accept(sut.pendingRequests.value.single()).isFailure) + if (!sdkSucceeded) verify(presentationStore, never()).addAcceptedOneTimeId(any(), any()) + + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(accepted)) + sut.refresh().getOrThrow() + assertTrue(sut.pendingRequests.value.isEmpty()) + assertTrue(sut.accept(sut.paymentRequestHistory.value.single()).isFailure) + assertTrue(sut.ensurePaymentAllowed(sut.paymentRequestHistory.value.single()).isFailure) + } + } + + @Test + fun `final authorization requires durable ownership regardless of snapshot lifecycle`() = test { + val proposed = paymentRequestRecord() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(proposed)) + whenever(paykitSdkService.acceptPaymentRequest(any(), any())) + .thenReturn(proposed.copy(state = PaymentRequestLifecycleState.ACCEPTED)) + val saving = CompletableDeferred() + val saved = CompletableDeferred() + whenever(presentationStore.addAcceptedOneTimeId(any(), any())).doSuspendableAnswer { + saving.complete(Unit) + saved.await() + setOf(it.getArgument(1)) + } + sut.refresh().getOrThrow() + val request = sut.pendingRequests.value.single() + val acceptedSnapshot = request.copy(lifecycleState = PaymentRequestLifecycleState.ACCEPTED) + val acceptance = async { sut.accept(request) } + saving.await() + assertFalse(acceptance.isCompleted) + assertTrue(sut.ensurePaymentAllowed(request).isFailure) + assertTrue(sut.ensurePaymentAllowed(acceptedSnapshot).isFailure) + + saved.complete(Unit) + acceptance.await().getOrThrow() + sut.ensurePaymentAllowed(request).getOrThrow() + sut.ensurePaymentAllowed(acceptedSnapshot).getOrThrow() + } + + @Test + fun `second install cannot accept stale proposal or resume first installs acceptance`() = test { + val otherStore = mock() + whenever(otherStore.loadSubscriptionState(any())).thenReturn(PaykitSubscriptionPresentationState()) + val other = PaykitPaymentRequestRepo( + testDispatcher, + paykitSdkService, + settingsStore, + otherStore, + diagnostics, + paymentProofStore, + paymentProofRepo, + subscriptionNotificationScheduler, + clock, + ) + other.activate(LOCAL_IDENTITY) + var record = paymentRequestRecord() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenAnswer { listOf(record) } + whenever(paykitSdkService.acceptPaymentRequest(any(), any())).thenAnswer { + check(record.state == PaymentRequestLifecycleState.PROPOSED) + record = record.copy(state = PaymentRequestLifecycleState.ACCEPTED) + record + } + sut.refresh().getOrThrow() + other.refresh().getOrThrow() + val stale = other.pendingRequests.value.single() + sut.accept(sut.pendingRequests.value.single()).getOrThrow() + + assertTrue(other.accept(stale).isFailure) + other.refresh().getOrThrow() + assertTrue(other.pendingRequests.value.isEmpty()) + assertTrue(other.automaticPendingRequests().isEmpty()) + val remoteAccepted = other.paymentRequestHistory.value.single() + assertNull(other.pendingRequest(remoteAccepted.id)) + assertTrue(other.accept(remoteAccepted).isFailure) + assertTrue(other.claimForPayment(remoteAccepted).isFailure) + assertTrue(other.ensurePaymentAllowed(remoteAccepted).isFailure) + verify(paykitSdkService, never()).claimPaymentRequestForExecution(any(), any()) + verify(otherStore, never()).addAcceptedOneTimeId(any(), any()) + sut.refresh().getOrThrow() + sut.accept(sut.pendingRequests.value.single()).getOrThrow() + other.clear() + } + + @Test + fun `identity switch during acceptance saves only the captured identity and prevents execution`() = test { + val proposed = paymentRequestRecord() + val accepting = CompletableDeferred() + val accepted = CompletableDeferred() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(proposed)) + whenever(paykitSdkService.acceptPaymentRequest(any(), any())).doSuspendableAnswer { + accepting.complete(Unit) + accepted.await() + proposed.copy(state = PaymentRequestLifecycleState.ACCEPTED) + } + sut.refresh().getOrThrow() + val request = sut.pendingRequests.value.single() + val acceptance = async { sut.accept(request) } + accepting.await() + val activation = async { sut.activate(SECOND_IDENTITY) } + runCurrent() + accepted.complete(Unit) + + assertTrue(acceptance.await().isFailure) + activation.await() + verify(presentationStore).addAcceptedOneTimeId(LOCAL_IDENTITY, request.id) + verify(presentationStore, never()).addAcceptedOneTimeId(eq(SECOND_IDENTITY), any()) + val snapshot = request.copy(lifecycleState = PaymentRequestLifecycleState.ACCEPTED) + assertTrue(sut.ensurePaymentAllowed(snapshot).isFailure) + } + + @Test + fun `unreadable accepted ownership prevents activation`() = test { + sut.clear() + whenever(presentationStore.loadAcceptedOneTimeIds(LOCAL_IDENTITY)) + .thenThrow(IllegalStateException("unreadable")) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(paymentRequestRecord())) + sut.activate(LOCAL_IDENTITY) + sut.refresh().getOrThrow() + assertTrue(sut.pendingRequests.value.isEmpty()) } @Test @@ -1207,6 +1418,14 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(!sut.isPending(request)) } + private suspend fun restoreAcceptedRequest() { + whenever(presentationStore.loadAcceptedOneTimeIds(LOCAL_IDENTITY)).thenReturn( + setOf(PaykitPaymentRequestId(PAYMENT_REQUEST_ID, COUNTERPARTY)), + ) + sut.clear() + sut.activate(LOCAL_IDENTITY) + } + @Suppress("LongParameterList") private fun paymentRequestRecord( id: String = PAYMENT_REQUEST_ID, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitReceivedPaymentContactsTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitReceivedPaymentContactsTest.kt index 2658ab80e3..61ce4bdab2 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitReceivedPaymentContactsTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitReceivedPaymentContactsTest.kt @@ -93,6 +93,17 @@ class PaykitReceivedPaymentContactsTest { assertTrue(contacts.contactsForAddresses(listOf(OTHER_ADDRESS)).isEmpty()) } + @Test + fun `contact snapshots compare by attribution values not record order or lifecycle`() = withDecoder { + val first = index(receivedRequest(), receivedRequest(counterparty = OTHER_BUYER)) + val refreshed = index( + receivedRequest(counterparty = OTHER_BUYER, state = PaymentRequestLifecycleState.ACCEPTED), + receivedRequest(state = PaymentRequestLifecycleState.CANCELED), + ) + assertEquals(first, refreshed) + assertEquals(first.hashCode(), refreshed.hashCode()) + } + @Test fun `terminal request states retain exact destination attribution`() = withDecoder { for (state in PaymentRequestLifecycleState.entries.filterNot { diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt index 478721d46d..a82342646d 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt @@ -19,8 +19,10 @@ import to.bitkit.services.AddressDerivationInfo import to.bitkit.services.CoreService import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNull +import kotlin.test.assertTrue class PrivatePaykitAddressReservationRepoTest : BaseUnitTest() { companion object { @@ -168,7 +170,7 @@ class PrivatePaykitAddressReservationRepoTest : BaseUnitTest() { } @Test - fun `clearContactAssignment removes private address attribution history`() = test { + fun `clearContactAssignment invalidates attribution even if persistence fails`() = test { reservationData.value = PrivatePaykitReservationData( contactAssignments = mapOf( CONTACT_KEY to PrivatePaykitStoredAssignmentData( @@ -188,11 +190,31 @@ class PrivatePaykitAddressReservationRepoTest : BaseUnitTest() { ), ) - sut.clearContactAssignment(CONTACT_KEY) + assertEquals(CONTACT_KEY, sut.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS)) + val version = sut.attributionVersion + whenever(reservationStore.update(any())).thenThrow(IllegalStateException("disk")) + assertFailsWith { sut.clearContactAssignment(CONTACT_KEY) } + assertTrue(sut.attributionVersion > version) assertNull(sut.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS)) } + @Test + fun `reservation derivation failure propagates and remains retryable`() = test { + reservationData.value = PrivatePaykitReservationData( + contactAssignments = mapOf( + CONTACT_KEY to PrivatePaykitStoredAssignmentData(addressType = "nativeSegwit", receiveIndex = 1), + ), + ) + whenever(lightningRepo.addressInfoForType(any(), any())).thenReturn( + Result.failure(IllegalStateException("unavailable")), + Result.success(AddressDerivationInfo(address = PRIVATE_ADDRESS, index = 1)), + ) + + assertFailsWith { sut.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS) } + assertEquals(CONTACT_KEY, sut.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS)) + } + @Test fun `contactPublicKeyForReservedAddress skips assignments for other address types`() = test { reservationData.value = PrivatePaykitReservationData( diff --git a/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt b/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt index 88f206c57d..79c2df9309 100644 --- a/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt +++ b/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt @@ -43,8 +43,10 @@ import to.bitkit.repositories.PaykitReceivedPaymentContactsTest.Companion.OTHER_ import to.bitkit.repositories.PrivatePaykitAddressReservationRepo import to.bitkit.repositories.PrivatePaykitContactResolver import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AppError import javax.inject.Provider import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -76,6 +78,9 @@ class ActivityServicePaykitContactsTest : BaseUnitTest() { private var rows = listOf() private var details: TransactionDetails? = null private val updates = mutableListOf() + private val reservationVersion = MutableStateFlow(0L) + private var activityReads = 0 + private var detailReads = 0 @Test fun `backfill matches receiving address in outputs and preserves all other onchain metadata`() = coreTest { @@ -193,6 +198,109 @@ class ActivityServicePaykitContactsTest : BaseUnitTest() { assertTrue(updates.isEmpty()) } + @Test + fun `completed backfill skips unchanged inputs until new activity arrives`() = coreTest { + rows = listOf(Activity.Onchain(onchain("wallet-address"))) + val outputs = listOf(output("wallet-address")) + details = mock { on { this.outputs }.thenReturn(outputs) } + + assertFalse(sut.backfillPaykitContacts()) + assertFalse(sut.backfillPaykitContacts()) + assertEquals(1, activityReads) + assertEquals(1, detailReads) + + val received = Activity.Lightning(lightning()) + sut.upsert(received) + rows = listOf(received) + updates.clear() + whenever(contacts.contactsForPaymentHash(any())).thenReturn(setOf(BUYER)) + + assertTrue(sut.backfillPaykitContacts()) + assertEquals(BUYER, (updates.single() as Activity.Lightning).v1.contact) + assertEquals(2, activityReads) + } + + @Test + fun `reservation changes invalidate an ambiguous cached result`() = coreTest { + rows = listOf(Activity.Onchain(onchain("request-address"))) + val outputs = listOf(output("request-address"), output("reserved-address")) + details = mock { on { this.outputs }.thenReturn(outputs) } + sharedAddresses("request-address" to BUYER) + whenever(reservations.contactPublicKeyForReservedAddress("reserved-address")).thenReturn(OTHER_BUYER) + assertFalse(sut.backfillPaykitContacts()) + assertFalse(sut.backfillPaykitContacts()) + assertEquals(1, activityReads) + + whenever(reservations.contactPublicKeyForReservedAddress("reserved-address")).thenReturn(null) + reservationVersion.value++ + assertTrue(sut.backfillPaykitContacts()) + assertEquals(BUYER, (updates.single() as Activity.Onchain).v1.contact) + assertEquals(2, activityReads) + } + + @Test + fun `incomplete details and failed reservation lookups retry until a scan completes`() = coreTest { + rows = listOf(Activity.Onchain(onchain("request-address")), Activity.Lightning(lightning())) + sharedAddresses("request-address" to BUYER) + assertFalse(sut.backfillPaykitContacts()) + assertFalse(sut.backfillPaykitContacts()) + val outputs = listOf(output("request-address")) + details = mock { on { this.outputs }.thenReturn(outputs) } + whenever(reservations.contactPublicKeyForReservedAddress(any())) + .thenThrow(IllegalStateException("unavailable")).thenReturn(null) + assertFailsWith { sut.backfillPaykitContacts() } + + assertTrue(sut.backfillPaykitContacts()) + assertFalse(sut.backfillPaykitContacts()) + assertEquals(4, activityReads) + assertEquals(4, detailReads) + assertEquals(BUYER, (updates.single() as Activity.Onchain).v1.contact) + } + + @Test + fun `updated transaction details invalidate a completed cached scan`() = coreTest { + rows = listOf(Activity.Onchain(onchain("request-address"))) + val ambiguousOutputs = listOf(output("request-address"), output("other-request-address")) + details = mock { on { outputs }.thenReturn(ambiguousOutputs) } + sharedAddresses("request-address" to BUYER, "other-request-address" to OTHER_BUYER) + assertFalse(sut.backfillPaykitContacts()) + + val resolvedOutputs = listOf(output("request-address")) + details = mock { on { outputs }.thenReturn(resolvedOutputs) } + sut.handleOnchainTransactionConfirmed("transaction", mock()) + assertTrue(sut.backfillPaykitContacts()) + assertEquals(2, activityReads) + } + + @Test + fun `identity generation invalidates equal contact snapshots`() = coreTest { + rows = listOf(Activity.Lightning(lightning())) + assertFalse(sut.backfillPaykitContacts()) + whenever(requestRepo.receivedPaymentContactsGeneration).thenReturn(1L) + whenever(contacts.contactsForPaymentHash(any())).thenReturn(setOf(BUYER)) + + assertTrue(sut.backfillPaykitContacts()) + assertEquals(2, activityReads) + } + + @Test + fun `reservation mutation during backfill cannot cache or write stale attribution`() = coreTest { + rows = listOf(Activity.Onchain(onchain("request-address"))) + val outputs = listOf(output("request-address")) + details = mock { on { this.outputs }.thenReturn(outputs) } + sharedAddresses("request-address" to BUYER) + whenever(reservations.contactPublicKeyForReservedAddress(any())).thenAnswer { + reservationVersion.value++ + null + } + + assertFalse(sut.backfillPaykitContacts()) + assertTrue(updates.isEmpty()) + whenever(reservations.contactPublicKeyForReservedAddress(any())).thenReturn(null) + assertTrue(sut.backfillPaykitContacts()) + assertEquals(2, activityReads) + } + @Test fun `live received payment rejects a request matching an unrelated output`() = coreTest { sharedAddresses("request-address" to BUYER) @@ -322,6 +430,7 @@ class ActivityServicePaykitContactsTest : BaseUnitTest() { private fun coreTest(block: suspend () -> Unit) = test { whenever(requestRepo.receivedPaymentContacts).thenReturn(contacts) + whenever(reservations.attributionVersion).thenAnswer { reservationVersion.value } whenever(privateCacheStore.data).thenReturn(flowOf(PrivatePaykitCacheData())) whenever(cacheStore.data).thenReturn(cacheData) whenever(cacheStore.update(any())).thenAnswer { @@ -338,8 +447,14 @@ class ActivityServicePaykitContactsTest : BaseUnitTest() { anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull() ) - }.thenAnswer { rows } - native.`when` { getTransactionDetails(any(), any()) }.thenAnswer { details } + }.thenAnswer { + activityReads++ + rows + } + native.`when` { getTransactionDetails(any(), any()) }.thenAnswer { + detailReads++ + details + } native.`when` { updateActivity(any(), any()) }.thenAnswer { updates.add(it.arguments[1] as Activity) null diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 87aaca6495..21310d3755 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -6681,6 +6681,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test + @Suppress("LongMethod") fun `failed LNURL request callback releases preparation and retry reopens request`() = test { val request = paymentRequest() val privateContext = privatePaymentContext(7uL) @@ -6726,6 +6727,10 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) verify(paykitPaymentProofRepo).cancelPreparation(request) verify(lightningRepo, never()).payInvoice(any(), anyOrNull()) + inOrder(paykitPaymentRequestRepo, lightningRepo).apply { + verify(paykitPaymentRequestRepo).accept(request) + verify(lightningRepo).fetchLnurlInvoice(lnurl, lnurl.callbackAmountMsats(request.amountSats), null) + } verify(toastManager, never()).enqueue(any()) pendingPaykitPaymentRequests.value = emptyList() diff --git a/changelog.d/next/1401.changed.md b/changelog.d/next/1401.changed.md index 3299757cb7..c6c333754f 100644 --- a/changelog.d/next/1401.changed.md +++ b/changelog.d/next/1401.changed.md @@ -1 +1 @@ -Share Paykit contacts and payment state with authorized apps while keeping wallet keys private, and label received payments with their Paykit payer contact. +Share Paykit contacts and payment state with authorized apps while keeping wallet keys private, keep accepted one-time requests payable only on the accepting install, and label received payments with their Paykit payer contact. diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index aba9ebf3e3..b48f8a2df7 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -24,6 +24,13 @@ Rejected fixture shapes stay in unit tests because Bitkit intentionally does not `definite-pre-broadcast-retry.xml` uses the linked fixture issuer and the local regtest LNURL server. Configure its LNURL-pay metadata endpoint normally, but make its invoice callback fail the first request and succeed after it is switched back to the healthy response. Do not republish the Paykit payment list between attempts. This makes the first send fail before Lightning dispatch and proves that the same private payment details can be opened and paid on retry. +## Accepting install + +`accepted-device-ownership.xml` extends the failing LNURL fixture to two separate installs sharing +one Pubky identity and App ID. Only the accepting install may retry after restart; the other keeps +the accepted request in history without payment controls. Confirm acceptance in shared request +state after the callback failure, before testing the second install. + ## Reference evidence The source wallet-leg run completed this path on regtest on 2026-08-22: Bitkit presented the incoming request, opened the on-chain payment, broadcast it, and confirmed transaction diff --git a/journeys/payment-requests/accepted-device-ownership.xml b/journeys/payment-requests/accepted-device-ownership.xml new file mode 100644 index 0000000000..b0c949b4e0 --- /dev/null +++ b/journeys/payment-requests/accepted-device-ownership.xml @@ -0,0 +1,21 @@ + + + Requires two separate Bitkit installs A and B authenticated as the same Pubky identity, + both using App ID bitkit, and the linked LNURL fixture from definite-pre-broadcast-retry.xml. + This covers one-time requests only. Do not copy app-private storage between installs. + + + Configure the fixture LNURL-pay invoice callback to fail before Lightning dispatch + Have the issuer send a proposed one-time Payment Request for 21,000 sats and record its request id + Open the request payment review on both installs before either install swipes to send + On install A swipe the send control (testTag "GRAB") and verify the failure screen (testTag "SendFailure") + Using the fixture's shared-runtime request inspection, verify the recorded request is ACCEPTED with no payment proof and no wallet dispatch; stop if acceptance has not committed + Configure the same LNURL-pay invoice callback to succeed without publishing a new Paykit payment list + On install B swipe the stale review's send control and verify that no wallet payment is dispatched + Restart install B and wait for Paykit synchronization; verify the request is not automatically presented or offered as payable + Open Payment Requests on install B and verify the recorded request remains in history without a Pay action + Restart install A and wait for Paykit synchronization + Open Payment Requests on install A and pay the recorded request + Verify the payment success screen (testTag "SendSuccess") and exactly one wallet payment to the issuer + + From ae0764a0f5e43dff5416521f5c1c814bcb65086d Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 1 Oct 2026 08:03:29 -0500 Subject: [PATCH 21/51] fix: prune completed paykit acceptance records --- .../PaykitPaymentRequestPresentationStore.kt | 19 +++++++ .../repositories/PaykitPaymentRequestRepo.kt | 22 ++++++++ ...ykitPaymentRequestPresentationStoreTest.kt | 30 +++++++++++ .../PaykitPaymentRequestRepoTest.kt | 53 +++++++++++++++++++ 4 files changed, 124 insertions(+) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt index abc97b191d..1b947eac66 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt @@ -90,6 +90,25 @@ class PaykitPaymentRequestPresentationStore @Inject constructor( ids } + suspend fun removeAcceptedOneTimeIds( + identity: String, + ids: Set, + ): Set = + mutex.withLock { + val normalizedIdentity = requireNotNull(PubkyPublicKeyFormat.normalized(identity)) + val current = loadAcceptedOneTimeIdsByIdentity() + val storedIds = current[normalizedIdentity].orEmpty() + val remaining = storedIds - ids + if (remaining == storedIds) return@withLock remaining + val state = if (remaining.isEmpty()) { + current - normalizedIdentity + } else { + current + (normalizedIdentity to remaining) + } + keychain.upsertString(ACCEPTED_KEY, Json.encodeToString(state)) + remaining + } + private fun loadAcceptedOneTimeIdsByIdentity(): Map> { val value = keychain.loadString(ACCEPTED_KEY) ?: return emptyMap() return runCatching { Json.decodeFromString>>(value) } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index b786220988..0c2cd64139 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -1062,6 +1062,8 @@ class PaykitPaymentRequestRepo @Inject constructor( val history = (recurringHistory + oneTimeHistory) .sortedByDescending { it.createdAt } if (!isCurrentState(generation, expectedIdentity) || expectedIdentity == null) return + pruneAcceptedOneTimeRequestIds(records, expectedIdentity, generation) + if (!isCurrentState(generation, expectedIdentity)) return receivedContacts = ReceivedContactsSnapshot(generation, expectedIdentity, contacts) val subscriptionStateChanged = subscriptionAcceptedAt != updatedSubscriptionAcceptedAt || @@ -1350,6 +1352,26 @@ class PaykitPaymentRequestRepo @Inject constructor( scheduleExpirationLocked() } + private suspend fun pruneAcceptedOneTimeRequestIds( + records: List, + identity: String, + generation: Long, + ) { + val finishedIds = records.filter { + it.localRole == PaymentRequestLocalRole.PAYER && it.terms?.recurrence == null && + it.state in setOf( + PaymentRequestLifecycleState.PROOF_SUBMITTED, + PaymentRequestLifecycleState.CANCELED, + PaymentRequestLifecycleState.REJECTED, + ) + }.mapTo(mutableSetOf()) { PaykitPaymentRequestId(it.paymentRequestId, it.counterparty) } + .intersect(acceptedOneTimeRequestIds) + if (finishedIds.isEmpty()) return + runSuspendCatching { presentationStore.removeAcceptedOneTimeIds(identity, finishedIds) } + .onSuccess { if (isCurrentState(generation, identity)) acceptedOneTimeRequestIds = it } + .onFailure { Logger.warn("Failed to prune accepted Paykit payment requests", it, context = TAG) } + } + private suspend fun prunePresentedRequestIds(requests: List) { val requestIds = requests.mapTo(mutableSetOf()) { it.id } val prunedIds = presentedRequestIds.intersect(requestIds) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt index 4d454033df..7922c4c27e 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt @@ -8,6 +8,7 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.junit.Test import org.mockito.kotlin.any +import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -122,6 +123,32 @@ class PaykitPaymentRequestPresentationStoreTest : BaseUnitTest() { assertEquals(emptySet(), reopened.loadAcceptedOneTimeIds(IDENTITY)) } + @Test + fun `acceptance cleanup removes only specified ids from the current stored identity`() = test { + val keychain = mock() + val key = Keychain.Key.PAYKIT_ACCEPTED_PAYMENT_REQUESTS.name + var stored: String? = null + whenever(keychain.loadString(key)).thenAnswer { stored } + whenever { keychain.upsertString(eq(key), any()) }.thenAnswer { + stored = it.getArgument(1) + Unit + } + val sut = PaykitPaymentRequestPresentationStore(keychain) + val finished = PaykitPaymentRequestId("finished", COUNTERPARTY) + val live = PaykitPaymentRequestId("live", COUNTERPARTY) + sut.addAcceptedOneTimeId(IDENTITY, finished) + sut.addAcceptedOneTimeId(COUNTERPARTY, finished) + sut.addAcceptedOneTimeId(IDENTITY, live) + + assertEquals(setOf(live), sut.removeAcceptedOneTimeIds(IDENTITY, setOf(finished))) + val reopened = PaykitPaymentRequestPresentationStore(keychain) + assertEquals(setOf(live), reopened.loadAcceptedOneTimeIds(IDENTITY)) + assertEquals(setOf(finished), reopened.loadAcceptedOneTimeIds(COUNTERPARTY)) + clearInvocations(keychain) + reopened.removeAcceptedOneTimeIds(IDENTITY, setOf(finished)) + verify(keychain, never()).upsertString(any(), any()) + } + @Test fun `unreadable accepted one time ownership is preserved and fails closed`() = test { val keychain = mock() @@ -133,6 +160,9 @@ class PaykitPaymentRequestPresentationStoreTest : BaseUnitTest() { assertFailsWith { sut.addAcceptedOneTimeId(IDENTITY, PaykitPaymentRequestId("request", COUNTERPARTY)) } + assertFailsWith { + sut.removeAcceptedOneTimeIds(IDENTITY, setOf(PaykitPaymentRequestId("request", COUNTERPARTY))) + } verify(keychain, never()).upsertString(any(), any()) } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index c1c72e3528..60233e3059 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -574,6 +574,59 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(sut.ensurePaymentAllowed(request).isFailure) } + @Test + fun `acceptance cleanup removes only confirmed finished requests`() = test { + val records = listOf( + paymentRequestRecord(id = "paid", state = PaymentRequestLifecycleState.PROOF_SUBMITTED), + paymentRequestRecord(id = "canceled", state = PaymentRequestLifecycleState.CANCELED), + paymentRequestRecord(id = "rejected", state = PaymentRequestLifecycleState.REJECTED), + paymentRequestRecord( + id = "retry", + state = PaymentRequestLifecycleState.ACCEPTED, + expiresAt = "2020-01-01T00:00:00Z", + ), + paymentRequestRecord(id = "recovery", state = PaymentRequestLifecycleState.RECOVERY_REQUIRED), + paymentRequestRecord(id = "conflict", state = PaymentRequestLifecycleState.INVALID_CONFLICT), + ) + val ids = records.mapTo(mutableSetOf()) { PaykitPaymentRequestId(it.paymentRequestId, it.counterparty) } + + PaykitPaymentRequestId("missing", COUNTERPARTY) + val finished = setOf("paid", "canceled", "rejected") + .mapTo(mutableSetOf()) { PaykitPaymentRequestId(it, COUNTERPARTY) } + whenever(presentationStore.loadAcceptedOneTimeIds(LOCAL_IDENTITY)).thenReturn(ids) + sut.clear() + sut.activate(LOCAL_IDENTITY) + sut.refresh().getOrThrow() + verify(presentationStore, never()).removeAcceptedOneTimeIds(any(), any()) + + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(records) + whenever(presentationStore.removeAcceptedOneTimeIds(LOCAL_IDENTITY, finished)).thenReturn(ids - finished) + sut.refresh().getOrThrow() + verify(presentationStore).removeAcceptedOneTimeIds(LOCAL_IDENTITY, finished) + val retry = sut.pendingRequests.value.single() + assertEquals("retry", retry.paymentRequestId) + sut.ensurePaymentAllowed(retry).getOrThrow() + sut.refresh().getOrThrow() + verify(presentationStore, times(1)).removeAcceptedOneTimeIds(any(), any()) + } + + @Test + fun `failed acceptance cleanup retains ids and retries`() = test { + val record = paymentRequestRecord(state = PaymentRequestLifecycleState.PROOF_SUBMITTED) + val ids = setOf(PaykitPaymentRequestId(record.paymentRequestId, record.counterparty)) + whenever(presentationStore.loadAcceptedOneTimeIds(LOCAL_IDENTITY)).thenReturn(ids) + whenever(presentationStore.removeAcceptedOneTimeIds(LOCAL_IDENTITY, ids)) + .thenThrow(IllegalStateException("disk")).thenReturn(emptySet()) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) + sut.clear() + sut.activate(LOCAL_IDENTITY) + + sut.refresh().getOrThrow() + assertTrue(sut.pendingRequests.value.isEmpty()) + sut.refresh().getOrThrow() + sut.refresh().getOrThrow() + verify(presentationStore, times(2)).removeAcceptedOneTimeIds(LOCAL_IDENTITY, ids) + } + @Test fun `final authorization rechecks identity after asynchronous peer lookup`() = test { restoreAcceptedRequest() From d2c5a03b4ed41a5b06ce423046e76e2777d3b273 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 1 Oct 2026 09:50:01 -0500 Subject: [PATCH 22/51] fix: preserve paykit payment recovery and contact choices --- .../main/java/to/bitkit/data/CacheStore.kt | 12 ++++ .../bitkit/models/PaykitPaymentStateBackup.kt | 1 + .../to/bitkit/repositories/ActivityRepo.kt | 2 + .../java/to/bitkit/repositories/BackupRepo.kt | 2 + .../repositories/PaykitPaymentProofRepo.kt | 14 ++++- .../PaykitPaymentRequestPresentationStore.kt | 10 +++- .../repositories/PaykitPaymentRequestRepo.kt | 29 ++++++++-- .../bitkit/repositories/PrivatePaykitRepo.kt | 5 +- .../java/to/bitkit/services/CoreService.kt | 8 ++- .../to/bitkit/services/PaykitSdkService.kt | 4 +- .../models/PaykitPaymentStateBackupTest.kt | 3 + .../PaykitPaymentProofRepoTest.kt | 15 +++++ ...ykitPaymentRequestPresentationStoreTest.kt | 13 +++-- .../PaykitPaymentRequestRepoTest.kt | 55 +++++++++++++------ .../repositories/PrivatePaykitRepoTest.kt | 13 ++--- .../ActivityServicePaykitContactsTest.kt | 14 +++++ journeys/payment-requests/README.md | 5 ++ journeys/pubky-marketplace/wallet-leg.xml | 3 + 18 files changed, 165 insertions(+), 43 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index 67e0becf8f..1fb975cf4b 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -59,6 +59,14 @@ class CacheStore internal constructor( store.updateData { it.copy(onchainAddress = address) } } + suspend fun setActivityContactDetached(activityId: String, walletId: String, detached: Boolean) { + val id = scopedActivityId(walletId, activityId) + store.updateData { + val contacts = it.detachedActivityContacts + it.copy(detachedActivityContacts = if (detached) contacts + id else contacts - id) + } + } + suspend fun saveBolt11(bolt11: String, paymentHash: String) { store.updateData { it.copy(bolt11 = bolt11, bolt11PaymentHash = paymentHash) } } @@ -171,6 +179,7 @@ data class AppCacheData( val balance: BalanceState? = null, val backupStatuses: Map = mapOf(), val deletedActivities: List = listOf(), + val detachedActivityContacts: Set = emptySet(), val pendingBoostActivities: List = listOf(), val backgroundReceive: NewTransactionSheetDetails? = null, val addressSearchLastUsedReceiveIndexes: Map = mapOf(), @@ -180,6 +189,9 @@ data class AppCacheData( /** LNURL-pay comments by payment hash, kept until the sent payment's activity stores them. */ val pendingLightningMessages: Map = mapOf(), ) { + fun isContactDetached(activityId: String, walletId: String): Boolean = + scopedActivityId(walletId, activityId) in detachedActivityContacts + fun isActivityDeleted(activityId: String, walletId: String): Boolean = scopedActivityId(walletId, activityId) in deletedActivities || walletId == WalletScope.default && activityId in deletedActivities diff --git a/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt b/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt index b9429d238a..205a881e1f 100644 --- a/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt +++ b/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt @@ -16,6 +16,7 @@ import kotlin.time.Instant data class PaykitPaymentStateBackup( val subscriptions: Map, val pendingProofs: List, + val acceptedOneTimeRequests: Map>? = null, ) { @Serializable data class Subscription( diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 20ee50dbff..af0023109b 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -508,6 +508,7 @@ class ActivityRepo @Inject constructor( return@runCatching } + cacheStore.setActivityContactDetached(activity.rawId(), walletId, detached = false) val updatedAt = nowTimestamp().epochSecond.toULong() val updatedActivity = activity.withContact(normalizedKey, updatedAt) updateActivity(updatedActivity.rawId(), updatedActivity).getOrThrow() @@ -539,6 +540,7 @@ class ActivityRepo @Inject constructor( } if (activity.contact() == null) return@runCatching + cacheStore.setActivityContactDetached(activity.rawId(), walletId, detached = true) val updatedAt = nowTimestamp().epochSecond.toULong() val updatedActivity = activity.withContact(null, updatedAt) updateActivity(updatedActivity.rawId(), updatedActivity).getOrThrow() diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index a45f162c62..a0ac179657 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -641,6 +641,7 @@ class BackupRepo @Inject constructor( paykitPaymentState = PaykitPaymentStateBackup( subscriptions = paykitPresentationStore.backupSnapshot(), pendingProofs = paykitPaymentProofRepo.get().backupSnapshot(), + acceptedOneTimeRequests = paykitPresentationStore.acceptedOneTimeBackupSnapshot(), ), ) @@ -781,6 +782,7 @@ class BackupRepo @Inject constructor( paykitPaymentRequestRepo.get().clear() paykitPresentationStore.restoreBackup(it.subscriptions) paykitPaymentProofRepo.get().restoreBackup(it.pendingProofs) + paykitPresentationStore.restoreAcceptedOneTimeRequests(it.acceptedOneTimeRequests.orEmpty()) } db.transferDao().upsert(parsed.transfers) watchOnlyAccountRepo.restore( diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index a46552f1e4..593f6f5b05 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -273,8 +273,18 @@ class PaykitPaymentProofRepo @Inject constructor( return@withContext } - val identity = currentIdentity() ?: return@withContext - val fallbackProof = runSuspendCatching { + val prepared = operationMutex.withLock { + runSuspendCatching { + loadProofs().filter { + it.requestId == request.id && it.paymentAppId == paymentAppId && + it.paymentEndpointIdentifier == paymentEndpointIdentifier && + it.kind == PaykitPaymentProofKind.Onchain && it.paymentStarted && + it.paymentIdentifier == null && it.proofData == null + }.singleOrNull() + }.getOrNull() + } + val identity = prepared?.identity ?: currentIdentity() ?: return@withContext + val fallbackProof = prepared ?: runSuspendCatching { pendingProof(request, paymentEndpointIdentifier, paymentAppId, PaykitPaymentProofKind.Onchain) }.getOrNull() operationMutex.withLock { diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt index 1b947eac66..a50fa1d099 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt @@ -74,7 +74,6 @@ class PaykitPaymentRequestPresentationStore @Inject constructor( } } - /** Local execution ownership is never exported or imported by wallet backups. */ fun loadAcceptedOneTimeIds(identity: String): Set { val normalizedIdentity = PubkyPublicKeyFormat.normalized(identity) ?: return emptySet() return loadAcceptedOneTimeIdsByIdentity()[normalizedIdentity].orEmpty() @@ -87,6 +86,7 @@ class PaykitPaymentRequestPresentationStore @Inject constructor( val ids = current[normalizedIdentity].orEmpty() + id val state = current + (normalizedIdentity to ids) keychain.upsertString(ACCEPTED_KEY, Json.encodeToString(state)) + _backupStateVersion.update { it + 1 } ids } @@ -106,9 +106,17 @@ class PaykitPaymentRequestPresentationStore @Inject constructor( current + (normalizedIdentity to remaining) } keychain.upsertString(ACCEPTED_KEY, Json.encodeToString(state)) + _backupStateVersion.update { it + 1 } remaining } + fun acceptedOneTimeBackupSnapshot(): Map> = loadAcceptedOneTimeIdsByIdentity() + + suspend fun restoreAcceptedOneTimeRequests(requests: Map>) = mutex.withLock { + keychain.upsertString(ACCEPTED_KEY, Json.encodeToString(requests)) + _backupStateVersion.update { it + 1 } + } + private fun loadAcceptedOneTimeIdsByIdentity(): Map> { val value = keychain.loadString(ACCEPTED_KEY) ?: return emptyMap() return runCatching { Json.decodeFromString>>(value) } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 0c2cd64139..c09de40097 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -5,6 +5,7 @@ package to.bitkit.repositories import com.synonym.paykit.LinkedPeerState import com.synonym.paykit.OutboundPrivateCounterpartySendReport import com.synonym.paykit.OutboundPrivateMessageStatus +import com.synonym.paykit.PaykitException import com.synonym.paykit.PaymentRequestLifecycleState import com.synonym.paykit.PaymentRequestLocalRole import com.synonym.paykit.PaymentRequestRecord @@ -836,13 +837,27 @@ class PaykitPaymentRequestRepo @Inject constructor( val identity = activeIdentity ?: throw PaykitPaymentRequestError.RequestUnavailable val generation = stateGeneration.get() ensurePaymentAllowed(it, forExecution = false).getOrThrow() - paykitSdkService.acceptPaymentRequest( - counterparty = it.counterparty, - paymentRequestId = it.paymentRequestId, - ) + val alreadySaved = it.id in presentationStore.loadAcceptedOneTimeIds(identity) val acceptedIds = presentationStore.addAcceptedOneTimeId(identity, it.id) if (!isCurrentState(generation, identity)) throw PaykitPaymentRequestError.RequestUnavailable acceptedOneTimeRequestIds = acceptedIds + runSuspendCatching { + paykitSdkService.acceptPaymentRequest( + counterparty = it.counterparty, + paymentRequestId = it.paymentRequestId, + ) + }.onFailure { error -> + // Acceptance may already be committed. Reconcile before discarding its owner. + val uncertain = when (error) { + is PaykitException.Transport, is PaykitException.Storage, is PaykitException.Identity -> true + else -> false + } + if (!alreadySaved && !uncertain) { + val remaining = presentationStore.removeAcceptedOneTimeIds(identity, setOf(it.id)) + if (isCurrentState(generation, identity)) acceptedOneTimeRequestIds = remaining + } + }.getOrThrow() + if (!isCurrentState(generation, identity)) throw PaykitPaymentRequestError.RequestUnavailable }.getOrThrow() } }.onFailure { @@ -1188,7 +1203,11 @@ class PaykitPaymentRequestRepo @Inject constructor( activatedGeneration == stateGeneration.get() && request.id in acceptedOneTimeRequestIds private fun hasCurrentExecutionContext(request: PaykitPaymentRequest): Boolean { - if (request.billingPeriod == null) return hasLocalAcceptance(request) + if (request.billingPeriod == null) { + return hasLocalAcceptance(request) && _paymentRequestHistory.value.any { + it.id == request.id && it.lifecycleState == PaymentRequestLifecycleState.ACCEPTED + } + } return activatedGeneration == stateGeneration.get() && _subscriptions.value.any { it.isPayer && it.lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING && request.belongsTo(it) } diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index cad5b717bb..74ca44193e 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -824,7 +824,10 @@ class PrivatePaykitRepo @Inject constructor( for (publicKey in publicKeys.distinct()) { val link = runSuspendCatching { paykitSdkService.ensureLinkWithPeer(publicKey) } .onFailure { Logger.warn("Failed to prepare private Paykit link during '$reason'", it, context = TAG) } - if (link.exceptionOrNull() is PaykitException.NotFound) continue + if (link.isFailure) { + if (link.exceptionOrNull() !is PaykitException.NotFound) linkRetryKeys += publicKey + continue + } linkRetryKeys += publicKey runSuspendCatching { privatePaymentListUpdate(publicKey, forceRefreshLightning) } .onSuccess { updates += it } diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 79afc00afb..254fdd942f 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -618,6 +618,7 @@ class ActivityService( var complete = true for (activity in activities) { if (!isCurrentPaykitContactBackfill(resolver, snapshot)) return@background changed + if (cacheStore.data.first().isContactDetached(activity.rawId(), activity.walletId())) continue val contact = when (activity) { is Activity.Lightning -> receivedPaykitContact(activity.v1, contacts) is Activity.Onchain -> { @@ -627,7 +628,7 @@ class ActivityService( } } if (!isCurrentPaykitContactBackfill(resolver, snapshot)) return@background changed - if (contact != null) { + if (contact != null && !cacheStore.data.first().isContactDetached(activity.rawId(), activity.walletId())) { val updated = when (activity) { is Activity.Lightning -> Activity.Lightning(activity.v1.copy(contact = contact)) is Activity.Onchain -> Activity.Onchain(activity.v1.copy(contact = contact)) @@ -884,7 +885,9 @@ class ActivityService( val contact = existingActivity ?.takeIf { it is Activity.Lightning } ?.let { (it as Activity.Lightning).v1.contact } - ?: payment.takeUnless { it.status == PaymentStatus.FAILED }?.let { + ?: payment.takeUnless { + it.status == PaymentStatus.FAILED || cacheStore.data.first().isContactDetached(it.id, defaultWalletId) + }?.let { privatePaykitContactPublicKeyForReceivedInvoicePaymentHash(it.id, it.direction) } @@ -1332,6 +1335,7 @@ class ActivityService( val existingContact = existingOnchainActivity?.v1?.contact val contact = existingContact ?: if ( payment.direction == PaymentDirection.INBOUND && payment.status != PaymentStatus.FAILED && + !cacheStore.data.first().isContactDetached(payment.id, defaultWalletId) && !details?.outputs.isNullOrEmpty() ) { privatePaykitContactResolver.get().contactPublicKeyForPrivateOnchainAddresses( diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 3316316e99..1a04128ef4 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -514,7 +514,7 @@ class PaykitSdkService @Inject constructor( suspend fun fetchPubkyFollows(publicKey: String): List { isSetup.await() return operationLock.withLock { - handle().fetchPubkyFollows(publicKey, maxEntries = 1000u) + handle().fetchPubkyFollows(publicKey, maxEntries = 10_000u) } } @@ -1303,7 +1303,7 @@ internal class PaykitSdkSessionProvider( } fun setPaykitIdentitySecretKey(key: PaykitIdentitySecretKey) = synchronized(lock) { - if ((paykitIdentitySecretKey?.keyGeneration() ?: 1uL) != key.keyGeneration()) { + if (paykitIdentitySecretKey?.keyGeneration() != key.keyGeneration()) { liveSessionAccess = null } paykitIdentitySecretKey = key diff --git a/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt b/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt index edc194cd23..2b958d3061 100644 --- a/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt +++ b/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt @@ -6,6 +6,7 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.junit.Test import to.bitkit.repositories.PaykitPaymentProofKind +import to.bitkit.repositories.PaykitPaymentRequestId import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -36,10 +37,12 @@ class PaykitPaymentStateBackupTest { ) }, pendingProofs = listOf(PaykitPaymentStateBackup.Proof(restored)), + acceptedOneTimeRequests = mapOf("alice" to setOf(PaykitPaymentRequestId("one-time", "bob"))), ) val decoded = Json.decodeFromString(Json.encodeToString(rebuilt)) assertEquals(restored, decoded.pendingProofs.single().restored()) assertEquals(backup.subscriptions, decoded.subscriptions) + assertEquals(rebuilt.acceptedOneTimeRequests, decoded.acceptedOneTimeRequests) val hardwareProof = restored.copy(onchainWalletId = "hardware-wallet") val hardwareBackup = PaykitPaymentStateBackup.Proof(hardwareProof) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 083dd620e8..4c0e5081f3 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -434,6 +434,21 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(storedProofs.isEmpty()) } + @Test + fun `broadcast transaction id is retained without a live identity`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val repo = paymentProofRepo() + val txid = "ab".repeat(32) + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + whenever(paykitSdkService.identityStatus()).thenReturn(null) + + repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue, "bitkit") + + assertEquals(txid, storedProofs.single().paymentIdentifier) + assertEquals(txid, storedProofs.single().proofData) + } + @Test fun `onchain proof submits without prepared proof`() = test { val txid = "ab".repeat(32) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt index 7922c4c27e..015d11d2da 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt @@ -94,11 +94,11 @@ class PaykitPaymentRequestPresentationStoreTest : BaseUnitTest() { } @Test - fun `accepted one time ownership survives reopening but never enters wallet backup`() = test { + fun `accepted one time ownership survives reopening and wallet restore`() = test { val keychain = mock() val values = mutableMapOf() whenever(keychain.loadString(any())).thenAnswer { values[it.getArgument(0)] } - whenever { keychain.upsertString(any(), any()) }.thenAnswer { + whenever(keychain.upsertString(any(), any())).thenAnswer { values[it.getArgument(0)] = it.getArgument(1) Unit } @@ -114,13 +114,16 @@ class PaykitPaymentRequestPresentationStoreTest : BaseUnitTest() { assertEquals(setOf(requestId, secondId), reopened.loadAcceptedOneTimeIds(IDENTITY)) assertEquals(setOf(secondId), reopened.loadAcceptedOneTimeIds(COUNTERPARTY)) assertEquals(emptyMap(), reopened.backupSnapshot()) - assertEquals(0L, sut.backupStateVersion.value) + assertEquals(3L, sut.backupStateVersion.value) + val backup = reopened.acceptedOneTimeBackupSnapshot() reopened.restoreBackup(emptyMap()) assertEquals(setOf(requestId, secondId), reopened.loadAcceptedOneTimeIds(IDENTITY)) values.clear() reopened.restoreBackup(emptyMap()) - assertEquals(emptySet(), reopened.loadAcceptedOneTimeIds(IDENTITY)) + reopened.restoreAcceptedOneTimeRequests(backup) + assertEquals(setOf(requestId, secondId), reopened.loadAcceptedOneTimeIds(IDENTITY)) + assertEquals(setOf(secondId), reopened.loadAcceptedOneTimeIds(COUNTERPARTY)) } @Test @@ -129,7 +132,7 @@ class PaykitPaymentRequestPresentationStoreTest : BaseUnitTest() { val key = Keychain.Key.PAYKIT_ACCEPTED_PAYMENT_REQUESTS.name var stored: String? = null whenever(keychain.loadString(key)).thenAnswer { stored } - whenever { keychain.upsertString(eq(key), any()) }.thenAnswer { + whenever(keychain.upsertString(eq(key), any())).thenAnswer { stored = it.getArgument(1) Unit } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 60233e3059..1e27cc2dc6 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -9,6 +9,7 @@ import com.synonym.bitkitcore.validateBitcoinAddress import com.synonym.paykit.IdentityStatus import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState +import com.synonym.paykit.PaykitException import com.synonym.paykit.PaymentDeadline import com.synonym.paykit.PaymentProofRecord import com.synonym.paykit.PaymentReference @@ -107,6 +108,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(presentationStore.addAcceptedOneTimeId(any(), any())).thenAnswer { setOf(it.getArgument(1)) } + whenever(presentationStore.removeAcceptedOneTimeIds(any(), any())).thenReturn(emptySet()) whenever( presentationStore.loadSubscriptionState(any()) ).thenReturn(PaykitSubscriptionPresentationState()) @@ -630,7 +632,8 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `final authorization rechecks identity after asynchronous peer lookup`() = test { restoreAcceptedRequest() - whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(paymentRequestRecord())) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())) + .thenReturn(listOf(paymentRequestRecord(state = PaymentRequestLifecycleState.ACCEPTED))) sut.refresh().getOrThrow() val request = sut.pendingRequests.value.single() val checking = CompletableDeferred() @@ -651,7 +654,8 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `queued identity switch invalidates ownership before acquiring the repository mutex`() = test { restoreAcceptedRequest() - whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(paymentRequestRecord())) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())) + .thenReturn(listOf(paymentRequestRecord(state = PaymentRequestLifecycleState.ACCEPTED))) sut.refresh().getOrThrow() val request = sut.pendingRequests.value.single() val refreshing = CompletableDeferred() @@ -673,24 +677,40 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `unknown acceptance or failed persistence never grants local ownership`() = test { + fun `failed intent persistence prevents remote acceptance`() = test { val proposed = paymentRequestRecord() val accepted = proposed.copy(state = PaymentRequestLifecycleState.ACCEPTED) - whenever(paykitSdkService.acceptPaymentRequest(any(), any())) - .thenThrow(IllegalStateException("unknown completion")).thenReturn(accepted) whenever(presentationStore.addAcceptedOneTimeId(any(), any())).thenThrow(IllegalStateException("disk")) - for (sdkSucceeded in listOf(false, true)) { - whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(proposed)) - sut.refresh().getOrThrow() - assertTrue(sut.accept(sut.pendingRequests.value.single()).isFailure) - if (!sdkSucceeded) verify(presentationStore, never()).addAcceptedOneTimeId(any(), any()) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(proposed)) + sut.refresh().getOrThrow() + assertTrue(sut.accept(sut.pendingRequests.value.single()).isFailure) + verify(paykitSdkService, never()).acceptPaymentRequest(any(), any()) - whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(accepted)) - sut.refresh().getOrThrow() - assertTrue(sut.pendingRequests.value.isEmpty()) - assertTrue(sut.accept(sut.paymentRequestHistory.value.single()).isFailure) - assertTrue(sut.ensurePaymentAllowed(sut.paymentRequestHistory.value.single()).isFailure) - } + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(accepted)) + sut.refresh().getOrThrow() + assertTrue(sut.pendingRequests.value.isEmpty()) + assertTrue(sut.accept(sut.paymentRequestHistory.value.single()).isFailure) + assertTrue(sut.ensurePaymentAllowed(sut.paymentRequestHistory.value.single()).isFailure) + } + + @Test + fun `lost acceptance response is reconciled from shared state`() = test { + val proposed = paymentRequestRecord() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(proposed)) + whenever(paykitSdkService.acceptPaymentRequest(any(), any())) + .thenAnswer { throw PaykitException.Transport("transport_error", "response lost") } + sut.refresh().getOrThrow() + val request = sut.pendingRequests.value.single() + assertTrue(sut.accept(request).isFailure) + assertTrue(sut.ensurePaymentAllowed(request).isFailure) + verify(presentationStore, never()).removeAcceptedOneTimeIds(any(), any()) + + whenever(paykitSdkService.allPaymentRequests(anyOrNull())) + .thenReturn(listOf(proposed.copy(state = PaymentRequestLifecycleState.ACCEPTED))) + sut.refresh().getOrThrow() + val retry = sut.pendingRequests.value.single() + sut.accept(retry).getOrThrow() + sut.ensurePaymentAllowed(retry).getOrThrow() } @Test @@ -724,6 +744,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `second install cannot accept stale proposal or resume first installs acceptance`() = test { val otherStore = mock() + whenever(otherStore.removeAcceptedOneTimeIds(any(), any())).thenReturn(emptySet()) whenever(otherStore.loadSubscriptionState(any())).thenReturn(PaykitSubscriptionPresentationState()) val other = PaykitPaymentRequestRepo( testDispatcher, @@ -759,7 +780,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(other.claimForPayment(remoteAccepted).isFailure) assertTrue(other.ensurePaymentAllowed(remoteAccepted).isFailure) verify(paykitSdkService, never()).claimPaymentRequestForExecution(any(), any()) - verify(otherStore, never()).addAcceptedOneTimeId(any(), any()) + verify(otherStore).removeAcceptedOneTimeIds(eq(LOCAL_IDENTITY), eq(setOf(stale.id))) sut.refresh().getOrThrow() sut.accept(sut.pendingRequests.value.single()).getOrThrow() other.clear() diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 5ba44c2201..d5b0a7ede4 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -233,24 +233,21 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `prepareSavedContacts succeeds when link preparation fails but SDK queues reservations`() = test { + fun `prepareSavedContacts defers reservations while link preparation is unavailable`() = test { settingsData.value = SettingsData( sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false, publicPaykitOnchainEnabled = true, ) - whenever { paykitSdkService.ensureLinkWithPeer(CONTACT_KEY) }.thenAnswer { - throw PrivatePaykitTestAppError("still linking") + whenever(paykitSdkService.ensureLinkWithPeer(CONTACT_KEY)).thenAnswer { + throw PaykitException.Transport("offline", "Unavailable homeserver") } val result = sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - verifyBlocking(paykitSdkService) { syncPrivatePaymentListsWithReservations(any(), eq(false)) } - assertEquals( - true, - cacheData.value.contacts.getValue(CONTACT_KEY).hasPublishedPrivatePaymentList, - ) + verify(paykitSdkService, never()).syncPrivatePaymentListsWithReservations(any(), any()) + verify(addressReservationRepo, never()).currentOrRotatedAddress(any()) } @Test diff --git a/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt b/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt index 79c2df9309..d9d3dcc369 100644 --- a/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt +++ b/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt @@ -36,6 +36,7 @@ import to.bitkit.data.PrivatePaykitCacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.ext.create +import to.bitkit.ext.scopedActivityId import to.bitkit.repositories.PaykitPaymentRequestRepo import to.bitkit.repositories.PaykitReceivedPaymentContacts import to.bitkit.repositories.PaykitReceivedPaymentContactsTest.Companion.BUYER @@ -123,6 +124,19 @@ class ActivityServicePaykitContactsTest : BaseUnitTest() { assertTrue(updates.isEmpty()) } + @Test + fun `backfill respects a manually detached contact`() = coreTest { + val original = lightning() + rows = listOf(Activity.Lightning(original)) + whenever(contacts.contactsForPaymentHash(original.id)).thenReturn(setOf(BUYER)) + cacheData.value = cacheData.value.copy( + detachedActivityContacts = setOf(scopedActivityId(original.walletId, original.id)), + ) + + assertFalse(sut.backfillPaykitContacts()) + assertTrue(updates.isEmpty()) + } + @Test fun `backfill refuses ambiguity across stored receiving address and other outputs`() = coreTest { rows = listOf(Activity.Onchain(onchain(address = "request-address"))) diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index b48f8a2df7..09349b3cd0 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -26,6 +26,11 @@ Rejected fixture shapes stay in unit tests because Bitkit intentionally does not ## Accepting install +Acceptance intent is saved before the remote operation and included in wallet backups. +An interrupted response is reconciled against the shared request before payment. Restoring +the wallet restores its pending acceptances; this does not support running the same wallet +on multiple devices concurrently. + `accepted-device-ownership.xml` extends the failing LNURL fixture to two separate installs sharing one Pubky identity and App ID. Only the accepting install may retry after restart; the other keeps the accepted request in history without payment controls. Confirm acceptance in shared request diff --git a/journeys/pubky-marketplace/wallet-leg.xml b/journeys/pubky-marketplace/wallet-leg.xml index b11efee9c2..87d7dabb61 100644 --- a/journeys/pubky-marketplace/wallet-leg.xml +++ b/journeys/pubky-marketplace/wallet-leg.xml @@ -50,5 +50,8 @@ Tap the seller's latest received activity (testTag "ActivityShort-0"), then tap transaction details (testTag "ActivityTxDetails") Verify the transaction id (testTag "TXID") matches the fixture transaction Capture the final activity, transaction id, confirmation height, and completed purchase evidence + Return to the received activity details and tap Detach + Wait for Paykit synchronization, then restart the seller app and reopen the received activity + Verify the activity no longer identifies the buyer and offers Assign instead of Detach From bef908f0624856b66b29c03911cbaa595cf09fb3 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 1 Oct 2026 10:03:16 -0500 Subject: [PATCH 23/51] refactor: simplify paykit contact backfill --- .../repositories/PaykitPaymentRequestRepo.kt | 4 ++- .../java/to/bitkit/services/CoreService.kt | 30 +++++++++++-------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index c09de40097..cb216f16aa 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -849,7 +849,9 @@ class PaykitPaymentRequestRepo @Inject constructor( }.onFailure { error -> // Acceptance may already be committed. Reconcile before discarding its owner. val uncertain = when (error) { - is PaykitException.Transport, is PaykitException.Storage, is PaykitException.Identity -> true + is PaykitException.Transport, + is PaykitException.Storage, + is PaykitException.Identity -> true else -> false } if (!alreadySaved && !uncertain) { diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 254fdd942f..9d96292365 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -619,20 +619,10 @@ class ActivityService( for (activity in activities) { if (!isCurrentPaykitContactBackfill(resolver, snapshot)) return@background changed if (cacheStore.data.first().isContactDetached(activity.rawId(), activity.walletId())) continue - val contact = when (activity) { - is Activity.Lightning -> receivedPaykitContact(activity.v1, contacts) - is Activity.Onchain -> { - val (contact, hydrated) = receivedPaykitContact(activity.v1) - complete = complete && hydrated - contact - } - } + val (updated, hydrated) = attributedPaykitActivity(activity, contacts) + complete = complete && hydrated if (!isCurrentPaykitContactBackfill(resolver, snapshot)) return@background changed - if (contact != null && !cacheStore.data.first().isContactDetached(activity.rawId(), activity.walletId())) { - val updated = when (activity) { - is Activity.Lightning -> Activity.Lightning(activity.v1.copy(contact = contact)) - is Activity.Onchain -> Activity.Onchain(activity.v1.copy(contact = contact)) - } + if (updated != null && !cacheStore.data.first().isContactDetached(activity.rawId(), activity.walletId())) { updateActivity(activityId = activity.rawId(), activity = updated) changed = true } @@ -651,6 +641,20 @@ class ActivityService( paykitActivityRevision == snapshot.activityRevision && resolver.reservationVersion == snapshot.reservationVersion + private suspend fun attributedPaykitActivity( + activity: Activity, + contacts: PaykitReceivedPaymentContacts, + ): Pair = when (activity) { + is Activity.Lightning -> { + val contact = receivedPaykitContact(activity.v1, contacts) + contact?.let { Activity.Lightning(activity.v1.copy(contact = it)) } to true + } + is Activity.Onchain -> { + val (contact, hydrated) = receivedPaykitContact(activity.v1) + contact?.let { Activity.Onchain(activity.v1.copy(contact = it)) } to hydrated + } + } + private fun receivedPaykitContact(row: LightningActivity, contacts: PaykitReceivedPaymentContacts): String? { if (row.contact != null || row.txType != PaymentType.RECEIVED || row.status == PaymentState.FAILED) return null return contacts.contactsForPaymentHash(row.id).singleOrNull() From d2525a5a906592f71945e207813ab0e049336192 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 1 Oct 2026 10:41:28 -0500 Subject: [PATCH 24/51] chore: update Paykit to rc59 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f1edd44eb0..35ddfa5263 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,7 +22,7 @@ appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.5.18" } -paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc58" } +paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc59" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } From af7c2bf095bce027e0fa90bf49bb2161cf7ee834 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 1 Oct 2026 12:13:18 -0500 Subject: [PATCH 25/51] fix: preserve private sharing settings during contact cleanup --- .../bitkit/repositories/PrivatePaykitRepo.kt | 4 +- .../bitkit/repositories/PublicPaykitRepo.kt | 8 +- .../to/bitkit/services/PaykitSdkService.kt | 15 +++- .../repositories/PrivatePaykitRepoTest.kt | 51 ++++++++++-- .../repositories/PublicPaykitRepoTest.kt | 22 ++---- .../services/PaykitKeyGenerationTest.kt | 4 +- .../bitkit/services/PaykitSdkServiceTest.kt | 77 +++++++++++++++---- .../services/PaykitSdkServiceWipeTest.kt | 9 ++- .../services/PubkyIdentityRepublishTest.kt | 8 ++ .../bitkit/usecases/WipeWalletUseCaseTest.kt | 2 +- 10 files changed, 150 insertions(+), 50 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 74ca44193e..f85bc82e1c 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -1250,7 +1250,6 @@ class PrivatePaykitRepo @Inject constructor( if (normalizedKeys.isEmpty()) return@runSuspendCatching ensureState() - publicPaykitRepo.syncPaykitApp(privateSharingEnabled = true).getOrThrow() val cleanupStateByPublicKey = normalizedKeys.associateWith(::publishedEndpointCleanupState) val preparation = clearPrivatePaymentLists(normalizedKeys) val failedPublicKeys = preparation.failedPublicKeys.toMutableSet() @@ -1282,6 +1281,9 @@ class PrivatePaykitRepo @Inject constructor( clearPublishedEndpointCache(normalizedKeys.filterNot { it in failedPublicKeys }) firstError?.let { throw it } publicPaykitRepo.syncPaykitApp().getOrThrow() + }.onFailure { + runSuspendCatching { settingsStore.update { it.copy(publicPaykitCleanupPending = true) } } + .onFailure(it::addSuppressed) } private suspend fun clearPrivatePaymentLists( diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index 60a1df90fb..657b845ec9 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -10,7 +10,6 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.lightningdevkit.ldknode.Bolt11Invoice import org.lightningdevkit.ldknode.Network -import to.bitkit.data.PrivatePaykitCacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher @@ -99,7 +98,6 @@ class PublicPaykitRepo @Inject constructor( private val coreService: CoreService, private val paykitSdkService: PaykitSdkService, private val settingsStore: SettingsStore, - private val privatePaykitCacheStore: PrivatePaykitCacheStore, private val clock: Clock, ) { companion object { @@ -275,11 +273,7 @@ class PublicPaykitRepo @Inject constructor( runSuspendCatching { val settings = settingsStore.data.first() val privateSharing = privateSharingEnabled ?: settings.sharesPrivatePaykitEndpoints - val privateCache = privatePaykitCacheStore.data.first() - paykitSdkService.syncPaykitApp( - privatePaymentsEnabled = privateSharing || privateCache.cleanupPending || - privateCache.deletedContactCleanupPendingPublicKeys.isNotEmpty(), - ) + paykitSdkService.syncPaykitApp(privatePaymentsEnabled = privateSharing) } } diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 1a04128ef4..9e60d814ab 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -82,6 +82,7 @@ import kotlinx.coroutines.withTimeoutOrNull import org.lightningdevkit.ldknode.Network import to.bitkit.async.BaseCoroutineScope import to.bitkit.data.PubkyStore +import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.data.sharedpubky.SharedPubkyClient import to.bitkit.data.sharedpubky.SharedPubkyContract @@ -157,6 +158,7 @@ class PaykitSdkService @Inject constructor( private val pubkyStore: PubkyStore, sharedPubky: SharedPubkyClient, @IoDispatcher ioDispatcher: CoroutineDispatcher, + private val settingsStore: SettingsStore, ) : BaseCoroutineScope(ioDispatcher, TAG) { private val sessionProvider = PaykitSdkSessionProvider(keychain, sharedPubky) private val paymentAdapter = PaykitSdkPaymentAdapter() @@ -199,8 +201,9 @@ class PaykitSdkService @Inject constructor( ioDispatcher: CoroutineDispatcher = Dispatchers.IO, sharedPubky: SharedPubkyClient = SharedPubkyClient(context, ioDispatcher), platformInitializer: (() -> Unit)? = null, + settingsStore: SettingsStore, sdkFactory: () -> PaykitSdk, - ) : this(context, keychain, pubkyStore, sharedPubky, ioDispatcher) { + ) : this(context, keychain, pubkyStore, sharedPubky, ioDispatcher, settingsStore) { this.sdkFactory = sdkFactory if (bootstrapFactory != null) this.bootstrapFactory = bootstrapFactory if (platformInitializer == null) { @@ -666,6 +669,11 @@ class PaykitSdkService @Inject constructor( ) { return@withStateRevisionTracking null } + val publicKey = handle.identityStatus()?.publicKey + if (publicKey != null) { + val app = handle.paykitAppRegistry(publicKey)?.apps?.find { it.appId == "bitkit" } + if (app?.capabilities?.privatePayments == false) return@withStateRevisionTracking null + } handle.clearPrivatePaymentListAndProcessOutbound(counterparty) } } @@ -1053,7 +1061,10 @@ class PaykitSdkService @Inject constructor( runSuspendCatching { val capabilities = appCapabilities(handle) if (capabilities.privatePayments) { - handle.publishPaykitApp("Bitkit", capabilities) + handle.publishPaykitApp( + "Bitkit", + capabilities.copy(privatePayments = settingsStore.data.first().sharesPrivatePaykitEndpoints), + ) } }.onFailure { Logger.warn("Failed to publish Paykit app", it, context = TAG) diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index d5b0a7ede4..64dd663966 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -113,6 +113,10 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { cacheData.value = PrivatePaykitCacheData() } whenever(settingsStore.data).thenReturn(settingsData) + whenever { settingsStore.update(any()) }.thenAnswer { + val transform = it.getArgument<(SettingsData) -> SettingsData>(0) + settingsData.value = transform(settingsData.value) + } whenever(lightningRepo.lightningState).thenReturn(lightningState) whenever(clock.now()).thenReturn(Instant.fromEpochSeconds(NOW_SECONDS)) whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) @@ -321,7 +325,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `disabled cleanup retains its capability through failures and direct retries`() = test { + fun `disabled cleanup uses existing capability and retries withdrawal and registry failures`() = test { val publicRepo = PublicPaykitRepo( ioDispatcher = testDispatcher, pubkyRepo = mock(), @@ -330,20 +334,20 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { coreService = coreService, paykitSdkService = paykitSdkService, settingsStore = settingsStore, - privatePaykitCacheStore = cacheStore, clock = clock, ) - for (failureStage in listOf("enable capability", "withdraw", "disable capability")) { + for (failureStage in listOf("withdraw", "disable capability")) { cacheData.value = PrivatePaykitCacheData( contacts = mapOf(CONTACT_KEY to PrivatePaykitContactCacheData(hasPublishedPrivatePaymentList = true)), ) settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) sut = createSut(publicRepo) - var capability = false + var capability = true var failed = false doSuspendableAnswer { val enabled = it.getArgument(0) - if (!failed && failureStage == if (enabled) "enable capability" else "disable capability") { + assertFalse(enabled, "Cleanup must not enable private payments") + if (!failed && failureStage == "disable capability") { failed = true throw AppError("Registration unavailable") } @@ -363,8 +367,8 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(result.isFailure, failureStage) assertTrue(failed, failureStage) assertTrue(cacheData.value.cleanupPending, failureStage) - publicRepo.syncPaykitApp(privateSharingEnabled = false).getOrThrow() assertTrue(capability, failureStage) + assertTrue(settingsData.value.publicPaykitCleanupPending, failureStage) sut.retryPendingEndpointRemoval(listOf(CONTACT_KEY)).getOrThrow() @@ -374,6 +378,41 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } } + @Test + fun `failed deleted contact cleanup does not enable private payments`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) + val failure = AppError("Peer lookup unavailable") + whenever(paykitSdkService.linkedPeers()).thenAnswer { throw failure } + + val result = sut.removeSavedContact(CONTACT_KEY) + + assertEquals(failure, result.exceptionOrNull()) + verifyBlocking(publicPaykitRepo, never()) { syncPaykitApp(anyOrNull()) } + assertTrue(settingsData.value.publicPaykitCleanupPending) + assertTrue(CONTACT_KEY in cacheData.value.deletedContactCleanupPendingPublicKeys) + } + + @Test + fun `deleted contact cleanup retries registry update after withdrawal`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) + cacheData.value = PrivatePaykitCacheData(contacts = mapOf(CONTACT_KEY to cachedPublishedContact())) + sut = createSut() + whenever(publicPaykitRepo.syncPaykitApp()).thenReturn( + Result.failure(AppError("Registry unavailable")), + Result.success(Unit), + ) + + assertTrue(sut.removeSavedContact(CONTACT_KEY).isFailure) + assertTrue(settingsData.value.publicPaykitCleanupPending) + assertTrue(CONTACT_KEY in cacheData.value.deletedContactCleanupPendingPublicKeys) + + sut.retryPendingEndpointRemoval(emptyList()).getOrThrow() + + assertFalse(CONTACT_KEY in cacheData.value.deletedContactCleanupPendingPublicKeys) + verifyBlocking(publicPaykitRepo, never()) { syncPaykitApp(true) } + verifyBlocking(publicPaykitRepo, times(2)) { syncPaykitApp() } + } + @Test fun `disable sharing removes onchain-only private publications from cache`() = test { settingsData.value = SettingsData( diff --git a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt index 16207eaafe..6ab7c4ceb2 100644 --- a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt @@ -17,8 +17,6 @@ import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever -import to.bitkit.data.PrivatePaykitCacheData -import to.bitkit.data.PrivatePaykitCacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.services.CoreService @@ -47,13 +45,11 @@ class PublicPaykitRepoTest : BaseUnitTest() { private val coreService = mock() private val paykitSdkService = mock() private val settingsStore = mock() - private val privatePaykitCacheStore = mock() private val clock = mock() private val publicKey = MutableStateFlow("pubkyself") private val walletState = MutableStateFlow(WalletState()) private val settingsFlow = MutableStateFlow(SettingsData()) - private val privateCacheFlow = MutableStateFlow(PrivatePaykitCacheData()) private lateinit var sut: PublicPaykitRepo @@ -67,7 +63,6 @@ class PublicPaykitRepoTest : BaseUnitTest() { whenever(pubkyRepo.publicKey).thenReturn(publicKey) whenever(walletRepo.walletState).thenReturn(walletState) whenever(settingsStore.data).thenReturn(settingsFlow) - whenever(privatePaykitCacheStore.data).thenReturn(privateCacheFlow) whenever(clock.now()).thenReturn(Instant.fromEpochMilliseconds(NOW_MILLIS)) whenever(paykitSdkService.syncPublicEndpoints(any())).thenReturn(syncReport()) whenever { walletRepo.refreshReusableReceiveAddress() }.thenReturn(Result.success(Unit)) @@ -84,18 +79,14 @@ class PublicPaykitRepoTest : BaseUnitTest() { } @Test - fun `private capability remains enabled until all pending cleanup is complete`() = test { - for (pending in listOf( - PrivatePaykitCacheData(cleanupPending = true), - PrivatePaykitCacheData(deletedContactCleanupPendingPublicKeys = setOf("pubkycontact")), - PrivatePaykitCacheData(), - )) { - privateCacheFlow.value = pending - sut.syncPaykitApp(privateSharingEnabled = false).getOrThrow() + fun `private capability follows sharing preference`() = test { + for (enabled in listOf(false, true)) { + settingsFlow.value = settingsFlow.value.copy(sharesPrivatePaykitEndpoints = enabled) + sut.syncPaykitApp().getOrThrow() } val capabilities = argumentCaptor() - verifyBlocking(paykitSdkService, times(3)) { syncPaykitApp(capabilities.capture()) } - assertEquals(listOf(true, true, false), capabilities.allValues) + verifyBlocking(paykitSdkService, times(2)) { syncPaykitApp(capabilities.capture()) } + assertEquals(listOf(false, true), capabilities.allValues) } @Test @@ -311,7 +302,6 @@ class PublicPaykitRepoTest : BaseUnitTest() { coreService = coreService, paykitSdkService = paykitSdkService, settingsStore = settingsStore, - privatePaykitCacheStore = privatePaykitCacheStore, clock = clock, ) diff --git a/app/src/test/java/to/bitkit/services/PaykitKeyGenerationTest.kt b/app/src/test/java/to/bitkit/services/PaykitKeyGenerationTest.kt index 6b6c51f9bf..364e4d9cc2 100644 --- a/app/src/test/java/to/bitkit/services/PaykitKeyGenerationTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitKeyGenerationTest.kt @@ -45,7 +45,7 @@ class PaykitKeyGenerationTest { whenever(keychain.loadString(storageKey)).thenReturn(null, generation.toString()) val key = mock() val bytes = ByteArray(32) { 1 } - val service = PaykitSdkService(mock(), keychain, mock()) { sdk } + val service = PaykitSdkService(mock(), keychain, mock(), settingsStore = mock()) { sdk } mockStatic(Class.forName("com.synonym.paykit.Paykit_androidKt")).use { native -> native.`when` { pubkyPublicKeyFromSecret(any()) }.thenReturn(RING_PUBKY) @@ -88,7 +88,7 @@ class PaykitKeyGenerationTest { mockConstruction(PaykitSdkSessionProvider::class.java) { provider, _ -> whenever(provider.loadLocalSecretKey()).thenReturn(root) }.use { providers -> - val service = PaykitSdkService(mock(), keychain, mock()) { sdk } + val service = PaykitSdkService(mock(), keychain, mock(), settingsStore = mock()) { sdk } val provider = providers.constructed().single() repeat(2) { assertEquals(RING_PUBKY, service.currentPublicKey()) } repeat(2) { diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index e534c4a9ed..dc99595fd1 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -4,6 +4,9 @@ import com.synonym.paykit.ContactRecord import com.synonym.paykit.IdentityStatus import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState +import com.synonym.paykit.PaykitApp +import com.synonym.paykit.PaykitAppCapabilities +import com.synonym.paykit.PaykitAppRegistry import com.synonym.paykit.PaykitException import com.synonym.paykit.PaykitIdentitySecretKey import com.synonym.paykit.PaykitSdk @@ -42,6 +45,8 @@ import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import to.bitkit.data.PubkyStore import to.bitkit.data.PubkyStoreData +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.data.keychain.KeychainError import to.bitkit.data.sharedpubky.SharedPubkyClient @@ -78,6 +83,7 @@ class PaykitSdkServiceTest { mock(), mock(), ioDispatcher = StandardTestDispatcher(testScheduler), + settingsStore = mock(), platformInitializer = { wipe = async(start = CoroutineStart.UNDISPATCHED) { service.withWalletWipe { @@ -101,7 +107,7 @@ class PaykitSdkServiceTest { val sdk = mock() whenever(sdk.contactRecords()).thenReturn(emptyList()) var handlesCreated = 0 - val service = PaykitSdkService(mock(), mock(), mock()) { + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { handlesCreated++ sdk } @@ -182,7 +188,7 @@ class PaykitSdkServiceTest { sdk.identityStatus() ).thenReturn(IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(sdk.listPaymentRequests(any())).thenReturn(listOf(server, bitkit)) - val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } assertEquals(listOf(server, bitkit), service.allPaymentRequests(RING_PUBKY)) assertEquals(listOf(bitkit), service.paymentRequests()) @@ -194,7 +200,7 @@ class PaykitSdkServiceTest { whenever( sdk.identityStatus() ).thenReturn(IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) - val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } assertFailsWith { service.allPaymentRequests("a3mduedw686dysw8ndr5c1dyry5h8k6i8hzbbmx9gf9k43zq4s9o") @@ -231,7 +237,7 @@ class PaykitSdkServiceTest { for ((authUrl, companion) in invalidRequests) { val keychain = mock() val sdk = mock() - val service = PaykitSdkService(mock(), keychain, mock()) { sdk } + val service = PaykitSdkService(mock(), keychain, mock(), settingsStore = mock()) { sdk } assertTrue( runSuspendCatching { service.approveAuthWithCompanionClaim( @@ -281,7 +287,7 @@ class PaykitSdkServiceTest { var handlesCreated = 0 val store = mock() whenever(store.data).thenReturn(flowOf(PubkyStoreData())) - val service = PaykitSdkService(mock(), keychain, store) { + val service = PaykitSdkService(mock(), keychain, store, settingsStore = mock()) { handlesCreated++ sdk } @@ -320,7 +326,7 @@ class PaykitSdkServiceTest { whenever(sdk.blockPeer(RING_PUBKY)) .thenThrow(IllegalStateException("storage failure")) } - val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } if (failBlock) { assertFailsWith { service.removeContact(RING_PUBKY) } verify(sdk, never()).removeContact(any()) @@ -352,7 +358,7 @@ class PaykitSdkServiceTest { } whenever(sdk.linkedPeers()).thenReturn(emptyList()) whenever(sdk.paymentRequests()).thenReturn(listOf(request)) - val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } assertFailsWith { service.removeContact(RING_PUBKY) } verify(sdk, never()).blockPeer(any()) verify(sdk, never()).removeContact(any()) @@ -378,7 +384,7 @@ class PaykitSdkServiceTest { whenever(store.data).thenReturn(flow { throw error }) val access = mock() whenever(access.exportSessionSecret()).thenReturn("new-session") - val service = PaykitSdkService(mock(), keychain, store) { sdk } + val service = PaykitSdkService(mock(), keychain, store, settingsStore = mock()) { sdk } val thrown = assertFailsWith { service.activateRegisteredIdentity( @@ -413,7 +419,7 @@ class PaykitSdkServiceTest { PrivatePaymentListDeliveryReport(emptyList(), emptyList(), emptyList(), emptyList()), ) } - val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } service.removeContact(RING_PUBKY) inOrder(sdk) { verify(sdk).clearPrivatePaymentListAndProcessOutbound(RING_PUBKY) @@ -454,7 +460,7 @@ class PaykitSdkServiceTest { whenever(noise.exportBytes()).thenReturn(ByteArray(32) { 1 }) whenever(access.exportSessionSecret()).thenReturn("new-session") whenever(access.exportPaykitIdentitySecretKey()).thenReturn(noise) - val service = PaykitSdkService(mock(), keychain, store, { bootstrap }) { sdk } + val service = PaykitSdkService(mock(), keychain, store, { bootstrap }, settingsStore = mock()) { sdk } val result = PubkySessionBootstrapResult( @@ -493,7 +499,7 @@ class PaykitSdkServiceTest { contactPeer(LinkedPeerState.BLOCKED), ), ) - val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } if (restoreConnection) { service.saveContact(RING_PUBKY, "Contact", restorePrivateConnection = true) verify(sdk).unblockPeer(RING_PUBKY) @@ -527,7 +533,7 @@ class PaykitSdkServiceTest { } "save" -> whenever(sdk.saveContact(any())).thenThrow(failure).thenReturn(mock()) } - val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } val thrown = assertFailsWith { service.saveContact(RING_PUBKY, "Contact", restorePrivateConnection = true) } @@ -553,7 +559,7 @@ class PaykitSdkServiceTest { whenever(sdk.linkedPeers()).thenReturn(peers) whenever(sdk.saveContact(any())).thenThrow(restorationFailure) whenever(sdk.blockPeer(RING_PUBKY)).thenThrow(rollbackFailure) - val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } val thrown = assertFailsWith { service.saveContact(RING_PUBKY, "Contact", restorePrivateConnection = true) @@ -568,11 +574,54 @@ class PaykitSdkServiceTest { fun `blocked peer cleanup does not attempt network delivery`() = runTest { val sdk = mock() whenever(sdk.linkedPeers()).thenReturn(listOf(contactPeer(LinkedPeerState.BLOCKED))) - val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } assertNull(service.clearPrivatePaymentList(RING_PUBKY)) verify(sdk, never()).clearPrivatePaymentListAndProcessOutbound(any()) } + @Test + fun `disabled private capability does not queue withdrawal`() = runTest { + val sdk = mock() + val capabilities = PaykitAppCapabilities(false, true, false, true) + whenever(sdk.linkedPeers()).thenReturn(listOf(contactPeer(LinkedPeerState.LINKED))) + whenever(sdk.identityStatus()).thenReturn( + IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE), + ) + whenever(sdk.paykitAppRegistry(RING_PUBKY)).thenReturn( + PaykitAppRegistry(1u, null, listOf(PaykitApp("bitkit", "Bitkit", capabilities)), null, emptyMap()), + ) + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + + assertNull(service.clearPrivatePaymentList(RING_PUBKY)) + + verify(sdk, never()).clearPrivatePaymentListAndProcessOutbound(any()) + verify(sdk, never()).publishPaykitApp(any(), any()) + } + + @Test + fun `initialization publishes the saved private sharing preference`() = runTest { + for (enabled in listOf(false, true)) { + val sdk = mock() + val settingsStore = mock() + whenever(settingsStore.data).thenReturn(flowOf(SettingsData(sharesPrivatePaykitEndpoints = enabled))) + whenever(sdk.identityStatus()).thenReturn( + IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE), + ) + val service = PaykitSdkService( + mock(), + mock(), + mock(), + ioDispatcher = StandardTestDispatcher(testScheduler), + platformInitializer = {}, + settingsStore = settingsStore, + ) { sdk } + + service.initialize() + + verify(sdk).publishPaykitApp("Bitkit", PaykitAppCapabilities(enabled, true, false, true)) + } + } + private fun contactPeer(state: LinkedPeerState) = LinkedPeerRecord( counterparty = RING_PUBKY, state = state, diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceWipeTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceWipeTest.kt index 40d00af7d9..cb00fea911 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceWipeTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceWipeTest.kt @@ -100,7 +100,14 @@ class PaykitSdkServiceWipeTest { } val store = mock { on { data } doReturn flowOf(PubkyStoreData()) } val dispatcher = StandardTestDispatcher(testScheduler) - val service = PaykitSdkService(mock(), keychain, store, { bootstrap }, dispatcher) { sdk } + val service = PaykitSdkService( + mock(), + keychain, + store, + { bootstrap }, + dispatcher, + settingsStore = mock(), + ) { sdk } mockStatic(Class.forName("com.synonym.paykit.Paykit_androidKt")).use { native -> native.`when` { requiredSessionCapabilities() }.thenReturn("capabilities") diff --git a/app/src/test/java/to/bitkit/services/PubkyIdentityRepublishTest.kt b/app/src/test/java/to/bitkit/services/PubkyIdentityRepublishTest.kt index 4037beda38..d5d2ccdffe 100644 --- a/app/src/test/java/to/bitkit/services/PubkyIdentityRepublishTest.kt +++ b/app/src/test/java/to/bitkit/services/PubkyIdentityRepublishTest.kt @@ -43,6 +43,7 @@ class PubkyIdentityRepublishTest { mock(), { bootstrap }, StandardTestDispatcher(testScheduler), + settingsStore = mock(), ) { mock() } service.republishIdentityIfNeeded(publicKey, now = 2_592_000_000) @@ -73,6 +74,7 @@ class PubkyIdentityRepublishTest { mock(), { bootstrap }, StandardTestDispatcher(testScheduler), + settingsStore = mock(), ) { mock() } service.republishIdentityIfNeeded(publicKey, now = 0) @@ -100,6 +102,7 @@ class PubkyIdentityRepublishTest { context = mock(), keychain = mock(), pubkyStore = mock(), + settingsStore = mock(), bootstrapFactory = { factories++ bootstrap @@ -129,6 +132,7 @@ class PubkyIdentityRepublishTest { context = mock(), keychain = mock(), pubkyStore = mock(), + settingsStore = mock(), bootstrapFactory = { bootstrap }, ioDispatcher = StandardTestDispatcher(testScheduler), sdkFactory = { mock() }, @@ -153,6 +157,7 @@ class PubkyIdentityRepublishTest { mock(), { bootstrap }, StandardTestDispatcher(testScheduler), + settingsStore = mock(), ) { sdk } val otherKey = publicKey.dropLast(1) + "y" @@ -176,6 +181,7 @@ class PubkyIdentityRepublishTest { mock(), { bootstrap }, StandardTestDispatcher(testScheduler), + settingsStore = mock(), ) { mock() } val first = async { service.republishIdentityIfNeeded(publicKey, now = 0) } runCurrent() @@ -204,6 +210,7 @@ class PubkyIdentityRepublishTest { mock(), { bootstrap }, StandardTestDispatcher(testScheduler), + settingsStore = mock(), ) { mock() } service.republishIdentityIfNeeded(publicKey, now = 0) @@ -237,6 +244,7 @@ class PubkyIdentityRepublishTest { mock(), { bootstrap }, StandardTestDispatcher(testScheduler), + settingsStore = mock(), ) { mock() } var continued = false val cancelledCaller = async { diff --git a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt index 13e102b7b8..fc7f2d144b 100644 --- a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt @@ -61,7 +61,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { private val privatePaykitAddressReservationRepo = mock() private val firebaseMessaging = mock() private val migrationService = mock() - private val paykitSdkService = PaykitSdkService(mock(), keychain, mock()) { mock() } + private val paykitSdkService = PaykitSdkService(mock(), keychain, mock(), settingsStore = mock()) { mock() } private val privatePaykitRepoProvider = Provider { privatePaykitRepo } private lateinit var sut: WipeWalletUseCase From b657fd07e8d53189b3cbb3a7e8fb61fe9c463ac3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 1 Oct 2026 19:20:42 +0200 Subject: [PATCH 26/51] refactor: bind allowances to the shared paykit identity --- .../to/bitkit/repositories/PaykitAllowance.kt | 17 +- .../repositories/PaykitAllowanceExecutor.kt | 35 ++- .../repositories/PaykitAllowanceRepo.kt | 146 ++-------- .../bitkit/repositories/PrivatePaykitRepo.kt | 31 ++- .../bitkit/repositories/PublicPaykitRepo.kt | 2 + .../PaykitAllowanceExecutorTest.kt | 73 +++-- .../repositories/PaykitAllowanceRepoTest.kt | 206 ++++---------- .../repositories/PaykitAllowanceTest.kt | 82 ++---- .../PaykitPaymentProofRepoTest.kt | 7 +- .../PrivatePaykitAllowancePaymentTest.kt | 22 +- .../services/PaykitSdkStateLayoutTest.kt | 252 ------------------ .../subscriptions/AllowancesViewModelTest.kt | 4 +- journeys/allowances/README.md | 2 +- 13 files changed, 219 insertions(+), 660 deletions(-) delete mode 100644 app/src/test/java/to/bitkit/services/PaykitSdkStateLayoutTest.kt diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowance.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowance.kt index 82ddae7538..bf65975479 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowance.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowance.kt @@ -13,7 +13,6 @@ import com.synonym.paykit.PaymentExecutionMode import com.synonym.paykit.PaymentExecutionStatus import kotlinx.serialization.Serializable import to.bitkit.models.safe -import to.bitkit.services.PaykitReceiverPaths import java.math.BigDecimal import java.time.ZoneOffset import java.time.ZonedDateTime @@ -24,7 +23,7 @@ import kotlin.time.toJavaInstant import kotlin.time.toKotlinInstant /** - * An Allowance between this wallet and one contact link, built from the SDK record. + * An Allowance between this wallet's identity and one contact identity, built from the SDK record. * Eligibility always runs on real time. */ @Immutable @@ -44,7 +43,6 @@ data class PaykitAllowance( @Serializable data class Id( val counterparty: String, - val counterpartyReceiverPath: String, val allowanceId: String, ) @@ -66,7 +64,6 @@ data class PaykitAllowance( } val counterparty: String get() = id.counterparty - val counterpartyReceiverPath: String get() = id.counterpartyReceiverPath val allowanceId: String get() = id.allowanceId /** The payer side: this wallet pays the counterparty's requests automatically. */ @@ -104,7 +101,7 @@ data class PaykitAllowance( if (terms.asset() != PaykitIssuerInterop.BITCOIN_ASSET) return null val monthly = terms.periodLimits().firstOrNull { isMonthly(it.period()) } return PaykitAllowance( - id = Id(record.counterparty, record.counterpartyReceiverPath, record.allowanceId), + id = Id(record.counterparty, record.allowanceId), role = role, lifecycleState = record.state, isProposedByMe = record.proposalOutboundMessageId != null, @@ -129,20 +126,16 @@ data class PaykitAllowance( } } -/** One grant as the user sees it: the same limits proposed on each of a contact's supported links. */ +/** One grant as the user sees it: the Allowance with a contact identity and the limits picked for it. */ @Immutable data class PaykitAllowanceEntry( val id: String, val allowances: List, val limits: PaykitAllowanceLimits?, ) { - /** An accepted link before any other, and the contact's wallet link before their server link. */ + /** An accepted Allowance before any other. */ val primary: PaykitAllowance - get() = allowances.minBy { - val acceptedRank = if (it.lifecycleState == AllowanceLifecycleState.ACCEPTED) 0 else 2 - val walletRank = if (it.counterpartyReceiverPath == PaykitReceiverPaths.WALLET) 0 else 1 - acceptedRank + walletRank - } + get() = allowances.firstOrNull { it.lifecycleState == AllowanceLifecycleState.ACCEPTED } ?: allowances.first() val counterparty: String get() = primary.counterparty val role: PaykitAllowance.Role get() = primary.role val perPaymentMaxSats: ULong? get() = primary.perPaymentMaxSats diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt index 67cc7091f2..3e5d708a6e 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt @@ -68,18 +68,21 @@ class PaykitAllowancePayer @Inject constructor( suspend fun prepareProof( request: PaykitPaymentRequest, paymentEndpointIdentifier: String, + paymentAppId: String, allowanceId: String?, ): Result { val kind = PaykitPaymentProofKind.fromPaymentEndpointIdentifier(paymentEndpointIdentifier) ?: return Result.failure(PaykitPaymentRequestError.RequestUnavailable) - return paymentProofRepo.prepare(request, paymentEndpointIdentifier, kind, allowanceId) + return paymentProofRepo.prepare(request, paymentEndpointIdentifier, paymentAppId, kind, allowanceId) } suspend fun associateLightningPayment( request: PaykitPaymentRequest, paymentHash: String, paymentEndpointIdentifier: String, - ): Result = paymentProofRepo.associateLightningPayment(request, paymentHash, paymentEndpointIdentifier) + paymentAppId: String, + ): Result = + paymentProofRepo.associateLightningPayment(request, paymentHash, paymentEndpointIdentifier, paymentAppId) suspend fun markOnchainPaymentStarted(request: PaykitPaymentRequest, address: String): Result = paymentProofRepo.markOnchainPaymentStarted(request, address) @@ -111,8 +114,9 @@ class PaykitAllowancePayer @Inject constructor( request: PaykitPaymentRequest, txid: String, paymentEndpointIdentifier: String, + paymentAppId: String, ) { - paymentProofRepo.completeOnchainPayment(request, txid, paymentEndpointIdentifier) + paymentProofRepo.completeOnchainPayment(request, txid, paymentEndpointIdentifier, paymentAppId) } /** Clears the proof when [error] proves the payment never left; returns whether it did. */ @@ -247,7 +251,7 @@ class PaykitAllowanceExecutor @Inject constructor( private suspend fun recoverOpenAttempts(identity: String) { runSuspendCatching { - // No ledger means nothing was ever admitted. Creating one here would also end the rc55 storage layout. + // No ledger means nothing was ever admitted, so there is nothing to recover. paykitSdkService.allowanceAccountingState() ?: return@runSuspendCatching val state = ensureReconciled(identity) for (attempt in state.history.occurrences.flatMap { it.attempts }) { @@ -342,8 +346,7 @@ class PaykitAllowanceExecutor @Inject constructor( allowances.any { it.isAllower && it.lifecycleState == AllowanceLifecycleState.ACCEPTED && - PubkyPublicKeyFormat.matches(it.counterparty, request.counterparty) && - it.counterpartyReceiverPath == request.counterpartyReceiverPath + PubkyPublicKeyFormat.matches(it.counterparty, request.counterparty) } if (!isCovered || !inFlightRequestIds.add(request.id)) { return@withContext PaykitAllowanceAutoPayResult.NOT_COVERED @@ -397,6 +400,8 @@ class PaykitAllowanceExecutor @Inject constructor( } val endpointIdentifier = payment.endpoint.methodId.rawValue + // Every identity-wide execution step needs this app to own the request's execution claim first. + paykitSdkService.claimPaymentRequestForExecution(request.counterparty, request.paymentRequestId) val association = paykitSdkService.acceptPaymentRequestAutomatically( scope = scope, selection = AllowanceSelectionInput( @@ -457,7 +462,7 @@ class PaykitAllowanceExecutor @Inject constructor( // Begin fetches nothing, so pull the link first: an End or a cancellation must be seen before the handoff. runSuspendCatching { - paykitSdkService.receivePrivateMessages(request.counterparty, request.counterpartyReceiverPath) + paykitSdkService.receivePrivateMessages(request.counterparty) }.onFailure { Logger.warn("Failed to receive private messages before an allowance payment", it, context = TAG) } val handoff = paykitSdkService.beginPaymentExecution( attemptId = prepared.attemptId, @@ -474,7 +479,7 @@ class PaykitAllowanceExecutor @Inject constructor( runSuspendCatching { payer.consumePaymentList(request.counterparty, payment.context).getOrThrow() - payer.prepareProof(request, endpointIdentifier, submitted.allowanceId).getOrThrow() + payer.prepareProof(request, endpointIdentifier, payment.appId, submitted.allowanceId).getOrThrow() }.exceptionOrNull()?.let { payer.cancelProofPreparation(request) record(submitted.attemptId, PaymentOutcome.FAILED, identity) @@ -493,7 +498,12 @@ class PaykitAllowanceExecutor @Inject constructor( attemptId: String, identity: String, ): PaykitAllowanceAutoPayResult { - payer.associateLightningPayment(request, paymentHash, payment.endpoint.methodId.rawValue).onFailure { + payer.associateLightningPayment( + request, + paymentHash, + payment.endpoint.methodId.rawValue, + payment.appId, + ).onFailure { payer.cancelProofPreparation(request) record(attemptId, PaymentOutcome.FAILED, identity) throw it @@ -538,7 +548,7 @@ class PaykitAllowanceExecutor @Inject constructor( } updateJournalEntry(attemptId, identity) { it.copy(transactionId = txid) } - payer.completeOnchainPayment(request, txid, payment.endpoint.methodId.rawValue) + payer.completeOnchainPayment(request, txid, payment.endpoint.methodId.rawValue, payment.appId) record(attemptId, PaymentOutcome.SUCCEEDED, identity) _events.tryEmit(PaykitAllowanceEvent.PaidAutomatically(request.counterparty, request.amountSats, txid)) Logger.info("Paid an allowance payment on-chain", context = TAG) @@ -705,7 +715,7 @@ class PaykitAllowanceExecutor @Inject constructor( private suspend fun sendQueuedMessages(request: PaykitPaymentRequest) { runSuspendCatching { - paykitSdkService.processOutboundPrivateMessages(request.counterparty, request.counterpartyReceiverPath) + paykitSdkService.processOutboundPrivateMessages(request.counterparty) }.onFailure { Logger.warn("Failed to send the automatic acceptance right away", it, context = TAG) } } @@ -744,8 +754,7 @@ class PaykitAllowanceExecutor @Inject constructor( // endregion } -private fun PaykitPaymentRequest.scope() = - PaymentRequestScope(counterparty, counterpartyReceiverPath, paymentRequestId) +private fun PaykitPaymentRequest.scope() = PaymentRequestScope(counterparty, paymentRequestId) private fun Throwable.isDefiniteOnchainPreBroadcastFailure(): Boolean = generateSequence(this as Throwable?) { it.cause } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt index 34e1a82982..76883eb7f4 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt @@ -28,7 +28,6 @@ import to.bitkit.env.Env import to.bitkit.ext.runSuspendCatching import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.safe -import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PaykitSdkService import to.bitkit.utils.AppError import to.bitkit.utils.Logger @@ -54,7 +53,7 @@ sealed interface PaykitAllowanceEvent { } enum class PaykitAllowanceAutoPayResult { - /** No accepted Allowance covers the request's link, or the request is already being paid. */ + /** No accepted Allowance covers the request's contact, or the request is already being paid. */ NOT_COVERED, /** An Allowance exists but this request stays on the manual flow (over a limit, ended, no payable endpoint). */ @@ -71,15 +70,15 @@ enum class PaykitAllowanceAutoPayResult { } sealed class PaykitAllowanceError(message: String) : AppError(message) { - data object ContactNotLinked : PaykitAllowanceError("The contact has no linked Paykit receiver") + data object ContactNotLinked : PaykitAllowanceError("The contact has no linked Paykit identity") data object Unavailable : PaykitAllowanceError("The allowance is unavailable") data object PaymentAlreadyRecorded : PaykitAllowanceError("A payment for this request is already in progress") data object PaymentListPending : PaykitAllowanceError("The payee has not published a new payment list yet") } /** - * Allowances for the active identity. One user-facing entry groups the same grant across a contact's links (their - * wallet, and their Paykit Server folder), and covered incoming requests are paid headlessly through + * Allowances for the active identity. One user-facing entry is one grant to a contact's identity, which covers every + * Paykit app of that contact, and covered incoming requests are paid headlessly through * [PaykitAllowanceExecutor]. The repository also settles its own Lightning attempts from the node's payment events * and attributes automatic payments to the contact's activity. */ @@ -103,16 +102,6 @@ class PaykitAllowanceRepo @Inject constructor( fun allowedPaymentEndpointIdentifiers(network: Network = Env.network): List = (listOf(MethodId.Bolt11) + MethodId.entries.filter { it.isOnchain }).map { it.rawValueForNetwork(network) } - /** A grant in one of these states still covers the contact, so links that appear later join it. */ - private val EXTENDABLE_STATUSES = setOf( - PaykitAllowance.Status.ACTIVE, - PaykitAllowance.Status.NOT_YET_ACTIVE, - PaykitAllowance.Status.AWAITING_ANSWER, - ) - - /** The supported receiver paths among [paths], the wallet link first. */ - fun orderedReceiverPaths(paths: Collection): List = - PaykitReceiverPaths.ordered.filter { it in paths } } private val scope = appScope(ioDispatcher, TAG) @@ -131,7 +120,7 @@ class PaykitAllowanceRepo @Inject constructor( private val _entries = MutableStateFlow>(emptyList()) - /** Grants grouped across a contact's links, newest first. */ + /** Grants by contact identity. */ val entries: StateFlow> = _entries.asStateFlow() private val _autoPaidSats = MutableStateFlow>(emptyMap()) @@ -197,8 +186,7 @@ class PaykitAllowanceRepo @Inject constructor( suspend fun refresh(): Result = withContext(ioDispatcher) { val identity = activeIdentity ?: return@withContext Result.success(Unit) refreshMutex.withLock { - var listing = listAllowances() - if (listing.getOrNull()?.let { extendToNewLinks(identity, it) } == true) listing = listAllowances() + val listing = listAllowances() if (activeIdentity == identity) { publish(listing.getOrDefault(_allowances.value), executor.localState(identity)) } @@ -210,7 +198,6 @@ class PaykitAllowanceRepo @Inject constructor( paykitSdkService.listAllowances( AllowanceFilter( counterparty = null, - counterpartyReceiverPath = null, localRole = null, states = emptyList(), ), @@ -222,98 +209,45 @@ class PaykitAllowanceRepo @Inject constructor( .mapNotNull { PaykitAllowance.from(it) } }.onFailure { Logger.warn("Failed to list Paykit allowances", it, context = TAG) } - /** - * A grant covers the contact's links that were linked when it was made. When another supported link of the - * contact links later (typically their Paykit Server folder), proposes the grant's terms there and adds it to the - * grant, as a grant made now would. Returns true when any proposal was made. - */ - private suspend fun extendToNewLinks(identity: String, allowances: List): Boolean { - val now = clock.now() - val liveGroups = executor.localState(identity).groups.mapNotNull { group -> - val members = allowances.filter { it.allowanceId in group.allowanceIds } - val isLive = members.size == group.allowanceIds.size && - members.any { it.isAllower && it.status(now) in EXTENDABLE_STATUSES } - if (isLive) group to members else null - } - if (liveGroups.isEmpty()) return false - val linkedPeers = runSuspendCatching { paykitSdkService.linkedPeers() } - .onFailure { Logger.warn("Failed to list linked peers for allowances", it, context = TAG) } - .getOrNull() - ?.filter { it.state == LinkedPeerState.LINKED } - ?: return false - - var proposed = false - for ((group, members) in liveGroups) { - val coveredPaths = members.map { it.counterpartyReceiverPath }.toSet() - val linkedPaths = linkedPeers - .filter { PubkyPublicKeyFormat.matches(it.counterparty, group.counterparty) } - .map { it.counterpartyReceiverPath } - val newPaths = orderedReceiverPaths(linkedPaths).filterNot { it in coveredPaths } - if (newPaths.isEmpty()) continue - - val terms = allowanceTerms( - group.limits, - members.firstNotNullOfOrNull { it.monthlyAnchor } ?: PaykitAllowanceTime.monthStart(now), - allowedPaymentEndpointIdentifiers(), - ) - runSuspendCatching { proposeOnLinks(group.counterparty, newPaths, terms) } - .onSuccess { allowanceIds -> - executor.updateLocalState(identity) { state -> - state.copy( - groups = state.groups.map { - if (it.id == group.id) it.copy(allowanceIds = it.allowanceIds + allowanceIds) else it - }, - ) - } - Logger.info("Extended an allowance to '${allowanceIds.size}' newly linked links", context = TAG) - proposed = true - } - .onFailure { Logger.warn("Failed to extend an allowance to a newly linked link", it, context = TAG) } - } - return proposed - } - - /** Proposes the same terms on every supported linked receiver path of the contact and records one entry. */ + /** Proposes the terms to the contact's identity, which needs a linked contact, and records one entry. */ suspend fun propose(contactPublicKey: String, limits: PaykitAllowanceLimits): Result = withContext(ioDispatcher) { runSuspendCatching { val identity = activeIdentity ?: throw PaykitAllowanceError.Unavailable val contactKey = PubkyPublicKeyFormat.normalized(contactPublicKey) ?: throw PaykitAllowanceError.ContactNotLinked - val linkedPaths = paykitSdkService.linkedPeers() - .filter { - PubkyPublicKeyFormat.matches(it.counterparty, contactKey) && it.state == LinkedPeerState.LINKED - } - .map { it.counterpartyReceiverPath } - val receiverPaths = orderedReceiverPaths(linkedPaths) - if (receiverPaths.isEmpty()) throw PaykitAllowanceError.ContactNotLinked + val isLinked = paykitSdkService.linkedPeers().any { + PubkyPublicKeyFormat.matches(it.counterparty, contactKey) && it.state == LinkedPeerState.LINKED + } + if (!isLinked) throw PaykitAllowanceError.ContactNotLinked val terms = allowanceTerms( limits, PaykitAllowanceTime.monthStart(clock.now()), allowedPaymentEndpointIdentifiers(), ) - val allowanceIds = proposeOnLinks(contactKey, receiverPaths, terms) + val allowance = paykitSdkService.proposeAllowance(contactKey, AllowanceLocalRole.ALLOWER, terms) + sendQueuedMessages(contactKey) val group = PaykitAllowanceLocalState.Group( id = UUID.randomUUID().toString(), counterparty = contactKey, limits = limits, - allowanceIds = allowanceIds, + allowanceIds = listOf(allowance.allowanceId), createdAtMillis = clock.now().toEpochMilliseconds(), ) executor.updateLocalState(identity) { it.copy(groups = it.groups + group) } - Logger.info("Proposed an allowance on '${allowanceIds.size}' links", context = TAG) + Logger.info("Proposed an allowance", context = TAG) refresh() Unit } } suspend fun accept(entryId: String): Result = respond(entryId) { - paykitSdkService.acceptAllowance(it.counterparty, it.counterpartyReceiverPath, it.allowanceId) + paykitSdkService.acceptAllowance(it.counterparty, it.allowanceId) } suspend fun reject(entryId: String): Result = respond(entryId) { - paykitSdkService.rejectAllowance(it.counterparty, it.counterpartyReceiverPath, it.allowanceId) + paykitSdkService.rejectAllowance(it.counterparty, it.allowanceId) } suspend fun end(entryId: String): Result = withContext(ioDispatcher) { @@ -322,12 +256,8 @@ class PaykitAllowanceRepo @Inject constructor( val endable = entry.allowances.filter { it.canEnd } if (endable.isEmpty()) throw PaykitAllowanceError.Unavailable for (allowance in endable) { - paykitSdkService.endAllowance( - allowance.counterparty, - allowance.counterpartyReceiverPath, - allowance.allowanceId, - ) - sendQueuedMessages(allowance.counterparty, allowance.counterpartyReceiverPath) + paykitSdkService.endAllowance(allowance.counterparty, allowance.allowanceId) + sendQueuedMessages(allowance.counterparty) } refresh() Unit @@ -345,7 +275,7 @@ class PaykitAllowanceRepo @Inject constructor( if (answerable.isEmpty()) throw PaykitAllowanceError.Unavailable for (allowance in answerable) { response(allowance) - sendQueuedMessages(allowance.counterparty, allowance.counterpartyReceiverPath) + sendQueuedMessages(allowance.counterparty) } val ids = entry.allowances.map { it.allowanceId } executor.updateLocalState(identity) { it.copy(presentedProposalIds = it.presentedProposalIds + ids) } @@ -354,34 +284,9 @@ class PaykitAllowanceRepo @Inject constructor( } } - private suspend fun proposeOnLinks( - contactKey: String, - receiverPaths: List, - terms: AllowanceTerms, - ): List { - val allowanceIds = mutableListOf() - var firstError: Throwable? = null - for (receiverPath in receiverPaths) { - runSuspendCatching { - paykitSdkService.proposeAllowance(contactKey, receiverPath, AllowanceLocalRole.ALLOWER, terms) - }.onSuccess { - allowanceIds += it.allowanceId - sendQueuedMessages(contactKey, receiverPath) - }.onFailure { - if (receiverPath == PaykitReceiverPaths.WALLET) throw it - if (firstError == null) firstError = it - Logger.warn("Failed to propose an allowance on the secondary link '$receiverPath'", it, context = TAG) - } - } - if (allowanceIds.isEmpty()) throw firstError ?: PaykitAllowanceError.Unavailable - return allowanceIds - } - - private suspend fun sendQueuedMessages(counterparty: String, receiverPath: String) { - runSuspendCatching { paykitSdkService.processOutboundPrivateMessages(counterparty, receiverPath) } - .onFailure { - Logger.warn("Failed to send allowance messages on '$receiverPath' right away", it, context = TAG) - } + private suspend fun sendQueuedMessages(counterparty: String) { + runSuspendCatching { paykitSdkService.processOutboundPrivateMessages(counterparty) } + .onFailure { Logger.warn("Failed to send allowance messages right away", it, context = TAG) } } // endregion @@ -410,8 +315,8 @@ class PaykitAllowanceRepo @Inject constructor( // region Automatic payments /** - * Whether an active Allowance this wallet granted covers the request's exact link. A request created before the - * Allowance was accepted stays manual: it may already have been shown to the user for a decision. + * Whether an active Allowance this wallet granted covers the request's contact identity. A request created before + * the Allowance was accepted stays manual: it may already have been shown to the user for a decision. */ fun coversRequest(request: PaykitPaymentRequest): Boolean { val now = clock.now() @@ -419,7 +324,6 @@ class PaykitAllowanceRepo @Inject constructor( allowance.isAllower && allowance.status(now) == PaykitAllowance.Status.ACTIVE && PubkyPublicKeyFormat.matches(allowance.counterparty, request.counterparty) && - allowance.counterpartyReceiverPath == request.counterpartyReceiverPath && isCreatedAfterAcceptance(request, allowance) } } diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 671f215536..6c0f1433a8 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -389,14 +389,11 @@ class PrivatePaykitRepo @Inject constructor( runSuspendCatching { if (request.isExpired(clock.now())) return@runSuspendCatching null val publicKey = normalizedPublicKey(request.counterparty) ?: return@runSuspendCatching null - val consumedVersion = ensureState().contacts[publicKey] - ?.consumedPrivatePaymentListVersionsByReceiverPath - ?.get(request.counterpartyReceiverPath) - val prepared = paykitSdkService.prepareAndResolvePrivateContactPayment( + val consumedVersion = ensureState().contacts[publicKey]?.consumedPrivatePaymentListVersion + val prepared = paykitSdkService.prepareAndResolvePrivatePaymentRequest( counterparty = publicKey, - receiverPath = request.counterpartyReceiverPath, + paymentRequestId = request.paymentRequestId, afterPrivatePaymentListVersion = consumedVersion, - amount = PaymentAmountContext(request.amountValue, PaykitIssuerInterop.BITCOIN_ASSET), ) val resolution = prepared.resolution if ( @@ -406,7 +403,7 @@ class PrivatePaykitRepo @Inject constructor( // The last list was already paid from; a new one arrives once the payee sees that payment settle. schedulePendingPrivateMessageDrainRetries( reason = "allowance payment", - retryKeys = listOf(PrivateMessageDrainRetryKey(publicKey, request.counterpartyReceiverPath)), + retryKeys = listOf(publicKey), ) throw PaykitAllowanceError.PaymentListPending } @@ -414,14 +411,15 @@ class PrivatePaykitRepo @Inject constructor( val eligible = eligibleIdentifiers.toSet() intersect request.acceptedPaymentEndpointIdentifiers.toSet() val candidates = prepared.resolution.payableEndpoints - .mapNotNull { PublicPaykitRepo.parseEndpoint(it.identifier, it.payload) } + .mapNotNull { PublicPaykitRepo.parseEndpoint(it.identifier, it.payload)?.copy(appId = it.appId) } .filter { it.methodId.rawValue in eligible && (it.methodId == MethodId.Bolt11 || it.methodId.isOnchain) } + val payable = privatePayableEndpoints(candidates, publicKey) allowancePayment( request = request, - payable = privatePayableEndpoints(candidates, publicKey), - context = PrivatePaykitPaymentContext(request.counterpartyReceiverPath, paymentListVersion), + payable = payable, + paymentListVersion = paymentListVersion, ) } } @@ -429,19 +427,25 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun allowancePayment( request: PaykitPaymentRequest, payable: List, - context: PrivatePaykitPaymentContext, + paymentListVersion: ULong, ): PrivatePaykitAllowancePayment? = PublicPaykitRepo.payablePreferenceOrder .mapNotNull { methodId -> payable.firstOrNull { it.methodId == methodId } } - .firstNotNullOfOrNull { allowancePayment(request, it, context) } + .firstNotNullOfOrNull { allowancePayment(request, it, paymentListVersion) } private suspend fun allowancePayment( request: PaykitPaymentRequest, endpoint: Endpoint, - context: PrivatePaykitPaymentContext, + paymentListVersion: ULong, ): PrivatePaykitAllowancePayment? { + val appId = endpoint.appId ?: return null + val context = PrivatePaykitPaymentContext( + paymentAppsByEndpoint = mapOf(endpoint.methodId.rawValue to appId), + paymentListVersion = paymentListVersion, + ) if (endpoint.methodId != MethodId.Bolt11) { return PrivatePaykitAllowancePayment( endpoint = endpoint, + appId = appId, context = context, lightningPaymentHash = null, lightningInvoiceHasAmount = false, @@ -453,6 +457,7 @@ class PrivatePaykitRepo @Inject constructor( if (!request.acceptsLightningInvoiceAmountSats(invoice.amountSatoshis)) return null return PrivatePaykitAllowancePayment( endpoint = endpoint, + appId = appId, context = context, lightningPaymentHash = invoice.paymentHash.toHex().lowercase(), lightningInvoiceHasAmount = invoice.amountSatoshis != 0uL, diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index b2ad2f2950..6e17bd8ccf 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -91,6 +91,8 @@ data class PrivatePaykitPaymentContext( /** The payee's current private endpoint for an automatic Allowance payment. */ data class PrivatePaykitAllowancePayment( val endpoint: Endpoint, + /** The payee Paykit app that owns [endpoint]; the payment proof names it. */ + val appId: String, val context: PrivatePaykitPaymentContext, val lightningPaymentHash: String?, val lightningInvoiceHasAmount: Boolean, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt index b3a9bce714..ed61f56efe 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt @@ -19,6 +19,7 @@ import com.synonym.paykit.PaymentOccurrenceKey import com.synonym.paykit.PaymentOccurrenceRecord import com.synonym.paykit.PaymentOutcome import com.synonym.paykit.PaymentOutcomeReport +import com.synonym.paykit.PaymentRequestRecord import com.synonym.paykit.PaymentRequestScope import com.synonym.paykit.PrivateStreamIntakeReport import kotlinx.coroutines.CompletableDeferred @@ -39,10 +40,9 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import to.bitkit.data.keychain.Keychain -import to.bitkit.repositories.PaykitAllowanceFixtures.WALLET_ALLOWANCE_ID +import to.bitkit.repositories.PaykitAllowanceFixtures.ALLOWANCE_ID import to.bitkit.repositories.PaykitAllowanceLocalState.JournalEntry import to.bitkit.repositories.PaykitAllowanceLocalState.Stage -import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError @@ -63,6 +63,7 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { private const val MANUAL_ATTEMPT_ID = "manual-1" private const val INVOICE = "lnbcrt10u1allowancetestinvoice" private const val ONCHAIN_ADDRESS = "bcrt1qallowancetestaddress" + private const val PAYMENT_APP_ID = "bitkit" private val PAYMENT_HASH = "ab".repeat(32) private val TXID = "c".repeat(64) } @@ -145,9 +146,10 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { ), ) } - whenever(sdk.processOutboundPrivateMessages(any(), any())) + whenever(sdk.claimPaymentRequestForExecution(any(), any())).thenReturn(mock()) + whenever(sdk.processOutboundPrivateMessages(any())) .thenReturn(OutboundPrivateSendReport(emptyList(), emptyList(), emptyList(), emptyList(), emptyList())) - whenever(sdk.receivePrivateMessages(any(), any())) + whenever(sdk.receivePrivateMessages(any())) .thenReturn(PrivateStreamIntakeReport(null, emptyList(), emptyList())) } @@ -202,8 +204,8 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { private suspend fun stubPayer() { whenever(payer.resolve(any(), any())).thenReturn(Result.success(lightningPayment())) whenever(payer.consumePaymentList(any(), any())).thenReturn(Result.success(Unit)) - whenever(payer.prepareProof(any(), any(), anyOrNull())).thenReturn(Result.success(Unit)) - whenever(payer.associateLightningPayment(any(), any(), any())).thenReturn(Result.success(Unit)) + whenever(payer.prepareProof(any(), any(), any(), anyOrNull())).thenReturn(Result.success(Unit)) + whenever(payer.associateLightningPayment(any(), any(), any(), any())).thenReturn(Result.success(Unit)) whenever(payer.markOnchainPaymentStarted(any(), any())).thenReturn(Result.success(Unit)) whenever(payer.payLightning(any(), anyOrNull())).thenReturn(Result.success(PAYMENT_HASH)) whenever(payer.payOnchain(any(), any())).thenReturn(Result.success(TXID)) @@ -221,7 +223,6 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { listOf(fixtures.allowance(role = PaykitAllowance.Role.ALLOWEE)), listOf(fixtures.allowance(state = AllowanceLifecycleState.PROPOSED)), listOf(fixtures.allowance(state = AllowanceLifecycleState.ENDED)), - listOf(fixtures.allowance(receiverPath = PaykitReceiverPaths.SERVER)), listOf(fixtures.allowance(counterparty = fixtures.otherCounterpartyKey)), ) @@ -241,17 +242,23 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { val order = inOrder(sdk, payer) order.verify(sdk).evaluateAllowanceCandidates(any(), any()) order.verify(payer).resolve(eq(request), eq(listOf(fixtures.lightningIdentifier))) + order.verify(sdk).claimPaymentRequestForExecution(request.counterparty, request.paymentRequestId) order.verify(sdk).acceptPaymentRequestAutomatically(any(), any(), any()) order.verify(sdk).reserveAutomaticPayment(any(), any(), any()) - order.verify(sdk).receivePrivateMessages(request.counterparty, request.counterpartyReceiverPath) + order.verify(sdk).receivePrivateMessages(request.counterparty) order.verify(sdk).beginPaymentExecution(eq(AUTOMATIC_ATTEMPT_ID), any()) order.verify(payer).consumePaymentList(request.counterparty, lightningPayment().context) - order.verify(payer).prepareProof(request, fixtures.lightningIdentifier, WALLET_ALLOWANCE_ID) - order.verify(payer).associateLightningPayment(request, PAYMENT_HASH, fixtures.lightningIdentifier) + order.verify(payer).prepareProof(request, fixtures.lightningIdentifier, PAYMENT_APP_ID, ALLOWANCE_ID) + order.verify(payer).associateLightningPayment( + request, + PAYMENT_HASH, + fixtures.lightningIdentifier, + PAYMENT_APP_ID, + ) order.verify(payer).payLightning(eq(INVOICE), isNull()) verify(sdk, never()).recordPaymentOutcome(any()) assertEquals(listOf(PaykitAllowanceTime.format(fixtures.now)), evaluatedTrustedTimes) - assertEquals(listOf(WALLET_ALLOWANCE_ID), selections.map { it.allowanceId }) + assertEquals(listOf(ALLOWANCE_ID), selections.map { it.allowanceId }) assertEquals(listOf(fixtures.lightningIdentifier), acceptedChecks.map { it.paymentEndpointIdentifier }) assertEquals(listOf(1uL), reservedAssociationRevisions) @@ -260,7 +267,7 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { assertEquals(Stage.SENT, entry.stage) assertTrue(entry.isAutomatic) assertEquals(request.id, entry.requestId) - assertEquals(WALLET_ALLOWANCE_ID, entry.allowanceId) + assertEquals(ALLOWANCE_ID, entry.allowanceId) assertEquals(1_000uL, entry.amountSats) assertEquals(PAYMENT_HASH, entry.paymentHash) assertEquals(fixtures.lightningIdentifier, entry.paymentEndpointIdentifier) @@ -333,6 +340,20 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { assertEquals(listOf(limitReached), events) } + @Test + fun `execution claim held by another app keeps the request manual before any acceptance`() = test { + whenever(sdk.claimPaymentRequestForExecution(any(), any())).doSuspendableAnswer { + error("Another app owns the execution claim") + } + + val result = sut.autoPay(fixtures.paymentRequest(), listOf(fixtures.allowance()), identity) + + assertEquals(PaykitAllowanceAutoPayResult.MANUAL, result) + verify(sdk, never()).acceptPaymentRequestAutomatically(any(), any(), any()) + verify(sdk, never()).reserveAutomaticPayment(any(), any(), any()) + verify(payer, never()).payLightning(any(), anyOrNull()) + } + @Test fun `blocked reservation marks the payment manual only`() = test { collectEvents() @@ -345,11 +366,7 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { assertEquals(PaykitAllowanceAutoPayResult.MANUAL, result) val limitReached: PaykitAllowanceEvent = PaykitAllowanceEvent.LimitReached(fixtures.counterpartyKey, 1_000uL) - val scope = PaymentRequestScope( - counterparty = request.counterparty, - counterpartyReceiverPath = request.counterpartyReceiverPath, - paymentRequestId = request.paymentRequestId, - ) + val scope = PaymentRequestScope(request.counterparty, request.paymentRequestId) verify(sdk).markPaymentManualOnly(PaymentOccurrence(scope, billingPeriod = null)) verify(sdk, never()).beginPaymentExecution(any(), any()) verify(payer, never()).payLightning(any(), anyOrNull()) @@ -367,13 +384,13 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { assertEquals(listOf(PaymentOutcomeReport(AUTOMATIC_ATTEMPT_ID, PaymentOutcome.FAILED)), recordedOutcomes) assertEquals(listOf(Stage.FAILED), sut.localState(identity).journal.map { it.stage }) verify(payer, never()).consumePaymentList(any(), any()) - verify(payer, never()).prepareProof(any(), any(), anyOrNull()) + verify(payer, never()).prepareProof(any(), any(), any(), anyOrNull()) verify(payer, never()).payLightning(any(), anyOrNull()) } @Test fun `failed proof preparation releases the attempt before any payment`() = test { - whenever(payer.prepareProof(any(), any(), anyOrNull())) + whenever(payer.prepareProof(any(), any(), any(), anyOrNull())) .thenReturn(Result.failure(PaykitPaymentRequestError.RequestUnavailable)) val request = fixtures.paymentRequest() @@ -412,10 +429,10 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { assertEquals(PaykitAllowanceAutoPayResult.COMPLETED, result) val order = inOrder(payer, sdk) - order.verify(payer).prepareProof(request, fixtures.onchainIdentifier, WALLET_ALLOWANCE_ID) + order.verify(payer).prepareProof(request, fixtures.onchainIdentifier, PAYMENT_APP_ID, ALLOWANCE_ID) order.verify(payer).markOnchainPaymentStarted(request, ONCHAIN_ADDRESS) order.verify(payer).payOnchain(eq(ONCHAIN_ADDRESS), any()) - order.verify(payer).completeOnchainPayment(request, TXID, fixtures.onchainIdentifier) + order.verify(payer).completeOnchainPayment(request, TXID, fixtures.onchainIdentifier, PAYMENT_APP_ID) order.verify(sdk).recordPaymentOutcome(PaymentOutcomeReport(AUTOMATIC_ATTEMPT_ID, PaymentOutcome.SUCCEEDED)) val entry = sut.localState(identity).journal.single() assertEquals(Stage.SUCCEEDED, entry.stage) @@ -442,7 +459,7 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { assertEquals(listOf(PaymentOutcome.FAILED, PaymentOutcome.UNKNOWN), recordedOutcomes.map { it.outcome }) verify(payer).failOnchainPayment(definite) verify(payer, never()).failOnchainPayment(uncertain) - verify(payer, never()).completeOnchainPayment(any(), any(), any()) + verify(payer, never()).completeOnchainPayment(any(), any(), any(), any()) } @Test @@ -458,7 +475,7 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { sendResult.complete(Result.success(TXID)) caller.join() - verify(payer).completeOnchainPayment(request, TXID, fixtures.onchainIdentifier) + verify(payer).completeOnchainPayment(request, TXID, fixtures.onchainIdentifier, PAYMENT_APP_ID) assertEquals(listOf(PaymentOutcomeReport(AUTOMATIC_ATTEMPT_ID, PaymentOutcome.SUCCEEDED)), recordedOutcomes) assertEquals(Stage.SUCCEEDED, sut.localState(identity).journal.single().stage) } @@ -739,7 +756,7 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { } private fun candidate(blocked: AllowanceAccountingBlock? = null) = AllowanceCandidate( - allowanceId = WALLET_ALLOWANCE_ID, + allowanceId = ALLOWANCE_ID, eligiblePaymentEndpointIdentifiers = listOf(PaykitAllowanceFixtures.lightningIdentifier), blocked = blocked, ) @@ -750,7 +767,8 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { value = INVOICE, rawPayload = """{"value":"$INVOICE"}""", ), - context = PrivatePaykitPaymentContext(PaykitReceiverPaths.WALLET, 3uL), + appId = PAYMENT_APP_ID, + context = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to PAYMENT_APP_ID), 3uL), lightningPaymentHash = PAYMENT_HASH, lightningInvoiceHasAmount = true, ) @@ -761,7 +779,8 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { value = ONCHAIN_ADDRESS, rawPayload = """{"value":"$ONCHAIN_ADDRESS"}""", ), - context = PrivatePaykitPaymentContext(PaykitReceiverPaths.WALLET, 3uL), + appId = PAYMENT_APP_ID, + context = PrivatePaykitPaymentContext(mapOf(MethodId.P2wpkh.rawValue to PAYMENT_APP_ID), 3uL), lightningPaymentHash = null, lightningInvoiceHasAmount = false, ) @@ -809,7 +828,7 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { attemptId = attemptId, isAutomatic = true, requestId = request.id, - allowanceId = WALLET_ALLOWANCE_ID, + allowanceId = ALLOWANCE_ID, amountSats = request.amountSats, paymentEndpointIdentifier = fixtures.lightningIdentifier, paymentHash = paymentHash, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt index 9687c73f5b..f1b8efbcbb 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt @@ -26,13 +26,11 @@ import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.models.NodeLifecycleState -import to.bitkit.repositories.PaykitAllowanceFixtures.SERVER_ALLOWANCE_ID -import to.bitkit.repositories.PaykitAllowanceFixtures.WALLET_ALLOWANCE_ID +import to.bitkit.repositories.PaykitAllowanceFixtures.SECOND_ALLOWANCE_ID +import to.bitkit.repositories.PaykitAllowanceFixtures.ALLOWANCE_ID import to.bitkit.repositories.PaykitAllowanceLocalState.Stage -import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest -import to.bitkit.utils.AppError import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertIs @@ -82,7 +80,7 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { whenever(activityRepo.setContact(any(), any(), any(), any())).thenReturn(Result.success(Unit)) whenever(sdk.listAllowances(any())).thenAnswer { records } whenever(sdk.linkedPeers()).thenReturn(emptyList()) - whenever(sdk.processOutboundPrivateMessages(any(), any())) + whenever(sdk.processOutboundPrivateMessages(any())) .thenReturn(OutboundPrivateSendReport(emptyList(), emptyList(), emptyList(), emptyList(), emptyList())) sut = PaykitAllowanceRepo( @@ -102,30 +100,29 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { // region Entries @Test - fun `entries group one grant across links with the wallet link as primary`() = test { + fun `entries list one grant per contact with the grant's limits`() = test { records = listOf( - fixtures.record(allowanceId = SERVER_ALLOWANCE_ID, receiverPath = PaykitReceiverPaths.SERVER), - fixtures.record(allowanceId = WALLET_ALLOWANCE_ID), + fixtures.record(allowanceId = ALLOWANCE_ID), fixtures.record(allowanceId = "allowance-other", counterparty = fixtures.otherCounterpartyKey), fixtures.record(allowanceId = "allowance-invalid", historyStatus = AllowanceHistoryStatus.INVALID), ) - localState = PaykitAllowanceLocalState(groups = listOf(group(WALLET_ALLOWANCE_ID, SERVER_ALLOWANCE_ID))) + localState = PaykitAllowanceLocalState(groups = listOf(group(ALLOWANCE_ID))) sut.activate(identity) val entries = sut.entries.value assertEquals(listOf("group-1", "allowance-other"), entries.map { it.id }) - val grouped = entries.first() - assertEquals(listOf(SERVER_ALLOWANCE_ID, WALLET_ALLOWANCE_ID), grouped.allowances.map { it.allowanceId }) - assertEquals(WALLET_ALLOWANCE_ID, grouped.primary.allowanceId) - assertEquals(fixtures.limits, grouped.limits) - assertEquals(fixtures.counterpartyKey, grouped.counterparty) - assertEquals(PaykitAllowance.Role.ALLOWER, grouped.role) - assertEquals(5_000uL, grouped.perPaymentMaxSats) - assertEquals(50_000uL, grouped.monthlyLimitSats) - assertEquals(PaykitAllowance.Status.ACTIVE, grouped.status(fixtures.now)) + val grant = entries.first() + assertEquals(listOf(ALLOWANCE_ID), grant.allowances.map { it.allowanceId }) + assertEquals(ALLOWANCE_ID, grant.primary.allowanceId) + assertEquals(fixtures.limits, grant.limits) + assertEquals(fixtures.counterpartyKey, grant.counterparty) + assertEquals(PaykitAllowance.Role.ALLOWER, grant.role) + assertEquals(5_000uL, grant.perPaymentMaxSats) + assertEquals(50_000uL, grant.monthlyLimitSats) + assertEquals(PaykitAllowance.Status.ACTIVE, grant.status(fixtures.now)) assertNull(entries.last().limits) - assertEquals(WALLET_ALLOWANCE_ID, sut.entry("group-1")?.primary?.allowanceId) + assertEquals(ALLOWANCE_ID, sut.entry("group-1")?.primary?.allowanceId) } @Test @@ -149,19 +146,16 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { @Test fun `auto-paid sats and requests come from succeeded automatic payments`() = test { - records = listOf( - fixtures.record(allowanceId = SERVER_ALLOWANCE_ID, receiverPath = PaykitReceiverPaths.SERVER), - fixtures.record(allowanceId = WALLET_ALLOWANCE_ID), - ) + records = listOf(fixtures.record(allowanceId = ALLOWANCE_ID)) val paid = fixtures.paymentRequest(id = "paid") val failed = fixtures.paymentRequest(id = "failed") - val serverPaid = fixtures.paymentRequest(id = "server") + val secondPaid = fixtures.paymentRequest(id = "second") localState = PaykitAllowanceLocalState( - groups = listOf(group(WALLET_ALLOWANCE_ID, SERVER_ALLOWANCE_ID)), + groups = listOf(group(ALLOWANCE_ID)), journal = listOf( - journalEntry("a", paid, WALLET_ALLOWANCE_ID, 1_000uL, Stage.SUCCEEDED), - journalEntry("b", serverPaid, SERVER_ALLOWANCE_ID, 2_000uL, Stage.SUCCEEDED), - journalEntry("c", failed, WALLET_ALLOWANCE_ID, 5_000uL, Stage.FAILED), + journalEntry("a", paid, ALLOWANCE_ID, 1_000uL, Stage.SUCCEEDED), + journalEntry("b", secondPaid, ALLOWANCE_ID, 2_000uL, Stage.SUCCEEDED), + journalEntry("c", failed, ALLOWANCE_ID, 5_000uL, Stage.FAILED), journalEntry("d", fixtures.paymentRequest(id = "manual"), null, 7_000uL, Stage.SUCCEEDED, false), ), ) @@ -179,14 +173,13 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { // region Coverage @Test - fun `coverage needs an active allower allowance on the request's exact link`() = test { + fun `coverage needs an active allower allowance for the request's contact identity`() = test { records = listOf(fixtures.record(terms = fixtures.terms(expiresAt = (fixtures.now + 30.days).toString()))) sut.activate(identity) assertTrue(sut.coversRequest(fixtures.paymentRequest())) assertFalse(sut.coversRequest(fixtures.paymentRequest(counterparty = fixtures.otherCounterpartyKey))) - assertFalse(sut.coversRequest(fixtures.paymentRequest(receiverPath = PaykitReceiverPaths.SERVER))) records = listOf( fixtures.record(terms = fixtures.terms(expiresAt = "2026-09-20T00:00:00Z")), @@ -214,23 +207,16 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { // region Lifecycle @Test - fun `propose sends the same terms on every supported linked path and records one entry`() = test { + fun `propose sends the terms to the linked contact and records one entry`() = test { whenever(sdk.linkedPeers()).thenReturn( listOf( - linkedPeer(fixtures.counterpartyKey, PaykitReceiverPaths.SERVER), - linkedPeer(fixtures.counterpartyKey, "a/other"), - linkedPeer(fixtures.counterpartyKey, PaykitReceiverPaths.WALLET), - linkedPeer(fixtures.otherCounterpartyKey, PaykitReceiverPaths.WALLET), - linkedPeer(fixtures.counterpartyKey, PaykitReceiverPaths.WALLET, LinkedPeerState.LINKING), + linkedPeer(fixtures.otherCounterpartyKey), + linkedPeer(fixtures.counterpartyKey), ), ) - whenever(sdk.proposeAllowance(any(), any(), any(), any())).doSuspendableAnswer { - val receiverPath = it.getArgument(1) - val isWallet = receiverPath == PaykitReceiverPaths.WALLET - val allowanceId = if (isWallet) WALLET_ALLOWANCE_ID else SERVER_ALLOWANCE_ID + whenever(sdk.proposeAllowance(any(), any(), any())).doSuspendableAnswer { fixtures.record( - allowanceId = allowanceId, - receiverPath = receiverPath, + allowanceId = ALLOWANCE_ID, state = AllowanceLifecycleState.PROPOSED, terms = terms, ).also { record -> records = records + record } @@ -240,18 +226,14 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { val result = sut.propose(fixtures.counterpartyKey, fixtures.limits) assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - val allower = AllowanceLocalRole.ALLOWER - verify(sdk).proposeAllowance(fixtures.counterpartyKey, PaykitReceiverPaths.WALLET, allower, terms) - verify(sdk).proposeAllowance(fixtures.counterpartyKey, PaykitReceiverPaths.SERVER, allower, terms) - verify(sdk, never()).proposeAllowance(any(), eq("a/other"), any(), any()) - verify(sdk).processOutboundPrivateMessages(fixtures.counterpartyKey, PaykitReceiverPaths.WALLET) - verify(sdk).processOutboundPrivateMessages(fixtures.counterpartyKey, PaykitReceiverPaths.SERVER) + verify(sdk).proposeAllowance(fixtures.counterpartyKey, AllowanceLocalRole.ALLOWER, terms) + verify(sdk).processOutboundPrivateMessages(fixtures.counterpartyKey) assertEquals( listOf(fixtures.septemberAnchor to PaykitAllowanceRepo.allowedPaymentEndpointIdentifiers()), proposedTerms, ) val group = localState.groups.single() - assertEquals(listOf(WALLET_ALLOWANCE_ID, SERVER_ALLOWANCE_ID), group.allowanceIds) + assertEquals(listOf(ALLOWANCE_ID), group.allowanceIds) assertEquals(fixtures.limits, group.limits) assertEquals(fixtures.counterpartyKey, group.counterparty) val entry = sut.entries.value.single() @@ -261,115 +243,50 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { } @Test - fun `propose keeps the wallet proposal when the server link fails`() = test { - whenever(sdk.linkedPeers()).thenReturn( - listOf( - linkedPeer(fixtures.counterpartyKey, PaykitReceiverPaths.WALLET), - linkedPeer(fixtures.counterpartyKey, PaykitReceiverPaths.SERVER), - ), - ) - whenever(sdk.proposeAllowance(any(), any(), any(), any())).doSuspendableAnswer { - if (it.getArgument(1) == PaykitReceiverPaths.SERVER) throw TestRepoError("server link") - fixtures.record(state = AllowanceLifecycleState.PROPOSED, terms = terms) - } - sut.activate(identity) - - assertTrue(sut.propose(fixtures.counterpartyKey, fixtures.limits).isSuccess) - - assertEquals(listOf(WALLET_ALLOWANCE_ID), localState.groups.single().allowanceIds) - } - - @Test - fun `propose fails when the contact has no linked receiver`() = test { + fun `propose fails when the contact link is not up`() = test { whenever(sdk.linkedPeers()) - .thenReturn( - listOf(linkedPeer(fixtures.counterpartyKey, PaykitReceiverPaths.WALLET, LinkedPeerState.LINKING)), - ) + .thenReturn(listOf(linkedPeer(fixtures.counterpartyKey, LinkedPeerState.LINKING))) sut.activate(identity) val result = sut.propose(fixtures.counterpartyKey, fixtures.limits) assertIs(result.exceptionOrNull()) - verify(sdk, never()).proposeAllowance(any(), any(), any(), any()) + verify(sdk, never()).proposeAllowance(any(), any(), any()) assertTrue(localState.groups.isEmpty()) } @Test - fun `refresh extends a grant to a contact link that linked after it`() = test { - records = listOf(fixtures.record(allowanceId = WALLET_ALLOWANCE_ID)) - localState = PaykitAllowanceLocalState(groups = listOf(group(WALLET_ALLOWANCE_ID))) - whenever(sdk.linkedPeers()).thenReturn( - listOf( - linkedPeer(fixtures.counterpartyKey, PaykitReceiverPaths.WALLET), - linkedPeer(fixtures.counterpartyKey, PaykitReceiverPaths.SERVER), - ), - ) - whenever(sdk.proposeAllowance(any(), any(), any(), any())).doSuspendableAnswer { - fixtures.record( - allowanceId = SERVER_ALLOWANCE_ID, - receiverPath = PaykitReceiverPaths.SERVER, - state = AllowanceLifecycleState.PROPOSED, - terms = terms, - ).also { record -> records = records + record } - } - - sut.activate(identity) - sut.refresh() - - val allower = AllowanceLocalRole.ALLOWER - verify(sdk).proposeAllowance(fixtures.counterpartyKey, PaykitReceiverPaths.SERVER, allower, terms) - verify(sdk, never()).proposeAllowance(any(), eq(PaykitReceiverPaths.WALLET), any(), any()) - verify(sdk).processOutboundPrivateMessages(fixtures.counterpartyKey, PaykitReceiverPaths.SERVER) - assertEquals( - listOf(fixtures.septemberAnchor to PaykitAllowanceRepo.allowedPaymentEndpointIdentifiers()), - proposedTerms, - ) - assertEquals(listOf(WALLET_ALLOWANCE_ID, SERVER_ALLOWANCE_ID), localState.groups.single().allowanceIds) - val entry = sut.entries.value.single() - assertEquals("group-1", entry.id) - assertEquals(setOf(WALLET_ALLOWANCE_ID, SERVER_ALLOWANCE_ID), entry.allowances.map { it.allowanceId }.toSet()) - assertEquals(PaykitAllowance.Status.ACTIVE, entry.status(fixtures.now)) - } - - @Test - fun `refresh does not extend an ended grant or one that covers every linked path`() = test { - whenever(sdk.linkedPeers()).thenReturn( - listOf( - linkedPeer(fixtures.counterpartyKey, PaykitReceiverPaths.WALLET), - linkedPeer(fixtures.counterpartyKey, PaykitReceiverPaths.SERVER), - ), - ) - records = listOf(fixtures.record(allowanceId = WALLET_ALLOWANCE_ID, state = AllowanceLifecycleState.ENDED)) - localState = PaykitAllowanceLocalState(groups = listOf(group(WALLET_ALLOWANCE_ID))) + fun `an allowance stays one grant for the identity when the contact adds another app later`() = test { + // The grant is bound to the contact's identity, so one that links an app later is covered without a proposal. + records = listOf(fixtures.record(allowanceId = ALLOWANCE_ID)) + localState = PaykitAllowanceLocalState(groups = listOf(group(ALLOWANCE_ID))) + whenever(sdk.linkedPeers()).thenReturn(listOf(linkedPeer(fixtures.counterpartyKey))) sut.activate(identity) - records = listOf( - fixtures.record(allowanceId = WALLET_ALLOWANCE_ID), - fixtures.record(allowanceId = SERVER_ALLOWANCE_ID, receiverPath = PaykitReceiverPaths.SERVER), - ) - localState = PaykitAllowanceLocalState(groups = listOf(group(WALLET_ALLOWANCE_ID, SERVER_ALLOWANCE_ID))) sut.refresh() - verify(sdk, never()).proposeAllowance(any(), any(), any(), any()) + verify(sdk, never()).proposeAllowance(any(), any(), any()) + assertTrue(sut.coversRequest(fixtures.paymentRequest())) + assertEquals(listOf(ALLOWANCE_ID), sut.entries.value.single().allowances.map { it.allowanceId }) } @Test fun `received proposal is presented once and accepting answers it`() = test { val proposalRecord = receivedProposal() records = listOf(proposalRecord) - whenever(sdk.acceptAllowance(any(), any(), any())).thenReturn(proposalRecord) + whenever(sdk.acceptAllowance(any(), any())).thenReturn(proposalRecord) sut.activate(identity) val proposal = checkNotNull(sut.proposalForPresentation()) - assertEquals(WALLET_ALLOWANCE_ID, proposal.id) + assertEquals(ALLOWANCE_ID, proposal.id) sut.markProposalPresented(proposal.id) assertNull(sut.proposalForPresentation()) - assertEquals(setOf(WALLET_ALLOWANCE_ID), localState.presentedProposalIds) + assertEquals(setOf(ALLOWANCE_ID), localState.presentedProposalIds) assertTrue(sut.accept(proposal.id).isSuccess) - verify(sdk).acceptAllowance(fixtures.counterpartyKey, PaykitReceiverPaths.WALLET, WALLET_ALLOWANCE_ID) - verify(sdk).processOutboundPrivateMessages(fixtures.counterpartyKey, PaykitReceiverPaths.WALLET) + verify(sdk).acceptAllowance(fixtures.counterpartyKey, ALLOWANCE_ID) + verify(sdk).processOutboundPrivateMessages(fixtures.counterpartyKey) } @Test @@ -377,25 +294,22 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { records = listOf(fixtures.record()) sut.activate(identity) - assertIs(sut.reject(WALLET_ALLOWANCE_ID).exceptionOrNull()) + assertIs(sut.reject(ALLOWANCE_ID).exceptionOrNull()) assertIs(sut.accept("missing").exceptionOrNull()) - verify(sdk, never()).rejectAllowance(any(), any(), any()) + verify(sdk, never()).rejectAllowance(any(), any()) } @Test - fun `ending an entry ends every endable allowance`() = test { - records = listOf( - fixtures.record(allowanceId = SERVER_ALLOWANCE_ID, receiverPath = PaykitReceiverPaths.SERVER), - fixtures.record(allowanceId = WALLET_ALLOWANCE_ID), - ) - localState = PaykitAllowanceLocalState(groups = listOf(group(WALLET_ALLOWANCE_ID, SERVER_ALLOWANCE_ID))) - whenever(sdk.endAllowance(any(), any(), any())).thenReturn(records.last()) + fun `ending an entry ends its allowance and sends the end right away`() = test { + records = listOf(fixtures.record(allowanceId = ALLOWANCE_ID)) + localState = PaykitAllowanceLocalState(groups = listOf(group(ALLOWANCE_ID))) + whenever(sdk.endAllowance(any(), any())).thenReturn(records.last()) sut.activate(identity) assertTrue(sut.end("group-1").isSuccess) - verify(sdk).endAllowance(fixtures.counterpartyKey, PaykitReceiverPaths.WALLET, WALLET_ALLOWANCE_ID) - verify(sdk).endAllowance(fixtures.counterpartyKey, PaykitReceiverPaths.SERVER, SERVER_ALLOWANCE_ID) + verify(sdk).endAllowance(fixtures.counterpartyKey, ALLOWANCE_ID) + verify(sdk).processOutboundPrivateMessages(fixtures.counterpartyKey) } // endregion @@ -418,7 +332,7 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { assertFalse(sut.processIncomingRequests(listOf(request))) verify(executor, times(1)).autoPay(any(), any(), any()) - records = listOf(fixtures.record(), fixtures.record(allowanceId = SERVER_ALLOWANCE_ID)) + records = listOf(fixtures.record(), fixtures.record(allowanceId = SECOND_ALLOWANCE_ID)) sut.refresh() sut.processIncomingRequests(listOf(request)) verify(executor, times(2)).autoPay(any(), any(), any()) @@ -501,14 +415,14 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { sut.activate(identity) localState = PaykitAllowanceLocalState( journal = listOf( - journalEntry("a", fixtures.paymentRequest(), WALLET_ALLOWANCE_ID, 1_000uL, Stage.SUCCEEDED), + journalEntry("a", fixtures.paymentRequest(), ALLOWANCE_ID, 1_000uL, Stage.SUCCEEDED), ), ) executorEvents.emit(PaykitAllowanceEvent.PaidAutomatically(fixtures.counterpartyKey, 1_000uL, TXID)) verify(activityRepo).setContact(eq(fixtures.counterpartyKey), eq(TXID), any(), any()) - assertEquals(mapOf(WALLET_ALLOWANCE_ID to 1_000uL), sut.autoPaidSats.value) + assertEquals(mapOf(ALLOWANCE_ID to 1_000uL), sut.autoPaidSats.value) } @Test @@ -559,11 +473,9 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { private fun linkedPeer( publicKey: String, - receiverPath: String, state: LinkedPeerState = LinkedPeerState.LINKED, ) = LinkedPeerRecord( counterparty = publicKey, - counterpartyReceiverPath = receiverPath, state = state, lastSyncAt = null, lastPrivateReceiveAt = null, @@ -577,5 +489,3 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { // endregion } - -private class TestRepoError(message: String) : AppError(message) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceTest.kt index faa65324ad..909dd24c8f 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceTest.kt @@ -26,7 +26,6 @@ import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import to.bitkit.data.keychain.Keychain -import to.bitkit.services.PaykitReceiverPaths import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -53,8 +52,7 @@ class PaykitAllowanceTest : BaseUnitTest() { val allowance = checkNotNull(PaykitAllowance.from(record)) assertEquals(fixtures.counterpartyKey, allowance.counterparty) - assertEquals(PaykitReceiverPaths.WALLET, allowance.counterpartyReceiverPath) - assertEquals(PaykitAllowanceFixtures.WALLET_ALLOWANCE_ID, allowance.allowanceId) + assertEquals(PaykitAllowanceFixtures.ALLOWANCE_ID, allowance.allowanceId) assertEquals(PaykitAllowance.Role.ALLOWER, allowance.role) assertTrue(allowance.isAllower) assertTrue(allowance.isProposedByMe) @@ -269,7 +267,7 @@ class PaykitAllowanceTest : BaseUnitTest() { fixtures.capacityAttempt(sats = 45_000uL, at = "2026-09-05T10:00:00Z", isLive = false), fixtures.capacityAttempt(sats = 50_000uL, at = "2026-08-31T23:59:59Z"), fixtures.capacityAttempt( - allowanceId = PaykitAllowanceFixtures.SERVER_ALLOWANCE_ID, + allowanceId = PaykitAllowanceFixtures.SECOND_ALLOWANCE_ID, sats = 50_000uL, at = "2026-09-10T10:00:00Z", ), @@ -339,7 +337,7 @@ class PaykitAllowanceTest : BaseUnitTest() { val attempts = PaykitAllowanceCapacity.attempts(history) - val walletId = PaykitAllowanceFixtures.WALLET_ALLOWANCE_ID + val walletId = PaykitAllowanceFixtures.ALLOWANCE_ID assertEquals( listOf( PaykitAllowanceCapacity.Attempt(walletId, 10_000uL, Instant.parse("2026-09-02T10:00:00Z"), false), @@ -366,46 +364,27 @@ class PaykitAllowanceTest : BaseUnitTest() { // region Grouping and terms @Test - fun `ordered receiver paths keep supported links with the wallet link first`() { - assertEquals( - listOf(PaykitReceiverPaths.WALLET, PaykitReceiverPaths.SERVER), - PaykitAllowanceRepo.orderedReceiverPaths( - listOf(PaykitReceiverPaths.SERVER, "a/other", PaykitReceiverPaths.WALLET, PaykitReceiverPaths.SERVER), - ), - ) - assertEquals( - listOf(PaykitReceiverPaths.SERVER), - PaykitAllowanceRepo.orderedReceiverPaths(listOf(PaykitReceiverPaths.SERVER)), - ) - assertEquals(emptyList(), PaykitAllowanceRepo.orderedReceiverPaths(emptyList())) - } - - @Test - fun `entry primary prefers an accepted link, then the wallet link`() { - val server = fixtures.allowance( - allowanceId = PaykitAllowanceFixtures.SERVER_ALLOWANCE_ID, - receiverPath = PaykitReceiverPaths.SERVER, + fun `entry primary prefers an accepted allowance`() { + val first = fixtures.allowance(perPaymentMaxSats = 5_000uL) + val second = fixtures.allowance( + allowanceId = PaykitAllowanceFixtures.SECOND_ALLOWANCE_ID, perPaymentMaxSats = 1uL, ) - val wallet = fixtures.allowance() - val entry = PaykitAllowanceEntry(id = "group", allowances = listOf(server, wallet), limits = fixtures.limits) - assertEquals(PaykitAllowanceFixtures.WALLET_ALLOWANCE_ID, entry.primary.allowanceId) + val entry = PaykitAllowanceEntry(id = "group", allowances = listOf(first, second), limits = fixtures.limits) + assertEquals(PaykitAllowanceFixtures.ALLOWANCE_ID, entry.primary.allowanceId) assertEquals(5_000uL, entry.perPaymentMaxSats) - val serverOnly = PaykitAllowanceEntry(id = "server", allowances = listOf(server), limits = null) - assertEquals(PaykitAllowanceFixtures.SERVER_ALLOWANCE_ID, serverOnly.primary.allowanceId) - - val walletDeclined = wallet.copy(lifecycleState = AllowanceLifecycleState.REJECTED) - val acceptedOnServer = PaykitAllowanceEntry(id = "g", listOf(walletDeclined, server), limits = null) - assertEquals(PaykitAllowanceFixtures.SERVER_ALLOWANCE_ID, acceptedOnServer.primary.allowanceId) + val firstDeclined = first.copy(lifecycleState = AllowanceLifecycleState.REJECTED) + val acceptedSecond = PaykitAllowanceEntry(id = "g", allowances = listOf(firstDeclined, second), limits = null) + assertEquals(PaykitAllowanceFixtures.SECOND_ALLOWANCE_ID, acceptedSecond.primary.allowanceId) val bothProposed = listOf( - server.copy(lifecycleState = AllowanceLifecycleState.PROPOSED), - wallet.copy(lifecycleState = AllowanceLifecycleState.PROPOSED), + first.copy(lifecycleState = AllowanceLifecycleState.PROPOSED), + second.copy(lifecycleState = AllowanceLifecycleState.PROPOSED), ) val proposed = PaykitAllowanceEntry(id = "p", allowances = bothProposed, limits = null) - assertEquals(PaykitAllowanceFixtures.WALLET_ALLOWANCE_ID, proposed.primary.allowanceId) + assertEquals(PaykitAllowanceFixtures.ALLOWANCE_ID, proposed.primary.allowanceId) } @Test @@ -437,7 +416,7 @@ class PaykitAllowanceTest : BaseUnitTest() { attemptId = "attempt-1", isAutomatic = true, requestId = fixtures.paymentRequest().id, - allowanceId = PaykitAllowanceFixtures.WALLET_ALLOWANCE_ID, + allowanceId = PaykitAllowanceFixtures.ALLOWANCE_ID, amountSats = 1_000uL, paymentEndpointIdentifier = fixtures.lightningIdentifier, paymentHash = "ab".repeat(32), @@ -448,7 +427,7 @@ class PaykitAllowanceTest : BaseUnitTest() { id = "group-1", counterparty = fixtures.counterpartyKey, limits = fixtures.limits, - allowanceIds = listOf(PaykitAllowanceFixtures.WALLET_ALLOWANCE_ID), + allowanceIds = listOf(PaykitAllowanceFixtures.ALLOWANCE_ID), createdAtMillis = 1L, ) val state = PaykitAllowanceLocalState( @@ -464,7 +443,7 @@ class PaykitAllowanceTest : BaseUnitTest() { assertEquals(state, store.load(fixtures.identityKey)) assertEquals(PaykitAllowanceLocalState(), store.load(fixtures.otherCounterpartyKey)) val loaded = store.load(fixtures.identityKey) - assertEquals(group, loaded.group(containing = PaykitAllowanceFixtures.WALLET_ALLOWANCE_ID)) + assertEquals(group, loaded.group(containing = PaykitAllowanceFixtures.ALLOWANCE_ID)) stored = "{not json" assertEquals(PaykitAllowanceLocalState(), store.load(fixtures.identityKey)) @@ -475,8 +454,8 @@ class PaykitAllowanceTest : BaseUnitTest() { /** Shared builders for the Allowance suites. */ internal object PaykitAllowanceFixtures { - const val WALLET_ALLOWANCE_ID = "allowance-wallet" - const val SERVER_ALLOWANCE_ID = "allowance-server" + const val ALLOWANCE_ID = "allowance-1" + const val SECOND_ALLOWANCE_ID = "allowance-second" val identityKey = "pubky" + "z".repeat(52) val counterpartyKey = "pubky" + "y".repeat(52) val otherCounterpartyKey = "pubky" + "x".repeat(52) @@ -536,9 +515,8 @@ internal object PaykitAllowanceFixtures { @Suppress("LongParameterList") fun record( - allowanceId: String = WALLET_ALLOWANCE_ID, + allowanceId: String = ALLOWANCE_ID, counterparty: String = counterpartyKey, - receiverPath: String = PaykitReceiverPaths.WALLET, localRole: AllowanceLocalRole? = AllowanceLocalRole.ALLOWER, state: AllowanceLifecycleState = AllowanceLifecycleState.ACCEPTED, historyStatus: AllowanceHistoryStatus = AllowanceHistoryStatus.CONSISTENT, @@ -547,7 +525,6 @@ internal object PaykitAllowanceFixtures { lastEventAt: String? = "2026-09-02T10:00:00Z", ) = AllowanceRecord( counterparty = counterparty, - counterpartyReceiverPath = receiverPath, allowanceId = allowanceId, localRole = localRole, state = state, @@ -574,9 +551,8 @@ internal object PaykitAllowanceFixtures { @Suppress("LongParameterList") fun allowance( - allowanceId: String = WALLET_ALLOWANCE_ID, + allowanceId: String = ALLOWANCE_ID, counterparty: String = counterpartyKey, - receiverPath: String = PaykitReceiverPaths.WALLET, role: PaykitAllowance.Role = PaykitAllowance.Role.ALLOWER, state: AllowanceLifecycleState = AllowanceLifecycleState.ACCEPTED, perPaymentMaxSats: ULong? = 5_000uL, @@ -585,7 +561,7 @@ internal object PaykitAllowanceFixtures { expiresAt: Instant? = null, lastEventAt: Instant? = null, ) = PaykitAllowance( - id = PaykitAllowance.Id(counterparty, receiverPath, allowanceId), + id = PaykitAllowance.Id(counterparty, allowanceId), role = role, lifecycleState = state, isProposedByMe = role == PaykitAllowance.Role.ALLOWER, @@ -598,14 +574,14 @@ internal object PaykitAllowanceFixtures { ) fun capacityAttempt( - allowanceId: String = WALLET_ALLOWANCE_ID, + allowanceId: String = ALLOWANCE_ID, sats: ULong, at: String, isLive: Boolean = true, ) = PaykitAllowanceCapacity.Attempt(allowanceId, sats, Instant.parse(at), isLive) fun usedSats(attempts: List): ULong = - PaykitAllowanceCapacity.usedSats(WALLET_ALLOWANCE_ID, attempts, septemberAnchor, now) + PaykitAllowanceCapacity.usedSats(ALLOWANCE_ID, attempts, septemberAnchor, now) fun accountingAmount(amount: String, currency: String = PaykitIssuerInterop.BITCOIN_ASSET): AccountingAmount = mock { @@ -617,7 +593,7 @@ internal object PaykitAllowanceFixtures { fun attemptRecord( id: String, mode: PaymentExecutionMode = PaymentExecutionMode.AUTOMATIC, - allowanceId: String? = WALLET_ALLOWANCE_ID, + allowanceId: String? = ALLOWANCE_ID, amount: String = "0.00001", admittedAt: String = "2026-09-24T12:00:00Z", status: PaymentExecutionStatus, @@ -635,16 +611,14 @@ internal object PaykitAllowanceFixtures { fun accountingScope(paymentRequestId: String) = PaymentAccountingScope( localPublicKey = identityKey, - localReceiverPath = PaykitReceiverPaths.WALLET, counterparty = counterpartyKey, - counterpartyReceiverPath = PaykitReceiverPaths.WALLET, paymentRequestId = paymentRequestId, ) fun occurrence( requestId: String, attempts: List, - allowanceId: String? = WALLET_ALLOWANCE_ID, + allowanceId: String? = ALLOWANCE_ID, ) = PaymentOccurrenceRecord( key = PaymentOccurrenceKey(request = accountingScope(requestId), billingPeriod = null), disposition = PaymentDisposition.Automatic, @@ -673,14 +647,12 @@ internal object PaykitAllowanceFixtures { fun paymentRequest( id: String = "550e8400-e29b-41d4-a716-446655440001", counterparty: String = counterpartyKey, - receiverPath: String = PaykitReceiverPaths.WALLET, amountValue: String = "0.00001", amountSats: ULong = 1_000uL, createdAt: String = "2026-09-24T11:00:00Z", ) = PaykitPaymentRequest( paymentRequestId = id, counterparty = counterparty, - counterpartyReceiverPath = receiverPath, amountValue = amountValue, amountSats = amountSats, createdAt = Instant.parse(createdAt), diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 7a06096d17..39f3039cc0 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -239,15 +239,16 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { .thenReturn(record) val sut = paymentProofRepo() - sut.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning, ALLOWANCE_ID).getOrThrow() - sut.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() + sut.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning, ALLOWANCE_ID) + .getOrThrow() + sut.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() assertEquals(ALLOWANCE_ID, storedProofs.single().allowanceId) sut.completeLightningPayment(PAYMENT_HASH, PREIMAGE) verify(paykitSdkService).submitPaymentProof( counterparty = any(), - counterpartyReceiverPath = any(), paymentRequestId = any(), + paymentAppId = eq("bitkit"), paymentEndpointIdentifier = eq(MethodId.Bolt11.rawValue), proofJson = any(), billingPeriod = isNull(), diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAllowancePaymentTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAllowancePaymentTest.kt index a84eb156a7..e3532c7372 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAllowancePaymentTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAllowancePaymentTest.kt @@ -4,7 +4,6 @@ import com.synonym.bitkitcore.LightningInvoice import com.synonym.bitkitcore.NetworkType import com.synonym.bitkitcore.Scanner import com.synonym.paykit.LinkedPeerState -import com.synonym.paykit.PaymentAmountContext import com.synonym.paykit.PrivatePaymentResolutionState import com.synonym.paykit.PrivatePaymentResolutionStatus import kotlinx.coroutines.flow.MutableStateFlow @@ -14,7 +13,6 @@ import org.junit.Before import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull -import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -29,7 +27,6 @@ import to.bitkit.models.NodeLifecycleState import to.bitkit.services.CoreService import to.bitkit.services.PaykitPreparedPrivateContactPayment import to.bitkit.services.PaykitPrivateContactPaymentResolution -import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PaykitResolvedPaymentEndpoint import to.bitkit.services.PaykitSdkService import to.bitkit.services.PubkyService @@ -45,6 +42,7 @@ class PrivatePaykitAllowancePaymentTest : BaseUnitTest(StandardTestDispatcher()) private const val PRIVATE_ADDRESS = "bcrt1qs04g2ka4pr9s3mv73nu32tvfy7r3cxd27wkyu8" private const val PRIVATE_BOLT11 = "lnbcrt1private" private const val PRIVATE_LNURL = "lnurl1private" + private const val PAYMENT_APP_ID = "bitkit" private const val NOW_SECONDS = 1_700_000_000L private val PAYMENT_HASH = byteArrayOf(9, 9, 9) } @@ -104,21 +102,19 @@ class PrivatePaykitAllowancePaymentTest : BaseUnitTest(StandardTestDispatcher()) val invoiceEndpoint = PublicPaykitRepo.parseEndpoint(MethodId.Bolt11.rawValue, payload(PRIVATE_BOLT11)) assertEquals( PrivatePaykitAllowancePayment( - endpoint = checkNotNull(invoiceEndpoint), - context = PrivatePaykitPaymentContext(PaykitReceiverPaths.SERVER, 7uL), + endpoint = checkNotNull(invoiceEndpoint).copy(appId = PAYMENT_APP_ID), + appId = PAYMENT_APP_ID, + context = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to PAYMENT_APP_ID), 7uL), lightningPaymentHash = PAYMENT_HASH.toHex(), lightningInvoiceHasAmount = true, ), payment, ) - val amountCaptor = argumentCaptor() - verify(paykitSdkService).prepareAndResolvePrivateContactPayment( + verify(paykitSdkService).prepareAndResolvePrivatePaymentRequest( eq(CONTACT_KEY), - eq(PaykitReceiverPaths.SERVER), + eq(request.paymentRequestId), eq(null), - amountCaptor.capture(), ) - assertEquals(PaymentAmountContext("0.000025", "btc"), amountCaptor.firstValue) } @Test @@ -201,7 +197,7 @@ class PrivatePaykitAllowancePaymentTest : BaseUnitTest(StandardTestDispatcher()) val expired = paymentRequest().copy(expiresAt = Instant.fromEpochSeconds(NOW_SECONDS - 1)) assertNull(sut.resolveAllowancePayment(expired, eligible(MethodId.Bolt11)).getOrThrow()) - verify(paykitSdkService, never()).prepareAndResolvePrivateContactPayment(any(), any(), anyOrNull(), anyOrNull()) + verify(paykitSdkService, never()).prepareAndResolvePrivatePaymentRequest(any(), any(), anyOrNull()) } private suspend fun stubResolution( @@ -209,7 +205,7 @@ class PrivatePaykitAllowancePaymentTest : BaseUnitTest(StandardTestDispatcher()) version: ULong? = 7uL, status: PrivatePaymentResolutionStatus = PrivatePaymentResolutionStatus.PAYABLE, ) { - whenever(paykitSdkService.prepareAndResolvePrivateContactPayment(any(), any(), anyOrNull(), anyOrNull())) + whenever(paykitSdkService.prepareAndResolvePrivatePaymentRequest(any(), any(), anyOrNull())) .thenReturn( PaykitPreparedPrivateContactPayment( resolution = PaykitPrivateContactPaymentResolution( @@ -246,6 +242,7 @@ class PrivatePaykitAllowancePaymentTest : BaseUnitTest(StandardTestDispatcher()) private fun payload(value: String) = PublicPaykitRepo.serializePayload(value) private fun resolvedEndpoint(methodId: MethodId, value: String) = PaykitResolvedPaymentEndpoint( + appId = PAYMENT_APP_ID, identifier = methodId.rawValue, payload = payload(value), ) @@ -254,7 +251,6 @@ class PrivatePaykitAllowancePaymentTest : BaseUnitTest(StandardTestDispatcher()) PaykitPaymentRequest( paymentRequestId = "request-id", counterparty = CONTACT_KEY, - counterpartyReceiverPath = PaykitReceiverPaths.SERVER, amountValue = "0.000025", amountSats = 2_500uL, expiresAt = Instant.fromEpochSeconds(NOW_SECONDS + 60), diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkStateLayoutTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkStateLayoutTest.kt deleted file mode 100644 index 5a05a180e7..0000000000 --- a/app/src/test/java/to/bitkit/services/PaykitSdkStateLayoutTest.kt +++ /dev/null @@ -1,252 +0,0 @@ -package to.bitkit.services - -import com.synonym.paykit.IdentityStatus -import com.synonym.paykit.PaykitException -import com.synonym.paykit.PaykitSdk -import com.synonym.paykit.SdkStateBlob -import com.synonym.paykit.SdkStateBlobSnapshot -import org.junit.Before -import org.junit.Test -import org.mockito.kotlin.any -import org.mockito.kotlin.doAnswer -import org.mockito.kotlin.doReturn -import org.mockito.kotlin.eq -import org.mockito.kotlin.mock -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever -import to.bitkit.data.keychain.Keychain -import to.bitkit.test.BaseUnitTest -import kotlin.test.assertContentEquals -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertTrue - -class PaykitSdkStateLayoutTest : BaseUnitTest() { - private companion object { - val STATE_KEY = Keychain.Key.PAYKIT_SDK_STATE.name - } - - private val rc55Bytes = byteArrayOf(1, 7, 8) - private val emptyAccountingBytes = byteArrayOf(1, 0, 7, 8) - private val accountingBytes = byteArrayOf(1, 1, 9) - - private val keychain = mock() - private val blocking = mock() - private var storedData: ByteArray? = null - - @Before - fun setUp() { - whenever(keychain.accessBlocking(any())).doAnswer { - it.getArgument Any?>(0).invoke(blocking) - } - whenever(blocking.load(STATE_KEY)).doAnswer { storedData } - doAnswer { storedData = it.getArgument(1) }.whenever(blocking).upsert(eq(STATE_KEY), any()) - } - - @Test - fun `rc55 layout gains the empty accounting tag and loses it again`() { - val sdkBytes = PaykitSdkStateLayout.RC55.sdkBytes(rc55Bytes) - val (layout, storedBytes) = PaykitSdkStateLayout.stored(sdkBytes) - - assertContentEquals(emptyAccountingBytes, sdkBytes) - assertEquals(PaykitSdkStateLayout.RC55, layout) - assertContentEquals(rc55Bytes, storedBytes) - } - - @Test - fun `state with accounting keeps the allowances layout`() { - val (layout, storedBytes) = PaykitSdkStateLayout.stored(accountingBytes) - - assertEquals(PaykitSdkStateLayout.ALLOWANCES, layout) - assertContentEquals(accountingBytes, storedBytes) - assertContentEquals(accountingBytes, PaykitSdkStateLayout.ALLOWANCES.sdkBytes(accountingBytes)) - } - - @Test - fun `revision suffix names the stored layout`() { - assertEquals("id.rc55", PaykitSdkStateLayout.RC55.revision("id")) - assertEquals("id.alw", PaykitSdkStateLayout.ALLOWANCES.revision("id")) - assertEquals(PaykitSdkStateLayout.RC55, PaykitSdkStateLayout.fromRevision("id.rc55")) - assertEquals(PaykitSdkStateLayout.ALLOWANCES, PaykitSdkStateLayout.fromRevision("id.alw")) - assertNull(PaykitSdkStateLayout.fromRevision("3f1c0e9a-7b8d-4c2e-9f10-2a6b5c4d3e21")) - } - - @Test - fun `rc55 blob loads in the allowances layout`() { - storeSnapshot(rc55Bytes, "r1.rc55") - - val snapshot = assertNotNull(store().loadStateBlob()) - - assertContentEquals(emptyAccountingBytes, snapshot.blob.exportBytes()) - assertEquals("r1.rc55", snapshot.revision) - } - - @Test - fun `unmarked blob loads unchanged`() { - storeSnapshot(rc55Bytes, "legacy") - - val snapshot = assertNotNull(store().loadStateBlob()) - - assertContentEquals(rc55Bytes, snapshot.blob.exportBytes()) - assertEquals("legacy", snapshot.revision) - } - - @Test - fun `save without accounting stores the rc55 layout`() { - val store = store() - - val revision = store.saveStateBlobAtomically(blob(emptyAccountingBytes), expectedRevision = null) - - val stored = storedSnapshot() - assertTrue(revision.endsWith(".rc55")) - assertEquals(revision, stored.revision) - assertContentEquals(rc55Bytes, stored.blob.exportBytes()) - assertContentEquals(emptyAccountingBytes, store.loadStateBlob()?.blob?.exportBytes()) - } - - @Test - fun `save with accounting stores the allowances layout`() { - storeSnapshot(rc55Bytes, "r1.rc55") - val store = store() - - val revision = store.saveStateBlobAtomically(blob(accountingBytes), expectedRevision = "r1.rc55") - - val stored = storedSnapshot() - assertTrue(revision.endsWith(".alw")) - assertEquals(revision, stored.revision) - assertContentEquals(accountingBytes, stored.blob.exportBytes()) - assertContentEquals(accountingBytes, store.loadStateBlob()?.blob?.exportBytes()) - } - - @Test - fun `save rejects a stale revision`() { - storeSnapshot(rc55Bytes, "r1.rc55") - val store = store() - val loadedRevision = assertNotNull(store.loadStateBlob()).revision - val nextRevision = store.saveStateBlobAtomically(blob(accountingBytes), loadedRevision) - - for (staleRevision in listOf(loadedRevision, null)) { - val error = assertFailsWith { - store.saveStateBlobAtomically(blob(emptyAccountingBytes), staleRevision) - } - assertEquals("revision_conflict", error.code) - } - assertEquals(nextRevision, storedSnapshot().revision) - assertContentEquals(accountingBytes, storedSnapshot().blob.exportBytes()) - } - - @Test - fun `unmarked rc55 blob resolves to the rc55 layout`() = test { - storeSnapshot(rc55Bytes, "legacy") - val store = store() - val probes = mutableListOf() - - store.resolveLegacyLayoutIfNeeded { - probe(decodes = it.contentEquals(emptyAccountingBytes), hasIdentity = true).also(probes::add) - } - - val stored = storedSnapshot() - assertEquals("legacy.rc55", stored.revision) - assertContentEquals(rc55Bytes, stored.blob.exportBytes()) - assertContentEquals(emptyAccountingBytes, store.loadStateBlob()?.blob?.exportBytes()) - assertEquals(2, probes.size) - probes.forEach { verify(it).close() } - } - - @Test - fun `unmarked allowances blob resolves to the allowances layout`() = test { - storeSnapshot(accountingBytes, "legacy") - val store = store() - - store.resolveLegacyLayoutIfNeeded { probe(decodes = it.contentEquals(accountingBytes), hasIdentity = true) } - - val stored = storedSnapshot() - assertEquals("legacy.alw", stored.revision) - assertContentEquals(accountingBytes, stored.blob.exportBytes()) - assertContentEquals(accountingBytes, store.loadStateBlob()?.blob?.exportBytes()) - } - - @Test - fun `identity decides when both layouts decode`() = test { - val cases = listOf( - true to "legacy.rc55", - false to "legacy.alw", - ) - for ((rc55HasIdentity, expectedRevision) in cases) { - storeSnapshot(rc55Bytes, "legacy") - - store().resolveLegacyLayoutIfNeeded { - val isRc55Candidate = it.contentEquals(emptyAccountingBytes) - probe(decodes = true, hasIdentity = isRc55Candidate == rc55HasIdentity) - } - - assertEquals(expectedRevision, storedSnapshot().revision) - } - } - - @Test - fun `undecodable or marked blobs keep their revision`() = test { - storeSnapshot(rc55Bytes, "legacy") - store().resolveLegacyLayoutIfNeeded { probe(decodes = false, hasIdentity = false) } - assertEquals("legacy", storedSnapshot().revision) - - storeSnapshot(rc55Bytes, "r1.alw") - var probeCount = 0 - store().resolveLegacyLayoutIfNeeded { - probeCount++ - probe(decodes = true, hasIdentity = true) - } - assertEquals("r1.alw", storedSnapshot().revision) - assertEquals(0, probeCount) - } - - @Test - fun `resolution keeps a state saved while probing`() = test { - storeSnapshot(rc55Bytes, "legacy") - - store().resolveLegacyLayoutIfNeeded { - storeSnapshot(accountingBytes, "concurrent.alw") - probe(decodes = true, hasIdentity = true) - } - - val stored = storedSnapshot() - assertEquals("concurrent.alw", stored.revision) - assertContentEquals(accountingBytes, stored.blob.exportBytes()) - } - - private fun store() = PaykitSdkStateBlobStore(keychain, ::decode, ::encode, ::blob) - - private fun probe(decodes: Boolean, hasIdentity: Boolean): PaykitSdk { - val sdk = mock() - if (decodes) { - whenever { sdk.allowanceAccountingState() }.thenReturn(null) - } else { - whenever { sdk.allowanceAccountingState() } - .thenThrow(PaykitException.Storage("decode", "decode SDK state blob")) - } - val publicKey = if (hasIdentity) "pubky_test" else null - whenever { sdk.identityStatus() }.thenReturn(IdentityStatus(publicKey, liveSessionAvailable = false)) - return sdk - } - - private fun storeSnapshot(bytes: ByteArray, revision: String) { - storedData = encode(SdkStateBlobSnapshot(blob(bytes), revision)) - } - - private fun storedSnapshot() = decode(checkNotNull(storedData)) - - private fun blob(bytes: ByteArray): SdkStateBlob = mock { on { exportBytes() } doReturn bytes } - - private fun encode(snapshot: SdkStateBlobSnapshot) = - "${snapshot.revision}\n".encodeToByteArray() + snapshot.blob.exportBytes() - - private fun decode(data: ByteArray): SdkStateBlobSnapshot { - val separator = data.indexOf('\n'.code.toByte()) - return SdkStateBlobSnapshot( - blob = blob(data.copyOfRange(separator + 1, data.size)), - revision = data.copyOf(separator).decodeToString(), - ) - } -} diff --git a/app/src/test/java/to/bitkit/ui/screens/subscriptions/AllowancesViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/subscriptions/AllowancesViewModelTest.kt index 7b44377a1f..ecc88ce7f8 100644 --- a/app/src/test/java/to/bitkit/ui/screens/subscriptions/AllowancesViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/subscriptions/AllowancesViewModelTest.kt @@ -152,7 +152,7 @@ class AllowancesViewModelTest : BaseUnitTest() { @Test fun `contact picker lists only contacts that can receive payment requests`() = test { - targets.value = listOf(PaykitPaymentRequestTarget(LEO_KEY, "bitkit/wallet")) + targets.value = listOf(PaykitPaymentRequestTarget(LEO_KEY)) assertEquals(listOf("Leo"), loadedState().contacts.map { it.name }) } @@ -210,7 +210,7 @@ class AllowancesViewModelTest : BaseUnitTest() { id = "entry-1", allowances = listOf( PaykitAllowance( - id = PaykitAllowance.Id(LEO_KEY, "bitkit/wallet", "allowance-1"), + id = PaykitAllowance.Id(LEO_KEY, "allowance-1"), role = PaykitAllowance.Role.ALLOWER, lifecycleState = state, isProposedByMe = true, diff --git a/journeys/allowances/README.md b/journeys/allowances/README.md index da9ccc442f..d2ccb4997e 100644 --- a/journeys/allowances/README.md +++ b/journeys/allowances/README.md @@ -8,7 +8,7 @@ pins the one rule that must never break: a payment interrupted mid-flight is nev ## Setup Run Bitkit against regtest with Paykit UI enabled on two instances that have each other saved as -contacts and linked on receiver path `bitkit/wallet`, exactly as for +contacts and linked over Paykit, exactly as for [`../subscriptions/README.md`](../subscriptions/README.md). One instance plays the payer (the one that sets the allowance), the other the payee (the one that sends requests). Automatic payments go over Lightning only, so the payer needs a spending balance above 50,000 sats with a usable channel, From 0b1f2664a0f754387b930d2f8efb6810b830e95c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 1 Oct 2026 19:28:36 +0200 Subject: [PATCH 27/51] fix: keep an automatically accepted request payable on this install --- .../repositories/PaykitAllowanceExecutor.kt | 29 +++++--- .../repositories/PaykitPaymentRequestRepo.kt | 67 +++++++++++++------ .../PaykitAllowanceExecutorTest.kt | 4 ++ .../PaykitPaymentRequestRepoTest.kt | 35 ++++++++++ 4 files changed, 102 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt index 3e5d708a6e..50bdff9855 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt @@ -56,6 +56,7 @@ class PaykitAllowancePayer @Inject constructor( private val privatePaykitRepo: PrivatePaykitRepo, private val paymentProofRepo: PaykitPaymentProofRepo, private val lightningRepo: LightningRepo, + private val paymentRequestRepo: PaykitPaymentRequestRepo, ) { suspend fun resolve( request: PaykitPaymentRequest, @@ -65,6 +66,10 @@ class PaykitAllowancePayer @Inject constructor( suspend fun consumePaymentList(publicKey: String, context: PrivatePaykitPaymentContext): Result = privatePaykitRepo.consumePrivatePaymentList(publicKey, context) + /** Accepts through [accept] with this install as the request's owner, so a failed payment stays payable here. */ + suspend fun acceptOnThisInstall(request: PaykitPaymentRequest, accept: suspend () -> T): Result = + paymentRequestRepo.acceptOnThisInstall(request, accept) + suspend fun prepareProof( request: PaykitPaymentRequest, paymentEndpointIdentifier: String, @@ -400,17 +405,19 @@ class PaykitAllowanceExecutor @Inject constructor( } val endpointIdentifier = payment.endpoint.methodId.rawValue - // Every identity-wide execution step needs this app to own the request's execution claim first. - paykitSdkService.claimPaymentRequestForExecution(request.counterparty, request.paymentRequestId) - val association = paykitSdkService.acceptPaymentRequestAutomatically( - scope = scope, - selection = AllowanceSelectionInput( - allowanceId = candidate.allowanceId, - expectedRevision = null, - trustedTime = selectionTime, - ), - checks = checks(request, endpointIdentifier, selectionTime), - ) + val association = payer.acceptOnThisInstall(request) { + // Every identity-wide execution step needs this app to own the request's execution claim first. + paykitSdkService.claimPaymentRequestForExecution(request.counterparty, request.paymentRequestId) + paykitSdkService.acceptPaymentRequestAutomatically( + scope = scope, + selection = AllowanceSelectionInput( + allowanceId = candidate.allowanceId, + expectedRevision = null, + trustedTime = selectionTime, + ), + checks = checks(request, endpointIdentifier, selectionTime), + ) + }.getOrThrow() sendQueuedMessages(request) val occurrence = PaymentOccurrence(scope, billingPeriod = null) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index cb216f16aa..cd75609e8b 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -834,32 +834,12 @@ class PaykitPaymentRequestRepo @Inject constructor( } } else { updateRequest(request, PaymentRequestLifecycleState.ACCEPTED) { - val identity = activeIdentity ?: throw PaykitPaymentRequestError.RequestUnavailable - val generation = stateGeneration.get() - ensurePaymentAllowed(it, forExecution = false).getOrThrow() - val alreadySaved = it.id in presentationStore.loadAcceptedOneTimeIds(identity) - val acceptedIds = presentationStore.addAcceptedOneTimeId(identity, it.id) - if (!isCurrentState(generation, identity)) throw PaykitPaymentRequestError.RequestUnavailable - acceptedOneTimeRequestIds = acceptedIds - runSuspendCatching { + withAcceptanceIntent(it) { paykitSdkService.acceptPaymentRequest( counterparty = it.counterparty, paymentRequestId = it.paymentRequestId, ) - }.onFailure { error -> - // Acceptance may already be committed. Reconcile before discarding its owner. - val uncertain = when (error) { - is PaykitException.Transport, - is PaykitException.Storage, - is PaykitException.Identity -> true - else -> false - } - if (!alreadySaved && !uncertain) { - val remaining = presentationStore.removeAcceptedOneTimeIds(identity, setOf(it.id)) - if (isCurrentState(generation, identity)) acceptedOneTimeRequestIds = remaining - } - }.getOrThrow() - if (!isCurrentState(generation, identity)) throw PaykitPaymentRequestError.RequestUnavailable + } }.getOrThrow() } }.onFailure { @@ -867,6 +847,49 @@ class PaykitPaymentRequestRepo @Inject constructor( } } + /** + * Accepts [request] through [accept] with this install as its owner, like [accept], for an acceptance that does not + * come from the request sheet: the Allowance accepts an automatically paid request. The request stays payable on + * this install if the payment that follows fails. + */ + suspend fun acceptOnThisInstall( + request: PaykitPaymentRequest, + accept: suspend () -> T, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + if (!request.requiresAcceptance) throw PaykitPaymentRequestError.RequestUnavailable + operationMutex.withLock { withAcceptanceIntent(request, accept) } + }.onFailure { + Logger.warn("Failed to accept an incoming Paykit payment request for this install", it, context = TAG) + } + } + + /** Saves this install as the owner of [request] before [operation] runs, and drops it on a definite failure. */ + private suspend fun withAcceptanceIntent(request: PaykitPaymentRequest, operation: suspend () -> T): T { + val identity = activeIdentity ?: throw PaykitPaymentRequestError.RequestUnavailable + val generation = stateGeneration.get() + ensurePaymentAllowed(request, forExecution = false).getOrThrow() + val alreadySaved = request.id in presentationStore.loadAcceptedOneTimeIds(identity) + val acceptedIds = presentationStore.addAcceptedOneTimeId(identity, request.id) + if (!isCurrentState(generation, identity)) throw PaykitPaymentRequestError.RequestUnavailable + acceptedOneTimeRequestIds = acceptedIds + val result = runSuspendCatching { operation() }.onFailure { error -> + // Acceptance may already be committed. Reconcile before discarding its owner. + val uncertain = when (error) { + is PaykitException.Transport, + is PaykitException.Storage, + is PaykitException.Identity -> true + else -> false + } + if (!alreadySaved && !uncertain) { + val remaining = presentationStore.removeAcceptedOneTimeIds(identity, setOf(request.id)) + if (isCurrentState(generation, identity)) acceptedOneTimeRequestIds = remaining + } + }.getOrThrow() + if (!isCurrentState(generation, identity)) throw PaykitPaymentRequestError.RequestUnavailable + return result + } + suspend fun reject(request: PaykitPaymentRequest): Result = updateRequest( request = request, resultingState = PaymentRequestLifecycleState.REJECTED, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt index ed61f56efe..b288db4562 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt @@ -203,6 +203,9 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { private suspend fun stubPayer() { whenever(payer.resolve(any(), any())).thenReturn(Result.success(lightningPayment())) + whenever(payer.acceptOnThisInstall(any(), any())).doSuspendableAnswer { + runCatching { it.getArgument AllowanceAssociationRecord>(1).invoke() } + } whenever(payer.consumePaymentList(any(), any())).thenReturn(Result.success(Unit)) whenever(payer.prepareProof(any(), any(), any(), anyOrNull())).thenReturn(Result.success(Unit)) whenever(payer.associateLightningPayment(any(), any(), any(), any())).thenReturn(Result.success(Unit)) @@ -242,6 +245,7 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { val order = inOrder(sdk, payer) order.verify(sdk).evaluateAllowanceCandidates(any(), any()) order.verify(payer).resolve(eq(request), eq(listOf(fixtures.lightningIdentifier))) + order.verify(payer).acceptOnThisInstall(eq(request), any()) order.verify(sdk).claimPaymentRequestForExecution(request.counterparty, request.paymentRequestId) order.verify(sdk).acceptPaymentRequestAutomatically(any(), any(), any()) order.verify(sdk).reserveAutomaticPayment(any(), any(), any()) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 1e27cc2dc6..113e1cdeb7 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -551,6 +551,41 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) } + @Test + fun `automatic acceptance saves this install as owner before it runs and keeps the request payable here`() = test { + val proposed = paymentRequestRecord() + val accepted = proposed.copy(state = PaymentRequestLifecycleState.ACCEPTED) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(proposed)) + sut.refresh().getOrThrow() + val request = sut.pendingRequests.value.single() + val requestId = PaykitPaymentRequestId(PAYMENT_REQUEST_ID, COUNTERPARTY) + + val result = sut.acceptOnThisInstall(request) { + verify(presentationStore).addAcceptedOneTimeId(LOCAL_IDENTITY, requestId) + "accepted" + } + + assertEquals("accepted", result.getOrThrow()) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(accepted)) + sut.refresh().getOrThrow() + sut.ensurePaymentAllowed(request).getOrThrow() + } + + @Test + fun `failed automatic acceptance drops this install as owner`() = test { + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(paymentRequestRecord())) + sut.refresh().getOrThrow() + val request = sut.pendingRequests.value.single() + + val result = sut.acceptOnThisInstall(request) { error("claimed by another app") } + + assertTrue(result.isFailure) + verify(presentationStore).removeAcceptedOneTimeIds( + LOCAL_IDENTITY, + setOf(PaykitPaymentRequestId(PAYMENT_REQUEST_ID, COUNTERPARTY)), + ) + } + @Test fun `local acceptance survives refresh and reconnect without accepting twice`() = test { val proposed = paymentRequestRecord() From 455bb90b5305ec1b258a9182a49949696059f2c0 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 1 Oct 2026 12:34:35 -0500 Subject: [PATCH 28/51] test: align request presentation with shared paykit --- .../test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt | 2 +- journeys/payment-requests/automatic-presentation.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 13fe5f01e9..0665807f26 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -934,7 +934,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { Result.success( PublicPaykitPaymentResult.Opened( paymentRequest = bolt11, - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 8uL), + privatePaymentContext = privatePaymentContext(version = 8uL), ), ), ) diff --git a/journeys/payment-requests/automatic-presentation.xml b/journeys/payment-requests/automatic-presentation.xml index 47b5009cdd..8f19ff2dc7 100644 --- a/journeys/payment-requests/automatic-presentation.xml +++ b/journeys/payment-requests/automatic-presentation.xml @@ -1,6 +1,6 @@ - Verifies that a newly received request opens automatically in the foreground, waits for another sheet to close, and does not reopen a request the payer already reviewed. Requires two Bitkit instances saved as each other's contacts and linked on receiver path "bitkit/wallet", with the payer funded for 21,000 sats; see README.md. + Verifies that a newly received request opens automatically in the foreground, waits for another sheet to close, and does not reopen a request the payer already reviewed. Requires two Bitkit instances with separate Pubky identities, saved as each other's contacts and linked, with the payer funded for 21,000 sats; see README.md. On the payer, leave Bitkit unlocked and in the foreground on Home From 6d42b5ac3b6a3852ab57bf09b10b5863528702f5 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 1 Oct 2026 12:56:19 -0500 Subject: [PATCH 29/51] test: align private paykit settings stub --- .../test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 64dd663966..11795347ba 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -113,7 +113,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { cacheData.value = PrivatePaykitCacheData() } whenever(settingsStore.data).thenReturn(settingsData) - whenever { settingsStore.update(any()) }.thenAnswer { + whenever(settingsStore.update(any())).thenAnswer { val transform = it.getArgument<(SettingsData) -> SettingsData>(0) settingsData.value = transform(settingsData.value) } From b55a79d377b602004c29e10f4d224315bf3bd342 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 1 Oct 2026 21:59:38 +0200 Subject: [PATCH 30/51] docs: align the end allowance journey with the ended row --- journeys/allowances/end-stops-auto-pay.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/journeys/allowances/end-stops-auto-pay.xml b/journeys/allowances/end-stops-auto-pay.xml index ab8d7a761b..65a0221380 100644 --- a/journeys/allowances/end-stops-auto-pay.xml +++ b/journeys/allowances/end-stops-auto-pay.xml @@ -19,7 +19,7 @@ Set and accept a fresh $5 a payment, $50 a month allowance between the same two instances, per set-and-accept.xml On the payee instance, tap the Active row, verify the detail ends with "Swipe To End Allowance", and swipe it to the end Verify the payee's row reads "Ended" - On the payer instance, verify its row for that allowance reads "Ended · $0.00 paid automatically" + On the payer instance, verify its row for that allowance reads "Ended"; tap it and verify the detail sheet shows PAID AUTOMATICALLY "$0.00" (testTag "AllowancePaidSoFar") On the payee instance, send the payer a one-time Payment Request for $2 with the note "AfterPayeeEnd" On the payer instance, verify it is not paid and waits in the bell as an ordinary Payment Request, then dismiss it From 5aa675e92f81e03879c6870ab2de34c780404ab3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 1 Oct 2026 22:05:28 +0200 Subject: [PATCH 31/51] style: order the allowance imports --- app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt | 6 +++--- .../to/bitkit/repositories/PaykitAllowanceRepoTest.kt | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index adcadedf0c..33cc0d829d 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -152,6 +152,9 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LnurlPayInvoiceMismatchError import to.bitkit.repositories.MethodId import to.bitkit.repositories.NodeEventUpdate +import to.bitkit.repositories.PaykitAllowanceError +import to.bitkit.repositories.PaykitAllowanceEvent +import to.bitkit.repositories.PaykitAllowanceRepo import to.bitkit.repositories.PaykitOnchainPaymentProofResolution import to.bitkit.repositories.PaykitPaymentProofKind import to.bitkit.repositories.PaykitPaymentProofRepo @@ -161,9 +164,6 @@ import to.bitkit.repositories.PaykitPaymentRequestDiagnostics import to.bitkit.repositories.PaykitPaymentRequestDraft import to.bitkit.repositories.PaykitPaymentRequestError import to.bitkit.repositories.PaykitPaymentRequestId -import to.bitkit.repositories.PaykitAllowanceError -import to.bitkit.repositories.PaykitAllowanceEvent -import to.bitkit.repositories.PaykitAllowanceRepo import to.bitkit.repositories.PaykitPaymentRequestRepo import to.bitkit.repositories.PaykitPaymentRequestTarget import to.bitkit.repositories.PaykitSubscription diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt index f1b8efbcbb..904b842dfb 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt @@ -1,8 +1,5 @@ package to.bitkit.repositories -import org.mockito.kotlin.doReturn -import kotlinx.collections.immutable.persistentListOf -import org.lightningdevkit.ldknode.ChannelDetails import com.synonym.paykit.AllowanceHistoryStatus import com.synonym.paykit.AllowanceLifecycleState import com.synonym.paykit.AllowanceLocalRole @@ -10,14 +7,17 @@ import com.synonym.paykit.AllowanceRecord import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState import com.synonym.paykit.OutboundPrivateSendReport +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import org.junit.Before import org.junit.Test +import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.PaymentFailureReason import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -26,8 +26,8 @@ import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.models.NodeLifecycleState -import to.bitkit.repositories.PaykitAllowanceFixtures.SECOND_ALLOWANCE_ID import to.bitkit.repositories.PaykitAllowanceFixtures.ALLOWANCE_ID +import to.bitkit.repositories.PaykitAllowanceFixtures.SECOND_ALLOWANCE_ID import to.bitkit.repositories.PaykitAllowanceLocalState.Stage import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest From 16d44b5ef55e9b5d64ad06b224ed007655de3233 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 1 Oct 2026 22:13:32 +0200 Subject: [PATCH 32/51] fix: satisfy detekt in the allowance acceptance --- .../java/to/bitkit/repositories/PaykitAllowanceRepo.kt | 1 - .../to/bitkit/repositories/PaykitPaymentRequestRepo.kt | 8 ++++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt index 76883eb7f4..2210134d7f 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt @@ -101,7 +101,6 @@ class PaykitAllowanceRepo @Inject constructor( /** Endpoints Bitkit pays automatically: bolt11 and every on-chain method of the network. */ fun allowedPaymentEndpointIdentifiers(network: Network = Env.network): List = (listOf(MethodId.Bolt11) + MethodId.entries.filter { it.isOnchain }).map { it.rawValueForNetwork(network) } - } private val scope = appScope(ioDispatcher, TAG) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index cd75609e8b..d947d8b5a1 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -871,7 +871,7 @@ class PaykitPaymentRequestRepo @Inject constructor( ensurePaymentAllowed(request, forExecution = false).getOrThrow() val alreadySaved = request.id in presentationStore.loadAcceptedOneTimeIds(identity) val acceptedIds = presentationStore.addAcceptedOneTimeId(identity, request.id) - if (!isCurrentState(generation, identity)) throw PaykitPaymentRequestError.RequestUnavailable + requireCurrentState(generation, identity) acceptedOneTimeRequestIds = acceptedIds val result = runSuspendCatching { operation() }.onFailure { error -> // Acceptance may already be committed. Reconcile before discarding its owner. @@ -886,10 +886,14 @@ class PaykitPaymentRequestRepo @Inject constructor( if (isCurrentState(generation, identity)) acceptedOneTimeRequestIds = remaining } }.getOrThrow() - if (!isCurrentState(generation, identity)) throw PaykitPaymentRequestError.RequestUnavailable + requireCurrentState(generation, identity) return result } + private fun requireCurrentState(generation: Long, identity: String) { + if (!isCurrentState(generation, identity)) throw PaykitPaymentRequestError.RequestUnavailable + } + suspend fun reject(request: PaykitPaymentRequest): Result = updateRequest( request = request, resultingState = PaymentRequestLifecycleState.REJECTED, From 1adde2a48e1292ce0198166d344d062dadabd62e Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 1 Oct 2026 16:53:39 -0500 Subject: [PATCH 33/51] fix: reduce paykit sync overhead and retry session setup --- .../ContactPaymentSettingsRepo.kt | 12 +- .../to/bitkit/services/PaykitSdkService.kt | 172 ++++++++++++------ .../java/to/bitkit/viewmodels/AppViewModel.kt | 1 + .../ContactPaymentSettingsRepoTest.kt | 25 +++ .../services/PaykitBackupStateTrackingTest.kt | 78 ++++++++ .../services/PaykitKeyGenerationTest.kt | 61 ++++++- .../bitkit/services/PaykitSdkServiceTest.kt | 43 ++++- .../viewmodels/AppViewModelSendFlowTest.kt | 29 +++ journeys/contacts/README.md | 11 ++ journeys/contacts/contact-payment-sharing.xml | 20 ++ 10 files changed, 372 insertions(+), 80 deletions(-) create mode 100644 journeys/contacts/README.md create mode 100644 journeys/contacts/contact-payment-sharing.xml diff --git a/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt b/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt index e21e6faa0d..6feff41ab7 100644 --- a/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt @@ -84,7 +84,6 @@ class ContactPaymentSettingsRepo @Inject constructor( } private suspend fun disable(contacts: List): Result { - val previous = settingsStore.data.first() runSuspendCatching { settingsStore.update { it.copy( @@ -108,16 +107,7 @@ class ContactPaymentSettingsRepo @Inject constructor( .onFailure { publicCleanupError = it } publicCleanupError?.let { error -> - runSuspendCatching { - settingsStore.update { settings -> - settings.copy(sharesPublicPaykitEndpoints = previous.sharesPublicPaykitEndpoints) - } - }.onFailure(error::addSuppressed) - publicPaykitRepo.syncPublishedEndpoints(publish = previous.sharesPublicPaykitEndpoints) - .onFailure { - error.addSuppressed(it) - markPublicPaykitRetry(error) - } + markPublicPaykitRetry(error) } val cleanupError = publicCleanupError ?: privateCleanupError publicCleanupError?.let { publicError -> diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 9e60d814ab..4957f3007e 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -1,6 +1,7 @@ package to.bitkit.services import android.content.Context +import androidx.annotation.VisibleForTesting import com.synonym.paykit.ContactRecord import com.synonym.paykit.ContactUpdate import com.synonym.paykit.EndpointSyncReport @@ -181,6 +182,8 @@ class PaykitSdkService @Inject constructor( private var setupFailed = false private var platformInitializer: () -> Unit = { PaykitAndroid.initializeOrThrow(context) } private var sdk: PaykitSdk? = null + private var cachedPaykitKey: PaykitKeyGeneration? = null + private var cachedBackupState: PaykitBackupStateSnapshot? = null private val _backupStateVersion = MutableStateFlow(0L) val backupStateVersion: StateFlow = _backupStateVersion.asStateFlow() private var sdkFactory: () -> PaykitSdk = { @@ -226,11 +229,12 @@ class PaykitSdkService @Inject constructor( platformInitializer() launch { republishIdentityIfNeeded() } operationLock.withLock { - refreshPaykitKey() + refreshPaykitKey(force = true) var handle = handle() try { handle.initialize() } catch (e: PaykitException.Identity) { + invalidatePaykitKeyIfNeeded(e) if (!sessionProvider.canDeferStaleSession(e.context)) throw e Logger.warn( @@ -304,18 +308,16 @@ class PaykitSdkService @Inject constructor( suspend fun currentPublicKey(): String? { isSetup.await() return operationLock.withLock { - refreshPaykitKey() - val handle = handle() - handle.identityStatus()?.publicKey?.let { return@withLock it } - handle.initialize().publicKey + withPaykitKey { handle -> + handle.identityStatus()?.publicKey ?: handle.initialize().publicKey + } } } suspend fun hasPrivatePaymentAccess(): Boolean { isSetup.await() return operationLock.withLock { - refreshPaykitKey() - handle().identityStatus()?.capability == PubkyIdentityCapability.PRIVATE_LINK_CAPABLE + withPaykitKey { it.identityStatus()?.capability == PubkyIdentityCapability.PRIVATE_LINK_CAPABLE } } } @@ -470,12 +472,12 @@ class PaykitSdkService @Inject constructor( suspend fun publishPaykitProfile(profile: PaykitProfile): PaykitProfileRecord { isSetup.await() return operationLock.withLock { - refreshPaykitKey() - val handle = handle() - val publicKey = requireNotNull(handle.identityStatus()?.publicKey) - val current = handle.fetchPaykitProfile(publicKey) - handle.publishPaykitProfile(profile, current?.revision).also { - notifyBackupStateChanged() + withPaykitKey { handle -> + val publicKey = requireNotNull(handle.identityStatus()?.publicKey) + val current = handle.fetchPaykitProfile(publicKey) + handle.publishPaykitProfile(profile, current?.revision).also { + notifyBackupStateChanged() + } } } } @@ -499,11 +501,11 @@ class PaykitSdkService @Inject constructor( suspend fun deletePaykitProfile() { isSetup.await() operationLock.withLock { - refreshPaykitKey() - val handle = handle() - val publicKey = requireNotNull(handle.identityStatus()?.publicKey) - handle.fetchPaykitProfile(publicKey)?.let { handle.deletePaykitProfile(it.revision) } - notifyBackupStateChanged() + withPaykitKey { handle -> + val publicKey = requireNotNull(handle.identityStatus()?.publicKey) + handle.fetchPaykitProfile(publicKey)?.let { handle.deletePaykitProfile(it.revision) } + notifyBackupStateChanged() + } } } @@ -524,16 +526,14 @@ class PaykitSdkService @Inject constructor( suspend fun contactRecords(): List { isSetup.await() return operationLock.withLock { - refreshPaykitKey() - handle().contactRecords() + withPaykitKey { it.contactRecords() } } } suspend fun contactRecord(publicKey: String): ContactRecord? { isSetup.await() return operationLock.withLock { - refreshPaykitKey() - handle().contactRecord(publicKey) + withPaykitKey { it.contactRecord(publicKey) } } } @@ -583,6 +583,7 @@ class PaykitSdkService @Inject constructor( Logger.warn("Failed to withdraw private endpoints before contact deletion", context = TAG) } }.onFailure { + invalidatePaykitKeyIfNeeded(it) Logger.warn("Failed to withdraw private endpoints before contact deletion", it, context = TAG) } } @@ -702,30 +703,29 @@ class PaykitSdkService @Inject constructor( suspend fun allPaymentRequests(expectedIdentity: String? = null): List { isSetup.await() return operationLock.withLock { - refreshPaykitKey() - val handle = handle() - if (expectedIdentity != null) { - check(PubkyPublicKeyFormat.matches(handle.identityStatus()?.publicKey, expectedIdentity)) { - "Payment Request identity changed" + withPaykitKey { handle -> + if (expectedIdentity != null) { + check(PubkyPublicKeyFormat.matches(handle.identityStatus()?.publicKey, expectedIdentity)) { + "Payment Request identity changed" + } } - } - handle.listPaymentRequests( - PaymentRequestFilter( - counterparty = null, - localRole = null, - states = emptyList(), - recurring = null, - receivedOnly = false, + handle.listPaymentRequests( + PaymentRequestFilter( + counterparty = null, + localRole = null, + states = emptyList(), + recurring = null, + receivedOnly = false, + ), ) - ) + } } } suspend fun identityStatus(): IdentityStatus? { isSetup.await() return operationLock.withLock { - refreshPaykitKey() - handle().identityStatus() + withPaykitKey { it.identityStatus() } } } @@ -853,16 +853,14 @@ class PaykitSdkService @Inject constructor( suspend fun linkedPeers(): List { isSetup.await() return operationLock.withLock { - refreshPaykitKey() - handle().linkedPeers() + withPaykitKey { it.linkedPeers() } } } suspend fun pendingOutboundPrivateCounterparties(): List { isSetup.await() return operationLock.withLock { - refreshPaykitKey() - handle().pendingOutboundPrivateCounterparties() + withPaykitKey { it.pendingOutboundPrivateCounterparties() } } } @@ -948,8 +946,7 @@ class PaykitSdkService @Inject constructor( suspend fun exportBackupState(): String { isSetup.await() return operationLock.withLock { - refreshPaykitKey() - handle().exportBackupString() + withPaykitKey { it.exportBackupString() } } } @@ -1003,12 +1000,24 @@ class PaykitSdkService @Inject constructor( } } - private suspend fun refreshPaykitKey() { - val root = sessionProvider.loadLocalSecretKey() ?: return - sessionProvider.setPaykitIdentitySecretKey(paykitKey(root)) + private suspend fun refreshPaykitKey(force: Boolean = false) { + if (force) cachedPaykitKey = null + val root = sessionProvider.loadLocalSecretKey() ?: run { + cachedPaykitKey = null + return + } + val publicKey = pubkyPublicKeyFromSecret(root) + val savedGeneration = keychain.loadString("${Keychain.Key.PAYKIT_KEY_GENERATION.name}:$publicKey")?.toULong() + val cached = cachedPaykitKey + if (cached != null && cached.publicKey == publicKey && cached.generation == savedGeneration) return + cachedPaykitKey = null + val key = paykitKey(root) + sessionProvider.setPaykitIdentitySecretKey(key) + cachedPaykitKey = PaykitKeyGeneration(publicKey, key.keyGeneration()) } - suspend fun paykitKeyForAuthorization(secretKeyHex: String): PaykitIdentitySecretKey { + @VisibleForTesting + internal suspend fun paykitKeyForAuthorization(secretKeyHex: String): PaykitIdentitySecretKey { isSetup.await() return operationLock.withLock { paykitKey(localSecretKey(secretKeyHex)) } } @@ -1067,6 +1076,7 @@ class PaykitSdkService @Inject constructor( ) } }.onFailure { + invalidatePaykitKeyIfNeeded(it) Logger.warn("Failed to publish Paykit app", it, context = TAG) } } @@ -1106,18 +1116,39 @@ class PaykitSdkService @Inject constructor( blockedPeers.forEach { peer -> runSuspendCatching { handle.blockPeer(peer.counterparty) - }.onFailure(restorationError::addSuppressed) + }.onFailure { + invalidatePaykitKeyIfNeeded(it) + restorationError.addSuppressed(it) + } } } } } } - private suspend fun withStateRevisionTracking(block: suspend (PaykitSdk) -> T): T { + private suspend fun withPaykitKey(block: suspend (PaykitSdk) -> T): T { refreshPaykitKey() - val handle = handle() - return withPaykitBackupStateTracking( - readRevision = { handle.backupStateRevision() }, + return runSuspendCatching { block(handle()) } + .onFailure(::invalidatePaykitKeyIfNeeded) + .getOrThrow() + } + + private fun invalidatePaykitKeyIfNeeded(error: Throwable) { + if (error !is PaykitException.Identity) return + cachedPaykitKey = null + cachedBackupState = null + } + + private suspend fun withStateRevisionTracking(block: suspend (PaykitSdk) -> T): T = withPaykitKey { handle -> + withPaykitBackupStateTracking( + readRevision = { + runSuspendCatching { handle.backupStateRevision() } + .onFailure(::invalidatePaykitKeyIfNeeded) + .getOrThrow() + }, + readStateRevision = { handle.stateRevision() }, + cachedSnapshot = cachedBackupState, + onSnapshot = { cachedBackupState = it }, onChange = ::notifyBackupStateChanged, ) { block(handle) @@ -1141,6 +1172,8 @@ class PaykitSdkService @Inject constructor( private fun resetRuntime() { sdk = null + cachedPaykitKey = null + cachedBackupState = null } companion object { @@ -1185,17 +1218,46 @@ internal fun isBitkitPaymentRequest(record: PaymentRequestRecord): Boolean = whe else -> false } +private data class PaykitKeyGeneration(val publicKey: String, val generation: ULong) + +internal data class PaykitBackupStateSnapshot(val stateRevision: String, val backupRevision: String) + +@Suppress("LongParameterList") internal suspend fun withPaykitBackupStateTracking( readRevision: suspend () -> String, + readStateRevision: () -> String? = { null }, + cachedSnapshot: PaykitBackupStateSnapshot? = null, + onSnapshot: (PaykitBackupStateSnapshot?) -> Unit = {}, onChange: () -> Unit, operation: suspend () -> T, ): T { - val previousRevision = runSuspendCatching { readRevision() }.getOrNull() + val observedStateRevision = runSuspendCatching { readStateRevision() }.getOrNull() + val previousRevision = if (cachedSnapshot != null && observedStateRevision == cachedSnapshot.stateRevision) { + cachedSnapshot.backupRevision + } else { + runSuspendCatching { readRevision() }.getOrNull() + } + val previousStateRevision = runSuspendCatching { readStateRevision() }.getOrNull() + var succeeded = false return try { - operation() + operation().also { succeeded = true } } finally { withContext(NonCancellable) { + val nextStateRevision = runSuspendCatching { readStateRevision() }.getOrNull() + val unchangedRevision = previousStateRevision?.takeIf { it == nextStateRevision } + if (succeeded && unchangedRevision != null && previousRevision != null) { + onSnapshot(PaykitBackupStateSnapshot(unchangedRevision, previousRevision)) + return@withContext + } val nextRevision = runSuspendCatching { readRevision() }.getOrNull() + val stateRevision = runSuspendCatching { readStateRevision() }.getOrNull() + onSnapshot( + if (succeeded && stateRevision != null && nextRevision != null) { + PaykitBackupStateSnapshot(stateRevision, nextRevision) + } else { + null + }, + ) if (previousRevision == null || nextRevision == null || previousRevision != nextRevision) { onChange() } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 1affda6575..3d953dc9d8 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -902,6 +902,7 @@ class AppViewModel @Inject constructor( if (isOnline.value != ConnectivityState.CONNECTED) continue val refreshMaintenance = maintenanceDelay <= Duration.ZERO if (refreshMaintenance) { + if (isPaykitEnabled.value && walletRepo.walletExists()) pubkyRepo.restoreSessionIfNeeded() pubkyRepo.republishIdentityIfNeeded() privatePaykitRepo.refreshKnownSavedContactEndpoints("payment request polling") maintenanceIntervalIndex = diff --git a/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt index b9efee5774..0bf9ffe89b 100644 --- a/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt @@ -18,6 +18,7 @@ import to.bitkit.models.PubkyProfile import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError import kotlin.test.assertFalse +import kotlin.test.assertSame import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) @@ -160,6 +161,30 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { verify(privatePaykitRepo, never()).enableSharingAndPrepareSavedContacts(any>()) } + @Test + fun `failed public cleanup keeps sharing disabled and retains its retry`() = test { + val cleanupResults = listOf(Result.success(Unit), Result.failure(ContactPaymentSettingsTestError("withdrawal"))) + for (privateCleanup in cleanupResults) { + settingsFlow.value = SettingsData( + sharesPublicPaykitEndpoints = true, + sharesPrivatePaykitEndpoints = true, + ) + val failure = ContactPaymentSettingsTestError("app update failed") + whenever(publicPaykitRepo.syncPublishedEndpoints(publish = false)).thenReturn(Result.failure(failure)) + whenever(privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>())) + .thenReturn(privateCleanup) + + val result = createSut().setEnabled(false) + + assertSame(failure, result.exceptionOrNull()) + assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) + assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) + assertTrue(settingsFlow.value.publicPaykitCleanupPending) + verify(publicPaykitRepo, never()).syncPublishedEndpoints(publish = true) + verify(privatePaykitRepo, never()).enableSharingAndPrepareSavedContacts(any>()) + } + } + private fun createSut() = ContactPaymentSettingsRepo( settingsStore = settingsStore, publicPaykitRepo = publicPaykitRepo, diff --git a/app/src/test/java/to/bitkit/services/PaykitBackupStateTrackingTest.kt b/app/src/test/java/to/bitkit/services/PaykitBackupStateTrackingTest.kt index 408ab4e501..24a8e57509 100644 --- a/app/src/test/java/to/bitkit/services/PaykitBackupStateTrackingTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitBackupStateTrackingTest.kt @@ -10,6 +10,7 @@ import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertNull import kotlin.test.assertSame import kotlin.test.assertTrue @@ -80,4 +81,81 @@ class PaykitBackupStateTrackingTest : BaseUnitTest() { assertEquals("after", revision) assertEquals(1, changes) } + + @Test + fun `unchanged operations reuse the backup fingerprint`() = test { + var snapshot: PaykitBackupStateSnapshot? = null + var reads = 0 + var changes = 0 + repeat(3) { + withPaykitBackupStateTracking( + readRevision = { + reads++ + "content" + }, + readStateRevision = { "state" }, + cachedSnapshot = snapshot, + onSnapshot = { snapshot = it }, + onChange = { changes++ }, + ) {} + } + assertEquals(1, reads) + assertEquals(0, changes) + assertEquals(PaykitBackupStateSnapshot("state", "content"), snapshot) + } + + @Test + fun `changed state revisions still compare backup content`() = test { + for ((cachedState, finalContent, expectedReads) in listOf( + Triple("before", "content", 1), + Triple("before", "changed", 1), + Triple("stale", "changed", 2), + )) { + var state = "before" + var content = "content" + var reads = 0 + var changes = 0 + var snapshot: PaykitBackupStateSnapshot? = null + withPaykitBackupStateTracking( + readRevision = { + reads++ + content + }, + readStateRevision = { state }, + cachedSnapshot = PaykitBackupStateSnapshot(cachedState, "content"), + onSnapshot = { snapshot = it }, + onChange = { changes++ }, + ) { + state = "after" + content = finalContent + } + assertEquals(expectedReads, reads) + assertEquals(if (finalContent == "content") 0 else 1, changes) + assertEquals(PaykitBackupStateSnapshot("after", finalContent), snapshot) + } + } + + @Test + fun `failed writes recheck backup content even when the local revision is unchanged`() = test { + var snapshot: PaykitBackupStateSnapshot? = PaykitBackupStateSnapshot("state", "before") + var reads = 0 + var changes = 0 + val failure = AppError("Write outcome unknown") + val thrown = assertFailsWith { + withPaykitBackupStateTracking( + readRevision = { + reads++ + "after" + }, + readStateRevision = { "state" }, + cachedSnapshot = snapshot, + onSnapshot = { snapshot = it }, + onChange = { changes++ }, + ) { throw failure } + } + assertSame(failure, thrown) + assertEquals(1, reads) + assertEquals(1, changes) + assertNull(snapshot) + } } diff --git a/app/src/test/java/to/bitkit/services/PaykitKeyGenerationTest.kt b/app/src/test/java/to/bitkit/services/PaykitKeyGenerationTest.kt index 364e4d9cc2..09bae705a5 100644 --- a/app/src/test/java/to/bitkit/services/PaykitKeyGenerationTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitKeyGenerationTest.kt @@ -2,6 +2,7 @@ package to.bitkit.services import com.synonym.paykit.IdentityStatus import com.synonym.paykit.PaykitAppRegistry +import com.synonym.paykit.PaykitException import com.synonym.paykit.PaykitIdentitySecretKey import com.synonym.paykit.PaykitSdk import com.synonym.paykit.PubkyIdentityCapability @@ -66,20 +67,24 @@ class PaykitKeyGenerationTest { } @Test - fun `stored root refresh rotates the session key and rejects registry rollback`() = runTest { + fun `cached keys refresh after remote rotation and reject registry rollback`() = runTest { val initialRegistry = mock { on { keyGeneration }.thenReturn(2uL) } val rotatedRegistry = mock { on { keyGeneration }.thenReturn(3uL) } val sdk = mock() whenever(sdk.paykitAppRegistry(RING_PUBKY)) .thenReturn(initialRegistry, rotatedRegistry, initialRegistry, null) - whenever(sdk.identityStatus()) - .thenReturn(IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + val status = IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE) + val staleKey = PaykitException.Identity("identity_error", "Shared state requires a newer key") + whenever(sdk.identityStatus()).thenReturn(status, status).thenThrow(staleKey).thenReturn(status) + .thenThrow(staleKey) val keychain = mock() val storageKey = "${Keychain.Key.PAYKIT_KEY_GENERATION.name}:$RING_PUBKY" - whenever(keychain.loadString(storageKey)).thenReturn("2", "2", "3", "3") + var savedGeneration = "2" + whenever(keychain.loadString(storageKey)).thenAnswer { savedGeneration } + whenever(keychain.upsertString(storageKey, "3")).thenAnswer { savedGeneration = "3" } val root = mock() - val initialKey = mock() - val rotatedKey = mock() + val initialKey = mock { on { keyGeneration() }.thenReturn(2uL) } + val rotatedKey = mock { on { keyGeneration() }.thenReturn(3uL) } whenever(root.derivePaykitIdentitySecretKey(2uL)).thenReturn(initialKey) whenever(root.derivePaykitIdentitySecretKey(3uL)).thenReturn(rotatedKey) @@ -91,6 +96,10 @@ class PaykitKeyGenerationTest { val service = PaykitSdkService(mock(), keychain, mock(), settingsStore = mock()) { sdk } val provider = providers.constructed().single() repeat(2) { assertEquals(RING_PUBKY, service.currentPublicKey()) } + verify(sdk).paykitAppRegistry(RING_PUBKY) + assertSame(staleKey, assertFailsWith { service.currentPublicKey() }) + assertEquals(RING_PUBKY, service.currentPublicKey()) + assertSame(staleKey, assertFailsWith { service.currentPublicKey() }) repeat(2) { val error = assertFailsWith { service.currentPublicKey() } assertEquals("The Paykit App Registry has an older key generation", error.message) @@ -105,7 +114,7 @@ class PaykitKeyGenerationTest { } verify(root, times(2)).derivePaykitIdentitySecretKey(any()) verify(keychain).upsertString(any(), any()) - verify(sdk, times(2)).identityStatus() + verify(sdk, times(5)).identityStatus() verify(provider, times(2)).setPaykitIdentitySecretKey(any()) } } @@ -138,4 +147,42 @@ class PaykitKeyGenerationTest { assertEquals(1, sessions.constructed().size) } } + + @Test + fun `best effort backup identity failures invalidate the cached session key`() = runTest { + val sdk = mock() + val initialRegistry = mock { on { keyGeneration }.thenReturn(1uL) } + val rotatedRegistry = mock { on { keyGeneration }.thenReturn(2uL) } + whenever(sdk.paykitAppRegistry(RING_PUBKY)).thenReturn(initialRegistry, rotatedRegistry) + whenever(sdk.identityStatus()) + .thenReturn(IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + whenever(sdk.stateRevision()).thenReturn("state") + whenever(sdk.backupStateRevision()).thenThrow(PaykitException.Identity("identity_error", "Stale key")) + whenever(sdk.processPendingPrivateMessages()).thenReturn(emptyList()) + val keychain = mock() + val storageKey = "${Keychain.Key.PAYKIT_KEY_GENERATION.name}:$RING_PUBKY" + whenever(keychain.loadString(storageKey)).thenReturn("1") + val root = mock() + val initialKey = mock { on { keyGeneration() }.thenReturn(1uL) } + val rotatedKey = mock { on { keyGeneration() }.thenReturn(2uL) } + whenever(root.derivePaykitIdentitySecretKey(1uL)).thenReturn(initialKey) + whenever(root.derivePaykitIdentitySecretKey(2uL)).thenReturn(rotatedKey) + + mockStatic(Class.forName("com.synonym.paykit.Paykit_androidKt")).use { native -> + native.`when` { pubkyPublicKeyFromSecret(root) }.thenReturn(RING_PUBKY) + mockConstruction(PaykitSdkSessionProvider::class.java) { provider, _ -> + whenever(provider.loadLocalSecretKey()).thenReturn(root) + }.use { providers -> + val service = PaykitSdkService(mock(), keychain, mock(), settingsStore = mock()) { sdk } + assertEquals(RING_PUBKY, service.currentPublicKey()) + service.processPendingPrivateMessages() + verify(sdk).paykitAppRegistry(RING_PUBKY) + + assertEquals(RING_PUBKY, service.currentPublicKey()) + verify(sdk, times(2)).paykitAppRegistry(RING_PUBKY) + verify(providers.constructed().single()).setPaykitIdentitySecretKey(rotatedKey) + verify(keychain).upsertString(storageKey, "2") + } + } + } } diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index dc99595fd1..b5ac804590 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -40,6 +40,7 @@ import org.mockito.kotlin.doReturn import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever @@ -70,6 +71,28 @@ class PaykitSdkServiceTest { private const val RING_PUBKY = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" } + @Test + fun `initialization can be retried after shared state contention`() = runTest { + val failure = PaykitException.ConcurrentUpdate("concurrent_update", "Resource locked") + val sdk = mock() + whenever(sdk.initialize()).thenThrow(failure).thenReturn(mock()) + whenever(sdk.contactRecords()).thenReturn(emptyList()) + val service = PaykitSdkService( + mock(), + mock(), + mock(), + ioDispatcher = StandardTestDispatcher(testScheduler), + settingsStore = mock(), + platformInitializer = {}, + sdkFactory = { sdk }, + ) + + assertSame(failure, assertFailsWith { service.initialize() }) + assertSame(failure, assertFailsWith { service.contactRecords() }) + service.initialize() + assertEquals(emptyList(), service.contactRecords()) + } + @Test fun `wallet wipe drains initialization before cleanup and allows fresh work`() = runTest { val sdk = mock() @@ -103,21 +126,27 @@ class PaykitSdkServiceTest { } @Test - fun `wallet wipe discards runtime handles before and after cleanup`() = runTest { + fun `wallet wipe discards handles and backup fingerprints even when cleanup fails`() = runTest { val sdk = mock() - whenever(sdk.contactRecords()).thenReturn(emptyList()) + whenever(sdk.processPendingPrivateMessages()).thenReturn(emptyList()) + whenever(sdk.stateRevision()).thenReturn("state") + whenever(sdk.backupStateRevision()).thenReturn("backup") var handlesCreated = 0 val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { handlesCreated++ sdk } - service.contactRecords() - service.withWalletWipe { - service.contactRecords() - assertEquals(2, handlesCreated) + repeat(2) { service.processPendingPrivateMessages() } + assertFailsWith { + service.withWalletWipe { + service.processPendingPrivateMessages() + assertEquals(2, handlesCreated) + throw AppError("Cleanup failed") + } } - service.contactRecords() + service.processPendingPrivateMessages() assertEquals(3, handlesCreated) + verify(sdk, times(3)).backupStateRevision() } @Test diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 0665807f26..a27e20d94a 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -736,6 +736,34 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } } + @Test + fun `foreground maintenance retries a missing session until restored`() = test { + enablePaykitUi() + pubkyPublicKey.value = null + var attempts = 0 + whenever(pubkyRepo.restoreSessionIfNeeded()).thenAnswer { + if (++attempts == 2) pubkyPublicKey.value = testPublicKey + Unit + } + whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + clearInvocations(pubkyRepo, paykitPaymentRequestRepo) + + sut.startPaykitPaymentRequestPolling() + try { + advanceTimeBy(30.seconds.inWholeMilliseconds) + runCurrent() + verify(pubkyRepo).restoreSessionIfNeeded() + verify(paykitPaymentRequestRepo, never()).refresh() + + advanceTimeBy(60.seconds.inWholeMilliseconds) + runCurrent() + verify(pubkyRepo, times(2)).restoreSessionIfNeeded() + verify(paykitPaymentRequestRepo).refresh() + } finally { + sut.stopPaykitPaymentRequestPolling() + } + } + @Test fun `offline periodic polling skips inbox and maintenance`() = test { enablePaykitUi() @@ -756,6 +784,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { runCurrent() verify(pubkyRepo, never()).republishIdentityIfNeeded() verify(paykitPaymentRequestRepo, never()).refresh() + verify(pubkyRepo, never()).restoreSessionIfNeeded() verify(paykitPaymentRequestRepo, never()).refreshEligibleTargets(any(), any()) verify(paykitPaymentProofRepo, never()).reconcile() verify(privatePaykitRepo, never()).refreshKnownSavedContactEndpoints("payment request polling") diff --git a/journeys/contacts/README.md b/journeys/contacts/README.md new file mode 100644 index 0000000000..331016b319 --- /dev/null +++ b/journeys/contacts/README.md @@ -0,0 +1,11 @@ +# Contacts + +`contact-payment-sharing.xml` checks disabling sharing and keeping it off after returning to +Settings. The same journey is available on iOS. + +Network and storage fault injection are outside journey-runner capabilities. With contact sharing +on, make one private-list withdrawal fail and let the following public endpoint or app-registry +update fail as well. Turn off contact payments. The app must report the failure, keep the toggle off, +and retain both cleanup jobs without republishing cleared private lists. Restore connectivity and +foreground the app. Cleanup must finish while sharing stays off. Repeat with only the trailing +public endpoint or app-registry update failing. diff --git a/journeys/contacts/contact-payment-sharing.xml b/journeys/contacts/contact-payment-sharing.xml new file mode 100644 index 0000000000..b183c9ef75 --- /dev/null +++ b/journeys/contacts/contact-payment-sharing.xml @@ -0,0 +1,20 @@ + + + Verifies the contact-payment preference stays off when leaving and returning to Settings. + Requires an authenticated disposable Pubky identity with contact payments enabled and a saved, + linked contact. Cleanup failure injection is a manual check because network and storage fault + injection are not journey capabilities, as documented in README.md. Use only one active + install of this wallet. + + + Open the menu and tap Settings (id "DrawerSettings") + Open the General tab (id "Tab-general") + Verify the contact payments toggle (id "ContactPaymentsToggle") is on + Tap the contact payments toggle (id "ContactPaymentsToggle") + Wait for the update to finish and verify the contact payments toggle is off + Return to the wallet home screen + Open the menu and tap Settings (id "DrawerSettings") + Open the General tab (id "Tab-general") + Verify the contact payments toggle (id "ContactPaymentsToggle") is still off + + From 7026f86936b9f9f535fb76c738ee0da1c2027985 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 2 Oct 2026 06:26:12 -0500 Subject: [PATCH 34/51] fix: keep private message sync off frequent request polls --- .../repositories/PaykitPaymentRequestRepo.kt | 11 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 2 +- .../PaykitPaymentRequestRepoTest.kt | 17 +++ .../viewmodels/AppViewModelSendFlowTest.kt | 113 +++++++++--------- journeys/payment-requests/README.md | 6 + .../automatic-presentation.xml | 2 +- 6 files changed, 91 insertions(+), 60 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index cb216f16aa..ef1249f302 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -438,7 +438,7 @@ class PaykitPaymentRequestRepo @Inject constructor( } } - suspend fun refresh(): Result { + suspend fun refresh(syncPrivateMessages: Boolean = true): Result { val generation = stateGeneration.get() val expectedIdentity = activeIdentity return withContext(ioDispatcher) { @@ -448,7 +448,7 @@ class PaykitPaymentRequestRepo @Inject constructor( clearStateLocked() return@withLock } - runSuspendCatching { synchronizeLocked(generation, expectedIdentity) } + runSuspendCatching { synchronizeLocked(generation, expectedIdentity, syncPrivateMessages) } .onFailure { discardExpiredRequestsLocked() } .getOrThrow() } @@ -968,9 +968,12 @@ class PaykitPaymentRequestRepo @Inject constructor( private suspend fun synchronizeLocked( generation: Long, expectedIdentity: String?, + syncPrivateMessages: Boolean = true, ) { - processPendingMessages() - paykitSdkService.receivePrivateMessagesFromLinkedPeers().also(::logIntakeFailures) + if (syncPrivateMessages) { + processPendingMessages() + paykitSdkService.receivePrivateMessagesFromLinkedPeers().also(::logIntakeFailures) + } val now = clock.now() receivedContacts = null val allRecords = paykitSdkService.allPaymentRequests(expectedIdentity) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 3d953dc9d8..9a6d0912df 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -875,7 +875,7 @@ class AppViewModel @Inject constructor( private suspend fun refreshIncomingPaykitPaymentRequests(refreshMaintenance: Boolean = true) { if (!isPaykitEnabled.value || pubkyRepo.publicKey.value == null || !walletRepo.walletExists()) return if (refreshMaintenance) paykitPaymentProofRepo.reconcile() - paykitPaymentRequestRepo.refresh().onSuccess { + paykitPaymentRequestRepo.refresh(syncPrivateMessages = refreshMaintenance).onSuccess { activityRepo.backfillPaykitContacts() presentNextIncomingPaykitPaymentRequest() } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 1e27cc2dc6..6d19642a18 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -135,6 +135,23 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.clear() } + @Test + fun `shared state refresh skips private messages until a full refresh`() = test { + val record = paymentRequestRecord() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) + + sut.refresh(syncPrivateMessages = false).getOrThrow() + + assertEquals(record.paymentRequestId, sut.pendingRequests.value.single().paymentRequestId) + verify(paykitSdkService, never()).processPendingPrivateMessages() + verify(paykitSdkService, never()).receivePrivateMessagesFromLinkedPeers() + + sut.refresh().getOrThrow() + + verify(paykitSdkService).processPendingPrivateMessages() + verify(paykitSdkService).receivePrivateMessagesFromLinkedPeers() + } + @Test fun `shared app destinations stay out of request UI and clear on identity switch`() = test { val address = PaykitReceivedPaymentContactsTest.ADDRESS diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index a27e20d94a..3ddce0ebf6 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -682,7 +682,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `network restoration republishes identity and resumes ten second polling`() = test { enablePaykitUi() pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) sut.startPaykitPaymentRequestPolling() try { advanceTimeBy(30.seconds.inWholeMilliseconds) @@ -704,32 +704,37 @@ class AppViewModelSendFlowTest : BaseUnitTest() { clearInvocations(paykitPaymentRequestRepo) advanceTimeBy(10.seconds.inWholeMilliseconds - 1) runCurrent() - verify(paykitPaymentRequestRepo, never()).refresh() + verify(paykitPaymentRequestRepo, never()).refresh(any()) advanceTimeBy(1) runCurrent() - verify(paykitPaymentRequestRepo).refresh() + verify(paykitPaymentRequestRepo).refresh(any()) } finally { sut.stopPaykitPaymentRequestPolling() } } @Test - fun `identity republish follows maintenance intervals instead of each payment request poll`() = test { + fun `private message sync and identity republish follow maintenance intervals`() = test { enablePaykitUi() pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) sut.startPaykitPaymentRequestPolling() try { + advanceTimeBy(30.seconds.inWholeMilliseconds) runCurrent() - for (interval in listOf(30.seconds, 60.seconds, 120.seconds, 120.seconds)) { - clearInvocations(pubkyRepo) + verify(paykitPaymentRequestRepo, atLeast(2)).refresh(syncPrivateMessages = true) + for (interval in listOf(60.seconds, 120.seconds, 120.seconds)) { + clearInvocations(pubkyRepo, paykitPaymentRequestRepo) advanceTimeBy(interval.inWholeMilliseconds - 1) runCurrent() verify(pubkyRepo, never()).republishIdentityIfNeeded() + verify(paykitPaymentRequestRepo, never()).refresh(syncPrivateMessages = true) + verify(paykitPaymentRequestRepo, atLeast(1)).refresh(syncPrivateMessages = false) advanceTimeBy(1) runCurrent() verify(pubkyRepo).republishIdentityIfNeeded() + verify(paykitPaymentRequestRepo).refresh(syncPrivateMessages = true) } } finally { sut.stopPaykitPaymentRequestPolling() @@ -745,7 +750,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { if (++attempts == 2) pubkyPublicKey.value = testPublicKey Unit } - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) clearInvocations(pubkyRepo, paykitPaymentRequestRepo) sut.startPaykitPaymentRequestPolling() @@ -753,12 +758,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceTimeBy(30.seconds.inWholeMilliseconds) runCurrent() verify(pubkyRepo).restoreSessionIfNeeded() - verify(paykitPaymentRequestRepo, never()).refresh() + verify(paykitPaymentRequestRepo, never()).refresh(any()) advanceTimeBy(60.seconds.inWholeMilliseconds) runCurrent() verify(pubkyRepo, times(2)).restoreSessionIfNeeded() - verify(paykitPaymentRequestRepo).refresh() + verify(paykitPaymentRequestRepo).refresh(any()) } finally { sut.stopPaykitPaymentRequestPolling() } @@ -783,7 +788,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceTimeBy(210.seconds.inWholeMilliseconds) runCurrent() verify(pubkyRepo, never()).republishIdentityIfNeeded() - verify(paykitPaymentRequestRepo, never()).refresh() + verify(paykitPaymentRequestRepo, never()).refresh(any()) verify(pubkyRepo, never()).restoreSessionIfNeeded() verify(paykitPaymentRequestRepo, never()).refreshEligibleTargets(any(), any()) verify(paykitPaymentProofRepo, never()).reconcile() @@ -815,7 +820,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `payment requests refresh promptly without repeating maintenance on each poll`() = test { isPaykitEnabled.value = true pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) runCurrent() clearInvocations(paykitPaymentRequestRepo) @@ -823,28 +828,28 @@ class AppViewModelSendFlowTest : BaseUnitTest() { try { runCurrent() - verify(paykitPaymentRequestRepo).refresh() + verify(paykitPaymentRequestRepo).refresh(any()) clearInvocations(paykitPaymentRequestRepo) advanceTimeBy(30.seconds.inWholeMilliseconds) runCurrent() - verify(paykitPaymentRequestRepo, atLeast(2)).refresh() + verify(paykitPaymentRequestRepo, atLeast(2)).refresh(any()) clearInvocations(paykitPaymentRequestRepo) clearInvocations(privatePaykitRepo, paykitPaymentProofRepo) advanceTimeBy(9.seconds.inWholeMilliseconds) runCurrent() - verify(paykitPaymentRequestRepo, never()).refresh() + verify(paykitPaymentRequestRepo, never()).refresh(any()) val request = paymentRequest() - whenever(paykitPaymentRequestRepo.refresh()).doSuspendableAnswer { + whenever(paykitPaymentRequestRepo.refresh(any())).doSuspendableAnswer { pendingPaykitPaymentRequests.value = listOf(request) Result.success(Unit) } advanceTimeBy(1.seconds.inWholeMilliseconds) runCurrent() - verify(paykitPaymentRequestRepo).refresh() + verify(paykitPaymentRequestRepo).refresh(any()) verify(privatePaykitRepo, never()).refreshKnownSavedContactEndpoints(any(), any()) verify(paykitPaymentProofRepo, never()).reconcile() verify(paykitPaymentRequestRepo, never()).refreshEligibleTargets(any(), eq(true)) @@ -853,10 +858,10 @@ class AppViewModelSendFlowTest : BaseUnitTest() { clearInvocations(paykitPaymentRequestRepo) advanceTimeBy(10.seconds.inWholeMilliseconds - 1) runCurrent() - verify(paykitPaymentRequestRepo, never()).refresh() + verify(paykitPaymentRequestRepo, never()).refresh(any()) advanceTimeBy(1) runCurrent() - verify(paykitPaymentRequestRepo).refresh() + verify(paykitPaymentRequestRepo).refresh(any()) } verify(privatePaykitRepo).refreshKnownSavedContactEndpoints(any(), any()) verify(paykitPaymentProofRepo).reconcile() @@ -874,19 +879,19 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceTimeBy(120.seconds.inWholeMilliseconds) runCurrent() - verify(paykitPaymentRequestRepo, never()).refresh() + verify(paykitPaymentRequestRepo, never()).refresh(any()) } @Test fun `failed inbox checks keep ten second cadence`() = test { enablePaykitUi() pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) sut.startPaykitPaymentRequestPolling() try { advanceTimeBy(30.seconds.inWholeMilliseconds) runCurrent() - whenever(paykitPaymentRequestRepo.refresh()).thenReturn( + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn( Result.failure(IllegalStateException("transport failure")), ) @@ -894,17 +899,17 @@ class AppViewModelSendFlowTest : BaseUnitTest() { clearInvocations(paykitPaymentRequestRepo) advanceTimeBy(10.seconds.inWholeMilliseconds - 1) runCurrent() - verify(paykitPaymentRequestRepo, never()).refresh() + verify(paykitPaymentRequestRepo, never()).refresh(any()) advanceTimeBy(1) runCurrent() - verify(paykitPaymentRequestRepo).refresh() + verify(paykitPaymentRequestRepo).refresh(any()) } - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) clearInvocations(paykitPaymentRequestRepo) advanceTimeBy(10.seconds.inWholeMilliseconds) runCurrent() - verify(paykitPaymentRequestRepo).refresh() + verify(paykitPaymentRequestRepo).refresh(any()) } finally { sut.stopPaykitPaymentRequestPolling() } @@ -916,7 +921,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val request = paymentRequest() val bolt11 = "lnbcrt1updatedpaymentrequest" val privateContext = privatePaymentContext(8uL) - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList), Result.success( @@ -977,7 +982,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) assertEquals(request.id, sut.sendUiState.value.incomingPaymentRequestId) verify(privatePaykitRepo).beginPaymentRequest(request) - verify(paykitPaymentRequestRepo, never()).refresh() + verify(paykitPaymentRequestRepo, never()).refresh(any()) } @Test @@ -1235,7 +1240,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { endsAt = Instant.parse("2026-09-01T12:00:00Z"), ), ) - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.beginPaymentRequest(targetRequest)).thenReturn( Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList) ) @@ -1267,7 +1272,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { endsAt = Instant.parse("2026-09-01T12:00:00Z"), ), ) - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.beginPaymentRequest(targetRequest)).thenReturn( Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList) ) @@ -1879,7 +1884,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val latestActivationStarted = CompletableDeferred() val finishLatestActivation = CompletableDeferred() sut.setIsAuthenticated(true) - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList) ) @@ -1930,7 +1935,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val finishClear = CompletableDeferred() runCurrent() sut.setIsAuthenticated(true) - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList) ) @@ -2006,7 +2011,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `unresolvable automatic request falls back to low frequency retries`() = test { val request = paymentRequest() - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.beginPaymentRequest(request)) .thenReturn(Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList)) pendingPaykitPaymentRequests.value = listOf(request) @@ -2043,7 +2048,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(pendingRequest) isPaykitEnabled.value = true pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) runCurrent() sut.startPaykitPaymentRequestPolling() @@ -2070,7 +2075,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(firstRequest, secondRequest) enablePaykitUi() pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) sut.onHomeResumed() sut.currentSheet.first { @@ -2105,7 +2110,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(request) isPaykitEnabled.value = true pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) sut.onHomeResumed() runCurrent() @@ -2129,7 +2134,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(request) isPaykitEnabled.value = true pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) sut.startPaykitPaymentRequestPolling() advanceTimeBy(30.seconds.inWholeMilliseconds) @@ -2170,7 +2175,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(request) isPaykitEnabled.value = true pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) sut.onHomeResumed() resolutionStarted.await() @@ -2217,7 +2222,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(request) enablePaykitUi() pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) sut.onHomeResumed() requestScanStarted.await() @@ -2390,7 +2395,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(request) enablePaykitUi() pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) sut.onHomeResumed() resolutionStarted.await() @@ -2431,7 +2436,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(request) isPaykitEnabled.value = true pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) sut.onHomeResumed() runCurrent() @@ -2465,7 +2470,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(request) enablePaykitUi() pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) sut.onHomeResumed() runCurrent() @@ -2497,7 +2502,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(unavailableRequest, payableRequest) isPaykitEnabled.value = true pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) sut.startPaykitPaymentRequestPolling() advanceTimeBy(30.seconds.inWholeMilliseconds) @@ -2534,7 +2539,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(expiredRequest, payableRequest) isPaykitEnabled.value = true pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) sut.startPaykitPaymentRequestPolling() advanceTimeBy(30.seconds.inWholeMilliseconds) @@ -2560,7 +2565,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(request) isPaykitEnabled.value = true pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) sut.startPaykitPaymentRequestPolling() advanceTimeBy(30.seconds.inWholeMilliseconds) @@ -3780,7 +3785,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { stubLightningScan(bolt11 = bolt11, amountSats = 0u) whenever(lightningRepo.canSend(request.amountSats)).thenReturn(true) stubOpenedPaymentRequest(request, bolt11) - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) pendingPaykitPaymentRequests.value = listOf(request) sut.onHomeResumed() @@ -3799,7 +3804,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(request) enablePaykitUi() pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) stubOpenedPaymentRequest(request, signupAuthUrl) sut.onHomeResumed() @@ -6134,7 +6139,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { stubLightningScan(bolt11 = bolt11, amountSats = 0u) whenever(lightningRepo.canSend(request.amountSats)).thenReturn(true) val privateContext = stubOpenedPaymentRequest(request, bolt11) - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) pendingPaykitPaymentRequests.value = listOf(request) sut.startPaykitPaymentRequestPolling() @@ -6304,7 +6309,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { balanceState.value = BalanceState(maxSendLightningSats = 100_000u) stubLightningScan(bolt11 = bolt11, amountSats = 0u) stubOpenedPaymentRequest(request, bolt11) - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) pendingPaykitPaymentRequests.value = listOf(request) sut.startPaykitPaymentRequestPolling() @@ -6829,7 +6834,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = emptyList() stubOpenedPaymentRequest(request, lnurl.uri) whenever(coreService.decode(lnurl.uri)).thenReturn(Scanner.LnurlPay(lnurl)) - whenever(paykitPaymentRequestRepo.refresh()).doSuspendableAnswer { + whenever(paykitPaymentRequestRepo.refresh(any())).doSuspendableAnswer { pendingPaykitPaymentRequests.value = listOf(request) Result.success(Unit) } @@ -6837,7 +6842,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.retryIncomingPaymentRequest(request.id) advanceUntilIdle() - verify(paykitPaymentRequestRepo).refresh() + verify(paykitPaymentRequestRepo).refresh(syncPrivateMessages = true) verify(privatePaykitRepo, atLeast(1)).beginPaymentRequest(request) assertEquals(request.id, sut.sendUiState.value.incomingPaymentRequestId) assertTrue(sut.currentSheet.value is Sheet.Send) @@ -7247,7 +7252,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { enablePaykitUi() balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) stubSuccessfulOnchainSend(address, request.amountSats) @@ -7265,7 +7270,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue, "bitkit") - verify(paykitPaymentRequestRepo).refresh() + verify(paykitPaymentRequestRepo).refresh(syncPrivateMessages = true) } @Test @@ -7399,7 +7404,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `initial subscription retry keeps the send sheet presented`() = test { val request = paymentRequest() pendingPaykitPaymentRequests.value = listOf(request) - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.beginPaymentRequestWaitingForUpdatedList(request)).thenReturn( Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList) ) diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index 0c4c4455f5..e558cf8919 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -24,6 +24,12 @@ Rejected fixture shapes stay in unit tests because Bitkit intentionally does not `definite-pre-broadcast-retry.xml` uses the linked fixture issuer and the local regtest LNURL server. Configure its LNURL-pay metadata endpoint normally, but make its invoice callback fail the first request and succeed after it is switched back to the healthy response. Do not republish the Paykit payment list between attempts. This makes the first send fail before Lightning dispatch and proves that the same private payment details can be opened and paid on retry. +## Foreground synchronization + +Bitkit refreshes shared request state every 10 seconds. Private messages are synchronized on +startup and explicit refreshes, and on maintenance rounds that back off from 30 to 60 to 120 seconds. +A new peer message can therefore take up to two minutes plus synchronization time to appear during steady polling. + ## Accepting install Acceptance intent is saved before the remote operation and included in wallet backups. diff --git a/journeys/payment-requests/automatic-presentation.xml b/journeys/payment-requests/automatic-presentation.xml index 8f19ff2dc7..e192331d0e 100644 --- a/journeys/payment-requests/automatic-presentation.xml +++ b/journeys/payment-requests/automatic-presentation.xml @@ -5,7 +5,7 @@ On the payer, leave Bitkit unlocked and in the foreground on Home On the requester, send the payer contact a Payment Request for 21,000 sats with the note "First request" - On the payer, verify Payment Request confirmation (testTag "PaymentRequestConfirm") appears automatically with 21,000 sats and For shows "First request" + On the payer, wait for the next private-message sync and verify Payment Request confirmation (testTag "PaymentRequestConfirm") appears automatically with 21,000 sats and For shows "First request" Close the confirmation without paying On the payer, open Receive and leave its sheet open On the requester, send the payer contact another Payment Request for 5,000 sats with the note "Second request" From 3733d2a80327978cd015e78eef34b52a8a8b913e Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 30 Sep 2026 22:53:41 -0500 Subject: [PATCH 35/51] feat: share paykit state across apps --- .../CreatePaymentRequestScreenTest.kt | 2 - .../PaymentRequestsScreenTest.kt | 2 - .../CreateSubscriptionScreenTest.kt | 5 +- .../to/bitkit/data/PrivatePaykitStores.kt | 6 +- .../java/to/bitkit/data/keychain/Keychain.kt | 4 +- .../bitkit/models/PaykitPaymentStateBackup.kt | 3 + .../to/bitkit/models/PubkyAuthClaimCodec.kt | 47 + .../java/to/bitkit/models/PubkyAuthRequest.kt | 54 +- .../to/bitkit/repositories/ActivityRepo.kt | 6 + .../ContactPaymentSettingsRepo.kt | 13 +- .../repositories/PaykitPaymentProofRepo.kt | 33 +- .../repositories/PaykitPaymentRequestRepo.kt | 94 +- .../PaykitReceivedPaymentContacts.kt | 90 ++ .../bitkit/repositories/PaykitSubscription.kt | 7 +- ...PaykitSubscriptionNotificationScheduler.kt | 10 +- .../PrivatePaykitAddressReservationRepo.kt | 43 +- .../PrivatePaykitContactResolver.kt | 21 +- .../repositories/PrivatePaykitModels.kt | 26 +- .../bitkit/repositories/PrivatePaykitRepo.kt | 541 ++++------- .../java/to/bitkit/repositories/PubkyRepo.kt | 43 +- .../bitkit/repositories/PublicPaykitRepo.kt | 33 +- .../repositories/WatchOnlyAccountRepo.kt | 3 +- .../java/to/bitkit/services/CoreService.kt | 62 +- .../to/bitkit/services/PaykitSdkService.kt | 557 ++++-------- .../java/to/bitkit/services/PubkyService.kt | 19 +- app/src/main/java/to/bitkit/ui/ContentView.kt | 7 - .../main/java/to/bitkit/ui/MainActivity.kt | 2 - .../main/java/to/bitkit/ui/Notifications.kt | 1 - .../screens/contacts/AddContactViewModel.kt | 6 +- .../ui/screens/contacts/ContactsViewModel.kt | 6 +- .../CreatePaymentRequestScreen.kt | 4 +- .../paymentrequests/PaymentRequestsScreen.kt | 3 +- .../screens/profile/PubkyAuthApprovalSheet.kt | 26 +- .../profile/PubkyAuthApprovalViewModel.kt | 38 +- .../screens/wallets/receive/ReceiveSheet.kt | 6 +- ....kt => RefreshContactPaykitLinkUseCase.kt} | 9 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 100 +- .../to/bitkit/viewmodels/SettingsViewModel.kt | 14 +- app/src/main/res/values/strings.xml | 2 + .../models/PaykitPaymentStateBackupTest.kt | 2 +- .../to/bitkit/models/PubkyAuthRequestTest.kt | 150 ++- .../bitkit/repositories/ActivityRepoTest.kt | 17 + .../ContactPaymentSettingsRepoTest.kt | 14 +- .../repositories/PaykitIssuerInteropTest.kt | 6 +- .../PaykitPaymentProofRepoTest.kt | 138 +-- ...ykitPaymentRequestPresentationStoreTest.kt | 12 +- ...aykitPaymentRequestRepoSubscriptionTest.kt | 134 +-- .../PaykitPaymentRequestRepoTest.kt | 385 ++++---- .../PaykitReceivedPaymentContactsTest.kt | 224 +++++ ...itSubscriptionNotificationSchedulerTest.kt | 6 +- .../repositories/PaykitSubscriptionTest.kt | 4 +- ...PrivatePaykitAddressReservationRepoTest.kt | 17 - .../PrivatePaykitContactResolverTest.kt | 56 +- .../repositories/PrivatePaykitRepoTest.kt | 856 +++++------------- .../to/bitkit/repositories/PubkyRepoTest.kt | 117 +-- .../repositories/PublicPaykitRepoTest.kt | 73 +- .../WatchOnlyAccountClaimCodecTest.kt | 102 ++- .../repositories/WatchOnlyAccountRepoTest.kt | 41 + .../ActivityServicePaykitContactsTest.kt | 165 ++++ .../bitkit/services/PaykitSdkServiceTest.kt | 337 +++---- .../services/PaykitSdkServiceWipeTest.kt | 128 +-- .../contacts/AddContactViewModelTest.kt | 10 +- .../contacts/ContactDetailViewModelTest.kt | 2 +- .../PaymentRequestPresentationTest.kt | 1 - .../profile/PubkyAuthApprovalViewModelTest.kt | 198 +++- .../subscriptions/SubscriptionsScreenTest.kt | 1 - ...=> RefreshContactPaykitLinkUseCaseTest.kt} | 23 +- .../viewmodels/AppViewModelSendFlowTest.kt | 322 ++++--- .../viewmodels/SettingsViewModelTest.kt | 9 +- .../resources/bitkit-combined-claim-v1.json | 10 + .../next/paykit-shared-runtime.changed.md | 1 + docs/paykit-issuer-interoperability.md | 2 +- docs/pubky-auth-companion-claims.md | 60 ++ docs/pubky.md | 15 +- docs/watch-only-account-claim-v1.md | 52 -- gradle/libs.versions.toml | 2 +- journeys/README.md | 2 +- journeys/payment-requests/README.md | 8 +- .../contact-request-or-pay.xml | 2 +- .../delete-and-readd-contact.xml | 2 +- .../issuer-interoperability.xml | 4 +- .../payment-deadline-history.xml | 2 +- journeys/payment-requests/request-summary.xml | 2 +- journeys/pubky-marketplace/README.md | 105 +-- .../paykit-only-approval.xml | 19 + .../pubky-marketplace/paykit-reconnect.xml | 18 + journeys/pubky-marketplace/wallet-leg.xml | 10 +- journeys/subscriptions/README.md | 2 +- journeys/subscriptions/create-and-propose.xml | 2 +- settings.gradle.kts | 4 +- 90 files changed, 3077 insertions(+), 2745 deletions(-) create mode 100644 app/src/main/java/to/bitkit/models/PubkyAuthClaimCodec.kt create mode 100644 app/src/main/java/to/bitkit/repositories/PaykitReceivedPaymentContacts.kt rename app/src/main/java/to/bitkit/usecases/{RefreshContactPaykitReceiversUseCase.kt => RefreshContactPaykitLinkUseCase.kt} (75%) create mode 100644 app/src/test/java/to/bitkit/repositories/PaykitReceivedPaymentContactsTest.kt create mode 100644 app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt rename app/src/test/java/to/bitkit/usecases/{RefreshContactPaykitReceiversUseCaseTest.kt => RefreshContactPaykitLinkUseCaseTest.kt} (68%) create mode 100644 app/src/test/resources/bitkit-combined-claim-v1.json create mode 100644 changelog.d/next/paykit-shared-runtime.changed.md create mode 100644 docs/pubky-auth-companion-claims.md delete mode 100644 docs/watch-only-account-claim-v1.md create mode 100644 journeys/pubky-marketplace/paykit-only-approval.xml create mode 100644 journeys/pubky-marketplace/paykit-reconnect.xml diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreenTest.kt index 69456ee0c2..ef1fca7fc3 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreenTest.kt @@ -163,13 +163,11 @@ class CreatePaymentRequestScreenTest { private val target = PaykitPaymentRequestTarget( publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", - receiverPath = "bitkit/wallet", ) private val request = PaykitPaymentRequest( paymentRequestId = "payment-request", counterparty = target.publicKey, - counterpartyReceiverPath = target.receiverPath, amountValue = "0.00025", amountSats = draft.amountSats, note = draft.note, diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt index de6f261ce4..c8ede596a7 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt @@ -289,7 +289,6 @@ class PaymentRequestsScreenTest { private fun request(id: String = "request") = PaykitPaymentRequest( paymentRequestId = id, counterparty = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", - counterpartyReceiverPath = "bitkit/wallet", amountValue = "0.00025", amountSats = 25_000uL, note = "Dinner", @@ -301,7 +300,6 @@ class PaymentRequestsScreenTest { private fun subscription(note: String) = PaykitSubscription( paymentRequestId = "subscription", counterparty = request().counterparty, - counterpartyReceiverPath = "bitkit/wallet", amountValue = "0.00025", amountSats = 25_000uL, note = note, diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreenTest.kt index 1cd6a6efaf..cf2aa3c9d5 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreenTest.kt @@ -93,7 +93,7 @@ class CreateSubscriptionScreenTest { @Test fun recipientAllowsExactlyOneSelectionAndChangesExpiry() { - val second = PaykitPaymentRequestTarget("pubky" + "z".repeat(52), "bitkit/wallet") + val second = PaykitPaymentRequestTarget("pubky" + "z".repeat(52)) var selected by mutableStateOf(null) var expiration by mutableStateOf(PaymentRequestExpiration.Week) var proposedTo: PaykitPaymentRequestTarget? = null @@ -158,13 +158,12 @@ class CreateSubscriptionScreenTest { composeTestRule.onNodeWithText("OK").assertIsDisplayed() } - private val target = PaykitPaymentRequestTarget("pubky" + "y".repeat(52), "bitkit/wallet") + private val target = PaykitPaymentRequestTarget("pubky" + "y".repeat(52)) private val contact = PubkyProfile.forDisplay(target.publicKey, "Anna", null) private val startsAt = Instant.parse("2027-01-15T08:00:00Z") private val subscription = PaykitSubscription( paymentRequestId = "creator-proposal", counterparty = target.publicKey, - counterpartyReceiverPath = target.receiverPath, amountValue = "0.00001", amountSats = 1000uL, note = "Support", diff --git a/app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt b/app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt index 3519b2186d..8aabaa02bc 100644 --- a/app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt +++ b/app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt @@ -68,10 +68,10 @@ data class PrivatePaykitCacheData( @Serializable data class PrivatePaykitContactCacheData( val remoteEndpoints: List = emptyList(), - val consumedPrivatePaymentListVersionsByReceiverPath: Map = emptyMap(), - val localInvoicesByReceiverPath: Map = emptyMap(), + val consumedPrivatePaymentListVersion: ULong? = null, + val localInvoice: PrivatePaykitStoredInvoiceData? = null, val receivedInvoicePaymentHashes: List = emptyList(), - val publishedPrivatePaymentReceiverPaths: Set = emptySet(), + val hasPublishedPrivatePaymentList: Boolean = false, ) @Serializable diff --git a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt index 2a41579d54..481fa0af58 100644 --- a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt +++ b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt @@ -232,8 +232,8 @@ class Keychain @Inject constructor( PIN, PIN_ATTEMPTS_REMAINING, PAYKIT_SESSION, - PAYKIT_RECEIVER_NOISE_SECRET_KEY, - PAYKIT_SDK_STATE, + PAYKIT_KEY_GENERATION, + PAYKIT_RECOVERY_BACKUP, PAYKIT_PENDING_BACKUP_RESTORE, PAYKIT_PENDING_PAYMENT_PROOFS, PAYKIT_PRESENTED_PAYMENT_REQUESTS, diff --git a/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt b/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt index f5301f8419..b9429d238a 100644 --- a/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt +++ b/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt @@ -41,6 +41,7 @@ data class PaykitPaymentStateBackup( val identity: String, val requestId: PaykitPaymentRequestId, val paymentEndpointIdentifier: String, + val paymentAppId: String, val kind: String, val paymentStarted: Boolean, val paymentIdentifier: String? = null, @@ -55,6 +56,7 @@ data class PaykitPaymentStateBackup( identity = proof.identity, requestId = proof.requestId, paymentEndpointIdentifier = proof.paymentEndpointIdentifier, + paymentAppId = proof.paymentAppId, kind = proof.kind.type, paymentStarted = proof.paymentStarted, paymentIdentifier = proof.paymentIdentifier, @@ -70,6 +72,7 @@ data class PaykitPaymentStateBackup( identity = identity, requestId = requestId.copy(billingPeriodStartsAt = billingPeriod?.startsAt?.toString()), paymentEndpointIdentifier = paymentEndpointIdentifier, + paymentAppId = paymentAppId, kind = requireNotNull(PaykitPaymentProofKind.entries.find { it.type == kind }), paymentStarted = paymentStarted, paymentIdentifier = paymentIdentifier, diff --git a/app/src/main/java/to/bitkit/models/PubkyAuthClaimCodec.kt b/app/src/main/java/to/bitkit/models/PubkyAuthClaimCodec.kt new file mode 100644 index 0000000000..7fe7b28ab3 --- /dev/null +++ b/app/src/main/java/to/bitkit/models/PubkyAuthClaimCodec.kt @@ -0,0 +1,47 @@ +package to.bitkit.models + +import java.nio.ByteBuffer + +object PubkyAuthClaimCodec { + /** Size of the versioned account metadata and serialized extended public key. */ + const val WATCH_ONLY_PAYLOAD_LENGTH = 84 + + /** Size of the version, generation, and Paykit identity secret. */ + const val PAYKIT_PAYLOAD_LENGTH = 41 + + /** Size of account metadata followed by the generation and Paykit identity secret. */ + const val COMBINED_PAYLOAD_LENGTH = 124 + + fun validateAccountPayload(claim: PubkyAuthClaim, accountPayload: ByteArray) { + if (!claim.includesWatchOnlyAccount) { + require(accountPayload.isEmpty()) { "Paykit-only approval cannot include an account" } + return + } + require(accountPayload.size == WATCH_ONLY_PAYLOAD_LENGTH) { "Invalid watch-only payload length" } + require(accountPayload[0] == 1.toByte() && accountPayload[5] == 0.toByte()) { + "Unsupported watch-only payload version or address type" + } + } + + fun encode( + claim: PubkyAuthClaim, + accountPayload: ByteArray, + generation: ULong? = null, + secret: ByteArray? = null, + ): ByteArray { + validateAccountPayload(claim, accountPayload) + if (!claim.includesPaykitAccess) { + require(generation == null && secret == null) { "Watch-only approval cannot include a Paykit key" } + return accountPayload.copyOf() + } + require(generation != null && generation > 0uL) { "Invalid Paykit key generation" } + require(secret?.size == 32) { "Invalid Paykit identity secret length" } + val prefix = if (claim.includesWatchOnlyAccount) accountPayload else byteArrayOf(1) + val length = if (claim.includesWatchOnlyAccount) COMBINED_PAYLOAD_LENGTH else PAYKIT_PAYLOAD_LENGTH + return ByteBuffer.allocate(length) + .put(prefix) + .putLong(generation.toLong()) + .put(secret) + .array() + } +} diff --git a/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt b/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt index 14e1f70d2c..05a6673a2d 100644 --- a/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt +++ b/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt @@ -1,39 +1,51 @@ package to.bitkit.models import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList import to.bitkit.utils.AppError import java.net.URI import java.net.URLDecoder import java.net.URLEncoder import java.nio.charset.StandardCharsets -enum class PubkyAuthClaim(val wireValue: String) { - WATCH_ONLY_ACCOUNT_V1("watch-only-account-v1"), - ; +@Immutable +@JvmInline +value class PubkyAuthClaim private constructor(val items: ImmutableList) { + constructor(vararg items: Item) : this(items.sortedBy { it.ordinal }.toImmutableList()) + + init { + require(items.isNotEmpty() && items.distinct().size == items.size) + } + + enum class Item(val wireValue: String) { + PAYKIT_ACCESS_V1("paykit-access-v1"), + WATCH_ONLY_ACCOUNT_V1("watch-only-account-v1"), + } + + val wireValue: String get() = items.joinToString(".") { it.wireValue } + val includesWatchOnlyAccount: Boolean get() = Item.WATCH_ONLY_ACCOUNT_V1 in items + val includesPaykitAccess: Boolean get() = Item.PAYKIT_ACCESS_V1 in items companion object { /** Query parameter used for Bitkit-specific Pubky auth claims. */ const val QUERY_PARAMETER = "x-bitkit-claim" - /** Both public and private Paykit Server capabilities required by the watch-only setup flow. */ - const val WATCH_ONLY_ACCOUNT_CAPABILITIES = - "/pub/paykit/v0/bitkit/server/:rw,/pub/paykit/v0/private/bitkit/server/:rw" - - private val watchOnlyAccountCapabilitySet = WATCH_ONLY_ACCOUNT_CAPABILITIES.split(",").toSet() + /** Exact Pubky storage scope required by Bitkit companion claims. */ + const val REQUIRED_CAPABILITIES = "/pub/paykit/:rw" - /** - * Matches exactly the required public and private capabilities regardless of ordering or surrounding spaces. - */ - fun matchesWatchOnlyAccountCapabilities(capabilities: String) = - capabilitySet(capabilities) == watchOnlyAccountCapabilitySet + /** Matches the required storage scope, allowing surrounding whitespace but no duplicate capabilities. */ + fun matchesRequiredCapabilities(capabilities: String) = + capabilities.trim() == REQUIRED_CAPABILITIES - private fun capabilitySet(capabilities: String): Set? { - val entries = capabilities.split(",").map { it.trim() } - if (entries.any { it.isEmpty() }) return null - return entries.toSet() + /** Preserves the received item order because the SDK signs and routes using this exact string. */ + fun fromWireValue(value: String): PubkyAuthClaim? { + val items = value.split(".").map { token -> + Item.entries.firstOrNull { it.wireValue == token } ?: return null + } + if (items.distinct().size != items.size) return null + return PubkyAuthClaim(items.toImmutableList()) } - - fun fromWireValue(value: String) = entries.firstOrNull { it.wireValue == value } } } @@ -189,8 +201,6 @@ data class PubkyAuthRequest( capabilities: String, ): Result = when { claimValues.size > 1 -> Result.failure(PubkyAuthRequestError.DuplicateBitkitClaim) - claimValues.isEmpty() && PubkyAuthClaim.matchesWatchOnlyAccountCapabilities(capabilities) -> - Result.failure(PubkyAuthRequestError.MissingBitkitClaim) claimValues.isEmpty() -> Result.success(null) else -> validateBitkitClaimValue(claimValues.first(), capabilities) } @@ -202,7 +212,7 @@ data class PubkyAuthRequest( val claim = PubkyAuthClaim.fromWireValue(claimValue) ?: return Result.failure(PubkyAuthRequestError.UnsupportedBitkitClaim(claimValue)) - return if (PubkyAuthClaim.matchesWatchOnlyAccountCapabilities(capabilities)) { + return if (PubkyAuthClaim.matchesRequiredCapabilities(capabilities)) { Result.success(claim) } else { Result.failure(PubkyAuthRequestError.InvalidBitkitClaimCapabilities) diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 4119de267f..20ee50dbff 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -105,6 +105,12 @@ class ActivityRepo @Inject constructor( Logger.debug("Activity state reset", context = TAG) } + suspend fun backfillPaykitContacts(): Result = withContext(bgDispatcher) { + runSuspendCatching { + if (coreService.activity.backfillPaykitContacts()) notifyActivitiesChanged() + }.onFailure { Logger.warn("Failed to backfill Paykit activity contacts", it, context = TAG) } + } + suspend fun syncActivities(): Result = withContext(bgDispatcher) { Logger.debug("syncActivities called", context = TAG) diff --git a/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt b/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt index ec02d0eded..e21e6faa0d 100644 --- a/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt @@ -67,6 +67,10 @@ class ContactPaymentSettingsRepo @Inject constructor( ) } }.onFailure(error::addSuppressed) + if (!previous.sharesPrivatePaykitEndpoints) { + privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contacts) + .onFailure(error::addSuppressed) + } publicPaykitRepo.syncPublishedEndpoints(publish = previous.sharesPublicPaykitEndpoints) .onFailure { error.addSuppressed(it) @@ -76,9 +80,6 @@ class ContactPaymentSettingsRepo @Inject constructor( privatePaykitRepo.enableSharingAndPrepareSavedContacts( publicKeys = contacts, ).onFailure(error::addSuppressed) - } else { - privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contacts) - .onFailure(error::addSuppressed) } } @@ -100,12 +101,12 @@ class ContactPaymentSettingsRepo @Inject constructor( var publicCleanupError: Throwable? = null var privateCleanupError: Throwable? = null - publicPaykitRepo.syncPublishedEndpoints(publish = false) - .onFailure { publicCleanupError = it } - privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contacts) .onFailure { privateCleanupError = it } + publicPaykitRepo.syncPublishedEndpoints(publish = false) + .onFailure { publicCleanupError = it } + publicCleanupError?.let { error -> runSuspendCatching { settingsStore.update { settings -> diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index 7520856c17..a46552f1e4 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -4,6 +4,7 @@ import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.ActivityFilter import com.synonym.bitkitcore.PaymentType import com.synonym.paykit.BillingPeriod +import com.synonym.paykit.PubkyIdentityCapability import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -59,6 +60,7 @@ data class PendingPaykitPaymentProof( val identity: String, val requestId: PaykitPaymentRequestId, val paymentEndpointIdentifier: String, + val paymentAppId: String, val kind: PaykitPaymentProofKind, val paymentStarted: Boolean = false, val paymentIdentifier: String? = null, @@ -136,11 +138,12 @@ class PaykitPaymentProofRepo @Inject constructor( suspend fun prepare( request: PaykitPaymentRequest, paymentEndpointIdentifier: String, + paymentAppId: String, kind: PaykitPaymentProofKind, ): Result = withContext(ioDispatcher) { runSuspendCatching { operationMutex.withLock { - val proof = pendingProof(request, paymentEndpointIdentifier, kind) + val proof = pendingProof(request, paymentEndpointIdentifier, paymentAppId, kind) val currentProofs = loadProofs() if (currentProofs.any { it.isStartedFor(proof.identity, request.id) }) { throw PaykitPaymentRequestError.OperationInProgress @@ -157,6 +160,7 @@ class PaykitPaymentProofRepo @Inject constructor( request: PaykitPaymentRequest, paymentHash: String, paymentEndpointIdentifier: String, + paymentAppId: String, ): Result = withContext(ioDispatcher) { runSuspendCatching { if (!paymentHash.isHex(HASH_BYTE_COUNT)) throw PaykitPaymentRequestError.RequestUnavailable @@ -177,7 +181,7 @@ class PaykitPaymentProofRepo @Inject constructor( paymentIdentifier = paymentHash.lowercase(), ) } else { - pendingProof(request, paymentEndpointIdentifier, PaykitPaymentProofKind.Lightning) + pendingProof(request, paymentEndpointIdentifier, paymentAppId, PaykitPaymentProofKind.Lightning) .copy( paymentStarted = true, paymentIdentifier = paymentHash.lowercase(), @@ -262,6 +266,7 @@ class PaykitPaymentProofRepo @Inject constructor( request: PaykitPaymentRequest, txid: String, paymentEndpointIdentifier: String, + paymentAppId: String, ) = withContext(ioDispatcher) { if (!txid.isHex(HASH_BYTE_COUNT)) { Logger.warn("Ignored a Paykit on-chain proof with an invalid transaction id", context = TAG) @@ -270,7 +275,7 @@ class PaykitPaymentProofRepo @Inject constructor( val identity = currentIdentity() ?: return@withContext val fallbackProof = runSuspendCatching { - pendingProof(request, paymentEndpointIdentifier, PaykitPaymentProofKind.Onchain) + pendingProof(request, paymentEndpointIdentifier, paymentAppId, PaykitPaymentProofKind.Onchain) }.getOrNull() operationMutex.withLock { val completion = runSuspendCatching { @@ -289,7 +294,7 @@ class PaykitPaymentProofRepo @Inject constructor( proofData = txid.lowercase(), ) } else { - pendingProof(request, paymentEndpointIdentifier, PaykitPaymentProofKind.Onchain).copy( + pendingProof(request, paymentEndpointIdentifier, paymentAppId, PaykitPaymentProofKind.Onchain).copy( paymentStarted = true, paymentIdentifier = txid.lowercase(), proofData = txid.lowercase(), @@ -367,8 +372,7 @@ class PaykitPaymentProofRepo @Inject constructor( PubkyPublicKeyFormat.matches(it.identity, identity) && it.requestId.billingPeriodStartsAt != null && it.requestId.paymentRequestId == subscriptionId.paymentRequestId && - it.requestId.counterparty == subscriptionId.counterparty && - it.requestId.counterpartyReceiverPath == subscriptionId.counterpartyReceiverPath + it.requestId.counterparty == subscriptionId.counterparty } val protectedRequestIds = proofs.filter(belongsToSubscription) .filter { it.paymentStarted || it.paymentIdentifier != null || it.proofData != null } @@ -396,7 +400,9 @@ class PaykitPaymentProofRepo @Inject constructor( return@runSuspendCatching } val identityStatus = paykitSdkService.identityStatus() - if (identityStatus?.liveSessionAvailable != true) return@runSuspendCatching + if (identityStatus?.capability != PubkyIdentityCapability.PRIVATE_LINK_CAPABLE) { + return@runSuspendCatching + } val publicKey = identityStatus.publicKey ?: return@runSuspendCatching val identity = PubkyPublicKeyFormat.normalized(publicKey) ?: return@runSuspendCatching val proofs = storedProofs.filter { PubkyPublicKeyFormat.matches(it.identity, identity) } @@ -508,7 +514,7 @@ class PaykitPaymentProofRepo @Inject constructor( val proofData = proof.proofData ?: return false val identityStatus = paykitSdkService.identityStatus() if ( - identityStatus?.liveSessionAvailable != true || + identityStatus?.capability != PubkyIdentityCapability.PRIVATE_LINK_CAPABLE || !PubkyPublicKeyFormat.matches(identityStatus.publicKey, proof.identity) ) { return false @@ -516,21 +522,21 @@ class PaykitPaymentProofRepo @Inject constructor( val record = paykitSdkService.paymentRequests().firstOrNull { it.paymentRequestId == proof.requestId.paymentRequestId && - PubkyPublicKeyFormat.matches(it.counterparty, proof.requestId.counterparty) && - it.counterpartyReceiverPath == proof.requestId.counterpartyReceiverPath + PubkyPublicKeyFormat.matches(it.counterparty, proof.requestId.counterparty) } ?: return false val proofJson = proofJson(proof.kind, proofData) val alreadyQueued = record.paymentProofs.any { it.billingPeriod.matches(proof.billingPeriod) && it.paymentEndpointIdentifier == proof.paymentEndpointIdentifier && + it.paymentAppId == proof.paymentAppId && it.proof.exportText().proofValues() == proofJson.proofValues() } if (!alreadyQueued) { paykitSdkService.submitPaymentProof( counterparty = proof.requestId.counterparty, - counterpartyReceiverPath = proof.requestId.counterpartyReceiverPath, paymentRequestId = proof.requestId.paymentRequestId, paymentEndpointIdentifier = proof.paymentEndpointIdentifier, + paymentAppId = proof.paymentAppId, proofJson = proofJson, billingPeriod = proof.billingPeriod, ) @@ -622,9 +628,11 @@ class PaykitPaymentProofRepo @Inject constructor( private suspend fun pendingProof( request: PaykitPaymentRequest, paymentEndpointIdentifier: String, + paymentAppId: String, kind: PaykitPaymentProofKind, ): PendingPaykitPaymentProof { if ( + paymentAppId.isBlank() || paymentEndpointIdentifier !in request.acceptedPaymentEndpointIdentifiers || !endpointSupports(paymentEndpointIdentifier, kind) ) { @@ -632,13 +640,14 @@ class PaykitPaymentProofRepo @Inject constructor( } val identityStatus = paykitSdkService.identityStatus() val identity = identityStatus?.publicKey?.let { PubkyPublicKeyFormat.normalized(it) } - if (identityStatus?.liveSessionAvailable != true || identity == null) { + if (identityStatus?.capability != PubkyIdentityCapability.PRIVATE_LINK_CAPABLE || identity == null) { throw PaykitPaymentRequestError.RequestUnavailable } return PendingPaykitPaymentProof( identity = identity, requestId = request.id, paymentEndpointIdentifier = paymentEndpointIdentifier, + paymentAppId = paymentAppId, kind = kind, billingPeriod = request.billingPeriod, ) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 8c7f905305..eb81148f86 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -11,6 +11,7 @@ import com.synonym.paykit.PaymentRequestRecord import com.synonym.paykit.PaymentRequestTerms import com.synonym.paykit.PrivateJsonObject import com.synonym.paykit.PrivateStreamCounterpartyIntakeReport +import com.synonym.paykit.PubkyIdentityCapability import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -45,8 +46,8 @@ import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.satsToMsat import to.bitkit.services.PaykitPaymentRequestProposalTerms import to.bitkit.services.PaykitPaymentRequestRecurrenceTerms -import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PaykitSdkService +import to.bitkit.services.isBitkitPaymentRequest import to.bitkit.utils.AppError import to.bitkit.utils.Logger import to.bitkit.utils.SubscriptionIcon @@ -65,14 +66,12 @@ import kotlin.time.Instant data class PaykitPaymentRequestId( val paymentRequestId: String, val counterparty: String, - val counterpartyReceiverPath: String, val billingPeriodStartsAt: String? = null, ) data class PaykitPaymentRequest( val paymentRequestId: String, val counterparty: String, - val counterpartyReceiverPath: String, val amountValue: String, val amountSats: ULong, val note: String? = null, @@ -109,7 +108,6 @@ data class PaykitPaymentRequest( get() = PaykitPaymentRequestId( paymentRequestId, counterparty, - counterpartyReceiverPath, billingPeriod?.startsAt?.toString(), ) @@ -130,8 +128,7 @@ data class PaykitPaymentRequest( fun belongsTo(subscription: PaykitSubscription): Boolean = billingPeriod != null && paymentRequestId == subscription.paymentRequestId && - counterparty == subscription.counterparty && - counterpartyReceiverPath == subscription.counterpartyReceiverPath + counterparty == subscription.counterparty } internal sealed interface PaykitPaymentRequestParseResult { @@ -197,7 +194,6 @@ enum class PaykitPaymentRequestDirection { Incoming, Outgoing } data class PaykitPaymentRequestTarget( val publicKey: String, - val receiverPath: String, ) data class PaykitPaymentRequestDraft( @@ -229,7 +225,7 @@ data class PaykitSubscriptionCreation( private data class PaykitPaymentRequestTargetContext( val savedPublicKeys: List, - val linkedReceiverPaths: Map>, + val linkedPublicKeys: Set, ) private data class PaykitPaymentRequestTargetDiscovery( @@ -293,6 +289,19 @@ class PaykitPaymentRequestRepo @Inject constructor( @Volatile private var activeIdentity: String? = null + @Volatile + private var receivedContacts: ReceivedContactsSnapshot? = null + + internal val receivedPaymentContacts: PaykitReceivedPaymentContacts + get() = receivedContacts?.takeIf { isCurrentState(it.generation, it.identity) }?.contacts + ?: PaykitReceivedPaymentContacts.Empty + + private data class ReceivedContactsSnapshot( + val generation: Long, + val identity: String, + val contacts: PaykitReceivedPaymentContacts, + ) + @Volatile private var presentedRequestIds = emptySet() @@ -561,7 +570,6 @@ class PaykitPaymentRequestRepo @Inject constructor( ) val record = paykitSdkService.proposePaymentRequest( counterparty = target.publicKey, - counterpartyReceiverPath = target.receiverPath, proposal = proposal, expectedIdentity = expectedIdentity, ) @@ -635,7 +643,6 @@ class PaykitPaymentRequestRepo @Inject constructor( PaykitSubscriptionProposal.validate(proposal) val record = paykitSdkService.proposePaymentRequest( counterparty = target.publicKey, - counterpartyReceiverPath = target.receiverPath, proposal = proposal, expectedIdentity = expectedIdentity, ) @@ -730,7 +737,6 @@ class PaykitPaymentRequestRepo @Inject constructor( val messageId = record.proposalOutboundMessageId ?: return false return reports.any { PubkyPublicKeyFormat.matches(it.counterparty, record.counterparty) && - it.counterpartyReceiverPath == record.counterpartyReceiverPath && messageId in it.report?.sent.orEmpty() } } @@ -770,8 +776,7 @@ class PaykitPaymentRequestRepo @Inject constructor( if ( paykitSdkService.linkedPeers().any { it.state == LinkedPeerState.BLOCKED && - PubkyPublicKeyFormat.matches(it.counterparty, request.counterparty) && - it.counterpartyReceiverPath == request.counterpartyReceiverPath + PubkyPublicKeyFormat.matches(it.counterparty, request.counterparty) } ) { throw PaykitPaymentRequestError.RequestUnavailable @@ -779,6 +784,13 @@ class PaykitPaymentRequestRepo @Inject constructor( } } + suspend fun claimForPayment(request: PaykitPaymentRequest): Result = withContext(ioDispatcher) { + runSuspendCatching { + paykitSdkService.claimPaymentRequestForExecution(request.counterparty, request.paymentRequestId) + Unit + } + } + suspend fun accept(request: PaykitPaymentRequest): Result = withContext(ioDispatcher) { runSuspendCatching { if (!request.requiresAcceptance) { @@ -793,7 +805,6 @@ class PaykitPaymentRequestRepo @Inject constructor( ensurePaymentAllowed(it).getOrThrow() paykitSdkService.acceptPaymentRequest( counterparty = it.counterparty, - counterpartyReceiverPath = it.counterpartyReceiverPath, paymentRequestId = it.paymentRequestId, ) }.getOrThrow() @@ -809,7 +820,6 @@ class PaykitPaymentRequestRepo @Inject constructor( ) { paykitSdkService.rejectPaymentRequest( counterparty = it.counterparty, - counterpartyReceiverPath = it.counterpartyReceiverPath, paymentRequestId = it.paymentRequestId, ) }.onFailure { @@ -829,7 +839,6 @@ class PaykitPaymentRequestRepo @Inject constructor( return updateRequest(request, PaymentRequestLifecycleState.CANCELED) { paykitSdkService.cancelPaymentRequest( counterparty = it.counterparty, - counterpartyReceiverPath = it.counterpartyReceiverPath, paymentRequestId = it.paymentRequestId, ) } @@ -847,7 +856,6 @@ class PaykitPaymentRequestRepo @Inject constructor( ?: throw PaykitPaymentRequestError.RequestUnavailable val record = paykitSdkService.acceptPaymentRequest( current.counterparty, - current.counterpartyReceiverPath, current.paymentRequestId, ) processPendingMessages() @@ -875,7 +883,7 @@ class PaykitPaymentRequestRepo @Inject constructor( throw PaykitPaymentRequestError.OperationInProgress } } - paykitSdkService.cancelPaymentRequest(it.counterparty, it.counterpartyReceiverPath, it.paymentRequestId) + paykitSdkService.cancelPaymentRequest(it.counterparty, it.paymentRequestId) } fun isPending(request: PaykitPaymentRequest): Boolean = @@ -910,12 +918,14 @@ class PaykitPaymentRequestRepo @Inject constructor( processPendingMessages() paykitSdkService.receivePrivateMessagesFromLinkedPeers().also(::logIntakeFailures) val now = clock.now() - val records = paykitSdkService.paymentRequests() + receivedContacts = null + val allRecords = paykitSdkService.allPaymentRequests(expectedIdentity) + val contacts = PaykitReceivedPaymentContacts.from(allRecords, Env.network) + val records = allRecords.filter(::isBitkitPaymentRequest) val blockedPeers = paykitSdkService.linkedPeers().filter { it.state == LinkedPeerState.BLOCKED } val availableRecords = records.filterNot { record -> blockedPeers.any { - PubkyPublicKeyFormat.matches(it.counterparty, record.counterparty) && - it.counterpartyReceiverPath == record.counterpartyReceiverPath + PubkyPublicKeyFormat.matches(it.counterparty, record.counterparty) } } val locallyCompletedProofKinds = expectedIdentity @@ -930,8 +940,7 @@ class PaykitPaymentRequestRepo @Inject constructor( val blockedSubscriptionIds = allSubscriptions.mapNotNull { subscription -> subscription.id.takeIf { blockedPeers.any { - PubkyPublicKeyFormat.matches(it.counterparty, subscription.counterparty) && - it.counterpartyReceiverPath == subscription.counterpartyReceiverPath + PubkyPublicKeyFormat.matches(it.counterparty, subscription.counterparty) } } }.toSet() @@ -1014,6 +1023,7 @@ class PaykitPaymentRequestRepo @Inject constructor( val history = (recurringHistory + oneTimeHistory) .sortedByDescending { it.createdAt } if (!isCurrentState(generation, expectedIdentity) || expectedIdentity == null) return + receivedContacts = ReceivedContactsSnapshot(generation, expectedIdentity, contacts) val subscriptionStateChanged = subscriptionAcceptedAt != updatedSubscriptionAcceptedAt || dismissedSubscriptionPaymentIds != updatedDismissedPaymentIds @@ -1057,24 +1067,19 @@ class PaykitPaymentRequestRepo @Inject constructor( val identityStatus = paykitSdkService.identityStatus() if ( savedKeys.isEmpty() || - identityStatus?.liveSessionAvailable != true || + identityStatus?.capability != PubkyIdentityCapability.PRIVATE_LINK_CAPABLE || !PubkyPublicKeyFormat.matches(identityStatus.publicKey, expectedIdentity) ) { return null } - val linkedPaths = mutableMapOf>() - paykitSdkService.linkedPeers() + val linkedPublicKeys = paykitSdkService.linkedPeers() .filter { it.state == LinkedPeerState.LINKED } - .forEach { peer -> - val publicKey = PubkyPublicKeyFormat.normalized(peer.counterparty) ?: return@forEach - if (peer.counterpartyReceiverPath in PaykitReceiverPaths.supported) { - linkedPaths.getOrPut(publicKey, ::mutableSetOf) += peer.counterpartyReceiverPath - } - } + .mapNotNull { PubkyPublicKeyFormat.normalized(it.counterparty) } + .toSet() return PaykitPaymentRequestTargetContext( savedPublicKeys = savedKeys, - linkedReceiverPaths = linkedPaths.mapValues { it.value.toSet() }, + linkedPublicKeys = linkedPublicKeys, ) } @@ -1085,9 +1090,9 @@ class PaykitPaymentRequestRepo @Inject constructor( var isComplete = true val failedPublicKeys = mutableSetOf() val targets = context.savedPublicKeys.mapNotNull { publicKey -> - val linked = context.linkedReceiverPaths[publicKey] ?: return@mapNotNull null + if (publicKey !in context.linkedPublicKeys) return@mapNotNull null val lookup = withTimeoutOrNull(TARGET_DISCOVERY_TIMEOUT) { - runSuspendCatching { paykitSdkService.paymentRequestReceiverPaths(publicKey) } + runSuspendCatching { paykitSdkService.canReceivePaymentRequests(publicKey) } } if (lookup == null) { isComplete = false @@ -1096,7 +1101,7 @@ class PaykitPaymentRequestRepo @Inject constructor( "Timed out inspecting payment request support for '${PubkyPublicKeyFormat.redacted(publicKey)}'", context = TAG, ) - return@mapNotNull previousTargets[publicKey]?.takeIf { it.receiverPath in linked } + return@mapNotNull previousTargets[publicKey] } val capable = lookup .onFailure { @@ -1109,14 +1114,13 @@ class PaykitPaymentRequestRepo @Inject constructor( ) } .getOrElse { - return@mapNotNull previousTargets[publicKey]?.takeIf { it.receiverPath in linked } + return@mapNotNull previousTargets[publicKey] } - val receiverPath = PaykitReceiverPaths.ordered.firstOrNull { it in linked && it in capable } - ?: run { - isComplete = false - return@mapNotNull null - } - PaykitPaymentRequestTarget(publicKey, receiverPath) + if (!capable) { + isComplete = false + return@mapNotNull null + } + PaykitPaymentRequestTarget(publicKey) } return PaykitPaymentRequestTargetDiscovery( targets = targets, @@ -1326,6 +1330,7 @@ class PaykitPaymentRequestRepo @Inject constructor( } private fun clearStateLocked() { + receivedContacts = null expirationJob?.cancel() expirationJob = null _pendingRequests.update { emptyList() } @@ -1472,7 +1477,6 @@ private fun PaymentRequestRecord.toPaykitPaymentRequest( ) = PaykitPaymentRequest( paymentRequestId = paymentRequestId, counterparty = counterparty, - counterpartyReceiverPath = counterpartyReceiverPath, amountValue = parsedTerms.terms.amount.value, amountSats = parsedTerms.amountSats, note = parsedTerms.terms.metadata.note(), @@ -1524,14 +1528,12 @@ private fun PaymentRequestRecord.toCreatedPaykitPaymentRequest( val wasSent = proposalOutboundMessageId?.let { messageId -> reports.any { report -> PubkyPublicKeyFormat.matches(report.counterparty, counterparty) && - report.counterpartyReceiverPath == counterpartyReceiverPath && messageId in report.report?.sent.orEmpty() } } == true return PaykitPaymentRequest( paymentRequestId = paymentRequestId, counterparty = target.publicKey, - counterpartyReceiverPath = target.receiverPath, amountValue = draft.amountSats.toBitcoinAmount(), amountSats = draft.amountSats, note = draft.note.takeIf(String::isNotBlank), diff --git a/app/src/main/java/to/bitkit/repositories/PaykitReceivedPaymentContacts.kt b/app/src/main/java/to/bitkit/repositories/PaykitReceivedPaymentContacts.kt new file mode 100644 index 0000000000..1487aeb5e6 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PaykitReceivedPaymentContacts.kt @@ -0,0 +1,90 @@ +package to.bitkit.repositories + +import com.synonym.bitkitcore.validateBitcoinAddress +import com.synonym.paykit.PaymentRequestLifecycleState +import com.synonym.paykit.PaymentRequestLocalRole +import com.synonym.paykit.PaymentRequestRecord +import org.lightningdevkit.ldknode.Bolt11Invoice +import org.lightningdevkit.ldknode.Currency +import org.lightningdevkit.ldknode.Network +import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.models.toLdkNetwork + +internal class PaykitReceivedPaymentContacts private constructor( + private val addresses: Map>, + private val paymentHashes: Map>, +) { + fun contactsForAddresses(values: Collection): Set = + values.flatMapTo(mutableSetOf()) { addresses[it].orEmpty() } + + fun contactsForPaymentHash(value: String): Set = paymentHashes[value.lowercase()].orEmpty() + + companion object { + val Empty = PaykitReceivedPaymentContacts(emptyMap(), emptyMap()) + + fun from( + records: List, + network: Network, + parseInvoice: (String) -> Bolt11Invoice = Bolt11Invoice::fromStr, + ): PaykitReceivedPaymentContacts { + val addresses = mutableMapOf>() + val hashes = mutableMapOf>() + for (record in records) { + val contact = validCounterparty(record) ?: continue + val terms = checkNotNull(record.terms) + // Only immutable request destinations identify a payer; proofs and current lists do not. + val acceptedEndpoints = terms.paymentEndpoints.orEmpty() + .filterKeys(terms.acceptedPaymentEndpointIdentifiers::contains) + for ((identifier, payload) in acceptedEndpoints) { + val endpoint = PublicPaykitRepo.parseEndpoint(identifier, payload, network) ?: continue + val value = endpoint.value + runCatching { + when { + endpoint.methodId.isOnchain && isValidAddress(value, network) -> { + addresses.getOrPut(value) { mutableSetOf() }.add(contact) + } + endpoint.methodId == MethodId.Bolt11 -> { + val invoice = parseInvoice(value) + if (invoice.currency() == currency(network)) { + hashes.getOrPut(invoice.paymentHash()) { mutableSetOf() }.add(contact) + } + } + } + } + } + } + return if (addresses.isEmpty() && hashes.isEmpty()) { + Empty + } else { + PaykitReceivedPaymentContacts(addresses, hashes) + } + } + + private fun validCounterparty(record: PaymentRequestRecord): String? { + if (record.localRole != PaymentRequestLocalRole.PAYEE || record.invalidReason != null) return null + if (record.state == PaymentRequestLifecycleState.INVALID_CONFLICT || + record.state == PaymentRequestLifecycleState.UNKNOWN + ) { + return null + } + if (record.terms?.amount?.asset != PaykitIssuerInterop.BITCOIN_ASSET) return null + if (record.counterparty.trim().length > PubkyPublicKeyFormat.maximumInputLength) return null + return PubkyPublicKeyFormat.normalized(record.counterparty) + } + + private fun isValidAddress(value: String, network: Network): Boolean { + val addressNetwork = validateBitcoinAddress(value).network.toLdkNetwork() + if (addressNetwork == network) return true + if (addressNetwork != Network.TESTNET) return false + return network == Network.SIGNET || + (network == Network.REGTEST && value.firstOrNull() in listOf('2', 'm', 'n')) + } + + private fun currency(network: Network): Currency = when (network) { + Network.BITCOIN -> Currency.BITCOIN + Network.TESTNET -> Currency.BITCOIN_TESTNET + Network.SIGNET -> Currency.SIGNET + Network.REGTEST -> Currency.REGTEST + } + } +} diff --git a/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt b/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt index 6e123cc896..10617aff53 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt @@ -168,13 +168,11 @@ enum class PaykitSubscriptionRole { Payer, Payee } data class PaykitSubscriptionId( val paymentRequestId: String, val counterparty: String, - val counterpartyReceiverPath: String, ) data class PaykitSubscription( val paymentRequestId: String, val counterparty: String, - val counterpartyReceiverPath: String, val amountValue: String, val amountSats: ULong, val note: String?, @@ -191,7 +189,7 @@ data class PaykitSubscription( val hasPaymentDeadline: Boolean = false, ) { val id: PaykitSubscriptionId - get() = PaykitSubscriptionId(paymentRequestId, counterparty, counterpartyReceiverPath) + get() = PaykitSubscriptionId(paymentRequestId, counterparty) val isPayer: Boolean get() = role == PaykitSubscriptionRole.Payer @@ -244,7 +242,6 @@ data class PaykitSubscription( PaykitPaymentRequest( paymentRequestId = paymentRequestId, counterparty = counterparty, - counterpartyReceiverPath = counterpartyReceiverPath, amountValue = amountValue, amountSats = amountSats, note = note, @@ -271,7 +268,6 @@ data class PaykitSubscription( PaykitPaymentRequest( paymentRequestId = paymentRequestId, counterparty = counterparty, - counterpartyReceiverPath = counterpartyReceiverPath, amountValue = amountValue, amountSats = amountSats, note = note, @@ -327,7 +323,6 @@ internal fun PaymentRequestRecord.toPaykitSubscription( return PaykitSubscription( paymentRequestId = paymentRequestId, counterparty = counterparty, - counterpartyReceiverPath = counterpartyReceiverPath, amountValue = requestTerms.amount.value, amountSats = amountSats, note = requestTerms.metadata.note()?.take(256), diff --git a/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt b/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt index b268e4e334..effd1280c7 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt @@ -20,7 +20,6 @@ import to.bitkit.R import to.bitkit.ext.runSuspendCatching import to.bitkit.ui.EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT import to.bitkit.ui.EXTRA_PAYKIT_COUNTERPARTY -import to.bitkit.ui.EXTRA_PAYKIT_COUNTERPARTY_RECEIVER_PATH import to.bitkit.ui.EXTRA_PAYKIT_PAYER_IDENTITY import to.bitkit.ui.EXTRA_PAYKIT_PAYMENT_REQUEST_ID import to.bitkit.ui.EXTRA_PAYKIT_SUBSCRIPTION_PAYMENT_DUE @@ -82,7 +81,7 @@ class PaykitSubscriptionNotificationScheduler @Inject constructor( .take(MAX_NOTIFICATIONS) .associate { (subscription, period) -> val workName = "$WORK_PREFIX$payerIdentity|${subscription.counterparty}|" + - "${subscription.counterpartyReceiverPath}|${subscription.paymentRequestId}|${period.startsAt}" + "${subscription.paymentRequestId}|${period.startsAt}" val delay = (period.startsAt - now).inWholeMilliseconds.coerceAtLeast(0) val work = OneTimeWorkRequestBuilder() .setInitialDelay(delay, TimeUnit.MILLISECONDS) @@ -91,7 +90,6 @@ class PaykitSubscriptionNotificationScheduler @Inject constructor( EXTRA_PAYKIT_PAYMENT_REQUEST_ID to subscription.paymentRequestId, EXTRA_PAYKIT_PAYER_IDENTITY to payerIdentity, EXTRA_PAYKIT_COUNTERPARTY to subscription.counterparty, - EXTRA_PAYKIT_COUNTERPARTY_RECEIVER_PATH to subscription.counterpartyReceiverPath, EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT to period.startsAt.toString(), ) ) @@ -101,7 +99,7 @@ class PaykitSubscriptionNotificationScheduler @Inject constructor( } val pendingWorkNames = pendingRequestIds.mapNotNullTo(mutableSetOf()) { requestId -> requestId.billingPeriodStartsAt?.let { - "$WORK_PREFIX$payerIdentity|${requestId.counterparty}|${requestId.counterpartyReceiverPath}|" + + "$WORK_PREFIX$payerIdentity|${requestId.counterparty}|" + "${requestId.paymentRequestId}|$it" } } @@ -170,10 +168,6 @@ class PaykitSubscriptionNotificationWorker @AssistedInject constructor( putString(EXTRA_PAYKIT_PAYER_IDENTITY, inputData.getString(EXTRA_PAYKIT_PAYER_IDENTITY)) putString(EXTRA_PAYKIT_PAYMENT_REQUEST_ID, inputData.getString(EXTRA_PAYKIT_PAYMENT_REQUEST_ID)) putString(EXTRA_PAYKIT_COUNTERPARTY, inputData.getString(EXTRA_PAYKIT_COUNTERPARTY)) - putString( - EXTRA_PAYKIT_COUNTERPARTY_RECEIVER_PATH, - inputData.getString(EXTRA_PAYKIT_COUNTERPARTY_RECEIVER_PATH), - ) putString( EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT, inputData.getString(EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT), diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt index 4b889a8ee6..e57115f1fc 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt @@ -22,7 +22,6 @@ import to.bitkit.models.addressTypeFromAddress import to.bitkit.models.toAddressType import to.bitkit.models.toSettingsString import to.bitkit.services.CoreService -import to.bitkit.services.PaykitReceiverPaths import to.bitkit.utils.AppError import to.bitkit.utils.Logger import javax.inject.Inject @@ -34,20 +33,6 @@ sealed class PrivatePaykitAddressReservationError(message: String) : AppError(me ) } -internal data class ContactAssignmentKey( - val publicKey: String, - val receiverPath: String, -) { - fun encoded(): String = - if (receiverPath == PaykitReceiverPaths.WALLET) publicKey else "$publicKey$SEPARATOR$receiverPath" - - companion object { - private const val SEPARATOR = '#' - - fun publicKeyOf(encoded: String): String = encoded.substringBefore(SEPARATOR) - } -} - @Singleton @Suppress("TooManyFunctions") class PrivatePaykitAddressReservationRepo @Inject constructor( @@ -98,10 +83,9 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( suspend fun currentOrRotatedAddress( publicKey: String, - receiverPath: String, ): Result = withContext(ioDispatcher) { runSuspendCatching { - val assignmentKey = contactAssignmentKey(publicKey, receiverPath) + val assignmentKey = normalizedPublicKey(publicKey) val current = locked { it.contactAssignments[assignmentKey] } if (current != null && isAddressTypeMonitored(current.addressType)) { val address = resolvedAddress(current).getOrThrow() @@ -170,7 +154,7 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( assignments.firstOrNull { (_, assignment) -> assignment.addressType == addressType && (assignment.address == address || resolvedAddress(assignment).getOrNull() == address) - }?.first?.publicKeyFromAssignmentKey() + }?.first } suspend fun currentContactPublicKeyForReservedAddress(address: String): String? = withContext(ioDispatcher) { @@ -180,7 +164,7 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( assignments.firstOrNull { (_, assignment) -> assignment.addressType == addressType && (assignment.address == address || resolvedAddress(assignment).getOrNull() == address) - }?.first?.publicKeyFromAssignmentKey() + }?.first } suspend fun contactsWithUsedReservedAddresses(): List = withContext(ioDispatcher) { @@ -191,13 +175,13 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( .onFailure { Logger.warn( "Failed to check private Paykit address usage for " + - "'${redacted(publicKey.publicKeyFromAssignmentKey())}'", + "'${redacted(publicKey)}'", it, context = TAG, ) } .getOrDefault(false) - publicKey.publicKeyFromAssignmentKey().takeIf { isUsed } + publicKey.takeIf { isUsed } }.distinct() } @@ -211,18 +195,18 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( val normalizedKey = normalizedPublicKey(publicKey) locked { current -> val hadAssignment = current.contactAssignments.keys.any { - it.publicKeyFromAssignmentKey() == normalizedKey + it == normalizedKey } val hadHistory = current.contactAssignmentHistory.keys.any { - it.publicKeyFromAssignmentKey() == normalizedKey + it == normalizedKey } if (!hadAssignment && !hadHistory) return@locked val next = current.copy( contactAssignments = current.contactAssignments.filterKeys { - it.publicKeyFromAssignmentKey() != normalizedKey + it != normalizedKey }, contactAssignmentHistory = current.contactAssignmentHistory.filterKeys { - it.publicKeyFromAssignmentKey() != normalizedKey + it != normalizedKey }, ) ledger = next @@ -236,10 +220,10 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( locked { current -> val next = current.copy( contactAssignments = current.contactAssignments.filterKeys { - it.publicKeyFromAssignmentKey() in savedKeys + it in savedKeys }, contactAssignmentHistory = current.contactAssignmentHistory.filterKeys { - it.publicKeyFromAssignmentKey() in savedKeys + it in savedKeys }, ) if (next == current) return@locked @@ -392,11 +376,6 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( private fun normalizedPublicKeyOrNull(publicKey: String): String? = PubkyPublicKeyFormat.normalized(publicKey) - private fun contactAssignmentKey(publicKey: String, receiverPath: String): String = - ContactAssignmentKey(normalizedPublicKey(publicKey), receiverPath).encoded() - - private fun String.publicKeyFromAssignmentKey(): String = ContactAssignmentKey.publicKeyOf(this) - private fun redacted(publicKey: String): String = PubkyPublicKeyFormat.redacted(publicKey) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt index ebb38c4c7f..b302089f40 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext import to.bitkit.data.PrivatePaykitCacheStore import to.bitkit.di.IoDispatcher +import to.bitkit.models.PubkyPublicKeyFormat import javax.inject.Inject import javax.inject.Provider import javax.inject.Singleton @@ -14,24 +15,34 @@ class PrivatePaykitContactResolver @Inject constructor( @IoDispatcher private val ioDispatcher: CoroutineDispatcher, private val cacheStore: PrivatePaykitCacheStore, private val addressReservationRepo: Provider, + private val paymentRequestRepo: Provider, ) { + internal val receivedPaymentContacts: PaykitReceivedPaymentContacts + get() = paymentRequestRepo.get().receivedPaymentContacts + suspend fun contactPublicKeyForPrivateInvoicePaymentHash(paymentHash: String): String? = withContext(ioDispatcher) { if (paymentHash.isBlank()) return@withContext null - cacheStore.data.first().contacts.firstNotNullOfOrNull { (publicKey, contactState) -> + val shared = receivedPaymentContacts + val contacts = cacheStore.data.first().contacts.mapNotNull { (publicKey, contactState) -> publicKey.takeIf { - contactState.localInvoicesByReceiverPath.values.any { invoice -> - invoice.paymentHash == paymentHash - } || + contactState.localInvoice?.paymentHash == paymentHash || paymentHash in contactState.receivedInvoicePaymentHashes } } + if (receivedPaymentContacts !== shared) return@withContext null + (contacts + shared.contactsForPaymentHash(paymentHash)) + .mapNotNull(PubkyPublicKeyFormat::normalized).distinct().singleOrNull() } suspend fun contactPublicKeyForPrivateOnchainAddresses(addresses: Collection): String? = withContext(ioDispatcher) { - addresses.firstNotNullOfOrNull { + val shared = receivedPaymentContacts + val contacts = addresses.mapNotNull { addressReservationRepo.get().contactPublicKeyForReservedAddress(it) } + if (receivedPaymentContacts !== shared) return@withContext null + (contacts + shared.contactsForAddresses(addresses)) + .mapNotNull(PubkyPublicKeyFormat::normalized).distinct().singleOrNull() } } diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt index 785f26ead8..49984ab725 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt @@ -35,36 +35,36 @@ internal data class PrivatePaykitState( internal data class ContactState( var remoteEndpoints: List = emptyList(), - var consumedPrivatePaymentListVersionsByReceiverPath: Map = emptyMap(), - var localInvoicesByReceiverPath: Map = emptyMap(), + var consumedPrivatePaymentListVersion: ULong? = null, + var localInvoice: StoredInvoice? = null, var receivedInvoicePaymentHashes: List = emptyList(), - var publishedPrivatePaymentReceiverPaths: Set = emptySet(), + var hasPublishedPrivatePaymentList: Boolean = false, ) { constructor(cache: PrivatePaykitContactCacheData) : this( remoteEndpoints = cache.remoteEndpoints.map { StoredPaymentEntry(it.methodId, it.endpointData) }, - consumedPrivatePaymentListVersionsByReceiverPath = cache.consumedPrivatePaymentListVersionsByReceiverPath, - localInvoicesByReceiverPath = cache.localInvoicesByReceiverPath.mapValues { (_, invoice) -> + consumedPrivatePaymentListVersion = cache.consumedPrivatePaymentListVersion, + localInvoice = cache.localInvoice?.let { invoice -> StoredInvoice(invoice.bolt11, invoice.paymentHash, invoice.expiresAt) }, receivedInvoicePaymentHashes = cache.receivedInvoicePaymentHashes, - publishedPrivatePaymentReceiverPaths = cache.publishedPrivatePaymentReceiverPaths, + hasPublishedPrivatePaymentList = cache.hasPublishedPrivatePaymentList, ) val hasCacheState: Boolean - get() = publishedPrivatePaymentReceiverPaths.isNotEmpty() || + get() = hasPublishedPrivatePaymentList || remoteEndpoints.isNotEmpty() || - consumedPrivatePaymentListVersionsByReceiverPath.isNotEmpty() || - localInvoicesByReceiverPath.isNotEmpty() || + (consumedPrivatePaymentListVersion != null) || + (localInvoice != null) || receivedInvoicePaymentHashes.isNotEmpty() fun cacheState() = PrivatePaykitContactCacheData( remoteEndpoints = remoteEndpoints.map { PrivatePaykitStoredPaymentEntryData(it.methodId, it.endpointData) }, - consumedPrivatePaymentListVersionsByReceiverPath = consumedPrivatePaymentListVersionsByReceiverPath, - localInvoicesByReceiverPath = localInvoicesByReceiverPath.mapValues { (_, invoice) -> + consumedPrivatePaymentListVersion = consumedPrivatePaymentListVersion, + localInvoice = localInvoice?.let { invoice -> PrivatePaykitStoredInvoiceData(invoice.bolt11, invoice.paymentHash, invoice.expiresAt) }, receivedInvoicePaymentHashes = receivedInvoicePaymentHashes, - publishedPrivatePaymentReceiverPaths = publishedPrivatePaymentReceiverPaths, + hasPublishedPrivatePaymentList = hasPublishedPrivatePaymentList, ) } @@ -76,7 +76,7 @@ internal data class StoredPaymentEntry( @Serializable internal data class PrivatePaykitBackup( val sdkState: String, - val consumedPrivatePaymentListVersions: Map>, + val consumedPrivatePaymentListVersions: Map, ) internal data class StoredInvoice( diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 550f2e90a9..57cba64f4e 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -2,6 +2,7 @@ package to.bitkit.repositories import com.synonym.bitkitcore.Scanner import com.synonym.paykit.LinkedPeerState +import com.synonym.paykit.PaykitException import com.synonym.paykit.PaymentAmountContext import com.synonym.paykit.PrivatePaymentEndpointReservationInput import com.synonym.paykit.PrivatePaymentListDeliveryReport @@ -43,7 +44,6 @@ import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.services.CoreService import to.bitkit.services.PaykitPreparedPrivateContactPayment import to.bitkit.services.PaykitPrivateContactPaymentResolution -import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PaykitSdkService import to.bitkit.services.PubkyService import to.bitkit.utils.Logger @@ -104,7 +104,7 @@ class PrivatePaykitRepo @Inject constructor( private val knownSavedContactKeys = mutableSetOf() private var state: PrivatePaykitState? = null private val pendingMessageDrainRetryLock = Any() - private val pendingMessageDrainRetryKeys = mutableSetOf() + private val pendingMessageDrainRetryKeys = mutableSetOf() private var pendingMessageDrainRetryJob: Job? = null private var pendingMessageDrainRetryGeneration = 0 private val _initialLinkBurstStarted = MutableSharedFlow(extraBufferCapacity = 1) @@ -116,12 +116,12 @@ class PrivatePaykitRepo @Inject constructor( private data class PrivatePublicationPreparation( val updates: List, - val linkRetryKeys: List, + val linkRetryKeys: List, val firstError: Throwable?, ) private data class PrivateEndpointCleanupPreparation( - val clearedRetryKeys: List, + val clearedRetryKeys: List, val failedPublicKeys: Set, val firstError: Throwable?, ) @@ -131,20 +131,10 @@ class PrivatePaykitRepo @Inject constructor( val invalidKeys: Set, ) - private data class LinkedReceiverPathsSnapshot( - val pathsByPublicKey: Map>, - val error: Throwable?, - ) - private data class PublishedEndpointCleanupState( val remoteEndpoints: List, - val localInvoicesByReceiverPath: Map, - val publishedPrivatePaymentReceiverPaths: Set, - ) - - private data class PrivateMessageDrainRetryKey( - val publicKey: String, - val receiverPath: String, + val localInvoice: StoredInvoice?, + val hasPublishedPrivatePaymentList: Boolean, ) private val _backupStateVersion = MutableStateFlow(0L) @@ -270,6 +260,9 @@ class PrivatePaykitRepo @Inject constructor( removePublishedEndpoints().getOrThrow() clearUnsavedContactState(savedPublicKeys).getOrThrow() updateContactSharingCleanupPending(false) + publicPaykitRepo.syncPaykitApp().onFailure { + updateContactSharingCleanupPending(true) + }.getOrThrow() } retryPendingDeletedContactEndpointRemoval(savedPublicKeys).getOrThrow() }.onFailure { @@ -309,6 +302,7 @@ class PrivatePaykitRepo @Inject constructor( suspend fun disableSharingAndPruneUnsavedContactState(savedPublicKeys: Collection): Result = withContext(serializedDispatcher) { runSuspendCatching { + updateContactSharingCleanupPending(true) val removalError = removePublishedEndpoints().exceptionOrNull() if (removalError != null) { updateContactSharingCleanupPending(true) @@ -322,6 +316,9 @@ class PrivatePaykitRepo @Inject constructor( clearUnsavedContactState(savedPublicKeys).getOrThrow() updateContactSharingCleanupPending(false) + publicPaykitRepo.syncPaykitApp().onFailure { + updateContactSharingCleanupPending(true) + }.getOrThrow() } } @@ -334,14 +331,15 @@ class PrivatePaykitRepo @Inject constructor( suspend fun removePublishedEndpointsForCleanup(context: String): Result = withContext(serializedDispatcher) { clearInitialLinkBurst() - removePublishedEndpoints() - .onSuccess { - updateContactSharingCleanupPending(false) - } - .onFailure { - updateContactSharingCleanupPending(true) - Logger.warn("Failed to remove private Paykit endpoints during '$context'", it, context = TAG) - } + runSuspendCatching { + updateContactSharingCleanupPending(true) + removePublishedEndpoints().getOrThrow() + updateContactSharingCleanupPending(false) + publicPaykitRepo.syncPaykitApp().getOrThrow() + }.onFailure { + updateContactSharingCleanupPending(true) + Logger.warn("Failed to remove private Paykit endpoints during '$context'", it, context = TAG) + } } suspend fun closeAndClear(): Result = withContext(serializedDispatcher) { @@ -395,19 +393,18 @@ class PrivatePaykitRepo @Inject constructor( ): Result = withContext(serializedDispatcher) { runSuspendCatching { val normalizedKey = normalizedPublicKey(publicKey) ?: throw PrivatePaykitError.InvalidPublicKey + val paymentListVersion = context.paymentListVersion ?: return@runSuspendCatching val contactState = ensureState().contacts.getOrPut(normalizedKey) { ContactState() } - val consumedVersion = contactState.consumedPrivatePaymentListVersionsByReceiverPath[context.receiverPath] - if (consumedVersion != null && context.paymentListVersion <= consumedVersion) { + val consumedVersion = contactState.consumedPrivatePaymentListVersion + if (consumedVersion != null && paymentListVersion <= consumedVersion) { throw PrivatePaykitError.PaymentListAlreadyConsumed } - contactState.consumedPrivatePaymentListVersionsByReceiverPath = - contactState.consumedPrivatePaymentListVersionsByReceiverPath + - (context.receiverPath to context.paymentListVersion) + contactState.consumedPrivatePaymentListVersion = paymentListVersion contactState.remoteEndpoints = emptyList() persistState(markWalletBackup = true) Logger.info( - "Consumed private Paykit payment list version ${context.paymentListVersion} " + + "Consumed private Paykit payment list version $paymentListVersion " + "for '${redacted(normalizedKey)}'", context = TAG, ) @@ -422,15 +419,15 @@ class PrivatePaykitRepo @Inject constructor( ): Result = withContext(serializedDispatcher) { runSuspendCatching { val normalizedKey = normalizedPublicKey(publicKey) ?: throw PrivatePaykitError.InvalidPublicKey + val paymentListVersion = context.paymentListVersion ?: return@runSuspendCatching val contactState = ensureState().contacts[normalizedKey] ?: return@runSuspendCatching - val consumedVersion = contactState.consumedPrivatePaymentListVersionsByReceiverPath[context.receiverPath] - if (consumedVersion != context.paymentListVersion) return@runSuspendCatching + val consumedVersion = contactState.consumedPrivatePaymentListVersion + if (consumedVersion != paymentListVersion) return@runSuspendCatching - contactState.consumedPrivatePaymentListVersionsByReceiverPath = - contactState.consumedPrivatePaymentListVersionsByReceiverPath - context.receiverPath + contactState.consumedPrivatePaymentListVersion = null persistState(markWalletBackup = true) Logger.info( - "Released private Paykit payment list version '${context.paymentListVersion}' " + + "Released private Paykit payment list version '$paymentListVersion' " + "for '${redacted(normalizedKey)}'", context = TAG, ) @@ -478,7 +475,7 @@ class PrivatePaykitRepo @Inject constructor( val matches = buildList { ensureState().contacts.forEach { (publicKey, contactState) -> if (publicKey !in knownSavedContactKeys) return@forEach - contactState.localInvoicesByReceiverPath.values.forEach { invoice -> + contactState.localInvoice?.let { invoice -> if (invoice.paymentHash in paymentHashes) add(publicKey to invoice.paymentHash) } } @@ -516,7 +513,7 @@ class PrivatePaykitRepo @Inject constructor( if (paymentHash.isBlank()) return@withContext null ensureState().contacts.firstNotNullOfOrNull { (publicKey, contactState) -> publicKey.takeIf { - contactState.localInvoices().any { invoice -> invoice.paymentHash == paymentHash } || + contactState.localInvoice?.paymentHash == paymentHash || paymentHash in contactState.receivedInvoicePaymentHashes } } @@ -538,9 +535,7 @@ class PrivatePaykitRepo @Inject constructor( sdkState = paykitSdkService.exportBackupState(), consumedPrivatePaymentListVersions = ensureState().contacts .mapNotNull { (publicKey, contactState) -> - contactState.consumedPrivatePaymentListVersionsByReceiverPath - .takeIf { it.isNotEmpty() } - ?.let { publicKey to it } + contactState.consumedPrivatePaymentListVersion?.let { publicKey to it } }.toMap(), ) ) @@ -550,17 +545,17 @@ class PrivatePaykitRepo @Inject constructor( suspend fun restoreBackup(backup: String?): Result = withContext(serializedDispatcher) { runSuspendCatching { + val decoded = backup?.let { json.decodeFromString(it) } + decoded?.let { paykitSdkService.retainRecoveryBackup(it.sdkState) } clearPendingMessageDrainRetries() state = PrivatePaykitState() knownSavedContactKeys.clear() if (backup == null) { paykitSdkService.clearState() } else { - val decoded = json.decodeFromString(backup) - paykitSdkService.restoreBackupState(decoded.sdkState) - decoded.consumedPrivatePaymentListVersions.forEach { (publicKey, versions) -> + requireNotNull(decoded).consumedPrivatePaymentListVersions.forEach { (publicKey, versions) -> ensureState().contacts.getOrPut(publicKey) { ContactState() } - .consumedPrivatePaymentListVersionsByReceiverPath = versions + .consumedPrivatePaymentListVersion = versions } } persistState(preserveCleanupMarkers = false) @@ -574,44 +569,38 @@ class PrivatePaykitRepo @Inject constructor( ): Result = withContext(serializedDispatcher) { runSuspendCatching { - val receiverPath = paymentRequest?.counterpartyReceiverPath ?: PaykitReceiverPaths.WALLET - val consumedVersion = ensureState().contacts[publicKey] - ?.consumedPrivatePaymentListVersionsByReceiverPath - ?.get(receiverPath) + val consumedVersion = ensureState().contacts[publicKey]?.consumedPrivatePaymentListVersion val amount = paymentRequest?.let { PaymentAmountContext(it.amountValue, PaykitIssuerInterop.BITCOIN_ASSET) } val prepared = runSuspendCatching { preparePrivateContactPayment( publicKey = publicKey, - receiverPath = receiverPath, consumedVersion = consumedVersion, amount = amount, - allowPublicResolution = paymentRequest == null, + paymentRequest = paymentRequest, ) }.getOrElse { if (paymentRequest == null || !it.isPaykitRecoveryRequired()) throw it if (paymentRequest.isExpired(clock.now())) throw PaykitPaymentRequestError.RequestExpired - return@runSuspendCatching privateLinkPendingResult(publicKey, receiverPath) + return@runSuspendCatching privateLinkPendingResult(publicKey) } ?: return@runSuspendCatching publicPaykitRepo.beginPayment(publicKey).getOrThrow() val resolution = prepared.resolution - val linkState = currentLinkState(publicKey, receiverPath, prepared.linkState) + val linkState = currentLinkState(publicKey, prepared.linkState) if (paymentRequest == null && canUsePublicPayment(linkState, resolution.status, resolution.state)) { return@runSuspendCatching publicPaykitRepo.beginPayment(publicKey).getOrThrow() } val result = unresolvedPrivateLinkResult( publicKey = publicKey, - receiverPath = receiverPath, paymentRequest = paymentRequest, resolution = resolution, linkState = linkState, ) ?: privatePaymentResult( publicKey = publicKey, - receiverPath = receiverPath, resolution = resolution, consumedVersion = consumedVersion, - acceptedEndpointIdentifiers = paymentRequest?.acceptedPaymentEndpointIdentifiers?.toSet(), + paymentRequest = paymentRequest, ) if (paymentRequest?.isExpired(clock.now()) == true) { throw PaykitPaymentRequestError.RequestExpired @@ -649,22 +638,28 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun preparePrivateContactPayment( publicKey: String, - receiverPath: String, consumedVersion: ULong?, amount: PaymentAmountContext?, - allowPublicResolution: Boolean, + paymentRequest: PaykitPaymentRequest?, ): PaykitPreparedPrivateContactPayment? { val result = runSuspendCatching { - paykitSdkService.prepareAndResolvePrivateContactPayment( - counterparty = publicKey, - receiverPath = receiverPath, - afterPrivatePaymentListVersion = consumedVersion, - amount = amount, - ) + if (paymentRequest != null) { + paykitSdkService.prepareAndResolvePrivatePaymentRequest( + counterparty = publicKey, + paymentRequestId = paymentRequest.paymentRequestId, + afterPrivatePaymentListVersion = consumedVersion, + ) + } else { + paykitSdkService.prepareAndResolvePrivateContactPayment( + counterparty = publicKey, + afterPrivatePaymentListVersion = consumedVersion, + amount = amount, + ) + } } val error = result.exceptionOrNull() ?: return result.getOrThrow() - if (!allowPublicResolution) throw error - if (!canUsePublicPayment(currentLinkState(publicKey, receiverPath))) throw error + if (paymentRequest != null) throw error + if (!canUsePublicPayment(currentLinkState(publicKey))) throw error Logger.warn( "Using public Paykit resolution for '${redacted(publicKey)}'", @@ -676,36 +671,42 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun privatePaymentResult( publicKey: String, - receiverPath: String, resolution: PaykitPrivateContactPaymentResolution, consumedVersion: ULong?, - acceptedEndpointIdentifiers: Set? = null, + paymentRequest: PaykitPaymentRequest?, ): PublicPaykitPaymentResult { val privateEndpoints = resolution.payableEndpoints - .mapNotNull { PublicPaykitRepo.parseEndpoint(it.identifier, it.payload) } - cacheResolvedPrivateEndpoints(publicKey, privateEndpoints) + .mapNotNull { PublicPaykitRepo.parseEndpoint(it.identifier, it.payload)?.copy(appId = it.appId) } + if (resolution.privatePaymentListVersion != null) { + cacheResolvedPrivateEndpoints(publicKey, privateEndpoints) + } + val acceptedEndpointIdentifiers = paymentRequest?.acceptedPaymentEndpointIdentifiers?.toSet() val acceptedEndpoints = privateEndpoints.filter { acceptedEndpointIdentifiers?.contains(it.methodId.rawValue) ?: true } val privatePayable = privatePayableEndpoints(acceptedEndpoints, publicKey) val paymentListVersion = resolution.privatePaymentListVersion - if (privatePayable.isNotEmpty() && paymentListVersion != null) { + if (privatePayable.isNotEmpty() && (paymentListVersion != null || paymentRequest != null)) { Logger.info( "Opened private Paykit payment for '${redacted(publicKey)}' using payment list version " + - "$paymentListVersion after ${consumedVersion ?: "none"}", + "${paymentListVersion ?: "none"} after ${consumedVersion ?: "none"}", context = TAG, ) return PublicPaykitPaymentResult.Opened( paymentRequest = PublicPaykitRepo.paymentRequest(privatePayable), - privatePaymentContext = PrivatePaykitPaymentContext(receiverPath, paymentListVersion), + privatePaymentContext = PrivatePaykitPaymentContext( + paymentAppsByEndpoint = privatePayable.distinctBy { it.methodId } + .associate { it.methodId.rawValue to requireNotNull(it.appId) }, + paymentListVersion = paymentListVersion, + ), ) } if (resolution.status == PrivatePaymentResolutionStatus.WAITING_FOR_UPDATED_PAYMENT_LIST) { schedulePendingPrivateMessageDrainRetries( reason = "payment recovery", - retryKeys = listOf(PrivateMessageDrainRetryKey(publicKey, receiverPath)), + retryKeys = listOf(publicKey), ) Logger.info( "Waiting for a private Paykit payment list newer than ${consumedVersion ?: "none"} " + @@ -724,27 +725,25 @@ class PrivatePaykitRepo @Inject constructor( private fun unresolvedPrivateLinkResult( publicKey: String, - receiverPath: String, paymentRequest: PaykitPaymentRequest?, resolution: PaykitPrivateContactPaymentResolution, linkState: LinkedPeerState?, ): PublicPaykitPaymentResult? = when { resolution.state == PrivatePaymentResolutionState.RECOVERY_PENDING -> - privateLinkPendingResult(publicKey, receiverPath) + privateLinkPendingResult(publicKey) paymentRequest == null -> null linkState == LinkedPeerState.LINKING || linkState == LinkedPeerState.RECOVERY_REQUIRED -> - privateLinkPendingResult(publicKey, receiverPath) + privateLinkPendingResult(publicKey) linkState != LinkedPeerState.LINKED -> PublicPaykitPaymentResult.NoEndpoint else -> null } private fun privateLinkPendingResult( publicKey: String, - receiverPath: String, ): PublicPaykitPaymentResult { schedulePendingPrivateMessageDrainRetries( reason = "payment link recovery", - retryKeys = listOf(PrivateMessageDrainRetryKey(publicKey, receiverPath)), + retryKeys = listOf(publicKey), ) Logger.info( "Waiting for private Paykit link recovery for '${redacted(publicKey)}'", @@ -755,10 +754,9 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun currentLinkState( publicKey: String, - receiverPath: String, preparedState: LinkedPeerState? = null, ): LinkedPeerState? = preparedState ?: paykitSdkService.linkedPeers().firstOrNull { - PubkyPublicKeyFormat.matches(it.counterparty, publicKey) && it.counterpartyReceiverPath == receiverPath + PubkyPublicKeyFormat.matches(it.counterparty, publicKey) }?.state private fun canUsePublicPayment( @@ -839,113 +837,31 @@ class PrivatePaykitRepo @Inject constructor( forceRefreshLightning: Boolean, ): PrivatePublicationPreparation { var firstError: Throwable? = null - var receiverPathSelectionError: Throwable? = null - var hasPublicationUpdate = false val updates = mutableListOf() - val linkRetryKeys = mutableListOf() - val linkedReceiverPathsSnapshot = linkedReceiverPathsSnapshot(reason) - firstError = linkedReceiverPathsSnapshot.error - - for (publicKey in publicKeys) { - val receiverPaths = runSuspendCatching { receiverPathsForSavedContact(publicKey) } + val linkRetryKeys = mutableListOf() + for (publicKey in publicKeys.distinct()) { + val link = runSuspendCatching { paykitSdkService.ensureLinkWithPeer(publicKey) } + .onFailure { Logger.warn("Failed to prepare private Paykit link during '$reason'", it, context = TAG) } + if (link.exceptionOrNull() is PaykitException.NotFound) continue + linkRetryKeys += publicKey + runSuspendCatching { privatePaymentListUpdate(publicKey, forceRefreshLightning) } + .onSuccess { updates += it } .onFailure { - firstError = firstError ?: it - Logger.warn( - "Failed to read saved Paykit receivers for '${redacted(publicKey)}' during '$reason'", - it, - context = TAG, - ) - }.getOrNull() ?: continue - val receiverPathSelection = paykitSdkService.privateReceiverPathSelection(publicKey, receiverPaths) - val linkableReceiverPaths = receiverPathSelection.linkableReceiverPaths - val publicationReceiverPaths = receiverPathSelection.publishableReceiverPaths - receiverPathSelection.error?.let { - logPrivateReceiverPathSelectionFailure(publicKey, reason, it) - receiverPathSelectionError = receiverPathSelectionError ?: it - } - val cleanupReceiverPaths = receiverPathsForPrivateEndpointCleanup( - publicKey = publicKey, - excludedReceiverPaths = publicationReceiverPaths + receiverPathSelection.cleanupProtectedReceiverPaths, - linkedReceiverPaths = linkedReceiverPathsSnapshot.pathsByPublicKey[publicKey].orEmpty(), - ) - - linkRetryKeys += preparePrivateLinks(publicKey, linkableReceiverPaths + cleanupReceiverPaths, reason) - - cleanupReceiverPaths.forEach { receiverPath -> - updates += PrivatePaymentListReservationUpdateInput( - counterparty = publicKey, - counterpartyReceiverPath = receiverPath, - reservations = emptyList(), - ) - } - - publicationReceiverPaths.forEach { receiverPath -> - runSuspendCatching { - privatePaymentListUpdate(publicKey, receiverPath, forceRefreshLightning) - }.onSuccess { - updates += it - hasPublicationUpdate = true - }.onFailure { firstError = firstError ?: it logPrivatePublicationPreparationFailure(publicKey, reason, it) } - } } - - if (!hasPublicationUpdate) firstError = firstError ?: receiverPathSelectionError - return PrivatePublicationPreparation(updates, linkRetryKeys.distinct(), firstError) + return PrivatePublicationPreparation(updates, linkRetryKeys, firstError) } private suspend fun prepareRelevantPrivateLinksIfAvailable(publicKeys: Collection, reason: String) { if (!hasPrivatePaymentAccessForCurrentProfile()) return - - val retryKeys = mutableListOf() - for (publicKey in publicKeys) { - val receiverPaths = runSuspendCatching { receiverPathsForSavedContact(publicKey) } - .onFailure { - Logger.warn( - "Failed to read saved Paykit receivers for '${redacted(publicKey)}' during '$reason'", - it, - context = TAG, - ) - }.getOrNull() ?: continue - val selection = paykitSdkService.privateReceiverPathSelection(publicKey, receiverPaths) - selection.error?.let { - Logger.warn( - "Failed to inspect private Paykit receiver markers for '${redacted(publicKey)}' during '$reason'", - it, - context = TAG, - ) - } - retryKeys += selection.linkableReceiverPaths.map { PrivateMessageDrainRetryKey(publicKey, it) } - } - - drainAndSchedulePrivateLinkRetries(reason, retryKeys.distinct()) - } - - private suspend fun preparePrivateLinks( - publicKey: String, - receiverPaths: Collection, - reason: String, - ): List { - val retryKeys = mutableListOf() - for (receiverPath in receiverPaths.distinct()) { - runSuspendCatching { paykitSdkService.ensureLinkWithPeer(publicKey, receiverPath) }.onFailure { - Logger.warn( - "Failed to prepare private Paykit link for '${redacted(publicKey)}' during '$reason'", - it, - context = TAG, - ) - } - retryKeys += PrivateMessageDrainRetryKey(publicKey, receiverPath) - } - - return retryKeys + drainAndSchedulePrivateLinkRetries(reason, publicKeys.distinct()) } private suspend fun drainAndSchedulePrivateLinkRetries( reason: String, - retryKeys: Collection, + retryKeys: Collection, ) { if (retryKeys.isEmpty()) return @@ -958,15 +874,13 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun privatePaymentListUpdate( publicKey: String, - receiverPath: String, forceRefreshLightning: Boolean, ): PrivatePaymentListReservationUpdateInput { - val endpoints = buildLocalEndpoints(publicKey, receiverPath, forceRefreshLightning).getOrThrow() + val endpoints = buildLocalEndpoints(publicKey, forceRefreshLightning).getOrThrow() if (endpoints.isEmpty()) throw PrivatePaykitError.PrivateUnavailable return PrivatePaymentListReservationUpdateInput( counterparty = publicKey, - counterpartyReceiverPath = receiverPath, - reservations = endpoints.map { endpoint -> privateReservation(publicKey, receiverPath, endpoint) }, + reservations = endpoints.map { endpoint -> privateReservation(publicKey, endpoint) }, ) } @@ -989,18 +903,6 @@ class PrivatePaykitRepo @Inject constructor( } } - private fun logPrivateReceiverPathSelectionFailure( - publicKey: String, - reason: String, - error: Throwable, - ) { - Logger.warn( - "Failed to inspect private Paykit receiver markers for '${redacted(publicKey)}' during '$reason'", - error, - context = TAG, - ) - } - private suspend fun applyPrivatePaymentListDeliveryReport( report: PrivatePaymentListDeliveryReport, reason: String, @@ -1008,14 +910,14 @@ class PrivatePaykitRepo @Inject constructor( report.failedToQueue.forEach { Logger.warn( "Failed to queue private Paykit endpoints for '${redacted(it.counterparty)}' during '$reason': " + - (it.error ?: "unknown error"), + (it.error?.redactedContext() ?: "unknown error"), context = TAG, ) } report.failedToDeliver.forEach { Logger.warn( "Failed to deliver private Paykit endpoints for '${redacted(it.counterparty)}' during '$reason': " + - it.error, + it.error.redactedContext(), context = TAG, ) } @@ -1023,14 +925,13 @@ class PrivatePaykitRepo @Inject constructor( var didUpdateCache = false for (change in report.queued) { val publicKey = normalizedPublicKey(change.counterparty) ?: continue - recordPublishedPrivatePaymentListCache(publicKey, change.counterpartyReceiverPath) + recordPublishedPrivatePaymentListCache(publicKey) didUpdateCache = true } for (change in report.cleared) { didUpdateCache = clearPublishedPrivatePaymentListCache( counterparty = change.counterparty, - receiverPath = change.counterpartyReceiverPath, ) || didUpdateCache } @@ -1045,32 +946,29 @@ class PrivatePaykitRepo @Inject constructor( private fun privatePaymentListDeliveryRetryKeys( report: PrivatePaymentListDeliveryReport, - ): List { - val changes = report.queued.map { it.counterparty to it.counterpartyReceiverPath } + - report.cleared.map { it.counterparty to it.counterpartyReceiverPath } + - report.failedToDeliver.map { it.counterparty to it.counterpartyReceiverPath } - - return changes - .mapNotNull { (counterparty, receiverPath) -> - normalizedPublicKey(counterparty)?.let { PrivateMessageDrainRetryKey(it, receiverPath) } - } + ): List { + return ( + report.queued.map { it.counterparty } + + report.cleared.map { it.counterparty } + + report.failedToDeliver.map { it.counterparty } + ) + .mapNotNull(::normalizedPublicKey) .distinct() } private suspend fun drainPendingPrivateMessages( reason: String, - advancingLinksFor: List = emptyList(), + advancingLinksFor: List = emptyList(), ) { runSuspendCatching { advancingLinksFor.distinct().forEach { retryKey -> runSuspendCatching { paykitSdkService.ensureLinkWithPeer( - counterparty = retryKey.publicKey, - receiverPath = retryKey.receiverPath, + counterparty = retryKey, ) }.onFailure { Logger.warn( - "Failed to advance private Paykit link for '${redacted(retryKey.publicKey)}' during '$reason'", + "Failed to advance private Paykit link for '${redacted(retryKey)}' during '$reason'", it, context = TAG, ) @@ -1087,7 +985,7 @@ class PrivatePaykitRepo @Inject constructor( private fun schedulePendingPrivateMessageDrainRetries( reason: String, - retryKeys: Collection, + retryKeys: Collection, ) { val retryKeys = retryKeys.toSet() if (retryKeys.isEmpty()) return @@ -1136,7 +1034,7 @@ class PrivatePaykitRepo @Inject constructor( } } - private suspend fun updatePendingMessageDrainRetryKeys(retryKeys: Collection) { + private suspend fun updatePendingMessageDrainRetryKeys(retryKeys: Collection) { val remainingKeys = pendingPrivateMessageDrainKeys(retryKeys) synchronized(pendingMessageDrainRetryLock) { pendingMessageDrainRetryKeys.removeAll(retryKeys.toSet()) @@ -1145,8 +1043,8 @@ class PrivatePaykitRepo @Inject constructor( } private suspend fun pendingPrivateMessageDrainKeys( - retryKeys: Collection, - ): Set { + retryKeys: Collection, + ): Set { val retryKeys = retryKeys.toSet() if (retryKeys.isEmpty()) return emptySet() @@ -1157,7 +1055,7 @@ class PrivatePaykitRepo @Inject constructor( } .mapNotNull { peer -> normalizedPublicKey(peer.counterparty)?.let { publicKey -> - PrivateMessageDrainRetryKey(publicKey, peer.counterpartyReceiverPath) to peer.state + publicKey to peer.state } } .toMap() @@ -1166,11 +1064,7 @@ class PrivatePaykitRepo @Inject constructor( Logger.warn("Failed to inspect pending private Paykit messages", it, context = TAG) return retryKeys } - .mapNotNull { receiver -> - normalizedPublicKey(receiver.counterparty)?.let { - PrivateMessageDrainRetryKey(it, receiver.counterpartyReceiverPath) - } - } + .mapNotNull(::normalizedPublicKey) .toSet() return retryKeys.filterTo(mutableSetOf()) { retryKey -> @@ -1191,21 +1085,18 @@ class PrivatePaykitRepo @Inject constructor( } } - private suspend fun recordPublishedPrivatePaymentListCache(publicKey: String, receiverPath: String) { + private suspend fun recordPublishedPrivatePaymentListCache(publicKey: String) { val contactState = ensureState().contacts.getOrPut(publicKey) { ContactState() } - contactState.publishedPrivatePaymentReceiverPaths = - (contactState.publishedPrivatePaymentReceiverPaths + receiverPath).toSortedSet() + contactState.hasPublishedPrivatePaymentList = true } private suspend fun clearPublishedPrivatePaymentListCache( counterparty: String, - receiverPath: String, ): Boolean { val publicKey = normalizedPublicKey(counterparty) ?: return false ensureState().contacts[publicKey]?.let { contactState -> - contactState.publishedPrivatePaymentReceiverPaths = - contactState.publishedPrivatePaymentReceiverPaths.filterTo(mutableSetOf()) { it != receiverPath } - contactState.localInvoicesByReceiverPath = contactState.localInvoicesByReceiverPath - receiverPath + contactState.hasPublishedPrivatePaymentList = false + contactState.localInvoice = null if (!contactState.hasCacheState) { state?.contacts?.remove(publicKey) } @@ -1215,7 +1106,6 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun buildLocalEndpoints( publicKey: String, - receiverPath: String, forceRefreshLightning: Boolean = false, ): Result> = withContext(serializedDispatcher) { runSuspendCatching { @@ -1224,7 +1114,6 @@ class PrivatePaykitRepo @Inject constructor( if (PublicPaykitRepo.isOnchainPaymentOptionEnabled(settings)) { val reservedAddress = addressReservationRepo.currentOrRotatedAddress( publicKey, - receiverPath, ).getOrThrow() walletRepo.refreshReusableReceiveAddressIfReserved().getOrThrow() endpoints += Endpoint( @@ -1237,7 +1126,6 @@ class PrivatePaykitRepo @Inject constructor( if (PublicPaykitRepo.isLightningPaymentOptionEnabled(settings) && lightningRepo.canReceive()) { currentOrRotatedInvoice( publicKey, - receiverPath, forceRefresh = forceRefreshLightning, ).onSuccess { invoice -> endpoints += Endpoint( @@ -1260,18 +1148,17 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun currentOrRotatedInvoice( publicKey: String, - receiverPath: String, forceRefresh: Boolean = false, ): Result = withContext(serializedDispatcher) { runSuspendCatching { - if (!forceRefresh) reusablePrivateInvoice(publicKey, receiverPath)?.let { return@runSuspendCatching it } + if (!forceRefresh) reusablePrivateInvoice(publicKey)?.let { return@runSuspendCatching it } val bolt11 = lightningRepo.createInvoice( amountSats = null, description = "", expirySeconds = privateInvoiceExpiry.inWholeSeconds.toUInt(), ).getOrThrow() - if (!forceRefresh) reusablePrivateInvoice(publicKey, receiverPath)?.let { return@runSuspendCatching it } + if (!forceRefresh) reusablePrivateInvoice(publicKey)?.let { return@runSuspendCatching it } val decoded = (coreService.decode(bolt11) as? Scanner.Lightning)?.invoice ?: throw PublicPaykitError.InvalidPayload @@ -1284,7 +1171,7 @@ class PrivatePaykitRepo @Inject constructor( paymentHash = decoded.paymentHash.toHex(), expiresAt = expiresAt, ) - setLocalInvoice(publicKey, receiverPath, invoice) + setLocalInvoice(publicKey, invoice) persistState() invoice } @@ -1292,9 +1179,8 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun reusablePrivateInvoice( publicKey: String, - receiverPath: String, ): StoredInvoice? { - val invoice = localInvoice(publicKey, receiverPath) ?: return null + val invoice = localInvoice(publicKey) ?: return null val refreshAt = clock.now().epochSeconds + invoiceRefreshBuffer.inWholeSeconds val decoded = (coreService.decode(invoice.bolt11) as? Scanner.Lightning)?.invoice ?: return null val isReusable = invoice.expiresAt > refreshAt && @@ -1307,31 +1193,28 @@ class PrivatePaykitRepo @Inject constructor( private fun privateReservation( publicKey: String, - receiverPath: String, endpoint: Endpoint, ): PrivatePaymentEndpointReservationInput { val contactState = state?.contacts?.get(publicKey) val attribution = if (endpoint.methodId == MethodId.Bolt11) { - val paymentHash = localInvoice(publicKey, receiverPath)?.takeIf { it.bolt11 == endpoint.value }?.paymentHash + val paymentHash = localInvoice(publicKey)?.takeIf { it.bolt11 == endpoint.value }?.paymentHash mapOf( "type" to "private_paykit", "counterparty" to publicKey, - "receiver_path" to receiverPath, ) + listOfNotNull(paymentHash?.let { "payment_hash" to it }).toMap() } else { mapOf( "type" to "private_paykit", "counterparty" to publicKey, - "receiver_path" to receiverPath, ) } val expiresAt = contactState - ?.let { localInvoice(publicKey, receiverPath) } + ?.let { localInvoice(publicKey) } ?.takeIf { endpoint.methodId == MethodId.Bolt11 && it.bolt11 == endpoint.value } ?.let { Instant.ofEpochSecond(it.expiresAt).toString() } return PrivatePaymentEndpointReservationInput( - reservationId = privateReservationId(publicKey, receiverPath, endpoint), + reservationId = privateReservationId(publicKey, endpoint), identifier = endpoint.methodId.rawValue, payload = endpoint.rawPayload, expiresAt = expiresAt, @@ -1339,12 +1222,12 @@ class PrivatePaykitRepo @Inject constructor( ) } - private fun privateReservationId(publicKey: String, receiverPath: String, endpoint: Endpoint): String { + private fun privateReservationId(publicKey: String, endpoint: Endpoint): String { val payloadHashPrefix = MessageDigest.getInstance("SHA-256") .digest(endpoint.rawPayload.toByteArray(Charsets.UTF_8)) .copyOfRange(0, 8) .toHex() - return "$publicKey:$receiverPath:${endpoint.methodId.rawValue}:$payloadHashPrefix" + return "$publicKey:${endpoint.methodId.rawValue}:$payloadHashPrefix" } private suspend fun cacheResolvedPrivateEndpoints(publicKey: String, endpoints: List) { @@ -1354,10 +1237,13 @@ class PrivatePaykitRepo @Inject constructor( } private suspend fun removePublishedEndpoints(): Result = withContext(serializedDispatcher) { - publicationMutex.withLock { - val keys = (knownSavedContactKeys + ensureState().contacts.keys + pendingDeletedContactCleanupPublicKeys()) - .distinct() - removePublishedEndpointsLocked(keys) + runSuspendCatching { + publicationMutex.withLock { + val keys = ( + knownSavedContactKeys + ensureState().contacts.keys + pendingDeletedContactCleanupPublicKeys() + ).distinct() + removePublishedEndpointsLocked(keys).getOrThrow() + } } } @@ -1379,9 +1265,9 @@ class PrivatePaykitRepo @Inject constructor( if (normalizedKeys.isEmpty()) return@runSuspendCatching ensureState() + publicPaykitRepo.syncPaykitApp(privateSharingEnabled = true).getOrThrow() val cleanupStateByPublicKey = normalizedKeys.associateWith(::publishedEndpointCleanupState) - val linkedReceiverPathsSnapshot = linkedReceiverPathsSnapshot("private endpoint cleanup") - val preparation = clearPrivatePaymentLists(normalizedKeys, linkedReceiverPathsSnapshot) + val preparation = clearPrivatePaymentLists(normalizedKeys) val failedPublicKeys = preparation.failedPublicKeys.toMutableSet() var firstError = preparation.firstError @@ -1392,7 +1278,7 @@ class PrivatePaykitRepo @Inject constructor( ) val pendingRetryKeys = pendingPrivateMessageDrainKeys(preparation.clearedRetryKeys) if (pendingRetryKeys.isNotEmpty()) { - failedPublicKeys += pendingRetryKeys.map { it.publicKey } + failedPublicKeys += pendingRetryKeys firstError = firstError ?: PrivatePaykitError.PrivateUnavailable } } @@ -1410,41 +1296,39 @@ class PrivatePaykitRepo @Inject constructor( clearPublishedEndpointCache(normalizedKeys.filterNot { it in failedPublicKeys }) firstError?.let { throw it } + publicPaykitRepo.syncPaykitApp().getOrThrow() } private suspend fun clearPrivatePaymentLists( publicKeys: Collection, - linkedReceiverPathsSnapshot: LinkedReceiverPathsSnapshot, ): PrivateEndpointCleanupPreparation { - val failedPublicKeys = if (linkedReceiverPathsSnapshot.error == null) { - mutableSetOf() - } else { - publicKeys.toMutableSet() - } - val clearedRetryKeys = mutableListOf() - var firstError = linkedReceiverPathsSnapshot.error - + val linkedPublicKeys = paykitSdkService.linkedPeers() + .filter { it.state != LinkedPeerState.NOT_LINKED } + .mapNotNull { normalizedPublicKey(it.counterparty) } + .toSet() + val failedPublicKeys = mutableSetOf() + val clearedRetryKeys = mutableListOf() + var firstError: Throwable? = null publicKeys.forEach { publicKey -> - receiverPathsForCleanup( - publicKey = publicKey, - linkedReceiverPaths = linkedReceiverPathsSnapshot.pathsByPublicKey[publicKey].orEmpty(), - ).forEach { receiverPath -> - runSuspendCatching { - val report = paykitSdkService.clearPrivatePaymentList(publicKey, receiverPath) - ?: return@runSuspendCatching false - if (report.failedToQueue.isNotEmpty() || report.failedToDeliver.isNotEmpty()) { - throw PrivatePaykitError.PrivateUnavailable - } - true - }.onSuccess { - if (it) clearedRetryKeys += PrivateMessageDrainRetryKey(publicKey, receiverPath) - }.onFailure { - failedPublicKeys += publicKey - firstError = firstError ?: it + if (publicKey !in linkedPublicKeys && + state?.contacts?.get(publicKey)?.hasPublishedPrivatePaymentList != true + ) { + return@forEach + } + runSuspendCatching { + val report = paykitSdkService.clearPrivatePaymentList(publicKey) + ?: return@runSuspendCatching false + if (report.failedToQueue.isNotEmpty() || report.failedToDeliver.isNotEmpty()) { + throw PrivatePaykitError.PrivateUnavailable } + true + }.onSuccess { + if (it) clearedRetryKeys += publicKey + }.onFailure { + failedPublicKeys += publicKey + firstError = firstError ?: it } } - return PrivateEndpointCleanupPreparation(clearedRetryKeys, failedPublicKeys, firstError) } @@ -1454,8 +1338,8 @@ class PrivatePaykitRepo @Inject constructor( publicKeys.forEach { publicKey -> state?.contacts?.get(publicKey)?.let { contactState -> contactState.remoteEndpoints = emptyList() - contactState.localInvoicesByReceiverPath = emptyMap() - contactState.publishedPrivatePaymentReceiverPaths = emptySet() + contactState.localInvoice = null + contactState.hasPublishedPrivatePaymentList = false if (!contactState.hasCacheState) { state?.contacts?.remove(publicKey) } @@ -1485,42 +1369,11 @@ class PrivatePaykitRepo @Inject constructor( val contactState = state?.contacts?.get(publicKey) return PublishedEndpointCleanupState( remoteEndpoints = contactState?.remoteEndpoints.orEmpty(), - localInvoicesByReceiverPath = contactState?.localInvoicesByReceiverPath.orEmpty(), - publishedPrivatePaymentReceiverPaths = contactState?.publishedPrivatePaymentReceiverPaths.orEmpty(), + localInvoice = contactState?.localInvoice, + hasPublishedPrivatePaymentList = contactState?.hasPublishedPrivatePaymentList == true, ) } - private suspend fun receiverPathsForSavedContact(publicKey: String): List { - val record = paykitSdkService.contactRecord(publicKey) - val savedPaths = supportedReceiverPaths(record?.receiverPaths.orEmpty()) - - return runSuspendCatching { - val discoveredPaths = pubkyService.discoverRelevantReceiverPaths(publicKey) - val currentRecord = paykitSdkService.contactRecord(publicKey) - ?: return@runSuspendCatching savedPaths - val currentSavedPaths = supportedReceiverPaths(currentRecord.receiverPaths) - val mergedPaths = supportedReceiverPaths(currentSavedPaths + discoveredPaths) - if (mergedPaths == currentSavedPaths) return@runSuspendCatching currentSavedPaths - - val updatedRecord = pubkyService.saveContact(publicKey, currentRecord.label, mergedPaths) - _initialLinkBurstStarted.tryEmit(Unit) - Logger.info("Discovered new Paykit receiver paths for '${redacted(publicKey)}'", context = TAG) - supportedReceiverPaths(updatedRecord.receiverPaths) - }.getOrElse { - if (it is CancellationException) throw it - Logger.warn( - "Failed to refresh Paykit receiver paths for '${redacted(publicKey)}'; using saved paths", - it, - context = TAG, - ) - savedPaths - } - } - - private fun supportedReceiverPaths(receiverPaths: Collection): List = - PaykitReceiverPaths.supported.filter { it in receiverPaths } - .ifEmpty { listOf(PaykitReceiverPaths.WALLET) } - private suspend fun refreshSavedContactEndpointsDuringInitialLinkBurst( publicKeys: Collection, reason: String, @@ -1542,57 +1395,6 @@ class PrivatePaykitRepo @Inject constructor( } } - private fun receiverPathsForPrivateEndpointCleanup( - publicKey: String, - excludedReceiverPaths: List, - linkedReceiverPaths: Collection, - ): List { - val publishedPaths = publishedPrivatePaymentReceiverPaths(publicKey) - return (publishedPaths + linkedReceiverPaths) - .filter { it in PaykitReceiverPaths.supported } - .filterNot { it in excludedReceiverPaths } - .distinct() - .sorted() - } - - private fun receiverPathsForCleanup( - publicKey: String, - linkedReceiverPaths: Collection, - ): List { - return (linkedReceiverPaths + publishedPrivatePaymentReceiverPaths(publicKey)) - .filter { it in PaykitReceiverPaths.supported } - .distinct() - .sorted() - } - - private suspend fun linkedReceiverPathsByPublicKey(): Map> { - val linkedPaths = mutableMapOf>() - paykitSdkService.linkedPeers().forEach { peer -> - val publicKey = normalizedPublicKey(peer.counterparty) ?: return@forEach - if (peer.counterpartyReceiverPath in PaykitReceiverPaths.supported) { - linkedPaths.getOrPut(publicKey, ::mutableSetOf) += peer.counterpartyReceiverPath - } - } - return linkedPaths - } - - private suspend fun linkedReceiverPathsSnapshot(reason: String): LinkedReceiverPathsSnapshot { - repeat(2) { attempt -> - val result = runSuspendCatching { linkedReceiverPathsByPublicKey() } - result.getOrNull()?.let { return LinkedReceiverPathsSnapshot(it, null) } - val error = result.exceptionOrNull() ?: PrivatePaykitError.PrivateUnavailable - val suffix = if (attempt == 0) "; retrying once" else " after retry" - Logger.warn( - "Failed to inspect private Paykit links during '$reason'$suffix", - error, - context = TAG, - ) - if (attempt == 1) return LinkedReceiverPathsSnapshot(emptyMap(), error) - } - - return LinkedReceiverPathsSnapshot(emptyMap(), PrivatePaykitError.PrivateUnavailable) - } - private fun normalizedPublicKeyBatch(publicKeys: Collection): NormalizedPublicKeyBatch { val invalidKeys = mutableSetOf() val normalizedKeys = publicKeys.mapNotNull { publicKey -> @@ -1604,11 +1406,6 @@ class PrivatePaykitRepo @Inject constructor( return NormalizedPublicKeyBatch(normalizedKeys, invalidKeys) } - private fun publishedPrivatePaymentReceiverPaths(publicKey: String): List { - val contactState = state?.contacts?.get(publicKey) ?: return emptyList() - return contactState.publishedPrivatePaymentReceiverPaths.toList() - } - private suspend fun clearUnsavedContactState(savedPublicKeys: Collection): Result = withContext(serializedDispatcher) { runSuspendCatching { @@ -1716,7 +1513,7 @@ class PrivatePaykitRepo @Inject constructor( cacheStore.data.first().cleanupPending private suspend fun hasPublishedPrivateEndpoints(): Boolean = - ensureState().contacts.values.any { it.publishedPrivatePaymentReceiverPaths.isNotEmpty() } + ensureState().contacts.values.any { it.hasPublishedPrivatePaymentList } private suspend fun updateContactSharingCleanupPending(isPending: Boolean) { cacheStore.update { it.copy(cleanupPending = isPending) } @@ -1767,7 +1564,7 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun settledPrivateInvoicePaymentHashes(): List { val settled = receivedSettledPaymentHashes() return ensureState().contacts.values - .flatMap { it.localInvoices() } + .mapNotNull { it.localInvoice } .map { it.paymentHash } .filter(settled::contains) } @@ -1816,18 +1613,14 @@ class PrivatePaykitRepo @Inject constructor( persistState() } - private fun localInvoice(publicKey: String, receiverPath: String): StoredInvoice? { + private fun localInvoice(publicKey: String): StoredInvoice? { val contactState = state?.contacts?.get(publicKey) ?: return null - return contactState.localInvoicesByReceiverPath[receiverPath] + return contactState.localInvoice } - private suspend fun setLocalInvoice(publicKey: String, receiverPath: String, invoice: StoredInvoice) { + private suspend fun setLocalInvoice(publicKey: String, invoice: StoredInvoice) { val contactState = ensureState().contacts.getOrPut(publicKey) { ContactState() } - contactState.localInvoicesByReceiverPath = contactState.localInvoicesByReceiverPath + (receiverPath to invoice) - } - - private fun ContactState.localInvoices(): List { - return localInvoicesByReceiverPath.values.toList() + contactState.localInvoice = invoice } private fun rememberSavedContacts(publicKeys: Collection, replacing: Boolean): List { diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 97da3d121b..8cdb5b6fd8 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -3,8 +3,8 @@ package to.bitkit.repositories import android.graphics.Bitmap import android.graphics.BitmapFactory import coil3.ImageLoader -import com.synonym.paykit.ContactProfileResolution import com.synonym.paykit.PaykitProfile +import com.synonym.paykit.ProfileResolution import com.synonym.paykit.PubkyAuthCompanionClaim import io.ktor.client.HttpClient import io.ktor.client.call.body @@ -46,13 +46,13 @@ import to.bitkit.ext.runSuspendCatching import to.bitkit.models.HomegateResponse import to.bitkit.models.PubkyAuthClaim import to.bitkit.models.PubkyAuthRequest +import to.bitkit.models.PubkyAuthRequestError import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyProfileData import to.bitkit.models.PubkyProfileLink import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.PubkySessionBackupKind import to.bitkit.models.PubkySessionBackupV1 -import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PubkyService import to.bitkit.utils.AppError import to.bitkit.utils.Logger @@ -902,7 +902,6 @@ class PubkyRepo @Inject constructor( pubkyService.saveContact( prefixedKey, profile.name, - relevantReceiverPaths(prefixedKey), restorePrivateConnection = true, ) updateContacts { current -> @@ -914,16 +913,6 @@ class PubkyRepo @Inject constructor( } } - suspend fun refreshContactReceiverPaths(publicKey: String): Result = runSuspendCatching { - withContext(ioDispatcher) { - val prefixedKey = requireAddableContactPublicKey(publicKey = publicKey, allowExisting = true) - val contact = _contacts.value.firstOrNull { PubkyPublicKeyFormat.matches(it.publicKey, prefixedKey) } - ?: return@withContext - pubkyService.saveContact(prefixedKey, contact.name, relevantReceiverPaths(prefixedKey)) - Logger.info("Refreshed contact receiver paths for '${redacted(prefixedKey)}'", context = TAG) - } - } - @Suppress("LongParameterList") suspend fun updateContact( publicKey: String, @@ -977,7 +966,6 @@ class PubkyRepo @Inject constructor( for (profile in profiles.distinctBy { it.publicKey }) { if (profile.publicKey in existing) continue runSuspendCatching { - // The preview already resolved this profile. Receiver discovery runs during contact refresh. pubkyService.saveContact(profile.publicKey, profile.name, restorePrivateConnection = true) imported.add(profile) existing.add(profile.publicKey) @@ -1139,17 +1127,21 @@ class PubkyRepo @Inject constructor( unsignedPayload: ByteArray, ): Result = runSuspendCatching { withContext(ioDispatcher) { + val claim = PubkyAuthRequest.parseBitkitClaim( + authUrl, + PubkyAuthClaim.REQUIRED_CAPABILITIES, + ).getOrThrow() ?: throw PubkyAuthRequestError.MissingBitkitClaim val secretKeyHex = requireNotNull(activeSecretKeyHex()) { "No secret key available — use Ring to manage authorizations" } pubkyService.approveAuthWithCompanionClaim( authUrl = authUrl, - expectedCapabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES, + expectedCapabilities = PubkyAuthClaim.REQUIRED_CAPABILITIES, approvedClientId = approvedClientId, secretKeyHex = secretKeyHex, claim = PubkyAuthCompanionClaim( queryParameter = PubkyAuthClaim.QUERY_PARAMETER, - claimType = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue, + claimType = claim.wireValue, unsignedPayload = unsignedPayload, ), ) @@ -1364,7 +1356,7 @@ class PubkyRepo @Inject constructor( } } - private fun profileFromResolution(resolution: ContactProfileResolution): PubkyProfile { + private fun profileFromResolution(resolution: ProfileResolution): PubkyProfile { val prefixedKey = resolution.publicKey.ensurePubkyPrefix() resolution.paykitProfile?.let { return PubkyProfile.fromPaykitProfile(prefixedKey, it) @@ -1379,14 +1371,6 @@ class PubkyRepo @Inject constructor( ) } - private suspend fun relevantReceiverPaths(publicKey: String): List = - runSuspendCatching { - pubkyService.discoverRelevantReceiverPaths(publicKey) - }.onFailure { - Logger.warn("Failed to discover Paykit receivers for '${redacted(publicKey)}'", it, context = TAG) - }.getOrNull() - ?: listOf(PaykitReceiverPaths.WALLET) - private suspend fun upsertContactProfileOverride(profile: PubkyProfile) { val prefixedKey = profile.publicKey.ensurePubkyPrefix() val ownerPublicKey = requireNotNull(_publicKey.value) { "Pubky identity unavailable" } @@ -1531,20 +1515,11 @@ class PubkyRepo @Inject constructor( settingsStore.setPubkyProfileSetupPending(false) } - private fun requireAddableContactPublicKey(publicKey: String, allowExisting: Boolean = false): String { - val prefixedKey = PubkyPublicKeyFormat.normalized(publicKey) - return requireValidAddableContactPublicKey(prefixedKey, allowExisting) - } - private fun requireCanonicalAddableContactPublicKey( publicKey: String, allowExisting: Boolean = false, ): String { val prefixedKey = PubkyPublicKeyFormat.canonicalized(publicKey) - return requireValidAddableContactPublicKey(prefixedKey, allowExisting) - } - - private fun requireValidAddableContactPublicKey(prefixedKey: String?, allowExisting: Boolean): String { contactValidationError(prefixedKey, allowExisting)?.let { throw it } return checkNotNull(prefixedKey) { "Normalized pubky key is required" } } diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index 8c309ae7b5..1cc5007b10 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.lightningdevkit.ldknode.Bolt11Invoice import org.lightningdevkit.ldknode.Network +import to.bitkit.data.PrivatePaykitCacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher @@ -19,7 +20,6 @@ import to.bitkit.ext.toHex import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.toLdkNetwork import to.bitkit.services.CoreService -import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PaykitSdkService import to.bitkit.utils.AppError import to.bitkit.utils.Logger @@ -85,8 +85,8 @@ internal val PublicPaykitPaymentResult.incomingPaymentRequestFailureReason: } data class PrivatePaykitPaymentContext( - val receiverPath: String, - val paymentListVersion: ULong, + val paymentAppsByEndpoint: Map, + val paymentListVersion: ULong?, ) @OptIn(ExperimentalTime::class) @@ -100,6 +100,7 @@ class PublicPaykitRepo @Inject constructor( private val coreService: CoreService, private val paykitSdkService: PaykitSdkService, private val settingsStore: SettingsStore, + private val privatePaykitCacheStore: PrivatePaykitCacheStore, private val clock: Clock, ) { companion object { @@ -213,18 +214,18 @@ class PublicPaykitRepo @Inject constructor( runSuspendCatching { if (!publish) { val endpointError = runSuspendCatching { removePublishedEndpoints() }.exceptionOrNull() - val markerError = syncLocalReceiverMarker(publicSharingEnabled = false).exceptionOrNull() + val appError = syncPaykitApp().exceptionOrNull() if (endpointError != null) { - markerError?.let(endpointError::addSuppressed) + appError?.let(endpointError::addSuppressed) throw endpointError } - markerError?.let { throw it } + appError?.let { throw it } settingsStore.update { it.copy(publicPaykitCleanupPending = false) } return@runSuspendCatching } val desired = buildWalletEndpoints(refresh = true) - syncLocalReceiverMarker(publicSharingEnabled = true).getOrThrow() + syncPaykitApp().getOrThrow() applyPublishedEndpoints(desired) settingsStore.update { it.copy(publicPaykitCleanupPending = false) } } @@ -240,7 +241,7 @@ class PublicPaykitRepo @Inject constructor( forceRefreshLightning = forceRefreshLightning, requireEndpoint = requireEndpoint, ) - syncLocalReceiverMarker(publicSharingEnabled = true).getOrThrow() + syncPaykitApp().getOrThrow() applyPublishedEndpoints(desired) settingsStore.update { it.copy(publicPaykitCleanupPending = false) } } @@ -263,25 +264,22 @@ class PublicPaykitRepo @Inject constructor( val normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) ?: publicKey paykitSdkService.resolvePublicContactPayment( counterparty = normalizedKey, - receiverPath = PaykitReceiverPaths.WALLET, ).payableEndpoints - .mapNotNull { parseEndpoint(it.identifier, it.payload) } - .associateBy { it.methodId } - .values + .mapNotNull { parseEndpoint(it.identifier, it.payload)?.copy(appId = it.appId) } .sortedBy { endpoint -> payablePreferenceOrder.indexOf(endpoint.methodId) } } } - suspend fun syncLocalReceiverMarker( - publicSharingEnabled: Boolean? = null, + suspend fun syncPaykitApp( privateSharingEnabled: Boolean? = null, ): Result = withContext(ioDispatcher) { runSuspendCatching { val settings = settingsStore.data.first() - val publicSharing = publicSharingEnabled ?: settings.sharesPublicPaykitEndpoints val privateSharing = privateSharingEnabled ?: settings.sharesPrivatePaykitEndpoints - paykitSdkService.syncLocalReceiverMarker( - isDiscoverable = publicSharing || privateSharing, + val privateCache = privatePaykitCacheStore.data.first() + paykitSdkService.syncPaykitApp( + privatePaymentsEnabled = privateSharing || privateCache.cleanupPending || + privateCache.deletedContactCleanupPendingPublicKeys.isNotEmpty(), ) } } @@ -454,6 +452,7 @@ data class Endpoint( val min: String? = null, val max: String? = null, val rawPayload: String, + val appId: String? = null, ) { val paymentRequest: String get() = value diff --git a/app/src/main/java/to/bitkit/repositories/WatchOnlyAccountRepo.kt b/app/src/main/java/to/bitkit/repositories/WatchOnlyAccountRepo.kt index 671dfc25ab..36bdc5b643 100644 --- a/app/src/main/java/to/bitkit/repositories/WatchOnlyAccountRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WatchOnlyAccountRepo.kt @@ -15,6 +15,7 @@ import to.bitkit.ext.nowMillis import to.bitkit.ext.runSuspendCatching import to.bitkit.models.PreparedWatchOnlyAccountClaim import to.bitkit.models.PubkyAuthClaim +import to.bitkit.models.PubkyAuthClaimCodec import to.bitkit.models.WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX import to.bitkit.models.WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE import to.bitkit.models.WATCH_ONLY_ACCOUNT_SERIALIZED_XPUB_LENGTH @@ -328,7 +329,7 @@ object WatchOnlyAccountClaimCodec { const val VERSION: Byte = 1 const val NATIVE_SEGWIT_ADDRESS_TYPE: Byte = 0 const val SERIALIZED_XPUB_LENGTH = WATCH_ONLY_ACCOUNT_SERIALIZED_XPUB_LENGTH - const val PAYLOAD_LENGTH = 1 + 4 + 1 + SERIALIZED_XPUB_LENGTH + const val PAYLOAD_LENGTH = PubkyAuthClaimCodec.WATCH_ONLY_PAYLOAD_LENGTH fun encode( account: WatchOnlyAccountRecord, diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 9d4ab907c8..bc798bdc73 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -100,6 +100,7 @@ import to.bitkit.models.msatFloorOf import to.bitkit.models.toAddressType import to.bitkit.models.toCoreNetwork import to.bitkit.models.toSettingsString +import to.bitkit.repositories.PaykitReceivedPaymentContacts import to.bitkit.repositories.PrivatePaykitContactResolver import to.bitkit.utils.AppError import to.bitkit.utils.Logger @@ -567,6 +568,53 @@ class ActivityService( updateActivity(activityId = id, activity = activity) } + suspend fun backfillPaykitContacts(): Boolean = ServiceQueue.CORE.background { + val resolver = privatePaykitContactResolver.get() + val contacts = resolver.receivedPaymentContacts + if (contacts === PaykitReceivedPaymentContacts.Empty) return@background false + val activities = getActivities( + walletId = null, + filter = ActivityFilter.ALL, + txType = PaymentType.RECEIVED, + tags = null, + search = null, + minDate = null, + maxDate = null, + limit = null, + sortDirection = null, + ) + var changed = false + for (activity in activities) { + if (resolver.receivedPaymentContacts !== contacts) break + val contact = when (activity) { + is Activity.Lightning -> receivedPaykitContact(activity.v1, contacts) + is Activity.Onchain -> receivedPaykitContact(activity.v1, contacts) + } + if (contact != null && resolver.receivedPaymentContacts === contacts) { + val updated = when (activity) { + is Activity.Lightning -> Activity.Lightning(activity.v1.copy(contact = contact)) + is Activity.Onchain -> Activity.Onchain(activity.v1.copy(contact = contact)) + } + updateActivity(activityId = activity.rawId(), activity = updated) + changed = true + } + } + changed + } + + private fun receivedPaykitContact(row: LightningActivity, contacts: PaykitReceivedPaymentContacts): String? { + if (row.contact != null || row.txType != PaymentType.RECEIVED || row.status == PaymentState.FAILED) return null + return contacts.contactsForPaymentHash(row.id).singleOrNull() + } + + private fun receivedPaykitContact(row: OnchainActivity, contacts: PaykitReceivedPaymentContacts): String? { + if (row.contact != null || row.txType != PaymentType.RECEIVED) return null + val details = getBitkitCoreTransactionDetails(walletId = row.walletId, txId = row.txId) ?: return null + if (details.outputs.isEmpty()) return null + val addresses = listOfNotNull(row.address) + details.outputs.mapNotNull { it.scriptpubkeyAddress } + return contacts.contactsForAddresses(addresses).singleOrNull() + } + suspend fun delete(id: String, walletId: String = defaultWalletId): Boolean = ServiceQueue.CORE.background { deleteActivityById(walletId = walletId, activityId = id) } @@ -784,7 +832,9 @@ class ActivityService( val contact = existingActivity ?.takeIf { it is Activity.Lightning } ?.let { (it as Activity.Lightning).v1.contact } - ?: privatePaykitContactPublicKeyForReceivedInvoicePaymentHash(payment.id, payment.direction) + ?: payment.takeUnless { it.status == PaymentStatus.FAILED }?.let { + privatePaykitContactPublicKeyForReceivedInvoicePaymentHash(it.id, it.direction) + } val ln = if (existingActivity is Activity.Lightning) { existingActivity.v1.withPaymentUpdate( @@ -1202,8 +1252,14 @@ class ActivityService( val resolvedAddress = resolveAddressForInboundPayment(kind, payment, transactionDetails) val existingContact = existingOnchainActivity?.v1?.contact - val contact = existingContact ?: if (payment.direction == PaymentDirection.INBOUND) { - resolvedAddress?.let { privatePaykitContactPublicKeyForReservedAddress(it) } + val contact = existingContact ?: if ( + payment.direction == PaymentDirection.INBOUND && payment.status != PaymentStatus.FAILED && + !transactionDetails?.outputs.isNullOrEmpty() + ) { + privatePaykitContactResolver.get().contactPublicKeyForPrivateOnchainAddresses( + listOfNotNull(resolvedAddress) + + transactionDetails.outputs.mapNotNull { it.scriptpubkeyAddress }, + ) } else { null } diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 59bada6697..5d91bce963 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -1,22 +1,19 @@ package to.bitkit.services import android.content.Context -import com.synonym.bitkitcore.mnemonicToSeed -import com.synonym.paykit.ContactProfileResolution import com.synonym.paykit.ContactRecord import com.synonym.paykit.ContactUpdate -import com.synonym.paykit.CounterpartyReceiver import com.synonym.paykit.EndpointSyncReport import com.synonym.paykit.IdentityStatus import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState import com.synonym.paykit.OutboundPrivateCounterpartySendReport import com.synonym.paykit.PaykitAndroid +import com.synonym.paykit.PaykitAppCapabilities import com.synonym.paykit.PaykitException +import com.synonym.paykit.PaykitIdentitySecretKey import com.synonym.paykit.PaykitProfile import com.synonym.paykit.PaykitProfileRecord -import com.synonym.paykit.PaykitReceiverCapabilities -import com.synonym.paykit.PaykitReceiverMarker import com.synonym.paykit.PaykitSdk import com.synonym.paykit.PaykitSdkDefaults import com.synonym.paykit.PaymentAmountContext @@ -26,6 +23,7 @@ import com.synonym.paykit.PaymentReference import com.synonym.paykit.PaymentRequestAmount import com.synonym.paykit.PaymentRequestFilter import com.synonym.paykit.PaymentRequestLifecycleState +import com.synonym.paykit.PaymentRequestLocalRole import com.synonym.paykit.PaymentRequestRecord import com.synonym.paykit.PaymentRequestRecurrence import com.synonym.paykit.PaymentRequestTerms @@ -43,8 +41,10 @@ import com.synonym.paykit.PrivateReceivingDetail import com.synonym.paykit.PrivateReceivingDetailReservationResponse import com.synonym.paykit.PrivateReceivingDetailReservationResponseKind import com.synonym.paykit.PrivateStreamCounterpartyIntakeReport +import com.synonym.paykit.ProfileResolution import com.synonym.paykit.PubkyAuthCompanionClaim import com.synonym.paykit.PubkyClientConfig +import com.synonym.paykit.PubkyIdentityCapability import com.synonym.paykit.PubkyLocalSecretKey import com.synonym.paykit.PubkyProfile import com.synonym.paykit.PubkySessionAccess @@ -54,16 +54,10 @@ import com.synonym.paykit.PublicContactPaymentResolution import com.synonym.paykit.PublicPaymentEndpointCandidate import com.synonym.paykit.PublicPaymentEndpointSelectionRequest import com.synonym.paykit.PublicReceivingDetail -import com.synonym.paykit.ReceiverNoiseSecretKey import com.synonym.paykit.SdkPaymentAdapter import com.synonym.paykit.SdkPubkySessionProvider -import com.synonym.paykit.SdkStateBlob -import com.synonym.paykit.SdkStateBlobSnapshot -import com.synonym.paykit.SdkStateBlobStore -import com.synonym.paykit.decodeSdkStateBlobSnapshot import com.synonym.paykit.defaultConfig import com.synonym.paykit.defaultPubkyClientConfig -import com.synonym.paykit.encodeSdkStateBlobSnapshot import com.synonym.paykit.parsePubkyAuthUrl import com.synonym.paykit.pubkyPublicKeyFromSecret import com.synonym.paykit.pubkySecretKeyFromBip39Mnemonic @@ -97,6 +91,9 @@ import to.bitkit.ext.fromHex import to.bitkit.ext.nowMillis import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.toHex +import to.bitkit.models.PubkyAuthClaim +import to.bitkit.models.PubkyAuthClaimCodec +import to.bitkit.models.PubkyAuthRequest import to.bitkit.models.PubkyAuthRequestError import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.repositories.Endpoint @@ -106,10 +103,6 @@ import to.bitkit.repositories.PubkyContactError import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.utils.AppError import to.bitkit.utils.Logger -import java.security.MessageDigest -import java.util.UUID -import javax.crypto.Mac -import javax.crypto.spec.SecretKeySpec import javax.inject.Inject import javax.inject.Singleton import kotlin.coroutines.cancellation.CancellationException @@ -134,6 +127,7 @@ data class PaykitPublicContactPaymentResolution( ) data class PaykitResolvedPaymentEndpoint( + val appId: String, val identifier: String, val payload: String, ) @@ -155,22 +149,6 @@ data class PaykitPaymentRequestRecurrenceTerms( val endsAt: String? = null, ) -data class PaykitPrivateReceiverPathSelection( - val linkableReceiverPaths: List, - val publishableReceiverPaths: List, - val cleanupProtectedReceiverPaths: List, - val error: Throwable?, -) - -internal object PaykitReceiverPaths { - const val WALLET = "bitkit/wallet" - const val SERVER = "bitkit/server" - - /** Current Bitkit flows only route its own receivers; cross-wallet routing can broaden this allowlist. */ - val ordered = listOf(WALLET, SERVER) - val supported = ordered.toSet() -} - @Singleton @Suppress("TooManyFunctions", "LargeClass") class PaykitSdkService @Inject constructor( @@ -180,7 +158,6 @@ class PaykitSdkService @Inject constructor( sharedPubky: SharedPubkyClient, @IoDispatcher ioDispatcher: CoroutineDispatcher, ) : BaseCoroutineScope(ioDispatcher, TAG) { - private val stateStore = PaykitSdkStateBlobStore(keychain) private val sessionProvider = PaykitSdkSessionProvider(keychain, sharedPubky) private val paymentAdapter = PaykitSdkPaymentAdapter() private val pubkyClientConfig by lazy { paykitPubkyClientConfig() } @@ -205,8 +182,7 @@ class PaykitSdkService @Inject constructor( private val _backupStateVersion = MutableStateFlow(0L) val backupStateVersion: StateFlow = _backupStateVersion.asStateFlow() private var sdkFactory: () -> PaykitSdk = { - PaykitSdk.withPaymentAdapterAndPubkyClientConfig( - stateStore = stateStore, + PaykitSdk.withPaymentAdapterAndPubkySharedStateAndClientConfig( sessionProvider = sessionProvider, paymentAdapter = paymentAdapter, config = paykitSdkConfig(), @@ -247,6 +223,7 @@ class PaykitSdkService @Inject constructor( platformInitializer() launch { republishIdentityIfNeeded() } operationLock.withLock { + refreshPaykitKey() var handle = handle() try { handle.initialize() @@ -267,7 +244,7 @@ class PaykitSdkService @Inject constructor( sessionProvider.resumeStoredSessionAccess() } } - publishReceiverMarkerIfLiveSessionAvailable(handle) + publishAppIfLiveSessionAvailable(handle) } isSetup.complete(Unit) } catch (t: Throwable) { @@ -324,33 +301,32 @@ class PaykitSdkService @Inject constructor( suspend fun currentPublicKey(): String? { isSetup.await() return operationLock.withLock { + refreshPaykitKey() val handle = handle() handle.identityStatus()?.publicKey?.let { return@withLock it } - handle.initialize().identity.publicKey + handle.initialize().publicKey } } suspend fun hasPrivatePaymentAccess(): Boolean { isSetup.await() return operationLock.withLock { - handle().identityStatus()?.liveSessionAvailable == true + refreshPaykitKey() + handle().identityStatus()?.capability == PubkyIdentityCapability.PRIVATE_LINK_CAPABLE } } suspend fun importSession(secret: String): PubkySessionBootstrapResult { isSetup.await() return operationLock.withLock { - val previousPublicKey = currentSdkStatePublicKeyLocked() val result = bootstrap().importSession( sessionSecret = secret, localSecretKey = sessionProvider.loadLocalSecretKey(), - receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey(), - requiredCapabilities = requiredSessionCapabilities(paykitSdkConfig()), + requiredCapabilities = requiredSessionCapabilities(), ) activateBootstrapResult( result = result, - previousPublicKey = previousPublicKey, ) notifyBackupStateChanged() @@ -365,10 +341,8 @@ class PaykitSdkService @Inject constructor( ): PubkySessionBootstrapResult { isSetup.await() return operationLock.withLock { - val previousPublicKey = currentSdkStatePublicKeyLocked() val result = bootstrap().signUp( localSecretKey = localSecretKey(secretKeyHex), - receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey(), homeserverPublicKey = homeserverPublicKey, signupCode = signupCode, requiredCapabilities = requiredCapabilities(), @@ -376,7 +350,6 @@ class PaykitSdkService @Inject constructor( activateBootstrapResult( result = result, - previousPublicKey = previousPublicKey, ) notifyBackupStateChanged() @@ -393,7 +366,6 @@ class PaykitSdkService @Inject constructor( return operationLock.withLock { bootstrap().signUp( localSecretKey = localSecretKey(secretKeyHex), - receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey(), homeserverPublicKey = homeserverPublicKey, signupCode = signupCode, requiredCapabilities = requiredCapabilities(), @@ -404,13 +376,10 @@ class PaykitSdkService @Inject constructor( suspend fun activateRegisteredIdentity(result: PubkySessionBootstrapResult) { isSetup.await() return operationLock.withLock { - val previousPublicKey = currentSdkStatePublicKeyLocked() - var activated = false try { activateBootstrapResult( result = result, - previousPublicKey = previousPublicKey, ) activated = true } finally { @@ -424,16 +393,13 @@ class PaykitSdkService @Inject constructor( suspend fun signIn(secretKeyHex: String): PubkySessionBootstrapResult { isSetup.await() return operationLock.withLock { - val previousPublicKey = currentSdkStatePublicKeyLocked() val result = bootstrap().signIn( localSecretKey = localSecretKey(secretKeyHex), - receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey(), requiredCapabilities = requiredCapabilities(), ) activateBootstrapResult( result = result, - previousPublicKey = previousPublicKey, ) notifyBackupStateChanged() @@ -466,11 +432,27 @@ class PaykitSdkService @Inject constructor( ) { isSetup.await() return operationLock.withLock { + val requestedClaim = PubkyAuthRequest.parseBitkitClaim(authUrl, expectedCapabilities).getOrThrow() + ?: throw PubkyAuthRequestError.MissingBitkitClaim + require( + claim.queryParameter == PubkyAuthClaim.QUERY_PARAMETER && claim.claimType == requestedClaim.wireValue + ) { + "Companion claim does not match the authorization request" + } + PubkyAuthClaimCodec.validateAccountPayload(requestedClaim, claim.unsignedPayload) + val paykitKey = if (requestedClaim.includesPaykitAccess) paykitKey(localSecretKey(secretKeyHex)) else null approvalBootstrap(authUrl, approvedClientId).approveAuthWithCompanionClaim( authUrl = authUrl, expectedCapabilities = expectedCapabilities, localSecretKey = localSecretKey(secretKeyHex), - claim = claim, + claim = claim.copy( + unsignedPayload = PubkyAuthClaimCodec.encode( + claim = requestedClaim, + accountPayload = claim.unsignedPayload, + generation = paykitKey?.keyGeneration(), + secret = paykitKey?.exportBytes(), + ), + ), ) } } @@ -485,7 +467,11 @@ class PaykitSdkService @Inject constructor( suspend fun publishPaykitProfile(profile: PaykitProfile): PaykitProfileRecord { isSetup.await() return operationLock.withLock { - handle().publishPaykitProfile(profile).also { + refreshPaykitKey() + val handle = handle() + val publicKey = requireNotNull(handle.identityStatus()?.publicKey) + val current = handle.fetchPaykitProfile(publicKey) + handle.publishPaykitProfile(profile, current?.revision).also { notifyBackupStateChanged() } } @@ -497,7 +483,7 @@ class PaykitSdkService @Inject constructor( if (expectedIdentity != null) { val identityStatus = handle().identityStatus() check( - identityStatus?.liveSessionAvailable == true && + identityStatus?.capability == PubkyIdentityCapability.PRIVATE_LINK_CAPABLE && PubkyPublicKeyFormat.matches(identityStatus.publicKey, expectedIdentity) ) { "Paykit identity changed before uploading the subscription icon" } } @@ -510,7 +496,10 @@ class PaykitSdkService @Inject constructor( suspend fun deletePaykitProfile() { isSetup.await() operationLock.withLock { - handle().deletePaykitProfile() + refreshPaykitKey() + val handle = handle() + val publicKey = requireNotNull(handle.identityStatus()?.publicKey) + handle.fetchPaykitProfile(publicKey)?.let { handle.deletePaykitProfile(it.revision) } notifyBackupStateChanged() } } @@ -525,13 +514,14 @@ class PaykitSdkService @Inject constructor( suspend fun fetchPubkyFollows(publicKey: String): List { isSetup.await() return operationLock.withLock { - handle().fetchPubkyFollows(publicKey) + handle().fetchPubkyFollows(publicKey, maxEntries = 1000u) } } suspend fun contactRecords(): List { isSetup.await() return operationLock.withLock { + refreshPaykitKey() handle().contactRecords() } } @@ -539,6 +529,7 @@ class PaykitSdkService @Inject constructor( suspend fun contactRecord(publicKey: String): ContactRecord? { isSetup.await() return operationLock.withLock { + refreshPaykitKey() handle().contactRecord(publicKey) } } @@ -546,7 +537,6 @@ class PaykitSdkService @Inject constructor( suspend fun saveContact( publicKey: String, label: String?, - receiverPaths: List? = null, restorePrivateConnection: Boolean = false, ): ContactRecord { isSetup.await() @@ -554,9 +544,7 @@ class PaykitSdkService @Inject constructor( withStateRevisionTracking { handle -> val existing = handle.contactRecord(publicKey) check(restorePrivateConnection || existing != null) { "Contact no longer exists" } - val existingPaths = existing?.receiverPaths.orEmpty() - val contactPaths = mergedReceiverPaths(existingPaths + receiverPaths.orEmpty()) - val update = ContactUpdate(publicKey, contactPaths, label) + val update = ContactUpdate(publicKey, label) if (!restorePrivateConnection) return@withStateRevisionTracking handle.saveContact(update) val blockedPeers = handle.linkedPeers().filter { it.state == LinkedPeerState.BLOCKED && PubkyPublicKeyFormat.matches(it.counterparty, publicKey) @@ -570,12 +558,9 @@ class PaykitSdkService @Inject constructor( isSetup.await() return operationLock.withLock { withStateRevisionTracking { handle -> - val record = handle.contactRecord(publicKey) val peers = handle.linkedPeers().filter { PubkyPublicKeyFormat.matches(it.counterparty, publicKey) } - val receiverPaths = - (record?.receiverPaths.orEmpty() + peers.map { it.counterpartyReceiverPath }).distinct() val now = nowMillis() val hasActiveSubscription = handle.paymentRequests().any { val endsAt = it.terms?.recurrence?.endsAt?.let { timestamp -> @@ -590,7 +575,6 @@ class PaykitSdkService @Inject constructor( runSuspendCatching { val report = handle.clearPrivatePaymentListAndProcessOutbound( publicKey, - peer.counterpartyReceiverPath, ) if (report.failedToQueue.isNotEmpty() || report.failedToDeliver.isNotEmpty()) { Logger.warn("Failed to withdraw private endpoints before contact deletion", context = TAG) @@ -599,7 +583,7 @@ class PaykitSdkService @Inject constructor( Logger.warn("Failed to withdraw private endpoints before contact deletion", it, context = TAG) } } - receiverPaths.forEach { handle.blockPeer(publicKey, it) } + peers.forEach { handle.blockPeer(publicKey) } handle.removeContact(publicKey) } } @@ -608,76 +592,24 @@ class PaykitSdkService @Inject constructor( suspend fun resolveContactProfile( publicKey: String, allowPubkyProfileFallback: Boolean, - ): ContactProfileResolution? { - isSetup.await() - return operationLock.withLock { - handle().resolveContactProfile(publicKey, PaykitReceiverPaths.WALLET, allowPubkyProfileFallback) - } - } - - suspend fun discoverRelevantReceiverPaths(publicKey: String): List { - isSetup.await() - return operationLock.withLock { - val handle = handle() - val discovered = handle.paykitReceiverPaths(publicKey) - .filter { it in PaykitReceiverPaths.supported } - .filter { - it == PaykitReceiverPaths.WALLET || - handle.paykitReceiverMarker(publicKey, it)?.requiresPrivateLink() == true - } - mergedReceiverPaths(discovered) - } - } - - suspend fun privateReceiverPathSelection( - publicKey: String, - savedReceiverPaths: List, - ): PaykitPrivateReceiverPathSelection { + ): ProfileResolution? { isSetup.await() return operationLock.withLock { - val handle = handle() - val linkable = mutableListOf() - val publishable = mutableListOf() - val cleanupProtected = mutableListOf() - var firstError: Throwable? = null - - mergedReceiverPaths(savedReceiverPaths).forEach { receiverPath -> - runSuspendCatching { handle.paykitReceiverMarker(publicKey, receiverPath) } - .onSuccess { marker -> - if (marker?.requiresPrivateLink() == true) { - linkable += receiverPath - } - if (marker.canReceivePrivatePaymentDetails()) { - publishable += receiverPath - } - } - .onFailure { - cleanupProtected += receiverPath - firstError = firstError ?: it - } - } - - PaykitPrivateReceiverPathSelection( - linkableReceiverPaths = linkable, - publishableReceiverPaths = publishable, - cleanupProtectedReceiverPaths = cleanupProtected, - error = firstError, - ) + handle().resolveProfile(publicKey, allowPubkyProfileFallback) } } - suspend fun syncLocalReceiverMarker( - isDiscoverable: Boolean, - ) { + suspend fun syncPaykitApp(privatePaymentsEnabled: Boolean) { isSetup.await() operationLock.withLock { withStateRevisionTracking { handle -> - if (!isDiscoverable) { - handle.removePaykitReceiverMarker() - return@withStateRevisionTracking - } - - handle.publishPaykitReceiverMarker(receiverCapabilities(handle)) + val capabilities = appCapabilities(handle) + handle.publishPaykitApp( + displayName = "Bitkit", + capabilities = capabilities.copy( + privatePayments = capabilities.privatePayments && privatePaymentsEnabled + ), + ) } } } @@ -691,7 +623,7 @@ class PaykitSdkService @Inject constructor( } } - fun requiredCapabilities(): String = requiredSessionCapabilities(paykitSdkConfig()) + fun requiredCapabilities(): String = requiredSessionCapabilities() suspend fun syncPrivatePaymentListsWithReservations( updates: List, @@ -710,20 +642,18 @@ class PaykitSdkService @Inject constructor( suspend fun ensureLinkWithPeer( counterparty: String, - receiverPath: String, maxAdvanceSteps: UInt = 8u, ) = run { isSetup.await() operationLock.withLock { withStateRevisionTracking { handle -> - handle.ensureLinkWithPeer(counterparty, receiverPath, maxAdvanceSteps) + handle.ensureLinkWithPeer(counterparty, maxAdvanceSteps) } } } suspend fun clearPrivatePaymentList( counterparty: String, - receiverPath: String, ): PrivatePaymentListDeliveryReport? { isSetup.await() return operationLock.withLock { @@ -731,13 +661,12 @@ class PaykitSdkService @Inject constructor( if ( handle.linkedPeers().any { it.state == LinkedPeerState.BLOCKED && - PubkyPublicKeyFormat.matches(it.counterparty, counterparty) && - it.counterpartyReceiverPath == receiverPath + PubkyPublicKeyFormat.matches(it.counterparty, counterparty) } ) { return@withStateRevisionTracking null } - handle.clearPrivatePaymentListAndProcessOutbound(counterparty, receiverPath) + handle.clearPrivatePaymentListAndProcessOutbound(counterparty) } } } @@ -760,13 +689,21 @@ class PaykitSdkService @Inject constructor( } } - suspend fun paymentRequests(): List { + suspend fun paymentRequests(): List = allPaymentRequests().filter(::isBitkitPaymentRequest) + + suspend fun allPaymentRequests(expectedIdentity: String? = null): List { isSetup.await() return operationLock.withLock { - handle().listPaymentRequests( + refreshPaykitKey() + val handle = handle() + if (expectedIdentity != null) { + check(PubkyPublicKeyFormat.matches(handle.identityStatus()?.publicKey, expectedIdentity)) { + "Payment Request identity changed" + } + } + handle.listPaymentRequests( PaymentRequestFilter( counterparty = null, - counterpartyReceiverPath = null, localRole = null, states = emptyList(), recurring = null, @@ -779,23 +716,22 @@ class PaykitSdkService @Inject constructor( suspend fun identityStatus(): IdentityStatus? { isSetup.await() return operationLock.withLock { + refreshPaykitKey() handle().identityStatus() } } - suspend fun paymentRequestReceiverPaths(publicKey: String): List { + suspend fun canReceivePaymentRequests(publicKey: String): Boolean { isSetup.await() return operationLock.withLock { - val handle = handle() - handle.paykitReceiverPaths(publicKey) - .filter { it in PaykitReceiverPaths.supported } - .filter { handle.paykitReceiverMarker(publicKey, it)?.capabilities?.paymentRequests == true } + handle().paykitAppRegistry(publicKey)?.apps?.any { + it.capabilities.paymentRequests && it.capabilities.outgoingPayments + } == true } } suspend fun proposePaymentRequest( counterparty: String, - counterpartyReceiverPath: String, proposal: PaykitPaymentRequestProposalTerms, expectedIdentity: String, ): PaymentRequestRecord { @@ -804,7 +740,7 @@ class PaykitSdkService @Inject constructor( withStateRevisionTracking { handle -> val identityStatus = handle.identityStatus() check( - identityStatus?.liveSessionAvailable == true && + identityStatus?.capability == PubkyIdentityCapability.PRIVATE_LINK_CAPABLE && PubkyPublicKeyFormat.matches(identityStatus.publicKey, expectedIdentity) ) { "Paykit identity changed before proposing the payment request" } val terms = PaymentRequestTerms( @@ -821,33 +757,42 @@ class PaykitSdkService @Inject constructor( ) }, acceptedPaymentEndpointIdentifiers = proposal.acceptedPaymentEndpointIdentifiers, + paymentEndpoints = null, + requiredAppId = "bitkit", conversion = null, paymentDeadline = null, metadata = PrivateJsonObject(proposal.metadataJson), ) - handle.proposePaymentRequest(counterparty, counterpartyReceiverPath, terms) + handle.proposePaymentRequest(counterparty, terms) } } } suspend fun acceptPaymentRequest( counterparty: String, - counterpartyReceiverPath: String, paymentRequestId: String, ): PaymentRequestRecord { isSetup.await() return operationLock.withLock { withStateRevisionTracking { handle -> - handle.acceptPaymentRequest(counterparty, counterpartyReceiverPath, paymentRequestId) + handle.claimPaymentRequestForExecution(counterparty, paymentRequestId) + handle.acceptPaymentRequest(counterparty, paymentRequestId) } } } + suspend fun claimPaymentRequestForExecution(counterparty: String, paymentRequestId: String): PaymentRequestRecord { + isSetup.await() + return operationLock.withLock { + withStateRevisionTracking { it.claimPaymentRequestForExecution(counterparty, paymentRequestId) } + } + } + @Suppress("LongParameterList") suspend fun submitPaymentProof( counterparty: String, - counterpartyReceiverPath: String, paymentRequestId: String, + paymentAppId: String, paymentEndpointIdentifier: String, proofJson: String, billingPeriod: PaykitBillingPeriod? = null, @@ -857,10 +802,10 @@ class PaykitSdkService @Inject constructor( withStateRevisionTracking { handle -> handle.submitPaymentProof( counterparty, - counterpartyReceiverPath, paymentRequestId, PaymentProofSubmission( billingPeriod = billingPeriod?.sdkValue, + paymentAppId = paymentAppId, paymentEndpointIdentifier = paymentEndpointIdentifier, allowanceId = null, conversionQuoteId = null, @@ -873,28 +818,26 @@ class PaykitSdkService @Inject constructor( suspend fun rejectPaymentRequest( counterparty: String, - counterpartyReceiverPath: String, paymentRequestId: String, reason: String? = null, ): PaymentRequestRecord { isSetup.await() return operationLock.withLock { withStateRevisionTracking { handle -> - handle.rejectPaymentRequest(counterparty, counterpartyReceiverPath, paymentRequestId, reason) + handle.rejectPaymentRequest(counterparty, paymentRequestId, reason) } } } suspend fun cancelPaymentRequest( counterparty: String, - counterpartyReceiverPath: String, paymentRequestId: String, reason: String? = null, ): PaymentRequestRecord { isSetup.await() return operationLock.withLock { withStateRevisionTracking { handle -> - handle.cancelPaymentRequest(counterparty, counterpartyReceiverPath, paymentRequestId, reason) + handle.cancelPaymentRequest(counterparty, paymentRequestId, reason) } } } @@ -902,20 +845,21 @@ class PaykitSdkService @Inject constructor( suspend fun linkedPeers(): List { isSetup.await() return operationLock.withLock { + refreshPaykitKey() handle().linkedPeers() } } - suspend fun pendingOutboundPrivateCounterparties(): List { + suspend fun pendingOutboundPrivateCounterparties(): List { isSetup.await() return operationLock.withLock { + refreshPaykitKey() handle().pendingOutboundPrivateCounterparties() } } suspend fun prepareAndResolvePrivateContactPayment( counterparty: String, - receiverPath: String, afterPrivatePaymentListVersion: ULong?, amount: PaymentAmountContext? = null, ): PaykitPreparedPrivateContactPayment { @@ -924,7 +868,6 @@ class PaykitSdkService @Inject constructor( withStateRevisionTracking { handle -> handle.prepareAndResolvePrivateContactPayment( counterparty = counterparty, - counterpartyReceiverPath = receiverPath, amount = amount, afterPrivatePaymentListVersion = afterPrivatePaymentListVersion, maxAdvanceSteps = 8u, @@ -937,13 +880,34 @@ class PaykitSdkService @Inject constructor( ) } + suspend fun prepareAndResolvePrivatePaymentRequest( + counterparty: String, + paymentRequestId: String, + afterPrivatePaymentListVersion: ULong?, + ): PaykitPreparedPrivateContactPayment { + isSetup.await() + val prepared = operationLock.withLock { + withStateRevisionTracking { + it.prepareAndResolvePrivatePaymentRequest( + counterparty, + paymentRequestId, + afterPrivatePaymentListVersion, + 8u, + ) + } + } + return PaykitPreparedPrivateContactPayment( + prepared.resolution.toPaykitPrivateContactPaymentResolution(), + prepared.linkReport?.state, + ) + } + suspend fun resolvePublicContactPayment( counterparty: String, - receiverPath: String, ): PaykitPublicContactPaymentResolution { isSetup.await() val resolution = operationLock.withLock { - handle().resolvePublicContactPayment(counterparty, receiverPath, amount = null) + handle().resolvePublicContactPayment(counterparty, amount = null) } return resolution.toPaykitPublicContactPaymentResolution() } @@ -955,6 +919,7 @@ class PaykitSdkService @Inject constructor( privatePaymentListVersion = privatePaymentListVersion, payableEndpoints = payableEndpoints.map { PaykitResolvedPaymentEndpoint( + appId = it.appId, identifier = it.identifier, payload = it.target.payload.exportText(), ) @@ -965,6 +930,7 @@ class PaykitSdkService @Inject constructor( PaykitPublicContactPaymentResolution( payableEndpoints = payableEndpoints.map { PaykitResolvedPaymentEndpoint( + appId = it.appId, identifier = it.identifier, payload = it.target.payload.exportText(), ) @@ -974,18 +940,13 @@ class PaykitSdkService @Inject constructor( suspend fun exportBackupState(): String { isSetup.await() return operationLock.withLock { + refreshPaykitKey() handle().exportBackupString() } } - suspend fun restoreBackupState(backup: String) { - isSetup.await() - operationLock.withLock { - withStateRevisionTracking { handle -> - handle.restoreBackupString(backup) - } - resetRuntime() - } + suspend fun retainRecoveryBackup(backup: String) { + keychain.upsertString(Keychain.Key.PAYKIT_RECOVERY_BACKUP.name, backup) } suspend fun signOut() { @@ -1004,9 +965,8 @@ class PaykitSdkService @Inject constructor( isSetup.await() operationLock.withLock { try { - withStateRevisionTracking { handle -> - handle.forgetSessionAccess() - } + handle().forgetSessionAccess() + notifyBackupStateChanged() } finally { resetRuntime() } @@ -1030,28 +990,33 @@ class PaykitSdkService @Inject constructor( suspend fun clearState() { operationLock.withLock { - clearStateLocked() + resetRuntime() + notifyBackupStateChanged() } } - private suspend fun clearStateLocked() { - keychain.delete(Keychain.Key.PAYKIT_SDK_STATE.name) - resetRuntime() - notifyBackupStateChanged() + private suspend fun refreshPaykitKey() { + val root = sessionProvider.loadLocalSecretKey() ?: return + sessionProvider.setPaykitIdentitySecretKey(paykitKey(root)) } - private suspend fun currentSdkStatePublicKeyLocked(): String? { - sessionProvider.suspendStoredSessionAccess() - return try { - handle().identityStatus()?.publicKey - } finally { - sessionProvider.resumeStoredSessionAccess() - } + suspend fun paykitKeyForAuthorization(secretKeyHex: String): PaykitIdentitySecretKey { + isSetup.await() + return operationLock.withLock { paykitKey(localSecretKey(secretKeyHex)) } + } + + private suspend fun paykitKey(root: PubkyLocalSecretKey): PaykitIdentitySecretKey { + val publicKey = pubkyPublicKeyFromSecret(root) + val generation = handle().paykitAppRegistry(publicKey)?.keyGeneration ?: 1uL + val storageKey = "${Keychain.Key.PAYKIT_KEY_GENERATION.name}:$publicKey" + val saved = keychain.loadString(storageKey)?.toULong() + check(generation >= (saved ?: 1uL)) { "The Paykit App Registry has an older key generation" } + if (saved != generation) keychain.upsertString(storageKey, generation.toString()) + return root.derivePaykitIdentitySecretKey(generation) } private suspend fun persistSessionAccess(access: PubkySessionAccess) { keychain.upsertString(Keychain.Key.PAYKIT_SESSION.name, access.exportSessionSecret()) - sessionProvider.persistReceiverNoiseSecretKey(access.exportReceiverNoiseSecretKey()) val localSecret = access.exportLocalSecretKey() if (localSecret != null && sessionProvider.adoptedPubky() == null) { keychain.upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, secretKeyHex(localSecret)) @@ -1062,48 +1027,43 @@ class PaykitSdkService @Inject constructor( private suspend fun activateBootstrapResult( result: PubkySessionBootstrapResult, - previousPublicKey: String?, ) { persistSessionAccess(result.sessionAccess) sessionProvider.setLiveSessionAccess(result.sessionAccess) - val cachedOwner = pubkyStore.data.first().ownerPublicKey - val previousOwner = cachedOwner ?: previousPublicKey + val previousOwner = pubkyStore.data.first().ownerPublicKey if (previousOwner != null && !PubkyPublicKeyFormat.matches(previousOwner, result.publicKey)) { pubkyStore.reset() } - if (!PubkyPublicKeyFormat.matches(previousPublicKey, result.publicKey)) { - keychain.delete(Keychain.Key.PAYKIT_SDK_STATE.name) - } resetRuntime() + refreshPaykitKey() val handle = handle() handle.initialize() - publishReceiverMarkerIfLiveSessionAvailable(handle) + publishAppIfLiveSessionAvailable(handle) republishIdentityIfNeeded(publicKey = result.publicKey) } private suspend fun clearRegisteredIdentityActivationLocked() = withContext(NonCancellable) { runSuspendCatching { sessionProvider.clearSessionAccess() } .onFailure { Logger.warn("Failed to clear incomplete Pubky signup session", it, context = TAG) } - runSuspendCatching { keychain.delete(Keychain.Key.PAYKIT_SDK_STATE.name) } - .onFailure { Logger.warn("Failed to clear incomplete Pubky signup state", it, context = TAG) } resetRuntime() notifyBackupStateChanged() } - private suspend fun publishReceiverMarkerIfLiveSessionAvailable(handle: PaykitSdk) { + private suspend fun publishAppIfLiveSessionAvailable(handle: PaykitSdk) { runSuspendCatching { - val capabilities = receiverCapabilities(handle) + val capabilities = appCapabilities(handle) if (capabilities.privatePayments) { - handle.publishPaykitReceiverMarker(capabilities) + handle.publishPaykitApp("Bitkit", capabilities) } }.onFailure { - Logger.warn("Failed to publish Paykit receiver marker", it, context = TAG) + Logger.warn("Failed to publish Paykit app", it, context = TAG) } } - private suspend fun receiverCapabilities(handle: PaykitSdk): PaykitReceiverCapabilities { - val hasPrivatePaymentAccess = handle.identityStatus()?.liveSessionAvailable == true - return PaykitReceiverCapabilities( + private suspend fun appCapabilities(handle: PaykitSdk): PaykitAppCapabilities { + val hasPrivatePaymentAccess = + handle.identityStatus()?.capability == PubkyIdentityCapability.PRIVATE_LINK_CAPABLE + return PaykitAppCapabilities( privatePayments = hasPrivatePaymentAccess, paymentRequests = hasPrivatePaymentAccess, receipts = false, @@ -1123,7 +1083,7 @@ class PaykitSdkService @Inject constructor( var failure: Throwable? = null return try { runSuspendCatching { - blockedPeers.forEach { handle.unblockPeer(it.counterparty, it.counterpartyReceiverPath) } + blockedPeers.forEach { handle.unblockPeer(it.counterparty) } handle.saveContact(update) }.onFailure { failure = it }.getOrThrow() } catch (error: CancellationException) { @@ -1134,7 +1094,7 @@ class PaykitSdkService @Inject constructor( withContext(NonCancellable) { blockedPeers.forEach { peer -> runSuspendCatching { - handle.blockPeer(peer.counterparty, peer.counterpartyReceiverPath) + handle.blockPeer(peer.counterparty) }.onFailure(restorationError::addSuppressed) } } @@ -1143,6 +1103,7 @@ class PaykitSdkService @Inject constructor( } private suspend fun withStateRevisionTracking(block: suspend (PaykitSdk) -> T): T { + refreshPaykitKey() val handle = handle() return withPaykitBackupStateTracking( readRevision = { handle.backupStateRevision() }, @@ -1171,18 +1132,6 @@ class PaykitSdkService @Inject constructor( sdk = null } - private fun mergedReceiverPaths(paths: List): List { - return (listOf(PaykitReceiverPaths.WALLET) + paths) - .filter { it in PaykitReceiverPaths.supported } - .distinct() - } - - private fun PaykitReceiverMarker.requiresPrivateLink(): Boolean = - capabilities.privatePayments || capabilities.paymentRequests || capabilities.receipts - - private fun PaykitReceiverMarker?.canReceivePrivatePaymentDetails(): Boolean = - this?.capabilities?.let { it.privatePayments && it.outgoingPayments } == true - companion object { private const val TAG = "PaykitSdkService" @@ -1215,6 +1164,16 @@ class PaykitSdkService @Inject constructor( } } +internal fun isBitkitPaymentRequest(record: PaymentRequestRecord): Boolean = when (record.localRole) { + PaymentRequestLocalRole.PAYEE -> record.proposalAppId == "bitkit" + PaymentRequestLocalRole.PAYER -> record.executionClaimAppId?.let { it == "bitkit" } ?: when (record.state) { + PaymentRequestLifecycleState.ACTIVE_RECURRING -> true + PaymentRequestLifecycleState.ACCEPTED if record.paymentProofs.isEmpty() -> true + else -> record.payerAppId == null || record.payerAppId == "bitkit" + } + else -> false +} + internal suspend fun withPaykitBackupStateTracking( readRevision: suspend () -> String, onChange: () -> Unit, @@ -1235,18 +1194,11 @@ internal suspend fun withPaykitBackupStateTracking( internal object BitkitPaykitSdkConfig { val clientId: String - get() = profileNamespace - val profileNamespace: String get() = if (Env.network == Network.BITCOIN) "bitkit.to" else "staging.bitkit.to" - val endpointManagementScope = PaykitSdkDefaults.DEFAULT_ENDPOINT_MANAGEMENT_SCOPE - val encryptedLinkRecoveryMarkers = PaykitSdkDefaults.DEFAULT_ENCRYPTED_LINK_RECOVERY_MARKER_POLICY val publicContactSharing = PaykitSdkDefaults.DEFAULT_PUBLIC_CONTACT_SHARING_POLICY } -internal fun paykitSdkConfig() = defaultConfig(PaykitReceiverPaths.WALLET).copy( - profileNamespace = BitkitPaykitSdkConfig.profileNamespace, - endpointManagementScope = BitkitPaykitSdkConfig.endpointManagementScope, - encryptedLinkRecoveryMarkers = BitkitPaykitSdkConfig.encryptedLinkRecoveryMarkers, +internal fun paykitSdkConfig() = defaultConfig("bitkit").copy( publicContactSharing = BitkitPaykitSdkConfig.publicContactSharing, ) @@ -1270,61 +1222,23 @@ internal fun validatedApprovalClientId(requestClientId: String, approvedClientId return requestClientId } -private class PaykitSdkStateBlobStore( - private val keychain: Keychain, -) : SdkStateBlobStore { - private val lock = Any() - - override fun loadStateBlob(): SdkStateBlobSnapshot? = paykitStorageCallback("state_load_failed") { - synchronized(lock) { - val data = keychain.accessBlocking { - load(Keychain.Key.PAYKIT_SDK_STATE.name) - } ?: return@synchronized null - decodeSdkStateBlobSnapshot(data) - } - } - - override fun saveStateBlobAtomically( - blob: SdkStateBlob, - expectedRevision: String?, - ): String = paykitStorageCallback("state_save_failed") { - synchronized(lock) { - val currentRevision = keychain.accessBlocking { - load(Keychain.Key.PAYKIT_SDK_STATE.name) - } - ?.let { decodeSdkStateBlobSnapshot(it).revision } - if (currentRevision != expectedRevision) { - throw PaykitException.Storage( - code = "revision_conflict", - context = "SDK state revision changed", - ) - } - - val nextRevision = UUID.randomUUID().toString() - val snapshot = SdkStateBlobSnapshot(blob = blob, revision = nextRevision) - keychain.accessBlocking { - upsert(Keychain.Key.PAYKIT_SDK_STATE.name, encodeSdkStateBlobSnapshot(snapshot)) - } - nextRevision - } - } -} - internal class PaykitSdkSessionProvider( private val keychain: Keychain, private val sharedPubky: SharedPubkyClient, ) : SdkPubkySessionProvider { private val lock = Any() - private val receiverNoiseKeyStore = PaykitReceiverNoiseKeyStore(keychain) + private var paykitIdentitySecretKey: PaykitIdentitySecretKey? = null private var liveSessionAccess: PubkySessionAccess? = null private var isStoredSessionAccessSuspended = false fun setLiveSessionAccess(access: PubkySessionAccess) = synchronized(lock) { liveSessionAccess = access + paykitIdentitySecretKey = access.exportPaykitIdentitySecretKey() } fun clearLiveSessionAccess() = synchronized(lock) { liveSessionAccess = null + paykitIdentitySecretKey = null } override fun loadSessionAccess(): PubkySessionAccess? = paykitStorageCallback("session_load_failed") { @@ -1342,8 +1256,8 @@ internal class PaykitSdkSessionProvider( clientId = BitkitPaykitSdkConfig.clientId, sessionSecret = sessionSecret, localSecretKey = loadLocalSecretKey(), - receiverNoiseSecretKey = loadOrDeriveReceiverNoiseSecretKey(), - ) + paykitIdentitySecretKey = paykitIdentitySecretKey, + ).also { liveSessionAccess = it } } } @@ -1388,48 +1302,12 @@ internal class PaykitSdkSessionProvider( return PaykitSdkService.localSecretKey(secretKeyHex) } - fun loadOrDeriveReceiverNoiseSecretKey(): ReceiverNoiseSecretKey = - receiverNoiseKeyStore.loadOrDerive() - - fun persistReceiverNoiseSecretKey(key: ReceiverNoiseSecretKey) { - receiverNoiseKeyStore.persist(key) - } -} - -internal object PaykitReceiverNoiseKeyDerivation { - private const val DOMAIN = "bitkit/paykit/receiver-noise-key" - private const val VERSION = "v1" - - fun deriveFromWalletSeed( - mnemonic: String, - passphrase: String?, - network: String, - receiverPath: String, - ): ByteArray { - val seed = mnemonicToSeed(mnemonic, passphrase?.takeIf { it.isNotEmpty() }) - return try { - derive(seed, network, receiverPath) - } finally { - seed.fill(0) - } - } - - fun derive(seed: ByteArray, network: String, receiverPath: String): ByteArray { - val salt = MessageDigest.getInstance("SHA-256").digest(DOMAIN.encodeToByteArray()) - val prk = hmacSha256(key = salt, data = seed) - return try { - val info = "$VERSION\u0000$network\u0000$receiverPath".encodeToByteArray() + byteArrayOf(0x01) - hmacSha256(key = prk, data = info) - } finally { - prk.fill(0) + fun setPaykitIdentitySecretKey(key: PaykitIdentitySecretKey) = synchronized(lock) { + if ((paykitIdentitySecretKey?.keyGeneration() ?: 1uL) != key.keyGeneration()) { + liveSessionAccess = null } + paykitIdentitySecretKey = key } - - private fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray = - Mac.getInstance("HmacSHA256").run { - init(SecretKeySpec(key, "HmacSHA256")) - doFinal(data) - } } internal fun clearPubkySessionCredentials(deleteKeychainValue: (String) -> Unit) { @@ -1439,88 +1317,15 @@ internal fun clearPubkySessionCredentials(deleteKeychainValue: (String) -> Unit) localSecretResult.getOrThrow() } -internal class PaykitReceiverNoiseKeyStore( - private val loadBytes: () -> ByteArray?, - private val upsertBytes: (ByteArray) -> Unit, - private val deriveBytes: () -> ByteArray, -) { - constructor(keychain: Keychain) : this( - loadBytes = { - keychain.accessBlocking { - load(Keychain.Key.PAYKIT_RECEIVER_NOISE_SECRET_KEY.name) - } - }, - upsertBytes = { bytes -> - keychain.accessBlocking { - upsert(Keychain.Key.PAYKIT_RECEIVER_NOISE_SECRET_KEY.name, bytes) - } - }, - deriveBytes = { - val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) - ?: throw AppError("Mnemonic not found while deriving the Paykit receiver Noise key") - val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) - PaykitReceiverNoiseKeyDerivation.deriveFromWalletSeed( - mnemonic = mnemonic, - passphrase = passphrase, - network = Env.network.name.lowercase(), - receiverPath = PaykitReceiverPaths.WALLET, - ) - }, - ) - - @Synchronized - fun loadOrDerive(): ReceiverNoiseSecretKey = - ReceiverNoiseSecretKey(validatedKeyBytes().copyOf()) - - @Synchronized - fun persist(key: ReceiverNoiseSecretKey) { - persistBytes(key.exportBytes()) - } - - @Synchronized - internal fun loadOrDeriveBytes(): ByteArray = - validatedKeyBytes().copyOf() - - @Synchronized - internal fun persistBytes(bytes: ByteArray) { - if (!validatedKeyBytes().contentEquals(bytes)) { - throw AppError("Paykit receiver Noise key changed unexpectedly") - } - } - - private fun validatedKeyBytes(): ByteArray { - loadBytes()?.let { - checkKeyLength(it, "Stored Paykit receiver Noise key is invalid") - return it - } - - val derivedBytes = deriveBytes() - checkKeyLength(derivedBytes, "Derived Paykit receiver Noise key is invalid") - upsertBytes(derivedBytes.copyOf()) - - return derivedBytes - } - - private fun checkKeyLength(bytes: ByteArray, message: String) { - if (bytes.size != RECEIVER_NOISE_KEY_LENGTH) throw AppError(message) - } - - private companion object { - const val RECEIVER_NOISE_KEY_LENGTH = 32 - } -} - class PaykitSdkPaymentAdapter : SdkPaymentAdapter { override fun currentPublicReceivingDetails(): List = emptyList() override fun currentPrivateReceivingDetails( counterparty: String, - counterpartyReceiverPath: String, ): List = emptyList() override fun reservePrivateReceivingDetails( counterparty: String, - counterpartyReceiverPath: String, ): PrivateReceivingDetailReservationResponse = PrivateReceivingDetailReservationResponse( kind = PrivateReceivingDetailReservationResponseKind.USE_CURRENT_RECEIVING_DETAILS, diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt index 8cb0667ccc..d5a04d4766 100644 --- a/app/src/main/java/to/bitkit/services/PubkyService.kt +++ b/app/src/main/java/to/bitkit/services/PubkyService.kt @@ -1,10 +1,10 @@ package to.bitkit.services import com.synonym.bitkitcore.approvePubkyAuth -import com.synonym.paykit.ContactProfileResolution import com.synonym.paykit.ContactRecord import com.synonym.paykit.PaykitProfile import com.synonym.paykit.PaykitPublicKeys +import com.synonym.paykit.ProfileResolution import com.synonym.paykit.PubkyAuthCompanionClaim import com.synonym.paykit.PubkySessionBootstrapResult import kotlinx.coroutines.withTimeoutOrNull @@ -61,11 +61,11 @@ class PubkyService @Inject constructor( val report = paykitSdkService.syncPublicEndpoints(emptyList()) if (report.failed.isNotEmpty()) throw AppError("Failed to remove Paykit payment endpoints") }.exceptionOrNull() - val markerError = runSuspendCatching { - paykitSdkService.syncLocalReceiverMarker(isDiscoverable = false) + val appError = runSuspendCatching { + paykitSdkService.syncPaykitApp(privatePaymentsEnabled = false) }.exceptionOrNull() - val cleanupError = endpointError ?: markerError - if (endpointError != null && markerError != null) endpointError.addSuppressed(markerError) + val cleanupError = endpointError ?: appError + if (endpointError != null && appError != null) endpointError.addSuppressed(appError) cleanupError?.let { throw it } } @@ -201,10 +201,9 @@ class PubkyService @Inject constructor( suspend fun saveContact( publicKey: String, label: String?, - receiverPaths: List? = null, restorePrivateConnection: Boolean = false, ): ContactRecord = ServiceQueue.CORE.background { - paykitSdkService.saveContact(publicKey, label, receiverPaths, restorePrivateConnection) + paykitSdkService.saveContact(publicKey, label, restorePrivateConnection) } suspend fun removeContact(publicKey: String): ContactRecord? = ServiceQueue.CORE.background { @@ -214,14 +213,10 @@ class PubkyService @Inject constructor( suspend fun resolveContactProfile( publicKey: String, allowPubkyProfileFallback: Boolean, - ): ContactProfileResolution? = ServiceQueue.CORE.background { + ): ProfileResolution? = ServiceQueue.CORE.background { paykitSdkService.resolveContactProfile(publicKey, allowPubkyProfileFallback) } - suspend fun discoverRelevantReceiverPaths(publicKey: String): List = ServiceQueue.CORE.background { - paykitSdkService.discoverRelevantReceiverPaths(publicKey) - } - // endregion } diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index d0967bc607..db61bc3a9f 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -855,7 +855,6 @@ private fun RootNavHost( Routes.SubscriptionDetail( paymentRequestId = it.paymentRequestId, counterparty = it.counterparty, - counterpartyReceiverPath = it.counterpartyReceiverPath, ) ) }, @@ -872,7 +871,6 @@ private fun RootNavHost( id = PaykitSubscriptionId( paymentRequestId = route.paymentRequestId, counterparty = route.counterparty, - counterpartyReceiverPath = route.counterpartyReceiverPath, ), onBack = { navController.popBackStack() }, ) @@ -1432,7 +1430,6 @@ private fun NavGraphBuilder.contacts( Sheet.Receive( route = ReceiveRoute.PaymentRequestAmount( publicKey = it.publicKey, - receiverPath = it.receiverPath, ) ) ) @@ -2191,14 +2188,12 @@ fun NavController.navigateToLanguageSettings() = navigateTo(Routes.LanguageSetti private fun PaykitPaymentRequestId.toRoute() = Routes.PaymentRequestDetails( paymentRequestId = paymentRequestId, counterparty = counterparty, - counterpartyReceiverPath = counterpartyReceiverPath, billingPeriodStartsAt = billingPeriodStartsAt, ) private fun Routes.PaymentRequestDetails.toId() = PaykitPaymentRequestId( paymentRequestId = paymentRequestId, counterparty = counterparty, - counterpartyReceiverPath = counterpartyReceiverPath, billingPeriodStartsAt = billingPeriodStartsAt, ) @@ -2526,14 +2521,12 @@ sealed interface Routes { data class SubscriptionDetail( val paymentRequestId: String, val counterparty: String, - val counterpartyReceiverPath: String, ) : Routes.InternalOnly @Serializable data class PaymentRequestDetails( val paymentRequestId: String, val counterparty: String, - val counterpartyReceiverPath: String, val billingPeriodStartsAt: String? = null, ) : Routes.InternalOnly diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 037b5d21b3..0bf8778c73 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -307,13 +307,11 @@ class MainActivity : FragmentActivity() { private fun Intent.paykitPaymentRequestId(): PaykitPaymentRequestId? { val requestId = getStringExtra(EXTRA_PAYKIT_PAYMENT_REQUEST_ID) ?: return null val counterparty = getStringExtra(EXTRA_PAYKIT_COUNTERPARTY) ?: return null - val receiverPath = getStringExtra(EXTRA_PAYKIT_COUNTERPARTY_RECEIVER_PATH) ?: return null val billingPeriodStartsAt = getStringExtra(EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT) ?: return null return PaykitPaymentRequestId( paymentRequestId = requestId, counterparty = counterparty, - counterpartyReceiverPath = receiverPath, billingPeriodStartsAt = billingPeriodStartsAt, ) } diff --git a/app/src/main/java/to/bitkit/ui/Notifications.kt b/app/src/main/java/to/bitkit/ui/Notifications.kt index 1e8d7b9668..f04ae82195 100644 --- a/app/src/main/java/to/bitkit/ui/Notifications.kt +++ b/app/src/main/java/to/bitkit/ui/Notifications.kt @@ -30,7 +30,6 @@ const val EXTRA_PAYKIT_SUBSCRIPTION_PAYMENT_DUE = "paykit_subscription_payment_d const val EXTRA_PAYKIT_PAYER_IDENTITY = "paykit_payer_identity" const val EXTRA_PAYKIT_PAYMENT_REQUEST_ID = "paykit_payment_request_id" const val EXTRA_PAYKIT_COUNTERPARTY = "paykit_counterparty" -const val EXTRA_PAYKIT_COUNTERPARTY_RECEIVER_PATH = "paykit_counterparty_receiver_path" const val EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT = "paykit_billing_period_starts_at" val Context.CHANNEL_MAIN get() = getString(R.string.app_notifications_channel_id) diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt index bb4e3920a9..9ecf84fd70 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt @@ -23,7 +23,7 @@ import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitPaymentResult import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.ui.shared.toast.ToastEventBus -import to.bitkit.usecases.RefreshContactPaykitReceiversUseCase +import to.bitkit.usecases.RefreshContactPaykitLinkUseCase import to.bitkit.utils.Logger import javax.inject.Inject @@ -32,7 +32,7 @@ class AddContactViewModel @Inject constructor( @ApplicationContext private val context: Context, private val pubkyRepo: PubkyRepo, private val publicPaykitRepo: PublicPaykitRepo, - private val refreshContactPaykitReceivers: RefreshContactPaykitReceiversUseCase, + private val refreshContactPaykitLink: RefreshContactPaykitLinkUseCase, savedStateHandle: SavedStateHandle, ) : ViewModel() { @@ -96,7 +96,7 @@ class AddContactViewModel @Inject constructor( ) } if (error == PubkyContactError.AlreadyExists) { - refreshContactPaykitReceivers(publicKey) + refreshContactPaykitLink(publicKey) } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsViewModel.kt index cfd1cb7439..f4e6283161 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsViewModel.kt @@ -16,13 +16,13 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.models.PubkyProfile import to.bitkit.repositories.PubkyRepo -import to.bitkit.usecases.RefreshContactPaykitReceiversUseCase +import to.bitkit.usecases.RefreshContactPaykitLinkUseCase import javax.inject.Inject @HiltViewModel class ContactsViewModel @Inject constructor( private val pubkyRepo: PubkyRepo, - private val refreshContactPaykitReceivers: RefreshContactPaykitReceiversUseCase, + private val refreshContactPaykitLink: RefreshContactPaykitLinkUseCase, ) : ViewModel() { private val _searchText = MutableStateFlow("") @@ -70,7 +70,7 @@ class ContactsViewModel @Inject constructor( } fun refreshExistingContact(publicKey: String) { - viewModelScope.launch { refreshContactPaykitReceivers(publicKey) } + viewModelScope.launch { refreshContactPaykitLink(publicKey) } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt index 1bb53ef3b7..2b927c88fc 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt @@ -481,7 +481,7 @@ internal fun PaymentRequestRecipientContent( } items( items = recipients, - key = { (target, _) -> "${target.publicKey}|${target.receiverPath}" }, + key = { (target, _) -> target.publicKey }, ) { (target, contact) -> PubkyContactRow( profile = contact, @@ -592,13 +592,11 @@ private val previewDraft = PaykitPaymentRequestDraft( private val previewTarget = PaykitPaymentRequestTarget( publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", - receiverPath = "bitkit/wallet", ) private val previewCreatedRequest = PaykitPaymentRequest( paymentRequestId = "payment-request", counterparty = previewTarget.publicKey, - counterpartyReceiverPath = previewTarget.receiverPath, amountValue = "0.00025", amountSats = previewDraft.amountSats, note = previewDraft.note, diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt index b5a744f931..90a5523e75 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt @@ -665,7 +665,7 @@ private fun List.nameFor(request: PaykitPaymentRequest): Str } private val PaykitPaymentRequest.lazyListKey: String - get() = "$paymentRequestId|$counterparty|$counterpartyReceiverPath|${billingPeriod?.startsAt ?: ""}" + get() = "$paymentRequestId|$counterparty|${billingPeriod?.startsAt ?: ""}" internal val PaykitPaymentRequest.paymentRailIconColor get() = if (paymentProofKind == PaykitPaymentProofKind.Lightning) Colors.Purple else Colors.Brand @@ -692,7 +692,6 @@ private fun PaymentRailIcon(request: PaykitPaymentRequest, paymentWasSent: Boole private val previewRequest = PaykitPaymentRequest( paymentRequestId = "payment-request", counterparty = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", - counterpartyReceiverPath = "bitkit/wallet", amountValue = "0.00025", amountSats = 25_000uL, note = "Dinner", diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalSheet.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalSheet.kt index 42bffae193..9d8c31c4eb 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalSheet.kt @@ -11,8 +11,10 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.runtime.Composable @@ -38,6 +40,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import to.bitkit.R import to.bitkit.models.PubkyAuthClaim +import to.bitkit.models.PubkyAuthClaim.Item import to.bitkit.models.PubkyAuthPermission import to.bitkit.models.PubkyProfile import to.bitkit.ui.appViewModel @@ -265,7 +268,7 @@ private fun approvalBackAction( onCancel: () -> Unit, onDismiss: () -> Unit, ): (() -> Unit)? = when (approvalState) { - ApprovalState.Authorize if bitkitClaim == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1 -> onBackToWatchOnly + ApprovalState.Authorize if bitkitClaim?.includesWatchOnlyAccount == true -> onBackToWatchOnly ApprovalState.Authorize, ApprovalState.Authenticating, ApprovalState.Authorizing -> onCancel ApprovalState.Success -> onDismiss else -> null @@ -384,7 +387,7 @@ private fun ColumnScope.AuthorizingContent( private fun ColumnScope.ApprovalDetails( uiState: PubkyAuthApprovalUiState, ) { - Column(modifier = Modifier.weight(1f)) { + Column(modifier = Modifier.weight(1f).verticalScroll(rememberScrollState())) { VerticalSpacer(26.dp) if (uiState.homeserverPublicKey != null) { @@ -410,7 +413,11 @@ private fun ColumnScope.ApprovalDetails( if (uiState.permissions.isNotEmpty()) { PermissionsSection(permissions = uiState.permissions) } - FillHeight(min = 32.dp) + if (uiState.bitkitClaim?.includesPaykitAccess == true) { + VerticalSpacer(16.dp) + PaykitAccessSection() + } + VerticalSpacer(32.dp) TrustWarning() VerticalSpacer(16.dp) @@ -432,6 +439,15 @@ private fun ColumnScope.ApprovalDetails( } } +@Composable +private fun PaykitAccessSection() { + Column(modifier = Modifier.testTag("PubkyAuthPaykitAccess")) { + BodyMSB(text = stringResource(R.string.profile__auth_approval_paykit_access_title)) + VerticalSpacer(8.dp) + BodyM(text = stringResource(R.string.profile__auth_approval_paykit_access_description), color = Colors.White64) + } +} + @Composable private fun ColumnScope.SuccessContent( uiState: PubkyAuthApprovalUiState, @@ -592,7 +608,7 @@ private fun WatchOnlyConsentPreview() { uiState = PubkyAuthApprovalUiState( state = ApprovalState.WatchOnlyConsent, serviceName = "paykit", - bitkitClaim = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, + bitkitClaim = PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1), ), isCurrentRequest = true, onAuthorize = {}, @@ -619,7 +635,7 @@ private fun AuthorizePreview() { PubkyAuthPermission(path = "/pub/pubky.app/", accessLevel = "rw"), PubkyAuthPermission(path = "/pub/paykit/v0/", accessLevel = "rw"), ), - bitkitClaim = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, + bitkitClaim = PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1), profile = PubkyProfile( publicKey = "pk8e3qm5f4kgczagxhertyuiop1gxag", name = "Satoshi Nakamoto", diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt index 9d6eeb3157..0cb1229438 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt @@ -21,6 +21,7 @@ import to.bitkit.ext.runSuspendCatching import to.bitkit.models.PubkyAuthClaim import to.bitkit.models.PubkyAuthPermission import to.bitkit.models.PubkyAuthRequest +import to.bitkit.models.PubkyAuthRequestError import to.bitkit.models.PubkyProfile import to.bitkit.models.Toast import to.bitkit.models.WatchOnlyAccountSetupState @@ -76,16 +77,9 @@ class PubkyAuthApprovalViewModel @Inject constructor( if (_uiState.value.authUrl != authUrl) return@launch val unknownService = context.getString(R.string.profile__auth_approval_service_unknown) val serviceName = request.serviceNames.firstOrNull() ?: unknownService - val profile = pubkyRepo.profile.value ?: pubkyRepo.publicKey.value?.let { publicKey -> - PubkyProfile.forDisplay( - publicKey = publicKey, - name = pubkyRepo.displayName.value, - imageUrl = pubkyRepo.displayImageUri.value, - ) - } _uiState.update { it.copy( - state = if (request.bitkitClaim == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1) { + state = if (request.bitkitClaim?.includesWatchOnlyAccount == true) { ApprovalState.WatchOnlyConsent } else { ApprovalState.Authorize @@ -95,7 +89,7 @@ class PubkyAuthApprovalViewModel @Inject constructor( serviceName = serviceName, permissions = request.permissions.toImmutableList(), bitkitClaim = request.bitkitClaim, - profile = profile, + profile = approvalProfile(), ) } } @@ -125,7 +119,7 @@ class PubkyAuthApprovalViewModel @Inject constructor( if ( state.authUrl == authUrl && state.state == ApprovalState.Authorize && - state.bitkitClaim == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1 + state.bitkitClaim?.includesWatchOnlyAccount == true ) { state.copy(state = ApprovalState.WatchOnlyConsent) } else { @@ -170,6 +164,12 @@ class PubkyAuthApprovalViewModel @Inject constructor( } val approvalState = _uiState.value if (approvalState.authUrl != authUrl) return + if (request.bitkitClaim != approvalState.bitkitClaim || request.clientId != approvalState.clientId || + request.permissions != approvalState.permissions + ) { + handleApprovalFailure(PubkyAuthRequestError.RequesterChanged, authUrl) + return + } if (!approveRequest(request, authUrl)) return Logger.info("Auth approved for '${request.serviceNames.firstOrNull().orEmpty()}'", context = TAG) @@ -202,7 +202,7 @@ class PubkyAuthApprovalViewModel @Inject constructor( authUrl: String, ): Boolean { val preparedClaim = runSuspendCatching { - if (request.bitkitClaim == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1) { + if (request.bitkitClaim?.includesWatchOnlyAccount == true) { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, defaultWatchOnlyAccountName(request)) } else { null @@ -226,9 +226,11 @@ class PubkyAuthApprovalViewModel @Inject constructor( } } - val approvalResult = preparedClaim?.let { - pubkyRepo.approveAuthWithCompanionClaim(authUrl, request.clientId, it.payload) - } ?: pubkyRepo.approveAuth(authUrl, request.capabilities, request.clientId) + val approvalResult = if (request.bitkitClaim != null) { + pubkyRepo.approveAuthWithCompanionClaim(authUrl, request.clientId, preparedClaim?.payload ?: byteArrayOf()) + } else { + pubkyRepo.approveAuth(authUrl, request.capabilities, request.clientId) + } if (approvalResult.isFailure) { val approvalError = checkNotNull(approvalResult.exceptionOrNull()) { "Authorization failed" } preparedClaim?.let { claim -> @@ -320,6 +322,14 @@ class PubkyAuthApprovalViewModel @Inject constructor( return context.getString(R.string.profile__auth_approval_watch_only_account_default_name, serviceName) } + private fun approvalProfile() = pubkyRepo.profile.value ?: pubkyRepo.publicKey.value?.let { publicKey -> + PubkyProfile.forDisplay( + publicKey = publicKey, + name = pubkyRepo.displayName.value, + imageUrl = pubkyRepo.displayImageUri.value, + ) + } + fun dismiss() { viewModelScope.launch { _effects.emit(PubkyAuthApprovalEffect.Dismiss) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt index b4036546fa..c40a2dd900 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt @@ -110,8 +110,7 @@ fun ReceiveSheet( mutableStateOf( (startRoute as? ReceiveRoute.PaymentRequestAmount)?.let { val publicKey = it.publicKey ?: return@let null - val receiverPath = it.receiverPath ?: return@let null - PaykitPaymentRequestTarget(publicKey, receiverPath) + PaykitPaymentRequestTarget(publicKey) } ) } @@ -195,7 +194,7 @@ fun ReceiveSheet( composableWithDefaultTransitions { backStackEntry -> val route = backStackEntry.toRoute() val routeTarget = route.publicKey?.let { publicKey -> - route.receiverPath?.let { receiverPath -> PaykitPaymentRequestTarget(publicKey, receiverPath) } + PaykitPaymentRequestTarget(publicKey) } val contact = (routeTarget ?: selectedPaymentRequestTarget)?.let { target -> paymentRequestContacts.firstOrNull { @@ -541,7 +540,6 @@ sealed interface ReceiveRoute { @Serializable data class PaymentRequestAmount( val publicKey: String? = null, - val receiverPath: String? = null, ) : InternalOnly @Serializable diff --git a/app/src/main/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCase.kt b/app/src/main/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCase.kt similarity index 75% rename from app/src/main/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCase.kt rename to app/src/main/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCase.kt index be69e7623f..830993fc54 100644 --- a/app/src/main/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCase.kt @@ -10,24 +10,23 @@ import to.bitkit.repositories.PubkyRepo import to.bitkit.utils.Logger import javax.inject.Inject -class RefreshContactPaykitReceiversUseCase @Inject constructor( +class RefreshContactPaykitLinkUseCase @Inject constructor( @IoDispatcher private val ioDispatcher: CoroutineDispatcher, private val pubkyRepo: PubkyRepo, private val privatePaykitRepo: PrivatePaykitRepo, ) { companion object { - private const val TAG = "RefreshContactPaykitReceiversUseCase" + private const val TAG = "RefreshContactPaykitLinkUseCase" } suspend operator fun invoke(publicKey: String): Result = withContext(ioDispatcher) { runSuspendCatching { - pubkyRepo.refreshContactReceiverPaths(publicKey).getOrThrow() val savedPublicKeys = (pubkyRepo.contacts.value.map { it.publicKey } + publicKey).distinct() privatePaykitRepo.refreshSavedContactEndpoints(publicKey, savedPublicKeys).getOrThrow() - privatePaykitRepo.startInitialLinkBurst(savedPublicKeys, "contact receiver refresh") + privatePaykitRepo.startInitialLinkBurst(savedPublicKeys, "contact link refresh") }.onFailure { Logger.warn( - "Failed to refresh Paykit receivers for '${PubkyPublicKeyFormat.redacted(publicKey)}'", + "Failed to refresh the Paykit link for '${PubkyPublicKeyFormat.redacted(publicKey)}'", it, context = TAG, ) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 9d70a988d1..ee66d5c496 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -199,7 +199,7 @@ import to.bitkit.ui.theme.TRANSITION_SCREEN_MS import to.bitkit.ui.utils.ScreenDeepLinks import to.bitkit.ui.utils.localizedPubkyAuthMessage import to.bitkit.usecases.FormatMoneyValue -import to.bitkit.usecases.RefreshContactPaykitReceiversUseCase +import to.bitkit.usecases.RefreshContactPaykitLinkUseCase import to.bitkit.utils.AppError import to.bitkit.utils.Bip21Utils import to.bitkit.utils.Logger @@ -259,7 +259,7 @@ class AppViewModel @Inject constructor( private val paykitPaymentRequestRepo: PaykitPaymentRequestRepo, private val paykitPaymentProofRepo: PaykitPaymentProofRepo, private val paykitPaymentRequestDiagnostics: PaykitPaymentRequestDiagnostics, - private val refreshContactPaykitReceivers: RefreshContactPaykitReceiversUseCase, + private val refreshContactPaykitLink: RefreshContactPaykitLinkUseCase, private val samRockRepo: SamRockRepo, private val appUpdateSheet: AppUpdateTimedSheet, private val backupSheet: BackupTimedSheet, @@ -741,7 +741,7 @@ class AppViewModel @Inject constructor( paykitPaymentRequestRepo.activate(state.publicKey) if (!PubkyPublicKeyFormat.matches(pubkyRepo.publicKey.value, state.publicKey)) return paymentRequestIdentity = state.publicKey - refreshPrivateOnlyPaykitReceiverMarker("contact sync") + refreshPrivateOnlyPaykitApp("contact sync") if (!state.contactsLoaded) return val removedKeys = lastPrivatePaykitContactKeys - state.contactKeys @@ -782,7 +782,7 @@ class AppViewModel @Inject constructor( if (!isPaykitEnabled.value) return - refreshPrivateOnlyPaykitReceiverMarker(reason) + refreshPrivateOnlyPaykitApp(reason) privatePaykitRepo.reconcileReservedReceiveIndexes() .onFailure { Logger.warn("Failed to reconcile private Paykit receive indexes for '$reason'", it, context = TAG) @@ -876,6 +876,7 @@ class AppViewModel @Inject constructor( if (!isPaykitEnabled.value || pubkyRepo.publicKey.value == null || !walletRepo.walletExists()) return if (refreshMaintenance) paykitPaymentProofRepo.reconcile() paykitPaymentRequestRepo.refresh().onSuccess { + activityRepo.backfillPaykitContacts() presentNextIncomingPaykitPaymentRequest() } } @@ -1348,18 +1349,23 @@ class AppViewModel @Inject constructor( return shouldHideRequestSendSheet } - private suspend fun refreshPrivateOnlyPaykitReceiverMarker(reason: String) { + private suspend fun refreshPrivateOnlyPaykitApp(reason: String) { val settings = settingsStore.data.first() if (!settings.sharesPrivatePaykitEndpoints || settings.sharesPublicPaykitEndpoints) return if (pubkyRepo.publicKey.value == null) return - publicPaykitRepo.syncLocalReceiverMarker() + publicPaykitRepo.syncPaykitApp() .onFailure { - Logger.warn("Failed to refresh private Paykit receiver marker for '$reason'", it, context = TAG) + Logger.warn("Failed to refresh private Paykit app registration for '$reason'", it, context = TAG) } } private suspend fun retryPendingPaykitEndpointRemoval(contactKeys: Collection, reason: String) { + privatePaykitRepo.retryPendingEndpointRemoval(contactKeys) + .onFailure { + Logger.warn("Failed to retry private Paykit endpoint removal for '$reason'", it, context = TAG) + } + val settings = settingsStore.data.first() if (settings.publicPaykitCleanupPending) { val reconciliationResult = if (settings.sharesPublicPaykitEndpoints) { @@ -1375,11 +1381,6 @@ class AppViewModel @Inject constructor( Logger.warn("Failed to reconcile public Paykit state for '$reason'", it, context = TAG) } } - - privatePaykitRepo.retryPendingEndpointRemoval(contactKeys) - .onFailure { - Logger.warn("Failed to retry private Paykit endpoint removal for '$reason'", it, context = TAG) - } } @Suppress("CyclomaticComplexMethod") @@ -3061,7 +3062,7 @@ class AppViewModel @Inject constructor( if (currentSheet.value is Sheet.Send) hideSheet() mainScreenEffect(MainScreenEffect.Navigate(route)) if (route is Routes.ContactDetail) { - refreshContactPaykitReceivers(route.publicKey) + refreshContactPaykitLink(route.publicKey) } return@withContext } @@ -4039,12 +4040,19 @@ class AppViewModel @Inject constructor( val incomingPaymentRequest = contactPaymentContext?.incomingPaymentRequest val proofPreparation = preparePaymentProof(incomingPaymentRequest) - if (proofPreparation.exceptionOrNull() is PaykitPaymentRequestError.OperationInProgress) { - handlePaymentPreparationFailure(PaykitPaymentRequestError.OperationInProgress, contactPaymentContext) + proofPreparation.exceptionOrNull()?.let { + handlePaymentPreparationFailure(it, contactPaymentContext) return } val preparedPaymentProofRequest = proofPreparation.getOrNull() + contactPaymentContext?.incomingPaymentRequest?.let { request -> + paykitPaymentRequestRepo.claimForPayment(request).onFailure { + cancelPaymentProofPreparation(preparedPaymentProofRequest) + handlePaymentPreparationFailure(it, contactPaymentContext) + return + } + } consumePrivatePaymentListIfNeeded(contactPaymentContext).onFailure { cancelPaymentProofPreparation(preparedPaymentProofRequest) handlePaymentPreparationFailure(it, contactPaymentContext) @@ -4119,6 +4127,7 @@ class AppViewModel @Inject constructor( ) { val address = _sendUiState.value.address val tags = _sendUiState.value.selectedTags + val proofPreparation = incomingPaymentRequest?.let { paymentProofPreparation(contactPaymentContext) } var proofRequest = preparedPaymentProofRequest var paymentProofStarted = false var sendAttempted = false @@ -4136,7 +4145,7 @@ class AppViewModel @Inject constructor( }, onBroadcast = { txId -> proofRequest = null - completeOnchainPaymentProofInBackground(incomingPaymentRequest, txId) + completeOnchainPaymentProofInBackground(incomingPaymentRequest, txId, proofPreparation) }, ).onSuccess { txId -> Logger.info("Onchain send result txid: $txId", context = TAG) @@ -4226,7 +4235,9 @@ class AppViewModel @Inject constructor( val paymentHash = decodedInvoice.paymentHash.toHex() associateLightningPaymentProof(incomingPaymentRequest, paymentHash).onFailure { cancelPaymentProofPreparation(proofRequest) - proofRequest = null + releasePrivatePaymentListIfNeeded(contactPaymentContext) + handlePaymentPreparationFailure(it, contactPaymentContext) + return } val tags = _sendUiState.value.selectedTags @@ -4322,10 +4333,17 @@ class AppViewModel @Inject constructor( else -> paykitPaymentProofRepo.failLightningPayment(paymentHash, error) } + @Suppress("ReturnCount") private suspend fun prepareContactPayment(contactPaymentContext: ContactPaymentContext?): Boolean { if (isPreparedContactPayment(contactPaymentContext)) return true if (!validateIncomingPaymentRequest(contactPaymentContext)) return false + contactPaymentContext?.incomingPaymentRequest?.let { request -> + paykitPaymentRequestRepo.claimForPayment(request).onFailure { + handlePaymentPreparationFailure(it, contactPaymentContext) + return false + } + } consumePrivatePaymentListIfNeeded(contactPaymentContext).onFailure { handlePaymentPreparationFailure(it, contactPaymentContext) return false @@ -4349,10 +4367,11 @@ class AppViewModel @Inject constructor( private suspend fun preparePaymentProof(request: PaykitPaymentRequest?): Result { if (request == null) return Result.success(null) - val preparation = paymentProofPreparation() + val preparation = runCatching { paymentProofPreparation() }.getOrElse { return Result.failure(it) } return paykitPaymentProofRepo.prepare( request = request, paymentEndpointIdentifier = preparation.endpointIdentifier, + paymentAppId = preparation.appId, kind = preparation.kind, ).map { request } } @@ -4360,23 +4379,30 @@ class AppViewModel @Inject constructor( private suspend fun associateLightningPaymentProof( request: PaykitPaymentRequest?, paymentHash: String, - ): Result = request?.let { + ): Result = runSuspendCatching { + if (request == null) return@runSuspendCatching + val preparation = paymentProofPreparation() paykitPaymentProofRepo.associateLightningPayment( - request = it, + request = request, paymentHash = paymentHash, - paymentEndpointIdentifier = paymentProofPreparation().endpointIdentifier, - ) + paymentEndpointIdentifier = preparation.endpointIdentifier, + paymentAppId = preparation.appId, + ).getOrThrow() } - ?: Result.success(Unit) - private fun completeOnchainPaymentProofInBackground(request: PaykitPaymentRequest?, txId: String) { + private fun completeOnchainPaymentProofInBackground( + request: PaykitPaymentRequest?, + txId: String, + preparation: PaymentProofPreparation?, + ) { val paymentRequest = request ?: return - val endpointIdentifier = paymentProofPreparation().endpointIdentifier + if (preparation == null) return viewModelScope.launch { paykitPaymentProofRepo.completeOnchainPayment( request = paymentRequest, txid = txId, - paymentEndpointIdentifier = endpointIdentifier, + paymentEndpointIdentifier = preparation.endpointIdentifier, + paymentAppId = preparation.appId, ) if (paymentRequest.billingPeriod != null) refreshIncomingPaykitPaymentRequests() } @@ -4393,7 +4419,11 @@ class AppViewModel @Inject constructor( request?.let { paykitPaymentProofRepo.cancelPreparation(it) } } - private fun paymentProofPreparation(): PaymentProofPreparation { + private fun paymentProofPreparation( + contactPaymentContext: ContactPaymentContext? = synchronized(contactPaymentContextLock) { + activeContactPaymentContext + }, + ): PaymentProofPreparation { val methodId = when (_sendUiState.value.payMethod) { SendMethod.ONCHAIN -> PublicPaykitRepo.onchainMethodId(_sendUiState.value.address) SendMethod.LIGHTNING -> if (_sendUiState.value.lnurl is LnurlParams.LnurlPay) { @@ -4402,8 +4432,11 @@ class AppViewModel @Inject constructor( MethodId.Bolt11 } } + val appId = contactPaymentContext?.privatePaymentContext?.paymentAppsByEndpoint?.get(methodId.rawValue) + ?: throw PaykitPaymentRequestError.RequestUnavailable return PaymentProofPreparation( endpointIdentifier = methodId.rawValue, + appId = appId, kind = if (methodId.isOnchain) PaykitPaymentProofKind.Onchain else PaykitPaymentProofKind.Lightning, ) } @@ -5248,8 +5281,8 @@ class AppViewModel @Inject constructor( val incomingPaymentRequest = contactPaymentContext?.incomingPaymentRequest val proofPreparation = preparePaymentProof(incomingPaymentRequest) - if (proofPreparation.exceptionOrNull() is PaykitPaymentRequestError.OperationInProgress) { - handlePaymentPreparationFailure(PaykitPaymentRequestError.OperationInProgress, contactPaymentContext) + proofPreparation.exceptionOrNull()?.let { + handlePaymentPreparationFailure(it, contactPaymentContext) return false } val preparedPaymentProofRequest = proofPreparation.getOrNull() @@ -5291,10 +5324,12 @@ class AppViewModel @Inject constructor( } fun completeHardwareContactPayment(txId: String) { - val incomingPaymentRequest = synchronized(contactPaymentContextLock) { - activeContactPaymentContext?.incomingPaymentRequest + val context = synchronized(contactPaymentContextLock) { + preparedContactPaymentContext } - completeOnchainPaymentProofInBackground(incomingPaymentRequest, txId) + val request = context?.incomingPaymentRequest + val preparation = request?.let { paymentProofPreparation(context) } + completeOnchainPaymentProofInBackground(request, txId, preparation) } fun onHardwareSignCancelled() { @@ -6060,6 +6095,7 @@ data class ContactPaymentContext( private data class PaymentProofPreparation( val endpointIdentifier: String, + val appId: String, val kind: PaykitPaymentProofKind, ) diff --git a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt index 224bf398c8..56fd4e6e62 100644 --- a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt @@ -277,6 +277,11 @@ class SettingsViewModel @Inject constructor( private suspend fun removePaykitEndpoints(hadPublicPaykitState: Boolean, hadPrivatePaykitState: Boolean) { val contacts = pubkyRepo.contacts.value.map { it.publicKey } + privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contacts) + .onFailure { + Logger.warn("Failed to remove private Paykit endpoints after disabling Paykit UI", it, context = TAG) + } + if (hadPublicPaykitState) { publicPaykitRepo.syncPublishedEndpoints(publish = false) .onSuccess { @@ -287,17 +292,12 @@ class SettingsViewModel @Inject constructor( Logger.warn("Failed to remove public Paykit endpoints after disabling Paykit UI", it, context = TAG) } } else if (hadPrivatePaykitState) { - publicPaykitRepo.syncLocalReceiverMarker(publicSharingEnabled = false, privateSharingEnabled = false) + publicPaykitRepo.syncPaykitApp(privateSharingEnabled = false) .onFailure { settingsStore.update { settings -> settings.copy(publicPaykitCleanupPending = true) } - Logger.warn("Failed to remove Paykit receiver marker after disabling Paykit UI", it, context = TAG) + Logger.warn("Failed to update Paykit app capabilities after disabling Paykit UI", it, context = TAG) } } - - privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contacts) - .onFailure { - Logger.warn("Failed to remove private Paykit endpoints after disabling Paykit UI", it, context = TAG) - } } val isPinEnabled = settingsStore.data.map { it.isPinEnabled } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bc65f16c45..41669bb81d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -640,6 +640,8 @@ Authorize Authorizing… OK + Read and manage your private Paykit data, and send Paykit messages on your behalf. Your Pubky identity secret and wallet spending keys are not shared. + Paykit access Requested permissions Requester ID: %1$s A service is requesting permission to access and edit your <accent>%1$s</accent> data. diff --git a/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt b/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt index 912a5174ac..edc194cd23 100644 --- a/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt +++ b/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt @@ -18,7 +18,7 @@ class PaykitPaymentStateBackupTest { fun `payment backup accepts shared wire format and retains pending payment`() { WalletScope.pushTestOverride("wallet0").use { val fixture = """ - {"subscriptions":{"alice":{"acceptances":[{"id":{"paymentRequestId":"request","counterparty":"bob","counterpartyReceiverPath":"bitkit/server"},"acceptedAt":"2026-09-24T10:00:00.123Z"}],"presentedProposalIds":[]}},"pendingProofs":[{"identity":"alice","requestId":{"paymentRequestId":"request","counterparty":"bob","counterpartyReceiverPath":"bitkit/server","billingPeriodStartsAt":"2026-09-24T10:00:00.100Z"},"paymentEndpointIdentifier":"bitcoin-onchain","kind":"bitcoin-onchain-txid","paymentStarted":true,"billingPeriod":{"startsAt":"2026-09-24T10:00:00.100Z","endsAt":"2026-09-25T10:00:00.100Z"},"onchainMatchingTransactionIdsBeforeAttempt":[]}]} + {"subscriptions":{"alice":{"acceptances":[{"id":{"paymentRequestId":"request","counterparty":"bob"},"acceptedAt":"2026-09-24T10:00:00.123Z"}],"presentedProposalIds":[]}},"pendingProofs":[{"identity":"alice","requestId":{"paymentRequestId":"request","counterparty":"bob","billingPeriodStartsAt":"2026-09-24T10:00:00.100Z"},"paymentAppId":"bitkit","paymentEndpointIdentifier":"bitcoin-onchain","kind":"bitcoin-onchain-txid","paymentStarted":true,"billingPeriod":{"startsAt":"2026-09-24T10:00:00.100Z","endsAt":"2026-09-25T10:00:00.100Z"},"onchainMatchingTransactionIdsBeforeAttempt":[]}]} """.trimIndent() val backup = Json.decodeFromString(fixture) val restored = backup.pendingProofs.single().restored() diff --git a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt index 38afcab01f..32f92a9362 100644 --- a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt +++ b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt @@ -1,8 +1,10 @@ package to.bitkit.models +import to.bitkit.models.PubkyAuthClaim.Item import java.net.URLEncoder import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertNull @@ -10,6 +12,92 @@ import kotlin.test.assertTrue class PubkyAuthRequestTest { + @Test + fun `parse preserves each companion selection and received item order`() { + val expected = mapOf( + "watch-only-account-v1" to listOf(Item.WATCH_ONLY_ACCOUNT_V1), + "paykit-access-v1" to listOf(Item.PAYKIT_ACCESS_V1), + "paykit-access-v1.watch-only-account-v1" to listOf(Item.PAYKIT_ACCESS_V1, Item.WATCH_ONLY_ACCOUNT_V1), + "watch-only-account-v1.paykit-access-v1" to listOf(Item.WATCH_ONLY_ACCOUNT_V1, Item.PAYKIT_ACCESS_V1), + ) + for ((wireValue, items) in expected) { + val claim = requireNotNull( + PubkyAuthRequest.parseBitkitClaim( + authUrl("/pub/paykit/:rw", wireValue), + "/pub/paykit/:rw", + ).getOrThrow(), + ) + assertEquals(items, claim.items) + assertEquals(wireValue, claim.wireValue) + assertEquals(Item.PAYKIT_ACCESS_V1 in items, claim.includesPaykitAccess) + assertEquals(Item.WATCH_ONLY_ACCOUNT_V1 in items, claim.includesWatchOnlyAccount) + } + } + + @Test + fun `constructor copies items in canonical order and rejects empty or duplicate selections`() { + val items = arrayOf(Item.WATCH_ONLY_ACCOUNT_V1, Item.PAYKIT_ACCESS_V1) + val claim = PubkyAuthClaim(*items) + items[0] = Item.PAYKIT_ACCESS_V1 + + assertEquals(listOf(Item.PAYKIT_ACCESS_V1, Item.WATCH_ONLY_ACCOUNT_V1), claim.items) + assertEquals("paykit-access-v1.watch-only-account-v1", claim.wireValue) + assertFailsWith { PubkyAuthClaim() } + for (item in Item.entries) { + assertFailsWith { PubkyAuthClaim(item, item) } + } + } + + @Test + fun `parse rejects empty duplicate unknown and combined identifiers`() { + val invalid = listOf( + "", ".", ".paykit-access-v1", "paykit-access-v1.", + "paykit-access-v1..watch-only-account-v1", + "paykit-access-v1.paykit-access-v1", "watch-only-account-v1.watch-only-account-v1", + "paykit-access-v1.watch-only-account-v1.paykit-access-v1", + "unknown-v1", "paykit-access-v1.unknown-v1", "unknown-v1.watch-only-account-v1", + "paykit-access-and-watch-only-account-v1", "PAYKIT-ACCESS-V1", "paykit-access-v1%20", + ) + for (wireValue in invalid) { + assertIs( + PubkyAuthRequest.parseBitkitClaim(authUrl("/pub/paykit/:rw", wireValue), "/pub/paykit/:rw") + .exceptionOrNull(), + wireValue, + ) + } + } + + @Test + fun `parse rejects duplicate mixed or encoded companion parameters`() { + for (claim in companionSelections()) { + for (other in companionSelections()) { + val url = authUrl("/pub/paykit/:rw", claim.wireValue) + "&x-bitkit-%63laim=${other.wireValue}" + assertIs( + PubkyAuthRequest.parseBitkitClaim(url, "/pub/paykit/:rw").exceptionOrNull(), + ) + } + } + } + + @Test + fun `all companion claims require the exact Paykit scope`() { + val invalidCapabilities = listOf( + "/pub/paykit/:r", + "/pub/paykit/v0/:rw", + "/pub/:rw", + "/pub/paykit/:rw,/pub/other/:rw", + "/pub/paykit/:rw,/pub/paykit/:rw", + ) + for (claim in companionSelections()) { + for (capabilities in invalidCapabilities) { + assertIs( + PubkyAuthRequest.parseBitkitClaim(authUrl(capabilities, claim.wireValue), capabilities) + .exceptionOrNull(), + ) + } + } + } + @Test fun `parse authorized signup preserves registration and authorization details`() { listOf("pubkyring", "pubkyauth").forEach { scheme -> @@ -57,35 +145,22 @@ class PubkyAuthRequestTest { @Test fun `parse recognizes watch-only account claim`() { - val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + val capabilities = PubkyAuthClaim.REQUIRED_CAPABILITIES val request = PubkyAuthRequest.parse( - rawUrl = authUrl(capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue), + rawUrl = authUrl(capabilities, PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1).wireValue), clientId = "paykit.test", relay = "https://httprelay.pubky.app/inbox/", capabilities = capabilities, ).getOrThrow() - assertEquals(PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, request.bitkitClaim) + assertEquals(PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1), request.bitkitClaim) } @Test - fun `parse recognizes watch-only account claim with reordered capabilities`() { - val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES.split(",").reversed().joinToString(",") - val request = PubkyAuthRequest.parse( - rawUrl = authUrl(capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue), - clientId = "paykit.test", - relay = "https://httprelay.pubky.app/inbox/", - capabilities = capabilities, - ).getOrThrow() - - assertEquals(PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, request.bitkitClaim) - } - - @Test - fun `matcher recognizes watch-only account claim with capability whitespace`() { - val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES.replace(",", " , ") + fun `matcher recognizes the Paykit scope with surrounding whitespace`() { + val capabilities = " ${PubkyAuthClaim.REQUIRED_CAPABILITIES} " - assertTrue(PubkyAuthClaim.matchesWatchOnlyAccountCapabilities(capabilities)) + assertTrue(PubkyAuthClaim.matchesRequiredCapabilities(capabilities)) } @Test @@ -134,8 +209,8 @@ class PubkyAuthRequestTest { } @Test - fun `parse rejects watch-only capability without Bitkit claim`() { - val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + fun `parse permits Paykit authorization without a companion claim`() { + val capabilities = PubkyAuthClaim.REQUIRED_CAPABILITIES val result = PubkyAuthRequest.parse( rawUrl = authUrl(capabilities), clientId = "paykit.test", @@ -143,17 +218,17 @@ class PubkyAuthRequestTest { capabilities = capabilities, ) - assertIs(result.exceptionOrNull()) + assertEquals(null, result.getOrThrow().bitkitClaim) } @Test fun `parse rejects duplicate Bitkit claim`() { - val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + val capabilities = PubkyAuthClaim.REQUIRED_CAPABILITIES val result = PubkyAuthRequest.parse( rawUrl = authUrl( capabilities, - PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue, - PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue, + PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1).wireValue, + PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1).wireValue, ), clientId = "paykit.test", relay = "https://httprelay.pubky.app/inbox/", @@ -165,7 +240,7 @@ class PubkyAuthRequestTest { @Test fun `parse rejects unknown Bitkit claim`() { - val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + val capabilities = PubkyAuthClaim.REQUIRED_CAPABILITIES val result = PubkyAuthRequest.parse( rawUrl = authUrl(capabilities, "unknown-v1"), clientId = "paykit.test", @@ -181,7 +256,7 @@ class PubkyAuthRequestTest { fun `parse rejects watch-only claim with other capabilities`() { val capabilities = "/pub/paykit/v0/:rw" val result = PubkyAuthRequest.parse( - rawUrl = authUrl(capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue), + rawUrl = authUrl(capabilities, PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1).wireValue), clientId = "paykit.test", relay = "https://httprelay.pubky.app/inbox/", capabilities = capabilities, @@ -191,10 +266,10 @@ class PubkyAuthRequestTest { } @Test - fun `parse rejects watch-only claim without private capability`() { - val capabilities = "/pub/paykit/v0/bitkit/server/:rw" + fun `parse rejects watch-only claim without write capability`() { + val capabilities = "/pub/paykit/:r" val result = PubkyAuthRequest.parse( - rawUrl = authUrl(capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue), + rawUrl = authUrl(capabilities, PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1).wireValue), clientId = "paykit.test", relay = "https://httprelay.pubky.app/inbox/", capabilities = capabilities, @@ -205,9 +280,9 @@ class PubkyAuthRequestTest { @Test fun `parse rejects watch-only claim with empty capability`() { - val capabilities = "${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}," + val capabilities = "${PubkyAuthClaim.REQUIRED_CAPABILITIES}," val result = PubkyAuthRequest.parse( - rawUrl = authUrl(capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue), + rawUrl = authUrl(capabilities, PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1).wireValue), clientId = "paykit.test", relay = "https://httprelay.pubky.app/inbox/", capabilities = capabilities, @@ -272,8 +347,8 @@ class PubkyAuthRequestTest { @Test fun `displayPath removes capability separator`() { - val perm = PubkyAuthPermission(path = "/pub/paykit/v0/bitkit/server/", accessLevel = "rw") - assertEquals("/pub/paykit/v0/bitkit/server", perm.displayPath) + val perm = PubkyAuthPermission(path = "/pub/paykit/", accessLevel = "rw") + assertEquals("/pub/paykit", perm.displayPath) } @Test @@ -303,6 +378,13 @@ class PubkyAuthRequestTest { ) } + private fun companionSelections() = listOf( + PubkyAuthClaim(Item.PAYKIT_ACCESS_V1), + PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1), + PubkyAuthClaim(Item.PAYKIT_ACCESS_V1, Item.WATCH_ONLY_ACCOUNT_V1), + requireNotNull(PubkyAuthClaim.fromWireValue("watch-only-account-v1.paykit-access-v1")), + ) + private fun authUrl(capabilities: String, vararg claimValues: String): String { val claims = claimValues.joinToString(separator = "") { "&${PubkyAuthClaim.QUERY_PARAMETER}=$it" diff --git a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt index 61b8b3e102..7db4f233e2 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -173,6 +173,23 @@ class ActivityRepoTest : BaseUnitTest() { wheneverBlocking { coreService.activity.allPossibleTags() }.thenReturn(emptyList()) } + @Test + fun `Paykit backfill notifies activity observers only after a changed row`() = test { + whenever(coreService.activity.backfillPaykitContacts()).thenReturn(false, true) + + sut.backfillPaykitContacts().getOrThrow() + assertEquals(0L, sut.activitiesChanged.value) + sut.backfillPaykitContacts().getOrThrow() + assertTrue(sut.activitiesChanged.value > 0L) + assertEquals(0L, sut.activityTagsChanged.value) + } + + @Test + fun `Paykit backfill preserves cancellation`() = test { + whenever(coreService.activity.backfillPaykitContacts()).thenThrow(CancellationException("canceled")) + assertFailsWith { sut.backfillPaykitContacts() } + } + @Test fun `syncActivities success flow`() = test { val payments = listOf(testPaymentDetails) diff --git a/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt index a386543212..b9efee5774 100644 --- a/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt @@ -7,6 +7,7 @@ import org.junit.Before import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -43,7 +44,7 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { Unit } whenever { publicPaykitRepo.syncPublishedEndpoints(any()) }.thenReturn(Result.success(Unit)) - whenever { publicPaykitRepo.syncLocalReceiverMarker(anyOrNull(), anyOrNull()) } + whenever { publicPaykitRepo.syncPaykitApp(anyOrNull()) } .thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.enableSharingAndPrepareSavedContacts(any>()) } .thenReturn(Result.success(Unit)) @@ -105,7 +106,10 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { assertTrue(result.isFailure) assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) - verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) + inOrder(privatePaykitRepo, publicPaykitRepo) { + verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) + verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) + } } @Test @@ -134,8 +138,10 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) - verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) - verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) + inOrder(privatePaykitRepo, publicPaykitRepo) { + verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) + verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) + } } @Test diff --git a/app/src/test/java/to/bitkit/repositories/PaykitIssuerInteropTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitIssuerInteropTest.kt index 9bafd350c0..a5febcda89 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitIssuerInteropTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitIssuerInteropTest.kt @@ -114,7 +114,6 @@ class PaykitIssuerInteropTest { endpointIdentifiers: List, ) = PaymentRequestRecord( counterparty = "pubkyissuerfixture", - counterpartyReceiverPath = "bitkit/server", paymentRequestId = "71300000-0000-4000-8000-000000000001", localRole = PaymentRequestLocalRole.PAYER, state = PaymentRequestLifecycleState.PROPOSED, @@ -131,6 +130,8 @@ class PaykitIssuerInteropTest { conversion = null, paymentDeadline = null, metadata = METADATA, + paymentEndpoints = null, + requiredAppId = "bitkit", ), acceptedEventId = null, acceptedOutboundStatus = null, @@ -145,6 +146,9 @@ class PaykitIssuerInteropTest { lastOutboundStatus = null, lastEventAt = NOW.toString(), invalidReason = null, + proposalAppId = "bitkit", + payerAppId = null, + executionClaimAppId = null, ) } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 9079e92502..c91bd32910 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -31,7 +31,6 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.models.NodeLifecycleState import to.bitkit.models.WalletScope -import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError @@ -69,7 +68,9 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { shouldFailNextSave = false shouldFailProofRemoval = false whenever(store.hasPendingProofs()).thenReturn(true) - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, com.synonym.paykit.PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.processPendingPrivateMessages()).thenReturn(emptyList()) whenever(onchainPaymentLookup.existingTransactionIds(any(), any(), any())).thenReturn(emptySet()) whenever(store.load()).thenAnswer { @@ -114,7 +115,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `completed lightning proof retries after repository restart`() = test { + fun `completed lightning proof retains the payment app when retrying after repository restart`() = test { val record = paymentRequestRecord() val request = paymentRequest(MethodId.Bolt11.rawValue) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) @@ -123,8 +124,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { .thenReturn(record) val firstRepo = paymentProofRepo() - firstRepo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - firstRepo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() + firstRepo.prepare(request, MethodId.Bolt11.rawValue, "merchant", PaykitPaymentProofKind.Lightning).getOrThrow() + firstRepo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "merchant").getOrThrow() firstRepo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) assertEquals(PREIMAGE, storedProofs.single().proofData) @@ -135,8 +136,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val proofCaptor = argumentCaptor() verify(paykitSdkService, times(2)).submitPaymentProof( counterparty = any(), - counterpartyReceiverPath = any(), paymentRequestId = any(), + paymentAppId = eq("merchant"), paymentEndpointIdentifier = endpointCaptor.capture(), proofJson = proofCaptor.capture(), billingPeriod = isNull(), @@ -172,8 +173,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val paymentRequestIdCaptor = argumentCaptor() verify(paykitSdkService, times(2)).submitPaymentProof( counterparty = any(), - counterpartyReceiverPath = any(), paymentRequestId = paymentRequestIdCaptor.capture(), + paymentAppId = eq("bitkit"), paymentEndpointIdentifier = any(), proofJson = any(), billingPeriod = isNull(), @@ -200,8 +201,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) val firstRepo = paymentProofRepo() - firstRepo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - firstRepo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() + firstRepo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() + firstRepo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() assertNull(storedProofs.single().proofData) paymentProofRepo().reconcile() @@ -210,8 +211,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val proofCaptor = argumentCaptor() verify(paykitSdkService).submitPaymentProof( counterparty = any(), - counterpartyReceiverPath = any(), paymentRequestId = any(), + paymentAppId = eq("bitkit"), paymentEndpointIdentifier = any(), proofJson = proofCaptor.capture(), billingPeriod = isNull(), @@ -231,7 +232,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) val repo = paymentProofRepo() - repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() repo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) @@ -243,8 +244,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.Bolt11.rawValue) val repo = paymentProofRepo() - repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() repo.completeLightningPayment(PAYMENT_HASH, "01".repeat(32)) assertNull(storedProofs.single().proofData) @@ -259,6 +260,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val existingProof = mock { on { billingPeriod } doReturn null on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue + on { paymentAppId } doReturn "bitkit" on { proof } doReturn existingProofJson } val record = paymentRequestRecord(listOf(existingProof)) @@ -266,8 +268,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) val repo = paymentProofRepo() - repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() repo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) assertTrue(storedProofs.isEmpty()) @@ -279,8 +281,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.Bolt11.rawValue) val repo = paymentProofRepo() - repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() repo.failLightningPayment(PAYMENT_HASH) assertTrue(storedProofs.isEmpty()) @@ -302,8 +304,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { for (error in errors) { val request = paymentRequest(MethodId.Bolt11.rawValue) val repo = paymentProofRepo() - repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() val failed = repo.failLightningPayment(PAYMENT_HASH, error) repo.cancelPreparation(request) @@ -313,7 +315,12 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertFalse(failed) assertTrue(storedProofs.single().paymentStarted) assertEquals(PAYMENT_HASH, storedProofs.single().paymentIdentifier) - val retry = restartedRepo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning) + val retry = restartedRepo.prepare( + request, + MethodId.Bolt11.rawValue, + "bitkit", + PaykitPaymentProofKind.Lightning + ) assertTrue(retry.exceptionOrNull() is PaykitPaymentRequestError.OperationInProgress) restartedRepo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) @@ -337,8 +344,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { for (error in errors) { val request = paymentRequest(MethodId.Bolt11.rawValue) val repo = paymentProofRepo() - repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() assertTrue(repo.failLightningPayment(PAYMENT_HASH, error)) assertTrue(storedProofs.isEmpty()) @@ -354,10 +361,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) val repo = paymentProofRepo() - repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() assertTrue(storedProofs.single().paymentStarted) - repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue) + repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue, "bitkit") val endpointCaptor = argumentCaptor() val proofCaptor = argumentCaptor() @@ -382,7 +389,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.P2wpkh.rawValue) val repo = paymentProofRepo() - repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() repo.cancelPreparation(request) @@ -394,7 +401,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.P2wpkh.rawValue) val repo = paymentProofRepo() - repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() repo.failOnchainPayment(request) @@ -405,7 +412,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `onchain failure clears started proof without a live identity`() = test { val request = paymentRequest(MethodId.P2wpkh.rawValue) val repo = paymentProofRepo() - repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() whenever(paykitSdkService.identityStatus()).thenReturn(null) @@ -418,7 +425,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `cancel preparation clears proof without a live identity`() = test { val request = paymentRequest(MethodId.Bolt11.rawValue) val repo = paymentProofRepo() - repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() whenever(paykitSdkService.identityStatus()).thenReturn(null) repo.cancelPreparation(request) @@ -436,7 +443,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) val repo = paymentProofRepo() - repo.completeOnchainPayment(request, txid, endpoint) + repo.completeOnchainPayment(request, txid, endpoint, "bitkit") verify(paykitSdkService).submitPaymentProof(any(), any(), any(), eq(endpoint), any(), isNull()) assertTrue(storedProofs.isEmpty()) @@ -454,15 +461,15 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), any())).thenReturn(record) val repo = paymentProofRepo() - repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() repo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) val periodCaptor = argumentCaptor() verify(paykitSdkService).submitPaymentProof( counterparty = any(), - counterpartyReceiverPath = any(), paymentRequestId = any(), + paymentAppId = eq("bitkit"), paymentEndpointIdentifier = any(), proofJson = any(), billingPeriod = periodCaptor.capture(), @@ -485,6 +492,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { endsAt = "2027-02-01T08:00:00.000Z", ) on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue + on { paymentAppId } doReturn "bitkit" on { proof } doReturn existingProofJson } val record = paymentRequestRecord(listOf(existingProof)) @@ -493,8 +501,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), any())).thenReturn(record) val repo = paymentProofRepo() - repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() repo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), any()) @@ -505,15 +513,15 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.Bolt11.rawValue) val repo = paymentProofRepo() - repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() - val retry = repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning) + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() + val retry = repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning) assertTrue(retry.exceptionOrNull() is PaykitPaymentRequestError.OperationInProgress) assertEquals(PAYMENT_HASH, storedProofs.single().paymentIdentifier) repo.failLightningPayment(PAYMENT_HASH) - repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() assertEquals(1, storedProofs.size) } @@ -524,9 +532,9 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val secondRequest = paymentRequest(MethodId.Bolt11.rawValue, secondRequestId) val repo = paymentProofRepo() - repo.prepare(firstRequest, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() + repo.prepare(firstRequest, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() storedProofs = emptyList() - repo.prepare(secondRequest, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() + repo.prepare(secondRequest, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() assertEquals(1, storedProofs.size) assertEquals(secondRequestId, storedProofs.single().requestId.paymentRequestId) @@ -541,10 +549,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) val repo = paymentProofRepo() - repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() shouldFailNextSave = true - repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue) + repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue, "bitkit") verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) assertTrue(storedProofs.isEmpty()) @@ -560,10 +568,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { .thenThrow(IllegalStateException("transient submission failure")) val repo = paymentProofRepo() - repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() shouldFailNextSave = true - repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue) + repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue, "bitkit") assertEquals(txid, storedProofs.single().proofData) verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) @@ -578,10 +586,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) val repo = paymentProofRepo() - repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() shouldFailProofRemoval = true - repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue) + repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue, "bitkit") verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) assertEquals(txid, storedProofs.single().proofData) @@ -597,17 +605,17 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) val repo = paymentProofRepo() - repo.prepare(request, endpoint, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.prepare(request, endpoint, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() shouldFailNextLoad = true - repo.completeOnchainPayment(request, txid, endpoint) + repo.completeOnchainPayment(request, txid, endpoint, "bitkit") val endpointCaptor = argumentCaptor() val proofCaptor = argumentCaptor() verify(paykitSdkService).submitPaymentProof( counterparty = any(), - counterpartyReceiverPath = any(), paymentRequestId = any(), + paymentAppId = eq("bitkit"), paymentEndpointIdentifier = endpointCaptor.capture(), proofJson = proofCaptor.capture(), billingPeriod = isNull(), @@ -637,7 +645,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ).thenReturn(txid) val repo = paymentProofRepo() - repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() repo.reconcile() @@ -645,8 +653,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val proofCaptor = argumentCaptor() verify(paykitSdkService).submitPaymentProof( counterparty = eq(request.counterparty), - counterpartyReceiverPath = eq(request.counterpartyReceiverPath), paymentRequestId = eq(request.paymentRequestId), + paymentAppId = eq("bitkit"), paymentEndpointIdentifier = eq(MethodId.P2wpkh.rawValue), proofJson = proofCaptor.capture(), billingPeriod = isNull(), @@ -670,9 +678,9 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(onchainPaymentLookup.transactionId(any(), any(), any(), any())) .thenReturn("ab".repeat(32), "cd".repeat(32)) val repo = paymentProofRepo() - repo.prepare(firstRequest, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.prepare(firstRequest, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(firstRequest, ONCHAIN_ADDRESS).getOrThrow() - repo.prepare(secondRequest, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.prepare(secondRequest, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(secondRequest, ONCHAIN_ADDRESS).getOrThrow() repo.reconcile() @@ -700,7 +708,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ).thenReturn(null) val repo = paymentProofRepo() - repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() repo.reconcile() @@ -717,11 +725,12 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { requestId = request.id, paymentEndpointIdentifier = MethodId.Bolt11.rawValue, kind = PaykitPaymentProofKind.Lightning, + paymentAppId = "bitkit", ) storedProofs = listOf(otherIdentityProof) val repo = paymentProofRepo() - repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() repo.cancelPreparation(request) assertEquals(listOf(otherIdentityProof), storedProofs) @@ -735,11 +744,11 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) val request = paymentRequest(MethodId.Bolt11.rawValue, billingPeriod = period) val repo = paymentProofRepo() - repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() val protectedRequestIds = repo.protectedRequestIdsForSubscriptionCancellation( LOCAL_IDENTITY, - PaykitSubscriptionId(PAYMENT_REQUEST_ID, COUNTERPARTY, PaykitReceiverPaths.WALLET), + PaykitSubscriptionId(PAYMENT_REQUEST_ID, COUNTERPARTY), ).getOrThrow() assertTrue(protectedRequestIds.isEmpty()) @@ -754,12 +763,12 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) val request = paymentRequest(MethodId.Bolt11.rawValue, billingPeriod = period) val repo = paymentProofRepo() - repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() val protectedRequestIds = repo.protectedRequestIdsForSubscriptionCancellation( LOCAL_IDENTITY, - PaykitSubscriptionId(PAYMENT_REQUEST_ID, COUNTERPARTY, PaykitReceiverPaths.WALLET), + PaykitSubscriptionId(PAYMENT_REQUEST_ID, COUNTERPARTY), ).getOrThrow() assertEquals(setOf(request.id), protectedRequestIds) @@ -781,7 +790,6 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) = PaykitPaymentRequest( paymentRequestId = paymentRequestId, counterparty = COUNTERPARTY, - counterpartyReceiverPath = PaykitReceiverPaths.WALLET, amountValue = "0.00001", amountSats = 1_000uL, expiresAt = null, @@ -793,7 +801,6 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { identity = LOCAL_IDENTITY, requestId = PaykitPaymentRequestId( counterparty = COUNTERPARTY, - counterpartyReceiverPath = PaykitReceiverPaths.WALLET, paymentRequestId = paymentRequestId, ), paymentEndpointIdentifier = MethodId.Bolt11.rawValue, @@ -801,6 +808,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentStarted = true, paymentIdentifier = PAYMENT_HASH, proofData = PREIMAGE, + paymentAppId = "bitkit", ) private fun paymentRequestRecord( @@ -808,7 +816,6 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentRequestId: String = PAYMENT_REQUEST_ID, ) = PaymentRequestRecord( counterparty = COUNTERPARTY, - counterpartyReceiverPath = PaykitReceiverPaths.WALLET, paymentRequestId = paymentRequestId, localRole = PaymentRequestLocalRole.PAYER, state = PaymentRequestLifecycleState.PROPOSED, @@ -825,6 +832,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { conversion = null, paymentDeadline = null, metadata = mock(), + paymentEndpoints = null, + requiredAppId = "bitkit", ), acceptedEventId = null, acceptedOutboundStatus = null, @@ -839,5 +848,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { lastOutboundStatus = null, lastEventAt = "2027-01-15T08:00:00Z", invalidReason = null, + proposalAppId = "bitkit", + payerAppId = null, + executionClaimAppId = null, ) } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt index 4b4310e50f..d16a5ea6c6 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt @@ -40,7 +40,7 @@ class PaykitPaymentRequestPresentationStoreTest : BaseUnitTest() { Unit } val sut = PaykitPaymentRequestPresentationStore(keychain) - val requestId = PaykitPaymentRequestId("request", COUNTERPARTY, "bitkit/server") + val requestId = PaykitPaymentRequestId("request", COUNTERPARTY) val loadError = assertFailsWith { sut.load(IDENTITY) } val saveError = assertFailsWith { sut.save(IDENTITY, setOf(requestId)) } @@ -63,8 +63,8 @@ class PaykitPaymentRequestPresentationStoreTest : BaseUnitTest() { Unit } val sut = PaykitPaymentRequestPresentationStore(keychain) - val requestId = PaykitPaymentRequestId("request", COUNTERPARTY, "bitkit/server") - val subscriptionId = PaykitSubscriptionId("subscription", COUNTERPARTY, "bitkit/server") + val requestId = PaykitPaymentRequestId("request", COUNTERPARTY) + val subscriptionId = PaykitSubscriptionId("subscription", COUNTERPARTY) val dismissedOnly = PaykitSubscriptionPresentationState(dismissedPaymentIds = setOf(requestId)) val accepted = dismissedOnly.copy( acceptedAt = mapOf(subscriptionId to Instant.parse("2026-09-24T08:00:00.123Z")), @@ -102,8 +102,8 @@ class PaykitPaymentRequestPresentationStoreTest : BaseUnitTest() { Unit } val sut = PaykitPaymentRequestPresentationStore(keychain) - val millisecondId = PaykitSubscriptionId("millisecond", COUNTERPARTY, "bitkit/server") - val nanosecondId = PaykitSubscriptionId("nanosecond", COUNTERPARTY, "bitkit/server") + val millisecondId = PaykitSubscriptionId("millisecond", COUNTERPARTY) + val nanosecondId = PaykitSubscriptionId("nanosecond", COUNTERPARTY) val acceptedAt = mapOf( millisecondId to Instant.parse("2026-09-24T10:00:00.123Z"), nanosecondId to Instant.parse("2026-09-24T10:00:00.123456789Z"), @@ -139,7 +139,7 @@ class PaykitPaymentRequestPresentationStoreTest : BaseUnitTest() { fun `invalid subscription timestamp identifies its preserved key`() = test { val keychain = mock() val value = """ - {"subscriptionStatesByIdentity":{"$IDENTITY":{"acceptances":[{"id":{"paymentRequestId":"subscription","counterparty":"$COUNTERPARTY","counterpartyReceiverPath":"bitkit/server"},"acceptedAt":"not-a-timestamp"}]}}} + {"subscriptionStatesByIdentity":{"$IDENTITY":{"acceptances":[{"id":{"paymentRequestId":"subscription","counterparty":"$COUNTERPARTY"},"acceptedAt":"not-a-timestamp"}]}}} """.trimIndent() whenever(keychain.loadString(KEY)).thenReturn(value) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt index 7efb36af63..98a383ac66 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt @@ -16,6 +16,7 @@ import com.synonym.paykit.PaymentRequestRecord import com.synonym.paykit.PaymentRequestRecurrence import com.synonym.paykit.PaymentRequestTerms import com.synonym.paykit.PrivateJsonObject +import com.synonym.paykit.PubkyIdentityCapability import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async @@ -41,7 +42,6 @@ import org.mockito.kotlin.whenever import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.services.PaykitPaymentRequestProposalTerms -import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals @@ -88,7 +88,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat schedulerOriginMillis = testDispatcher.scheduler.currentTime whenever(paykitSdkService.processPendingPrivateMessages()).thenReturn(emptyList()) whenever(paykitSdkService.receivePrivateMessagesFromLinkedPeers()).thenReturn(emptyList()) - whenever(paykitSdkService.paymentRequests()).thenReturn(emptyList()) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(emptyList()) whenever(paykitSdkService.linkedPeers()).thenReturn(emptyList()) whenever(settingsStore.isPaykitEnabled).thenReturn(flowOf(true)) whenever(settingsStore.data).thenReturn(flowOf(SettingsData(sharesPrivatePaykitEndpoints = true))) @@ -127,7 +127,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat val metadata = mock { on { exportText() } doReturn metadataText } - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord( id = "recurring", @@ -166,7 +166,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat val metadata = mock { on { exportText() } doReturn metadataText } - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord( role = PaymentRequestLocalRole.PAYEE, @@ -219,17 +219,17 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat @Test fun `creator proposal sends recurring terms and stays queued until delivery`() = test { - val target = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + val target = PaykitPaymentRequestTarget(COUNTERPARTY) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), - ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).thenReturn( - listOf(PaykitReceiverPaths.SERVER), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.proposePaymentRequest(any(), any(), any(), eq(LOCAL_IDENTITY))) + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).thenReturn(true) + whenever(paykitSdkService.proposePaymentRequest(any(), any(), eq(LOCAL_IDENTITY))) .thenAnswer { invocation -> - val proposal = invocation.getArgument(2) + val proposal = invocation.getArgument(1) paymentRequestRecord( role = PaymentRequestLocalRole.PAYEE, expiresAt = proposal.proposalExpiresAt, @@ -259,7 +259,6 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat verifyBlocking(paykitSdkService) { proposePaymentRequest( eq(COUNTERPARTY), - eq(PaykitReceiverPaths.SERVER), captured.capture(), eq(LOCAL_IDENTITY), ) @@ -282,13 +281,15 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat @Test fun `oversized creator proposal is rejected before icon upload or enqueue`() = test { - val target = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + val target = PaykitPaymentRequestTarget(COUNTERPARTY) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)) - .thenReturn(listOf(PaykitReceiverPaths.SERVER)) + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)) + .thenReturn(true) listOf(null, byteArrayOf(0, 1, 2)).forEach { icon -> val result = sut.proposeSubscription( @@ -307,7 +308,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat } verifyBlocking(paykitSdkService, never()) { uploadProfileAvatar(any(), any(), anyOrNull()) } - verifyBlocking(paykitSdkService, never()) { proposePaymentRequest(any(), any(), any(), any()) } + verifyBlocking(paykitSdkService, never()) { proposePaymentRequest(any(), any(), any()) } assertTrue(sut.subscriptions.value.isEmpty()) assertFalse(sut.isCreatingRequest.value) } @@ -319,11 +320,10 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat role = PaymentRequestLocalRole.PAYEE, state = PaymentRequestLifecycleState.CANCELED, ) - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(proposed), listOf(canceled)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(proposed), listOf(canceled)) whenever( paykitSdkService.cancelPaymentRequest( COUNTERPARTY, - PaykitReceiverPaths.SERVER, PAYMENT_REQUEST_ID, ) ).thenReturn(canceled) @@ -335,7 +335,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat protectedRequestIdsForSubscriptionCancellation(any(), any()) } verifyBlocking(paykitSdkService) { - cancelPaymentRequest(COUNTERPARTY, PaykitReceiverPaths.SERVER, PAYMENT_REQUEST_ID) + cancelPaymentRequest(COUNTERPARTY, PAYMENT_REQUEST_ID) } assertEquals(PaymentRequestLifecycleState.CANCELED, sut.subscriptions.value.single().lifecycleState) assertFalse(sut.subscriptions.value.single().isCreatedVisible(clock.now())) @@ -358,9 +358,9 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat state = PaymentRequestLifecycleState.CANCELED, paymentProofs = listOf(proof), ) - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(active), listOf(canceled)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(active), listOf(canceled)) whenever( - paykitSdkService.cancelPaymentRequest(COUNTERPARTY, PaykitReceiverPaths.SERVER, PAYMENT_REQUEST_ID) + paykitSdkService.cancelPaymentRequest(COUNTERPARTY, PAYMENT_REQUEST_ID) ).thenReturn(canceled) sut.refresh().getOrThrow() val subscription = sut.subscriptions.value.single() @@ -383,7 +383,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat @Test fun `refresh does not report subscription proposals as one time parse failures`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(paymentRequestRecord())) sut.refresh().getOrThrow() @@ -401,7 +401,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat anchor = "2027-01-01T08:00:00Z", endsAt = null, ) - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf(paymentRequestRecord(recurrence = unsupportedRecurrence)), ) @@ -419,20 +419,21 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat fun `accepting subscription returns current period and preserves payment targets`() = test { val proposal = paymentRequestRecord() val active = paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING) - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(proposal), listOf(active)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(proposal), listOf(active)) whenever( paykitSdkService.acceptPaymentRequest( COUNTERPARTY, - PaykitReceiverPaths.SERVER, PAYMENT_REQUEST_ID, ) ).thenReturn(active) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)) - .thenReturn(listOf(PaykitReceiverPaths.SERVER)) - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)) + .thenReturn(true) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) sut.refresh().getOrThrow() sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() @@ -453,7 +454,9 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat fun `accepting subscription rejects terms changed after review`() = test { val reviewedRecord = paymentRequestRecord() val changedRecord = paymentRequestRecord(amount = "0.002") - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(reviewedRecord), listOf(changedRecord)) + whenever( + paykitSdkService.allPaymentRequests(anyOrNull()) + ).thenReturn(listOf(reviewedRecord), listOf(changedRecord)) sut.refresh().getOrThrow() val reviewedSubscription = sut.subscriptions.value.single() sut.refresh().getOrThrow() @@ -461,20 +464,19 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat val result = sut.accept(reviewedSubscription) assertTrue(result.exceptionOrNull() is PaykitPaymentRequestError.RequestUnavailable) - verifyBlocking(paykitSdkService, never()) { acceptPaymentRequest(any(), any(), any()) } + verifyBlocking(paykitSdkService, never()) { acceptPaymentRequest(any(), any()) } } @Test fun `accepted subscription stays successful when its immediate refresh fails`() = test { val proposal = paymentRequestRecord() val active = paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING) - whenever(paykitSdkService.paymentRequests()) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())) .thenReturn(listOf(proposal)) .thenThrow(IllegalStateException("refresh failed")) whenever( paykitSdkService.acceptPaymentRequest( COUNTERPARTY, - PaykitReceiverPaths.SERVER, PAYMENT_REQUEST_ID, ) ).thenReturn(active) @@ -489,7 +491,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat @Test fun `dismissed subscription period stays out of queue after refresh`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf(paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING)), ) sut.refresh().getOrThrow() @@ -508,7 +510,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat @Test fun `failed dismissal persistence keeps subscription payment in queue`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf(paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING)), ) sut.refresh().getOrThrow() @@ -535,7 +537,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat resumeRefresh.await() emptyList() } - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf(paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING)), ) whenever(presentationStore.load(SECOND_IDENTITY)).thenReturn(emptySet()) @@ -559,12 +561,11 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat val requestId = PaykitPaymentRequestId( paymentRequestId = PAYMENT_REQUEST_ID, counterparty = COUNTERPARTY, - counterpartyReceiverPath = PaykitReceiverPaths.SERVER, billingPeriodStartsAt = "2027-01-01T08:00:00Z", ) whenever(paymentProofStore.completedRequestProofKindsAwaitingSubmission(LOCAL_IDENTITY)) .thenReturn(mapOf(requestId to PaykitPaymentProofKind.Onchain)) - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord( state = PaymentRequestLifecycleState.ACTIVE_RECURRING, @@ -593,7 +594,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat ) on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue } - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord( state = PaymentRequestLifecycleState.ACTIVE_RECURRING, @@ -618,7 +619,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat ) on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue } - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord( state = PaymentRequestLifecycleState.CANCELED, @@ -627,7 +628,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat ), ) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED)), ) sut.refresh().getOrThrow() @@ -650,7 +651,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat ) on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue } - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord( role = PaymentRequestLocalRole.PAYEE, @@ -660,7 +661,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat ), ) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED)), ) sut.refresh().getOrThrow() @@ -683,7 +684,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat ) on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue } - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord( state = PaymentRequestLifecycleState.ACTIVE_RECURRING, @@ -693,7 +694,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat ), ) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED)), ) sut.refresh().getOrThrow() @@ -712,7 +713,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat on { billingPeriod } doReturn BillingPeriod("2027-01-01T08:00:00Z", "2027-02-01T08:00:00Z") on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue } - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf(PaymentRequestLocalRole.PAYER, PaymentRequestLocalRole.PAYEE).map { role -> paymentRequestRecord( id = role.name, @@ -744,11 +745,10 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat val requestId = PaykitPaymentRequestId( paymentRequestId = PAYMENT_REQUEST_ID, counterparty = COUNTERPARTY, - counterpartyReceiverPath = PaykitReceiverPaths.SERVER, billingPeriodStartsAt = "2027-01-01T08:00:00Z", ) whenever(paymentProofStore.inFlightRequestIds(LOCAL_IDENTITY)).thenReturn(setOf(requestId)) - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf(paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING)), ) @@ -763,20 +763,19 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat val requestId = PaykitPaymentRequestId( paymentRequestId = PAYMENT_REQUEST_ID, counterparty = COUNTERPARTY, - counterpartyReceiverPath = PaykitReceiverPaths.SERVER, billingPeriodStartsAt = "2027-01-01T08:00:00Z", ) val active = paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING) whenever(paymentProofRepo.protectedRequestIdsForSubscriptionCancellation(eq(LOCAL_IDENTITY), any())) .thenReturn(Result.success(setOf(requestId))) - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(active)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(active)) sut.refresh().getOrThrow() val result = sut.cancel(sut.subscriptions.value.single()) assertTrue(result.exceptionOrNull() is PaykitPaymentRequestError.OperationInProgress) assertEquals(1, sut.subscriptions.value.size) - verifyBlocking(paykitSdkService, never()) { cancelPaymentRequest(any(), any(), any(), anyOrNull()) } + verifyBlocking(paykitSdkService, never()) { cancelPaymentRequest(any(), any(), anyOrNull()) } } @Test @@ -789,11 +788,10 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat state = PaymentRequestLifecycleState.CANCELED, paymentDeadline = PaymentDeadline.PeriodStart(3600uL), ) - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(active), emptyList()) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(active), emptyList()) whenever( paykitSdkService.cancelPaymentRequest( COUNTERPARTY, - PaykitReceiverPaths.SERVER, PAYMENT_REQUEST_ID, ) ).thenReturn(canceled) @@ -802,13 +800,13 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat sut.cancel(sut.subscriptions.value.single()).getOrThrow() verifyBlocking(paykitSdkService) { - cancelPaymentRequest(COUNTERPARTY, PaykitReceiverPaths.SERVER, PAYMENT_REQUEST_ID) + cancelPaymentRequest(COUNTERPARTY, PAYMENT_REQUEST_ID) } } @Test fun `malformed expiry is rejected and unsupported payment details disable acceptance`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord(id = "malformed", expiresAt = "not-a-timestamp"), paymentRequestRecord(id = "deadline", paymentDeadline = PaymentDeadline.PeriodStart(3600uL)), @@ -825,12 +823,12 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat val deadlineSubscription = subscriptions.first { it.paymentRequestId == "deadline" } assertEquals(null, deadlineSubscription.paymentDueOnAcceptance(clock.now())) assertTrue(sut.accept(deadlineSubscription).exceptionOrNull() is PaykitPaymentRequestError.RequestUnavailable) - verifyBlocking(paykitSdkService, never()) { acceptPaymentRequest(any(), any(), any()) } + verifyBlocking(paykitSdkService, never()) { acceptPaymentRequest(any(), any()) } } @Test fun `presented subscription stays available without auto presenting after reactivation`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf(paymentRequestRecord(id = "subscription")), ) sut.refresh().getOrThrow() @@ -855,7 +853,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat @Test fun `subscription proposal moves to expired at its deadline`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf(paymentRequestRecord(expiresAt = clock.now().plus(10.seconds).toString())), ) sut.refresh().getOrThrow() @@ -876,7 +874,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat anchor = "2027-01-01T08:00:00Z", endsAt = clock.now().plus(10.seconds).toString(), ) - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf(paymentRequestRecord(recurrence = endingRecurrence)), ) sut.refresh().getOrThrow() @@ -890,7 +888,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat @Test fun `ended subscription keeps its unpaid period available`() = test { - val subscriptionId = PaykitSubscriptionId(PAYMENT_REQUEST_ID, COUNTERPARTY, PaykitReceiverPaths.SERVER) + val subscriptionId = PaykitSubscriptionId(PAYMENT_REQUEST_ID, COUNTERPARTY) val endingRecurrence = PaymentRequestRecurrence( every = 1u, unit = "month", @@ -904,7 +902,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat acceptedAt = mapOf(subscriptionId to Instant.parse("2027-01-01T08:00:00Z")), ), ) - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord( state = PaymentRequestLifecycleState.ACTIVE_RECURRING, @@ -937,7 +935,6 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat paymentProofs: List = emptyList(), ) = PaymentRequestRecord( counterparty = COUNTERPARTY, - counterpartyReceiverPath = PaykitReceiverPaths.SERVER, paymentRequestId = id, localRole = role, state = state, @@ -954,6 +951,8 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat conversion = null, paymentDeadline = paymentDeadline, metadata = metadata, + paymentEndpoints = null, + requiredAppId = "bitkit", ), acceptedEventId = null, acceptedOutboundStatus = null, @@ -968,14 +967,15 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat lastOutboundStatus = null, lastEventAt = clock.now().toString(), invalidReason = null, + proposalAppId = "bitkit", + payerAppId = null, + executionClaimAppId = null, ) private fun linkedPeer( publicKey: String, state: LinkedPeerState, - receiverPath: String, ) = LinkedPeerRecord( counterparty = publicKey, - counterpartyReceiverPath = receiverPath, state = state, lastSyncAt = null, lastPrivateReceiveAt = null, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index c198abd9da..9549766b94 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -2,6 +2,10 @@ package to.bitkit.repositories +import com.synonym.bitkitcore.AddressType +import com.synonym.bitkitcore.NetworkType +import com.synonym.bitkitcore.ValidationResult +import com.synonym.bitkitcore.validateBitcoinAddress import com.synonym.paykit.IdentityStatus import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState @@ -17,6 +21,7 @@ import com.synonym.paykit.PaymentRequestTerms import com.synonym.paykit.PrivateJsonObject import com.synonym.paykit.PrivateOperationError import com.synonym.paykit.PrivateStreamCounterpartyIntakeReport +import com.synonym.paykit.PubkyIdentityCapability import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async @@ -27,7 +32,9 @@ import kotlinx.coroutines.test.runCurrent import org.junit.After import org.junit.Before import org.junit.Test +import org.mockito.Mockito.mockStatic import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.doReturn @@ -41,8 +48,8 @@ import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore +import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.services.PaykitPaymentRequestProposalTerms -import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals @@ -91,7 +98,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { schedulerOriginMillis = testDispatcher.scheduler.currentTime whenever(paykitSdkService.processPendingPrivateMessages()).thenReturn(emptyList()) whenever(paykitSdkService.receivePrivateMessagesFromLinkedPeers()).thenReturn(emptyList()) - whenever(paykitSdkService.paymentRequests()).thenReturn(emptyList()) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(emptyList()) whenever(paykitSdkService.linkedPeers()).thenReturn(emptyList()) whenever(settingsStore.isPaykitEnabled).thenReturn(flowOf(true)) whenever(settingsStore.data).thenReturn(flowOf(SettingsData(sharesPrivatePaykitEndpoints = true))) @@ -122,18 +129,52 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.clear() } + @Test + fun `shared app destinations stay out of request UI and clear on identity switch`() = test { + val address = PaykitReceivedPaymentContactsTest.ADDRESS + val record = paymentRequestRecord(role = PaymentRequestLocalRole.PAYEE).let { + it.copy( + proposalAppId = "marketplace", + terms = it.terms!!.copy( + acceptedPaymentEndpointIdentifiers = listOf(MethodId.P2wpkh.rawValue), + paymentEndpoints = mapOf( + MethodId.P2wpkh.rawValue to PaykitReceivedPaymentContactsTest.payload(address) + ), + ) + ) + } + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) + mockStatic(Class.forName("com.synonym.bitkitcore.Bitkitcore_androidKt")).use { native -> + native.`when` { validateBitcoinAddress(address) } + .thenReturn(ValidationResult(address, NetworkType.REGTEST, AddressType.P2WPKH)) + sut.refresh().getOrThrow() + } + assertEquals( + setOf(PubkyPublicKeyFormat.normalized(COUNTERPARTY)), + sut.receivedPaymentContacts.contactsForAddresses(listOf(address)) + ) + assertTrue(sut.pendingRequests.value.isEmpty()) + assertTrue(sut.paymentRequestHistory.value.isEmpty()) + verify(paykitSdkService).allPaymentRequests(PubkyPublicKeyFormat.normalized(LOCAL_IDENTITY)) + + sut.activate(SECOND_IDENTITY) + assertTrue(sut.receivedPaymentContacts.contactsForAddresses(listOf(address)).isEmpty()) + sut.clear() + assertTrue(sut.receivedPaymentContacts.contactsForAddresses(listOf(address)).isEmpty()) + } + @Test fun `blocking peer hides requests from an earlier snapshot`() = test { val record = paymentRequestRecord() - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) sut.refresh().getOrThrow() assertEquals(1, sut.pendingRequests.value.size) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED, record.counterpartyReceiverPath)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED)), ) sut.refresh().getOrThrow() assertTrue(sut.pendingRequests.value.isEmpty()) - whenever(paykitSdkService.paymentRequests()).thenReturn(emptyList()) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(emptyList()) sut.refresh().getOrThrow() assertTrue(sut.paymentRequestHistory.value.isEmpty()) } @@ -141,11 +182,11 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `blocking an already presented accepted request prevents payment`() = test { val record = paymentRequestRecord(state = PaymentRequestLifecycleState.ACCEPTED) - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) sut.refresh().getOrThrow() val request = sut.pendingRequests.value.single() whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED, record.counterpartyReceiverPath)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED)), ) assertEquals(PaykitPaymentRequestError.RequestUnavailable, sut.accept(request).exceptionOrNull()) } @@ -153,7 +194,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `accepted request checks blocking after waiting for synchronization`() = test { val record = paymentRequestRecord(state = PaymentRequestLifecycleState.ACCEPTED) - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) sut.refresh().getOrThrow() val request = sut.pendingRequests.value.single() val refreshPaused = CompletableDeferred() @@ -166,7 +207,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { resumeRefresh.await() emptyList() } else if (blocked) { - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED, record.counterpartyReceiverPath)) + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED)) } else { emptyList() } @@ -185,7 +226,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `refresh maps actionable bitcoin request`() = test { val record = paymentRequestRecord(expiresAt = clock.now().plus(60.seconds).toString()) - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) sut.refresh().getOrThrow() @@ -200,12 +241,11 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { val error = mock { on { redactedContext() } doReturn "transport failure" } - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) whenever(paykitSdkService.receivePrivateMessagesFromLinkedPeers()).thenReturn( listOf( PrivateStreamCounterpartyIntakeReport( counterparty = COUNTERPARTY, - counterpartyReceiverPath = PaykitReceiverPaths.WALLET, report = null, error = error, ), @@ -254,7 +294,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { asset = "BTC", counterparty = "secret", ) - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) sut.refresh().getOrThrow() @@ -262,22 +302,22 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `refresh logs unknown local role as unsupported_local_role`() = test { + fun `refresh excludes unknown local roles at the app boundary`() = test { val record = paymentRequestRecord( role = PaymentRequestLocalRole.UNKNOWN, counterparty = "secret", ) - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) sut.refresh().getOrThrow() - verify(diagnostics).logParseRejection("secret", PaykitPaymentRequest.ParseFailure.UnsupportedLocalRole) + verify(diagnostics, never()).logParseRejection(any(), any()) assertTrue(sut.pendingRequests.value.isEmpty()) } @Test fun `refresh does not log outgoing payee requests`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf(paymentRequestRecord(role = PaymentRequestLocalRole.PAYEE, counterparty = "secret")), ) @@ -288,7 +328,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `refresh does not log expired requests`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf(paymentRequestRecord(expiresAt = clock.now().toString())), ) @@ -299,7 +339,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `refresh rejects amounts outside the app payment range`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord(id = "millisatoshi-safe-max", amount = "184467440.73709551"), paymentRequestRecord(id = "millisatoshi-overflow", amount = "184467440.73709552"), @@ -320,7 +360,6 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = PaykitPaymentRequest( paymentRequestId = PAYMENT_REQUEST_ID, counterparty = COUNTERPARTY, - counterpartyReceiverPath = PaykitReceiverPaths.SERVER, amountValue = "0.000025", amountSats = 2_500uL, expiresAt = null, @@ -335,7 +374,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `refresh drops expired unsupported and non payer requests`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord(expiresAt = clock.now().toString()), paymentRequestRecord(id = "unsupported", endpoints = listOf("btc-unsupported-method")), @@ -350,7 +389,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `refresh keeps one time bitcoin lifecycle history`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord(id = "incoming"), paymentRequestRecord(id = "accepted", state = PaymentRequestLifecycleState.ACCEPTED), @@ -405,11 +444,10 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { val requestId = PaykitPaymentRequestId( paymentRequestId = PAYMENT_REQUEST_ID, counterparty = COUNTERPARTY, - counterpartyReceiverPath = PaykitReceiverPaths.SERVER, ) whenever(paymentProofStore.completedRequestProofKindsAwaitingSubmission(LOCAL_IDENTITY)) .thenReturn(mapOf(requestId to PaykitPaymentProofKind.Onchain)) - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) sut.refresh().getOrThrow() @@ -423,7 +461,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { val proof = mock { on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue } - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord( state = PaymentRequestLifecycleState.PROOF_SUBMITTED, @@ -439,7 +477,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `pending request is removed exactly when it expires`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf(paymentRequestRecord(expiresAt = clock.now().plus(10.seconds).toString())), ) sut.refresh().getOrThrow() @@ -459,7 +497,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `outgoing request moves to expired history exactly when it expires`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord( role = PaymentRequestLocalRole.PAYEE, @@ -484,11 +522,10 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `accept removes current request and delivers queued response`() = test { val record = paymentRequestRecord() - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) whenever( paykitSdkService.acceptPaymentRequest( COUNTERPARTY, - PaykitReceiverPaths.SERVER, PAYMENT_REQUEST_ID, ), ).thenReturn(record) @@ -507,11 +544,10 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { val record = paymentRequestRecord() val deliveryStarted = CompletableDeferred() val finishDelivery = CompletableDeferred() - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) whenever( paykitSdkService.rejectPaymentRequest( COUNTERPARTY, - PaykitReceiverPaths.SERVER, PAYMENT_REQUEST_ID, ), ).thenReturn(record) @@ -538,7 +574,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `surfaced request stays pending and is excluded from automatic presentation`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(paymentRequestRecord())) sut.refresh().getOrThrow() val request = sut.pendingRequests.value.single() @@ -551,7 +587,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `switching identity clears request state and restores only that identity suppression`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(paymentRequestRecord())) sut.refresh().getOrThrow() val request = sut.pendingRequests.value.single() sut.markPresented(request) @@ -573,7 +609,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { resumeRefresh.await() emptyList() } - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(paymentRequestRecord())) whenever(presentationStore.load(SECOND_IDENTITY)).thenReturn(emptySet()) val refresh = async { sut.refresh() } @@ -593,18 +629,19 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `proposal uses exact linked capable path and canonical bitcoin terms`() = test { - val target = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + val target = PaykitPaymentRequestTarget(COUNTERPARTY) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).thenReturn( - listOf(PaykitReceiverPaths.SERVER), + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).thenReturn( + true, ) whenever( paykitSdkService.proposePaymentRequest( eq(COUNTERPARTY), - eq(PaykitReceiverPaths.SERVER), any(), eq(LOCAL_IDENTITY), ), @@ -612,7 +649,6 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentRequestRecord( role = PaymentRequestLocalRole.PAYEE, counterparty = COUNTERPARTY, - receiverPath = PaykitReceiverPaths.SERVER, ), ) val expiry = clock.now().plus(60.seconds) @@ -628,7 +664,6 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { verifyBlocking(paykitSdkService) { proposePaymentRequest( eq(COUNTERPARTY), - eq(PaykitReceiverPaths.SERVER), proposal.capture(), eq(LOCAL_IDENTITY), ) @@ -646,27 +681,28 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `proposal revalidates only the selected saved contact`() = test { - val target = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) + val target = PaykitPaymentRequestTarget(COUNTERPARTY) val stalledDiscovery = CompletableDeferred() - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( listOf( - linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER), - linkedPeer(SECOND_IDENTITY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER), + linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED), + linkedPeer(SECOND_IDENTITY, LinkedPeerState.LINKED), ), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).thenReturn( - listOf(PaykitReceiverPaths.SERVER), + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).thenReturn( + true, ) - whenever(paykitSdkService.paymentRequestReceiverPaths(SECOND_IDENTITY)).doSuspendableAnswer { + whenever(paykitSdkService.canReceivePaymentRequests(SECOND_IDENTITY)).doSuspendableAnswer { stalledDiscovery.await() - listOf(PaykitReceiverPaths.SERVER) + true } - whenever(paykitSdkService.proposePaymentRequest(any(), any(), any(), eq(LOCAL_IDENTITY))).thenReturn( + whenever(paykitSdkService.proposePaymentRequest(any(), any(), eq(LOCAL_IDENTITY))).thenReturn( paymentRequestRecord( role = PaymentRequestLocalRole.PAYEE, counterparty = COUNTERPARTY, - receiverPath = PaykitReceiverPaths.SERVER, ), ) @@ -681,22 +717,24 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(proposal.isCompleted) proposal.await().getOrThrow() - verifyBlocking(paykitSdkService, never()) { paymentRequestReceiverPaths(SECOND_IDENTITY) } + verifyBlocking(paykitSdkService, never()) { canReceivePaymentRequests(SECOND_IDENTITY) } } @Test fun `identity switch keeps a committed proposal out of the replacement identity state`() = test { - val target = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) + val target = PaykitPaymentRequestTarget(COUNTERPARTY) val proposalStarted = CompletableDeferred() val finishProposal = CompletableDeferred() - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).thenReturn( - listOf(PaykitReceiverPaths.SERVER), + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).thenReturn( + true, ) - whenever(paykitSdkService.proposePaymentRequest(any(), any(), any(), eq(LOCAL_IDENTITY))).doSuspendableAnswer { + whenever(paykitSdkService.proposePaymentRequest(any(), any(), eq(LOCAL_IDENTITY))).doSuspendableAnswer { proposalStarted.complete(Unit) finishProposal.await() paymentRequestRecord(role = PaymentRequestLocalRole.PAYEE) @@ -725,64 +763,72 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `incoming refresh does not wait for recipient discovery`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(paymentRequestRecord())) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) sut.refresh().getOrThrow() assertEquals(1, sut.pendingRequests.value.size) - verifyBlocking(paykitSdkService, never()) { paymentRequestReceiverPaths(any()) } + verifyBlocking(paykitSdkService, never()) { canReceivePaymentRequests(any()) } } @Test fun `recipient discovery reuses unchanged link state`() = test { - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).thenReturn( - listOf(PaykitReceiverPaths.SERVER), + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).thenReturn( + true, ) sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() assertEquals( - listOf(PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER)), + listOf(PaykitPaymentRequestTarget(COUNTERPARTY)), sut.eligibleTargets.value, ) - verifyBlocking(paykitSdkService, times(1)) { paymentRequestReceiverPaths(COUNTERPARTY) } + verifyBlocking(paykitSdkService, times(1)) { canReceivePaymentRequests(COUNTERPARTY) } } @Test fun `single recipient refresh adds a newly eligible contact`() = test { - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).thenReturn( - listOf(PaykitReceiverPaths.SERVER), + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).thenReturn( + true, ) val target = sut.refreshEligibleTarget(COUNTERPARTY).getOrThrow().target - val expected = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) + val expected = PaykitPaymentRequestTarget(COUNTERPARTY) assertEquals(expected, target) assertEquals(listOf(expected), sut.eligibleTargets.value) } @Test fun `single recipient refresh removes a contact that is no longer linked`() = test { - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), emptyList(), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).thenReturn( - listOf(PaykitReceiverPaths.SERVER), + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).thenReturn( + true, ) sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() @@ -794,12 +840,14 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `single recipient refresh removes a contact that stopped accepting requests`() = test { - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)) - .thenReturn(listOf(PaykitReceiverPaths.SERVER), emptyList()) + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)) + .thenReturn(true, false) sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() val target = sut.refreshEligibleTarget(COUNTERPARTY).getOrThrow().target @@ -810,18 +858,20 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `single recipient refresh keeps a known target while capability lookup fails`() = test { - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)) - .thenReturn(listOf(PaykitReceiverPaths.SERVER)) + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)) + .thenReturn(true) .thenThrow(IllegalStateException("marker unavailable")) sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() val check = sut.refreshEligibleTarget(COUNTERPARTY).getOrThrow() - val expected = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) + val expected = PaykitPaymentRequestTarget(COUNTERPARTY) assertEquals(expected, check.target) assertFalse(check.isComplete) assertEquals(listOf(expected), sut.eligibleTargets.value) @@ -832,18 +882,20 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { val fullLookupStarted = CompletableDeferred() val releaseFullLookup = CompletableDeferred() var lookups = 0 - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).doSuspendableAnswer { + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).doSuspendableAnswer { lookups += 1 when (lookups) { - 1 -> listOf(PaykitReceiverPaths.SERVER) + 1 -> true 2 -> { fullLookupStarted.complete(Unit) releaseFullLookup.await() - emptyList() + false } else -> error("marker unavailable") } @@ -865,20 +917,22 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { val fullLinkLookupStarted = CompletableDeferred() val releaseFullLinkLookup = CompletableDeferred() var linkLookups = 0 - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).doSuspendableAnswer { linkLookups += 1 if (linkLookups > 1) { return@doSuspendableAnswer listOf( - linkedPeer(SECOND_IDENTITY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER), + linkedPeer(SECOND_IDENTITY, LinkedPeerState.LINKED), ) } fullLinkLookupStarted.complete(Unit) releaseFullLinkLookup.await() error("linked peers unavailable") } - whenever(paykitSdkService.paymentRequestReceiverPaths(SECOND_IDENTITY)) - .thenReturn(listOf(PaykitReceiverPaths.SERVER)) + whenever(paykitSdkService.canReceivePaymentRequests(SECOND_IDENTITY)) + .thenReturn(true) val fullRefresh = async { sut.refreshEligibleTargets(listOf(COUNTERPARTY)) } fullLinkLookupStarted.await() @@ -887,7 +941,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(fullRefresh.await().isFailure) assertEquals( - listOf(PaykitPaymentRequestTarget(SECOND_IDENTITY, PaykitReceiverPaths.SERVER)), + listOf(PaykitPaymentRequestTarget(SECOND_IDENTITY)), sut.eligibleTargets.value, ) } @@ -897,16 +951,18 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { val fullLookupStarted = CompletableDeferred() val releaseFullLookup = CompletableDeferred() var lookups = 0 - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).doSuspendableAnswer { + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).doSuspendableAnswer { lookups += 1 - if (lookups > 1) return@doSuspendableAnswer emptyList() + if (lookups > 1) return@doSuspendableAnswer false fullLookupStarted.complete(Unit) releaseFullLookup.await() - listOf(PaykitReceiverPaths.SERVER) + true } val fullRefresh = async { sut.refreshEligibleTargets(listOf(COUNTERPARTY)) } @@ -924,16 +980,18 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { val singleLookupStarted = CompletableDeferred() val releaseSingleLookup = CompletableDeferred() var lookups = 0 - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).doSuspendableAnswer { + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).doSuspendableAnswer { lookups += 1 - if (lookups > 1) return@doSuspendableAnswer listOf(PaykitReceiverPaths.SERVER) + if (lookups > 1) return@doSuspendableAnswer true singleLookupStarted.complete(Unit) releaseSingleLookup.await() - emptyList() + false } val singleRefresh = async { sut.refreshEligibleTarget(COUNTERPARTY) } @@ -942,7 +1000,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { releaseSingleLookup.complete(Unit) val target = singleRefresh.await().getOrThrow().target - val expected = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) + val expected = PaykitPaymentRequestTarget(COUNTERPARTY) assertEquals(expected, target) assertEquals(listOf(expected), sut.eligibleTargets.value) } @@ -952,22 +1010,24 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { val fullLookupStarted = CompletableDeferred() val releaseFullLookup = CompletableDeferred() var counterpartyLookups = 0 - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( listOf( - linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER), - linkedPeer(SECOND_IDENTITY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER), + linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED), + linkedPeer(SECOND_IDENTITY, LinkedPeerState.LINKED), ), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).doSuspendableAnswer { + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).doSuspendableAnswer { counterpartyLookups += 1 - if (counterpartyLookups > 1) return@doSuspendableAnswer emptyList() + if (counterpartyLookups > 1) return@doSuspendableAnswer false fullLookupStarted.complete(Unit) releaseFullLookup.await() - listOf(PaykitReceiverPaths.SERVER) + true } - whenever(paykitSdkService.paymentRequestReceiverPaths(SECOND_IDENTITY)) - .thenReturn(listOf(PaykitReceiverPaths.SERVER)) + whenever(paykitSdkService.canReceivePaymentRequests(SECOND_IDENTITY)) + .thenReturn(true) val fullRefresh = async { sut.refreshEligibleTargets(listOf(COUNTERPARTY, SECOND_IDENTITY)) } fullLookupStarted.await() @@ -976,19 +1036,21 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { fullRefresh.await().getOrThrow() assertEquals( - listOf(PaykitPaymentRequestTarget(SECOND_IDENTITY, PaykitReceiverPaths.SERVER)), + listOf(PaykitPaymentRequestTarget(SECOND_IDENTITY)), sut.eligibleTargets.value, ) } @Test fun `failed recipient discovery drops contacts that are no longer saved`() = test { - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()) - .thenReturn(listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER))) + .thenReturn(listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED))) .thenThrow(IllegalStateException("linked peers unavailable")) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).thenReturn( - listOf(PaykitReceiverPaths.SERVER), + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).thenReturn( + true, ) sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() @@ -1000,55 +1062,61 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `recipient discovery retries capabilities that are not published yet`() = test { - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)) - .thenReturn(emptyList(), listOf(PaykitReceiverPaths.SERVER)) + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)) + .thenReturn(false, true) sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() assertEquals( - listOf(PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER)), + listOf(PaykitPaymentRequestTarget(COUNTERPARTY)), sut.eligibleTargets.value, ) - verifyBlocking(paykitSdkService, times(2)) { paymentRequestReceiverPaths(COUNTERPARTY) } + verifyBlocking(paykitSdkService, times(2)) { canReceivePaymentRequests(COUNTERPARTY) } } @Test fun `recipient discovery retains a known target while capability refresh fails`() = test { - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)) - .thenReturn(listOf(PaykitReceiverPaths.SERVER)) + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)) + .thenReturn(true) .thenThrow(IllegalStateException("marker unavailable")) sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() sut.refreshEligibleTargets(listOf(COUNTERPARTY), force = true).getOrThrow() assertEquals( - listOf(PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER)), + listOf(PaykitPaymentRequestTarget(COUNTERPARTY)), sut.eligibleTargets.value, ) - verifyBlocking(paykitSdkService, times(2)) { paymentRequestReceiverPaths(COUNTERPARTY) } + verifyBlocking(paykitSdkService, times(2)) { canReceivePaymentRequests(COUNTERPARTY) } } @Test fun `recipient discovery bounds a stalled capability lookup`() = test { val discoveryStarted = CompletableDeferred() val stalledDiscovery = CompletableDeferred() - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).doSuspendableAnswer { + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).doSuspendableAnswer { discoveryStarted.complete(Unit) stalledDiscovery.await() - listOf(PaykitReceiverPaths.SERVER) + true } val targetRefresh = async { sut.refreshEligibleTargets(listOf(COUNTERPARTY)) } @@ -1064,39 +1132,43 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `outgoing requests require private payment publication`() = test { whenever(settingsStore.data).thenReturn(flowOf(SettingsData(sharesPrivatePaykitEndpoints = false))) - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).thenReturn( - listOf(PaykitReceiverPaths.SERVER), + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).thenReturn( + true, ) sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() assertTrue(sut.eligibleTargets.value.isEmpty()) - verifyBlocking(paykitSdkService, never()) { proposePaymentRequest(any(), any(), any(), any()) } + verifyBlocking(paykitSdkService, never()) { proposePaymentRequest(any(), any(), any()) } } @Test fun `outgoing requests require the active SDK identity`() = test { - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(SECOND_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(SECOND_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).thenReturn( - listOf(PaykitReceiverPaths.SERVER), + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).thenReturn( + true, ) sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() assertTrue(sut.eligibleTargets.value.isEmpty()) - verifyBlocking(paykitSdkService, never()) { proposePaymentRequest(any(), any(), any(), any()) } + verifyBlocking(paykitSdkService, never()) { proposePaymentRequest(any(), any(), any()) } } @Test fun `expired draft is rejected before proposal is queued`() = test { - val target = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) + val target = PaykitPaymentRequestTarget(COUNTERPARTY) assertFailsWith { sut.propose( @@ -1105,13 +1177,13 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { savedPublicKeys = listOf(COUNTERPARTY), ).getOrThrow() } - verifyBlocking(paykitSdkService, never()) { proposePaymentRequest(any(), any(), any(), any()) } + verifyBlocking(paykitSdkService, never()) { proposePaymentRequest(any(), any(), any()) } } @Test fun `expired request cannot be accepted`() = test { val record = paymentRequestRecord(expiresAt = clock.now().plus(1.seconds).toString()) - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) sut.refresh().getOrThrow() val request = sut.pendingRequests.value.single() advanceTimeBy(1_000) @@ -1120,14 +1192,14 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.accept(request).getOrThrow() } verifyBlocking(paykitSdkService, never()) { - acceptPaymentRequest(COUNTERPARTY, PaykitReceiverPaths.SERVER, PAYMENT_REQUEST_ID) + acceptPaymentRequest(COUNTERPARTY, PAYMENT_REQUEST_ID) } } @Test fun `expired request is no longer pending before the expiration job runs`() = test { val record = paymentRequestRecord(expiresAt = clock.now().plus(1.seconds).toString()) - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) sut.refresh().getOrThrow() val request = sut.pendingRequests.value.single() advanceTimeBy(1_000) @@ -1146,13 +1218,11 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentDeadline: PaymentDeadline? = null, endpoints: List = listOf(MethodId.Bolt11.rawValue), counterparty: String = COUNTERPARTY, - receiverPath: String = PaykitReceiverPaths.SERVER, recurrence: PaymentRequestRecurrence? = null, metadata: PrivateJsonObject = METADATA, paymentProofs: List = emptyList(), ) = PaymentRequestRecord( counterparty = counterparty, - counterpartyReceiverPath = receiverPath, paymentRequestId = id, localRole = role, state = state, @@ -1169,6 +1239,8 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { conversion = null, paymentDeadline = paymentDeadline, metadata = metadata, + paymentEndpoints = null, + requiredAppId = "bitkit", ), acceptedEventId = null, acceptedOutboundStatus = null, @@ -1183,15 +1255,16 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { lastOutboundStatus = null, lastEventAt = clock.now().toString(), invalidReason = null, + proposalAppId = "bitkit", + payerAppId = null, + executionClaimAppId = null, ) private fun linkedPeer( publicKey: String, state: LinkedPeerState, - receiverPath: String, ) = LinkedPeerRecord( counterparty = publicKey, - counterpartyReceiverPath = receiverPath, state = state, lastSyncAt = null, lastPrivateReceiveAt = null, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitReceivedPaymentContactsTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitReceivedPaymentContactsTest.kt new file mode 100644 index 0000000000..ecf380e884 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PaykitReceivedPaymentContactsTest.kt @@ -0,0 +1,224 @@ +package to.bitkit.repositories + +import com.synonym.bitkitcore.AddressType +import com.synonym.bitkitcore.NetworkType +import com.synonym.bitkitcore.ValidationResult +import com.synonym.bitkitcore.validateBitcoinAddress +import com.synonym.paykit.PaymentProofRecord +import com.synonym.paykit.PaymentRequestAmount +import com.synonym.paykit.PaymentRequestLifecycleState +import com.synonym.paykit.PaymentRequestLocalRole +import com.synonym.paykit.PaymentRequestRecord +import com.synonym.paykit.PaymentRequestTerms +import com.synonym.paykit.PrivateJsonObject +import org.junit.Test +import org.lightningdevkit.ldknode.Bolt11Invoice +import org.lightningdevkit.ldknode.Currency +import org.lightningdevkit.ldknode.Network +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PaykitReceivedPaymentContactsTest { + @Test + fun `shared app immutable address attributes received payment`() = withDecoder { + val contacts = index(receivedRequest()) + assertEquals(setOf(BUYER), contacts.contactsForAddresses(listOf(ADDRESS))) + assertTrue(contacts.contactsForAddresses(listOf(OTHER_ADDRESS)).isEmpty()) + } + + @Test + fun `terminal request states retain exact destination attribution`() = withDecoder { + for (state in PaymentRequestLifecycleState.entries.filterNot { + it == PaymentRequestLifecycleState.INVALID_CONFLICT || it == PaymentRequestLifecycleState.UNKNOWN + }) { + assertEquals(setOf(BUYER), index(receivedRequest(state = state)).contactsForAddresses(listOf(ADDRESS))) + } + } + + @Test + fun `payer unknown role and invalid records cannot attribute`() = withDecoder { + val invalidRecords = listOf( + receivedRequest(role = PaymentRequestLocalRole.PAYER), + receivedRequest(role = null), + receivedRequest(state = PaymentRequestLifecycleState.INVALID_CONFLICT), + receivedRequest(state = PaymentRequestLifecycleState.UNKNOWN), + receivedRequest(invalidReason = "conflict"), + receivedRequest(counterparty = "invalid"), + receivedRequest(counterparty = BUYER + "excess"), + receivedRequest(asset = "usd"), + receivedRequest(terms = null), + ) + assertTrue(index(*invalidRecords.toTypedArray()).contactsForAddresses(listOf(ADDRESS)).isEmpty()) + } + + @Test + fun `missing unaccepted malformed and wrong network endpoints cannot attribute`() = withDecoder { + val records = listOf( + receivedRequest(endpoints = null), + receivedRequest(endpoints = emptyMap()), + receivedRequest(accepted = emptyList()), + receivedRequest(endpoints = mapOf(METHOD to "not json")), + receivedRequest(endpoints = mapOf(METHOD to "{\"value\":\"\"}")), + receivedRequest(endpoints = mapOf(METHOD to payload("malformed"))), + receivedRequest(endpoints = mapOf("btc-bitcoin-p2wpkh" to payload(ADDRESS))), + receivedRequest(endpoints = mapOf(METHOD to payload(MAINNET_ADDRESS))), + receivedRequest(endpoints = mapOf(METHOD to payload(TESTNET_ADDRESS))), + ) + assertTrue( + index(*records.toTypedArray()) + .contactsForAddresses(listOf(ADDRESS, MAINNET_ADDRESS, TESTNET_ADDRESS)).isEmpty() + ) + } + + @Test + fun `signet accepts testnet address encoding only with a signet endpoint`() = withDecoder { + val identifier = "btc-signet-p2wpkh" + val record = receivedRequest( + accepted = listOf(identifier), + endpoints = mapOf(identifier to payload(TESTNET_ADDRESS)) + ) + val contacts = PaykitReceivedPaymentContacts.from(listOf(record), Network.SIGNET) + assertEquals(setOf(BUYER), contacts.contactsForAddresses(listOf(TESTNET_ADDRESS))) + assertTrue(index(record).contactsForAddresses(listOf(TESTNET_ADDRESS)).isEmpty()) + } + + @Test + fun `regtest accepts network ambiguous legacy address encodings`() = withDecoder { + for (address in LEGACY_ADDRESSES) { + val identifier = if (address.startsWith("2")) "btc-regtest-p2sh" else "btc-regtest-p2pkh" + val record = receivedRequest( + accepted = listOf(identifier), + endpoints = mapOf(identifier to payload(address)) + ) + assertEquals(setOf(BUYER), index(record).contactsForAddresses(listOf(address))) + } + } + + @Test + fun `normalized duplicate counterparties remain unique`() = withDecoder { + val contacts = index(receivedRequest(), receivedRequest(counterparty = BUYER.removePrefix("pubky").uppercase())) + assertEquals(setOf(BUYER), contacts.contactsForAddresses(listOf(ADDRESS, ADDRESS))) + } + + @Test + fun `same destination shared by different counterparties stays ambiguous`() = withDecoder { + val contacts = index(receivedRequest(), receivedRequest(counterparty = OTHER_BUYER)) + assertEquals(setOf(BUYER, OTHER_BUYER), contacts.contactsForAddresses(listOf(ADDRESS))) + } + + @Test + fun `all output addresses contribute to ambiguity`() = withDecoder { + val contacts = index( + receivedRequest(), + receivedRequest(counterparty = OTHER_BUYER, endpoints = mapOf(METHOD to payload(OTHER_ADDRESS))), + ) + assertEquals(setOf(BUYER, OTHER_BUYER), contacts.contactsForAddresses(listOf(ADDRESS, OTHER_ADDRESS))) + } + + @Test + fun `validated expired bolt11 matches exact payment hash`() = withDecoder { + val contacts = index(receivedRequest(endpoints = mapOf(BOLT11_METHOD to payload(INVOICE)))) + assertEquals(setOf(BUYER), contacts.contactsForPaymentHash(HASH.uppercase())) + assertTrue(contacts.contactsForPaymentHash("cd".repeat(32)).isEmpty()) + } + + @Test + fun `malformed or wrong network bolt11 cannot attribute`() = withDecoder { + val contacts = index( + receivedRequest(endpoints = mapOf(BOLT11_METHOD to payload("invalid-invoice"))), + receivedRequest(endpoints = mapOf(BOLT11_METHOD to payload(MAINNET_INVOICE))), + ) + assertTrue(contacts.contactsForPaymentHash(HASH).isEmpty()) + } + + @Test + fun `proof hash alone cannot attribute without immutable request endpoints`() = withDecoder { + val record = receivedRequest(endpoints = null) + val proof = mock { + on { exportText() }.thenReturn("{\"payment_hash\":\"$HASH\",\"txid\":\"$HASH\"}") + } + val proofRecord = mock { on { this.proof }.thenReturn(proof) } + whenever(record.paymentProofs).thenReturn(listOf(proofRecord)) + + assertTrue(index(record).contactsForPaymentHash(HASH).isEmpty()) + assertTrue(index(record).contactsForAddresses(listOf(ADDRESS)).isEmpty()) + } + + @Test + fun `same invoice hash across contacts stays ambiguous`() = withDecoder { + val contacts = index( + receivedRequest(endpoints = mapOf(BOLT11_METHOD to payload(INVOICE))), + receivedRequest(counterparty = OTHER_BUYER, endpoints = mapOf(BOLT11_METHOD to payload(INVOICE))), + ) + assertEquals(setOf(BUYER, OTHER_BUYER), contacts.contactsForPaymentHash(HASH)) + } + + private fun index(vararg records: PaymentRequestRecord) = + PaykitReceivedPaymentContacts.from(records.toList(), Network.REGTEST) { value -> + require(value == INVOICE || value == MAINNET_INVOICE) + mock { + on { currency() }.thenReturn(if (value == INVOICE) Currency.REGTEST else Currency.BITCOIN) + on { paymentHash() }.thenReturn(HASH) + } + } + + private fun withDecoder(block: () -> Unit) { + mockStatic(Class.forName("com.synonym.bitkitcore.Bitkitcore_androidKt")).use { native -> + for (address in listOf(ADDRESS, OTHER_ADDRESS, MAINNET_ADDRESS, TESTNET_ADDRESS) + LEGACY_ADDRESSES) { + val network = when (address) { + MAINNET_ADDRESS -> NetworkType.BITCOIN + TESTNET_ADDRESS, in LEGACY_ADDRESSES -> NetworkType.TESTNET + else -> NetworkType.REGTEST + } + native.`when` { validateBitcoinAddress(address) }.thenReturn( + ValidationResult(address, network, AddressType.UNKNOWN), + ) + } + block() + } + } + + companion object { + const val BUYER = "pubky7don8zi885feihpjsyx7t53srod6z1n4xjiyaaxucpqarm6sh85o" + const val OTHER_BUYER = "pubkya3mduedw686dysw8ndr5c1dyry5h8k6i8hzbbmx9gf9k43zq4s9o" + const val ADDRESS = "bcrt1qfn50lqawrce0evh66qrnlt8j447lwmeyqp5gmd" + const val OTHER_ADDRESS = "bcrt1qpsps9chsjnnd3veems9phzlvw42em682rsj8hh" + const val MAINNET_ADDRESS = "bc1qexample" + const val TESTNET_ADDRESS = "tb1qexample" + val LEGACY_ADDRESSES = listOf("mlegacy", "nlegacy", "2legacy") + const val METHOD = "btc-regtest-p2wpkh" + const val BOLT11_METHOD = "btc-lightning-bolt11" + const val INVOICE = "lnbcrt1example" + const val MAINNET_INVOICE = "lnbc1example" + val HASH = "ab".repeat(32) + + fun payload(value: String) = "{\"value\":\"$value\"}" + + @Suppress("LongParameterList") + fun receivedRequest( + counterparty: String = BUYER, + role: PaymentRequestLocalRole? = PaymentRequestLocalRole.PAYEE, + state: PaymentRequestLifecycleState = PaymentRequestLifecycleState.PROOF_SUBMITTED, + invalidReason: String? = null, + asset: String = "btc", + endpoints: Map? = mapOf(METHOD to payload(ADDRESS)), + accepted: List = listOf(METHOD, BOLT11_METHOD), + terms: PaymentRequestTerms? = PaymentRequestTerms( + amount = PaymentRequestAmount("0.00015", asset), paymentReference = mock(), + proposalExpiresAt = null, recurrence = null, acceptedPaymentEndpointIdentifiers = accepted, + paymentEndpoints = endpoints, requiredAppId = "marketplace", conversion = null, + paymentDeadline = null, metadata = mock(), + ), + ): PaymentRequestRecord = mock { + on { this.counterparty }.thenReturn(counterparty) + on { this.localRole }.thenReturn(role) + on { this.state }.thenReturn(state) + on { this.invalidReason }.thenReturn(invalidReason) + on { this.terms }.thenReturn(terms) + on { proposalAppId }.thenReturn("marketplace") + } + } +} diff --git a/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionNotificationSchedulerTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionNotificationSchedulerTest.kt index b1641949a0..1ea3cafa95 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionNotificationSchedulerTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionNotificationSchedulerTest.kt @@ -27,7 +27,6 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import to.bitkit.ui.EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT import to.bitkit.ui.EXTRA_PAYKIT_COUNTERPARTY -import to.bitkit.ui.EXTRA_PAYKIT_COUNTERPARTY_RECEIVER_PATH import to.bitkit.ui.EXTRA_PAYKIT_PAYER_IDENTITY import to.bitkit.ui.EXTRA_PAYKIT_PAYMENT_REQUEST_ID import kotlin.test.assertEquals @@ -43,11 +42,10 @@ class PaykitSubscriptionNotificationSchedulerTest { const val COUNTERPARTY = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" const val PAYER_IDENTITY = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" const val PAYMENT_REQUEST_ID = "request-id" - const val RECEIVER_PATH = "bitkit/server" const val WORK_TAG = "paykit-subscriptions" val NOW = Instant.parse("2027-01-02T08:00:00Z") val NEXT_PERIOD_START = Instant.parse("2027-01-08T08:00:00Z") - val WORK_NAME = "paykit-subscription-$PAYER_IDENTITY|$COUNTERPARTY|$RECEIVER_PATH|" + + val WORK_NAME = "paykit-subscription-$PAYER_IDENTITY|$COUNTERPARTY|" + "$PAYMENT_REQUEST_ID|$NEXT_PERIOD_START" } @@ -88,7 +86,6 @@ class PaykitSubscriptionNotificationSchedulerTest { assertEquals(PAYMENT_REQUEST_ID, request.workSpec.input.getString(EXTRA_PAYKIT_PAYMENT_REQUEST_ID)) assertEquals(PAYER_IDENTITY, request.workSpec.input.getString(EXTRA_PAYKIT_PAYER_IDENTITY)) assertEquals(COUNTERPARTY, request.workSpec.input.getString(EXTRA_PAYKIT_COUNTERPARTY)) - assertEquals(RECEIVER_PATH, request.workSpec.input.getString(EXTRA_PAYKIT_COUNTERPARTY_RECEIVER_PATH)) assertEquals( NEXT_PERIOD_START.toString(), request.workSpec.input.getString(EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT), @@ -168,7 +165,6 @@ class PaykitSubscriptionNotificationSchedulerTest { private fun subscription() = PaykitSubscription( paymentRequestId = PAYMENT_REQUEST_ID, counterparty = COUNTERPARTY, - counterpartyReceiverPath = RECEIVER_PATH, amountValue = "0.00025", amountSats = 25_000uL, note = "Weekly coffee", diff --git a/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionTest.kt index dc9d822b82..7751a3f744 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionTest.kt @@ -168,7 +168,7 @@ class PaykitSubscriptionTest { } @Test - fun `subscription payment matching includes counterparty and receiver path`() { + fun `subscription payment matching includes counterparty`() { val recurrence = PaykitSubscriptionRecurrence( every = 1, unit = PaykitRecurrenceUnit.Month, @@ -179,7 +179,6 @@ class PaykitSubscriptionTest { val subscription = PaykitSubscription( paymentRequestId = "shared", counterparty = "counterparty-a", - counterpartyReceiverPath = "bitkit/server", amountValue = "0.001", amountSats = 100_000uL, note = null, @@ -198,6 +197,5 @@ class PaykitSubscriptionTest { assertTrue(request.belongsTo(subscription)) assertFalse(request.copy(counterparty = "counterparty-b").belongsTo(subscription)) - assertFalse(request.copy(counterpartyReceiverPath = "bitkit/wallet").belongsTo(subscription)) } } diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt index b39eb66159..478721d46d 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt @@ -17,7 +17,6 @@ import to.bitkit.data.SettingsStore import to.bitkit.models.NodeLifecycleState import to.bitkit.services.AddressDerivationInfo import to.bitkit.services.CoreService -import to.bitkit.services.PaykitReceiverPaths import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -60,22 +59,6 @@ class PrivatePaykitAddressReservationRepoTest : BaseUnitTest() { ) } - @Test - fun `wallet assignment key keeps bare public key`() { - val key = ContactAssignmentKey(CONTACT_KEY, PaykitReceiverPaths.WALLET) - - assertEquals(CONTACT_KEY, key.encoded()) - assertEquals(CONTACT_KEY, ContactAssignmentKey.publicKeyOf(key.encoded())) - } - - @Test - fun `server assignment key includes receiver path`() { - val key = ContactAssignmentKey(CONTACT_KEY, PaykitReceiverPaths.SERVER) - - assertEquals("$CONTACT_KEY#${PaykitReceiverPaths.SERVER}", key.encoded()) - assertEquals(CONTACT_KEY, ContactAssignmentKey.publicKeyOf(key.encoded())) - } - @Test fun `contactsWithUsedReservedAddresses treats positive ldk address balance as used`() = test { reservationData.value = PrivatePaykitReservationData( diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt index dcb74d2530..a9bacffdc8 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt @@ -9,6 +9,7 @@ import to.bitkit.data.PrivatePaykitCacheData import to.bitkit.data.PrivatePaykitCacheStore import to.bitkit.data.PrivatePaykitContactCacheData import to.bitkit.data.PrivatePaykitStoredInvoiceData +import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.test.BaseUnitTest import javax.inject.Provider import kotlin.test.assertEquals @@ -23,6 +24,7 @@ class PrivatePaykitContactResolverTest : BaseUnitTest() { private val cacheStore = mock() private val addressReservationRepo = mock() + private val paymentRequestRepo = mock() private val cacheData = MutableStateFlow(PrivatePaykitCacheData()) private lateinit var sut: PrivatePaykitContactResolver @@ -30,24 +32,60 @@ class PrivatePaykitContactResolverTest : BaseUnitTest() { @Before fun setUp() { whenever(cacheStore.data).thenReturn(cacheData) + whenever(paymentRequestRepo.receivedPaymentContacts).thenReturn(PaykitReceivedPaymentContacts.Empty) sut = PrivatePaykitContactResolver( ioDispatcher = testDispatcher, cacheStore = cacheStore, addressReservationRepo = Provider { addressReservationRepo }, + paymentRequestRepo = Provider { paymentRequestRepo }, ) } + @Test + fun `shared request invoice hash resolves without local invoice reservations`() = test { + val shared = mock() + whenever(shared.contactsForPaymentHash(PAYMENT_HASH)).thenReturn(setOf(CONTACT_KEY)) + whenever(paymentRequestRepo.receivedPaymentContacts).thenReturn(shared) + + assertEquals( + PubkyPublicKeyFormat.normalized(CONTACT_KEY), + sut.contactPublicKeyForPrivateInvoicePaymentHash(PAYMENT_HASH) + ) + } + + @Test + fun `conflicting local reservation and shared request stay unattributed`() = test { + val shared = mock() + whenever(shared.contactsForAddresses(listOf(PRIVATE_ADDRESS))) + .thenReturn(setOf(PaykitReceivedPaymentContactsTest.BUYER)) + whenever(paymentRequestRepo.receivedPaymentContacts).thenReturn(shared) + whenever(addressReservationRepo.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS)).thenReturn(CONTACT_KEY) + + assertNull(sut.contactPublicKeyForPrivateOnchainAddresses(listOf(PRIVATE_ADDRESS))) + } + + @Test + fun `identity changes while resolving discard old shared request matches`() = test { + val shared = mock() + whenever(shared.contactsForAddresses(listOf(PRIVATE_ADDRESS))).thenReturn(setOf(CONTACT_KEY)) + whenever(paymentRequestRepo.receivedPaymentContacts).thenReturn(shared) + whenever(addressReservationRepo.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS)).thenAnswer { + whenever(paymentRequestRepo.receivedPaymentContacts).thenReturn(PaykitReceivedPaymentContacts.Empty) + null + } + + assertNull(sut.contactPublicKeyForPrivateOnchainAddresses(listOf(PRIVATE_ADDRESS))) + } + @Test fun `contactPublicKeyForPrivateInvoicePaymentHash resolves current local invoice`() = test { cacheData.value = PrivatePaykitCacheData( contacts = mapOf( CONTACT_KEY to PrivatePaykitContactCacheData( - localInvoicesByReceiverPath = mapOf( - "bitkit/wallet" to PrivatePaykitStoredInvoiceData( - bolt11 = "lnbcrt1private", - paymentHash = PAYMENT_HASH, - expiresAt = 1_700_000_000L, - ), + localInvoice = PrivatePaykitStoredInvoiceData( + bolt11 = "lnbcrt1private", + paymentHash = PAYMENT_HASH, + expiresAt = 1_700_000_000L, ), ), ), @@ -55,7 +93,7 @@ class PrivatePaykitContactResolverTest : BaseUnitTest() { val result = sut.contactPublicKeyForPrivateInvoicePaymentHash(PAYMENT_HASH) - assertEquals(CONTACT_KEY, result) + assertEquals(PubkyPublicKeyFormat.normalized(CONTACT_KEY), result) } @Test @@ -70,7 +108,7 @@ class PrivatePaykitContactResolverTest : BaseUnitTest() { val result = sut.contactPublicKeyForPrivateInvoicePaymentHash(PAYMENT_HASH) - assertEquals(CONTACT_KEY, result) + assertEquals(PubkyPublicKeyFormat.normalized(CONTACT_KEY), result) } @Test @@ -87,6 +125,6 @@ class PrivatePaykitContactResolverTest : BaseUnitTest() { val result = sut.contactPublicKeyForPrivateOnchainAddresses(listOf(PRIVATE_ADDRESS)) - assertEquals(CONTACT_KEY, result) + assertEquals(PubkyPublicKeyFormat.normalized(CONTACT_KEY), result) } } diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 4005ab2fc6..e74e85a7c7 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -5,11 +5,9 @@ import com.synonym.bitkitcore.LightningInvoice import com.synonym.bitkitcore.NetworkType import com.synonym.bitkitcore.Scanner import com.synonym.paykit.ContactRecord -import com.synonym.paykit.CounterpartyReceiver import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState import com.synonym.paykit.PaykitException -import com.synonym.paykit.PaymentAmountContext import com.synonym.paykit.PrivatePaymentListDeliveryReport import com.synonym.paykit.PrivatePaymentListReservationUpdateInput import com.synonym.paykit.PrivatePaymentListSyncChange @@ -28,15 +26,11 @@ import kotlinx.coroutines.test.runCurrent import org.junit.After import org.junit.Before import org.junit.Test -import org.lightningdevkit.ldknode.PaymentDetails -import org.lightningdevkit.ldknode.PaymentDirection -import org.lightningdevkit.ldknode.PaymentKind -import org.lightningdevkit.ldknode.PaymentStatus import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.atLeast import org.mockito.kotlin.clearInvocations -import org.mockito.kotlin.doReturn import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -56,7 +50,6 @@ import to.bitkit.models.NodeLifecycleState import to.bitkit.services.CoreService import to.bitkit.services.PaykitPreparedPrivateContactPayment import to.bitkit.services.PaykitPrivateContactPaymentResolution -import to.bitkit.services.PaykitPrivateReceiverPathSelection import to.bitkit.services.PaykitResolvedPaymentEndpoint import to.bitkit.services.PaykitSdkService import to.bitkit.services.PubkyService @@ -83,11 +76,8 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { private const val OTHER_PRIVATE_ADDRESS = "bcrt1q9x0pz2tqf8clz0lq6m9wj8t47zffnrdz2tkt6v" private const val PRIVATE_BOLT11 = "lnbcrt1private" private const val SERVER_PRIVATE_BOLT11 = "lnbcrt1serverprivate" - private const val ROTATED_PRIVATE_BOLT11 = "lnbcrt1rotatedprivate" private const val PRIVATE_BOLT11_EXPIRY_SECONDS = 86_400u private const val NOW_SECONDS = 1_700_000_000L - private const val WALLET_RECEIVER_PATH = "bitkit/wallet" - private const val SERVER_RECEIVER_PATH = "bitkit/server" } private val paykitSdkService = mock() @@ -126,25 +116,21 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(lightningRepo.lightningState).thenReturn(lightningState) whenever(clock.now()).thenReturn(Instant.fromEpochSeconds(NOW_SECONDS)) whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) - whenever { pubkyService.discoverRelevantReceiverPaths(any()) } - .thenReturn(listOf(WALLET_RECEIVER_PATH)) whenever(paykitSdkService.hasPrivatePaymentAccess()).thenReturn(true) whenever(walletRepo.walletExists()).thenReturn(true) whenever { walletRepo.refreshReusableReceiveAddressIfReserved() }.thenReturn(Result.success(Unit)) whenever { addressReservationRepo.reconcileReservedIndexesWithLdk() }.thenReturn(Result.success(Unit)) - whenever { addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY, WALLET_RECEIVER_PATH) } + whenever { addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY) } .thenReturn(Result.success(PRIVATE_ADDRESS)) - whenever { paykitSdkService.privateReceiverPathSelection(any(), any()) }.thenAnswer { - privateReceiverPathSelection(it.getArgument(1)) - } whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) } .thenReturn(privateListDeliveryReport(queuedCounterparties = listOf(CONTACT_KEY))) whenever { paykitSdkService.linkedPeers() }.thenReturn(emptyList()) whenever { paykitSdkService.pendingOutboundPrivateCounterparties() }.thenReturn(emptyList()) - whenever { paykitSdkService.clearPrivatePaymentList(any(), any()) }.thenReturn(privateListDeliveryReport()) + whenever { paykitSdkService.clearPrivatePaymentList(any()) }.thenReturn(privateListDeliveryReport()) whenever { publicPaykitRepo.beginPayment(any()) } .thenReturn(Result.success(PublicPaykitPaymentResult.Opened("bitcoin:bcrt1qpublic"))) whenever { publicPaykitRepo.payableEndpoints(any()) }.thenAnswer { it.getArgument>(0) } + whenever { publicPaykitRepo.syncPaykitApp(anyOrNull()) }.thenReturn(Result.success(Unit)) whenever(lightningRepo.getPayments()).thenReturn(Result.success(emptyList())) PublicPaykitRepo.lightningRouteHintsValidator = { true } @@ -179,16 +165,15 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(PublicPaykitRepo.serializePayload(PRIVATE_ADDRESS), reservation.payload) assertTrue( reservation.reservationId.startsWith( - "$CONTACT_KEY:$WALLET_RECEIVER_PATH:${MethodId.P2wpkh.rawValue}:", + "$CONTACT_KEY:${MethodId.P2wpkh.rawValue}:", ), ) assertTrue(reservation.reservationId.length <= 128) assertEquals("private_paykit", reservation.attribution["type"]) assertEquals(CONTACT_KEY, reservation.attribution["counterparty"]) - assertEquals(WALLET_RECEIVER_PATH, reservation.attribution["receiver_path"]) assertEquals( - setOf(WALLET_RECEIVER_PATH), - cacheData.value.contacts.getValue(CONTACT_KEY).publishedPrivatePaymentReceiverPaths, + true, + cacheData.value.contacts.getValue(CONTACT_KEY).hasPublishedPrivatePaymentList, ) } @@ -200,168 +185,16 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `prepareSavedContacts publishes distinct private reservations for eligible receiver paths`() = test { - settingsData.value = SettingsData( - sharesPrivatePaykitEndpoints = true, - publicPaykitLightningEnabled = false, - publicPaykitOnchainEnabled = true, - ) - whenever { paykitSdkService.contactRecord(CONTACT_KEY) } - .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) - whenever { addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY, SERVER_RECEIVER_PATH) } - .thenReturn(Result.success(OTHER_PRIVATE_ADDRESS)) - whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) }.thenAnswer { - privateListDeliveryReportForUpdates(it.getArgument(0)) - } - - val result = sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) - - assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - val captor = argumentCaptor>() - verifyBlocking(paykitSdkService) { syncPrivatePaymentListsWithReservations(captor.capture(), eq(false)) } - - assertEquals( - listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH), - captor.firstValue.map { it.counterpartyReceiverPath }, - ) - assertEquals( - listOf(PRIVATE_ADDRESS, OTHER_PRIVATE_ADDRESS).map(PublicPaykitRepo::serializePayload), - captor.firstValue.map { it.reservations.single().payload }, - ) - assertEquals(2, captor.firstValue.map { it.reservations.single().reservationId }.distinct().size) - assertEquals( - setOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH), - cacheData.value.contacts.getValue(CONTACT_KEY).publishedPrivatePaymentReceiverPaths, - ) - verifyBlocking(paykitSdkService, atLeast(1)) { ensureLinkWithPeer(CONTACT_KEY, WALLET_RECEIVER_PATH) } - verifyBlocking(paykitSdkService, atLeast(1)) { ensureLinkWithPeer(CONTACT_KEY, SERVER_RECEIVER_PATH) } - } - - @Test - fun `prepareSavedContacts skips public-only receiver paths`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) - whenever { paykitSdkService.contactRecord(CONTACT_KEY) } - .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) - whenever { paykitSdkService.privateReceiverPathSelection(eq(CONTACT_KEY), any()) } - .thenReturn(privateReceiverPathSelection(emptyList())) - - val result = sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) - - assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - verifyBlocking(paykitSdkService, never()) { ensureLinkWithPeer(any(), any(), any()) } - verifyBlocking(paykitSdkService, never()) { syncPrivatePaymentListsWithReservations(any(), any()) } - - assertTrue(sut.removePublishedEndpointsForCleanup("test").isSuccess) - verifyBlocking(paykitSdkService, never()) { clearPrivatePaymentList(any(), any()) } - } - - @Test - fun `prepareSavedContacts links server receiver without publishing payment details`() = test { - settingsData.value = SettingsData( - sharesPrivatePaykitEndpoints = true, - publicPaykitLightningEnabled = false, - publicPaykitOnchainEnabled = true, - ) - whenever { paykitSdkService.contactRecord(CONTACT_KEY) } - .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) - whenever { paykitSdkService.privateReceiverPathSelection(eq(CONTACT_KEY), any()) } - .thenReturn( - privateReceiverPathSelection( - linkableReceiverPaths = listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH), - publishableReceiverPaths = listOf(WALLET_RECEIVER_PATH), - ), - ) - whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) }.thenAnswer { - privateListDeliveryReportForUpdates(it.getArgument(0)) - } - - val result = sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) - - assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - verifyBlocking(paykitSdkService, atLeast(1)) { ensureLinkWithPeer(CONTACT_KEY, SERVER_RECEIVER_PATH) } - val captor = argumentCaptor>() - verifyBlocking(paykitSdkService) { syncPrivatePaymentListsWithReservations(captor.capture(), eq(false)) } - assertEquals(listOf(WALLET_RECEIVER_PATH), captor.firstValue.map { it.counterpartyReceiverPath }) - } - - @Test - fun `prepareSavedContacts links relevant receivers when endpoint sharing is disabled`() = test { + fun `prepareSavedContacts links contacts when endpoint sharing is disabled`() = test { settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) whenever { paykitSdkService.contactRecord(CONTACT_KEY) } - .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) - whenever { paykitSdkService.privateReceiverPathSelection(eq(CONTACT_KEY), any()) } - .thenReturn( - privateReceiverPathSelection( - linkableReceiverPaths = listOf(SERVER_RECEIVER_PATH), - publishableReceiverPaths = emptyList(), - ), - ) - + .thenReturn(contactRecord(CONTACT_KEY)) val result = sut.prepareSavedContacts(listOf(CONTACT_KEY)) assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - verifyBlocking(paykitSdkService) { ensureLinkWithPeer(CONTACT_KEY, SERVER_RECEIVER_PATH) } + verifyBlocking(paykitSdkService) { ensureLinkWithPeer(CONTACT_KEY) } verifyBlocking(paykitSdkService, never()) { syncPrivatePaymentListsWithReservations(any(), any()) } - verify(addressReservationRepo, never()).currentOrRotatedAddress(any(), any()) - } - - @Test - fun `initial link burst discovers a server receiver published after the contact was saved`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) - whenever { paykitSdkService.contactRecord(CONTACT_KEY) } - .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH))) - whenever { pubkyService.discoverRelevantReceiverPaths(CONTACT_KEY) } - .thenReturn(listOf(WALLET_RECEIVER_PATH)) - .thenReturn(listOf(WALLET_RECEIVER_PATH)) - .thenReturn(listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH)) - whenever { - pubkyService.saveContact( - CONTACT_KEY, - null, - listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH), - ) - }.thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) - - assertTrue(sut.prepareSavedContacts(listOf(CONTACT_KEY)).isSuccess) - clearInvocations(paykitSdkService, pubkyService) - - sut.startInitialLinkBurst(listOf(CONTACT_KEY), "test") - runCurrent() - advanceTimeBy(2_000) - runCurrent() - - verifyBlocking(pubkyService) { - saveContact( - CONTACT_KEY, - null, - listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH), - ) - } - verifyBlocking(paykitSdkService) { ensureLinkWithPeer(CONTACT_KEY, SERVER_RECEIVER_PATH) } - verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } - sut.closeAndClear() - } - - @Test - fun `initial link burst does not recreate a contact deleted during discovery`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) - whenever(paykitSdkService.contactRecord(CONTACT_KEY)) - .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH)), null) - whenever { pubkyService.discoverRelevantReceiverPaths(CONTACT_KEY) } - .thenReturn(listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH)) - - sut.startInitialLinkBurst(listOf(CONTACT_KEY), "test") - runCurrent() - - verify(paykitSdkService, times(2)).contactRecord(CONTACT_KEY) - verifyBlocking(pubkyService, never()) { - saveContact( - CONTACT_KEY, - null, - listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH), - ) - } - sut.closeAndClear() + verify(addressReservationRepo, never()).currentOrRotatedAddress(any()) } @Test @@ -370,16 +203,16 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.startInitialLinkBurst(listOf(CONTACT_KEY), "test") runCurrent() - clearInvocations(pubkyService) + clearInvocations(paykitSdkService) sut.startInitialLinkBurst(listOf(OTHER_CONTACT_KEY), "test") runCurrent() - clearInvocations(pubkyService) + clearInvocations(paykitSdkService) advanceTimeBy(2_000) runCurrent() - verifyBlocking(pubkyService) { discoverRelevantReceiverPaths(OTHER_CONTACT_KEY) } - verifyBlocking(pubkyService, never()) { discoverRelevantReceiverPaths(CONTACT_KEY) } + verifyBlocking(paykitSdkService) { ensureLinkWithPeer(OTHER_CONTACT_KEY) } + verifyBlocking(paykitSdkService, never()) { ensureLinkWithPeer(CONTACT_KEY) } sut.closeAndClear() } @@ -394,7 +227,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { advanceTimeBy(30_000) runCurrent() - verifyBlocking(pubkyService, never()) { discoverRelevantReceiverPaths(any()) } + verify(paykitSdkService, never()).ensureLinkWithPeer(CONTACT_KEY) verifyBlocking(paykitSdkService, never()) { syncPrivatePaymentListsWithReservations(any(), any()) } sut.closeAndClear() } @@ -405,142 +238,16 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.startInitialLinkBurst(listOf(CONTACT_KEY), "test") runCurrent() - clearInvocations(pubkyService) + clearInvocations(paykitSdkService) sut.startInitialLinkBurst(emptyList(), "test") advanceTimeBy(30_000) runCurrent() - verifyBlocking(pubkyService, never()) { discoverRelevantReceiverPaths(any()) } + verifyBlocking(paykitSdkService, never()) { ensureLinkWithPeer(any(), any()) } sut.closeAndClear() } - @Test - fun `prepareSavedContacts clears receiver paths that are no longer eligible`() = test { - settingsData.value = SettingsData( - sharesPrivatePaykitEndpoints = true, - publicPaykitLightningEnabled = false, - publicPaykitOnchainEnabled = true, - ) - whenever { paykitSdkService.contactRecord(CONTACT_KEY) } - .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) - whenever { addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY, SERVER_RECEIVER_PATH) } - .thenReturn(Result.success(OTHER_PRIVATE_ADDRESS)) - whenever { paykitSdkService.privateReceiverPathSelection(eq(CONTACT_KEY), any()) } - .thenReturn(privateReceiverPathSelection(listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) - .thenReturn(privateReceiverPathSelection(listOf(WALLET_RECEIVER_PATH))) - whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) }.thenAnswer { - privateListDeliveryReportForUpdates(it.getArgument(0)) - } - - sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) - clearInvocations(paykitSdkService) - val result = sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) - - assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - val captor = argumentCaptor>() - verifyBlocking(paykitSdkService) { syncPrivatePaymentListsWithReservations(captor.capture(), eq(false)) } - val updatesByPath = captor.firstValue.associateBy { it.counterpartyReceiverPath } - assertEquals(1, updatesByPath.getValue(WALLET_RECEIVER_PATH).reservations.size) - assertTrue(updatesByPath.getValue(SERVER_RECEIVER_PATH).reservations.isEmpty()) - assertEquals( - setOf(WALLET_RECEIVER_PATH), - cacheData.value.contacts.getValue(CONTACT_KEY).publishedPrivatePaymentReceiverPaths, - ) - } - - @Test - fun `prepareSavedContacts preserves receiver paths when marker lookup fails`() = test { - settingsData.value = SettingsData( - sharesPrivatePaykitEndpoints = true, - publicPaykitLightningEnabled = false, - publicPaykitOnchainEnabled = true, - ) - whenever { paykitSdkService.contactRecord(CONTACT_KEY) } - .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) - whenever { addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY, SERVER_RECEIVER_PATH) } - .thenReturn(Result.success(OTHER_PRIVATE_ADDRESS)) - whenever { paykitSdkService.privateReceiverPathSelection(eq(CONTACT_KEY), any()) } - .thenReturn(privateReceiverPathSelection(listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) - .thenReturn( - privateReceiverPathSelection( - publishableReceiverPaths = listOf(WALLET_RECEIVER_PATH), - cleanupProtectedReceiverPaths = listOf(SERVER_RECEIVER_PATH), - error = PrivatePaykitTestAppError("marker unavailable"), - ), - ) - whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) }.thenAnswer { - privateListDeliveryReportForUpdates(it.getArgument(0)) - } - - sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) - clearInvocations(paykitSdkService) - val result = sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) - - assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - val captor = argumentCaptor>() - verifyBlocking(paykitSdkService) { syncPrivatePaymentListsWithReservations(captor.capture(), eq(false)) } - assertEquals(listOf(WALLET_RECEIVER_PATH), captor.firstValue.map { it.counterpartyReceiverPath }) - assertEquals( - setOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH), - cacheData.value.contacts.getValue(CONTACT_KEY).publishedPrivatePaymentReceiverPaths, - ) - } - - @Test - fun `immediate preparation fails when every marker lookup fails`() = test { - settingsData.value = SettingsData( - sharesPrivatePaykitEndpoints = true, - publicPaykitLightningEnabled = false, - publicPaykitOnchainEnabled = true, - ) - whenever { paykitSdkService.privateReceiverPathSelection(eq(CONTACT_KEY), any()) } - .thenReturn( - privateReceiverPathSelection( - publishableReceiverPaths = emptyList(), - cleanupProtectedReceiverPaths = listOf(WALLET_RECEIVER_PATH), - error = PrivatePaykitTestAppError("marker unavailable"), - ), - ) - - val result = sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) - - assertTrue(result.isFailure) - verifyBlocking(paykitSdkService, never()) { syncPrivatePaymentListsWithReservations(any(), any()) } - } - - @Test - fun `immediate preparation keeps valid contacts when another marker lookup fails`() = test { - settingsData.value = SettingsData( - sharesPrivatePaykitEndpoints = true, - publicPaykitLightningEnabled = false, - publicPaykitOnchainEnabled = true, - ) - whenever { addressReservationRepo.currentOrRotatedAddress(OTHER_CONTACT_KEY, WALLET_RECEIVER_PATH) } - .thenReturn(Result.success(OTHER_PRIVATE_ADDRESS)) - whenever { paykitSdkService.privateReceiverPathSelection(eq(CONTACT_KEY), any()) } - .thenReturn( - privateReceiverPathSelection( - publishableReceiverPaths = emptyList(), - cleanupProtectedReceiverPaths = listOf(WALLET_RECEIVER_PATH), - error = PrivatePaykitTestAppError("marker unavailable"), - ), - ) - whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) }.thenAnswer { - privateListDeliveryReportForUpdates(it.getArgument(0)) - } - - val result = sut.prepareSavedContacts( - listOf(CONTACT_KEY, OTHER_CONTACT_KEY), - requireImmediatePublication = true, - ) - - assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - val captor = argumentCaptor>() - verifyBlocking(paykitSdkService) { syncPrivatePaymentListsWithReservations(captor.capture(), eq(false)) } - assertEquals(listOf(OTHER_CONTACT_KEY), captor.firstValue.map { it.counterparty }) - } - @Test fun `prepareSavedContacts succeeds when link preparation fails but SDK queues reservations`() = test { settingsData.value = SettingsData( @@ -548,7 +255,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { publicPaykitLightningEnabled = false, publicPaykitOnchainEnabled = true, ) - whenever { paykitSdkService.ensureLinkWithPeer(CONTACT_KEY, WALLET_RECEIVER_PATH) }.thenAnswer { + whenever { paykitSdkService.ensureLinkWithPeer(CONTACT_KEY) }.thenAnswer { throw PrivatePaykitTestAppError("still linking") } @@ -557,31 +264,11 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(result.isSuccess, result.exceptionOrNull().toString()) verifyBlocking(paykitSdkService) { syncPrivatePaymentListsWithReservations(any(), eq(false)) } assertEquals( - setOf(WALLET_RECEIVER_PATH), - cacheData.value.contacts.getValue(CONTACT_KEY).publishedPrivatePaymentReceiverPaths, + true, + cacheData.value.contacts.getValue(CONTACT_KEY).hasPublishedPrivatePaymentList, ) } - @Test - fun `prepareSavedContacts reads linked peers once for multiple contacts`() = test { - settingsData.value = SettingsData( - sharesPrivatePaykitEndpoints = true, - publicPaykitLightningEnabled = false, - publicPaykitOnchainEnabled = true, - ) - whenever { paykitSdkService.privateReceiverPathSelection(any(), any()) }.thenReturn( - privateReceiverPathSelection( - publishableReceiverPaths = emptyList(), - linkableReceiverPaths = emptyList(), - ), - ) - - val result = sut.prepareSavedContacts(listOf(CONTACT_KEY, OTHER_CONTACT_KEY)) - - assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - verifyBlocking(paykitSdkService, times(1)) { linkedPeers() } - } - @Test fun `private message drain keeps retrying while link is still pending`() = test { settingsData.value = SettingsData( @@ -598,7 +285,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { advanceTimeBy(257_000) runCurrent() - verifyBlocking(paykitSdkService, atLeast(8)) { ensureLinkWithPeer(CONTACT_KEY, WALLET_RECEIVER_PATH) } + verifyBlocking(paykitSdkService, atLeast(8)) { ensureLinkWithPeer(CONTACT_KEY) } sut.closeAndClear() } @@ -633,49 +320,75 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `received wallet invoice rotation preserves server receiver invoice`() = test { + fun `publication skips contacts without Paykit and cleanup has nothing to withdraw`() = test { settingsData.value = SettingsData( sharesPrivatePaykitEndpoints = true, - publicPaykitLightningEnabled = true, - publicPaykitOnchainEnabled = false, - ) - whenever(lightningRepo.canReceive()).thenReturn(true) - whenever { paykitSdkService.contactRecord(CONTACT_KEY) } - .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) - whenever { - lightningRepo.createInvoice( - amountSats = null, - description = "", - expirySeconds = PRIVATE_BOLT11_EXPIRY_SECONDS, - ) - }.thenReturn( - Result.success(PRIVATE_BOLT11), - Result.success(SERVER_PRIVATE_BOLT11), - Result.success(ROTATED_PRIVATE_BOLT11), + publicPaykitLightningEnabled = false, + publicPaykitOnchainEnabled = true, ) - whenever(coreService.decode(PRIVATE_BOLT11)) - .thenReturn(Scanner.Lightning(lightningInvoice(PRIVATE_BOLT11, byteArrayOf(1, 1, 1)))) - whenever(coreService.decode(SERVER_PRIVATE_BOLT11)) - .thenReturn(Scanner.Lightning(lightningInvoice(SERVER_PRIVATE_BOLT11, byteArrayOf(2, 2, 2)))) - whenever(coreService.decode(ROTATED_PRIVATE_BOLT11)) - .thenReturn(Scanner.Lightning(lightningInvoice(ROTATED_PRIVATE_BOLT11, byteArrayOf(3, 3, 3)))) + whenever { paykitSdkService.ensureLinkWithPeer(CONTACT_KEY) } + .thenAnswer { throw PaykitException.NotFound("not_found", "No App Registry") } - sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true).getOrThrow() - val settledPayment = mock { - on { id } doReturn "010101" - on { kind } doReturn mock() - on { direction } doReturn PaymentDirection.INBOUND - on { status } doReturn PaymentStatus.SUCCEEDED - } - whenever(lightningRepo.getPayments()).thenReturn(Result.success(listOf(settledPayment))) + val publication = sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) + val cleanup = sut.disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) - val result = sut.handleReceivedPayment("010101") + assertTrue(publication.isSuccess, publication.exceptionOrNull().toString()) + assertTrue(cleanup.isSuccess, cleanup.exceptionOrNull().toString()) + verifyBlocking(addressReservationRepo, never()) { currentOrRotatedAddress(any()) } + verifyBlocking(paykitSdkService, never()) { syncPrivatePaymentListsWithReservations(any(), any()) } + verifyBlocking(paykitSdkService, never()) { clearPrivatePaymentList(any()) } + } - assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - val invoices = cacheData.value.contacts.getValue(CONTACT_KEY).localInvoicesByReceiverPath - assertEquals(ROTATED_PRIVATE_BOLT11, invoices.getValue(WALLET_RECEIVER_PATH).bolt11) - assertEquals(SERVER_PRIVATE_BOLT11, invoices.getValue(SERVER_RECEIVER_PATH).bolt11) - sut.closeAndClear() + @Test + fun `disabled cleanup retains its capability through failures and direct retries`() = test { + val publicRepo = PublicPaykitRepo( + ioDispatcher = testDispatcher, + pubkyRepo = mock(), + walletRepo = walletRepo, + lightningRepo = lightningRepo, + coreService = coreService, + paykitSdkService = paykitSdkService, + settingsStore = settingsStore, + privatePaykitCacheStore = cacheStore, + clock = clock, + ) + for (failureStage in listOf("enable capability", "withdraw", "disable capability")) { + cacheData.value = PrivatePaykitCacheData( + contacts = mapOf(CONTACT_KEY to PrivatePaykitContactCacheData(hasPublishedPrivatePaymentList = true)), + ) + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) + sut = createSut(publicRepo) + var capability = false + var failed = false + doSuspendableAnswer { + val enabled = it.getArgument(0) + if (!failed && failureStage == if (enabled) "enable capability" else "disable capability") { + failed = true + throw AppError("Registration unavailable") + } + capability = enabled + }.whenever(paykitSdkService).syncPaykitApp(any()) + doSuspendableAnswer { + assertTrue(capability, "Withdrawal requires the private capability") + if (!failed && failureStage == "withdraw") { + failed = true + throw AppError("Delivery unavailable") + } + privateListDeliveryReport() + }.whenever(paykitSdkService).clearPrivatePaymentList(CONTACT_KEY) + + sut.disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) + assertTrue(failed, failureStage) + assertTrue(cacheData.value.cleanupPending, failureStage) + publicRepo.syncPaykitApp(privateSharingEnabled = false).getOrThrow() + assertTrue(capability, failureStage) + + sut.retryPendingEndpointRemoval(listOf(CONTACT_KEY)).getOrThrow() + + assertFalse(capability, failureStage) + assertFalse(cacheData.value.cleanupPending, failureStage) + assertFalse(cacheData.value.contacts[CONTACT_KEY]?.hasPublishedPrivatePaymentList == true, failureStage) + } } @Test @@ -690,7 +403,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { val result = sut.disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) assertTrue(result.isSuccess) - verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } + verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY) } assertTrue(cacheData.value.contacts.isEmpty()) } @@ -702,14 +415,13 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { publicPaykitOnchainEnabled = true, ) sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true).getOrThrow() - whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) }.thenReturn( + whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY) }.thenReturn( privateListDeliveryReport( failedToQueue = listOf( PrivatePaymentListSyncChange( counterparty = CONTACT_KEY, - counterpartyReceiverPath = WALLET_RECEIVER_PATH, outboundMessageId = null, - error = "failed", + error = mock(), ), ), ), @@ -749,7 +461,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.retryPendingEndpointRemoval(listOf(CONTACT_KEY)).getOrThrow() - verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } + verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY) } assertFalse(cacheData.value.cleanupPending) } @@ -771,7 +483,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(result.isFailure) assertTrue(cacheData.value.cleanupPending) assertTrue( - cacheData.value.contacts.values.all { it.publishedPrivatePaymentReceiverPaths.isEmpty() }, + cacheData.value.contacts.values.all { !it.hasPublishedPrivatePaymentList }, ) } @@ -783,14 +495,13 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { publicPaykitOnchainEnabled = true, ) sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) - whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) }.thenReturn( + whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY) }.thenReturn( privateListDeliveryReport( failedToQueue = listOf( PrivatePaymentListSyncChange( counterparty = CONTACT_KEY, - counterpartyReceiverPath = WALLET_RECEIVER_PATH, outboundMessageId = null, - error = "failed", + error = mock(), ), ), ), @@ -810,123 +521,27 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { publicPaykitOnchainEnabled = true, ) sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) - whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } + whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY) } .thenReturn(privateListDeliveryReport(clearedCounterparties = listOf(CONTACT_KEY))) - val pendingReceiver = mock() - whenever(pendingReceiver.counterparty).thenReturn(CONTACT_KEY) - whenever(pendingReceiver.counterpartyReceiverPath).thenReturn(WALLET_RECEIVER_PATH) whenever { paykitSdkService.pendingOutboundPrivateCounterparties() } - .thenReturn(listOf(pendingReceiver)) + .thenReturn(listOf(CONTACT_KEY)) val result = sut.removePublishedEndpointsForCleanup("test") assertTrue(result.isFailure) assertTrue(cacheData.value.cleanupPending) assertEquals( - setOf(WALLET_RECEIVER_PATH), - cacheData.value.contacts.getValue(CONTACT_KEY).publishedPrivatePaymentReceiverPaths, + true, + cacheData.value.contacts.getValue(CONTACT_KEY).hasPublishedPrivatePaymentList, ) sut.closeAndClear() } - @Test - fun `cleanup keeps publication state when any saved receiver cleanup fails`() = test { - settingsData.value = SettingsData( - sharesPrivatePaykitEndpoints = true, - publicPaykitLightningEnabled = false, - publicPaykitOnchainEnabled = true, - ) - whenever { paykitSdkService.contactRecord(CONTACT_KEY) } - .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) - whenever { addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY, SERVER_RECEIVER_PATH) } - .thenReturn(Result.success(OTHER_PRIVATE_ADDRESS)) - whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) }.thenAnswer { - privateListDeliveryReportForUpdates(it.getArgument(0)) - } - sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) - assertEquals( - setOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH), - cacheData.value.contacts.getValue(CONTACT_KEY).publishedPrivatePaymentReceiverPaths, - ) - whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } - .thenReturn(privateListDeliveryReport(clearedCounterparties = listOf(CONTACT_KEY))) - whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY, SERVER_RECEIVER_PATH) }.thenReturn( - privateListDeliveryReport( - failedToQueue = listOf( - PrivatePaymentListSyncChange( - counterparty = CONTACT_KEY, - counterpartyReceiverPath = SERVER_RECEIVER_PATH, - outboundMessageId = null, - error = "failed", - ), - ), - ), - ) - - val result = sut.removePublishedEndpointsForCleanup("test") - - assertTrue(result.isFailure) - assertEquals(true, cacheData.value.cleanupPending) - assertEquals( - setOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH), - cacheData.value.contacts.getValue(CONTACT_KEY).publishedPrivatePaymentReceiverPaths, - ) - } - - @Test - fun `cleanup keeps pending state when linked receiver inspection fails`() = test { - settingsData.value = SettingsData( - sharesPrivatePaykitEndpoints = true, - publicPaykitLightningEnabled = false, - publicPaykitOnchainEnabled = true, - ) - sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true).getOrThrow() - whenever { paykitSdkService.linkedPeers() } - .thenThrow(IllegalStateException("link inspection failed")) - - val result = sut.removePublishedEndpointsForCleanup("test") - - assertTrue(result.isFailure) - assertTrue(cacheData.value.cleanupPending) - verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } - assertEquals( - setOf(WALLET_RECEIVER_PATH), - cacheData.value.contacts.getValue(CONTACT_KEY).publishedPrivatePaymentReceiverPaths, - ) - } - - @Test - fun `cleanup retries linked receiver inspection once for the batch`() = test { - cacheData.value = PrivatePaykitCacheData( - contacts = mapOf( - CONTACT_KEY to cachedPublishedContact(WALLET_RECEIVER_PATH), - OTHER_CONTACT_KEY to cachedPublishedContact(SERVER_RECEIVER_PATH), - ), - ) - sut = createSut() - var linkedPeerReads = 0 - whenever { paykitSdkService.linkedPeers() }.thenAnswer { - linkedPeerReads += 1 - if (linkedPeerReads <= 2) error("link inspection failed") - emptyList() - } - - val result = sut.removePublishedEndpointsForCleanup("test") - - assertTrue(result.isFailure) - verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } - verifyBlocking(paykitSdkService) { clearPrivatePaymentList(OTHER_CONTACT_KEY, SERVER_RECEIVER_PATH) } - assertTrue(CONTACT_KEY in cacheData.value.contacts) - assertTrue(OTHER_CONTACT_KEY in cacheData.value.contacts) - assertTrue(cacheData.value.cleanupPending) - assertEquals(3, linkedPeerReads) - } - @Test fun `invalid deleted contact key is dropped from cleanup state`() = test { val invalidPublicKey = "not-a-pubky" cacheData.value = PrivatePaykitCacheData( - contacts = mapOf(invalidPublicKey to cachedPublishedContact(WALLET_RECEIVER_PATH)), + contacts = mapOf(invalidPublicKey to cachedPublishedContact()), deletedContactCleanupPendingPublicKeys = setOf(invalidPublicKey), ) sut = createSut() @@ -936,50 +551,31 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(result.isSuccess, result.exceptionOrNull().toString()) assertTrue(cacheData.value.contacts.isEmpty()) assertTrue(cacheData.value.deletedContactCleanupPendingPublicKeys.isEmpty()) - verifyBlocking(paykitSdkService, never()) { clearPrivatePaymentList(any(), any()) } - } - - @Test - fun `cleanup uses linked receiver paths when contact record is gone`() = test { - settingsData.value = SettingsData( - sharesPrivatePaykitEndpoints = true, - publicPaykitLightningEnabled = false, - publicPaykitOnchainEnabled = true, - ) - whenever { paykitSdkService.contactRecord(CONTACT_KEY) }.thenReturn(null) - whenever { paykitSdkService.linkedPeers() } - .thenReturn(listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.LINKED))) - sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) - - val result = sut.removePublishedEndpointsForCleanup("test") - - assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } - verifyBlocking(paykitSdkService, never()) { clearPrivatePaymentList(CONTACT_KEY, SERVER_RECEIVER_PATH) } + verifyBlocking(paykitSdkService, never()) { clearPrivatePaymentList(any()) } } @Test fun `cleanup drains all contacts in one batch`() = test { cacheData.value = PrivatePaykitCacheData( contacts = mapOf( - CONTACT_KEY to cachedPublishedContact(WALLET_RECEIVER_PATH), - OTHER_CONTACT_KEY to cachedPublishedContact(SERVER_RECEIVER_PATH), + CONTACT_KEY to cachedPublishedContact(), + OTHER_CONTACT_KEY to cachedPublishedContact(), ), ) sut = createSut() whenever { paykitSdkService.linkedPeers() }.thenReturn( listOf( linkedPeer(CONTACT_KEY, LinkedPeerState.LINKED), - linkedPeer(OTHER_CONTACT_KEY, LinkedPeerState.LINKED, SERVER_RECEIVER_PATH), + linkedPeer(OTHER_CONTACT_KEY, LinkedPeerState.LINKED), ), ) val result = sut.removePublishedEndpointsForCleanup("test") assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } - verifyBlocking(paykitSdkService) { clearPrivatePaymentList(OTHER_CONTACT_KEY, SERVER_RECEIVER_PATH) } - verifyBlocking(paykitSdkService, times(2)) { linkedPeers() } + verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY) } + verifyBlocking(paykitSdkService) { clearPrivatePaymentList(OTHER_CONTACT_KEY) } + verifyBlocking(paykitSdkService, atLeast(1)) { linkedPeers() } verifyBlocking(paykitSdkService, times(1)) { pendingOutboundPrivateCounterparties() } verifyBlocking(paykitSdkService, times(2)) { processPendingPrivateMessages() } verifyBlocking(paykitSdkService, times(2)) { receivePrivateMessagesFromLinkedPeers() } @@ -990,8 +586,8 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `deleted contact retry cleans all pending contacts in one batch`() = test { cacheData.value = PrivatePaykitCacheData( contacts = mapOf( - CONTACT_KEY to cachedPublishedContact(WALLET_RECEIVER_PATH), - OTHER_CONTACT_KEY to cachedPublishedContact(SERVER_RECEIVER_PATH), + CONTACT_KEY to cachedPublishedContact(), + OTHER_CONTACT_KEY to cachedPublishedContact(), ), deletedContactCleanupPendingPublicKeys = setOf(CONTACT_KEY, OTHER_CONTACT_KEY), ) @@ -1000,9 +596,9 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { val result = sut.retryPendingEndpointRemoval(emptyList()) assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } - verifyBlocking(paykitSdkService) { clearPrivatePaymentList(OTHER_CONTACT_KEY, SERVER_RECEIVER_PATH) } - verifyBlocking(paykitSdkService, times(2)) { linkedPeers() } + verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY) } + verifyBlocking(paykitSdkService) { clearPrivatePaymentList(OTHER_CONTACT_KEY) } + verifyBlocking(paykitSdkService, atLeast(1)) { linkedPeers() } assertTrue(cacheData.value.contacts.isEmpty()) assertTrue(cacheData.value.deletedContactCleanupPendingPublicKeys.isEmpty()) } @@ -1011,13 +607,12 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `cleanup retains the batch when drain inspection fails`() = test { cacheData.value = PrivatePaykitCacheData( contacts = mapOf( - CONTACT_KEY to cachedPublishedContact(WALLET_RECEIVER_PATH), - OTHER_CONTACT_KEY to cachedPublishedContact(SERVER_RECEIVER_PATH), + CONTACT_KEY to cachedPublishedContact(), + OTHER_CONTACT_KEY to cachedPublishedContact(), ), ) sut = createSut() whenever { paykitSdkService.linkedPeers() } - .thenReturn(emptyList()) .thenThrow(IllegalStateException("drain inspection failed")) val result = sut.removePublishedEndpointsForCleanup("test") @@ -1031,23 +626,22 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `cleanup retains endpoint cache updated during remote removal`() = test { cacheData.value = PrivatePaykitCacheData( - contacts = mapOf(CONTACT_KEY to cachedPublishedContact(WALLET_RECEIVER_PATH)), + contacts = mapOf(CONTACT_KEY to cachedPublishedContact()), ) sut = createSut() val cleanupStarted = CompletableDeferred() val resumeCleanup = CompletableDeferred() - whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } + whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY) } .doSuspendableAnswer { cleanupStarted.complete(Unit) resumeCleanup.await() privateListDeliveryReport(clearedCounterparties = listOf(CONTACT_KEY)) } whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment( + paykitSdkService.prepareAndResolvePrivatePaymentRequest( eq(CONTACT_KEY), - eq(SERVER_RECEIVER_PATH), + eq("request-id"), eq(null), - any(), ) }.thenReturn(resolution(resolvedEndpoint(MethodId.P2wpkh, PRIVATE_ADDRESS), version = 7uL)) whenever(coreService.isAddressUsed(PRIVATE_ADDRESS)).thenReturn(false) @@ -1068,19 +662,18 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `cleanup isolates a failed contact while clearing successful contacts`() = test { cacheData.value = PrivatePaykitCacheData( contacts = mapOf( - CONTACT_KEY to cachedPublishedContact(WALLET_RECEIVER_PATH), - OTHER_CONTACT_KEY to cachedPublishedContact(SERVER_RECEIVER_PATH), + CONTACT_KEY to cachedPublishedContact(), + OTHER_CONTACT_KEY to cachedPublishedContact(), ), ) sut = createSut() - whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) }.thenReturn( + whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY) }.thenReturn( privateListDeliveryReport( failedToQueue = listOf( PrivatePaymentListSyncChange( counterparty = CONTACT_KEY, - counterpartyReceiverPath = WALLET_RECEIVER_PATH, outboundMessageId = null, - error = "failed", + error = mock(), ), ), ), @@ -1101,9 +694,9 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { publicPaykitLightningEnabled = false, publicPaykitOnchainEnabled = true, ) - whenever { addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY, WALLET_RECEIVER_PATH) } + whenever { addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY) } .thenReturn(Result.failure(PrivatePaykitTestAppError("address unavailable"))) - whenever { addressReservationRepo.currentOrRotatedAddress(OTHER_CONTACT_KEY, WALLET_RECEIVER_PATH) } + whenever { addressReservationRepo.currentOrRotatedAddress(OTHER_CONTACT_KEY) } .thenReturn(Result.success(OTHER_PRIVATE_ADDRESS)) whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) }.thenReturn( privateListDeliveryReport(queuedCounterparties = listOf(OTHER_CONTACT_KEY)), @@ -1113,13 +706,13 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(result.isSuccess) assertEquals( - setOf(WALLET_RECEIVER_PATH), - cacheData.value.contacts.getValue(OTHER_CONTACT_KEY).publishedPrivatePaymentReceiverPaths, + true, + cacheData.value.contacts.getValue(OTHER_CONTACT_KEY).hasPublishedPrivatePaymentList, ) } @Test - fun `prepareSavedContacts continues when another contact record cannot be read`() = test { + fun `prepareSavedContacts does not require a cached contact record`() = test { settingsData.value = SettingsData( sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false, @@ -1127,7 +720,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) whenever { paykitSdkService.contactRecord(CONTACT_KEY) } .thenThrow(IllegalStateException("contact unavailable")) - whenever { addressReservationRepo.currentOrRotatedAddress(OTHER_CONTACT_KEY, WALLET_RECEIVER_PATH) } + whenever { addressReservationRepo.currentOrRotatedAddress(OTHER_CONTACT_KEY) } .thenReturn(Result.success(OTHER_PRIVATE_ADDRESS)) whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) }.thenAnswer { privateListDeliveryReportForUpdates(it.getArgument(0)) @@ -1138,7 +731,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(result.isSuccess) val captor = argumentCaptor>() verifyBlocking(paykitSdkService) { syncPrivatePaymentListsWithReservations(captor.capture(), eq(false)) } - assertEquals(listOf(OTHER_CONTACT_KEY), captor.firstValue.map { it.counterparty }) + assertEquals(listOf(CONTACT_KEY, OTHER_CONTACT_KEY), captor.firstValue.map { it.counterparty }) } @Test @@ -1173,7 +766,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `beginSavedContactPayment uses public resolution while Noise link is not established`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, null) }.thenReturn( resolution( status = PrivatePaymentResolutionStatus.NO_ENDPOINT, @@ -1194,7 +787,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever(paykitSdkService.hasPrivatePaymentAccess()).thenReturn(false) whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, null) }.thenReturn(resolution(resolvedEndpoint(MethodId.Bolt11, PRIVATE_BOLT11), version = 7uL)) whenever(coreService.decode(PRIVATE_BOLT11)) .thenReturn(Scanner.Lightning(lightningInvoice(PRIVATE_BOLT11, byteArrayOf(9, 9, 9)))) @@ -1204,7 +797,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals( PublicPaykitPaymentResult.Opened( paymentRequest = PRIVATE_BOLT11, - privatePaymentContext = PrivatePaykitPaymentContext(WALLET_RECEIVER_PATH, 7uL), + privatePaymentContext = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL), ), result, ) @@ -1227,7 +820,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { privateListDeliveryReport(queuedCounterparties = listOf(CONTACT_KEY)) } whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, null) }.thenReturn(resolution(resolvedEndpoint(MethodId.Bolt11, PRIVATE_BOLT11), version = 7uL)) whenever(coreService.decode(PRIVATE_BOLT11)) .thenReturn(Scanner.Lightning(lightningInvoice(PRIVATE_BOLT11, byteArrayOf(9, 9, 9)))) @@ -1257,7 +850,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { privateListDeliveryReport(queuedCounterparties = listOf(CONTACT_KEY)) } whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, null) }.thenReturn(resolution(resolvedEndpoint(MethodId.Bolt11, PRIVATE_BOLT11), version = 7uL)) whenever(coreService.decode(PRIVATE_BOLT11)) .thenReturn(Scanner.Lightning(lightningInvoice(PRIVATE_BOLT11, byteArrayOf(9, 9, 9)))) @@ -1276,7 +869,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `beginSavedContactPayment opens private endpoint with its list version`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, null) }.thenReturn(resolution(resolvedEndpoint(MethodId.Bolt11, PRIVATE_BOLT11), version = 7uL)) whenever(coreService.decode(PRIVATE_BOLT11)) .thenReturn(Scanner.Lightning(lightningInvoice(PRIVATE_BOLT11, byteArrayOf(9, 9, 9)))) @@ -1286,7 +879,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals( PublicPaykitPaymentResult.Opened( paymentRequest = PRIVATE_BOLT11, - privatePaymentContext = PrivatePaykitPaymentContext(WALLET_RECEIVER_PATH, 7uL), + privatePaymentContext = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL), ), result, ) @@ -1297,7 +890,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `beginSavedContactPayment never falls back while linked recovery is pending`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, null) }.thenReturn( resolution( status = PrivatePaymentResolutionStatus.NO_ENDPOINT, @@ -1317,11 +910,10 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `beginPaymentRequest waits for a linking peer before opening cached details`() = test { val request = paymentRequest() whenever( - paykitSdkService.prepareAndResolvePrivateContactPayment( + paykitSdkService.prepareAndResolvePrivatePaymentRequest( eq(CONTACT_KEY), - eq(SERVER_RECEIVER_PATH), + eq("request-id"), eq(null), - any(), ) ).thenReturn( resolution( @@ -1345,7 +937,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals( PublicPaykitPaymentResult.Opened( paymentRequest = SERVER_PRIVATE_BOLT11, - privatePaymentContext = PrivatePaykitPaymentContext(SERVER_RECEIVER_PATH, 7uL), + privatePaymentContext = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL), ), opened, ) @@ -1356,11 +948,10 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `beginPaymentRequest rejects cached details when the peer is not linked`() = test { val request = paymentRequest() whenever( - paykitSdkService.prepareAndResolvePrivateContactPayment( + paykitSdkService.prepareAndResolvePrivatePaymentRequest( eq(CONTACT_KEY), - eq(SERVER_RECEIVER_PATH), + eq("request-id"), eq(null), - any(), ) ).thenReturn( resolution( @@ -1381,11 +972,10 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `beginPaymentRequest keeps typed recovery failures pending`() = test { val request = paymentRequest() whenever( - paykitSdkService.prepareAndResolvePrivateContactPayment( + paykitSdkService.prepareAndResolvePrivatePaymentRequest( eq(CONTACT_KEY), - eq(SERVER_RECEIVER_PATH), + eq("request-id"), eq(null), - any(), ) ).doSuspendableAnswer { throw PaykitException.RecoveryRequired("recovery_required", "Handshake is in progress") @@ -1401,7 +991,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `beginSavedContactPayment retries a newer private list without public fallback`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, null) }.thenReturn( resolution( status = PrivatePaymentResolutionStatus.WAITING_FOR_UPDATED_PAYMENT_LIST, @@ -1419,12 +1009,12 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals( PublicPaykitPaymentResult.Opened( paymentRequest = PRIVATE_BOLT11, - privatePaymentContext = PrivatePaykitPaymentContext(WALLET_RECEIVER_PATH, 7uL), + privatePaymentContext = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL), ), result, ) verifyBlocking(paykitSdkService, times(2)) { - prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) + prepareAndResolvePrivateContactPayment(CONTACT_KEY, null) } verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } @@ -1433,7 +1023,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `beginSavedContactPayment only falls back after failure when Noise link is absent`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, null) }.thenThrow(IllegalStateException("private unavailable")) val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() @@ -1446,7 +1036,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `beginSavedContactPayment propagates failure when Noise link exists`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, null) }.thenThrow(IllegalStateException("private unavailable")) whenever(paykitSdkService.linkedPeers()) .thenReturn(listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.LINKED))) @@ -1459,14 +1049,14 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `consumePrivatePaymentList persists version clears list and rejects reuse`() = test { - val context = PrivatePaykitPaymentContext(WALLET_RECEIVER_PATH, 7uL) + val context = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL) sut.consumePrivatePaymentList(CONTACT_KEY, context).getOrThrow() assertEquals( 7uL, cacheData.value.contacts.getValue(CONTACT_KEY) - .consumedPrivatePaymentListVersionsByReceiverPath[WALLET_RECEIVER_PATH], + .consumedPrivatePaymentListVersion, ) assertFailsWith { sut.consumePrivatePaymentList(CONTACT_KEY, context).getOrThrow() @@ -1475,16 +1065,15 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `releasePrivatePaymentList makes matching version reusable without clearing newer consumption`() = test { - val releasedContext = PrivatePaykitPaymentContext(WALLET_RECEIVER_PATH, 7uL) - val newerContext = PrivatePaykitPaymentContext(WALLET_RECEIVER_PATH, 8uL) + val releasedContext = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL) + val newerContext = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 8uL) sut.consumePrivatePaymentList(CONTACT_KEY, releasedContext).getOrThrow() sut.releasePrivatePaymentList(CONTACT_KEY, releasedContext).getOrThrow() assertNull( cacheData.value.contacts[CONTACT_KEY] - ?.consumedPrivatePaymentListVersionsByReceiverPath - ?.get(WALLET_RECEIVER_PATH), + ?.consumedPrivatePaymentListVersion, ) sut.consumePrivatePaymentList(CONTACT_KEY, releasedContext).getOrThrow() sut.consumePrivatePaymentList(CONTACT_KEY, newerContext).getOrThrow() @@ -1494,7 +1083,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals( 8uL, cacheData.value.contacts.getValue(CONTACT_KEY) - .consumedPrivatePaymentListVersionsByReceiverPath[WALLET_RECEIVER_PATH], + .consumedPrivatePaymentListVersion, ) } @@ -1503,10 +1092,10 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.prepareSavedContacts(listOf(CONTACT_KEY)) sut.consumePrivatePaymentList( CONTACT_KEY, - PrivatePaykitPaymentContext(WALLET_RECEIVER_PATH, 7uL), + PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL), ).getOrThrow() whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, 7uL) + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, 7uL) }.thenReturn( resolution( status = PrivatePaymentResolutionStatus.WAITING_FOR_UPDATED_PAYMENT_LIST, @@ -1519,7 +1108,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() verifyBlocking(paykitSdkService, times(4)) { - prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, 7uL) + prepareAndResolvePrivateContactPayment(CONTACT_KEY, 7uL) } } @@ -1527,7 +1116,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `beginSavedContactPayment does not fall back when private resolution is cancelled`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, null) }.thenThrow(CancellationException("cancelled")) assertFailsWith { @@ -1540,11 +1129,10 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `beginPaymentRequestWaitingForUpdatedList retries a newer private list`() = test { val request = paymentRequest() whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment( + paykitSdkService.prepareAndResolvePrivatePaymentRequest( eq(CONTACT_KEY), - eq(SERVER_RECEIVER_PATH), + eq("request-id"), eq(null), - any(), ) }.thenReturn( resolution( @@ -1563,16 +1151,15 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals( PublicPaykitPaymentResult.Opened( paymentRequest = SERVER_PRIVATE_BOLT11, - privatePaymentContext = PrivatePaykitPaymentContext(SERVER_RECEIVER_PATH, 7uL), + privatePaymentContext = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL), ), result, ) verifyBlocking(paykitSdkService, times(2)) { - prepareAndResolvePrivateContactPayment( + prepareAndResolvePrivatePaymentRequest( eq(CONTACT_KEY), - eq(SERVER_RECEIVER_PATH), + eq("request-id"), eq(null), - any(), ) } } @@ -1581,11 +1168,10 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `beginPaymentRequest resolves only accepted private endpoints with the requested amount`() = test { val request = paymentRequest(acceptedEndpointIdentifiers = listOf(MethodId.Bolt11.rawValue)) whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment( + paykitSdkService.prepareAndResolvePrivatePaymentRequest( eq(CONTACT_KEY), - eq(SERVER_RECEIVER_PATH), + eq("request-id"), eq(null), - any(), ) }.thenReturn( resolution( @@ -1602,21 +1188,17 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals( PublicPaykitPaymentResult.Opened( paymentRequest = PRIVATE_BOLT11, - privatePaymentContext = PrivatePaykitPaymentContext(SERVER_RECEIVER_PATH, 7uL), + privatePaymentContext = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL), ), result, ) - val amountCaptor = argumentCaptor() verifyBlocking(paykitSdkService) { - prepareAndResolvePrivateContactPayment( + prepareAndResolvePrivatePaymentRequest( eq(CONTACT_KEY), - eq(SERVER_RECEIVER_PATH), + eq("request-id"), eq(null), - amountCaptor.capture(), ) } - assertEquals("0.000025", amountCaptor.firstValue.value) - assertEquals("btc", amountCaptor.firstValue.asset) verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } @@ -1625,11 +1207,10 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest() whenever(paykitSdkService.hasPrivatePaymentAccess()).thenReturn(false) whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment( + paykitSdkService.prepareAndResolvePrivatePaymentRequest( eq(CONTACT_KEY), - eq(SERVER_RECEIVER_PATH), + eq("request-id"), eq(null), - any(), ) }.thenReturn( resolution( @@ -1645,13 +1226,67 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals( PublicPaykitPaymentResult.Opened( paymentRequest = SERVER_PRIVATE_BOLT11, - privatePaymentContext = PrivatePaykitPaymentContext(SERVER_RECEIVER_PATH, 7uL), + privatePaymentContext = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL), ), result, ) verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } + @Test + fun `bound requests keep their own addresses after contact list updates without consuming the list`() = test { + sut.prepareSavedContacts(listOf(CONTACT_KEY)) + sut.consumePrivatePaymentList(CONTACT_KEY, PrivatePaykitPaymentContext(emptyMap(), 7uL)).getOrThrow() + whenever { + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, 7uL) + }.thenReturn(resolution(resolvedEndpoint(MethodId.P2wpkh, "latest-address"), version = 8uL)) + whenever(coreService.isAddressUsed(any())).thenReturn(false) + sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() + val cachedEndpoints = cacheData.value.contacts.getValue(CONTACT_KEY).remoteEndpoints + val addresses = mapOf("invoice-a" to PRIVATE_ADDRESS, "invoice-b" to OTHER_PRIVATE_ADDRESS) + whenever { + paykitSdkService.prepareAndResolvePrivatePaymentRequest(eq(CONTACT_KEY), any(), eq(7uL)) + }.thenAnswer { + resolution(resolvedEndpoint(MethodId.P2wpkh, addresses.getValue(it.getArgument(1))), version = null) + } + + for (id in listOf("invoice-a", "invoice-b", "invoice-a")) { + val request = paymentRequest(listOf(MethodId.P2wpkh.rawValue)).copy(paymentRequestId = id) + val result = sut.beginPaymentRequest(request).getOrThrow() + val context = PrivatePaykitPaymentContext(mapOf(MethodId.P2wpkh.rawValue to "bitkit"), null) + assertEquals(PublicPaykitPaymentResult.Opened(addresses.getValue(id), context), result) + sut.consumePrivatePaymentList(CONTACT_KEY, context).getOrThrow() + sut.releasePrivatePaymentList(CONTACT_KEY, context).getOrThrow() + assertEquals(7uL, cacheData.value.contacts.getValue(CONTACT_KEY).consumedPrivatePaymentListVersion) + assertEquals(cachedEndpoints, cacheData.value.contacts.getValue(CONTACT_KEY).remoteEndpoints) + } + verifyBlocking(paykitSdkService, times(1)) { prepareAndResolvePrivateContactPayment(CONTACT_KEY, 7uL) } + verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } + } + + @Test + fun `bound request with no payable candidate never falls back to contact or public endpoints`() = test { + whenever { + paykitSdkService.prepareAndResolvePrivatePaymentRequest(CONTACT_KEY, "request-id", null) + }.thenReturn(resolution(version = null, linkState = LinkedPeerState.LINKED)) + + assertEquals(PublicPaykitPaymentResult.NoEndpoint, sut.beginPaymentRequest(paymentRequest()).getOrThrow()) + verifyBlocking(paykitSdkService, never()) { prepareAndResolvePrivateContactPayment(any(), any(), any()) } + verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } + } + + @Test + fun `bound request keeps wallet address usage validation`() = test { + whenever { + paykitSdkService.prepareAndResolvePrivatePaymentRequest(CONTACT_KEY, "request-id", null) + }.thenReturn(resolution(resolvedEndpoint(MethodId.P2wpkh, PRIVATE_ADDRESS), version = null)) + whenever(coreService.isAddressUsed(PRIVATE_ADDRESS)).thenReturn(true) + + val request = paymentRequest(listOf(MethodId.P2wpkh.rawValue)) + assertEquals(PublicPaykitPaymentResult.NotOpened, sut.beginPaymentRequest(request).getOrThrow()) + verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } + } + @Test fun `beginPaymentRequest rechecks expiration after private resolution`() = test { val request = paymentRequest() @@ -1660,11 +1295,10 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { Instant.fromEpochSeconds(NOW_SECONDS + 61), ) whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment( + paykitSdkService.prepareAndResolvePrivatePaymentRequest( eq(CONTACT_KEY), - eq(SERVER_RECEIVER_PATH), + eq("request-id"), eq(null), - any(), ) }.thenReturn( resolution( @@ -1687,7 +1321,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.exportBackupState()).thenReturn(backup) sut.consumePrivatePaymentList( CONTACT_KEY, - PrivatePaykitPaymentContext(WALLET_RECEIVER_PATH, 7uL), + PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL), ).getOrThrow() val snapshot = sut.backupSnapshot().getOrThrow() @@ -1697,12 +1331,12 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals( 7uL, cacheData.value.contacts.getValue(CONTACT_KEY) - .consumedPrivatePaymentListVersionsByReceiverPath[WALLET_RECEIVER_PATH], + .consumedPrivatePaymentListVersion, ) - verifyBlocking(paykitSdkService) { restoreBackupState(backup) } + verifyBlocking(paykitSdkService) { retainRecoveryBackup(backup) } } - private fun createSut() = PrivatePaykitRepo( + private fun createSut(publicPaykitRepo: PublicPaykitRepo = this.publicPaykitRepo) = PrivatePaykitRepo( ioDispatcher = testDispatcher, paykitSdkService = paykitSdkService, pubkyService = pubkyService, @@ -1747,6 +1381,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { return PaykitResolvedPaymentEndpoint( identifier = methodId.rawValue, payload = PublicPaykitRepo.serializePayload(value), + appId = "bitkit", ) } @@ -1755,7 +1390,6 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) = PaykitPaymentRequest( paymentRequestId = "request-id", counterparty = CONTACT_KEY, - counterpartyReceiverPath = SERVER_RECEIVER_PATH, amountValue = "0.000025", amountSats = 2_500uL, expiresAt = Instant.fromEpochSeconds(NOW_SECONDS + 60), @@ -1770,7 +1404,6 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { queued = queuedCounterparties.map { PrivatePaymentListSyncChange( counterparty = it, - counterpartyReceiverPath = WALLET_RECEIVER_PATH, outboundMessageId = null, error = null, ) @@ -1778,7 +1411,6 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { cleared = clearedCounterparties.map { PrivatePaymentListSyncChange( counterparty = it, - counterpartyReceiverPath = WALLET_RECEIVER_PATH, outboundMessageId = null, error = null, ) @@ -1787,20 +1419,8 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { failedToDeliver = emptyList(), ) - private fun cachedPublishedContact(receiverPath: String) = PrivatePaykitContactCacheData( - publishedPrivatePaymentReceiverPaths = setOf(receiverPath), - ) - - private fun privateReceiverPathSelection( - publishableReceiverPaths: List, - linkableReceiverPaths: List = publishableReceiverPaths, - cleanupProtectedReceiverPaths: List = emptyList(), - error: Throwable? = null, - ) = PaykitPrivateReceiverPathSelection( - linkableReceiverPaths = linkableReceiverPaths, - publishableReceiverPaths = publishableReceiverPaths, - cleanupProtectedReceiverPaths = cleanupProtectedReceiverPaths, - error = error, + private fun cachedPublishedContact() = PrivatePaykitContactCacheData( + hasPublishedPrivatePaymentList = true, ) private fun privateListDeliveryReportForUpdates( @@ -1808,34 +1428,30 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) = PrivatePaymentListDeliveryReport( queued = updates .filter { it.reservations.isNotEmpty() } - .map { privateListSyncChange(it.counterparty, it.counterpartyReceiverPath) }, + .map { privateListSyncChange(it.counterparty) }, cleared = updates .filter { it.reservations.isEmpty() } - .map { privateListSyncChange(it.counterparty, it.counterpartyReceiverPath) }, + .map { privateListSyncChange(it.counterparty) }, failedToQueue = emptyList(), failedToDeliver = emptyList(), ) private fun privateListSyncChange( counterparty: String, - receiverPath: String, ) = PrivatePaymentListSyncChange( counterparty = counterparty, - counterpartyReceiverPath = receiverPath, outboundMessageId = null, error = null, ) - private fun contactRecord(publicKey: String, receiverPaths: List) = ContactRecord( + private fun contactRecord(publicKey: String) = ContactRecord( publicKey = publicKey, - receiverPaths = receiverPaths, label = null, profile = null, profileFetchedAt = null, createdAt = "2026-01-01T00:00:00Z", updatedAt = "2026-01-01T00:00:00Z", publicContactMarkerStatus = PublicationStatus.NOT_PUBLISHED, - publicContactMarkerReceiverPath = null, publicContactPublishedAt = null, publicContactRemovedAt = null, publicContactLastError = null, @@ -1844,10 +1460,8 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { private fun linkedPeer( publicKey: String, state: LinkedPeerState, - receiverPath: String = WALLET_RECEIVER_PATH, ) = LinkedPeerRecord( counterparty = publicKey, - counterpartyReceiverPath = receiverPath, state = state, lastSyncAt = null, lastPrivateReceiveAt = null, diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index c85a168be5..3c01d1cb84 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -4,11 +4,11 @@ import app.cash.turbine.test import coil3.ImageLoader import coil3.disk.DiskCache import coil3.memory.MemoryCache -import com.synonym.paykit.ContactProfileResolution -import com.synonym.paykit.ContactProfileSource import com.synonym.paykit.ContactRecord import com.synonym.paykit.PaykitException import com.synonym.paykit.PaykitProfile +import com.synonym.paykit.ProfileResolution +import com.synonym.paykit.ProfileSource import com.synonym.paykit.PubkyAuthCompanionClaim import com.synonym.paykit.PubkySessionBootstrapResult import com.synonym.paykit.PublicationStatus @@ -54,6 +54,7 @@ import to.bitkit.data.sharedpubky.SharedPubkyClient import to.bitkit.data.sharedpubky.SharedPubkyContract import to.bitkit.ext.runSuspendCatching import to.bitkit.models.PubkyAuthClaim +import to.bitkit.models.PubkyAuthClaim.Item import to.bitkit.models.PubkyAuthRequest import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkySessionBackupKind @@ -148,7 +149,6 @@ class PubkyRepoTest : BaseUnitTest() { .thenReturn(mock()) } whenever(pubkyService.resolveContactProfile(any(), any())).thenAnswer { throw TestAppError("Offline") } - whenever(pubkyService.discoverRelevantReceiverPaths(any())).thenAnswer { throw TestAppError("Offline") } val result = sut.importContacts(profiles + profiles) @@ -158,7 +158,6 @@ class PubkyRepoTest : BaseUnitTest() { verify(pubkyService).saveContact(profile.publicKey, profile.name, restorePrivateConnection = true) } verify(pubkyService, never()).resolveContactProfile(any(), any()) - verify(pubkyService, never()).discoverRelevantReceiverPaths(any()) } @Test @@ -390,27 +389,35 @@ class PubkyRepoTest : BaseUnitTest() { @Test fun `approveAuthWithCompanionClaim forwards exact claim identifiers and capability`() = test { - val authUrl = "pubkyauth://signin?x-bitkit-claim=watch-only-account-v1" val clientId = "paykit.test" val secretKey = "local_secret" - val payload = ByteArray(84) { it.toByte() } whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(secretKey) - val result = sut.approveAuthWithCompanionClaim(authUrl, clientId, payload) - - assertTrue(result.isSuccess) - verifyBlocking(pubkyService) { - approveAuthWithCompanionClaim( - authUrl = authUrl, - expectedCapabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES, - approvedClientId = clientId, - secretKeyHex = secretKey, - claim = PubkyAuthCompanionClaim( - queryParameter = PubkyAuthClaim.QUERY_PARAMETER, - claimType = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue, - unsignedPayload = payload, - ), - ) + val selections = listOf( + PubkyAuthClaim(Item.PAYKIT_ACCESS_V1), + PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1), + PubkyAuthClaim(Item.PAYKIT_ACCESS_V1, Item.WATCH_ONLY_ACCOUNT_V1), + requireNotNull(PubkyAuthClaim.fromWireValue("watch-only-account-v1.paykit-access-v1")), + ) + for (claimType in selections) { + val payload = if (claimType.includesWatchOnlyAccount) ByteArray(84).apply { this[0] = 1 } else byteArrayOf() + val authUrl = "pubkyauth://signin?x-bitkit-claim=${claimType.wireValue}" + val result = sut.approveAuthWithCompanionClaim(authUrl, clientId, payload) + + assertTrue(result.isSuccess) + verifyBlocking(pubkyService) { + approveAuthWithCompanionClaim( + authUrl = authUrl, + expectedCapabilities = PubkyAuthClaim.REQUIRED_CAPABILITIES, + approvedClientId = clientId, + secretKeyHex = secretKey, + claim = PubkyAuthCompanionClaim( + queryParameter = PubkyAuthClaim.QUERY_PARAMETER, + claimType = claimType.wireValue, + unsignedPayload = payload, + ), + ) + } } } @@ -2253,71 +2260,13 @@ class PubkyRepoTest : BaseUnitTest() { fun `addContact should canonicalize key before persistence`() = test { authenticateForTesting() val profile = PubkyProfile.placeholder(NON_CANONICAL_CONTACT_KEY_A) - whenever { pubkyService.discoverRelevantReceiverPaths(VALID_CONTACT_KEY_A) }.thenReturn(emptyList()) val result = sut.addContact(NON_CANONICAL_CONTACT_KEY_A, existingProfile = profile) assertTrue(result.isSuccess) assertEquals(VALID_CONTACT_KEY_A, sut.contacts.value.single().publicKey) verifyBlocking(pubkyService) { - saveContact(VALID_CONTACT_KEY_A, profile.name, emptyList(), restorePrivateConnection = true) - } - } - - @Test - fun `refreshContactReceiverPaths should update saved contact receiver paths`() = test { - authenticateForTesting() - val contact = PubkyProfile( - publicKey = VALID_CONTACT_KEY_B, - name = "Alice", - bio = "", - imageUrl = null, - links = emptyList(), - tags = emptyList(), - status = null, - ) - sut.addContact(VALID_CONTACT_KEY_B, existingProfile = contact) - clearInvocations(pubkyService) - whenever(pubkyService.discoverRelevantReceiverPaths(VALID_CONTACT_KEY_B)) - .thenReturn(listOf("bitkit/wallet", "bitkit/server")) - - val result = sut.refreshContactReceiverPaths(VALID_CONTACT_KEY_B) - - assertTrue(result.isSuccess) - verifyBlocking(pubkyService) { - saveContact( - VALID_CONTACT_KEY_B, - "Alice", - listOf("bitkit/wallet", "bitkit/server"), - ) - } - } - - @Test - fun `refreshContactReceiverPaths should preserve a loaded noncanonical key`() = test { - authenticateForTesting() - whenever(pubkyService.contactRecords()).thenReturn( - listOf( - createContactRecord( - publicKey = NON_CANONICAL_CONTACT_KEY_A, - profile = createPaykitProfile("Alice"), - ), - ), - ) - sut.loadContacts() - clearInvocations(pubkyService) - whenever(pubkyService.discoverRelevantReceiverPaths(NON_CANONICAL_CONTACT_KEY_A)) - .thenReturn(listOf("bitkit/wallet", "bitkit/server")) - - val result = sut.refreshContactReceiverPaths(NON_CANONICAL_CONTACT_KEY_A) - - assertTrue(result.isSuccess) - verifyBlocking(pubkyService) { - saveContact( - NON_CANONICAL_CONTACT_KEY_A, - "Alice", - listOf("bitkit/wallet", "bitkit/server"), - ) + saveContact(VALID_CONTACT_KEY_A, profile.name, restorePrivateConnection = true) } } @@ -2690,14 +2639,12 @@ class PubkyRepoTest : BaseUnitTest() { profile: PaykitProfile? = null, ) = ContactRecord( publicKey = publicKey, - receiverPaths = listOf("bitkit/wallet"), label = label, profile = profile, profileFetchedAt = null, createdAt = "2026-01-01T00:00:00Z", updatedAt = "2026-01-01T00:00:00Z", publicContactMarkerStatus = PublicationStatus.NOT_PUBLISHED, - publicContactMarkerReceiverPath = null, publicContactPublishedAt = null, publicContactRemovedAt = null, publicContactLastError = null, @@ -2707,12 +2654,12 @@ class PubkyRepoTest : BaseUnitTest() { publicKey: String, paykitProfile: PaykitProfile? = null, pubkyProfile: SdkPubkyProfile? = null, - ) = ContactProfileResolution( + ) = ProfileResolution( publicKey = publicKey, source = if (paykitProfile != null) { - ContactProfileSource.PAYKIT_PROFILE + ProfileSource.PAYKIT_PROFILE } else { - ContactProfileSource.PUBKY_PROFILE + ProfileSource.PUBKY_PROFILE }, displayName = paykitProfile?.displayName ?: pubkyProfile?.name, imageUri = paykitProfile?.imageUri ?: pubkyProfile?.image, diff --git a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt index faed638630..16207eaafe 100644 --- a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt @@ -14,13 +14,15 @@ import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever +import to.bitkit.data.PrivatePaykitCacheData +import to.bitkit.data.PrivatePaykitCacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.services.CoreService import to.bitkit.services.PaykitPublicContactPaymentResolution -import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PaykitResolvedPaymentEndpoint import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest @@ -45,11 +47,13 @@ class PublicPaykitRepoTest : BaseUnitTest() { private val coreService = mock() private val paykitSdkService = mock() private val settingsStore = mock() + private val privatePaykitCacheStore = mock() private val clock = mock() private val publicKey = MutableStateFlow("pubkyself") private val walletState = MutableStateFlow(WalletState()) private val settingsFlow = MutableStateFlow(SettingsData()) + private val privateCacheFlow = MutableStateFlow(PrivatePaykitCacheData()) private lateinit var sut: PublicPaykitRepo @@ -63,6 +67,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { whenever(pubkyRepo.publicKey).thenReturn(publicKey) whenever(walletRepo.walletState).thenReturn(walletState) whenever(settingsStore.data).thenReturn(settingsFlow) + whenever(privatePaykitCacheStore.data).thenReturn(privateCacheFlow) whenever(clock.now()).thenReturn(Instant.fromEpochMilliseconds(NOW_MILLIS)) whenever(paykitSdkService.syncPublicEndpoints(any())).thenReturn(syncReport()) whenever { walletRepo.refreshReusableReceiveAddress() }.thenReturn(Result.success(Unit)) @@ -78,6 +83,21 @@ class PublicPaykitRepoTest : BaseUnitTest() { PublicPaykitRepo.lightningRouteHintsValidator = null } + @Test + fun `private capability remains enabled until all pending cleanup is complete`() = test { + for (pending in listOf( + PrivatePaykitCacheData(cleanupPending = true), + PrivatePaykitCacheData(deletedContactCleanupPendingPublicKeys = setOf("pubkycontact")), + PrivatePaykitCacheData(), + )) { + privateCacheFlow.value = pending + sut.syncPaykitApp(privateSharingEnabled = false).getOrThrow() + } + val capabilities = argumentCaptor() + verifyBlocking(paykitSdkService, times(3)) { syncPaykitApp(capabilities.capture()) } + assertEquals(listOf(true, true, false), capabilities.allValues) + } + @Test fun `syncCurrentPublishedEndpoints configures SDK public endpoints`() = test { walletState.value = WalletState(onchainAddress = "bc1ptest") @@ -120,19 +140,19 @@ class PublicPaykitRepoTest : BaseUnitTest() { } @Test - fun `syncPublishedEndpoints does not publish endpoints when receiver marker publish fails`() = test { + fun `syncPublishedEndpoints does not publish endpoints when app registration fails`() = test { settingsFlow.value = SettingsData( publicPaykitLightningEnabled = false, publicPaykitOnchainEnabled = true, ) walletState.value = WalletState(onchainAddress = "bc1ptest") - val markerError = RuntimeException("marker failed") - whenever { paykitSdkService.syncLocalReceiverMarker(isDiscoverable = true) } - .thenThrow(markerError) + val appError = RuntimeException("app registration failed") + whenever { paykitSdkService.syncPaykitApp(privatePaymentsEnabled = false) } + .thenThrow(appError) val error = sut.syncPublishedEndpoints(publish = true).exceptionOrNull() - assertEquals(markerError, error) + assertEquals(appError, error) verifyBlocking(paykitSdkService, never()) { syncPublicEndpoints(any()) } } @@ -154,15 +174,15 @@ class PublicPaykitRepoTest : BaseUnitTest() { } @Test - fun `syncPublishedEndpoints remove keeps cleanup pending when receiver marker removal fails`() = test { + fun `syncPublishedEndpoints remove keeps cleanup pending when app capability update fails`() = test { settingsFlow.value = SettingsData(publicPaykitCleanupPending = true) - val markerError = RuntimeException("marker failed") - whenever { paykitSdkService.syncLocalReceiverMarker(isDiscoverable = false) } - .thenThrow(markerError) + val appError = RuntimeException("app registration failed") + whenever { paykitSdkService.syncPaykitApp(privatePaymentsEnabled = false) } + .thenThrow(appError) val error = sut.syncPublishedEndpoints(publish = false).exceptionOrNull() - assertEquals(markerError, error) + assertEquals(appError, error) assertTrue(settingsFlow.value.publicPaykitCleanupPending) verifyBlocking(paykitSdkService) { syncPublicEndpoints(emptyList()) } } @@ -170,14 +190,14 @@ class PublicPaykitRepoTest : BaseUnitTest() { @Test fun `syncPublishedEndpoints remove preserves both cleanup failures`() = test { val endpointError = RuntimeException("endpoint failed") - val markerError = RuntimeException("marker failed") + val appError = RuntimeException("app registration failed") whenever { paykitSdkService.syncPublicEndpoints(emptyList()) }.thenThrow(endpointError) - whenever { paykitSdkService.syncLocalReceiverMarker(isDiscoverable = false) }.thenThrow(markerError) + whenever { paykitSdkService.syncPaykitApp(privatePaymentsEnabled = false) }.thenThrow(appError) val error = sut.syncPublishedEndpoints(publish = false).exceptionOrNull() assertEquals(endpointError, error) - assertEquals(listOf(markerError), error?.suppressedExceptions) + assertEquals(listOf(appError), error?.suppressedExceptions) } @Test @@ -214,7 +234,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { @Test fun `beginPayment opens SDK resolved public endpoint`() = test { whenever { - paykitSdkService.resolvePublicContactPayment("pubkycontact", PaykitReceiverPaths.WALLET) + paykitSdkService.resolvePublicContactPayment("pubkycontact") }.thenReturn( resolution( resolvedEndpoint( @@ -231,6 +251,27 @@ class PublicPaykitRepoTest : BaseUnitTest() { assertEquals(PublicPaykitPaymentResult.Opened(PUBLIC_BOLT11), result) } + @Test + fun `beginPayment retains payable alternatives regardless of app order`() = test { + val unavailableInvoice = "lnbcrt1unavailable" + val endpoints = listOf( + resolvedEndpoint(MethodId.Bolt11, unavailableInvoice), + resolvedEndpoint(MethodId.Bolt11, PUBLIC_BOLT11).copy(appId = "another-wallet"), + ) + whenever(coreService.decode(unavailableInvoice)).thenThrow(IllegalArgumentException("Invalid invoice")) + whenever(coreService.decode(PUBLIC_BOLT11)) + .thenReturn(Scanner.Lightning(lightningInvoice(PUBLIC_BOLT11, byteArrayOf(4, 5, 6)))) + + for (ordered in listOf(endpoints, endpoints.reversed())) { + whenever { paykitSdkService.resolvePublicContactPayment("pubkycontact") } + .thenReturn(resolution(*ordered.toTypedArray())) + + val result = sut.beginPayment("pubkycontact").getOrThrow() + + assertEquals(PublicPaykitPaymentResult.Opened(PUBLIC_BOLT11), result) + } + } + @Test fun `payment launch results have reason specific incoming request failures`() { assertEquals( @@ -270,6 +311,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { coreService = coreService, paykitSdkService = paykitSdkService, settingsStore = settingsStore, + privatePaykitCacheStore = privatePaykitCacheStore, clock = clock, ) @@ -282,6 +324,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { value: String, ): PaykitResolvedPaymentEndpoint { return PaykitResolvedPaymentEndpoint( + appId = "bitkit", identifier = methodId.rawValue, payload = PublicPaykitRepo.serializePayload(value), ) diff --git a/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountClaimCodecTest.kt b/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountClaimCodecTest.kt index dfcee0ac5e..c4bb58bf0d 100644 --- a/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountClaimCodecTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountClaimCodecTest.kt @@ -1,6 +1,14 @@ package to.bitkit.repositories +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import org.junit.Test +import to.bitkit.ext.fromHex +import to.bitkit.models.PubkyAuthClaim +import to.bitkit.models.PubkyAuthClaim.Item +import to.bitkit.models.PubkyAuthClaimCodec import to.bitkit.models.WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE import to.bitkit.models.WatchOnlyAccountRecord import to.bitkit.models.WatchOnlyAccountSetupState @@ -10,9 +18,92 @@ import kotlin.test.assertEquals import kotlin.test.assertFailsWith class WatchOnlyAccountClaimCodecTest { + @Test + fun `combined companion claim matches the canonical server fixture`() { + val fixture = requireNotNull(javaClass.getResourceAsStream("/bitkit-combined-claim-v1.json")) + .bufferedReader().use { Json.parseToJsonElement(it.readText()).jsonObject } + val claimType = PubkyAuthClaim(Item.PAYKIT_ACCESS_V1, Item.WATCH_ONLY_ACCOUNT_V1) + val accountPayload = WatchOnlyAccountClaimCodec.encode( + account(fixture.getValue("account_index").jsonPrimitive.int, TESTNET_TPUB), + ) { fixture.getValue("serialized_xpub_hex").jsonPrimitive.content.fromHex() } + val payload = PubkyAuthClaimCodec.encode( + claim = claimType, + accountPayload = accountPayload, + generation = fixture.getValue("key_generation").jsonPrimitive.content.toULong(), + secret = fixture.getValue("paykit_secret_hex").jsonPrimitive.content.fromHex(), + ) + + assertEquals(fixture.getValue("query_parameter").jsonPrimitive.content, PubkyAuthClaim.QUERY_PARAMETER) + assertEquals(fixture.getValue("claim_type").jsonPrimitive.content, claimType.wireValue) + assertEquals( + fixture.getValue("capabilities").jsonPrimitive.content, + PubkyAuthClaim.REQUIRED_CAPABILITIES, + ) + assertEquals(124, payload.size) + assertContentEquals(fixture.getValue("unsigned_payload_hex").jsonPrimitive.content.fromHex(), payload) + assertContentEquals( + payload, + PubkyAuthClaimCodec.encode( + claim = requireNotNull(PubkyAuthClaim.fromWireValue("watch-only-account-v1.paykit-access-v1")), + accountPayload = accountPayload, + generation = fixture.getValue("key_generation").jsonPrimitive.content.toULong(), + secret = fixture.getValue("paykit_secret_hex").jsonPrimitive.content.fromHex(), + ), + ) + } + + @Test + fun `companion claims match shared fixed bytes`() { + val accountPayload = WatchOnlyAccountClaimCodec.encode(account(42, TESTNET_TPUB)) { + TESTNET_SERIALIZED_HEX.fromHex() + } + val fixtures = listOf( + PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1) to WATCH_ONLY_HEX, + PubkyAuthClaim(Item.PAYKIT_ACCESS_V1) to PAYKIT_HEX, + PubkyAuthClaim(Item.PAYKIT_ACCESS_V1, Item.WATCH_ONLY_ACCOUNT_V1) to COMBINED_HEX, + requireNotNull(PubkyAuthClaim.fromWireValue("watch-only-account-v1.paykit-access-v1")) to COMBINED_HEX, + ) + for ((claim, expected) in fixtures) { + val payload = PubkyAuthClaimCodec.encode( + claim = claim, + accountPayload = if (claim.includesWatchOnlyAccount) accountPayload else byteArrayOf(), + generation = if (claim.includesPaykitAccess) 3uL else null, + secret = if (claim.includesPaykitAccess) ByteArray(32) { 7 } else null, + ) + assertContentEquals(expected.fromHex(), payload, claim.wireValue) + } + } + + @Test + fun `companion claims reject mismatched account or key payloads`() { + val accountPayload = WATCH_ONLY_HEX.fromHex() + val key = ByteArray(32) { 7 } + assertFailsWith { + PubkyAuthClaimCodec.encode(PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1), accountPayload, 3uL, key) + } + for (payload in listOf(byteArrayOf(), COMBINED_HEX.fromHex(), ByteArray(84))) { + assertFailsWith { + PubkyAuthClaimCodec.encode(PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1), payload) + } + } + assertFailsWith { + PubkyAuthClaimCodec.encode(PubkyAuthClaim(Item.PAYKIT_ACCESS_V1), accountPayload, 3uL, key) + } + for (generation in listOf(null, 0uL)) { + assertFailsWith { + PubkyAuthClaimCodec.encode(PubkyAuthClaim(Item.PAYKIT_ACCESS_V1), byteArrayOf(), generation, key) + } + } + for (secret in listOf(null, ByteArray(31), ByteArray(33))) { + assertFailsWith { + PubkyAuthClaimCodec.encode(PubkyAuthClaim(Item.PAYKIT_ACCESS_V1), byteArrayOf(), 3uL, secret) + } + } + } + @Test fun `unsigned claim contains exact account metadata`() { - val rawXpub = TESTNET_SERIALIZED_HEX.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + val rawXpub = TESTNET_SERIALIZED_HEX.fromHex() val account = account(accountIndex = 42, xpub = TESTNET_TPUB) val payload = WatchOnlyAccountClaimCodec.encode(account) { xpub -> @@ -53,6 +144,15 @@ class WatchOnlyAccountClaimCodecTest { ) private companion object { + const val WATCH_ONLY_HEX = + "010000002a00043587cf03caafd489800000004b5fcc4a5fe210d9fba6616b4db1d025237dd7f035101f11f562401bc7104699" + + "02e0bf22b51a6a49e0b149b995670d0ed9bb1fd99417748bacefba88fae655572d" + const val PAYKIT_HEX = + "0100000000000000030707070707070707070707070707070707070707070707070707070707070707" + const val COMBINED_HEX = + "010000002a00043587cf03caafd489800000004b5fcc4a5fe210d9fba6616b4db1d025237dd7f035101f11f562401bc7104699" + + "02e0bf22b51a6a49e0b149b995670d0ed9bb1fd99417748bacefba88fae655572d" + + "00000000000000030707070707070707070707070707070707070707070707070707070707070707" const val TESTNET_TPUB = "tpubDDWohsp5dx2iMJ9N7iHbgAEDhH4BJB9NWW1fEW3yA3AFNDREmpzteCXNqppMLUmKFY5q5e3" + "PXtS5CuqWCQbYcGhpPqYAgQSYdwknW9J6sQv" diff --git a/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRepoTest.kt index e30318410f..697b64fbc6 100644 --- a/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRepoTest.kt @@ -41,6 +41,47 @@ import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class WatchOnlyAccountRepoTest : BaseUnitTest() { + @Test + fun `explicit watch-only request allocates a new account instead of reusing an active account`() = test { + val active = account() + val store = mock() + val lightningService = mock() + val node = mock() + whenever(store.data).thenReturn(flowOf(WatchOnlyAccountData(accounts = listOf(active)))) + whenever(store.load()).thenReturn(listOf(active)) + whenever(store.reserveAccountIndex(any(), any())).thenReturn(2) + whenever(lightningService.node).thenReturn(node) + whenever(node.exportOnchainWalletAccountXpub(AddressType.NATIVE_SEGWIT, 2u)).thenReturn(TEST_XPUB_ALTERNATE) + + val prepared = repository(store, lightningService).prepareUnsignedClaim( + "pubkyauth://signin?secret=new&x-bitkit-claim=watch-only-account-v1", + "New service account", + ) + + assertNotEquals(active.id, prepared.account.id) + assertEquals(2, prepared.account.accountIndex) + assertEquals(TEST_XPUB_ALTERNATE, prepared.account.xpub) + assertEquals(WatchOnlyAccountSetupState.PendingDelivery, prepared.account.setupState) + assertFalse(prepared.account.isTrackingEnabled) + verify(store).save(listOf(active, prepared.account)) + verify(node, never()).addOnchainWalletAccount(any(), any(), any()) + } + + @Test + fun `authorization rejects an active account before tracking`() = test { + val active = account() + val store = mock() + val lightningService = mock() + whenever(store.data).thenReturn(flowOf(WatchOnlyAccountData(accounts = listOf(active)))) + whenever(store.load()).thenReturn(listOf(active)) + + assertFailsWith { + repository(store, lightningService).beginAuthorization(active.id) + } + verify(lightningService, never()).node + verify(store, never()).update(any()) + } + @Test fun `current wallet accounts exclude records from other wallets`() = test { val currentWalletAccount = account().copy(walletIndex = 1) diff --git a/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt b/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt new file mode 100644 index 0000000000..3a3076c3be --- /dev/null +++ b/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt @@ -0,0 +1,165 @@ +package to.bitkit.services + +import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.LightningActivity +import com.synonym.bitkitcore.OnchainActivity +import com.synonym.bitkitcore.PaymentState +import com.synonym.bitkitcore.PaymentType +import com.synonym.bitkitcore.TransactionDetails +import com.synonym.bitkitcore.TxOutput +import com.synonym.bitkitcore.getActivities +import com.synonym.bitkitcore.getTransactionDetails +import com.synonym.bitkitcore.updateActivity +import org.junit.Test +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.async.ServiceQueue +import to.bitkit.ext.create +import to.bitkit.repositories.PaykitReceivedPaymentContacts +import to.bitkit.repositories.PrivatePaykitContactResolver +import to.bitkit.test.BaseUnitTest +import javax.inject.Provider +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ActivityServicePaykitContactsTest : BaseUnitTest() { + private val contacts = mock() + private val resolver = mock() + private val sut by lazy { ActivityService(mock(), mock(), mock(), mock(), Provider { resolver }) } + private var rows = listOf() + private var details: TransactionDetails? = null + private val updates = mutableListOf() + + @Test + fun `backfill matches cached outputs and preserves all other onchain metadata`() = coreTest { + val original = onchain(address = "unknown") + rows = listOf(Activity.Onchain(original)) + val outputs = listOf(output("request-address"), output("change-address")) + details = mock { on { this.outputs }.thenReturn(outputs) } + whenever(contacts.contactsForAddresses(listOf("unknown", "request-address", "change-address"))) + .thenReturn(setOf("buyer")) + + assertTrue(sut.backfillPaykitContacts()) + + assertEquals(listOf(Activity.Onchain(original.copy(contact = "buyer"))), updates) + verify(contacts).contactsForAddresses(listOf("unknown", "request-address", "change-address")) + } + + @Test + fun `backfill matches lightning hash when invoice text is missing and preserves metadata`() = coreTest { + val original = lightning() + rows = listOf(Activity.Lightning(original)) + whenever(contacts.contactsForPaymentHash(original.id)).thenReturn(setOf("buyer")) + + assertTrue(sut.backfillPaykitContacts()) + + assertEquals(listOf(Activity.Lightning(original.copy(contact = "buyer"))), updates) + } + + @Test + fun `backfill skips explicit contacts outgoing and failed received activities`() = coreTest { + rows = listOf( + Activity.Onchain(onchain().copy(contact = "explicit")), + Activity.Onchain(onchain().copy(txType = PaymentType.SENT)), + Activity.Lightning(lightning().copy(contact = "explicit")), + Activity.Lightning(lightning().copy(txType = PaymentType.SENT)), + Activity.Lightning(lightning().copy(status = PaymentState.FAILED)), + ) + whenever(contacts.contactsForPaymentHash(any())).thenReturn(setOf("buyer")) + whenever(contacts.contactsForAddresses(any())).thenReturn(setOf("buyer")) + + assertFalse(sut.backfillPaykitContacts()) + assertTrue(updates.isEmpty()) + } + + @Test + fun `backfill refuses ambiguity across stored receiving address and other outputs`() = coreTest { + rows = listOf(Activity.Onchain(onchain(address = "request-address"))) + val outputs = listOf(output("other-request-address")) + details = mock { on { this.outputs }.thenReturn(outputs) } + whenever(contacts.contactsForAddresses(listOf("request-address", "other-request-address"))) + .thenReturn(setOf("buyer", "other-buyer")) + + assertFalse(sut.backfillPaykitContacts()) + assertTrue(updates.isEmpty()) + } + + @Test + fun `backfill waits for complete cached outputs even when stored address matches`() = coreTest { + rows = listOf(Activity.Onchain(onchain(address = "request-address"))) + whenever(contacts.contactsForAddresses(listOf("request-address"))).thenReturn(setOf("buyer")) + + assertFalse(sut.backfillPaykitContacts()) + details = mock { on { outputs }.thenReturn(emptyList()) } + assertFalse(sut.backfillPaykitContacts()) + + val outputs = listOf(output("other-request-address")) + details = mock { on { this.outputs }.thenReturn(outputs) } + whenever(contacts.contactsForAddresses(listOf("request-address", "other-request-address"))) + .thenReturn(setOf("buyer", "other-buyer")) + assertFalse(sut.backfillPaykitContacts()) + assertTrue(updates.isEmpty()) + } + + @Test + fun `backfill refuses stale identity snapshot before write`() = coreTest { + rows = listOf(Activity.Lightning(lightning())) + whenever(contacts.contactsForPaymentHash(any())).thenAnswer { + whenever(resolver.receivedPaymentContacts).thenReturn(PaykitReceivedPaymentContacts.Empty) + setOf("buyer") + } + + assertFalse(sut.backfillPaykitContacts()) + assertTrue(updates.isEmpty()) + } + + @Test + fun `backfill remains idempotent after persisted activity is reopened`() = coreTest { + rows = listOf(Activity.Lightning(lightning())) + whenever(contacts.contactsForPaymentHash(any())).thenReturn(setOf("buyer")) + assertTrue(sut.backfillPaykitContacts()) + rows = updates.toList() + updates.clear() + + assertFalse(sut.backfillPaykitContacts()) + assertTrue(updates.isEmpty()) + } + + private fun coreTest(block: suspend () -> Unit) = test { + whenever(resolver.receivedPaymentContacts).thenReturn(contacts) + ServiceQueue.CORE.background { + mockStatic(Class.forName("com.synonym.bitkitcore.Bitkitcore_androidKt")).use { native -> + native.`when`> { + getActivities( + anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), + anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull() + ) + }.thenAnswer { rows } + native.`when` { getTransactionDetails(any(), any()) }.thenAnswer { details } + native.`when` { updateActivity(any(), any()) }.thenAnswer { + updates.add(it.arguments[1] as Activity) + null + } + block() + } + } + } + + private fun output(address: String): TxOutput = mock { on { scriptpubkeyAddress }.thenReturn(address) } + + private fun onchain(address: String = "address") = OnchainActivity.create( + id = "received", txId = "transaction", txType = PaymentType.RECEIVED, address = address, + value = 15_000uL, fee = 1uL, timestamp = 100uL, confirmed = true, seenAt = 101uL, + ) + + private fun lightning() = LightningActivity.create( + id = "payment-hash", txType = PaymentType.RECEIVED, status = PaymentState.SUCCEEDED, + value = 15_000uL, invoice = "No invoice", timestamp = 100uL, fee = 1uL, message = "existing note", + preimage = "preimage", seenAt = 101uL, + ) +} diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index efc08ce571..7e564a5259 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -1,12 +1,11 @@ package to.bitkit.services import com.synonym.paykit.ContactRecord -import com.synonym.paykit.EncryptedLinkRecoveryMarkerPolicy -import com.synonym.paykit.EndpointManagementScope import com.synonym.paykit.IdentityStatus import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState import com.synonym.paykit.PaykitException +import com.synonym.paykit.PaykitIdentitySecretKey import com.synonym.paykit.PaykitSdk import com.synonym.paykit.PaymentRequestLifecycleState import com.synonym.paykit.PaymentRequestLocalRole @@ -14,17 +13,19 @@ import com.synonym.paykit.PaymentRequestRecord import com.synonym.paykit.PaymentRequestRecurrence import com.synonym.paykit.PaymentRequestTerms import com.synonym.paykit.PrivatePaymentListDeliveryReport +import com.synonym.paykit.PubkyAuthCompanionClaim import com.synonym.paykit.PubkyClientConfig +import com.synonym.paykit.PubkyIdentityCapability import com.synonym.paykit.PubkyLocalSecretKey import com.synonym.paykit.PubkySessionAccess import com.synonym.paykit.PubkySessionBootstrap import com.synonym.paykit.PubkySessionBootstrapResult import com.synonym.paykit.PublicContactSharingPolicy -import com.synonym.paykit.ReceiverNoiseSecretKey import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Deferred import kotlinx.coroutines.async +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.runTest @@ -37,20 +38,22 @@ import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import to.bitkit.data.PubkyStore import to.bitkit.data.PubkyStoreData import to.bitkit.data.keychain.Keychain import to.bitkit.data.keychain.KeychainError import to.bitkit.data.sharedpubky.SharedPubkyClient -import to.bitkit.ext.fromHex +import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.toHex +import to.bitkit.models.PubkyAuthClaim +import to.bitkit.models.PubkyAuthClaim.Item import to.bitkit.models.PubkyAuthRequestError import to.bitkit.models.PubkyProfileData import to.bitkit.repositories.PubkyContactError import to.bitkit.utils.AppError import kotlin.coroutines.cancellation.CancellationException -import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertNull @@ -129,6 +132,121 @@ class PaykitSdkServiceTest { assertEquals("healthy", paykitStorageCallback("state_save_failed") { "healthy" }) } + @Test + fun `payer ownership follows execution claims and released actionable work`() { + data class Case( + val state: PaymentRequestLifecycleState, + val payer: String?, + val claim: String?, + val hasProof: Boolean = false, + val visible: Boolean, + ) + val cases = listOf( + Case(PaymentRequestLifecycleState.PROPOSED, null, null, visible = true), + Case(PaymentRequestLifecycleState.ACCEPTED, "other", "bitkit", visible = true), + Case(PaymentRequestLifecycleState.ACCEPTED, "bitkit", "other", visible = false), + Case(PaymentRequestLifecycleState.ACCEPTED, "other", null, visible = true), + Case(PaymentRequestLifecycleState.ACCEPTED, "other", null, hasProof = true, visible = false), + Case(PaymentRequestLifecycleState.ACTIVE_RECURRING, "other", null, hasProof = true, visible = true), + Case(PaymentRequestLifecycleState.ACTIVE_RECURRING, "bitkit", "other", visible = false), + Case(PaymentRequestLifecycleState.PROOF_SUBMITTED, "other", "bitkit", hasProof = true, visible = true), + Case(PaymentRequestLifecycleState.PROOF_SUBMITTED, "other", null, hasProof = true, visible = false), + Case(PaymentRequestLifecycleState.CANCELED, "other", null, visible = false), + Case(PaymentRequestLifecycleState.CANCELED, "bitkit", null, visible = true), + Case(PaymentRequestLifecycleState.PROPOSAL_EXPIRED, null, null, visible = true), + ) + cases.forEach { case -> + val record = mock() + whenever(record.localRole).thenReturn(PaymentRequestLocalRole.PAYER) + whenever(record.state).thenReturn(case.state) + whenever(record.payerAppId).thenReturn(case.payer) + whenever(record.executionClaimAppId).thenReturn(case.claim) + whenever(record.paymentProofs).thenReturn(if (case.hasProof) listOf(mock()) else emptyList()) + + assertEquals(case.visible, isBitkitPaymentRequest(record), case.toString()) + } + } + + @Test + fun `all payment requests retain server payee records while payment API remains app scoped`() = runTest { + val server = mock { + on { localRole }.thenReturn(PaymentRequestLocalRole.PAYEE) + on { proposalAppId }.thenReturn("marketplace") + } + val bitkit = mock { + on { localRole }.thenReturn(PaymentRequestLocalRole.PAYEE) + on { proposalAppId }.thenReturn("bitkit") + } + val sdk = mock() + whenever( + sdk.identityStatus() + ).thenReturn(IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + whenever(sdk.listPaymentRequests(any())).thenReturn(listOf(server, bitkit)) + val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + + assertEquals(listOf(server, bitkit), service.allPaymentRequests(RING_PUBKY)) + assertEquals(listOf(bitkit), service.paymentRequests()) + } + + @Test + fun `all payment requests reject a different active identity before listing`() = runTest { + val sdk = mock() + whenever( + sdk.identityStatus() + ).thenReturn(IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + + assertFailsWith { + service.allPaymentRequests("a3mduedw686dysw8ndr5c1dyry5h8k6i8hzbbmx9gf9k43zq4s9o") + } + verify(sdk, never()).listPaymentRequests(any()) + } + + @Test + fun `companion approval rejects mismatched requests before accessing keys or transport`() = runTest { + val watchOnly = PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1) + val claim = PubkyAuthCompanionClaim( + queryParameter = PubkyAuthClaim.QUERY_PARAMETER, + claimType = watchOnly.wireValue, + unsignedPayload = ByteArray(84).apply { this[0] = 1 }, + ) + val url = "pubkyauth://signin?x-bitkit-claim=${watchOnly.wireValue}" + val invalidRequests = listOf( + "pubkyauth://signin" to claim, + "$url&x-bitkit-claim=${watchOnly.wireValue}" to claim, + "pubkyauth://signin?x-bitkit-claim=unknown" to claim, + "$url." to claim, + "$url.${watchOnly.wireValue}" to claim, + "pubkyauth://signin?x-bitkit-claim=paykit-access-and-watch-only-account-v1" to claim, + "pubkyauth://signin?x-bitkit-claim=paykit-access-v1.watch-only-account-v1" to + claim.copy(claimType = "watch-only-account-v1.paykit-access-v1"), + "pubkyauth://signin?x-bitkit-claim=watch-only-account-v1.paykit-access-v1" to + claim.copy(claimType = "paykit-access-v1.watch-only-account-v1"), + url to claim.copy(queryParameter = "other-claim"), + url to claim.copy(claimType = PubkyAuthClaim(Item.PAYKIT_ACCESS_V1).wireValue), + url to claim.copy(unsignedPayload = ByteArray(124)), + "pubkyauth://signin?x-bitkit-claim=paykit-access-v1" to + claim.copy(claimType = PubkyAuthClaim(Item.PAYKIT_ACCESS_V1).wireValue), + ) + for ((authUrl, companion) in invalidRequests) { + val keychain = mock() + val sdk = mock() + val service = PaykitSdkService(mock(), keychain, mock()) { sdk } + assertTrue( + runSuspendCatching { + service.approveAuthWithCompanionClaim( + authUrl, + PubkyAuthClaim.REQUIRED_CAPABILITIES, + "test", + "secret", + companion, + ) + }.isFailure, + ) + verifyNoInteractions(keychain, sdk) + } + } + @Test fun `registered identity activation persists credentials or clears partial activation`() = runTest { for (failure in listOf(null, "session", "secret", "initialize", "cancel")) { @@ -138,17 +256,16 @@ class PaykitSdkServiceTest { it.getArgument Any?>(0).invoke(blocking) } val bytes = ByteArray(32) { 1 } - whenever(blocking.load(Keychain.Key.PAYKIT_RECEIVER_NOISE_SECRET_KEY.name)).thenReturn(bytes) val sdk = mock() whenever(sdk.contactRecords()).thenReturn(emptyList()) val access = mock() val secret = mock() - val noise = mock() + val noise = mock() whenever(secret.exportBytes()).thenReturn(bytes) whenever(noise.exportBytes()).thenReturn(bytes) whenever(access.exportSessionSecret()).thenReturn("new-session") whenever(access.exportLocalSecretKey()).thenReturn(secret) - whenever(access.exportReceiverNoiseSecretKey()).thenReturn(noise) + whenever(access.exportPaykitIdentitySecretKey()).thenReturn(noise) val error = if (failure == "cancel") { CancellationException("cancelled") } else { @@ -168,7 +285,7 @@ class PaykitSdkServiceTest { handlesCreated++ sdk } - val result = PubkySessionBootstrapResult(access, "pubky_test") + val result = PubkySessionBootstrapResult(access, "pubky_test", PubkyIdentityCapability.PRIVATE_LINK_CAPABLE) if (failure == null) { service.activateRegisteredIdentity(result) @@ -183,7 +300,6 @@ class PaykitSdkServiceTest { assertEquals(error, thrown) verify(blocking).delete(Keychain.Key.PAYKIT_SESSION.name) verify(blocking).delete(Keychain.Key.PUBKY_SECRET_KEY.name) - verify(keychain, atLeastOnce()).delete(Keychain.Key.PAYKIT_SDK_STATE.name) val handlesBeforeReload = handlesCreated service.contactRecords() assertEquals(handlesBeforeReload + 1, handlesCreated) @@ -192,16 +308,16 @@ class PaykitSdkServiceTest { } @Test - fun `deletion blocks all known receivers before removing the contact`() = runTest { + fun `deletion blocks the identity before removing the contact`() = runTest { for (failBlock in listOf(false, true)) { val sdk = mock() whenever(sdk.paymentRequests()).thenReturn(emptyList()) - val contact = mock { on { receiverPaths } doReturn listOf(PaykitReceiverPaths.WALLET) } + val contact = mock() whenever(sdk.contactRecord(RING_PUBKY)).thenReturn(contact) - val peer = contactPeer(PaykitReceiverPaths.SERVER, LinkedPeerState.LINKED) + val peer = contactPeer(LinkedPeerState.LINKED) whenever(sdk.linkedPeers()).thenReturn(listOf(peer)) if (failBlock) { - whenever(sdk.blockPeer(RING_PUBKY, PaykitReceiverPaths.SERVER)) + whenever(sdk.blockPeer(RING_PUBKY)) .thenThrow(IllegalStateException("storage failure")) } val service = PaykitSdkService(mock(), mock(), mock()) { sdk } @@ -211,8 +327,7 @@ class PaykitSdkServiceTest { } else { service.removeContact(RING_PUBKY) inOrder(sdk) { - verify(sdk).blockPeer(RING_PUBKY, PaykitReceiverPaths.WALLET) - verify(sdk).blockPeer(RING_PUBKY, PaykitReceiverPaths.SERVER) + verify(sdk).blockPeer(RING_PUBKY) verify(sdk).removeContact(RING_PUBKY) } } @@ -239,7 +354,7 @@ class PaykitSdkServiceTest { whenever(sdk.paymentRequests()).thenReturn(listOf(request)) val service = PaykitSdkService(mock(), mock(), mock()) { sdk } assertFailsWith { service.removeContact(RING_PUBKY) } - verify(sdk, never()).blockPeer(any(), any()) + verify(sdk, never()).blockPeer(any()) verify(sdk, never()).removeContact(any()) if (endsNaturally) { whenever(recurrence.endsAt).thenReturn("2026-02-01T00:00:00Z") @@ -252,23 +367,31 @@ class PaykitSdkServiceTest { } @Test - fun `identity lookup failure preserves stored state and stops activation`() = runTest { + fun `unreadable profile cache stops activation`() = runTest { for (error in listOf( PaykitException.Identity("identity_error", "restore Pubky grant session from platform provider"), PaykitException.Storage("storage_error", "unavailable"), )) { val keychain = mock() val sdk = mock() - whenever(sdk.identityStatus()).thenThrow(error) - val service = PaykitSdkService(mock(), keychain, mock()) { sdk } + val store = mock() + whenever(store.data).thenReturn(flow { throw error }) + val access = mock() + whenever(access.exportSessionSecret()).thenReturn("new-session") + val service = PaykitSdkService(mock(), keychain, store) { sdk } val thrown = assertFailsWith { - service.activateRegisteredIdentity(PubkySessionBootstrapResult(mock(), "pubky_test")) + service.activateRegisteredIdentity( + PubkySessionBootstrapResult( + access, + "pubky_test", + PubkyIdentityCapability.PRIVATE_LINK_CAPABLE + ) + ) } assertEquals(error, thrown) - verify(keychain, never()).delete(any()) - verify(keychain, never()).upsertString(any(), any()) + verify(sdk, never()).initialize() } } @@ -278,10 +401,10 @@ class PaykitSdkServiceTest { val sdk = mock() whenever(sdk.paymentRequests()).thenReturn(emptyList()) whenever(sdk.linkedPeers()).thenReturn( - listOf(contactPeer(PaykitReceiverPaths.SERVER, LinkedPeerState.LINKED)), + listOf(contactPeer(LinkedPeerState.LINKED)), ) val withdrawal = whenever( - sdk.clearPrivatePaymentListAndProcessOutbound(RING_PUBKY, PaykitReceiverPaths.SERVER), + sdk.clearPrivatePaymentListAndProcessOutbound(RING_PUBKY), ) if (failWithdrawal) { withdrawal.thenThrow(IllegalStateException("network unavailable")) @@ -293,8 +416,8 @@ class PaykitSdkServiceTest { val service = PaykitSdkService(mock(), mock(), mock()) { sdk } service.removeContact(RING_PUBKY) inOrder(sdk) { - verify(sdk).clearPrivatePaymentListAndProcessOutbound(RING_PUBKY, PaykitReceiverPaths.SERVER) - verify(sdk).blockPeer(RING_PUBKY, PaykitReceiverPaths.SERVER) + verify(sdk).clearPrivatePaymentListAndProcessOutbound(RING_PUBKY) + verify(sdk).blockPeer(RING_PUBKY) verify(sdk).removeContact(RING_PUBKY) } } @@ -306,9 +429,10 @@ class PaykitSdkServiceTest { val differentKey = "5" + originalKey.drop(1) for ((previousKey, cachedOwner, resetFails) in identityCacheCases(originalKey, differentKey)) { val keychain = mock() - stubReceiverNoiseSecret(keychain) val sdk = mock() - whenever(sdk.identityStatus()).thenReturn(IdentityStatus(previousKey, false)) + whenever( + sdk.identityStatus() + ).thenReturn(IdentityStatus(previousKey, PubkyIdentityCapability.PUBLIC_ONLY)) val originalCache = PubkyStoreData( ownerPublicKey = cachedOwner, cachedName = "Original profile", @@ -326,13 +450,18 @@ class PaykitSdkServiceTest { val bootstrap = mock() whenever(bootstrap.republishIdentity(any())).thenReturn(true) val access = mock() - val noise = mock() + val noise = mock() whenever(noise.exportBytes()).thenReturn(ByteArray(32) { 1 }) whenever(access.exportSessionSecret()).thenReturn("new-session") - whenever(access.exportReceiverNoiseSecretKey()).thenReturn(noise) + whenever(access.exportPaykitIdentitySecretKey()).thenReturn(noise) val service = PaykitSdkService(mock(), keychain, store, { bootstrap }) { sdk } - val result = PubkySessionBootstrapResult(access, "pubky$originalKey") + val result = + PubkySessionBootstrapResult( + access, + "pubky$originalKey", + PubkyIdentityCapability.PRIVATE_LINK_CAPABLE + ) if (resetFails) { assertEquals(resetError, assertFailsWith { service.activateRegisteredIdentity(result) }) verify(sdk, never()).initialize() @@ -341,7 +470,7 @@ class PaykitSdkServiceTest { } service.activateRegisteredIdentity(result) - if (previousKey == differentKey || cachedOwner == differentKey) { + if (cachedOwner == differentKey) { assertEquals(PubkyStoreData(), cache) inOrder(store, sdk) { verify(store).reset() @@ -355,25 +484,23 @@ class PaykitSdkServiceTest { } @Test - fun `only explicit readd unblocks every saved private receiver`() = runTest { + fun `only explicit readd unblocks the contact`() = runTest { for (restoreConnection in listOf(false, true)) { val sdk = mock() whenever(sdk.saveContact(any())).thenReturn(mock()) whenever(sdk.linkedPeers()).thenReturn( listOf( - contactPeer(PaykitReceiverPaths.WALLET, LinkedPeerState.BLOCKED), - contactPeer(PaykitReceiverPaths.SERVER, LinkedPeerState.BLOCKED), + contactPeer(LinkedPeerState.BLOCKED), ), ) val service = PaykitSdkService(mock(), mock(), mock()) { sdk } if (restoreConnection) { service.saveContact(RING_PUBKY, "Contact", restorePrivateConnection = true) - verify(sdk).unblockPeer(RING_PUBKY, PaykitReceiverPaths.WALLET) - verify(sdk).unblockPeer(RING_PUBKY, PaykitReceiverPaths.SERVER) + verify(sdk).unblockPeer(RING_PUBKY) } else { assertFailsWith { service.saveContact(RING_PUBKY, "Contact") } verify(sdk, never()).saveContact(any()) - verify(sdk, never()).unblockPeer(any(), any()) + verify(sdk, never()).unblockPeer(any()) } } } @@ -383,8 +510,7 @@ class PaykitSdkServiceTest { for (failurePoint in listOf("lookup", "unblock", "save", "cancel")) { val sdk = mock() val peers = listOf( - contactPeer(PaykitReceiverPaths.WALLET, LinkedPeerState.BLOCKED), - contactPeer(PaykitReceiverPaths.SERVER, LinkedPeerState.BLOCKED), + contactPeer(LinkedPeerState.BLOCKED), ) val failure = if (failurePoint == "cancel") { CancellationException("cancelled") @@ -396,7 +522,7 @@ class PaykitSdkServiceTest { when (failurePoint) { "lookup" -> whenever(sdk.linkedPeers()).thenThrow(failure).thenReturn(peers) "unblock", "cancel" -> { - whenever(sdk.unblockPeer(RING_PUBKY, PaykitReceiverPaths.SERVER)) + whenever(sdk.unblockPeer(RING_PUBKY)) .thenThrow(failure).thenReturn(peers.last().copy(state = LinkedPeerState.NOT_LINKED)) } "save" -> whenever(sdk.saveContact(any())).thenThrow(failure).thenReturn(mock()) @@ -407,10 +533,9 @@ class PaykitSdkServiceTest { } assertSame(failure, thrown) if (failurePoint == "lookup") { - verify(sdk, never()).blockPeer(any(), any()) + verify(sdk, never()).blockPeer(any()) } else { - verify(sdk).blockPeer(RING_PUBKY, PaykitReceiverPaths.WALLET) - verify(sdk).blockPeer(RING_PUBKY, PaykitReceiverPaths.SERVER) + verify(sdk).blockPeer(RING_PUBKY) } service.saveContact(RING_PUBKY, "Contact", restorePrivateConnection = true) verify(sdk, atLeastOnce()).saveContact(any()) @@ -421,14 +546,13 @@ class PaykitSdkServiceTest { fun `private connection restoration preserves failures from rollback`() = runTest { val sdk = mock() val peers = listOf( - contactPeer(PaykitReceiverPaths.WALLET, LinkedPeerState.BLOCKED), - contactPeer(PaykitReceiverPaths.SERVER, LinkedPeerState.BLOCKED), + contactPeer(LinkedPeerState.BLOCKED), ) val restorationFailure = IllegalStateException("save failed") val rollbackFailure = IllegalStateException("block failed") whenever(sdk.linkedPeers()).thenReturn(peers) whenever(sdk.saveContact(any())).thenThrow(restorationFailure) - whenever(sdk.blockPeer(RING_PUBKY, PaykitReceiverPaths.WALLET)).thenThrow(rollbackFailure) + whenever(sdk.blockPeer(RING_PUBKY)).thenThrow(rollbackFailure) val service = PaykitSdkService(mock(), mock(), mock()) { sdk } val thrown = assertFailsWith { @@ -437,22 +561,20 @@ class PaykitSdkServiceTest { assertSame(restorationFailure, thrown) assertEquals(listOf(rollbackFailure), thrown.suppressed.toList()) - verify(sdk).blockPeer(RING_PUBKY, PaykitReceiverPaths.WALLET) - verify(sdk).blockPeer(RING_PUBKY, PaykitReceiverPaths.SERVER) + verify(sdk).blockPeer(RING_PUBKY) } @Test fun `blocked peer cleanup does not attempt network delivery`() = runTest { val sdk = mock() - whenever(sdk.linkedPeers()).thenReturn(listOf(contactPeer(PaykitReceiverPaths.SERVER, LinkedPeerState.BLOCKED))) + whenever(sdk.linkedPeers()).thenReturn(listOf(contactPeer(LinkedPeerState.BLOCKED))) val service = PaykitSdkService(mock(), mock(), mock()) { sdk } - assertNull(service.clearPrivatePaymentList(RING_PUBKY, PaykitReceiverPaths.SERVER)) - verify(sdk, never()).clearPrivatePaymentListAndProcessOutbound(any(), any()) + assertNull(service.clearPrivatePaymentList(RING_PUBKY)) + verify(sdk, never()).clearPrivatePaymentListAndProcessOutbound(any()) } - private fun contactPeer(path: String, state: LinkedPeerState) = LinkedPeerRecord( + private fun contactPeer(state: LinkedPeerState) = LinkedPeerRecord( counterparty = RING_PUBKY, - counterpartyReceiverPath = path, state = state, lastSyncAt = null, lastPrivateReceiveAt = null, @@ -471,11 +593,9 @@ class PaykitSdkServiceTest { ) @Test - fun `config scopes public endpoint sync to Bitkit managed endpoints`() { - assertEquals(BitkitPaykitSdkConfig.profileNamespace, BitkitPaykitSdkConfig.clientId) - assertEquals(EndpointManagementScope.MANAGED_ONLY, BitkitPaykitSdkConfig.endpointManagementScope) - assertEquals(PublicContactSharingPolicy.LOCAL_ONLY, BitkitPaykitSdkConfig.publicContactSharing) - assertEquals(EncryptedLinkRecoveryMarkerPolicy.ENABLED, BitkitPaykitSdkConfig.encryptedLinkRecoveryMarkers) + fun `config keeps contacts private`() { + assertEquals("staging.bitkit.to", BitkitPaykitSdkConfig.clientId) + assertEquals(PublicContactSharingPolicy.PRIVATE_ONLY, BitkitPaykitSdkConfig.publicContactSharing) } @Test @@ -512,86 +632,6 @@ class PaykitSdkServiceTest { } } - @Test - fun `receiver noise derivation matches cross platform vector`() { - val seed = ( - "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e534955" + - "31f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04" - ).fromHex() - - val key = PaykitReceiverNoiseKeyDerivation.derive( - seed = seed, - network = "bitcoin", - receiverPath = "bitkit/wallet", - ) - - assertEquals("500f4799bbb2d02103e3b74b365ddb478a3187333c053fa9eb62f4052ba6a327", key.toHex()) - } - - @Test - fun `receiver noise key is persisted and reused`() { - var persistedBytes: ByteArray? = null - val derivedBytes = ByteArray(32) { 7 } - val store = keyStore( - loadBytes = { persistedBytes }, - upsertBytes = { persistedBytes = it.copyOf() }, - deriveBytes = { derivedBytes }, - ) - - val first = store.loadOrDeriveBytes() - val second = store.loadOrDeriveBytes() - val restored = keyStore( - loadBytes = { persistedBytes }, - deriveBytes = { derivedBytes }, - ).loadOrDeriveBytes() - - assertContentEquals(first, persistedBytes) - assertContentEquals(first, second) - assertContentEquals(first, restored) - assertEquals("PAYKIT_RECEIVER_NOISE_SECRET_KEY", Keychain.Key.PAYKIT_RECEIVER_NOISE_SECRET_KEY.name) - } - - @Test - fun `receiver noise key loads for external session without wallet seed`() { - val persistedBytes = ByteArray(32) { 7 } - val store = keyStore( - loadBytes = { persistedBytes }, - deriveBytes = { throw AppError("wallet seed unavailable") }, - ) - - assertContentEquals(persistedBytes, store.loadOrDeriveBytes()) - } - - @Test - fun `receiver noise key cannot be replaced`() { - val store = keyStore( - loadBytes = { ByteArray(32) { 1 } }, - deriveBytes = { ByteArray(32) { 1 } }, - ) - - assertFailsWith { - store.persistBytes(ByteArray(32) { 2 }) - } - } - - @Test - fun `receiver noise key follows wallet replacement after keychain wipe`() { - var persistedBytes: ByteArray? = null - var derivedBytes = ByteArray(32) { 1 } - val store = keyStore( - loadBytes = { persistedBytes }, - upsertBytes = { persistedBytes = it.copyOf() }, - deriveBytes = { derivedBytes }, - ) - store.loadOrDeriveBytes() - - persistedBytes = null - derivedBytes = ByteArray(32) { 2 } - - assertContentEquals(derivedBytes, store.loadOrDeriveBytes()) - assertContentEquals(derivedBytes, persistedBytes) - } - @Test fun `external session retains private payment access`() { val keychain = mock() @@ -657,21 +697,6 @@ class PaykitSdkServiceTest { ) } - private fun keyStore( - loadBytes: () -> ByteArray?, - upsertBytes: (ByteArray) -> Unit = {}, - deriveBytes: () -> ByteArray, - ) = PaykitReceiverNoiseKeyStore(loadBytes, upsertBytes, deriveBytes) - - private fun stubReceiverNoiseSecret(keychain: Keychain) { - val blocking = mock() - whenever(keychain.accessBlocking(any())).doAnswer { - it.getArgument Any?>(0).invoke(blocking) - } - whenever(blocking.load(Keychain.Key.PAYKIT_RECEIVER_NOISE_SECRET_KEY.name)) - .thenReturn(ByteArray(32) { 1 }) - } - private fun identityCacheCases(originalKey: String, differentKey: String) = listOf( Triple(originalKey, null, false), Triple("pubky$originalKey", null, false), @@ -680,6 +705,6 @@ class PaykitSdkServiceTest { Triple(null, originalKey, false), Triple(null, differentKey, false), Triple(originalKey, differentKey, false), - Triple(differentKey, null, true), + Triple(originalKey, differentKey, true), ) } diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceWipeTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceWipeTest.kt index ea6ff80aa3..40d00af7d9 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceWipeTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceWipeTest.kt @@ -2,17 +2,10 @@ package to.bitkit.services import com.synonym.paykit.PaykitException import com.synonym.paykit.PaykitSdk -import com.synonym.paykit.PaykitSdkConfig +import com.synonym.paykit.PubkyIdentityCapability import com.synonym.paykit.PubkySessionAccess import com.synonym.paykit.PubkySessionBootstrap import com.synonym.paykit.PubkySessionBootstrapResult -import com.synonym.paykit.ReceiverNoiseSecretKey -import com.synonym.paykit.SdkStateBlob -import com.synonym.paykit.SdkStateBlobSnapshot -import com.synonym.paykit.SdkStateBlobStore -import com.synonym.paykit.decodeSdkStateBlobSnapshot -import com.synonym.paykit.defaultConfig -import com.synonym.paykit.encodeSdkStateBlobSnapshot import com.synonym.paykit.requiredSessionCapabilities import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineStart @@ -21,7 +14,6 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.Test -import org.mockito.Mockito.mockConstruction import org.mockito.Mockito.mockStatic import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull @@ -48,62 +40,6 @@ class PaykitSdkServiceWipeTest { private const val RING_PUBKY = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" } - private val sdkConfig = PaykitSdkConfig( - receiverPath = PaykitReceiverPaths.WALLET, - profileNamespace = BitkitPaykitSdkConfig.profileNamespace, - endpointManagementScope = BitkitPaykitSdkConfig.endpointManagementScope, - encryptedLinkRecoveryMarkers = BitkitPaykitSdkConfig.encryptedLinkRecoveryMarkers, - publicContactSharing = BitkitPaykitSdkConfig.publicContactSharing, - peerLinkOperationLeaseTimeoutSecs = 1uL, - outboundPrivateSendLeaseTimeoutSecs = 1uL, - outboundPrivateRetryBackoffSecs = 1uL, - ) - - @Test - fun `state storage callbacks translate keychain failures and recover on retry`() { - val keychain = mock() - val blocking = mock() - whenever(keychain.accessBlocking(any())).doAnswer { - it.getArgument Any?>(0).invoke(blocking) - } - val service = PaykitSdkService(mock(), keychain, mock()) { mock() } - val store = PaykitSdkService::class.java.getDeclaredField("stateStore") - .apply { isAccessible = true }.get(service) as SdkStateBlobStore - val key = Keychain.Key.PAYKIT_SDK_STATE.name - whenever(blocking.load(key)).thenAnswer { throw KeychainError.FailedToLoad(key) }.thenReturn(null) - - assertEquals("state_load_failed", assertFailsWith { store.loadStateBlob() }.code) - assertNull(store.loadStateBlob()) - - val blob = mock() - val encoded = byteArrayOf(1, 2, 3) - lateinit var snapshot: SdkStateBlobSnapshot - mockStatic(Class.forName("com.synonym.paykit.Paykit_androidKt")).use { native -> - native.`when` { encodeSdkStateBlobSnapshot(any()) }.thenAnswer { - snapshot = it.getArgument(0) - encoded - } - native.`when` { decodeSdkStateBlobSnapshot(encoded) }.thenAnswer { snapshot } - whenever(blocking.upsert(key, encoded)).thenAnswer { throw KeychainError.FailedToSave(key) }.thenAnswer { - whenever(blocking.load(key)).thenReturn(encoded) - } - - assertEquals( - "state_save_failed", - assertFailsWith { store.saveStateBlobAtomically(blob, null) }.code, - ) - assertNull(store.loadStateBlob()) - val revision = store.saveStateBlobAtomically(blob, null) - val restored = store.loadStateBlob() - assertSame(blob, restored?.blob) - assertEquals(revision, restored?.revision) - assertEquals( - "revision_conflict", - assertFailsWith { store.saveStateBlobAtomically(blob, null) }.code, - ) - } - } - @Test fun `session storage callbacks translate keychain failures and recover on retry`() { val keychain = mock() @@ -139,21 +75,17 @@ class PaykitSdkServiceWipeTest { @Test fun `wallet wipe drains identity bootstrap and persistence and rejects queued imports`() = runTest { val keychain = mock() - stubReceiverNoiseSecret(keychain) val sdk = mock() val bootstrap = mock() val access = mock() - val noise = mock() - whenever(noise.exportBytes()).thenReturn(ByteArray(32) { 1 }) - whenever(access.exportReceiverNoiseSecretKey()).thenReturn(noise) whenever(access.exportSessionSecret()).thenReturn("new-session") - val result = PubkySessionBootstrapResult(access, RING_PUBKY) + val result = PubkySessionBootstrapResult(access, RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE) val bootstrapStarted = CompletableDeferred() val releaseBootstrap = CompletableDeferred() val persistenceStarted = CompletableDeferred() val releasePersistence = CompletableDeferred() val events = mutableListOf() - whenever(bootstrap.importSession(eq("active"), anyOrNull(), any(), any())).doSuspendableAnswer { + whenever(bootstrap.importSession(eq("active"), anyOrNull(), any())).doSuspendableAnswer { events.add("bootstrap") bootstrapStarted.complete(Unit) releaseBootstrap.await() @@ -171,41 +103,29 @@ class PaykitSdkServiceWipeTest { val service = PaykitSdkService(mock(), keychain, store, { bootstrap }, dispatcher) { sdk } mockStatic(Class.forName("com.synonym.paykit.Paykit_androidKt")).use { native -> - native.`when` { defaultConfig(PaykitReceiverPaths.WALLET) }.thenReturn(sdkConfig) - native.`when` { requiredSessionCapabilities(any()) }.thenReturn("capabilities") - mockConstruction(ReceiverNoiseSecretKey::class.java).use { - val active = async(start = CoroutineStart.UNDISPATCHED) { service.importSession("active") } - bootstrapStarted.await() - val queued = async(start = CoroutineStart.UNDISPATCHED) { - assertFailsWith { service.importSession("queued") } - } - val wipe = async(start = CoroutineStart.UNDISPATCHED) { - service.withWalletWipe { events.add("cleanup") } - } - assertFalse(wipe.isCompleted) - assertEquals(listOf("bootstrap"), events) - - releaseBootstrap.complete(Unit) - persistenceStarted.await() - assertFalse(wipe.isCompleted) - assertEquals(listOf("bootstrap"), events) - releasePersistence.complete(Unit) - - assertSame(result, active.await()) - assertEquals("wallet_wipe_in_progress", queued.await().code) - wipe.await() - assertEquals(listOf("bootstrap", "persisted", "cleanup"), events) - verify(bootstrap, never()).importSession(eq("queued"), anyOrNull(), any(), any()) + native.`when` { requiredSessionCapabilities() }.thenReturn("capabilities") + val active = async(start = CoroutineStart.UNDISPATCHED) { service.importSession("active") } + bootstrapStarted.await() + val queued = async(start = CoroutineStart.UNDISPATCHED) { + assertFailsWith { service.importSession("queued") } } - } - } + val wipe = async(start = CoroutineStart.UNDISPATCHED) { + service.withWalletWipe { events.add("cleanup") } + } + assertFalse(wipe.isCompleted) + assertEquals(listOf("bootstrap"), events) - private fun stubReceiverNoiseSecret(keychain: Keychain) { - val blocking = mock() - whenever(keychain.accessBlocking(any())).doAnswer { - it.getArgument Any?>(0).invoke(blocking) + releaseBootstrap.complete(Unit) + persistenceStarted.await() + assertFalse(wipe.isCompleted) + assertEquals(listOf("bootstrap"), events) + releasePersistence.complete(Unit) + + assertSame(result, active.await()) + assertEquals("wallet_wipe_in_progress", queued.await().code) + wipe.await() + assertEquals(listOf("bootstrap", "persisted", "cleanup"), events) + verify(bootstrap, never()).importSession(eq("queued"), anyOrNull(), any()) } - whenever(blocking.load(Keychain.Key.PAYKIT_RECEIVER_NOISE_SECRET_KEY.name)) - .thenReturn(ByteArray(32) { 1 }) } } diff --git a/app/src/test/java/to/bitkit/ui/screens/contacts/AddContactViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/contacts/AddContactViewModelTest.kt index 6ad1eb9c06..9fa6a397b2 100644 --- a/app/src/test/java/to/bitkit/ui/screens/contacts/AddContactViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/contacts/AddContactViewModelTest.kt @@ -16,7 +16,7 @@ import to.bitkit.repositories.PubkyContactError import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.test.BaseUnitTest -import to.bitkit.usecases.RefreshContactPaykitReceiversUseCase +import to.bitkit.usecases.RefreshContactPaykitLinkUseCase import kotlin.test.assertEquals import kotlin.test.assertNull @@ -25,7 +25,7 @@ class AddContactViewModelTest : BaseUnitTest() { private val context: Context = mock() private val pubkyRepo: PubkyRepo = mock() private val publicPaykitRepo: PublicPaykitRepo = mock() - private val refreshContactPaykitReceivers = mock() + private val refreshContactPaykitLink = mock() @Test fun `self add failure should show dedicated error`() = test { @@ -58,14 +58,14 @@ class AddContactViewModelTest : BaseUnitTest() { whenever(context.getString(R.string.contacts__add_error_existing)).thenReturn("existing contact") whenever(pubkyRepo.fetchContactProfile(any())) .thenReturn(Result.failure(PubkyContactError.AlreadyExists)) - whenever { refreshContactPaykitReceivers(TEST_PUBLIC_KEY) }.thenReturn(Result.success(Unit)) + whenever { refreshContactPaykitLink(TEST_PUBLIC_KEY) }.thenReturn(Result.success(Unit)) val sut = createSut() advanceUntilIdle() assertEquals("existing contact", sut.uiState.value.error) assertNull(sut.uiState.value.fetchedProfile) - verify(refreshContactPaykitReceivers).invoke(TEST_PUBLIC_KEY) + verify(refreshContactPaykitLink).invoke(TEST_PUBLIC_KEY) } @Test @@ -103,7 +103,7 @@ class AddContactViewModelTest : BaseUnitTest() { context = context, pubkyRepo = pubkyRepo, publicPaykitRepo = publicPaykitRepo, - refreshContactPaykitReceivers = refreshContactPaykitReceivers, + refreshContactPaykitLink = refreshContactPaykitLink, savedStateHandle = SavedStateHandle(mapOf("publicKey" to publicKey)), ) } diff --git a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt index 585e559f59..0c03d9fff6 100644 --- a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt @@ -52,7 +52,7 @@ class ContactDetailViewModelTest : BaseUnitTest() { override fun now() = now } private val eligibleTargets = MutableStateFlow>(emptyList()) - private val target = PaykitPaymentRequestTarget(TEST_PUBLIC_KEY, "bitkit/wallet") + private val target = PaykitPaymentRequestTarget(TEST_PUBLIC_KEY) private val openedPayment = PublicPaykitPaymentResult.Opened( paymentRequest = "bitcoin:bcrt1qtest", privatePaymentContext = null, diff --git a/app/src/test/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestPresentationTest.kt b/app/src/test/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestPresentationTest.kt index c65284712b..29d06c1924 100644 --- a/app/src/test/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestPresentationTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestPresentationTest.kt @@ -71,7 +71,6 @@ class PaymentRequestPresentationTest { ) = PaykitPaymentRequest( paymentRequestId = "request-id", counterparty = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", - counterpartyReceiverPath = "bitkit/server", amountValue = "0.000025", amountSats = 2_500uL, expiresAt = null, diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModelTest.kt index 4a1184dddd..d25a5abecd 100644 --- a/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModelTest.kt @@ -23,10 +23,12 @@ import org.mockito.kotlin.never import org.mockito.kotlin.same import org.mockito.kotlin.times import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import to.bitkit.R import to.bitkit.models.PreparedWatchOnlyAccountClaim import to.bitkit.models.PubkyAuthClaim +import to.bitkit.models.PubkyAuthClaim.Item import to.bitkit.models.PubkyAuthPermission import to.bitkit.models.PubkyAuthRequest import to.bitkit.models.PubkyProfile @@ -41,6 +43,7 @@ import to.bitkit.utils.AppError import kotlin.test.assertEquals @OptIn(ExperimentalCoroutinesApi::class) +@Suppress("LargeClass") class PubkyAuthApprovalViewModelTest : BaseUnitTest() { private val clientId = "paykit.test" private val context: Context = mock() @@ -72,6 +75,135 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { assertEquals(ApprovalState.Loading, sut.uiState.value.state) } + @Test + fun `Paykit-only reconnect never prepares or tracks a wallet account`() = test { + val capabilities = PubkyAuthClaim.REQUIRED_CAPABILITIES + val authUrl = "pubkyauth://signin?x-bitkit-claim=paykit-access-v1" + whenever(pubkyRepo.parseAuthUrl(authUrl)).thenReturn( + Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim(Item.PAYKIT_ACCESS_V1))), + ) + whenever(pubkyRepo.approveAuthWithCompanionClaim(eq(authUrl), eq(clientId), argThat { isEmpty() })) + .thenReturn(Result.success(Unit)) + val sut = createSut() + + sut.load(authUrl) + advanceUntilIdle() + assertEquals(ApprovalState.Authorize, sut.uiState.value.state) + sut.confirmAuthorize(authUrl) + advanceUntilIdle() + + assertEquals(ApprovalState.Success, sut.uiState.value.state) + verifyBlocking(pubkyRepo) { approveAuthWithCompanionClaim(eq(authUrl), eq(clientId), argThat { isEmpty() }) } + verifyBlocking(pubkyRepo, never()) { approveAuth(any(), any(), any()) } + verifyNoInteractions(watchOnlyAccountRepo) + } + + @Test + fun `combined authorization prepares and activates a new watch-only account`() = test { + val authUrl = "pubkyauth://signin?x-bitkit-claim=paykit-access-v1.watch-only-account-v1" + val request = authRequest( + authUrl, + PubkyAuthClaim.REQUIRED_CAPABILITIES, + PubkyAuthClaim(Item.PAYKIT_ACCESS_V1, Item.WATCH_ONLY_ACCOUNT_V1), + ) + val pending = watchOnlyAccount() + val prepared = PreparedWatchOnlyAccountClaim(pending, ByteArray(84)) + whenever(pubkyRepo.parseAuthUrl(authUrl)).thenReturn(Result.success(request)) + whenever(watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server")).thenReturn(prepared) + whenever(watchOnlyAccountRepo.beginAuthorization(pending.id)).thenReturn(false) + whenever(pubkyRepo.approveAuthWithCompanionClaim(authUrl, clientId, prepared.payload)) + .thenReturn(Result.success(Unit)) + val sut = createSut() + + mockStatic(Log::class.java).use { + sut.load(authUrl) + advanceUntilIdle() + assertEquals(ApprovalState.WatchOnlyConsent, sut.uiState.value.state) + sut.approveWatchOnlyConsent(authUrl) + sut.confirmAuthorize(authUrl) + advanceUntilIdle() + } + + assertEquals(ApprovalState.Success, sut.uiState.value.state) + verifyBlocking(watchOnlyAccountRepo) { prepareUnsignedClaim(authUrl, "paykit server") } + verifyBlocking(watchOnlyAccountRepo, never()) { cancelAuthorization(any(), any()) } + verifyBlocking(watchOnlyAccountRepo) { markActive(pending.id) } + } + + @Test + fun `canceling local auth does not allocate or change a watch-only account`() = test { + val authUrl = "pubkyauth://signin?x-bitkit-claim=watch-only-account-v1" + whenever(pubkyRepo.parseAuthUrl(authUrl)).thenReturn( + Result.success( + authRequest( + authUrl, + PubkyAuthClaim.REQUIRED_CAPABILITIES, + PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1), + ), + ), + ) + val sut = createSut() + sut.load(authUrl) + advanceUntilIdle() + sut.approveWatchOnlyConsent(authUrl) + sut.requestAuthorize(authUrl) + advanceUntilIdle() + sut.cancelLocalAuth(authUrl) + sut.dismiss() + advanceUntilIdle() + + verifyNoInteractions(watchOnlyAccountRepo) + verifyBlocking(pubkyRepo, never()) { approveAuthWithCompanionClaim(any(), any(), any()) } + } + + @Test + fun `authorization rejects a claim type that differs from the displayed consent`() = test { + val authUrl = "pubkyauth://signin?x-bitkit-claim=paykit-access-v1" + val shown = authRequest( + authUrl, + PubkyAuthClaim.REQUIRED_CAPABILITIES, + PubkyAuthClaim(Item.PAYKIT_ACCESS_V1), + ) + whenever(pubkyRepo.parseAuthUrl(authUrl)).thenReturn( + Result.success(shown), + Result.success(shown.copy(bitkitClaim = PubkyAuthClaim(Item.PAYKIT_ACCESS_V1, Item.WATCH_ONLY_ACCOUNT_V1))), + ) + val sut = createSut() + mockStatic(Log::class.java).use { + sut.load(authUrl) + advanceUntilIdle() + sut.confirmAuthorize(authUrl) + advanceUntilIdle() + } + assertEquals(ApprovalState.Authorize, sut.uiState.value.state) + verifyNoInteractions(watchOnlyAccountRepo) + verifyBlocking(pubkyRepo, never()) { approveAuthWithCompanionClaim(any(), any(), any()) } + } + + @Test + fun `authorization rejects item order changed after consent`() = test { + val claim = PubkyAuthClaim(Item.PAYKIT_ACCESS_V1, Item.WATCH_ONLY_ACCOUNT_V1) + val authUrl = "pubkyauth://signin?x-bitkit-claim=${claim.wireValue}" + val shown = authRequest(authUrl, PubkyAuthClaim.REQUIRED_CAPABILITIES, claim) + whenever(pubkyRepo.parseAuthUrl(authUrl)).thenReturn( + Result.success(shown), + Result.success( + shown.copy(bitkitClaim = PubkyAuthClaim.fromWireValue("watch-only-account-v1.paykit-access-v1")), + ), + ) + val sut = createSut() + mockStatic(Log::class.java).use { + sut.load(authUrl) + advanceUntilIdle() + sut.approveWatchOnlyConsent(authUrl) + sut.confirmAuthorize(authUrl) + advanceUntilIdle() + } + assertEquals(ApprovalState.Authorize, sut.uiState.value.state) + verifyBlocking(watchOnlyAccountRepo, never()) { prepareUnsignedClaim(any(), any()) } + verifyBlocking(pubkyRepo, never()) { approveAuthWithCompanionClaim(any(), any(), any()) } + } + @Test fun `auth display public key omits pubky prefix`() { assertEquals("3rsd...w5xg", pubkyAuthDisplayPublicKey("pubky3rsd123456789w5xg")) @@ -294,13 +426,13 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { @Test fun `load exposes watch-only account claim for approval`() = test { - val authUrl = "pubkyauth://signin?caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" + val authUrl = "pubkyauth://signin?caps=${PubkyAuthClaim.REQUIRED_CAPABILITIES}" whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( Result.success( authRequest( authUrl = authUrl, - capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES, - bitkitClaim = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, + capabilities = PubkyAuthClaim.REQUIRED_CAPABILITIES, + bitkitClaim = PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1), ), ), ) @@ -310,7 +442,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { advanceUntilIdle() assertEquals(ApprovalState.WatchOnlyConsent, sut.uiState.value.state) - assertEquals(PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, sut.uiState.value.bitkitClaim) + assertEquals(PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1), sut.uiState.value.bitkitClaim) sut.confirmAuthorize(authUrl) advanceUntilIdle() @@ -331,15 +463,15 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { } @Test - fun `watch-only authorization uses combined companion approval`() = test { - val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" - val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + fun `watch-only authorization delivers the account companion claim`() = test { + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.REQUIRED_CAPABILITIES}" + val capabilities = PubkyAuthClaim.REQUIRED_CAPABILITIES val prepared = PreparedWatchOnlyAccountClaim( account = watchOnlyAccount(), payload = ByteArray(84), ) whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( - Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1)), + Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1))), ) whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) }.thenReturn(false) @@ -366,14 +498,14 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { @Test fun `duplicate confirmations start one companion authorization`() = test { - val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" - val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.REQUIRED_CAPABILITIES}" + val capabilities = PubkyAuthClaim.REQUIRED_CAPABILITIES val prepared = PreparedWatchOnlyAccountClaim( account = watchOnlyAccount(), payload = ByteArray(84), ) whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( - Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1)), + Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1))), ) whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) }.thenReturn(false) @@ -399,8 +531,8 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { @Test fun `switching requests and reopening during companion approval does not start another authorization`() = test { - val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" - val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.REQUIRED_CAPABILITIES}" + val capabilities = PubkyAuthClaim.REQUIRED_CAPABILITIES val secondAuthUrl = "pubkyauth://signin?caps=/pub/second/:rw" val secondCapabilities = "/pub/second/:rw" val prepared = PreparedWatchOnlyAccountClaim( @@ -409,7 +541,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { ) val approvalResult = CompletableDeferred>() whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( - Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1)), + Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1))), ) whenever { pubkyRepo.parseAuthUrl(secondAuthUrl) }.thenReturn( Result.success(authRequest(secondAuthUrl, secondCapabilities)), @@ -456,8 +588,8 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { @Test fun `wrapped post-delivery authorization failure keeps account authorizing for retry`() = test { - val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" - val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.REQUIRED_CAPABILITIES}" + val capabilities = PubkyAuthClaim.REQUIRED_CAPABILITIES val prepared = PreparedWatchOnlyAccountClaim( account = watchOnlyAccount(), payload = ByteArray(84), @@ -466,7 +598,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { "AuthToken delivery failed" ) whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( - Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1)), + Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1))), ) whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) }.thenReturn(false) @@ -488,14 +620,14 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { @Test fun `companion delivery failure does not approve normal auth or activate account`() = test { - val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" - val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.REQUIRED_CAPABILITIES}" + val capabilities = PubkyAuthClaim.REQUIRED_CAPABILITIES val prepared = PreparedWatchOnlyAccountClaim( account = watchOnlyAccount(), payload = ByteArray(84), ) whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( - Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1)), + Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1))), ) whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) }.thenReturn(false) @@ -518,8 +650,8 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { @Test fun `retry delivery failure keeps a previously delivered account authorizing`() = test { - val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" - val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.REQUIRED_CAPABILITIES}" + val capabilities = PubkyAuthClaim.REQUIRED_CAPABILITIES val prepared = PreparedWatchOnlyAccountClaim( account = watchOnlyAccount().copy( isTrackingEnabled = true, @@ -528,7 +660,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { payload = ByteArray(84), ) whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( - Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1)), + Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1))), ) whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) }.thenReturn(true) @@ -552,7 +684,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { @Test fun `tracking preparation failure unloads account without attempting approval`() = test { - val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.REQUIRED_CAPABILITIES}" val prepared = PreparedWatchOnlyAccountClaim( account = watchOnlyAccount(), payload = ByteArray(84), @@ -561,8 +693,8 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { Result.success( authRequest( authUrl, - PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES, - PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, + PubkyAuthClaim.REQUIRED_CAPABILITIES, + PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1), ), ), ) @@ -585,7 +717,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { @Test fun `tracking failure uses the current authorizing disposition`() = test { - val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.REQUIRED_CAPABILITIES}" val prepared = PreparedWatchOnlyAccountClaim( account = watchOnlyAccount(), payload = ByteArray(84), @@ -594,8 +726,8 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { Result.success( authRequest( authUrl, - PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES, - PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, + PubkyAuthClaim.REQUIRED_CAPABILITIES, + PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1), ), ), ) @@ -623,7 +755,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { @Test fun `retry after process restart reuses account and repeats authorization`() = test { - val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.REQUIRED_CAPABILITIES}" val prepared = PreparedWatchOnlyAccountClaim( account = watchOnlyAccount(), payload = ByteArray(84), @@ -638,8 +770,8 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { Result.success( authRequest( authUrl, - PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES, - PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, + PubkyAuthClaim.REQUIRED_CAPABILITIES, + PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1), ), ), ) @@ -694,7 +826,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { clientId = clientId, relay = "https://httprelay.pubky.app/inbox/", capabilities = capabilities, - permissions = listOf(PubkyAuthPermission(path = "/pub/paykit/v0/bitkit/server/", accessLevel = "rw")), + permissions = listOf(PubkyAuthPermission(path = "/pub/paykit/", accessLevel = "rw")), serviceNames = listOf("paykit"), bitkitClaim = bitkitClaim, homeserverPublicKey = if (PubkyAuthRequest.isSignupUrl(authUrl)) "homeserver" else null, diff --git a/app/src/test/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt b/app/src/test/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt index e741447bf8..cebdd19857 100644 --- a/app/src/test/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt @@ -176,7 +176,6 @@ class SubscriptionsScreenTest { ) = PaykitSubscription( paymentRequestId = "subscription", counterparty = "pubkypayee", - counterpartyReceiverPath = "bitkit/server", amountValue = "0.001", amountSats = amountSats, note = "Subscription", diff --git a/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCaseTest.kt similarity index 68% rename from app/src/test/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCaseTest.kt rename to app/src/test/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCaseTest.kt index 052a91a001..7470d823cb 100644 --- a/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCaseTest.kt @@ -15,7 +15,7 @@ import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals import kotlin.test.assertTrue -class RefreshContactPaykitReceiversUseCaseTest : BaseUnitTest() { +class RefreshContactPaykitLinkUseCaseTest : BaseUnitTest() { private val pubkyRepo = mock() private val privatePaykitRepo = mock() private val contactKeys = listOf("pubky-alice", "pubky-bob") @@ -32,7 +32,7 @@ class RefreshContactPaykitReceiversUseCaseTest : BaseUnitTest() { }, ) - private val sut = RefreshContactPaykitReceiversUseCase( + private val sut = RefreshContactPaykitLinkUseCase( ioDispatcher = testDispatcher, pubkyRepo = pubkyRepo, privatePaykitRepo = privatePaykitRepo, @@ -44,8 +44,7 @@ class RefreshContactPaykitReceiversUseCaseTest : BaseUnitTest() { } @Test - fun `refreshes receiver paths before publishing the contact`() = test { - whenever { pubkyRepo.refreshContactReceiverPaths(contactKeys.last()) }.thenReturn(Result.success(Unit)) + fun `refreshes contact endpoints before starting the link burst`() = test { whenever { privatePaykitRepo.refreshSavedContactEndpoints(contactKeys.last(), contactKeys) }.thenReturn(Result.success(Unit)) @@ -53,22 +52,22 @@ class RefreshContactPaykitReceiversUseCaseTest : BaseUnitTest() { val result = sut(contactKeys.last()) assertTrue(result.isSuccess) - inOrder(pubkyRepo, privatePaykitRepo).apply { - verify(pubkyRepo).refreshContactReceiverPaths(contactKeys.last()) + inOrder(privatePaykitRepo).apply { verify(privatePaykitRepo).refreshSavedContactEndpoints(contactKeys.last(), contactKeys) - verify(privatePaykitRepo).startInitialLinkBurst(contactKeys, "contact receiver refresh") + verify(privatePaykitRepo).startInitialLinkBurst(contactKeys, "contact link refresh") } } @Test - fun `stops when receiver discovery fails`() = test { - val error = IllegalStateException("Discovery failed") - whenever { pubkyRepo.refreshContactReceiverPaths(contactKeys.last()) }.thenReturn(Result.failure(error)) + fun `stops when endpoint refresh fails`() = test { + val error = IllegalStateException("Endpoint refresh failed") + whenever { + privatePaykitRepo.refreshSavedContactEndpoints(contactKeys.last(), contactKeys) + }.thenReturn(Result.failure(error)) val result = sut(contactKeys.last()) assertEquals(error, result.exceptionOrNull()) - verify(privatePaykitRepo, never()).refreshSavedContactEndpoints(contactKeys.last(), contactKeys) - verify(privatePaykitRepo, never()).startInitialLinkBurst(contactKeys, "contact receiver refresh") + verify(privatePaykitRepo, never()).startInitialLinkBurst(contactKeys, "contact link refresh") } } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index e1971da6d7..89a4a487b0 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -181,7 +181,7 @@ import to.bitkit.ui.sheets.hardware.HardwareRoute import to.bitkit.ui.theme.TRANSITION_SCREEN_MS import to.bitkit.ui.utils.ScreenDeepLinks import to.bitkit.usecases.FormatMoneyValue -import to.bitkit.usecases.RefreshContactPaykitReceiversUseCase +import to.bitkit.usecases.RefreshContactPaykitLinkUseCase import to.bitkit.utils.AppError import to.bitkit.utils.timedsheets.TimedSheetManager import java.math.BigDecimal @@ -241,7 +241,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val samRockRepo = mock() private val widgetsRepo = mock() private val formatMoneyValue = mock() - private val refreshContactPaykitReceivers = mock() + private val refreshContactPaykitLink = mock() private val clipboardManager = mock() private val toastManager = mock() private val toastState = MutableStateFlow(null) @@ -385,8 +385,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever { pubkyRepo.republishIdentityIfNeeded() }.thenReturn(Result.success(Unit)) whenever { pubkyRepo.hasIdentity() }.thenAnswer { pubkyPublicKey.value != null } whenever(pubkyRepo.contacts).thenReturn(pubkyContacts) - whenever { refreshContactPaykitReceivers(any()) }.thenReturn(Result.success(Unit)) - whenever { publicPaykitRepo.syncLocalReceiverMarker(anyOrNull(), anyOrNull()) } + whenever { refreshContactPaykitLink(any()) }.thenReturn(Result.success(Unit)) + whenever { publicPaykitRepo.syncPaykitApp(anyOrNull()) } .thenReturn(Result.success(Unit)) whenever(pubkyRepo.contactsLoadVersion).thenReturn(pubkyContactsLoadVersion) whenever(pubkyRepo.contactsLoadCompletionVersion).thenReturn(pubkyContactsLoadCompletionVersion) @@ -410,6 +410,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } whenever(paykitPaymentRequestRepo.isPending(any())).thenReturn(true) whenever { paykitPaymentRequestRepo.ensurePaymentAllowed(any()) }.thenReturn(Result.success(Unit)) + whenever { paykitPaymentRequestRepo.claimForPayment(any()) }.thenReturn(Result.success(Unit)) whenever { lightningRepo.payInvoice(any(), anyOrNull(), any()) }.doSuspendableAnswer { if (it.getArgument Boolean>(2)()) { lightningRepo.payInvoice(it.getArgument(0), it.getArgument(1)) @@ -420,9 +421,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitPaymentRequestRepo.isExpired(any())).thenReturn(false) whenever(paykitPaymentRequestRepo.isProcessing(any())).thenReturn(false) whenever(paykitPaymentProofRepo.onchainPaymentResolutions).thenReturn(onchainPaymentResolutions) - whenever { paykitPaymentProofRepo.prepare(any(), any(), any()) }.thenReturn(Result.success(Unit)) + whenever { paykitPaymentProofRepo.prepare(any(), any(), any(), any()) }.thenReturn(Result.success(Unit)) whenever { - paykitPaymentProofRepo.associateLightningPayment(any(), any(), any()) + paykitPaymentProofRepo.associateLightningPayment(any(), any(), any(), eq("bitkit")) }.thenReturn(Result.success(Unit)) whenever { paykitPaymentProofRepo.markOnchainPaymentStarted(any(), any(), any()) @@ -520,7 +521,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { paykitPaymentRequestRepo = paykitPaymentRequestRepo, paykitPaymentProofRepo = paykitPaymentProofRepo, paykitPaymentRequestDiagnostics = paykitPaymentRequestDiagnostics, - refreshContactPaykitReceivers = refreshContactPaykitReceivers, + refreshContactPaykitLink = refreshContactPaykitLink, samRockRepo = samRockRepo, appUpdateSheet = mock(), backupSheet = mock(), @@ -887,7 +888,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.setIsAuthenticated(true) val request = paymentRequest() val bolt11 = "lnbcrt1updatedpaymentrequest" - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 8uL) + val privateContext = privatePaymentContext(8uL) whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList), @@ -996,7 +997,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { Result.success( PublicPaykitPaymentResult.Opened( paymentRequest = bolt11, - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 8uL), + privatePaymentContext = privatePaymentContext(8uL), ), ), ) @@ -1023,7 +1024,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.setIsAuthenticated(true) val request = paymentRequest() val bolt11 = "lnbcrt1updatedmanualrequest" - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 8uL) + val privateContext = privatePaymentContext(8uL) whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList), Result.success( @@ -1105,7 +1106,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { surfacedPaykitPaymentRequestIds += request.id setActiveContactPaymentContext( publicKey = testPublicKey, - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), + privatePaymentContext = privatePaymentContext(7uL), incomingPaymentRequest = request, ) setRequestedPaymentRequestId(request.id) @@ -1169,7 +1170,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { Result.success( PublicPaykitPaymentResult.Opened( paymentRequest = bolt11, - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 8uL), + privatePaymentContext = privatePaymentContext(8uL), ), ), ) @@ -1184,7 +1185,6 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val subscriptionId = PaykitSubscriptionId( request.paymentRequestId, request.counterparty, - request.counterpartyReceiverPath, ) sut.showSheet(Sheet.Subscription(SubscriptionRoute.Review(subscriptionId))) sut.openIncomingPaymentRequest(request.id) @@ -1950,7 +1950,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { Result.success( PublicPaykitPaymentResult.Opened( paymentRequest = automaticInvoice, - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), + privatePaymentContext = privatePaymentContext(7uL), ), ) } @@ -2096,7 +2096,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { setActiveContactPaymentContext(manualContext.publicKey) PublicPaykitPaymentResult.Opened( paymentRequest = "lnbcrt1incoming", - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), + privatePaymentContext = privatePaymentContext(7uL), ) } pendingPaykitPaymentRequests.value = listOf(request) @@ -2129,7 +2129,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { Result.success( PublicPaykitPaymentResult.Opened( paymentRequest = requestInvoice, - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), + privatePaymentContext = privatePaymentContext(7uL), ), ) } @@ -2349,7 +2349,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { Result.success( PublicPaykitPaymentResult.Opened( paymentRequest = requestInvoice, - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), + privatePaymentContext = privatePaymentContext(7uL), ), ) } @@ -2488,7 +2488,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val expiredRequest = paymentRequest() val payableRequest = expiredRequest.copy(paymentRequestId = "payable-request") val bolt11 = "lnbcrt1payableafterexpired" - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) var payableAttempts = 0 whenever(privatePaykitRepo.beginPaymentRequest(expiredRequest)) .thenReturn(Result.failure(PaykitPaymentRequestError.RequestExpired)) @@ -3039,7 +3039,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() } - verify(refreshContactPaykitReceivers).invoke(testPublicKey) + verify(refreshContactPaykitLink).invoke(testPublicKey) } @Test @@ -3109,7 +3109,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { expectNoEvents() } verify(pubkyRepo, never()).loadContacts() - verify(refreshContactPaykitReceivers).invoke(testPublicKey) + verify(refreshContactPaykitLink).invoke(testPublicKey) verify(coreService, never()).decode(any()) } @@ -3133,7 +3133,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() } verify(pubkyRepo).loadContacts() - verify(refreshContactPaykitReceivers).invoke(testPublicKey) + verify(refreshContactPaykitLink).invoke(testPublicKey) verify(coreService, never()).decode(any()) } @@ -3160,7 +3160,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() } verify(pubkyRepo).loadContacts() - verify(refreshContactPaykitReceivers).invoke(testPublicKey) + verify(refreshContactPaykitLink).invoke(testPublicKey) verify(coreService, never()).decode(any()) } @@ -6134,7 +6134,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `incoming onchain payment request has a valid fixed amount`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) whenever { coreService.decode(REGTEST_ADDRESS) }.thenReturn( Scanner.OnChain( @@ -6179,7 +6179,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `outgoing payment request creation continues after its caller returns`() = test { val request = paymentRequest().copy(counterparty = "pubkyrecipient") - val target = PaykitPaymentRequestTarget(request.counterparty, request.counterpartyReceiverPath) + val target = PaykitPaymentRequestTarget(request.counterparty) val draft = PaykitPaymentRequestDraft( amountSats = request.amountSats, note = "Lunch", @@ -6206,7 +6206,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `committed outgoing request closes inactive identity flow and shows queued feedback`() = test { val request = paymentRequest().copy(counterparty = "pubkyrecipient") - val target = PaykitPaymentRequestTarget(request.counterparty, request.counterpartyReceiverPath) + val target = PaykitPaymentRequestTarget(request.counterparty) val draft = PaykitPaymentRequestDraft( amountSats = request.amountSats, note = "Lunch", @@ -6235,7 +6235,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `inactive identity completion preserves a replacement sheet`() = test { val request = paymentRequest().copy(counterparty = "pubkyrecipient") - val target = PaykitPaymentRequestTarget(request.counterparty, request.counterpartyReceiverPath) + val target = PaykitPaymentRequestTarget(request.counterparty) val draft = PaykitPaymentRequestDraft( amountSats = request.amountSats, note = "Lunch", @@ -6304,7 +6304,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `duplicate payment request confirmation submits only once`() = test { val address = "bcrt1qpaymentrequest" val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) @@ -6342,7 +6342,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { beforeSendAttempt = any(), onBroadcast = any(), ) - verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue) + verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue, "bitkit") } @Test @@ -6354,7 +6354,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.openContactPayment( paymentRequest = bolt11, publicKey = testPublicKey, - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), + privatePaymentContext = privatePaymentContext(7uL), incomingPaymentRequest = request, ) advanceUntilIdle() @@ -6390,7 +6390,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `private onchain contact payment consumes private list before send`() = test { val address = "bcrt1qprivatecontact" val contactKey = "pubkycontact" - val privateContext = PrivatePaykitPaymentContext("bitkit/wallet", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) stubSuccessfulOnchainSend(address, 1000u) whenever(privatePaykitRepo.consumePrivatePaymentList(contactKey, privateContext)) @@ -6414,7 +6414,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `incoming payment request consumes its private list before it is accepted`() = test { val address = "bcrt1qpaymentrequest" val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) @@ -6434,21 +6434,25 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() inOrder(paykitPaymentProofRepo, privatePaykitRepo, paykitPaymentRequestRepo).apply { - verify(paykitPaymentProofRepo).prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain) + verify( + paykitPaymentProofRepo + ).prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain) verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) verify(paykitPaymentRequestRepo).accept(request) } verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) - verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue) + verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue, "bitkit") } @Test - fun `proof preparation failure does not block incoming onchain payment`() = test { + fun `proof preparation failure blocks incoming onchain payment`() = test { val address = "bcrt1qpaymentrequest" val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) - whenever(paykitPaymentProofRepo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain)) + whenever( + paykitPaymentProofRepo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain) + ) .thenReturn(Result.failure(IllegalStateException("proof unavailable"))) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) @@ -6467,9 +6471,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() - verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) - verify(paykitPaymentRequestRepo).accept(request) - verify(lightningRepo).sendOnChain( + verify(privatePaykitRepo, never()).consumePrivatePaymentList(testPublicKey, privateContext) + verify(paykitPaymentRequestRepo, never()).accept(request) + verify(lightningRepo, never()).sendOnChain( address = any(), sats = any(), speed = anyOrNull(), @@ -6486,12 +6490,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `uncertain onchain outcome without prepared proof keeps private payment details consumed`() = test { + fun `execution claim failure blocks versionless request without consuming private details`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(null) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) - whenever(paykitPaymentProofRepo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain)) - .thenReturn(Result.failure(IllegalStateException("proof unavailable"))) + whenever(paykitPaymentRequestRepo.claimForPayment(request)) + .thenReturn(Result.failure(IllegalStateException("request claimed by another app"))) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) @@ -6511,29 +6515,34 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ), ) - sut.sendEffect.test { - confirmCurrentPayment() - - assertTrue(awaitItem() is SendEffect.NavigateToPending) - } + confirmCurrentPayment() + verify(privatePaykitRepo, never()).consumePrivatePaymentList(any(), any()) + verify(paykitPaymentRequestRepo, never()).accept(request) verify(paykitPaymentProofRepo, never()).failOnchainPayment(any()) - verify(paykitPaymentProofRepo, never()).cancelPreparation(any()) + verify(paykitPaymentProofRepo).cancelPreparation(request) verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) } @Test - fun `proof association failure does not block incoming lightning payment`() = test { + fun `proof association failure blocks incoming lightning payment`() = test { val request = paymentRequest() val bolt11 = "lnbcrt1paymentrequest" val paymentHash = "010203" - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) - whenever(paykitPaymentProofRepo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning)) + whenever( + paykitPaymentProofRepo.prepare( + request, + MethodId.Bolt11.rawValue, + "bitkit", + PaykitPaymentProofKind.Lightning + ) + ) .doSuspendableAnswer { setSendState(sut.sendUiState.value.copy(decodedInvoice = lightningInvoice(bolt11, request.amountSats))) Result.success(Unit) } - whenever { paykitPaymentProofRepo.associateLightningPayment(any(), any(), any()) } + whenever { paykitPaymentProofRepo.associateLightningPayment(any(), any(), any(), eq("bitkit")) } .thenReturn(Result.failure(IllegalStateException("proof unavailable"))) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) @@ -6552,10 +6561,14 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.setSendEvent(SendEvent.PayConfirmed) advanceUntilIdle() - verify(paykitPaymentProofRepo).prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning) - verify(paykitPaymentProofRepo).associateLightningPayment(request, paymentHash, MethodId.Bolt11.rawValue) + verify( + paykitPaymentProofRepo + ).prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning) + verify( + paykitPaymentProofRepo + ).associateLightningPayment(request, paymentHash, MethodId.Bolt11.rawValue, "bitkit") verify(paykitPaymentProofRepo).cancelPreparation(request) - verify(lightningRepo).payInvoice(bolt11 = bolt11, sats = null) + verify(lightningRepo, never()).payInvoice(bolt11 = bolt11, sats = null) } @Test @@ -6564,14 +6577,17 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val request = paymentRequest() val bolt11 = "lnbcrt1paymentrequest" val paymentHash = "010203" - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) whenever( - paykitPaymentProofRepo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning), + paykitPaymentProofRepo.prepare( + request, + MethodId.Bolt11.rawValue, + "bitkit", + PaykitPaymentProofKind.Lightning + ), ).doSuspendableAnswer { - setSendState( - sut.sendUiState.value.copy(decodedInvoice = lightningInvoice(bolt11, request.amountSats)), - ) + setSendState(sut.sendUiState.value.copy(decodedInvoice = lightningInvoice(bolt11, request.amountSats))) if (preparationSucceeds) { Result.success(Unit) } else { @@ -6581,7 +6597,14 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) - whenever(paykitPaymentProofRepo.associateLightningPayment(request, paymentHash, MethodId.Bolt11.rawValue)) + whenever( + paykitPaymentProofRepo.associateLightningPayment( + request, + paymentHash, + MethodId.Bolt11.rawValue, + "bitkit" + ) + ) .doSuspendableAnswer { whenever(paykitPaymentRequestRepo.ensurePaymentAllowed(request)) .thenReturn(Result.failure(PaykitPaymentRequestError.RequestUnavailable)) @@ -6598,10 +6621,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) sut.setSendEvent(SendEvent.PayConfirmed) advanceUntilIdle() - verify(paykitPaymentRequestRepo).accept(request) + verify(paykitPaymentRequestRepo, times(if (preparationSucceeds) 1 else 0)).accept(request) verify(lightningRepo, never()).payInvoice(any(), anyOrNull()) - verify(paykitPaymentProofRepo).failLightningPayment(paymentHash) - verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) + verify(paykitPaymentProofRepo, times(if (preparationSucceeds) 1 else 0)).failLightningPayment(paymentHash) + verify( + privatePaykitRepo, + times(if (preparationSucceeds) 1 else 0) + ).releasePrivatePaymentList(testPublicKey, privateContext) clearInvocations(lightningRepo, paykitPaymentProofRepo, paykitPaymentRequestRepo, privatePaykitRepo) } } @@ -6611,9 +6637,16 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val request = paymentRequest() val bolt11 = "lnbcrt1pendingrequest" val invoicePaymentHash = "010203" - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) - whenever(paykitPaymentProofRepo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning)) + whenever( + paykitPaymentProofRepo.prepare( + request, + MethodId.Bolt11.rawValue, + "bitkit", + PaykitPaymentProofKind.Lightning + ) + ) .doSuspendableAnswer { setSendState(sut.sendUiState.value.copy(decodedInvoice = lightningInvoice(bolt11, request.amountSats))) Result.success(Unit) @@ -6637,13 +6670,16 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() inOrder(paykitPaymentProofRepo, privatePaykitRepo, paykitPaymentRequestRepo, lightningRepo).apply { - verify(paykitPaymentProofRepo).prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning) + verify( + paykitPaymentProofRepo + ).prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning) verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) verify(paykitPaymentRequestRepo).accept(request) verify(paykitPaymentProofRepo).associateLightningPayment( request, invoicePaymentHash, MethodId.Bolt11.rawValue, + "bitkit", ) verify(lightningRepo).payInvoice(bolt11 = bolt11, sats = null) } @@ -6658,9 +6694,16 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val bolt11 = "lnbcrt1failedrequest" val invoicePaymentHash = "010203" val error = NodeException.PaymentSendingFailed("no route") - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) - whenever(paykitPaymentProofRepo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning)) + whenever( + paykitPaymentProofRepo.prepare( + request, + MethodId.Bolt11.rawValue, + "bitkit", + PaykitPaymentProofKind.Lightning + ) + ) .doSuspendableAnswer { setSendState(sut.sendUiState.value.copy(decodedInvoice = lightningInvoice(bolt11, request.amountSats))) Result.success(Unit) @@ -6685,13 +6728,16 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() inOrder(paykitPaymentProofRepo, privatePaykitRepo, paykitPaymentRequestRepo, lightningRepo).apply { - verify(paykitPaymentProofRepo).prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning) + verify( + paykitPaymentProofRepo + ).prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning) verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) verify(paykitPaymentRequestRepo).accept(request) verify(paykitPaymentProofRepo).associateLightningPayment( request, invoicePaymentHash, MethodId.Bolt11.rawValue, + "bitkit", ) verify(lightningRepo).payInvoice(bolt11 = bolt11, sats = null) verify(paykitPaymentProofRepo).failLightningPayment(invoicePaymentHash, error) @@ -6703,7 +6749,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `failed LNURL request callback releases preparation and retry reopens request`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) val lnurl = LnurlPayData( uri = "lnurl1failedrequest", callback = "https://example.com/callback", @@ -6771,9 +6817,16 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val bolt11 = "lnbcrt1pendingrequest" val paymentHash = "010203" val error = NodeException.PersistenceFailed("io") - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) - whenever(paykitPaymentProofRepo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning)) + whenever( + paykitPaymentProofRepo.prepare( + request, + MethodId.Bolt11.rawValue, + "bitkit", + PaykitPaymentProofKind.Lightning + ) + ) .doSuspendableAnswer { setSendState(sut.sendUiState.value.copy(decodedInvoice = lightningInvoice(bolt11, request.amountSats))) Result.success(Unit) @@ -6809,9 +6862,16 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `in flight proof blocks another payment before consuming private details`() = test { val request = paymentRequest() val bolt11 = "lnbcrt1paymentrequest" - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) - whenever(paykitPaymentProofRepo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning)) + whenever( + paykitPaymentProofRepo.prepare( + request, + MethodId.Bolt11.rawValue, + "bitkit", + PaykitPaymentProofKind.Lightning + ) + ) .thenReturn(Result.failure(PaykitPaymentRequestError.OperationInProgress)) setActiveContactPaymentContext(testPublicKey, privateContext, request) setSendState( @@ -6826,7 +6886,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.setSendEvent(SendEvent.PayConfirmed) advanceUntilIdle() - verify(paykitPaymentProofRepo).prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning) + verify( + paykitPaymentProofRepo + ).prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning) verify(privatePaykitRepo, never()).consumePrivatePaymentList(any(), any()) verify(paykitPaymentRequestRepo, never()).accept(any()) verify(lightningRepo, never()).payInvoice(any(), anyOrNull()) @@ -6836,8 +6898,10 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `in flight proof blocks switching to a hardware payment`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) - whenever(paykitPaymentProofRepo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain)) + val privateContext = privatePaymentContext(7uL) + whenever( + paykitPaymentProofRepo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain) + ) .thenReturn(Result.failure(PaykitPaymentRequestError.OperationInProgress)) setActiveContactPaymentContext(testPublicKey, privateContext, request) setSendState( @@ -6854,7 +6918,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `hardware payment request releases private details when acceptance fails`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) whenever(paykitPaymentRequestRepo.accept(request)) .thenReturn(Result.failure(IllegalStateException("accept failed"))) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) @@ -6881,7 +6945,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `hardware payment request releases private details when proof start fails`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) @@ -6913,7 +6977,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `approved hardware payment request preparation is idempotent`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) @@ -6933,7 +6997,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertTrue(sut.prepareHardwareContactPayment()) inOrder(paykitPaymentProofRepo, privatePaykitRepo, paykitPaymentRequestRepo).apply { - verify(paykitPaymentProofRepo).prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain) + verify( + paykitPaymentProofRepo + ).prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain) verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) verify(paykitPaymentRequestRepo).accept(request) verify(paykitPaymentProofRepo).markOnchainPaymentStarted( @@ -6954,7 +7020,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `hardware retry denial keeps the started proof until cancellation`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) whenever(context.getString(R.string.common__error)).thenReturn("Error") whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(paykitPaymentRequestRepo.ensurePaymentAllowed(request)) @@ -7002,7 +7068,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `cancelling hardware signing fails the started payment proof`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) @@ -7029,7 +7095,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `dismissing hardware signing fails the started payment proof`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) @@ -7056,10 +7122,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `proof preparation failure does not block hardware payment request`() = test { + fun `proof preparation failure blocks hardware payment request`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) - whenever(paykitPaymentProofRepo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain)) + val privateContext = privatePaymentContext(7uL) + whenever( + paykitPaymentProofRepo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain) + ) .thenReturn(Result.failure(IllegalStateException("proof unavailable"))) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) @@ -7075,23 +7143,23 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ), ) - assertTrue(sut.prepareHardwareContactPayment()) + assertFalse(sut.prepareHardwareContactPayment()) - verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) - verify(paykitPaymentRequestRepo).accept(request) + verify(privatePaykitRepo, never()).consumePrivatePaymentList(testPublicKey, privateContext) + verify(paykitPaymentRequestRepo, never()).accept(request) verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted(any(), any(), any()) } @Test fun `hardware payment request completes proof in background after broadcast`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) val completionStarted = CompletableDeferred() val finishCompletion = CompletableDeferred() whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) - whenever(paykitPaymentProofRepo.completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue)) + whenever(paykitPaymentProofRepo.completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue, "bitkit")) .doSuspendableAnswer { completionStarted.complete(Unit) finishCompletion.await() @@ -7116,13 +7184,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { finishCompletion.complete(Unit) advanceUntilIdle() - verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue) + verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue, "bitkit") verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) } @Test fun `private hardware contact preparation is idempotent`() = test { - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) setActiveContactPaymentContext(testPublicKey, privateContext) @@ -7142,7 +7210,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { endsAt = Instant.parse("2026-08-31T00:00:00Z"), ) ) - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) pubkyPublicKey.value = testPublicKey enablePaykitUi() balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) @@ -7164,7 +7232,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() - verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue) + verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue, "bitkit") verify(paykitPaymentRequestRepo).refresh() } @@ -7378,7 +7446,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `onchain payment failure before send attempt releases private payment details`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) @@ -7414,7 +7482,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `onchain authorization denial after proof starts releases private payment details`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) val address = "bcrt1qauthorizationdenied" balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) @@ -7453,7 +7521,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(paykitPaymentProofRepo).failOnchainPayment(request) verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) verify(paykitPaymentProofRepo).cancelPreparation(request) - verify(paykitPaymentProofRepo, never()).completeOnchainPayment(any(), any(), any()) + verify(paykitPaymentProofRepo, never()).completeOnchainPayment(any(), any(), any(), eq("bitkit")) } @Test @@ -7466,7 +7534,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { )) { clearInvocations(paykitPaymentProofRepo, privatePaykitRepo) val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) @@ -7506,7 +7574,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `uncertain onchain failure resolves the matching pending payment`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) pubkyPublicKey.value = testPublicKey runCurrent() balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) @@ -7592,6 +7660,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `unrelated uncertain onchain resolution does not hijack another send`() = test { val request = paymentRequest() + val privateContext = PrivatePaykitPaymentContext(mapOf(MethodId.P2wpkh.rawValue to "bitkit"), 7uL) pubkyPublicKey.value = testPublicKey runCurrent() val replacementRequest = paymentRequest().copy(paymentRequestId = "replacement-request") @@ -7602,7 +7671,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sats = request.amountSats, result = Result.failure(IllegalStateException("outcome unknown")), ) - setActiveContactPaymentContext(testPublicKey, incomingPaymentRequest = request) + whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) + .thenReturn(Result.success(Unit)) + setActiveContactPaymentContext(testPublicKey, privateContext, request) setSendState( SendUiState( address = "bcrt1quncertainreplacement", @@ -7647,7 +7718,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `post broadcast bookkeeping failure still completes payment proof`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) @@ -7671,7 +7742,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() - verify(paykitPaymentProofRepo).completeOnchainPayment(request, "broadcast-txid", MethodId.P2wpkh.rawValue) + verify( + paykitPaymentProofRepo + ).completeOnchainPayment(request, "broadcast-txid", MethodId.P2wpkh.rawValue, "bitkit") verify(paykitPaymentProofRepo, never()).failOnchainPayment(any()) verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) } @@ -7680,7 +7753,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `incoming payment request is not accepted when private list consumption fails`() = test { val address = "bcrt1qpaymentrequest" val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.failure(IllegalStateException("Payment list already consumed"))) @@ -7717,7 +7790,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `incoming payment request releases private details when acceptance fails`() = test { val address = "bcrt1qpaymentrequest" val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) @@ -7757,7 +7830,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `incoming payment request rejects mismatched fixed invoice amount before acceptance`() = test { val request = paymentRequest() val bolt11 = "lnbcrt1mismatchedrequest" - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) setActiveContactPaymentContext(testPublicKey, privateContext, request) setSendState( SendUiState( @@ -7782,7 +7855,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `incoming payment request rejects changed onchain amount before acceptance`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) setActiveContactPaymentContext(testPublicKey, privateContext, request) setSendState( SendUiState( @@ -7817,7 +7890,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `incoming payment request rejects changed amount for amountless invoice`() = test { val request = paymentRequest() val bolt11 = "lnbcrt1changedrequest" - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) setActiveContactPaymentContext(testPublicKey, privateContext, request) setSendState( SendUiState( @@ -7840,7 +7913,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `expired incoming payment request is not submitted`() = test { val address = "bcrt1qexpiredrequest" val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) whenever(paykitPaymentRequestRepo.isPending(request)).thenReturn(false) setActiveContactPaymentContext(testPublicKey, privateContext, request) setSendState( @@ -7894,7 +7967,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val bolt11 = "lnbcrt1privatecontact" val paymentHash = "payment_hash" val contactKey = "pubkycontact" - val privateContext = PrivatePaykitPaymentContext("bitkit/wallet", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) whenever(lightningRepo.payInvoice(bolt11 = bolt11, sats = null)).thenReturn(Result.success(paymentHash)) whenever(privatePaykitRepo.consumePrivatePaymentList(contactKey, privateContext)) @@ -7929,7 +8002,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val bolt11 = "lnbcrt1pending" val paymentHash = "pending_hash" val contactKey = "pubkycontact" - val privateContext = PrivatePaykitPaymentContext("bitkit/wallet", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) whenever(lightningRepo.payInvoice(bolt11 = bolt11, sats = null)) .thenReturn(Result.failure(PaymentPendingException(paymentHash))) @@ -7955,7 +8028,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `private lightning duplicate payment consumes private list`() = test { val bolt11 = "lnbcrt1duplicate" val contactKey = "pubkycontact" - val privateContext = PrivatePaykitPaymentContext("bitkit/wallet", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) whenever(lightningRepo.payInvoice(bolt11 = bolt11, sats = null)) .thenReturn(Result.failure(AppError("DuplicatePayment"))) @@ -8298,7 +8371,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pubkyPublicKey.value = testPublicKey advanceUntilIdle() - verify(publicPaykitRepo).syncLocalReceiverMarker() + verify(publicPaykitRepo).syncPaykitApp() verify(privatePaykitRepo, never()).prepareSavedContacts(any>(), any()) verify(privatePaykitRepo, never()).pruneUnsavedContactState(any>()) @@ -8380,7 +8453,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) - verify(publicPaykitRepo, never()).syncLocalReceiverMarker() + verify(publicPaykitRepo, never()).syncPaykitApp() assertFalse(settingsData.value.publicPaykitCleanupPending) verify(privatePaykitRepo).retryPendingEndpointRemoval(emptyList()) } @@ -8394,7 +8467,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.refreshPrivatePaykitEndpoints() advanceUntilIdle() - verify(publicPaykitRepo).syncLocalReceiverMarker() + verify(publicPaykitRepo).syncPaykitApp() } private suspend fun TestScope.confirmCurrentPayment() { @@ -8527,7 +8600,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { paymentRequest: String, privateListIndex: ULong = 7uL, ): PrivatePaykitPaymentContext { - val privateContext = PrivatePaykitPaymentContext("bitkit/server", privateListIndex) + val privateContext = privatePaymentContext(privateListIndex) whenever { privatePaykitRepo.beginPaymentRequest(request) }.thenReturn( Result.success( PublicPaykitPaymentResult.Opened( @@ -8738,10 +8811,18 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fundingBalanceSats = fundingBalanceSats, ) + private fun privatePaymentContext(version: ULong?) = PrivatePaykitPaymentContext( + paymentAppsByEndpoint = mapOf( + MethodId.P2wpkh.rawValue to "bitkit", + MethodId.Bolt11.rawValue to "bitkit", + MethodId.Lnurl.rawValue to "bitkit", + ), + paymentListVersion = version, + ) + private fun paymentRequest() = PaykitPaymentRequest( paymentRequestId = "request-id", counterparty = testPublicKey, - counterpartyReceiverPath = "bitkit/server", amountValue = "0.000025", amountSats = 2_500uL, expiresAt = null, @@ -8751,7 +8832,6 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private fun subscriptionStartingAt(startsAt: Instant) = PaykitSubscription( paymentRequestId = "subscription-id", counterparty = testPublicKey, - counterpartyReceiverPath = "bitkit/server", amountValue = "0.000025", amountSats = 2_500uL, note = "Weekly coffee", diff --git a/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt index 56382133aa..e9b71e7433 100644 --- a/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt @@ -80,7 +80,7 @@ class SettingsViewModelTest : BaseUnitTest() { whenever(pubkyRepo.identityExists).thenReturn(MutableStateFlow(false)) whenever(pubkyRepo.contacts).thenReturn(contacts) whenever { publicPaykitRepo.syncPublishedEndpoints(publish = false) }.thenReturn(Result.success(Unit)) - whenever { publicPaykitRepo.syncLocalReceiverMarker(anyOrNull(), anyOrNull()) }.thenReturn(Result.success(Unit)) + whenever { publicPaykitRepo.syncPaykitApp(anyOrNull()) }.thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>()) } .thenReturn(Result.success(Unit)) @@ -145,7 +145,7 @@ class SettingsViewModelTest : BaseUnitTest() { assertFalse(settingsData.value.publicPaykitCleanupPending) verify(publicPaykitRepo, never()).syncPublishedEndpoints(publish = false) - verify(publicPaykitRepo).syncLocalReceiverMarker(publicSharingEnabled = false, privateSharingEnabled = false) + verify(publicPaykitRepo).syncPaykitApp(privateSharingEnabled = false) verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(contacts.value.map { it.publicKey }) } @@ -153,8 +153,7 @@ class SettingsViewModelTest : BaseUnitTest() { fun `disabling Paykit with private-only state keeps cleanup pending when marker removal fails`() = test { clearInvocations(publicPaykitRepo) whenever { - publicPaykitRepo.syncLocalReceiverMarker( - publicSharingEnabled = false, + publicPaykitRepo.syncPaykitApp( privateSharingEnabled = false, ) } @@ -169,7 +168,7 @@ class SettingsViewModelTest : BaseUnitTest() { assertTrue(settingsData.value.publicPaykitCleanupPending) verify(publicPaykitRepo, never()).syncPublishedEndpoints(publish = false) - verify(publicPaykitRepo).syncLocalReceiverMarker(publicSharingEnabled = false, privateSharingEnabled = false) + verify(publicPaykitRepo).syncPaykitApp(privateSharingEnabled = false) verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(contacts.value.map { it.publicKey }) } diff --git a/app/src/test/resources/bitkit-combined-claim-v1.json b/app/src/test/resources/bitkit-combined-claim-v1.json new file mode 100644 index 0000000000..8fd50f23fb --- /dev/null +++ b/app/src/test/resources/bitkit-combined-claim-v1.json @@ -0,0 +1,10 @@ +{ + "query_parameter": "x-bitkit-claim", + "claim_type": "paykit-access-v1.watch-only-account-v1", + "capabilities": "/pub/paykit/:rw", + "account_index": 16909060, + "key_generation": 72623859790382856, + "serialized_xpub_hex": "090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909", + "paykit_secret_hex": "0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b", + "unsigned_payload_hex": "01010203040009090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090901020304050607080b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b" +} diff --git a/changelog.d/next/paykit-shared-runtime.changed.md b/changelog.d/next/paykit-shared-runtime.changed.md new file mode 100644 index 0000000000..3299757cb7 --- /dev/null +++ b/changelog.d/next/paykit-shared-runtime.changed.md @@ -0,0 +1 @@ +Share Paykit contacts and payment state with authorized apps while keeping wallet keys private, and label received payments with their Paykit payer contact. diff --git a/docs/paykit-issuer-interoperability.md b/docs/paykit-issuer-interoperability.md index 1f30e14df5..6b38bb5e25 100644 --- a/docs/paykit-issuer-interoperability.md +++ b/docs/paykit-issuer-interoperability.md @@ -74,7 +74,7 @@ After this shape check, Bitkit validates that the value is usable: an on-chain a ## Delivery prerequisites -The issuer and wallet must be linked Paykit peers on the same receiver path before Bitkit polls the request. The issuer must advertise a usable endpoint for at least one identifier retained from the request. A request that fails the request gate is not presented; a request whose endpoint cannot be resolved is deferred until usable payment details arrive. +The issuer and wallet identities must be linked Paykit peers before Bitkit polls the request. Requests may require a specific payment App; Bitkit uses the SDK's request-specific resolver and preserves the selected App in its proof. The issuer can include exact `paymentEndpoints` in the Payment Request or supply current endpoints resolved for the requested App. At least one endpoint must be usable for an identifier retained from the request; embedded endpoints do not require separate advertisement. A request that fails the request gate is not presented; a request whose endpoint cannot be resolved is deferred until usable payment details arrive. ## Contract fixtures diff --git a/docs/pubky-auth-companion-claims.md b/docs/pubky-auth-companion-claims.md new file mode 100644 index 0000000000..6a724d6685 --- /dev/null +++ b/docs/pubky-auth-companion-claims.md @@ -0,0 +1,60 @@ +# Bitkit Pubky Auth companion claims + +Bitkit can authorize a Pubky session and share Paykit access, a watch-only Bitcoin account, or both. The request explicitly selects the material to share; homeserver write permissions alone never imply key export. + +## Request + +- The Pubky Auth URL includes one `x-bitkit-claim` query parameter containing a dot-separated list of independent items: `paykit-access-v1` and `watch-only-account-v1`. Each item requests only its corresponding permission and material. +- Request builders emit Paykit first when requesting both: `x-bitkit-claim=paykit-access-v1.watch-only-account-v1`. Paykit Server requests both items for initial setup and only `paykit-access-v1` for reconnect. +- Either item order is accepted. Bitkit preserves the exact received list string as the SDK's `claim_type`; it must not be reordered before signing or relay delivery. +- The capability is `/pub/paykit/:rw`. +- Empty, unknown, or duplicate items, mismatched selections, and duplicate companion query parameters are rejected. Ordinary Pubky Auth without a companion claim shares only the session, not a Paykit access key or watch-only account. +- Explicit watch-only approval creates a fresh native-SegWit account. Account indexes begin at `1`, increase monotonically, and are never recycled. Retrying the same logical auth request reuses its incomplete account even if query parameters are reordered. +- Bitkit automatically names the account from the requesting service. The user can rename it later. The local name is not disclosed in the claim. +- Paykit-only reconnect leaves local watch-only accounts unchanged. The server retains its existing xpub, account index, and allocation state without requesting the account again; it binds the reconnect to the expected authenticated Pubky identity. +- Paykit-only approval neither allocates nor loads a Bitcoin account. It opens authorization directly, explaining private Paykit data and messages without watch-only or content-earning UI. + +## Claim payload + +The watch-only unsigned payload is exactly 84 bytes: + +| Offset | Size | Value | +| --- | ---: | --- | +| 0 | 1 | Claim version, `0x01` | +| 1 | 4 | BIP account index, unsigned big-endian | +| 5 | 1 | Address type, `0x00` for native SegWit | +| 6 | 78 | Base58Check-decoded extended public key, including its 4-byte version | + +The Paykit-only unsigned payload is exactly 41 bytes: version `1`, an 8-byte nonzero unsigned big-endian key generation, and the 32-byte Paykit access key. Requesting both items produces exactly 124 bytes: the 84-byte watch-only payload followed by that generation and key, without another version byte. This payload order is fixed regardless of the requested item order. + +Bitkit passes the exact item list as `claim_type` and the payload to Paykit's `approveAuthWithCompanionClaim` API. Paykit appends a 64-byte Ed25519 signature, encrypts the signed claim, delivers it on the companion relay channel, and only then approves normal Pubky Auth. The signed sizes are respectively 148, 105, and 188 bytes. + +For requests including Paykit access, Bitkit derives the Paykit secret from its local or shared Pubky identity secret and the current App Registry generation. A locally recorded generation prevents rollback. The recipient derives the separate Noise and shared-state encryption keys from the delegated secret. Neither the Pubky root secret nor Bitcoin spending keys are shared. Watch-only requests do not derive or send a Paykit secret. + +The signature binds the UTF-8 prefix `x-bitkit-claim|`, the exact received item list and trailing `|`, the SHA256 digest of the decoded auth request secret, and the complete unsigned claim bytes, concatenated in that order. Changing the list order changes both the signature domain and relay channel even though the unsigned payload is identical. Each selection requires its exact payload length: 84 bytes for watch-only, 41 bytes for Paykit access, and 124 bytes for both. + +`decoded_auth_request_secret` is the raw 32-byte value produced by base64url-no-pad decoding the URL's `secret` parameter, not UTF-8 text. + +The server verifies the signature with the creator's Pubky Ed25519 public key from the authenticated session. Binding the signature to the request secret prevents a valid signed claim from being moved to a different request; possession of the relay secret alone is insufficient to substitute an attacker's xpub. + +## Delivery and lifecycle + +- The normal AuthToken channel is `base_relay/{base64url_no_pad(BLAKE3(secret))}`. +- The companion channel is `base_relay/{base64url_no_pad(BLAKE3(UTF8(claim_type || "|") || secret))}`. +- Paykit encrypts the complete signed claim on the companion channel with the auth request secret using XSalsa20-Poly1305. The SDK owns transport and cryptography. +- Paykit delivers the claim before approving the normal Pubky Auth token, avoiding a session that was authorized without its required account claim. +- Bitkit persists the account before delivery and reuses the same account index and unsigned xpub payload when retrying an incomplete setup. Each attempt may create new encrypted relay messages; delivery is not guaranteed exactly once. +- Bitkit durably marks and loads a new incomplete account as authorizing before calling Paykit. Successful approval marks it active and leaves tracking enabled. An initial preparation or companion-delivery failure returns it to pending and unloads it again. +- Account registration and address revelation finish before authorization. LDK's periodic sync fetches transaction history; a full wallet sync is not a prerequisite for delivering the authorization. +- If Paykit reports that companion delivery succeeded but normal AuthToken delivery failed, Bitkit leaves the account authorizing and tracked. The same conservative state is retained if local activation persistence fails after Paykit returns success. Retrying reruns the combined Paykit approval with the same account and xpub payload; retry failures keep the account tracked so Bitkit does not lose visibility into addresses the server may already have derived. +- Disabling tracking unloads the account from LDK Node at runtime. It does not delete persisted wallet state, the xpub, or the server session. +- Enabled active or authorizing accounts are configured before LDK Node starts. Electrum full scans use a batch size of `100` and stop gap of `1000`. +- Bitkit pre-reveals external receive indexes `0...999` for each tracked account. LDK then maintains a rolling stop-gap window: the first address with transaction history must be at or below index `999`, and after activity at index `n`, the next active index must be at or below `n + 1000` so there are never `1000` consecutive inactive addresses. +- On startup and before app-driven sync, Bitkit reconciles persisted account state with LDK and restores the pre-revealed range. Accounts removed by a backup remain scheduled for unload until reconciliation succeeds, allowing transient failures to retry safely. +- Account metadata and monotonic allocation state are included in the existing encrypted wallet backup and use the same JSON field names on iOS and Android. + +## Shared fixtures and consent + +The canonical server fixture `bitkit-combined-claim-v1.json`, also included in Android test resources, defines the combined wire layout and exact bytes. + +The shared UI identifiers are `PubkyAuthPaykitAccess` for private Paykit consent and `PubkyAuthWatchOnlyConsent` for the original wallet introduction. Requested Paykit access appears only on final authorization. Journey specifications cover visible consent and cancellation separately from fixture-backed delivery and Paykit-only reconnect. diff --git a/docs/pubky.md b/docs/pubky.md index 12b81d2ebe..75be389dca 100644 --- a/docs/pubky.md +++ b/docs/pubky.md @@ -27,11 +27,22 @@ Delegates Pubky operations to `PaykitSdkService`, which uses: - **paykit-ffi** (`com.synonym:paykit-android`) — session management, auth approval, profile/contact resolution, and bounded file fetching - `fetchPubkyProfile()`, `fetchPubkyFollows()`, `resolveContactProfile()`, `fetchPubkyFileBounded()` -- **bitkit-core** (`com.synonym:bitkit-core-android`) — mnemonic-to-seed conversion for receiver noise-key derivation - - `mnemonicToSeed()` + +Paykit derives the delegated Paykit key from the active Pubky identity secret and the App Registry's current key generation. Authorized apps share encrypted Pubky-hosted Paykit state; Bitkit retains wallet-owned address reservations and pending payment proofs locally. + +The Android dependency is `com.synonym:paykit-android:0.1.0-rc57` from GitHub Packages. Paykit is excluded from Maven-local resolution. Companion authorization and key-sharing consent are described in [Pubky Auth companion claims](pubky-auth-companion-claims.md). All calls are dispatched on `ServiceQueue.CORE` (single-thread executor) to ensure serial access to the underlying Rust state. +### Received Payment Attribution + +Shared Payment Requests can identify a received payment's payer even when another authorized app +created the request. Attribution uses the request's immutable, accepted Bitcoin destinations, not +current contact endpoints or unverified payment proofs. Bitkit validates the network, checks every +known transaction output or the received Lightning payment hash, and leaves ambiguous matches +unlabelled. A successful shared-state refresh backfills only incoming activity with no contact; +existing labels and notes remain unchanged. Snapshots are scoped to the active Pubky identity. + ## Repository Layer (`PubkyRepo`) Manages session lifecycle, identity adoption, and profile data. Singleton scoped. diff --git a/docs/watch-only-account-claim-v1.md b/docs/watch-only-account-claim-v1.md deleted file mode 100644 index 2d9e9446ac..0000000000 --- a/docs/watch-only-account-claim-v1.md +++ /dev/null @@ -1,52 +0,0 @@ -# Bitkit watch-only account claim v1 - -This document records the client contract implemented by Bitkit iOS and Android for Paykit Server setup requests. - -## Request - -- The Pubky Auth URL includes `x-bitkit-claim=watch-only-account-v1`. -- The exact capabilities are `/pub/paykit/v0/bitkit/server/:rw` and `/pub/paykit/v0/private/bitkit/server/:rw`. -- Missing, unknown, mismatched, or duplicate companion-claim parameters are rejected. -- Every distinct auth request creates a fresh native-SegWit account, beginning at BIP84 account index `1`. Account indexes increase monotonically and are never reused. Retrying the same logical auth request reuses its incomplete account even if query parameters are reordered. -- Bitkit automatically names the account from the requesting service. The user can rename it later. The local name is not disclosed in the claim. - -## Claim payload - -Bitkit serializes this exact 84-byte unsigned payload: - -| Offset | Size | Value | -| --- | ---: | --- | -| 0 | 1 | Claim version, `0x01` | -| 1 | 4 | BIP account index, unsigned big-endian | -| 5 | 1 | Address type, `0x00` for native SegWit | -| 6 | 78 | Base58Check-decoded extended public key, including its 4-byte version | - -Bitkit passes the payload to Paykit's `approveAuthWithCompanionClaim` API. Paykit appends a 64-byte Ed25519 signature, encrypts the resulting 148-byte claim, delivers it on the companion relay channel, and only then approves normal Pubky Auth. - -The signature input is the byte concatenation: - -```text -UTF8("x-bitkit-claim|watch-only-account-v1|") -|| SHA256(decoded_auth_request_secret) -|| claim_bytes[0..<84] -``` - -`decoded_auth_request_secret` is the raw 32-byte value produced by base64url-no-pad decoding the URL's `secret` parameter, not UTF-8 text. - -The server verifies the signature with the creator's Pubky Ed25519 public key from the authenticated session. Binding the signature to the request secret prevents a valid signed claim from being moved to a different request; possession of the relay secret alone is insufficient to substitute an attacker's xpub. - -## Delivery and lifecycle - -- The normal AuthToken channel is `base_relay/{base64url_no_pad(BLAKE3(secret))}`. -- The companion channel is `base_relay/{base64url_no_pad(BLAKE3(ASCII("watch-only-account-v1|") || secret))}`. -- Paykit encrypts the complete 148-byte signed claim on the companion channel with the auth request secret using XSalsa20-Poly1305. -- Paykit delivers the claim before approving the normal Pubky Auth token, avoiding a session that was authorized without its required account claim. -- Bitkit persists the account before delivery and reuses the same account index and unsigned xpub payload when retrying an incomplete setup. Each attempt may create new encrypted relay messages; delivery is not guaranteed exactly once. -- Bitkit durably marks and loads an incomplete account as authorizing before calling Paykit. Successful combined approval marks it active and leaves tracking enabled. An initial preparation or companion-delivery failure returns it to pending and unloads it again. -- Account registration and address revelation finish before authorization. LDK's periodic sync fetches transaction history; a full wallet sync is not a prerequisite for delivering the authorization. -- If Paykit reports that companion delivery succeeded but normal AuthToken delivery failed, Bitkit leaves the account authorizing and tracked. The same conservative state is retained if local activation persistence fails after Paykit returns success. Retrying reruns the combined Paykit approval with the same account and xpub payload; retry failures keep the account tracked so Bitkit does not lose visibility into addresses the server may already have derived. -- Disabling tracking unloads the account from LDK Node at runtime. It does not delete persisted wallet state, the xpub, or the server session. -- Enabled active or authorizing accounts are configured before LDK Node starts. Electrum full scans use a batch size of `100` and stop gap of `1000`. -- Bitkit pre-reveals external receive indexes `0...999` for each tracked account. LDK then maintains a rolling stop-gap window: the first address with transaction history must be at or below index `999`, and after activity at index `n`, the next active index must be at or below `n + 1000` so there are never `1000` consecutive inactive addresses. -- On startup and before app-driven sync, Bitkit reconciles persisted account state with LDK and restores the pre-revealed range. Accounts removed by a backup remain scheduled for unload until reconciliation succeeds, allowing transient failures to retry safely. -- Account metadata and monotonic allocation state are included in the existing encrypted wallet backup and use the same JSON field names on iOS and Android. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c3a4f08a16..f1edd44eb0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,7 +22,7 @@ appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.5.18" } -paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc56" } +paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc58" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } diff --git a/journeys/README.md b/journeys/README.md index 8c24ba27d6..f53c2d0005 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -127,7 +127,7 @@ journey PR, which is what made this file conflict on every merge. | A hardware wallet to pair, watch and sign with | the deterministic Trezor emulator from `bitkit-docker` over the Bridge transport, with the USB attach intent injected by `adb`; USB enumeration, permission grants, the OS picker and BLE are not simulated — [hardware-wallet](hardware-wallet/README.md) | | Push notifications to a backgrounded or killed app | an FCM push from a CJIT order paid through `./lsp`, read back with `adb shell dumpsys notification` — [cjit-notifications](cjit-notifications/README.md) | | The OS notification-permission dialog | an API 33+ target, reset with `adb shell pm revoke to.bitkit.dev android.permission.POST_NOTIFICATIONS` — [notification-permission](notification-permission/README.md) | -| An incoming Payment Request from a linked issuer | the fixture issuer, saved as a contact and linked on receiver path `bitkit/server` — [payment-requests](payment-requests/README.md) | +| An incoming Payment Request from a linked issuer | the fixture issuer, saved as a contact and linked as identities — [payment-requests](payment-requests/README.md) | | Two linked Bitkit wallets for a subscription lifecycle | a second Bitkit instance linked to the first, so a proposal can be reviewed and accepted — [subscriptions](subscriptions) | | A Pubky identity and a two-wallet marketplace purchase | the integration fixture runtime: Pubky testnet, Paykit Server, regtest bitcoind and Fulcrum — [pubky-marketplace](pubky-marketplace/README.md) | | LNURL pay, withdraw, channel and auth, and Lightning Addresses | the `bitkit-docker` `lnurl-server` on local regtest, with the app started by `just run docker`, which builds with `E2E=true` and forwards its ports over `adb reverse`; it issues memo invoices, so a check that needs a description-hash invoice needs another endpoint — [lnurl](lnurl) | diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index f6c0b4074f..1bde13b8ce 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -6,7 +6,7 @@ Cover incoming Paykit Payment Requests from a linked issuer. The issuer contract ## Setup -Run Bitkit against regtest with Paykit UI enabled. Authenticate a Pubky identity, save the fixture issuer as a contact, link it on receiver path `bitkit/server`, and give the wallet enough on-chain balance to pay 100,000 sats. The fixture issuer must be able to publish a Paykit endpoint and send a one-time Payment Request to that linked peer. The `bitkit/server` path belongs to the third-party fixture issuer; use `bitkit/wallet` when another Bitkit instance is the issuer. +Run Bitkit against regtest with Paykit UI enabled. Authenticate a Pubky identity, save and link the fixture issuer as a contact, and give the wallet enough on-chain balance to pay 100,000 sats. The fixture issuer must be able to publish a Paykit endpoint and send a one-time Payment Request to that linked peer. Its App ID is `paykit-server`; Bitkit uses `bitkit`. The accepted journey uses: @@ -18,7 +18,7 @@ The accepted journey uses: Rejected fixture shapes stay in unit tests because Bitkit intentionally does not present requests that fail the contract gate. -`request-summary.xml` uses a second Bitkit instance as the requester instead of the fixture issuer: both instances are authenticated Pubky identities, saved as each other's contacts and linked on receiver path `bitkit/wallet`, and the payer holds enough balance to pay 21,000 sats. +`request-summary.xml` uses a second Bitkit instance as the requester instead of the fixture issuer: both instances are authenticated Pubky identities, saved as each other's contacts and linked, and the payer holds enough balance to pay 21,000 sats. `contact-request-or-pay.xml` uses the same two-instance setup and starts from the payer's Contact Detail screen, opened through the `bitkit://contact` deeplink. Its timing step assumes the payer has been running for about a minute: right after launch, the Paykit session restore and link refresh hold the SDK and can push the Pay step well past the budget. @@ -59,10 +59,10 @@ That run established the issuer shapes captured by the fixture: lowercase `btc`, ## Payment deadline history -`payment-deadline-history.xml` covers rc56 requests with actual-payment deadlines. +`payment-deadline-history.xml` covers requests with actual-payment deadlines. Bitkit keeps their lifecycle and paid-period history, and subscription cancellation, but does not accept them, offer payments, or schedule payment reminders. The journey -requires a controlled rc56 peer to prepare the accepted and paid records; repository +requires a controlled shared-runtime peer to prepare the accepted and paid records; repository tests cover these states without sending funds. On Android, unpaid history rows show lifecycle labels, while paid rows show subscription names, notes, or dates. Active subscriptions are opened from Overview. The journeys record each fixture's payment diff --git a/journeys/payment-requests/contact-request-or-pay.xml b/journeys/payment-requests/contact-request-or-pay.xml index 575a09769f..11d800076a 100644 --- a/journeys/payment-requests/contact-request-or-pay.xml +++ b/journeys/payment-requests/contact-request-or-pay.xml @@ -1,6 +1,6 @@ - Verifies that Pay on a linked contact offers Request or Pay, that paying shows progress on the sheet until the amount screen opens within a few seconds, and that Request opens the Payment Request amount screen. Requires two Bitkit instances saved as each other's contacts and linked on receiver path "bitkit/wallet", with the payer funded; see README.md. Let the payer run for a minute after launch before starting, so start-up Paykit work does not dominate the timings. Open the contact with adb shell am start -a android.intent.action.VIEW -d "bitkit://contact?pubky=<requester-public-key>" to.bitkit.dev. + Verifies that Pay on a linked contact offers Request or Pay, that paying shows progress on the sheet until the amount screen opens within a few seconds, and that Request opens the Payment Request amount screen. Requires two Bitkit instances saved as each other's contacts and linked as identities, with the payer funded; see README.md. Let the payer run for a minute after launch before starting, so start-up Paykit work does not dominate the timings. Open the contact with adb shell am start -a android.intent.action.VIEW -d "bitkit://contact?pubky=<requester-public-key>" to.bitkit.dev. Open bitkit://contact?pubky=<requester-public-key> on the payer diff --git a/journeys/payment-requests/delete-and-readd-contact.xml b/journeys/payment-requests/delete-and-readd-contact.xml index e740907019..9c03487386 100644 --- a/journeys/payment-requests/delete-and-readd-contact.xml +++ b/journeys/payment-requests/delete-and-readd-contact.xml @@ -1,6 +1,6 @@ - Verifies that deleting a contact stops incoming private Payment Requests until the user explicitly adds the contact again. Requires two authenticated Bitkit instances saved as each other's contacts and linked on receiver path "bitkit/wallet". The payer must have no active subscription with the requester. No payment needs to be sent. + Verifies that deleting a contact stops incoming private Payment Requests until the user explicitly adds the contact again. Requires two authenticated Bitkit instances saved as each other's contacts and linked as identities. The payer must have no active subscription with the requester. No payment needs to be sent. On the requester, send the payer contact a Payment Request for 5,000 sats with the note "Before deletion" diff --git a/journeys/payment-requests/issuer-interoperability.xml b/journeys/payment-requests/issuer-interoperability.xml index bfe6b3ad8a..bd1e42760f 100644 --- a/journeys/payment-requests/issuer-interoperability.xml +++ b/journeys/payment-requests/issuer-interoperability.xml @@ -1,9 +1,9 @@ - Verifies that the canonical accepted regtest issuer fixture reaches Bitkit and opens the payment confirmation flow. Requires the saved and linked server fixture plus the funded regtest wallet described in README.md. The canonical fixture uses "bitkit/server"; a Bitkit app acting as issuer uses its negotiated "bitkit/wallet" path instead. Rejected shapes are covered by the shared fixture unit tests because Bitkit intentionally does not present them. + Verifies that the canonical accepted regtest issuer fixture reaches Bitkit and opens the payment confirmation flow. Requires the saved and linked server fixture plus the funded regtest wallet described in README.md. The fixture uses App ID "paykit-server"; Bitkit uses "bitkit". Rejected shapes are covered by the shared fixture unit tests because Bitkit intentionally does not present them. - Launch the E2E Bitkit app with Paykit UI enabled and the fixture issuer saved as a contact and linked on receiver path "bitkit/server" + Launch the E2E Bitkit app with Paykit UI enabled and the fixture issuer saved as a contact and linked as identities Have the issuer publish a current regtest P2WPKH address under identifier "btc-regtest-p2wpkh" with JSON payload {"value":"<current address>"} Have the issuer send proposed one-time Payment Request "71300000-0000-4000-8000-000000000001" for amount "0.001", asset "btc", and accepted identifier "btc-regtest-p2wpkh" Verify the Payment Request confirmation screen (testTag "PaymentRequestConfirm") appears with 100,000 sats diff --git a/journeys/payment-requests/payment-deadline-history.xml b/journeys/payment-requests/payment-deadline-history.xml index 1ba8b15b3d..ad08b9421b 100644 --- a/journeys/payment-requests/payment-deadline-history.xml +++ b/journeys/payment-requests/payment-deadline-history.xml @@ -1,7 +1,7 @@ Requests with actual-payment deadlines are visible but cannot be paid by this version of Bitkit. - Use a disposable Paykit test identity linked to a controlled rc56 peer. Prepare one-time BTC + Use a disposable Paykit test identity linked to a controlled shared-runtime peer. Prepare one-time BTC requests with deadlines in proposed, accepted, rejected, canceled and proof-submitted states, plus an incoming, accepted monthly BTC subscription with no end date, a period-start deadline, one paid period and one unpaid period. Acceptance and proof submission must be prepared through the controlled diff --git a/journeys/payment-requests/request-summary.xml b/journeys/payment-requests/request-summary.xml index c4da618fdf..1e341de5af 100644 --- a/journeys/payment-requests/request-summary.xml +++ b/journeys/payment-requests/request-summary.xml @@ -1,6 +1,6 @@ - Verifies that the collapsed Payment Request confirmation shows who the request is from and what it is for, keeps the note in the details, and shows "Not specified" in For when the request has no note. Requires two Bitkit instances saved as each other's contacts and linked on receiver path "bitkit/wallet", with the payer funded to cover 21,000 sats; see README.md. + Verifies that the collapsed Payment Request confirmation shows who the request is from and what it is for, keeps the note in the details, and shows "Not specified" in For when the request has no note. Requires two Bitkit instances saved as each other's contacts and linked as identities, with the payer funded to cover 21,000 sats; see README.md. On the requester, send the payer contact a Payment Request for 21,000 sats with the note "Lunch last week" diff --git a/journeys/pubky-marketplace/README.md b/journeys/pubky-marketplace/README.md index 60e837a9ac..bbcdbffd41 100644 --- a/journeys/pubky-marketplace/README.md +++ b/journeys/pubky-marketplace/README.md @@ -5,6 +5,23 @@ watch-only account claim, a linked buyer receives the resulting Payment Request, the request on regtest through confirmation. It does not cover marketplace browsing, Locks content delivery, fiat payment, or Hypercolor. +## Companion consent and reconnect + +- `paykit-only-approval.xml` checks Paykit-only consent and cancellation without account creation. +- `paykit-reconnect.xml` checks Paykit-only reconnect consent and cancellation without changing the existing service account. + +These visible consent and cancel checks require valid auth URL fixtures but do not authorize a +network session. The reconnect journey additionally needs a known active, tracked account in the +isolated current wallet. Do not manufacture that fixture from real wallet data. + +With a live fixture, also approve each request: verify Paykit-only delivers exactly 41 unsigned +bytes and does not change account allocation or tracking. Initial combined setup delivers +124 unsigned bytes and creates a new account. Paykit-only reconnect delivers 41 unsigned bytes +with a nondecreasing Paykit generation; the server retains its xpub, account index, and allocation state. +Repeat reconnect with a rejected authorization and a cancelled local authentication prompt; +neither may change or unload the existing account. Watch-only requests deliver exactly 84 unsigned +bytes and no Paykit secret. Unknown, duplicate, mismatched, or wrong-sized claims must fail closed. + ## Required integration fixture runtime The journey needs a controlled integration fixture runtime. It must provide: @@ -12,7 +29,7 @@ The journey needs a controlled integration fixture runtime. It must provide: - A fresh Pubky testnet or isolated staging namespace reachable by both wallets. - A Paykit Server including the canonical request behavior from merged upstream [`pubky/paykit-server#2`](https://github.com/pubky/paykit-server/pull/2), plus a `/setup` flow whose - auth URL carries `x-bitkit-claim=watch-only-account-v1`. + auth URL carries `x-bitkit-claim=paykit-access-v1.watch-only-account-v1`. - A regtest bitcoind and Electrum/Fulcrum endpoint on the same chain. Configure the endpoint in both wallets before their first launch so neither wallet retains a taller foreign regtest tip. - A clean seller wallet, a separate clean funded buyer wallet, and the seller Pubky public key. @@ -33,8 +50,9 @@ adb -s reverse tcp:15412 tcp:15412 ``` After creating each wallet, choose **Create profile with Bitkit** to create a Bitkit-generated Pubky -identity in each wallet. Do not import the identity with Pubky Ring; an imported identity cannot -approve the fixture setup auth URL. +identity in each wallet, or use a Ring identity whose root secret is available to Bitkit. +The fixture must use the same shared-runtime SDK and the +[companion claim contract](../../docs/pubky-auth-companion-claims.md). The request and endpoint must satisfy the issuer contract from Android issue [#1208](https://github.com/synonymdev/bitkit-android/issues/1208): lowercase `btc`, a @@ -43,14 +61,12 @@ string `value`. The fixture must keep watch-only account material and spending a Evidence must show the claimed account xpub and account index while omitting wallet seed material and tokens. -Use the pinned +The pinned [`BitcoinErrorLog/pubky-marketplace/payments-env`](https://github.com/BitcoinErrorLog/pubky-marketplace/tree/ed03a32ecfe02deab40ad10ae1bac7fa18465c10/payments-env) -runtime as the marketplace driver and Locks harness. Fixture commit `ed03a32e` pins Paykit Server -source `867fc883` and verifies the canonical lowercase asset, network-correct endpoint identifier, -JSON value payload, and initial private-link retry behavior. Its `scripts/verify.sh` proves the -Locks, Paykit, Pubky, bitcoind, and Fulcrum protocol path. The seller wallet fills the -companion-auth role and the buyer wallet fills the reader role; the other fixture roles remain -unchanged. +runtime is a reference for the marketplace driver and Locks harness, not a shared-runtime +acceptance fixture. Use a fixture revision updated for the companion claim contract above and +record its exact revisions. The seller wallet fills the companion-auth role and the buyer wallet +fills the reader role; the other fixture roles remain unchanged. ## Required app changes @@ -90,8 +106,8 @@ Keep these artifacts at each boundary: | Boundary | Bitkit evidence | Fixture evidence | | --- | --- | --- | -| Watch-only claim | `PubkyAuthWatchOnlyConsent`, `PubkyAuthWatchOnlyApprove`, `PubkyAuthAuthorize`, and `PubkyAuthOK` snapshots | Setup completion and the claimed xpub/account index, with no spending key | -| Contact payments | `ContactPaymentsToggle` snapshots from both wallets | Public receiver markers for both wallet identities | +| Combined claim | `PubkyAuthWatchOnlyConsent`, `PubkyAuthPaykitAccess`, `PubkyAuthAuthorize`, and `PubkyAuthOK` snapshots | Setup completion and the claimed xpub/account index, with no spending key | +| Contact payments | `ContactPaymentsToggle` snapshots from both wallets | Public App Registry entries for both wallet identities | | Linked buyer | `Contact_` snapshot | Seller and buyer peer-link state | | Incoming request | `PaymentRequestsSheet` and `PaymentRequestRow-` snapshots showing seller, amount, and note when present | Delivery record and exact Payment Request id | | Payment approval | `PaymentRequestPay-`, `ReviewAmount`, and `ReviewContactRecipient` snapshots | Derived regtest address and expected amount | @@ -101,68 +117,3 @@ Keep these artifacts at each boundary: `SendSuccess` is evidence of backend acceptance, not confirmation. The fixture's chain and purchase status are the confirmation authority. `PaymentRequestPay-` is shared with the iOS counterpart supplied by [`bitkit-ios#721`](https://github.com/synonymdev/bitkit-ios/pull/721). - -## Release provenance - -The watch-only claim entered the repository in `6f3134e1`, and the incoming Payment Request surface -entered in `4ea3dd16` and `7385376d`. No shipped Android release or tag contains both surfaces as of -2026-09-02. The first intended shipped release is the open `2.6.0` milestone; record its final tag -here when it ships. - -## Acceptance run from 2026-09-02 - -The initial successful payment replay used an isolated runtime including upstream Paykit Server -merge `867fc883` and a pre-merge copy of the incoming-request swipe behavior now supplied by merged -[#1178](https://github.com/synonymdev/bitkit-android/pull/1178). The installed E2E APK had SHA-256 -`cb494d870a255b96e3e289cbe7e659aa89fff1987308502b2fd55f121a0b0c42`, used the local backend at -`10.0.2.2`, and targeted homeserver -`8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo`. A deployed seller completed the unchanged -watch-only consent, approval, authorization, companion-claim delivery, and `/setup` completion -path. The fixture verifier passed with lowercase `btc`, endpoint identifier -`btc-regtest-p2wpkh`, and a JSON string `value`. - -Fresh Android buyer `pubkycecq8ssqnfgfwifioj7djoutnupmpjgomobistnz34zd9d5yyn4o` linked seller -`pubkyhbn4tahj71yzpmtarz5amtqqf5fmicdd7rs8ao448tzaujdapfiy`; both wallets saved the reciprocal -contact and enabled contact payments. The buyer received 1,000,000 sats at -`bcrt1q6mkkp26tu8zm4g78d58uksmvv3una04dryp7s0` in transaction -`3b48b259cd9aec8817b902a44c1609738268880cb16f25179ab87dea21a81a11`, confirmed at height -16,397 in block `5ad00a6d1d9e0e5a2348a255eae5bc83d507a759a4e9f377567af92fa3bacef6`. - -The fixture delivered Locks bundle `1GC8SBDYB2HHA2E0NZ51ZVEZX4`, server invoice -`92fdee57-6a46-40f2-9714-8a0e68d7e60e`, Payment Request -`ad1a8463-59e0-4cf8-b037-99ffb9d5b6ca`, and event -`4282bad8-270d-4e5c-a5f9-bca52d1583dd`. Android displayed the incoming Seller row for 15,000 -sats with an absent note, opened the payment review, resolved -`bcrt1qkuajc36azmaf9kk9ndwy9rdttl6vdlqwtvgyg5`, preserved the amount and Seller recipient, and -enabled the confirmation slider with a 141-sat fee. - -One swipe broadcast transaction `a3e2801d8cf3afa6a461a30fa38bc46680602311a7728b97e5d88f3767f3d9d2`. -The frozen zero-confirmation boundary contained only that mempool transaction, whose output zero -paid exactly 15,000 sats to the derived address; Paykit reported -`detected/0/amount_matched=true` and Locks remained pending. The fixture then mined exactly one -block. Android synced height 16,398 and showed a confirmed Seller activity with a 15,000-sat -payment and 141-sat fee. The transaction confirmed in block -`5f2a356cace276a17e0759d8d34e7c196c852b4b299901e592552fb8f8f0bb19`, Paykit reported -`confirmed/1/amount_matched=true`, the Locks bundle completed, and the paid request remained as -history without Pay or Dismiss actions. - -The selector-specific acceptance replay used Android buyer `emulator-5560` and seller iOS simulator -`B379B7A4-715A-427F-8CB6-A6479BC73050`. It ran a pre-merge integration build containing the journey -selectors and the incoming-request swipe behavior now supplied by merged #1178. The built and -device-installed APKs both had SHA-256 -`8d62881da626a6f6eab1e243c05079305ebd3efd2f9abb820a1a6b55d6454cf4`. The retained Buyer profile -was synced at height 16,398 with 984,859 sats before the fixture created Locks bundle -`J9AKW3TNASDM6MJB0SNE08RJ0M`, server invoice `8d810705-6eeb-46f6-9c57-dd3bc4bdc0cf`, Payment -Request `b7f67854-b052-4eed-a6e7-e1ac41c31a7a`, event -`cec538cb-57f5-4c89-9d80-7584699af1ec`, and payment reference -`94f212c9-ed8c-4b85-9b0a-e9eea09f0a73`. - -Android exposed the exact `PaymentRequestRow-b7f67854-b052-4eed-a6e7-e1ac41c31a7a` and -`PaymentRequestPay-b7f67854-b052-4eed-a6e7-e1ac41c31a7a` selectors, then preserved the 15,000-sat -amount, Seller recipient, and 141-sat fee with an enabled confirmation slider. One swipe broadcast -transaction `3dacd7591e0e9d1b7eab62f814f86017c41c1a1b8dda839a79b7244d9b20f997`, whose output zero paid -exactly 15,000 sats to `bcrt1qxluk70usejytg7ehgdhpsq657eshhmr8lmkcsv`. The frozen -zero-confirmation boundary contained that single mempool transaction. The fixture mined exactly one -block, `7cabfa65673a0472d70c0b1f217e93d6114e040a342381a446f9a50987d18f30`, at height 16,399. Paykit -reported `confirmed/1/amount_matched=true`, the Locks bundle completed, Android showed the Seller -payment as confirmed, and the paid request remained in history without Pay or Dismiss actions. diff --git a/journeys/pubky-marketplace/paykit-only-approval.xml b/journeys/pubky-marketplace/paykit-only-approval.xml new file mode 100644 index 0000000000..340ee26eb4 --- /dev/null +++ b/journeys/pubky-marketplace/paykit-only-approval.xml @@ -0,0 +1,19 @@ + + + Verifies explicit Paykit-only consent and cancellation without allocating a Bitcoin account. + Precondition: an isolated wallet with a Bitkit-generated Pubky identity or an available Ring + root secret. The fixture supplies a valid fresh auth URL with exactly /pub/paykit/:rw and + x-bitkit-claim=paykit-access-v1. Consent and cancellation require no live authorization relay. + Successful delivery is a separate fixture-backed check described in the suite README. + + + Record the current wallet's watch-only account names, derivation paths, and tracking settings + Open the fixture Paykit-only auth URL in the wallet + Verify the authorization screen shows exactly /pub/paykit with READ, WRITE access + Verify Paykit access (id "PubkyAuthPaykitAccess") describes access to private Paykit data and sending Paykit messages, without wallet spending keys. + Verify no Earn introduction, watch-only consent (id "PubkyAuthWatchOnlyConsent"), or account picker is shown. + Tap Cancel on the authorization screen + Verify the authorization sheet is dismissed + Verify the current wallet's watch-only accounts and tracking settings are unchanged + + diff --git a/journeys/pubky-marketplace/paykit-reconnect.xml b/journeys/pubky-marketplace/paykit-reconnect.xml new file mode 100644 index 0000000000..82325a8e74 --- /dev/null +++ b/journeys/pubky-marketplace/paykit-reconnect.xml @@ -0,0 +1,18 @@ + + + Verifies Paykit-only reconnect consent and cancellation without a new watch-only account. + Precondition: an isolated wallet with one known service account that is active and tracked, + and a valid fresh pubkyauth://signin URL with exactly /pub/paykit/:rw and + x-bitkit-claim=paykit-access-v1. The cancel steps need no live authorization relay. + Successful reconnect is a separate fixture-backed README check. + + + Record the known service account's name, derivation path, xpub, and tracking setting + Open the fixture Paykit-only reconnect auth URL in the wallet + Verify the authorization screen is visible without watch-only consent or an account picker + Verify Paykit access (testTag "PubkyAuthPaykitAccess") describes private Paykit data and messages without sharing identity or spending keys + Tap Cancel on the authorization screen + Verify the authorization sheet is dismissed + Verify the known service account's name, derivation path, xpub, and tracking setting are unchanged and no new account was created + + diff --git a/journeys/pubky-marketplace/wallet-leg.xml b/journeys/pubky-marketplace/wallet-leg.xml index b174d4e03b..b11efee9c2 100644 --- a/journeys/pubky-marketplace/wallet-leg.xml +++ b/journeys/pubky-marketplace/wallet-leg.xml @@ -1,23 +1,24 @@ - Verifies a Bitkit seller grants a watch-only account claim and a separate linked Bitkit buyer + Verifies a Bitkit seller grants Paykit access and a watch-only account claim and a separate linked Bitkit buyer receives, approves, pays, and confirms the resulting marketplace Payment Request on regtest. Precondition: two clean wallets and the integration fixture runtime described in this suite's README. Configure the fixture Electrum endpoint and Android emulator port mappings before either wallet's first launch. After creating both wallets, create a Bitkit-generated Pubky identity - in each wallet. Pubky Ring-imported identities cannot approve the fixture setup auth URL. Start + in each wallet, or use a Ring identity whose root secret is available to Bitkit. Start with the seller wallet open and the fixture's fresh setup auth URL available. Open the fixture setup auth URL in the seller wallet Verify the watch-only consent screen (testTag "PubkyAuthWatchOnlyConsent") is visible Capture the watch-only consent evidence, then tap Approve (testTag "PubkyAuthWatchOnlyApprove") - Verify the authorization screen shows exactly /pub/paykit/v0/bitkit/server and /pub/paykit/v0/private/bitkit/server, each with READ, WRITE access + Verify the authorization screen shows exactly /pub/paykit with READ, WRITE access + Verify Paykit access (testTag "PubkyAuthPaykitAccess") describes private Paykit data and messages without sharing identity or spending keys Tap Authorize (testTag "PubkyAuthAuthorize") Verify authorization succeeds (testTag "PubkyAuthOK") Tap OK (testTag "PubkyAuthOK") - Verify the fixture completes setup with the seller account xpub and no spending authority + Verify the fixture completes setup with the seller account xpub and generation-bound Paykit key, but no Pubky root secret or spending authority Open General Settings in both wallets and verify contact payments (testTag "ContactPaymentsToggle") are enabled Open Contacts in the buyer wallet and save the fixture's seller public key Verify the seller contact (testTag "Contact_<seller-public-key>") is visible @@ -45,6 +46,7 @@ Wait for periodic synchronization to detect the payout while the seller app remains active If the seller's received-transaction sheet appears, capture it and tap its button (testTag "ReceivedTransactionButton") to dismiss it Verify the seller savings balance (testTag "ActivitySavings") increased by the fixture payout amount and the latest received activity is visible + Verify the received activity identifies the buyer contact after shared Paykit synchronization, including after reopening the app Tap the seller's latest received activity (testTag "ActivityShort-0"), then tap transaction details (testTag "ActivityTxDetails") Verify the transaction id (testTag "TXID") matches the fixture transaction Capture the final activity, transaction id, confirmation height, and completed purchase evidence diff --git a/journeys/subscriptions/README.md b/journeys/subscriptions/README.md index b8287d1873..ddda3e5f61 100644 --- a/journeys/subscriptions/README.md +++ b/journeys/subscriptions/README.md @@ -7,7 +7,7 @@ Payments tab inside Subscriptions is covered here too, because it shares the scr ## Setup Run Bitkit against regtest with Paykit UI enabled on two instances that have each other saved as -contacts and linked on receiver path `bitkit/wallet`. One instance plays the creator, the other the +contacts with an established Paykit Encrypted Link. One instance plays the creator, the other the payer. Fund the payer with enough on-chain or spending balance to cover the subscription amount when the proposal is accepted with payment due on acceptance. diff --git a/journeys/subscriptions/create-and-propose.xml b/journeys/subscriptions/create-and-propose.xml index 382dda8e6f..a548c004b6 100644 --- a/journeys/subscriptions/create-and-propose.xml +++ b/journeys/subscriptions/create-and-propose.xml @@ -5,7 +5,7 @@ the two linked Bitkit instances described in README.md. - Launch the E2E Bitkit app with Paykit UI enabled and the payer instance saved as a contact linked on receiver path "bitkit/wallet" + Launch the E2E Bitkit app with Paykit UI enabled and the payer instance saved as a contact linked as identities Open the drawer menu and tap Subscriptions Verify the Subscriptions screen (testTag "SubscriptionsScreen") appears with the Overview tab (testTag "Tab-overview") selected Tap Create (testTag "SubscriptionCreate") diff --git a/settings.gradle.kts b/settings.gradle.kts index 9790eeb9eb..b1aede9e15 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -36,7 +36,9 @@ plugins { dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { - mavenLocal() + mavenLocal { + content { excludeModule("com.synonym", "paykit-android") } + } google() mavenCentral() maven { From ce9910c899e533363f1a8c7b54906aded9e6acd2 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 30 Sep 2026 22:55:41 -0500 Subject: [PATCH 36/51] chore: rename changelog fragment --- .../next/{paykit-shared-runtime.changed.md => 1401.changed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{paykit-shared-runtime.changed.md => 1401.changed.md} (100%) diff --git a/changelog.d/next/paykit-shared-runtime.changed.md b/changelog.d/next/1401.changed.md similarity index 100% rename from changelog.d/next/paykit-shared-runtime.changed.md rename to changelog.d/next/1401.changed.md From 44fae4bcd596f7e913d126fc98e3433192ca5cbf Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 30 Sep 2026 23:12:03 -0500 Subject: [PATCH 37/51] docs: clarify paykit integration contracts --- docs/pubky.md | 2 +- journeys/payment-requests/request-summary.xml | 2 +- journeys/pubky-marketplace/README.md | 47 +++++-------------- .../paykit-only-approval.xml | 2 +- .../pubky-marketplace/paykit-reconnect.xml | 2 +- 5 files changed, 15 insertions(+), 40 deletions(-) diff --git a/docs/pubky.md b/docs/pubky.md index 75be389dca..180a8f01e3 100644 --- a/docs/pubky.md +++ b/docs/pubky.md @@ -30,7 +30,7 @@ Delegates Pubky operations to `PaykitSdkService`, which uses: Paykit derives the delegated Paykit key from the active Pubky identity secret and the App Registry's current key generation. Authorized apps share encrypted Pubky-hosted Paykit state; Bitkit retains wallet-owned address reservations and pending payment proofs locally. -The Android dependency is `com.synonym:paykit-android:0.1.0-rc57` from GitHub Packages. Paykit is excluded from Maven-local resolution. Companion authorization and key-sharing consent are described in [Pubky Auth companion claims](pubky-auth-companion-claims.md). +The Android dependency is `com.synonym:paykit-android` from GitHub Packages, pinned in `gradle/libs.versions.toml`. Paykit is excluded from Maven-local resolution. Companion authorization and key-sharing consent are described in [Pubky Auth companion claims](pubky-auth-companion-claims.md). All calls are dispatched on `ServiceQueue.CORE` (single-thread executor) to ensure serial access to the underlying Rust state. diff --git a/journeys/payment-requests/request-summary.xml b/journeys/payment-requests/request-summary.xml index 1e341de5af..118ee10abb 100644 --- a/journeys/payment-requests/request-summary.xml +++ b/journeys/payment-requests/request-summary.xml @@ -8,7 +8,7 @@ Verify From (testTag "PaymentRequestFrom") shows the requester's contact name Verify For (testTag "PaymentRequestFor") shows "Lunch last week" Tap Show details (testTag "SendConfirmToggleDetails") - Verify From (testTag "PaymentRequestFrom") and For (testTag "PaymentRequestFor") are no longer shown, and the recipient (testTag "ReviewContactRecipient") under Contact is the requester's contact + Verify From (testTag "PaymentRequestFrom") and For (testTag "PaymentRequestFor") are hidden in details, and the recipient (testTag "ReviewContactRecipient") under Contact is the requester's contact Verify the invoice note (testTag "PaymentRequestInvoiceNote") shows "Lunch last week" Close the Payment Request confirmation screen without paying On the requester, send the payer contact a Payment Request for 5,000 sats with no note diff --git a/journeys/pubky-marketplace/README.md b/journeys/pubky-marketplace/README.md index bbcdbffd41..e5da87d9b9 100644 --- a/journeys/pubky-marketplace/README.md +++ b/journeys/pubky-marketplace/README.md @@ -1,7 +1,7 @@ # Pubky marketplace wallet leg -This suite covers the two-wallet Bitkit leg of a Pubky marketplace purchase: a seller grants a -watch-only account claim, a linked buyer receives the resulting Payment Request, and the buyer pays +This suite covers the two-wallet Bitkit leg of a Pubky marketplace purchase: a seller grants +Paykit access and a watch-only account, a linked buyer receives the Payment Request, and the buyer pays the request on regtest through confirmation. It does not cover marketplace browsing, Locks content delivery, fiat payment, or Hypercolor. @@ -27,9 +27,9 @@ bytes and no Paykit secret. Unknown, duplicate, mismatched, or wrong-sized claim The journey needs a controlled integration fixture runtime. It must provide: - A fresh Pubky testnet or isolated staging namespace reachable by both wallets. -- A Paykit Server including the canonical request behavior from merged upstream - [`pubky/paykit-server#2`](https://github.com/pubky/paykit-server/pull/2), plus a `/setup` flow whose - auth URL carries `x-bitkit-claim=paykit-access-v1.watch-only-account-v1`. +- A Paykit Server using the same shared-runtime SDK and the + [companion-claim contract](../../docs/pubky-auth-companion-claims.md), including a `/setup` auth + URL whose payload requests `x-bitkit-claim=paykit-access-v1.watch-only-account-v1`. - A regtest bitcoind and Electrum/Fulcrum endpoint on the same chain. Configure the endpoint in both wallets before their first launch so neither wallet retains a taller foreign regtest tip. - A clean seller wallet, a separate clean funded buyer wallet, and the seller Pubky public key. @@ -51,41 +51,17 @@ adb -s reverse tcp:15412 tcp:15412 After creating each wallet, choose **Create profile with Bitkit** to create a Bitkit-generated Pubky identity in each wallet, or use a Ring identity whose root secret is available to Bitkit. -The fixture must use the same shared-runtime SDK and the -[companion claim contract](../../docs/pubky-auth-companion-claims.md). -The request and endpoint must satisfy the issuer contract from Android issue -[#1208](https://github.com/synonymdev/bitkit-android/issues/1208): lowercase `btc`, a +The request and endpoint must satisfy the +[issuer contract](../../docs/paykit-issuer-interoperability.md): lowercase `btc`, a network-correct `btc-regtest-*` endpoint identifier, and a JSON endpoint payload with a non-empty string `value`. The fixture must keep watch-only account material and spending authority separate. Evidence must show the claimed account xpub and account index while omitting wallet seed material and tokens. -The pinned -[`BitcoinErrorLog/pubky-marketplace/payments-env`](https://github.com/BitcoinErrorLog/pubky-marketplace/tree/ed03a32ecfe02deab40ad10ae1bac7fa18465c10/payments-env) -runtime is a reference for the marketplace driver and Locks harness, not a shared-runtime -acceptance fixture. Use a fixture revision updated for the companion claim contract above and -record its exact revisions. The seller wallet fills the companion-auth role and the buyer wallet -fills the reader role; the other fixture roles remain unchanged. - -## Required app changes - -The full journey depends on the sibling work from the parent epic: - -- [#1208](https://github.com/synonymdev/bitkit-android/issues/1208) defines the issuer interop - contract. -- [#1209](https://github.com/synonymdev/bitkit-android/issues/1209) adds reason-specific parse - diagnostics and terminal feedback when an open-time Pay retry is exhausted. -- [#1210](https://github.com/synonymdev/bitkit-android/issues/1210) owns the approved Payment Request - intake policy. This journey does not implement or widen that policy. -- [#1211](https://github.com/synonymdev/bitkit-android/issues/1211) prevents an Electrum-rejected - broadcast from reaching `SendSuccess`. -- [#1218](https://github.com/synonymdev/bitkit-android/issues/1218) tracks the incoming on-chain - request swipe requirement. The behavior is supplied by merged - [#1178](https://github.com/synonymdev/bitkit-android/pull/1178) at `9698dea4`. - -The linked-contact prerequisite is existing Paykit behavior: the buyer must save the seller before -Bitkit's private-message poll can receive the request. The seller must also save the buyer when the +The seller wallet authorizes the server; the buyer wallet receives and pays the request. +The buyer must save the seller before Bitkit's private-message poll can receive the request. +The seller must also save the buyer when the fixture exercises bilateral private delivery. ## Periodic payout detection @@ -115,5 +91,4 @@ Keep these artifacts at each boundary: | Confirmation | Confirmed buyer activity snapshot | Transaction id at one or more confirmations and completed purchase status | `SendSuccess` is evidence of backend acceptance, not confirmation. The fixture's chain and purchase -status are the confirmation authority. `PaymentRequestPay-` is shared with the -iOS counterpart supplied by [`bitkit-ios#721`](https://github.com/synonymdev/bitkit-ios/pull/721). +status are the confirmation authority. `PaymentRequestPay-` is shared with iOS. diff --git a/journeys/pubky-marketplace/paykit-only-approval.xml b/journeys/pubky-marketplace/paykit-only-approval.xml index 340ee26eb4..06f77fa5fa 100644 --- a/journeys/pubky-marketplace/paykit-only-approval.xml +++ b/journeys/pubky-marketplace/paykit-only-approval.xml @@ -11,7 +11,7 @@ Open the fixture Paykit-only auth URL in the wallet Verify the authorization screen shows exactly /pub/paykit with READ, WRITE access Verify Paykit access (id "PubkyAuthPaykitAccess") describes access to private Paykit data and sending Paykit messages, without wallet spending keys. - Verify no Earn introduction, watch-only consent (id "PubkyAuthWatchOnlyConsent"), or account picker is shown. + Verify watch-only consent (id "PubkyAuthWatchOnlyConsent") is not shown Tap Cancel on the authorization screen Verify the authorization sheet is dismissed Verify the current wallet's watch-only accounts and tracking settings are unchanged diff --git a/journeys/pubky-marketplace/paykit-reconnect.xml b/journeys/pubky-marketplace/paykit-reconnect.xml index 82325a8e74..259599f0d5 100644 --- a/journeys/pubky-marketplace/paykit-reconnect.xml +++ b/journeys/pubky-marketplace/paykit-reconnect.xml @@ -9,7 +9,7 @@ Record the known service account's name, derivation path, xpub, and tracking setting Open the fixture Paykit-only reconnect auth URL in the wallet - Verify the authorization screen is visible without watch-only consent or an account picker + Verify the authorization screen is visible without watch-only consent Verify Paykit access (testTag "PubkyAuthPaykitAccess") describes private Paykit data and messages without sharing identity or spending keys Tap Cancel on the authorization screen Verify the authorization sheet is dismissed From ffb3755980df3647eed15099ab90f3a0c6a30a14 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 30 Sep 2026 23:52:02 -0500 Subject: [PATCH 38/51] fix: tighten paykit attribution and cleanup reporting --- .../PrivatePaykitContactResolver.kt | 24 +- .../bitkit/repositories/PrivatePaykitRepo.kt | 19 +- .../java/to/bitkit/services/CoreService.kt | 59 +++-- .../to/bitkit/services/LightningService.kt | 24 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 8 - .../PrivatePaykitContactResolverTest.kt | 27 +- .../repositories/PrivatePaykitRepoTest.kt | 11 +- .../ActivityServicePaykitContactsTest.kt | 242 ++++++++++++++++-- .../bitkit/services/LightningServiceTest.kt | 30 +++ .../viewmodels/AppViewModelSendFlowTest.kt | 6 +- 10 files changed, 367 insertions(+), 83 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt index b302089f40..7d83815b5a 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt @@ -35,14 +35,28 @@ class PrivatePaykitContactResolver @Inject constructor( .mapNotNull(PubkyPublicKeyFormat::normalized).distinct().singleOrNull() } - suspend fun contactPublicKeyForPrivateOnchainAddresses(addresses: Collection): String? = + suspend fun contactPublicKeyForReservedAddress(address: String): String? = withContext(ioDispatcher) { + addressReservationRepo.get().contactPublicKeyForReservedAddress(address) + } + + suspend fun contactPublicKeyForPrivateOnchainAddresses( + receivingAddress: String?, + addresses: Collection, + ): String? = withContext(ioDispatcher) { + if (receivingAddress.isNullOrBlank() || receivingAddress !in addresses) return@withContext null val shared = receivedPaymentContacts - val contacts = addresses.mapNotNull { - addressReservationRepo.get().contactPublicKeyForReservedAddress(it) + val reservedContacts = addresses.distinct().associateWith { + contactPublicKeyForReservedAddress(it) } + val receivingContacts = listOfNotNull(reservedContacts[receivingAddress]) + + shared.contactsForAddresses(listOf(receivingAddress)) + val contact = receivingContacts.mapNotNull(PubkyPublicKeyFormat::normalized).distinct().singleOrNull() + ?: return@withContext null if (receivedPaymentContacts !== shared) return@withContext null - (contacts + shared.contactsForAddresses(addresses)) - .mapNotNull(PubkyPublicKeyFormat::normalized).distinct().singleOrNull() + contact.takeIf { + (reservedContacts.values.filterNotNull() + shared.contactsForAddresses(addresses)) + .mapNotNull(PubkyPublicKeyFormat::normalized).distinct().singleOrNull() == contact + } } } diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 57cba64f4e..e8796cbe1b 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -303,17 +303,7 @@ class PrivatePaykitRepo @Inject constructor( withContext(serializedDispatcher) { runSuspendCatching { updateContactSharingCleanupPending(true) - val removalError = removePublishedEndpoints().exceptionOrNull() - if (removalError != null) { - updateContactSharingCleanupPending(true) - Logger.warn( - "Deferred private Paykit endpoint cleanup after disable failed", - removalError, - context = TAG, - ) - return@runSuspendCatching - } - + removePublishedEndpoints().getOrThrow() clearUnsavedContactState(savedPublicKeys).getOrThrow() updateContactSharingCleanupPending(false) publicPaykitRepo.syncPaykitApp().onFailure { @@ -519,13 +509,6 @@ class PrivatePaykitRepo @Inject constructor( } } - suspend fun contactPublicKeyForPrivateOnchainAddresses(addresses: Collection): String? = - withContext(serializedDispatcher) { - addresses.firstNotNullOfOrNull { - addressReservationRepo.contactPublicKeyForReservedAddress(it) - } - } - suspend fun backupSnapshot(): Result = withContext(serializedDispatcher) { runSuspendCatching { diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index bc798bdc73..1ff13f5362 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -588,7 +588,7 @@ class ActivityService( if (resolver.receivedPaymentContacts !== contacts) break val contact = when (activity) { is Activity.Lightning -> receivedPaykitContact(activity.v1, contacts) - is Activity.Onchain -> receivedPaykitContact(activity.v1, contacts) + is Activity.Onchain -> receivedPaykitContact(activity.v1) } if (contact != null && resolver.receivedPaymentContacts === contacts) { val updated = when (activity) { @@ -607,12 +607,13 @@ class ActivityService( return contacts.contactsForPaymentHash(row.id).singleOrNull() } - private fun receivedPaykitContact(row: OnchainActivity, contacts: PaykitReceivedPaymentContacts): String? { + private suspend fun receivedPaykitContact(row: OnchainActivity): String? { if (row.contact != null || row.txType != PaymentType.RECEIVED) return null val details = getBitkitCoreTransactionDetails(walletId = row.walletId, txId = row.txId) ?: return null - if (details.outputs.isEmpty()) return null - val addresses = listOfNotNull(row.address) + details.outputs.mapNotNull { it.scriptpubkeyAddress } - return contacts.contactsForAddresses(addresses).singleOrNull() + return privatePaykitContactResolver.get().contactPublicKeyForPrivateOnchainAddresses( + receivingAddress = row.address, + addresses = details.outputs.mapNotNull { it.scriptpubkeyAddress }, + ) } suspend fun delete(id: String, walletId: String = defaultWalletId): Boolean = ServiceQueue.CORE.background { @@ -921,11 +922,10 @@ class ActivityService( private suspend fun resolveAddressForInboundPayment( kind: PaymentKind.Onchain, payment: PaymentDetails, - transactionDetails: BitkitCoreTransactionDetails? = null, + details: BitkitCoreTransactionDetails?, ): String? { if (payment.direction != PaymentDirection.INBOUND) return null - val details = transactionDetails ?: fetchTransactionDetails(kind.txid) if (details == null) { Logger.verbose( "Skipped address resolution because transaction details are unavailable for '${kind.txid}'", @@ -950,7 +950,7 @@ class ActivityService( private suspend fun findPrivateReservedAddress(details: BitkitCoreTransactionDetails): String? { for (output in details.outputs) { val address = output.scriptpubkeyAddress ?: continue - if (privatePaykitContactPublicKeyForReservedAddress(address) != null) return address + if (privatePaykitContactResolver.get().contactPublicKeyForReservedAddress(address) != null) return address } return null } @@ -977,23 +977,45 @@ class ActivityService( } } + val accounts = runSuspendCatching { lightningService.listOnchainWalletAccounts() } + .onFailure { Logger.warn("Failed to list onchain wallet accounts", it, context = TAG) } + .getOrDefault(emptyList()) + .filter { it.accountIndex != 0u } + for (isChange in listOf(false, true)) { + for (account in accounts) { + searchReceivingAddressForType( + details = details, + value = value, + currentWalletAddress = "", + addressType = account.addressType.toBitkitAddressType(), + isChange = isChange, + accountIndex = account.accountIndex, + )?.let { return it } + } + } + return null } + @Suppress("LongParameterList") private suspend fun searchReceivingAddressForType( details: BitkitCoreTransactionDetails, value: ULong, currentWalletAddress: String, addressType: AddressType, isChange: Boolean, + accountIndex: UInt = 0u, ): String? { - val addressTypeKey = addressType.toSettingsString() + val addressTypeKey = addressType.toSettingsString().let { + if (accountIndex == 0u) it else "$it:account:$accountIndex" + } val endIndex = addressSearchEndIndex(lastUsedAddressSearchIndex(addressTypeKey, isChange)) var index = 0 var currentAddressBatch: Int? = null while (index < endIndex) { - val addresses = fetchAddressSearchBatch(addressType, isChange, index, addressTypeKey) ?: return null + val addresses = fetchAddressSearchBatch(addressType, isChange, index, addressTypeKey, accountIndex) + ?: return null if ( currentWalletAddress.isNotBlank() && @@ -1021,14 +1043,16 @@ class ActivityService( isChange: Boolean, index: Int, addressTypeKey: String, + accountIndex: UInt, ): List? { val scope = if (isChange) "change" else "receive" - return runCatching { + return runSuspendCatching { lightningService.addressInfosForType( addressType = addressType, isChange = isChange, startIndex = index, count = ADDRESS_SEARCH_BATCH_SIZE, + accountIndex = accountIndex, ).map { it.address } }.onFailure { Logger.warn( @@ -1250,15 +1274,17 @@ class ActivityService( } } - val resolvedAddress = resolveAddressForInboundPayment(kind, payment, transactionDetails) + val details = transactionDetails ?: payment.takeIf { it.direction == PaymentDirection.INBOUND } + ?.let { fetchTransactionDetails(kind.txid) } + val resolvedAddress = resolveAddressForInboundPayment(kind, payment, details) val existingContact = existingOnchainActivity?.v1?.contact val contact = existingContact ?: if ( payment.direction == PaymentDirection.INBOUND && payment.status != PaymentStatus.FAILED && - !transactionDetails?.outputs.isNullOrEmpty() + !details?.outputs.isNullOrEmpty() ) { privatePaykitContactResolver.get().contactPublicKeyForPrivateOnchainAddresses( - listOfNotNull(resolvedAddress) + - transactionDetails.outputs.mapNotNull { it.scriptpubkeyAddress }, + receivingAddress = resolvedAddress, + addresses = details.outputs.mapNotNull { it.scriptpubkeyAddress }, ) } else { null @@ -1310,9 +1336,6 @@ class ActivityService( return privatePaykitContactResolver.get().contactPublicKeyForPrivateInvoicePaymentHash(paymentHash) } - private suspend fun privatePaykitContactPublicKeyForReservedAddress(address: String): String? = - privatePaykitContactResolver.get().contactPublicKeyForPrivateOnchainAddresses(listOf(address)) - // MARK: - Test Data Generation (regtest only) @Suppress("LongMethod") diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 71b75c30ef..b00abd93fd 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -37,6 +37,7 @@ import org.lightningdevkit.ldknode.KeychainKind import org.lightningdevkit.ldknode.Node import org.lightningdevkit.ldknode.NodeException import org.lightningdevkit.ldknode.NodeStatus +import org.lightningdevkit.ldknode.OnchainWalletAccount import org.lightningdevkit.ldknode.OnchainWalletAccountConfig import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PaymentId @@ -114,6 +115,13 @@ internal fun enabledOnchainWalletAccountConfigs( ) } +internal fun LdkAddressType.toBitkitAddressType(): AddressType = when (this) { + LdkAddressType.LEGACY -> AddressType.P2PKH + LdkAddressType.NESTED_SEGWIT -> AddressType.P2SH + LdkAddressType.NATIVE_SEGWIT -> AddressType.P2WPKH + LdkAddressType.TAPROOT -> AddressType.P2TR +} + private fun accountKey(addressType: LdkAddressType, accountIndex: UInt): String = "${addressType.name}:$accountIndex" @@ -679,14 +687,16 @@ class LightningService internal constructor( isChange: Boolean, startIndex: Int, count: Int, + accountIndex: UInt = 0u, ): List { val node = this.node ?: throw ServiceError.NodeNotSetup() val keychain = if (isChange) KeychainKind.INTERNAL else KeychainKind.EXTERNAL return ServiceQueue.LDK.background(ldkQueue) { node.onchainPayment() - .addressInfosForType( + .addressInfosForAccount( addressType.toLdkAddressType(), + accountIndex, keychain, startIndex.toUInt(), count.toUInt(), @@ -695,6 +705,11 @@ class LightningService internal constructor( } } + suspend fun listOnchainWalletAccounts(): List = ServiceQueue.LDK.background(ldkQueue) { + val n = node ?: throw ServiceError.NodeNotSetup() + n.listOnchainWalletAccounts() + } + suspend fun revealReceiveAddresses(toReceiveIndex: Int, forType: AddressType) { val node = this.node ?: throw ServiceError.NodeNotSetup() @@ -1330,13 +1345,6 @@ class LightningService internal constructor( n.listMonitoredAddressTypes().map { it.toBitkitAddressType() } } - private fun LdkAddressType.toBitkitAddressType(): AddressType = when (this) { - LdkAddressType.LEGACY -> AddressType.P2PKH - LdkAddressType.NESTED_SEGWIT -> AddressType.P2SH - LdkAddressType.NATIVE_SEGWIT -> AddressType.P2WPKH - LdkAddressType.TAPROOT -> AddressType.P2TR - } - private fun AddressType.toLdkAddressType(): LdkAddressType = when (this) { AddressType.P2PKH -> LdkAddressType.LEGACY AddressType.P2SH -> LdkAddressType.NESTED_SEGWIT diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index ee66d5c496..6c1f832744 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1698,15 +1698,7 @@ class AppViewModel @Inject constructor( ) { closeSettledReceiveSheet(receiveSheetToClose) val addresses = event.details.outputs.mapNotNull { it.scriptpubkeyAddress } - val contactPublicKey = privatePaykitRepo.contactPublicKeyForPrivateOnchainAddresses(addresses) notifyPaymentReceived(event) - if (contactPublicKey != null) { - activityRepo.setContact( - contactPublicKey = contactPublicKey, - forPaymentId = event.txid, - syncLdkPayments = false, - ) - } privatePaykitRepo.handleOnchainActivity(addresses) .onFailure { Logger.warn("Failed to rotate private Paykit address for '${event.txid}'", it, context = TAG) diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt index a9bacffdc8..500c8252a3 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt @@ -61,7 +61,7 @@ class PrivatePaykitContactResolverTest : BaseUnitTest() { whenever(paymentRequestRepo.receivedPaymentContacts).thenReturn(shared) whenever(addressReservationRepo.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS)).thenReturn(CONTACT_KEY) - assertNull(sut.contactPublicKeyForPrivateOnchainAddresses(listOf(PRIVATE_ADDRESS))) + assertNull(sut.contactPublicKeyForPrivateOnchainAddresses(PRIVATE_ADDRESS, listOf(PRIVATE_ADDRESS))) } @Test @@ -74,7 +74,7 @@ class PrivatePaykitContactResolverTest : BaseUnitTest() { null } - assertNull(sut.contactPublicKeyForPrivateOnchainAddresses(listOf(PRIVATE_ADDRESS))) + assertNull(sut.contactPublicKeyForPrivateOnchainAddresses(PRIVATE_ADDRESS, listOf(PRIVATE_ADDRESS))) } @Test @@ -123,8 +123,29 @@ class PrivatePaykitContactResolverTest : BaseUnitTest() { whenever(addressReservationRepo.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS)) .thenReturn(CONTACT_KEY) - val result = sut.contactPublicKeyForPrivateOnchainAddresses(listOf(PRIVATE_ADDRESS)) + val result = sut.contactPublicKeyForPrivateOnchainAddresses(PRIVATE_ADDRESS, listOf(PRIVATE_ADDRESS)) assertEquals(PubkyPublicKeyFormat.normalized(CONTACT_KEY), result) } + + @Test + fun `shared request is not a local address reservation`() = test { + val shared = mock() + whenever(shared.contactsForAddresses(listOf(PRIVATE_ADDRESS))).thenReturn(setOf(CONTACT_KEY)) + whenever(paymentRequestRepo.receivedPaymentContacts).thenReturn(shared) + + assertNull(sut.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS)) + whenever(addressReservationRepo.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS)).thenReturn(CONTACT_KEY) + assertEquals(CONTACT_KEY, sut.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS)) + } + + @Test + fun `unrelated output cannot supply a contact for the receiving address`() = test { + val outputs = listOf("wallet-address", PRIVATE_ADDRESS) + whenever(addressReservationRepo.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS)).thenReturn(CONTACT_KEY) + + assertNull(sut.contactPublicKeyForPrivateOnchainAddresses("wallet-address", outputs)) + assertNull(sut.contactPublicKeyForPrivateOnchainAddresses(null, outputs)) + assertNull(sut.contactPublicKeyForPrivateOnchainAddresses(PRIVATE_ADDRESS, listOf("wallet-address"))) + } } diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index e74e85a7c7..4e75d489c9 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -377,7 +377,9 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { privateListDeliveryReport() }.whenever(paykitSdkService).clearPrivatePaymentList(CONTACT_KEY) - sut.disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) + val result = sut.disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) + + assertTrue(result.isFailure, failureStage) assertTrue(failed, failureStage) assertTrue(cacheData.value.cleanupPending, failureStage) publicRepo.syncPaykitApp(privateSharingEnabled = false).getOrThrow() @@ -408,13 +410,14 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `disable sharing defers unavailable endpoint cleanup`() = test { + fun `disable sharing reports unavailable endpoint cleanup and retains retry state`() = test { settingsData.value = SettingsData( sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false, publicPaykitOnchainEnabled = true, ) sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true).getOrThrow() + settingsData.value = settingsData.value.copy(sharesPrivatePaykitEndpoints = false) whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY) }.thenReturn( privateListDeliveryReport( failedToQueue = listOf( @@ -429,8 +432,10 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { val result = sut.disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) - assertTrue(result.isSuccess, result.exceptionOrNull().toString()) + assertEquals(PrivatePaykitError.PrivateUnavailable, result.exceptionOrNull()) assertTrue(cacheData.value.cleanupPending) + assertTrue(cacheData.value.contacts.getValue(CONTACT_KEY).hasPublishedPrivatePaymentList) + verifyBlocking(addressReservationRepo, never()) { clearContactAssignments(excludingPublicKeys = any()) } } @Test diff --git a/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt b/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt index 3a3076c3be..88f206c57d 100644 --- a/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt +++ b/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt @@ -1,6 +1,7 @@ package to.bitkit.services import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.AddressType import com.synonym.bitkitcore.LightningActivity import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentState @@ -10,44 +11,84 @@ import com.synonym.bitkitcore.TxOutput import com.synonym.bitkitcore.getActivities import com.synonym.bitkitcore.getTransactionDetails import com.synonym.bitkitcore.updateActivity +import com.synonym.bitkitcore.upsertActivity +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf import org.junit.Test +import org.lightningdevkit.ldknode.ConfirmationStatus +import org.lightningdevkit.ldknode.OnchainWalletAccount +import org.lightningdevkit.ldknode.PaymentDetails +import org.lightningdevkit.ldknode.PaymentDirection +import org.lightningdevkit.ldknode.PaymentKind +import org.lightningdevkit.ldknode.PaymentStatus import org.mockito.Mockito.mockStatic import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.async.ServiceQueue +import to.bitkit.data.AppCacheData +import to.bitkit.data.CacheStore +import to.bitkit.data.PrivatePaykitCacheData +import to.bitkit.data.PrivatePaykitCacheStore +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore import to.bitkit.ext.create +import to.bitkit.repositories.PaykitPaymentRequestRepo import to.bitkit.repositories.PaykitReceivedPaymentContacts +import to.bitkit.repositories.PaykitReceivedPaymentContactsTest.Companion.BUYER +import to.bitkit.repositories.PaykitReceivedPaymentContactsTest.Companion.OTHER_BUYER +import to.bitkit.repositories.PrivatePaykitAddressReservationRepo import to.bitkit.repositories.PrivatePaykitContactResolver import to.bitkit.test.BaseUnitTest import javax.inject.Provider import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue +import org.lightningdevkit.ldknode.AddressType as LdkAddressType +import org.lightningdevkit.ldknode.TransactionDetails as LdkTransactionDetails +import org.lightningdevkit.ldknode.TxOutput as LdkTxOutput class ActivityServicePaykitContactsTest : BaseUnitTest() { private val contacts = mock() - private val resolver = mock() - private val sut by lazy { ActivityService(mock(), mock(), mock(), mock(), Provider { resolver }) } + private val requestRepo = mock() + private val reservations = mock() + private val privateCacheStore = mock() + private val cacheStore = mock() + private val cacheData = MutableStateFlow(AppCacheData(onchainAddress = "wallet-address")) + private val settingsStore = mock() + private val lightningService = mock() + private val coreService = mock() + private val resolver by lazy { + PrivatePaykitContactResolver( + ioDispatcher = testDispatcher, + cacheStore = privateCacheStore, + addressReservationRepo = Provider { reservations }, + paymentRequestRepo = Provider { requestRepo }, + ) + } + private val sut by lazy { + ActivityService(coreService, cacheStore, lightningService, settingsStore, Provider { resolver }) + } private var rows = listOf() private var details: TransactionDetails? = null private val updates = mutableListOf() @Test - fun `backfill matches cached outputs and preserves all other onchain metadata`() = coreTest { - val original = onchain(address = "unknown") + fun `backfill matches receiving address in outputs and preserves all other onchain metadata`() = coreTest { + val original = onchain(address = "request-address") rows = listOf(Activity.Onchain(original)) val outputs = listOf(output("request-address"), output("change-address")) details = mock { on { this.outputs }.thenReturn(outputs) } - whenever(contacts.contactsForAddresses(listOf("unknown", "request-address", "change-address"))) - .thenReturn(setOf("buyer")) + sharedAddresses("request-address" to BUYER) assertTrue(sut.backfillPaykitContacts()) - assertEquals(listOf(Activity.Onchain(original.copy(contact = "buyer"))), updates) - verify(contacts).contactsForAddresses(listOf("unknown", "request-address", "change-address")) + assertEquals(listOf(Activity.Onchain(original.copy(contact = BUYER))), updates) + verify(contacts).contactsForAddresses(listOf("request-address", "change-address")) } @Test @@ -80,10 +121,9 @@ class ActivityServicePaykitContactsTest : BaseUnitTest() { @Test fun `backfill refuses ambiguity across stored receiving address and other outputs`() = coreTest { rows = listOf(Activity.Onchain(onchain(address = "request-address"))) - val outputs = listOf(output("other-request-address")) + val outputs = listOf(output("request-address"), output("other-request-address")) details = mock { on { this.outputs }.thenReturn(outputs) } - whenever(contacts.contactsForAddresses(listOf("request-address", "other-request-address"))) - .thenReturn(setOf("buyer", "other-buyer")) + sharedAddresses("request-address" to BUYER, "other-request-address" to OTHER_BUYER) assertFalse(sut.backfillPaykitContacts()) assertTrue(updates.isEmpty()) @@ -92,7 +132,7 @@ class ActivityServicePaykitContactsTest : BaseUnitTest() { @Test fun `backfill waits for complete cached outputs even when stored address matches`() = coreTest { rows = listOf(Activity.Onchain(onchain(address = "request-address"))) - whenever(contacts.contactsForAddresses(listOf("request-address"))).thenReturn(setOf("buyer")) + sharedAddresses("request-address" to BUYER) assertFalse(sut.backfillPaykitContacts()) details = mock { on { outputs }.thenReturn(emptyList()) } @@ -100,8 +140,6 @@ class ActivityServicePaykitContactsTest : BaseUnitTest() { val outputs = listOf(output("other-request-address")) details = mock { on { this.outputs }.thenReturn(outputs) } - whenever(contacts.contactsForAddresses(listOf("request-address", "other-request-address"))) - .thenReturn(setOf("buyer", "other-buyer")) assertFalse(sut.backfillPaykitContacts()) assertTrue(updates.isEmpty()) } @@ -110,7 +148,7 @@ class ActivityServicePaykitContactsTest : BaseUnitTest() { fun `backfill refuses stale identity snapshot before write`() = coreTest { rows = listOf(Activity.Lightning(lightning())) whenever(contacts.contactsForPaymentHash(any())).thenAnswer { - whenever(resolver.receivedPaymentContacts).thenReturn(PaykitReceivedPaymentContacts.Empty) + whenever(requestRepo.receivedPaymentContacts).thenReturn(PaykitReceivedPaymentContacts.Empty) setOf("buyer") } @@ -130,8 +168,169 @@ class ActivityServicePaykitContactsTest : BaseUnitTest() { assertTrue(updates.isEmpty()) } + @Test + fun `backfill rejects a request that only matches another output`() = coreTest { + for (address in listOf("wallet-address", "unknown")) { + rows = listOf(Activity.Onchain(onchain(address))) + val outputs = listOf(output("wallet-address"), output("request-address")) + details = mock { on { this.outputs }.thenReturn(outputs) } + sharedAddresses("request-address" to BUYER) + + assertFalse(sut.backfillPaykitContacts()) + } + assertTrue(updates.isEmpty()) + } + + @Test + fun `backfill includes local reservations in ambiguity checks`() = coreTest { + rows = listOf(Activity.Onchain(onchain("request-address"))) + val outputs = listOf(output("request-address"), output("reserved-address")) + details = mock { on { this.outputs }.thenReturn(outputs) } + sharedAddresses("request-address" to BUYER) + whenever(reservations.contactPublicKeyForReservedAddress("reserved-address")).thenReturn(OTHER_BUYER) + + assertFalse(sut.backfillPaykitContacts()) + assertTrue(updates.isEmpty()) + } + + @Test + fun `live received payment rejects a request matching an unrelated output`() = coreTest { + sharedAddresses("request-address" to BUYER) + + receive("wallet-address", "request-address") + + val row = (updates.single() as Activity.Onchain).v1 + assertEquals("wallet-address", row.address) + assertNull(row.contact) + } + + @Test + fun `live received payment attributes a positively matched wallet destination`() = coreTest { + sharedAddresses("wallet-address" to BUYER) + + receive("wallet-address", "unrelated-address") + + assertEquals(BUYER, (updates.single() as Activity.Onchain).v1.contact) + } + + @Test + fun `live received payment rejects conflicting request outputs`() = coreTest { + sharedAddresses("wallet-address" to BUYER, "request-address" to OTHER_BUYER) + + receive("wallet-address", "request-address") + + assertNull((updates.single() as Activity.Onchain).v1.contact) + } + + @Test + fun `shared request destination alone cannot resolve the receiving address`() = coreTest { + sharedAddresses("request-address" to BUYER) + + receive("request-address") + + val row = (updates.single() as Activity.Onchain).v1 + assertEquals("Loading...", row.address) + assertNull(row.contact) + } + + @Test + fun `local reservation can resolve and attribute a received destination`() = coreTest { + whenever(reservations.contactPublicKeyForReservedAddress("reserved-address")).thenReturn(BUYER) + + receive("reserved-address") + + val row = (updates.single() as Activity.Onchain).v1 + assertEquals("reserved-address", row.address) + assertEquals(BUYER, row.contact) + } + + @Test + fun `registered companion account resolves request destination with scoped search indexes`() = coreTest { + sharedAddresses("server-address" to BUYER) + cacheData.value = cacheData.value.copy( + addressSearchLastUsedReceiveIndexes = mapOf("nativeSegwit" to 600), + ) + whenever(lightningService.listOnchainWalletAccounts()).thenReturn( + listOf(OnchainWalletAccount(LdkAddressType.NATIVE_SEGWIT, 5u)), + ) + whenever(lightningService.addressInfosForType(AddressType.P2WPKH, false, 200, 200, 5u)) + .thenReturn(listOf(AddressDerivationInfo("server-address", 203))) + + receive("change-address", "server-address") + + val row = (updates.single() as Activity.Onchain).v1 + assertEquals("server-address", row.address) + assertEquals(BUYER, row.contact) + assertEquals( + mapOf("nativeSegwit" to 600, "nativeSegwit:account:5" to 200), + cacheData.value.addressSearchLastUsedReceiveIndexes, + ) + } + + @Test + fun `companion account search keeps its own bounded receive and change windows`() = coreTest { + cacheData.value = cacheData.value.copy( + addressSearchLastUsedReceiveIndexes = mapOf("nativeSegwit" to 600), + addressSearchLastUsedChangeIndexes = mapOf("nativeSegwit:account:5" to 200), + ) + whenever(lightningService.listOnchainWalletAccounts()).thenReturn( + listOf(OnchainWalletAccount(LdkAddressType.NATIVE_SEGWIT, 5u)), + ) + + receive("unowned-address") + + verify(lightningService).addressInfosForType(AddressType.P2WPKH, false, 800, 200, 5u) + verify(lightningService, never()).addressInfosForType(AddressType.P2WPKH, false, 1000, 200, 5u) + verify(lightningService).addressInfosForType(AddressType.P2WPKH, true, 1000, 200, 5u) + verify(lightningService, never()).addressInfosForType(AddressType.P2WPKH, true, 1200, 200, 5u) + } + + @Test + fun `payment sync uses stored outputs for receiving address attribution`() = coreTest { + val outputs = listOf(output("wallet-address"), output("unrelated-address")) + details = mock { on { this.outputs }.thenReturn(outputs) } + sharedAddresses("wallet-address" to BUYER) + + sut.syncLdkNodePaymentsToActivities(listOf(payment())) + + assertEquals(BUYER, (updates.single() as Activity.Onchain).v1.contact) + } + + private suspend fun receive(vararg addresses: String) { + whenever(lightningService.listPayments()).thenReturn(listOf(payment())) + sut.handleOnchainTransactionReceived( + "transaction", + LdkTransactionDetails( + amountSats = 15_000, + inputs = emptyList(), + outputs = addresses.mapIndexed { index, address -> + LdkTxOutput("", "", address, 15_000, index.toUInt()) + }, + ), + ) + } + + private fun payment() = PaymentDetails( + id = "received", + kind = PaymentKind.Onchain("transaction", ConfirmationStatus.Unconfirmed), + amountMsat = 15_000_000uL, + feePaidMsat = 0uL, + direction = PaymentDirection.INBOUND, + status = PaymentStatus.SUCCEEDED, + latestUpdateTimestamp = 100uL, + ) + private fun coreTest(block: suspend () -> Unit) = test { - whenever(resolver.receivedPaymentContacts).thenReturn(contacts) + whenever(requestRepo.receivedPaymentContacts).thenReturn(contacts) + whenever(privateCacheStore.data).thenReturn(flowOf(PrivatePaykitCacheData())) + whenever(cacheStore.data).thenReturn(cacheData) + whenever(cacheStore.update(any())).thenAnswer { + cacheData.value = it.getArgument<(AppCacheData) -> AppCacheData>(0)(cacheData.value) + } + whenever(settingsStore.data).thenReturn(flowOf(SettingsData())) + whenever(coreService.activity).thenReturn(mock()) + whenever(lightningService.listOnchainWalletAccounts()).thenReturn(emptyList()) + whenever(lightningService.addressInfosForType(any(), any(), any(), any(), any())).thenReturn(emptyList()) ServiceQueue.CORE.background { mockStatic(Class.forName("com.synonym.bitkitcore.Bitkitcore_androidKt")).use { native -> native.`when`> { @@ -145,11 +344,22 @@ class ActivityServicePaykitContactsTest : BaseUnitTest() { updates.add(it.arguments[1] as Activity) null } + native.`when` { upsertActivity(any()) }.thenAnswer { + updates.add(it.arguments[0] as Activity) + null + } block() } } } + private fun sharedAddresses(vararg values: Pair) { + val byAddress = values.toMap() + whenever(contacts.contactsForAddresses(any())).thenAnswer { + it.getArgument>(0).mapNotNull(byAddress::get).toSet() + } + } + private fun output(address: String): TxOutput = mock { on { scriptpubkeyAddress }.thenReturn(address) } private fun onchain(address: String = "address") = OnchainActivity.create( diff --git a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt index 14ecdf1b26..b2f0137724 100644 --- a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt @@ -15,8 +15,10 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder +import org.lightningdevkit.ldknode.AddressInfo import org.lightningdevkit.ldknode.AddressType import org.lightningdevkit.ldknode.Event +import org.lightningdevkit.ldknode.KeychainKind import org.lightningdevkit.ldknode.Node import org.lightningdevkit.ldknode.NodeException import org.lightningdevkit.ldknode.NodeStatus @@ -51,6 +53,7 @@ import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Duration.Companion.milliseconds +import com.synonym.bitkitcore.AddressType as BitkitAddressType private const val VERIFY_TIMEOUT_MS = 2_000L private const val RELEASE_GATE_PROBE_MS = 200L @@ -98,6 +101,33 @@ class LightningServiceTest : BaseUnitTest() { assertFalse(sut.canReceive()) } + @Test + fun `address derivation forwards companion account and keychain without changing account zero`() = test { + val onchain = mock() + whenever(node.onchainPayment()).thenReturn(onchain) + val address = AddressInfo(201u, "derived-address", KeychainKind.INTERNAL) + whenever(onchain.addressInfosForAccount(AddressType.NATIVE_SEGWIT, 5u, KeychainKind.INTERNAL, 200u, 200u)) + .thenReturn(listOf(address)) + whenever(onchain.addressInfosForAccount(AddressType.NATIVE_SEGWIT, 0u, KeychainKind.EXTERNAL, 0u, 200u)) + .thenReturn(listOf(address)) + + val companion = sut.addressInfosForType(BitkitAddressType.P2WPKH, true, 200, 200, accountIndex = 5u) + val primary = sut.addressInfosForType(BitkitAddressType.P2WPKH, false, 0, 200) + + assertEquals(listOf(AddressDerivationInfo("derived-address", 201)), companion) + assertEquals(companion, primary) + verify(onchain).addressInfosForAccount(AddressType.NATIVE_SEGWIT, 5u, KeychainKind.INTERNAL, 200u, 200u) + verify(onchain).addressInfosForAccount(AddressType.NATIVE_SEGWIT, 0u, KeychainKind.EXTERNAL, 0u, 200u) + } + + @Test + fun `listOnchainWalletAccounts returns registered accounts from LDK`() = test { + val accounts = listOf(OnchainWalletAccount(AddressType.NATIVE_SEGWIT, 5u)) + whenever(node.listOnchainWalletAccounts()).thenReturn(accounts) + + assertEquals(accounts, sut.listOnchainWalletAccounts()) + } + @Test fun `canReceive returns true when channel is usable`() { val usableChannel = createChannelDetails().copy( diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 89a4a487b0..57a863a403 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -465,8 +465,6 @@ class AppViewModelSendFlowTest : BaseUnitTest() { true } } - whenever { privatePaykitRepo.contactPublicKeyForPrivateOnchainAddresses(any>()) } - .thenReturn(null) whenever { privatePaykitRepo.discardRemoteLightningEndpoints(any(), any()) } .thenReturn(Result.success(Unit)) whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())) @@ -4815,11 +4813,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `received onchain payment preserves a replacement receive sheet`() = test { val processingStarted = CompletableDeferred() val resumeProcessing = CompletableDeferred() - whenever(privatePaykitRepo.contactPublicKeyForPrivateOnchainAddresses(any>())) + whenever(privatePaykitRepo.handleOnchainActivity(any>())) .doSuspendableAnswer { processingStarted.complete(Unit) resumeProcessing.await() - null + Result.success(Unit) } val settledAddress = "bcrt1qsettled" walletState.value = WalletState(onchainAddress = settledAddress) From c4fb1db0dcc387645f869747de1b6887d91c1520 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 1 Oct 2026 00:06:49 -0500 Subject: [PATCH 39/51] test: cover paykit key generation and rotation --- .../PaykitReceivedPaymentContacts.kt | 12 +- .../to/bitkit/services/PaykitSdkService.kt | 2 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 2 +- .../PaykitPaymentProofRepoTest.kt | 3 +- .../PaykitPaymentRequestRepoTest.kt | 8 +- .../PaykitReceivedPaymentContactsTest.kt | 111 ++++++++------ .../repositories/PrivatePaykitRepoTest.kt | 12 +- .../services/PaykitKeyGenerationTest.kt | 141 ++++++++++++++++++ .../RefreshContactPaykitLinkUseCaseTest.kt | 10 +- 9 files changed, 232 insertions(+), 69 deletions(-) create mode 100644 app/src/test/java/to/bitkit/services/PaykitKeyGenerationTest.kt diff --git a/app/src/main/java/to/bitkit/repositories/PaykitReceivedPaymentContacts.kt b/app/src/main/java/to/bitkit/repositories/PaykitReceivedPaymentContacts.kt index 1487aeb5e6..11584865a2 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitReceivedPaymentContacts.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitReceivedPaymentContacts.kt @@ -14,11 +14,6 @@ internal class PaykitReceivedPaymentContacts private constructor( private val addresses: Map>, private val paymentHashes: Map>, ) { - fun contactsForAddresses(values: Collection): Set = - values.flatMapTo(mutableSetOf()) { addresses[it].orEmpty() } - - fun contactsForPaymentHash(value: String): Set = paymentHashes[value.lowercase()].orEmpty() - companion object { val Empty = PaykitReceivedPaymentContacts(emptyMap(), emptyMap()) @@ -32,7 +27,7 @@ internal class PaykitReceivedPaymentContacts private constructor( for (record in records) { val contact = validCounterparty(record) ?: continue val terms = checkNotNull(record.terms) - // Only immutable request destinations identify a payer; proofs and current lists do not. + // Use immutable request destinations for attribution, not proofs or current lists. val acceptedEndpoints = terms.paymentEndpoints.orEmpty() .filterKeys(terms.acceptedPaymentEndpointIdentifiers::contains) for ((identifier, payload) in acceptedEndpoints) { @@ -87,4 +82,9 @@ internal class PaykitReceivedPaymentContacts private constructor( Network.REGTEST -> Currency.REGTEST } } + + fun contactsForAddresses(values: Collection): Set = + values.flatMapTo(mutableSetOf()) { addresses[it].orEmpty() } + + fun contactsForPaymentHash(value: String): Set = paymentHashes[value.lowercase()].orEmpty() } diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 5d91bce963..3316316e99 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -571,7 +571,7 @@ class PaykitSdkService @Inject constructor( (endsAt == null || endsAt > now) } if (hasActiveSubscription) throw PubkyContactError.ActiveSubscription - peers.filter { it.state == LinkedPeerState.LINKED }.forEach { peer -> + peers.filter { it.state == LinkedPeerState.LINKED }.forEach { runSuspendCatching { val report = handle.clearPrivatePaymentListAndProcessOutbound( publicKey, diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 6c1f832744..1affda6575 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -4359,7 +4359,7 @@ class AppViewModel @Inject constructor( private suspend fun preparePaymentProof(request: PaykitPaymentRequest?): Result { if (request == null) return Result.success(null) - val preparation = runCatching { paymentProofPreparation() }.getOrElse { return Result.failure(it) } + val preparation = runSuspendCatching { paymentProofPreparation() }.getOrElse { return Result.failure(it) } return paykitPaymentProofRepo.prepare( request = request, paymentEndpointIdentifier = preparation.endpointIdentifier, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index c91bd32910..083dd620e8 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -10,6 +10,7 @@ import com.synonym.paykit.PaymentRequestLocalRole import com.synonym.paykit.PaymentRequestRecord import com.synonym.paykit.PaymentRequestTerms import com.synonym.paykit.PrivateJsonObject +import com.synonym.paykit.PubkyIdentityCapability import kotlinx.coroutines.test.StandardTestDispatcher import org.junit.Before import org.junit.Test @@ -70,7 +71,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(store.hasPendingProofs()).thenReturn(true) whenever( paykitSdkService.identityStatus() - ).thenReturn(IdentityStatus(LOCAL_IDENTITY, com.synonym.paykit.PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.processPendingPrivateMessages()).thenReturn(emptyList()) whenever(onchainPaymentLookup.existingTransactionIds(any(), any(), any())).thenReturn(emptySet()) whenever(store.load()).thenAnswer { diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 9549766b94..2cf07a26d2 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -135,12 +135,12 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { val record = paymentRequestRecord(role = PaymentRequestLocalRole.PAYEE).let { it.copy( proposalAppId = "marketplace", - terms = it.terms!!.copy( + terms = requireNotNull(it.terms).copy( acceptedPaymentEndpointIdentifiers = listOf(MethodId.P2wpkh.rawValue), paymentEndpoints = mapOf( - MethodId.P2wpkh.rawValue to PaykitReceivedPaymentContactsTest.payload(address) + MethodId.P2wpkh.rawValue to PaykitReceivedPaymentContactsTest.payload(address), ), - ) + ), ) } whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) @@ -151,7 +151,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { } assertEquals( setOf(PubkyPublicKeyFormat.normalized(COUNTERPARTY)), - sut.receivedPaymentContacts.contactsForAddresses(listOf(address)) + sut.receivedPaymentContacts.contactsForAddresses(listOf(address)), ) assertTrue(sut.pendingRequests.value.isEmpty()) assertTrue(sut.paymentRequestHistory.value.isEmpty()) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitReceivedPaymentContactsTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitReceivedPaymentContactsTest.kt index ecf380e884..2658ab80e3 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitReceivedPaymentContactsTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitReceivedPaymentContactsTest.kt @@ -22,6 +22,70 @@ import kotlin.test.assertEquals import kotlin.test.assertTrue class PaykitReceivedPaymentContactsTest { + companion object { + /** Synthetic payer identity shared by request fixtures. */ + const val BUYER = "pubky7don8zi885feihpjsyx7t53srod6z1n4xjiyaaxucpqarm6sh85o" + + /** Second payer identity for ambiguous attribution checks. */ + const val OTHER_BUYER = "pubkya3mduedw686dysw8ndr5c1dyry5h8k6i8hzbbmx9gf9k43zq4s9o" + + /** Valid regtest receiving address used by request fixtures. */ + const val ADDRESS = "bcrt1qfn50lqawrce0evh66qrnlt8j447lwmeyqp5gmd" + + /** Distinct regtest address for unrelated-output checks. */ + const val OTHER_ADDRESS = "bcrt1qpsps9chsjnnd3veems9phzlvw42em682rsj8hh" + + /** Mainnet address sentinel accepted by the mocked decoder. */ + const val MAINNET_ADDRESS = "bc1qexample" + + /** Testnet address sentinel accepted by the mocked decoder. */ + const val TESTNET_ADDRESS = "tb1qexample" + + /** Network-ambiguous prefixes reported as testnet by the mocked decoder. */ + val LEGACY_ADDRESSES = listOf("mlegacy", "nlegacy", "2legacy") + + /** Regtest on-chain endpoint identifier for address fixtures. */ + const val METHOD = "btc-regtest-p2wpkh" + + /** Endpoint identifier for the invoice fixtures. */ + const val BOLT11_METHOD = "btc-lightning-bolt11" + + /** Regtest invoice sentinel accepted by the test invoice parser. */ + const val INVOICE = "lnbcrt1example" + + /** Mainnet invoice sentinel for wrong-network checks. */ + const val MAINNET_INVOICE = "lnbc1example" + + /** Payment hash returned by the test invoice parser. */ + val HASH = "ab".repeat(32) + + fun payload(value: String) = "{\"value\":\"$value\"}" + + @Suppress("LongParameterList") + fun receivedRequest( + counterparty: String = BUYER, + role: PaymentRequestLocalRole? = PaymentRequestLocalRole.PAYEE, + state: PaymentRequestLifecycleState = PaymentRequestLifecycleState.PROOF_SUBMITTED, + invalidReason: String? = null, + asset: String = "btc", + endpoints: Map? = mapOf(METHOD to payload(ADDRESS)), + accepted: List = listOf(METHOD, BOLT11_METHOD), + terms: PaymentRequestTerms? = PaymentRequestTerms( + amount = PaymentRequestAmount("0.00015", asset), paymentReference = mock(), + proposalExpiresAt = null, recurrence = null, acceptedPaymentEndpointIdentifiers = accepted, + paymentEndpoints = endpoints, requiredAppId = "marketplace", conversion = null, + paymentDeadline = null, metadata = mock(), + ), + ): PaymentRequestRecord = mock { + on { this.counterparty }.thenReturn(counterparty) + on { this.localRole }.thenReturn(role) + on { this.state }.thenReturn(state) + on { this.invalidReason }.thenReturn(invalidReason) + on { this.terms }.thenReturn(terms) + on { proposalAppId }.thenReturn("marketplace") + } + } + @Test fun `shared app immutable address attributes received payment`() = withDecoder { val contacts = index(receivedRequest()) @@ -69,7 +133,7 @@ class PaykitReceivedPaymentContactsTest { ) assertTrue( index(*records.toTypedArray()) - .contactsForAddresses(listOf(ADDRESS, MAINNET_ADDRESS, TESTNET_ADDRESS)).isEmpty() + .contactsForAddresses(listOf(ADDRESS, MAINNET_ADDRESS, TESTNET_ADDRESS)).isEmpty(), ) } @@ -78,7 +142,7 @@ class PaykitReceivedPaymentContactsTest { val identifier = "btc-signet-p2wpkh" val record = receivedRequest( accepted = listOf(identifier), - endpoints = mapOf(identifier to payload(TESTNET_ADDRESS)) + endpoints = mapOf(identifier to payload(TESTNET_ADDRESS)), ) val contacts = PaykitReceivedPaymentContacts.from(listOf(record), Network.SIGNET) assertEquals(setOf(BUYER), contacts.contactsForAddresses(listOf(TESTNET_ADDRESS))) @@ -91,7 +155,7 @@ class PaykitReceivedPaymentContactsTest { val identifier = if (address.startsWith("2")) "btc-regtest-p2sh" else "btc-regtest-p2pkh" val record = receivedRequest( accepted = listOf(identifier), - endpoints = mapOf(identifier to payload(address)) + endpoints = mapOf(identifier to payload(address)), ) assertEquals(setOf(BUYER), index(record).contactsForAddresses(listOf(address))) } @@ -180,45 +244,4 @@ class PaykitReceivedPaymentContactsTest { block() } } - - companion object { - const val BUYER = "pubky7don8zi885feihpjsyx7t53srod6z1n4xjiyaaxucpqarm6sh85o" - const val OTHER_BUYER = "pubkya3mduedw686dysw8ndr5c1dyry5h8k6i8hzbbmx9gf9k43zq4s9o" - const val ADDRESS = "bcrt1qfn50lqawrce0evh66qrnlt8j447lwmeyqp5gmd" - const val OTHER_ADDRESS = "bcrt1qpsps9chsjnnd3veems9phzlvw42em682rsj8hh" - const val MAINNET_ADDRESS = "bc1qexample" - const val TESTNET_ADDRESS = "tb1qexample" - val LEGACY_ADDRESSES = listOf("mlegacy", "nlegacy", "2legacy") - const val METHOD = "btc-regtest-p2wpkh" - const val BOLT11_METHOD = "btc-lightning-bolt11" - const val INVOICE = "lnbcrt1example" - const val MAINNET_INVOICE = "lnbc1example" - val HASH = "ab".repeat(32) - - fun payload(value: String) = "{\"value\":\"$value\"}" - - @Suppress("LongParameterList") - fun receivedRequest( - counterparty: String = BUYER, - role: PaymentRequestLocalRole? = PaymentRequestLocalRole.PAYEE, - state: PaymentRequestLifecycleState = PaymentRequestLifecycleState.PROOF_SUBMITTED, - invalidReason: String? = null, - asset: String = "btc", - endpoints: Map? = mapOf(METHOD to payload(ADDRESS)), - accepted: List = listOf(METHOD, BOLT11_METHOD), - terms: PaymentRequestTerms? = PaymentRequestTerms( - amount = PaymentRequestAmount("0.00015", asset), paymentReference = mock(), - proposalExpiresAt = null, recurrence = null, acceptedPaymentEndpointIdentifiers = accepted, - paymentEndpoints = endpoints, requiredAppId = "marketplace", conversion = null, - paymentDeadline = null, metadata = mock(), - ), - ): PaymentRequestRecord = mock { - on { this.counterparty }.thenReturn(counterparty) - on { this.localRole }.thenReturn(role) - on { this.state }.thenReturn(state) - on { this.invalidReason }.thenReturn(invalidReason) - on { this.terms }.thenReturn(terms) - on { proposalAppId }.thenReturn("marketplace") - } - } } diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 4e75d489c9..ea185f8e51 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -1242,16 +1242,16 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `bound requests keep their own addresses after contact list updates without consuming the list`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) sut.consumePrivatePaymentList(CONTACT_KEY, PrivatePaykitPaymentContext(emptyMap(), 7uL)).getOrThrow() - whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, 7uL) - }.thenReturn(resolution(resolvedEndpoint(MethodId.P2wpkh, "latest-address"), version = 8uL)) + whenever( + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, 7uL), + ).thenReturn(resolution(resolvedEndpoint(MethodId.P2wpkh, "latest-address"), version = 8uL)) whenever(coreService.isAddressUsed(any())).thenReturn(false) sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() val cachedEndpoints = cacheData.value.contacts.getValue(CONTACT_KEY).remoteEndpoints val addresses = mapOf("invoice-a" to PRIVATE_ADDRESS, "invoice-b" to OTHER_PRIVATE_ADDRESS) - whenever { - paykitSdkService.prepareAndResolvePrivatePaymentRequest(eq(CONTACT_KEY), any(), eq(7uL)) - }.thenAnswer { + whenever( + paykitSdkService.prepareAndResolvePrivatePaymentRequest(eq(CONTACT_KEY), any(), eq(7uL)), + ).thenAnswer { resolution(resolvedEndpoint(MethodId.P2wpkh, addresses.getValue(it.getArgument(1))), version = null) } diff --git a/app/src/test/java/to/bitkit/services/PaykitKeyGenerationTest.kt b/app/src/test/java/to/bitkit/services/PaykitKeyGenerationTest.kt new file mode 100644 index 0000000000..6b6c51f9bf --- /dev/null +++ b/app/src/test/java/to/bitkit/services/PaykitKeyGenerationTest.kt @@ -0,0 +1,141 @@ +package to.bitkit.services + +import com.synonym.paykit.IdentityStatus +import com.synonym.paykit.PaykitAppRegistry +import com.synonym.paykit.PaykitIdentitySecretKey +import com.synonym.paykit.PaykitSdk +import com.synonym.paykit.PubkyIdentityCapability +import com.synonym.paykit.PubkyLocalSecretKey +import com.synonym.paykit.PubkySessionAccess +import com.synonym.paykit.pubkyPublicKeyFromSecret +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.mockito.Mockito.mockConstruction +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.any +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.data.keychain.Keychain +import to.bitkit.ext.toHex +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertSame + +class PaykitKeyGenerationTest { + companion object { + /** Fixture identity for generation-scoped storage keys. */ + private const val RING_PUBKY = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + } + + @Test + fun `authorization derives the registry generation and persists its identity scoped floor`() = runTest { + for (registryGeneration in listOf(null, 7uL)) { + val generation = registryGeneration ?: 1uL + val registry = registryGeneration?.let { + mock { on { keyGeneration }.thenReturn(generation) } + } + val sdk = mock() + whenever(sdk.paykitAppRegistry(RING_PUBKY)).thenReturn(registry) + val keychain = mock() + val storageKey = "${Keychain.Key.PAYKIT_KEY_GENERATION.name}:$RING_PUBKY" + whenever(keychain.loadString(storageKey)).thenReturn(null, generation.toString()) + val key = mock() + val bytes = ByteArray(32) { 1 } + val service = PaykitSdkService(mock(), keychain, mock()) { sdk } + + mockStatic(Class.forName("com.synonym.paykit.Paykit_androidKt")).use { native -> + native.`when` { pubkyPublicKeyFromSecret(any()) }.thenReturn(RING_PUBKY) + mockConstruction(PubkyLocalSecretKey::class.java) { root, context -> + assertContentEquals(bytes, context.arguments().single() as ByteArray) + whenever(root.derivePaykitIdentitySecretKey(generation)).thenReturn(key) + }.use { roots -> + repeat(2) { + assertSame(key, service.paykitKeyForAuthorization(bytes.toHex())) + } + assertEquals(2, roots.constructed().size) + roots.constructed().forEach { verify(it).derivePaykitIdentitySecretKey(generation) } + verify(keychain).upsertString(storageKey, generation.toString()) + verify(sdk, times(2)).paykitAppRegistry(RING_PUBKY) + } + } + } + } + + @Test + fun `stored root refresh rotates the session key and rejects registry rollback`() = runTest { + val initialRegistry = mock { on { keyGeneration }.thenReturn(2uL) } + val rotatedRegistry = mock { on { keyGeneration }.thenReturn(3uL) } + val sdk = mock() + whenever(sdk.paykitAppRegistry(RING_PUBKY)) + .thenReturn(initialRegistry, rotatedRegistry, initialRegistry, null) + whenever(sdk.identityStatus()) + .thenReturn(IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + val keychain = mock() + val storageKey = "${Keychain.Key.PAYKIT_KEY_GENERATION.name}:$RING_PUBKY" + whenever(keychain.loadString(storageKey)).thenReturn("2", "2", "3", "3") + val root = mock() + val initialKey = mock() + val rotatedKey = mock() + whenever(root.derivePaykitIdentitySecretKey(2uL)).thenReturn(initialKey) + whenever(root.derivePaykitIdentitySecretKey(3uL)).thenReturn(rotatedKey) + + mockStatic(Class.forName("com.synonym.paykit.Paykit_androidKt")).use { native -> + native.`when` { pubkyPublicKeyFromSecret(root) }.thenReturn(RING_PUBKY) + mockConstruction(PaykitSdkSessionProvider::class.java) { provider, _ -> + whenever(provider.loadLocalSecretKey()).thenReturn(root) + }.use { providers -> + val service = PaykitSdkService(mock(), keychain, mock()) { sdk } + val provider = providers.constructed().single() + repeat(2) { assertEquals(RING_PUBKY, service.currentPublicKey()) } + repeat(2) { + val error = assertFailsWith { service.currentPublicKey() } + assertEquals("The Paykit App Registry has an older key generation", error.message) + } + + inOrder(provider, keychain, root) { + verify(root).derivePaykitIdentitySecretKey(2uL) + verify(provider).setPaykitIdentitySecretKey(initialKey) + verify(keychain).upsertString(storageKey, "3") + verify(root).derivePaykitIdentitySecretKey(3uL) + verify(provider).setPaykitIdentitySecretKey(rotatedKey) + } + verify(root, times(2)).derivePaykitIdentitySecretKey(any()) + verify(keychain).upsertString(any(), any()) + verify(sdk, times(2)).identityStatus() + verify(provider, times(2)).setPaykitIdentitySecretKey(any()) + } + } + } + + @Test + fun `session key rotation rebuilds cached session access with the refreshed key`() { + val keychain = mock() + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("saved-session") + val initialKey = mock { on { keyGeneration() }.thenReturn(2uL) } + val rotatedKey = mock { on { keyGeneration() }.thenReturn(3uL) } + val initialAccess = mock() + whenever(initialAccess.exportSessionSecret()).thenReturn("saved-session") + whenever(initialAccess.exportPaykitIdentitySecretKey()).thenReturn(initialKey) + val provider = PaykitSdkSessionProvider(keychain, mock()) + provider.setLiveSessionAccess(initialAccess) + provider.setPaykitIdentitySecretKey(initialKey) + assertSame(initialAccess, provider.loadSessionAccess()) + + mockConstruction(PubkySessionAccess::class.java) { access, context -> + assertEquals(BitkitPaykitSdkConfig.clientId, context.arguments()[0]) + assertEquals("saved-session", context.arguments()[1]) + assertSame(rotatedKey, context.arguments()[3]) + whenever(access.exportSessionSecret()).thenReturn("saved-session") + }.use { sessions -> + provider.setPaykitIdentitySecretKey(rotatedKey) + val refreshedAccess = provider.loadSessionAccess() + assertSame(sessions.constructed().single(), refreshedAccess) + assertSame(refreshedAccess, provider.loadSessionAccess()) + assertEquals(1, sessions.constructed().size) + } + } +} diff --git a/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCaseTest.kt index 7470d823cb..86a1abf911 100644 --- a/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCaseTest.kt @@ -45,9 +45,8 @@ class RefreshContactPaykitLinkUseCaseTest : BaseUnitTest() { @Test fun `refreshes contact endpoints before starting the link burst`() = test { - whenever { - privatePaykitRepo.refreshSavedContactEndpoints(contactKeys.last(), contactKeys) - }.thenReturn(Result.success(Unit)) + whenever(privatePaykitRepo.refreshSavedContactEndpoints(contactKeys.last(), contactKeys)) + .thenReturn(Result.success(Unit)) val result = sut(contactKeys.last()) @@ -61,9 +60,8 @@ class RefreshContactPaykitLinkUseCaseTest : BaseUnitTest() { @Test fun `stops when endpoint refresh fails`() = test { val error = IllegalStateException("Endpoint refresh failed") - whenever { - privatePaykitRepo.refreshSavedContactEndpoints(contactKeys.last(), contactKeys) - }.thenReturn(Result.failure(error)) + whenever(privatePaykitRepo.refreshSavedContactEndpoints(contactKeys.last(), contactKeys)) + .thenReturn(Result.failure(error)) val result = sut(contactKeys.last()) From 925256bffab5de462f39fe4d6a87835cf7d67025 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 1 Oct 2026 07:02:19 -0500 Subject: [PATCH 40/51] fix: guard paykit execution and cache contact backfills --- .../java/to/bitkit/data/keychain/Keychain.kt | 1 + .../PaykitPaymentRequestPresentationStore.kt | 23 ++ .../repositories/PaykitPaymentRequestRepo.kt | 61 ++++- .../PaykitReceivedPaymentContacts.kt | 3 +- .../PrivatePaykitAddressReservationRepo.kt | 10 +- .../PrivatePaykitContactResolver.kt | 6 + .../java/to/bitkit/services/CoreService.kt | 88 +++++-- ...ykitPaymentRequestPresentationStoreTest.kt | 44 ++++ ...aykitPaymentRequestRepoSubscriptionTest.kt | 30 +++ .../PaykitPaymentRequestRepoTest.kt | 221 +++++++++++++++++- .../PaykitReceivedPaymentContactsTest.kt | 11 + ...PrivatePaykitAddressReservationRepoTest.kt | 26 ++- .../ActivityServicePaykitContactsTest.kt | 119 +++++++++- .../viewmodels/AppViewModelSendFlowTest.kt | 5 + changelog.d/next/1401.changed.md | 2 +- journeys/payment-requests/README.md | 7 + .../accepted-device-ownership.xml | 21 ++ 17 files changed, 650 insertions(+), 28 deletions(-) create mode 100644 journeys/payment-requests/accepted-device-ownership.xml diff --git a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt index 481fa0af58..1a8df74c5d 100644 --- a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt +++ b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt @@ -236,6 +236,7 @@ class Keychain @Inject constructor( PAYKIT_RECOVERY_BACKUP, PAYKIT_PENDING_BACKUP_RESTORE, PAYKIT_PENDING_PAYMENT_PROOFS, + PAYKIT_ACCEPTED_PAYMENT_REQUESTS, PAYKIT_PRESENTED_PAYMENT_REQUESTS, PUBKY_SECRET_KEY, SHARED_PUBKY_SOURCE, diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt index 8ffba9fe87..abc97b191d 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt @@ -31,6 +31,7 @@ class PaykitPaymentRequestPresentationStore @Inject constructor( ) { companion object { private val KEY = Keychain.Key.PAYKIT_PRESENTED_PAYMENT_REQUESTS.name + private val ACCEPTED_KEY = Keychain.Key.PAYKIT_ACCEPTED_PAYMENT_REQUESTS.name } private val mutex = Mutex() @@ -73,6 +74,28 @@ class PaykitPaymentRequestPresentationStore @Inject constructor( } } + /** Local execution ownership is never exported or imported by wallet backups. */ + fun loadAcceptedOneTimeIds(identity: String): Set { + val normalizedIdentity = PubkyPublicKeyFormat.normalized(identity) ?: return emptySet() + return loadAcceptedOneTimeIdsByIdentity()[normalizedIdentity].orEmpty() + } + + suspend fun addAcceptedOneTimeId(identity: String, id: PaykitPaymentRequestId): Set = + mutex.withLock { + val normalizedIdentity = requireNotNull(PubkyPublicKeyFormat.normalized(identity)) + val current = loadAcceptedOneTimeIdsByIdentity() + val ids = current[normalizedIdentity].orEmpty() + id + val state = current + (normalizedIdentity to ids) + keychain.upsertString(ACCEPTED_KEY, Json.encodeToString(state)) + ids + } + + private fun loadAcceptedOneTimeIdsByIdentity(): Map> { + val value = keychain.loadString(ACCEPTED_KEY) ?: return emptyMap() + return runCatching { Json.decodeFromString>>(value) } + .getOrElse { throw PaykitPaymentStateUnreadableError(ACCEPTED_KEY, it) } + } + fun loadSubscriptionState(identity: String): PaykitSubscriptionPresentationState { val normalizedIdentity = PubkyPublicKeyFormat.normalized(identity) ?: return PaykitSubscriptionPresentationState() diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index eb81148f86..b786220988 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -296,6 +296,9 @@ class PaykitPaymentRequestRepo @Inject constructor( get() = receivedContacts?.takeIf { isCurrentState(it.generation, it.identity) }?.contacts ?: PaykitReceivedPaymentContacts.Empty + internal val receivedPaymentContactsGeneration: Long + get() = stateGeneration.get() + private data class ReceivedContactsSnapshot( val generation: Long, val identity: String, @@ -305,6 +308,12 @@ class PaykitPaymentRequestRepo @Inject constructor( @Volatile private var presentedRequestIds = emptySet() + @Volatile + private var acceptedOneTimeRequestIds = emptySet() + + @Volatile + private var activatedGeneration = -1L + @Volatile private var subscriptionAcceptedAt = emptyMap() @@ -319,10 +328,17 @@ class PaykitPaymentRequestRepo @Inject constructor( if (!PubkyPublicKeyFormat.matches(activeIdentity, normalizedIdentity)) { stateGeneration.incrementAndGet() } + val generation = stateGeneration.get() operationMutex.withLock { if (PubkyPublicKeyFormat.matches(activeIdentity, normalizedIdentity)) return@withLock clearStateLocked() activeIdentity = null + acceptedOneTimeRequestIds = emptySet() + acceptedOneTimeRequestIds = runSuspendCatching { + presentationStore.loadAcceptedOneTimeIds(normalizedIdentity) + } + .onFailure { Logger.error("Failed to restore accepted Paykit payment requests", it, context = TAG) } + .getOrElse { return@withLock } presentedRequestIds = runSuspendCatching { presentationStore.load(normalizedIdentity) } .onFailure { Logger.error("Failed to restore surfaced Paykit payment requests", it, context = TAG) } .getOrDefault(emptySet()) @@ -333,6 +349,7 @@ class PaykitPaymentRequestRepo @Inject constructor( presentedSubscriptionProposalIds = subscriptionState.presentedProposalIds dismissedSubscriptionPaymentIds = subscriptionState.dismissedPaymentIds activeIdentity = normalizedIdentity + activatedGeneration = generation } } @@ -771,8 +788,20 @@ class PaykitPaymentRequestRepo @Inject constructor( return PaykitPaymentRequestCreation(request, creatorIdentity, wasPublishedToActiveState) } - suspend fun ensurePaymentAllowed(request: PaykitPaymentRequest): Result = withContext(ioDispatcher) { + suspend fun ensurePaymentAllowed(request: PaykitPaymentRequest): Result = + ensurePaymentAllowed(request, forExecution = true) + + private suspend fun ensurePaymentAllowed( + request: PaykitPaymentRequest, + forExecution: Boolean, + ): Result = withContext(ioDispatcher) { runSuspendCatching { + val identity = activeIdentity ?: throw PaykitPaymentRequestError.RequestUnavailable + val generation = stateGeneration.get() + if (!isLocallyPayable(request)) throw PaykitPaymentRequestError.RequestUnavailable + if (forExecution && !hasCurrentExecutionContext(request)) { + throw PaykitPaymentRequestError.RequestUnavailable + } if ( paykitSdkService.linkedPeers().any { it.state == LinkedPeerState.BLOCKED && @@ -781,11 +810,13 @@ class PaykitPaymentRequestRepo @Inject constructor( ) { throw PaykitPaymentRequestError.RequestUnavailable } + if (!isCurrentState(generation, identity)) throw PaykitPaymentRequestError.RequestUnavailable } } suspend fun claimForPayment(request: PaykitPaymentRequest): Result = withContext(ioDispatcher) { runSuspendCatching { + ensurePaymentAllowed(request, forExecution = false).getOrThrow() paykitSdkService.claimPaymentRequestForExecution(request.counterparty, request.paymentRequestId) Unit } @@ -795,18 +826,23 @@ class PaykitPaymentRequestRepo @Inject constructor( runSuspendCatching { if (!request.requiresAcceptance) { operationMutex.withLock { - ensurePaymentAllowed(request).getOrThrow() + ensurePaymentAllowed(request, forExecution = false).getOrThrow() if (_pendingRequests.value.none { it.id == request.id }) { throw PaykitPaymentRequestError.RequestUnavailable } } } else { updateRequest(request, PaymentRequestLifecycleState.ACCEPTED) { - ensurePaymentAllowed(it).getOrThrow() + val identity = activeIdentity ?: throw PaykitPaymentRequestError.RequestUnavailable + val generation = stateGeneration.get() + ensurePaymentAllowed(it, forExecution = false).getOrThrow() paykitSdkService.acceptPaymentRequest( counterparty = it.counterparty, paymentRequestId = it.paymentRequestId, ) + val acceptedIds = presentationStore.addAcceptedOneTimeId(identity, it.id) + if (!isCurrentState(generation, identity)) throw PaykitPaymentRequestError.RequestUnavailable + acceptedOneTimeRequestIds = acceptedIds }.getOrThrow() } }.onFailure { @@ -904,6 +940,7 @@ class PaykitPaymentRequestRepo @Inject constructor( clearStateLocked() activeIdentity = null presentedRequestIds = emptySet() + acceptedOneTimeRequestIds = emptySet() presentedSubscriptionProposalIds = emptySet() dismissedSubscriptionPaymentIds = emptySet() } @@ -1011,7 +1048,9 @@ class PaykitPaymentRequestRepo @Inject constructor( null } } - }.filter { it.id !in locallyCompletedRequestIds && it.id !in locallyInFlightRequestIds } + }.filter { + isLocallyPayable(it) && it.id !in locallyCompletedRequestIds && it.id !in locallyInFlightRequestIds + } val incoming = (dueRequests + oneTimeIncoming).sortedBy { it.createdAt } val oneTimeHistory = records.mapNotNull { it.toPaykitPaymentRequestHistory(now) }.map { request -> val proofKind = locallyCompletedProofKinds[request.id] ?: return@map request @@ -1139,6 +1178,20 @@ class PaykitPaymentRequestRepo @Inject constructor( private suspend fun isAvailable(): Boolean = activeIdentity != null && PaykitFeatureFlags.isUiEnabled(settingsStore.isPaykitEnabled.first()) + private fun isLocallyPayable(request: PaykitPaymentRequest): Boolean = + request.billingPeriod != null || request.lifecycleState != PaymentRequestLifecycleState.ACCEPTED || + hasLocalAcceptance(request) + + private fun hasLocalAcceptance(request: PaykitPaymentRequest): Boolean = + activatedGeneration == stateGeneration.get() && request.id in acceptedOneTimeRequestIds + + private fun hasCurrentExecutionContext(request: PaykitPaymentRequest): Boolean { + if (request.billingPeriod == null) return hasLocalAcceptance(request) + return activatedGeneration == stateGeneration.get() && _subscriptions.value.any { + it.isPayer && it.lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING && request.belongsTo(it) + } + } + private suspend fun updateRequest( request: PaykitPaymentRequest, resultingState: PaymentRequestLifecycleState, diff --git a/app/src/main/java/to/bitkit/repositories/PaykitReceivedPaymentContacts.kt b/app/src/main/java/to/bitkit/repositories/PaykitReceivedPaymentContacts.kt index 11584865a2..4e5a7f5f0e 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitReceivedPaymentContacts.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitReceivedPaymentContacts.kt @@ -10,7 +10,8 @@ import org.lightningdevkit.ldknode.Network import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.toLdkNetwork -internal class PaykitReceivedPaymentContacts private constructor( +@ConsistentCopyVisibility +internal data class PaykitReceivedPaymentContacts private constructor( private val addresses: Map>, private val paymentHashes: Map>, ) { diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt index e57115f1fc..4fde83adc5 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt @@ -47,7 +47,15 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( } private val mutex = Mutex() + + @Volatile + internal var attributionVersion = 0L + private set private var ledger: PrivatePaykitReservationData? = null + set(value) { + if (field != value) attributionVersion++ + field = value + } private val _backupStateVersion = MutableStateFlow(0L) val backupStateVersion: StateFlow = _backupStateVersion.asStateFlow() @@ -153,7 +161,7 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( assignments.firstOrNull { (_, assignment) -> assignment.addressType == addressType && - (assignment.address == address || resolvedAddress(assignment).getOrNull() == address) + (assignment.address == address || resolvedAddress(assignment).getOrThrow() == address) }?.first } diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt index 7d83815b5a..47c01cdf88 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt @@ -20,6 +20,12 @@ class PrivatePaykitContactResolver @Inject constructor( internal val receivedPaymentContacts: PaykitReceivedPaymentContacts get() = paymentRequestRepo.get().receivedPaymentContacts + internal val receivedPaymentContactsGeneration: Long + get() = paymentRequestRepo.get().receivedPaymentContactsGeneration + + internal val reservationVersion: Long + get() = addressReservationRepo.get().attributionVersion + suspend fun contactPublicKeyForPrivateInvoicePaymentHash(paymentHash: String): String? = withContext(ioDispatcher) { if (paymentHash.isBlank()) return@withContext null diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 1ff13f5362..79afc00afb 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -211,6 +211,7 @@ class CoreService @Inject constructor( suspend fun wipeData(): Result = ServiceQueue.CORE.background { runCatching { + activity.invalidatePaykitContactBackfill() val result = wipeAllDatabases() Logger.info("Core DB wipe: '$result'", context = TAG) }.onFailure { @@ -372,9 +373,23 @@ class ActivityService( private val privatePaykitContactResolver: Provider, ) { private val defaultWalletId = WalletScope.default + private var paykitActivityRevision = 0L + private var lastPaykitContactBackfill: PaykitContactBackfillState? = null + + private data class PaykitContactBackfillState( + val contacts: PaykitReceivedPaymentContacts, + val generation: Long, + val activityRevision: Long, + val reservationVersion: Long, + ) + + internal suspend fun invalidatePaykitContactBackfill() = ServiceQueue.CORE.background { + paykitActivityRevision++ + } suspend fun removeAll() { ServiceQueue.CORE.background { + invalidatePaykitContactBackfill() // Get all activities and delete them one by one val activities = getActivities( walletId = null, @@ -397,18 +412,22 @@ class ActivityService( } suspend fun deleteByWalletId(walletId: String): UInt = ServiceQueue.CORE.background { + invalidatePaykitContactBackfill() deleteActivitiesByWalletId(walletId) } suspend fun insert(activity: Activity) = ServiceQueue.CORE.background { + invalidatePaykitContactBackfill() insertActivity(activity) } suspend fun upsert(activity: Activity) = ServiceQueue.CORE.background { + invalidatePaykitContactBackfill() upsertActivity(activity) } suspend fun upsertList(activities: List) = ServiceQueue.CORE.background { + invalidatePaykitContactBackfill() upsertActivities(activities) } @@ -428,6 +447,7 @@ class ActivityService( transactionDetails: List, transferChannelIdsByFundingTxId: Map, ): HwSnapshotResult = ServiceQueue.CORE.background { + invalidatePaykitContactBackfill() val existingActivities = getActivities( walletId = walletId, filter = ActivityFilter.ONCHAIN, @@ -565,13 +585,24 @@ class ActivityService( } suspend fun update(id: String, activity: Activity) = ServiceQueue.CORE.background { + invalidatePaykitContactBackfill() updateActivity(activityId = id, activity = activity) } suspend fun backfillPaykitContacts(): Boolean = ServiceQueue.CORE.background { val resolver = privatePaykitContactResolver.get() val contacts = resolver.receivedPaymentContacts - if (contacts === PaykitReceivedPaymentContacts.Empty) return@background false + if (contacts === PaykitReceivedPaymentContacts.Empty) { + lastPaykitContactBackfill = null + return@background false + } + val snapshot = PaykitContactBackfillState( + contacts = contacts, + generation = resolver.receivedPaymentContactsGeneration, + activityRevision = paykitActivityRevision, + reservationVersion = resolver.reservationVersion, + ) + if (snapshot == lastPaykitContactBackfill) return@background false val activities = getActivities( walletId = null, filter = ActivityFilter.ALL, @@ -584,13 +615,19 @@ class ActivityService( sortDirection = null, ) var changed = false + var complete = true for (activity in activities) { - if (resolver.receivedPaymentContacts !== contacts) break + if (!isCurrentPaykitContactBackfill(resolver, snapshot)) return@background changed val contact = when (activity) { is Activity.Lightning -> receivedPaykitContact(activity.v1, contacts) - is Activity.Onchain -> receivedPaykitContact(activity.v1) + is Activity.Onchain -> { + val (contact, hydrated) = receivedPaykitContact(activity.v1) + complete = complete && hydrated + contact + } } - if (contact != null && resolver.receivedPaymentContacts === contacts) { + if (!isCurrentPaykitContactBackfill(resolver, snapshot)) return@background changed + if (contact != null) { val updated = when (activity) { is Activity.Lightning -> Activity.Lightning(activity.v1.copy(contact = contact)) is Activity.Onchain -> Activity.Onchain(activity.v1.copy(contact = contact)) @@ -599,24 +636,38 @@ class ActivityService( changed = true } } + if (complete && isCurrentPaykitContactBackfill(resolver, snapshot)) { + lastPaykitContactBackfill = snapshot + } changed } + private fun isCurrentPaykitContactBackfill( + resolver: PrivatePaykitContactResolver, + snapshot: PaykitContactBackfillState, + ): Boolean = resolver.receivedPaymentContacts === snapshot.contacts && + resolver.receivedPaymentContactsGeneration == snapshot.generation && + paykitActivityRevision == snapshot.activityRevision && + resolver.reservationVersion == snapshot.reservationVersion + private fun receivedPaykitContact(row: LightningActivity, contacts: PaykitReceivedPaymentContacts): String? { if (row.contact != null || row.txType != PaymentType.RECEIVED || row.status == PaymentState.FAILED) return null return contacts.contactsForPaymentHash(row.id).singleOrNull() } - private suspend fun receivedPaykitContact(row: OnchainActivity): String? { - if (row.contact != null || row.txType != PaymentType.RECEIVED) return null - val details = getBitkitCoreTransactionDetails(walletId = row.walletId, txId = row.txId) ?: return null + private suspend fun receivedPaykitContact(row: OnchainActivity): Pair { + if (row.contact != null || row.txType != PaymentType.RECEIVED) return null to true + val details = getBitkitCoreTransactionDetails(walletId = row.walletId, txId = row.txId) + val addresses = details?.outputs?.mapNotNull { it.scriptpubkeyAddress }.orEmpty() + if (row.address !in addresses) return null to false return privatePaykitContactResolver.get().contactPublicKeyForPrivateOnchainAddresses( receivingAddress = row.address, - addresses = details.outputs.mapNotNull { it.scriptpubkeyAddress }, - ) + addresses = addresses, + ) to true } suspend fun delete(id: String, walletId: String = defaultWalletId): Boolean = ServiceQueue.CORE.background { + invalidatePaykitContactBackfill() deleteActivityById(walletId = walletId, activityId = id) } @@ -860,6 +911,7 @@ class ActivityService( ) }.withPendingMessage(pendingMessage = pendingMessage, description = kind.description) + if (existingActivity != Activity.Lightning(ln)) invalidatePaykitContactBackfill() if (getActivityById(walletId = defaultWalletId, activityId = payment.id) != null) { updateActivity(activityId = payment.id, activity = Activity.Lightning(ln)) } else { @@ -884,7 +936,7 @@ class ActivityService( as? Activity.Lightning ?: return@background val updated = existing.v1.withPendingMessage(pendingMessage = message, description = description) if (updated != existing.v1) { - updateActivity(activityId = paymentHash, activity = Activity.Lightning(updated)) + update(paymentHash, Activity.Lightning(updated)) } cacheStore.removePendingLightningMessage(paymentHash) } @@ -1317,6 +1369,7 @@ class ActivityService( return } + if (existingActivity != Activity.Onchain(onChain)) invalidatePaykitContactBackfill() if (existingActivity != null && existingActivity is Activity.Onchain) { val existingOnchain = existingActivity.v1 updateActivity(activityId = existingOnchain.id, activity = Activity.Onchain(onChain)) @@ -1413,7 +1466,7 @@ class ActivityService( } // Insert activity - insertActivity(activity) + insert(activity) // Add random tags val numTags = (0..3).random() @@ -1445,7 +1498,7 @@ class ActivityService( isTransfer = true, channelId = existing.channelId ?: channelId, ) - if (updated != existing) upsertActivity(Activity.Onchain(updated)) + if (updated != existing) upsert(Activity.Onchain(updated)) } Logger.debug("Activity already exists for txid $txid, skipping immediate creation", context = TAG) return@background @@ -1468,7 +1521,7 @@ class ActivityService( updatedAt = 0u, seenAt = now, ) - upsertActivity(Activity.Onchain(onchain)) + upsert(Activity.Onchain(onchain)) Logger.info("Created sent onchain activity for txid $txid from send result", context = TAG) }.onFailure { Logger.error("Failed to create sent onchain activity for txid $txid", it, context = TAG) @@ -1480,6 +1533,7 @@ class ActivityService( ServiceQueue.CORE.background { runCatching { val coreDetails = mapToCoreTransactionDetails(txid, details) + invalidatePaykitContactBackfill() upsertTransactionDetails(listOf(coreDetails)) val payments = lightningService.listPayments() ?: run { @@ -1511,6 +1565,7 @@ class ActivityService( ServiceQueue.CORE.background { runCatching { val coreDetails = mapToCoreTransactionDetails(txid, details) + invalidatePaykitContactBackfill() upsertTransactionDetails(listOf(coreDetails)) val payments = lightningService.listPayments() ?: run { @@ -1573,6 +1628,7 @@ class ActivityService( isBoosted = false, updatedAt = System.currentTimeMillis().toULong() / 1000u ) + paykitActivityRevision++ updateActivity(activityId = replacedActivity.id, activity = Activity.Onchain(updatedActivity)) Logger.info("Marked transaction $txid as replaced", context = TAG) } else { @@ -1633,7 +1689,7 @@ class ActivityService( contact = replacementActivity.contact ?: replacedActivity?.contact, updatedAt = System.currentTimeMillis().toULong() / 1000u, ) - updateActivity(activityId = replacementActivity.id, activity = Activity.Onchain(updatedActivity)) + update(replacementActivity.id, Activity.Onchain(updatedActivity)) if (replacedActivity != null) { copyTagsFromReplacedActivity(txid, conflictTxid, replacedActivity.id, replacementActivity.id) @@ -1677,7 +1733,7 @@ class ActivityService( updatedAt = System.currentTimeMillis().toULong() / 1000u ) - updateActivity(activityId = onchain.id, activity = Activity.Onchain(updatedActivity)) + update(onchain.id, Activity.Onchain(updatedActivity)) }.onFailure { e -> Logger.error("Error handling onchain transaction reorged for $txid", e, context = TAG) } @@ -1697,7 +1753,7 @@ class ActivityService( updatedAt = System.currentTimeMillis().toULong() / 1000u ) - updateActivity(activityId = onchain.id, activity = Activity.Onchain(updatedActivity)) + update(onchain.id, Activity.Onchain(updatedActivity)) }.onFailure { e -> Logger.error("Error handling onchain transaction evicted for $txid", e, context = TAG) } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt index d16a5ea6c6..4d454033df 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt @@ -92,6 +92,50 @@ class PaykitPaymentRequestPresentationStoreTest : BaseUnitTest() { assertEquals(2L, sut.backupStateVersion.value) } + @Test + fun `accepted one time ownership survives reopening but never enters wallet backup`() = test { + val keychain = mock() + val values = mutableMapOf() + whenever(keychain.loadString(any())).thenAnswer { values[it.getArgument(0)] } + whenever { keychain.upsertString(any(), any()) }.thenAnswer { + values[it.getArgument(0)] = it.getArgument(1) + Unit + } + val sut = PaykitPaymentRequestPresentationStore(keychain) + val requestId = PaykitPaymentRequestId("request", COUNTERPARTY) + val secondId = PaykitPaymentRequestId("second", COUNTERPARTY) + sut.addAcceptedOneTimeId(IDENTITY, requestId) + sut.addAcceptedOneTimeId(COUNTERPARTY, secondId) + sut.addAcceptedOneTimeId(IDENTITY, secondId) + sut.save(IDENTITY, setOf(requestId)) + + val reopened = PaykitPaymentRequestPresentationStore(keychain) + assertEquals(setOf(requestId, secondId), reopened.loadAcceptedOneTimeIds(IDENTITY)) + assertEquals(setOf(secondId), reopened.loadAcceptedOneTimeIds(COUNTERPARTY)) + assertEquals(emptyMap(), reopened.backupSnapshot()) + assertEquals(0L, sut.backupStateVersion.value) + reopened.restoreBackup(emptyMap()) + assertEquals(setOf(requestId, secondId), reopened.loadAcceptedOneTimeIds(IDENTITY)) + + values.clear() + reopened.restoreBackup(emptyMap()) + assertEquals(emptySet(), reopened.loadAcceptedOneTimeIds(IDENTITY)) + } + + @Test + fun `unreadable accepted one time ownership is preserved and fails closed`() = test { + val keychain = mock() + val acceptedKey = Keychain.Key.PAYKIT_ACCEPTED_PAYMENT_REQUESTS.name + whenever(keychain.loadString(acceptedKey)).thenReturn("not-json") + val sut = PaykitPaymentRequestPresentationStore(keychain) + + assertFailsWith { sut.loadAcceptedOneTimeIds(IDENTITY) } + assertFailsWith { + sut.addAcceptedOneTimeId(IDENTITY, PaykitPaymentRequestId("request", COUNTERPARTY)) + } + verify(keychain, never()).upsertString(any(), any()) + } + @Test fun `backup restore preserves precise acceptance billing boundaries`() = test { val keychain = mock() diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt index 98a383ac66..d555f9c048 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt @@ -150,6 +150,36 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat assertEquals(Instant.parse("2027-02-01T08:00:00Z"), request.billingPeriod?.endsAt) } + @Test + fun `recurring final authorization survives in flight filtering but rejects identity switches`() = test { + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( + listOf(paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING)), + ) + sut.refresh().getOrThrow() + val request = sut.pendingRequests.value.single() + sut.accept(request).getOrThrow() + sut.ensurePaymentAllowed(request).getOrThrow() + whenever(paymentProofStore.inFlightRequestIds(LOCAL_IDENTITY)).thenReturn(setOf(request.id)) + sut.refresh().getOrThrow() + assertTrue(sut.pendingRequests.value.isEmpty()) + sut.ensurePaymentAllowed(request).getOrThrow() + + val checking = CompletableDeferred() + val checked = CompletableDeferred() + whenever(paykitSdkService.linkedPeers()).doSuspendableAnswer { + checking.complete(Unit) + checked.await() + emptyList() + } + val authorization = async { sut.ensurePaymentAllowed(request) } + checking.await() + sut.activate(SECOND_IDENTITY) + checked.complete(Unit) + + assertTrue(authorization.await().isFailure) + assertTrue(sut.ensurePaymentAllowed(request).isFailure) + } + @Test fun `refresh keeps creator subscription without generating a payer payment`() = test { val metadataText = """ diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 2cf07a26d2..c1c72e3528 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -103,6 +103,10 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(settingsStore.isPaykitEnabled).thenReturn(flowOf(true)) whenever(settingsStore.data).thenReturn(flowOf(SettingsData(sharesPrivatePaykitEndpoints = true))) whenever(presentationStore.load(LOCAL_IDENTITY)).thenReturn(emptySet()) + whenever(presentationStore.loadAcceptedOneTimeIds(any())).thenReturn(emptySet()) + whenever(presentationStore.addAcceptedOneTimeId(any(), any())).thenAnswer { + setOf(it.getArgument(1)) + } whenever( presentationStore.loadSubscriptionState(any()) ).thenReturn(PaykitSubscriptionPresentationState()) @@ -181,6 +185,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `blocking an already presented accepted request prevents payment`() = test { + restoreAcceptedRequest() val record = paymentRequestRecord(state = PaymentRequestLifecycleState.ACCEPTED) whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) sut.refresh().getOrThrow() @@ -193,6 +198,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `accepted request checks blocking after waiting for synchronization`() = test { + restoreAcceptedRequest() val record = paymentRequestRecord(state = PaymentRequestLifecycleState.ACCEPTED) whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) sut.refresh().getOrThrow() @@ -419,7 +425,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.refresh().getOrThrow() - assertEquals(listOf("incoming", "accepted"), sut.pendingRequests.value.map { it.paymentRequestId }) + assertEquals(listOf("incoming"), sut.pendingRequests.value.map { it.paymentRequestId }) assertEquals( setOf( "incoming", "accepted", "rejected", "expired", "outgoing", "unsupported", @@ -537,6 +543,211 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(sut.pendingRequests.value.isEmpty()) assertEquals(PaymentRequestLifecycleState.ACCEPTED, sut.paymentRequestHistory.value.single().lifecycleState) verifyBlocking(paykitSdkService) { processPendingPrivateMessages() } + verify(presentationStore).addAcceptedOneTimeId( + LOCAL_IDENTITY, + PaykitPaymentRequestId(PAYMENT_REQUEST_ID, COUNTERPARTY), + ) + } + + @Test + fun `local acceptance survives refresh and reconnect without accepting twice`() = test { + val proposed = paymentRequestRecord() + val accepted = proposed.copy(state = PaymentRequestLifecycleState.ACCEPTED) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(proposed)) + whenever(paykitSdkService.acceptPaymentRequest(any(), any())).thenReturn(accepted) + sut.refresh().getOrThrow() + val request = sut.pendingRequests.value.single() + sut.accept(request).getOrThrow() + + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(accepted)) + sut.refresh().getOrThrow() + sut.accept(sut.pendingRequests.value.single()).getOrThrow() + whenever(presentationStore.loadAcceptedOneTimeIds(LOCAL_IDENTITY)).thenReturn(setOf(request.id)) + sut.clear() + sut.activate(LOCAL_IDENTITY) + sut.refresh().getOrThrow() + sut.accept(sut.pendingRequests.value.single()).getOrThrow() + verify(paykitSdkService, times(1)).acceptPaymentRequest(any(), any()) + sut.ensurePaymentAllowed(request).getOrThrow() + + sut.activate(SECOND_IDENTITY) + assertTrue(sut.ensurePaymentAllowed(request).isFailure) + } + + @Test + fun `final authorization rechecks identity after asynchronous peer lookup`() = test { + restoreAcceptedRequest() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(paymentRequestRecord())) + sut.refresh().getOrThrow() + val request = sut.pendingRequests.value.single() + val checking = CompletableDeferred() + val checked = CompletableDeferred() + whenever(paykitSdkService.linkedPeers()).doSuspendableAnswer { + checking.complete(Unit) + checked.await() + emptyList() + } + val authorization = async { sut.ensurePaymentAllowed(request) } + checking.await() + sut.activate(SECOND_IDENTITY) + checked.complete(Unit) + + assertTrue(authorization.await().isFailure) + } + + @Test + fun `queued identity switch invalidates ownership before acquiring the repository mutex`() = test { + restoreAcceptedRequest() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(paymentRequestRecord())) + sut.refresh().getOrThrow() + val request = sut.pendingRequests.value.single() + val refreshing = CompletableDeferred() + val refreshed = CompletableDeferred() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).doSuspendableAnswer { + refreshing.complete(Unit) + refreshed.await() + emptyList() + } + val refresh = async { sut.refresh() } + refreshing.await() + val activation = async { sut.activate(SECOND_IDENTITY) } + runCurrent() + + assertTrue(sut.ensurePaymentAllowed(request).isFailure) + refreshed.complete(Unit) + refresh.await() + activation.await() + } + + @Test + fun `unknown acceptance or failed persistence never grants local ownership`() = test { + val proposed = paymentRequestRecord() + val accepted = proposed.copy(state = PaymentRequestLifecycleState.ACCEPTED) + whenever(paykitSdkService.acceptPaymentRequest(any(), any())) + .thenThrow(IllegalStateException("unknown completion")).thenReturn(accepted) + whenever(presentationStore.addAcceptedOneTimeId(any(), any())).thenThrow(IllegalStateException("disk")) + for (sdkSucceeded in listOf(false, true)) { + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(proposed)) + sut.refresh().getOrThrow() + assertTrue(sut.accept(sut.pendingRequests.value.single()).isFailure) + if (!sdkSucceeded) verify(presentationStore, never()).addAcceptedOneTimeId(any(), any()) + + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(accepted)) + sut.refresh().getOrThrow() + assertTrue(sut.pendingRequests.value.isEmpty()) + assertTrue(sut.accept(sut.paymentRequestHistory.value.single()).isFailure) + assertTrue(sut.ensurePaymentAllowed(sut.paymentRequestHistory.value.single()).isFailure) + } + } + + @Test + fun `final authorization requires durable ownership regardless of snapshot lifecycle`() = test { + val proposed = paymentRequestRecord() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(proposed)) + whenever(paykitSdkService.acceptPaymentRequest(any(), any())) + .thenReturn(proposed.copy(state = PaymentRequestLifecycleState.ACCEPTED)) + val saving = CompletableDeferred() + val saved = CompletableDeferred() + whenever(presentationStore.addAcceptedOneTimeId(any(), any())).doSuspendableAnswer { + saving.complete(Unit) + saved.await() + setOf(it.getArgument(1)) + } + sut.refresh().getOrThrow() + val request = sut.pendingRequests.value.single() + val acceptedSnapshot = request.copy(lifecycleState = PaymentRequestLifecycleState.ACCEPTED) + val acceptance = async { sut.accept(request) } + saving.await() + assertFalse(acceptance.isCompleted) + assertTrue(sut.ensurePaymentAllowed(request).isFailure) + assertTrue(sut.ensurePaymentAllowed(acceptedSnapshot).isFailure) + + saved.complete(Unit) + acceptance.await().getOrThrow() + sut.ensurePaymentAllowed(request).getOrThrow() + sut.ensurePaymentAllowed(acceptedSnapshot).getOrThrow() + } + + @Test + fun `second install cannot accept stale proposal or resume first installs acceptance`() = test { + val otherStore = mock() + whenever(otherStore.loadSubscriptionState(any())).thenReturn(PaykitSubscriptionPresentationState()) + val other = PaykitPaymentRequestRepo( + testDispatcher, + paykitSdkService, + settingsStore, + otherStore, + diagnostics, + paymentProofStore, + paymentProofRepo, + subscriptionNotificationScheduler, + clock, + ) + other.activate(LOCAL_IDENTITY) + var record = paymentRequestRecord() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenAnswer { listOf(record) } + whenever(paykitSdkService.acceptPaymentRequest(any(), any())).thenAnswer { + check(record.state == PaymentRequestLifecycleState.PROPOSED) + record = record.copy(state = PaymentRequestLifecycleState.ACCEPTED) + record + } + sut.refresh().getOrThrow() + other.refresh().getOrThrow() + val stale = other.pendingRequests.value.single() + sut.accept(sut.pendingRequests.value.single()).getOrThrow() + + assertTrue(other.accept(stale).isFailure) + other.refresh().getOrThrow() + assertTrue(other.pendingRequests.value.isEmpty()) + assertTrue(other.automaticPendingRequests().isEmpty()) + val remoteAccepted = other.paymentRequestHistory.value.single() + assertNull(other.pendingRequest(remoteAccepted.id)) + assertTrue(other.accept(remoteAccepted).isFailure) + assertTrue(other.claimForPayment(remoteAccepted).isFailure) + assertTrue(other.ensurePaymentAllowed(remoteAccepted).isFailure) + verify(paykitSdkService, never()).claimPaymentRequestForExecution(any(), any()) + verify(otherStore, never()).addAcceptedOneTimeId(any(), any()) + sut.refresh().getOrThrow() + sut.accept(sut.pendingRequests.value.single()).getOrThrow() + other.clear() + } + + @Test + fun `identity switch during acceptance saves only the captured identity and prevents execution`() = test { + val proposed = paymentRequestRecord() + val accepting = CompletableDeferred() + val accepted = CompletableDeferred() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(proposed)) + whenever(paykitSdkService.acceptPaymentRequest(any(), any())).doSuspendableAnswer { + accepting.complete(Unit) + accepted.await() + proposed.copy(state = PaymentRequestLifecycleState.ACCEPTED) + } + sut.refresh().getOrThrow() + val request = sut.pendingRequests.value.single() + val acceptance = async { sut.accept(request) } + accepting.await() + val activation = async { sut.activate(SECOND_IDENTITY) } + runCurrent() + accepted.complete(Unit) + + assertTrue(acceptance.await().isFailure) + activation.await() + verify(presentationStore).addAcceptedOneTimeId(LOCAL_IDENTITY, request.id) + verify(presentationStore, never()).addAcceptedOneTimeId(eq(SECOND_IDENTITY), any()) + val snapshot = request.copy(lifecycleState = PaymentRequestLifecycleState.ACCEPTED) + assertTrue(sut.ensurePaymentAllowed(snapshot).isFailure) + } + + @Test + fun `unreadable accepted ownership prevents activation`() = test { + sut.clear() + whenever(presentationStore.loadAcceptedOneTimeIds(LOCAL_IDENTITY)) + .thenThrow(IllegalStateException("unreadable")) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(paymentRequestRecord())) + sut.activate(LOCAL_IDENTITY) + sut.refresh().getOrThrow() + assertTrue(sut.pendingRequests.value.isEmpty()) } @Test @@ -1207,6 +1418,14 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(!sut.isPending(request)) } + private suspend fun restoreAcceptedRequest() { + whenever(presentationStore.loadAcceptedOneTimeIds(LOCAL_IDENTITY)).thenReturn( + setOf(PaykitPaymentRequestId(PAYMENT_REQUEST_ID, COUNTERPARTY)), + ) + sut.clear() + sut.activate(LOCAL_IDENTITY) + } + @Suppress("LongParameterList") private fun paymentRequestRecord( id: String = PAYMENT_REQUEST_ID, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitReceivedPaymentContactsTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitReceivedPaymentContactsTest.kt index 2658ab80e3..61ce4bdab2 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitReceivedPaymentContactsTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitReceivedPaymentContactsTest.kt @@ -93,6 +93,17 @@ class PaykitReceivedPaymentContactsTest { assertTrue(contacts.contactsForAddresses(listOf(OTHER_ADDRESS)).isEmpty()) } + @Test + fun `contact snapshots compare by attribution values not record order or lifecycle`() = withDecoder { + val first = index(receivedRequest(), receivedRequest(counterparty = OTHER_BUYER)) + val refreshed = index( + receivedRequest(counterparty = OTHER_BUYER, state = PaymentRequestLifecycleState.ACCEPTED), + receivedRequest(state = PaymentRequestLifecycleState.CANCELED), + ) + assertEquals(first, refreshed) + assertEquals(first.hashCode(), refreshed.hashCode()) + } + @Test fun `terminal request states retain exact destination attribution`() = withDecoder { for (state in PaymentRequestLifecycleState.entries.filterNot { diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt index 478721d46d..a82342646d 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt @@ -19,8 +19,10 @@ import to.bitkit.services.AddressDerivationInfo import to.bitkit.services.CoreService import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNull +import kotlin.test.assertTrue class PrivatePaykitAddressReservationRepoTest : BaseUnitTest() { companion object { @@ -168,7 +170,7 @@ class PrivatePaykitAddressReservationRepoTest : BaseUnitTest() { } @Test - fun `clearContactAssignment removes private address attribution history`() = test { + fun `clearContactAssignment invalidates attribution even if persistence fails`() = test { reservationData.value = PrivatePaykitReservationData( contactAssignments = mapOf( CONTACT_KEY to PrivatePaykitStoredAssignmentData( @@ -188,11 +190,31 @@ class PrivatePaykitAddressReservationRepoTest : BaseUnitTest() { ), ) - sut.clearContactAssignment(CONTACT_KEY) + assertEquals(CONTACT_KEY, sut.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS)) + val version = sut.attributionVersion + whenever(reservationStore.update(any())).thenThrow(IllegalStateException("disk")) + assertFailsWith { sut.clearContactAssignment(CONTACT_KEY) } + assertTrue(sut.attributionVersion > version) assertNull(sut.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS)) } + @Test + fun `reservation derivation failure propagates and remains retryable`() = test { + reservationData.value = PrivatePaykitReservationData( + contactAssignments = mapOf( + CONTACT_KEY to PrivatePaykitStoredAssignmentData(addressType = "nativeSegwit", receiveIndex = 1), + ), + ) + whenever(lightningRepo.addressInfoForType(any(), any())).thenReturn( + Result.failure(IllegalStateException("unavailable")), + Result.success(AddressDerivationInfo(address = PRIVATE_ADDRESS, index = 1)), + ) + + assertFailsWith { sut.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS) } + assertEquals(CONTACT_KEY, sut.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS)) + } + @Test fun `contactPublicKeyForReservedAddress skips assignments for other address types`() = test { reservationData.value = PrivatePaykitReservationData( diff --git a/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt b/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt index 88f206c57d..79c2df9309 100644 --- a/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt +++ b/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt @@ -43,8 +43,10 @@ import to.bitkit.repositories.PaykitReceivedPaymentContactsTest.Companion.OTHER_ import to.bitkit.repositories.PrivatePaykitAddressReservationRepo import to.bitkit.repositories.PrivatePaykitContactResolver import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AppError import javax.inject.Provider import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -76,6 +78,9 @@ class ActivityServicePaykitContactsTest : BaseUnitTest() { private var rows = listOf() private var details: TransactionDetails? = null private val updates = mutableListOf() + private val reservationVersion = MutableStateFlow(0L) + private var activityReads = 0 + private var detailReads = 0 @Test fun `backfill matches receiving address in outputs and preserves all other onchain metadata`() = coreTest { @@ -193,6 +198,109 @@ class ActivityServicePaykitContactsTest : BaseUnitTest() { assertTrue(updates.isEmpty()) } + @Test + fun `completed backfill skips unchanged inputs until new activity arrives`() = coreTest { + rows = listOf(Activity.Onchain(onchain("wallet-address"))) + val outputs = listOf(output("wallet-address")) + details = mock { on { this.outputs }.thenReturn(outputs) } + + assertFalse(sut.backfillPaykitContacts()) + assertFalse(sut.backfillPaykitContacts()) + assertEquals(1, activityReads) + assertEquals(1, detailReads) + + val received = Activity.Lightning(lightning()) + sut.upsert(received) + rows = listOf(received) + updates.clear() + whenever(contacts.contactsForPaymentHash(any())).thenReturn(setOf(BUYER)) + + assertTrue(sut.backfillPaykitContacts()) + assertEquals(BUYER, (updates.single() as Activity.Lightning).v1.contact) + assertEquals(2, activityReads) + } + + @Test + fun `reservation changes invalidate an ambiguous cached result`() = coreTest { + rows = listOf(Activity.Onchain(onchain("request-address"))) + val outputs = listOf(output("request-address"), output("reserved-address")) + details = mock { on { this.outputs }.thenReturn(outputs) } + sharedAddresses("request-address" to BUYER) + whenever(reservations.contactPublicKeyForReservedAddress("reserved-address")).thenReturn(OTHER_BUYER) + assertFalse(sut.backfillPaykitContacts()) + assertFalse(sut.backfillPaykitContacts()) + assertEquals(1, activityReads) + + whenever(reservations.contactPublicKeyForReservedAddress("reserved-address")).thenReturn(null) + reservationVersion.value++ + assertTrue(sut.backfillPaykitContacts()) + assertEquals(BUYER, (updates.single() as Activity.Onchain).v1.contact) + assertEquals(2, activityReads) + } + + @Test + fun `incomplete details and failed reservation lookups retry until a scan completes`() = coreTest { + rows = listOf(Activity.Onchain(onchain("request-address")), Activity.Lightning(lightning())) + sharedAddresses("request-address" to BUYER) + assertFalse(sut.backfillPaykitContacts()) + assertFalse(sut.backfillPaykitContacts()) + val outputs = listOf(output("request-address")) + details = mock { on { this.outputs }.thenReturn(outputs) } + whenever(reservations.contactPublicKeyForReservedAddress(any())) + .thenThrow(IllegalStateException("unavailable")).thenReturn(null) + assertFailsWith { sut.backfillPaykitContacts() } + + assertTrue(sut.backfillPaykitContacts()) + assertFalse(sut.backfillPaykitContacts()) + assertEquals(4, activityReads) + assertEquals(4, detailReads) + assertEquals(BUYER, (updates.single() as Activity.Onchain).v1.contact) + } + + @Test + fun `updated transaction details invalidate a completed cached scan`() = coreTest { + rows = listOf(Activity.Onchain(onchain("request-address"))) + val ambiguousOutputs = listOf(output("request-address"), output("other-request-address")) + details = mock { on { outputs }.thenReturn(ambiguousOutputs) } + sharedAddresses("request-address" to BUYER, "other-request-address" to OTHER_BUYER) + assertFalse(sut.backfillPaykitContacts()) + + val resolvedOutputs = listOf(output("request-address")) + details = mock { on { outputs }.thenReturn(resolvedOutputs) } + sut.handleOnchainTransactionConfirmed("transaction", mock()) + assertTrue(sut.backfillPaykitContacts()) + assertEquals(2, activityReads) + } + + @Test + fun `identity generation invalidates equal contact snapshots`() = coreTest { + rows = listOf(Activity.Lightning(lightning())) + assertFalse(sut.backfillPaykitContacts()) + whenever(requestRepo.receivedPaymentContactsGeneration).thenReturn(1L) + whenever(contacts.contactsForPaymentHash(any())).thenReturn(setOf(BUYER)) + + assertTrue(sut.backfillPaykitContacts()) + assertEquals(2, activityReads) + } + + @Test + fun `reservation mutation during backfill cannot cache or write stale attribution`() = coreTest { + rows = listOf(Activity.Onchain(onchain("request-address"))) + val outputs = listOf(output("request-address")) + details = mock { on { this.outputs }.thenReturn(outputs) } + sharedAddresses("request-address" to BUYER) + whenever(reservations.contactPublicKeyForReservedAddress(any())).thenAnswer { + reservationVersion.value++ + null + } + + assertFalse(sut.backfillPaykitContacts()) + assertTrue(updates.isEmpty()) + whenever(reservations.contactPublicKeyForReservedAddress(any())).thenReturn(null) + assertTrue(sut.backfillPaykitContacts()) + assertEquals(2, activityReads) + } + @Test fun `live received payment rejects a request matching an unrelated output`() = coreTest { sharedAddresses("request-address" to BUYER) @@ -322,6 +430,7 @@ class ActivityServicePaykitContactsTest : BaseUnitTest() { private fun coreTest(block: suspend () -> Unit) = test { whenever(requestRepo.receivedPaymentContacts).thenReturn(contacts) + whenever(reservations.attributionVersion).thenAnswer { reservationVersion.value } whenever(privateCacheStore.data).thenReturn(flowOf(PrivatePaykitCacheData())) whenever(cacheStore.data).thenReturn(cacheData) whenever(cacheStore.update(any())).thenAnswer { @@ -338,8 +447,14 @@ class ActivityServicePaykitContactsTest : BaseUnitTest() { anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull() ) - }.thenAnswer { rows } - native.`when` { getTransactionDetails(any(), any()) }.thenAnswer { details } + }.thenAnswer { + activityReads++ + rows + } + native.`when` { getTransactionDetails(any(), any()) }.thenAnswer { + detailReads++ + details + } native.`when` { updateActivity(any(), any()) }.thenAnswer { updates.add(it.arguments[1] as Activity) null diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 57a863a403..13fe5f01e9 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -6745,6 +6745,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test + @Suppress("LongMethod") fun `failed LNURL request callback releases preparation and retry reopens request`() = test { val request = paymentRequest() val privateContext = privatePaymentContext(7uL) @@ -6790,6 +6791,10 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) verify(paykitPaymentProofRepo).cancelPreparation(request) verify(lightningRepo, never()).payInvoice(any(), anyOrNull()) + inOrder(paykitPaymentRequestRepo, lightningRepo).apply { + verify(paykitPaymentRequestRepo).accept(request) + verify(lightningRepo).fetchLnurlInvoice(lnurl, lnurl.callbackAmountMsats(request.amountSats), null) + } verify(toastManager, never()).enqueue(any()) pendingPaykitPaymentRequests.value = emptyList() diff --git a/changelog.d/next/1401.changed.md b/changelog.d/next/1401.changed.md index 3299757cb7..c6c333754f 100644 --- a/changelog.d/next/1401.changed.md +++ b/changelog.d/next/1401.changed.md @@ -1 +1 @@ -Share Paykit contacts and payment state with authorized apps while keeping wallet keys private, and label received payments with their Paykit payer contact. +Share Paykit contacts and payment state with authorized apps while keeping wallet keys private, keep accepted one-time requests payable only on the accepting install, and label received payments with their Paykit payer contact. diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index 1bde13b8ce..48b10e4369 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -24,6 +24,13 @@ Rejected fixture shapes stay in unit tests because Bitkit intentionally does not `definite-pre-broadcast-retry.xml` uses the linked fixture issuer and the local regtest LNURL server. Configure its LNURL-pay metadata endpoint normally, but make its invoice callback fail the first request and succeed after it is switched back to the healthy response. Do not republish the Paykit payment list between attempts. This makes the first send fail before Lightning dispatch and proves that the same private payment details can be opened and paid on retry. +## Accepting install + +`accepted-device-ownership.xml` extends the failing LNURL fixture to two separate installs sharing +one Pubky identity and App ID. Only the accepting install may retry after restart; the other keeps +the accepted request in history without payment controls. Confirm acceptance in shared request +state after the callback failure, before testing the second install. + ## Reference evidence The source wallet-leg run completed this path on regtest on 2026-08-22: Bitkit presented the incoming request, opened the on-chain payment, broadcast it, and confirmed transaction diff --git a/journeys/payment-requests/accepted-device-ownership.xml b/journeys/payment-requests/accepted-device-ownership.xml new file mode 100644 index 0000000000..b0c949b4e0 --- /dev/null +++ b/journeys/payment-requests/accepted-device-ownership.xml @@ -0,0 +1,21 @@ + + + Requires two separate Bitkit installs A and B authenticated as the same Pubky identity, + both using App ID bitkit, and the linked LNURL fixture from definite-pre-broadcast-retry.xml. + This covers one-time requests only. Do not copy app-private storage between installs. + + + Configure the fixture LNURL-pay invoice callback to fail before Lightning dispatch + Have the issuer send a proposed one-time Payment Request for 21,000 sats and record its request id + Open the request payment review on both installs before either install swipes to send + On install A swipe the send control (testTag "GRAB") and verify the failure screen (testTag "SendFailure") + Using the fixture's shared-runtime request inspection, verify the recorded request is ACCEPTED with no payment proof and no wallet dispatch; stop if acceptance has not committed + Configure the same LNURL-pay invoice callback to succeed without publishing a new Paykit payment list + On install B swipe the stale review's send control and verify that no wallet payment is dispatched + Restart install B and wait for Paykit synchronization; verify the request is not automatically presented or offered as payable + Open Payment Requests on install B and verify the recorded request remains in history without a Pay action + Restart install A and wait for Paykit synchronization + Open Payment Requests on install A and pay the recorded request + Verify the payment success screen (testTag "SendSuccess") and exactly one wallet payment to the issuer + + From 963d3ea1a12de47a38e81b2ceebc730d59f0035c Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 1 Oct 2026 08:03:29 -0500 Subject: [PATCH 41/51] fix: prune completed paykit acceptance records --- .../PaykitPaymentRequestPresentationStore.kt | 19 +++++++ .../repositories/PaykitPaymentRequestRepo.kt | 22 ++++++++ ...ykitPaymentRequestPresentationStoreTest.kt | 30 +++++++++++ .../PaykitPaymentRequestRepoTest.kt | 53 +++++++++++++++++++ 4 files changed, 124 insertions(+) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt index abc97b191d..1b947eac66 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt @@ -90,6 +90,25 @@ class PaykitPaymentRequestPresentationStore @Inject constructor( ids } + suspend fun removeAcceptedOneTimeIds( + identity: String, + ids: Set, + ): Set = + mutex.withLock { + val normalizedIdentity = requireNotNull(PubkyPublicKeyFormat.normalized(identity)) + val current = loadAcceptedOneTimeIdsByIdentity() + val storedIds = current[normalizedIdentity].orEmpty() + val remaining = storedIds - ids + if (remaining == storedIds) return@withLock remaining + val state = if (remaining.isEmpty()) { + current - normalizedIdentity + } else { + current + (normalizedIdentity to remaining) + } + keychain.upsertString(ACCEPTED_KEY, Json.encodeToString(state)) + remaining + } + private fun loadAcceptedOneTimeIdsByIdentity(): Map> { val value = keychain.loadString(ACCEPTED_KEY) ?: return emptyMap() return runCatching { Json.decodeFromString>>(value) } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index b786220988..0c2cd64139 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -1062,6 +1062,8 @@ class PaykitPaymentRequestRepo @Inject constructor( val history = (recurringHistory + oneTimeHistory) .sortedByDescending { it.createdAt } if (!isCurrentState(generation, expectedIdentity) || expectedIdentity == null) return + pruneAcceptedOneTimeRequestIds(records, expectedIdentity, generation) + if (!isCurrentState(generation, expectedIdentity)) return receivedContacts = ReceivedContactsSnapshot(generation, expectedIdentity, contacts) val subscriptionStateChanged = subscriptionAcceptedAt != updatedSubscriptionAcceptedAt || @@ -1350,6 +1352,26 @@ class PaykitPaymentRequestRepo @Inject constructor( scheduleExpirationLocked() } + private suspend fun pruneAcceptedOneTimeRequestIds( + records: List, + identity: String, + generation: Long, + ) { + val finishedIds = records.filter { + it.localRole == PaymentRequestLocalRole.PAYER && it.terms?.recurrence == null && + it.state in setOf( + PaymentRequestLifecycleState.PROOF_SUBMITTED, + PaymentRequestLifecycleState.CANCELED, + PaymentRequestLifecycleState.REJECTED, + ) + }.mapTo(mutableSetOf()) { PaykitPaymentRequestId(it.paymentRequestId, it.counterparty) } + .intersect(acceptedOneTimeRequestIds) + if (finishedIds.isEmpty()) return + runSuspendCatching { presentationStore.removeAcceptedOneTimeIds(identity, finishedIds) } + .onSuccess { if (isCurrentState(generation, identity)) acceptedOneTimeRequestIds = it } + .onFailure { Logger.warn("Failed to prune accepted Paykit payment requests", it, context = TAG) } + } + private suspend fun prunePresentedRequestIds(requests: List) { val requestIds = requests.mapTo(mutableSetOf()) { it.id } val prunedIds = presentedRequestIds.intersect(requestIds) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt index 4d454033df..7922c4c27e 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt @@ -8,6 +8,7 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.junit.Test import org.mockito.kotlin.any +import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -122,6 +123,32 @@ class PaykitPaymentRequestPresentationStoreTest : BaseUnitTest() { assertEquals(emptySet(), reopened.loadAcceptedOneTimeIds(IDENTITY)) } + @Test + fun `acceptance cleanup removes only specified ids from the current stored identity`() = test { + val keychain = mock() + val key = Keychain.Key.PAYKIT_ACCEPTED_PAYMENT_REQUESTS.name + var stored: String? = null + whenever(keychain.loadString(key)).thenAnswer { stored } + whenever { keychain.upsertString(eq(key), any()) }.thenAnswer { + stored = it.getArgument(1) + Unit + } + val sut = PaykitPaymentRequestPresentationStore(keychain) + val finished = PaykitPaymentRequestId("finished", COUNTERPARTY) + val live = PaykitPaymentRequestId("live", COUNTERPARTY) + sut.addAcceptedOneTimeId(IDENTITY, finished) + sut.addAcceptedOneTimeId(COUNTERPARTY, finished) + sut.addAcceptedOneTimeId(IDENTITY, live) + + assertEquals(setOf(live), sut.removeAcceptedOneTimeIds(IDENTITY, setOf(finished))) + val reopened = PaykitPaymentRequestPresentationStore(keychain) + assertEquals(setOf(live), reopened.loadAcceptedOneTimeIds(IDENTITY)) + assertEquals(setOf(finished), reopened.loadAcceptedOneTimeIds(COUNTERPARTY)) + clearInvocations(keychain) + reopened.removeAcceptedOneTimeIds(IDENTITY, setOf(finished)) + verify(keychain, never()).upsertString(any(), any()) + } + @Test fun `unreadable accepted one time ownership is preserved and fails closed`() = test { val keychain = mock() @@ -133,6 +160,9 @@ class PaykitPaymentRequestPresentationStoreTest : BaseUnitTest() { assertFailsWith { sut.addAcceptedOneTimeId(IDENTITY, PaykitPaymentRequestId("request", COUNTERPARTY)) } + assertFailsWith { + sut.removeAcceptedOneTimeIds(IDENTITY, setOf(PaykitPaymentRequestId("request", COUNTERPARTY))) + } verify(keychain, never()).upsertString(any(), any()) } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index c1c72e3528..60233e3059 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -574,6 +574,59 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(sut.ensurePaymentAllowed(request).isFailure) } + @Test + fun `acceptance cleanup removes only confirmed finished requests`() = test { + val records = listOf( + paymentRequestRecord(id = "paid", state = PaymentRequestLifecycleState.PROOF_SUBMITTED), + paymentRequestRecord(id = "canceled", state = PaymentRequestLifecycleState.CANCELED), + paymentRequestRecord(id = "rejected", state = PaymentRequestLifecycleState.REJECTED), + paymentRequestRecord( + id = "retry", + state = PaymentRequestLifecycleState.ACCEPTED, + expiresAt = "2020-01-01T00:00:00Z", + ), + paymentRequestRecord(id = "recovery", state = PaymentRequestLifecycleState.RECOVERY_REQUIRED), + paymentRequestRecord(id = "conflict", state = PaymentRequestLifecycleState.INVALID_CONFLICT), + ) + val ids = records.mapTo(mutableSetOf()) { PaykitPaymentRequestId(it.paymentRequestId, it.counterparty) } + + PaykitPaymentRequestId("missing", COUNTERPARTY) + val finished = setOf("paid", "canceled", "rejected") + .mapTo(mutableSetOf()) { PaykitPaymentRequestId(it, COUNTERPARTY) } + whenever(presentationStore.loadAcceptedOneTimeIds(LOCAL_IDENTITY)).thenReturn(ids) + sut.clear() + sut.activate(LOCAL_IDENTITY) + sut.refresh().getOrThrow() + verify(presentationStore, never()).removeAcceptedOneTimeIds(any(), any()) + + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(records) + whenever(presentationStore.removeAcceptedOneTimeIds(LOCAL_IDENTITY, finished)).thenReturn(ids - finished) + sut.refresh().getOrThrow() + verify(presentationStore).removeAcceptedOneTimeIds(LOCAL_IDENTITY, finished) + val retry = sut.pendingRequests.value.single() + assertEquals("retry", retry.paymentRequestId) + sut.ensurePaymentAllowed(retry).getOrThrow() + sut.refresh().getOrThrow() + verify(presentationStore, times(1)).removeAcceptedOneTimeIds(any(), any()) + } + + @Test + fun `failed acceptance cleanup retains ids and retries`() = test { + val record = paymentRequestRecord(state = PaymentRequestLifecycleState.PROOF_SUBMITTED) + val ids = setOf(PaykitPaymentRequestId(record.paymentRequestId, record.counterparty)) + whenever(presentationStore.loadAcceptedOneTimeIds(LOCAL_IDENTITY)).thenReturn(ids) + whenever(presentationStore.removeAcceptedOneTimeIds(LOCAL_IDENTITY, ids)) + .thenThrow(IllegalStateException("disk")).thenReturn(emptySet()) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) + sut.clear() + sut.activate(LOCAL_IDENTITY) + + sut.refresh().getOrThrow() + assertTrue(sut.pendingRequests.value.isEmpty()) + sut.refresh().getOrThrow() + sut.refresh().getOrThrow() + verify(presentationStore, times(2)).removeAcceptedOneTimeIds(LOCAL_IDENTITY, ids) + } + @Test fun `final authorization rechecks identity after asynchronous peer lookup`() = test { restoreAcceptedRequest() From 5266e8373fc908a42f96972da5e4c29f596099f5 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 1 Oct 2026 09:50:01 -0500 Subject: [PATCH 42/51] fix: preserve paykit payment recovery and contact choices --- .../main/java/to/bitkit/data/CacheStore.kt | 12 ++++ .../bitkit/models/PaykitPaymentStateBackup.kt | 1 + .../to/bitkit/repositories/ActivityRepo.kt | 2 + .../java/to/bitkit/repositories/BackupRepo.kt | 2 + .../repositories/PaykitPaymentProofRepo.kt | 14 ++++- .../PaykitPaymentRequestPresentationStore.kt | 10 +++- .../repositories/PaykitPaymentRequestRepo.kt | 29 ++++++++-- .../bitkit/repositories/PrivatePaykitRepo.kt | 5 +- .../java/to/bitkit/services/CoreService.kt | 8 ++- .../to/bitkit/services/PaykitSdkService.kt | 4 +- .../models/PaykitPaymentStateBackupTest.kt | 3 + .../PaykitPaymentProofRepoTest.kt | 15 +++++ ...ykitPaymentRequestPresentationStoreTest.kt | 13 +++-- .../PaykitPaymentRequestRepoTest.kt | 55 +++++++++++++------ .../repositories/PrivatePaykitRepoTest.kt | 13 ++--- .../ActivityServicePaykitContactsTest.kt | 14 +++++ journeys/payment-requests/README.md | 5 ++ journeys/pubky-marketplace/wallet-leg.xml | 3 + 18 files changed, 165 insertions(+), 43 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index 67e0becf8f..1fb975cf4b 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -59,6 +59,14 @@ class CacheStore internal constructor( store.updateData { it.copy(onchainAddress = address) } } + suspend fun setActivityContactDetached(activityId: String, walletId: String, detached: Boolean) { + val id = scopedActivityId(walletId, activityId) + store.updateData { + val contacts = it.detachedActivityContacts + it.copy(detachedActivityContacts = if (detached) contacts + id else contacts - id) + } + } + suspend fun saveBolt11(bolt11: String, paymentHash: String) { store.updateData { it.copy(bolt11 = bolt11, bolt11PaymentHash = paymentHash) } } @@ -171,6 +179,7 @@ data class AppCacheData( val balance: BalanceState? = null, val backupStatuses: Map = mapOf(), val deletedActivities: List = listOf(), + val detachedActivityContacts: Set = emptySet(), val pendingBoostActivities: List = listOf(), val backgroundReceive: NewTransactionSheetDetails? = null, val addressSearchLastUsedReceiveIndexes: Map = mapOf(), @@ -180,6 +189,9 @@ data class AppCacheData( /** LNURL-pay comments by payment hash, kept until the sent payment's activity stores them. */ val pendingLightningMessages: Map = mapOf(), ) { + fun isContactDetached(activityId: String, walletId: String): Boolean = + scopedActivityId(walletId, activityId) in detachedActivityContacts + fun isActivityDeleted(activityId: String, walletId: String): Boolean = scopedActivityId(walletId, activityId) in deletedActivities || walletId == WalletScope.default && activityId in deletedActivities diff --git a/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt b/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt index b9429d238a..205a881e1f 100644 --- a/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt +++ b/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt @@ -16,6 +16,7 @@ import kotlin.time.Instant data class PaykitPaymentStateBackup( val subscriptions: Map, val pendingProofs: List, + val acceptedOneTimeRequests: Map>? = null, ) { @Serializable data class Subscription( diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 20ee50dbff..af0023109b 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -508,6 +508,7 @@ class ActivityRepo @Inject constructor( return@runCatching } + cacheStore.setActivityContactDetached(activity.rawId(), walletId, detached = false) val updatedAt = nowTimestamp().epochSecond.toULong() val updatedActivity = activity.withContact(normalizedKey, updatedAt) updateActivity(updatedActivity.rawId(), updatedActivity).getOrThrow() @@ -539,6 +540,7 @@ class ActivityRepo @Inject constructor( } if (activity.contact() == null) return@runCatching + cacheStore.setActivityContactDetached(activity.rawId(), walletId, detached = true) val updatedAt = nowTimestamp().epochSecond.toULong() val updatedActivity = activity.withContact(null, updatedAt) updateActivity(updatedActivity.rawId(), updatedActivity).getOrThrow() diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index a45f162c62..a0ac179657 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -641,6 +641,7 @@ class BackupRepo @Inject constructor( paykitPaymentState = PaykitPaymentStateBackup( subscriptions = paykitPresentationStore.backupSnapshot(), pendingProofs = paykitPaymentProofRepo.get().backupSnapshot(), + acceptedOneTimeRequests = paykitPresentationStore.acceptedOneTimeBackupSnapshot(), ), ) @@ -781,6 +782,7 @@ class BackupRepo @Inject constructor( paykitPaymentRequestRepo.get().clear() paykitPresentationStore.restoreBackup(it.subscriptions) paykitPaymentProofRepo.get().restoreBackup(it.pendingProofs) + paykitPresentationStore.restoreAcceptedOneTimeRequests(it.acceptedOneTimeRequests.orEmpty()) } db.transferDao().upsert(parsed.transfers) watchOnlyAccountRepo.restore( diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index a46552f1e4..593f6f5b05 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -273,8 +273,18 @@ class PaykitPaymentProofRepo @Inject constructor( return@withContext } - val identity = currentIdentity() ?: return@withContext - val fallbackProof = runSuspendCatching { + val prepared = operationMutex.withLock { + runSuspendCatching { + loadProofs().filter { + it.requestId == request.id && it.paymentAppId == paymentAppId && + it.paymentEndpointIdentifier == paymentEndpointIdentifier && + it.kind == PaykitPaymentProofKind.Onchain && it.paymentStarted && + it.paymentIdentifier == null && it.proofData == null + }.singleOrNull() + }.getOrNull() + } + val identity = prepared?.identity ?: currentIdentity() ?: return@withContext + val fallbackProof = prepared ?: runSuspendCatching { pendingProof(request, paymentEndpointIdentifier, paymentAppId, PaykitPaymentProofKind.Onchain) }.getOrNull() operationMutex.withLock { diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt index 1b947eac66..a50fa1d099 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt @@ -74,7 +74,6 @@ class PaykitPaymentRequestPresentationStore @Inject constructor( } } - /** Local execution ownership is never exported or imported by wallet backups. */ fun loadAcceptedOneTimeIds(identity: String): Set { val normalizedIdentity = PubkyPublicKeyFormat.normalized(identity) ?: return emptySet() return loadAcceptedOneTimeIdsByIdentity()[normalizedIdentity].orEmpty() @@ -87,6 +86,7 @@ class PaykitPaymentRequestPresentationStore @Inject constructor( val ids = current[normalizedIdentity].orEmpty() + id val state = current + (normalizedIdentity to ids) keychain.upsertString(ACCEPTED_KEY, Json.encodeToString(state)) + _backupStateVersion.update { it + 1 } ids } @@ -106,9 +106,17 @@ class PaykitPaymentRequestPresentationStore @Inject constructor( current + (normalizedIdentity to remaining) } keychain.upsertString(ACCEPTED_KEY, Json.encodeToString(state)) + _backupStateVersion.update { it + 1 } remaining } + fun acceptedOneTimeBackupSnapshot(): Map> = loadAcceptedOneTimeIdsByIdentity() + + suspend fun restoreAcceptedOneTimeRequests(requests: Map>) = mutex.withLock { + keychain.upsertString(ACCEPTED_KEY, Json.encodeToString(requests)) + _backupStateVersion.update { it + 1 } + } + private fun loadAcceptedOneTimeIdsByIdentity(): Map> { val value = keychain.loadString(ACCEPTED_KEY) ?: return emptyMap() return runCatching { Json.decodeFromString>>(value) } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 0c2cd64139..c09de40097 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -5,6 +5,7 @@ package to.bitkit.repositories import com.synonym.paykit.LinkedPeerState import com.synonym.paykit.OutboundPrivateCounterpartySendReport import com.synonym.paykit.OutboundPrivateMessageStatus +import com.synonym.paykit.PaykitException import com.synonym.paykit.PaymentRequestLifecycleState import com.synonym.paykit.PaymentRequestLocalRole import com.synonym.paykit.PaymentRequestRecord @@ -836,13 +837,27 @@ class PaykitPaymentRequestRepo @Inject constructor( val identity = activeIdentity ?: throw PaykitPaymentRequestError.RequestUnavailable val generation = stateGeneration.get() ensurePaymentAllowed(it, forExecution = false).getOrThrow() - paykitSdkService.acceptPaymentRequest( - counterparty = it.counterparty, - paymentRequestId = it.paymentRequestId, - ) + val alreadySaved = it.id in presentationStore.loadAcceptedOneTimeIds(identity) val acceptedIds = presentationStore.addAcceptedOneTimeId(identity, it.id) if (!isCurrentState(generation, identity)) throw PaykitPaymentRequestError.RequestUnavailable acceptedOneTimeRequestIds = acceptedIds + runSuspendCatching { + paykitSdkService.acceptPaymentRequest( + counterparty = it.counterparty, + paymentRequestId = it.paymentRequestId, + ) + }.onFailure { error -> + // Acceptance may already be committed. Reconcile before discarding its owner. + val uncertain = when (error) { + is PaykitException.Transport, is PaykitException.Storage, is PaykitException.Identity -> true + else -> false + } + if (!alreadySaved && !uncertain) { + val remaining = presentationStore.removeAcceptedOneTimeIds(identity, setOf(it.id)) + if (isCurrentState(generation, identity)) acceptedOneTimeRequestIds = remaining + } + }.getOrThrow() + if (!isCurrentState(generation, identity)) throw PaykitPaymentRequestError.RequestUnavailable }.getOrThrow() } }.onFailure { @@ -1188,7 +1203,11 @@ class PaykitPaymentRequestRepo @Inject constructor( activatedGeneration == stateGeneration.get() && request.id in acceptedOneTimeRequestIds private fun hasCurrentExecutionContext(request: PaykitPaymentRequest): Boolean { - if (request.billingPeriod == null) return hasLocalAcceptance(request) + if (request.billingPeriod == null) { + return hasLocalAcceptance(request) && _paymentRequestHistory.value.any { + it.id == request.id && it.lifecycleState == PaymentRequestLifecycleState.ACCEPTED + } + } return activatedGeneration == stateGeneration.get() && _subscriptions.value.any { it.isPayer && it.lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING && request.belongsTo(it) } diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index e8796cbe1b..c90be47998 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -825,7 +825,10 @@ class PrivatePaykitRepo @Inject constructor( for (publicKey in publicKeys.distinct()) { val link = runSuspendCatching { paykitSdkService.ensureLinkWithPeer(publicKey) } .onFailure { Logger.warn("Failed to prepare private Paykit link during '$reason'", it, context = TAG) } - if (link.exceptionOrNull() is PaykitException.NotFound) continue + if (link.isFailure) { + if (link.exceptionOrNull() !is PaykitException.NotFound) linkRetryKeys += publicKey + continue + } linkRetryKeys += publicKey runSuspendCatching { privatePaymentListUpdate(publicKey, forceRefreshLightning) } .onSuccess { updates += it } diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 79afc00afb..254fdd942f 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -618,6 +618,7 @@ class ActivityService( var complete = true for (activity in activities) { if (!isCurrentPaykitContactBackfill(resolver, snapshot)) return@background changed + if (cacheStore.data.first().isContactDetached(activity.rawId(), activity.walletId())) continue val contact = when (activity) { is Activity.Lightning -> receivedPaykitContact(activity.v1, contacts) is Activity.Onchain -> { @@ -627,7 +628,7 @@ class ActivityService( } } if (!isCurrentPaykitContactBackfill(resolver, snapshot)) return@background changed - if (contact != null) { + if (contact != null && !cacheStore.data.first().isContactDetached(activity.rawId(), activity.walletId())) { val updated = when (activity) { is Activity.Lightning -> Activity.Lightning(activity.v1.copy(contact = contact)) is Activity.Onchain -> Activity.Onchain(activity.v1.copy(contact = contact)) @@ -884,7 +885,9 @@ class ActivityService( val contact = existingActivity ?.takeIf { it is Activity.Lightning } ?.let { (it as Activity.Lightning).v1.contact } - ?: payment.takeUnless { it.status == PaymentStatus.FAILED }?.let { + ?: payment.takeUnless { + it.status == PaymentStatus.FAILED || cacheStore.data.first().isContactDetached(it.id, defaultWalletId) + }?.let { privatePaykitContactPublicKeyForReceivedInvoicePaymentHash(it.id, it.direction) } @@ -1332,6 +1335,7 @@ class ActivityService( val existingContact = existingOnchainActivity?.v1?.contact val contact = existingContact ?: if ( payment.direction == PaymentDirection.INBOUND && payment.status != PaymentStatus.FAILED && + !cacheStore.data.first().isContactDetached(payment.id, defaultWalletId) && !details?.outputs.isNullOrEmpty() ) { privatePaykitContactResolver.get().contactPublicKeyForPrivateOnchainAddresses( diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 3316316e99..1a04128ef4 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -514,7 +514,7 @@ class PaykitSdkService @Inject constructor( suspend fun fetchPubkyFollows(publicKey: String): List { isSetup.await() return operationLock.withLock { - handle().fetchPubkyFollows(publicKey, maxEntries = 1000u) + handle().fetchPubkyFollows(publicKey, maxEntries = 10_000u) } } @@ -1303,7 +1303,7 @@ internal class PaykitSdkSessionProvider( } fun setPaykitIdentitySecretKey(key: PaykitIdentitySecretKey) = synchronized(lock) { - if ((paykitIdentitySecretKey?.keyGeneration() ?: 1uL) != key.keyGeneration()) { + if (paykitIdentitySecretKey?.keyGeneration() != key.keyGeneration()) { liveSessionAccess = null } paykitIdentitySecretKey = key diff --git a/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt b/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt index edc194cd23..2b958d3061 100644 --- a/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt +++ b/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt @@ -6,6 +6,7 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.junit.Test import to.bitkit.repositories.PaykitPaymentProofKind +import to.bitkit.repositories.PaykitPaymentRequestId import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -36,10 +37,12 @@ class PaykitPaymentStateBackupTest { ) }, pendingProofs = listOf(PaykitPaymentStateBackup.Proof(restored)), + acceptedOneTimeRequests = mapOf("alice" to setOf(PaykitPaymentRequestId("one-time", "bob"))), ) val decoded = Json.decodeFromString(Json.encodeToString(rebuilt)) assertEquals(restored, decoded.pendingProofs.single().restored()) assertEquals(backup.subscriptions, decoded.subscriptions) + assertEquals(rebuilt.acceptedOneTimeRequests, decoded.acceptedOneTimeRequests) val hardwareProof = restored.copy(onchainWalletId = "hardware-wallet") val hardwareBackup = PaykitPaymentStateBackup.Proof(hardwareProof) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 083dd620e8..4c0e5081f3 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -434,6 +434,21 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(storedProofs.isEmpty()) } + @Test + fun `broadcast transaction id is retained without a live identity`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val repo = paymentProofRepo() + val txid = "ab".repeat(32) + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + whenever(paykitSdkService.identityStatus()).thenReturn(null) + + repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue, "bitkit") + + assertEquals(txid, storedProofs.single().paymentIdentifier) + assertEquals(txid, storedProofs.single().proofData) + } + @Test fun `onchain proof submits without prepared proof`() = test { val txid = "ab".repeat(32) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt index 7922c4c27e..015d11d2da 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt @@ -94,11 +94,11 @@ class PaykitPaymentRequestPresentationStoreTest : BaseUnitTest() { } @Test - fun `accepted one time ownership survives reopening but never enters wallet backup`() = test { + fun `accepted one time ownership survives reopening and wallet restore`() = test { val keychain = mock() val values = mutableMapOf() whenever(keychain.loadString(any())).thenAnswer { values[it.getArgument(0)] } - whenever { keychain.upsertString(any(), any()) }.thenAnswer { + whenever(keychain.upsertString(any(), any())).thenAnswer { values[it.getArgument(0)] = it.getArgument(1) Unit } @@ -114,13 +114,16 @@ class PaykitPaymentRequestPresentationStoreTest : BaseUnitTest() { assertEquals(setOf(requestId, secondId), reopened.loadAcceptedOneTimeIds(IDENTITY)) assertEquals(setOf(secondId), reopened.loadAcceptedOneTimeIds(COUNTERPARTY)) assertEquals(emptyMap(), reopened.backupSnapshot()) - assertEquals(0L, sut.backupStateVersion.value) + assertEquals(3L, sut.backupStateVersion.value) + val backup = reopened.acceptedOneTimeBackupSnapshot() reopened.restoreBackup(emptyMap()) assertEquals(setOf(requestId, secondId), reopened.loadAcceptedOneTimeIds(IDENTITY)) values.clear() reopened.restoreBackup(emptyMap()) - assertEquals(emptySet(), reopened.loadAcceptedOneTimeIds(IDENTITY)) + reopened.restoreAcceptedOneTimeRequests(backup) + assertEquals(setOf(requestId, secondId), reopened.loadAcceptedOneTimeIds(IDENTITY)) + assertEquals(setOf(secondId), reopened.loadAcceptedOneTimeIds(COUNTERPARTY)) } @Test @@ -129,7 +132,7 @@ class PaykitPaymentRequestPresentationStoreTest : BaseUnitTest() { val key = Keychain.Key.PAYKIT_ACCEPTED_PAYMENT_REQUESTS.name var stored: String? = null whenever(keychain.loadString(key)).thenAnswer { stored } - whenever { keychain.upsertString(eq(key), any()) }.thenAnswer { + whenever(keychain.upsertString(eq(key), any())).thenAnswer { stored = it.getArgument(1) Unit } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 60233e3059..1e27cc2dc6 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -9,6 +9,7 @@ import com.synonym.bitkitcore.validateBitcoinAddress import com.synonym.paykit.IdentityStatus import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState +import com.synonym.paykit.PaykitException import com.synonym.paykit.PaymentDeadline import com.synonym.paykit.PaymentProofRecord import com.synonym.paykit.PaymentReference @@ -107,6 +108,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(presentationStore.addAcceptedOneTimeId(any(), any())).thenAnswer { setOf(it.getArgument(1)) } + whenever(presentationStore.removeAcceptedOneTimeIds(any(), any())).thenReturn(emptySet()) whenever( presentationStore.loadSubscriptionState(any()) ).thenReturn(PaykitSubscriptionPresentationState()) @@ -630,7 +632,8 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `final authorization rechecks identity after asynchronous peer lookup`() = test { restoreAcceptedRequest() - whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(paymentRequestRecord())) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())) + .thenReturn(listOf(paymentRequestRecord(state = PaymentRequestLifecycleState.ACCEPTED))) sut.refresh().getOrThrow() val request = sut.pendingRequests.value.single() val checking = CompletableDeferred() @@ -651,7 +654,8 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `queued identity switch invalidates ownership before acquiring the repository mutex`() = test { restoreAcceptedRequest() - whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(paymentRequestRecord())) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())) + .thenReturn(listOf(paymentRequestRecord(state = PaymentRequestLifecycleState.ACCEPTED))) sut.refresh().getOrThrow() val request = sut.pendingRequests.value.single() val refreshing = CompletableDeferred() @@ -673,24 +677,40 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `unknown acceptance or failed persistence never grants local ownership`() = test { + fun `failed intent persistence prevents remote acceptance`() = test { val proposed = paymentRequestRecord() val accepted = proposed.copy(state = PaymentRequestLifecycleState.ACCEPTED) - whenever(paykitSdkService.acceptPaymentRequest(any(), any())) - .thenThrow(IllegalStateException("unknown completion")).thenReturn(accepted) whenever(presentationStore.addAcceptedOneTimeId(any(), any())).thenThrow(IllegalStateException("disk")) - for (sdkSucceeded in listOf(false, true)) { - whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(proposed)) - sut.refresh().getOrThrow() - assertTrue(sut.accept(sut.pendingRequests.value.single()).isFailure) - if (!sdkSucceeded) verify(presentationStore, never()).addAcceptedOneTimeId(any(), any()) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(proposed)) + sut.refresh().getOrThrow() + assertTrue(sut.accept(sut.pendingRequests.value.single()).isFailure) + verify(paykitSdkService, never()).acceptPaymentRequest(any(), any()) - whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(accepted)) - sut.refresh().getOrThrow() - assertTrue(sut.pendingRequests.value.isEmpty()) - assertTrue(sut.accept(sut.paymentRequestHistory.value.single()).isFailure) - assertTrue(sut.ensurePaymentAllowed(sut.paymentRequestHistory.value.single()).isFailure) - } + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(accepted)) + sut.refresh().getOrThrow() + assertTrue(sut.pendingRequests.value.isEmpty()) + assertTrue(sut.accept(sut.paymentRequestHistory.value.single()).isFailure) + assertTrue(sut.ensurePaymentAllowed(sut.paymentRequestHistory.value.single()).isFailure) + } + + @Test + fun `lost acceptance response is reconciled from shared state`() = test { + val proposed = paymentRequestRecord() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(proposed)) + whenever(paykitSdkService.acceptPaymentRequest(any(), any())) + .thenAnswer { throw PaykitException.Transport("transport_error", "response lost") } + sut.refresh().getOrThrow() + val request = sut.pendingRequests.value.single() + assertTrue(sut.accept(request).isFailure) + assertTrue(sut.ensurePaymentAllowed(request).isFailure) + verify(presentationStore, never()).removeAcceptedOneTimeIds(any(), any()) + + whenever(paykitSdkService.allPaymentRequests(anyOrNull())) + .thenReturn(listOf(proposed.copy(state = PaymentRequestLifecycleState.ACCEPTED))) + sut.refresh().getOrThrow() + val retry = sut.pendingRequests.value.single() + sut.accept(retry).getOrThrow() + sut.ensurePaymentAllowed(retry).getOrThrow() } @Test @@ -724,6 +744,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `second install cannot accept stale proposal or resume first installs acceptance`() = test { val otherStore = mock() + whenever(otherStore.removeAcceptedOneTimeIds(any(), any())).thenReturn(emptySet()) whenever(otherStore.loadSubscriptionState(any())).thenReturn(PaykitSubscriptionPresentationState()) val other = PaykitPaymentRequestRepo( testDispatcher, @@ -759,7 +780,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(other.claimForPayment(remoteAccepted).isFailure) assertTrue(other.ensurePaymentAllowed(remoteAccepted).isFailure) verify(paykitSdkService, never()).claimPaymentRequestForExecution(any(), any()) - verify(otherStore, never()).addAcceptedOneTimeId(any(), any()) + verify(otherStore).removeAcceptedOneTimeIds(eq(LOCAL_IDENTITY), eq(setOf(stale.id))) sut.refresh().getOrThrow() sut.accept(sut.pendingRequests.value.single()).getOrThrow() other.clear() diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index ea185f8e51..f0bb6bf6af 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -249,24 +249,21 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `prepareSavedContacts succeeds when link preparation fails but SDK queues reservations`() = test { + fun `prepareSavedContacts defers reservations while link preparation is unavailable`() = test { settingsData.value = SettingsData( sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false, publicPaykitOnchainEnabled = true, ) - whenever { paykitSdkService.ensureLinkWithPeer(CONTACT_KEY) }.thenAnswer { - throw PrivatePaykitTestAppError("still linking") + whenever(paykitSdkService.ensureLinkWithPeer(CONTACT_KEY)).thenAnswer { + throw PaykitException.Transport("offline", "Unavailable homeserver") } val result = sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - verifyBlocking(paykitSdkService) { syncPrivatePaymentListsWithReservations(any(), eq(false)) } - assertEquals( - true, - cacheData.value.contacts.getValue(CONTACT_KEY).hasPublishedPrivatePaymentList, - ) + verify(paykitSdkService, never()).syncPrivatePaymentListsWithReservations(any(), any()) + verify(addressReservationRepo, never()).currentOrRotatedAddress(any()) } @Test diff --git a/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt b/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt index 79c2df9309..d9d3dcc369 100644 --- a/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt +++ b/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt @@ -36,6 +36,7 @@ import to.bitkit.data.PrivatePaykitCacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.ext.create +import to.bitkit.ext.scopedActivityId import to.bitkit.repositories.PaykitPaymentRequestRepo import to.bitkit.repositories.PaykitReceivedPaymentContacts import to.bitkit.repositories.PaykitReceivedPaymentContactsTest.Companion.BUYER @@ -123,6 +124,19 @@ class ActivityServicePaykitContactsTest : BaseUnitTest() { assertTrue(updates.isEmpty()) } + @Test + fun `backfill respects a manually detached contact`() = coreTest { + val original = lightning() + rows = listOf(Activity.Lightning(original)) + whenever(contacts.contactsForPaymentHash(original.id)).thenReturn(setOf(BUYER)) + cacheData.value = cacheData.value.copy( + detachedActivityContacts = setOf(scopedActivityId(original.walletId, original.id)), + ) + + assertFalse(sut.backfillPaykitContacts()) + assertTrue(updates.isEmpty()) + } + @Test fun `backfill refuses ambiguity across stored receiving address and other outputs`() = coreTest { rows = listOf(Activity.Onchain(onchain(address = "request-address"))) diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index 48b10e4369..0c4c4455f5 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -26,6 +26,11 @@ Rejected fixture shapes stay in unit tests because Bitkit intentionally does not ## Accepting install +Acceptance intent is saved before the remote operation and included in wallet backups. +An interrupted response is reconciled against the shared request before payment. Restoring +the wallet restores its pending acceptances; this does not support running the same wallet +on multiple devices concurrently. + `accepted-device-ownership.xml` extends the failing LNURL fixture to two separate installs sharing one Pubky identity and App ID. Only the accepting install may retry after restart; the other keeps the accepted request in history without payment controls. Confirm acceptance in shared request diff --git a/journeys/pubky-marketplace/wallet-leg.xml b/journeys/pubky-marketplace/wallet-leg.xml index b11efee9c2..87d7dabb61 100644 --- a/journeys/pubky-marketplace/wallet-leg.xml +++ b/journeys/pubky-marketplace/wallet-leg.xml @@ -50,5 +50,8 @@ Tap the seller's latest received activity (testTag "ActivityShort-0"), then tap transaction details (testTag "ActivityTxDetails") Verify the transaction id (testTag "TXID") matches the fixture transaction Capture the final activity, transaction id, confirmation height, and completed purchase evidence + Return to the received activity details and tap Detach + Wait for Paykit synchronization, then restart the seller app and reopen the received activity + Verify the activity no longer identifies the buyer and offers Assign instead of Detach From 011c6730cccbc389d8aa51a75d3e307ca0567d3d Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 1 Oct 2026 10:03:16 -0500 Subject: [PATCH 43/51] refactor: simplify paykit contact backfill --- .../repositories/PaykitPaymentRequestRepo.kt | 4 ++- .../java/to/bitkit/services/CoreService.kt | 30 +++++++++++-------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index c09de40097..cb216f16aa 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -849,7 +849,9 @@ class PaykitPaymentRequestRepo @Inject constructor( }.onFailure { error -> // Acceptance may already be committed. Reconcile before discarding its owner. val uncertain = when (error) { - is PaykitException.Transport, is PaykitException.Storage, is PaykitException.Identity -> true + is PaykitException.Transport, + is PaykitException.Storage, + is PaykitException.Identity -> true else -> false } if (!alreadySaved && !uncertain) { diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 254fdd942f..9d96292365 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -619,20 +619,10 @@ class ActivityService( for (activity in activities) { if (!isCurrentPaykitContactBackfill(resolver, snapshot)) return@background changed if (cacheStore.data.first().isContactDetached(activity.rawId(), activity.walletId())) continue - val contact = when (activity) { - is Activity.Lightning -> receivedPaykitContact(activity.v1, contacts) - is Activity.Onchain -> { - val (contact, hydrated) = receivedPaykitContact(activity.v1) - complete = complete && hydrated - contact - } - } + val (updated, hydrated) = attributedPaykitActivity(activity, contacts) + complete = complete && hydrated if (!isCurrentPaykitContactBackfill(resolver, snapshot)) return@background changed - if (contact != null && !cacheStore.data.first().isContactDetached(activity.rawId(), activity.walletId())) { - val updated = when (activity) { - is Activity.Lightning -> Activity.Lightning(activity.v1.copy(contact = contact)) - is Activity.Onchain -> Activity.Onchain(activity.v1.copy(contact = contact)) - } + if (updated != null && !cacheStore.data.first().isContactDetached(activity.rawId(), activity.walletId())) { updateActivity(activityId = activity.rawId(), activity = updated) changed = true } @@ -651,6 +641,20 @@ class ActivityService( paykitActivityRevision == snapshot.activityRevision && resolver.reservationVersion == snapshot.reservationVersion + private suspend fun attributedPaykitActivity( + activity: Activity, + contacts: PaykitReceivedPaymentContacts, + ): Pair = when (activity) { + is Activity.Lightning -> { + val contact = receivedPaykitContact(activity.v1, contacts) + contact?.let { Activity.Lightning(activity.v1.copy(contact = it)) } to true + } + is Activity.Onchain -> { + val (contact, hydrated) = receivedPaykitContact(activity.v1) + contact?.let { Activity.Onchain(activity.v1.copy(contact = it)) } to hydrated + } + } + private fun receivedPaykitContact(row: LightningActivity, contacts: PaykitReceivedPaymentContacts): String? { if (row.contact != null || row.txType != PaymentType.RECEIVED || row.status == PaymentState.FAILED) return null return contacts.contactsForPaymentHash(row.id).singleOrNull() From 81c1865e08c9511e810fcb7da712d59c2d2932ee Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 1 Oct 2026 10:41:28 -0500 Subject: [PATCH 44/51] chore: update Paykit to rc59 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f1edd44eb0..35ddfa5263 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,7 +22,7 @@ appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.5.18" } -paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc58" } +paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc59" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } From 1f41d515de75f3b5a35b46a4ae468af1bda48aa8 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 1 Oct 2026 12:13:18 -0500 Subject: [PATCH 45/51] fix: preserve private sharing settings during contact cleanup --- .../bitkit/repositories/PrivatePaykitRepo.kt | 4 +- .../bitkit/repositories/PublicPaykitRepo.kt | 8 +- .../to/bitkit/services/PaykitSdkService.kt | 15 +++- .../repositories/PrivatePaykitRepoTest.kt | 51 ++++++++++-- .../repositories/PublicPaykitRepoTest.kt | 22 ++---- .../services/PaykitKeyGenerationTest.kt | 4 +- .../bitkit/services/PaykitSdkServiceTest.kt | 77 +++++++++++++++---- .../services/PaykitSdkServiceWipeTest.kt | 9 ++- .../services/PubkyIdentityRepublishTest.kt | 8 ++ .../bitkit/usecases/WipeWalletUseCaseTest.kt | 2 +- 10 files changed, 150 insertions(+), 50 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index c90be47998..8f7ba449c1 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -1251,7 +1251,6 @@ class PrivatePaykitRepo @Inject constructor( if (normalizedKeys.isEmpty()) return@runSuspendCatching ensureState() - publicPaykitRepo.syncPaykitApp(privateSharingEnabled = true).getOrThrow() val cleanupStateByPublicKey = normalizedKeys.associateWith(::publishedEndpointCleanupState) val preparation = clearPrivatePaymentLists(normalizedKeys) val failedPublicKeys = preparation.failedPublicKeys.toMutableSet() @@ -1283,6 +1282,9 @@ class PrivatePaykitRepo @Inject constructor( clearPublishedEndpointCache(normalizedKeys.filterNot { it in failedPublicKeys }) firstError?.let { throw it } publicPaykitRepo.syncPaykitApp().getOrThrow() + }.onFailure { + runSuspendCatching { settingsStore.update { it.copy(publicPaykitCleanupPending = true) } } + .onFailure(it::addSuppressed) } private suspend fun clearPrivatePaymentLists( diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index 1cc5007b10..ce09246cba 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -10,7 +10,6 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.lightningdevkit.ldknode.Bolt11Invoice import org.lightningdevkit.ldknode.Network -import to.bitkit.data.PrivatePaykitCacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher @@ -100,7 +99,6 @@ class PublicPaykitRepo @Inject constructor( private val coreService: CoreService, private val paykitSdkService: PaykitSdkService, private val settingsStore: SettingsStore, - private val privatePaykitCacheStore: PrivatePaykitCacheStore, private val clock: Clock, ) { companion object { @@ -276,11 +274,7 @@ class PublicPaykitRepo @Inject constructor( runSuspendCatching { val settings = settingsStore.data.first() val privateSharing = privateSharingEnabled ?: settings.sharesPrivatePaykitEndpoints - val privateCache = privatePaykitCacheStore.data.first() - paykitSdkService.syncPaykitApp( - privatePaymentsEnabled = privateSharing || privateCache.cleanupPending || - privateCache.deletedContactCleanupPendingPublicKeys.isNotEmpty(), - ) + paykitSdkService.syncPaykitApp(privatePaymentsEnabled = privateSharing) } } diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 1a04128ef4..9e60d814ab 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -82,6 +82,7 @@ import kotlinx.coroutines.withTimeoutOrNull import org.lightningdevkit.ldknode.Network import to.bitkit.async.BaseCoroutineScope import to.bitkit.data.PubkyStore +import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.data.sharedpubky.SharedPubkyClient import to.bitkit.data.sharedpubky.SharedPubkyContract @@ -157,6 +158,7 @@ class PaykitSdkService @Inject constructor( private val pubkyStore: PubkyStore, sharedPubky: SharedPubkyClient, @IoDispatcher ioDispatcher: CoroutineDispatcher, + private val settingsStore: SettingsStore, ) : BaseCoroutineScope(ioDispatcher, TAG) { private val sessionProvider = PaykitSdkSessionProvider(keychain, sharedPubky) private val paymentAdapter = PaykitSdkPaymentAdapter() @@ -199,8 +201,9 @@ class PaykitSdkService @Inject constructor( ioDispatcher: CoroutineDispatcher = Dispatchers.IO, sharedPubky: SharedPubkyClient = SharedPubkyClient(context, ioDispatcher), platformInitializer: (() -> Unit)? = null, + settingsStore: SettingsStore, sdkFactory: () -> PaykitSdk, - ) : this(context, keychain, pubkyStore, sharedPubky, ioDispatcher) { + ) : this(context, keychain, pubkyStore, sharedPubky, ioDispatcher, settingsStore) { this.sdkFactory = sdkFactory if (bootstrapFactory != null) this.bootstrapFactory = bootstrapFactory if (platformInitializer == null) { @@ -666,6 +669,11 @@ class PaykitSdkService @Inject constructor( ) { return@withStateRevisionTracking null } + val publicKey = handle.identityStatus()?.publicKey + if (publicKey != null) { + val app = handle.paykitAppRegistry(publicKey)?.apps?.find { it.appId == "bitkit" } + if (app?.capabilities?.privatePayments == false) return@withStateRevisionTracking null + } handle.clearPrivatePaymentListAndProcessOutbound(counterparty) } } @@ -1053,7 +1061,10 @@ class PaykitSdkService @Inject constructor( runSuspendCatching { val capabilities = appCapabilities(handle) if (capabilities.privatePayments) { - handle.publishPaykitApp("Bitkit", capabilities) + handle.publishPaykitApp( + "Bitkit", + capabilities.copy(privatePayments = settingsStore.data.first().sharesPrivatePaykitEndpoints), + ) } }.onFailure { Logger.warn("Failed to publish Paykit app", it, context = TAG) diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index f0bb6bf6af..d2f5cdce93 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -113,6 +113,10 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { cacheData.value = PrivatePaykitCacheData() } whenever(settingsStore.data).thenReturn(settingsData) + whenever { settingsStore.update(any()) }.thenAnswer { + val transform = it.getArgument<(SettingsData) -> SettingsData>(0) + settingsData.value = transform(settingsData.value) + } whenever(lightningRepo.lightningState).thenReturn(lightningState) whenever(clock.now()).thenReturn(Instant.fromEpochSeconds(NOW_SECONDS)) whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) @@ -337,7 +341,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `disabled cleanup retains its capability through failures and direct retries`() = test { + fun `disabled cleanup uses existing capability and retries withdrawal and registry failures`() = test { val publicRepo = PublicPaykitRepo( ioDispatcher = testDispatcher, pubkyRepo = mock(), @@ -346,20 +350,20 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { coreService = coreService, paykitSdkService = paykitSdkService, settingsStore = settingsStore, - privatePaykitCacheStore = cacheStore, clock = clock, ) - for (failureStage in listOf("enable capability", "withdraw", "disable capability")) { + for (failureStage in listOf("withdraw", "disable capability")) { cacheData.value = PrivatePaykitCacheData( contacts = mapOf(CONTACT_KEY to PrivatePaykitContactCacheData(hasPublishedPrivatePaymentList = true)), ) settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) sut = createSut(publicRepo) - var capability = false + var capability = true var failed = false doSuspendableAnswer { val enabled = it.getArgument(0) - if (!failed && failureStage == if (enabled) "enable capability" else "disable capability") { + assertFalse(enabled, "Cleanup must not enable private payments") + if (!failed && failureStage == "disable capability") { failed = true throw AppError("Registration unavailable") } @@ -379,8 +383,8 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(result.isFailure, failureStage) assertTrue(failed, failureStage) assertTrue(cacheData.value.cleanupPending, failureStage) - publicRepo.syncPaykitApp(privateSharingEnabled = false).getOrThrow() assertTrue(capability, failureStage) + assertTrue(settingsData.value.publicPaykitCleanupPending, failureStage) sut.retryPendingEndpointRemoval(listOf(CONTACT_KEY)).getOrThrow() @@ -390,6 +394,41 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } } + @Test + fun `failed deleted contact cleanup does not enable private payments`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) + val failure = AppError("Peer lookup unavailable") + whenever(paykitSdkService.linkedPeers()).thenAnswer { throw failure } + + val result = sut.removeSavedContact(CONTACT_KEY) + + assertEquals(failure, result.exceptionOrNull()) + verifyBlocking(publicPaykitRepo, never()) { syncPaykitApp(anyOrNull()) } + assertTrue(settingsData.value.publicPaykitCleanupPending) + assertTrue(CONTACT_KEY in cacheData.value.deletedContactCleanupPendingPublicKeys) + } + + @Test + fun `deleted contact cleanup retries registry update after withdrawal`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) + cacheData.value = PrivatePaykitCacheData(contacts = mapOf(CONTACT_KEY to cachedPublishedContact())) + sut = createSut() + whenever(publicPaykitRepo.syncPaykitApp()).thenReturn( + Result.failure(AppError("Registry unavailable")), + Result.success(Unit), + ) + + assertTrue(sut.removeSavedContact(CONTACT_KEY).isFailure) + assertTrue(settingsData.value.publicPaykitCleanupPending) + assertTrue(CONTACT_KEY in cacheData.value.deletedContactCleanupPendingPublicKeys) + + sut.retryPendingEndpointRemoval(emptyList()).getOrThrow() + + assertFalse(CONTACT_KEY in cacheData.value.deletedContactCleanupPendingPublicKeys) + verifyBlocking(publicPaykitRepo, never()) { syncPaykitApp(true) } + verifyBlocking(publicPaykitRepo, times(2)) { syncPaykitApp() } + } + @Test fun `disable sharing removes onchain-only private publications from cache`() = test { settingsData.value = SettingsData( diff --git a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt index 16207eaafe..6ab7c4ceb2 100644 --- a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt @@ -17,8 +17,6 @@ import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever -import to.bitkit.data.PrivatePaykitCacheData -import to.bitkit.data.PrivatePaykitCacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.services.CoreService @@ -47,13 +45,11 @@ class PublicPaykitRepoTest : BaseUnitTest() { private val coreService = mock() private val paykitSdkService = mock() private val settingsStore = mock() - private val privatePaykitCacheStore = mock() private val clock = mock() private val publicKey = MutableStateFlow("pubkyself") private val walletState = MutableStateFlow(WalletState()) private val settingsFlow = MutableStateFlow(SettingsData()) - private val privateCacheFlow = MutableStateFlow(PrivatePaykitCacheData()) private lateinit var sut: PublicPaykitRepo @@ -67,7 +63,6 @@ class PublicPaykitRepoTest : BaseUnitTest() { whenever(pubkyRepo.publicKey).thenReturn(publicKey) whenever(walletRepo.walletState).thenReturn(walletState) whenever(settingsStore.data).thenReturn(settingsFlow) - whenever(privatePaykitCacheStore.data).thenReturn(privateCacheFlow) whenever(clock.now()).thenReturn(Instant.fromEpochMilliseconds(NOW_MILLIS)) whenever(paykitSdkService.syncPublicEndpoints(any())).thenReturn(syncReport()) whenever { walletRepo.refreshReusableReceiveAddress() }.thenReturn(Result.success(Unit)) @@ -84,18 +79,14 @@ class PublicPaykitRepoTest : BaseUnitTest() { } @Test - fun `private capability remains enabled until all pending cleanup is complete`() = test { - for (pending in listOf( - PrivatePaykitCacheData(cleanupPending = true), - PrivatePaykitCacheData(deletedContactCleanupPendingPublicKeys = setOf("pubkycontact")), - PrivatePaykitCacheData(), - )) { - privateCacheFlow.value = pending - sut.syncPaykitApp(privateSharingEnabled = false).getOrThrow() + fun `private capability follows sharing preference`() = test { + for (enabled in listOf(false, true)) { + settingsFlow.value = settingsFlow.value.copy(sharesPrivatePaykitEndpoints = enabled) + sut.syncPaykitApp().getOrThrow() } val capabilities = argumentCaptor() - verifyBlocking(paykitSdkService, times(3)) { syncPaykitApp(capabilities.capture()) } - assertEquals(listOf(true, true, false), capabilities.allValues) + verifyBlocking(paykitSdkService, times(2)) { syncPaykitApp(capabilities.capture()) } + assertEquals(listOf(false, true), capabilities.allValues) } @Test @@ -311,7 +302,6 @@ class PublicPaykitRepoTest : BaseUnitTest() { coreService = coreService, paykitSdkService = paykitSdkService, settingsStore = settingsStore, - privatePaykitCacheStore = privatePaykitCacheStore, clock = clock, ) diff --git a/app/src/test/java/to/bitkit/services/PaykitKeyGenerationTest.kt b/app/src/test/java/to/bitkit/services/PaykitKeyGenerationTest.kt index 6b6c51f9bf..364e4d9cc2 100644 --- a/app/src/test/java/to/bitkit/services/PaykitKeyGenerationTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitKeyGenerationTest.kt @@ -45,7 +45,7 @@ class PaykitKeyGenerationTest { whenever(keychain.loadString(storageKey)).thenReturn(null, generation.toString()) val key = mock() val bytes = ByteArray(32) { 1 } - val service = PaykitSdkService(mock(), keychain, mock()) { sdk } + val service = PaykitSdkService(mock(), keychain, mock(), settingsStore = mock()) { sdk } mockStatic(Class.forName("com.synonym.paykit.Paykit_androidKt")).use { native -> native.`when` { pubkyPublicKeyFromSecret(any()) }.thenReturn(RING_PUBKY) @@ -88,7 +88,7 @@ class PaykitKeyGenerationTest { mockConstruction(PaykitSdkSessionProvider::class.java) { provider, _ -> whenever(provider.loadLocalSecretKey()).thenReturn(root) }.use { providers -> - val service = PaykitSdkService(mock(), keychain, mock()) { sdk } + val service = PaykitSdkService(mock(), keychain, mock(), settingsStore = mock()) { sdk } val provider = providers.constructed().single() repeat(2) { assertEquals(RING_PUBKY, service.currentPublicKey()) } repeat(2) { diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 7e564a5259..3fd488337c 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -4,6 +4,9 @@ import com.synonym.paykit.ContactRecord import com.synonym.paykit.IdentityStatus import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState +import com.synonym.paykit.PaykitApp +import com.synonym.paykit.PaykitAppCapabilities +import com.synonym.paykit.PaykitAppRegistry import com.synonym.paykit.PaykitException import com.synonym.paykit.PaykitIdentitySecretKey import com.synonym.paykit.PaykitSdk @@ -42,6 +45,8 @@ import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import to.bitkit.data.PubkyStore import to.bitkit.data.PubkyStoreData +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.data.keychain.KeychainError import to.bitkit.data.sharedpubky.SharedPubkyClient @@ -78,6 +83,7 @@ class PaykitSdkServiceTest { mock(), mock(), ioDispatcher = StandardTestDispatcher(testScheduler), + settingsStore = mock(), platformInitializer = { wipe = async(start = CoroutineStart.UNDISPATCHED) { service.withWalletWipe { @@ -101,7 +107,7 @@ class PaykitSdkServiceTest { val sdk = mock() whenever(sdk.contactRecords()).thenReturn(emptyList()) var handlesCreated = 0 - val service = PaykitSdkService(mock(), mock(), mock()) { + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { handlesCreated++ sdk } @@ -182,7 +188,7 @@ class PaykitSdkServiceTest { sdk.identityStatus() ).thenReturn(IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(sdk.listPaymentRequests(any())).thenReturn(listOf(server, bitkit)) - val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } assertEquals(listOf(server, bitkit), service.allPaymentRequests(RING_PUBKY)) assertEquals(listOf(bitkit), service.paymentRequests()) @@ -194,7 +200,7 @@ class PaykitSdkServiceTest { whenever( sdk.identityStatus() ).thenReturn(IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) - val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } assertFailsWith { service.allPaymentRequests("a3mduedw686dysw8ndr5c1dyry5h8k6i8hzbbmx9gf9k43zq4s9o") @@ -231,7 +237,7 @@ class PaykitSdkServiceTest { for ((authUrl, companion) in invalidRequests) { val keychain = mock() val sdk = mock() - val service = PaykitSdkService(mock(), keychain, mock()) { sdk } + val service = PaykitSdkService(mock(), keychain, mock(), settingsStore = mock()) { sdk } assertTrue( runSuspendCatching { service.approveAuthWithCompanionClaim( @@ -281,7 +287,7 @@ class PaykitSdkServiceTest { var handlesCreated = 0 val store = mock() whenever(store.data).thenReturn(flowOf(PubkyStoreData())) - val service = PaykitSdkService(mock(), keychain, store) { + val service = PaykitSdkService(mock(), keychain, store, settingsStore = mock()) { handlesCreated++ sdk } @@ -320,7 +326,7 @@ class PaykitSdkServiceTest { whenever(sdk.blockPeer(RING_PUBKY)) .thenThrow(IllegalStateException("storage failure")) } - val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } if (failBlock) { assertFailsWith { service.removeContact(RING_PUBKY) } verify(sdk, never()).removeContact(any()) @@ -352,7 +358,7 @@ class PaykitSdkServiceTest { } whenever(sdk.linkedPeers()).thenReturn(emptyList()) whenever(sdk.paymentRequests()).thenReturn(listOf(request)) - val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } assertFailsWith { service.removeContact(RING_PUBKY) } verify(sdk, never()).blockPeer(any()) verify(sdk, never()).removeContact(any()) @@ -378,7 +384,7 @@ class PaykitSdkServiceTest { whenever(store.data).thenReturn(flow { throw error }) val access = mock() whenever(access.exportSessionSecret()).thenReturn("new-session") - val service = PaykitSdkService(mock(), keychain, store) { sdk } + val service = PaykitSdkService(mock(), keychain, store, settingsStore = mock()) { sdk } val thrown = assertFailsWith { service.activateRegisteredIdentity( @@ -413,7 +419,7 @@ class PaykitSdkServiceTest { PrivatePaymentListDeliveryReport(emptyList(), emptyList(), emptyList(), emptyList()), ) } - val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } service.removeContact(RING_PUBKY) inOrder(sdk) { verify(sdk).clearPrivatePaymentListAndProcessOutbound(RING_PUBKY) @@ -454,7 +460,7 @@ class PaykitSdkServiceTest { whenever(noise.exportBytes()).thenReturn(ByteArray(32) { 1 }) whenever(access.exportSessionSecret()).thenReturn("new-session") whenever(access.exportPaykitIdentitySecretKey()).thenReturn(noise) - val service = PaykitSdkService(mock(), keychain, store, { bootstrap }) { sdk } + val service = PaykitSdkService(mock(), keychain, store, { bootstrap }, settingsStore = mock()) { sdk } val result = PubkySessionBootstrapResult( @@ -493,7 +499,7 @@ class PaykitSdkServiceTest { contactPeer(LinkedPeerState.BLOCKED), ), ) - val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } if (restoreConnection) { service.saveContact(RING_PUBKY, "Contact", restorePrivateConnection = true) verify(sdk).unblockPeer(RING_PUBKY) @@ -527,7 +533,7 @@ class PaykitSdkServiceTest { } "save" -> whenever(sdk.saveContact(any())).thenThrow(failure).thenReturn(mock()) } - val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } val thrown = assertFailsWith { service.saveContact(RING_PUBKY, "Contact", restorePrivateConnection = true) } @@ -553,7 +559,7 @@ class PaykitSdkServiceTest { whenever(sdk.linkedPeers()).thenReturn(peers) whenever(sdk.saveContact(any())).thenThrow(restorationFailure) whenever(sdk.blockPeer(RING_PUBKY)).thenThrow(rollbackFailure) - val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } val thrown = assertFailsWith { service.saveContact(RING_PUBKY, "Contact", restorePrivateConnection = true) @@ -568,11 +574,54 @@ class PaykitSdkServiceTest { fun `blocked peer cleanup does not attempt network delivery`() = runTest { val sdk = mock() whenever(sdk.linkedPeers()).thenReturn(listOf(contactPeer(LinkedPeerState.BLOCKED))) - val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } assertNull(service.clearPrivatePaymentList(RING_PUBKY)) verify(sdk, never()).clearPrivatePaymentListAndProcessOutbound(any()) } + @Test + fun `disabled private capability does not queue withdrawal`() = runTest { + val sdk = mock() + val capabilities = PaykitAppCapabilities(false, true, false, true) + whenever(sdk.linkedPeers()).thenReturn(listOf(contactPeer(LinkedPeerState.LINKED))) + whenever(sdk.identityStatus()).thenReturn( + IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE), + ) + whenever(sdk.paykitAppRegistry(RING_PUBKY)).thenReturn( + PaykitAppRegistry(1u, null, listOf(PaykitApp("bitkit", "Bitkit", capabilities)), null, emptyMap()), + ) + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + + assertNull(service.clearPrivatePaymentList(RING_PUBKY)) + + verify(sdk, never()).clearPrivatePaymentListAndProcessOutbound(any()) + verify(sdk, never()).publishPaykitApp(any(), any()) + } + + @Test + fun `initialization publishes the saved private sharing preference`() = runTest { + for (enabled in listOf(false, true)) { + val sdk = mock() + val settingsStore = mock() + whenever(settingsStore.data).thenReturn(flowOf(SettingsData(sharesPrivatePaykitEndpoints = enabled))) + whenever(sdk.identityStatus()).thenReturn( + IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE), + ) + val service = PaykitSdkService( + mock(), + mock(), + mock(), + ioDispatcher = StandardTestDispatcher(testScheduler), + platformInitializer = {}, + settingsStore = settingsStore, + ) { sdk } + + service.initialize() + + verify(sdk).publishPaykitApp("Bitkit", PaykitAppCapabilities(enabled, true, false, true)) + } + } + private fun contactPeer(state: LinkedPeerState) = LinkedPeerRecord( counterparty = RING_PUBKY, state = state, diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceWipeTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceWipeTest.kt index 40d00af7d9..cb00fea911 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceWipeTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceWipeTest.kt @@ -100,7 +100,14 @@ class PaykitSdkServiceWipeTest { } val store = mock { on { data } doReturn flowOf(PubkyStoreData()) } val dispatcher = StandardTestDispatcher(testScheduler) - val service = PaykitSdkService(mock(), keychain, store, { bootstrap }, dispatcher) { sdk } + val service = PaykitSdkService( + mock(), + keychain, + store, + { bootstrap }, + dispatcher, + settingsStore = mock(), + ) { sdk } mockStatic(Class.forName("com.synonym.paykit.Paykit_androidKt")).use { native -> native.`when` { requiredSessionCapabilities() }.thenReturn("capabilities") diff --git a/app/src/test/java/to/bitkit/services/PubkyIdentityRepublishTest.kt b/app/src/test/java/to/bitkit/services/PubkyIdentityRepublishTest.kt index 4037beda38..d5d2ccdffe 100644 --- a/app/src/test/java/to/bitkit/services/PubkyIdentityRepublishTest.kt +++ b/app/src/test/java/to/bitkit/services/PubkyIdentityRepublishTest.kt @@ -43,6 +43,7 @@ class PubkyIdentityRepublishTest { mock(), { bootstrap }, StandardTestDispatcher(testScheduler), + settingsStore = mock(), ) { mock() } service.republishIdentityIfNeeded(publicKey, now = 2_592_000_000) @@ -73,6 +74,7 @@ class PubkyIdentityRepublishTest { mock(), { bootstrap }, StandardTestDispatcher(testScheduler), + settingsStore = mock(), ) { mock() } service.republishIdentityIfNeeded(publicKey, now = 0) @@ -100,6 +102,7 @@ class PubkyIdentityRepublishTest { context = mock(), keychain = mock(), pubkyStore = mock(), + settingsStore = mock(), bootstrapFactory = { factories++ bootstrap @@ -129,6 +132,7 @@ class PubkyIdentityRepublishTest { context = mock(), keychain = mock(), pubkyStore = mock(), + settingsStore = mock(), bootstrapFactory = { bootstrap }, ioDispatcher = StandardTestDispatcher(testScheduler), sdkFactory = { mock() }, @@ -153,6 +157,7 @@ class PubkyIdentityRepublishTest { mock(), { bootstrap }, StandardTestDispatcher(testScheduler), + settingsStore = mock(), ) { sdk } val otherKey = publicKey.dropLast(1) + "y" @@ -176,6 +181,7 @@ class PubkyIdentityRepublishTest { mock(), { bootstrap }, StandardTestDispatcher(testScheduler), + settingsStore = mock(), ) { mock() } val first = async { service.republishIdentityIfNeeded(publicKey, now = 0) } runCurrent() @@ -204,6 +210,7 @@ class PubkyIdentityRepublishTest { mock(), { bootstrap }, StandardTestDispatcher(testScheduler), + settingsStore = mock(), ) { mock() } service.republishIdentityIfNeeded(publicKey, now = 0) @@ -237,6 +244,7 @@ class PubkyIdentityRepublishTest { mock(), { bootstrap }, StandardTestDispatcher(testScheduler), + settingsStore = mock(), ) { mock() } var continued = false val cancelledCaller = async { diff --git a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt index 13e102b7b8..fc7f2d144b 100644 --- a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt @@ -61,7 +61,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { private val privatePaykitAddressReservationRepo = mock() private val firebaseMessaging = mock() private val migrationService = mock() - private val paykitSdkService = PaykitSdkService(mock(), keychain, mock()) { mock() } + private val paykitSdkService = PaykitSdkService(mock(), keychain, mock(), settingsStore = mock()) { mock() } private val privatePaykitRepoProvider = Provider { privatePaykitRepo } private lateinit var sut: WipeWalletUseCase From 8f055853c56e5baa16b70fcaebed8bc27603fd33 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 1 Oct 2026 12:34:35 -0500 Subject: [PATCH 46/51] test: align request presentation with shared paykit --- .../test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt | 2 +- journeys/payment-requests/automatic-presentation.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 13fe5f01e9..0665807f26 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -934,7 +934,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { Result.success( PublicPaykitPaymentResult.Opened( paymentRequest = bolt11, - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 8uL), + privatePaymentContext = privatePaymentContext(version = 8uL), ), ), ) diff --git a/journeys/payment-requests/automatic-presentation.xml b/journeys/payment-requests/automatic-presentation.xml index 47b5009cdd..8f19ff2dc7 100644 --- a/journeys/payment-requests/automatic-presentation.xml +++ b/journeys/payment-requests/automatic-presentation.xml @@ -1,6 +1,6 @@ - Verifies that a newly received request opens automatically in the foreground, waits for another sheet to close, and does not reopen a request the payer already reviewed. Requires two Bitkit instances saved as each other's contacts and linked on receiver path "bitkit/wallet", with the payer funded for 21,000 sats; see README.md. + Verifies that a newly received request opens automatically in the foreground, waits for another sheet to close, and does not reopen a request the payer already reviewed. Requires two Bitkit instances with separate Pubky identities, saved as each other's contacts and linked, with the payer funded for 21,000 sats; see README.md. On the payer, leave Bitkit unlocked and in the foreground on Home From 0af40d5d7ce81eb70ebaf10fb85e68aaffe6eafe Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 1 Oct 2026 12:56:19 -0500 Subject: [PATCH 47/51] test: align private paykit settings stub --- .../test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index d2f5cdce93..bfa098398a 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -113,7 +113,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { cacheData.value = PrivatePaykitCacheData() } whenever(settingsStore.data).thenReturn(settingsData) - whenever { settingsStore.update(any()) }.thenAnswer { + whenever(settingsStore.update(any())).thenAnswer { val transform = it.getArgument<(SettingsData) -> SettingsData>(0) settingsData.value = transform(settingsData.value) } From 6125336016385b15d22ac67219cee16fca68bd4e Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 1 Oct 2026 16:53:39 -0500 Subject: [PATCH 48/51] fix: reduce paykit sync overhead and retry session setup --- .../ContactPaymentSettingsRepo.kt | 12 +- .../to/bitkit/services/PaykitSdkService.kt | 172 ++++++++++++------ .../java/to/bitkit/viewmodels/AppViewModel.kt | 1 + .../ContactPaymentSettingsRepoTest.kt | 25 +++ .../services/PaykitBackupStateTrackingTest.kt | 78 ++++++++ .../services/PaykitKeyGenerationTest.kt | 61 ++++++- .../bitkit/services/PaykitSdkServiceTest.kt | 43 ++++- .../viewmodels/AppViewModelSendFlowTest.kt | 29 +++ journeys/contacts/README.md | 10 + journeys/contacts/contact-payment-sharing.xml | 20 ++ 10 files changed, 371 insertions(+), 80 deletions(-) create mode 100644 journeys/contacts/contact-payment-sharing.xml diff --git a/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt b/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt index e21e6faa0d..6feff41ab7 100644 --- a/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt @@ -84,7 +84,6 @@ class ContactPaymentSettingsRepo @Inject constructor( } private suspend fun disable(contacts: List): Result { - val previous = settingsStore.data.first() runSuspendCatching { settingsStore.update { it.copy( @@ -108,16 +107,7 @@ class ContactPaymentSettingsRepo @Inject constructor( .onFailure { publicCleanupError = it } publicCleanupError?.let { error -> - runSuspendCatching { - settingsStore.update { settings -> - settings.copy(sharesPublicPaykitEndpoints = previous.sharesPublicPaykitEndpoints) - } - }.onFailure(error::addSuppressed) - publicPaykitRepo.syncPublishedEndpoints(publish = previous.sharesPublicPaykitEndpoints) - .onFailure { - error.addSuppressed(it) - markPublicPaykitRetry(error) - } + markPublicPaykitRetry(error) } val cleanupError = publicCleanupError ?: privateCleanupError publicCleanupError?.let { publicError -> diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 9e60d814ab..4957f3007e 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -1,6 +1,7 @@ package to.bitkit.services import android.content.Context +import androidx.annotation.VisibleForTesting import com.synonym.paykit.ContactRecord import com.synonym.paykit.ContactUpdate import com.synonym.paykit.EndpointSyncReport @@ -181,6 +182,8 @@ class PaykitSdkService @Inject constructor( private var setupFailed = false private var platformInitializer: () -> Unit = { PaykitAndroid.initializeOrThrow(context) } private var sdk: PaykitSdk? = null + private var cachedPaykitKey: PaykitKeyGeneration? = null + private var cachedBackupState: PaykitBackupStateSnapshot? = null private val _backupStateVersion = MutableStateFlow(0L) val backupStateVersion: StateFlow = _backupStateVersion.asStateFlow() private var sdkFactory: () -> PaykitSdk = { @@ -226,11 +229,12 @@ class PaykitSdkService @Inject constructor( platformInitializer() launch { republishIdentityIfNeeded() } operationLock.withLock { - refreshPaykitKey() + refreshPaykitKey(force = true) var handle = handle() try { handle.initialize() } catch (e: PaykitException.Identity) { + invalidatePaykitKeyIfNeeded(e) if (!sessionProvider.canDeferStaleSession(e.context)) throw e Logger.warn( @@ -304,18 +308,16 @@ class PaykitSdkService @Inject constructor( suspend fun currentPublicKey(): String? { isSetup.await() return operationLock.withLock { - refreshPaykitKey() - val handle = handle() - handle.identityStatus()?.publicKey?.let { return@withLock it } - handle.initialize().publicKey + withPaykitKey { handle -> + handle.identityStatus()?.publicKey ?: handle.initialize().publicKey + } } } suspend fun hasPrivatePaymentAccess(): Boolean { isSetup.await() return operationLock.withLock { - refreshPaykitKey() - handle().identityStatus()?.capability == PubkyIdentityCapability.PRIVATE_LINK_CAPABLE + withPaykitKey { it.identityStatus()?.capability == PubkyIdentityCapability.PRIVATE_LINK_CAPABLE } } } @@ -470,12 +472,12 @@ class PaykitSdkService @Inject constructor( suspend fun publishPaykitProfile(profile: PaykitProfile): PaykitProfileRecord { isSetup.await() return operationLock.withLock { - refreshPaykitKey() - val handle = handle() - val publicKey = requireNotNull(handle.identityStatus()?.publicKey) - val current = handle.fetchPaykitProfile(publicKey) - handle.publishPaykitProfile(profile, current?.revision).also { - notifyBackupStateChanged() + withPaykitKey { handle -> + val publicKey = requireNotNull(handle.identityStatus()?.publicKey) + val current = handle.fetchPaykitProfile(publicKey) + handle.publishPaykitProfile(profile, current?.revision).also { + notifyBackupStateChanged() + } } } } @@ -499,11 +501,11 @@ class PaykitSdkService @Inject constructor( suspend fun deletePaykitProfile() { isSetup.await() operationLock.withLock { - refreshPaykitKey() - val handle = handle() - val publicKey = requireNotNull(handle.identityStatus()?.publicKey) - handle.fetchPaykitProfile(publicKey)?.let { handle.deletePaykitProfile(it.revision) } - notifyBackupStateChanged() + withPaykitKey { handle -> + val publicKey = requireNotNull(handle.identityStatus()?.publicKey) + handle.fetchPaykitProfile(publicKey)?.let { handle.deletePaykitProfile(it.revision) } + notifyBackupStateChanged() + } } } @@ -524,16 +526,14 @@ class PaykitSdkService @Inject constructor( suspend fun contactRecords(): List { isSetup.await() return operationLock.withLock { - refreshPaykitKey() - handle().contactRecords() + withPaykitKey { it.contactRecords() } } } suspend fun contactRecord(publicKey: String): ContactRecord? { isSetup.await() return operationLock.withLock { - refreshPaykitKey() - handle().contactRecord(publicKey) + withPaykitKey { it.contactRecord(publicKey) } } } @@ -583,6 +583,7 @@ class PaykitSdkService @Inject constructor( Logger.warn("Failed to withdraw private endpoints before contact deletion", context = TAG) } }.onFailure { + invalidatePaykitKeyIfNeeded(it) Logger.warn("Failed to withdraw private endpoints before contact deletion", it, context = TAG) } } @@ -702,30 +703,29 @@ class PaykitSdkService @Inject constructor( suspend fun allPaymentRequests(expectedIdentity: String? = null): List { isSetup.await() return operationLock.withLock { - refreshPaykitKey() - val handle = handle() - if (expectedIdentity != null) { - check(PubkyPublicKeyFormat.matches(handle.identityStatus()?.publicKey, expectedIdentity)) { - "Payment Request identity changed" + withPaykitKey { handle -> + if (expectedIdentity != null) { + check(PubkyPublicKeyFormat.matches(handle.identityStatus()?.publicKey, expectedIdentity)) { + "Payment Request identity changed" + } } - } - handle.listPaymentRequests( - PaymentRequestFilter( - counterparty = null, - localRole = null, - states = emptyList(), - recurring = null, - receivedOnly = false, + handle.listPaymentRequests( + PaymentRequestFilter( + counterparty = null, + localRole = null, + states = emptyList(), + recurring = null, + receivedOnly = false, + ), ) - ) + } } } suspend fun identityStatus(): IdentityStatus? { isSetup.await() return operationLock.withLock { - refreshPaykitKey() - handle().identityStatus() + withPaykitKey { it.identityStatus() } } } @@ -853,16 +853,14 @@ class PaykitSdkService @Inject constructor( suspend fun linkedPeers(): List { isSetup.await() return operationLock.withLock { - refreshPaykitKey() - handle().linkedPeers() + withPaykitKey { it.linkedPeers() } } } suspend fun pendingOutboundPrivateCounterparties(): List { isSetup.await() return operationLock.withLock { - refreshPaykitKey() - handle().pendingOutboundPrivateCounterparties() + withPaykitKey { it.pendingOutboundPrivateCounterparties() } } } @@ -948,8 +946,7 @@ class PaykitSdkService @Inject constructor( suspend fun exportBackupState(): String { isSetup.await() return operationLock.withLock { - refreshPaykitKey() - handle().exportBackupString() + withPaykitKey { it.exportBackupString() } } } @@ -1003,12 +1000,24 @@ class PaykitSdkService @Inject constructor( } } - private suspend fun refreshPaykitKey() { - val root = sessionProvider.loadLocalSecretKey() ?: return - sessionProvider.setPaykitIdentitySecretKey(paykitKey(root)) + private suspend fun refreshPaykitKey(force: Boolean = false) { + if (force) cachedPaykitKey = null + val root = sessionProvider.loadLocalSecretKey() ?: run { + cachedPaykitKey = null + return + } + val publicKey = pubkyPublicKeyFromSecret(root) + val savedGeneration = keychain.loadString("${Keychain.Key.PAYKIT_KEY_GENERATION.name}:$publicKey")?.toULong() + val cached = cachedPaykitKey + if (cached != null && cached.publicKey == publicKey && cached.generation == savedGeneration) return + cachedPaykitKey = null + val key = paykitKey(root) + sessionProvider.setPaykitIdentitySecretKey(key) + cachedPaykitKey = PaykitKeyGeneration(publicKey, key.keyGeneration()) } - suspend fun paykitKeyForAuthorization(secretKeyHex: String): PaykitIdentitySecretKey { + @VisibleForTesting + internal suspend fun paykitKeyForAuthorization(secretKeyHex: String): PaykitIdentitySecretKey { isSetup.await() return operationLock.withLock { paykitKey(localSecretKey(secretKeyHex)) } } @@ -1067,6 +1076,7 @@ class PaykitSdkService @Inject constructor( ) } }.onFailure { + invalidatePaykitKeyIfNeeded(it) Logger.warn("Failed to publish Paykit app", it, context = TAG) } } @@ -1106,18 +1116,39 @@ class PaykitSdkService @Inject constructor( blockedPeers.forEach { peer -> runSuspendCatching { handle.blockPeer(peer.counterparty) - }.onFailure(restorationError::addSuppressed) + }.onFailure { + invalidatePaykitKeyIfNeeded(it) + restorationError.addSuppressed(it) + } } } } } } - private suspend fun withStateRevisionTracking(block: suspend (PaykitSdk) -> T): T { + private suspend fun withPaykitKey(block: suspend (PaykitSdk) -> T): T { refreshPaykitKey() - val handle = handle() - return withPaykitBackupStateTracking( - readRevision = { handle.backupStateRevision() }, + return runSuspendCatching { block(handle()) } + .onFailure(::invalidatePaykitKeyIfNeeded) + .getOrThrow() + } + + private fun invalidatePaykitKeyIfNeeded(error: Throwable) { + if (error !is PaykitException.Identity) return + cachedPaykitKey = null + cachedBackupState = null + } + + private suspend fun withStateRevisionTracking(block: suspend (PaykitSdk) -> T): T = withPaykitKey { handle -> + withPaykitBackupStateTracking( + readRevision = { + runSuspendCatching { handle.backupStateRevision() } + .onFailure(::invalidatePaykitKeyIfNeeded) + .getOrThrow() + }, + readStateRevision = { handle.stateRevision() }, + cachedSnapshot = cachedBackupState, + onSnapshot = { cachedBackupState = it }, onChange = ::notifyBackupStateChanged, ) { block(handle) @@ -1141,6 +1172,8 @@ class PaykitSdkService @Inject constructor( private fun resetRuntime() { sdk = null + cachedPaykitKey = null + cachedBackupState = null } companion object { @@ -1185,17 +1218,46 @@ internal fun isBitkitPaymentRequest(record: PaymentRequestRecord): Boolean = whe else -> false } +private data class PaykitKeyGeneration(val publicKey: String, val generation: ULong) + +internal data class PaykitBackupStateSnapshot(val stateRevision: String, val backupRevision: String) + +@Suppress("LongParameterList") internal suspend fun withPaykitBackupStateTracking( readRevision: suspend () -> String, + readStateRevision: () -> String? = { null }, + cachedSnapshot: PaykitBackupStateSnapshot? = null, + onSnapshot: (PaykitBackupStateSnapshot?) -> Unit = {}, onChange: () -> Unit, operation: suspend () -> T, ): T { - val previousRevision = runSuspendCatching { readRevision() }.getOrNull() + val observedStateRevision = runSuspendCatching { readStateRevision() }.getOrNull() + val previousRevision = if (cachedSnapshot != null && observedStateRevision == cachedSnapshot.stateRevision) { + cachedSnapshot.backupRevision + } else { + runSuspendCatching { readRevision() }.getOrNull() + } + val previousStateRevision = runSuspendCatching { readStateRevision() }.getOrNull() + var succeeded = false return try { - operation() + operation().also { succeeded = true } } finally { withContext(NonCancellable) { + val nextStateRevision = runSuspendCatching { readStateRevision() }.getOrNull() + val unchangedRevision = previousStateRevision?.takeIf { it == nextStateRevision } + if (succeeded && unchangedRevision != null && previousRevision != null) { + onSnapshot(PaykitBackupStateSnapshot(unchangedRevision, previousRevision)) + return@withContext + } val nextRevision = runSuspendCatching { readRevision() }.getOrNull() + val stateRevision = runSuspendCatching { readStateRevision() }.getOrNull() + onSnapshot( + if (succeeded && stateRevision != null && nextRevision != null) { + PaykitBackupStateSnapshot(stateRevision, nextRevision) + } else { + null + }, + ) if (previousRevision == null || nextRevision == null || previousRevision != nextRevision) { onChange() } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 1affda6575..3d953dc9d8 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -902,6 +902,7 @@ class AppViewModel @Inject constructor( if (isOnline.value != ConnectivityState.CONNECTED) continue val refreshMaintenance = maintenanceDelay <= Duration.ZERO if (refreshMaintenance) { + if (isPaykitEnabled.value && walletRepo.walletExists()) pubkyRepo.restoreSessionIfNeeded() pubkyRepo.republishIdentityIfNeeded() privatePaykitRepo.refreshKnownSavedContactEndpoints("payment request polling") maintenanceIntervalIndex = diff --git a/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt index b9efee5774..0bf9ffe89b 100644 --- a/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt @@ -18,6 +18,7 @@ import to.bitkit.models.PubkyProfile import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError import kotlin.test.assertFalse +import kotlin.test.assertSame import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) @@ -160,6 +161,30 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { verify(privatePaykitRepo, never()).enableSharingAndPrepareSavedContacts(any>()) } + @Test + fun `failed public cleanup keeps sharing disabled and retains its retry`() = test { + val cleanupResults = listOf(Result.success(Unit), Result.failure(ContactPaymentSettingsTestError("withdrawal"))) + for (privateCleanup in cleanupResults) { + settingsFlow.value = SettingsData( + sharesPublicPaykitEndpoints = true, + sharesPrivatePaykitEndpoints = true, + ) + val failure = ContactPaymentSettingsTestError("app update failed") + whenever(publicPaykitRepo.syncPublishedEndpoints(publish = false)).thenReturn(Result.failure(failure)) + whenever(privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>())) + .thenReturn(privateCleanup) + + val result = createSut().setEnabled(false) + + assertSame(failure, result.exceptionOrNull()) + assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) + assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) + assertTrue(settingsFlow.value.publicPaykitCleanupPending) + verify(publicPaykitRepo, never()).syncPublishedEndpoints(publish = true) + verify(privatePaykitRepo, never()).enableSharingAndPrepareSavedContacts(any>()) + } + } + private fun createSut() = ContactPaymentSettingsRepo( settingsStore = settingsStore, publicPaykitRepo = publicPaykitRepo, diff --git a/app/src/test/java/to/bitkit/services/PaykitBackupStateTrackingTest.kt b/app/src/test/java/to/bitkit/services/PaykitBackupStateTrackingTest.kt index 408ab4e501..24a8e57509 100644 --- a/app/src/test/java/to/bitkit/services/PaykitBackupStateTrackingTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitBackupStateTrackingTest.kt @@ -10,6 +10,7 @@ import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertNull import kotlin.test.assertSame import kotlin.test.assertTrue @@ -80,4 +81,81 @@ class PaykitBackupStateTrackingTest : BaseUnitTest() { assertEquals("after", revision) assertEquals(1, changes) } + + @Test + fun `unchanged operations reuse the backup fingerprint`() = test { + var snapshot: PaykitBackupStateSnapshot? = null + var reads = 0 + var changes = 0 + repeat(3) { + withPaykitBackupStateTracking( + readRevision = { + reads++ + "content" + }, + readStateRevision = { "state" }, + cachedSnapshot = snapshot, + onSnapshot = { snapshot = it }, + onChange = { changes++ }, + ) {} + } + assertEquals(1, reads) + assertEquals(0, changes) + assertEquals(PaykitBackupStateSnapshot("state", "content"), snapshot) + } + + @Test + fun `changed state revisions still compare backup content`() = test { + for ((cachedState, finalContent, expectedReads) in listOf( + Triple("before", "content", 1), + Triple("before", "changed", 1), + Triple("stale", "changed", 2), + )) { + var state = "before" + var content = "content" + var reads = 0 + var changes = 0 + var snapshot: PaykitBackupStateSnapshot? = null + withPaykitBackupStateTracking( + readRevision = { + reads++ + content + }, + readStateRevision = { state }, + cachedSnapshot = PaykitBackupStateSnapshot(cachedState, "content"), + onSnapshot = { snapshot = it }, + onChange = { changes++ }, + ) { + state = "after" + content = finalContent + } + assertEquals(expectedReads, reads) + assertEquals(if (finalContent == "content") 0 else 1, changes) + assertEquals(PaykitBackupStateSnapshot("after", finalContent), snapshot) + } + } + + @Test + fun `failed writes recheck backup content even when the local revision is unchanged`() = test { + var snapshot: PaykitBackupStateSnapshot? = PaykitBackupStateSnapshot("state", "before") + var reads = 0 + var changes = 0 + val failure = AppError("Write outcome unknown") + val thrown = assertFailsWith { + withPaykitBackupStateTracking( + readRevision = { + reads++ + "after" + }, + readStateRevision = { "state" }, + cachedSnapshot = snapshot, + onSnapshot = { snapshot = it }, + onChange = { changes++ }, + ) { throw failure } + } + assertSame(failure, thrown) + assertEquals(1, reads) + assertEquals(1, changes) + assertNull(snapshot) + } } diff --git a/app/src/test/java/to/bitkit/services/PaykitKeyGenerationTest.kt b/app/src/test/java/to/bitkit/services/PaykitKeyGenerationTest.kt index 364e4d9cc2..09bae705a5 100644 --- a/app/src/test/java/to/bitkit/services/PaykitKeyGenerationTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitKeyGenerationTest.kt @@ -2,6 +2,7 @@ package to.bitkit.services import com.synonym.paykit.IdentityStatus import com.synonym.paykit.PaykitAppRegistry +import com.synonym.paykit.PaykitException import com.synonym.paykit.PaykitIdentitySecretKey import com.synonym.paykit.PaykitSdk import com.synonym.paykit.PubkyIdentityCapability @@ -66,20 +67,24 @@ class PaykitKeyGenerationTest { } @Test - fun `stored root refresh rotates the session key and rejects registry rollback`() = runTest { + fun `cached keys refresh after remote rotation and reject registry rollback`() = runTest { val initialRegistry = mock { on { keyGeneration }.thenReturn(2uL) } val rotatedRegistry = mock { on { keyGeneration }.thenReturn(3uL) } val sdk = mock() whenever(sdk.paykitAppRegistry(RING_PUBKY)) .thenReturn(initialRegistry, rotatedRegistry, initialRegistry, null) - whenever(sdk.identityStatus()) - .thenReturn(IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + val status = IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE) + val staleKey = PaykitException.Identity("identity_error", "Shared state requires a newer key") + whenever(sdk.identityStatus()).thenReturn(status, status).thenThrow(staleKey).thenReturn(status) + .thenThrow(staleKey) val keychain = mock() val storageKey = "${Keychain.Key.PAYKIT_KEY_GENERATION.name}:$RING_PUBKY" - whenever(keychain.loadString(storageKey)).thenReturn("2", "2", "3", "3") + var savedGeneration = "2" + whenever(keychain.loadString(storageKey)).thenAnswer { savedGeneration } + whenever(keychain.upsertString(storageKey, "3")).thenAnswer { savedGeneration = "3" } val root = mock() - val initialKey = mock() - val rotatedKey = mock() + val initialKey = mock { on { keyGeneration() }.thenReturn(2uL) } + val rotatedKey = mock { on { keyGeneration() }.thenReturn(3uL) } whenever(root.derivePaykitIdentitySecretKey(2uL)).thenReturn(initialKey) whenever(root.derivePaykitIdentitySecretKey(3uL)).thenReturn(rotatedKey) @@ -91,6 +96,10 @@ class PaykitKeyGenerationTest { val service = PaykitSdkService(mock(), keychain, mock(), settingsStore = mock()) { sdk } val provider = providers.constructed().single() repeat(2) { assertEquals(RING_PUBKY, service.currentPublicKey()) } + verify(sdk).paykitAppRegistry(RING_PUBKY) + assertSame(staleKey, assertFailsWith { service.currentPublicKey() }) + assertEquals(RING_PUBKY, service.currentPublicKey()) + assertSame(staleKey, assertFailsWith { service.currentPublicKey() }) repeat(2) { val error = assertFailsWith { service.currentPublicKey() } assertEquals("The Paykit App Registry has an older key generation", error.message) @@ -105,7 +114,7 @@ class PaykitKeyGenerationTest { } verify(root, times(2)).derivePaykitIdentitySecretKey(any()) verify(keychain).upsertString(any(), any()) - verify(sdk, times(2)).identityStatus() + verify(sdk, times(5)).identityStatus() verify(provider, times(2)).setPaykitIdentitySecretKey(any()) } } @@ -138,4 +147,42 @@ class PaykitKeyGenerationTest { assertEquals(1, sessions.constructed().size) } } + + @Test + fun `best effort backup identity failures invalidate the cached session key`() = runTest { + val sdk = mock() + val initialRegistry = mock { on { keyGeneration }.thenReturn(1uL) } + val rotatedRegistry = mock { on { keyGeneration }.thenReturn(2uL) } + whenever(sdk.paykitAppRegistry(RING_PUBKY)).thenReturn(initialRegistry, rotatedRegistry) + whenever(sdk.identityStatus()) + .thenReturn(IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + whenever(sdk.stateRevision()).thenReturn("state") + whenever(sdk.backupStateRevision()).thenThrow(PaykitException.Identity("identity_error", "Stale key")) + whenever(sdk.processPendingPrivateMessages()).thenReturn(emptyList()) + val keychain = mock() + val storageKey = "${Keychain.Key.PAYKIT_KEY_GENERATION.name}:$RING_PUBKY" + whenever(keychain.loadString(storageKey)).thenReturn("1") + val root = mock() + val initialKey = mock { on { keyGeneration() }.thenReturn(1uL) } + val rotatedKey = mock { on { keyGeneration() }.thenReturn(2uL) } + whenever(root.derivePaykitIdentitySecretKey(1uL)).thenReturn(initialKey) + whenever(root.derivePaykitIdentitySecretKey(2uL)).thenReturn(rotatedKey) + + mockStatic(Class.forName("com.synonym.paykit.Paykit_androidKt")).use { native -> + native.`when` { pubkyPublicKeyFromSecret(root) }.thenReturn(RING_PUBKY) + mockConstruction(PaykitSdkSessionProvider::class.java) { provider, _ -> + whenever(provider.loadLocalSecretKey()).thenReturn(root) + }.use { providers -> + val service = PaykitSdkService(mock(), keychain, mock(), settingsStore = mock()) { sdk } + assertEquals(RING_PUBKY, service.currentPublicKey()) + service.processPendingPrivateMessages() + verify(sdk).paykitAppRegistry(RING_PUBKY) + + assertEquals(RING_PUBKY, service.currentPublicKey()) + verify(sdk, times(2)).paykitAppRegistry(RING_PUBKY) + verify(providers.constructed().single()).setPaykitIdentitySecretKey(rotatedKey) + verify(keychain).upsertString(storageKey, "2") + } + } + } } diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 3fd488337c..0f4256194c 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -40,6 +40,7 @@ import org.mockito.kotlin.doReturn import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever @@ -70,6 +71,28 @@ class PaykitSdkServiceTest { private const val RING_PUBKY = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" } + @Test + fun `initialization can be retried after shared state contention`() = runTest { + val failure = PaykitException.ConcurrentUpdate("concurrent_update", "Resource locked") + val sdk = mock() + whenever(sdk.initialize()).thenThrow(failure).thenReturn(mock()) + whenever(sdk.contactRecords()).thenReturn(emptyList()) + val service = PaykitSdkService( + mock(), + mock(), + mock(), + ioDispatcher = StandardTestDispatcher(testScheduler), + settingsStore = mock(), + platformInitializer = {}, + sdkFactory = { sdk }, + ) + + assertSame(failure, assertFailsWith { service.initialize() }) + assertSame(failure, assertFailsWith { service.contactRecords() }) + service.initialize() + assertEquals(emptyList(), service.contactRecords()) + } + @Test fun `wallet wipe drains initialization before cleanup and allows fresh work`() = runTest { val sdk = mock() @@ -103,21 +126,27 @@ class PaykitSdkServiceTest { } @Test - fun `wallet wipe discards runtime handles before and after cleanup`() = runTest { + fun `wallet wipe discards handles and backup fingerprints even when cleanup fails`() = runTest { val sdk = mock() - whenever(sdk.contactRecords()).thenReturn(emptyList()) + whenever(sdk.processPendingPrivateMessages()).thenReturn(emptyList()) + whenever(sdk.stateRevision()).thenReturn("state") + whenever(sdk.backupStateRevision()).thenReturn("backup") var handlesCreated = 0 val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { handlesCreated++ sdk } - service.contactRecords() - service.withWalletWipe { - service.contactRecords() - assertEquals(2, handlesCreated) + repeat(2) { service.processPendingPrivateMessages() } + assertFailsWith { + service.withWalletWipe { + service.processPendingPrivateMessages() + assertEquals(2, handlesCreated) + throw AppError("Cleanup failed") + } } - service.contactRecords() + service.processPendingPrivateMessages() assertEquals(3, handlesCreated) + verify(sdk, times(3)).backupStateRevision() } @Test diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 0665807f26..a27e20d94a 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -736,6 +736,34 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } } + @Test + fun `foreground maintenance retries a missing session until restored`() = test { + enablePaykitUi() + pubkyPublicKey.value = null + var attempts = 0 + whenever(pubkyRepo.restoreSessionIfNeeded()).thenAnswer { + if (++attempts == 2) pubkyPublicKey.value = testPublicKey + Unit + } + whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + clearInvocations(pubkyRepo, paykitPaymentRequestRepo) + + sut.startPaykitPaymentRequestPolling() + try { + advanceTimeBy(30.seconds.inWholeMilliseconds) + runCurrent() + verify(pubkyRepo).restoreSessionIfNeeded() + verify(paykitPaymentRequestRepo, never()).refresh() + + advanceTimeBy(60.seconds.inWholeMilliseconds) + runCurrent() + verify(pubkyRepo, times(2)).restoreSessionIfNeeded() + verify(paykitPaymentRequestRepo).refresh() + } finally { + sut.stopPaykitPaymentRequestPolling() + } + } + @Test fun `offline periodic polling skips inbox and maintenance`() = test { enablePaykitUi() @@ -756,6 +784,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { runCurrent() verify(pubkyRepo, never()).republishIdentityIfNeeded() verify(paykitPaymentRequestRepo, never()).refresh() + verify(pubkyRepo, never()).restoreSessionIfNeeded() verify(paykitPaymentRequestRepo, never()).refreshEligibleTargets(any(), any()) verify(paykitPaymentProofRepo, never()).reconcile() verify(privatePaykitRepo, never()).refreshKnownSavedContactEndpoints("payment request polling") diff --git a/journeys/contacts/README.md b/journeys/contacts/README.md index 3e7abb48c3..b19cd2bbff 100644 --- a/journeys/contacts/README.md +++ b/journeys/contacts/README.md @@ -1,5 +1,15 @@ # Contacts +`contact-payment-sharing.xml` checks disabling sharing and keeping it off after returning to +Settings. The same journey is available on iOS. + +Network and storage fault injection are outside journey-runner capabilities. With contact sharing +on, make one private-list withdrawal fail and let the following public endpoint or app-registry +update fail as well. Turn off contact payments. The app must report the failure, keep the toggle off, +and retain both cleanup jobs without republishing cleared private lists. Restore connectivity and +foreground the app. Cleanup must finish while sharing stays off. Repeat with only the trailing +public endpoint or app-registry update failing. + Import journeys require a disposable identity with a known following list. They save local Bitkit contacts; payment sharing remains a separate step. For Import All, include the identity's own key in the following list. Repeat with a spelling that changes only the final z-base32 padding bits, which still represents the same 32-byte key. The preview friend count and saved contacts exclude that identity, while the other follows import normally. Carry the same self-follow checks and padding-alias repeat in the matching iOS journey. diff --git a/journeys/contacts/contact-payment-sharing.xml b/journeys/contacts/contact-payment-sharing.xml new file mode 100644 index 0000000000..b183c9ef75 --- /dev/null +++ b/journeys/contacts/contact-payment-sharing.xml @@ -0,0 +1,20 @@ + + + Verifies the contact-payment preference stays off when leaving and returning to Settings. + Requires an authenticated disposable Pubky identity with contact payments enabled and a saved, + linked contact. Cleanup failure injection is a manual check because network and storage fault + injection are not journey capabilities, as documented in README.md. Use only one active + install of this wallet. + + + Open the menu and tap Settings (id "DrawerSettings") + Open the General tab (id "Tab-general") + Verify the contact payments toggle (id "ContactPaymentsToggle") is on + Tap the contact payments toggle (id "ContactPaymentsToggle") + Wait for the update to finish and verify the contact payments toggle is off + Return to the wallet home screen + Open the menu and tap Settings (id "DrawerSettings") + Open the General tab (id "Tab-general") + Verify the contact payments toggle (id "ContactPaymentsToggle") is still off + + From 14d5383fff9ca9e50497fc28a491e8467e2d951d Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 2 Oct 2026 06:26:12 -0500 Subject: [PATCH 49/51] fix: keep private message sync off frequent request polls --- .../repositories/PaykitPaymentRequestRepo.kt | 11 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 2 +- .../PaykitPaymentRequestRepoTest.kt | 17 +++ .../viewmodels/AppViewModelSendFlowTest.kt | 113 +++++++++--------- journeys/payment-requests/README.md | 6 + .../automatic-presentation.xml | 2 +- 6 files changed, 91 insertions(+), 60 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index cb216f16aa..ef1249f302 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -438,7 +438,7 @@ class PaykitPaymentRequestRepo @Inject constructor( } } - suspend fun refresh(): Result { + suspend fun refresh(syncPrivateMessages: Boolean = true): Result { val generation = stateGeneration.get() val expectedIdentity = activeIdentity return withContext(ioDispatcher) { @@ -448,7 +448,7 @@ class PaykitPaymentRequestRepo @Inject constructor( clearStateLocked() return@withLock } - runSuspendCatching { synchronizeLocked(generation, expectedIdentity) } + runSuspendCatching { synchronizeLocked(generation, expectedIdentity, syncPrivateMessages) } .onFailure { discardExpiredRequestsLocked() } .getOrThrow() } @@ -968,9 +968,12 @@ class PaykitPaymentRequestRepo @Inject constructor( private suspend fun synchronizeLocked( generation: Long, expectedIdentity: String?, + syncPrivateMessages: Boolean = true, ) { - processPendingMessages() - paykitSdkService.receivePrivateMessagesFromLinkedPeers().also(::logIntakeFailures) + if (syncPrivateMessages) { + processPendingMessages() + paykitSdkService.receivePrivateMessagesFromLinkedPeers().also(::logIntakeFailures) + } val now = clock.now() receivedContacts = null val allRecords = paykitSdkService.allPaymentRequests(expectedIdentity) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 3d953dc9d8..9a6d0912df 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -875,7 +875,7 @@ class AppViewModel @Inject constructor( private suspend fun refreshIncomingPaykitPaymentRequests(refreshMaintenance: Boolean = true) { if (!isPaykitEnabled.value || pubkyRepo.publicKey.value == null || !walletRepo.walletExists()) return if (refreshMaintenance) paykitPaymentProofRepo.reconcile() - paykitPaymentRequestRepo.refresh().onSuccess { + paykitPaymentRequestRepo.refresh(syncPrivateMessages = refreshMaintenance).onSuccess { activityRepo.backfillPaykitContacts() presentNextIncomingPaykitPaymentRequest() } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 1e27cc2dc6..6d19642a18 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -135,6 +135,23 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.clear() } + @Test + fun `shared state refresh skips private messages until a full refresh`() = test { + val record = paymentRequestRecord() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) + + sut.refresh(syncPrivateMessages = false).getOrThrow() + + assertEquals(record.paymentRequestId, sut.pendingRequests.value.single().paymentRequestId) + verify(paykitSdkService, never()).processPendingPrivateMessages() + verify(paykitSdkService, never()).receivePrivateMessagesFromLinkedPeers() + + sut.refresh().getOrThrow() + + verify(paykitSdkService).processPendingPrivateMessages() + verify(paykitSdkService).receivePrivateMessagesFromLinkedPeers() + } + @Test fun `shared app destinations stay out of request UI and clear on identity switch`() = test { val address = PaykitReceivedPaymentContactsTest.ADDRESS diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index a27e20d94a..3ddce0ebf6 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -682,7 +682,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `network restoration republishes identity and resumes ten second polling`() = test { enablePaykitUi() pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) sut.startPaykitPaymentRequestPolling() try { advanceTimeBy(30.seconds.inWholeMilliseconds) @@ -704,32 +704,37 @@ class AppViewModelSendFlowTest : BaseUnitTest() { clearInvocations(paykitPaymentRequestRepo) advanceTimeBy(10.seconds.inWholeMilliseconds - 1) runCurrent() - verify(paykitPaymentRequestRepo, never()).refresh() + verify(paykitPaymentRequestRepo, never()).refresh(any()) advanceTimeBy(1) runCurrent() - verify(paykitPaymentRequestRepo).refresh() + verify(paykitPaymentRequestRepo).refresh(any()) } finally { sut.stopPaykitPaymentRequestPolling() } } @Test - fun `identity republish follows maintenance intervals instead of each payment request poll`() = test { + fun `private message sync and identity republish follow maintenance intervals`() = test { enablePaykitUi() pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) sut.startPaykitPaymentRequestPolling() try { + advanceTimeBy(30.seconds.inWholeMilliseconds) runCurrent() - for (interval in listOf(30.seconds, 60.seconds, 120.seconds, 120.seconds)) { - clearInvocations(pubkyRepo) + verify(paykitPaymentRequestRepo, atLeast(2)).refresh(syncPrivateMessages = true) + for (interval in listOf(60.seconds, 120.seconds, 120.seconds)) { + clearInvocations(pubkyRepo, paykitPaymentRequestRepo) advanceTimeBy(interval.inWholeMilliseconds - 1) runCurrent() verify(pubkyRepo, never()).republishIdentityIfNeeded() + verify(paykitPaymentRequestRepo, never()).refresh(syncPrivateMessages = true) + verify(paykitPaymentRequestRepo, atLeast(1)).refresh(syncPrivateMessages = false) advanceTimeBy(1) runCurrent() verify(pubkyRepo).republishIdentityIfNeeded() + verify(paykitPaymentRequestRepo).refresh(syncPrivateMessages = true) } } finally { sut.stopPaykitPaymentRequestPolling() @@ -745,7 +750,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { if (++attempts == 2) pubkyPublicKey.value = testPublicKey Unit } - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) clearInvocations(pubkyRepo, paykitPaymentRequestRepo) sut.startPaykitPaymentRequestPolling() @@ -753,12 +758,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceTimeBy(30.seconds.inWholeMilliseconds) runCurrent() verify(pubkyRepo).restoreSessionIfNeeded() - verify(paykitPaymentRequestRepo, never()).refresh() + verify(paykitPaymentRequestRepo, never()).refresh(any()) advanceTimeBy(60.seconds.inWholeMilliseconds) runCurrent() verify(pubkyRepo, times(2)).restoreSessionIfNeeded() - verify(paykitPaymentRequestRepo).refresh() + verify(paykitPaymentRequestRepo).refresh(any()) } finally { sut.stopPaykitPaymentRequestPolling() } @@ -783,7 +788,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceTimeBy(210.seconds.inWholeMilliseconds) runCurrent() verify(pubkyRepo, never()).republishIdentityIfNeeded() - verify(paykitPaymentRequestRepo, never()).refresh() + verify(paykitPaymentRequestRepo, never()).refresh(any()) verify(pubkyRepo, never()).restoreSessionIfNeeded() verify(paykitPaymentRequestRepo, never()).refreshEligibleTargets(any(), any()) verify(paykitPaymentProofRepo, never()).reconcile() @@ -815,7 +820,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `payment requests refresh promptly without repeating maintenance on each poll`() = test { isPaykitEnabled.value = true pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) runCurrent() clearInvocations(paykitPaymentRequestRepo) @@ -823,28 +828,28 @@ class AppViewModelSendFlowTest : BaseUnitTest() { try { runCurrent() - verify(paykitPaymentRequestRepo).refresh() + verify(paykitPaymentRequestRepo).refresh(any()) clearInvocations(paykitPaymentRequestRepo) advanceTimeBy(30.seconds.inWholeMilliseconds) runCurrent() - verify(paykitPaymentRequestRepo, atLeast(2)).refresh() + verify(paykitPaymentRequestRepo, atLeast(2)).refresh(any()) clearInvocations(paykitPaymentRequestRepo) clearInvocations(privatePaykitRepo, paykitPaymentProofRepo) advanceTimeBy(9.seconds.inWholeMilliseconds) runCurrent() - verify(paykitPaymentRequestRepo, never()).refresh() + verify(paykitPaymentRequestRepo, never()).refresh(any()) val request = paymentRequest() - whenever(paykitPaymentRequestRepo.refresh()).doSuspendableAnswer { + whenever(paykitPaymentRequestRepo.refresh(any())).doSuspendableAnswer { pendingPaykitPaymentRequests.value = listOf(request) Result.success(Unit) } advanceTimeBy(1.seconds.inWholeMilliseconds) runCurrent() - verify(paykitPaymentRequestRepo).refresh() + verify(paykitPaymentRequestRepo).refresh(any()) verify(privatePaykitRepo, never()).refreshKnownSavedContactEndpoints(any(), any()) verify(paykitPaymentProofRepo, never()).reconcile() verify(paykitPaymentRequestRepo, never()).refreshEligibleTargets(any(), eq(true)) @@ -853,10 +858,10 @@ class AppViewModelSendFlowTest : BaseUnitTest() { clearInvocations(paykitPaymentRequestRepo) advanceTimeBy(10.seconds.inWholeMilliseconds - 1) runCurrent() - verify(paykitPaymentRequestRepo, never()).refresh() + verify(paykitPaymentRequestRepo, never()).refresh(any()) advanceTimeBy(1) runCurrent() - verify(paykitPaymentRequestRepo).refresh() + verify(paykitPaymentRequestRepo).refresh(any()) } verify(privatePaykitRepo).refreshKnownSavedContactEndpoints(any(), any()) verify(paykitPaymentProofRepo).reconcile() @@ -874,19 +879,19 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceTimeBy(120.seconds.inWholeMilliseconds) runCurrent() - verify(paykitPaymentRequestRepo, never()).refresh() + verify(paykitPaymentRequestRepo, never()).refresh(any()) } @Test fun `failed inbox checks keep ten second cadence`() = test { enablePaykitUi() pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) sut.startPaykitPaymentRequestPolling() try { advanceTimeBy(30.seconds.inWholeMilliseconds) runCurrent() - whenever(paykitPaymentRequestRepo.refresh()).thenReturn( + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn( Result.failure(IllegalStateException("transport failure")), ) @@ -894,17 +899,17 @@ class AppViewModelSendFlowTest : BaseUnitTest() { clearInvocations(paykitPaymentRequestRepo) advanceTimeBy(10.seconds.inWholeMilliseconds - 1) runCurrent() - verify(paykitPaymentRequestRepo, never()).refresh() + verify(paykitPaymentRequestRepo, never()).refresh(any()) advanceTimeBy(1) runCurrent() - verify(paykitPaymentRequestRepo).refresh() + verify(paykitPaymentRequestRepo).refresh(any()) } - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) clearInvocations(paykitPaymentRequestRepo) advanceTimeBy(10.seconds.inWholeMilliseconds) runCurrent() - verify(paykitPaymentRequestRepo).refresh() + verify(paykitPaymentRequestRepo).refresh(any()) } finally { sut.stopPaykitPaymentRequestPolling() } @@ -916,7 +921,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val request = paymentRequest() val bolt11 = "lnbcrt1updatedpaymentrequest" val privateContext = privatePaymentContext(8uL) - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList), Result.success( @@ -977,7 +982,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) assertEquals(request.id, sut.sendUiState.value.incomingPaymentRequestId) verify(privatePaykitRepo).beginPaymentRequest(request) - verify(paykitPaymentRequestRepo, never()).refresh() + verify(paykitPaymentRequestRepo, never()).refresh(any()) } @Test @@ -1235,7 +1240,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { endsAt = Instant.parse("2026-09-01T12:00:00Z"), ), ) - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.beginPaymentRequest(targetRequest)).thenReturn( Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList) ) @@ -1267,7 +1272,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { endsAt = Instant.parse("2026-09-01T12:00:00Z"), ), ) - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.beginPaymentRequest(targetRequest)).thenReturn( Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList) ) @@ -1879,7 +1884,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val latestActivationStarted = CompletableDeferred() val finishLatestActivation = CompletableDeferred() sut.setIsAuthenticated(true) - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList) ) @@ -1930,7 +1935,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val finishClear = CompletableDeferred() runCurrent() sut.setIsAuthenticated(true) - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList) ) @@ -2006,7 +2011,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `unresolvable automatic request falls back to low frequency retries`() = test { val request = paymentRequest() - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.beginPaymentRequest(request)) .thenReturn(Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList)) pendingPaykitPaymentRequests.value = listOf(request) @@ -2043,7 +2048,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(pendingRequest) isPaykitEnabled.value = true pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) runCurrent() sut.startPaykitPaymentRequestPolling() @@ -2070,7 +2075,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(firstRequest, secondRequest) enablePaykitUi() pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) sut.onHomeResumed() sut.currentSheet.first { @@ -2105,7 +2110,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(request) isPaykitEnabled.value = true pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) sut.onHomeResumed() runCurrent() @@ -2129,7 +2134,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(request) isPaykitEnabled.value = true pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) sut.startPaykitPaymentRequestPolling() advanceTimeBy(30.seconds.inWholeMilliseconds) @@ -2170,7 +2175,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(request) isPaykitEnabled.value = true pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) sut.onHomeResumed() resolutionStarted.await() @@ -2217,7 +2222,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(request) enablePaykitUi() pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) sut.onHomeResumed() requestScanStarted.await() @@ -2390,7 +2395,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(request) enablePaykitUi() pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) sut.onHomeResumed() resolutionStarted.await() @@ -2431,7 +2436,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(request) isPaykitEnabled.value = true pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) sut.onHomeResumed() runCurrent() @@ -2465,7 +2470,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(request) enablePaykitUi() pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) sut.onHomeResumed() runCurrent() @@ -2497,7 +2502,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(unavailableRequest, payableRequest) isPaykitEnabled.value = true pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) sut.startPaykitPaymentRequestPolling() advanceTimeBy(30.seconds.inWholeMilliseconds) @@ -2534,7 +2539,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(expiredRequest, payableRequest) isPaykitEnabled.value = true pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) sut.startPaykitPaymentRequestPolling() advanceTimeBy(30.seconds.inWholeMilliseconds) @@ -2560,7 +2565,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(request) isPaykitEnabled.value = true pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) sut.startPaykitPaymentRequestPolling() advanceTimeBy(30.seconds.inWholeMilliseconds) @@ -3780,7 +3785,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { stubLightningScan(bolt11 = bolt11, amountSats = 0u) whenever(lightningRepo.canSend(request.amountSats)).thenReturn(true) stubOpenedPaymentRequest(request, bolt11) - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) pendingPaykitPaymentRequests.value = listOf(request) sut.onHomeResumed() @@ -3799,7 +3804,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(request) enablePaykitUi() pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) stubOpenedPaymentRequest(request, signupAuthUrl) sut.onHomeResumed() @@ -6134,7 +6139,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { stubLightningScan(bolt11 = bolt11, amountSats = 0u) whenever(lightningRepo.canSend(request.amountSats)).thenReturn(true) val privateContext = stubOpenedPaymentRequest(request, bolt11) - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) pendingPaykitPaymentRequests.value = listOf(request) sut.startPaykitPaymentRequestPolling() @@ -6304,7 +6309,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { balanceState.value = BalanceState(maxSendLightningSats = 100_000u) stubLightningScan(bolt11 = bolt11, amountSats = 0u) stubOpenedPaymentRequest(request, bolt11) - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) pendingPaykitPaymentRequests.value = listOf(request) sut.startPaykitPaymentRequestPolling() @@ -6829,7 +6834,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = emptyList() stubOpenedPaymentRequest(request, lnurl.uri) whenever(coreService.decode(lnurl.uri)).thenReturn(Scanner.LnurlPay(lnurl)) - whenever(paykitPaymentRequestRepo.refresh()).doSuspendableAnswer { + whenever(paykitPaymentRequestRepo.refresh(any())).doSuspendableAnswer { pendingPaykitPaymentRequests.value = listOf(request) Result.success(Unit) } @@ -6837,7 +6842,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.retryIncomingPaymentRequest(request.id) advanceUntilIdle() - verify(paykitPaymentRequestRepo).refresh() + verify(paykitPaymentRequestRepo).refresh(syncPrivateMessages = true) verify(privatePaykitRepo, atLeast(1)).beginPaymentRequest(request) assertEquals(request.id, sut.sendUiState.value.incomingPaymentRequestId) assertTrue(sut.currentSheet.value is Sheet.Send) @@ -7247,7 +7252,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { enablePaykitUi() balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) stubSuccessfulOnchainSend(address, request.amountSats) @@ -7265,7 +7270,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue, "bitkit") - verify(paykitPaymentRequestRepo).refresh() + verify(paykitPaymentRequestRepo).refresh(syncPrivateMessages = true) } @Test @@ -7399,7 +7404,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `initial subscription retry keeps the send sheet presented`() = test { val request = paymentRequest() pendingPaykitPaymentRequests.value = listOf(request) - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.beginPaymentRequestWaitingForUpdatedList(request)).thenReturn( Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList) ) diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index 0c4c4455f5..e558cf8919 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -24,6 +24,12 @@ Rejected fixture shapes stay in unit tests because Bitkit intentionally does not `definite-pre-broadcast-retry.xml` uses the linked fixture issuer and the local regtest LNURL server. Configure its LNURL-pay metadata endpoint normally, but make its invoice callback fail the first request and succeed after it is switched back to the healthy response. Do not republish the Paykit payment list between attempts. This makes the first send fail before Lightning dispatch and proves that the same private payment details can be opened and paid on retry. +## Foreground synchronization + +Bitkit refreshes shared request state every 10 seconds. Private messages are synchronized on +startup and explicit refreshes, and on maintenance rounds that back off from 30 to 60 to 120 seconds. +A new peer message can therefore take up to two minutes plus synchronization time to appear during steady polling. + ## Accepting install Acceptance intent is saved before the remote operation and included in wallet backups. diff --git a/journeys/payment-requests/automatic-presentation.xml b/journeys/payment-requests/automatic-presentation.xml index 8f19ff2dc7..e192331d0e 100644 --- a/journeys/payment-requests/automatic-presentation.xml +++ b/journeys/payment-requests/automatic-presentation.xml @@ -5,7 +5,7 @@ On the payer, leave Bitkit unlocked and in the foreground on Home On the requester, send the payer contact a Payment Request for 21,000 sats with the note "First request" - On the payer, verify Payment Request confirmation (testTag "PaymentRequestConfirm") appears automatically with 21,000 sats and For shows "First request" + On the payer, wait for the next private-message sync and verify Payment Request confirmation (testTag "PaymentRequestConfirm") appears automatically with 21,000 sats and For shows "First request" Close the confirmation without paying On the payer, open Receive and leave its sheet open On the requester, send the payer contact another Payment Request for 5,000 sats with the note "Second request" From 56dbcf70c94d7499a0b49e4d2ad08c176d8f463f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 2 Oct 2026 16:02:08 +0200 Subject: [PATCH 50/51] fix: pay allowances only to unused on-chain addresses --- app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 4cd9605dbf..0c380ba81e 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -416,7 +416,7 @@ class PrivatePaykitRepo @Inject constructor( .filter { it.methodId.rawValue in eligible && (it.methodId == MethodId.Bolt11 || it.methodId.isOnchain) } - val payable = privatePayableEndpoints(candidates, publicKey) + val payable = privatePayableEndpoints(candidates, publicKey, allowUsedOnchainAddress = false) allowancePayment( request = request, payable = payable, From a2f33ad638af6149c5d0f15ba89ae0dbc85d7475 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 2 Oct 2026 16:02:08 +0200 Subject: [PATCH 51/51] test: pin that allowances ignore the subscription clock offset --- .../PaykitAllowanceExecutorTest.kt | 32 +++++++++++++++++++ .../repositories/PaykitAllowanceRepoTest.kt | 23 +++++++++++++ .../repositories/PaykitAllowanceTest.kt | 26 +++++++++++++++ 3 files changed, 81 insertions(+) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt index b288db4562..58a7d8c751 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt @@ -25,6 +25,8 @@ import com.synonym.paykit.PrivateStreamIntakeReport import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.launch import kotlinx.coroutines.test.TestScope +import org.junit.After +import org.junit.Assume.assumeTrue import org.junit.Before import org.junit.Test import org.lightningdevkit.ldknode.PaymentStatus @@ -46,6 +48,7 @@ import to.bitkit.repositories.PaykitAllowanceLocalState.Stage import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError +import to.bitkit.utils.SubscriptionClockOffset import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertIs @@ -751,10 +754,39 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { assertEquals(listOf(PaykitAllowanceTime.format(now)), evaluatedTrustedTimes) } + @Test + fun `admission ignores the subscription clock offset`() = test { + offsetSubscriptionClock(days = 400) + accountingState = stateNearTheMonthlyCap() + + val result = sut.autoPay(fixtures.paymentRequest(), listOf(fixtures.allowance()), identity) + + assertEquals( + PaykitAllowanceAutoPayResult.MANUAL, + result, + "September's attempts must count; the offset would move the window a year ahead", + ) + assertEquals(listOf(PaykitAllowanceTime.format(fixtures.now)), evaluatedTrustedTimes) + verify(sdk, never()).acceptPaymentRequestAutomatically(any(), any(), any()) + } + // endregion // region Helpers + private fun offsetSubscriptionClock(days: Int) { + SubscriptionClockOffset.setOffsetDays(days) + assumeTrue( + "The subscription clock offset is unavailable in this build", + SubscriptionClockOffset.offsetDays == days, + ) + } + + @After + fun resetSubscriptionClockOffset() { + SubscriptionClockOffset.setOffsetDays(0) + } + private fun TestScope.collectEvents() { backgroundScope.launch { sut.events.collect { events += it } } } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt index 904b842dfb..1c186bfa00 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt @@ -11,6 +11,8 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update +import org.junit.After +import org.junit.Assume.assumeTrue import org.junit.Before import org.junit.Test import org.lightningdevkit.ldknode.ChannelDetails @@ -31,6 +33,7 @@ import to.bitkit.repositories.PaykitAllowanceFixtures.SECOND_ALLOWANCE_ID import to.bitkit.repositories.PaykitAllowanceLocalState.Stage import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.SubscriptionClockOffset import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertIs @@ -191,6 +194,26 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { assertFalse(sut.coversRequest(fixtures.paymentRequest())) } + @Test + fun `coverage ignores the subscription clock offset`() = test { + SubscriptionClockOffset.setOffsetDays(400) + assumeTrue( + "The subscription clock offset is unavailable in this build", + SubscriptionClockOffset.offsetDays == 400, + ) + records = listOf(fixtures.record(terms = fixtures.terms(expiresAt = (fixtures.now + 30.days).toString()))) + + sut.activate(identity) + + assertTrue(sut.coversRequest(fixtures.paymentRequest()), "The allowance still has 30 days in real time") + assertFalse(sut.coversRequest(fixtures.paymentRequest(counterparty = fixtures.otherCounterpartyKey))) + } + + @After + fun resetSubscriptionClockOffset() { + SubscriptionClockOffset.setOffsetDays(0) + } + @Test fun `requests created before the allowance was accepted stay manual`() = test { records = listOf(fixtures.record(lastEventAt = "2026-09-24T11:30:00Z")) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceTest.kt index 909dd24c8f..daf5442875 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceTest.kt @@ -18,6 +18,8 @@ import com.synonym.paykit.PaymentExecutionMode import com.synonym.paykit.PaymentExecutionStatus import com.synonym.paykit.PaymentOccurrenceKey import com.synonym.paykit.PaymentOccurrenceRecord +import org.junit.After +import org.junit.Assume.assumeTrue import org.junit.Test import org.lightningdevkit.ldknode.Network import org.mockito.kotlin.any @@ -27,10 +29,12 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import to.bitkit.data.keychain.Keychain import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.SubscriptionClockOffset import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlin.time.Clock import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant @@ -359,6 +363,28 @@ class PaykitAllowanceTest : BaseUnitTest() { assertTrue(PaykitAllowanceCapacity.fits(1uL, allowance, attempts, Instant.parse("2026-10-01T00:00:00Z"))) } + @Test + fun `status and capacity ignore the subscription clock offset`() { + SubscriptionClockOffset.setOffsetDays(365) + assumeTrue( + "The subscription clock offset is unavailable in this build", + SubscriptionClockOffset.offsetDays == 365, + ) + assertTrue(SubscriptionClockOffset.subscriptionNow() > Clock.System.now() + 364.days) + + val allowance = fixtures.allowance(expiresAt = fixtures.now + 30.days) + val attempts = listOf(fixtures.capacityAttempt(sats = 50_000uL, at = "2026-09-10T10:00:00Z")) + + assertEquals(PaykitAllowance.Status.ACTIVE, allowance.status(fixtures.now)) + assertEquals(50_000uL, fixtures.usedSats(attempts)) + assertFalse(PaykitAllowanceCapacity.fits(1uL, allowance, attempts, fixtures.now)) + } + + @After + fun resetSubscriptionClockOffset() { + SubscriptionClockOffset.setOffsetDays(0) + } + // endregion // region Grouping and terms