Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
38417b4
chore: add forceClose parameter
jvsena42 Nov 6, 2025
2c61e60
feat: force transfer sheet and action
jvsena42 Nov 6, 2025
d235f73
Merge branch 'master' into feat/force-close
jvsena42 Nov 6, 2025
0c61e10
fix: parameter name
jvsena42 Nov 6, 2025
a8a927f
chore: remove redundant variable
jvsena42 Nov 6, 2025
7a5c6cf
fix: agregate errors and throw an agregated list
jvsena42 Nov 6, 2025
9cc4ab8
chore: remove weak reference
jvsena42 Nov 6, 2025
ac75fd7
Merge branch 'master' into feat/force-close
jvsena42 Nov 6, 2025
5eaba87
Merge branch 'feat/force-close' of github.com:synonymdev/bitkit-ios i…
jvsena42 Nov 6, 2025
354976c
chore: remove log
jvsena42 Nov 6, 2025
120f986
Merge branch 'master' into feat/transfer-activities
jvsena42 Nov 9, 2025
a017499
feat: metadata model
jvsena42 Nov 10, 2025
386d12f
feat: create store class
jvsena42 Nov 10, 2025
f5846f3
feat: save metadata on send method
jvsena42 Nov 10, 2025
7cb329d
refactor: move metadata saving to LightningService
jvsena42 Nov 10, 2025
8881e51
chore: create updateActivityMetadata method
jvsena42 Nov 10, 2025
77c1905
feat: Update activities with saved metadata after syncing payments
jvsena42 Nov 10, 2025
2aea2f0
Merge branch 'master' into feat/transfer-activities
jvsena42 Nov 11, 2025
4c6c59e
Merge branch 'master' into feat/transfer-activities
jvsena42 Nov 12, 2025
4c15d11
chore: add logs
jvsena42 Nov 12, 2025
a69df2a
chore: log
jvsena42 Nov 12, 2025
878f35b
fix: update pbxproj
jvsena42 Nov 12, 2025
bfa4025
fix: prevent rewrite with old payment data
jvsena42 Nov 12, 2025
e27b069
feat: transfer fee estimation
jvsena42 Nov 12, 2025
46f8fed
feat: backup activities
jvsena42 Nov 12, 2025
b1f27b1
chore: update pbxproj
jvsena42 Nov 13, 2025
7c1df36
Merge branch 'master' into feat/transfer-activities
jvsena42 Nov 13, 2025
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
4 changes: 4 additions & 0 deletions Bitkit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,12 @@
Models/BlocktankNotificationType.swift,
Models/LnPeer.swift,
Models/Toast.swift,
Models/TransactionMetadata.swift,
Services/CoreService.swift,
Services/LightningService.swift,
Services/MigrationsService.swift,
Services/ServiceQueue.swift,
Services/TransactionMetadataStorage.swift,
Services/VssStoreIdProvider.swift,
Utilities/AddressChecker.swift,
Utilities/Crypto.swift,
Expand All @@ -112,9 +114,11 @@
Models/LnPeer.swift,
Models/ReceivedTxSheetDetails.swift,
Models/Toast.swift,
Models/TransactionMetadata.swift,
Services/CoreService.swift,
Services/LightningService.swift,
Services/ServiceQueue.swift,
Services/TransactionMetadataStorage.swift,
Services/VssStoreIdProvider.swift,
Utilities/AddressChecker.swift,
Utilities/Crypto.swift,
Expand Down
2 changes: 1 addition & 1 deletion Bitkit/AppScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ struct AppScene: View {
_currency = StateObject(wrappedValue: CurrencyViewModel())
_blocktank = StateObject(wrappedValue: BlocktankViewModel())
_activity = StateObject(wrappedValue: ActivityListViewModel(transferService: transferService))
_transfer = StateObject(wrappedValue: TransferViewModel(transferService: transferService))
_transfer = StateObject(wrappedValue: TransferViewModel(transferService: transferService, sheetViewModel: sheetViewModel))
_widgets = StateObject(wrappedValue: WidgetsViewModel())
_settings = StateObject(wrappedValue: SettingsViewModel.shared)

Expand Down
8 changes: 8 additions & 0 deletions Bitkit/MainNavView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,14 @@ struct MainNavView: View {
) {
config in SendSheet(config: config)
}
.sheet(
item: $sheets.forceTransferSheetItem,
onDismiss: {
sheets.hideSheet()
}
) {
config in ForceTransferSheet(config: config)
}
.accentColor(.white)
.overlay {
TabBar()
Expand Down
1 change: 1 addition & 0 deletions Bitkit/Models/BackupPayloads.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ struct AppCacheData: Codable {
let highBalanceIgnoreTimestamp: TimeInterval
let dismissedSuggestions: [String]
let lastUsedTags: [String]
let transactionsMetadata: [TransactionMetadata]
}

struct BlocktankBackupV1: Codable {
Expand Down
41 changes: 41 additions & 0 deletions Bitkit/Models/TransactionMetadata.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import Foundation

/// Metadata for onchain transactions that needs to be temporarily stored
/// until it can be applied to activities during sync operations
struct TransactionMetadata: Codable, Identifiable {
/// Transaction ID (also serves as the unique identifier)
let txId: String

/// Fee rate in satoshis per vbyte
let feeRate: UInt64

/// Destination address
let address: String

/// Whether this transaction is a transfer between wallets (e.g., channel funding)
let isTransfer: Bool

/// Associated channel ID for channel funding transactions
let channelId: String?

/// Timestamp when this metadata was created (for cleanup purposes)
let createdAt: UInt64

var id: String { txId }

init(
txId: String,
feeRate: UInt64,
address: String,
isTransfer: Bool,
channelId: String? = nil,
createdAt: UInt64
) {
self.txId = txId
self.feeRate = feeRate
self.address = address
self.isTransfer = isTransfer
self.channelId = channelId
self.createdAt = createdAt
}
}
13 changes: 12 additions & 1 deletion Bitkit/Services/BackupService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,9 @@ class BackupService {
await SettingsViewModel.shared.restoreAppCacheData(payload.cache)

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

// Apply transaction metadata to activities after restore
try await CoreService.shared.activity.updateActivitiesMetadata()
}

try await performRestore(category: .blocktank) { dataBytes in
Expand Down Expand Up @@ -324,6 +327,14 @@ class BackupService {
}
.store(in: &cancellables)

TransactionMetadataStorage.shared.metadataChangedPublisher
.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
.sink { [weak self] _ in
guard let self, !self.isRestoring else { return }
markBackupRequired(category: .metadata)
}
.store(in: &cancellables)

// BLOCKTANK
CoreService.shared.blocktank.stateChangedPublisher
.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
Expand All @@ -348,7 +359,7 @@ class BackupService {
}
.store(in: &cancellables)

Logger.debug("Started 7 data store listeners", context: "BackupService")
Logger.debug("Started 8 data store listeners", context: "BackupService")
}

private func startPeriodicBackupFailureCheck() {
Expand Down
132 changes: 122 additions & 10 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 All @@ -117,7 +117,7 @@
confirmedTimestamp = timestamp
}

// Get existing activity to preserve certain flags like isBoosted and boostTxIds
// Get existing activity to preserve certain flags like isBoosted, boostTxIds, isTransfer, etc.
let existingActivity = try getActivityById(activityId: payment.id)
let existingOnchain: OnchainActivity? = {
if let existingActivity, case let .onchain(existing) = existingActivity {
Expand All @@ -127,6 +127,11 @@
}()
let preservedIsBoosted = existingOnchain?.isBoosted ?? false
let preservedBoostTxIds = existingOnchain?.boostTxIds ?? []
let preservedIsTransfer = existingOnchain?.isTransfer ?? false
let preservedChannelId = existingOnchain?.channelId
let preservedTransferTxId = existingOnchain?.transferTxId
let preservedFeeRate = existingOnchain?.feeRate ?? 1
let preservedAddress = existingOnchain?.address ?? "todo_find_address"

// Check if this is a replacement transaction (RBF) that should be marked as boosted
let isReplacementTransaction = ActivityService.replacementTransactions.keys.contains(txid)
Expand Down Expand Up @@ -167,31 +172,41 @@
txId: txid,
value: value,
fee: (payment.feePaidMsat ?? 0) / 1000,
feeRate: 1, // TODO: get from somewhere
address: "todo_find_address",
feeRate: preservedFeeRate, // Preserve metadata fee rate
address: preservedAddress, // Preserve metadata address
confirmed: isConfirmed,
timestamp: timestamp,
isBoosted: shouldMarkAsBoosted, // Mark as boosted if it's a replacement transaction
boostTxIds: boostTxIds,
isTransfer: false, // TODO: handle when paying for order
isTransfer: preservedIsTransfer, // Preserve metadata isTransfer flag
doesExist: true,
confirmTimestamp: confirmedTimestamp,
channelId: nil, // TODO: get from linked order
transferTxId: nil, // TODO: get from linked order
channelId: preservedChannelId, // Preserve metadata channelId
transferTxId: preservedTransferTxId, // Preserve metadata transferTxId
createdAt: UInt64(payment.creationTime.timeIntervalSince1970),
updatedAt: timestamp
)

if existingActivity != nil {
try updateActivity(activityId: payment.id, activity: .onchain(onchain))
print(payment)
updatedCount += 1
if let existingOnchain {
// Only update if the new data is actually newer
// This prevents overwriting metadata updates with older LDK sync data
let existingUpdatedAt = existingOnchain.updatedAt ?? 0
if timestamp >= existingUpdatedAt {
try updateActivity(activityId: payment.id, activity: .onchain(onchain))
print(payment)
updatedCount += 1
} else {
Logger.debug(
"Skipping update for \(txid) - existing data is newer (existing: \(existingUpdatedAt), new: \(timestamp))",
context: "CoreService.syncLdkNodePayments"
)
}
} else {
try upsertActivity(activity: .onchain(onchain))
print(payment)
addedCount += 1
}
} else if case let .bolt11(hash, preimage, secret, description, bolt11) = payment.kind {

Check warning on line 209 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 209 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 @@ -233,6 +248,103 @@
Logger.info("Synced LDK payments - Added: \(addedCount) - Updated: \(updatedCount)", context: "CoreService")
self.activitiesChangedSubject.send()
}

try await updateActivitiesMetadata()
}

func updateActivitiesMetadata() async throws {
let allMetadata = try TransactionMetadataStorage.shared.getAll()

guard !allMetadata.isEmpty else {
Logger.debug("No transaction metadata to update", context: "CoreService.updateActivitiesMetadata")
return
}

try await ServiceQueue.background(.core) {
Logger.info("Updating activities with \(allMetadata.count) metadata entries", context: "CoreService.updateActivitiesMetadata")

var updatedCount = 0
var removedCount = 0

for metadata in allMetadata {
do {
// Find activity by txId field (not by ID)
// Note: Activity IDs are payment.id, but we need to match on the txId field for onchain activities
let allActivities = try getActivities(
filter: nil,
txType: nil,
tags: nil,
search: nil,
minDate: nil,
maxDate: nil,
limit: nil,
sortDirection: nil
)
var matchingActivity: Activity?

for activity in allActivities {
if case let .onchain(onchainActivity) = activity, onchainActivity.txId == metadata.txId {
matchingActivity = activity
break
}
}

guard let activity = matchingActivity else {
Logger.debug(
"Activity not found for txId: \(metadata.txId), keeping metadata for next sync",
context: "CoreService.updateActivitiesMetadata"
)
continue
}

// Only update onchain activities (already verified above)
guard case var .onchain(onchainActivity) = activity else {
Logger.debug("Activity \(metadata.txId) is not onchain, skipping", context: "CoreService.updateActivitiesMetadata")
// Remove metadata since it won't be applicable
try? TransactionMetadataStorage.shared.remove(txId: metadata.txId)
removedCount += 1
continue
}

// Update with metadata
onchainActivity.feeRate = metadata.feeRate
onchainActivity.address = metadata.address
onchainActivity.isTransfer = metadata.isTransfer
onchainActivity.channelId = metadata.channelId

// Update transferTxId if this is a transfer
if metadata.isTransfer {
onchainActivity.transferTxId = metadata.txId
}

onchainActivity.updatedAt = UInt64(Date().timeIntervalSince1970)

// Save updated activity using the activity ID (not txId)
try updateActivity(activityId: onchainActivity.id, activity: .onchain(onchainActivity))
updatedCount += 1

// Remove metadata after successful update
do {
try TransactionMetadataStorage.shared.remove(txId: metadata.txId)
} catch {
Logger.error("Failed to remove metadata for \(metadata.txId): \(error)", context: "CoreService.updateActivitiesMetadata")
}
removedCount += 1

Logger.debug("Updated activity with metadata: \(metadata.txId)", context: "CoreService.updateActivitiesMetadata")
} catch {
Logger.error("Failed to update activity metadata for \(metadata.txId): \(error)", context: "CoreService.updateActivitiesMetadata")
}
}

if updatedCount > 0 {
Logger.info(
"Updated \(updatedCount) activities with metadata, removed \(removedCount) metadata entries",
context: "CoreService.updateActivitiesMetadata"
)
self.activitiesChangedSubject.send()
}
}
}

func getActivity(id: String) async throws -> Activity? {
Expand Down
48 changes: 40 additions & 8 deletions Bitkit/Services/LightningService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -350,16 +350,20 @@
sats: UInt64,
satsPerVbyte: UInt32,
utxosToSpend: [SpendableUtxo]? = nil,
isMaxAmount: Bool = false
isMaxAmount: Bool = false,
isTransfer: Bool = false
) async throws -> Txid {
guard let node else {
throw AppError(serviceError: .nodeNotSetup)
}

Logger.info("Sending \(sats) sats to \(address) with fee rate \(satsPerVbyte) sats/vbyte (isMaxAmount: \(isMaxAmount))")
Logger
.info(
"Sending \(sats) sats to \(address) with fee rate \(satsPerVbyte) sats/vbyte (isMaxAmount: \(isMaxAmount), isTransfer: \(isTransfer))"
)

do {
return try await ServiceQueue.background(.ldk) {
let txid = try await ServiceQueue.background(.ldk) {
if isMaxAmount {
// For max amount sends, use sendAllToAddress to send all available funds
try node.onchainPayment().sendAllToAddress(
Expand All @@ -377,6 +381,24 @@
)
}
}

// Capture transaction metadata for later activity update
let metadata = TransactionMetadata(
txId: txid,
feeRate: UInt64(satsPerVbyte),
address: address,
isTransfer: isTransfer,
channelId: nil,
createdAt: UInt64(Date().timeIntervalSince1970)
)
do {
try TransactionMetadataStorage.shared.insert(metadata)
} catch {
Logger.error("Failed to insert transaction metadata", context: error.localizedDescription)
}
Logger.debug("Captured transaction metadata for txid: \(txid), isTransfer: \(isTransfer)", context: "LightningService")

return txid
} catch {
dumpLdkLogs()
throw error
Expand Down Expand Up @@ -419,16 +441,26 @@
}
}

func closeChannel(_ channel: ChannelDetails) async throws {
func closeChannel(_ channel: ChannelDetails, force: Bool = false, forceCloseReason: String? = nil) async throws {
guard let node else {
throw AppError(serviceError: .nodeNotStarted)
}

return try await ServiceQueue.background(.ldk) {
try node.closeChannel(
userChannelId: channel.userChannelId,
counterpartyNodeId: channel.counterpartyNodeId
)
Logger.debug("Initiating channel close (force=\(force)): '\(channel.channelId)'", context: "LightningService")

if force {
try node.forceCloseChannel(
userChannelId: channel.userChannelId,
counterpartyNodeId: channel.counterpartyNodeId,
reason: forceCloseReason ?? ""
)
} else {
try node.closeChannel(
userChannelId: channel.userChannelId,
counterpartyNodeId: channel.counterpartyNodeId
)
}
}
}

Expand Down Expand Up @@ -558,15 +590,15 @@

// TODO: actual event handler
switch event {
case let .paymentSuccessful(paymentId, paymentHash, paymentPreimage, feePaidMsat):

Check warning on line 593 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

immutable value 'paymentPreimage' was never used; consider replacing with '_' or removing it
Logger.info("✅ Payment successful: paymentId: \(paymentId ?? "?") paymentHash: \(paymentHash) feePaidMsat: \(feePaidMsat ?? 0)")
case let .paymentFailed(paymentId, paymentHash, reason):
Logger.info(
"❌ Payment failed: paymentId: \(paymentId ?? "?") paymentHash: \(paymentHash ?? "") reason: \(reason.debugDescription)"
)
case let .paymentReceived(paymentId, paymentHash, amountMsat, feePaidMsat):

Check warning on line 599 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

immutable value 'feePaidMsat' was never used; consider replacing with '_' or removing it
Logger.info("🤑 Payment received: paymentId: \(paymentId ?? "?") paymentHash: \(paymentHash) amountMsat: \(amountMsat)")
case let .paymentClaimable(paymentId, paymentHash, claimableAmountMsat, claimDeadline, customRecords):

Check warning on line 601 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

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

Check warning on line 601 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

immutable value 'claimDeadline' was never used; consider replacing with '_' or removing it
Logger.info(
"🫰 Payment claimable: paymentId: \(paymentId) paymentHash: \(paymentHash) claimableAmountMsat: \(claimableAmountMsat)"
)
Expand Down
Loading
Loading