Repository: btcsuite/btcwallet

Issue:
Interface Design for `TxReader`
The ["Transaction History"](https://gist.github.com/yyforyongyu/c0a6ec78cedcbf5fb342be412dc591ea) group, which contains methods used for querying and managing the wallet's transaction history. We now introduce `TxReader` interface, which is responsible for all "read" operations related to transactions: querying history and subscribing to new events.

### Rationale for Changes

1.  **Clear Naming (`TxHistoryManager` -> `TxReader`)**: The name `TxReader` is more explicit and consistent with the CQRS pattern, clearly indicating that this interface performs read-only operations.

2.  **Remove Internal Methods**: `RemoveDescendants` is a low-level utility for managing transaction store integrity (e.g., during re-orgs). This should be handled internally by the wallet and not exposed in the high-level public API.

3.  **Standardize Naming**: Method names are standardized to use the `Tx` prefix for consistency (e.g., `GetTransactionDetails` becomes `GetTxDetails`).

4.  **Re-categorization**: `SubscribeTransactions` (renamed to `SubscribeTxes`) is moved here from the `WalletController` interface, as it is fundamentally a read/query operation.

### Final `TxReader` Interface Design

This results in a clean, focused, and safe read-only interface for transaction history.

```go
// TxReader provides an interface for querying transaction history.
type TxReader interface {
    // GetTxDetails returns a detailed description of a transaction
    // given its transaction hash.
    GetTxDetails(ctx context.Context, txHash *chainhash.Hash) (*TransactionDetail, error)

    // ListTxDetails returns a list of all transactions which are
    // relevant to the wallet over a given block range.
    ListTxDetails(ctx context.Context, startHeight, endHeight int32, accountFilter string) ([]*TransactionDetail, error)

    // LabelTx adds a label to a transaction.
    LabelTx(ctx context.Context, hash chainhash.Hash, label string, overwrite bool) error

    // FetchTx attempts to fetch a transaction in the wallet's database.
    FetchTx(ctx context.Context, txHash *chainhash.Hash) (*wire.MsgTx, error)

    // TODO: This is a legacy method that should be removed in favor
    // of a more robust notification system.
    // SubscribeTxes returns a TransactionSubscription client which
    // is capable of receiving async notifications as new transactions
    // related to the wallet are seen within the network, or found in
    // blocks.
    SubscribeTxes() (TransactionSubscription, error)
}
```

Discussion:

Solution patch:
diff --git a/pkg/btcunit/README.md b/pkg/btcunit/README.md
new file mode 100644
index 0000000..ed4abef
--- /dev/null
+++ b/pkg/btcunit/README.md
@@ -0,0 +1,25 @@
+# btcwallet/btcunit
+
+This package provides a set of idiomatic, type-safe units for handling common
+Bitcoin quantities like transaction sizes, weights, and fee rates.
+
+## Purpose
+
+In complex Bitcoin applications, it is crucial to handle different units of
+measurement safely and consistently. Raw integer types can lead to ambiguity and
+errors (e.g., is a fee rate in sat/byte, sat/vbyte, or sat/kw?).
+
+This package establishes a canonical set of types to be used within `btcwallet`
+and by any application that consumes it. By using these types, developers can
+avoid conversion errors and make their code more readable and self-documenting.
+
+## Provided Units
+
+- **Transaction Size**: `WeightUnit` and `VByte` for handling transaction
+  weight and virtual size according to SegWit (BIP-141) standards.
+- **Fee Rates**: `SatPerVByte`, `SatPerKVByte`, and `SatPerKWeight` for
+  expressing fee rates in the most common industry formats. These types use
+  `math/big.Rat` internally to allow for fractional (sub-satoshi) values,
+  ensuring precision in fee calculations. These types use
+  `math/big.Rat` internally to allow for fractional (sub-satoshi) values,
+  ensuring precision in fee calculations.
diff --git a/pkg/btcunit/rates.go b/pkg/btcunit/rates.go
new file mode 100644
index 0000000..97f886a
--- /dev/null
+++ b/pkg/btcunit/rates.go
@@ -0,0 +1,295 @@
+// Copyright (c) 2025 The btcsuite developers
+// Use of this source code is governed by an ISC
+// license that can be found in the LICENSE file.
+
+// Package btcunit provides a set of types for dealing with bitcoin units.
+package btcunit
+
+import (
+	"log/slog"
+	"math"
+	"math/big"
+
+	"github.com/btcsuite/btcd/blockchain"
+	"github.com/btcsuite/btcd/btcutil"
+)
+
+const (
+	// SatsPerKilo is the number of satoshis in a kilo-satoshi.
+	SatsPerKilo = 1000
+
+	// floatStringPrecision is the number of decimal places to use when
+	// converting a fee rate to a string.
+	floatStringPrecision = 2
+)
+
+// SatPerVByte represents a fee rate in sat/vbyte. The fee rate is encoded
+// as a big.Rat to allow for fractional (sub-satoshi) fee rates.
+type SatPerVByte struct {
+	*big.Rat
+}
+
+// NewSatPerVByte creates a new fee rate in sat/vb. The given fee and vbytes
+// are used to calculate the fee rate.
+func NewSatPerVByte(fee btcutil.Amount, vb VByte) SatPerVByte {
+	if vb.val == 0 {
+		return SatPerVByte{big.NewRat(0, 1)}
+	}
+
+	return SatPerVByte{
+		big.NewRat(int64(fee), safeUint64ToInt64(vb.val)),
+	}
+}
+
+// FeePerKWeight converts the current fee rate from sat/vb to sat/kw.
+func (s SatPerVByte) FeePerKWeight() SatPerKWeight {
+	vbToKwRate := big.NewRat(SatsPerKilo, blockchain.WitnessScaleFactor)
+	kwRate := new(big.Rat).Mul(s.Rat, vbToKwRate)
+
+	return SatPerKWeight{kwRate}
+}
+
+// FeePerKVByte converts the current fee rate from sat/vb to sat/kvb.
+func (s SatPerVByte) FeePerKVByte() SatPerKVByte {
+	vbToKvbRate := big.NewRat(SatsPerKilo, 1)
+	kvbRate := new(big.Rat).Mul(s.Rat, vbToKvbRate)
+
+	return SatPerKVByte{kvbRate}
+}
+
+// String returns a human-readable string of the fee rate.
+func (s SatPerVByte) String() string {
+	return s.FloatString(floatStringPrecision) + " sat/vb"
+}
+
+// Equal returns true if the fee rate is equal to the other fee rate.
+func (s SatPerVByte) Equal(other SatPerVByte) bool {
+	return s.Cmp(other.Rat) == 0
+}
+
+// GreaterThan returns true if the fee rate is greater than the other fee rate.
+func (s SatPerVByte) GreaterThan(other SatPerVByte) bool {
+	return s.Cmp(other.Rat) > 0
+}
+
+// LessThan returns true if the fee rate is less than the other fee rate.
+func (s SatPerVByte) LessThan(other SatPerVByte) bool {
+	return s.Cmp(other.Rat) < 0
+}
+
+// GreaterThanOrEqual returns true if the fee rate is greater than or equal to
+// the other fee rate.
+func (s SatPerVByte) GreaterThanOrEqual(other SatPerVByte) bool {
+	return s.Cmp(other.Rat) >= 0
+}
+
+// LessThanOrEqual returns true if the fee rate is less than or equal to the
+// other fee rate.
+func (s SatPerVByte) LessThanOrEqual(other SatPerVByte) bool {
+	return s.Cmp(other.Rat) <= 0
+}
+
+// SatPerKVByte represents a fee rate in sat/kb. The fee rate is encoded as a
+// big.Rat to allow for fractional (sub-satoshi) fee rates.
+type SatPerKVByte struct {
+	*big.Rat
+}
+
+// NewSatPerKVByte creates a new fee rate in sat/kvb. The given fee and kvbytes
+// are used to calculate the fee rate.
+func NewSatPerKVByte(fee btcutil.Amount, kvb VByte) SatPerKVByte {
+	if kvb.val == 0 {
+		return SatPerKVByte{big.NewRat(0, 1)}
+	}
+
+	return SatPerKVByte{
+		big.NewRat(
+			int64(fee)*SatsPerKilo,
+			safeUint64ToInt64(kvb.val),
+		),
+	}
+}
+
+// FeeForVSize calculates the fee resulting from this fee rate and the given
+// vsize in vbytes.
+func (s SatPerKVByte) FeeForVSize(vbytes VByte) btcutil.Amount {
+	fee := new(big.Rat).Mul(
+		s.Rat,
+		big.NewRat(safeUint64ToInt64(vbytes.val), SatsPerKilo),
+	)
+
+	return roundToAmount(fee)
+}
+
+// FeePerKWeight converts the current fee rate from sat/kb to sat/kw.
+func (s SatPerKVByte) FeePerKWeight() SatPerKWeight {
+	kvbToKwRate := big.NewRat(1, blockchain.WitnessScaleFactor)
+	kwRate := new(big.Rat).Mul(s.Rat, kvbToKwRate)
+
+	return SatPerKWeight{kwRate}
+}
+
+// String returns a human-readable string of the fee rate.
+func (s SatPerKVByte) String() string {
+	return s.FloatString(floatStringPrecision) + " sat/kvb"
+}
+
+// Equal returns true if the fee rate is equal to the other fee rate.
+func (s SatPerKVByte) Equal(other SatPerKVByte) bool {
+	return s.Cmp(other.Rat) == 0
+}
+
+// GreaterThan returns true if the fee rate is greater than the other fee rate.
+func (s SatPerKVByte) GreaterThan(other SatPerKVByte) bool {
+	return s.Cmp(other.Rat) > 0
+}
+
+// LessThan returns true if the fee rate is less than the other fee rate.
+func (s SatPerKVByte) LessThan(other SatPerKVByte) bool {
+	return s.Cmp(other.Rat) < 0
+}
+
+// GreaterThanOrEqual returns true if the fee rate is greater than or equal to
+// the other fee rate.
+func (s SatPerKVByte) GreaterThanOrEqual(other SatPerKVByte) bool {
+	return s.Cmp(other.Rat) >= 0
+}
+
+// LessThanOrEqual returns true if the fee rate is less than or equal to the
+// other fee rate.
+func (s SatPerKVByte) LessThanOrEqual(other SatPerKVByte) bool {
+	return s.Cmp(other.Rat) <= 0
+}
+
+// SatPerKWeight represents a fee rate in sat/kw. The fee rate is encoded as a
+// big.Rat to allow for fractional (sub-satoshi) fee rates.
+type SatPerKWeight struct {
+	*big.Rat
+}
+
+// NewSatPerKWeight creates a new fee rate in sat/kw. The given fee and weight
+// are used to calculate the fee rate.
+func NewSatPerKWeight(fee btcutil.Amount, wu WeightUnit) SatPerKWeight {
+	if wu.val == 0 {
+		return SatPerKWeight{big.NewRat(0, 1)}
+	}
+
+	return SatPerKWeight{
+		big.NewRat(
+			int64(fee)*SatsPerKilo,
+			safeUint64ToInt64(wu.val),
+		),
+	}
+}
+
+// FeeForWeight calculates the fee resulting from this fee rate and the given
+// weight in weight units (wu).
+func (s SatPerKWeight) FeeForWeight(wu WeightUnit) btcutil.Amount {
+	// The resulting fee is rounded down, as specified in BOLT#03.
+	fee := new(big.Rat).Mul(
+		s.Rat, big.NewRat(safeUint64ToInt64(wu.val), SatsPerKilo),
+	)
+
+	return btcutil.Amount(new(big.Int).Div(fee.Num(), fee.Denom()).Int64())
+}
+
+// FeeForWeightRoundUp calculates the fee resulting from this fee rate and the
+// given weight in weight units (wu), rounding up to the nearest satoshi.
+func (s SatPerKWeight) FeeForWeightRoundUp(
+	wu WeightUnit) btcutil.Amount {
+
+	// The rounding logic is based on the ceiling division formula:
+	// (numerator + denominator - 1) / denominator
+	//
+	// This ensures that any fractional part of the fee is rounded up to
+	// the next whole satoshi.
+	feeRat := new(big.Rat).Mul(
+		s.Rat, big.NewRat(safeUint64ToInt64(wu.val), SatsPerKilo),
+	)
+
+	num := feeRat.Num()
+	den := feeRat.Denom()
+	num.Add(num, den)
+	num.Sub(num, big.NewInt(1))
+	num.Div(num, den)
+
+	return btcutil.Amount(num.Int64())
+}
+
+// FeeForVByte calculates the fee resulting from this fee rate and the given
+// size in vbytes (vb).
+func (s SatPerKWeight) FeeForVByte(vb VByte) btcutil.Amount {
+	return s.FeePerKVByte().FeeForVSize(vb)
+}
+
+// FeePerKVByte converts the current fee rate from sat/kw to sat/kb.
+func (s SatPerKWeight) FeePerKVByte() SatPerKVByte {
+	kwToKvbRate := big.NewRat(blockchain.WitnessScaleFactor, 1)
+	kvbRate := new(big.Rat).Mul(s.Rat, kwToKvbRate)
+
+	return SatPerKVByte{kvbRate}
+}
+
+// FeePerVByte converts the current fee rate from sat/kw to sat/vb.
+func (s SatPerKWeight) FeePerVByte() SatPerVByte {
+	kwToVbRate := big.NewRat(blockchain.WitnessScaleFactor, SatsPerKilo)
+	vbRate := new(big.Rat).Mul(s.Rat, kwToVbRate)
+
+	return SatPerVByte{vbRate}
+}
+
+// String returns a human-readable string of the fee rate.
+func (s SatPerKWeight) String() string {
+	return s.FloatString(floatStringPrecision) + " sat/kw"
+}
+
+// Equal returns true if the fee rate is equal to the other fee rate.
+func (s SatPerKWeight) Equal(other SatPerKWeight) bool {
+	return s.Cmp(other.Rat) == 0
+}
+
+// GreaterThan returns true if the fee rate is greater than the other fee rate.
+func (s SatPerKWeight) GreaterThan(other SatPerKWeight) bool {
+	return s.Cmp(other.Rat) > 0
+}
+
+// LessThan returns true if the fee rate is less than the other fee rate.
+func (s SatPerKWeight) LessThan(other SatPerKWeight) bool {
+	return s.Cmp(other.Rat) < 0
+}
+
+// GreaterThanOrEqual returns true if the fee rate is greater than or equal to
+// the other fee rate.
+func (s SatPerKWeight) GreaterThanOrEqual(other SatPerKWeight) bool {
+	return s.Cmp(other.Rat) >= 0
+}
+
+// LessThanOrEqual returns true if the fee rate is less than or equal to the
+// other fee rate.
+func (s SatPerKWeight) LessThanOrEqual(other SatPerKWeight) bool {
+	return s.Cmp(other.Rat) <= 0
+}
+
+// roundToAmount rounds a big.Rat to the nearest btcutil.Amount (int64),
+// with halves rounded away from zero. For example, 2.4 rounds to 2, 2.5
+// rounds to 3, and -2.5 rounds to -3.
+func roundToAmount(r *big.Rat) btcutil.Amount {
+	f, _ := r.Float64()
+
+	return btcutil.Amount(math.Round(f))
+}
+
+// safeUint64ToInt64 converts a uint64 to an int64, capping at math.MaxInt64.
+// This is used to silence gosec warnings about integer overflows. In practice,
+// the values being converted are transaction weights or sizes, which are
+// limited by consensus rules and are not expected to overflow an int64.
+func safeUint64ToInt64(u uint64) int64 {
+	if u > math.MaxInt64 {
+		slog.Warn("Capping uint64 value to math.MaxInt64",
+			slog.Uint64("old", u), slog.Int64("new", math.MaxInt64))
+
+		return math.MaxInt64
+	}
+
+	return int64(u)
+}
diff --git a/pkg/btcunit/txsize.go b/pkg/btcunit/txsize.go
new file mode 100644
index 0000000..6910ca4
--- /dev/null
+++ b/pkg/btcunit/txsize.go
@@ -0,0 +1,59 @@
+package btcunit
+
+import (
+	"fmt"
+	"math"
+
+	"github.com/btcsuite/btcd/blockchain"
+)
+
+// WeightUnit defines a unit to express the transaction size. One weight unit
+// is 1/4_000_000 of the max block size. The tx weight is calculated using
+// `Base tx size * 3 + Total tx size`.
+//   - Base tx size is size of the transaction serialized without the witness
+//     data.
+//   - Total tx size is the transaction size in bytes serialized according
+//     #BIP144.
+type WeightUnit struct {
+	val uint64
+}
+
+// NewWeightUnit creates a new WeightUnit from a uint64.
+func NewWeightUnit(val uint64) WeightUnit {
+	return WeightUnit{val: val}
+}
+
+// ToVB converts a value expressed in weight units to virtual bytes.
+func (wu WeightUnit) ToVB() VByte {
+	// According to BIP141: Virtual transaction size is defined as
+	// Transaction weight / 4 (rounded up to the next integer).
+	vbytes := math.Ceil(float64(wu.val) / blockchain.WitnessScaleFactor)
+	return VByte{val: uint64(vbytes)}
+}
+
+// String returns the string representation of the weight unit.
+func (wu WeightUnit) String() string {
+	return fmt.Sprintf("%d wu", wu.val)
+}
+
+// VByte defines a unit to express the transaction size. One virtual byte is
+// 1/4th of a weight unit. The tx virtual bytes is calculated using `TxWeight /
+// 4`.
+type VByte struct {
+	val uint64
+}
+
+// NewVByte creates a new VByte from a uint64.
+func NewVByte(val uint64) VByte {
+	return VByte{val: val}
+}
+
+// ToWU converts a value expressed in virtual bytes to weight units.
+func (vb VByte) ToWU() WeightUnit {
+	return WeightUnit{val: vb.val * blockchain.WitnessScaleFactor}
+}
+
+// String returns the string representation of the virtual byte.
+func (vb VByte) String() string {
+	return fmt.Sprintf("%d vb", vb.val)
+}
diff --git a/waddrmgr/manager.go b/waddrmgr/manager.go
index b546864..1343fb8 100644
--- a/waddrmgr/manager.go
+++ b/waddrmgr/manager.go
@@ -431,17 +431,16 @@ func (m *Manager) IsWatchOnlyAccount(ns walletdb.ReadBucket, keyScope KeyScope,
 func (m *Manager) lock() {
 	for _, manager := range m.scopedManagers {
 		// Clear all of the account private keys.
-		for _, acctInfo := range manager.acctInfo {
+		for _, acctInfo := range manager.accountInfo() {
 			if acctInfo.acctKeyPriv != nil {
 				acctInfo.acctKeyPriv.Zero()
 			}
 			acctInfo.acctKeyPriv = nil
 		}
-	}
 
-	// Remove clear text private keys and scripts from all address entries.
-	for _, manager := range m.scopedManagers {
-		for _, ma := range manager.addrs {
+		// Remove clear text private keys and scripts from all address
+		// entries.
+		for _, ma := range manager.addresses() {
 			switch addr := ma.(type) {
 			case *managedAddress:
 				addr.lock()
diff --git a/waddrmgr/scoped_manager.go b/waddrmgr/scoped_manager.go
index 152e95c..ed3fd69 100644
--- a/waddrmgr/scoped_manager.go
+++ b/waddrmgr/scoped_manager.go
@@ -4,6 +4,7 @@ import (
 	"crypto/sha256"
 	"encoding/binary"
 	"fmt"
+	"maps"
 	"sync"
 
 	"github.com/btcsuite/btcd/btcec/v2"
@@ -2654,3 +2655,27 @@ func (s *ScopedKeyManager) NewAddress(addrmgrNs walletdb.ReadWriteBucket,
 
 	return addr, nil
 }
+
+// accountInfo returns a copy of the account info map.
+func (s *ScopedKeyManager) accountInfo() map[uint32]*accountInfo {
+	s.mtx.RLock()
+	defer s.mtx.RUnlock()
+
+	acctInfoCopy := make(map[uint32]*accountInfo, len(s.acctInfo))
+	maps.Copy(acctInfoCopy, s.acctInfo)
+
+	return acctInfoCopy
+}
+
+// addresses returns a slice of all managed addresses.
+func (s *ScopedKeyManager) addresses() []ManagedAddress {
+	s.mtx.RLock()
+	defer s.mtx.RUnlock()
+
+	addrs := make([]ManagedAddress, 0, len(s.addrs))
+	for _, ma := range s.addrs {
+		addrs = append(addrs, ma)
+	}
+
+	return addrs
+}
diff --git a/wallet/tx_reader.go b/wallet/tx_reader.go
new file mode 100644
index 0000000..6f39435
--- /dev/null
+++ b/wallet/tx_reader.go
@@ -0,0 +1,432 @@
+// Copyright (c) 2025 The btcsuite developers
+// Use of this source code is governed by an ISC
+// license that can be found in the LICENSE file.
+
+package wallet
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"math"
+	"math/big"
+	"time"
+
+	"github.com/btcsuite/btcd/blockchain"
+	"github.com/btcsuite/btcd/btcutil"
+	"github.com/btcsuite/btcd/chaincfg/chainhash"
+	"github.com/btcsuite/btcd/txscript"
+	"github.com/btcsuite/btcd/wire"
+	"github.com/btcsuite/btcwallet/pkg/btcunit"
+	"github.com/btcsuite/btcwallet/walletdb"
+	"github.com/btcsuite/btcwallet/wtxmgr"
+)
+
+var (
+	// ErrTxNotFound is returned when a transaction is not found in the
+	// store.
+	ErrTxNotFound = errors.New("tx not found")
+)
+
+// TxReader provides an interface for querying tx history.
+type TxReader interface {
+	// GetTx returns a detailed description of a tx given its tx hash.
+	GetTx(ctx context.Context, txHash chainhash.Hash) (*TxDetail, error)
+
+	// ListTxns returns a list of all txns which are relevant to the wallet
+	// over a given block range.
+	ListTxns(ctx context.Context, startHeight, endHeight int32) (
+		[]*TxDetail, error)
+}
+
+// A compile-time assertion to ensure that Wallet implements the TxReader
+// interface.
+var _ TxReader = (*Wallet)(nil)
+
+// Output contains details for a tx output.
+type Output struct {
+	// Addresses are the addresses associated with the output script.
+	Addresses []btcutil.Address
+
+	// PkScript is the raw output script.
+	PkScript []byte
+
+	// Index is the index of the output in the tx.
+	Index int
+
+	// Amount is the value of the output.
+	Amount btcutil.Amount
+
+	// Type is the script class of the output.
+	Type txscript.ScriptClass
+
+	// IsOurs is true if the output is controlled by the wallet.
+	IsOurs bool
+}
+
+// PrevOut describes a tx input.
+type PrevOut struct {
+	// OutPoint is the unique reference to the output being spent.
+	OutPoint wire.OutPoint
+
+	// IsOurs is true if the input spends an output controlled by the
+	// wallet.
+	IsOurs bool
+}
+
+// BlockDetails contains details about the block that includes a tx.
+type BlockDetails struct {
+	// Hash is the hash of the block.
+	Hash chainhash.Hash
+
+	// Height is the height of the block.
+	Height int32
+
+	// Timestamp is the unix timestamp of the block.
+	Timestamp int64
+}
+
+// TxDetail describes a tx relevant to a wallet. This is a flattened
+// and information-dense structure designed to be returned by the TxReader
+// interface.
+type TxDetail struct {
+	// Hash is the tx hash.
+	Hash chainhash.Hash
+
+	// RawTx is the serialized tx.
+	RawTx []byte
+
+	// Value is the net value of this tx (in satoshis) from the
+	// POV of the wallet.
+	Value btcutil.Amount
+
+	// Fee is the total fee in satoshis paid by this tx.
+	//
+	// NOTE: This is only calculated if all inputs are known to the wallet.
+	// Otherwise, it will be zero.
+	//
+	// TODO(yy): This should also be calculated for txns with external
+	// inputs. This requires adding a `GetRawTransaction` method to the
+	// `chain.Interface`.
+	Fee btcutil.Amount
+
+	// FeeRate is the fee rate of the tx in sat/vbyte.
+	//
+	// NOTE: This is only calculated if all inputs are known to the wallet.
+	// Otherwise, it will be zero.
+	FeeRate btcunit.SatPerVByte
+
+	// Weight is the tx's weight.
+	Weight btcunit.WeightUnit
+
+	// Confirmations is the number of confirmations this tx has.
+	// This will be 0 for unconfirmed txns.
+	Confirmations int32
+
+	// Block contains details of the block that includes this tx.
+	Block *BlockDetails
+
+	// ReceivedTime is the time the tx was received by the wallet.
+	ReceivedTime time.Time
+
+	// Outputs contains data for each tx output.
+	Outputs []Output
+
+	// PrevOuts are the inputs for the tx.
+	PrevOuts []PrevOut
+
+	// Label is an optional tx label.
+	Label string
+}
+
+// GetTx returns a detailed description of a tx given its tx hash.
+//
+// NOTE: This method is part of the TxReader interface.
+//
+// Time complexity: O(log n + I + O), where n is the number of
+// transactions in the database, I is the number of inputs, and O is the
+// number of outputs. The lookup is dominated by a key-based B-tree lookup
+// in the database and the processing of the transaction's inputs and
+// outputs.
+func (w *Wallet) GetTx(_ context.Context, txHash chainhash.Hash) (
+	*TxDetail, error) {
+
+	txDetails, err := w.fetchTxDetails(&txHash)
+	if err != nil {
+		return nil, err
+	}
+
+	bestBlock := w.SyncedTo()
+	currentHeight := bestBlock.Height
+
+	return w.buildTxDetail(txDetails, currentHeight), nil
+}
+
+// ListTxns returns a list of all txns which are relevant to the
+// wallet over a given block range. The block range is inclusive of the
+// start and end heights.
+//
+// The underlying transaction store allows for reverse iteration, so if
+// startHeight > endHeight, the transactions will be returned in reverse
+// order.
+//
+// The special height -1 may be used to include unmined transactions. For
+// example, to get all transactions from block 100 to the current tip including
+// unmined, use a startHeight of 100 and an endHeight of -1. To get all
+// transactions in the wallet, use a startHeight of 0 and an endHeight of -1.
+//
+// NOTE: This method is part of the TxReader interface.
+//
+// Time complexity: O(B + N), where B is the number of blocks in the
+// range and N is the total number of inputs and outputs across all
+// transactions in the range.
+func (w *Wallet) ListTxns(_ context.Context, startHeight,
+	endHeight int32) ([]*TxDetail, error) {
+
+	bestBlock := w.SyncedTo()
+	currentHeight := bestBlock.Height
+
+	// We'll first fetch all the transaction records from the database
+	// within a single database transaction. This is done to minimize the
+	// time we hold the database lock.
+	var records []wtxmgr.TxDetails
+
+	err := walletdb.View(w.db, func(dbtx walletdb.ReadTx) error {
+		txmgrNs := dbtx.ReadBucket(wtxmgrNamespaceKey)
+
+		err := w.txStore.RangeTransactions(
+			txmgrNs, startHeight, endHeight,
+			func(d []wtxmgr.TxDetails) (bool, error) {
+				records = append(records, d...)
+
+				return false, nil
+			},
+		)
+		if err != nil {
+			return fmt.Errorf("tx range failed: %w", err)
+		}
+
+		return nil
+	})
+	if err != nil {
+		return nil, fmt.Errorf("failed to view wallet db: %w", err)
+	}
+
+	// Now that we have all the records, we can build the detailed
+	// response without holding the database lock.
+	details := make([]*TxDetail, 0, len(records))
+	for _, detail := range records {
+		txDetail := w.buildTxDetail(&detail, currentHeight)
+		details = append(details, txDetail)
+	}
+
+	return details, nil
+}
+
+// fetchTxDetails fetches the tx details for the given tx hash
+// from the wallet's tx store.
+func (w *Wallet) fetchTxDetails(txHash *chainhash.Hash) (
+	*wtxmgr.TxDetails, error) {
+
+	var txDetails *wtxmgr.TxDetails
+
+	err := walletdb.View(w.db, func(dbtx walletdb.ReadTx) error {
+		txmgrNs := dbtx.ReadBucket(wtxmgrNamespaceKey)
+
+		var err error
+
+		txDetails, err = w.txStore.TxDetails(txmgrNs, txHash)
+		if err != nil {
+			return fmt.Errorf("failed to fetch tx details: %w", err)
+		}
+
+		return nil
+	})
+	if err != nil {
+		return nil, fmt.Errorf("failed to view wallet db: %w", err)
+	}
+
+	// TxDetails will return nil when the tx is not found.
+	//
+	// TODO(yy): We should instead return an error when the tx cannot be
+	// found in the db.
+	if txDetails == nil {
+		return nil, ErrTxNotFound
+	}
+
+	return txDetails, nil
+}
+
+// buildTxDetail builds a TxDetail from the given wtxmgr.TxDetails.
+func (w *Wallet) buildTxDetail(txDetails *wtxmgr.TxDetails,
+	currentHeight int32) *TxDetail {
+
+	details := w.buildBasicTxDetail(txDetails)
+
+	w.populateBlockDetails(details, txDetails, currentHeight)
+	w.calculateValueAndFee(details, txDetails)
+	w.populateOutputs(details, txDetails)
+	w.populatePrevOuts(details, txDetails)
+
+	return details
+}
+
+// buildBasicTxDetail builds the basic TxDetail from the given wtxmgr.TxDetails.
+func (w *Wallet) buildBasicTxDetail(txDetails *wtxmgr.TxDetails) *TxDetail {
+	txWeight := blockchain.GetTransactionWeight(
+		btcutil.NewTx(&txDetails.MsgTx),
+	)
+
+	return &TxDetail{
+		Hash:         txDetails.Hash,
+		RawTx:        txDetails.SerializedTx,
+		Label:        txDetails.Label,
+		ReceivedTime: txDetails.Received,
+		Weight:       safeInt64ToWeightUnit(txWeight),
+		FeeRate:      btcunit.SatPerVByte{Rat: big.NewRat(0, 1)},
+	}
+}
+
+// populateBlockDetails populates the block details for the given TxDetail.
+func (w *Wallet) populateBlockDetails(details *TxDetail,
+	txDetails *wtxmgr.TxDetails, currentHeight int32) {
+
+	height := txDetails.Block.Height
+	if height == -1 {
+		return
+	}
+
+	details.Block = &BlockDetails{
+		Hash:      txDetails.Block.Hash,
+		Height:    txDetails.Block.Height,
+		Timestamp: txDetails.Block.Time.Unix(),
+	}
+
+	details.Confirmations = calcConf(height, currentHeight)
+}
+
+// calculateValueAndFee calculates the value and fee for the given TxDetail.
+func (w *Wallet) calculateValueAndFee(details *TxDetail,
+	txDetails *wtxmgr.TxDetails) {
+
+	var balanceDelta btcutil.Amount
+	for _, debit := range txDetails.Debits {
+		balanceDelta -= debit.Amount
+	}
+
+	for _, credit := range txDetails.Credits {
+		balanceDelta += credit.Amount
+	}
+
+	details.Value = balanceDelta
+
+	// If not all inputs are ours, we can't calculate the total fee.
+	// txDetails.Debits contains only our inputs, while
+	// txDetails.MsgTx.TxIn contains all inputs. If they differ, some
+	// inputs belong to external wallets and we don't know their input
+	// values.
+	if len(txDetails.Debits) != len(txDetails.MsgTx.TxIn) {
+		return
+	}
+
+	var totalInput btcutil.Amount
+	for _, debit := range txDetails.Debits {
+		totalInput += debit.Amount
+	}
+
+	var totalOutput btcutil.Amount
+	for _, txOut := range txDetails.MsgTx.TxOut {
+		totalOutput += btcutil.Amount(txOut.Value)
+	}
+
+	details.Fee = totalInput - totalOutput
+	details.FeeRate = btcunit.NewSatPerVByte(
+		details.Fee, details.Weight.ToVB(),
+	)
+}
+
+// populateOutputs populates the outputs for the given TxDetail.
+func (w *Wallet) populateOutputs(details *TxDetail,
+	txDetails *wtxmgr.TxDetails) {
+
+	isOurAddress := make(map[uint32]bool)
+	for _, credit := range txDetails.Credits {
+		isOurAddress[credit.Index] = true
+	}
+
+	for i, txOut := range txDetails.MsgTx.TxOut {
+		sc, outAddresses, _, err := txscript.ExtractPkScriptAddrs(
+			txOut.PkScript, w.chainParams,
+		)
+
+		var addresses []btcutil.Address
+		if err != nil {
+			log.Warnf("Cannot extract addresses from pkScript for "+
+				"tx %v, output %d: %v", details.Hash, i, err)
+		} else {
+			addresses = outAddresses
+		}
+
+		idx, ok := safeIntToUint32(i)
+		if !ok {
+			log.Warnf("Output index %d out of uint32 range", i)
+			continue
+		}
+
+		details.Outputs = append(
+			details.Outputs, Output{
+				Type:      sc,
+				Addresses: addresses,
+				PkScript:  txOut.PkScript,
+				Index:     i,
+				Amount:    btcutil.Amount(txOut.Value),
+				IsOurs:    isOurAddress[idx],
+			},
+		)
+	}
+}
+
+// populatePrevOuts populates the previous outputs for the given TxDetail.
+func (w *Wallet) populatePrevOuts(details *TxDetail,
+	txDetails *wtxmgr.TxDetails) {
+
+	isOurOutput := make(map[uint32]bool)
+	for _, debit := range txDetails.Debits {
+		isOurOutput[debit.Index] = true
+	}
+
+	for i, txIn := range txDetails.MsgTx.TxIn {
+		idx, ok := safeIntToUint32(i)
+		if !ok {
+			log.Warnf("Input index %d out of uint32 range", i)
+			continue
+		}
+
+		details.PrevOuts = append(
+			details.PrevOuts, PrevOut{
+				OutPoint: txIn.PreviousOutPoint,
+				IsOurs:   isOurOutput[idx],
+			},
+		)
+	}
+}
+
+// safeInt64ToWeightUnit converts an int64 to a unit.WeightUnit, ensuring the
+// value is non-negative.
+func safeInt64ToWeightUnit(w int64) btcunit.WeightUnit {
+	if w < 0 {
+		return btcunit.NewWeightUnit(0)
+	}
+
+	return btcunit.NewWeightUnit(uint64(w))
+}
+
+// safeIntToUint32 converts an int to a uint32, returning false if the
+// conversion would overflow.
+func safeIntToUint32(i int) (uint32, bool) {
+	if i < 0 || i > math.MaxUint32 {
+		return 0, false
+	}
+
+	return uint32(i), true
+}
diff --git a/wallet/tx_writer.go b/wallet/tx_writer.go
new file mode 100644
index 0000000..1faafdb
--- /dev/null
+++ b/wallet/tx_writer.go
@@ -0,0 +1,55 @@
+// Copyright (c) 2025 The btcsuite developers
+// Use of this source code is governed by an ISC
+// license that can be found in the LICENSE file.
+
+package wallet
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/btcsuite/btcd/chaincfg/chainhash"
+	"github.com/btcsuite/btcwallet/walletdb"
+)
+
+// TxWriter provides an interface for updating wallet txns.
+type TxWriter interface {
+	// LabelTx adds a label to a tx. If a label already exists, it will be
+	// overwritten.
+	LabelTx(ctx context.Context, hash chainhash.Hash, label string) error
+}
+
+// A compile time check to ensure that Wallet implements the interface.
+var _ TxWriter = (*Wallet)(nil)
+
+// LabelTx adds a label to a tx. If a label already exists, it will be
+// overwritten.
+func (w *Wallet) LabelTx(_ context.Context,
+	hash chainhash.Hash, label string) error {
+
+	err := walletdb.Update(w.db, func(dbtx walletdb.ReadWriteTx) error {
+		txmgrNs := dbtx.ReadWriteBucket(wtxmgrNamespaceKey)
+
+		// Check that the transaction is known to the wallet.
+		details, err := w.txStore.TxDetails(txmgrNs, &hash)
+		if err != nil {
+			return fmt.Errorf("failed to get tx details: %w", err)
+		}
+
+		if details == nil {
+			return ErrTxNotFound
+		}
+
+		err = w.txStore.PutTxLabel(txmgrNs, hash, label)
+		if err != nil {
+			return fmt.Errorf("failed to put tx label: %w", err)
+		}
+
+		return nil
+	})
+	if err != nil {
+		return fmt.Errorf("failed to update wallet db: %w", err)
+	}
+
+	return nil
+}
diff --git a/wtxmgr/db.go b/wtxmgr/db.go
index c7e3619..6a7533d 100644
--- a/wtxmgr/db.go
+++ b/wtxmgr/db.go
@@ -434,6 +434,10 @@ func readRawTxRecord(txHash *chainhash.Hash, v []byte, rec *TxRecord) error {
 			bucketTxRecords, txHash)
 		return storeError(ErrData, str, err)
 	}
+
+	// Cache the raw bytes when the above deserialization succeeded.
+	rec.SerializedTx = v[8:]
+
 	return nil
 }
 



Test patch:
diff --git a/pkg/btcunit/rates_test.go b/pkg/btcunit/rates_test.go
new file mode 100644
index 0000000..29cbc96
--- /dev/null
+++ b/pkg/btcunit/rates_test.go
@@ -0,0 +1,338 @@
+package btcunit
+
+import (
+	"math"
+	"math/big"
+	"testing"
+
+	"github.com/btcsuite/btcd/btcutil"
+	"github.com/stretchr/testify/require"
+)
+
+// TestFeeRateConversions checks that the conversion between the different fee
+// rate units is correct.
+func TestFeeRateConversions(t *testing.T) {
+	t.Parallel()
+
+	testCases := []struct {
+		name         string
+		rate         any
+		expectedVB   SatPerVByte
+		expectedKVB  SatPerKVByte
+		expectedKW   SatPerKWeight
+		expectedSats btcutil.Amount
+	}{
+		{
+			name:         "1 sat/vb",
+			rate:         SatPerVByte{big.NewRat(1, 1)},
+			expectedVB:   SatPerVByte{big.NewRat(1, 1)},
+			expectedKVB:  SatPerKVByte{big.NewRat(1000, 1)},
+			expectedKW:   SatPerKWeight{big.NewRat(250, 1)},
+			expectedSats: 1,
+		},
+		{
+			name:         "1000 sat/kvb",
+			rate:         SatPerKVByte{big.NewRat(1000, 1)},
+			expectedVB:   SatPerVByte{big.NewRat(1, 1)},
+			expectedKVB:  SatPerKVByte{big.NewRat(1000, 1)},
+			expectedKW:   SatPerKWeight{big.NewRat(250, 1)},
+			expectedSats: 1000,
+		},
+		{
+			name:         "250 sat/kw",
+			rate:         SatPerKWeight{big.NewRat(250, 1)},
+			expectedVB:   SatPerVByte{big.NewRat(1, 1)},
+			expectedKVB:  SatPerKVByte{big.NewRat(1000, 1)},
+			expectedKW:   SatPerKWeight{big.NewRat(250, 1)},
+			expectedSats: 250,
+		},
+		{
+			name:         "0.11 sat/vb",
+			rate:         SatPerVByte{big.NewRat(11, 100)},
+			expectedVB:   SatPerVByte{big.NewRat(11, 100)},
+			expectedKVB:  SatPerKVByte{big.NewRat(110, 1)},
+			expectedKW:   SatPerKWeight{big.NewRat(11000, 400)},
+			expectedSats: 0,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			switch r := tc.rate.(type) {
+			case SatPerVByte:
+				require.True(t, tc.expectedVB.Equal(r))
+				require.True(t, tc.expectedKVB.Equal(
+					r.FeePerKVByte()),
+				)
+				require.True(t, tc.expectedKW.Equal(
+					r.FeePerKWeight()),
+				)
+
+				// The expected sats is the floor of the fee
+				// rate.
+				floor := new(big.Int).Div(r.Num(), r.Denom())
+				require.Equal(
+					t, tc.expectedSats,
+					btcutil.Amount(floor.Int64()),
+				)
+
+			case SatPerKVByte:
+				require.True(t, tc.expectedVB.Equal(
+					r.FeePerKWeight().FeePerVByte()),
+				)
+				require.True(t, tc.expectedKVB.Equal(r))
+				require.True(t, tc.expectedKW.Equal(
+					r.FeePerKWeight()),
+				)
+				floor := new(big.Int).Div(r.Num(), r.Denom())
+				require.Equal(
+					t, tc.expectedSats,
+					btcutil.Amount(floor.Int64()),
+				)
+
+			case SatPerKWeight:
+				require.True(t, tc.expectedVB.Equal(
+					r.FeePerVByte()),
+				)
+				require.True(t, tc.expectedKVB.Equal(
+					r.FeePerKVByte()),
+				)
+				require.True(t, tc.expectedKW.Equal(r))
+				floor := new(big.Int).Div(r.Num(), r.Denom())
+				require.Equal(
+					t, tc.expectedSats,
+					btcutil.Amount(floor.Int64()),
+				)
+			}
+		})
+	}
+}
+
+// TestFeeRateComparisons tests the comparison methods of the fee rate types.
+func TestFeeRateComparisons(t *testing.T) {
+	t.Parallel()
+
+	// Create a set of fee rates to compare.
+	r1 := SatPerVByte{big.NewRat(1, 1)}
+	r2 := SatPerVByte{big.NewRat(2, 1)}
+	r3 := SatPerVByte{big.NewRat(1, 1)}
+
+	// Test Equal.
+	require.True(t, r1.Equal(r3))
+	require.False(t, r1.Equal(r2))
+
+	// Test GreaterThan.
+	require.True(t, r2.GreaterThan(r1))
+	require.False(t, r1.GreaterThan(r2))
+	require.False(t, r1.GreaterThan(r3))
+
+	// Test LessThan.
+	require.True(t, r1.LessThan(r2))
+	require.False(t, r2.LessThan(r1))
+	require.False(t, r1.LessThan(r3))
+
+	// Test GreaterThanOrEqual.
+	require.True(t, r2.GreaterThanOrEqual(r1))
+	require.True(t, r1.GreaterThanOrEqual(r3))
+	require.False(t, r1.GreaterThanOrEqual(r2))
+
+	// Test LessThanOrEqual.
+	require.True(t, r1.LessThanOrEqual(r2))
+	require.True(t, r1.LessThanOrEqual(r3))
+	require.False(t, r2.LessThanOrEqual(r1))
+}
+
+// TestFeeForWeightRoundUp checks that the FeeForWeightRoundUp method correctly
+// rounds up the fee for a given weight.
+func TestFeeForWeightRoundUp(t *testing.T) {
+	t.Parallel()
+
+	feeRate := SatPerVByte{big.NewRat(1, 1)}.FeePerKWeight()
+	txWeight := NewWeightUnit(674) // 674 weight units is 168.5 vb.
+
+	require.EqualValues(t, 168, feeRate.FeeForWeight(txWeight))
+	require.EqualValues(t, 169, feeRate.FeeForWeightRoundUp(txWeight))
+}
+
+// TestNewFeeRateConstructors checks that the New* fee rate constructors work
+// as expected.
+func TestNewFeeRateConstructors(t *testing.T) {
+	t.Parallel()
+
+	// Test NewSatPerKWeight.
+	fee := btcutil.Amount(1000)
+	wu := NewWeightUnit(1000)
+	expectedRate := SatPerKWeight{big.NewRat(1000, 1)}
+	require.Zero(
+		t, expectedRate.Cmp(NewSatPerKWeight(fee, wu).Rat),
+	)
+
+	// Test NewSatPerVByte.
+	vb := NewVByte(250)
+	expectedRateVB := SatPerVByte{big.NewRat(4, 1)}
+	require.Zero(
+		t, expectedRateVB.Cmp(NewSatPerVByte(fee, vb).Rat),
+	)
+
+	// Test NewSatPerKVByte.
+	kvb := NewVByte(1)
+	expectedRateKVB := SatPerKVByte{big.NewRat(1000000, 1)}
+	require.Zero(
+		t, expectedRateKVB.Cmp(NewSatPerKVByte(fee, kvb).Rat),
+	)
+}
+
+// TestStringer tests the stringer methods of the fee rate types.
+func TestStringer(t *testing.T) {
+	t.Parallel()
+
+	// Create a set of fee rates to test.
+	r1 := SatPerVByte{big.NewRat(1, 1)}
+	r2 := SatPerKVByte{big.NewRat(1000, 1)}
+	r3 := SatPerKWeight{big.NewRat(250, 1)}
+
+	// Test String.
+	require.Equal(t, "1.00 sat/vb", r1.String())
+	require.Equal(t, "1000.00 sat/kvb", r2.String())
+	require.Equal(t, "250.00 sat/kw", r3.String())
+}
+
+// TestFeeRateComparisonsKVB tests the comparison methods of the SatPerKVByte
+// type.
+func TestFeeRateComparisonsKVB(t *testing.T) {
+	t.Parallel()
+
+	// Create a set of fee rates to compare.
+	r1 := SatPerKVByte{big.NewRat(1, 1)}
+	r2 := SatPerKVByte{big.NewRat(2, 1)}
+	r3 := SatPerKVByte{big.NewRat(1, 1)}
+
+	// Test Equal.
+	require.True(t, r1.Equal(r3))
+	require.False(t, r1.Equal(r2))
+
+	// Test GreaterThan.
+	require.True(t, r2.GreaterThan(r1))
+	require.False(t, r1.GreaterThan(r2))
+	require.False(t, r1.GreaterThan(r3))
+
+	// Test LessThan.
+	require.True(t, r1.LessThan(r2))
+	require.False(t, r2.LessThan(r1))
+	require.False(t, r1.LessThan(r3))
+
+	// Test GreaterThanOrEqual.
+	require.True(t, r2.GreaterThanOrEqual(r1))
+	require.True(t, r1.GreaterThanOrEqual(r3))
+	require.False(t, r1.GreaterThanOrEqual(r2))
+
+	// Test LessThanOrEqual.
+	require.True(t, r1.LessThanOrEqual(r2))
+	require.True(t, r1.LessThanOrEqual(r3))
+	require.False(t, r2.LessThanOrEqual(r1))
+}
+
+// TestFeeRateComparisonsKW tests the comparison methods of the SatPerKWeight
+// type.
+func TestFeeRateComparisonsKW(t *testing.T) {
+	t.Parallel()
+
+	// Create a set of fee rates to compare.
+	r1 := SatPerKWeight{big.NewRat(1, 1)}
+	r2 := SatPerKWeight{big.NewRat(2, 1)}
+	r3 := SatPerKWeight{big.NewRat(1, 1)}
+
+	// Test Equal.
+	require.True(t, r1.Equal(r3))
+	require.False(t, r1.Equal(r2))
+
+	// Test GreaterThan.
+	require.True(t, r2.GreaterThan(r1))
+	require.False(t, r1.GreaterThan(r2))
+	require.False(t, r1.GreaterThan(r3))
+
+	// Test LessThan.
+	require.True(t, r1.LessThan(r2))
+	require.False(t, r2.LessThan(r1))
+	require.False(t, r1.LessThan(r3))
+
+	// Test GreaterThanOrEqual.
+	require.True(t, r2.GreaterThanOrEqual(r1))
+	require.True(t, r1.GreaterThanOrEqual(r3))
+	require.False(t, r1.GreaterThanOrEqual(r2))
+
+	// Test LessThanOrEqual.
+	require.True(t, r1.LessThanOrEqual(r2))
+	require.True(t, r1.LessThanOrEqual(r3))
+	require.False(t, r2.LessThanOrEqual(r1))
+}
+
+// TestFeeForSize tests the FeeForVSize and FeeForVByte methods.
+func TestFeeForSize(t *testing.T) {
+	t.Parallel()
+
+	// Create a set of fee rates to test.
+	r1 := SatPerKVByte{big.NewRat(1000, 1)}
+	r2 := SatPerKWeight{big.NewRat(250, 1)}
+
+	// Test FeeForVSize.
+	require.Equal(t, btcutil.Amount(250), r1.FeeForVSize(NewVByte(250)))
+
+	// Test FeeForVByte.
+	require.Equal(t, btcutil.Amount(250), r2.FeeForVByte(NewVByte(250)))
+}
+
+// TestNewFeeRateConstructorsZero tests the New* fee rate constructors with
+// zero values.
+func TestNewFeeRateConstructorsZero(t *testing.T) {
+	t.Parallel()
+
+	// Test NewSatPerKWeight with zero weight.
+	fee := btcutil.Amount(1000)
+	wu := NewWeightUnit(0)
+	expectedRate := SatPerKWeight{big.NewRat(0, 1)}
+	require.Zero(
+		t, expectedRate.Cmp(NewSatPerKWeight(fee, wu).Rat),
+	)
+
+	// Test NewSatPerVByte with zero vbytes.
+	vb := NewVByte(0)
+	expectedRateVB := SatPerVByte{big.NewRat(0, 1)}
+	require.Zero(
+		t, expectedRateVB.Cmp(NewSatPerVByte(fee, vb).Rat),
+	)
+
+	// Test NewSatPerKVByte with zero kvbytes.
+	kvb := NewVByte(0)
+	expectedRateKVB := SatPerKVByte{big.NewRat(0, 1)}
+	require.Zero(
+		t, expectedRateKVB.Cmp(NewSatPerKVByte(fee, kvb).Rat),
+	)
+}
+
+// TestSafeUint64ToInt64Overflow tests the overflow condition in
+// safeUint64ToInt64 through the New* constructors.
+func TestSafeUint64ToInt64Overflow(t *testing.T) {
+	t.Parallel()
+
+	fee := btcutil.Amount(1)
+	overflowVByte := NewVByte(math.MaxInt64 + 1)
+
+	// Test NewSatPerVByte with an overflowing vbyte value.
+	// The denominator should be capped at math.MaxInt64.
+	rateVB := NewSatPerVByte(fee, overflowVByte)
+	expectedDenom := big.NewInt(math.MaxInt64)
+	require.Zero(t, expectedDenom.Cmp(rateVB.Denom()))
+
+	// Test NewSatPerKVByte with an overflowing kvb value.
+	// The denominator should be capped at math.MaxInt64.
+	rateKVB := NewSatPerKVByte(fee, overflowVByte)
+	require.Zero(t, expectedDenom.Cmp(rateKVB.Denom()))
+
+	// Test NewSatPerKWeight with an overflowing weight unit value.
+	overflowWU := NewWeightUnit(math.MaxInt64 + 1)
+	rateKW := NewSatPerKWeight(fee, overflowWU)
+	require.Zero(t, expectedDenom.Cmp(rateKW.Denom()))
+}
diff --git a/pkg/btcunit/txsize_test.go b/pkg/btcunit/txsize_test.go
new file mode 100644
index 0000000..d86936f
--- /dev/null
+++ b/pkg/btcunit/txsize_test.go
@@ -0,0 +1,35 @@
+package btcunit
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+// TestTxSizeConversion checks that the conversion between weight units and
+// virtual bytes is correct.
+func TestTxSizeConversion(t *testing.T) {
+	t.Parallel()
+
+	// Create a test weight of 1000 wu.
+	wu := NewWeightUnit(1000)
+
+	// 1000 wu should be equal to 250 vb.
+	require.Equal(t, NewVByte(250), wu.ToVB())
+
+	// 250 vb should be equal to 1000 wu.
+	require.Equal(t, wu, NewVByte(250).ToWU())
+}
+
+// TestTxSizeStringer tests the stringer methods of the tx size types.
+func TestTxSizeStringer(t *testing.T) {
+	t.Parallel()
+
+	// Create a test weight of 1000 wu.
+	wu := NewWeightUnit(1000)
+	vb := NewVByte(250)
+
+	// Test String.
+	require.Equal(t, "1000 wu", wu.String())
+	require.Equal(t, "250 vb", vb.String())
+}
diff --git a/wallet/tx_reader_test.go b/wallet/tx_reader_test.go
new file mode 100644
index 0000000..bef5c3d
--- /dev/null
+++ b/wallet/tx_reader_test.go
@@ -0,0 +1,312 @@
+// Copyright (c) 2025 The btcsuite developers
+// Use of this source code is governed by an ISC
+// license that can be found in the LICENSE file.
+
+package wallet
+
+import (
+	"math/big"
+	"testing"
+	"time"
+
+	"github.com/btcsuite/btcd/blockchain"
+	"github.com/btcsuite/btcd/btcutil"
+	"github.com/btcsuite/btcd/chaincfg"
+	"github.com/btcsuite/btcd/txscript"
+	"github.com/btcsuite/btcwallet/pkg/btcunit"
+	"github.com/btcsuite/btcwallet/waddrmgr"
+	"github.com/btcsuite/btcwallet/wtxmgr"
+	"github.com/stretchr/testify/mock"
+	"github.com/stretchr/testify/require"
+)
+
+// TestBuildTxDetail tests the buildTxDetail function.
+func TestBuildTxDetail(t *testing.T) {
+	t.Parallel()
+
+	// Create the various test cases.
+	minedDetails, minedTxDetail := createMinedTxDetail(t)
+	unminedDetails, unminedTxDetail := createUnminedTxDetail(t)
+	unminedNoFeeDetails, unminedNoFeeTxDetail := createUnminedTxDetail(t)
+	unminedNoFeeDetails.Debits = nil
+	unminedNoFeeTxDetail.Fee = 0
+	unminedNoFeeTxDetail.FeeRate = btcunit.SatPerVByte{
+		Rat: big.NewRat(0, 1),
+	}
+	unminedNoFeeTxDetail.Value = unminedNoFeeDetails.Credits[0].Amount +
+		unminedNoFeeDetails.Credits[1].Amount
+	unminedNoFeeTxDetail.PrevOuts[0].IsOurs = false
+
+	testCases := []struct {
+		name             string
+		details          *wtxmgr.TxDetails
+		expectedTxDetail *TxDetail
+	}{
+		{
+			name:             "mined tx",
+			details:          minedDetails,
+			expectedTxDetail: minedTxDetail,
+		},
+		{
+			name:             "unmined tx",
+			details:          unminedDetails,
+			expectedTxDetail: unminedTxDetail,
+		},
+		{
+			name:             "unmined tx no fee",
+			details:          unminedNoFeeDetails,
+			expectedTxDetail: unminedNoFeeTxDetail,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+			// Arrange: Create a test wallet with mocks.
+			w, _ := testWalletWithMocks(t)
+			currentHeight := int32(100)
+
+			// Act: Build the TxDetail.
+			result := w.buildTxDetail(tc.details, currentHeight)
+
+			// Assert: Check that the correct details are returned.
+			require.Equal(t, tc.expectedTxDetail, result)
+		})
+	}
+}
+
+// TestGetTxSuccess tests the GetTx method of the wallet for success scenarios.
+func TestGetTxSuccess(t *testing.T) {
+	t.Parallel()
+
+	minedDetails, minedTxDetail := createMinedTxDetail(t)
+	unminedDetails, unminedTxDetail := createUnminedTxDetail(t)
+
+	testCases := []struct {
+		name             string
+		mockDetails      *wtxmgr.TxDetails
+		expectedTxDetail *TxDetail
+	}{
+		{
+			name:             "mined tx",
+			mockDetails:      minedDetails,
+			expectedTxDetail: minedTxDetail,
+		},
+		{
+			name:             "unmined tx",
+			mockDetails:      unminedDetails,
+			expectedTxDetail: unminedTxDetail,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+			// Arrange: Create a test wallet with mocks.
+			w, mocks := testWalletWithMocks(t)
+			mocks.addrStore.On("SyncedTo").Return(
+				waddrmgr.BlockStamp{
+					Height: 100,
+				},
+			)
+			mocks.txStore.On("TxDetails", mock.Anything, TstTxHash).
+				Return(tc.mockDetails, nil).Once()
+
+			// Act: Get the transaction.
+			details, err := w.GetTx(t.Context(), *TstTxHash)
+
+			// Assert: Check that the correct details are returned.
+			require.NoError(t, err)
+			require.Equal(t, tc.expectedTxDetail, details)
+		})
+	}
+}
+
+// TestGetTxNotFound tests that GetTx returns the correct error when a
+// transaction is not found.
+func TestGetTxNotFound(t *testing.T) {
+	t.Parallel()
+
+	// Arrange: Create a test wallet with mocks and mock the TxDetails call
+	// to return nil, simulating a non-existing tx.
+	w, mocks := testWalletWithMocks(t)
+	mocks.txStore.On("TxDetails", mock.Anything, TstTxHash).
+		Return(nil, nil).Once()
+
+	// Act: Attempt to get the transaction.
+	_, err := w.GetTx(t.Context(), *TstTxHash)
+
+	// Assert that the correct error is returned.
+	require.ErrorIs(t, err, ErrTxNotFound)
+}
+
+// TestListTxnsSuccess tests the ListTxns method of the wallet.
+func TestListTxnsSuccess(t *testing.T) {
+	t.Parallel()
+
+	// Arrange: Create a test wallet with mocks and a mock tx record.
+	w, mocks := testWalletWithMocks(t)
+	_, expectedTxDetail := createMinedTxDetail(t)
+
+	mocks.addrStore.On("SyncedTo").Return(waddrmgr.BlockStamp{
+		Height: 100,
+	})
+
+	// Set up the mock for the tx store. We use .Run to execute the
+	// callback function that's passed in as an argument to the mock.
+	mocks.txStore.On("RangeTransactions",
+		mock.Anything, mock.Anything, mock.Anything, mock.Anything,
+	).Run(func(args mock.Arguments) {
+		// Get the callback function from the arguments.
+		f, ok := args.Get(3).(func([]wtxmgr.TxDetails) (bool, error))
+		require.True(t, ok)
+
+		// Create the mock details to pass to the callback.
+		minedDetails, _ := createMinedTxDetail(t)
+		details := []wtxmgr.TxDetails{*minedDetails}
+
+		// Call the callback.
+		_, err := f(details)
+		require.NoError(t, err)
+	}).Return(nil).Once()
+
+	// Act: List txns.
+	details, err := w.ListTxns(t.Context(), 0, 1000)
+
+	// Assert: Check that the correct details are returned.
+	require.NoError(t, err)
+	require.Len(t, details, 1)
+	require.Equal(t, expectedTxDetail, details[0])
+}
+
+// createUnminedTxDetail creates a test transaction that sends funds from the
+// wallet to two of its own addresses. The transaction is unmined and has no
+// confirmations.
+//
+// The transaction details are as follows:
+//   - The transaction has one input, which is owned by the wallet.
+//   - The transaction has two outputs, both of which are owned by the wallet.
+//   - The total value of the outputs (totalCredits) is the sum of the two
+//     output amounts.
+//   - The total value of the inputs (debitAmt) is the sum of the credits plus
+//     a fee.
+//   - The net value of the transaction from the wallet's perspective (Value) is
+//     totalCredits - debitAmt, which is equal to -fee.
+func createUnminedTxDetail(t *testing.T) (*wtxmgr.TxDetails, *TxDetail) {
+	t.Helper()
+
+	// Create a deterministic timestamp for the test tx record.
+	txTime := time.Unix(1616161616, 0)
+	rec, err := wtxmgr.NewTxRecord(TstSerializedTx, txTime)
+	require.NoError(t, err)
+
+	// Deserialize the test transaction to avoid using a global variable.
+	tx, err := btcutil.NewTxFromBytes(TstSerializedTx)
+	require.NoError(t, err)
+
+	msgTx := tx.MsgTx()
+
+	// The credits are the sum of all outputs of the test tx.
+	var totalCredits btcutil.Amount
+	for _, txOut := range msgTx.TxOut {
+		totalCredits += btcutil.Amount(txOut.Value)
+	}
+
+	// The debit amount is the total credit amount plus a fee.
+	fee := btcutil.Amount(1000)
+	debitAmt := totalCredits + fee
+	testLabel := "test"
+
+	out0Amt := btcutil.Amount(msgTx.TxOut[0].Value)
+	out1Amt := btcutil.Amount(msgTx.TxOut[1].Value)
+
+	// Create a fully populated TxDetail for the unmined case.
+	unminedDetails := &wtxmgr.TxDetails{
+		TxRecord: *rec,
+		Block: wtxmgr.BlockMeta{
+			Block: wtxmgr.Block{
+				Height: -1,
+			},
+		},
+		Credits: []wtxmgr.CreditRecord{
+			{
+				Index:  0,
+				Amount: out0Amt,
+			},
+			{
+				Index:  1,
+				Amount: out1Amt,
+			},
+		},
+		Debits: []wtxmgr.DebitRecord{
+			{
+				Index:  0,
+				Amount: debitAmt,
+			},
+		},
+		Label: testLabel,
+	}
+
+	// Manually build the expected outputs for the test tx.
+	expectedOutputs := make([]Output, len(msgTx.TxOut))
+	for i, txOut := range msgTx.TxOut {
+		_, addrs, _, err := txscript.ExtractPkScriptAddrs(
+			txOut.PkScript, &chaincfg.RegressionNetParams,
+		)
+		require.NoError(t, err)
+
+		expectedOutputs[i] = Output{
+			Type:      2,
+			Addresses: addrs,
+			PkScript:  txOut.PkScript,
+			Index:     i,
+			Amount:    btcutil.Amount(txOut.Value),
+			IsOurs:    true,
+		}
+	}
+
+	// Manually build the expected previous outputs for the test tx.
+	expectedPrevOuts := []PrevOut{
+		{
+			OutPoint: msgTx.TxIn[0].PreviousOutPoint,
+			IsOurs:   true,
+		},
+	}
+
+	// Define the expected TxDetail for the unmined case.
+	weight := btcunit.NewWeightUnit(uint64(blockchain.GetTransactionWeight(
+		btcutil.NewTx(&rec.MsgTx),
+	)))
+	unminedTxDetail := &TxDetail{
+		Hash:          *TstTxHash,
+		RawTx:         TstSerializedTx,
+		Label:         testLabel,
+		Value:         totalCredits - debitAmt,
+		Fee:           fee,
+		FeeRate:       btcunit.NewSatPerVByte(fee, weight.ToVB()),
+		Confirmations: 0,
+		Weight:        weight,
+		ReceivedTime:  txTime,
+		Outputs:       expectedOutputs,
+		PrevOuts:      expectedPrevOuts,
+	}
+
+	return unminedDetails, unminedTxDetail
+}
+
+// createMinedTxDetail builds on createUnminedTxDetail to create a mined
+// transaction. The transaction has one confirmation.
+func createMinedTxDetail(t *testing.T) (*wtxmgr.TxDetails, *TxDetail) {
+	t.Helper()
+
+	minedDetails, minedTxDetail := createUnminedTxDetail(t)
+	minedDetails.Block.Height = 100
+	minedDetails.Block.Time = time.Unix(1616161617, 0)
+	minedTxDetail.Confirmations = 1
+	minedTxDetail.Block = &BlockDetails{
+		Height:    100,
+		Timestamp: minedDetails.Block.Time.Unix(),
+	}
+
+	return minedDetails, minedTxDetail
+}
diff --git a/wallet/tx_writer_test.go b/wallet/tx_writer_test.go
new file mode 100644
index 0000000..0cdc144
--- /dev/null
+++ b/wallet/tx_writer_test.go
@@ -0,0 +1,59 @@
+// Copyright (c) 2025 The btcsuite developers
+// Use of this source code is governed by an ISC
+// license that can be found in the LICENSE file.
+
+package wallet
+
+import (
+	"testing"
+
+	"github.com/btcsuite/btcwallet/wtxmgr"
+	"github.com/stretchr/testify/mock"
+	"github.com/stretchr/testify/require"
+)
+
+// TestLabelTxSuccess tests that we can successfully label a transaction.
+func TestLabelTxSuccess(t *testing.T) {
+	t.Parallel()
+
+	w, mocks := testWalletWithMocks(t)
+
+	// Arrange: Mock the TxDetails call to simulate a known transaction.
+	// We return a non-nil TxDetails to pass the check.
+	mocks.txStore.On("TxDetails", mock.Anything, TstTxHash).
+		Return(&wtxmgr.TxDetails{}, nil).Once()
+
+	// Arrange: Mock the PutTxLabel call. We expect it to be called with
+	// the new label.
+	newLabel := "new label"
+	mocks.txStore.On("PutTxLabel", mock.Anything, *TstTxHash, newLabel).
+		Return(nil).Once()
+
+	// Act: Call the LabelTx function.
+	err := w.LabelTx(t.Context(), *TstTxHash, newLabel)
+
+	// Assert: Check that there was no error and that the mocks were called
+	// as expected.
+	require.NoError(t, err)
+	mocks.txStore.AssertExpectations(t)
+}
+
+// TestLabelTxNotFound tests that we get an error when we try to label a tx
+// that is not known to the wallet.
+func TestLabelTxNotFound(t *testing.T) {
+	t.Parallel()
+
+	w, mocks := testWalletWithMocks(t)
+
+	// Arrange: Mock the TxDetails call to return nil, simulating a tx
+	// that is not known to the wallet.
+	mocks.txStore.On("TxDetails", mock.Anything, TstTxHash).
+		Return(nil, nil).Once()
+
+	// Act: Attempt to label a tx that is not known to the wallet.
+	err := w.LabelTx(t.Context(), *TstTxHash, "some label")
+
+	// Assert: Check that the correct error is returned.
+	require.ErrorIs(t, err, ErrTxNotFound)
+	mocks.txStore.AssertExpectations(t)
+}
diff --git a/wtxmgr/query_test.go b/wtxmgr/query_test.go
index 367d57d..fc888cc 100644
--- a/wtxmgr/query_test.go
+++ b/wtxmgr/query_test.go
@@ -277,11 +277,12 @@ func TestStoreQueries(t *testing.T) {
 	newState.blocks = [][]TxDetails{
 		{
 			{
-				TxRecord: *stripSerializedTx(recA),
+				TxRecord: *recA,
 				Block:    BlockMeta{Block: Block{Height: -1}},
 			},
 		},
 	}
+
 	newState.txDetails[recA.Hash] = []TxDetails{
 		newState.blocks[0][0],
 	}
@@ -324,7 +325,7 @@ func TestStoreQueries(t *testing.T) {
 	newState = lastState.deepCopy()
 	newState.blocks[0][0].Credits[0].Spent = true
 	newState.blocks[0] = append(newState.blocks[0], TxDetails{
-		TxRecord: *stripSerializedTx(recB),
+		TxRecord: *recB,
 		Block:    BlockMeta{Block: Block{Height: -1}},
 		Debits: []DebitRecord{
 			{