diff --git a/Cargo.lock b/Cargo.lock index e476319..b4723da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -475,7 +475,7 @@ dependencies = [ [[package]] name = "bitkitcore" -version = "0.3.3" +version = "0.3.4" dependencies = [ "android_logger", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 65c9d0a..1da0bca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bitkitcore" -version = "0.3.3" +version = "0.3.4" edition = "2021" [lib] diff --git a/Package.swift b/Package.swift index 5bb1fb4..3ea4ac6 100644 --- a/Package.swift +++ b/Package.swift @@ -3,8 +3,8 @@ import PackageDescription -let tag = "v0.3.3" -let checksum = "768aabf1cbf92c1dbc705151540ac687d607d8b625e912f25c08a1e17858ea47" +let tag = "v0.3.4" +let checksum = "a34aafd347e8fdb2cba31b504e441083467ba2974b437e5934ffa23eeb950923" let url = "https://github.com/synonymdev/bitkit-core/releases/download/\(tag)/BitkitCore.xcframework.zip" let package = Package( diff --git a/bindings/android/gradle.properties b/bindings/android/gradle.properties index dd31de4..0b84703 100644 --- a/bindings/android/gradle.properties +++ b/bindings/android/gradle.properties @@ -3,4 +3,4 @@ android.useAndroidX=true android.enableJetifier=true kotlin.code.style=official group=com.synonym -version=0.3.3 +version=0.3.4 diff --git a/bindings/android/lib/src/main/jniLibs/arm64-v8a/libbitkitcore.so b/bindings/android/lib/src/main/jniLibs/arm64-v8a/libbitkitcore.so index 0ecbd14..0efcd5f 100755 Binary files a/bindings/android/lib/src/main/jniLibs/arm64-v8a/libbitkitcore.so and b/bindings/android/lib/src/main/jniLibs/arm64-v8a/libbitkitcore.so differ diff --git a/bindings/android/lib/src/main/jniLibs/armeabi-v7a/libbitkitcore.so b/bindings/android/lib/src/main/jniLibs/armeabi-v7a/libbitkitcore.so index 57e90b7..8891d49 100755 Binary files a/bindings/android/lib/src/main/jniLibs/armeabi-v7a/libbitkitcore.so and b/bindings/android/lib/src/main/jniLibs/armeabi-v7a/libbitkitcore.so differ diff --git a/bindings/android/lib/src/main/jniLibs/x86/libbitkitcore.so b/bindings/android/lib/src/main/jniLibs/x86/libbitkitcore.so index 3a4efa3..9d9beb1 100755 Binary files a/bindings/android/lib/src/main/jniLibs/x86/libbitkitcore.so and b/bindings/android/lib/src/main/jniLibs/x86/libbitkitcore.so differ diff --git a/bindings/android/lib/src/main/jniLibs/x86_64/libbitkitcore.so b/bindings/android/lib/src/main/jniLibs/x86_64/libbitkitcore.so index 1599194..06ae8a3 100755 Binary files a/bindings/android/lib/src/main/jniLibs/x86_64/libbitkitcore.so and b/bindings/android/lib/src/main/jniLibs/x86_64/libbitkitcore.so differ diff --git a/bindings/android/lib/src/main/kotlin/com/synonym/bitkitcore/bitkitcore.android.kt b/bindings/android/lib/src/main/kotlin/com/synonym/bitkitcore/bitkitcore.android.kt index d54c2ed..6ae5266 100644 --- a/bindings/android/lib/src/main/kotlin/com/synonym/bitkitcore/bitkitcore.android.kt +++ b/bindings/android/lib/src/main/kotlin/com/synonym/bitkitcore/bitkitcore.android.kt @@ -5603,6 +5603,7 @@ public object FfiConverterTypeHistoryTransaction: FfiConverterRustBuffer WatcherEvent.TransactionsChanged( - FfiConverterSequenceTypeHistoryTransaction.read(buf), + FfiConverterSequenceTypeActivity.read(buf), + FfiConverterSequenceTypeTransactionDetails.read(buf), FfiConverterTypeWalletBalance.read(buf), FfiConverterUInt.read(buf), FfiConverterUInt.read(buf), @@ -10688,7 +10695,8 @@ public object FfiConverterTypeWatcherEvent : FfiConverterRustBuffer { buf.putInt(1) - FfiConverterSequenceTypeHistoryTransaction.write(value.`transactions`, buf) + FfiConverterSequenceTypeActivity.write(value.`activities`, buf) + FfiConverterSequenceTypeTransactionDetails.write(value.`transactionDetails`, buf) FfiConverterTypeWalletBalance.write(value.`balance`, buf) FfiConverterUInt.write(value.`txCount`, buf) FfiConverterUInt.write(value.`blockHeight`, buf) diff --git a/bindings/android/lib/src/main/kotlin/com/synonym/bitkitcore/bitkitcore.common.kt b/bindings/android/lib/src/main/kotlin/com/synonym/bitkitcore/bitkitcore.common.kt index bcc484c..cff9dc6 100644 --- a/bindings/android/lib/src/main/kotlin/com/synonym/bitkitcore/bitkitcore.common.kt +++ b/bindings/android/lib/src/main/kotlin/com/synonym/bitkitcore/bitkitcore.common.kt @@ -664,6 +664,10 @@ public data class HistoryTransaction ( * Transaction fee in sats (None if not available, e.g. for received-only txs) */ val `fee`: kotlin.ULong?, + /** + * Fee rate in sats per virtual byte (None if fee or tx size is unavailable). + */ + val `feeRate`: kotlin.Double?, /** * Display amount in sats: * - Received: the received value @@ -2567,6 +2571,13 @@ public data class WatcherParams ( * Caller-supplied identifier for this watcher. */ val `watcherId`: kotlin.String, + /** + * Wallet id that scopes the activities this watcher emits. One watcher + * watches one address type, so this stays at the address-type boundary — + * the same txid seen under two address types yields two wallet-scoped + * activities under different `wallet_id`s, not one merged activity. + */ + val `walletId`: kotlin.String, /** * Extended public key (xpub/ypub/zpub/tpub/upub/vpub). */ @@ -4184,9 +4195,16 @@ public sealed class WatcherEvent { /** * Transaction activity changed — contains full updated state. + * + * `activities` and `transaction_details` are persistence-ready: they carry + * the watcher's `wallet_id`, real decoded addresses, fees from the watched + * wallet's perspective, and DB-valid timestamps, so the app can store them + * directly through the normal Core activity APIs (e.g. `upsert_activity` / + * `upsert_transaction_details`). The two vecs are parallel by `tx_id`. */@kotlinx.serialization.Serializable public data class TransactionsChanged( - val `transactions`: List, + val `activities`: List, + val `transactionDetails`: List, val `balance`: WalletBalance, val `txCount`: kotlin.UInt, val `blockHeight`: kotlin.UInt, diff --git a/bindings/ios/BitkitCore.xcframework.zip b/bindings/ios/BitkitCore.xcframework.zip index 4a24c3d..5a42200 100644 Binary files a/bindings/ios/BitkitCore.xcframework.zip and b/bindings/ios/BitkitCore.xcframework.zip differ diff --git a/bindings/ios/BitkitCore.xcframework/Info.plist b/bindings/ios/BitkitCore.xcframework/Info.plist index b7357e0..478a88f 100644 --- a/bindings/ios/BitkitCore.xcframework/Info.plist +++ b/bindings/ios/BitkitCore.xcframework/Info.plist @@ -10,7 +10,7 @@ HeadersPath Headers LibraryIdentifier - ios-arm64-simulator + ios-arm64 LibraryPath libbitkitcore.a SupportedArchitectures @@ -19,8 +19,6 @@ SupportedPlatform ios - SupportedPlatformVariant - simulator BinaryPath @@ -28,7 +26,7 @@ HeadersPath Headers LibraryIdentifier - ios-arm64 + ios-arm64-simulator LibraryPath libbitkitcore.a SupportedArchitectures @@ -37,6 +35,8 @@ SupportedPlatform ios + SupportedPlatformVariant + simulator CFBundlePackageType diff --git a/bindings/ios/BitkitCore.xcframework/ios-arm64-simulator/libbitkitcore.a b/bindings/ios/BitkitCore.xcframework/ios-arm64-simulator/libbitkitcore.a index 948fdad..a8551a6 100644 Binary files a/bindings/ios/BitkitCore.xcframework/ios-arm64-simulator/libbitkitcore.a and b/bindings/ios/BitkitCore.xcframework/ios-arm64-simulator/libbitkitcore.a differ diff --git a/bindings/ios/BitkitCore.xcframework/ios-arm64/libbitkitcore.a b/bindings/ios/BitkitCore.xcframework/ios-arm64/libbitkitcore.a index 1a527cf..17bb259 100644 Binary files a/bindings/ios/BitkitCore.xcframework/ios-arm64/libbitkitcore.a and b/bindings/ios/BitkitCore.xcframework/ios-arm64/libbitkitcore.a differ diff --git a/bindings/ios/bitkitcore.swift b/bindings/ios/bitkitcore.swift index 4a74613..968c161 100644 --- a/bindings/ios/bitkitcore.swift +++ b/bindings/ios/bitkitcore.swift @@ -3652,6 +3652,10 @@ public struct HistoryTransaction { * Transaction fee in sats (None if not available, e.g. for received-only txs) */ public var fee: UInt64? + /** + * Fee rate in sats per virtual byte (None if fee or tx size is unavailable). + */ + public var feeRate: Double? /** * Display amount in sats: * - Received: the received value @@ -3694,6 +3698,9 @@ public struct HistoryTransaction { /** * Transaction fee in sats (None if not available, e.g. for received-only txs) */fee: UInt64?, + /** + * Fee rate in sats per virtual byte (None if fee or tx size is unavailable). + */feeRate: Double?, /** * Display amount in sats: * - Received: the received value @@ -3717,6 +3724,7 @@ public struct HistoryTransaction { self.sent = sent self.net = net self.fee = fee + self.feeRate = feeRate self.amount = amount self.direction = direction self.blockHeight = blockHeight @@ -3747,6 +3755,9 @@ extension HistoryTransaction: Equatable, Hashable { if lhs.fee != rhs.fee { return false } + if lhs.feeRate != rhs.feeRate { + return false + } if lhs.amount != rhs.amount { return false } @@ -3771,6 +3782,7 @@ extension HistoryTransaction: Equatable, Hashable { hasher.combine(sent) hasher.combine(net) hasher.combine(fee) + hasher.combine(feeRate) hasher.combine(amount) hasher.combine(direction) hasher.combine(blockHeight) @@ -3795,6 +3807,7 @@ public struct FfiConverterTypeHistoryTransaction: FfiConverterRustBuffer { sent: FfiConverterUInt64.read(from: &buf), net: FfiConverterInt64.read(from: &buf), fee: FfiConverterOptionUInt64.read(from: &buf), + feeRate: FfiConverterOptionDouble.read(from: &buf), amount: FfiConverterUInt64.read(from: &buf), direction: FfiConverterTypeTxDirection.read(from: &buf), blockHeight: FfiConverterOptionUInt32.read(from: &buf), @@ -3809,6 +3822,7 @@ public struct FfiConverterTypeHistoryTransaction: FfiConverterRustBuffer { FfiConverterUInt64.write(value.sent, into: &buf) FfiConverterInt64.write(value.net, into: &buf) FfiConverterOptionUInt64.write(value.fee, into: &buf) + FfiConverterOptionDouble.write(value.feeRate, into: &buf) FfiConverterUInt64.write(value.amount, into: &buf) FfiConverterTypeTxDirection.write(value.direction, into: &buf) FfiConverterOptionUInt32.write(value.blockHeight, into: &buf) @@ -13208,6 +13222,13 @@ public struct WatcherParams { * Caller-supplied identifier for this watcher. */ public var watcherId: String + /** + * Wallet id that scopes the activities this watcher emits. One watcher + * watches one address type, so this stays at the address-type boundary — + * the same txid seen under two address types yields two wallet-scoped + * activities under different `wallet_id`s, not one merged activity. + */ + public var walletId: String /** * Extended public key (xpub/ypub/zpub/tpub/upub/vpub). */ @@ -13236,6 +13257,12 @@ public struct WatcherParams { /** * Caller-supplied identifier for this watcher. */watcherId: String, + /** + * Wallet id that scopes the activities this watcher emits. One watcher + * watches one address type, so this stays at the address-type boundary — + * the same txid seen under two address types yields two wallet-scoped + * activities under different `wallet_id`s, not one merged activity. + */walletId: String, /** * Extended public key (xpub/ypub/zpub/tpub/upub/vpub). */extendedKey: String, @@ -13253,6 +13280,7 @@ public struct WatcherParams { * (defaults to `DEFAULT_GAP_LIMIT` when None). */gapLimit: UInt32?) { self.watcherId = watcherId + self.walletId = walletId self.extendedKey = extendedKey self.electrumUrl = electrumUrl self.network = network @@ -13271,6 +13299,9 @@ extension WatcherParams: Equatable, Hashable { if lhs.watcherId != rhs.watcherId { return false } + if lhs.walletId != rhs.walletId { + return false + } if lhs.extendedKey != rhs.extendedKey { return false } @@ -13291,6 +13322,7 @@ extension WatcherParams: Equatable, Hashable { public func hash(into hasher: inout Hasher) { hasher.combine(watcherId) + hasher.combine(walletId) hasher.combine(extendedKey) hasher.combine(electrumUrl) hasher.combine(network) @@ -13311,6 +13343,7 @@ public struct FfiConverterTypeWatcherParams: FfiConverterRustBuffer { return try WatcherParams( watcherId: FfiConverterString.read(from: &buf), + walletId: FfiConverterString.read(from: &buf), extendedKey: FfiConverterString.read(from: &buf), electrumUrl: FfiConverterString.read(from: &buf), network: FfiConverterOptionTypeNetwork.read(from: &buf), @@ -13321,6 +13354,7 @@ public struct FfiConverterTypeWatcherParams: FfiConverterRustBuffer { public static func write(_ value: WatcherParams, into buf: inout [UInt8]) { FfiConverterString.write(value.watcherId, into: &buf) + FfiConverterString.write(value.walletId, into: &buf) FfiConverterString.write(value.extendedKey, into: &buf) FfiConverterString.write(value.electrumUrl, into: &buf) FfiConverterOptionTypeNetwork.write(value.network, into: &buf) @@ -17929,8 +17963,14 @@ public enum WatcherEvent { /** * Transaction activity changed — contains full updated state. + * + * `activities` and `transaction_details` are persistence-ready: they carry + * the watcher's `wallet_id`, real decoded addresses, fees from the watched + * wallet's perspective, and DB-valid timestamps, so the app can store them + * directly through the normal Core activity APIs (e.g. `upsert_activity` / + * `upsert_transaction_details`). The two vecs are parallel by `tx_id`. */ - case transactionsChanged(transactions: [HistoryTransaction], balance: WalletBalance, txCount: UInt32, blockHeight: UInt32, accountType: AccountType + case transactionsChanged(activities: [Activity], transactionDetails: [TransactionDetails], balance: WalletBalance, txCount: UInt32, blockHeight: UInt32, accountType: AccountType ) /** * An error occurred in the watcher loop. @@ -17963,7 +18003,7 @@ public struct FfiConverterTypeWatcherEvent: FfiConverterRustBuffer { let variant: Int32 = try readInt(&buf) switch variant { - case 1: return .transactionsChanged(transactions: try FfiConverterSequenceTypeHistoryTransaction.read(from: &buf), balance: try FfiConverterTypeWalletBalance.read(from: &buf), txCount: try FfiConverterUInt32.read(from: &buf), blockHeight: try FfiConverterUInt32.read(from: &buf), accountType: try FfiConverterTypeAccountType.read(from: &buf) + case 1: return .transactionsChanged(activities: try FfiConverterSequenceTypeActivity.read(from: &buf), transactionDetails: try FfiConverterSequenceTypeTransactionDetails.read(from: &buf), balance: try FfiConverterTypeWalletBalance.read(from: &buf), txCount: try FfiConverterUInt32.read(from: &buf), blockHeight: try FfiConverterUInt32.read(from: &buf), accountType: try FfiConverterTypeAccountType.read(from: &buf) ) case 2: return .error(message: try FfiConverterString.read(from: &buf) @@ -17982,9 +18022,10 @@ public struct FfiConverterTypeWatcherEvent: FfiConverterRustBuffer { switch value { - case let .transactionsChanged(transactions,balance,txCount,blockHeight,accountType): + case let .transactionsChanged(activities,transactionDetails,balance,txCount,blockHeight,accountType): writeInt(&buf, Int32(1)) - FfiConverterSequenceTypeHistoryTransaction.write(transactions, into: &buf) + FfiConverterSequenceTypeActivity.write(activities, into: &buf) + FfiConverterSequenceTypeTransactionDetails.write(transactionDetails, into: &buf) FfiConverterTypeWalletBalance.write(balance, into: &buf) FfiConverterUInt32.write(txCount, into: &buf) FfiConverterUInt32.write(blockHeight, into: &buf) diff --git a/bindings/python/bitkitcore/bitkitcore.py b/bindings/python/bitkitcore/bitkitcore.py index 0cd0f32..e4e55d8 100644 --- a/bindings/python/bitkitcore/bitkitcore.py +++ b/bindings/python/bitkitcore/bitkitcore.py @@ -3961,6 +3961,11 @@ class HistoryTransaction: Transaction fee in sats (None if not available, e.g. for received-only txs) """ + fee_rate: "typing.Optional[float]" + """ + Fee rate in sats per virtual byte (None if fee or tx size is unavailable). + """ + amount: "int" """ Display amount in sats: @@ -3989,12 +3994,13 @@ class HistoryTransaction: Number of confirmations (0 if unconfirmed) """ - def __init__(self, *, txid: "str", received: "int", sent: "int", net: "int", fee: "typing.Optional[int]", amount: "int", direction: "TxDirection", block_height: "typing.Optional[int]", timestamp: "typing.Optional[int]", confirmations: "int"): + def __init__(self, *, txid: "str", received: "int", sent: "int", net: "int", fee: "typing.Optional[int]", fee_rate: "typing.Optional[float]", amount: "int", direction: "TxDirection", block_height: "typing.Optional[int]", timestamp: "typing.Optional[int]", confirmations: "int"): self.txid = txid self.received = received self.sent = sent self.net = net self.fee = fee + self.fee_rate = fee_rate self.amount = amount self.direction = direction self.block_height = block_height @@ -4002,7 +4008,7 @@ def __init__(self, *, txid: "str", received: "int", sent: "int", net: "int", fee self.confirmations = confirmations def __str__(self): - return "HistoryTransaction(txid={}, received={}, sent={}, net={}, fee={}, amount={}, direction={}, block_height={}, timestamp={}, confirmations={})".format(self.txid, self.received, self.sent, self.net, self.fee, self.amount, self.direction, self.block_height, self.timestamp, self.confirmations) + return "HistoryTransaction(txid={}, received={}, sent={}, net={}, fee={}, fee_rate={}, amount={}, direction={}, block_height={}, timestamp={}, confirmations={})".format(self.txid, self.received, self.sent, self.net, self.fee, self.fee_rate, self.amount, self.direction, self.block_height, self.timestamp, self.confirmations) def __eq__(self, other): if self.txid != other.txid: @@ -4015,6 +4021,8 @@ def __eq__(self, other): return False if self.fee != other.fee: return False + if self.fee_rate != other.fee_rate: + return False if self.amount != other.amount: return False if self.direction != other.direction: @@ -4036,6 +4044,7 @@ def read(buf): sent=_UniffiConverterUInt64.read(buf), net=_UniffiConverterInt64.read(buf), fee=_UniffiConverterOptionalUInt64.read(buf), + fee_rate=_UniffiConverterOptionalDouble.read(buf), amount=_UniffiConverterUInt64.read(buf), direction=_UniffiConverterTypeTxDirection.read(buf), block_height=_UniffiConverterOptionalUInt32.read(buf), @@ -4050,6 +4059,7 @@ def check_lower(value): _UniffiConverterUInt64.check_lower(value.sent) _UniffiConverterInt64.check_lower(value.net) _UniffiConverterOptionalUInt64.check_lower(value.fee) + _UniffiConverterOptionalDouble.check_lower(value.fee_rate) _UniffiConverterUInt64.check_lower(value.amount) _UniffiConverterTypeTxDirection.check_lower(value.direction) _UniffiConverterOptionalUInt32.check_lower(value.block_height) @@ -4063,6 +4073,7 @@ def write(value, buf): _UniffiConverterUInt64.write(value.sent, buf) _UniffiConverterInt64.write(value.net, buf) _UniffiConverterOptionalUInt64.write(value.fee, buf) + _UniffiConverterOptionalDouble.write(value.fee_rate, buf) _UniffiConverterUInt64.write(value.amount, buf) _UniffiConverterTypeTxDirection.write(value.direction, buf) _UniffiConverterOptionalUInt32.write(value.block_height, buf) @@ -10049,6 +10060,14 @@ class WatcherParams: Caller-supplied identifier for this watcher. """ + wallet_id: "str" + """ + Wallet id that scopes the activities this watcher emits. One watcher + watches one address type, so this stays at the address-type boundary — + the same txid seen under two address types yields two wallet-scoped + activities under different `wallet_id`s, not one merged activity. + """ + extended_key: "str" """ Extended public key (xpub/ypub/zpub/tpub/upub/vpub). @@ -10075,8 +10094,9 @@ class WatcherParams: (defaults to `DEFAULT_GAP_LIMIT` when None). """ - def __init__(self, *, watcher_id: "str", extended_key: "str", electrum_url: "str", network: "typing.Optional[Network]", account_type: "typing.Optional[AccountType]", gap_limit: "typing.Optional[int]"): + def __init__(self, *, watcher_id: "str", wallet_id: "str", extended_key: "str", electrum_url: "str", network: "typing.Optional[Network]", account_type: "typing.Optional[AccountType]", gap_limit: "typing.Optional[int]"): self.watcher_id = watcher_id + self.wallet_id = wallet_id self.extended_key = extended_key self.electrum_url = electrum_url self.network = network @@ -10084,11 +10104,13 @@ def __init__(self, *, watcher_id: "str", extended_key: "str", electrum_url: "str self.gap_limit = gap_limit def __str__(self): - return "WatcherParams(watcher_id={}, extended_key={}, electrum_url={}, network={}, account_type={}, gap_limit={})".format(self.watcher_id, self.extended_key, self.electrum_url, self.network, self.account_type, self.gap_limit) + return "WatcherParams(watcher_id={}, wallet_id={}, extended_key={}, electrum_url={}, network={}, account_type={}, gap_limit={})".format(self.watcher_id, self.wallet_id, self.extended_key, self.electrum_url, self.network, self.account_type, self.gap_limit) def __eq__(self, other): if self.watcher_id != other.watcher_id: return False + if self.wallet_id != other.wallet_id: + return False if self.extended_key != other.extended_key: return False if self.electrum_url != other.electrum_url: @@ -10106,6 +10128,7 @@ class _UniffiConverterTypeWatcherParams(_UniffiConverterRustBuffer): def read(buf): return WatcherParams( watcher_id=_UniffiConverterString.read(buf), + wallet_id=_UniffiConverterString.read(buf), extended_key=_UniffiConverterString.read(buf), electrum_url=_UniffiConverterString.read(buf), network=_UniffiConverterOptionalTypeNetwork.read(buf), @@ -10116,6 +10139,7 @@ def read(buf): @staticmethod def check_lower(value): _UniffiConverterString.check_lower(value.watcher_id) + _UniffiConverterString.check_lower(value.wallet_id) _UniffiConverterString.check_lower(value.extended_key) _UniffiConverterString.check_lower(value.electrum_url) _UniffiConverterOptionalTypeNetwork.check_lower(value.network) @@ -10125,6 +10149,7 @@ def check_lower(value): @staticmethod def write(value, buf): _UniffiConverterString.write(value.watcher_id, buf) + _UniffiConverterString.write(value.wallet_id, buf) _UniffiConverterString.write(value.extended_key, buf) _UniffiConverterString.write(value.electrum_url, buf) _UniffiConverterOptionalTypeNetwork.write(value.network, buf) @@ -14851,28 +14876,38 @@ def __init__(self): class TRANSACTIONS_CHANGED: """ Transaction activity changed — contains full updated state. + + `activities` and `transaction_details` are persistence-ready: they carry + the watcher's `wallet_id`, real decoded addresses, fees from the watched + wallet's perspective, and DB-valid timestamps, so the app can store them + directly through the normal Core activity APIs (e.g. `upsert_activity` / + `upsert_transaction_details`). The two vecs are parallel by `tx_id`. """ - transactions: "typing.List[HistoryTransaction]" + activities: "typing.List[Activity]" + transaction_details: "typing.List[TransactionDetails]" balance: "WalletBalance" tx_count: "int" block_height: "int" account_type: "AccountType" - def __init__(self,transactions: "typing.List[HistoryTransaction]", balance: "WalletBalance", tx_count: "int", block_height: "int", account_type: "AccountType"): - self.transactions = transactions + def __init__(self,activities: "typing.List[Activity]", transaction_details: "typing.List[TransactionDetails]", balance: "WalletBalance", tx_count: "int", block_height: "int", account_type: "AccountType"): + self.activities = activities + self.transaction_details = transaction_details self.balance = balance self.tx_count = tx_count self.block_height = block_height self.account_type = account_type def __str__(self): - return "WatcherEvent.TRANSACTIONS_CHANGED(transactions={}, balance={}, tx_count={}, block_height={}, account_type={})".format(self.transactions, self.balance, self.tx_count, self.block_height, self.account_type) + return "WatcherEvent.TRANSACTIONS_CHANGED(activities={}, transaction_details={}, balance={}, tx_count={}, block_height={}, account_type={})".format(self.activities, self.transaction_details, self.balance, self.tx_count, self.block_height, self.account_type) def __eq__(self, other): if not other.is_TRANSACTIONS_CHANGED(): return False - if self.transactions != other.transactions: + if self.activities != other.activities: + return False + if self.transaction_details != other.transaction_details: return False if self.balance != other.balance: return False @@ -14980,7 +15015,8 @@ def read(buf): variant = buf.read_i32() if variant == 1: return WatcherEvent.TRANSACTIONS_CHANGED( - _UniffiConverterSequenceTypeHistoryTransaction.read(buf), + _UniffiConverterSequenceTypeActivity.read(buf), + _UniffiConverterSequenceTypeTransactionDetails.read(buf), _UniffiConverterTypeWalletBalance.read(buf), _UniffiConverterUInt32.read(buf), _UniffiConverterUInt32.read(buf), @@ -15002,7 +15038,8 @@ def read(buf): @staticmethod def check_lower(value): if value.is_TRANSACTIONS_CHANGED(): - _UniffiConverterSequenceTypeHistoryTransaction.check_lower(value.transactions) + _UniffiConverterSequenceTypeActivity.check_lower(value.activities) + _UniffiConverterSequenceTypeTransactionDetails.check_lower(value.transaction_details) _UniffiConverterTypeWalletBalance.check_lower(value.balance) _UniffiConverterUInt32.check_lower(value.tx_count) _UniffiConverterUInt32.check_lower(value.block_height) @@ -15022,7 +15059,8 @@ def check_lower(value): def write(value, buf): if value.is_TRANSACTIONS_CHANGED(): buf.write_i32(1) - _UniffiConverterSequenceTypeHistoryTransaction.write(value.transactions, buf) + _UniffiConverterSequenceTypeActivity.write(value.activities, buf) + _UniffiConverterSequenceTypeTransactionDetails.write(value.transaction_details, buf) _UniffiConverterTypeWalletBalance.write(value.balance, buf) _UniffiConverterUInt32.write(value.tx_count, buf) _UniffiConverterUInt32.write(value.block_height, buf) diff --git a/bindings/python/bitkitcore/libbitkitcore.dylib b/bindings/python/bitkitcore/libbitkitcore.dylib index 8e7d25a..60a2830 100755 Binary files a/bindings/python/bitkitcore/libbitkitcore.dylib and b/bindings/python/bitkitcore/libbitkitcore.dylib differ diff --git a/bindings/python/setup.py b/bindings/python/setup.py index 99362a4..3fb29ce 100644 --- a/bindings/python/setup.py +++ b/bindings/python/setup.py @@ -2,7 +2,7 @@ setup( name="bitkitcore", - version="0.3.3", + version="0.3.4", packages=find_packages(), package_data={ "bitkitcore": ["*.so", "*.dylib", "*.dll"], diff --git a/src/modules/activity/implementation.rs b/src/modules/activity/implementation.rs index 59ffb27..f85cdb6 100644 --- a/src/modules/activity/implementation.rs +++ b/src/modules/activity/implementation.rs @@ -699,16 +699,11 @@ impl ActivityDB { pub fn upsert_activity(&mut self, activity: &Activity) -> Result<(), ActivityError> { match activity { + // Route through the batch upsert so a single upsert gets the same + // insert-or-merge semantics (contact / unconfirmed-timestamp + // preservation) as a watcher refresh. Activity::Onchain(onchain) => { - match self.update_onchain_activity_by_id(&onchain.id, onchain) { - Ok(_) => Ok(()), - Err(ActivityError::DataError { error_details }) - if error_details == "No activity found with given ID" => - { - self.insert_onchain_activity(onchain) - } - Err(e) => Err(e), - } + self.upsert_onchain_activities(std::slice::from_ref(onchain)) } Activity::Lightning(lightning) => { match self.update_lightning_activity_by_id(&lightning.id, lightning) { @@ -912,6 +907,13 @@ impl ActivityDB { { let mut stmt_act = tx .prepare( + // Preserve user / first-seen state across watcher refreshes: + // - keep the existing contact when the incoming one is NULL + // (COALESCE), so a refresh can't wipe a user-set contact; + // - keep the existing timestamp while the tx is still + // unconfirmed (?6 = confirmed), so a pending activity's + // first-seen time doesn't churn; snap to the new (block) + // timestamp once it confirms. "INSERT INTO activities ( id, wallet_id, activity_type, tx_type, timestamp, contact ) VALUES ( @@ -920,8 +922,8 @@ impl ActivityDB { ON CONFLICT(wallet_id, id) DO UPDATE SET activity_type = excluded.activity_type, tx_type = excluded.tx_type, - timestamp = excluded.timestamp, - contact = excluded.contact", + timestamp = CASE WHEN ?6 THEN excluded.timestamp ELSE timestamp END, + contact = COALESCE(excluded.contact, contact)", ) .map_err(|e| ActivityError::DataError { error_details: format!("Failed to prepare activities statement: {}", e), @@ -969,6 +971,7 @@ impl ActivityDB { Self::payment_type_to_string(&activity.tx_type), activity.timestamp, &activity.contact, + activity.confirmed, )) .map_err(|e| ActivityError::InsertError { error_details: format!("Failed to upsert activities: {}", e), @@ -1609,6 +1612,10 @@ impl ActivityDB { error_details: format!("Failed to start transaction: {}", e), })?; + // A literal replacement: callers using update_activity expect every field + // (including timestamp and contact) to be overwritten. Preservation of + // user/first-seen state across watcher refreshes lives in the upsert path + // (see upsert_onchain_activities), not here. let activities_sql = " UPDATE activities SET tx_type = ?1, diff --git a/src/modules/activity/tests.rs b/src/modules/activity/tests.rs index 3424122..b5eb80f 100644 --- a/src/modules/activity/tests.rs +++ b/src/modules/activity/tests.rs @@ -6417,6 +6417,131 @@ mod tests { cleanup(&db_path); } + #[test] + fn test_upsert_preserves_contact_and_unconfirmed_timestamp() { + let (mut db, db_path) = setup(); + + // Seed: unconfirmed activity with a user-set contact and first-seen timestamp. + let mut seed = create_test_onchain_activity(); + seed.confirmed = false; + seed.timestamp = 1_000; + seed.contact = Some("npub_alice".to_string()); + db.insert_onchain_activity(&seed).unwrap(); + + // Watcher refresh: still unconfirmed, no contact known, fresh "now" timestamp. + let mut refresh = seed.clone(); + refresh.contact = None; + refresh.timestamp = 9_999; + db.upsert_activity(&Activity::Onchain(refresh)).unwrap(); + + let got = db + .get_activity_by_id(DEFAULT_WALLET_ID, &seed.id) + .unwrap() + .unwrap(); + let Activity::Onchain(o) = got else { + panic!("expected onchain") + }; + assert_eq!( + o.contact.as_deref(), + Some("npub_alice"), + "contact must survive a None refresh" + ); + assert_eq!( + o.timestamp, 1_000, + "unconfirmed timestamp must not churn on refresh" + ); + + // Once confirmed, the block timestamp is applied. + let mut confirmed = seed.clone(); + confirmed.contact = None; + confirmed.confirmed = true; + confirmed.timestamp = 2_000; + db.upsert_activity(&Activity::Onchain(confirmed)).unwrap(); + + let got = db + .get_activity_by_id(DEFAULT_WALLET_ID, &seed.id) + .unwrap() + .unwrap(); + let Activity::Onchain(o) = got else { + panic!("expected onchain") + }; + assert_eq!( + o.timestamp, 2_000, + "confirmed tx adopts the block timestamp" + ); + assert_eq!( + o.contact.as_deref(), + Some("npub_alice"), + "contact still preserved" + ); + + cleanup(&db_path); + } + + #[test] + fn test_update_activity_is_pure_replacement() { + let (mut db, db_path) = setup(); + + let mut seed = create_test_onchain_activity(); + seed.confirmed = false; + seed.timestamp = 1_000; + seed.contact = Some("npub_alice".to_string()); + db.insert_onchain_activity(&seed).unwrap(); + + // update_* is a literal replacement: it clears the contact (None) and + // moves the timestamp even while the tx is unconfirmed. + let mut replaced = seed.clone(); + replaced.contact = None; + replaced.timestamp = 5_000; + replaced.confirmed = false; + db.update_onchain_activity_by_id(&seed.id, &replaced) + .unwrap(); + + let got = db + .get_activity_by_id(DEFAULT_WALLET_ID, &seed.id) + .unwrap() + .unwrap(); + let Activity::Onchain(o) = got else { + panic!("expected onchain") + }; + assert_eq!(o.contact, None, "update clears contact"); + assert_eq!( + o.timestamp, 5_000, + "update moves timestamp even when unconfirmed" + ); + + cleanup(&db_path); + } + + #[test] + fn test_batch_upsert_preserves_contact_and_unconfirmed_timestamp() { + let (mut db, db_path) = setup(); + + let mut seed = create_test_onchain_activity(); + seed.confirmed = false; + seed.timestamp = 1_000; + seed.contact = Some("npub_bob".to_string()); + db.insert_onchain_activity(&seed).unwrap(); + + // Batch path (the watcher refresh entry point): same tx, no contact, new now. + let mut refresh = seed.clone(); + refresh.contact = None; + refresh.timestamp = 9_999; + db.upsert_onchain_activities(&[refresh]).unwrap(); + + let got = db + .get_activity_by_id(DEFAULT_WALLET_ID, &seed.id) + .unwrap() + .unwrap(); + let Activity::Onchain(o) = got else { + panic!("expected onchain") + }; + assert_eq!(o.contact.as_deref(), Some("npub_bob")); + assert_eq!(o.timestamp, 1_000); + + cleanup(&db_path); + } + #[test] fn test_derive_wallet_id_is_order_independent() { use crate::activity::derive_wallet_id; diff --git a/src/modules/activity/types.rs b/src/modules/activity/types.rs index 8bca9f4..3812c32 100644 --- a/src/modules/activity/types.rs +++ b/src/modules/activity/types.rs @@ -56,7 +56,7 @@ pub fn derive_wallet_id(device_type: String, xpubs: Vec) -> Result (None, None, 0), }; + // Fee rate (sat/vB) requires both the fee and the raw transaction (for vsize). + let fee_rate = match (tx.fee, tx.transaction.as_ref()) { + (Some(fee), Some(raw_tx)) => { + let vsize = raw_tx.vsize(); + if vsize > 0 { + Some(fee as f64 / vsize as f64) + } else { + None + } + } + _ => None, + }; + HistoryTransaction { txid: tx.txid.to_string(), received: tx.received, sent: tx.sent, net, fee: tx.fee, + fee_rate, amount, direction, block_height, @@ -1433,6 +1450,217 @@ pub(crate) fn sort_history_transactions(history: &mut [HistoryTransaction]) { }); } +/// Decode a single BDK transaction into a rich [`TransactionDetail`] (addresses, +/// per-output ownership, fee rate, vsize). Requires `tx.transaction` to be present, +/// i.e. the tx must come from `list_transactions(true)`. +pub(crate) fn map_bdk_tx_to_detail( + wallet: &Wallet, + tx: &bdk::TransactionDetails, + tip_height: u32, + wallet_network: BdkNetwork, +) -> Result { + let (direction, amount, net) = classify_tx(tx.sent, tx.received, tx.fee); + + let (block_height, timestamp, confirmations) = match tx.confirmation_time.as_ref() { + Some(conf) => { + let confs = tip_height.saturating_sub(conf.height) + 1; + (Some(conf.height), Some(conf.timestamp), confs) + } + None => (None, None, 0), + }; + + let raw_tx = tx + .transaction + .as_ref() + .ok_or_else(|| AccountInfoError::WalletError { + error_details: format!("Raw transaction data not available for {}", tx.txid), + })?; + + let inputs: Vec = raw_tx + .input + .iter() + .map(|inp| TxDetailInput { + txid: inp.previous_output.txid.to_string(), + vout: inp.previous_output.vout, + sequence: inp.sequence.0, + script_sig: hex::encode(inp.script_sig.as_bytes()), + witness: inp.witness.iter().map(|w| hex::encode(w)).collect(), + }) + .collect(); + + let outputs: Vec = raw_tx + .output + .iter() + .map(|out| { + let address = BdkAddress::from_script(&out.script_pubkey, wallet_network) + .ok() + .map(|a| a.to_string()); + let is_mine = + wallet + .is_mine(&out.script_pubkey) + .map_err(|e| AccountInfoError::WalletError { + error_details: format!("Failed to check script ownership: {}", e), + })?; + Ok(TxDetailOutput { + value: out.value, + script_pubkey: hex::encode(out.script_pubkey.as_bytes()), + address, + is_mine, + }) + }) + .collect::, AccountInfoError>>()?; + + let size = u32::try_from(raw_tx.size()).unwrap_or(u32::MAX); + let vsize = u32::try_from(raw_tx.vsize()).unwrap_or(u32::MAX); + let weight = u32::try_from(raw_tx.weight().to_wu()).unwrap_or(u32::MAX); + let fee_rate = match tx.fee { + Some(f) if vsize > 0 => Some(f as f64 / vsize as f64), + _ => None, + }; + + Ok(TransactionDetail { + txid: tx.txid.to_string(), + received: tx.received, + sent: tx.sent, + net, + amount, + fee: tx.fee, + direction, + block_height, + timestamp, + confirmations, + inputs, + outputs, + size, + vsize, + weight, + fee_rate, + }) +} + +/// Convert a decoded [`TransactionDetail`] into persistence-ready Core activity +/// data scoped to `wallet_id`, from the *watched wallet's* perspective. +/// +/// Semantics chosen so these render identically to any other onchain activity: +/// - `Received`: `value` = amount received, `fee`/`fee_rate` = 0 (the fee was paid +/// by the sender, not this wallet — even when BDK attaches one to the row). +/// - `Sent`: `value` = amount that left the wallet (sent − received − fee), +/// `fee`/`fee_rate` = the paid fee. Bitkit shows `value + fee` as the total. +/// - `SelfTransfer`: `value` = 0, `fee`/`fee_rate` = the paid fee (total = fee). +/// - `address`: the owned output for receives, the destination (non-owned) output +/// for sends, falling back to any decodable output address. +/// - `timestamp`: block time when confirmed, else `now` (always > 0 so the row is +/// DB-valid and sorts to the top while pending). +pub(crate) fn watch_only_activity_from_detail( + wallet_id: &str, + detail: &TransactionDetail, + now: u64, +) -> (Activity, TransactionDetails) { + // Did the watched wallet actually spend inputs? If not, the fee isn't ours. + let spent = detail.sent > 0; + let fee = if spent { detail.fee.unwrap_or(0) } else { 0 }; + let fee_rate = if spent { + detail.fee_rate.map(|r| r.round() as u64).unwrap_or(0) + } else { + 0 + }; + + let (tx_type, value) = match detail.direction { + TxDirection::Received => (PaymentType::Received, detail.received), + TxDirection::Sent => ( + PaymentType::Sent, + detail + .sent + .saturating_sub(detail.received) + .saturating_sub(fee), + ), + // Pure self-transfer: nothing leaves the wallet but the fee. + TxDirection::SelfTransfer => (PaymentType::Sent, 0), + }; + + // Pick the most meaningful address for this direction. + let pick_address = || -> String { + let owned = detail + .outputs + .iter() + .find(|o| o.is_mine) + .and_then(|o| o.address.clone()); + let external = detail + .outputs + .iter() + .find(|o| !o.is_mine) + .and_then(|o| o.address.clone()); + let any = detail.outputs.iter().find_map(|o| o.address.clone()); + let chosen = match detail.direction { + TxDirection::Received => owned.or(any), + TxDirection::Sent => external.or(any), + TxDirection::SelfTransfer => owned.or(any), + }; + chosen.unwrap_or_default() + }; + + let confirmed = detail.confirmations > 0; + let timestamp = detail.timestamp.unwrap_or(now); + + let activity = Activity::Onchain(OnchainActivity { + wallet_id: wallet_id.to_string(), + id: detail.txid.clone(), + tx_type, + tx_id: detail.txid.clone(), + value, + fee, + fee_rate, + address: pick_address(), + confirmed, + timestamp, + is_boosted: false, + boost_tx_ids: Vec::new(), + is_transfer: false, + does_exist: true, + confirm_timestamp: if confirmed { detail.timestamp } else { None }, + channel_id: None, + transfer_tx_id: None, + contact: None, + created_at: None, + updated_at: None, + seen_at: None, + }); + + let details = TransactionDetails { + wallet_id: wallet_id.to_string(), + tx_id: detail.txid.clone(), + // `amount_sats` is documented as fee-excluded. BDK's `sent` includes the + // fee, so `net` (received - sent) is fee-inclusive for spends; add our fee + // back to exclude it. For receive-only rows `fee` is 0 (sender paid it). + amount_sats: detail.net + fee as i64, + inputs: detail + .inputs + .iter() + .map(|i| TxInput { + txid: i.txid.clone(), + vout: i.vout, + scriptsig: i.script_sig.clone(), + witness: i.witness.clone(), + sequence: i.sequence, + }) + .collect(), + outputs: detail + .outputs + .iter() + .enumerate() + .map(|(n, o)| TxOutput { + scriptpubkey: o.script_pubkey.clone(), + scriptpubkey_type: None, + scriptpubkey_address: o.address.clone(), + value: o.value as i64, + n: n as u32, + }) + .collect(), + }; + + (activity, details) +} + // ============================================================================ // Account info: main async functions // ============================================================================ @@ -1671,9 +1899,10 @@ pub async fn get_transaction_history( })?; let balance: WalletBalance = bdk_balance.into(); - // Transaction history + // Transaction history. `true` includes the raw transaction so + // map_bdk_tx_to_history can derive fee_rate from the tx vsize. let txs = wallet - .list_transactions(false) + .list_transactions(true) .map_err(|e| AccountInfoError::WalletError { error_details: format!("Failed to list transactions: {}", e), })?; @@ -1740,84 +1969,7 @@ pub async fn get_transaction_detail( } })?; - // Summary fields - let (direction, amount, net) = classify_tx(tx.sent, tx.received, tx.fee); - - let (block_height, timestamp, confirmations) = match tx.confirmation_time.as_ref() { - Some(conf) => { - let confs = tip_height.saturating_sub(conf.height) + 1; - (Some(conf.height), Some(conf.timestamp), confs) - } - None => (None, None, 0), - }; - - // Raw transaction details - let raw_tx = tx - .transaction - .as_ref() - .ok_or_else(|| AccountInfoError::WalletError { - error_details: format!("Raw transaction data not available for {}", target_txid), - })?; - - let inputs: Vec = raw_tx - .input - .iter() - .map(|inp| TxDetailInput { - txid: inp.previous_output.txid.to_string(), - vout: inp.previous_output.vout, - sequence: inp.sequence.0, - script_sig: hex::encode(inp.script_sig.as_bytes()), - witness: inp.witness.iter().map(|w| hex::encode(w)).collect(), - }) - .collect(); - - let outputs: Vec = raw_tx - .output - .iter() - .map(|out| { - let address = BdkAddress::from_script(&out.script_pubkey, wallet_network) - .ok() - .map(|a| a.to_string()); - let is_mine = wallet.is_mine(&out.script_pubkey).map_err(|e| { - AccountInfoError::WalletError { - error_details: format!("Failed to check script ownership: {}", e), - } - })?; - Ok(TxDetailOutput { - value: out.value, - script_pubkey: hex::encode(out.script_pubkey.as_bytes()), - address, - is_mine, - }) - }) - .collect::, AccountInfoError>>()?; - - let size = u32::try_from(raw_tx.size()).unwrap_or(u32::MAX); - let vsize = u32::try_from(raw_tx.vsize()).unwrap_or(u32::MAX); - let weight = u32::try_from(raw_tx.weight().to_wu()).unwrap_or(u32::MAX); - let fee_rate = match tx.fee { - Some(f) if vsize > 0 => Some(f as f64 / vsize as f64), - _ => None, - }; - - Ok(TransactionDetail { - txid: tx.txid.to_string(), - received: tx.received, - sent: tx.sent, - net, - amount, - fee: tx.fee, - direction, - block_height, - timestamp, - confirmations, - inputs, - outputs, - size, - vsize, - weight, - fee_rate, - }) + map_bdk_tx_to_detail(&wallet, tx, tip_height, wallet_network) }) .await?; diff --git a/src/modules/onchain/listener.rs b/src/modules/onchain/listener.rs index 89fa008..0862f9b 100644 --- a/src/modules/onchain/listener.rs +++ b/src/modules/onchain/listener.rs @@ -14,12 +14,13 @@ use tokio::sync::{oneshot, watch}; use super::errors::AccountInfoError; use super::implementation::{ - create_wallet, map_bdk_tx_to_history, resolve_wallet_setup, sort_history_transactions, - sync_wallet, + create_wallet, map_bdk_tx_to_detail, resolve_wallet_setup, sync_wallet, + watch_only_activity_from_detail, }; use super::types::{ AccountType, Network as OnchainNetwork, WalletBalance, WatcherEvent, DEFAULT_GAP_LIMIT, }; +use bdk::bitcoin::Network as BdkNetwork; // ============================================================================ // Callback trait @@ -47,6 +48,11 @@ pub trait EventListener: Send + Sync { pub struct WatcherParams { /// Caller-supplied identifier for this watcher. pub watcher_id: String, + /// Wallet id that scopes the activities this watcher emits. One watcher + /// watches one address type, so this stays at the address-type boundary — + /// the same txid seen under two address types yields two wallet-scoped + /// activities under different `wallet_id`s, not one merged activity. + pub wallet_id: String, /// Extended public key (xpub/ypub/zpub/tpub/upub/vpub). pub extended_key: String, /// Electrum server URL (e.g. "ssl://electrum.example.com:50002"). @@ -319,11 +325,26 @@ fn derive_scripts( Ok(scripts) } +/// Current unix time in seconds (used as the timestamp for still-unconfirmed txs +/// so emitted activities are DB-valid and sort to the top while pending). +fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + /// Build a TransactionsChanged event from a synced wallet. +/// +/// Emits persistence-ready Core activities + transaction details scoped to +/// `wallet_id` (the watched address type). No cross-address-type merging: each +/// tx in this wallet becomes exactly one activity. fn build_tx_changed_event( wallet: &Wallet, tip_height: u32, account_type: AccountType, + wallet_id: &str, + wallet_network: BdkNetwork, ) -> WatcherEvent { let bdk_balance = match wallet.get_balance() { Ok(b) => b, @@ -335,7 +356,8 @@ fn build_tx_changed_event( }; let balance: WalletBalance = bdk_balance.into(); - let txs = match wallet.list_transactions(false) { + // `true` includes the raw transaction so we can decode addresses / vsize. + let txs = match wallet.list_transactions(true) { Ok(t) => t, Err(e) => { return WatcherEvent::Error { @@ -344,16 +366,28 @@ fn build_tx_changed_event( } }; - let mut history = txs - .iter() - .map(|tx| map_bdk_tx_to_history(tx, tip_height)) - .collect::>(); - sort_history_transactions(&mut history); + let now = now_secs(); + let mut activities = Vec::with_capacity(txs.len()); + let mut transaction_details = Vec::with_capacity(txs.len()); + for tx in &txs { + let detail = match map_bdk_tx_to_detail(wallet, tx, tip_height, wallet_network) { + Ok(d) => d, + Err(e) => { + return WatcherEvent::Error { + message: format!("Failed to decode transaction: {}", e), + }; + } + }; + let (activity, details) = watch_only_activity_from_detail(wallet_id, &detail, now); + activities.push(activity); + transaction_details.push(details); + } - let tx_count = u32::try_from(history.len()).unwrap_or(u32::MAX); + let tx_count = u32::try_from(activities.len()).unwrap_or(u32::MAX); WatcherEvent::TransactionsChanged { - transactions: history, + activities, + transaction_details, balance, tx_count, block_height: tip_height, @@ -546,6 +580,8 @@ fn watcher_init_and_loop( // + gap). Never drop below BDK's default stop-gap. let sync_stop_gap = (gap as usize).max(DEFAULT_GAP_LIMIT as usize); let account_type = setup.account_type; + let wallet_network = setup.network; + let wallet_id = params.wallet_id.clone(); let watcher_id = params.watcher_id.clone(); // --- Initialization --- @@ -637,7 +673,13 @@ fn watcher_init_and_loop( // Send initial state. listener.on_event( watcher_id.clone(), - build_tx_changed_event(&wallet, tip_height, account_type), + build_tx_changed_event( + &wallet, + tip_height, + account_type, + &wallet_id, + wallet_network, + ), ); // Track the last-used indices to detect gap limit extensions. @@ -827,7 +869,13 @@ fn watcher_init_and_loop( listener.on_event( watcher_id.clone(), - build_tx_changed_event(&wallet, tip_height, account_type), + build_tx_changed_event( + &wallet, + tip_height, + account_type, + &wallet_id, + wallet_network, + ), ); } diff --git a/src/modules/onchain/tests.rs b/src/modules/onchain/tests.rs index 63674d1..d1dbec1 100644 --- a/src/modules/onchain/tests.rs +++ b/src/modules/onchain/tests.rs @@ -2452,6 +2452,184 @@ mod tests { ); } + // ------------------------------------------------------------------ + // watch_only_activity_from_detail: persistence-ready activity building + // ------------------------------------------------------------------ + + mod watch_only_activity { + use super::super::super::implementation::watch_only_activity_from_detail; + use crate::activity::{Activity, PaymentType}; + use crate::onchain::types::{TransactionDetail, TxDetailOutput, TxDirection}; + + fn output(value: u64, address: &str, is_mine: bool) -> TxDetailOutput { + TxDetailOutput { + value, + script_pubkey: "00".to_string(), + address: Some(address.to_string()), + is_mine, + } + } + + fn detail( + direction: TxDirection, + sent: u64, + received: u64, + fee: Option, + confirmations: u32, + timestamp: Option, + outputs: Vec, + ) -> TransactionDetail { + TransactionDetail { + txid: "tx_abc".to_string(), + received, + sent, + net: received as i64 - sent as i64, + amount: 0, + fee, + direction, + block_height: timestamp.map(|_| 800_000), + timestamp, + confirmations, + inputs: vec![], + outputs, + size: 200, + vsize: 150, + weight: 600, + fee_rate: fee.map(|f| f as f64 / 150.0), + } + } + + fn onchain(act: &Activity) -> &crate::activity::OnchainActivity { + match act { + Activity::Onchain(o) => o, + _ => panic!("expected onchain"), + } + } + + #[test] + fn received_drops_sender_fee_and_uses_owned_address() { + // Receive-only row that still carries BDK's fee (paid by the sender). + let d = detail( + TxDirection::Received, + 0, + 50_000, + Some(450), + 3, + Some(1_700_000_000), + vec![ + output(50_000, "bc1qowned", true), + output(10_000, "bc1qexternal_change", false), + ], + ); + let (act, details) = watch_only_activity_from_detail("trezor:nw", &d, 999); + let o = onchain(&act); + assert_eq!(o.wallet_id, "trezor:nw"); + assert_eq!(o.id, "tx_abc"); + assert_eq!(o.tx_type, PaymentType::Received); + assert_eq!(o.value, 50_000); + assert_eq!(o.fee, 0, "sender's fee must not be attributed to us"); + assert_eq!(o.fee_rate, 0); + assert_eq!(o.address, "bc1qowned"); + assert!(o.confirmed); + assert_eq!(o.timestamp, 1_700_000_000); + // Fee-excluded; sender's fee is not subtracted from our received amount. + assert_eq!(details.amount_sats, 50_000); + } + + #[test] + fn sent_keeps_value_and_fee_separate() { + // Bitkit renders sent rows as value + fee, so value excludes the fee. + let d = detail( + TxDirection::Sent, + 60_000, + 10_000, + Some(500), + 1, + Some(1_700_000_000), + vec![ + output(49_500, "bc1qdestination", false), + output(10_000, "bc1qchange", true), + ], + ); + let (act, _d) = watch_only_activity_from_detail("w", &d, 999); + let o = onchain(&act); + assert_eq!(o.tx_type, PaymentType::Sent); + assert_eq!(o.value, 60_000 - 10_000 - 500); + assert_eq!(o.fee, 500); + assert_eq!(o.address, "bc1qdestination"); // external output + } + + #[test] + fn self_transfer_is_zero_value_plus_fee() { + // value + fee must equal the true cost (the fee), not double it. + let d = detail( + TxDirection::SelfTransfer, + 50_000, + 49_800, + Some(300), + 2, + Some(1_700_000_000), + vec![output(49_800, "bc1qown_change", true)], + ); + let (act, _d) = watch_only_activity_from_detail("w", &d, 999); + let o = onchain(&act); + assert_eq!(o.tx_type, PaymentType::Sent); + assert_eq!(o.value, 0); + assert_eq!(o.fee, 300); + } + + #[test] + fn unconfirmed_uses_now_and_no_confirm_timestamp() { + let d = detail( + TxDirection::Received, + 0, + 10_000, + None, + 0, + None, + vec![output(10_000, "bc1qowned", true)], + ); + let (act, _d) = watch_only_activity_from_detail("w", &d, 1_234); + let o = onchain(&act); + assert!(!o.confirmed); + assert_eq!( + o.timestamp, 1_234, + "positive (now) timestamp for unconfirmed" + ); + assert_eq!(o.confirm_timestamp, None); + } + + #[test] + fn emits_matching_transaction_details() { + let d = detail( + TxDirection::Sent, + 60_000, + 0, + Some(500), + 1, + Some(1_700_000_000), + vec![ + output(59_500, "bc1qdestination", false), + output(0, "bc1qopreturnish", false), + ], + ); + let (act, details) = watch_only_activity_from_detail("w", &d, 999); + assert_eq!(details.wallet_id, "w"); + assert_eq!(details.tx_id, onchain(&act).tx_id); + assert_eq!(details.outputs.len(), 2); + assert_eq!(details.outputs[0].n, 0); + assert_eq!(details.outputs[1].n, 1); + assert_eq!(details.outputs[0].value, 59_500); + assert_eq!( + details.outputs[0].scriptpubkey_address.as_deref(), + Some("bc1qdestination") + ); + // Fee-excluded: net (-60000) + our fee (500) = -59500, matching the + // 59500 that actually left the wallet. + assert_eq!(details.amount_sats, -59_500); + } + } + #[test] fn default_gap_limit_is_twenty() { use crate::onchain::DEFAULT_GAP_LIMIT; diff --git a/src/modules/onchain/types.rs b/src/modules/onchain/types.rs index 97a6de5..6e0f166 100644 --- a/src/modules/onchain/types.rs +++ b/src/modules/onchain/types.rs @@ -1,3 +1,4 @@ +use crate::modules::activity::{Activity, TransactionDetails}; use crate::modules::scanner::NetworkType; use bitcoin::Network as BitcoinNetwork; use bitcoin_address_generator::{ @@ -487,6 +488,8 @@ pub struct HistoryTransaction { pub net: i64, /// Transaction fee in sats (None if not available, e.g. for received-only txs) pub fee: Option, + /// Fee rate in sats per virtual byte (None if fee or tx size is unavailable). + pub fee_rate: Option, /// Display amount in sats: /// - Received: the received value /// - Sent: amount that left the wallet (sent - received - fee) @@ -536,8 +539,15 @@ impl From for WalletBalance { #[derive(Debug, Clone, uniffi::Enum)] pub enum WatcherEvent { /// Transaction activity changed — contains full updated state. + /// + /// `activities` and `transaction_details` are persistence-ready: they carry + /// the watcher's `wallet_id`, real decoded addresses, fees from the watched + /// wallet's perspective, and DB-valid timestamps, so the app can store them + /// directly through the normal Core activity APIs (e.g. `upsert_activity` / + /// `upsert_transaction_details`). The two vecs are parallel by `tx_id`. TransactionsChanged { - transactions: Vec, + activities: Vec, + transaction_details: Vec, balance: WalletBalance, tx_count: u32, block_height: u32,