Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
c005917
feat: add public contact payments
ben-kaufman Apr 30, 2026
216319b
Merge branch 'master' into codex-public-payments-contacts
ben-kaufman Apr 30, 2026
aab781c
fix: allow contact replacement
ben-kaufman Apr 30, 2026
758ea69
fix: address review comments
ben-kaufman Apr 30, 2026
4219bae
fix: address claude comments
ben-kaufman Apr 30, 2026
fa64cb4
Update ContactActivityViewModel.kt
ben-kaufman Apr 30, 2026
266f923
fix: align add contact send
ben-kaufman May 4, 2026
2d7d9ef
fix: preload contact payments
ben-kaufman May 4, 2026
6f9aa72
fix: align onchain paykit
ben-kaufman May 4, 2026
263a297
fix: gate contact details loading
ben-kaufman May 4, 2026
2da3269
fix: preserve pubky session
ben-kaufman May 4, 2026
c1b8120
fix: load contacts after auth
ben-kaufman May 4, 2026
37eb079
fix: show contact activity titles
ben-kaufman May 4, 2026
98b4e0c
fix: expose immutable contacts
ben-kaufman May 4, 2026
0ad5fd3
fix: polish paykit contacts
ben-kaufman May 4, 2026
c401c84
fix: serialize paykit publishing
ben-kaufman May 4, 2026
2affee5
Merge branch 'master' into codex-public-payments-contacts
ben-kaufman May 4, 2026
b7e7791
fix: isolate paykit invoice
ben-kaufman May 4, 2026
185de8c
fix: harden contact paykit
ben-kaufman May 4, 2026
6a67b2c
fix: follow repo rules
ben-kaufman May 4, 2026
1a0d751
fix: manage paykit endpoint lifecycle
ben-kaufman May 4, 2026
7b030f1
fix: allow cleanup failure
ben-kaufman May 4, 2026
de2cee7
fix: tag pending contact payments
ben-kaufman May 4, 2026
9a25b62
fix: harden public contact payments
ben-kaufman May 4, 2026
21825ef
fix: clean up activity contact titles
ben-kaufman May 4, 2026
57d0de6
fix: encode paykit bip21 invoice
ben-kaufman May 4, 2026
e9a12c1
fix: align contact detail actions
ben-kaufman May 4, 2026
2920a97
fix: reset paykit state on signout
ben-kaufman May 4, 2026
25b823b
fix: refresh paykit on channel events
ben-kaufman May 4, 2026
70bde1a
fix: skip quickpay for contacts
ben-kaufman May 4, 2026
afeb1f4
fix: update paykit bindings
ben-kaufman May 5, 2026
94938d2
fix: address paykit review feedback
ovitrif May 5, 2026
d666ceb
fix: restore paykit node gate
ovitrif May 5, 2026
b54ba49
fix: type paykit endpoint payload
ovitrif May 5, 2026
774f154
fix: align bolt11 expiry
ovitrif May 5, 2026
1e4dd83
fix: restore paykit invoice expiry
ovitrif May 5, 2026
8a5c573
Merge pull request #930 from synonymdev/codex/pr924-review-fixes
ovitrif May 5, 2026
50e1552
fix: harden paykit endpoints
ben-kaufman May 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/src/main/java/to/bitkit/data/SettingsStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@ data class SettingsData(
val hasSeenShopIntro: Boolean = false,
val hasSeenProfileIntro: Boolean = false,
val hasSeenContactsIntro: Boolean = false,
val hasConfirmedPublicPaykitEndpoints: Boolean = false,
Comment thread
ovitrif marked this conversation as resolved.
val sharesPublicPaykitEndpoints: Boolean = false,
val publicPaykitBolt11: String = "",
val publicPaykitBolt11PaymentHash: String = "",
val publicPaykitBolt11ExpiresAtMillis: Long = 0,
val quickPayIntroSeen: Boolean = false,
val bgPaymentsIntroSeen: Boolean = false,
val isQuickPayEnabled: Boolean = false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ import to.bitkit.models.NewTransactionSheetType
import to.bitkit.models.NotificationDetails
import to.bitkit.models.PrimaryDisplay
import to.bitkit.models.formatToModernDisplay
import to.bitkit.repositories.ActivityRepo
import to.bitkit.models.msatCeilOf
import to.bitkit.repositories.ActivityRepo
import to.bitkit.repositories.CurrencyRepo
import to.bitkit.utils.Logger
import javax.inject.Inject
Expand Down
5 changes: 4 additions & 1 deletion app/src/main/java/to/bitkit/env/Env.kt
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ internal object Env {
Network.BITCOIN -> ""
else -> "staging."
}
return "/pub/$pubkyDomain/:rw,/pub/${prefix}pubky.app/:r,/pub/${prefix}paykit/v0/:rw"
return "/pub/$pubkyDomain/:rw,/pub/${prefix}pubky.app/:r,/pub/paykit/v0/:rw"
}

val homegateUrl: String
Expand Down Expand Up @@ -245,6 +245,9 @@ internal object Env {

@Suppress("ConstPropertyName")
object Defaults {
/** Default Bolt11 invoice expiry in seconds. */
const val bolt11InvoiceExpirySeconds = 3_600u

/** Recommended transaction base fee in sats */
const val recommendedBaseFee = 256u

Expand Down
9 changes: 9 additions & 0 deletions app/src/main/java/to/bitkit/ext/Activities.kt
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ fun Activity.isSent() = when (this) {
is Activity.Onchain -> v1.txType == PaymentType.SENT
}

fun Activity.contact(): String? = when (this) {
is Activity.Lightning -> v1.contact
is Activity.Onchain -> v1.contact
}

fun Activity.matchesPaymentId(paymentHashOrTxId: String): Boolean = when (this) {
is Activity.Lightning -> paymentHashOrTxId == v1.id
is Activity.Onchain -> paymentHashOrTxId == v1.txId
Expand Down Expand Up @@ -92,6 +97,7 @@ fun LightningActivity.Companion.create(
fee: ULong = 0u,
message: String = "",
preimage: String? = null,
contact: String? = null,
createdAt: ULong? = timestamp,
updatedAt: ULong? = createdAt,
seenAt: ULong? = null,
Expand All @@ -105,6 +111,7 @@ fun LightningActivity.Companion.create(
message = message,
timestamp = timestamp,
preimage = preimage,
contact = contact,
createdAt = createdAt,
updatedAt = updatedAt,
seenAt = seenAt,
Expand All @@ -128,6 +135,7 @@ fun OnchainActivity.Companion.create(
confirmTimestamp: ULong? = null,
channelId: String? = null,
transferTxId: String? = null,
contact: String? = null,
createdAt: ULong? = timestamp,
updatedAt: ULong? = createdAt,
seenAt: ULong? = null,
Expand All @@ -148,6 +156,7 @@ fun OnchainActivity.Companion.create(
confirmTimestamp = confirmTimestamp,
channelId = channelId,
transferTxId = transferTxId,
contact = contact,
createdAt = createdAt,
updatedAt = updatedAt,
seenAt = seenAt,
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import to.bitkit.ext.amountOnClose
import to.bitkit.ext.toUserMessage
import to.bitkit.models.BITCOIN_SYMBOL
import to.bitkit.models.BlocktankNotificationType
import to.bitkit.models.msatCeilOf
import to.bitkit.models.BlocktankNotificationType.cjitPaymentArrived
import to.bitkit.models.BlocktankNotificationType.incomingHtlc
import to.bitkit.models.BlocktankNotificationType.mutualClose
Expand All @@ -36,6 +35,7 @@ import to.bitkit.models.NewTransactionSheetDetails
import to.bitkit.models.NewTransactionSheetDirection
import to.bitkit.models.NewTransactionSheetType
import to.bitkit.models.NotificationDetails
import to.bitkit.models.msatCeilOf
import to.bitkit.repositories.ActivityRepo
import to.bitkit.repositories.BlocktankRepo
import to.bitkit.repositories.LightningRepo
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/java/to/bitkit/models/MSat.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,7 @@ fun msatCeilOf(msat: ULong): ULong = MSat(msat).ceil()

/** Truncate sub-sat remainder from [msat]. Use for fees and upper bounds. */
fun msatFloorOf(msat: ULong): ULong = MSat(msat).floor()

/** Convert [sats] to millisats with saturating overflow protection. */
fun satsToMsat(sats: ULong): ULong =
if (sats <= ULong.MAX_VALUE / MSat.PER_SAT) sats * MSat.PER_SAT else ULong.MAX_VALUE
7 changes: 7 additions & 0 deletions app/src/main/java/to/bitkit/models/PubkyPublicKeyFormat.kt
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package to.bitkit.models

import to.bitkit.ext.ellipsisMiddle
import java.util.Locale

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

private val zBase32Regex = Regex("^[ybndrfg8ejkmcpqxot1uwisza345h769]+$")
Expand Down Expand Up @@ -32,4 +34,9 @@ object PubkyPublicKeyFormat {
val normalizedRhs = rhs?.let(::normalized) ?: return false
return normalizedLhs == normalizedRhs
}

fun redacted(input: String): String {
val normalizedInput = normalized(input) ?: input.trim()
return normalizedInput.ellipsisMiddle(redactedLength)
}
}
68 changes: 68 additions & 0 deletions app/src/main/java/to/bitkit/repositories/ActivityRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,15 @@ import org.lightningdevkit.ldknode.TransactionDetails
import to.bitkit.data.CacheStore
import to.bitkit.data.dto.PendingBoostActivity
import to.bitkit.di.BgDispatcher
import to.bitkit.di.IoDispatcher
import to.bitkit.ext.amountOnClose
import to.bitkit.ext.contact
import to.bitkit.ext.matchesPaymentId
import to.bitkit.ext.nowMillis
import to.bitkit.ext.nowTimestamp
import to.bitkit.ext.rawId
import to.bitkit.models.ActivityBackupV1
import to.bitkit.models.PubkyPublicKeyFormat
import to.bitkit.services.CoreService
import to.bitkit.utils.AppError
import to.bitkit.utils.Logger
Expand All @@ -55,6 +58,7 @@ private const val MS_SYNC_TIMEOUT = 40_000L
@Singleton
class ActivityRepo @Inject constructor(
@BgDispatcher private val bgDispatcher: CoroutineDispatcher,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val coreService: CoreService,
private val lightningRepo: LightningRepo,
private val blocktankRepo: BlocktankRepo,
Expand Down Expand Up @@ -338,6 +342,69 @@ class ActivityRepo @Inject constructor(
}
}

suspend fun contactActivities(publicKey: String): Result<List<Activity>> = withContext(ioDispatcher) {
runCatching {
val normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) ?: publicKey
getActivities(
filter = ActivityFilter.ALL,
sortDirection = SortDirection.DESC,
).getOrThrow().filter { PubkyPublicKeyFormat.matches(it.contact(), normalizedKey) }
}.onFailure {
Logger.error("Failed to load contact activities for '$publicKey'", it, context = TAG)
}
}

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

val normalizedKey = PubkyPublicKeyFormat.normalized(contactPublicKey) ?: contactPublicKey
val activity = findActivityForPaymentId(forPaymentId, syncLdkPayments)
if (activity == null) {
Logger.warn(
"Skipped setting contact for payment '$forPaymentId' because activity was not found",
context = TAG,
)
return@runCatching
}
if (PubkyPublicKeyFormat.matches(activity.contact(), normalizedKey)) {
return@runCatching
}

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

private suspend fun findActivityForPaymentId(forPaymentId: String, syncLdkPayments: Boolean): Activity? {
val activity = getActivityByPaymentId(forPaymentId)
if (activity != null) return activity
if (!syncLdkPayments) return null

syncActivities().getOrThrow()
return getActivityByPaymentId(forPaymentId)
}

private suspend fun getActivityByPaymentId(forPaymentId: String): Activity? =
coreService.activity.getActivity(forPaymentId)
?: getOnchainActivityByTxId(forPaymentId)?.let { Activity.Onchain(it) }

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))
}

suspend fun getClosedChannels(
sortDirection: SortDirection = SortDirection.ASC,
): Result<List<ClosedChannelDetails>> = withContext(bgDispatcher) {
Expand Down Expand Up @@ -515,6 +582,7 @@ class ActivityRepo @Inject constructor(
message = "",
timestamp = now,
preimage = null,
contact = null,
createdAt = now,
updatedAt = null,
seenAt = null,
Expand Down
3 changes: 2 additions & 1 deletion app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import org.lightningdevkit.ldknode.ChannelDetails
import to.bitkit.async.ServiceQueue
import to.bitkit.data.CacheStore
import to.bitkit.di.BgDispatcher
import to.bitkit.env.Defaults
import to.bitkit.env.Env
import to.bitkit.ext.calculateRemoteBalance
import to.bitkit.ext.nowTimestamp
Expand Down Expand Up @@ -462,7 +463,7 @@ class BlocktankRepo @Inject constructor(
val invoice = lightningRepo.createInvoice(
amountSats = null,
description = "blocktank-gift-code:$code",
expirySeconds = 3600u,
expirySeconds = Defaults.bolt11InvoiceExpirySeconds,
).getOrThrow()

Logger.debug("Created invoice for gift code, requesting payment from LSP", context = TAG)
Expand Down
6 changes: 3 additions & 3 deletions app/src/main/java/to/bitkit/repositories/LightningRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,12 @@ import to.bitkit.ext.nowTimestamp
import to.bitkit.ext.toPeerDetailsList
import to.bitkit.models.ALL_ADDRESS_TYPE_STRINGS
import to.bitkit.models.CoinSelectionPreference
import to.bitkit.models.MSat
import to.bitkit.models.NATIVE_WITNESS_TYPES
import to.bitkit.models.NodeLifecycleState
import to.bitkit.models.OpenChannelResult
import to.bitkit.models.TransactionSpeed
import to.bitkit.models.safe
import to.bitkit.models.satsToMsat
import to.bitkit.models.toAddressType
import to.bitkit.models.toCoinSelectAlgorithm
import to.bitkit.models.toCoreNetwork
Expand Down Expand Up @@ -1396,7 +1396,7 @@ class LightningRepo @Inject constructor(
context = TAG,
)
val result = if (amountSats != null) {
val amountMsat = amountSats.safe() * MSat.PER_SAT.safe()
val amountMsat = satsToMsat(amountSats)
lightningService.sendProbesUsingAmount(bolt11, amountMsat)
} else {
lightningService.sendProbes(bolt11)
Expand All @@ -1411,7 +1411,7 @@ class LightningRepo @Inject constructor(
"Sending keysend probe to nodeId='$nodeId' amountSats='$amountSats'",
context = TAG,
)
val amountMsat = amountSats.safe() * MSat.PER_SAT.safe()
val amountMsat = satsToMsat(amountSats)
lightningService.sendKeysendProbe(nodeId, amountMsat).map {
ProbeDispatch(paymentIds = it)
}
Expand Down
Loading
Loading