Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
d71e3c0
WIP: add public Paykit endpoints
ben-kaufman Apr 25, 2026
11b62ce
fix: align contact add flow with Android
ben-kaufman Apr 27, 2026
c4458e4
fix: complete public Paykit contact payments
ben-kaufman Apr 29, 2026
c863f46
Update CHANGELOG.md
ben-kaufman Apr 29, 2026
16b34dc
fix: surface contact activity load errors
ben-kaufman Apr 29, 2026
85858ee
fix: move contact activity sync out of send views
ben-kaufman Apr 29, 2026
220ee6a
fix: keep contact payment boundaries in view layer
ben-kaufman Apr 29, 2026
3d1f2b2
fix: show contact names in activity rows
ben-kaufman May 4, 2026
edc5e9f
fix: disambiguate contact test models
ben-kaufman May 4, 2026
01aefdd
fix: address public payments review cleanup
ben-kaufman May 4, 2026
98d3e04
refactor: share contact payment route helper
ben-kaufman May 4, 2026
c430f9a
fix: include pubky key format in shared targets
ben-kaufman May 4, 2026
2249d9d
fix: address public payment review followups
ben-kaufman May 4, 2026
7d48489
fix: preserve contact context for fast payments
ben-kaufman May 4, 2026
73971b6
fix: isolate public paykit receive invoice
ben-kaufman May 4, 2026
30ce344
fix: address public paykit review blockers
ben-kaufman May 4, 2026
07b7a2a
chore: align pay contacts lifecycle hook
ben-kaufman May 4, 2026
fac5074
fix: handle optional public invoice description
ben-kaufman May 4, 2026
e037fe7
fix: align public paykit endpoint lifecycle
ben-kaufman May 4, 2026
1b7c140
fix: avoid async autoclosure in paykit refresh
ben-kaufman May 4, 2026
3ea3f58
fix: tighten public paykit lifecycle
ben-kaufman May 4, 2026
5ee3d84
fix: address public paykit cleanup review
ben-kaufman May 4, 2026
a41afa5
fix: align paykit contacts with android
ben-kaufman May 4, 2026
c589229
chore: update paykit bindings to rc5
ben-kaufman May 5, 2026
31b2b28
fix: use network-specific paykit onchain methods
ben-kaufman May 5, 2026
768c3d0
Merge branch 'master' into codex-paykit-public-endpoints-pr527
ovitrif May 5, 2026
2881537
fix: address paykit review feedback
ben-kaufman May 5, 2026
6aeb1a8
Merge branch 'master' into codex-paykit-public-endpoints-pr527
ben-kaufman May 6, 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
8 changes: 4 additions & 4 deletions Bitkit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -904,8 +904,8 @@
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/pubky/paykit-rs";
requirement = {
kind = revision;
revision = cd1253291b1582759d569372d5942b8871527ea1;
kind = exactVersion;
version = 0.1.0-rc3;
};
};
18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */ = {
Expand Down Expand Up @@ -936,8 +936,8 @@
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/synonymdev/bitkit-core";
requirement = {
kind = revision;
revision = 99bd86bb60c1f14e8ce8a6356cd2ab36f222fc69;
kind = exactVersion;
version = 0.1.58;
};
};
96E20CD22CB6D91A00C24149 /* XCRemoteSwiftPackageReference "CodeScanner" */ = {
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion Bitkit/Components/Activity/ActivityList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import SwiftUI

struct ActivityList: View {
@EnvironmentObject var activity: ActivityListViewModel
@EnvironmentObject var contactsManager: ContactsManager
@EnvironmentObject var feeEstimatesManager: FeeEstimatesManager
@State private var isHorizontalSwipe = false

Expand All @@ -28,7 +29,11 @@ struct ActivityList: View {

case let .activity(item):
NavigationLink(value: Route.activityDetail(item)) {
ActivityRow(item: item, feeEstimates: feeEstimatesManager.estimates)
ActivityRow(
item: item,
feeEstimates: feeEstimatesManager.estimates,
contact: item.contact(in: contactsManager.contacts)
)
}
.accessibilityIdentifier("Activity-\(index)")
.disabled(isHorizontalSwipe)
Expand Down
2 changes: 1 addition & 1 deletion Bitkit/Constants/Env.swift
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ enum Env {
case .bitcoin:
return "/pub/bitkit.to/:rw,/pub/pubky.app/:r,/pub/paykit/v0/:rw"
default:
return "/pub/staging.bitkit.to/:rw,/pub/staging.pubky.app/:r,/pub/staging.paykit/v0/:rw"
return "/pub/staging.bitkit.to/:rw,/pub/staging.pubky.app/:r,/pub/paykit/v0/:rw"
}
}

Expand Down
18 changes: 18 additions & 0 deletions Bitkit/Extensions/Activity+Contact.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import BitkitCore

extension Activity {
func contact(in contacts: [PubkyContact]) -> PubkyContact? {
guard let contactPublicKey else { return nil }
return contacts.first(where: { PubkyPublicKeyFormat.matches($0.publicKey, contactPublicKey) })
}

private var contactPublicKey: String? {
switch self {
case let .lightning(lightning):
return lightning.contact

case let .onchain(onchain):
return onchain.contact
}
}
}
1 change: 1 addition & 0 deletions Bitkit/MainNavView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ struct MainNavView: View {
}
case .contactsIntro: ContactsIntroView()
case let .contactDetail(publicKey): ContactDetailView(publicKey: publicKey)
case let .contactActivity(publicKey): ContactActivityView(publicKey: publicKey)
case .contactImportOverview:
if let fallbackRoute = fallbackRouteForMissingPendingImport(hasPendingImport: contactsManager.hasPendingImport) {
missingPendingImportView(fallbackRoute: fallbackRoute)
Expand Down
29 changes: 21 additions & 8 deletions Bitkit/Managers/ContactsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ enum PubkyPublicKeyFormat {

enum AddContactValidationResult: Equatable {
case empty
case existingContact
case invalidKey
case ownKey
case valid(normalizedKey: String)
Expand All @@ -57,6 +58,8 @@ enum AddContactValidationResult: Equatable {
switch self {
case .empty, .valid:
nil
case .existingContact:
t("contacts__add_error_existing")
case .invalidKey:
t("contacts__add_error_invalid_key")
case .ownKey:
Expand All @@ -65,7 +68,11 @@ enum AddContactValidationResult: Equatable {
}
}

func resolveAddContactValidation(input: String, ownPublicKey: String?) -> AddContactValidationResult {
func resolveAddContactValidation(
input: String,
ownPublicKey: String?,
existingContacts: [PubkyContact] = []
) -> AddContactValidationResult {
let trimmedInput = input.trimmingCharacters(in: .whitespacesAndNewlines)

guard !trimmedInput.isEmpty else {
Expand All @@ -80,26 +87,33 @@ func resolveAddContactValidation(input: String, ownPublicKey: String?) -> AddCon
return .invalidKey
}

if existingContacts.contains(where: { PubkyPublicKeyFormat.matches($0.publicKey, normalizedKey) }) {
return .existingContact
}

return .valid(normalizedKey: normalizedKey)
}

enum ContactsManagerError: LocalizedError {
case invalidPublicKey
case cannotAddYourself
case alreadyExists

var errorDescription: String? {
switch self {
case .invalidPublicKey:
return t("contacts__add_error_invalid_key")
case .cannotAddYourself:
return t("contacts__add_error_self")
case .alreadyExists:
return t("contacts__add_error_existing")
}
}
}

// MARK: - PubkyContact

struct PubkyContact: Identifiable, Hashable, Sendable {
struct PubkyContact: Identifiable, Hashable {
let id: String
let publicKey: String
let profile: PubkyProfile
Expand Down Expand Up @@ -142,6 +156,7 @@ class ContactsManager: ObservableObject {
@Published var isLoading = false
@Published var hasLoaded = false
@Published var loadErrorMessage: String?
@Published var shouldOpenAddContactSheet = false

/// Temporarily holds contacts discovered during import (e.g., from pubky.app after Ring auth).
/// Cleared after import is completed or discarded.
Expand All @@ -164,6 +179,7 @@ class ContactsManager: ObservableObject {
isLoading = false
hasLoaded = false
loadErrorMessage = nil
shouldOpenAddContactSheet = false
clearPendingImport()
}

Expand Down Expand Up @@ -290,9 +306,8 @@ class ContactsManager: ObservableObject {
throw ContactsManagerError.cannotAddYourself
}

guard !contacts.contains(where: { $0.publicKey == prefixedKey }) else {
Logger.debug("Contact \(prefixedKey) already exists, skipping add", context: "ContactsManager")
return
guard !contacts.contains(where: { PubkyPublicKeyFormat.matches($0.publicKey, prefixedKey) }) else {
throw ContactsManagerError.alreadyExists
}

// Use existing profile if provided (e.g., already fetched during preview),
Expand Down Expand Up @@ -697,13 +712,11 @@ class ContactsManager: ObservableObject {
}

let normalized = message.lowercased()
let indicatesMissingResource = normalized.contains("404")
return normalized.contains("404")
|| normalized.contains("no such file")
|| normalized.contains("does not exist")
|| normalized.contains("profile not found")
|| normalized.contains("profilenotfound")
|| (normalized.contains("fetch failed") && normalized.contains("not found"))

return indicatesMissingResource
}
}
8 changes: 8 additions & 0 deletions Bitkit/Managers/ScannerManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import SwiftUI
import Vision

enum ScannerContext {
case addContact
case main
case send
case electrum
Expand Down Expand Up @@ -40,6 +41,8 @@ class ScannerManager: ObservableObject {
Haptics.play(.scanSuccess)

switch context {
case .addContact:
handleAddContactScan(uri)
case .main:
await handleMainScan(uri)
case .send:
Expand All @@ -49,6 +52,11 @@ class ScannerManager: ObservableObject {
}
}

private func handleAddContactScan(_ input: String) {
navigation?.navigateBack()
navigation?.navigate(.addContact(publicKey: input))
Comment thread
ben-kaufman marked this conversation as resolved.
}

private func handleMainScan(_ uri: String) async {
guard let app else { return }

Expand Down
4 changes: 4 additions & 0 deletions Bitkit/Resources/Localization/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -958,6 +958,8 @@
"slashtags__onboarding_text" = "Get automatic updates from your Bitkit contacts, pay them, and follow their public profiles.";
"slashtags__onboarding_button" = "Add First Contact";
"contacts__detail_title" = "Contact";
"contacts__activity_sent_to" = "Sent to {name}";
"contacts__activity_received_from" = "Received from {name}";
"contacts__detail_empty_state" = "Unable to load contact.";
"contacts__empty_state" = "You don't have any contacts yet.";
"contacts__intro_add_contact" = "Add Contact";
Expand All @@ -973,6 +975,7 @@
"contacts__add_pubky_placeholder" = "Paste a pubky";
"contacts__add_scan_qr" = "Scan QR";
"contacts__add_button" = "Add";
"contacts__add_error_existing" = "This pubky is already in your contacts.";
"contacts__add_error_invalid_key" = "Invalid pubky key format. Please check and try again.";
"contacts__add_error_self" = "You can't add your own pubky as a contact.";
"contacts__add_retrieving" = "Retrieving\n<accent>contact info</accent>";
Expand Down Expand Up @@ -1037,6 +1040,7 @@
"slashtags__error_deleting_profile" = "Unable To Delete Profile";
"slashtags__error_pay_title" = "Unable To Pay Contact";
"slashtags__error_pay_empty_msg" = "The contact you're trying to send to hasn't enabled payments.";
"slashtags__error_pay_not_opened_msg" = "No compatible payment endpoint is available.";
"slashtags__auth_depricated_title" = "Deprecated";
"slashtags__auth_depricated_msg" = "Slashauth is deprecated. Please use Bitkit Beta.";
"profile__nav_title" = "Profile";
Expand Down
68 changes: 67 additions & 1 deletion Bitkit/Services/CoreService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,25 @@ import LDKNode
// MARK: - Activity Service

class ActivityService {
private enum PubkyContactKey {
private static let prefix = "pubky"
private static let rawKeyLength = 52
private static let allowedCharacters = Set("ybndrfg8ejkmcpqxot1uwisza345h769")

static func normalized(_ input: String) -> String? {
let boundedInput = String(input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().prefix(prefix.count + rawKeyLength))
let rawKey = boundedInput.hasPrefix(prefix) ? String(boundedInput.dropFirst(prefix.count)) : boundedInput

guard rawKey.count == rawKeyLength,
rawKey.allSatisfy({ allowedCharacters.contains($0) })
else {
return nil
}

return "\(prefix)\(rawKey)"
}
}

private let coreService: CoreService

private let activitiesChangedSubject = PassthroughSubject<Void, Never>()
Expand Down Expand Up @@ -395,6 +414,7 @@ class ActivityService {
var isTransfer = existingOnchain?.isTransfer ?? false
var channelId = existingOnchain?.channelId
let transferTxId = existingOnchain?.transferTxId
let contact = existingOnchain?.contact
let feeRate = existingOnchain?.feeRate ?? 1
let preservedAddress = existingOnchain?.address ?? "Loading..."
let doesExist = existingOnchain?.doesExist ?? true
Expand Down Expand Up @@ -466,6 +486,7 @@ class ActivityService {
confirmTimestamp: blockTimestamp,
channelId: channelId,
transferTxId: transferTxId,
contact: contact,
createdAt: UInt64(payment.creationTime.timeIntervalSince1970),
updatedAt: paymentTimestamp,
seenAt: seenAt
Expand Down Expand Up @@ -691,6 +712,7 @@ class ActivityService {
message: description ?? "",
timestamp: paymentTimestamp,
preimage: preimage,
contact: existingLightning?.contact,
createdAt: paymentTimestamp,
updatedAt: paymentTimestamp,
seenAt: existingLightning?.seenAt
Expand Down Expand Up @@ -960,6 +982,20 @@ class ActivityService {
}
}

func get(contact publicKey: String, sortDirection: SortDirection = .desc) async throws -> [Activity] {
let normalizedKey = PubkyContactKey.normalized(publicKey) ?? publicKey
let activities = try await get(filter: .all, sortDirection: sortDirection)

return activities.filter { activity in
switch activity {
case let .lightning(lightning):
return lightning.contact == normalizedKey
case let .onchain(onchain):
return onchain.contact == normalizedKey
}
}
}

func update(id: String, activity: Activity) async throws {
try await ServiceQueue.background(.core) {
try updateActivity(activityId: id, activity: activity)
Expand All @@ -982,7 +1018,8 @@ class ActivityService {
address: String,
amount: UInt64,
fee: UInt64,
feeRate: UInt32
feeRate: UInt32,
contact: String? = nil
) async {
do {
try await ServiceQueue.background(.core) {
Expand All @@ -1008,6 +1045,7 @@ class ActivityService {
confirmTimestamp: nil,
channelId: nil,
transferTxId: nil,
contact: contact.map { PubkyContactKey.normalized($0) ?? $0 },
createdAt: now,
updatedAt: now,
seenAt: now
Expand All @@ -1022,6 +1060,32 @@ class ActivityService {
}
}

func setContact(_ publicKey: String?, forActivity id: String) async throws {
let normalizedContact = publicKey.map { PubkyContactKey.normalized($0) ?? $0 }

try await ServiceQueue.background(.core) {
guard let activity = try getActivityById(activityId: id) else {
return
}

switch activity {
case var .lightning(lightning):
guard lightning.contact != normalizedContact else { return }
lightning.contact = normalizedContact
lightning.updatedAt = UInt64(Date().timeIntervalSince1970)
try updateActivity(activityId: id, activity: .lightning(lightning))
self.activitiesChangedSubject.send()

case var .onchain(onchain):
guard onchain.contact != normalizedContact else { return }
onchain.contact = normalizedContact
onchain.updatedAt = UInt64(Date().timeIntervalSince1970)
try updateActivity(activityId: id, activity: .onchain(onchain))
self.activitiesChangedSubject.send()
}
}
}

func delete(id: String) async throws -> Bool {
try await ServiceQueue.background(.core) {
// Rebuild cache if deleting an onchain activity with boostTxIds
Expand Down Expand Up @@ -1217,6 +1281,7 @@ class ActivityService {
message: template.message,
timestamp: timestamp,
preimage: template.status == .succeeded ? "preimage\(activityId)" : nil,
contact: nil,
createdAt: timestamp,
updatedAt: timestamp,
seenAt: nil
Expand All @@ -1241,6 +1306,7 @@ class ActivityService {
confirmTimestamp: template.confirmed == true ? timestamp + 3600 : nil,
channelId: nil,
transferTxId: nil,
contact: nil,
createdAt: timestamp,
updatedAt: timestamp,
seenAt: nil
Expand Down
Loading
Loading