Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
88 commits
Select commit Hold shift + click to select a range
659256a
fix: prevent false on-chain send success
ovitrif Sep 30, 2026
a063fe7
Merge remote-tracking branch 'origin/master' into codex/717-explicit-…
ovitrif Sep 30, 2026
e925ca9
chore: link release notes to the pull request
ovitrif Sep 30, 2026
bdccb67
docs: clarify accepted on-chain send journeys
ovitrif Sep 30, 2026
e447862
fix: resume original accepted payment follow-up
ovitrif Sep 30, 2026
9c84067
fix: require observed hardware shop payments
ovitrif Sep 30, 2026
f16736d
chore: use ldk node rc.68
ovitrif Sep 30, 2026
2039d39
fix: finish verified payments without another broadcast
ovitrif Sep 30, 2026
c339e9e
fix: preserve payment authorization when syncing master
ovitrif Sep 30, 2026
64dea0b
fix: preserve on-chain proof and contact state
ovitrif Sep 30, 2026
3cf7b91
fix: preserve send guards when syncing master
ovitrif Oct 5, 2026
a733791
chore: keep draft integration current with master
ovitrif Oct 5, 2026
bfa9646
fix: restore original pending payment details
ovitrif Oct 5, 2026
402d006
fix: preserve on-chain payment guards with current master
ovitrif Oct 5, 2026
994ab3b
fix: retain original payment context during startup recovery
ovitrif Oct 5, 2026
498fdeb
fix: safely retry the original on-chain payment
ovitrif Oct 5, 2026
7632642
fix: restore pending payment protection from backup
ovitrif Oct 6, 2026
2bccf53
fix: prevent unfundable send-all fee increases
ovitrif Oct 6, 2026
95f7566
fix: show the winning payment fee during recovery
ovitrif Oct 6, 2026
b229508
fix: preserve pending context after broadcast read failures
ovitrif Oct 6, 2026
b1012af
fix: use validated native payment recovery release
ovitrif Oct 6, 2026
d432db2
fix: retain payment safety with shared paykit state
ovitrif Oct 6, 2026
61e7b52
fix: retain original payments across deadline checks
ovitrif Oct 6, 2026
fc2eb30
test: preserve original payment guard expectations
ovitrif Oct 6, 2026
30a4de1
fix: preserve payment expiry during original retry
ovitrif Oct 7, 2026
182b0a0
fix: refresh visible pending payment details
ovitrif Oct 7, 2026
73d2b35
fix: open pending for unknown channel funding
ovitrif Oct 7, 2026
892fe2d
fix: open pending for rejected channel funding
ovitrif Oct 7, 2026
4b2dea6
fix: reset funding swipe while payment is pending
ovitrif Oct 7, 2026
3000b91
fix: resume original funding after recovery
ovitrif Oct 7, 2026
2db78a1
fix: reopen incomplete accepted funding
ovitrif Oct 7, 2026
cd18c1f
fix: refresh completed funding in pending
ovitrif Oct 7, 2026
bce32a8
fix: prevent unsigned payment backups
ovitrif Oct 7, 2026
1fbbb80
fix: retain failed hardware shop payments
ovitrif Oct 7, 2026
ceb327a
fix: restore original hardware shop payments
ovitrif Oct 7, 2026
bdfad61
fix: finish observed hardware shop payments
ovitrif Oct 7, 2026
a302ea2
fix: resume original shop hardware payments
ovitrif Oct 7, 2026
c742e37
fix: restore hardware dispatch state
ovitrif Oct 7, 2026
f2f14d0
fix: keep private shop cleanup retryable
ovitrif Oct 7, 2026
7deedcd
fix: preserve prior private payment boundary
ovitrif Oct 7, 2026
cbe12e9
fix: preserve private boundary on queued expiry
ovitrif Oct 7, 2026
9402476
fix: preserve payment safety with shared paykit
ovitrif Oct 7, 2026
309e5c9
fix: retain private payment version during cleanup
ovitrif Oct 7, 2026
40e97a2
fix: keep accepted payments pending until completed
ovitrif Oct 7, 2026
a4eacf2
fix: finish shop sends only after durable local follow-up
ovitrif Oct 7, 2026
b452a49
fix: finish pending shop payments after recovery
ovitrif Oct 7, 2026
132848f
fix: keep ambiguous payment candidates pending
ovitrif Oct 7, 2026
5ef04eb
fix: complete recovered shop payments once
ovitrif Oct 7, 2026
d5dcac6
fix: defer inconsistent payment backups
ovitrif Oct 7, 2026
b27a96c
fix: stop cancelled hardware sends before submission
ovitrif Oct 7, 2026
6a5f8e1
fix: keep hardware receipt cleanup serialized
ovitrif Oct 7, 2026
c86b04d
fix: serialize recovered hardware payment completion
ovitrif Oct 7, 2026
0079e44
fix: preserve hardware receipts across reopened sends
ovitrif Oct 8, 2026
50e8d3a
fix: discard cancelled on-chain retry candidates
ovitrif Oct 8, 2026
237afc9
fix: reject incomplete accepted replacement backups
ovitrif Oct 8, 2026
8918b67
fix: reject incomplete hardware payment backups
ovitrif Oct 8, 2026
4108d10
test: restore complete hardware payment receipt
ovitrif Oct 8, 2026
30d7df3
fix: preserve original payment terms on restore
ovitrif Oct 8, 2026
8dea5f2
fix: preserve send-all funding backups
ovitrif Oct 8, 2026
c307043
fix: validate prepared funding before dispatch
ovitrif Oct 8, 2026
b2b05e2
test: keep concurrent funding attempts valid
ovitrif Oct 8, 2026
2f6a202
fix: retain payment guard when proof rollback fails
ovitrif Oct 8, 2026
d14c64a
fix: release unsigned payments abandoned before dispatch
ovitrif Oct 8, 2026
b797647
fix: preserve recurring payment identity in backups
ovitrif Oct 8, 2026
db7dcc9
fix: serialize ordinary payment activity completion
ovitrif Oct 8, 2026
af4a5fe
fix: show the original payment when a new send is blocked
ovitrif Oct 8, 2026
c705902
fix: bound prepared funding by the approved total
ovitrif Oct 8, 2026
3c1f4ae
fix: recover the original recurring payment period
ovitrif Oct 8, 2026
40d6fb8
fix: release definitely unsent expired payments
ovitrif Oct 8, 2026
8dc5f93
fix: authorize recovery of the saved original payment
ovitrif Oct 8, 2026
407cf6d
fix: preserve hardware payment cleanup and contact edits
ovitrif Oct 8, 2026
a69b107
fix: defer backups during prepared retry authorization
ovitrif Oct 8, 2026
afa93df
fix: preserve hardware completion until its payer returns
ovitrif Oct 8, 2026
436adad
fix: approve exact recovery fee before authentication
ovitrif Oct 8, 2026
2992a46
fix: resume original payments after unsent retries
ovitrif Oct 8, 2026
8c16c3b
fix: allow leaving refused hardware payments safely
ovitrif Oct 9, 2026
24dc1be
fix: preserve hardware navigation guards for unknown errors
ovitrif Oct 9, 2026
0a9d82d
fix: recover unfinished shop payments and preserve signed fees
ovitrif Oct 9, 2026
ace6252
fix: retain hardware refusal navigation after restart
ovitrif Oct 9, 2026
cbe332e
fix: prevent expired hardware payment dispatch
ovitrif Oct 9, 2026
ec99bd0
fix: recognize hardware payment refusals
ovitrif Oct 9, 2026
1067305
fix: reopen retained hardware payments
ovitrif Oct 9, 2026
72814b0
fix: preserve retained recurring payment access
ovitrif Oct 9, 2026
b24a470
fix: allow maximum sends and lock hardware retry fees
ovitrif Oct 9, 2026
0e00f06
fix: keep payment recovery compatible with paykit
ovitrif Oct 9, 2026
bb02e83
docs: make fallback payment checks reproducible
ovitrif Oct 10, 2026
d89e046
fix: preserve contact edits during send recovery
ovitrif Oct 10, 2026
0c95852
fix: restore contacts on recovered sent activity
ovitrif Oct 10, 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
2 changes: 1 addition & 1 deletion Bitkit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1202,7 +1202,7 @@
repositoryURL = "https://github.com/synonymdev/ldk-node";
requirement = {
kind = exactVersion;
version = "0.7.0-rc.66";
version = "0.7.0-rc.71";
};
};
96DEA0382DE8BBA1009932BF /* XCRemoteSwiftPackageReference "bitkit-core" */ = {
Expand Down

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

48 changes: 34 additions & 14 deletions Bitkit/AppScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1357,7 +1357,19 @@ struct AppScene: View {
}

private func associateResolvedPaykitOnchainPayment(_ resolution: PaykitOnchainPaymentResolution) async {
if let identity = pubkyProfile.publicKey,
await Self.associateResolvedPaykitOnchainPayment(resolution, activeIdentity: pubkyProfile.publicKey, activity: activity)
await PaykitPaymentProofService.shared.consumeOnchainPaymentResolution(resolution, activeIdentity: pubkyProfile.publicKey)
}

static func associateResolvedPaykitOnchainPayment(
_ resolution: PaykitOnchainPaymentResolution,
activeIdentity: String?,
activity: ActivityListViewModel
) async {
// Hardware proof reconciliation already saved contact/tags in the original wallet before publishing.
// Replaying the default-wallet association would overwrite unrelated Savings rows and later edits.
guard resolution.walletId == WalletScope.default else { return }
if let identity = activeIdentity,
PubkyPublicKeyFormat.matches(resolution.identity, identity)
{
do {
Expand All @@ -1384,7 +1396,6 @@ struct AppScene: View {
)
}
}
await PaykitPaymentProofService.shared.consumeOnchainPaymentResolution(resolution)
}

private func pollIncomingPaykitPaymentRequests() async {
Expand Down Expand Up @@ -1460,14 +1471,19 @@ struct AppScene: View {
} else {
preparation = IncomingPaykitPaymentRequestPreparation(request: request, session: pubkyProfile.currentSession)
incomingPaymentRequestPreparation = preparation
sheets.showSheet(.send, data: SendConfig(view: .confirm, preparation: preparation, onDismiss: {
if preparation.resolvedRoute == nil, scenePhase == .active,
preparation.matchesSession(pubkyProfile.currentSession), let request = preparation.request
{
paykitPaymentRequestManager.dismissPreparingRequest(request)
sheets.showSheet(.send, data: SendConfig(
view: .confirm,
hardwareWalletId: paykitPaymentRequestManager.retainedHardwareRetries[request.id]?.onchainWalletId,
preparation: preparation,
onDismiss: {
if preparation.resolvedRoute == nil, scenePhase == .active,
preparation.matchesSession(pubkyProfile.currentSession), let request = preparation.request
{
paykitPaymentRequestManager.dismissPreparingRequest(request)
}
preparation.clear()
}
preparation.clear()
}))
))
}
defer {
if preparation.resolvedRoute == nil {
Expand Down Expand Up @@ -1518,11 +1534,15 @@ struct AppScene: View {

do {
try await preparation.whilePreparing {
try await app.handleScannedData(
paymentTarget,
claimedContactPaymentContext: contactPaymentContext,
alternativeOnchainBalanceSats: hwWalletManager.maximumFundingBalanceSats
)
if let proof = paykitPaymentRequestManager.retainedHardwareRetries[request.id] {
try app.handleRetainedHardwarePayment(proof, context: contactPaymentContext)
} else {
try await app.handleScannedData(
paymentTarget,
claimedContactPaymentContext: contactPaymentContext,
alternativeOnchainBalanceSats: hwWalletManager.maximumFundingBalanceSats
)
}
}
guard isCurrentIncomingPaymentRequestPreparation(preparation),
app.ownsContactPaymentContext(contactPaymentContext)
Expand Down
47 changes: 47 additions & 0 deletions Bitkit/Extensions/BroadcastError+Connectivity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,53 @@ extension Error {
return false
}

func isHardwareBroadcastRefusal() -> Bool {
if isDefiniteHardwarePreBroadcastFailure() {
return true
}
if let error = self as? BroadcastError, case let .ElectrumError(details) = error {
let prefix = "broadcast failed: "
let details = details.lowercased()
guard details.hasPrefix(prefix) else { return false }
var reason = String(details.dropFirst(prefix.count))
for envelope in ["electrum server error: ", "sendrawtransaction rpc error: "] where reason.hasPrefix(envelope) {
guard let data = String(reason.dropFirst(envelope.count)).data(using: .utf8),
let response = try? JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed)
else { return false }
if let message = response as? String {
reason = message
} else if let response = response as? [String: Any], let message = response["message"] as? String {
reason = message
} else {
return false
}
}
for prefix in ["sendrawtransaction rpc error -25: ", "sendrawtransaction rpc error -26: "] where reason.hasPrefix(prefix) {
reason = String(reason.dropFirst(prefix.count))
}
// This exact Bitcoin Core replacement refusal only releases navigation, never the receipt.
let replacementRefusal = "\\Ainsufficient fee, rejecting replacement [0-9a-f]{64}; new feerate [0-9]+\\.[0-9]{8} btc/kvb <= old feerate [0-9]+\\.[0-9]{8} btc/kvb\\z"
if reason.range(of: replacementRefusal, options: .regularExpression) != nil {
return true
}
let rejectionPrefix = "the transaction was rejected by network rules.\n\n"
if reason.hasPrefix(rejectionPrefix) {
reason = String(reason.dropFirst(rejectionPrefix.count).split(separator: "\n", maxSplits: 1).first ?? "")
}
return [
"min relay fee not met",
"mempool min fee not met",
"bad-txns-inputs-missingorspent",
"txn-mempool-conflict",
"non-final",
].contains { reason == $0 || reason.hasPrefix($0 + ", ") }
}
if let error = self as? AppError, let underlyingError = error.underlyingError {
return underlyingError.isHardwareBroadcastRefusal()
}
return false
}

func isBroadcastConnectivityFailure() -> Bool {
if let broadcastError = self as? BroadcastError {
return broadcastError.isConnectivityFailure
Expand Down
17 changes: 15 additions & 2 deletions Bitkit/Managers/HwWalletManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1235,6 +1235,14 @@ final class HwWalletManager {
)
}

nonisolated static func persistedFundingAccount(walletId: String) throws -> HwFundingAccount {
let entries = HwKnownDeviceStorage.loadAll(walletId: walletId)
guard let xpub = entries.compactMap({ $0.xpubs[hwFundingDefaultAddressType.stringValue] }).first else {
throw PaykitPaymentRequestError.requestUnavailable
}
return HwFundingAccount(xpub: xpub, addressType: hwFundingDefaultAddressType, balanceSats: 0)
}

func watcherReceiveAddress(
walletId: String,
addressType: AddressScriptType = hwFundingDefaultAddressType
Expand Down Expand Up @@ -1541,11 +1549,16 @@ final class HwWalletManager {
}

/// Broadcast a signed funding transaction and return its txid. Does not require a connected device.
func broadcastFunding(serializedTx: String, paymentDeadline: PaykitPreciseInstant? = nil) async throws -> String {
func broadcastFunding(
serializedTx: String,
paymentDeadline: PaykitPreciseInstant? = nil,
beforeDispatch: @escaping @MainActor @Sendable () async throws -> Void = {}
) async throws -> String {
try await OnChainHwService.shared.broadcastRawTx(
serializedTx: serializedTx,
electrumUrl: electrumUrlProvider(),
paymentDeadline: paymentDeadline
paymentDeadline: paymentDeadline,
beforeDispatch: beforeDispatch
)
}

Expand Down
75 changes: 75 additions & 0 deletions Bitkit/Models/HwFunding.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import BitkitCore
import CryptoKit
import Foundation
import LDKNode

/// The default address type funds are sourced from when transferring from a hardware wallet to
Expand Down Expand Up @@ -47,3 +49,76 @@ struct HwFundingBroadcastResult: Equatable {
let feeRate: UInt64
let totalSpent: UInt64
}

/// Matches Android's txid calculation: hash consensus bytes without marker, flag or witness.
/// This identifies a signed candidate; it does not establish backend acceptance.
enum SignedTransactionId {
static func fromHex(_ hex: String) throws -> String {
func invalid() -> PaykitPaymentRequestError {
.requestUnavailable
}
guard (20 ... 2_000_000).contains(hex.count), hex.count.isMultiple(of: 2) else { throw invalid() }
let chars = Array(hex.utf8)
var bytes = [UInt8]()
for index in stride(from: 0, to: chars.count, by: 2) {
guard let byte = UInt8(String(decoding: chars[index ... index + 1], as: UTF8.self), radix: 16) else { throw invalid() }
bytes.append(byte)
}
var offset = 4
func skip(_ length: Int) throws {
guard length >= 0, length <= bytes.count - offset else { throw invalid() }
offset += length
}
func compactSize() throws -> Int {
guard offset < bytes.count else { throw invalid() }
let prefix = bytes[offset]
offset += 1
if prefix < 253 {
return Int(prefix)
}
let length = prefix == 253 ? 2 : prefix == 254 ? 4 : 8
guard length <= bytes.count - offset else { throw invalid() }
var value: UInt64 = 0
for index in 0 ..< length {
value |= UInt64(bytes[offset]) << (8 * index)
offset += 1
}
guard value <= UInt64(Int.max) else { throw invalid() }
return Int(value)
}
let hasWitness = bytes[offset] == 0
if hasWitness {
guard bytes[offset + 1] == 1 else { throw invalid() }
try skip(2)
}
let baseStart = offset
let inputCount = try compactSize()
guard inputCount > 0, inputCount <= bytes.count / 41 else { throw invalid() }
for _ in 0 ..< inputCount {
try skip(36)
try skip(compactSize())
try skip(4)
}
let outputCount = try compactSize()
guard outputCount > 0, outputCount <= bytes.count / 9 else { throw invalid() }
for _ in 0 ..< outputCount {
try skip(8)
try skip(compactSize())
}
let baseEnd = offset
if hasWitness {
for _ in 0 ..< inputCount {
let count = try compactSize()
guard count <= bytes.count - offset else { throw invalid() }
for _ in 0 ..< count {
try skip(compactSize())
}
}
}
let lockTimeStart = offset
try skip(4)
guard offset == bytes.count else { throw invalid() }
let canonical = Data(bytes[0 ..< 4] + bytes[baseStart ..< baseEnd] + bytes[lockTimeStart ..< offset])
return SHA256.hash(data: Data(SHA256.hash(data: canonical))).reversed().map { String(format: "%02x", $0) }.joined()
}
}
Loading
Loading