diff --git a/btcwbackend/boarding_backend.go b/btcwbackend/boarding_backend.go new file mode 100644 index 000000000..b2ac9ed43 --- /dev/null +++ b/btcwbackend/boarding_backend.go @@ -0,0 +1,211 @@ +package btcwbackend + +import ( + "context" + "fmt" + "log/slog" + "math" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/darepo-client/wallet" + "github.com/lightninglabs/darepo-client/walletcore" + "github.com/lightninglabs/neutrino" + "github.com/lightningnetwork/lnd/blockcache" + "github.com/lightningnetwork/lnd/lnwallet/btcwallet" +) + +// BoardingBackendAdapter implements wallet.BoardingBackend by +// embedding walletcore.BoardingBackendBase for shared key derivation +// and script import, with neutrino providing block and transaction +// data. Unlike the lwwallet adapter which bypasses btcwallet for +// UTXO queries, this adapter uses btcwallet's native ListUnspent +// since neutrino's chain sync naturally detects matching outputs via +// compact block filters after address import. +type BoardingBackendAdapter struct { + // BoardingBackendBase provides shared DeriveNextKey, + // ImportTaprootScript, and address tracking. + walletcore.BoardingBackendBase + + neutrinoCS *neutrino.ChainService + blockCache *blockcache.BlockCache + + chainParams *chaincfg.Params +} + +// NewBoardingBackendAdapter creates a new boarding backend adapter +// wrapping the given btcwallet instance with neutrino for chain data. +func NewBoardingBackendAdapter(btcw *btcwallet.BtcWallet, + neutrinoCS *neutrino.ChainService, + blockCache *blockcache.BlockCache, + chainParams *chaincfg.Params, coinType uint32, + logger btclog.Logger) *BoardingBackendAdapter { + + return &BoardingBackendAdapter{ + BoardingBackendBase: walletcore.NewBoardingBackendBase( + btcw, coinType, logger, + ), + neutrinoCS: neutrinoCS, + blockCache: blockCache, + chainParams: chainParams, + } +} + +// ListUnspent returns all UTXOs known to btcwallet with confirmation +// counts between minConfs and maxConfs. Unlike the lwwallet adapter +// which filters by imported boarding addresses, this returns all +// watched UTXOs directly from btcwallet. This avoids UTXO loss on +// restart since btcwallet persists its watch set independently of the +// in-memory importedAddrs map. +func (b *BoardingBackendAdapter) ListUnspent(ctx context.Context, + minConfs, maxConfs int32) ([]*wallet.Utxo, error) { + + // Treat maxConfs of 0 as "no upper bound" so callers that + // omit the parameter don't accidentally filter everything. + if maxConfs == 0 { + maxConfs = math.MaxInt32 + } + + // Query btcwallet for all unspent outputs. btcwallet already + // scopes results to watched scripts (including those imported + // via ImportTaprootScript), so no additional address filtering + // is needed. + results, err := b.BtcWallet.InternalWallet().ListUnspent( + minConfs, maxConfs, "", + ) + if err != nil { + return nil, fmt.Errorf("list unspent: %w", err) + } + + var utxos []*wallet.Utxo + + for _, r := range results { + addr, err := btcutil.DecodeAddress( + r.Address, b.chainParams, + ) + if err != nil { + b.Log.WarnS(ctx, + "Failed to decode UTXO address", err, + slog.String("address", r.Address)) + + continue + } + + pkScript, err := txscript.PayToAddrScript(addr) + if err != nil { + b.Log.WarnS(ctx, + "Failed to create pkScript for address", + err, + slog.String("address", r.Address)) + + continue + } + + txid, err := chainhash.NewHashFromStr(r.TxID) + if err != nil { + b.Log.WarnS(ctx, + "Failed to parse UTXO txid", err, + slog.String("txid", r.TxID)) + + continue + } + + // Convert BTC amount to satoshis. + amount, err := btcutil.NewAmount(r.Amount) + if err != nil { + b.Log.WarnS(ctx, + "Failed to parse UTXO amount", err) + + continue + } + + utxos = append(utxos, &wallet.Utxo{ + Outpoint: wire.OutPoint{ + Hash: *txid, + Index: r.Vout, + }, + PkScript: pkScript, + Amount: amount, + Confirmations: int32(r.Confirmations), + }) + } + + b.Log.DebugS(ctx, "ListUnspent called", + slog.Int("min_confs", int(minConfs)), + slog.Int("max_confs", int(maxConfs)), + slog.Int("utxo_count", len(utxos))) + + return utxos, nil +} + +// GetTransaction returns the full transaction and its confirmation +// block hash for the given txid. It fetches from btcwallet's +// transaction store, which is populated by neutrino's chain sync. +func (b *BoardingBackendAdapter) GetTransaction(ctx context.Context, + txid chainhash.Hash) (*wire.MsgTx, *chainhash.Hash, error) { + + // Fetch the raw transaction from btcwallet's store. + tx, err := b.BtcWallet.FetchTx(txid) + if err != nil || tx == nil { + b.Log.DebugS(ctx, + "Transaction not in wallet store", + slog.String("txid", txid.String()), + ) + + return nil, nil, fmt.Errorf( + "transaction %s not found in wallet", txid, + ) + } + + // Look up the transaction details from btcwallet to get the + // block hash. + txDetails, err := b.BtcWallet.InternalWallet().GetTransaction( + txid, + ) + if err != nil { + // We have the tx but no details — return without block + // hash. + return tx, nil, nil + } + + return tx, txDetails.BlockHash, nil +} + +// GetBlock returns the full block for the given block hash via +// neutrino's P2P network. The block cache is used to avoid redundant +// fetches. +func (b *BoardingBackendAdapter) GetBlock(ctx context.Context, + blockHash chainhash.Hash) (*wire.MsgBlock, error) { + + b.Log.DebugS(ctx, "Fetching block via neutrino", + slog.String("block_hash", blockHash.String())) + + block, err := b.blockCache.GetBlock( + &blockHash, + func(hash *chainhash.Hash) (*wire.MsgBlock, error) { + blk, err := b.neutrinoCS.GetBlock(*hash) + if err != nil { + return nil, err + } + + return blk.MsgBlock(), nil + }, + ) + if err != nil { + return nil, fmt.Errorf("get block: %w", err) + } + + b.Log.DebugS(ctx, "Fetched block successfully", + slog.String("block_hash", blockHash.String()), + slog.Int("num_txs", len(block.Transactions))) + + return block, nil +} + +// Compile-time check that BoardingBackendAdapter implements +// wallet.BoardingBackend. +var _ wallet.BoardingBackend = (*BoardingBackendAdapter)(nil) diff --git a/btcwbackend/chain_backend.go b/btcwbackend/chain_backend.go new file mode 100644 index 000000000..253eb8351 --- /dev/null +++ b/btcwbackend/chain_backend.go @@ -0,0 +1,492 @@ +package btcwbackend + +import ( + "context" + "errors" + "fmt" + "log/slog" + "sync" + "time" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/darepo-client/build" + "github.com/lightninglabs/darepo-client/chainsource" + "github.com/lightninglabs/neutrino" + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/lightningnetwork/lnd/chainntnfs/neutrinonotify" + "github.com/lightningnetwork/lnd/channeldb" + fn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/kvdb" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" +) + +// ChainBackend implements chainsource.ChainBackend using neutrino's +// native chain notification system and a WebAPI-based fee estimator. +// This provides event-driven confirmation, spend, and block +// notifications without polling — neutrino's compact block filters +// enable efficient client-side filtering. +type ChainBackend struct { + // neutrinoCS is the running neutrino chain service used for + // broadcasting transactions and querying the best block. + neutrinoCS *neutrino.ChainService + + // notifier provides chain notification services backed by + // neutrino's compact block filter scanning. + notifier *neutrinonotify.NeutrinoNotifier + + // feeEstimator provides fee estimation from a web API since + // neutrino has no mempool visibility. + feeEstimator *chainfee.WebAPIEstimator + + // hintDB is the kvdb backend for the height hint cache. We keep + // a reference so we can close it on Stop(). + hintDB kvdb.Backend + + // Log is an optional logger for this backend. + Log fn.Option[btclog.Logger] + + // startOnce ensures Start() logic runs exactly once, even if + // called from both Wallet.Start() and daemon wiring. + startOnce sync.Once + + // startErr caches the error from the first Start() call so + // subsequent calls return the same result. + startErr error + + // stopOnce ensures Stop() logic runs exactly once, preventing + // double-close panics on the hint DB and notifier. + stopOnce sync.Once +} + +// NewChainBackend creates a new neutrino-backed chain backend. The +// neutrino service must already be started before calling this. +func NewChainBackend(svc *NeutrinoService, feeURL string, + feeMinTimeout, feeMaxTimeout time.Duration, + hintDBPath string, + logger btclog.Logger) (*ChainBackend, error) { + + // Open the height hint cache database, creating it if it does + // not yet exist (first run). + hintDB, err := kvdb.Open( + kvdb.BoltBackendName, hintDBPath, true, + defaultDBTimeout, false, + ) + if err != nil { + hintDB, err = kvdb.Create( + kvdb.BoltBackendName, hintDBPath, true, + defaultDBTimeout, false, + ) + if err != nil { + return nil, fmt.Errorf( + "create hint cache db: %w", err, + ) + } + } + + hintCache, err := channeldb.NewHeightHintCache( + channeldb.CacheConfig{QueryDisable: false}, hintDB, + ) + if err != nil { + _ = hintDB.Close() + + return nil, fmt.Errorf("create height hint cache: %w", err) + } + + // Create the NeutrinoNotifier for event-driven chain + // notifications. + notifier := neutrinonotify.New( + svc.ChainService(), hintCache, hintCache, + svc.BlockCache(), + ) + + // Create the fee estimator. For neutrino, we must use a web API + // since there is no mempool access. + feeSource := chainfee.SparseConfFeeSource{URL: feeURL} + feeEstimator, err := chainfee.NewWebAPIEstimator( + feeSource, false, feeMinTimeout, feeMaxTimeout, + ) + if err != nil { + _ = hintDB.Close() + + return nil, fmt.Errorf("create fee estimator: %w", err) + } + + return &ChainBackend{ + neutrinoCS: svc.ChainService(), + notifier: notifier, + feeEstimator: feeEstimator, + hintDB: hintDB, + Log: fn.Some(logger), + }, nil +} + +// logger returns the configured logger, falling back to the context +// logger. +func (b *ChainBackend) logger(ctx context.Context) btclog.Logger { + return b.Log.UnwrapOr(build.LoggerFromContext(ctx)) +} + +// Start initializes the chain backend by starting the notifier and +// fee estimator. +func (b *ChainBackend) Start() error { + b.startOnce.Do(func() { + b.logger(context.TODO()).InfoS( + context.TODO(), + "Starting neutrino chain backend", + ) + + if err := b.notifier.Start(); err != nil { + b.startErr = fmt.Errorf( + "start notifier: %w", err, + ) + + return + } + + if err := b.feeEstimator.Start(); err != nil { + _ = b.notifier.Stop() + b.startErr = fmt.Errorf( + "start fee estimator: %w", err, + ) + + return + } + + b.logger(context.TODO()).InfoS( + context.TODO(), + "Neutrino chain backend started", + ) + }) + + return b.startErr +} + +// Stop shuts down the chain backend by stopping the notifier, fee +// estimator, and closing the hint cache database. +func (b *ChainBackend) Stop() error { + var stopErr error + + b.stopOnce.Do(func() { + b.logger(context.TODO()).InfoS( + context.TODO(), + "Stopping neutrino chain backend", + ) + + var errs []error + + if err := b.notifier.Stop(); err != nil { + errs = append(errs, fmt.Errorf( + "stop notifier: %w", err, + )) + } + + if err := b.feeEstimator.Stop(); err != nil { + errs = append(errs, fmt.Errorf( + "stop fee estimator: %w", err, + )) + } + + if err := b.hintDB.Close(); err != nil { + errs = append(errs, fmt.Errorf( + "close hint db: %w", err, + )) + } + + b.logger(context.TODO()).InfoS( + context.TODO(), + "Neutrino chain backend stopped", + ) + + stopErr = errors.Join(errs...) + }) + + return stopErr +} + +// EstimateFee returns the estimated fee rate in satoshis per vbyte +// for the given confirmation target. The fee estimator queries a web +// API since neutrino has no mempool visibility. +func (b *ChainBackend) EstimateFee(ctx context.Context, + targetConf uint32) (btcutil.Amount, error) { + + b.logger(ctx).DebugS(ctx, "Estimating fee rate", + slog.Int("target_confs", int(targetConf))) + + feePerKw, err := b.feeEstimator.EstimateFeePerKW(targetConf) + if err != nil { + return 0, fmt.Errorf("estimate fee: %w", err) + } + + // Convert from sat/kw to sat/vbyte. + satPerVByte := feePerKw.FeePerVByte() + + b.logger(ctx).DebugS(ctx, "Fee rate estimated", + slog.Int("target_confs", int(targetConf)), + slog.Int64("sat_per_vbyte", int64(satPerVByte))) + + return btcutil.Amount(satPerVByte), nil +} + +// BestBlock returns the current best block height and hash from +// neutrino's view of the blockchain. +func (b *ChainBackend) BestBlock(ctx context.Context) (int32, + chainhash.Hash, error) { + + b.logger(ctx).DebugS(ctx, "Querying best block from neutrino") + + bs, err := b.neutrinoCS.BestBlock() + if err != nil { + return 0, chainhash.Hash{}, fmt.Errorf( + "neutrino best block: %w", err, + ) + } + + b.logger(ctx).DebugS(ctx, "Best block retrieved", + slog.Int("height", int(bs.Height)), + btclog.Hex("hash", bs.Hash[:])) + + return bs.Height, bs.Hash, nil +} + +// TestMempoolAccept is not supported by the neutrino backend since +// neutrino does not maintain a mempool. +func (b *ChainBackend) TestMempoolAccept(_ context.Context, + _ *wire.MsgTx) (bool, string, error) { + + return false, "", fmt.Errorf( + "test mempool accept not supported by neutrino backend", + ) +} + +// BroadcastTx broadcasts a transaction to the Bitcoin P2P network +// via neutrino's connected peers. +func (b *ChainBackend) BroadcastTx(ctx context.Context, + tx *wire.MsgTx, label string) error { + + txHash := tx.TxHash() + b.logger(ctx).InfoS(ctx, "Broadcasting transaction via neutrino", + slog.String("txid", txHash.String()), + slog.String("label", label)) + + if err := b.neutrinoCS.SendTransaction(tx); err != nil { + return fmt.Errorf("broadcast transaction: %w", err) + } + + b.logger(ctx).InfoS(ctx, "Transaction broadcast successfully", + slog.String("txid", txHash.String())) + + return nil +} + +// RegisterConf registers for confirmation notifications using +// neutrino's chain notifier. The registration returns a +// ConfRegistration with channels for receiving confirmation events. +func (b *ChainBackend) RegisterConf(ctx context.Context, + txid *chainhash.Hash, pkScript []byte, numConfs uint32, + heightHint uint32, + includeBlock bool) (*chainsource.ConfRegistration, error) { + + b.logger(ctx).DebugS( + ctx, "Registering for confirmation notifications", + slog.Int("num_confs", int(numConfs)), + slog.Int("height_hint", int(heightHint)), + slog.Bool("include_block", includeBlock), + ) + + var opts []chainntnfs.NotifierOption + if includeBlock { + opts = append(opts, chainntnfs.WithIncludeBlock()) + } + + event, err := b.notifier.RegisterConfirmationsNtfn( + txid, pkScript, numConfs, heightHint, opts..., + ) + if err != nil { + return nil, fmt.Errorf("register confirmation: %w", err) + } + + // The caller context only scopes registration setup. Keep the + // delivery forwarder alive until the registration itself is + // cancelled. + notifyCtx, cancel := context.WithCancel(context.Background()) + + confChan := make(chan *chainsource.TxConfirmation, 1) + + go func() { + defer close(confChan) + defer cancel() + defer event.Cancel() + + select { + case lndConf, ok := <-event.Confirmed: + if !ok { + return + } + + conf := &chainsource.TxConfirmation{ + BlockHash: lndConf.BlockHash, + BlockHeight: lndConf.BlockHeight, + TxIndex: lndConf.TxIndex, + Tx: lndConf.Tx, + Block: lndConf.Block, + } + + select { + case confChan <- conf: + + case <-notifyCtx.Done(): + return + } + + case <-notifyCtx.Done(): + return + } + }() + + return &chainsource.ConfRegistration{ + Confirmed: confChan, + Cancel: func() { + cancel() + event.Cancel() + }, + }, nil +} + +// RegisterSpend registers for spend notifications using neutrino's +// chain notifier. +func (b *ChainBackend) RegisterSpend(ctx context.Context, + outpoint *wire.OutPoint, pkScript []byte, + heightHint uint32) (*chainsource.SpendRegistration, error) { + + b.logger(ctx).DebugS(ctx, "Registering for spend notifications", + slog.String("outpoint", outpoint.String()), + slog.Int("height_hint", int(heightHint))) + + event, err := b.notifier.RegisterSpendNtfn( + outpoint, pkScript, heightHint, + ) + if err != nil { + return nil, fmt.Errorf("register spend: %w", err) + } + + notifyCtx, cancel := context.WithCancel(context.Background()) + + spendChan := make(chan *chainsource.SpendDetail, 1) + + go func() { + defer close(spendChan) + defer cancel() + defer event.Cancel() + + select { + case lndSpend, ok := <-event.Spend: + if !ok { + return + } + + spend := &chainsource.SpendDetail{ + SpentOutPoint: lndSpend.SpentOutPoint, + SpenderTxHash: lndSpend.SpenderTxHash, + SpendingTx: lndSpend.SpendingTx, + SpenderInputIndex: lndSpend.SpenderInputIndex, + SpendingHeight: lndSpend.SpendingHeight, + } + + select { + case spendChan <- spend: + + case <-notifyCtx.Done(): + return + } + + case <-notifyCtx.Done(): + return + } + }() + + return &chainsource.SpendRegistration{ + Spend: spendChan, + Cancel: func() { + cancel() + event.Cancel() + }, + }, nil +} + +// RegisterBlocks registers for new block notifications using +// neutrino's chain notifier. +func (b *ChainBackend) RegisterBlocks( + ctx context.Context) (*chainsource.BlockRegistration, error) { + + b.logger(ctx).InfoS( + ctx, "Registering for block epoch notifications", + ) + + event, err := b.notifier.RegisterBlockEpochNtfn(nil) + if err != nil { + return nil, fmt.Errorf("register blocks: %w", err) + } + + // Use an independent context so the forwarding goroutine + // outlives the caller's context and can be cancelled via + // the returned Cancel function. + notifyCtx, cancel := context.WithCancel(context.Background()) + + epochChan := make(chan *chainsource.BlockEpoch, 10) + + go func() { + defer close(epochChan) + defer cancel() + defer event.Cancel() + + for { + select { + case lndEpoch, ok := <-event.Epochs: + if !ok { + return + } + + if lndEpoch.Hash == nil { + continue + } + + var timestamp int64 + if lndEpoch.BlockHeader != nil { + ts := lndEpoch.BlockHeader.Timestamp + timestamp = ts.Unix() + } + + epoch := &chainsource.BlockEpoch{ + Hash: *lndEpoch.Hash, + Height: lndEpoch.Height, + Timestamp: timestamp, + } + + select { + case epochChan <- epoch: + + case <-notifyCtx.Done(): + return + } + + case <-notifyCtx.Done(): + return + } + } + }() + + return &chainsource.BlockRegistration{ + Epochs: epochChan, + Cancel: func() { + cancel() + event.Cancel() + }, + }, nil +} + +// Compile-time check that ChainBackend implements +// chainsource.ChainBackend. +var _ chainsource.ChainBackend = (*ChainBackend)(nil) diff --git a/btcwbackend/config.go b/btcwbackend/config.go new file mode 100644 index 000000000..9b6aca58b --- /dev/null +++ b/btcwbackend/config.go @@ -0,0 +1,93 @@ +package btcwbackend + +import ( + "time" + + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/darepo-client/walletcore" + fn "github.com/lightningnetwork/lnd/fn/v2" +) + +// DefaultFeeMinUpdateTimeout is the default minimum interval between +// fee estimation API queries. +const DefaultFeeMinUpdateTimeout = 5 * time.Minute + +// DefaultFeeMaxUpdateTimeout is the default maximum interval between +// fee estimation API queries. +const DefaultFeeMaxUpdateTimeout = 20 * time.Minute + +// Config holds the configuration for the neutrino-backed wallet. +// It embeds walletcore.Config for shared base fields (Seed, +// ChainParams, RecoveryWindow, DBDir, Log). +type Config struct { + // Config provides shared base configuration. + walletcore.Config + + // NeutrinoDataDir is the directory for neutrino's chain data + // (headers, cfilters). Defaults to DBDir if empty. + NeutrinoDataDir string + + // ConnectPeers is a list of host:port addresses to connect to + // exclusively. When set, neutrino will NOT use DNS seeding and + // will only connect to these peers. + ConnectPeers []string + + // AddPeers is a list of additional persistent peers. Unlike + // ConnectPeers, DNS seeding still runs when AddPeers is set. + AddPeers []string + + // FeeURL is the URL for the fee estimation API endpoint. The + // endpoint must return JSON in the format expected by + // chainfee.SparseConfFeeSource. Required for btcwallet mode. + FeeURL string + + // FeeMinUpdateTimeout is the minimum interval between fee + // estimation API queries. Defaults to DefaultFeeMinUpdateTimeout. + FeeMinUpdateTimeout time.Duration + + // FeeMaxUpdateTimeout is the maximum interval between fee + // estimation API queries. Defaults to DefaultFeeMaxUpdateTimeout. + FeeMaxUpdateTimeout time.Duration + + // PersistFilters controls whether neutrino writes compact block + // filters to disk in addition to the in-memory cache. Useful + // for wallets that perform frequent rescans. + PersistFilters bool +} + +// WithLogger returns a new config with the given logger set. +func (c Config) WithLogger(log btclog.Logger) Config { + c.Log = fn.Some(log) + + return c +} + +// neutrinoDataDir returns the configured neutrino data directory, +// falling back to DBDir if not explicitly set. +func (c Config) neutrinoDataDir() string { + if c.NeutrinoDataDir != "" { + return c.NeutrinoDataDir + } + + return c.DBDir +} + +// feeMinTimeout returns the configured minimum fee update timeout, +// falling back to the default. +func (c Config) feeMinTimeout() time.Duration { + if c.FeeMinUpdateTimeout > 0 { + return c.FeeMinUpdateTimeout + } + + return DefaultFeeMinUpdateTimeout +} + +// feeMaxTimeout returns the configured maximum fee update timeout, +// falling back to the default. +func (c Config) feeMaxTimeout() time.Duration { + if c.FeeMaxUpdateTimeout > 0 { + return c.FeeMaxUpdateTimeout + } + + return DefaultFeeMaxUpdateTimeout +} diff --git a/btcwbackend/doc.go b/btcwbackend/doc.go new file mode 100644 index 000000000..a0a74701d --- /dev/null +++ b/btcwbackend/doc.go @@ -0,0 +1,18 @@ +// Package btcwbackend provides a lightweight in-process Bitcoin wallet backed +// by LND's btcwallet and a neutrino (BIP 157/158) chain backend. It wraps +// lnwallet/btcwallet.BtcWallet with a neutrino-based chain.Interface, +// providing a self-contained SPV wallet that connects directly to the Bitcoin +// P2P network without requiring an external Esplora server or LND node: +// +// - Full on-chain wallet: HD key management via waddrmgr, UTXO +// tracking, address generation, balance queries +// - Ark round participation: Schnorr signing, MuSig2 sessions, +// boarding address management via btcwallet's signer +// - Chain monitoring: block subscriptions, confirmation tracking, +// spend detection via neutrino's native ChainNotifier +// +// The wallet exposes wallet.BoardingBackend (via BoardingBackendAdapter), +// input.Signer + MuSig2 (via BtcWallet), and chainsource.ChainBackend +// (via ChainBackend), making it a drop-in replacement for the LND-backed +// and lwwallet (Esplora-backed) implementations. +package btcwbackend diff --git a/btcwbackend/log.go b/btcwbackend/log.go new file mode 100644 index 000000000..f7e681e22 --- /dev/null +++ b/btcwbackend/log.go @@ -0,0 +1,4 @@ +package btcwbackend + +// Subsystem defines the logging code for this subsystem. +const Subsystem = "BTCW" diff --git a/btcwbackend/neutrino.go b/btcwbackend/neutrino.go new file mode 100644 index 000000000..673267255 --- /dev/null +++ b/btcwbackend/neutrino.go @@ -0,0 +1,177 @@ +package btcwbackend + +import ( + "context" + "fmt" + "log/slog" + "path/filepath" + "time" + + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btclog/v2" + "github.com/btcsuite/btcwallet/chain" + "github.com/btcsuite/btcwallet/walletdb" + _ "github.com/btcsuite/btcwallet/walletdb/bdb" // Register bdb backend. + "github.com/lightninglabs/neutrino" + "github.com/lightningnetwork/lnd/blockcache" +) + +// neutrinoDBName is the filename for the neutrino bbolt database. +const neutrinoDBName = "neutrino.db" + +// defaultBlockCacheSize is the number of blocks to cache in memory. +// This prevents redundant block fetches during wallet sync and chain +// notification processing. +const defaultBlockCacheSize uint64 = 20 + +// defaultDBTimeout is the default timeout for opening the neutrino +// bbolt database. +const defaultDBTimeout = 60 * time.Second + +// NeutrinoService manages the lifecycle of a neutrino ChainService. +// It handles database creation, peer configuration, and exposes the +// chain service for use by btcwallet and the chain backend. +type NeutrinoService struct { + // cs is the running neutrino chain service. + cs *neutrino.ChainService + + // db is the walletdb backing neutrino's header and filter state. + db walletdb.DB + + // blockCache is the shared LRU block cache used by both neutrino + // and the NeutrinoNotifier to avoid duplicate block fetches. + blockCache *blockcache.BlockCache + + // chainParams identifies the Bitcoin network. + chainParams *chaincfg.Params + + // log is the structured logger. + log btclog.Logger +} + +// NewNeutrinoService creates a new neutrino chain service from the +// given configuration. The service is NOT started — call Start() +// after construction. +func NewNeutrinoService(dataDir string, chainParams *chaincfg.Params, + connectPeers, addPeers []string, persistFilters bool, + logger btclog.Logger) (*NeutrinoService, error) { + + dbPath := filepath.Join(dataDir, neutrinoDBName) + + // Try to open an existing DB first (daemon restart), falling + // back to creating a new one (first run). + db, err := walletdb.Open( + "bdb", dbPath, true, defaultDBTimeout, false, + ) + if err != nil { + db, err = walletdb.Create( + "bdb", dbPath, true, defaultDBTimeout, + false, + ) + if err != nil { + return nil, fmt.Errorf( + "create neutrino db: %w", err, + ) + } + } + + blockCache := blockcache.NewBlockCache(defaultBlockCacheSize) + + cfg := neutrino.Config{ + DataDir: dataDir, + Database: db, + ChainParams: *chainParams, + ConnectPeers: connectPeers, + AddPeers: addPeers, + BlockCache: blockCache.Cache, + PersistToDisk: persistFilters, + } + + cs, err := neutrino.NewChainService(cfg) + if err != nil { + _ = db.Close() + + return nil, fmt.Errorf("create neutrino service: %w", err) + } + + return &NeutrinoService{ + cs: cs, + db: db, + blockCache: blockCache, + chainParams: chainParams, + log: logger, + }, nil +} + +// Start begins the neutrino chain service, connecting to peers and +// syncing headers and compact block filters. +func (n *NeutrinoService) Start() error { + n.log.InfoS(context.Background(), "Starting neutrino chain service") + + if err := n.cs.Start(); err != nil { + return fmt.Errorf("start neutrino: %w", err) + } + + n.log.InfoS( + context.Background(), "Neutrino chain service started", + ) + + return nil +} + +// Stop shuts down the neutrino chain service and closes the +// backing database. +func (n *NeutrinoService) Stop() error { + n.log.InfoS(context.Background(), "Stopping neutrino chain service") + + if err := n.cs.Stop(); err != nil { + n.log.WarnS( + context.Background(), + "Error stopping neutrino", err, + ) + } + + if err := n.db.Close(); err != nil { + return fmt.Errorf("close neutrino db: %w", err) + } + + n.log.InfoS( + context.Background(), "Neutrino chain service stopped", + ) + + return nil +} + +// ChainService returns the underlying neutrino.ChainService. The +// service must be started before calling this. +func (n *NeutrinoService) ChainService() *neutrino.ChainService { + return n.cs +} + +// BlockCache returns the shared block cache used by both neutrino +// and the NeutrinoNotifier. +func (n *NeutrinoService) BlockCache() *blockcache.BlockCache { + return n.blockCache +} + +// ChainClient creates a new btcwallet chain.NeutrinoClient that +// implements chain.Interface for use by btcwallet. Each call creates +// a fresh client instance. +func (n *NeutrinoService) ChainClient() *chain.NeutrinoClient { + return chain.NewNeutrinoClient(n.chainParams, n.cs) +} + +// BestBlock returns the current best block height and hash from +// neutrino's perspective. +func (n *NeutrinoService) BestBlock() (int32, error) { + bs, err := n.cs.BestBlock() + if err != nil { + return 0, fmt.Errorf("neutrino best block: %w", err) + } + + n.log.DebugS(context.Background(), "Neutrino best block", + slog.Int("height", int(bs.Height)), + slog.String("hash", bs.Hash.String())) + + return bs.Height, nil +} diff --git a/btcwbackend/wallet.go b/btcwbackend/wallet.go new file mode 100644 index 000000000..a181e5cd8 --- /dev/null +++ b/btcwbackend/wallet.go @@ -0,0 +1,200 @@ +package btcwbackend + +import ( + "context" + "fmt" + "log/slog" + "path/filepath" + "time" + + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/darepo-client/walletcore" + "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lnwallet/btcwallet" +) + +// hintCacheDBName is the filename for the height hint cache database. +const hintCacheDBName = "heighthint.db" + +// Wallet is a lightweight in-process Bitcoin wallet backed by LND's +// btcwallet implementation and a neutrino (BIP 157/158) chain +// backend. It embeds walletcore.Wallet for shared btcwallet +// operations and adds neutrino-specific chain service, chain backend, +// and boarding backend. +// +// The wallet exposes sub-interfaces via accessor methods: +// - BoardingBackend() returns the wallet.BoardingBackend adapter +// - Signer() returns the input.Signer implementation +// - ChainBackend() returns the chainsource.ChainBackend for actors +// - KeyRing() returns the keychain.SecretKeyRing for key operations +type Wallet struct { + // Wallet provides shared btcwallet-backed operations. + walletcore.Wallet + + // neutrinoSvc manages the neutrino chain service lifecycle. + neutrinoSvc *NeutrinoService + + // chainBackend implements chainsource.ChainBackend for the + // actor system (confirmation/spend/block registrations). + chainBackend *ChainBackend + + // boardingBackend wraps btcwallet to provide the + // wallet.BoardingBackend interface for Ark boarding. + boardingBackend *BoardingBackendAdapter +} + +// New creates a new neutrino-backed wallet from the given +// configuration. The caller must provide a DBDir for btcwallet's +// bbolt database and is responsible for managing the directory's +// lifecycle. +func New(cfg Config) (*Wallet, error) { + walletLog := cfg.Log.UnwrapOr(btclog.Disabled) + + neutrinoDataDir := cfg.neutrinoDataDir() + + // Create and start the neutrino chain service. + neutrinoSvc, err := NewNeutrinoService( + neutrinoDataDir, cfg.ChainParams, + cfg.ConnectPeers, cfg.AddPeers, + cfg.PersistFilters, walletLog, + ) + if err != nil { + return nil, fmt.Errorf("create neutrino service: %w", err) + } + + if err := neutrinoSvc.Start(); err != nil { + return nil, fmt.Errorf("start neutrino service: %w", err) + } + + // Create the btcwallet chain client backed by neutrino. + chainClient := neutrinoSvc.ChainClient() + + coinType := walletcore.CoinTypeForNet(cfg.ChainParams) + blockCache := neutrinoSvc.BlockCache() + + btcw, err := btcwallet.New(btcwallet.Config{ + PrivatePass: walletcore.WalletPassphrase, + PublicPass: walletcore.WalletPassphrase, + HdSeed: cfg.Seed[:], + ChainSource: chainClient, + NetParams: cfg.ChainParams, + CoinType: coinType, + RecoveryWindow: cfg.RecoveryWindow, + LoaderOptions: []btcwallet.LoaderOption{ + btcwallet.LoaderWithLocalWalletDB( + cfg.DBDir, false, 60*time.Second, + ), + }, + }, blockCache) + if err != nil { + _ = neutrinoSvc.Stop() + + return nil, fmt.Errorf("create btcwallet: %w", err) + } + + // Create the keyring from btcwallet's internal wallet. + keyRing := keychain.NewBtcWalletKeyRing( + btcw.InternalWallet(), coinType, + ) + + // Create the chain backend with neutrino notifier and fee + // estimation. + hintDBPath := filepath.Join(neutrinoDataDir, hintCacheDBName) + chainBackend, err := NewChainBackend( + neutrinoSvc, cfg.FeeURL, + cfg.feeMinTimeout(), cfg.feeMaxTimeout(), + hintDBPath, walletLog, + ) + if err != nil { + _ = btcw.Stop() + _ = neutrinoSvc.Stop() + + return nil, fmt.Errorf("create chain backend: %w", err) + } + + // Create the boarding backend adapter. + boardingBackend := NewBoardingBackendAdapter( + btcw, neutrinoSvc.ChainService(), + blockCache, cfg.ChainParams, coinType, walletLog, + ) + + walletLog.InfoS(context.Background(), + "Neutrino-backed wallet created", + slog.String("db_dir", cfg.DBDir), + slog.String("neutrino_dir", neutrinoDataDir), + slog.Uint64("coin_type", uint64(coinType))) + + return &Wallet{ + Wallet: walletcore.Wallet{ + Signer: btcw, + BtcWallet: btcw, + KeyRing: keyRing, + ChainParams: cfg.ChainParams, + WalletLog: cfg.Log, + }, + neutrinoSvc: neutrinoSvc, + chainBackend: chainBackend, + boardingBackend: boardingBackend, + }, nil +} + +// Start initializes the wallet by starting btcwallet (which +// internally starts the chain client and syncs the wallet) and the +// chainsource ChainBackend. +func (w *Wallet) Start() error { + ctx := context.Background() + + // btcWallet.Start() unlocks the wallet, creates key scopes, + // starts the chain client, and begins wallet synchronization. + if err := w.BtcWallet.Start(); err != nil { + return fmt.Errorf("start btcwallet: %w", err) + } + + // Start the chain backend. This is idempotent (sync.Once) so + // it is safe even if the daemon's startBtcwallet also calls + // Start(). + if err := w.chainBackend.Start(); err != nil { + return fmt.Errorf("start chain backend: %w", err) + } + + w.Logger(ctx).InfoS(ctx, "Neutrino-backed wallet started") + + return nil +} + +// Stop shuts down the wallet, neutrino service, and chain backend. +func (w *Wallet) Stop() { + ctx := context.Background() + + w.Logger(ctx).InfoS(ctx, "Stopping neutrino-backed wallet") + + // Note: chainBackend is NOT stopped here — the daemon's + // server.go defer owns that lifecycle. The ChainBackend.Stop() + // is idempotent (sync.Once) so it is safe even if called from + // both Wallet and server. + // + // Stop order: btcwallet (depends on neutrino chain client) + // must stop before neutrino service. + _ = w.BtcWallet.Stop() + _ = w.neutrinoSvc.Stop() + + w.Logger(ctx).InfoS(ctx, "Neutrino-backed wallet stopped") +} + +// BoardingBackend returns the wallet.BoardingBackend adapter that +// wraps btcwallet for Ark boarding address management. +func (w *Wallet) BoardingBackend() *BoardingBackendAdapter { + return w.boardingBackend +} + +// ChainBackend returns the chainsource.ChainBackend used by the +// actor system for confirmation, spend, and block registrations. +func (w *Wallet) ChainBackend() *ChainBackend { + return w.chainBackend +} + +// KeyRing returns the wallet's secret key ring for key derivation +// and message signing operations. +func (w *Wallet) KeyRing() keychain.SecretKeyRing { + return w.Wallet.KeyRing +} diff --git a/darepod/config.go b/darepod/config.go index 246b44a38..e4635e03b 100644 --- a/darepod/config.go +++ b/darepod/config.go @@ -61,6 +61,10 @@ const ( // backed by btcwallet and Esplora. WalletTypeLwwallet = "lwwallet" + // WalletTypeBtcwallet selects the in-process wallet backed by + // btcwallet and neutrino (BIP 157/158 compact block filters). + WalletTypeBtcwallet = "btcwallet" + // DefaultEsploraPollInterval is the default interval at which the // lwwallet polls the Esplora API for new blocks and transactions. DefaultEsploraPollInterval = 5 * time.Second @@ -221,6 +225,30 @@ type WalletConfig struct { // an existing encrypted seed file, the daemon unlocks the wallet // automatically without requiring an UnlockWallet RPC call. PasswordFile string `mapstructure:"password_file"` + + // BtcwalletPeers is a list of host:port addresses for neutrino + // to connect to exclusively (no DNS seeding). Only used when + // Type is "btcwallet". + BtcwalletPeers []string `mapstructure:"btcwallet_peers"` + + // BtcwalletAddPeers is a list of additional persistent peers + // for neutrino. DNS seeding still runs. Only used when Type is + // "btcwallet". + BtcwalletAddPeers []string `mapstructure:"btcwallet_addpeers"` + + // BtcwalletDataDir is the directory for neutrino's chain data + // (headers, cfilters). Defaults to the network data directory. + // Only used when Type is "btcwallet". + BtcwalletDataDir string `mapstructure:"btcwallet_datadir"` + + // FeeURL is the URL for the fee estimation API endpoint used by + // the btcwallet/neutrino backend. Required on mainnet since + // neutrino has no mempool visibility. + FeeURL string `mapstructure:"feeurl"` + + // PersistFilters controls whether neutrino writes compact block + // filters to disk in addition to the in-memory cache. + PersistFilters bool `mapstructure:"persist_filters"` } // DefaultConfig returns a Config populated with sensible defaults. @@ -293,10 +321,18 @@ func (c *Config) Validate() error { "lwwallet") } + case WalletTypeBtcwallet: + // Neutrino has no mempool visibility, so fee estimation + // always requires an external API regardless of network. + if c.Wallet.FeeURL == "" { + return fmt.Errorf("wallet.feeurl is required " + + "when wallet.type is btcwallet") + } + default: return fmt.Errorf( "unknown wallet type %q, valid values: "+ - "lnd, lwwallet", + "lnd, lwwallet, btcwallet", c.Wallet.Type, ) } diff --git a/darepod/rpc_oor_receive.go b/darepod/rpc_oor_receive.go index 46d23e7d5..95190f16f 100644 --- a/darepod/rpc_oor_receive.go +++ b/darepod/rpc_oor_receive.go @@ -147,6 +147,21 @@ func (r *RPCServer) oorReceiveKeyOps() (DeriveDefaultOORReceiveKeyFunc, ) }, nil + case r.server.btcwWallet.IsSome(): + wallet := r.server.btcwWallet.UnsafeFromSome() + + return func(ctx context.Context) (*keychain.KeyDescriptor, error) { //nolint:ll + return wallet.DeriveNextKey( + ctx, keychain.KeyFamilyMultiSig, + ) + }, func( + keyDesc keychain.KeyDescriptor) indexer.SchnorrSigner { //nolint:ll + + return indexer.NewKeyRingSchnorrSigner( + wallet.KeyRing(), keyDesc, + ) + }, nil + default: return nil, nil, fmt.Errorf("wallet backend not initialized") } diff --git a/darepod/rpc_wallet.go b/darepod/rpc_wallet.go index e45e9c3bb..14465b4b0 100644 --- a/darepod/rpc_wallet.go +++ b/darepod/rpc_wallet.go @@ -23,10 +23,11 @@ func (r *RPCServer) GenSeed(ctx context.Context, req *daemonrpc.GenSeedRequest) (*daemonrpc.GenSeedResponse, error) { - // GenSeed is only available in lwwallet mode. - if r.server.cfg.Wallet.Type != WalletTypeLwwallet { + // GenSeed is only available in lwwallet/btcwallet mode. + if !r.server.isSelfManagedWallet() { return nil, status.Errorf(codes.FailedPrecondition, - "GenSeed is only available in lwwallet mode") + "GenSeed is only available in lwwallet/"+ + "btcwallet mode") } // GenSeed is only available when no wallet exists yet. @@ -55,10 +56,11 @@ func (r *RPCServer) InitWallet(ctx context.Context, req *daemonrpc.InitWalletRequest) ( *daemonrpc.InitWalletResponse, error) { - // InitWallet is only available in lwwallet mode. - if r.server.cfg.Wallet.Type != WalletTypeLwwallet { + // InitWallet is only available in lwwallet/btcwallet mode. + if !r.server.isSelfManagedWallet() { return nil, status.Errorf(codes.FailedPrecondition, - "InitWallet is only available in lwwallet mode") + "InitWallet is only available in lwwallet/"+ + "btcwallet mode") } // Atomically check that no wallet exists and claim the @@ -109,8 +111,8 @@ func (r *RPCServer) InitWallet(ctx context.Context, r.server.log.InfoS(ctx, "Wallet seed encrypted and saved", "path", SeedFilePath(networkDir)) - // Start the lwwallet with the derived seed. - if err := r.server.startLwwallet(ctx, seed); err != nil { + // Start the wallet with the derived seed. + if err := r.server.startSelfManagedWallet(ctx, seed); err != nil { rollbackState() return nil, status.Errorf(codes.Internal, @@ -136,11 +138,11 @@ func (r *RPCServer) UnlockWallet(ctx context.Context, req *daemonrpc.UnlockWalletRequest) ( *daemonrpc.UnlockWalletResponse, error) { - // UnlockWallet is only available in lwwallet mode. - if r.server.cfg.Wallet.Type != WalletTypeLwwallet { + // UnlockWallet is only available in lwwallet/btcwallet mode. + if !r.server.isSelfManagedWallet() { return nil, status.Errorf(codes.FailedPrecondition, "UnlockWallet is only available in "+ - "lwwallet mode") + "lwwallet/btcwallet mode") } // Atomically verify the wallet is locked. We do not need a CAS @@ -175,8 +177,8 @@ func (r *RPCServer) UnlockWallet(ctx context.Context, r.server.log.InfoS(ctx, "Wallet seed decrypted via UnlockWallet RPC") - // Start the lwwallet with the decrypted seed. - if err := r.server.startLwwallet(ctx, seed); err != nil { + // Start the wallet with the decrypted seed. + if err := r.server.startSelfManagedWallet(ctx, seed); err != nil { return nil, status.Errorf(codes.Internal, "unable to start wallet: %v", err) } @@ -194,21 +196,67 @@ func (r *RPCServer) UnlockWallet(ctx context.Context, } // deriveIdentityPubkey derives the node identity public key from the -// active lwwallet using KeyFamilyNodeKey (family 6, index 0). This -// matches lnd's identity key derivation path. DeriveKey (not +// active self-managed wallet using KeyFamilyNodeKey (family 6, index +// 0). This matches lnd's identity key derivation path. DeriveKey (not // DeriveNextKey) is used so the identity key is stable across calls. func (r *RPCServer) deriveIdentityPubkey( ctx context.Context) (string, error) { - w := r.server.lwWallet.UnsafeFromSome() - - desc, err := w.DeriveKey(ctx, keychain.KeyLocator{ + loc := keychain.KeyLocator{ Family: identityKeyFamily, Index: 0, - }) + } + + var ( + desc *keychain.KeyDescriptor + err error + ) + + switch r.server.cfg.Wallet.Type { + case WalletTypeLwwallet: + w := r.server.lwWallet.UnsafeFromSome() + desc, err = w.DeriveKey(ctx, loc) + + case WalletTypeBtcwallet: + w := r.server.btcwWallet.UnsafeFromSome() + desc, err = w.DeriveKey(ctx, loc) + + default: + return "", fmt.Errorf("deriveIdentityPubkey not "+ + "supported for wallet type %q", + r.server.cfg.Wallet.Type) + } if err != nil { return "", fmt.Errorf("derive identity key: %w", err) } - return fmt.Sprintf("%x", desc.PubKey.SerializeCompressed()), nil + return fmt.Sprintf( + "%x", desc.PubKey.SerializeCompressed(), + ), nil +} + +// isSelfManagedWallet returns true if the wallet type manages its +// own seed (lwwallet or btcwallet), as opposed to LND which manages +// the wallet externally. +func (s *Server) isSelfManagedWallet() bool { + return s.cfg.Wallet.Type == WalletTypeLwwallet || + s.cfg.Wallet.Type == WalletTypeBtcwallet +} + +// startSelfManagedWallet starts the appropriate self-managed wallet +// based on the configured wallet type. +func (s *Server) startSelfManagedWallet(ctx context.Context, + seed [rawSeedLen]byte) error { + + switch s.cfg.Wallet.Type { + case WalletTypeLwwallet: + return s.startLwwallet(ctx, seed) + + case WalletTypeBtcwallet: + return s.startBtcwallet(ctx, seed) + + default: + return fmt.Errorf("unsupported wallet type %q", + s.cfg.Wallet.Type) + } } diff --git a/darepod/server.go b/darepod/server.go index c14b640d0..68c23260a 100644 --- a/darepod/server.go +++ b/darepod/server.go @@ -11,6 +11,7 @@ import ( "strings" "sync" "sync/atomic" + "time" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" @@ -20,6 +21,7 @@ import ( "github.com/btcsuite/btclog/v2" "github.com/lightninglabs/darepo-client/arkrpc" "github.com/lightninglabs/darepo-client/baselib/actor" + "github.com/lightninglabs/darepo-client/btcwbackend" "github.com/lightninglabs/darepo-client/build" "github.com/lightninglabs/darepo-client/chainbackends" "github.com/lightninglabs/darepo-client/chainsource" @@ -41,6 +43,7 @@ import ( "github.com/lightninglabs/darepo-client/timeout" "github.com/lightninglabs/darepo-client/vtxo" "github.com/lightninglabs/darepo-client/wallet" + "github.com/lightninglabs/darepo-client/walletcore" "github.com/lightninglabs/lndclient" lndbuild "github.com/lightningnetwork/lnd/build" "github.com/lightningnetwork/lnd/clock" @@ -132,6 +135,10 @@ type Server struct { // wallet.type is "lwwallet". It is None in lnd mode. lwWallet fn.Option[*lwwallet.Wallet] + // btcwWallet holds the neutrino-backed wallet instance when + // wallet.type is "btcwallet". It is None in other modes. + btcwWallet fn.Option[*btcwbackend.Wallet] + // walletState tracks the lifecycle state of the wallet // subsystem. In lnd mode this is always WalletStateReady // after successful lnd connection. In lwwallet mode it @@ -362,6 +369,11 @@ func (s *Server) run(ctx context.Context, // RPCs. s.tryAutoUnlockLwwallet(ctx) + case WalletTypeBtcwallet: + // In btcwallet mode, use the same auto-unlock flow + // as lwwallet but start a neutrino-backed wallet. + s.tryAutoUnlockBtcwallet(ctx) + default: return fmt.Errorf("unknown wallet type %q", s.cfg.Wallet.Type) @@ -402,6 +414,13 @@ func (s *Server) run(ctx context.Context, _ = s.chainBackend.Stop() } }() + defer func() { + s.btcwWallet.WhenSome( + func(w *btcwbackend.Wallet) { + w.Stop() + }, + ) + }() defer func() { shutdownCtx, shutdownCancel := context.WithTimeout( context.Background(), DefaultShutdownTimeout, @@ -433,18 +452,18 @@ func (s *Server) run(ctx context.Context, return err } - chainActor := chainsource.NewChainSourceActor( - chainsource.ChainSourceConfig{ - Backend: s.chainBackend, - System: s.actorSystem, - }, - ) - chainSourceRef := actor.RegisterWithSystem( - s.actorSystem, "chain-source", - chainsource.ChainSourceKey, chainActor, - ) - - s.log.InfoS(ctx, "Chain source actor registered") + // For btcwallet mode when the wallet is not yet unlocked, + // s.chainBackend is nil because neutrino requires the full + // service to be running. In this case, chain source actor + // registration is deferred until startBtcwallet populates + // s.chainBackend. The wallet-dependent actors (which are + // the only consumers) are also deferred behind walletReady. + var chainSourceRef actor.ActorRef[ + chainsource.ChainSourceMsg, chainsource.ChainSourceResp, + ] + if s.chainBackend != nil { + chainSourceRef = s.registerChainSourceActor(ctx) + } // ------------------------------------------------------- // 5. Open the database and create the delivery store. @@ -578,6 +597,16 @@ func (s *Server) run(ctx context.Context, return } + // For btcwallet mode, the chain source actor + // was deferred because s.chainBackend was nil + // at startup. Now that startBtcwallet has run, + // register it before starting dependent actors. + if chainSourceRef == nil { + chainSourceRef = s.registerChainSourceActor( + ctx, + ) + } + if err := s.startWalletDependentActors( ctx, chainSourceRef, timeoutRef, ); err != nil { @@ -787,6 +816,246 @@ func (s *Server) startLwwallet(ctx context.Context, return nil } +// tryAutoUnlockBtcwallet attempts to initialize the btcwallet+neutrino +// backend at startup without user interaction. It follows the same +// pattern as tryAutoUnlockLwwallet: check for a seed in the +// environment, then check for an encrypted seed file with a password. +func (s *Server) tryAutoUnlockBtcwallet(ctx context.Context) { + // Check for a raw seed in the environment (dev/CI path). + seed, err := LoadSeedFromEnv() + if err != nil { + s.log.WarnS(ctx, + "Invalid seed in environment variable", err) + + return + } + + if seed != nil { + s.log.InfoS(ctx, + "Loaded seed from environment variable") + + if err := s.startBtcwallet(ctx, *seed); err != nil { + s.log.ErrorS(ctx, + "Failed to start btcwallet from env seed", + err) + + return + } + + return + } + + networkDir, err := s.cfg.NetworkDir() + if err != nil { + s.log.ErrorS(ctx, + "Unable to resolve network directory", err) + + return + } + + // Check for an encrypted seed file on disk. + if !SeedFileExists(networkDir) { + s.log.InfoS(ctx, + "No wallet seed found, awaiting InitWallet RPC") + + s.walletState.Store(int32(WalletStateNone)) + + return + } + + // Encrypted seed exists. Try to find a password for + // auto-unlock. + s.walletState.Store(int32(WalletStateLocked)) + + password, ok := LoadPasswordFromEnv() + if !ok && s.cfg.Wallet.PasswordFile != "" { + var err error + password, err = LoadPasswordFromFile( + s.cfg.Wallet.PasswordFile, + ) + if err != nil { + s.log.WarnS(ctx, + "Failed to read wallet password file", + err) + + return + } + + ok = true + } + + if !ok { + s.log.InfoS(ctx, "Encrypted seed found but no "+ + "password available, awaiting UnlockWallet "+ + "RPC") + + return + } + + // We have both seed file and password: auto-unlock. + seedPath := SeedFilePath(networkDir) + ciphertext, err := LoadEncryptedSeed(seedPath) + if err != nil { + s.log.ErrorS(ctx, + "Failed to load encrypted seed", err) + + return + } + + decryptedSeed, err := DecryptSeed(ciphertext, password) + if err != nil { + s.log.ErrorS(ctx, + "Failed to decrypt seed at startup", err) + + return + } + + s.log.InfoS(ctx, + "Auto-unlocking btcwallet from encrypted seed") + + if err := s.startBtcwallet(ctx, decryptedSeed); err != nil { + s.log.ErrorS(ctx, + "Failed to start btcwallet", err) + + return + } +} + +// startBtcwallet creates and starts the neutrino-backed wallet from +// the given raw seed. On success it populates s.btcwWallet and marks +// the wallet as ready. +func (s *Server) startBtcwallet(ctx context.Context, + seed [rawSeedLen]byte) error { + + networkDir, err := s.cfg.NetworkDir() + if err != nil { + return fmt.Errorf("resolve network directory: %w", err) + } + + recoveryWindow := s.cfg.Wallet.RecoveryWindow + if recoveryWindow == 0 { + recoveryWindow = DefaultRecoveryWindow + } + + w, err := btcwbackend.New(btcwbackend.Config{ + Config: walletcore.Config{ + Seed: seed, + ChainParams: s.chainParams, + RecoveryWindow: recoveryWindow, + DBDir: networkDir, + Log: fn.Some( + s.subLogger(btcwbackend.Subsystem), + ), + }, + NeutrinoDataDir: s.cfg.Wallet.BtcwalletDataDir, + ConnectPeers: s.cfg.Wallet.BtcwalletPeers, + AddPeers: s.cfg.Wallet.BtcwalletAddPeers, + FeeURL: s.cfg.Wallet.FeeURL, + PersistFilters: s.cfg.Wallet.PersistFilters, + }) + if err != nil { + return fmt.Errorf("create btcwallet: %w", err) + } + + if err := w.Start(); err != nil { + return fmt.Errorf("start btcwallet: %w", err) + } + + s.btcwWallet = fn.Some(w) + + // Initialize the chain backend if it was deferred at startup + // because the wallet was not yet available. + if s.chainBackend == nil { + s.chainBackend = w.ChainBackend() + + if err := s.chainBackend.Start(); err != nil { + return fmt.Errorf( + "start chain backend: %w", err, + ) + } + } + + // Refresh the RPC clients once the wallet is available so the + // indexer client picks up the wallet-backed identity key and + // signer before any deferred wallet-dependent actors start. + if s.runtime != nil { + s.initRPCClients(ctx) + } + + s.log.InfoS(ctx, "Neutrino-backed wallet started, "+ + "waiting for initial sync in background") + + // Wait for neutrino to sync at least one block before marking + // the wallet ready. This runs in a goroutine so the InitWallet + // RPC can return without blocking on neutrino header sync. + go func() { + syncCtx, syncCancel := context.WithTimeout( + context.Background(), 2*time.Minute, + ) + defer syncCancel() + + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-syncCtx.Done(): + s.log.ErrorS( + syncCtx, + "Neutrino did not sync in time", + syncCtx.Err(), + ) + + return + + case <-ticker.C: + height, _, err := w.ChainBackend().BestBlock( + syncCtx, + ) + if err == nil && height > 0 { + s.log.InfoS(syncCtx, + "Neutrino initial sync "+ + "complete", + slog.Int("height", + int(height)), + ) + + s.markWalletReady() + + return + } + } + } + }() + + return nil +} + +// registerChainSourceActor creates and registers the chain source +// actor with the current s.chainBackend. The caller must ensure +// s.chainBackend is non-nil before calling this. +func (s *Server) registerChainSourceActor( + ctx context.Context) actor.ActorRef[ + chainsource.ChainSourceMsg, chainsource.ChainSourceResp, +] { + + chainActor := chainsource.NewChainSourceActor( + chainsource.ChainSourceConfig{ + Backend: s.chainBackend, + System: s.actorSystem, + }, + ) + + ref := actor.RegisterWithSystem( + s.actorSystem, "chain-source", + chainsource.ChainSourceKey, chainActor, + ) + + s.log.InfoS(ctx, "Chain source actor registered") + + return ref +} + // initChainBackend creates and starts the chain backend appropriate // for the configured wallet type. In lnd mode it uses the lndclient // chain notifier and fee estimator. In lwwallet mode it uses the @@ -845,6 +1114,24 @@ func (s *Server) initChainBackend(ctx context.Context) error { ) } + case WalletTypeBtcwallet: + // If the btcwallet is already started (auto-unlock + // succeeded), use its chain backend. Otherwise the + // chain backend will be initialized in + // startBtcwallet when the wallet is created via + // InitWallet/UnlockWallet RPC, since neutrino + // requires the full service to be running. + if s.btcwWallet.IsSome() { + w := s.btcwWallet.UnsafeFromSome() + s.chainBackend = w.ChainBackend() + alreadyStarted = true + } else { + // Defer chain backend start to + // startBtcwallet. Skip the Start() call + // below. + return nil + } + default: return fmt.Errorf("unknown wallet type %q", s.cfg.Wallet.Type) @@ -1738,6 +2025,27 @@ func (s *Server) initRPCClients(ctx context.Context) { ) } }) + s.btcwWallet.WhenSome(func(w *btcwbackend.Wallet) { + identityDesc, err := w.DeriveKey(ctx, keychain.KeyLocator{ + Family: identityKeyFamily, + Index: 0, + }) + if err != nil { + s.log.WarnS(ctx, + "Unable to derive identity key for "+ + "indexer", err) + } else { + s.clientKeyDesc = *identityDesc + signer = NewOwnedReceiveScriptSigner( + packageStore, + func(keyDesc keychain.KeyDescriptor) indexer.SchnorrSigner { //nolint:ll + return indexer.NewKeyRingSchnorrSigner( + w.KeyRing(), keyDesc, + ) + }, + ) + } + }) s.indexer = indexer.New( s.runtime.Unary(), signer, @@ -1785,6 +2093,10 @@ func (s *Server) initWalletActor(ctx context.Context, case WalletTypeLwwallet: w := s.lwWallet.UnsafeFromSome() boardingBackend = w.BoardingBackend() + + case WalletTypeBtcwallet: + w := s.btcwWallet.UnsafeFromSome() + boardingBackend = w.BoardingBackend() } // Adapt the VTXO persistence store to the wallet's VTXOReader @@ -1869,6 +2181,9 @@ func (s *Server) initRoundActor(ctx context.Context, case WalletTypeLwwallet: clientWallet = s.lwWallet.UnsafeFromSome() + + case WalletTypeBtcwallet: + clientWallet = s.btcwWallet.UnsafeFromSome() } clk := clock.NewDefaultClock() @@ -1968,6 +2283,9 @@ func (s *Server) initVTXOManager(ctx context.Context, case WalletTypeLwwallet: vtxoWallet = s.lwWallet.UnsafeFromSome() + + case WalletTypeBtcwallet: + vtxoWallet = s.btcwWallet.UnsafeFromSome() } clk := clock.NewDefaultClock() @@ -2045,6 +2363,9 @@ func (s *Server) initOORActor(ctx context.Context, case WalletTypeLwwallet: oorSigner = s.lwWallet.UnsafeFromSome() + + case WalletTypeBtcwallet: + oorSigner = s.btcwWallet.UnsafeFromSome() } vtxoStore := dbStore.NewVTXOStore(clk) diff --git a/go.mod b/go.mod index 93700e143..36c240a3f 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 github.com/btcsuite/btclog/v2 v2.0.1-0.20250728225537-6090e87c6c5b github.com/btcsuite/btcwallet v0.16.17 + github.com/btcsuite/btcwallet/walletdb v1.5.1 github.com/btcsuite/btcwallet/wtxmgr v1.5.6 github.com/golang-migrate/migrate/v4 v4.17.0 github.com/google/uuid v1.6.0 @@ -22,11 +23,13 @@ require ( github.com/lib/pq v1.10.9 github.com/lightninglabs/darepo-client/baselib v0.0.0-00010101000000-000000000000 github.com/lightninglabs/lndclient v1.0.1-0.20260110234312-aefc13f693ea + github.com/lightninglabs/neutrino v0.16.2-0.20250820152345-800584718688 github.com/lightninglabs/taproot-assets v0.7.0 github.com/lightninglabs/taproot-assets/taprpc v1.0.11 github.com/lightningnetwork/lnd v0.20.0-beta.rc4.0.20260110233730-15227a4ff50a github.com/lightningnetwork/lnd/clock v1.1.1 github.com/lightningnetwork/lnd/fn/v2 v2.0.9 + github.com/lightningnetwork/lnd/kvdb v1.4.16 github.com/lightningnetwork/lnd/tlv v1.3.2 github.com/modelcontextprotocol/go-sdk v1.4.0 github.com/ory/dockertest/v3 v3.12.0 @@ -59,7 +62,6 @@ require ( github.com/btcsuite/btcwallet/wallet/txauthor v1.3.5 // indirect github.com/btcsuite/btcwallet/wallet/txrules v1.2.2 // indirect github.com/btcsuite/btcwallet/wallet/txsizes v1.2.5 // indirect - github.com/btcsuite/btcwallet/walletdb v1.5.1 // indirect github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd // indirect github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 // indirect github.com/btcsuite/winsvc v1.0.0 // indirect @@ -118,12 +120,10 @@ require ( github.com/klauspost/compress v1.17.9 // indirect github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf // indirect github.com/lightninglabs/lightning-node-connect/hashmailrpc v1.0.3 // indirect - github.com/lightninglabs/neutrino v0.16.2-0.20250820152345-800584718688 // indirect github.com/lightninglabs/neutrino/cache v1.1.2 // indirect github.com/lightningnetwork/lightning-onion v1.2.1-0.20240815225420-8b40adf04ab9 // indirect github.com/lightningnetwork/lnd/cert v1.2.2 // indirect github.com/lightningnetwork/lnd/healthcheck v1.2.6 // indirect - github.com/lightningnetwork/lnd/kvdb v1.4.16 // indirect github.com/lightningnetwork/lnd/queue v1.1.1 // indirect github.com/lightningnetwork/lnd/sqldb v1.0.12 // indirect github.com/lightningnetwork/lnd/ticker v1.1.1 // indirect diff --git a/harness/harness.go b/harness/harness.go index 6a45f14fa..d0f67be62 100644 --- a/harness/harness.go +++ b/harness/harness.go @@ -207,6 +207,11 @@ type Harness struct { // BitcoindZMQTx is the host:port of bitcoind ZMQ for raw txs (28333). BitcoindZMQTx string + // BitcoindP2P is the host:port of bitcoind's P2P interface (18444). + // Used by neutrino (BIP 157/158) to sync headers and compact block + // filters directly from the regtest bitcoind node. + BitcoindP2P string + // LNDGRPCPort is the host port mapped to lnd gRPC (10009). LNDGRPCPort string @@ -1077,6 +1082,14 @@ func (h *Harness) startBitcoind() { "-rpcbind=0.0.0.0", "-zmqpubrawblock=tcp://0.0.0.0:28332", "-zmqpubrawtx=tcp://0.0.0.0:28333", + // Enable P2P listening on all interfaces so neutrino can + // connect from the host for compact block filter sync. + "-listen=1", + "-bind=0.0.0.0:18444", + // Enable compact block filter index and serving so + // neutrino (BIP 157/158) clients can sync. + "-blockfilterindex=1", + "-peerblockfilters=1", "-printtoconsole", } @@ -1094,7 +1107,8 @@ func (h *Harness) startBitcoind() { Cmd: cmd, Env: []string{}, ExposedPorts: []string{ - "18443/tcp", "28332/tcp", "28333/tcp", + "18443/tcp", "18444/tcp", + "28332/tcp", "28333/tcp", }, Name: containerName, Networks: []*dockertest.Network{h.network}, @@ -1116,6 +1130,10 @@ func (h *Harness) startBitcoind() { HostIP: "0.0.0.0", HostPort: "", }}, + "18444/tcp": {{ + HostIP: "0.0.0.0", + HostPort: "", + }}, "28332/tcp": {{ HostIP: "0.0.0.0", HostPort: "", @@ -1145,13 +1163,16 @@ func (h *Harness) startBitcoind() { h.waitContainerRunning(res) rpcPort := res.GetPort("18443/tcp") + p2pPort := res.GetPort("18444/tcp") zmqBlock := res.GetPort("28332/tcp") zmqTx := res.GetPort("28333/tcp") h.BitcoindRPC = net.JoinHostPort("127.0.0.1", rpcPort) + h.BitcoindP2P = net.JoinHostPort("127.0.0.1", p2pPort) h.BitcoindZMQBlock = fmt.Sprintf("tcp://127.0.0.1:%s", zmqBlock) h.BitcoindZMQTx = fmt.Sprintf("tcp://127.0.0.1:%s", zmqTx) - h.Logf("bitcoind RPC=%s ZMQ(block)=%s ZMQ(tx)=%s", h.BitcoindRPC, + h.Logf("bitcoind RPC=%s P2P=%s ZMQ(block)=%s ZMQ(tx)=%s", + h.BitcoindRPC, h.BitcoindP2P, h.BitcoindZMQBlock, h.BitcoindZMQTx) // Ensure JSON-RPC is responsive before proceeding. diff --git a/lwwallet/boarding_backend.go b/lwwallet/boarding_backend.go index eaefb6778..dff291a2c 100644 --- a/lwwallet/boarding_backend.go +++ b/lwwallet/boarding_backend.go @@ -5,7 +5,6 @@ import ( "fmt" "log/slog" "math" - "sync" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" @@ -13,40 +12,25 @@ import ( "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btclog/v2" - "github.com/btcsuite/btcwallet/waddrmgr" "github.com/lightninglabs/darepo-client/wallet" - "github.com/lightningnetwork/lnd/keychain" + "github.com/lightninglabs/darepo-client/walletcore" "github.com/lightningnetwork/lnd/lnwallet/btcwallet" ) -// BoardingBackendAdapter implements wallet.BoardingBackend by wrapping -// btcwallet.BtcWallet for key derivation and script import, while -// using the Esplora API directly for UTXO queries. This is necessary -// because btcwallet's internal UTXO tracking skips addresses imported -// under non-default key scopes (like LND's m/1017' scope), so we -// query Esplora for UTXOs at imported boarding addresses instead. +// BoardingBackendAdapter implements wallet.BoardingBackend by +// embedding walletcore.BoardingBackendBase for shared key derivation +// and script import, while using the Esplora API directly for UTXO +// queries, transaction fetching, and block fetching. The Esplora +// bypass for UTXOs is necessary because btcwallet's internal UTXO +// tracking skips addresses imported under non-default key scopes +// (like LND's m/1017' scope). type BoardingBackendAdapter struct { - btcWallet *btcwallet.BtcWallet + // BoardingBackendBase provides shared DeriveNextKey, + // ImportTaprootScript, and address tracking. + walletcore.BoardingBackendBase + esplora *EsploraClient chainParams *chaincfg.Params - - // log is the structured logger for this boarding backend - // instance. - log btclog.Logger - - // chainKeyScope is the key scope used for script imports. - // This matches LND's m/1017'/coinType' derivation. - chainKeyScope waddrmgr.KeyScope - - // mu protects importedAddrs. - mu sync.Mutex - - // importedAddrs tracks addresses imported via ImportTaprootScript. - // ListUnspent queries Esplora for UTXOs at each of these - // addresses, since btcwallet's internal UTXO tracking skips - // non-default scope addresses in its addRelevantTx credit - // marking. - importedAddrs map[string]btcutil.Address } // NewBoardingBackendAdapter creates a new boarding backend adapter @@ -56,77 +40,14 @@ func NewBoardingBackendAdapter(btcw *btcwallet.BtcWallet, coinType uint32, logger btclog.Logger) *BoardingBackendAdapter { return &BoardingBackendAdapter{ - btcWallet: btcw, + BoardingBackendBase: walletcore.NewBoardingBackendBase( + btcw, coinType, logger, + ), esplora: esplora, chainParams: chainParams, - log: logger, - chainKeyScope: waddrmgr.KeyScope{ - Purpose: keychain.BIP0043Purpose, - Coin: coinType, - }, - importedAddrs: make(map[string]btcutil.Address), } } -// DeriveNextKey derives the next key in the specified key family. This -// delegates to btcwallet's keyring which uses the waddrmgr for HD key -// derivation following the m/1017'/coinType'/family'/0/index path. -func (b *BoardingBackendAdapter) DeriveNextKey(ctx context.Context, - family keychain.KeyFamily) (*keychain.KeyDescriptor, error) { - - keyRing := keychain.NewBtcWalletKeyRing( - b.btcWallet.InternalWallet(), - b.chainKeyScope.Coin, - ) - - desc, err := keyRing.DeriveNextKey(family) - if err != nil { - return nil, fmt.Errorf("derive next key: %w", err) - } - - b.log.DebugS(ctx, "Derived next key", - slog.Int("family", int(family)), - slog.Int("index", int(desc.Index))) - - return &desc, nil -} - -// ImportTaprootScript imports a taproot script into btcwallet and -// tracks the resulting address for Esplora-based UTXO queries. The -// import into btcwallet registers the address for chain notifications -// via NotifyReceived, while the local address tracking enables -// ListUnspent to query Esplora directly. -func (b *BoardingBackendAdapter) ImportTaprootScript( - ctx context.Context, - script *waddrmgr.Tapscript) (btcutil.Address, error) { - - managedAddr, err := b.btcWallet.ImportTaprootScript( - b.chainKeyScope, script, - ) - if err != nil { - return nil, fmt.Errorf( - "import taproot script: %w", err, - ) - } - - addr := managedAddr.Address() - - // Track the imported address so ListUnspent can query - // Esplora for UTXOs at this address. We use Esplora - // directly because btcwallet's addRelevantTx skips credit - // marking for non-default scope addresses (1017). - b.mu.Lock() - b.importedAddrs[addr.String()] = addr - b.mu.Unlock() - - b.log.DebugS(ctx, - "Imported taproot script via btcwallet", - slog.String("address", addr.String()), - slog.Int("tracked_addrs", len(b.importedAddrs))) - - return addr, nil -} - // ListUnspent returns UTXOs at all imported boarding addresses by // querying the Esplora API directly. This bypasses btcwallet's // internal UTXO tracking, which skips credit marking for addresses @@ -148,19 +69,14 @@ func (b *BoardingBackendAdapter) ListUnspent(ctx context.Context, return nil, fmt.Errorf("get tip height: %w", err) } - b.mu.Lock() - addrs := make(map[string]btcutil.Address, len(b.importedAddrs)) - for k, v := range b.importedAddrs { - addrs[k] = v - } - b.mu.Unlock() + addrs := b.SnapshotAddrs() var utxos []*wallet.Utxo for addrStr, addr := range addrs { esploraUtxos, err := b.esplora.GetAddressUtxos(addrStr) if err != nil { - b.log.WarnS(ctx, + b.Log.WarnS(ctx, "Failed to query Esplora for address UTXOs", err, slog.String("address", addrStr)) @@ -205,7 +121,7 @@ func (b *BoardingBackendAdapter) ListUnspent(ctx context.Context, } } - b.log.DebugS(ctx, "ListUnspent called", + b.Log.DebugS(ctx, "ListUnspent called", slog.Int("min_confs", int(minConfs)), slog.Int("max_confs", int(maxConfs)), slog.Int("tracked_addrs", len(addrs)), @@ -223,11 +139,11 @@ func (b *BoardingBackendAdapter) GetTransaction(ctx context.Context, txid chainhash.Hash) (*wire.MsgTx, *chainhash.Hash, error) { // Try btcwallet's transaction store first for the raw tx. - tx, err := b.btcWallet.FetchTx(txid) + tx, err := b.BtcWallet.FetchTx(txid) if err != nil || tx == nil { // Fall back to Esplora for transactions not in the wallet // DB. - b.log.DebugS(ctx, + b.Log.DebugS(ctx, "Transaction not in wallet, falling back "+ "to Esplora", slog.String("txid", txid.String()), @@ -247,7 +163,7 @@ func (b *BoardingBackendAdapter) GetTransaction(ctx context.Context, status, err := b.esplora.GetTxStatus(txid) if err != nil { - b.log.WarnS(ctx, + b.Log.WarnS(ctx, "Failed fetching tx status from Esplora", err, slog.String("txid", txid.String()), ) @@ -270,7 +186,7 @@ func (b *BoardingBackendAdapter) GetTransaction(ctx context.Context, func (b *BoardingBackendAdapter) GetBlock(ctx context.Context, blockHash chainhash.Hash) (*wire.MsgBlock, error) { - b.log.DebugS(ctx, "Fetching block from Esplora", + b.Log.DebugS(ctx, "Fetching block from Esplora", slog.String("block_hash", blockHash.String())) block, err := b.esplora.GetRawBlock(blockHash) @@ -278,7 +194,7 @@ func (b *BoardingBackendAdapter) GetBlock(ctx context.Context, return nil, fmt.Errorf("get block: %w", err) } - b.log.DebugS(ctx, "Fetched block successfully", + b.Log.DebugS(ctx, "Fetched block successfully", slog.String("block_hash", blockHash.String()), slog.Int("num_txs", len(block.Transactions))) diff --git a/lwwallet/config.go b/lwwallet/config.go index 11d7bad5e..654328ec0 100644 --- a/lwwallet/config.go +++ b/lwwallet/config.go @@ -8,17 +8,8 @@ import ( fn "github.com/lightningnetwork/lnd/fn/v2" ) -// coinTypeForNet returns the BIP44 coin type for the given network. -// Mainnet uses coin type 0, while all test networks use coin type 1. -func coinTypeForNet(params *chaincfg.Params) uint32 { - switch params.Net { - case chaincfg.MainNetParams.Net: - return 0 - - default: - return 1 - } -} +// NOTE: coinTypeForNet, WalletPassphrase, and DefaultBlockCacheSize +// are now in the walletcore package. // Config holds the configuration for the lightweight wallet. type Config struct { diff --git a/lwwallet/wallet.go b/lwwallet/wallet.go index 2751c519e..93a4f735f 100644 --- a/lwwallet/wallet.go +++ b/lwwallet/wallet.go @@ -6,36 +6,22 @@ import ( "log/slog" "time" - "github.com/btcsuite/btcd/btcutil" - "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btclog/v2" - "github.com/lightninglabs/darepo-client/build" + "github.com/lightninglabs/darepo-client/walletcore" "github.com/lightningnetwork/lnd/blockcache" - fn "github.com/lightningnetwork/lnd/fn/v2" - "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/keychain" - "github.com/lightningnetwork/lnd/lnwallet" "github.com/lightningnetwork/lnd/lnwallet/btcwallet" ) -// defaultBlockCacheSize is the number of blocks to cache in memory. -// This prevents redundant block fetches during wallet sync. -const defaultBlockCacheSize uint64 = 20 - -// walletPassphrase is the default passphrase for the wallet DB. -var walletPassphrase = []byte("lwwallet") - // Wallet is a lightweight in-process Bitcoin wallet backed by LND's // btcwallet implementation and an Esplora chain backend. It provides // full on-chain wallet capabilities (receive, balance, key derivation, // signing, MuSig2) plus Ark protocol participation (boarding address // management, chain monitoring). // -// The wallet wraps a btcwallet.BtcWallet instance which handles key -// management via waddrmgr, UTXO tracking, and transaction signing. -// Chain data is fetched from Esplora via the EsploraChainService -// (implementing btcwallet's chain.Interface) and the existing -// ChainBackend (implementing chainsource.ChainBackend for actors). +// The wallet embeds walletcore.Wallet for shared btcwallet operations +// and adds Esplora-specific chain service, chain backend, and +// boarding backend. // // The wallet exposes sub-interfaces via accessor methods: // - BoardingBackend() returns the wallet.BoardingBackend adapter @@ -43,16 +29,8 @@ var walletPassphrase = []byte("lwwallet") // - ChainBackend() returns the chainsource.ChainBackend for actors // - KeyRing() returns the keychain.SecretKeyRing for key operations type Wallet struct { - // Signer is the input.Signer implementation backed by - // btcwallet. This supports Schnorr signing, taproot - // key/script-path signing, and MuSig2 sessions. Embedding - // this (together with DeriveNextKey) means Wallet directly - // satisfies round.ClientWallet. - input.Signer - - // btcWallet is the LND btcwallet instance that provides key - // management, signing, UTXO tracking, and address generation. - btcWallet *btcwallet.BtcWallet + // Wallet provides shared btcwallet-backed operations. + walletcore.Wallet // chainSvc implements btcwallet's chain.Interface, feeding // block notifications to btcwallet for wallet sync. @@ -69,19 +47,6 @@ type Wallet struct { // boardingBackend wraps btcwallet to provide the // wallet.BoardingBackend interface for Ark boarding. boardingBackend *BoardingBackendAdapter - - // keyRing provides keychain.SecretKeyRing backed by btcwallet's - // waddrmgr for HD key derivation. - keyRing keychain.SecretKeyRing - - // chainParams identifies the Bitcoin network. - chainParams *chaincfg.Params - - // walletLog is an optional logger for this wallet instance. When set, - // it takes precedence over the context-based logger from - // build.LoggerFromContext. When None, the wallet falls back to the - // context logger (or btclog.Disabled if none is found). - walletLog fn.Option[btclog.Logger] } // New creates a new lightweight wallet from the given configuration. @@ -89,8 +54,9 @@ type Wallet struct { // is responsible for managing the directory's lifecycle (creation // before calling New, cleanup after Stop if desired). func New(cfg Config) (*Wallet, error) { - // Constructors run before a contextual logger is guaranteed, so default to - // a disabled logger when one was not explicitly provided. + // Constructors run before a contextual logger is guaranteed, + // so default to a disabled logger when one was not explicitly + // provided. walletLog := cfg.Log.UnwrapOr(btclog.Disabled) esplora := NewEsploraClient(cfg.EsploraURL, walletLog) @@ -107,12 +73,14 @@ func New(cfg Config) (*Wallet, error) { esplora, cfg.PollInterval, walletLog, ) - coinType := coinTypeForNet(cfg.ChainParams) - blockCache := blockcache.NewBlockCache(defaultBlockCacheSize) + coinType := walletcore.CoinTypeForNet(cfg.ChainParams) + blockCache := blockcache.NewBlockCache( + walletcore.DefaultBlockCacheSize, + ) btcw, err := btcwallet.New(btcwallet.Config{ - PrivatePass: walletPassphrase, - PublicPass: walletPassphrase, + PrivatePass: walletcore.WalletPassphrase, + PublicPass: walletcore.WalletPassphrase, HdSeed: cfg.Seed[:], ChainSource: chainSvc, NetParams: cfg.ChainParams, @@ -144,24 +112,20 @@ func New(cfg Config) (*Wallet, error) { slog.Uint64("coin_type", uint64(coinType))) return &Wallet{ - Signer: btcw, - btcWallet: btcw, + Wallet: walletcore.Wallet{ + Signer: btcw, + BtcWallet: btcw, + KeyRing: keyRing, + ChainParams: cfg.ChainParams, + WalletLog: cfg.Log, + }, chainSvc: chainSvc, esplora: esplora, chainBackend: chainBackend, boardingBackend: boardingBackend, - keyRing: keyRing, - chainParams: cfg.ChainParams, - walletLog: cfg.Log, }, nil } -// logger returns the configured logger or falls back to extracting from -// context. If no logger is found in either location, returns btclog.Disabled. -func (w *Wallet) logger(ctx context.Context) btclog.Logger { - return w.walletLog.UnwrapOr(build.LoggerFromContext(ctx)) -} - // Start initializes the wallet by starting btcwallet (which // internally starts the EsploraChainService and syncs the wallet) // and the chainsource ChainBackend. @@ -170,7 +134,7 @@ func (w *Wallet) Start() error { // btcWallet.Start() unlocks the wallet, creates key scopes, // starts the chain service, and begins wallet synchronization. - if err := w.btcWallet.Start(); err != nil { + if err := w.BtcWallet.Start(); err != nil { return fmt.Errorf("start btcwallet: %w", err) } @@ -180,7 +144,7 @@ func (w *Wallet) Start() error { return fmt.Errorf("start chain backend: %w", err) } - w.logger(ctx).InfoS(ctx, "Lightweight wallet started") + w.Logger(ctx).InfoS(ctx, "Lightweight wallet started") return nil } @@ -191,13 +155,13 @@ func (w *Wallet) Start() error { func (w *Wallet) Stop() { ctx := context.Background() - w.logger(ctx).InfoS(ctx, "Stopping lightweight wallet") + w.Logger(ctx).InfoS(ctx, "Stopping lightweight wallet") - _ = w.btcWallet.Stop() + _ = w.BtcWallet.Stop() w.chainSvc.WaitForShutdown() _ = w.chainBackend.Stop() - w.logger(ctx).InfoS(ctx, "Lightweight wallet stopped") + w.Logger(ctx).InfoS(ctx, "Lightweight wallet stopped") } // BoardingBackend returns the wallet.BoardingBackend adapter that @@ -212,119 +176,9 @@ func (w *Wallet) ChainBackend() *ChainBackend { return w.chainBackend } -// KeyRing returns the wallet's secret key ring for key derivation and message -// signing operations that need direct access to wallet-owned keys. +// KeyRing returns the wallet's secret key ring for key derivation and +// message signing operations that need direct access to wallet-owned +// keys. func (w *Wallet) KeyRing() keychain.SecretKeyRing { - return w.keyRing -} - -// DeriveNextKey derives the next key in the specified key family. -// This delegates to the btcwallet-backed keyring. -func (w *Wallet) DeriveNextKey(_ context.Context, - family keychain.KeyFamily) (*keychain.KeyDescriptor, error) { - - desc, err := w.keyRing.DeriveNextKey(family) - if err != nil { - return nil, fmt.Errorf("derive next key: %w", err) - } - - return &desc, nil -} - -// DeriveKey derives a specific key identified by the given KeyLocator. -// Unlike DeriveNextKey, this always returns the same key for the same -// locator, making it suitable for stable identity keys. -func (w *Wallet) DeriveKey(_ context.Context, - loc keychain.KeyLocator) (*keychain.KeyDescriptor, error) { - - desc, err := w.keyRing.DeriveKey(loc) - if err != nil { - return nil, fmt.Errorf("derive key: %w", err) - } - - return &desc, nil -} - -// NewAddress generates a new BIP86 taproot receiving address (P2TR -// key-path only) via btcwallet. -func (w *Wallet) NewAddress( - ctx context.Context) (btcutil.Address, error) { - - addr, err := w.btcWallet.NewAddress( - lnwallet.TaprootPubkey, false, - lnwallet.DefaultAccountName, - ) - if err != nil { - return nil, err - } - - w.logger(ctx).DebugS(ctx, "Generated new P2TR address", - slog.String("address", addr.String())) - - return addr, nil -} - -// Balance returns the confirmed and unconfirmed balance across all -// wallet-managed addresses. Confirmed balance requires at least 1 -// confirmation. -func (w *Wallet) Balance( - ctx context.Context) (btcutil.Amount, btcutil.Amount, error) { - - // Log sync state for debugging. The sync height determines - // whether confirmed transactions are counted. - syncedTo := w.btcWallet.InternalWallet().SyncedTo() - chainSynced := w.btcWallet.InternalWallet().ChainSynced() - w.logger(ctx).DebugS(ctx, "Checking wallet balance", - slog.Int("sync_height", int(syncedTo.Height)), - slog.String("sync_hash", syncedTo.Hash.String()), - slog.Bool("chain_synced", chainSynced)) - - confirmed, err := w.btcWallet.ConfirmedBalance(1, "") - if err != nil { - return 0, 0, fmt.Errorf("get confirmed balance: %w", err) - } - - // Total includes unconfirmed (0-conf) outputs. - total, err := w.btcWallet.ConfirmedBalance( - 0, "", - ) - if err != nil { - return 0, 0, fmt.Errorf("get total balance: %w", err) - } - - unconfirmed := total - confirmed - - w.logger(ctx).DebugS(ctx, "Wallet balance result", - slog.Int64("confirmed_sats", int64(confirmed)), - slog.Int64("unconfirmed_sats", int64(unconfirmed)), - slog.Int64("total_sats", int64(total))) - - return confirmed, unconfirmed, nil -} - -// InternalWallet returns the underlying btcwallet instance for -// advanced operations not exposed through the Wallet API. -func (w *Wallet) InternalWallet() *btcwallet.BtcWallet { - return w.btcWallet -} - -// ConfirmedBalance returns the confirmed balance with the specified -// minimum confirmations. This is a convenience wrapper around -// btcwallet's ConfirmedBalance. -func (w *Wallet) ConfirmedBalance( - minConfs int32) (btcutil.Amount, error) { - - return w.btcWallet.ConfirmedBalance(minConfs, "") -} - -// ListUnspentWitness returns all unspent witness outputs with -// confirmations in the given range. This delegates to btcwallet's -// ListUnspentWitness which returns P2WKH, P2TR, and nested P2SH -// outputs. -func (w *Wallet) ListUnspentWitness(minConfs, - maxConfs int32) ([]*lnwallet.Utxo, error) { - - return w.btcWallet.ListUnspentWitness( - minConfs, maxConfs, "", - ) + return w.Wallet.KeyRing } diff --git a/walletcore/boarding_backend.go b/walletcore/boarding_backend.go new file mode 100644 index 000000000..5e485d758 --- /dev/null +++ b/walletcore/boarding_backend.go @@ -0,0 +1,140 @@ +package walletcore + +import ( + "context" + "fmt" + "log/slog" + "sync" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btclog/v2" + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lnwallet/btcwallet" +) + +// BoardingBackendBase provides shared btcwallet-backed boarding +// functionality used by both lwwallet and btcwbackend boarding +// adapters. It handles key derivation, taproot script import, and +// imported address tracking. Chain-specific adapters embed this +// struct and implement ListUnspent, GetTransaction, and GetBlock +// using their respective chain data sources. +type BoardingBackendBase struct { + // BtcWallet is the btcwallet instance for script imports. + BtcWallet *btcwallet.BtcWallet + + // Log is the structured logger for this boarding backend. + Log btclog.Logger + + // KeyRing is the cached key ring for HD key derivation. + KeyRing keychain.KeyRing + + // ChainKeyScope is the key scope used for script imports. + // This matches LND's m/1017'/coinType' derivation. + ChainKeyScope waddrmgr.KeyScope + + // Mu protects ImportedAddrs. + Mu sync.Mutex + + // ImportedAddrs tracks addresses imported via + // ImportTaprootScript. Chain-specific ListUnspent + // implementations use this to filter results to only boarding + // UTXOs. + // + // This map is in-memory and is repopulated on daemon restart + // by the wallet actor, which re-imports all persisted boarding + // addresses from the database during startup (see + // wallet.Ark.handleStartupRecovery). + ImportedAddrs map[string]btcutil.Address +} + +// NewBoardingBackendBase creates a new base boarding backend wrapping +// the given btcwallet instance. +func NewBoardingBackendBase(btcw *btcwallet.BtcWallet, + coinType uint32, + logger btclog.Logger) BoardingBackendBase { + + keyRing := keychain.NewBtcWalletKeyRing( + btcw.InternalWallet(), coinType, + ) + + return BoardingBackendBase{ + BtcWallet: btcw, + Log: logger, + KeyRing: keyRing, + ChainKeyScope: waddrmgr.KeyScope{ + Purpose: keychain.BIP0043Purpose, + Coin: coinType, + }, + ImportedAddrs: make(map[string]btcutil.Address), + } +} + +// DeriveNextKey derives the next key in the specified key family. +// This delegates to btcwallet's keyring which uses the waddrmgr for +// HD key derivation following the m/1017'/coinType'/family'/0/index +// path. +func (b *BoardingBackendBase) DeriveNextKey(ctx context.Context, + family keychain.KeyFamily) (*keychain.KeyDescriptor, error) { + + desc, err := b.KeyRing.DeriveNextKey(family) + if err != nil { + return nil, fmt.Errorf("derive next key: %w", err) + } + + b.Log.DebugS(ctx, "Derived next key", + slog.Int("family", int(family)), + slog.Int("index", int(desc.Index))) + + return &desc, nil +} + +// ImportTaprootScript imports a taproot script into btcwallet and +// tracks the resulting address for UTXO filtering. After import, +// btcwallet will track UTXOs paying to this address via whatever +// chain source is configured (Esplora notifications or neutrino +// compact block filter matching). +func (b *BoardingBackendBase) ImportTaprootScript( + ctx context.Context, + script *waddrmgr.Tapscript) (btcutil.Address, error) { + + managedAddr, err := b.BtcWallet.ImportTaprootScript( + b.ChainKeyScope, script, + ) + if err != nil { + return nil, fmt.Errorf( + "import taproot script: %w", err, + ) + } + + addr := managedAddr.Address() + + // Track the imported address so ListUnspent implementations + // can filter results to only return boarding UTXOs. + b.Mu.Lock() + b.ImportedAddrs[addr.String()] = addr + numAddrs := len(b.ImportedAddrs) + b.Mu.Unlock() + + b.Log.DebugS(ctx, + "Imported taproot script via btcwallet", + slog.String("address", addr.String()), + slog.Int("tracked_addrs", numAddrs)) + + return addr, nil +} + +// SnapshotAddrs returns a snapshot of the currently imported +// addresses under the lock. This is a convenience for ListUnspent +// implementations that need to iterate over addresses without +// holding the lock for the duration of chain queries. +func (b *BoardingBackendBase) SnapshotAddrs() map[string]btcutil.Address { + b.Mu.Lock() + addrs := make(map[string]btcutil.Address, len(b.ImportedAddrs)) + for k, v := range b.ImportedAddrs { + addrs[k] = v + } + b.Mu.Unlock() + + return addrs +} diff --git a/walletcore/config.go b/walletcore/config.go new file mode 100644 index 000000000..97a54c9c1 --- /dev/null +++ b/walletcore/config.go @@ -0,0 +1,58 @@ +package walletcore + +import ( + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btclog/v2" + fn "github.com/lightningnetwork/lnd/fn/v2" +) + +// DefaultBlockCacheSize is the number of blocks to cache in memory. +// This prevents redundant block fetches during wallet sync. +const DefaultBlockCacheSize uint64 = 20 + +// WalletPassphrase is the default passphrase for the wallet DB. +// Both lwwallet and btcwbackend use this for btcwallet's +// PrivatePass and PublicPass. +var WalletPassphrase = []byte("lwwallet") + +// CoinTypeForNet returns the BIP44 coin type for the given network. +// Mainnet uses coin type 0, while all test networks use coin type 1. +func CoinTypeForNet(params *chaincfg.Params) uint32 { + switch params.Net { + case chaincfg.MainNetParams.Net: + return 0 + + default: + return 1 + } +} + +// Config holds the base configuration shared by all wallet backends +// that wrap btcwallet. +type Config struct { + // Seed is the 32-byte master seed used for HD key derivation. + // The caller is responsible for seed generation, encryption at + // rest, and BIP39 mnemonic handling. The wallet only uses the + // raw seed bytes. + Seed [32]byte + + // ChainParams identifies the Bitcoin network (mainnet, testnet, + // regtest). Used for address encoding and HD derivation paths. + ChainParams *chaincfg.Params + + // RecoveryWindow specifies the address look-ahead for + // discovering used addresses during wallet recovery or restart. + // A value of 0 means no recovery is performed. Typical value: + // 100 for restart scenarios where previously derived keys must + // be rediscovered. + RecoveryWindow uint32 + + // DBDir is the directory for btcwallet's bbolt database. The + // caller owns the lifecycle of this directory. + DBDir string + + // Log is an optional logger for the wallet and all its + // sub-components. If None, the wallet falls back to + // btclog.Disabled. + Log fn.Option[btclog.Logger] +} diff --git a/walletcore/doc.go b/walletcore/doc.go new file mode 100644 index 000000000..a437e5f1b --- /dev/null +++ b/walletcore/doc.go @@ -0,0 +1,11 @@ +// Package walletcore provides shared btcwallet wrapping used by both +// the lwwallet (Esplora-backed) and btcwbackend (neutrino-backed) +// wallet implementations. It extracts common HD key management, +// signing, address generation, and balance operations that delegate +// to btcwallet.BtcWallet regardless of the underlying chain source. +// +// Chain-specific implementations (lwwallet, btcwbackend) embed +// walletcore.Wallet and walletcore.BoardingBackendBase, adding their +// own chain data sources for UTXO queries, block fetching, and +// chain monitoring. +package walletcore diff --git a/walletcore/log.go b/walletcore/log.go new file mode 100644 index 000000000..4e77dbb72 --- /dev/null +++ b/walletcore/log.go @@ -0,0 +1,4 @@ +package walletcore + +// Subsystem defines the logging code for this subsystem. +const Subsystem = "WLCR" diff --git a/walletcore/wallet.go b/walletcore/wallet.go new file mode 100644 index 000000000..e2b3c2e39 --- /dev/null +++ b/walletcore/wallet.go @@ -0,0 +1,170 @@ +package walletcore + +import ( + "context" + "fmt" + "log/slog" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/darepo-client/build" + fn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/lightningnetwork/lnd/lnwallet/btcwallet" +) + +// Wallet provides shared btcwallet-backed functionality used by both +// lwwallet and btcwbackend. It wraps a btcwallet.BtcWallet instance +// and provides HD key management, signing (including MuSig2), +// address generation, and balance queries. +// +// Chain-specific wallet implementations embed this struct and add +// their own chain service, chain backend, and boarding backend. +// Embedding Wallet (together with DeriveNextKey) makes the outer +// type satisfy round.ClientWallet. +type Wallet struct { + // Signer is the input.Signer implementation backed by + // btcwallet. This supports Schnorr signing, taproot + // key/script-path signing, and MuSig2 sessions. + input.Signer + + // BtcWallet is the LND btcwallet instance that provides key + // management, signing, UTXO tracking, and address generation. + BtcWallet *btcwallet.BtcWallet + + // KeyRing provides keychain.SecretKeyRing backed by + // btcwallet's waddrmgr for HD key derivation. + KeyRing keychain.SecretKeyRing + + // ChainParams identifies the Bitcoin network. + ChainParams *chaincfg.Params + + // WalletLog is an optional logger for this wallet instance. + WalletLog fn.Option[btclog.Logger] +} + +// Logger returns the configured logger or falls back to extracting +// from context. If no logger is found in either location, returns +// btclog.Disabled. +func (w *Wallet) Logger(ctx context.Context) btclog.Logger { + return w.WalletLog.UnwrapOr(build.LoggerFromContext(ctx)) +} + +// DeriveNextKey derives the next key in the specified key family. +// This delegates to the btcwallet-backed keyring. +func (w *Wallet) DeriveNextKey(_ context.Context, + family keychain.KeyFamily) (*keychain.KeyDescriptor, error) { + + desc, err := w.KeyRing.DeriveNextKey(family) + if err != nil { + return nil, fmt.Errorf("derive next key: %w", err) + } + + return &desc, nil +} + +// DeriveKey derives a specific key identified by the given +// KeyLocator. Unlike DeriveNextKey, this always returns the same key +// for the same locator, making it suitable for stable identity keys. +func (w *Wallet) DeriveKey(_ context.Context, + loc keychain.KeyLocator) (*keychain.KeyDescriptor, error) { + + desc, err := w.KeyRing.DeriveKey(loc) + if err != nil { + return nil, fmt.Errorf("derive key: %w", err) + } + + return &desc, nil +} + +// NewAddress generates a new BIP86 taproot receiving address (P2TR +// key-path only) via btcwallet. +func (w *Wallet) NewAddress( + ctx context.Context) (btcutil.Address, error) { + + addr, err := w.BtcWallet.NewAddress( + lnwallet.TaprootPubkey, false, + lnwallet.DefaultAccountName, + ) + if err != nil { + return nil, err + } + + w.Logger(ctx).DebugS(ctx, "Generated new P2TR address", + slog.String("address", addr.String())) + + return addr, nil +} + +// Balance returns the confirmed and unconfirmed balance across all +// wallet-managed addresses. Confirmed balance requires at least 1 +// confirmation. +func (w *Wallet) Balance( + ctx context.Context) (btcutil.Amount, btcutil.Amount, error) { + + syncedTo := w.BtcWallet.InternalWallet().SyncedTo() + chainSynced := w.BtcWallet.InternalWallet().ChainSynced() + w.Logger(ctx).DebugS(ctx, "Checking wallet balance", + slog.Int("sync_height", int(syncedTo.Height)), + slog.String("sync_hash", syncedTo.Hash.String()), + slog.Bool("chain_synced", chainSynced)) + + confirmed, err := w.BtcWallet.ConfirmedBalance(1, "") + if err != nil { + return 0, 0, fmt.Errorf( + "get confirmed balance: %w", err, + ) + } + + // Total includes unconfirmed (0-conf) outputs. + total, err := w.BtcWallet.ConfirmedBalance(0, "") + if err != nil { + return 0, 0, fmt.Errorf("get total balance: %w", err) + } + + unconfirmed := total - confirmed + + w.Logger(ctx).DebugS(ctx, "Wallet balance result", + slog.Int64("confirmed_sats", int64(confirmed)), + slog.Int64("unconfirmed_sats", int64(unconfirmed)), + slog.Int64("total_sats", int64(total))) + + return confirmed, unconfirmed, nil +} + +// InternalWallet returns the underlying btcwallet instance for +// advanced operations not exposed through the Wallet API. +func (w *Wallet) InternalWallet() *btcwallet.BtcWallet { + return w.BtcWallet +} + +// ConfirmedBalance returns the confirmed balance with the specified +// minimum confirmations. This is a convenience wrapper around +// btcwallet's ConfirmedBalance. +func (w *Wallet) ConfirmedBalance( + minConfs int32) (btcutil.Amount, error) { + + return w.BtcWallet.ConfirmedBalance(minConfs, "") +} + +// ListUnspentWitness returns all unspent witness outputs with +// confirmations in the given range. This delegates to btcwallet's +// ListUnspentWitness which returns P2WKH, P2TR, and nested P2SH +// outputs. +func (w *Wallet) ListUnspentWitness(minConfs, + maxConfs int32) ([]*lnwallet.Utxo, error) { + + return w.BtcWallet.ListUnspentWitness( + minConfs, maxConfs, "", + ) +} + +// GetKeyRing returns the wallet's secret key ring for key derivation +// and message signing operations that need direct access to +// wallet-owned keys. +func (w *Wallet) GetKeyRing() keychain.SecretKeyRing { + return w.KeyRing +}