Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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

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

3 changes: 2 additions & 1 deletion Bitkit/Models/BackupPayloads.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ struct WalletBackupV1: Codable {
struct MetadataBackupV1: Codable {
let version: Int
let createdAt: UInt64
let tagMetadata: [ActivityTagsMetadata]
let tagMetadata: [PreActivityMetadata]

@ovitrif ovitrif Nov 14, 2025

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we name it preActivityMetadata? I can do the same in Android ;)

Or even activityMetadata, since we won't likely have a different activity metadata soon 🤷🏻

let cache: AppCacheData
}

Expand Down Expand Up @@ -47,5 +47,6 @@ struct ActivityBackupV1: Codable {
let version: Int
let createdAt: UInt64
let activities: [Activity]
let activityTags: [ActivityTags]
let closedChannels: [ClosedChannelDetails]
}
21 changes: 10 additions & 11 deletions Bitkit/Services/BackupService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -202,26 +202,23 @@ class BackupService {
let payload = try JSONDecoder().decode(ActivityBackupV1.self, from: dataBytes)

try await CoreService.shared.activity.upsertList(payload.activities)
try await CoreService.shared.activity.upsertTags(payload.activityTags)
Comment thread
jvsena42 marked this conversation as resolved.
try await CoreService.shared.activity.upsertClosedChannelList(payload.closedChannels)

Logger.debug(
"Restored \(payload.activities.count) activities, \(payload.closedChannels.count) closed channels",
"Restored \(payload.activities.count) activities, \(payload.activityTags.count) activity tags, \(payload.closedChannels.count) closed channels",
context: "BackupService"
)
}

try await performRestore(category: .metadata) { dataBytes in
let payload = try JSONDecoder().decode(MetadataBackupV1.self, from: dataBytes)

let activityTags = payload.tagMetadata.map { item in
ActivityTags(activityId: item.id, tags: item.tags)
}

try await CoreService.shared.activity.upsertTags(activityTags)
try await CoreService.shared.activity.upsertPreActivityMetadata(payload.tagMetadata)

await SettingsViewModel.shared.restoreAppCacheData(payload.cache)

Logger.debug("Restored caches and \(payload.tagMetadata.count) tags metadata records", context: "BackupService")
Logger.debug("Restored caches, \(payload.tagMetadata.count) pre-activity metadata", context: "BackupService")
}

try await performRestore(category: .blocktank) { dataBytes in
Expand Down Expand Up @@ -306,12 +303,11 @@ class BackupService {
}
.store(in: &cancellables)

// ACTIVITIES (triggers both metadata and activity backups)
// ACTIVITIES
CoreService.shared.activity.activitiesChangedPublisher
.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
.sink { [weak self] _ in
guard let self, !self.isRestoring else { return }
markBackupRequired(category: .metadata)
markBackupRequired(category: .activity)
}
.store(in: &cancellables)
Expand Down Expand Up @@ -585,13 +581,14 @@ class BackupService {

case .metadata:
let currentTime = UInt64(Date().timeIntervalSince1970)
let tagMetadata = try await CoreService.shared.activity.getAllTagMetadata()
let cache = await SettingsViewModel.shared.getAppCacheData()

let preActivityMetadata = try await CoreService.shared.activity.getAllPreActivityMetadata()

let payload = MetadataBackupV1(
version: 1,
createdAt: currentTime,
tagMetadata: tagMetadata,
tagMetadata: preActivityMetadata,
cache: cache
)
return try JSONEncoder().encode(payload)
Expand All @@ -614,11 +611,13 @@ class BackupService {
case .activity:
let activities = try await CoreService.shared.activity.get()
let closedChannels = try await CoreService.shared.activity.closedChannels()
let activityTags = try await CoreService.shared.activity.getAllActivitiesTags()

let payload = ActivityBackupV1(
version: 1,
createdAt: UInt64(Date().timeIntervalSince1970),
activities: activities,
activityTags: activityTags,
closedChannels: closedChannels
)

Expand Down
186 changes: 183 additions & 3 deletions Bitkit/Services/CoreService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@

var isConfirmed = false
var confirmedTimestamp: UInt64?
if case let .confirmed(blockHash, height, timestamp) = txStatus {

Check warning on line 108 in Bitkit/Services/CoreService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

immutable value 'height' was never used; consider replacing with '_' or removing it

Check warning on line 108 in Bitkit/Services/CoreService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

immutable value 'blockHash' was never used; consider replacing with '_' or removing it
isConfirmed = true
confirmedTimestamp = timestamp
}
Expand Down Expand Up @@ -161,14 +161,25 @@
return
}

// Find the address for the transaction
// Outbound txs have address set in bitkit-core automatically from the pre-activity metadata
var address = "todo_find_address"
Comment thread
jvsena42 marked this conversation as resolved.
Outdated
if payment.direction == .inbound {
do {
address = try await self.findReceivingAddress(for: txid, value: value)
} catch {
Logger.warn("Failed to find address for txid \(txid): \(error)", context: "CoreService.syncLdkNodePayments")
}
}

let onchain = OnchainActivity(
id: payment.id,
txType: payment.direction == .outbound ? .sent : .received,
txId: txid,
value: value,
fee: (payment.feePaidMsat ?? 0) / 1000,
feeRate: 1, // TODO: get from somewhere
address: "todo_find_address",
address: address,
confirmed: isConfirmed,
timestamp: timestamp,
isBoosted: shouldMarkAsBoosted, // Mark as boosted if it's a replacement transaction
Expand All @@ -191,7 +202,7 @@
print(payment)
addedCount += 1
}
} else if case let .bolt11(hash, preimage, secret, description, bolt11) = payment.kind {

Check warning on line 205 in Bitkit/Services/CoreService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

immutable value 'secret' was never used; consider replacing with '_' or removing it

Check warning on line 205 in Bitkit/Services/CoreService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

immutable value 'hash' was never used; consider replacing with '_' or removing it
// Skip pending inbound payments, just means they created an invoice
guard !(payment.status == .pending && payment.direction == .inbound) else { continue }

Expand Down Expand Up @@ -235,6 +246,118 @@
}
}

/// Check pre-activity metadata for addresses in the transaction
private func findAddressInPreActivityMetadata(txDetails: TxDetails, value: UInt64) async -> String? {
for output in txDetails.vout {
guard let address = output.scriptpubkey_address else { continue }
if let metadata = try? await getPreActivityMetadata(searchKey: address, searchByAddress: true),
metadata.isReceive
{
return address
}
}

return nil
}

/// Find the receiving address for an onchain transaction
private func findReceivingAddress(for txid: String, value: UInt64) async throws -> String {
let txDetails = try await AddressChecker.getTransaction(txid: txid)
let batchSize: UInt32 = 20
let currentWalletAddress = UserDefaults.standard.string(forKey: "onchainAddress") ?? ""

// Check if an address matches any transaction output
func matchesTransaction(_ address: String) -> Bool {
txDetails.vout.contains { output in
output.scriptpubkey_address == address
}
}

// Find matching address from a list, preferring exact value match
func findMatch(in addresses: [String]) -> String? {
// Try exact value match first
for address in addresses {
for output in txDetails.vout {
if output.scriptpubkey_address == address,
output.value == Int64(value)
{
return address
}
}
}
// Fallback to any address match
for address in addresses {
if matchesTransaction(address) {
return address
}
}
return nil
}

// First, check pre-activity metadata for addresses in the transaction
if let address = await findAddressInPreActivityMetadata(txDetails: txDetails, value: value) {
return address
}

// Check current address if it exists
if !currentWalletAddress.isEmpty && matchesTransaction(currentWalletAddress) {
return currentWalletAddress
}

// Search addresses forward in batches
func searchAddresses(isChange: Bool) async throws -> String? {
var index: UInt32 = 0
var currentAddressIndex: UInt32? = nil
let hasCurrentAddress = !currentWalletAddress.isEmpty
let maxIndex: UInt32 = hasCurrentAddress ? 100_000 : batchSize // 100k if current address exists, one batch otherwise
Comment thread
jvsena42 marked this conversation as resolved.
Outdated

while index < maxIndex {
let accountAddresses = try await coreService.utility.getAccountAddresses(
walletIndex: 0,
isChange: isChange,
startIndex: index,
count: batchSize
)

let addresses = accountAddresses.unused.map(\.address) + accountAddresses.used.map(\.address)

// Track when we find the current address
if hasCurrentAddress, currentAddressIndex == nil, addresses.contains(currentWalletAddress) {
currentAddressIndex = index
}

// Check for matches
if let match = findMatch(in: addresses) {
return match
}

// Stop if we've checked one batch after finding current address
if let foundIndex = currentAddressIndex, index >= foundIndex + batchSize {
break
}

// Stop if we've reached the end
if addresses.count < Int(batchSize) {
break
}

index += batchSize
}
return nil
}

// Try receiving addresses first, then change addresses
if let address = try await searchAddresses(isChange: false) {
return address
}
if let address = try await searchAddresses(isChange: true) {
return address
}

// Fallback: return first output address
return txDetails.vout.first?.scriptpubkey_address ?? "todo_find_address"
Comment thread
jvsena42 marked this conversation as resolved.
Outdated
}

func getActivity(id: String) async throws -> Activity? {
try await ServiceQueue.background(.core) {
try getActivityById(activityId: id)
Expand Down Expand Up @@ -308,9 +431,9 @@
}
}

func getAllTagMetadata() async throws -> [ActivityTagsMetadata] {
func getAllActivitiesTags() async throws -> [ActivityTags] {
try await ServiceQueue.background(.core) {
try BitkitCore.getAllTagMetadata()
try BitkitCore.getAllActivitiesTags()
}
}

Expand All @@ -320,6 +443,63 @@
}
}

// MARK: - Pre-Activity Metadata Methods

func addPreActivityMetadata(_ preActivityMetadata: BitkitCore.PreActivityMetadata) async throws {
try await ServiceQueue.background(.core) {
try BitkitCore.addPreActivityMetadata(preActivityMetadata: preActivityMetadata)
SettingsViewModel.shared.notifyAppStateChanged()

Check failure on line 451 in Bitkit/Services/CoreService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

cannot find 'SettingsViewModel' in scope
}
}

func addPreActivityMetadataTags(paymentId: String, tags: [String]) async throws {
try await ServiceQueue.background(.core) {
try BitkitCore.addPreActivityMetadataTags(paymentId: paymentId, tags: tags)
SettingsViewModel.shared.notifyAppStateChanged()

Check failure on line 458 in Bitkit/Services/CoreService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

cannot find 'SettingsViewModel' in scope
}
}

func removePreActivityMetadataTags(paymentId: String, tags: [String]) async throws {
try await ServiceQueue.background(.core) {
try BitkitCore.removePreActivityMetadataTags(paymentId: paymentId, tags: tags)
SettingsViewModel.shared.notifyAppStateChanged()

Check failure on line 465 in Bitkit/Services/CoreService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

cannot find 'SettingsViewModel' in scope
}
}

func getPreActivityMetadata(searchKey: String, searchByAddress: Bool = false) async throws -> BitkitCore.PreActivityMetadata? {
try await ServiceQueue.background(.core) {
try BitkitCore.getPreActivityMetadata(searchKey: searchKey, searchByAddress: searchByAddress)
}
}

func deletePreActivityMetadata(paymentId: String) async throws {
try await ServiceQueue.background(.core) {
try BitkitCore.deletePreActivityMetadata(paymentId: paymentId)
SettingsViewModel.shared.notifyAppStateChanged()

Check failure on line 478 in Bitkit/Services/CoreService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

cannot find 'SettingsViewModel' in scope
}
}

func resetPreActivityMetadataTags(paymentId: String) async throws {
try await ServiceQueue.background(.core) {
try BitkitCore.resetPreActivityMetadataTags(paymentId: paymentId)
SettingsViewModel.shared.notifyAppStateChanged()

Check failure on line 485 in Bitkit/Services/CoreService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

cannot find 'SettingsViewModel' in scope
}
}

// MARK: - Pre-Activity Metadata Methods (for backup service)

func upsertPreActivityMetadata(_ preActivityMetadata: [BitkitCore.PreActivityMetadata]) async throws {
try await ServiceQueue.background(.core) {
try BitkitCore.upsertPreActivityMetadata(preActivityMetadata: preActivityMetadata)
}
}

func getAllPreActivityMetadata() async throws -> [BitkitCore.PreActivityMetadata] {
try await ServiceQueue.background(.core) {
try BitkitCore.getAllPreActivityMetadata()
}
}

func boostOnchainTransaction(activityId: String, feeRate: UInt32) async throws -> String {
return try await ServiceQueue.background(.core) {
// Get the existing activity
Expand Down
21 changes: 0 additions & 21 deletions Bitkit/ViewModels/ActivityListViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -281,27 +281,6 @@ class ActivityListViewModel: ObservableObject {
try await coreService.activity.tags(forActivity: activityId)
}

func findActivityAndAddTags(paymentHashOrTxId: String, tags: [String]) async throws {
guard !tags.isEmpty else { return }

// Find the activity by payment ID
let activity = try await tryNTimes(
toTry: { try await findActivity(byPaymentId: paymentHashOrTxId) },
times: 12,
interval: 5
)

let activityId = switch activity {
case let .lightning(lightning): lightning.id
case let .onchain(onchain): onchain.id
}

// Apply tags to the activity
try await appendTags(toActivity: activityId, tags: tags)

Logger.info("Applied tags to activity: \(tags)")
}

// MARK: - Boost Methods

func boost(activityId: String, feeRate: UInt32) async throws -> String {
Expand Down
4 changes: 4 additions & 0 deletions Bitkit/ViewModels/SettingsViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,10 @@ class SettingsViewModel: NSObject, ObservableObject {
}
}

nonisolated func notifyAppStateChanged() {
appStateSubject.send()
}

// MARK: - Computed Properties

var electrumHasEdited: Bool {
Expand Down
Loading
Loading