Skip to content
Closed
Show file tree
Hide file tree
Changes from 17 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
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
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
}
}
73 changes: 73 additions & 0 deletions Bitkit/Services/CoreService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,79 @@ class ActivityService {
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
guard let activity = try getActivityById(activityId: metadata.txId) else {
Logger.debug(
"Activity not found for txId: \(metadata.txId), keeping metadata for next sync",
context: "CoreService.updateActivitiesMetadata"
)
continue
}

// Only update onchain activities
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
try updateActivity(activityId: metadata.txId, activity: .onchain(onchainActivity))
updatedCount += 1

// Remove metadata after successful update
try? TransactionMetadataStorage.shared.remove(txId: metadata.txId)
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
44 changes: 36 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,20 @@
)
}
}

// Capture transaction metadata for later activity update
let metadata = TransactionMetadata(

Check failure on line 386 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

cannot find 'TransactionMetadata' in scope
txId: txid,
feeRate: UInt64(satsPerVbyte),
address: address,
isTransfer: isTransfer,
channelId: nil,

Check failure on line 391 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

'nil' requires a contextual type
createdAt: UInt64(Date().timeIntervalSince1970)
)
try? TransactionMetadataStorage.shared.insert(metadata)

Check failure on line 394 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

cannot find 'TransactionMetadataStorage' in scope
Logger.debug("Captured transaction metadata for txid: \(txid), isTransfer: \(isTransfer)", context: "LightningService")

return txid
} catch {
dumpLdkLogs()
throw error
Expand Down Expand Up @@ -419,16 +437,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 +586,15 @@

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

Check warning on line 589 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 595 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 597 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 597 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
120 changes: 120 additions & 0 deletions Bitkit/Services/TransactionMetadataStorage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import Combine
import Foundation

/// Handles persistence of TransactionMetadata objects using UserDefaults
/// Metadata is temporarily stored until it can be applied to activities during sync
class TransactionMetadataStorage {
static let shared = TransactionMetadataStorage()

private let defaults: UserDefaults
private let metadataKey = "transactionMetadata"

private let metadataChangedSubject = PassthroughSubject<Void, Never>()

var metadataChangedPublisher: AnyPublisher<Void, Never> {
metadataChangedSubject.eraseToAnyPublisher()
}

private init(suiteName: String? = nil) {
if let suiteName {
defaults = UserDefaults(suiteName: suiteName) ?? .standard
} else {
defaults = .standard
}
}

/// Insert a new transaction metadata entry
func insert(_ metadata: TransactionMetadata) throws {
var allMetadata = try getAll()

// Check if metadata for this txId already exists
if allMetadata.contains(where: { $0.txId == metadata.txId }) {
Logger.warn("Transaction metadata for \(metadata.txId) already exists, skipping insert", context: "TransactionMetadataStorage")
return
}

allMetadata.append(metadata)
try save(allMetadata)
Logger.info("Inserted transaction metadata: txId=\(metadata.txId)", context: "TransactionMetadataStorage")
metadataChangedSubject.send()
}

/// Insert a list of transaction metadata entries (for restore operations)
func insertList(_ metadataList: [TransactionMetadata]) throws {
var allMetadata = try getAll()
var hasChanges = false

for metadata in metadataList {
// Only insert if not already present
if !allMetadata.contains(where: { $0.txId == metadata.txId }) {
allMetadata.append(metadata)
hasChanges = true
}
}

if hasChanges {
try save(allMetadata)
Logger.info("Inserted \(metadataList.count) transaction metadata entries", context: "TransactionMetadataStorage")
metadataChangedSubject.send()
}
}

/// Get all stored transaction metadata
func getAll() throws -> [TransactionMetadata] {
guard let data = defaults.data(forKey: metadataKey) else {
return []
}

let decoder = JSONDecoder()
return try decoder.decode([TransactionMetadata].self, from: data)
}

/// Remove metadata by transaction ID
func remove(txId: String) throws {
var allMetadata = try getAll()
let originalCount = allMetadata.count

allMetadata.removeAll { $0.txId == txId }

if allMetadata.count != originalCount {
try save(allMetadata)
Logger.info("Removed transaction metadata: txId=\(txId)", context: "TransactionMetadataStorage")
metadataChangedSubject.send()
}
}

/// Remove all transaction metadata (for testing or cleanup)
func removeAll() throws {
let allMetadata = try getAll()

if allMetadata.isEmpty {
return
}

defaults.removeObject(forKey: metadataKey)
Logger.info("Removed all transaction metadata (\(allMetadata.count) entries)", context: "TransactionMetadataStorage")
metadataChangedSubject.send()
}

/// Remove old metadata entries that are older than the specified timestamp
func removeOld(olderThan timestamp: UInt64) throws {
var allMetadata = try getAll()
let originalCount = allMetadata.count

allMetadata.removeAll { $0.createdAt < timestamp }

if allMetadata.count != originalCount {
try save(allMetadata)
Logger.info("Removed \(originalCount - allMetadata.count) old transaction metadata entries", context: "TransactionMetadataStorage")
metadataChangedSubject.send()
}
}

// MARK: - Private Helpers

private func save(_ metadata: [TransactionMetadata]) throws {
let encoder = JSONEncoder()
let data = try encoder.encode(metadata)
defaults.set(data, forKey: metadataKey)
}
}
13 changes: 13 additions & 0 deletions Bitkit/ViewModels/SheetViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ enum SheetID: String, CaseIterable {
case appUpdate
case backup
case boost
case forceTransfer
case forgotPin
case gift
case highBalance
Expand Down Expand Up @@ -287,4 +288,16 @@ class SheetViewModel: ObservableObject {
}
}
}

var forceTransferSheetItem: ForceTransferSheetItem? {
get {
guard let config = activeSheetConfiguration, config.id == .forceTransfer else { return nil }
return ForceTransferSheetItem()
}
set {
if newValue == nil {
activeSheetConfiguration = nil
}
}
}
}
Loading
Loading