Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/src/main/java/to/bitkit/data/SettingsStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ data class SettingsData(
val hasSeenContactsIntro: Boolean = false,
val hasConfirmedPublicPaykitEndpoints: Boolean = false,
val sharesPublicPaykitEndpoints: Boolean = false,
val sharesPrivatePaykitEndpoints: Boolean = false,
val publicPaykitLightningEnabled: Boolean = true,
val publicPaykitOnchainEnabled: Boolean = true,
val publicPaykitBolt11: String = "",
val publicPaykitBolt11PaymentHash: String = "",
val publicPaykitBolt11ExpiresAtMillis: Long = 0,
Expand Down
34 changes: 32 additions & 2 deletions app/src/main/java/to/bitkit/repositories/ActivityRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -392,9 +392,39 @@ class ActivityRepo @Inject constructor(
}
}

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

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

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

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

private fun Activity.withContact(normalizedKey: String, updatedAt: ULong): Activity = when (this) {
private fun Activity.withContact(normalizedKey: String?, updatedAt: ULong): Activity = when (this) {
is Activity.Lightning -> Activity.Lightning(v1.copy(contact = normalizedKey, updatedAt = updatedAt))
is Activity.Onchain -> Activity.Onchain(v1.copy(contact = normalizedKey, updatedAt = updatedAt))
}
Expand Down
90 changes: 62 additions & 28 deletions app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -100,15 +100,37 @@ class PrivatePaykitRepo @Inject constructor(
suspend fun reconcileReservedReceiveIndexes(): Result<Unit> =
addressReservationRepo.reconcileReservedIndexesWithLdk()

suspend fun prepareSavedContacts(publicKeys: Collection<String>): Result<Unit> = withContext(serializedDispatcher) {
suspend fun prepareSavedContacts(
publicKeys: Collection<String>,
requireImmediatePublication: Boolean = false,
): Result<Unit> = withContext(serializedDispatcher) {
runCatching {
val keys = rememberSavedContacts(publicKeys, replacing = true)
if (!canPublishPrivateEndpoints()) return@runCatching
addressReservationRepo.reconcileReservedIndexesWithLdk().getOrThrow()
publishLocalEndpoints(keys, maxAdvanceSteps = 3, reason = "prepare").getOrThrow()
publishLocalEndpoints(
publicKeys = keys,
maxAdvanceSteps = 3,
reason = "prepare",
requireImmediatePublication = requireImmediatePublication,
).getOrThrow()
}
}

suspend fun enableSharingAndPrepareSavedContacts(publicKeys: Collection<String>): Result<Unit> =
withContext(serializedDispatcher) {
runCatching {
val wasCleanupPending = isContactSharingCleanupPending()
updateContactSharingCleanupPending(false)
prepareSavedContacts(publicKeys).onFailure {
if (wasCleanupPending) {
runCatching { updateContactSharingCleanupPending(true) }
.onFailure(it::addSuppressed)
}
}.getOrThrow()
}
}

suspend fun refreshSavedContactEndpoints(publicKeys: Collection<String>): Result<Unit> =
withContext(serializedDispatcher) {
runCatching {
Expand Down Expand Up @@ -140,14 +162,9 @@ class PrivatePaykitRepo @Inject constructor(
): Result<Unit> = withContext(serializedDispatcher) {
runCatching {
if (isContactSharingCleanupPending()) {
if (settingsStore.data.first().sharesPublicPaykitEndpoints) {
updateContactSharingCleanupPending(false)
} else {
publicPaykitRepo.syncPublishedEndpoints(publish = false).getOrThrow()
removePublishedEndpoints().getOrThrow()
clearUnsavedContactState(savedPublicKeys).getOrThrow()
updateContactSharingCleanupPending(false)
}
removePublishedEndpoints().getOrThrow()
clearUnsavedContactState(savedPublicKeys).getOrThrow()
updateContactSharingCleanupPending(false)
}
retryPendingDeletedContactEndpointRemoval(savedPublicKeys).getOrThrow()
}.onFailure {
Expand Down Expand Up @@ -507,17 +524,19 @@ class PrivatePaykitRepo @Inject constructor(
return PublicPaykitPaymentResult.Opened(PublicPaykitRepo.paymentRequest(payable))
}

@Suppress("CyclomaticComplexMethod")
@Suppress("CyclomaticComplexMethod", "LongMethod")
private suspend fun publishLocalEndpoints(
publicKeys: Collection<String>,
maxAdvanceSteps: Int,
reason: String,
scheduleRetries: Boolean = true,
forceLocalPublishWhenRemoteEmpty: Boolean = false,
forceRefreshLightning: Boolean = false,
requireImmediatePublication: Boolean = false,
): Result<Unit> = withContext(serializedDispatcher) {
runCatching {
val generation = currentStateGeneration()
var firstError: Throwable? = null
publicKeys.forEach { publicKey ->
val normalizedKey = knownSavedContact(publicKey) ?: return@forEach
val redactedKey = redacted(normalizedKey)
Expand All @@ -527,7 +546,14 @@ class PrivatePaykitRepo @Inject constructor(
maxAdvanceSteps = maxAdvanceSteps,
generation = generation,
scheduleRetries = scheduleRetries,
) ?: return@forEach
) ?: run {
val shouldFailImmediatePublish = requireImmediatePublication &&
shouldRequirePrivateEndpointRemoval(normalizedKey)
if (firstError == null && shouldFailImmediatePublish) {
firstError = PrivatePaykitError.PrivateUnavailable
}
return@forEach
}

if (publishLocalEndpointsBeforeFetch(normalizedKey, linkId, reason, scheduleRetries, generation)) {
return@forEach
Expand All @@ -539,7 +565,14 @@ class PrivatePaykitRepo @Inject constructor(
reason = reason,
scheduleRetries = scheduleRetries,
generation = generation,
) ?: return@forEach
) ?: run {
val shouldFailImmediatePublish = requireImmediatePublication &&
shouldRequirePrivateEndpointRemoval(normalizedKey)
if (firstError == null && shouldFailImmediatePublish) {
firstError = PrivatePaykitError.PrivateUnavailable
}
return@forEach
}
val contactState = ensureState().contacts[normalizedKey]
val shouldForcePublish = forceLocalPublishWhenRemoteEmpty &&
fetchedCount == 0 &&
Expand All @@ -558,6 +591,7 @@ class PrivatePaykitRepo @Inject constructor(
it,
context = TAG,
)
if (firstError == null && requireImmediatePublication) firstError = it
}
val updatedContactState = ensureState().contacts[normalizedKey]
val needsRetry = publishResult.isFailure ||
Expand All @@ -570,6 +604,8 @@ class PrivatePaykitRepo @Inject constructor(
cancelPendingPublicationRetry(normalizedKey)
}
}
firstError?.let { throw it }
Unit
}
}

Expand Down Expand Up @@ -751,10 +787,10 @@ class PrivatePaykitRepo @Inject constructor(
if (!canPublishPrivateEndpoints() || knownSavedContact(publicKey) == null) return@withLock

val endpoints = buildLocalEndpoints(publicKey, forceRefreshLightning).getOrThrow()
if (endpoints.isEmpty()) throw PublicPaykitError.NoSupportedEndpoint
ensureCurrentGeneration(generation)
val payloadSelection = PrivatePaykitPayloads.entriesWithinNoiseLimit(endpoints)
if (payloadSelection.droppedLightning) {
ensureState().contacts[publicKey]?.localInvoice = null
Logger.warn(
"Published private Paykit on-chain only for '${redacted(publicKey)}'",
context = TAG,
Expand Down Expand Up @@ -784,34 +820,32 @@ class PrivatePaykitRepo @Inject constructor(
): Result<List<Endpoint>> =
withContext(serializedDispatcher) {
runCatching {
val settings = settingsStore.data.first()
val endpoints = mutableListOf<Endpoint>()
val reservedAddress = addressReservationRepo.currentOrRotatedAddress(publicKey).getOrThrow()
walletRepo.refreshReusableReceiveAddressIfReserved().getOrThrow()
endpoints += Endpoint(
methodId = PublicPaykitRepo.onchainMethodId(reservedAddress),
value = reservedAddress,
rawPayload = PublicPaykitRepo.serializePayload(reservedAddress),
)
if (PublicPaykitRepo.isOnchainPaymentOptionEnabled(settings)) {
val reservedAddress = addressReservationRepo.currentOrRotatedAddress(publicKey).getOrThrow()
walletRepo.refreshReusableReceiveAddressIfReserved().getOrThrow()
endpoints += Endpoint(
methodId = PublicPaykitRepo.onchainMethodId(reservedAddress),
value = reservedAddress,
rawPayload = PublicPaykitRepo.serializePayload(reservedAddress),
)
}

if (lightningRepo.canReceive()) {
if (PublicPaykitRepo.isLightningPaymentOptionEnabled(settings) && lightningRepo.canReceive()) {
currentOrRotatedInvoice(publicKey, forceRefresh = forceRefreshLightning).onSuccess { invoice ->
endpoints += Endpoint(
methodId = MethodId.Bolt11,
value = invoice.bolt11,
rawPayload = PublicPaykitRepo.serializePayload(invoice.bolt11),
)
}.onFailure {
ensureState().contacts[publicKey]?.localInvoice = null
persistState()
Logger.warn(
"Failed to prepare private Paykit invoice for '${redacted(publicKey)}'",
it,
context = TAG,
)
}
} else {
ensureState().contacts[publicKey]?.localInvoice = null
persistState()
}

endpoints
Expand Down Expand Up @@ -1565,7 +1599,7 @@ class PrivatePaykitRepo @Inject constructor(

private suspend fun canPublishPrivateEndpoints(): Boolean {
val settings = settingsStore.data.first()
return settings.sharesPublicPaykitEndpoints &&
return settings.sharesPrivatePaykitEndpoints &&
App.currentActivity?.value != null &&
walletRepo.walletExists() &&
lightningRepo.lightningState.value.nodeLifecycleState.isRunning()
Expand Down
27 changes: 23 additions & 4 deletions app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ class PublicPaykitRepo @Inject constructor(
private val publicBolt11Expiry = 24.hours
private val publicBolt11RefreshWindow = 30.minutes

fun isLightningPaymentOptionEnabled(settings: SettingsData): Boolean =
settings.publicPaykitLightningEnabled

fun isOnchainPaymentOptionEnabled(settings: SettingsData): Boolean =
settings.publicPaykitOnchainEnabled

fun parseEndpoint(methodId: String, endpointData: String): Endpoint? {
if (!methodIdPattern.matches(methodId)) return null

Expand Down Expand Up @@ -171,11 +177,13 @@ class PublicPaykitRepo @Inject constructor(

suspend fun syncCurrentPublishedEndpoints(
forceRefreshLightning: Boolean = false,
requireEndpoint: Boolean = false,
): Result<Unit> = withContext(ioDispatcher) {
runCatching {
val desired = buildWalletEndpoints(
refresh = false,
forceRefreshLightning = forceRefreshLightning,
requireEndpoint = requireEndpoint,
)
applyPublishedEndpoints(desired)
}
Expand Down Expand Up @@ -247,22 +255,33 @@ class PublicPaykitRepo @Inject constructor(
private suspend fun buildWalletEndpoints(
refresh: Boolean,
forceRefreshLightning: Boolean = false,
requireEndpoint: Boolean = true,
): List<Endpoint> {
val settings = settingsStore.data.first()
val includeLightning = isLightningPaymentOptionEnabled(settings)
val includeOnchain = isOnchainPaymentOptionEnabled(settings)

if (refresh) {
lightningRepo.executeWhenNodeRunning(
operationName = "sync public Paykit endpoints",
) {
Result.success(Unit)
}.getOrThrow()
}
walletRepo.refreshReusableReceiveAddressIfReserved().getOrThrow()
if (includeOnchain) {
walletRepo.refreshReusableReceiveAddressIfReserved().getOrThrow()
}

val state = walletRepo.walletState.value
val endpoints = mutableListOf<Endpoint>()
buildPublicBolt11Endpoint(forceRefreshLightning)?.let { endpoints += it }
if (includeLightning) {
buildPublicBolt11Endpoint(forceRefreshLightning)?.let { endpoints += it }
} else {
clearPublicBolt11Metadata()
}

val onchainAddress = state.onchainAddress
if (onchainAddress.isNotBlank()) {
if (includeOnchain && onchainAddress.isNotBlank()) {
val methodId = onchainMethodId(onchainAddress)
endpoints += Endpoint(
methodId = methodId,
Expand All @@ -271,7 +290,7 @@ class PublicPaykitRepo @Inject constructor(
)
}

if (endpoints.isEmpty()) throw PublicPaykitError.NoSupportedEndpoint
if (endpoints.isEmpty() && requireEndpoint) throw PublicPaykitError.NoSupportedEndpoint

return endpoints
}
Expand Down
11 changes: 11 additions & 0 deletions app/src/main/java/to/bitkit/ui/ContentView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ import to.bitkit.ui.settings.lightning.ChannelDetailScreen
import to.bitkit.ui.settings.lightning.CloseConnectionScreen
import to.bitkit.ui.settings.lightning.LightningConnectionsScreen
import to.bitkit.ui.settings.lightning.LightningConnectionsViewModel
import to.bitkit.ui.settings.paymentPreference.PaymentPreferenceScreen
import to.bitkit.ui.settings.pin.PinManagementScreen
import to.bitkit.ui.settings.quickPay.QuickPayIntroScreen
import to.bitkit.ui.settings.quickPay.QuickPaySettingsScreen
Expand Down Expand Up @@ -1185,6 +1186,11 @@ private fun NavGraphBuilder.generalSettingsSubScreens(navController: NavHostCont
onBack = { navController.popBackStack() },
)
}
composableWithDefaultTransitions<Routes.PaymentPreferenceSettings> {
PaymentPreferenceScreen(
onBack = { navController.popBackStack() },
)
}

composableWithDefaultTransitions<Routes.BackgroundPaymentsIntro> {
BackgroundPaymentsIntroScreen(
Expand Down Expand Up @@ -1715,6 +1721,8 @@ fun NavController.navigateToLogDetail(fileName: String) = navigateTo(Routes.LogD

fun NavController.navigateToTransactionSpeedSettings() = navigateTo(Routes.TransactionSpeedSettings)

fun NavController.navigateToPaymentPreferenceSettings() = navigateTo(Routes.PaymentPreferenceSettings)

fun NavController.navigateToCustomFeeSettings() = navigateTo(Routes.CustomFeeSettings)

fun NavController.navigateToWidgetsSettings() = navigateTo(Routes.WidgetsSettings)
Expand Down Expand Up @@ -1748,6 +1756,9 @@ sealed interface Routes {
@Serializable
data object TransactionSpeedSettings : Routes

@Serializable
data object PaymentPreferenceSettings : Routes

@Serializable
data object WidgetsSettings : Routes

Expand Down
Loading
Loading