diff --git a/chainntnfs/bitcoindnotify/bitcoind.go b/chainntnfs/bitcoindnotify/bitcoind.go index a1beb78207d..77c0606201c 100644 --- a/chainntnfs/bitcoindnotify/bitcoind.go +++ b/chainntnfs/bitcoindnotify/bitcoind.go @@ -52,6 +52,9 @@ type BitcoindNotifier struct { notificationCancels chan interface{} notificationRegistry chan interface{} + historicalPkScriptDispatches chan *chainntnfs.HistoricalPkScriptDispatch + historicalPkScriptDispatchSlots chan struct{} + txNotifier *chainntnfs.TxNotifier blockEpochClients map[uint64]*blockEpochRegistration @@ -99,6 +102,14 @@ func New(chainConn *chain.BitcoindConn, chainParams *chaincfg.Params, notificationCancels: make(chan interface{}), notificationRegistry: make(chan interface{}), + historicalPkScriptDispatches: make( + chan *chainntnfs.HistoricalPkScriptDispatch, + chainntnfs.MaxPkScriptHistoricalDispatchQueueSize, + ), + historicalPkScriptDispatchSlots: make( + chan struct{}, + chainntnfs.MaxPkScriptHistoricalDispatchQueueSize, + ), blockEpochClients: make(map[uint64]*blockEpochRegistration), @@ -206,6 +217,9 @@ func (b *BitcoindNotifier) startNotifier() error { b.wg.Add(1) go b.notificationDispatcher() + b.wg.Add(1) + go b.historicalPkScriptDispatcher() + // Set the active flag now that we've completed the full // startup. atomic.StoreInt32(&b.active, 1) @@ -351,6 +365,16 @@ out: } }(msg) + case *chainntnfs.HistoricalPkScriptDispatch: + err := b.queueHistoricalPkScriptDispatch(msg) + if err != nil { + chainntnfs.Log.Errorf("Unable to queue "+ + "historical pkScript dispatch for "+ + "sub=%d within range %d-%d: %v", + msg.SubscriptionID, msg.StartHeight, + msg.EndHeight, err) + } + case *blockEpochRegistration: chainntnfs.Log.Infof("New block epoch subscription") @@ -917,6 +941,128 @@ func (b *BitcoindNotifier) historicalSpendDetails( return nil, nil } +// queueHistoricalPkScriptDispatch reserves capacity and queues a historical +// pkScript scan for the bitcoind backend. +func (b *BitcoindNotifier) queueHistoricalPkScriptDispatch( + msg *chainntnfs.HistoricalPkScriptDispatch) error { + + if msg == nil { + return nil + } + + err := b.reserveHistoricalPkScriptDispatchSlot() + if err != nil { + return err + } + + return b.queueReservedHistoricalPkScriptDispatch(msg) +} + +// reserveHistoricalPkScriptDispatchSlot reserves one pending historical pkScript +// scan slot. +func (b *BitcoindNotifier) reserveHistoricalPkScriptDispatchSlot() error { + select { + case <-b.quit: + return chainntnfs.ErrChainNotifierShuttingDown + default: + } + + select { + case b.historicalPkScriptDispatchSlots <- struct{}{}: + return nil + + case <-b.quit: + return chainntnfs.ErrChainNotifierShuttingDown + + default: + return fmt.Errorf("%w: pending scans %d exceeds limit %d", + chainntnfs.ErrTooManyHistoricalPkScriptScans, + len(b.historicalPkScriptDispatchSlots), + chainntnfs.MaxPkScriptHistoricalDispatchQueueSize) + } +} + +// releaseHistoricalPkScriptDispatchSlot releases one pending historical pkScript +// scan slot. +func (b *BitcoindNotifier) releaseHistoricalPkScriptDispatchSlot() { + select { + case <-b.historicalPkScriptDispatchSlots: + default: + } +} + +// queueReservedHistoricalPkScriptDispatch queues a dispatch after capacity has +// already been reserved. +func (b *BitcoindNotifier) queueReservedHistoricalPkScriptDispatch( + msg *chainntnfs.HistoricalPkScriptDispatch) error { + + select { + case b.historicalPkScriptDispatches <- msg: + return nil + + case <-b.quit: + b.releaseHistoricalPkScriptDispatchSlot() + + return chainntnfs.ErrChainNotifierShuttingDown + } +} + +// historicalPkScriptDispatcher serially executes queued historical pkScript +// scans. +func (b *BitcoindNotifier) historicalPkScriptDispatcher() { + defer b.wg.Done() + + for { + select { + case msg := <-b.historicalPkScriptDispatches: + err := b.historicalPkScriptDispatch(msg) + b.releaseHistoricalPkScriptDispatchSlot() + if err != nil { + chainntnfs.Log.Errorf("Historical pkScript dispatch "+ + "for sub=%d within range %d-%d failed: %v", + msg.SubscriptionID, msg.StartHeight, + msg.EndHeight, err) + } + + case <-b.quit: + return + } + } +} + +// historicalPkScriptDispatch manually scans blocks for pkScript activity for a +// single subscription. +func (b *BitcoindNotifier) historicalPkScriptDispatch( + msg *chainntnfs.HistoricalPkScriptDispatch) error { + + return b.txNotifier.SyncHistoricalPkScriptDispatch( + msg, + func(height uint32) error { + select { + case <-b.quit: + return chainntnfs.ErrChainNotifierShuttingDown + default: + } + + blockHash, err := b.chainConn.GetBlockHash(int64(height)) + if err != nil { + return fmt.Errorf("unable to retrieve hash for block "+ + "with height %d: %v", height, err) + } + + block, err := b.GetBlock(blockHash) + if err != nil { + return fmt.Errorf("unable to retrieve block with hash "+ + "%v: %v", blockHash, err) + } + + return b.txNotifier.ProcessHistoricalPkScriptBlockWithDispatch( + msg, btcutil.NewBlock(block), height, + ) + }, + ) +} + // RegisterConfirmationsNtfn registers an intent to be notified once the target // txid/output script has reached numConfs confirmations on-chain. When // intending to be notified of the confirmation of an output script, a nil txid @@ -953,6 +1099,49 @@ func (b *BitcoindNotifier) RegisterConfirmationsNtfn(txid *chainhash.Hash, } } +// RegisterPkScriptNotifier creates a new pkScript notification stream. +func (b *BitcoindNotifier) RegisterPkScriptNotifier() ( + *chainntnfs.PkScriptNotificationRegistration, error) { + + reg, err := b.txNotifier.RegisterPkScriptNotifier() + if err != nil { + return nil, err + } + + reg.Event.AddPkScripts = func(pkScripts [][]byte, + opts ...chainntnfs.NotifierOption) (*chainntnfs.PkScriptAddResult, + error) { + + dispatch, _, addedScripts, err := reg.AddPkScripts( + pkScripts, opts..., + ) + if err != nil { + return nil, err + } + result := chainntnfs.NewPkScriptAddResult(dispatch, addedScripts) + + err = b.queueHistoricalPkScriptDispatch(dispatch) + if err != nil { + removeErr := reg.RemovePkScripts(addedScripts) + if removeErr != nil { + return nil, fmt.Errorf("unable to queue historical "+ + "pkScript scan: %w, rollback failed: %v", + err, removeErr) + } + + return nil, err + } + + return result, nil + } + + reg.Event.RemovePkScripts = func(pkScripts [][]byte) error { + return reg.RemovePkScripts(pkScripts) + } + + return reg.Event, nil +} + // blockEpochRegistration represents a client's intent to receive a // notification with each newly connected block. type blockEpochRegistration struct { diff --git a/chainntnfs/btcdnotify/btcd.go b/chainntnfs/btcdnotify/btcd.go index 91178044cfb..9bb27c1153b 100644 --- a/chainntnfs/btcdnotify/btcd.go +++ b/chainntnfs/btcdnotify/btcd.go @@ -66,6 +66,9 @@ type BtcdNotifier struct { notificationCancels chan interface{} notificationRegistry chan interface{} + historicalPkScriptDispatches chan *chainntnfs.HistoricalPkScriptDispatch + historicalPkScriptDispatchSlots chan struct{} + txNotifier *chainntnfs.TxNotifier blockEpochClients map[uint64]*blockEpochRegistration @@ -114,6 +117,14 @@ func New(config *rpcclient.ConnConfig, chainParams *chaincfg.Params, notificationCancels: make(chan interface{}), notificationRegistry: make(chan interface{}), + historicalPkScriptDispatches: make( + chan *chainntnfs.HistoricalPkScriptDispatch, + chainntnfs.MaxPkScriptHistoricalDispatchQueueSize, + ), + historicalPkScriptDispatchSlots: make( + chan struct{}, + chainntnfs.MaxPkScriptHistoricalDispatchQueueSize, + ), blockEpochClients: make(map[uint64]*blockEpochRegistration), @@ -262,6 +273,9 @@ func (b *BtcdNotifier) startNotifier() error { b.wg.Add(1) go b.notificationDispatcher() + b.wg.Add(1) + go b.historicalPkScriptDispatcher() + // Set the active flag now that we've completed the full // startup. atomic.StoreInt32(&b.active, 1) @@ -405,6 +419,16 @@ out: } }(msg) + case *chainntnfs.HistoricalPkScriptDispatch: + err := b.queueHistoricalPkScriptDispatch(msg) + if err != nil { + chainntnfs.Log.Errorf("Unable to queue "+ + "historical pkScript dispatch for "+ + "sub=%d within range %d-%d: %v", + msg.SubscriptionID, msg.StartHeight, + msg.EndHeight, err) + } + case *blockEpochRegistration: chainntnfs.Log.Infof("New block epoch subscription") @@ -1014,6 +1038,171 @@ func (b *BtcdNotifier) RegisterConfirmationsNtfn(txid *chainhash.Hash, } } +// queueHistoricalPkScriptDispatch reserves capacity and queues a historical +// pkScript scan for the btcd backend. +func (b *BtcdNotifier) queueHistoricalPkScriptDispatch( + msg *chainntnfs.HistoricalPkScriptDispatch) error { + + if msg == nil { + return nil + } + + err := b.reserveHistoricalPkScriptDispatchSlot() + if err != nil { + return err + } + + return b.queueReservedHistoricalPkScriptDispatch(msg) +} + +// reserveHistoricalPkScriptDispatchSlot reserves one pending historical pkScript +// scan slot. +func (b *BtcdNotifier) reserveHistoricalPkScriptDispatchSlot() error { + select { + case <-b.quit: + return chainntnfs.ErrChainNotifierShuttingDown + default: + } + + select { + case b.historicalPkScriptDispatchSlots <- struct{}{}: + return nil + + case <-b.quit: + return chainntnfs.ErrChainNotifierShuttingDown + + default: + return fmt.Errorf("%w: pending scans %d exceeds limit %d", + chainntnfs.ErrTooManyHistoricalPkScriptScans, + len(b.historicalPkScriptDispatchSlots), + chainntnfs.MaxPkScriptHistoricalDispatchQueueSize) + } +} + +// releaseHistoricalPkScriptDispatchSlot releases one pending historical pkScript +// scan slot. +func (b *BtcdNotifier) releaseHistoricalPkScriptDispatchSlot() { + select { + case <-b.historicalPkScriptDispatchSlots: + default: + } +} + +// queueReservedHistoricalPkScriptDispatch queues a dispatch after capacity has +// already been reserved. +func (b *BtcdNotifier) queueReservedHistoricalPkScriptDispatch( + msg *chainntnfs.HistoricalPkScriptDispatch) error { + + select { + case b.historicalPkScriptDispatches <- msg: + return nil + + case <-b.quit: + b.releaseHistoricalPkScriptDispatchSlot() + + return chainntnfs.ErrChainNotifierShuttingDown + } +} + +// historicalPkScriptDispatcher serially executes queued historical pkScript +// scans. +func (b *BtcdNotifier) historicalPkScriptDispatcher() { + defer b.wg.Done() + + for { + select { + case msg := <-b.historicalPkScriptDispatches: + err := b.historicalPkScriptDispatch(msg) + b.releaseHistoricalPkScriptDispatchSlot() + if err != nil { + chainntnfs.Log.Errorf("Historical pkScript dispatch "+ + "for sub=%d within range %d-%d failed: %v", + msg.SubscriptionID, msg.StartHeight, + msg.EndHeight, err) + } + + case <-b.quit: + return + } + } +} + +// historicalPkScriptDispatch manually scans blocks for pkScript activity for a +// single subscription. +func (b *BtcdNotifier) historicalPkScriptDispatch( + msg *chainntnfs.HistoricalPkScriptDispatch) error { + + return b.txNotifier.SyncHistoricalPkScriptDispatch( + msg, + func(height uint32) error { + select { + case <-b.quit: + return chainntnfs.ErrChainNotifierShuttingDown + default: + } + + blockHash, err := b.chainConn.GetBlockHash(int64(height)) + if err != nil { + return fmt.Errorf("unable to retrieve hash for block "+ + "with height %d: %v", height, err) + } + + block, err := b.GetBlock(blockHash) + if err != nil { + return fmt.Errorf("unable to retrieve block with hash "+ + "%v: %v", blockHash, err) + } + + return b.txNotifier.ProcessHistoricalPkScriptBlockWithDispatch( + msg, btcutil.NewBlock(block), height, + ) + }, + ) +} + +// RegisterPkScriptNotifier creates a new pkScript notification stream. +func (b *BtcdNotifier) RegisterPkScriptNotifier() ( + *chainntnfs.PkScriptNotificationRegistration, error) { + + reg, err := b.txNotifier.RegisterPkScriptNotifier() + if err != nil { + return nil, err + } + + reg.Event.AddPkScripts = func(pkScripts [][]byte, + opts ...chainntnfs.NotifierOption) (*chainntnfs.PkScriptAddResult, + error) { + + dispatch, _, addedScripts, err := reg.AddPkScripts( + pkScripts, opts..., + ) + if err != nil { + return nil, err + } + result := chainntnfs.NewPkScriptAddResult(dispatch, addedScripts) + + err = b.queueHistoricalPkScriptDispatch(dispatch) + if err != nil { + removeErr := reg.RemovePkScripts(addedScripts) + if removeErr != nil { + return nil, fmt.Errorf("unable to queue historical "+ + "pkScript scan: %w, rollback failed: %v", + err, removeErr) + } + + return nil, err + } + + return result, nil + } + + reg.Event.RemovePkScripts = func(pkScripts [][]byte) error { + return reg.RemovePkScripts(pkScripts) + } + + return reg.Event, nil +} + // blockEpochRegistration represents a client's intent to receive a // notification with each newly connected block. type blockEpochRegistration struct { diff --git a/chainntnfs/interface.go b/chainntnfs/interface.go index 342d268fd72..1352ae6ee69 100644 --- a/chainntnfs/interface.go +++ b/chainntnfs/interface.go @@ -22,6 +22,13 @@ var ( ErrChainNotifierShuttingDown = errors.New("chain notifier shutting down") ) +const ( + // pkScriptNotificationChanSize is the handoff buffer between the + // notifier's bounded internal queue and the caller-facing notification + // channel. + pkScriptNotificationChanSize = 1 +) + // TxConfStatus denotes the status of a transaction's lookup. type TxConfStatus uint8 @@ -74,13 +81,36 @@ func (t TxConfStatus) String() string { // modify the type of chain event notifications they receive. type NotifierOptions struct { // IncludeBlock if true, then the dispatched confirmation notification - // will include the block that mined the transaction. + // will include the relevant block. IncludeBlock bool + + // IncludeTx if true, then the dispatched pkScript notification will + // include the transaction relevant to the event. + IncludeTx bool + + // Events is the set of pkScript events that should be watched. + Events PkScriptEventType + + // NumConfs is the confirmation depth required before final + // confirmation notifications are dispatched. + NumConfs uint32 + + // HistoricalScanFrom, if non-nil, requests a historical scan starting + // at the given block height. A nil value means future-only watching. + HistoricalScanFrom *uint32 + + // IncludeConfirmationUpdates, if true, requests partial confirmation + // progress notifications before the final confirmation notification is + // dispatched. + IncludeConfirmationUpdates bool } // DefaultNotifierOptions returns the set of default options for the notifier. func DefaultNotifierOptions() *NotifierOptions { - return &NotifierOptions{} + return &NotifierOptions{ + Events: PkScriptEventConfirm | PkScriptEventSpend, + NumConfs: 1, + } } // NotifierOption is a functional option that allows a caller to modify the @@ -88,13 +118,57 @@ func DefaultNotifierOptions() *NotifierOptions { type NotifierOption func(*NotifierOptions) // WithIncludeBlock is an optional argument that allows the caller to specify -// that the block that mined a transaction should be included in the response. +// that the relevant block should be included in the response. func WithIncludeBlock() NotifierOption { return func(o *NotifierOptions) { o.IncludeBlock = true } } +// WithIncludeTx is an optional argument that allows the caller to specify that +// the transaction relevant to a pkScript notification should be included in the +// response. +func WithIncludeTx() NotifierOption { + return func(o *NotifierOptions) { + o.IncludeTx = true + } +} + +// WithEvents is an optional argument that sets which pkScript events should be +// watched by an AddPkScripts call. +func WithEvents(events PkScriptEventType) NotifierOption { + return func(o *NotifierOptions) { + o.Events = events + } +} + +// WithNumConfs is an optional argument that sets the confirmation depth +// required before final pkScript confirmation notifications are dispatched. +func WithNumConfs(numConfs uint32) NotifierOption { + return func(o *NotifierOptions) { + o.NumConfs = numConfs + } +} + +// WithHistoricalScanFrom is an optional argument that requests a historical +// pkScript scan from the given block height through the current tip. Omitting +// this option means future-only watching. Passing height 0 is allowed and means +// an explicit scan from genesis. +func WithHistoricalScanFrom(height uint32) NotifierOption { + return func(o *NotifierOptions) { + o.HistoricalScanFrom = &height + } +} + +// WithIncludeConfirmationUpdates is an optional argument that asks the +// notifier to send partial confirmation progress notifications for watched +// pkScripts before the requested confirmation depth is reached. +func WithIncludeConfirmationUpdates() NotifierOption { + return func(o *NotifierOptions) { + o.IncludeConfirmationUpdates = true + } +} + // ChainNotifier represents a trusted source to receive notifications concerning // targeted events on the Bitcoin blockchain. The interface specification is // intentionally general in order to support a wide array of chain notification @@ -178,6 +252,27 @@ type ChainNotifier interface { Stop() error } +// PkScriptNotifier provides a subscription-based interface for receiving +// per-block spend/confirmation notifications for all on-chain occurrences of +// watched pkScripts. +// +// Callers that already know the exact transaction to confirm or the exact +// outpoint to spend should prefer RegisterConfirmationsNtfn or +// RegisterSpendNtfn, which track a single target rather than a stream of +// script matches. +// +// AddPkScripts acknowledgments only mean the scripts were accepted and any +// requested historical scan was queued; they do not mean historical backfill has +// completed. Confirmation notifications are only sent once the requested +// confirmation depth is reached. Reorg invalidations are sent on the same stream +// with Disconnected=true. +type PkScriptNotifier interface { + // RegisterPkScriptNotifier creates a new pkScript notification stream. + // Scripts are added explicitly with AddPkScripts on the returned + // registration. + RegisterPkScriptNotifier() (*PkScriptNotificationRegistration, error) +} + // TxConfirmation carries some additional block-level details of the exact // block that specified transactions was confirmed within. type TxConfirmation struct { @@ -363,6 +458,147 @@ type SpendEvent struct { Cancel func() } +// PkScriptEventType identifies which events a pkScript watch should receive. +type PkScriptEventType uint8 + +const ( + // PkScriptEventConfirm enables confirmation notifications for + // matched outputs once they reach the requested confirmation depth. + PkScriptEventConfirm PkScriptEventType = 1 << iota + + // PkScriptEventSpend enables spend notifications for matched outputs. + PkScriptEventSpend +) + +// Has returns true if the receiver contains the given flag. +func (t PkScriptEventType) Has(flag PkScriptEventType) bool { + return t&flag == flag +} + +// PkScriptNotificationType identifies the type of pkScript notification. +type PkScriptNotificationType uint8 + +const ( + // PkScriptNotificationConfirm signals that a matched output has reached + // the configured confirmation depth. + PkScriptNotificationConfirm PkScriptNotificationType = iota + + // PkScriptNotificationSpend signals a spend of a previously matched + // output. + PkScriptNotificationSpend + + // PkScriptNotificationConfirmUpdate signals partial confirmation + // progress for a matched output before it reaches the configured + // confirmation depth. + PkScriptNotificationConfirmUpdate + + // PkScriptNotificationHistoricalScanComplete signals that a historical + // scan requested by an AddPkScripts call has completed or failed. + PkScriptNotificationHistoricalScanComplete +) + +// PkScriptUTXO describes a UTXO created for a watched pkScript. +type PkScriptUTXO struct { + OutPoint wire.OutPoint + Value btcutil.Amount + PkScript []byte + BlockHeight uint32 + BlockHash *chainhash.Hash + TxIndex uint32 +} + +// PkScriptNotification describes a spend or confirmation event for a watched +// pkScript. +type PkScriptNotification struct { + Type PkScriptNotificationType + Height uint32 + BlockHash *chainhash.Hash + TxHash *chainhash.Hash + + // TxIndex is the index of the relevant transaction in the block that + // mined it. For confirmations, this is the funding transaction's index + // in the UTXO block. For spends, this is the spending transaction's index + // in the spend block. + TxIndex uint32 + + // InputIndex is the index of the input that spends the watched UTXO. It + // is only set for spend notifications. + InputIndex uint32 + NumConfirmations uint32 + RequiredConfs uint32 + Disconnected bool + UTXO *PkScriptUTXO + + // Tx is the transaction relevant to this notification if the caller + // requested that full transactions be included. For confirmation + // notifications, this is the transaction that created the watched UTXO. + // For spend notifications, this is the transaction that spent it. + Tx *wire.MsgTx + + // Block is the block relevant to this notification if the caller + // requested that full blocks be included. For confirmation + // notifications, this is the block at which the watched UTXO reached + // the required confirmation depth. For spend notifications, this is the + // block containing the spend. + Block *wire.MsgBlock + + // HistoricalScan, if non-nil, contains lifecycle details for a + // historical pkScript scan. + HistoricalScan *PkScriptHistoricalScan +} + +// PkScriptHistoricalScan describes the completion status of a historical +// pkScript scan. +type PkScriptHistoricalScan struct { + ScanID uint64 + StartHeight uint32 + EndHeight uint32 + CompletedHeight uint32 + Error string +} + +// PkScriptAddResult describes the result of adding pkScripts to a registration. +type PkScriptAddResult struct { + HistoricalScanID uint64 + HistoricalScanQueued bool + HistoricalScanStartHeight uint32 + HistoricalScanEndHeight uint32 + NumAdded uint32 +} + +// PkScriptNotificationRegistration encapsulates an on-going stream of pkScript +// notifications. +// +// NOTE: If the caller wishes to cancel their registered pkScript notification, +// the Cancel closure MUST be called. +type PkScriptNotificationRegistration struct { + notifications chan *PkScriptNotification + + // Notifications is a receive-only channel that will be sent upon for + // each matched confirmation/spend event. + // + // NOTE: This channel must be buffered. + Notifications <-chan *PkScriptNotification + + // AddPkScripts adds scripts to this notification stream and returns + // metadata for any historical scan queued by the mutation. Scripts + // already watched by this stream are ignored and keep their original + // options. + AddPkScripts func(pkScripts [][]byte, + opts ...NotifierOption) (*PkScriptAddResult, error) + + // RemovePkScripts removes scripts from this notification stream. + RemovePkScripts func(pkScripts [][]byte) error + + // Err returns the terminal error that closed the notification stream, if + // known. It returns nil while the stream is active. + Err func() error + + // Cancel is a closure that should be executed by the caller in the case + // that they wish to abandon their registered pkScript notification. + Cancel func() +} + // NewSpendEvent constructs a new SpendEvent with newly opened channels. func NewSpendEvent(cancel func()) *SpendEvent { return &SpendEvent{ @@ -373,6 +609,25 @@ func NewSpendEvent(cancel func()) *SpendEvent { } } +// NewPkScriptNotificationRegistration constructs a new pkScript notification +// registration with a newly opened channel. +func NewPkScriptNotificationRegistration( + cancel func()) *PkScriptNotificationRegistration { + + notifications := make( + chan *PkScriptNotification, pkScriptNotificationChanSize, + ) + + return &PkScriptNotificationRegistration{ + notifications: notifications, + Notifications: notifications, + Err: func() error { + return nil + }, + Cancel: cancel, + } +} + // BlockEpoch represents metadata concerning each new block connected to the // main chain. type BlockEpoch struct { diff --git a/chainntnfs/neutrinonotify/neutrino.go b/chainntnfs/neutrinonotify/neutrino.go index 51e82a118aa..b6a6fdf68b9 100644 --- a/chainntnfs/neutrinonotify/neutrino.go +++ b/chainntnfs/neutrinonotify/neutrino.go @@ -11,6 +11,7 @@ import ( "github.com/btcsuite/btcd/btcjson" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/gcs/builder" + "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/rpcclient" "github.com/btcsuite/btcd/txscript" @@ -56,6 +57,9 @@ type NeutrinoNotifier struct { notificationCancels chan interface{} notificationRegistry chan interface{} + historicalPkScriptDispatches chan *chainntnfs.HistoricalPkScriptDispatch + historicalPkScriptDispatchSlots chan struct{} + txNotifier *chainntnfs.TxNotifier blockEpochClients map[uint64]*blockEpochRegistration @@ -98,6 +102,14 @@ func New(node *neutrino.ChainService, spendHintCache chainntnfs.SpendHintCache, return &NeutrinoNotifier{ notificationCancels: make(chan interface{}), notificationRegistry: make(chan interface{}), + historicalPkScriptDispatches: make( + chan *chainntnfs.HistoricalPkScriptDispatch, + chainntnfs.MaxPkScriptHistoricalDispatchQueueSize, + ), + historicalPkScriptDispatchSlots: make( + chan struct{}, + chainntnfs.MaxPkScriptHistoricalDispatchQueueSize, + ), blockEpochClients: make(map[uint64]*blockEpochRegistration), @@ -232,6 +244,9 @@ func (n *NeutrinoNotifier) startNotifier() error { n.wg.Add(1) go n.notificationDispatcher() + n.wg.Add(1) + go n.historicalPkScriptDispatcher() + // Set the active flag now that we've completed the full // startup. atomic.StoreInt32(&n.active, 1) @@ -471,6 +486,16 @@ func (n *NeutrinoNotifier) notificationDispatcher() { } }(msg) + case *chainntnfs.HistoricalPkScriptDispatch: + err := n.queueHistoricalPkScriptDispatch(msg) + if err != nil { + chainntnfs.Log.Errorf("Unable to queue "+ + "historical pkScript dispatch for "+ + "sub=%d within range %d-%d: %v", + msg.SubscriptionID, msg.StartHeight, + msg.EndHeight, err) + } + case *blockEpochRegistration: chainntnfs.Log.Infof("New block epoch subscription") @@ -918,6 +943,124 @@ func (n *NeutrinoNotifier) RegisterSpendNtfn(outpoint *wire.OutPoint, return ntfn.Event, nil } +// pkScriptsToAddrs converts scripts into the unique set of addresses that +// neutrino can watch for compact filter matching. +func pkScriptsToAddrs(pkScripts [][]byte, + params chaincfg.Params) ([]btcutil.Address, error) { + + dedup := make(map[string]btcutil.Address) + + for _, pkScript := range pkScripts { + _, addrs, _, err := txscript.ExtractPkScriptAddrs( + pkScript, ¶ms, + ) + if err != nil { + return nil, fmt.Errorf("unable to extract script: %w", err) + } + if len(addrs) == 0 { + return nil, fmt.Errorf("%w: script cannot be watched "+ + "by neutrino compact filters", + chainntnfs.ErrUnsupportedPkScript) + } + + for _, addr := range addrs { + dedup[addr.EncodeAddress()] = addr + } + } + + addrs := make([]btcutil.Address, 0, len(dedup)) + for _, addr := range dedup { + addrs = append(addrs, addr) + } + + return addrs, nil +} + +// pkScriptsToAddrs converts scripts into neutrino filter addresses using +// the notifier's active chain parameters. +func (n *NeutrinoNotifier) pkScriptsToAddrs( + pkScripts [][]byte) ([]btcutil.Address, error) { + + params := n.p2pNode.ChainParams() + + return pkScriptsToAddrs(pkScripts, params) +} + +// updatePkScriptFilter updates neutrino's rescan filter to watch for the given +// pkScripts. +func (n *NeutrinoNotifier) updatePkScriptFilter(pkScripts [][]byte, + rewindHeight uint32) error { + + addrs, err := n.pkScriptsToAddrs(pkScripts) + if err != nil { + return err + } + if len(addrs) == 0 { + return nil + } + + errChan := make(chan error, 1) + select { + case n.notificationRegistry <- &rescanFilterUpdate{ + updateOptions: []neutrino.UpdateOption{ + neutrino.AddAddrs(addrs...), + neutrino.Rewind(rewindHeight), + neutrino.DisableDisconnectedNtfns(true), + }, + errChan: errChan, + }: + case <-n.quit: + return chainntnfs.ErrChainNotifierShuttingDown + } + + select { + case err = <-errChan: + case <-n.quit: + return chainntnfs.ErrChainNotifierShuttingDown + } + if err != nil { + return fmt.Errorf("unable to update filter: %w", err) + } + + return nil +} + +// validatePkScriptFilterUpdate validates that scripts can be added to the +// compact filter watch set. +func (n *NeutrinoNotifier) validatePkScriptFilterUpdate(pkScripts [][]byte) error { + + if len(pkScripts) == 0 { + return chainntnfs.ErrNoScript + } + if len(pkScripts) > chainntnfs.MaxPkScriptsPerBatch { + return fmt.Errorf("%w: batch size %d exceeds limit %d", + chainntnfs.ErrTooManyPkScripts, len(pkScripts), + chainntnfs.MaxPkScriptsPerBatch) + } + + var batchBytes uint64 + for _, pkScript := range pkScripts { + if len(pkScript) == 0 { + return chainntnfs.ErrNoScript + } + if len(pkScript) > txscript.MaxScriptSize { + return fmt.Errorf("%w: script size %d exceeds limit %d", + chainntnfs.ErrPkScriptTooLarge, len(pkScript), + txscript.MaxScriptSize) + } + batchBytes += uint64(len(pkScript)) + if batchBytes > chainntnfs.MaxPkScriptBatchBytes { + return fmt.Errorf("%w: batch byte size %d exceeds limit %d", + chainntnfs.ErrTooManyPkScripts, batchBytes, + chainntnfs.MaxPkScriptBatchBytes) + } + } + + _, err := n.pkScriptsToAddrs(pkScripts) + + return err +} + // RegisterConfirmationsNtfn registers an intent to be notified once the target // txid/output script has reached numConfs confirmations on-chain. When // intending to be notified of the confirmation of an output script, a nil txid @@ -1007,6 +1150,229 @@ func (n *NeutrinoNotifier) RegisterConfirmationsNtfn(txid *chainhash.Hash, return ntfn.Event, nil } +// queueHistoricalPkScriptDispatch reserves capacity and queues a historical +// pkScript scan for the neutrino backend. +func (n *NeutrinoNotifier) queueHistoricalPkScriptDispatch( + msg *chainntnfs.HistoricalPkScriptDispatch) error { + + if msg == nil { + return nil + } + + err := n.reserveHistoricalPkScriptDispatchSlot() + if err != nil { + return err + } + + return n.queueReservedHistoricalPkScriptDispatch(msg) +} + +// reserveHistoricalPkScriptDispatchSlot reserves one pending historical pkScript +// scan slot. +func (n *NeutrinoNotifier) reserveHistoricalPkScriptDispatchSlot() error { + select { + case <-n.quit: + return chainntnfs.ErrChainNotifierShuttingDown + default: + } + + select { + case n.historicalPkScriptDispatchSlots <- struct{}{}: + return nil + + case <-n.quit: + return chainntnfs.ErrChainNotifierShuttingDown + + default: + return fmt.Errorf("%w: pending scans %d exceeds limit %d", + chainntnfs.ErrTooManyHistoricalPkScriptScans, + len(n.historicalPkScriptDispatchSlots), + chainntnfs.MaxPkScriptHistoricalDispatchQueueSize) + } +} + +// releaseHistoricalPkScriptDispatchSlot releases one pending historical pkScript +// scan slot. +func (n *NeutrinoNotifier) releaseHistoricalPkScriptDispatchSlot() { + select { + case <-n.historicalPkScriptDispatchSlots: + default: + } +} + +// queueReservedHistoricalPkScriptDispatch queues a dispatch after capacity has +// already been reserved. +func (n *NeutrinoNotifier) queueReservedHistoricalPkScriptDispatch( + msg *chainntnfs.HistoricalPkScriptDispatch) error { + + select { + case n.historicalPkScriptDispatches <- msg: + return nil + + case <-n.quit: + n.releaseHistoricalPkScriptDispatchSlot() + + return chainntnfs.ErrChainNotifierShuttingDown + } +} + +// historicalPkScriptDispatcher serially executes queued historical pkScript +// scans. +func (n *NeutrinoNotifier) historicalPkScriptDispatcher() { + defer n.wg.Done() + + for { + select { + case msg := <-n.historicalPkScriptDispatches: + err := n.historicalPkScriptDispatch(msg) + n.releaseHistoricalPkScriptDispatchSlot() + if err != nil { + chainntnfs.Log.Errorf("Historical pkScript dispatch "+ + "for sub=%d within range %d-%d failed: %v", + msg.SubscriptionID, msg.StartHeight, + msg.EndHeight, err) + } + + case <-n.quit: + return + } + } +} + +// historicalPkScriptDispatch scans historical blocks for pkScript activity for a +// single subscription. +func (n *NeutrinoNotifier) historicalPkScriptDispatch( + msg *chainntnfs.HistoricalPkScriptDispatch) error { + + return n.txNotifier.SyncHistoricalPkScriptDispatch( + msg, + func(height uint32) error { + select { + case <-n.quit: + return chainntnfs.ErrChainNotifierShuttingDown + default: + } + + blockHash, err := n.p2pNode.GetBlockHash(int64(height)) + if err != nil { + return fmt.Errorf("unable to retrieve hash for block "+ + "with height %d: %v", height, err) + } + + block, err := n.GetBlock(*blockHash) + if err != nil { + return fmt.Errorf("unable to retrieve block with hash "+ + "%v: %v", blockHash, err) + } + + return n.txNotifier.ProcessHistoricalPkScriptBlockWithDispatch( + msg, block, height, + ) + }, + ) +} + +// pkScriptRollbackErr reports a mutation failure where undoing the partial +// registration also failed. +func pkScriptRollbackErr(action string, err, rollbackErr error) error { + return fmt.Errorf( + "unable to %s: %w, rollback failed: %v", + action, err, rollbackErr, + ) +} + +// RegisterPkScriptNotifier creates a new pkScript notification stream. +func (n *NeutrinoNotifier) RegisterPkScriptNotifier() ( + *chainntnfs.PkScriptNotificationRegistration, error) { + + reg, err := n.txNotifier.RegisterPkScriptNotifier() + if err != nil { + return nil, err + } + + reg.Event.AddPkScripts = func(pkScripts [][]byte, + opts ...chainntnfs.NotifierOption) (*chainntnfs.PkScriptAddResult, + error) { + + err := n.validatePkScriptFilterUpdate(pkScripts) + if err != nil { + return nil, err + } + + dispatch, addHeight, addedScripts, err := reg.AddPkScripts( + pkScripts, opts..., + ) + if err != nil { + return nil, err + } + + scanReserved := false + if dispatch != nil { + n.bestBlockMtx.RLock() + dispatch.EndHeight = uint32(n.bestBlock.Height) + n.bestBlockMtx.RUnlock() + + err = n.reserveHistoricalPkScriptDispatchSlot() + if err != nil { + removeErr := reg.RemovePkScripts(addedScripts) + if removeErr != nil { + return nil, pkScriptRollbackErr( + "queue historical scan", + err, removeErr, + ) + } + + return nil, err + } + scanReserved = true + } + + result := chainntnfs.NewPkScriptAddResult(dispatch, addedScripts) + + if len(addedScripts) > 0 { + err := n.updatePkScriptFilter(addedScripts, addHeight) + if err != nil { + if scanReserved { + n.releaseHistoricalPkScriptDispatchSlot() + } + + removeErr := reg.RemovePkScripts(addedScripts) + if removeErr != nil { + return nil, pkScriptRollbackErr( + "update filter", + err, removeErr, + ) + } + + return nil, err + } + } + + if scanReserved { + err := n.queueReservedHistoricalPkScriptDispatch(dispatch) + if err != nil { + removeErr := reg.RemovePkScripts(addedScripts) + if removeErr != nil { + return nil, pkScriptRollbackErr( + "queue historical scan", + err, removeErr, + ) + } + + return nil, err + } + } + + return result, nil + } + + reg.Event.RemovePkScripts = func(pkScripts [][]byte) error { + return reg.RemovePkScripts(pkScripts) + } + + return reg.Event, nil +} + // GetBlock is used to retrieve the block with the given hash. Since the block // cache used by neutrino will be the same as that used by LND (since it is // passed to neutrino on initialisation), the neutrino GetBlock method can be diff --git a/chainntnfs/neutrinonotify/neutrino_test.go b/chainntnfs/neutrinonotify/neutrino_test.go new file mode 100644 index 00000000000..78b4239ce95 --- /dev/null +++ b/chainntnfs/neutrinonotify/neutrino_test.go @@ -0,0 +1,43 @@ +package neutrinonotify + +import ( + "testing" + + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/txscript" + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/stretchr/testify/require" +) + +var testP2SHPkScript = []byte{ + txscript.OP_HASH160, + txscript.OP_DATA_20, + 0x90, 0x1c, 0x86, 0x94, 0xc0, 0x3f, 0xaf, 0xd5, + 0x52, 0x28, 0x10, 0xe0, 0x33, 0x0f, 0x26, 0xe6, + 0x7a, 0x85, 0x33, 0xcd, + txscript.OP_EQUAL, +} + +// TestPkScriptsToAddrsRejectsUnsupportedScripts ensures scripts that +// cannot be converted into neutrino filter addresses are rejected. +func TestPkScriptsToAddrsRejectsUnsupportedScripts(t *testing.T) { + t.Parallel() + + _, err := pkScriptsToAddrs( + [][]byte{{txscript.OP_TRUE}}, chaincfg.MainNetParams, + ) + require.ErrorIs(t, err, chainntnfs.ErrUnsupportedPkScript) +} + +// TestPkScriptsToAddrsDeduplicatesScripts ensures duplicate scripts only +// produce one neutrino filter address. +func TestPkScriptsToAddrsDeduplicatesScripts(t *testing.T) { + t.Parallel() + + addrs, err := pkScriptsToAddrs( + [][]byte{testP2SHPkScript, testP2SHPkScript}, + chaincfg.MainNetParams, + ) + require.NoError(t, err) + require.Len(t, addrs, 1) +} diff --git a/chainntnfs/pkscriptnotifier.go b/chainntnfs/pkscriptnotifier.go new file mode 100644 index 00000000000..c99dd09c73f --- /dev/null +++ b/chainntnfs/pkscriptnotifier.go @@ -0,0 +1,2398 @@ +package chainntnfs + +import ( + "container/list" + "errors" + "fmt" + "sync" + "sync/atomic" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" +) + +const ( + // MaxPkScriptNotificationQueueSize is the maximum number of pending + // pkScript notifications that may be queued for a single subscription + // beyond the registration channel's own buffer. + MaxPkScriptNotificationQueueSize = 1000 + + // MaxPkScriptNotificationQueueBytes is the approximate maximum memory + // footprint of pending pkScript notifications for a single subscription + // beyond the registration channel's own buffer. + MaxPkScriptNotificationQueueBytes = 32 << 20 + + // MaxPkScriptRegistrations is the maximum number of active pkScript + // notification streams. + MaxPkScriptRegistrations = 1000 + + // MaxPkScriptHistoricalDispatchQueueSize is the maximum number of queued + // historical pkScript scans per backend notifier. + MaxPkScriptHistoricalDispatchQueueSize = 1000 + + // MaxPkScriptsPerBatch is the maximum number of pkScripts that may be + // added or removed in a single mutation. + MaxPkScriptsPerBatch = 10000 + + // MaxPkScriptBatchBytes is the maximum total byte size of all pkScripts + // accepted in a single mutation. + MaxPkScriptBatchBytes = 4 << 20 + + // MaxPkScriptsPerRegistration is the maximum number of pkScripts that a + // single pkScript notification registration may watch. + MaxPkScriptsPerRegistration = 100000 + + // MaxPkScriptBytesPerRegistration is the maximum total byte size of all + // active pkScripts watched by a single registration. + MaxPkScriptBytesPerRegistration = 32 << 20 + + // MaxPkScriptWatches is the maximum number of pkScript watches across all + // active pkScript notification registrations. + MaxPkScriptWatches = 500000 + + // MaxPkScriptWatchBytes is the maximum total byte size of all active + // pkScript watches across all registrations. + MaxPkScriptWatchBytes = 128 << 20 +) + +var ( + // ErrPkScriptNotificationQueueFull is returned when a pkScript + // registration's notification queue is full. The registration is canceled + // when this happens so the queue cannot grow without bound. + ErrPkScriptNotificationQueueFull = errors.New("pkScript notification " + + "queue full") + + // ErrTooManyPkScripts is returned when a pkScript registration or mutation + // exceeds one of the notifier's resource limits. + ErrTooManyPkScripts = errors.New("too many pkScripts") + + // ErrTooManyPkScriptRegistrations is returned when too many pkScript + // notification streams are active. + ErrTooManyPkScriptRegistrations = errors.New("too many pkScript " + + "registrations") + + // ErrTooManyHistoricalPkScriptScans is returned when too many historical + // pkScript scans are already queued. + ErrTooManyHistoricalPkScriptScans = errors.New("too many historical " + + "pkScript scans") + + // ErrPkScriptTooLarge is returned when a watched pkScript exceeds the + // maximum script size accepted by the txscript engine. + ErrPkScriptTooLarge = errors.New("pkScript too large") + + // ErrUnsupportedPkScript is returned when a backend cannot watch a + // particular pkScript. + ErrUnsupportedPkScript = errors.New("unsupported pkScript") +) + +// pkScriptMatch tracks the lifecycle of an output matched by a pkScript +// subscription. +type pkScriptMatch struct { + watchConfig pkScriptWatchConfig + + utxo *PkScriptUTXO + + fundingTx *wire.MsgTx + + confirmHeight uint32 + confirmBlockHash *chainhash.Hash + confirmBlock *wire.MsgBlock + confirmDispatched bool + confirmUpdates map[uint32]*pkScriptConfirmUpdate + + spendTxHash *chainhash.Hash + spendBlockHash *chainhash.Hash + spendTx *wire.MsgTx + spendBlock *wire.MsgBlock + spendHeight uint32 + spendTxIndex uint32 + spendInputIndex uint32 + spendDispatched bool +} + +// pkScriptWatchConfig holds the notification behavior for one watched script. +type pkScriptWatchConfig struct { + events PkScriptEventType + numConfs uint32 + includeTx bool + includeBlock bool + includeConfUpdates bool +} + +// pkScriptConfirmUpdate tracks one dispatched partial confirmation update so it +// can be invalidated if its block is disconnected. +type pkScriptConfirmUpdate struct { + blockHeight uint32 + blockHash *chainhash.Hash + block *wire.MsgBlock + numConfs uint32 +} + +// pkScriptSubscription tracks pkScript notifications for a single client. +type pkScriptSubscription struct { + id uint64 + + // historicalDispatchMtx serializes historical rescans for this + // subscription so blocks are never replayed out of order. + historicalDispatchMtx sync.Mutex + + // scripts maps a watched script to its notification config. + scripts map[string]pkScriptWatchConfig + + // scriptBytes tracks the total byte size of all active watched scripts. + scriptBytes uint64 + + // scriptEpochCounter is incremented whenever a script is added to this + // subscription. Historical dispatches use per-script epochs from this + // counter. Removed scripts can drop epoch entries without letting stale + // dispatches match a later add. + scriptEpochCounter uint64 + + // scriptEpochs tracks the active epoch for each watched script. + // + // The value comes from scriptEpochCounter when a script is added. + // Historical dispatches capture that epoch, so stale queued dispatches + // cannot affect a removed and re-added script. + scriptEpochs map[string]uint64 + + // historicalScripts tracks the active historical dispatch epoch. + // Live tip processing skips a script only if the historical epoch + // matches the script's current epoch. + historicalScripts map[string]uint64 + + // matches stores all outputs currently tracked by this subscription. + matches map[wire.OutPoint]*pkScriptMatch + + notificationRegistration *PkScriptNotificationRegistration + + notificationQueue *pkScriptNotificationQueue +} + +// pkScriptNotificationQueue is a bounded per-subscription queue that decouples +// chain processing from slow pkScript notification consumers. +type pkScriptNotificationQueue struct { + out chan<- *PkScriptNotification + + mtx sync.Mutex + cond *sync.Cond + pending *list.List + pendingBytes uint64 + + stopped bool + err error + quit chan struct{} + wg sync.WaitGroup +} + +// queuedPkScriptNotification stores a queued notification with its estimated +// memory size. +type queuedPkScriptNotification struct { + ntfn *PkScriptNotification + size uint64 +} + +// newPkScriptNotificationQueue creates and starts a bounded pkScript +// notification queue. +func newPkScriptNotificationQueue( + out chan<- *PkScriptNotification) *pkScriptNotificationQueue { + + q := &pkScriptNotificationQueue{ + out: out, + pending: list.New(), + quit: make(chan struct{}), + } + q.cond = sync.NewCond(&q.mtx) + + q.wg.Add(1) + go q.run() + + return q +} + +// enqueue adds a notification to the bounded queue or stops the queue if a +// resource limit would be exceeded. +func (q *pkScriptNotificationQueue) enqueue( + ntfn *PkScriptNotification) error { + + q.mtx.Lock() + defer q.mtx.Unlock() + + if q.stopped { + if q.err != nil { + return q.err + } + + return ErrTxNotifierExiting + } + + if q.pending.Len() >= MaxPkScriptNotificationQueueSize { + q.stopLocked(ErrPkScriptNotificationQueueFull) + + return ErrPkScriptNotificationQueueFull + } + + size := pkScriptNotificationSize(ntfn) + if q.pendingBytes+size > MaxPkScriptNotificationQueueBytes { + q.stopLocked(ErrPkScriptNotificationQueueFull) + + return ErrPkScriptNotificationQueueFull + } + + q.pending.PushBack(&queuedPkScriptNotification{ + ntfn: ntfn, + size: size, + }) + q.pendingBytes += size + q.cond.Signal() + + return nil +} + +// run drains queued notifications to the subscriber channel until stopped. +func (q *pkScriptNotificationQueue) run() { + defer q.wg.Done() + defer close(q.out) + + for { + q.mtx.Lock() + for q.pending.Len() == 0 && !q.stopped { + q.cond.Wait() + } + + if q.stopped { + q.mtx.Unlock() + return + } + + next := q.pending.Front() + queued, ok := next.Value.(*queuedPkScriptNotification) + if !ok { + q.pending.Remove(next) + q.mtx.Unlock() + + return + } + q.pending.Remove(next) + q.pendingBytes -= queued.size + q.mtx.Unlock() + + select { + case q.out <- queued.ntfn: + case <-q.quit: + return + } + } +} + +// stop terminates the queue and waits for its worker to exit. +func (q *pkScriptNotificationQueue) stop() { + q.mtx.Lock() + q.stopLocked(ErrTxNotifierExiting) + q.mtx.Unlock() + + q.wg.Wait() +} + +// stopLocked stops the queue with the given error. The caller must hold q.mtx. +func (q *pkScriptNotificationQueue) stopLocked(err error) { + if q.stopped { + return + } + + q.stopped = true + q.err = err + close(q.quit) + q.cond.Broadcast() +} + +// terminalErr returns the terminal error that stopped the notification queue, if +// any. +func (q *pkScriptNotificationQueue) terminalErr() error { + q.mtx.Lock() + defer q.mtx.Unlock() + + return q.err +} + +// HistoricalPkScriptDispatch parametrizes a manual rescan for the newly added +// pkScripts from one AddPkScripts call. The scripts in the dispatch are scanned +// together as one historical job. +type HistoricalPkScriptDispatch struct { + // ScanID identifies this historical scan within its subscription. + ScanID uint64 + + // SubscriptionID identifies the subscription that owns the rescan. + SubscriptionID uint64 + + // PkScripts are the scripts that should be matched while scanning. + PkScripts [][]byte + + // pkScriptSet indexes the scripts in PkScripts. The historical scanner + // uses this cached set so large pkScript subscriptions don't rebuild the + // same map once per replayed block. + pkScriptSet map[string]struct{} + + // scriptEpochs tracks the subscription script epoch for each pkScript in + // this dispatch. It prevents stale queued dispatches from replaying or + // clearing scripts that were removed and then re-added. + scriptEpochs map[string]uint64 + + // StartHeight specifies the block height at which to begin the + // historical scan. + StartHeight uint32 + + // EndHeight specifies the last block height (inclusive) that the + // historical scan should consider. + EndHeight uint32 +} + +// PkScriptSet returns the cached script lookup set for this historical +// dispatch. +func (h *HistoricalPkScriptDispatch) PkScriptSet() map[string]struct{} { + if h == nil { + return nil + } + if h.pkScriptSet == nil { + h.pkScriptSet = makePkScriptSet(h.PkScripts) + } + + return h.pkScriptSet +} + +// PkScriptRegistration encompasses all of the information required for callers +// to retrieve details about an pkScript notification stream. +type PkScriptRegistration struct { + // Event contains the registration details and notification channel. + Event *PkScriptNotificationRegistration + + // Height is the height of the TxNotifier at the time the pkScript + // notification was registered. + Height uint32 + + // AddPkScripts adds more scripts to this registration and returns a + // historical dispatch if one is required to backfill them, the notifier's + // current height, and the scripts that were newly added. Scripts already + // watched by this registration are ignored. + AddPkScripts func(pkScripts [][]byte, + opts ...NotifierOption) (*HistoricalPkScriptDispatch, uint32, + [][]byte, error) + + // RemovePkScripts removes scripts from this registration. + RemovePkScripts func(pkScripts [][]byte) error +} + +// validatePkScripts ensures that all provided scripts are non-empty. +func validatePkScripts(pkScripts [][]byte) error { + if len(pkScripts) == 0 { + return ErrNoScript + } + if len(pkScripts) > MaxPkScriptsPerBatch { + return fmt.Errorf( + "%w: batch size %d exceeds limit %d", + ErrTooManyPkScripts, len(pkScripts), + MaxPkScriptsPerBatch, + ) + } + + var batchBytes uint64 + for _, pkScript := range pkScripts { + if len(pkScript) == 0 { + return ErrNoScript + } + if len(pkScript) > txscript.MaxScriptSize { + return fmt.Errorf("%w: script size %d exceeds limit %d", + ErrPkScriptTooLarge, len(pkScript), + txscript.MaxScriptSize) + } + + batchBytes += uint64(len(pkScript)) + if batchBytes > MaxPkScriptBatchBytes { + return fmt.Errorf( + "%w: batch byte size %d exceeds limit %d", + ErrTooManyPkScripts, batchBytes, + MaxPkScriptBatchBytes, + ) + } + } + + return nil +} + +// validatePkScriptResourceLimits ensures a mutation will not exceed the +// notifier's pkScript watch limits. +// +// NOTE: This method must be called with the TxNotifier's lock held. +func (n *TxNotifier) validatePkScriptResourceLimits( + sub *pkScriptSubscription, pkScripts [][]byte) error { + + newKeys := make(map[string]uint64, len(pkScripts)) + var newBytes uint64 + for _, pkScript := range pkScripts { + key := string(pkScript) + if _, ok := sub.scripts[key]; ok { + continue + } + if _, ok := newKeys[key]; ok { + continue + } + + scriptBytes := uint64(len(pkScript)) + newKeys[key] = scriptBytes + newBytes += scriptBytes + } + + newWatches := len(newKeys) + if len(sub.scripts)+newWatches > MaxPkScriptsPerRegistration { + return fmt.Errorf("%w: registration would watch %d scripts, "+ + "limit is %d", ErrTooManyPkScripts, + len(sub.scripts)+newWatches, MaxPkScriptsPerRegistration) + } + + if n.numPkScriptWatches+newWatches > MaxPkScriptWatches { + return fmt.Errorf("%w: notifier would watch %d scripts, "+ + "limit is %d", ErrTooManyPkScripts, + n.numPkScriptWatches+newWatches, MaxPkScriptWatches) + } + if sub.scriptBytes+newBytes > MaxPkScriptBytesPerRegistration { + return fmt.Errorf("%w: registration would watch %d script bytes, "+ + "limit is %d", ErrTooManyPkScripts, + sub.scriptBytes+newBytes, MaxPkScriptBytesPerRegistration) + } + if n.numPkScriptWatchBytes+newBytes > MaxPkScriptWatchBytes { + return fmt.Errorf("%w: notifier would watch %d script bytes, "+ + "limit is %d", ErrTooManyPkScripts, + n.numPkScriptWatchBytes+newBytes, MaxPkScriptWatchBytes) + } + + return nil +} + +// validatePkScriptOptions ensures the pkScript watch options are well formed. +func (n *TxNotifier) validatePkScriptOptions(opts *NotifierOptions) error { + const validEvents = PkScriptEventConfirm | PkScriptEventSpend + + if opts.Events == 0 { + return errors.New("a pkScript event type must be provided") + } + if opts.Events&^validEvents != 0 { + return errors.New("unknown pkScript event type") + } + + if opts.Events.Has(PkScriptEventConfirm) { + if opts.NumConfs == 0 || opts.NumConfs > n.reorgSafetyLimit { + return ErrNumConfsOutOfRange + } + } else if opts.IncludeConfirmationUpdates { + return errors.New("confirmation updates require confirmation events") + } + + return nil +} + +// pkScriptWatchConfigFromOptions converts public notifier options into the +// internal pkScript watch configuration. +func pkScriptWatchConfigFromOptions(opts *NotifierOptions) pkScriptWatchConfig { + return pkScriptWatchConfig{ + events: opts.Events, + numConfs: opts.NumConfs, + includeTx: opts.IncludeTx, + includeBlock: opts.IncludeBlock, + includeConfUpdates: opts.IncludeConfirmationUpdates, + } +} + +// RegisterPkScriptNotifier creates a new pkScript notification stream. +func (n *TxNotifier) RegisterPkScriptNotifier() (*PkScriptRegistration, error) { + select { + case <-n.quit: + return nil, ErrTxNotifierExiting + default: + } + + n.Lock() + defer n.Unlock() + + if len(n.pkScriptNotifications) >= MaxPkScriptRegistrations { + return nil, fmt.Errorf("%w: active registrations %d exceeds "+ + "limit %d", ErrTooManyPkScriptRegistrations, + len(n.pkScriptNotifications), MaxPkScriptRegistrations) + } + + subID := atomic.AddUint64(&n.pkScriptClientCounter, 1) + sub := &pkScriptSubscription{ + id: subID, + scripts: make(map[string]pkScriptWatchConfig), + scriptEpochs: make(map[string]uint64), + historicalScripts: make(map[string]uint64), + matches: make(map[wire.OutPoint]*pkScriptMatch), + } + sub.notificationRegistration = NewPkScriptNotificationRegistration( + func() { + n.CancelPkScript(subID) + }, + ) + sub.notificationQueue = newPkScriptNotificationQueue( + sub.notificationRegistration.notifications, + ) + sub.notificationRegistration.Err = sub.notificationQueue.terminalErr + + n.pkScriptNotifications[subID] = sub + + return &PkScriptRegistration{ + Event: sub.notificationRegistration, + Height: n.currentHeight, + AddPkScripts: func( + pkScripts [][]byte, addOptFuncs ...NotifierOption, + ) (*HistoricalPkScriptDispatch, uint32, [][]byte, error) { + + return n.AddPkScripts(subID, pkScripts, addOptFuncs...) + }, + RemovePkScripts: func(pkScripts [][]byte) error { + return n.RemovePkScripts(subID, pkScripts) + }, + }, nil +} + +// AddPkScripts adds a set of pkScripts to an existing pkScript subscription. +func (n *TxNotifier) AddPkScripts(id uint64, pkScripts [][]byte, + optFuncs ...NotifierOption) (*HistoricalPkScriptDispatch, uint32, + [][]byte, error) { + + select { + case <-n.quit: + return nil, 0, nil, ErrTxNotifierExiting + default: + } + + err := validatePkScripts(pkScripts) + if err != nil { + return nil, 0, nil, err + } + opts := DefaultNotifierOptions() + for _, optFunc := range optFuncs { + optFunc(opts) + } + + err = n.validatePkScriptOptions(opts) + if err != nil { + return nil, 0, nil, err + } + + n.Lock() + defer n.Unlock() + + sub, ok := n.pkScriptNotifications[id] + if !ok { + return nil, 0, nil, fmt.Errorf( + "pkScript subscription %d not found", id, + ) + } + err = n.validatePkScriptResourceLimits(sub, pkScripts) + if err != nil { + return nil, 0, nil, err + } + + cfg := pkScriptWatchConfigFromOptions(opts) + dispatch, addedScripts, err := n.addPkScripts( + sub, pkScripts, cfg, opts.HistoricalScanFrom, + ) + if err != nil { + return nil, 0, nil, err + } + + return dispatch, n.currentHeight, addedScripts, nil +} + +// RemovePkScripts removes a set of pkScripts from an existing pkScript +// subscription. +func (n *TxNotifier) RemovePkScripts(id uint64, pkScripts [][]byte) error { + select { + case <-n.quit: + return ErrTxNotifierExiting + default: + } + + err := validatePkScripts(pkScripts) + if err != nil { + return err + } + + n.Lock() + defer n.Unlock() + + sub, ok := n.pkScriptNotifications[id] + if !ok { + return fmt.Errorf("pkScript subscription %d not found", id) + } + + removeKeys := make(map[string]struct{}, len(pkScripts)) + for _, pkScript := range pkScripts { + removeKeys[string(pkScript)] = struct{}{} + } + + for key := range removeKeys { + if _, ok := sub.scripts[key]; !ok { + continue + } + scriptBytes := uint64(len(key)) + delete(sub.scripts, key) + delete(sub.scriptEpochs, key) + delete(sub.historicalScripts, key) + n.numPkScriptWatches-- + if sub.scriptBytes >= scriptBytes { + sub.scriptBytes -= scriptBytes + } else { + sub.scriptBytes = 0 + } + if n.numPkScriptWatchBytes >= scriptBytes { + n.numPkScriptWatchBytes -= scriptBytes + } else { + n.numPkScriptWatchBytes = 0 + } + + subs := n.pkScriptByScript[key] + delete(subs, id) + if len(subs) == 0 { + delete(n.pkScriptByScript, key) + } + } + + for outpoint, match := range sub.matches { + if _, ok := removeKeys[string(match.utxo.PkScript)]; !ok { + continue + } + + n.removePkScriptMatch(sub, outpoint) + } + + return nil +} + +// CancelPkScript cancels an existing pkScript subscription. +func (n *TxNotifier) CancelPkScript(id uint64) { + select { + case <-n.quit: + return + default: + } + + n.Lock() + defer n.Unlock() + + n.cancelPkScriptLocked(id) +} + +// cancelPkScriptLocked cancels an existing pkScript subscription. The caller must +// hold the TxNotifier's lock. +func (n *TxNotifier) cancelPkScriptLocked(id uint64) { + sub, ok := n.pkScriptNotifications[id] + if !ok { + return + } + + for scriptKey := range sub.scripts { + n.numPkScriptWatches-- + + subs := n.pkScriptByScript[scriptKey] + delete(subs, id) + if len(subs) == 0 { + delete(n.pkScriptByScript, scriptKey) + } + } + if n.numPkScriptWatchBytes >= sub.scriptBytes { + n.numPkScriptWatchBytes -= sub.scriptBytes + } else { + n.numPkScriptWatchBytes = 0 + } + + for outpoint := range sub.matches { + n.removePkScriptMatch(sub, outpoint) + } + + sub.notificationQueue.stop() + delete(n.pkScriptNotifications, id) +} + +// addPkScripts adds new scripts to a subscription and updates indexes. +// +// NOTE: This method must be called with the TxNotifier's lock held. +func (n *TxNotifier) addPkScripts(sub *pkScriptSubscription, + pkScripts [][]byte, cfg pkScriptWatchConfig, + historicalStart *uint32) (*HistoricalPkScriptDispatch, [][]byte, error) { + + var ( + addedScripts [][]byte + scriptsToScan [][]byte + scriptEpochs map[string]uint64 + startHeight uint32 + startHeightSet bool + ) + + for _, pkScript := range pkScripts { + key := string(pkScript) + if _, ok := sub.scripts[key]; ok { + continue + } + + sub.scripts[key] = cfg + sub.scriptBytes += uint64(len(pkScript)) + sub.scriptEpochCounter++ + if sub.scriptEpochCounter == 0 { + sub.scriptEpochCounter++ + } + scriptEpoch := sub.scriptEpochCounter + sub.scriptEpochs[key] = scriptEpoch + n.numPkScriptWatches++ + n.numPkScriptWatchBytes += uint64(len(pkScript)) + + scriptCopy := copyBytes(pkScript) + addedScripts = append(addedScripts, scriptCopy) + + subSet, ok := n.pkScriptByScript[key] + if !ok { + subSet = make(map[uint64]struct{}) + n.pkScriptByScript[key] = subSet + } + subSet[sub.id] = struct{}{} + + if historicalStart != nil { + scriptsToScan = append(scriptsToScan, scriptCopy) + if scriptEpochs == nil { + scriptEpochs = make(map[string]uint64) + } + scriptEpochs[key] = scriptEpoch + if !startHeightSet || *historicalStart < startHeight { + startHeight = *historicalStart + startHeightSet = true + } + } + } + + if len(scriptsToScan) == 0 || startHeight > n.currentHeight { + return nil, addedScripts, nil + } + + setPkScriptHistoricalScriptsLocked(sub, scriptEpochs, true) + + scanID := atomic.AddUint64(&n.pkScriptHistoricalScanCounter, 1) + + return newHistoricalPkScriptDispatch( + scanID, sub.id, scriptsToScan, scriptEpochs, startHeight, + n.currentHeight, + ), addedScripts, nil +} + +// newHistoricalPkScriptDispatch creates the internal work item for a historical +// pkScript scan. +func newHistoricalPkScriptDispatch(scanID, subscriptionID uint64, + pkScripts [][]byte, scriptEpochs map[string]uint64, startHeight, + endHeight uint32) *HistoricalPkScriptDispatch { + + return &HistoricalPkScriptDispatch{ + ScanID: scanID, + SubscriptionID: subscriptionID, + PkScripts: pkScripts, + pkScriptSet: makePkScriptSet(pkScripts), + scriptEpochs: scriptEpochs, + StartHeight: startHeight, + EndHeight: endHeight, + } +} + +// NewPkScriptAddResult returns caller-facing metadata for an AddPkScripts +// mutation. +func NewPkScriptAddResult(dispatch *HistoricalPkScriptDispatch, + addedScripts [][]byte) *PkScriptAddResult { + + result := &PkScriptAddResult{ + NumAdded: uint32(len(addedScripts)), + } + if dispatch == nil { + return result + } + + result.HistoricalScanID = dispatch.ScanID + result.HistoricalScanQueued = true + result.HistoricalScanStartHeight = dispatch.StartHeight + result.HistoricalScanEndHeight = dispatch.EndHeight + + return result +} + +// makePkScriptSet creates a lookup set for pkScripts. +func makePkScriptSet(pkScripts [][]byte) map[string]struct{} { + scriptSet := make(map[string]struct{}, len(pkScripts)) + for _, pkScript := range pkScripts { + scriptSet[string(pkScript)] = struct{}{} + } + + return scriptSet +} + +// isPkScriptHistoricalActive returns true if the script is currently being +// replayed by a historical dispatch for this subscription. +// +// NOTE: This method must be called with the TxNotifier's lock held. +func (sub *pkScriptSubscription) isPkScriptHistoricalActive( + pkScript []byte) bool { + + key := string(pkScript) + activeEpoch := sub.historicalScripts[key] + + return activeEpoch != 0 && activeEpoch == sub.scriptEpochs[key] +} + +// setPkScriptHistoricalScriptsLocked marks or unmarks historical replay activity +// for the given script epochs. +func setPkScriptHistoricalScriptsLocked(sub *pkScriptSubscription, + scriptEpochs map[string]uint64, active bool) { + + if active && sub.historicalScripts == nil { + sub.historicalScripts = make(map[string]uint64) + } + + for key, epoch := range scriptEpochs { + if active { + if _, ok := sub.scripts[key]; !ok { + continue + } + if sub.scriptEpochs[key] != epoch { + continue + } + + sub.historicalScripts[key] = epoch + + continue + } + + if sub.historicalScripts[key] == epoch { + delete(sub.historicalScripts, key) + } + } +} + +// historicalDispatchMatchesCurrentScript reports whether a historical dispatch +// still owns the current script epoch. +func historicalDispatchMatchesCurrentScript(sub *pkScriptSubscription, + key string, scriptEpochs map[string]uint64) bool { + + if scriptEpochs == nil { + return true + } + + epoch, ok := scriptEpochs[key] + if !ok { + return false + } + + return sub.scriptEpochs[key] == epoch && sub.historicalScripts[key] == epoch +} + +// historicalDispatchHasCurrentScript reports whether any script in a dispatch +// still matches the subscription's current epoch state. +func historicalDispatchHasCurrentScript(sub *pkScriptSubscription, + scriptEpochs map[string]uint64) bool { + + if scriptEpochs == nil { + return true + } + + for key := range scriptEpochs { + if historicalDispatchMatchesCurrentScript(sub, key, scriptEpochs) { + return true + } + } + + return false +} + +// historicalDispatchActive reports whether a historical dispatch should +// continue replaying for the subscription. +func (n *TxNotifier) historicalDispatchActive(subscriptionID uint64, + scriptEpochs map[string]uint64) bool { + + n.Lock() + defer n.Unlock() + + sub := n.pkScriptNotifications[subscriptionID] + if sub == nil { + return false + } + + return historicalDispatchHasCurrentScript(sub, scriptEpochs) +} + +// removeOutpointSubscription removes a subscription's UTXO from the global +// outpoint map. +// +// NOTE: This method must be called with the TxNotifier's lock held. +func (n *TxNotifier) removeOutpointSubscription(outpoint wire.OutPoint, + subID uint64) { + + subMap, ok := n.pkScriptByOutpoint[outpoint] + if !ok { + return + } + + delete(subMap, subID) + if len(subMap) == 0 { + delete(n.pkScriptByOutpoint, outpoint) + } +} + +// dispatchPkScriptNotification sends an pkScript notification to a subscriber. +// +// NOTE: This method must be called with the TxNotifier's lock held. +func (n *TxNotifier) dispatchPkScriptNotification(sub *pkScriptSubscription, + ntfn *PkScriptNotification) error { + + select { + case <-n.quit: + return ErrTxNotifierExiting + default: + } + + err := sub.notificationQueue.enqueue(ntfn) + if errors.Is(err, ErrPkScriptNotificationQueueFull) { + Log.Warnf("Canceling pkScript notification registration %d: "+ + "notification queue exceeded limit %d", sub.id, + MaxPkScriptNotificationQueueSize) + + n.cancelPkScriptLocked(sub.id) + } + + return err +} + +// copyBytes returns a detached copy of b. +func copyBytes(b []byte) []byte { + if len(b) == 0 { + return nil + } + out := make([]byte, len(b)) + copy(out, b) + + return out +} + +// copyHash returns a detached copy of hash. +func copyHash(hash *chainhash.Hash) *chainhash.Hash { + if hash == nil { + return nil + } + + hashCopy := *hash + + return &hashCopy +} + +// copyMsgTx returns a detached copy of tx. +func copyMsgTx(tx *wire.MsgTx) *wire.MsgTx { + if tx == nil { + return nil + } + + return tx.Copy() +} + +// copyMsgBlock returns a detached copy of block. +func copyMsgBlock(block *wire.MsgBlock) *wire.MsgBlock { + if block == nil { + return nil + } + + return block.Copy() +} + +// pkScriptNotificationSize estimates a notification's queued memory footprint. +func pkScriptNotificationSize(ntfn *PkScriptNotification) uint64 { + if ntfn == nil { + return 0 + } + + // Account for fixed metadata and pointer fields. This is deliberately an + // estimate because the queue limit is a resource guard, not accounting. + var size uint64 = 256 + if ntfn.UTXO != nil { + size += uint64(len(ntfn.UTXO.PkScript)) + 128 + } + if ntfn.Tx != nil { + size += uint64(ntfn.Tx.SerializeSize()) + } + if ntfn.Block != nil { + size += uint64(ntfn.Block.SerializeSize()) + } + if ntfn.HistoricalScan != nil { + size += uint64(len(ntfn.HistoricalScan.Error)) + 64 + } + + return size +} + +// addPkScriptHeightIndex indexes an outpoint by height and subscription ID. +func addPkScriptHeightIndex(index map[uint32]map[uint64]map[wire.OutPoint]struct{}, + height uint32, subID uint64, outpoint wire.OutPoint) { + + heightIndex, ok := index[height] + if !ok { + heightIndex = make(map[uint64]map[wire.OutPoint]struct{}) + index[height] = heightIndex + } + + subIndex, ok := heightIndex[subID] + if !ok { + subIndex = make(map[wire.OutPoint]struct{}) + heightIndex[subID] = subIndex + } + + subIndex[outpoint] = struct{}{} +} + +// removePkScriptHeightIndex removes an outpoint from a height/subscription +// index and prunes empty buckets. +func removePkScriptHeightIndex( + index map[uint32]map[uint64]map[wire.OutPoint]struct{}, + height uint32, subID uint64, outpoint wire.OutPoint) { + + heightIndex, ok := index[height] + if !ok { + return + } + + subIndex, ok := heightIndex[subID] + if !ok { + return + } + + delete(subIndex, outpoint) + if len(subIndex) == 0 { + delete(heightIndex, subID) + } + if len(heightIndex) == 0 { + delete(index, height) + } +} + +// schedulePkScriptConfirmUpdate schedules a future partial confirmation update +// for an pkScript match. +func (n *TxNotifier) schedulePkScriptConfirmUpdate( + sub *pkScriptSubscription, match *pkScriptMatch, outpoint wire.OutPoint, + height uint32) { + + if !match.watchConfig.includeConfUpdates { + return + } + if height >= match.confirmHeight { + return + } + + addPkScriptHeightIndex( + n.pkScriptConfUpdatesByHeight, height, sub.id, outpoint, + ) +} + +// addDispatchedPkScriptConfirmUpdate records a delivered partial confirmation +// update so it can be invalidated by a reorg. +func (n *TxNotifier) addDispatchedPkScriptConfirmUpdate( + sub *pkScriptSubscription, match *pkScriptMatch, outpoint wire.OutPoint, + update *pkScriptConfirmUpdate) { + + if match.confirmUpdates == nil { + match.confirmUpdates = make(map[uint32]*pkScriptConfirmUpdate) + } + match.confirmUpdates[update.blockHeight] = update + + addPkScriptHeightIndex( + n.pkScriptConfUpdatesDispatchedByHeight, update.blockHeight, + sub.id, outpoint, + ) +} + +// removeDispatchedPkScriptConfirmUpdate removes a delivered partial confirmation +// update from the reorg tracking indexes. +func (n *TxNotifier) removeDispatchedPkScriptConfirmUpdate( + sub *pkScriptSubscription, match *pkScriptMatch, outpoint wire.OutPoint, + height uint32) { + + delete(match.confirmUpdates, height) + if len(match.confirmUpdates) == 0 { + match.confirmUpdates = nil + } + + removePkScriptHeightIndex( + n.pkScriptConfUpdatesDispatchedByHeight, height, sub.id, outpoint, + ) +} + +// shouldRetainPkScriptMatch returns true if the match is still needed for +// future notifications or reorg handling. +// +// NOTE: This method must be called with the TxNotifier's lock held. +func (n *TxNotifier) shouldRetainPkScriptMatch(sub *pkScriptSubscription, + match *pkScriptMatch) bool { + + if match.watchConfig.events.Has(PkScriptEventConfirm) { + if !match.confirmDispatched { + return true + } + + if match.confirmHeight+n.reorgSafetyLimit > n.currentHeight { + return true + } + } + + if match.watchConfig.events.Has(PkScriptEventSpend) { + if match.spendTxHash == nil { + return true + } + + if match.spendHeight+n.reorgSafetyLimit > n.currentHeight { + return true + } + } + + return false +} + +// removePkScriptMatch clears all indexes associated with a tracked output. +// +// NOTE: This method must be called with the TxNotifier's lock held. +func (n *TxNotifier) removePkScriptMatch(sub *pkScriptSubscription, + outpoint wire.OutPoint) { + + match, ok := sub.matches[outpoint] + if !ok { + return + } + + if match.utxo != nil { + removePkScriptHeightIndex( + n.pkScriptReceivesByHeight, match.utxo.BlockHeight, + sub.id, outpoint, + ) + } + if match.watchConfig.events.Has(PkScriptEventConfirm) { + removePkScriptHeightIndex( + n.pkScriptConfsByHeight, match.confirmHeight, + sub.id, outpoint, + ) + removePkScriptHeightIndex( + n.pkScriptConfirmedByHeight, match.confirmHeight, + sub.id, outpoint, + ) + } + if match.watchConfig.includeConfUpdates && match.utxo != nil { + startHeight := match.utxo.BlockHeight + endHeight := match.confirmHeight + for height := startHeight; height < endHeight; height++ { + + removePkScriptHeightIndex( + n.pkScriptConfUpdatesByHeight, height, + sub.id, outpoint, + ) + removePkScriptHeightIndex( + n.pkScriptConfUpdatesDispatchedByHeight, height, + sub.id, outpoint, + ) + } + } + if match.spendTxHash != nil || match.spendDispatched { + removePkScriptHeightIndex( + n.pkScriptSpendsByHeight, match.spendHeight, + sub.id, outpoint, + ) + } + + n.removeOutpointSubscription(outpoint, sub.id) + delete(sub.matches, outpoint) +} + +// trackPkScriptReceive adds a matched output to the subscription state. +// +// NOTE: This method must be called with the TxNotifier's lock held. +func (n *TxNotifier) trackPkScriptReceive(sub *pkScriptSubscription, + outpoint wire.OutPoint, value btcutil.Amount, pkScript []byte, + tx *wire.MsgTx, blockHeight uint32, blockHash *chainhash.Hash, + txIndex uint32, cfg pkScriptWatchConfig) { + + if _, ok := sub.matches[outpoint]; ok { + return + } + + match := &pkScriptMatch{ + watchConfig: cfg, + utxo: &PkScriptUTXO{ + OutPoint: outpoint, + Value: value, + PkScript: copyBytes(pkScript), + BlockHeight: blockHeight, + BlockHash: copyHash(blockHash), + TxIndex: txIndex, + }, + } + if cfg.includeTx { + match.fundingTx = copyMsgTx(tx) + } + sub.matches[outpoint] = match + + if blockHeight+n.reorgSafetyLimit > n.currentHeight { + addPkScriptHeightIndex( + n.pkScriptReceivesByHeight, blockHeight, sub.id, outpoint, + ) + } + + if cfg.events.Has(PkScriptEventSpend) { + subMap, ok := n.pkScriptByOutpoint[outpoint] + if !ok { + subMap = make(map[uint64]struct{}) + n.pkScriptByOutpoint[outpoint] = subMap + } + subMap[sub.id] = struct{}{} + } + + if cfg.events.Has(PkScriptEventConfirm) { + match.confirmHeight = blockHeight + cfg.numConfs - 1 + addPkScriptHeightIndex( + n.pkScriptConfsByHeight, match.confirmHeight, + sub.id, outpoint, + ) + n.schedulePkScriptConfirmUpdate(sub, match, outpoint, blockHeight) + } +} + +// dispatchPkScriptConfirmUpdate sends a partial confirmation progress +// notification for a matched output. +// +// NOTE: This method must be called with the TxNotifier's lock held. +func (n *TxNotifier) dispatchPkScriptConfirmUpdate(sub *pkScriptSubscription, + match *pkScriptMatch, outpoint wire.OutPoint, blockHeight uint32, + blockHash *chainhash.Hash, block *wire.MsgBlock) error { + + cfg := match.watchConfig + if !cfg.events.Has(PkScriptEventConfirm) || !cfg.includeConfUpdates || + match.confirmDispatched || blockHeight >= match.confirmHeight || + blockHeight < match.utxo.BlockHeight { + + return nil + } + + numConfs := blockHeight - match.utxo.BlockHeight + 1 + if numConfs == 0 || numConfs >= cfg.numConfs { + return nil + } + + var ( + txCopy *wire.MsgTx + blockCopy *wire.MsgBlock + ) + if cfg.includeTx { + txCopy = copyMsgTx(match.fundingTx) + } + if cfg.includeBlock { + blockCopy = copyMsgBlock(block) + } + + err := n.dispatchPkScriptNotification(sub, &PkScriptNotification{ + Type: PkScriptNotificationConfirmUpdate, + Height: blockHeight, + BlockHash: copyHash(blockHash), + TxHash: copyHash(&outpoint.Hash), + TxIndex: match.utxo.TxIndex, + NumConfirmations: numConfs, + RequiredConfs: cfg.numConfs, + UTXO: match.utxo, + Tx: txCopy, + Block: blockCopy, + }) + if err != nil { + if errors.Is(err, ErrPkScriptNotificationQueueFull) { + return nil + } + + return err + } + + removePkScriptHeightIndex( + n.pkScriptConfUpdatesByHeight, blockHeight, sub.id, outpoint, + ) + n.addDispatchedPkScriptConfirmUpdate( + sub, match, outpoint, &pkScriptConfirmUpdate{ + blockHeight: blockHeight, + blockHash: copyHash(blockHash), + block: blockCopy, + numConfs: numConfs, + }, + ) + + n.schedulePkScriptConfirmUpdate(sub, match, outpoint, blockHeight+1) + + return nil +} + +// dispatchPkScriptConfirmUpdateReorg invalidates a dispatched partial +// confirmation progress notification. +// +// NOTE: This method must be called with the TxNotifier's lock held. +func (n *TxNotifier) dispatchPkScriptConfirmUpdateReorg( + sub *pkScriptSubscription, match *pkScriptMatch, outpoint wire.OutPoint, + blockHeight uint32) error { + + update := match.confirmUpdates[blockHeight] + if update == nil { + return nil + } + + err := n.dispatchPkScriptNotification(sub, &PkScriptNotification{ + Type: PkScriptNotificationConfirmUpdate, + Height: update.blockHeight, + BlockHash: copyHash(update.blockHash), + TxHash: copyHash(&outpoint.Hash), + TxIndex: match.utxo.TxIndex, + NumConfirmations: update.numConfs, + RequiredConfs: match.watchConfig.numConfs, + Disconnected: true, + UTXO: match.utxo, + Tx: copyMsgTx(match.fundingTx), + Block: copyMsgBlock(update.block), + }) + if err != nil { + if errors.Is(err, ErrPkScriptNotificationQueueFull) { + return nil + } + + return err + } + + n.removeDispatchedPkScriptConfirmUpdate( + sub, match, outpoint, blockHeight, + ) + + return nil +} + +// dispatchPkScriptConfirm sends a confirmation notification for the matched +// output when it reaches the configured confirmation height. +// +// NOTE: This method must be called with the TxNotifier's lock held. +func (n *TxNotifier) dispatchPkScriptConfirm(sub *pkScriptSubscription, + match *pkScriptMatch, outpoint wire.OutPoint, blockHeight uint32, + blockHash *chainhash.Hash, block *wire.MsgBlock) error { + + cfg := match.watchConfig + if !cfg.events.Has(PkScriptEventConfirm) || + match.confirmDispatched || match.confirmHeight != blockHeight { + + return nil + } + + var ( + txCopy *wire.MsgTx + blockCopy *wire.MsgBlock + ) + if cfg.includeTx { + txCopy = copyMsgTx(match.fundingTx) + } + if cfg.includeBlock { + blockCopy = copyMsgBlock(block) + } + + err := n.dispatchPkScriptNotification(sub, &PkScriptNotification{ + Type: PkScriptNotificationConfirm, + Height: blockHeight, + BlockHash: copyHash(blockHash), + TxHash: copyHash(&outpoint.Hash), + TxIndex: match.utxo.TxIndex, + NumConfirmations: cfg.numConfs, + RequiredConfs: cfg.numConfs, + UTXO: match.utxo, + Tx: txCopy, + Block: blockCopy, + }) + if err != nil { + if errors.Is(err, ErrPkScriptNotificationQueueFull) { + return nil + } + + return err + } + + match.confirmDispatched = true + match.confirmBlockHash = copyHash(blockHash) + if cfg.includeBlock { + match.confirmBlock = copyMsgBlock(block) + } + removePkScriptHeightIndex( + n.pkScriptConfsByHeight, blockHeight, sub.id, outpoint, + ) + + if blockHeight+n.reorgSafetyLimit > n.currentHeight { + addPkScriptHeightIndex( + n.pkScriptConfirmedByHeight, blockHeight, sub.id, outpoint, + ) + } + + if !n.shouldRetainPkScriptMatch(sub, match) { + n.removePkScriptMatch(sub, outpoint) + } + + return nil +} + +// dispatchPkScriptConfirmReorg invalidates a dispatched confirmation +// notification. +// +// NOTE: This method must be called with the TxNotifier's lock held. +func (n *TxNotifier) dispatchPkScriptConfirmReorg(sub *pkScriptSubscription, + match *pkScriptMatch, outpoint wire.OutPoint) error { + + if !match.confirmDispatched { + return nil + } + + err := n.dispatchPkScriptNotification(sub, &PkScriptNotification{ + Type: PkScriptNotificationConfirm, + Height: match.confirmHeight, + BlockHash: copyHash(match.confirmBlockHash), + TxHash: copyHash(&outpoint.Hash), + TxIndex: match.utxo.TxIndex, + NumConfirmations: match.watchConfig.numConfs, + RequiredConfs: match.watchConfig.numConfs, + Disconnected: true, + UTXO: match.utxo, + Tx: copyMsgTx(match.fundingTx), + Block: copyMsgBlock(match.confirmBlock), + }) + if err != nil { + if errors.Is(err, ErrPkScriptNotificationQueueFull) { + return nil + } + + return err + } + + match.confirmDispatched = false + match.confirmBlockHash = nil + removePkScriptHeightIndex( + n.pkScriptConfirmedByHeight, match.confirmHeight, + sub.id, outpoint, + ) + + return nil +} + +// dispatchPkScriptSpend sends a spend notification for a tracked output. +// +// NOTE: This method must be called with the TxNotifier's lock held. +func (n *TxNotifier) dispatchPkScriptSpend(sub *pkScriptSubscription, + match *pkScriptMatch, outpoint wire.OutPoint, txHash *chainhash.Hash, + txIndex, inputIdx uint32, blockHeight uint32, + blockHash *chainhash.Hash, tx *wire.MsgTx, + block *wire.MsgBlock) error { + + cfg := match.watchConfig + if !cfg.events.Has(PkScriptEventSpend) || + match.spendTxHash != nil { + + return nil + } + + var ( + txCopy *wire.MsgTx + blockCopy *wire.MsgBlock + ) + if cfg.includeTx { + txCopy = copyMsgTx(tx) + } + if cfg.includeBlock { + blockCopy = copyMsgBlock(block) + } + + err := n.dispatchPkScriptNotification(sub, &PkScriptNotification{ + Type: PkScriptNotificationSpend, + Height: blockHeight, + BlockHash: copyHash(blockHash), + TxHash: copyHash(txHash), + TxIndex: txIndex, + InputIndex: inputIdx, + UTXO: match.utxo, + Tx: txCopy, + Block: blockCopy, + }) + if err != nil { + if errors.Is(err, ErrPkScriptNotificationQueueFull) { + return nil + } + + return err + } + + match.spendTxHash = copyHash(txHash) + match.spendBlockHash = copyHash(blockHash) + if cfg.includeTx { + match.spendTx = copyMsgTx(tx) + } + if cfg.includeBlock { + match.spendBlock = copyMsgBlock(block) + } + match.spendHeight = blockHeight + match.spendTxIndex = txIndex + match.spendInputIndex = inputIdx + match.spendDispatched = true + + n.removeOutpointSubscription(outpoint, sub.id) + + if blockHeight+n.reorgSafetyLimit > n.currentHeight { + addPkScriptHeightIndex( + n.pkScriptSpendsByHeight, blockHeight, sub.id, outpoint, + ) + } + + if !n.shouldRetainPkScriptMatch(sub, match) { + n.removePkScriptMatch(sub, outpoint) + } + + return nil +} + +// dispatchPkScriptSpendReorg invalidates a dispatched spend notification. +// +// NOTE: This method must be called with the TxNotifier's lock held. +func (n *TxNotifier) dispatchPkScriptSpendReorg(sub *pkScriptSubscription, + match *pkScriptMatch) error { + + if !match.spendDispatched { + return nil + } + + err := n.dispatchPkScriptNotification(sub, &PkScriptNotification{ + Type: PkScriptNotificationSpend, + Height: match.spendHeight, + BlockHash: copyHash(match.spendBlockHash), + TxHash: copyHash(match.spendTxHash), + TxIndex: match.spendTxIndex, + InputIndex: match.spendInputIndex, + Disconnected: true, + UTXO: match.utxo, + Tx: copyMsgTx(match.spendTx), + Block: copyMsgBlock(match.spendBlock), + }) + if err != nil { + if errors.Is(err, ErrPkScriptNotificationQueueFull) { + return nil + } + + return err + } + + match.spendTxHash = nil + match.spendBlockHash = nil + match.spendTx = nil + match.spendBlock = nil + match.spendHeight = 0 + match.spendTxIndex = 0 + match.spendInputIndex = 0 + match.spendDispatched = false + + return nil +} + +// dispatchPkScriptNotificationsAtTip dispatches pkScript notifications scheduled +// at the connected tip height. +// +// NOTE: This method must be called with the TxNotifier's lock held. +func (n *TxNotifier) dispatchPkScriptNotificationsAtTip(blockHeight uint32, + blockHash *chainhash.Hash, block *btcutil.Block) error { + + var msgBlock *wire.MsgBlock + if block != nil { + msgBlock = block.MsgBlock() + } + + if len(n.pkScriptConfUpdatesByHeight[blockHeight]) > 0 { + err := n.dispatchPkScriptConfirmUpdatesAtTip( + blockHeight, blockHash, msgBlock, + ) + if err != nil { + return err + } + } + + if len(n.pkScriptConfsByHeight[blockHeight]) > 0 { + err := n.dispatchPkScriptConfsAtTip( + blockHeight, blockHash, msgBlock, + ) + if err != nil { + return err + } + } + + return nil +} + +// dispatchPkScriptConfirmUpdatesAtTip dispatches partial confirmation updates +// scheduled at the given height. +// +// NOTE: This method must be called with the TxNotifier's lock held. +func (n *TxNotifier) dispatchPkScriptConfirmUpdatesAtTip(blockHeight uint32, + blockHash *chainhash.Hash, block *wire.MsgBlock) error { + + subIndex := n.pkScriptConfUpdatesByHeight[blockHeight] + for subID, outpoints := range subIndex { + sub := n.pkScriptNotifications[subID] + if sub == nil { + delete(subIndex, subID) + continue + } + + for outpoint := range outpoints { + match := sub.matches[outpoint] + if match == nil { + delete(outpoints, outpoint) + continue + } + if sub.isPkScriptHistoricalActive(match.utxo.PkScript) { + continue + } + + err := n.dispatchPkScriptConfirmUpdate( + sub, match, outpoint, blockHeight, blockHash, block, + ) + if err != nil { + return err + } + if _, ok := n.pkScriptNotifications[subID]; !ok { + break + } + } + + if len(outpoints) == 0 { + delete(subIndex, subID) + } + } + + if len(n.pkScriptConfUpdatesByHeight[blockHeight]) == 0 { + delete(n.pkScriptConfUpdatesByHeight, blockHeight) + } + + return nil +} + +// dispatchPkScriptConfirmUpdatesForSub dispatches partial confirmation updates +// for a single subscription at the given historical replay height. +// +// NOTE: This method must be called with the TxNotifier's lock held. +func (n *TxNotifier) dispatchPkScriptConfirmUpdatesForSub( + sub *pkScriptSubscription, blockHeight uint32, blockHash *chainhash.Hash, + block *wire.MsgBlock, scriptSet map[string]struct{}, + scriptEpochs map[string]uint64) error { + + heightIndex := n.pkScriptConfUpdatesByHeight[blockHeight] + if len(heightIndex) == 0 { + return nil + } + + outpoints := heightIndex[sub.id] + for outpoint := range outpoints { + match := sub.matches[outpoint] + if match == nil { + delete(outpoints, outpoint) + continue + } + + matchKey := string(match.utxo.PkScript) + if _, ok := scriptSet[matchKey]; !ok { + continue + } + if !historicalDispatchMatchesCurrentScript( + sub, matchKey, scriptEpochs, + ) { + + continue + } + + err := n.dispatchPkScriptConfirmUpdate( + sub, match, outpoint, blockHeight, blockHash, block, + ) + if err != nil { + return err + } + if _, ok := n.pkScriptNotifications[sub.id]; !ok { + return nil + } + } + + if len(heightIndex[sub.id]) == 0 { + delete(heightIndex, sub.id) + } + if len(heightIndex) == 0 { + delete(n.pkScriptConfUpdatesByHeight, blockHeight) + } + + return nil +} + +// completeHistoricalPkScriptDispatchLocked clears the historical replay gate and +// sends the terminal lifecycle event for a historical pkScript scan. +// +// NOTE: This method must be called with the TxNotifier's lock held. +func (n *TxNotifier) completeHistoricalPkScriptDispatchLocked( + sub *pkScriptSubscription, dispatch *HistoricalPkScriptDispatch, + completedHeight uint32, scanErr error) error { + + if sub == nil || dispatch == nil || !historicalDispatchHasCurrentScript( + sub, dispatch.scriptEpochs, + ) { + + return nil + } + + setPkScriptHistoricalScriptsLocked(sub, dispatch.scriptEpochs, false) + + var errString string + if scanErr != nil { + errString = scanErr.Error() + } + + err := n.dispatchPkScriptNotification(sub, &PkScriptNotification{ + Type: PkScriptNotificationHistoricalScanComplete, + HistoricalScan: &PkScriptHistoricalScan{ + ScanID: dispatch.ScanID, + StartHeight: dispatch.StartHeight, + EndHeight: dispatch.EndHeight, + CompletedHeight: completedHeight, + Error: errString, + }, + }) + if err != nil { + if errors.Is(err, ErrPkScriptNotificationQueueFull) { + return nil + } + + return err + } + + return nil +} + +// finishHistoricalDispatch clears historical replay state while holding the +// TxNotifier lock. +func (n *TxNotifier) finishHistoricalDispatch( + dispatch *HistoricalPkScriptDispatch, completedHeight uint32, + scanErr error) error { + + n.Lock() + defer n.Unlock() + + sub := n.pkScriptNotifications[dispatch.SubscriptionID] + + return n.completeHistoricalPkScriptDispatchLocked( + sub, dispatch, completedHeight, scanErr, + ) +} + +// dispatchPkScriptConfsAtTip dispatches all pkScript confirmations that mature +// at the given height. +// +// NOTE: This method must be called with the TxNotifier's lock held. +func (n *TxNotifier) dispatchPkScriptConfsAtTip(blockHeight uint32, + blockHash *chainhash.Hash, block *wire.MsgBlock) error { + + subIndex := n.pkScriptConfsByHeight[blockHeight] + for subID, outpoints := range subIndex { + sub := n.pkScriptNotifications[subID] + if sub == nil { + delete(subIndex, subID) + continue + } + + for outpoint := range outpoints { + match := sub.matches[outpoint] + if match == nil { + delete(outpoints, outpoint) + continue + } + if sub.isPkScriptHistoricalActive(match.utxo.PkScript) { + continue + } + + err := n.dispatchPkScriptConfirm( + sub, match, outpoint, blockHeight, blockHash, block, + ) + if err != nil { + return err + } + if _, ok := n.pkScriptNotifications[subID]; !ok { + break + } + } + + if len(outpoints) == 0 { + delete(subIndex, subID) + } + } + + if len(n.pkScriptConfsByHeight[blockHeight]) == 0 { + delete(n.pkScriptConfsByHeight, blockHeight) + } + + return nil +} + +// dispatchPkScriptConfsForSub dispatches all pkScript confirmations that mature +// for a single subscription at the given height. +// +// NOTE: This method must be called with the TxNotifier's lock held. +func (n *TxNotifier) dispatchPkScriptConfsForSub(sub *pkScriptSubscription, + blockHeight uint32, blockHash *chainhash.Hash, block *wire.MsgBlock, + scriptSet map[string]struct{}, scriptEpochs map[string]uint64) error { + + heightIndex := n.pkScriptConfsByHeight[blockHeight] + if len(heightIndex) == 0 { + return nil + } + + outpoints := heightIndex[sub.id] + for outpoint := range outpoints { + match := sub.matches[outpoint] + if match == nil { + delete(outpoints, outpoint) + continue + } + + matchKey := string(match.utxo.PkScript) + if _, ok := scriptSet[matchKey]; !ok { + continue + } + if !historicalDispatchMatchesCurrentScript( + sub, matchKey, scriptEpochs, + ) { + + continue + } + + err := n.dispatchPkScriptConfirm( + sub, match, outpoint, blockHeight, blockHash, block, + ) + if err != nil { + return err + } + if _, ok := n.pkScriptNotifications[sub.id]; !ok { + return nil + } + } + + if len(heightIndex[sub.id]) == 0 { + delete(heightIndex, sub.id) + } + if len(heightIndex) == 0 { + delete(n.pkScriptConfsByHeight, blockHeight) + } + + return nil +} + +// processPkScriptTxAtTip updates pkScript subscription state using a transaction +// seen at the chain tip. +// +// NOTE: This method must be called with the TxNotifier's lock held. +func (n *TxNotifier) processPkScriptTxAtTip(tx *btcutil.Tx, txIndex, + blockHeight uint32, block *btcutil.Block) error { + + txHash := tx.Hash() + var ( + blockHash *chainhash.Hash + msgBlock *wire.MsgBlock + ) + if block != nil { + blockHash = block.Hash() + msgBlock = block.MsgBlock() + } + + for inputIdx, txIn := range tx.MsgTx().TxIn { + prevOut := txIn.PreviousOutPoint + subscriptions := n.pkScriptByOutpoint[prevOut] + for subID := range subscriptions { + sub := n.pkScriptNotifications[subID] + if sub == nil { + continue + } + + match := sub.matches[prevOut] + if match == nil { + continue + } + if sub.isPkScriptHistoricalActive(match.utxo.PkScript) { + continue + } + + err := n.dispatchPkScriptSpend( + sub, match, prevOut, txHash, txIndex, + uint32(inputIdx), blockHeight, blockHash, + tx.MsgTx(), msgBlock, + ) + if err != nil { + return err + } + if _, ok := n.pkScriptNotifications[subID]; !ok { + break + } + } + } + + for outIdx, txOut := range tx.MsgTx().TxOut { + subscriptions := n.pkScriptByScript[string(txOut.PkScript)] + if len(subscriptions) == 0 { + continue + } + + outpoint := wire.OutPoint{ + Hash: *txHash, + Index: uint32(outIdx), + } + + for subID := range subscriptions { + sub := n.pkScriptNotifications[subID] + if sub == nil { + continue + } + if sub.isPkScriptHistoricalActive(txOut.PkScript) { + continue + } + cfg, ok := sub.scripts[string(txOut.PkScript)] + if !ok { + continue + } + + n.trackPkScriptReceive( + sub, outpoint, btcutil.Amount(txOut.Value), + txOut.PkScript, tx.MsgTx(), blockHeight, + blockHash, txIndex, cfg, + ) + } + } + + return nil +} + +// pruneMaturePkScriptState removes pkScript reorg indexes that are no longer +// needed and drops fully mature matches when possible. +// +// NOTE: This method must be called with the TxNotifier's lock held. +func (n *TxNotifier) pruneMaturePkScriptState(matureBlockHeight uint32) { + pruneIndex := func( + index map[uint32]map[uint64]map[wire.OutPoint]struct{}) { + + subIndex := index[matureBlockHeight] + for subID, outpoints := range subIndex { + sub := n.pkScriptNotifications[subID] + if sub == nil { + continue + } + + for outpoint := range outpoints { + match := sub.matches[outpoint] + if match == nil { + continue + } + + if !n.shouldRetainPkScriptMatch(sub, match) { + n.removePkScriptMatch(sub, outpoint) + } + } + } + + delete(index, matureBlockHeight) + } + + pruneIndex(n.pkScriptReceivesByHeight) + + updateIndex := n.pkScriptConfUpdatesDispatchedByHeight[matureBlockHeight] + for subID, outpoints := range updateIndex { + sub := n.pkScriptNotifications[subID] + if sub == nil { + continue + } + + for outpoint := range outpoints { + match := sub.matches[outpoint] + if match == nil { + continue + } + + n.removeDispatchedPkScriptConfirmUpdate( + sub, match, outpoint, matureBlockHeight, + ) + if !n.shouldRetainPkScriptMatch(sub, match) { + n.removePkScriptMatch(sub, outpoint) + } + } + } + delete(n.pkScriptConfUpdatesDispatchedByHeight, matureBlockHeight) + + pruneIndex(n.pkScriptConfirmedByHeight) + pruneIndex(n.pkScriptSpendsByHeight) +} + +// ProcessHistoricalPkScriptBlock updates a subscription using a block returned +// by a historical pkScript rescan. +func (n *TxNotifier) ProcessHistoricalPkScriptBlock(subscriptionID uint64, + block *btcutil.Block, blockHeight uint32, pkScripts [][]byte) error { + + return n.ProcessHistoricalPkScriptBlockWithScriptSet( + subscriptionID, block, blockHeight, makePkScriptSet(pkScripts), + nil, + ) +} + +// ProcessHistoricalPkScriptBlockWithDispatch updates a subscription using a block +// returned by a historical pkScript rescan dispatch. +func (n *TxNotifier) ProcessHistoricalPkScriptBlockWithDispatch( + dispatch *HistoricalPkScriptDispatch, block *btcutil.Block, + blockHeight uint32) error { + + if dispatch == nil { + return nil + } + + return n.ProcessHistoricalPkScriptBlockWithScriptSet( + dispatch.SubscriptionID, block, blockHeight, dispatch.PkScriptSet(), + dispatch.scriptEpochs, + ) +} + +// ProcessHistoricalPkScriptBlockWithScriptSet updates a subscription using a +// block returned by a historical pkScript rescan and a precomputed script lookup +// set. +func (n *TxNotifier) ProcessHistoricalPkScriptBlockWithScriptSet( + subscriptionID uint64, block *btcutil.Block, blockHeight uint32, + scriptSet map[string]struct{}, scriptEpochs map[string]uint64) error { + + select { + case <-n.quit: + return ErrTxNotifierExiting + default: + } + + if block == nil { + return nil + } + + blockHash := block.Hash() + msgBlock := block.MsgBlock() + + for txIdx, tx := range block.Transactions() { + txHash := *tx.Hash() + txIndex := uint32(txIdx) + msgTx := tx.MsgTx() + + n.Lock() + sub := n.pkScriptNotifications[subscriptionID] + if sub == nil { + n.Unlock() + return nil + } + + for inputIdx, txIn := range msgTx.TxIn { + prevOut := txIn.PreviousOutPoint + match := sub.matches[prevOut] + if match == nil { + continue + } + matchKey := string(match.utxo.PkScript) + if _, ok := scriptSet[matchKey]; !ok { + continue + } + if !historicalDispatchMatchesCurrentScript( + sub, matchKey, scriptEpochs, + ) { + continue + } + + err := n.dispatchPkScriptSpend( + sub, match, prevOut, &txHash, txIndex, + uint32(inputIdx), blockHeight, blockHash, msgTx, + msgBlock, + ) + if err != nil { + n.Unlock() + return err + } + if _, ok := n.pkScriptNotifications[subscriptionID]; !ok { + n.Unlock() + return nil + } + } + + for outIdx, txOut := range msgTx.TxOut { + key := string(txOut.PkScript) + if _, ok := scriptSet[key]; !ok { + continue + } + + cfg, ok := sub.scripts[key] + if !ok { + continue + } + if !historicalDispatchMatchesCurrentScript( + sub, key, scriptEpochs, + ) { + continue + } + + outpoint := wire.OutPoint{ + Hash: txHash, + Index: uint32(outIdx), + } + + n.trackPkScriptReceive( + sub, outpoint, btcutil.Amount(txOut.Value), + txOut.PkScript, msgTx, blockHeight, + blockHash, txIndex, cfg, + ) + } + + n.Unlock() + } + + n.Lock() + defer n.Unlock() + + sub := n.pkScriptNotifications[subscriptionID] + if sub == nil { + return nil + } + + err := n.dispatchPkScriptConfirmUpdatesForSub( + sub, blockHeight, blockHash, msgBlock, scriptSet, scriptEpochs, + ) + if err != nil { + return err + } + if _, ok := n.pkScriptNotifications[subscriptionID]; !ok { + return nil + } + + return n.dispatchPkScriptConfsForSub( + sub, blockHeight, blockHash, msgBlock, scriptSet, scriptEpochs, + ) +} + +// SyncHistoricalPkScriptDispatch executes a historical pkScript scan while +// serializing all historical scans for the same subscription. This preserves +// block ordering for replayed pkScript activity. Live tip processing continues for +// other watched scripts, but skips the scripts being replayed so the historical +// dispatcher can catch them up in order. +// +// The scan is extended through the notifier's current tip before it completes. +// This closes the window where a live block can arrive after replay starts but +// before the replay has discovered an older UTXO that the live block spends. +func (n *TxNotifier) SyncHistoricalPkScriptDispatch( + dispatch *HistoricalPkScriptDispatch, + scanBlock func(height uint32) error, +) error { + + select { + case <-n.quit: + return ErrTxNotifierExiting + default: + } + + if dispatch == nil { + return nil + } + + n.Lock() + sub := n.pkScriptNotifications[dispatch.SubscriptionID] + n.Unlock() + + if sub == nil { + return nil + } + + n.pkScriptHistoricalDispatchMtx.Lock() + defer n.pkScriptHistoricalDispatchMtx.Unlock() + + sub.historicalDispatchMtx.Lock() + defer sub.historicalDispatchMtx.Unlock() + + select { + case <-n.quit: + return ErrTxNotifierExiting + default: + } + if !n.historicalDispatchActive( + dispatch.SubscriptionID, dispatch.scriptEpochs, + ) { + return nil + } + + startHeight := dispatch.StartHeight + endHeight := dispatch.EndHeight + completedHeight := uint32(0) + + for { + if startHeight <= endHeight { + for height := startHeight; ; height++ { + if !n.historicalDispatchActive( + dispatch.SubscriptionID, + dispatch.scriptEpochs, + ) { + return nil + } + + err := scanBlock(height) + if err != nil { + _ = n.finishHistoricalDispatch( + dispatch, completedHeight, err, + ) + + return err + } + completedHeight = height + + if height == endHeight { + break + } + } + } + + n.Lock() + sub := n.pkScriptNotifications[dispatch.SubscriptionID] + currentHeight := n.currentHeight + + if sub == nil { + n.Unlock() + + return nil + } + if currentHeight <= endHeight { + err := n.completeHistoricalPkScriptDispatchLocked( + sub, dispatch, completedHeight, nil, + ) + n.Unlock() + + return err + } + n.Unlock() + + startHeight = endHeight + 1 + endHeight = currentHeight + dispatch.EndHeight = endHeight + } +} + +// disconnectPkScriptTip rolls back pkScript indexes and dispatches +// disconnected notifications for the block being disconnected. +// +// NOTE: This method must be called with the TxNotifier's lock held. +func (n *TxNotifier) disconnectPkScriptTip(blockHeight uint32) error { + pkScriptConfirmUpdates := + n.pkScriptConfUpdatesDispatchedByHeight[blockHeight] + for subID, outpoints := range pkScriptConfirmUpdates { + sub := n.pkScriptNotifications[subID] + if sub == nil { + continue + } + + for outpoint := range outpoints { + match := sub.matches[outpoint] + if match == nil { + continue + } + + err := n.dispatchPkScriptConfirmUpdateReorg( + sub, match, outpoint, blockHeight, + ) + if err != nil { + return err + } + if _, ok := n.pkScriptNotifications[subID]; !ok { + continue + } + + n.schedulePkScriptConfirmUpdate( + sub, match, outpoint, blockHeight, + ) + } + } + delete(n.pkScriptConfUpdatesDispatchedByHeight, blockHeight) + + pkScriptConfirmed := n.pkScriptConfirmedByHeight[blockHeight] + for subID, outpoints := range pkScriptConfirmed { + sub := n.pkScriptNotifications[subID] + if sub == nil { + continue + } + + for outpoint := range outpoints { + match := sub.matches[outpoint] + if match == nil { + continue + } + + err := n.dispatchPkScriptConfirmReorg(sub, match, outpoint) + if err != nil { + return err + } + if _, ok := n.pkScriptNotifications[subID]; !ok { + continue + } + + addPkScriptHeightIndex( + n.pkScriptConfsByHeight, match.confirmHeight, + subID, outpoint, + ) + } + } + delete(n.pkScriptConfirmedByHeight, blockHeight) + + pkScriptSpends := n.pkScriptSpendsByHeight[blockHeight] + for subID, outpoints := range pkScriptSpends { + sub := n.pkScriptNotifications[subID] + if sub == nil { + continue + } + + for outpoint := range outpoints { + match := sub.matches[outpoint] + if match == nil { + continue + } + + err := n.dispatchPkScriptSpendReorg(sub, match) + if err != nil { + return err + } + if _, ok := n.pkScriptNotifications[subID]; !ok { + continue + } + + if match.watchConfig.events.Has(PkScriptEventSpend) { + subMap, ok := n.pkScriptByOutpoint[outpoint] + if !ok { + subMap = make(map[uint64]struct{}) + n.pkScriptByOutpoint[outpoint] = subMap + } + subMap[subID] = struct{}{} + } + } + } + delete(n.pkScriptSpendsByHeight, blockHeight) + + pkScriptReceives := n.pkScriptReceivesByHeight[blockHeight] + for subID, outpoints := range pkScriptReceives { + sub := n.pkScriptNotifications[subID] + if sub == nil { + continue + } + + for outpoint := range outpoints { + n.removePkScriptMatch(sub, outpoint) + } + } + delete(n.pkScriptReceivesByHeight, blockHeight) + + return nil +} diff --git a/chainntnfs/pkscriptnotifier_internal_test.go b/chainntnfs/pkscriptnotifier_internal_test.go new file mode 100644 index 00000000000..f45b890a2af --- /dev/null +++ b/chainntnfs/pkscriptnotifier_internal_test.go @@ -0,0 +1,383 @@ +package chainntnfs + +import ( + "container/list" + "errors" + "sync" + "testing" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" +) + +var internalTestPkScript = []byte{ + // OP_HASH160 OP_DATA_20 <20-byte script hash> OP_EQUAL. + 0xa9, 0x14, + 0x90, 0x1c, 0x86, 0x94, 0xc0, 0x3f, 0xaf, 0xd5, + 0x52, 0x28, 0x10, 0xe0, 0x33, 0x0f, 0x26, 0xe6, + 0x7a, 0x85, 0x33, 0xcd, + 0x87, +} + +type internalHintCache struct{} + +// CommitSpendHint satisfies SpendHintCache for internal tests. +func (i internalHintCache) CommitSpendHint(uint32, ...SpendRequest) error { + return nil +} + +// QuerySpendHint satisfies SpendHintCache for internal tests. +func (i internalHintCache) QuerySpendHint(SpendRequest) (uint32, error) { + return 0, ErrSpendHintNotFound +} + +// PurgeSpendHint satisfies SpendHintCache for internal tests. +func (i internalHintCache) PurgeSpendHint(...SpendRequest) error { + return nil +} + +// CommitConfirmHint satisfies ConfirmHintCache for internal tests. +func (i internalHintCache) CommitConfirmHint(uint32, ...ConfRequest) error { + return nil +} + +// QueryConfirmHint satisfies ConfirmHintCache for internal tests. +func (i internalHintCache) QueryConfirmHint(ConfRequest) (uint32, error) { + return 0, ErrConfirmHintNotFound +} + +// PurgeConfirmHint satisfies ConfirmHintCache for internal tests. +func (i internalHintCache) PurgeConfirmHint(...ConfRequest) error { + return nil +} + +// fullPkScriptNotificationQueue creates a queue already filled to its item +// limit. +func fullPkScriptNotificationQueue() *pkScriptNotificationQueue { + q := &pkScriptNotificationQueue{ + out: make(chan *PkScriptNotification), + pending: list.New(), + quit: make(chan struct{}), + } + q.cond = sync.NewCond(&q.mtx) + + for i := 0; i < MaxPkScriptNotificationQueueSize; i++ { + q.pending.PushBack(&queuedPkScriptNotification{ + ntfn: &PkScriptNotification{}, + size: 1, + }) + q.pendingBytes++ + } + + return q +} + +// TestPkScriptNotificationQueueByteLimit ensures a notification that would push +// the queue over its byte limit is rejected. +func TestPkScriptNotificationQueueByteLimit(t *testing.T) { + t.Parallel() + + q := &pkScriptNotificationQueue{ + out: make(chan *PkScriptNotification), + pending: list.New(), + pendingBytes: MaxPkScriptNotificationQueueBytes - 1, + quit: make(chan struct{}), + } + q.cond = sync.NewCond(&q.mtx) + + err := q.enqueue(&PkScriptNotification{}) + if !errors.Is(err, ErrPkScriptNotificationQueueFull) { + t.Fatalf("expected queue full error, got %v", err) + } +} + +// TestValidatePkScriptsRejectsOversizedScript ensures scripts larger than +// the consensus script size limit are rejected before registration. +func TestValidatePkScriptsRejectsOversizedScript(t *testing.T) { + t.Parallel() + + err := validatePkScripts( + [][]byte{make([]byte, txscript.MaxScriptSize+1)}, + ) + if !errors.Is(err, ErrPkScriptTooLarge) { + t.Fatalf("expected oversized script error, got %v", err) + } +} + +// makePkScriptBatchOverByteLimit returns a script batch whose aggregate size +// exceeds the provided byte limit while each individual script remains valid. +func makePkScriptBatchOverByteLimit(limit uint64) [][]byte { + var pkScripts [][]byte + for remaining := limit + 1; remaining > 0; { + size := uint64(txscript.MaxScriptSize) + if remaining < size { + size = remaining + } + + pkScripts = append(pkScripts, make([]byte, int(size))) + remaining -= size + } + + return pkScripts +} + +// TestValidatePkScriptsRejectsAggregateBatchBytes ensures a single mutation +// cannot carry an unbounded amount of script data. +func TestValidatePkScriptsRejectsAggregateBatchBytes(t *testing.T) { + t.Parallel() + + err := validatePkScripts( + makePkScriptBatchOverByteLimit(MaxPkScriptBatchBytes), + ) + if !errors.Is(err, ErrTooManyPkScripts) { + t.Fatalf("expected aggregate script byte error, got %v", err) + } +} + +// TestTxNotifierPkScriptResourceByteLimits ensures the notifier enforces byte +// limits in addition to script count limits. +func TestTxNotifierPkScriptResourceByteLimits(t *testing.T) { + t.Parallel() + + hintCache := internalHintCache{} + n := NewTxNotifier(10, ReorgSafetyLimit, hintCache, hintCache) + sub := &pkScriptSubscription{ + scripts: make(map[string]pkScriptWatchConfig), + scriptBytes: MaxPkScriptBytesPerRegistration - 1, + } + + err := n.validatePkScriptResourceLimits(sub, [][]byte{{0x51, 0x51}}) + if !errors.Is(err, ErrTooManyPkScripts) { + t.Fatalf("expected registration byte limit error, got %v", err) + } + + sub.scriptBytes = 0 + n.numPkScriptWatchBytes = MaxPkScriptWatchBytes - 1 + err = n.validatePkScriptResourceLimits(sub, [][]byte{{0x51, 0x51}}) + if !errors.Is(err, ErrTooManyPkScripts) { + t.Fatalf("expected global byte limit error, got %v", err) + } +} + +// TestTxNotifierPkScriptRemoveCleansEpochsWithoutRevivingStaleDispatch ensures +// removed script epochs are freed without allowing an old historical dispatch to +// match a script that was later re-added. +func TestTxNotifierPkScriptRemoveCleansEpochsWithoutRevivingStaleDispatch( + t *testing.T) { + + t.Parallel() + + hintCache := internalHintCache{} + n := NewTxNotifier(10, ReorgSafetyLimit, hintCache, hintCache) + reg, err := n.RegisterPkScriptNotifier() + if err != nil { + t.Fatalf("unable to register pkScript notifier: %v", err) + } + defer reg.Event.Cancel() + + dispatch1, _, _, err := reg.AddPkScripts( + [][]byte{internalTestPkScript}, WithHistoricalScanFrom(1), + ) + if err != nil { + t.Fatalf("unable to add first script: %v", err) + } + if dispatch1 == nil { + t.Fatalf("expected first historical dispatch") + } + + sub := n.pkScriptNotifications[dispatch1.SubscriptionID] + key := string(internalTestPkScript) + if sub.scriptEpochs[key] == 0 { + t.Fatalf("expected script epoch to be set") + } + + err = reg.RemovePkScripts([][]byte{internalTestPkScript}) + if err != nil { + t.Fatalf("unable to remove script: %v", err) + } + if _, ok := sub.scriptEpochs[key]; ok { + t.Fatalf("expected removed script epoch to be deleted") + } + if sub.scriptBytes != 0 { + t.Fatalf("expected removed script bytes to be freed") + } + if n.numPkScriptWatchBytes != 0 { + t.Fatalf("expected global script bytes to be freed") + } + + dispatch2, _, _, err := reg.AddPkScripts( + [][]byte{internalTestPkScript}, WithHistoricalScanFrom(1), + ) + if err != nil { + t.Fatalf("unable to re-add script: %v", err) + } + if dispatch2 == nil { + t.Fatalf("expected second historical dispatch") + } + if dispatch1.scriptEpochs[key] == dispatch2.scriptEpochs[key] { + t.Fatalf("expected re-added script to use a new epoch") + } + if historicalDispatchHasCurrentScript(sub, dispatch1.scriptEpochs) { + t.Fatalf("expected stale dispatch to be inactive") + } + if !historicalDispatchHasCurrentScript(sub, dispatch2.scriptEpochs) { + t.Fatalf("expected current dispatch to remain active") + } +} + +// TestTxNotifierPkScriptRegistrationLimit ensures the notifier rejects new +// pkScript registrations once the registration limit has been reached. +func TestTxNotifierPkScriptRegistrationLimit(t *testing.T) { + t.Parallel() + + hintCache := internalHintCache{} + n := NewTxNotifier(10, ReorgSafetyLimit, hintCache, hintCache) + + for i := 0; i < MaxPkScriptRegistrations; i++ { + n.pkScriptNotifications[uint64(i)] = &pkScriptSubscription{} + } + + _, err := n.RegisterPkScriptNotifier() + if !errors.Is(err, ErrTooManyPkScriptRegistrations) { + t.Fatalf("expected registration limit error, got %v", err) + } +} + +// TestTxNotifierDisconnectDoesNotReindexCanceledPkScriptSub ensures a +// subscription canceled during reorg handling is not reinserted into the +// notifier indexes. +func TestTxNotifierDisconnectDoesNotReindexCanceledPkScriptSub(t *testing.T) { + t.Parallel() + + hintCache := internalHintCache{} + n := NewTxNotifier(10, ReorgSafetyLimit, hintCache, hintCache) + + var ( + subID uint64 = 1 + blockHash = chainhash.Hash{1} + txHash = chainhash.Hash{2} + outpoint = wire.OutPoint{Hash: txHash} + ) + + sub := &pkScriptSubscription{ + id: subID, + scripts: map[string]pkScriptWatchConfig{string(internalTestPkScript): { + events: PkScriptEventConfirm | PkScriptEventSpend, + numConfs: 1, + }}, + scriptEpochs: make(map[string]uint64), + historicalScripts: make(map[string]uint64), + matches: map[wire.OutPoint]*pkScriptMatch{ + outpoint: { + watchConfig: pkScriptWatchConfig{ + events: PkScriptEventConfirm | + PkScriptEventSpend, + numConfs: 1, + }, + utxo: &PkScriptUTXO{ + OutPoint: outpoint, + PkScript: internalTestPkScript, + BlockHeight: 10, + }, + confirmDispatched: true, + confirmHeight: 10, + confirmBlockHash: &blockHash, + spendDispatched: true, + spendHeight: 10, + spendBlockHash: &blockHash, + spendTxHash: &txHash, + }, + }, + notificationQueue: fullPkScriptNotificationQueue(), + } + + n.pkScriptNotifications[subID] = sub + n.pkScriptByScript[string(internalTestPkScript)] = map[uint64]struct{}{ + subID: {}, + } + n.pkScriptByOutpoint[outpoint] = map[uint64]struct{}{subID: {}} + n.pkScriptConfirmedByHeight[10] = map[uint64]map[wire.OutPoint]struct{}{ + subID: {outpoint: {}}, + } + n.pkScriptSpendsByHeight[10] = map[uint64]map[wire.OutPoint]struct{}{ + subID: {outpoint: {}}, + } + n.numPkScriptWatches = 1 + + err := n.DisconnectTip(10) + if err != nil { + t.Fatalf("unable to disconnect tip: %v", err) + } + + if _, ok := n.pkScriptNotifications[subID]; ok { + t.Fatalf("expected overflowing subscription to be canceled") + } + if len(n.pkScriptConfsByHeight) != 0 { + t.Fatalf("expected no re-added confirmation indexes") + } + if len(n.pkScriptByOutpoint) != 0 { + t.Fatalf("expected no re-added outpoint indexes") + } + if n.numPkScriptWatches != 0 { + t.Fatalf("expected watch count to be zero") + } +} + +// TestTxNotifierRemovePkScriptsMatchCleansHeightZeroConfirm ensures removing a +// zero-height pkScript match also clears its confirmation indexes. +func TestTxNotifierRemovePkScriptsMatchCleansHeightZeroConfirm(t *testing.T) { + t.Parallel() + + hintCache := internalHintCache{} + n := NewTxNotifier(0, ReorgSafetyLimit, hintCache, hintCache) + + var ( + subID uint64 = 1 + txHash = chainhash.Hash{3} + outpoint = wire.OutPoint{Hash: txHash} + ) + + sub := &pkScriptSubscription{ + id: subID, + matches: map[wire.OutPoint]*pkScriptMatch{ + outpoint: { + watchConfig: pkScriptWatchConfig{ + events: PkScriptEventConfirm, + numConfs: 1, + }, + utxo: &PkScriptUTXO{ + OutPoint: outpoint, + PkScript: internalTestPkScript, + BlockHeight: 0, + }, + confirmHeight: 0, + }, + }, + } + + n.pkScriptConfsByHeight[0] = map[uint64]map[wire.OutPoint]struct{}{ + subID: {outpoint: {}}, + } + n.pkScriptConfirmedByHeight[0] = map[uint64]map[wire.OutPoint]struct{}{ + subID: {outpoint: {}}, + } + n.pkScriptReceivesByHeight[0] = map[uint64]map[wire.OutPoint]struct{}{ + subID: {outpoint: {}}, + } + + n.removePkScriptMatch(sub, outpoint) + + if len(sub.matches) != 0 { + t.Fatalf("expected match to be removed") + } + if len(n.pkScriptConfsByHeight) != 0 { + t.Fatalf("expected height-zero confirmation index to be removed") + } + if len(n.pkScriptConfirmedByHeight) != 0 { + t.Fatalf("expected height-zero confirmed index to be removed") + } + if len(n.pkScriptReceivesByHeight) != 0 { + t.Fatalf("expected height-zero receive index to be removed") + } +} diff --git a/chainntnfs/pkscriptnotifier_test.go b/chainntnfs/pkscriptnotifier_test.go new file mode 100644 index 00000000000..76213e4f1af --- /dev/null +++ b/chainntnfs/pkscriptnotifier_test.go @@ -0,0 +1,1723 @@ +package chainntnfs_test + +import ( + "errors" + "testing" + "time" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/stretchr/testify/require" +) + +var testRawScript2 = []byte{ + // OP_HASH160 + 0xa9, + // OP_DATA_20 + 0x14, + // <20-byte script hash> + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, + 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00, + 0x12, 0x34, 0x56, 0x78, + // OP_EQUAL + 0x87, +} + +// makeTestPkScripts creates count distinct scripts based on the test script +// template. +func makeTestPkScripts(count int) [][]byte { + pkScripts := make([][]byte, count) + for i := range pkScripts { + pkScript := append([]byte(nil), testRawScript...) + pkScript[2] = byte(i) + pkScript[3] = byte(i >> 8) + pkScript[4] = byte(i >> 16) + pkScript[5] = byte(i >> 24) + pkScripts[i] = pkScript + } + + return pkScripts +} + +// recvPkScriptNotifications reads count pkScript notifications from a +// registration. +func recvPkScriptNotifications(t *testing.T, + reg *chainntnfs.PkScriptNotificationRegistration, + count int) []*chainntnfs.PkScriptNotification { + + t.Helper() + + ntfns := make([]*chainntnfs.PkScriptNotification, 0, count) + for i := 0; i < count; i++ { + ntfns = append(ntfns, <-reg.Notifications) + } + + return ntfns +} + +// recvPkScriptNotificationTimeout reads one pkScript notification or fails the +// test on timeout. +func recvPkScriptNotificationTimeout(t *testing.T, + reg *chainntnfs.PkScriptNotificationRegistration, +) *chainntnfs.PkScriptNotification { + + t.Helper() + + select { + case ntfn, ok := <-reg.Notifications: + require.True(t, ok, "pkScript notification channel closed") + return ntfn + + case <-time.After(2 * time.Second): + t.Fatal("pkScript notification not received") + return nil + } +} + +// registerPkScriptNotifier registers scripts with a historical scan height on a +// TxNotifier. +func registerPkScriptNotifier(t *testing.T, n *chainntnfs.TxNotifier, + pkScripts [][]byte, events chainntnfs.PkScriptEventType, + numConfs, historicalScanFrom uint32, + opts ...chainntnfs.NotifierOption) (*chainntnfs.PkScriptRegistration, + *chainntnfs.HistoricalPkScriptDispatch) { + + t.Helper() + + reg, err := n.RegisterPkScriptNotifier() + require.NoError(t, err) + + addOpts := []chainntnfs.NotifierOption{ + chainntnfs.WithEvents(events), + chainntnfs.WithNumConfs(numConfs), + chainntnfs.WithHistoricalScanFrom(historicalScanFrom), + } + addOpts = append(addOpts, opts...) + + dispatch, _, _, err := reg.AddPkScripts(pkScripts, addOpts...) + require.NoError(t, err) + + return reg, dispatch +} + +// registerFuturePkScriptNotifier registers scripts for future-only TxNotifier +// pkScript notifications. +func registerFuturePkScriptNotifier(t *testing.T, n *chainntnfs.TxNotifier, + pkScripts [][]byte, events chainntnfs.PkScriptEventType, + numConfs uint32, + opts ...chainntnfs.NotifierOption) *chainntnfs.PkScriptRegistration { + + t.Helper() + + reg, err := n.RegisterPkScriptNotifier() + require.NoError(t, err) + + addOpts := []chainntnfs.NotifierOption{ + chainntnfs.WithEvents(events), + chainntnfs.WithNumConfs(numConfs), + } + addOpts = append(addOpts, opts...) + + _, _, _, err = reg.AddPkScripts(pkScripts, addOpts...) + require.NoError(t, err) + + return reg +} + +// TestTxNotifierPkScriptBatchLimit ensures add/remove mutations are bounded. +func TestTxNotifierPkScriptBatchLimit(t *testing.T) { + t.Parallel() + + hintCache := newMockHintCache() + n := chainntnfs.NewTxNotifier( + 10, chainntnfs.ReorgSafetyLimit, hintCache, hintCache, + ) + + reg, err := n.RegisterPkScriptNotifier() + require.NoError(t, err) + + oversizedBatch := makeTestPkScripts(chainntnfs.MaxPkScriptsPerBatch + 1) + _, _, _, err = reg.AddPkScripts(oversizedBatch) + require.True(t, errors.Is(err, chainntnfs.ErrTooManyPkScripts)) + + err = reg.RemovePkScripts(oversizedBatch) + require.True(t, errors.Is(err, chainntnfs.ErrTooManyPkScripts)) +} + +// TestTxNotifierLegacyNotificationsWithActivePkScriptNotifier ensures legacy +// confirmation and spend notifications still dispatch while pkScript +// subscriptions are active on the same block. +func TestTxNotifierLegacyNotificationsWithActivePkScriptNotifier(t *testing.T) { + t.Parallel() + + hintCache := newMockHintCache() + n := chainntnfs.NewTxNotifier( + 10, chainntnfs.ReorgSafetyLimit, hintCache, hintCache, + ) + + pkScriptReg := registerFuturePkScriptNotifier( + t, n, [][]byte{testRawScript2}, + chainntnfs.PkScriptEventConfirm, 1, + ) + + confTx := wire.NewMsgTx(2) + confTx.AddTxOut(&wire.TxOut{PkScript: testRawScript}) + confHash := confTx.TxHash() + + confNtfn, err := n.RegisterConf(&confHash, testRawScript, 1, 1) + require.NoError(t, err) + + spendOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{1}, + Index: 0, + } + spendNtfn, err := n.RegisterSpend( + &spendOutpoint, testRawScript, 1, + ) + require.NoError(t, err) + + spendTx := wire.NewMsgTx(2) + spendTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: spendOutpoint, + Witness: testWitness, + SignatureScript: testSigScript, + }) + + pkScriptTx := wire.NewMsgTx(2) + pkScriptTx.AddTxOut(&wire.TxOut{PkScript: testRawScript2}) + pkScriptHash := pkScriptTx.TxHash() + pkScriptOutpoint := wire.OutPoint{ + Hash: pkScriptHash, + Index: 0, + } + + block11 := btcutil.NewBlock(&wire.MsgBlock{ + Transactions: []*wire.MsgTx{confTx, spendTx, pkScriptTx}, + }) + + err = n.ConnectTip(block11, 11) + require.NoError(t, err) + + requirePkScriptNotification( + t, recvPkScriptNotificationTimeout(t, pkScriptReg.Event), + chainntnfs.PkScriptNotificationConfirm, false, 11, 1, 2, + &pkScriptHash, block11.Hash(), pkScriptOutpoint, + ) + + err = n.NotifyHeight(11) + require.NoError(t, err) + + select { + case conf := <-confNtfn.Event.Confirmed: + require.Equal(t, confHash, conf.Tx.TxHash()) + + case <-time.After(2 * time.Second): + t.Fatal("legacy confirmation notification not received") + } + + select { + case spend := <-spendNtfn.Event.Spend: + require.Equal(t, spendOutpoint, *spend.SpentOutPoint) + require.Equal(t, spendTx.TxHash(), *spend.SpenderTxHash) + + case <-time.After(2 * time.Second): + t.Fatal("legacy spend notification not received") + } +} + +// TestTxNotifierHistoricalPkScriptDispatch tests that pkScript notifications are +// replayed correctly from historical blocks. +func TestTxNotifierHistoricalPkScriptDispatch(t *testing.T) { + t.Parallel() + + hintCache := newMockHintCache() + n := chainntnfs.NewTxNotifier( + 13, chainntnfs.ReorgSafetyLimit, hintCache, hintCache, + ) + + reg, dispatch := registerPkScriptNotifier( + t, n, + [][]byte{testRawScript}, + chainntnfs.PkScriptEventConfirm| + chainntnfs.PkScriptEventSpend, + 2, 11, chainntnfs.WithIncludeBlock(), + chainntnfs.WithIncludeTx(), + ) + require.NotNil(t, dispatch) + + receiveTx := wire.NewMsgTx(2) + receiveTx.AddTxOut(&wire.TxOut{ + Value: 1000, + PkScript: testRawScript, + }) + receiveHash := receiveTx.TxHash() + receiveOutPoint := wire.OutPoint{Hash: receiveHash, Index: 0} + + spendTx := wire.NewMsgTx(2) + spendTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: receiveOutPoint, + Witness: testWitness, + SignatureScript: testSigScript, + }) + spendHash := spendTx.TxHash() + + block11 := btcutil.NewBlock(&wire.MsgBlock{ + Transactions: []*wire.MsgTx{receiveTx}, + }) + block12 := btcutil.NewBlock(&wire.MsgBlock{}) + block13 := btcutil.NewBlock(&wire.MsgBlock{ + Transactions: []*wire.MsgTx{spendTx}, + }) + + err := n.ProcessHistoricalPkScriptBlock( + dispatch.SubscriptionID, block11, 11, + dispatch.PkScripts, + ) + require.NoError(t, err) + + select { + case ntfn := <-reg.Event.Notifications: + t.Fatalf("received unexpected pkScript notification: %v", ntfn) + default: + } + + err = n.ProcessHistoricalPkScriptBlock( + dispatch.SubscriptionID, block12, 12, + dispatch.PkScripts, + ) + require.NoError(t, err) + + confirmNtfn := <-reg.Event.Notifications + requirePkScriptNotification( + t, confirmNtfn, + chainntnfs.PkScriptNotificationConfirm, false, 12, 2, 0, + &receiveHash, block12.Hash(), receiveOutPoint, + ) + require.NotNil(t, confirmNtfn.Tx) + require.Equal(t, receiveHash, confirmNtfn.Tx.TxHash()) + require.NotNil(t, confirmNtfn.Block) + require.Equal(t, *block12.Hash(), confirmNtfn.Block.BlockHash()) + + err = n.ProcessHistoricalPkScriptBlock( + dispatch.SubscriptionID, block13, 13, + dispatch.PkScripts, + ) + require.NoError(t, err) + + spendNtfn := <-reg.Event.Notifications + requirePkScriptNotification( + t, spendNtfn, + chainntnfs.PkScriptNotificationSpend, false, 13, 0, 0, + &spendHash, block13.Hash(), receiveOutPoint, + ) + require.NotNil(t, spendNtfn.Tx) + require.Equal(t, spendHash, spendNtfn.Tx.TxHash()) + require.NotNil(t, spendNtfn.Block) + require.Equal(t, *block13.Hash(), spendNtfn.Block.BlockHash()) +} + +// TestTxNotifierHistoricalPkScriptScanComplete ensures historical pkScript scans +// emit a terminal lifecycle event after replayed notifications. +func TestTxNotifierHistoricalPkScriptScanComplete(t *testing.T) { + t.Parallel() + + hintCache := newMockHintCache() + n := chainntnfs.NewTxNotifier( + 6, chainntnfs.ReorgSafetyLimit, hintCache, hintCache, + ) + + reg, dispatch := registerPkScriptNotifier( + t, n, + [][]byte{testRawScript}, chainntnfs.PkScriptEventConfirm, + 1, 5, + ) + require.NotNil(t, dispatch) + require.NotZero(t, dispatch.ScanID) + + receiveTx := wire.NewMsgTx(2) + receiveTx.AddTxOut(&wire.TxOut{ + Value: 1000, + PkScript: testRawScript, + }) + receiveHash := receiveTx.TxHash() + receiveOutPoint := wire.OutPoint{Hash: receiveHash, Index: 0} + block5 := btcutil.NewBlock(&wire.MsgBlock{ + Transactions: []*wire.MsgTx{receiveTx}, + }) + + err := n.SyncHistoricalPkScriptDispatch( + dispatch, + func(height uint32) error { + block := btcutil.NewBlock(&wire.MsgBlock{}) + if height == 5 { + block = block5 + } + + return n.ProcessHistoricalPkScriptBlockWithDispatch( + dispatch, block, height, + ) + }, + ) + require.NoError(t, err) + + confirmNtfn := recvPkScriptNotificationTimeout(t, reg.Event) + requirePkScriptNotification( + t, confirmNtfn, chainntnfs.PkScriptNotificationConfirm, false, + 5, 1, 0, &receiveHash, block5.Hash(), receiveOutPoint, + ) + + scanNtfn := recvPkScriptNotificationTimeout(t, reg.Event) + require.Equal( + t, chainntnfs.PkScriptNotificationHistoricalScanComplete, + scanNtfn.Type, + ) + require.NotNil(t, scanNtfn.HistoricalScan) + require.Equal(t, dispatch.ScanID, scanNtfn.HistoricalScan.ScanID) + require.Equal(t, uint32(5), scanNtfn.HistoricalScan.StartHeight) + require.Equal(t, uint32(6), scanNtfn.HistoricalScan.EndHeight) + require.Equal(t, uint32(6), scanNtfn.HistoricalScan.CompletedHeight) + require.Empty(t, scanNtfn.HistoricalScan.Error) +} + +// TestTxNotifierHistoricalPkScriptScanFailure ensures historical scan failures +// are surfaced to clients instead of only being logged by the backend. +func TestTxNotifierHistoricalPkScriptScanFailure(t *testing.T) { + t.Parallel() + + hintCache := newMockHintCache() + n := chainntnfs.NewTxNotifier( + 6, chainntnfs.ReorgSafetyLimit, hintCache, hintCache, + ) + + reg, dispatch := registerPkScriptNotifier( + t, n, + [][]byte{testRawScript}, chainntnfs.PkScriptEventConfirm, + 1, 5, + ) + require.NotNil(t, dispatch) + + expectedErr := errors.New("scan failed") + err := n.SyncHistoricalPkScriptDispatch( + dispatch, + func(height uint32) error { + if height == 6 { + return expectedErr + } + + return n.ProcessHistoricalPkScriptBlockWithDispatch( + dispatch, btcutil.NewBlock(&wire.MsgBlock{}), height, + ) + }, + ) + require.ErrorIs(t, err, expectedErr) + + scanNtfn := recvPkScriptNotificationTimeout(t, reg.Event) + require.Equal( + t, chainntnfs.PkScriptNotificationHistoricalScanComplete, + scanNtfn.Type, + ) + require.NotNil(t, scanNtfn.HistoricalScan) + require.Equal(t, dispatch.ScanID, scanNtfn.HistoricalScan.ScanID) + require.Equal(t, uint32(5), scanNtfn.HistoricalScan.CompletedHeight) + require.Contains(t, scanNtfn.HistoricalScan.Error, expectedErr.Error()) +} + +// TestTxNotifierHistoricalPkScriptDispatchCatchesLiveTip ensures historical +// pkScript replay rescans live blocks that arrived while the replay was still +// catching up. This prevents a spend from being missed when its funding output +// is discovered by the historical replay after the live block was processed. +func TestTxNotifierHistoricalPkScriptDispatchCatchesLiveTip(t *testing.T) { + t.Parallel() + + hintCache := newMockHintCache() + n := chainntnfs.NewTxNotifier( + 10, chainntnfs.ReorgSafetyLimit, hintCache, hintCache, + ) + + reg, dispatch := registerPkScriptNotifier( + t, n, + [][]byte{testRawScript}, chainntnfs.PkScriptEventSpend, + 0, 5, + ) + require.NotNil(t, dispatch) + require.Equal(t, uint32(10), dispatch.EndHeight) + + receiveTx := wire.NewMsgTx(2) + receiveTx.AddTxOut(&wire.TxOut{ + Value: 1000, + PkScript: testRawScript, + }) + receiveHash := receiveTx.TxHash() + receiveOutPoint := wire.OutPoint{Hash: receiveHash, Index: 0} + + spendTx := wire.NewMsgTx(2) + spendTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: receiveOutPoint, + Witness: testWitness, + SignatureScript: testSigScript, + }) + spendHash := spendTx.TxHash() + + block5 := btcutil.NewBlock(&wire.MsgBlock{ + Transactions: []*wire.MsgTx{receiveTx}, + }) + block11 := btcutil.NewBlock(&wire.MsgBlock{ + Transactions: []*wire.MsgTx{spendTx}, + }) + + // The live spend is processed before the historical replay has + // discovered the funding output, so it cannot be matched yet. + err := n.ConnectTip(block11, 11) + require.NoError(t, err) + + select { + case ntfn := <-reg.Event.Notifications: + t.Fatalf("received unexpected pkScript notification: %v", ntfn) + default: + } + + err = n.SyncHistoricalPkScriptDispatch( + dispatch, + func(height uint32) error { + block := btcutil.NewBlock(&wire.MsgBlock{}) + switch height { + case 5: + block = block5 + case 11: + block = block11 + } + + return n.ProcessHistoricalPkScriptBlockWithDispatch( + dispatch, block, height, + ) + }, + ) + require.NoError(t, err) + + spendNtfn := recvPkScriptNotificationTimeout(t, reg.Event) + requirePkScriptNotification( + t, spendNtfn, chainntnfs.PkScriptNotificationSpend, false, + 11, 0, 0, &spendHash, block11.Hash(), receiveOutPoint, + ) + require.Equal(t, uint32(0), spendNtfn.InputIndex) +} + +// TestTxNotifierHistoricalPkScriptDispatchGatesLiveTipBeforeStart ensures live +// blocks for a historical add request are withheld even while the historical +// scan is still queued and has not started yet. +func TestTxNotifierHistoricalPkScriptDispatchGatesLiveTipBeforeStart(t *testing.T) { + t.Parallel() + + hintCache := newMockHintCache() + n := chainntnfs.NewTxNotifier( + 10, chainntnfs.ReorgSafetyLimit, hintCache, hintCache, + ) + + reg, dispatch := registerPkScriptNotifier( + t, n, + [][]byte{testRawScript}, chainntnfs.PkScriptEventConfirm, + 1, 5, + ) + require.NotNil(t, dispatch) + require.Equal(t, uint32(10), dispatch.EndHeight) + + receiveTx := wire.NewMsgTx(2) + receiveTx.AddTxOut(&wire.TxOut{ + Value: 1000, + PkScript: testRawScript, + }) + receiveHash := receiveTx.TxHash() + receiveOutPoint := wire.OutPoint{Hash: receiveHash, Index: 0} + block11 := btcutil.NewBlock(&wire.MsgBlock{ + Transactions: []*wire.MsgTx{receiveTx}, + }) + + // The live block arrives before the queued historical scan starts. It must + // not be delivered directly because older historical events for this script + // may still need to be replayed first. + err := n.ConnectTip(block11, 11) + require.NoError(t, err) + + select { + case ntfn := <-reg.Event.Notifications: + t.Fatalf("received unexpected live notification: %v", ntfn) + + case <-time.After(100 * time.Millisecond): + } + + var scannedLiveBlock bool + err = n.SyncHistoricalPkScriptDispatch( + dispatch, + func(height uint32) error { + block := btcutil.NewBlock(&wire.MsgBlock{}) + if height == 11 { + scannedLiveBlock = true + block = block11 + } + + return n.ProcessHistoricalPkScriptBlockWithDispatch( + dispatch, block, height, + ) + }, + ) + require.NoError(t, err) + require.True(t, scannedLiveBlock) + + confirmNtfn := recvPkScriptNotificationTimeout(t, reg.Event) + requirePkScriptNotification( + t, confirmNtfn, chainntnfs.PkScriptNotificationConfirm, false, + 11, 1, 0, &receiveHash, block11.Hash(), receiveOutPoint, + ) +} + +// TestTxNotifierStaleHistoricalDispatchDoesNotAffectReAdd ensures a historical +// scan queued for a removed script cannot replay or clear a later re-add of that +// same script. +func TestTxNotifierStaleHistoricalDispatchDoesNotAffectReAdd(t *testing.T) { + t.Parallel() + + hintCache := newMockHintCache() + n := chainntnfs.NewTxNotifier( + 10, chainntnfs.ReorgSafetyLimit, hintCache, hintCache, + ) + + reg, oldDispatch := registerPkScriptNotifier( + t, n, + [][]byte{testRawScript}, chainntnfs.PkScriptEventConfirm, + 1, 5, + ) + require.NotNil(t, oldDispatch) + + err := reg.RemovePkScripts([][]byte{testRawScript}) + require.NoError(t, err) + + newDispatch, _, _, err := reg.AddPkScripts( + [][]byte{testRawScript}, + chainntnfs.WithEvents(chainntnfs.PkScriptEventConfirm), + chainntnfs.WithHistoricalScanFrom(8), + ) + require.NoError(t, err) + require.NotNil(t, newDispatch) + + var staleScanCalled bool + err = n.SyncHistoricalPkScriptDispatch( + oldDispatch, + func(height uint32) error { + staleScanCalled = true + + return nil + }, + ) + require.NoError(t, err) + require.False(t, staleScanCalled) + + select { + case ntfn := <-reg.Event.Notifications: + t.Fatalf("stale historical dispatch sent notification: %v", ntfn) + default: + } + + liveTx := wire.NewMsgTx(2) + liveTx.AddTxOut(&wire.TxOut{ + Value: 2000, + PkScript: testRawScript, + }) + liveHash := liveTx.TxHash() + liveOutPoint := wire.OutPoint{Hash: liveHash, Index: 0} + liveBlock := btcutil.NewBlock(&wire.MsgBlock{ + Transactions: []*wire.MsgTx{liveTx}, + }) + + err = n.ConnectTip(liveBlock, 11) + require.NoError(t, err) + + select { + case ntfn := <-reg.Event.Notifications: + t.Fatalf("live notification was not gated: %v", ntfn) + default: + } + + err = n.SyncHistoricalPkScriptDispatch( + newDispatch, + func(height uint32) error { + block := btcutil.NewBlock(&wire.MsgBlock{}) + if height == 11 { + block = liveBlock + } + + return n.ProcessHistoricalPkScriptBlockWithDispatch( + newDispatch, block, height, + ) + }, + ) + require.NoError(t, err) + + confirmNtfn := recvPkScriptNotificationTimeout(t, reg.Event) + requirePkScriptNotification( + t, confirmNtfn, chainntnfs.PkScriptNotificationConfirm, false, + 11, 1, 0, &liveHash, liveBlock.Hash(), liveOutPoint, + ) +} + +// TestTxNotifierHistoricalPkScriptDispatchDoesNotConfirmUnrelatedScripts ensures +// the confirmation pass at the end of each historical block only dispatches +// confirmations for scripts that belong to the historical add request. +func TestTxNotifierHistoricalPkScriptDispatchDoesNotConfirmUnrelatedScripts( + t *testing.T) { + + t.Parallel() + + hintCache := newMockHintCache() + n := chainntnfs.NewTxNotifier( + 10, chainntnfs.ReorgSafetyLimit, hintCache, hintCache, + ) + + reg := registerFuturePkScriptNotifier( + t, n, [][]byte{testRawScript2}, + chainntnfs.PkScriptEventConfirm, 2, + ) + + receiveTx := wire.NewMsgTx(2) + receiveTx.AddTxOut(&wire.TxOut{ + Value: 1000, + PkScript: testRawScript2, + }) + receiveHash := receiveTx.TxHash() + receiveOutPoint := wire.OutPoint{Hash: receiveHash, Index: 0} + block11 := btcutil.NewBlock(&wire.MsgBlock{ + Transactions: []*wire.MsgTx{receiveTx}, + }) + + err := n.ConnectTip(block11, 11) + require.NoError(t, err) + + select { + case ntfn := <-reg.Event.Notifications: + t.Fatalf("received unexpected confirmation: %v", ntfn) + default: + } + + dispatch, _, _, err := reg.AddPkScripts( + [][]byte{testRawScript}, + chainntnfs.WithEvents(chainntnfs.PkScriptEventConfirm), + chainntnfs.WithNumConfs(1), + chainntnfs.WithHistoricalScanFrom(0), + ) + require.NoError(t, err) + require.NotNil(t, dispatch) + + block12 := btcutil.NewBlock(&wire.MsgBlock{}) + err = n.ProcessHistoricalPkScriptBlockWithDispatch(dispatch, block12, 12) + require.NoError(t, err) + + select { + case ntfn := <-reg.Event.Notifications: + t.Fatalf("historical dispatch confirmed unrelated script: %v", ntfn) + case <-time.After(100 * time.Millisecond): + } + + err = n.ConnectTip(block12, 12) + require.NoError(t, err) + + confirmNtfn := recvPkScriptNotificationTimeout(t, reg.Event) + requirePkScriptNotification( + t, confirmNtfn, chainntnfs.PkScriptNotificationConfirm, false, + 12, 2, 0, &receiveHash, block12.Hash(), receiveOutPoint, + ) +} + +// TestTxNotifierHistoricalPkScriptDispatchOrdersLiveTip ensures live blocks that +// arrive while a historical replay is active are replayed by the historical +// dispatcher, so notifications for that script remain height ordered. +func TestTxNotifierHistoricalPkScriptDispatchOrdersLiveTip(t *testing.T) { + t.Parallel() + + hintCache := newMockHintCache() + n := chainntnfs.NewTxNotifier( + 10, chainntnfs.ReorgSafetyLimit, hintCache, hintCache, + ) + + reg, dispatch := registerPkScriptNotifier( + t, n, + [][]byte{testRawScript}, + chainntnfs.PkScriptEventConfirm| + chainntnfs.PkScriptEventSpend, + 2, 5, + ) + require.NotNil(t, dispatch) + + receiveTx := wire.NewMsgTx(2) + receiveTx.AddTxOut(&wire.TxOut{ + Value: 1000, + PkScript: testRawScript, + }) + receiveHash := receiveTx.TxHash() + receiveOutPoint := wire.OutPoint{Hash: receiveHash, Index: 0} + + spendTx := wire.NewMsgTx(2) + spendTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: receiveOutPoint, + Witness: testWitness, + SignatureScript: testSigScript, + }) + spendHash := spendTx.TxHash() + + block5 := btcutil.NewBlock(&wire.MsgBlock{ + Transactions: []*wire.MsgTx{receiveTx}, + }) + block6 := btcutil.NewBlock(&wire.MsgBlock{}) + block11 := btcutil.NewBlock(&wire.MsgBlock{ + Transactions: []*wire.MsgTx{spendTx}, + }) + + var connectedLiveTip bool + err := n.SyncHistoricalPkScriptDispatch( + dispatch, + func(height uint32) error { + block := btcutil.NewBlock(&wire.MsgBlock{}) + switch height { + case 5: + block = block5 + case 6: + block = block6 + case 11: + block = block11 + } + + err := n.ProcessHistoricalPkScriptBlockWithDispatch( + dispatch, block, height, + ) + if err != nil { + return err + } + + if height == 5 && !connectedLiveTip { + connectedLiveTip = true + return n.ConnectTip(block11, 11) + } + + return nil + }, + ) + require.NoError(t, err) + + confirmNtfn := recvPkScriptNotificationTimeout(t, reg.Event) + requirePkScriptNotification( + t, confirmNtfn, chainntnfs.PkScriptNotificationConfirm, false, + 6, 2, 0, &receiveHash, block6.Hash(), receiveOutPoint, + ) + + spendNtfn := recvPkScriptNotificationTimeout(t, reg.Event) + requirePkScriptNotification( + t, spendNtfn, chainntnfs.PkScriptNotificationSpend, false, + 11, 0, 0, &spendHash, block11.Hash(), receiveOutPoint, + ) +} + +// TestTxNotifierHistoricalPkScriptDispatchMultipleScripts ensures all scripts +// supplied at registration are replayed from the provided height hint. +func TestTxNotifierHistoricalPkScriptDispatchMultipleScripts(t *testing.T) { + t.Parallel() + + hintCache := newMockHintCache() + n := chainntnfs.NewTxNotifier( + 12, chainntnfs.ReorgSafetyLimit, hintCache, hintCache, + ) + + scriptA := testRawScript + scriptB := testRawScript2 + + reg, dispatch := registerPkScriptNotifier( + t, n, + [][]byte{scriptA, scriptB}, + chainntnfs.PkScriptEventConfirm| + chainntnfs.PkScriptEventSpend, + 1, 11, + ) + require.NotNil(t, dispatch) + require.Len(t, dispatch.PkScripts, 2) + + receiveTxA := wire.NewMsgTx(2) + receiveTxA.AddTxOut(&wire.TxOut{ + Value: 1000, + PkScript: scriptA, + }) + receiveHashA := receiveTxA.TxHash() + receiveOutPointA := wire.OutPoint{Hash: receiveHashA, Index: 0} + + receiveTxB := wire.NewMsgTx(2) + receiveTxB.AddTxOut(&wire.TxOut{ + Value: 2000, + PkScript: scriptB, + }) + receiveHashB := receiveTxB.TxHash() + receiveOutPointB := wire.OutPoint{Hash: receiveHashB, Index: 0} + + block11 := btcutil.NewBlock(&wire.MsgBlock{ + Transactions: []*wire.MsgTx{receiveTxA, receiveTxB}, + }) + + err := n.ProcessHistoricalPkScriptBlock( + dispatch.SubscriptionID, block11, 11, + dispatch.PkScripts, + ) + require.NoError(t, err) + + confirmNtfns := recvPkScriptNotifications(t, reg.Event, 2) + seenConfirm := make(map[wire.OutPoint]struct{}, 2) + for _, ntfn := range confirmNtfns { + require.Equal(t, chainntnfs.PkScriptNotificationConfirm, ntfn.Type) + switch ntfn.UTXO.OutPoint { + case receiveOutPointA: + requirePkScriptNotification( + t, ntfn, chainntnfs.PkScriptNotificationConfirm, + false, 11, 1, 0, &receiveHashA, block11.Hash(), + receiveOutPointA, + ) + seenConfirm[receiveOutPointA] = struct{}{} + + case receiveOutPointB: + requirePkScriptNotification( + t, ntfn, chainntnfs.PkScriptNotificationConfirm, + false, 11, 1, 1, &receiveHashB, block11.Hash(), + receiveOutPointB, + ) + seenConfirm[receiveOutPointB] = struct{}{} + } + } + require.Len(t, seenConfirm, 2) + + spendTxA := wire.NewMsgTx(2) + spendTxA.AddTxIn(&wire.TxIn{ + PreviousOutPoint: receiveOutPointA, + Witness: testWitness, + SignatureScript: testSigScript, + }) + spendHashA := spendTxA.TxHash() + + spendTxB := wire.NewMsgTx(2) + spendTxB.AddTxIn(&wire.TxIn{ + PreviousOutPoint: receiveOutPointB, + Witness: testWitness, + SignatureScript: testSigScript, + }) + spendHashB := spendTxB.TxHash() + + block12 := btcutil.NewBlock(&wire.MsgBlock{ + Transactions: []*wire.MsgTx{spendTxA, spendTxB}, + }) + + err = n.ProcessHistoricalPkScriptBlock( + dispatch.SubscriptionID, block12, 12, + dispatch.PkScripts, + ) + require.NoError(t, err) + + spendNtfns := recvPkScriptNotifications(t, reg.Event, 2) + seenSpend := make(map[wire.OutPoint]struct{}, 2) + for _, ntfn := range spendNtfns { + require.Equal(t, chainntnfs.PkScriptNotificationSpend, ntfn.Type) + switch ntfn.UTXO.OutPoint { + case receiveOutPointA: + requirePkScriptNotification( + t, ntfn, chainntnfs.PkScriptNotificationSpend, + false, 12, 0, 0, &spendHashA, block12.Hash(), + receiveOutPointA, + ) + seenSpend[receiveOutPointA] = struct{}{} + + case receiveOutPointB: + requirePkScriptNotification( + t, ntfn, chainntnfs.PkScriptNotificationSpend, + false, 12, 0, 1, &spendHashB, block12.Hash(), + receiveOutPointB, + ) + seenSpend[receiveOutPointB] = struct{}{} + } + } + require.Len(t, seenSpend, 2) +} + +// TestTxNotifierPkScriptAddRemove ensures pkScripts can be added and removed on +// the fly. +func TestTxNotifierPkScriptAddRemove(t *testing.T) { + t.Parallel() + + hintCache := newMockHintCache() + n := chainntnfs.NewTxNotifier( + 12, chainntnfs.ReorgSafetyLimit, hintCache, hintCache, + ) + + reg, err := n.RegisterPkScriptNotifier() + require.NoError(t, err) + + dispatch, _, _, err := reg.AddPkScripts( + [][]byte{testRawScript}, + chainntnfs.WithHistoricalScanFrom(11), + ) + require.NoError(t, err) + require.NotNil(t, dispatch) + + receiveTx := wire.NewMsgTx(2) + receiveTx.AddTxOut(&wire.TxOut{ + Value: 1000, + PkScript: testRawScript, + }) + receiveHash := receiveTx.TxHash() + receiveOutPoint := wire.OutPoint{Hash: receiveHash, Index: 0} + + block11 := btcutil.NewBlock(&wire.MsgBlock{ + Transactions: []*wire.MsgTx{receiveTx}, + }) + + err = n.ProcessHistoricalPkScriptBlock( + dispatch.SubscriptionID, block11, 11, dispatch.PkScripts, + ) + require.NoError(t, err) + + requirePkScriptNotification( + t, <-reg.Event.Notifications, + chainntnfs.PkScriptNotificationConfirm, false, 11, 1, 0, + &receiveHash, block11.Hash(), receiveOutPoint, + ) + + err = reg.RemovePkScripts([][]byte{testRawScript}) + require.NoError(t, err) + + spendTx := wire.NewMsgTx(2) + spendTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: receiveOutPoint, + Witness: testWitness, + SignatureScript: testSigScript, + }) + block13 := btcutil.NewBlock(&wire.MsgBlock{ + Transactions: []*wire.MsgTx{spendTx}, + }) + + err = n.ConnectTip(block13, 13) + require.NoError(t, err) + + select { + case ntfn := <-reg.Event.Notifications: + t.Fatalf("received unexpected pkScript notification: %v", ntfn) + default: + } +} + +// TestTxNotifierPkScriptAddMultipleScriptsHistorical ensures AddPkScripts +// replays all provided scripts from the supplied height hint. +func TestTxNotifierPkScriptAddMultipleScriptsHistorical(t *testing.T) { + t.Parallel() + + hintCache := newMockHintCache() + n := chainntnfs.NewTxNotifier( + 12, chainntnfs.ReorgSafetyLimit, hintCache, hintCache, + ) + + reg, err := n.RegisterPkScriptNotifier() + require.NoError(t, err) + + dispatch, _, _, err := reg.AddPkScripts( + [][]byte{testRawScript, testRawScript2}, + chainntnfs.WithHistoricalScanFrom(11), + ) + require.NoError(t, err) + require.NotNil(t, dispatch) + require.Len(t, dispatch.PkScripts, 2) + + receiveTxA := wire.NewMsgTx(2) + receiveTxA.AddTxOut(&wire.TxOut{ + Value: 1000, + PkScript: testRawScript, + }) + receiveHashA := receiveTxA.TxHash() + receiveOutPointA := wire.OutPoint{Hash: receiveHashA, Index: 0} + + receiveTxB := wire.NewMsgTx(2) + receiveTxB.AddTxOut(&wire.TxOut{ + Value: 2000, + PkScript: testRawScript2, + }) + receiveHashB := receiveTxB.TxHash() + receiveOutPointB := wire.OutPoint{Hash: receiveHashB, Index: 0} + + block11 := btcutil.NewBlock(&wire.MsgBlock{ + Transactions: []*wire.MsgTx{receiveTxA, receiveTxB}, + }) + + err = n.ProcessHistoricalPkScriptBlock( + dispatch.SubscriptionID, block11, 11, dispatch.PkScripts, + ) + require.NoError(t, err) + + confirmNtfns := recvPkScriptNotifications(t, reg.Event, 2) + seenConfirm := make(map[wire.OutPoint]struct{}, 2) + for _, ntfn := range confirmNtfns { + require.Equal(t, chainntnfs.PkScriptNotificationConfirm, ntfn.Type) + switch ntfn.UTXO.OutPoint { + case receiveOutPointA: + requirePkScriptNotification( + t, ntfn, chainntnfs.PkScriptNotificationConfirm, + false, 11, 1, 0, &receiveHashA, block11.Hash(), + receiveOutPointA, + ) + seenConfirm[receiveOutPointA] = struct{}{} + + case receiveOutPointB: + requirePkScriptNotification( + t, ntfn, chainntnfs.PkScriptNotificationConfirm, + false, 11, 1, 1, &receiveHashB, block11.Hash(), + receiveOutPointB, + ) + seenConfirm[receiveOutPointB] = struct{}{} + } + } + require.Len(t, seenConfirm, 2) +} + +// TestTxNotifierPkScriptSkipHistoricalScan ensures pkScript registrations can opt +// out of historical replay while still receiving future notifications. +func TestTxNotifierPkScriptSkipHistoricalScan(t *testing.T) { + t.Parallel() + + hintCache := newMockHintCache() + n := chainntnfs.NewTxNotifier( + 12, chainntnfs.ReorgSafetyLimit, hintCache, hintCache, + ) + + reg := registerFuturePkScriptNotifier( + t, n, + [][]byte{testRawScript}, + chainntnfs.PkScriptEventConfirm| + chainntnfs.PkScriptEventSpend, + 1, + ) + + receiveTx := wire.NewMsgTx(2) + receiveTx.AddTxOut(&wire.TxOut{ + Value: 1000, + PkScript: testRawScript, + }) + receiveHash := receiveTx.TxHash() + receiveOutPoint := wire.OutPoint{Hash: receiveHash, Index: 0} + + block13 := btcutil.NewBlock(&wire.MsgBlock{ + Transactions: []*wire.MsgTx{receiveTx}, + }) + err := n.ConnectTip(block13, 13) + require.NoError(t, err) + + requirePkScriptNotification( + t, <-reg.Event.Notifications, + chainntnfs.PkScriptNotificationConfirm, false, 13, 1, 0, + &receiveHash, block13.Hash(), receiveOutPoint, + ) + + spendTx := wire.NewMsgTx(2) + spendTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: receiveOutPoint, + Witness: testWitness, + SignatureScript: testSigScript, + }) + spendHash := spendTx.TxHash() + + block14 := btcutil.NewBlock(&wire.MsgBlock{ + Transactions: []*wire.MsgTx{spendTx}, + }) + err = n.ConnectTip(block14, 14) + require.NoError(t, err) + + requirePkScriptNotification( + t, <-reg.Event.Notifications, + chainntnfs.PkScriptNotificationSpend, false, 14, 0, 0, + &spendHash, block14.Hash(), receiveOutPoint, + ) +} + +// TestTxNotifierPkScriptAddSkipHistoricalScan ensures scripts added to an +// existing subscription can opt out of historical replay independently. +func TestTxNotifierPkScriptAddSkipHistoricalScan(t *testing.T) { + t.Parallel() + + hintCache := newMockHintCache() + n := chainntnfs.NewTxNotifier( + 12, chainntnfs.ReorgSafetyLimit, hintCache, hintCache, + ) + + reg, err := n.RegisterPkScriptNotifier() + require.NoError(t, err) + + dispatch, _, _, err := reg.AddPkScripts( + [][]byte{testRawScript}, + chainntnfs.WithEvents(chainntnfs.PkScriptEventConfirm), + ) + require.NoError(t, err) + require.Nil(t, dispatch) + + receiveTx := wire.NewMsgTx(2) + receiveTx.AddTxOut(&wire.TxOut{ + Value: 1000, + PkScript: testRawScript, + }) + receiveHash := receiveTx.TxHash() + receiveOutPoint := wire.OutPoint{Hash: receiveHash, Index: 0} + + block13 := btcutil.NewBlock(&wire.MsgBlock{ + Transactions: []*wire.MsgTx{receiveTx}, + }) + err = n.ConnectTip(block13, 13) + require.NoError(t, err) + + requirePkScriptNotification( + t, <-reg.Event.Notifications, + chainntnfs.PkScriptNotificationConfirm, false, 13, 1, 0, + &receiveHash, block13.Hash(), receiveOutPoint, + ) +} + +// TestTxNotifierHistoricalPkScriptDispatchCachedSetRespectsRemove ensures the +// cached historical script set doesn't resurrect scripts removed mid-replay. +func TestTxNotifierHistoricalPkScriptDispatchCachedSetRespectsRemove( + t *testing.T) { + + t.Parallel() + + hintCache := newMockHintCache() + n := chainntnfs.NewTxNotifier( + 12, chainntnfs.ReorgSafetyLimit, hintCache, hintCache, + ) + + reg, dispatch := registerPkScriptNotifier( + t, n, + [][]byte{testRawScript, testRawScript2}, + chainntnfs.PkScriptEventConfirm, 1, 11, + ) + require.NotNil(t, dispatch) + + err := reg.RemovePkScripts([][]byte{testRawScript2}) + require.NoError(t, err) + + receiveTx := wire.NewMsgTx(2) + receiveTx.AddTxOut(&wire.TxOut{ + Value: 1000, + PkScript: testRawScript2, + }) + block11 := btcutil.NewBlock(&wire.MsgBlock{ + Transactions: []*wire.MsgTx{receiveTx}, + }) + + err = n.ProcessHistoricalPkScriptBlockWithScriptSet( + dispatch.SubscriptionID, block11, 11, + dispatch.PkScriptSet(), nil, + ) + require.NoError(t, err) + + select { + case ntfn := <-reg.Event.Notifications: + t.Fatalf("received unexpected pkScript notification: %v", ntfn) + default: + } +} + +// TestTxNotifierPkScriptReorg ensures pkScript notifications are invalidated and +// redelivered across reorgs. +func TestTxNotifierPkScriptReorg(t *testing.T) { + t.Parallel() + + hintCache := newMockHintCache() + n := chainntnfs.NewTxNotifier( + 10, chainntnfs.ReorgSafetyLimit, hintCache, hintCache, + ) + + reg := registerFuturePkScriptNotifier( + t, n, + [][]byte{testRawScript}, + chainntnfs.PkScriptEventConfirm| + chainntnfs.PkScriptEventSpend, + 2, + ) + + receiveTx := wire.NewMsgTx(2) + receiveTx.AddTxOut(&wire.TxOut{ + Value: 1000, + PkScript: testRawScript, + }) + receiveHash := receiveTx.TxHash() + receiveOutPoint := wire.OutPoint{Hash: receiveHash, Index: 0} + + spendTx := wire.NewMsgTx(2) + spendTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: receiveOutPoint, + Witness: testWitness, + SignatureScript: testSigScript, + }) + spendHash := spendTx.TxHash() + + block11 := btcutil.NewBlock(&wire.MsgBlock{ + Transactions: []*wire.MsgTx{receiveTx}, + }) + block12 := btcutil.NewBlock(&wire.MsgBlock{}) + block13 := btcutil.NewBlock(&wire.MsgBlock{ + Transactions: []*wire.MsgTx{spendTx}, + }) + block12Reorg := btcutil.NewBlock(&wire.MsgBlock{}) + block13Reorg := btcutil.NewBlock(&wire.MsgBlock{ + Transactions: []*wire.MsgTx{spendTx}, + }) + + err := n.ConnectTip(block11, 11) + require.NoError(t, err) + + select { + case ntfn := <-reg.Event.Notifications: + t.Fatalf("received unexpected pkScript notification: %v", ntfn) + default: + } + + err = n.ConnectTip(block12, 12) + require.NoError(t, err) + requirePkScriptNotification( + t, <-reg.Event.Notifications, + chainntnfs.PkScriptNotificationConfirm, false, 12, 2, 0, + &receiveHash, block12.Hash(), receiveOutPoint, + ) + + err = n.ConnectTip(block13, 13) + require.NoError(t, err) + requirePkScriptNotification( + t, <-reg.Event.Notifications, + chainntnfs.PkScriptNotificationSpend, false, 13, 0, 0, + &spendHash, block13.Hash(), receiveOutPoint, + ) + + err = n.DisconnectTip(13) + require.NoError(t, err) + requirePkScriptNotification( + t, <-reg.Event.Notifications, + chainntnfs.PkScriptNotificationSpend, true, 13, 0, 0, + &spendHash, block13.Hash(), receiveOutPoint, + ) + + err = n.DisconnectTip(12) + require.NoError(t, err) + requirePkScriptNotification( + t, <-reg.Event.Notifications, + chainntnfs.PkScriptNotificationConfirm, true, 12, 2, 0, + &receiveHash, block12.Hash(), receiveOutPoint, + ) + + err = n.ConnectTip(block12Reorg, 12) + require.NoError(t, err) + requirePkScriptNotification( + t, <-reg.Event.Notifications, + chainntnfs.PkScriptNotificationConfirm, false, 12, 2, 0, + &receiveHash, block12Reorg.Hash(), receiveOutPoint, + ) + + err = n.ConnectTip(block13Reorg, 13) + require.NoError(t, err) + requirePkScriptNotification( + t, <-reg.Event.Notifications, + chainntnfs.PkScriptNotificationSpend, false, 13, 0, 0, + &spendHash, block13Reorg.Hash(), receiveOutPoint, + ) +} + +// TestTxNotifierPkScriptPartialConfirmUpdates ensures pkScript registrations can +// opt in to confirmation progress before the final confirmation notification. +func TestTxNotifierPkScriptPartialConfirmUpdates(t *testing.T) { + t.Parallel() + + hintCache := newMockHintCache() + n := chainntnfs.NewTxNotifier( + 10, chainntnfs.ReorgSafetyLimit, hintCache, hintCache, + ) + + reg := registerFuturePkScriptNotifier( + t, n, + [][]byte{testRawScript}, + chainntnfs.PkScriptEventConfirm, 3, + chainntnfs.WithIncludeConfirmationUpdates(), + ) + + receiveTx := wire.NewMsgTx(2) + receiveTx.AddTxOut(&wire.TxOut{ + Value: 1000, + PkScript: testRawScript, + }) + receiveHash := receiveTx.TxHash() + receiveOutPoint := wire.OutPoint{Hash: receiveHash, Index: 0} + + block11 := btcutil.NewBlock(&wire.MsgBlock{ + Transactions: []*wire.MsgTx{receiveTx}, + }) + err := n.ConnectTip(block11, 11) + require.NoError(t, err) + + update1 := recvPkScriptNotificationTimeout(t, reg.Event) + requirePkScriptNotification( + t, update1, chainntnfs.PkScriptNotificationConfirmUpdate, + false, 11, 1, 0, &receiveHash, block11.Hash(), receiveOutPoint, + ) + require.Equal(t, uint32(3), update1.RequiredConfs) + + block12 := btcutil.NewBlock(&wire.MsgBlock{}) + err = n.ConnectTip(block12, 12) + require.NoError(t, err) + + update2 := recvPkScriptNotificationTimeout(t, reg.Event) + requirePkScriptNotification( + t, update2, chainntnfs.PkScriptNotificationConfirmUpdate, + false, 12, 2, 0, &receiveHash, block12.Hash(), receiveOutPoint, + ) + require.Equal(t, uint32(3), update2.RequiredConfs) + + err = n.DisconnectTip(12) + require.NoError(t, err) + + update2Reorg := recvPkScriptNotificationTimeout(t, reg.Event) + requirePkScriptNotification( + t, update2Reorg, chainntnfs.PkScriptNotificationConfirmUpdate, + true, 12, 2, 0, &receiveHash, block12.Hash(), receiveOutPoint, + ) + require.Equal(t, uint32(3), update2Reorg.RequiredConfs) + + block12Reorg := btcutil.NewBlock(&wire.MsgBlock{}) + err = n.ConnectTip(block12Reorg, 12) + require.NoError(t, err) + + update2Redelivered := recvPkScriptNotificationTimeout(t, reg.Event) + requirePkScriptNotification( + t, update2Redelivered, + chainntnfs.PkScriptNotificationConfirmUpdate, false, 12, 2, + 0, &receiveHash, block12Reorg.Hash(), receiveOutPoint, + ) + require.Equal(t, uint32(3), update2Redelivered.RequiredConfs) + + block13 := btcutil.NewBlock(&wire.MsgBlock{}) + err = n.ConnectTip(block13, 13) + require.NoError(t, err) + + finalNtfn := recvPkScriptNotificationTimeout(t, reg.Event) + requirePkScriptNotification( + t, finalNtfn, chainntnfs.PkScriptNotificationConfirm, false, + 13, 3, 0, &receiveHash, block13.Hash(), receiveOutPoint, + ) + require.Equal(t, uint32(3), finalNtfn.RequiredConfs) +} + +// TestTxNotifierPkScriptNotificationQueueDoesNotBlock ensures pkScript +// notifications are queued outside of the TxNotifier lock, so a slow client +// cannot stall chain processing or cancellation. +func TestTxNotifierPkScriptNotificationQueueDoesNotBlock(t *testing.T) { + t.Parallel() + + hintCache := newMockHintCache() + n := chainntnfs.NewTxNotifier( + 10, chainntnfs.ReorgSafetyLimit, hintCache, hintCache, + ) + + reg := registerFuturePkScriptNotifier( + t, n, + [][]byte{testRawScript}, + chainntnfs.PkScriptEventConfirm, 1, + ) + + const numOutputs = 150 + receiveTx := wire.NewMsgTx(2) + for i := 0; i < numOutputs; i++ { + receiveTx.AddTxOut(&wire.TxOut{ + Value: int64(1000 + i), + PkScript: testRawScript, + }) + } + + block11 := btcutil.NewBlock(&wire.MsgBlock{ + Transactions: []*wire.MsgTx{receiveTx}, + }) + + connectDone := make(chan error, 1) + go func() { + connectDone <- n.ConnectTip(block11, 11) + }() + + select { + case err := <-connectDone: + require.NoError(t, err) + + case <-time.After(2 * time.Second): + t.Fatal("ConnectTip blocked on pkScript notification delivery") + } + + cancelDone := make(chan struct{}) + go func() { + reg.Event.Cancel() + close(cancelDone) + }() + + select { + case <-cancelDone: + case <-time.After(2 * time.Second): + t.Fatal("pkScript notification cancellation blocked") + } +} + +// TestTxNotifierPkScriptNotificationQueueOverflowCancels ensures a slow +// consumer is canceled instead of allowing its notification queue to grow without +// bound. +func TestTxNotifierPkScriptNotificationQueueOverflowCancels(t *testing.T) { + t.Parallel() + + hintCache := newMockHintCache() + n := chainntnfs.NewTxNotifier( + 10, chainntnfs.ReorgSafetyLimit, hintCache, hintCache, + ) + + reg := registerFuturePkScriptNotifier( + t, n, + [][]byte{testRawScript}, + chainntnfs.PkScriptEventConfirm, 1, + ) + + numOutputs := cap(reg.Event.Notifications) + + chainntnfs.MaxPkScriptNotificationQueueSize + 10 + receiveTx := wire.NewMsgTx(2) + for i := 0; i < numOutputs; i++ { + receiveTx.AddTxOut(&wire.TxOut{ + Value: int64(1000 + i), + PkScript: testRawScript, + }) + } + + block11 := btcutil.NewBlock(&wire.MsgBlock{ + Transactions: []*wire.MsgTx{receiveTx}, + }) + + err := n.ConnectTip(block11, 11) + require.NoError(t, err) + + for { + select { + case _, ok := <-reg.Event.Notifications: + if !ok { + _, _, _, err = reg.AddPkScripts( + [][]byte{testRawScript2}, + ) + require.Error(t, err) + + return + } + + case <-time.After(2 * time.Second): + t.Fatal("expected overflowing notification channel to close") + } + } +} + +// TestTxNotifierPkScriptAddExistingScriptNoop ensures re-adding an already +// watched script is a no-op and does not rewind its height hint. +func TestTxNotifierPkScriptAddExistingScriptNoop(t *testing.T) { + t.Parallel() + + hintCache := newMockHintCache() + n := chainntnfs.NewTxNotifier( + 10, chainntnfs.ReorgSafetyLimit, hintCache, hintCache, + ) + + reg, dispatch := registerPkScriptNotifier( + t, n, + [][]byte{testRawScript}, + chainntnfs.PkScriptEventConfirm, 1, 11, + ) + require.Nil(t, dispatch) + + dispatch, _, _, err := reg.AddPkScripts( + [][]byte{testRawScript}, + chainntnfs.WithEvents(chainntnfs.PkScriptEventConfirm), + chainntnfs.WithHistoricalScanFrom(5), + ) + require.NoError(t, err) + require.Nil(t, dispatch) + + err = reg.RemovePkScripts([][]byte{testRawScript}) + require.NoError(t, err) + + dispatch, _, _, err = reg.AddPkScripts( + [][]byte{testRawScript}, + chainntnfs.WithEvents(chainntnfs.PkScriptEventConfirm), + chainntnfs.WithHistoricalScanFrom(5), + ) + require.NoError(t, err) + require.NotNil(t, dispatch) + require.Equal(t, uint32(5), dispatch.StartHeight) + require.Equal(t, uint32(10), dispatch.EndHeight) + require.Len(t, dispatch.PkScripts, 1) + + receiveTx := wire.NewMsgTx(2) + receiveTx.AddTxOut(&wire.TxOut{ + Value: 1000, + PkScript: testRawScript, + }) + receiveHash := receiveTx.TxHash() + receiveOutPoint := wire.OutPoint{Hash: receiveHash, Index: 0} + + block5 := btcutil.NewBlock(&wire.MsgBlock{ + Transactions: []*wire.MsgTx{receiveTx}, + }) + + err = n.ProcessHistoricalPkScriptBlock( + dispatch.SubscriptionID, block5, 5, dispatch.PkScripts, + ) + require.NoError(t, err) + + ntfn := recvPkScriptNotificationTimeout(t, reg.Event) + requirePkScriptNotification( + t, ntfn, chainntnfs.PkScriptNotificationConfirm, false, 5, + 1, 0, &receiveHash, block5.Hash(), receiveOutPoint, + ) + require.NotNil(t, ntfn.UTXO.BlockHash) + require.True(t, ntfn.UTXO.BlockHash.IsEqual(block5.Hash())) + require.Equal(t, uint32(0), ntfn.UTXO.TxIndex) +} + +// TestTxNotifierPkScriptTransactionIndexes ensures pkScript notifications report +// transaction indexes separately from output and input indexes. +func TestTxNotifierPkScriptTransactionIndexes(t *testing.T) { + t.Parallel() + + hintCache := newMockHintCache() + n := chainntnfs.NewTxNotifier( + 10, chainntnfs.ReorgSafetyLimit, hintCache, hintCache, + ) + + reg := registerFuturePkScriptNotifier( + t, n, + [][]byte{testRawScript}, + chainntnfs.PkScriptEventConfirm| + chainntnfs.PkScriptEventSpend, + 1, + ) + + dummyTx1 := wire.NewMsgTx(2) + dummyTx1.AddTxOut(&wire.TxOut{Value: 1, PkScript: testRawScript2}) + dummyTx2 := wire.NewMsgTx(2) + dummyTx2.AddTxOut(&wire.TxOut{Value: 2, PkScript: testRawScript2}) + + receiveTx := wire.NewMsgTx(2) + receiveTx.AddTxOut(&wire.TxOut{Value: 1, PkScript: testRawScript2}) + receiveTx.AddTxOut(&wire.TxOut{Value: 1000, PkScript: testRawScript}) + receiveHash := receiveTx.TxHash() + receiveOutPoint := wire.OutPoint{Hash: receiveHash, Index: 1} + + block11 := btcutil.NewBlock(&wire.MsgBlock{ + Transactions: []*wire.MsgTx{dummyTx1, dummyTx2, receiveTx}, + }) + + err := n.ConnectTip(block11, 11) + require.NoError(t, err) + + confirmNtfn := recvPkScriptNotificationTimeout(t, reg.Event) + requirePkScriptNotification( + t, confirmNtfn, chainntnfs.PkScriptNotificationConfirm, false, + 11, 1, 2, &receiveHash, block11.Hash(), receiveOutPoint, + ) + require.Equal(t, uint32(2), confirmNtfn.UTXO.TxIndex) + require.NotNil(t, confirmNtfn.UTXO.BlockHash) + require.True(t, confirmNtfn.UTXO.BlockHash.IsEqual(block11.Hash())) + + spendTx := wire.NewMsgTx(2) + spendTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{Hash: chainntnfs.ZeroHash}, + }) + spendTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: receiveOutPoint, + Witness: testWitness, + SignatureScript: testSigScript, + }) + spendHash := spendTx.TxHash() + + spendDummy := wire.NewMsgTx(2) + spendDummy.AddTxOut(&wire.TxOut{Value: 3, PkScript: testRawScript2}) + + block12 := btcutil.NewBlock(&wire.MsgBlock{ + Transactions: []*wire.MsgTx{spendDummy, spendTx}, + }) + + err = n.ConnectTip(block12, 12) + require.NoError(t, err) + + spendNtfn := recvPkScriptNotificationTimeout(t, reg.Event) + requirePkScriptNotification( + t, spendNtfn, chainntnfs.PkScriptNotificationSpend, false, + 12, 0, 1, &spendHash, block12.Hash(), receiveOutPoint, + ) + require.Equal(t, uint32(1), spendNtfn.TxIndex) + require.Equal(t, uint32(1), spendNtfn.InputIndex) +} + +// TestTxNotifierPkScriptTearDown ensures that tearing down the TxNotifier closes +// pkScript notification streams and rejects new pkScript registrations. +func TestTxNotifierPkScriptTearDown(t *testing.T) { + t.Parallel() + + hintCache := newMockHintCache() + n := chainntnfs.NewTxNotifier( + 10, chainntnfs.ReorgSafetyLimit, hintCache, hintCache, + ) + + pkScriptNtfn, err := n.RegisterPkScriptNotifier() + require.NoError(t, err, "unable to register pkScript ntfn") + + n.TearDown() + + select { + case _, ok := <-pkScriptNtfn.Event.Notifications: + require.False( + t, ok, "expected closed Notifications channel for "+ + "pkScript ntfn", + ) + + default: + t.Fatalf("expected closed notification channel for pkScript ntfn") + } + + _, err = n.RegisterPkScriptNotifier() + require.ErrorIs(t, err, chainntnfs.ErrTxNotifierExiting) +} + +// requirePkScriptNotification checks the common fields of a pkScript +// notification. +func requirePkScriptNotification(t *testing.T, + result *chainntnfs.PkScriptNotification, + expectedType chainntnfs.PkScriptNotificationType, + disconnected bool, height, numConfs, txIndex uint32, + txHash, blockHash *chainhash.Hash, outpoint wire.OutPoint) { + + t.Helper() + + require.NotNil(t, result) + require.Equal(t, expectedType, result.Type) + require.Equal(t, disconnected, result.Disconnected) + require.Equal(t, height, result.Height) + require.Equal(t, numConfs, result.NumConfirmations) + require.Equal(t, txIndex, result.TxIndex) + + if txHash == nil { + require.Nil(t, result.TxHash) + } else { + require.NotNil(t, result.TxHash) + require.True(t, result.TxHash.IsEqual(txHash)) + } + + if blockHash == nil { + require.Nil(t, result.BlockHash) + } else { + require.NotNil(t, result.BlockHash) + require.True(t, result.BlockHash.IsEqual(blockHash)) + } + + require.NotNil(t, result.UTXO) + require.Equal(t, outpoint, result.UTXO.OutPoint) +} diff --git a/chainntnfs/test/test_interface.go b/chainntnfs/test/test_interface.go index 7536d24c503..fb80181c1b3 100644 --- a/chainntnfs/test/test_interface.go +++ b/chainntnfs/test/test_interface.go @@ -11,10 +11,12 @@ import ( "testing" "time" + "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/integration/rpctest" "github.com/btcsuite/btcd/rpcclient" + "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/chain" _ "github.com/btcsuite/btcwallet/walletdb/bdb" // Required to auto-register the boltdb walletdb implementation. @@ -30,6 +32,215 @@ import ( "github.com/stretchr/testify/require" ) +// requirePkScriptNotifier asserts that the test notifier supports the pkScript +// notifier interface. +func requirePkScriptNotifier(t *testing.T, + notifier chainntnfs.TestChainNotifier) chainntnfs.PkScriptNotifier { + + t.Helper() + + pkScriptNotifier, ok := notifier.(chainntnfs.PkScriptNotifier) + require.True(t, ok, "notifier does not implement PkScriptNotifier") + + return pkScriptNotifier +} + +// registerPkScriptNotifier registers scripts with a historical scan height and +// returns the resulting notification registration. +func registerPkScriptNotifier(t *testing.T, + notifier chainntnfs.PkScriptNotifier, pkScripts [][]byte, + events chainntnfs.PkScriptEventType, numConfs, + historicalScanFrom uint32, + opts ...chainntnfs.NotifierOption, +) *chainntnfs.PkScriptNotificationRegistration { + + t.Helper() + + reg, err := notifier.RegisterPkScriptNotifier() + require.NoError(t, err) + + addOpts := []chainntnfs.NotifierOption{ + chainntnfs.WithEvents(events), + chainntnfs.WithNumConfs(numConfs), + chainntnfs.WithHistoricalScanFrom(historicalScanFrom), + } + addOpts = append(addOpts, opts...) + + _, err = reg.AddPkScripts(pkScripts, addOpts...) + require.NoError(t, err) + + return reg +} + +// newPkScriptTestScript creates a fresh P2WPKH pkScript and corresponding +// private key for tests. +func newPkScriptTestScript(t *testing.T) ([]byte, *btcec.PrivateKey) { + t.Helper() + + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + pubKeyHash := btcutil.Hash160(privKey.PubKey().SerializeCompressed()) + addr, err := btcutil.NewAddressWitnessPubKeyHash( + pubKeyHash, unittest.NetParams, + ) + require.NoError(t, err) + + pkScript, err := txscript.PayToAddrScript(addr) + require.NoError(t, err) + + return pkScript, privKey +} + +// newEpochClient registers a block epoch client and consumes its initial tip +// notification. +func newEpochClient(t *testing.T, + notifier chainntnfs.TestChainNotifier) *chainntnfs.BlockEpochEvent { + + t.Helper() + + epochClient, err := notifier.RegisterBlockEpochNtfn(nil) + require.NoError(t, err) + + select { + case _, ok := <-epochClient.Epochs: + require.True(t, ok, "epoch channel closed") + case <-time.After(15 * time.Second): + t.Fatal("did not receive current tip epoch") + } + + return epochClient +} + +// waitForEpochs blocks until the expected number of block epoch notifications +// is received. +func waitForEpochs(t *testing.T, epochClient *chainntnfs.BlockEpochEvent, + numBlocks int) { + + t.Helper() + + for i := 0; i < numBlocks; i++ { + select { + case _, ok := <-epochClient.Epochs: + require.True(t, ok, "epoch channel closed") + case <-time.After(20 * time.Second): + t.Fatalf( + "did not receive block epoch %d/%d", i+1, + numBlocks, + ) + } + } +} + +// sendOutputToScript sends a wallet output paying directly to pkScript. +func sendOutputToScript(t *testing.T, miner *rpctest.Harness, pkScript []byte, + value int64) (*chainhash.Hash, *wire.OutPoint, *wire.TxOut) { + + t.Helper() + + output := &wire.TxOut{ + Value: value, + PkScript: pkScript, + } + txid, err := miner.SendOutputsWithoutChange([]*wire.TxOut{output}, 10) + require.NoError(t, err) + + return txid, wire.NewOutPoint(txid, 0), output +} + +// recvPkScriptNotification waits for a single non-lifecycle pkScript +// notification. +func recvPkScriptNotification(t *testing.T, + reg *chainntnfs.PkScriptNotificationRegistration, +) *chainntnfs.PkScriptNotification { + + t.Helper() + + timeout := time.After(20 * time.Second) + scanComplete := + chainntnfs.PkScriptNotificationHistoricalScanComplete + for { + select { + case ntfn, ok := <-reg.Notifications: + require.True( + t, ok, "pkScript notification channel closed", + ) + + if ntfn.Type == scanComplete { + + continue + } + + return ntfn + + case <-timeout: + t.Fatal("pkScript notification never received") + return nil + } + } +} + +// recvPkScriptNotifications waits for count pkScript notifications. +func recvPkScriptNotifications(t *testing.T, + reg *chainntnfs.PkScriptNotificationRegistration, + count int) []*chainntnfs.PkScriptNotification { + + t.Helper() + + ntfns := make([]*chainntnfs.PkScriptNotification, 0, count) + for i := 0; i < count; i++ { + ntfns = append(ntfns, recvPkScriptNotification(t, reg)) + } + + return ntfns +} + +// assertNoPkScriptNotification asserts that no non-lifecycle pkScript +// notification is received before timeout. +func assertNoPkScriptNotification(t *testing.T, + reg *chainntnfs.PkScriptNotificationRegistration, + timeout time.Duration) { + + t.Helper() + + select { + case ntfn := <-reg.Notifications: + if ntfn.Type == + chainntnfs.PkScriptNotificationHistoricalScanComplete { + + assertNoPkScriptNotification(t, reg, timeout) + return + } + + t.Fatalf("received unexpected pkScript notification: %v", ntfn) + case <-time.After(timeout): + } +} + +// assertPkScriptNotification verifies the common fields of a pkScript +// notification. +func assertPkScriptNotification(t *testing.T, + ntfn *chainntnfs.PkScriptNotification, + expectedType chainntnfs.PkScriptNotificationType, + disconnected bool, height, numConfs uint32, + txHash, blockHash *chainhash.Hash, outpoint wire.OutPoint) { + + t.Helper() + + require.NotNil(t, ntfn) + require.Equal(t, expectedType, ntfn.Type) + require.Equal(t, disconnected, ntfn.Disconnected) + require.Equal(t, height, ntfn.Height) + require.Equal(t, numConfs, ntfn.NumConfirmations) + + require.NotNil(t, ntfn.TxHash) + require.True(t, ntfn.TxHash.IsEqual(txHash)) + require.NotNil(t, ntfn.BlockHash) + require.True(t, ntfn.BlockHash.IsEqual(blockHash)) + require.NotNil(t, ntfn.UTXO) + require.Equal(t, outpoint, ntfn.UTXO.OutPoint) +} + func testSingleConfirmationNotification(miner *rpctest.Harness, notifier chainntnfs.TestChainNotifier, scriptDispatch bool, t *testing.T) { @@ -1784,12 +1995,758 @@ func testIncludeBlockAsymmetry(miner *rpctest.Harness, assertNtfns() } +// testPkScriptHistoricalConfirmSpend verifies historical pkScript confirmation +// and spend replay for one watched script. +func testPkScriptHistoricalConfirmSpend(miner *rpctest.Harness, + notifier chainntnfs.TestChainNotifier, t *testing.T) { + + pkScriptNotifier := requirePkScriptNotifier(t, notifier) + epochClient := newEpochClient(t, notifier) + defer epochClient.Cancel() + + pkScript, privKey := newPkScriptTestScript(t) + txid, outpoint, output := sendOutputToScript(t, miner, pkScript, 2e8) + require.NoError(t, chainntnfs.WaitForMempoolTx(miner, txid)) + + fundingBlocks, err := miner.Client.Generate(1) + require.NoError(t, err) + waitForEpochs(t, epochClient, 1) + _, fundingHeight, err := miner.Client.GetBestBlock() + require.NoError(t, err) + + confBlocks, err := miner.Client.Generate(1) + require.NoError(t, err) + waitForEpochs(t, epochClient, 1) + + spendTx := chainntnfs.CreateSpendTx(t, outpoint, output, privKey) + spendHash, err := miner.Client.SendRawTransaction(spendTx, true) + require.NoError(t, err) + require.NoError(t, chainntnfs.WaitForMempoolTx(miner, spendHash)) + + spendBlocks, err := miner.Client.Generate(1) + require.NoError(t, err) + waitForEpochs(t, epochClient, 1) + _, spendHeight, err := miner.Client.GetBestBlock() + require.NoError(t, err) + + reg := registerPkScriptNotifier( + t, pkScriptNotifier, + [][]byte{pkScript}, + chainntnfs.PkScriptEventConfirm| + chainntnfs.PkScriptEventSpend, + 2, uint32(fundingHeight), + chainntnfs.WithIncludeTx(), chainntnfs.WithIncludeBlock(), + ) + defer reg.Cancel() + + confirmNtfn := recvPkScriptNotification(t, reg) + assertPkScriptNotification( + t, confirmNtfn, chainntnfs.PkScriptNotificationConfirm, + false, uint32(fundingHeight+1), 2, txid, confBlocks[0], + *outpoint, + ) + require.NotNil(t, confirmNtfn.Tx) + require.Equal(t, *txid, confirmNtfn.Tx.TxHash()) + require.NotNil(t, confirmNtfn.Block) + require.Equal(t, *confBlocks[0], confirmNtfn.Block.BlockHash()) + + spendNtfn := recvPkScriptNotification(t, reg) + assertPkScriptNotification( + t, spendNtfn, chainntnfs.PkScriptNotificationSpend, + false, uint32(spendHeight), 0, spendHash, spendBlocks[0], + *outpoint, + ) + require.NotNil(t, spendNtfn.Tx) + require.Equal(t, *spendHash, spendNtfn.Tx.TxHash()) + require.NotNil(t, spendNtfn.Block) + require.Equal(t, *spendBlocks[0], spendNtfn.Block.BlockHash()) + + assertNoPkScriptNotification(t, reg, 2*time.Second) + + // Ensure the historical confirm references the original funding block, + // not the block where it reached its target depth. + require.NotEqual(t, *fundingBlocks[0], *confirmNtfn.BlockHash) +} + +// testPkScriptHistoricalMultiScriptRegister verifies historical replay for +// multiple scripts registered in a single request. +func testPkScriptHistoricalMultiScriptRegister(miner *rpctest.Harness, + notifier chainntnfs.TestChainNotifier, t *testing.T) { + + pkScriptNotifier := requirePkScriptNotifier(t, notifier) + epochClient := newEpochClient(t, notifier) + defer epochClient.Cancel() + + scriptA, privKeyA := newPkScriptTestScript(t) + scriptB, privKeyB := newPkScriptTestScript(t) + + txidA, outpointA, outputA := sendOutputToScript(t, miner, scriptA, 2e8) + require.NoError(t, chainntnfs.WaitForMempoolTx(miner, txidA)) + txidB, outpointB, outputB := sendOutputToScript(t, miner, scriptB, 3e8) + require.NoError(t, chainntnfs.WaitForMempoolTx(miner, txidB)) + + fundingBlocks, err := miner.Client.Generate(1) + require.NoError(t, err) + waitForEpochs(t, epochClient, 1) + _, fundingHeight, err := miner.Client.GetBestBlock() + require.NoError(t, err) + + spendTxA := chainntnfs.CreateSpendTx(t, outpointA, outputA, privKeyA) + spendHashA, err := miner.Client.SendRawTransaction(spendTxA, true) + require.NoError(t, err) + require.NoError(t, chainntnfs.WaitForMempoolTx(miner, spendHashA)) + + spendTxB := chainntnfs.CreateSpendTx(t, outpointB, outputB, privKeyB) + spendHashB, err := miner.Client.SendRawTransaction(spendTxB, true) + require.NoError(t, err) + require.NoError(t, chainntnfs.WaitForMempoolTx(miner, spendHashB)) + + spendBlocks, err := miner.Client.Generate(1) + require.NoError(t, err) + waitForEpochs(t, epochClient, 1) + _, spendHeight, err := miner.Client.GetBestBlock() + require.NoError(t, err) + + reg := registerPkScriptNotifier( + t, pkScriptNotifier, + [][]byte{scriptA, scriptB}, + chainntnfs.PkScriptEventConfirm| + chainntnfs.PkScriptEventSpend, + 1, uint32(fundingHeight), + ) + defer reg.Cancel() + + ntfns := recvPkScriptNotifications(t, reg, 4) + seen := make(map[string]struct{}, 4) + for _, ntfn := range ntfns { + switch ntfn.UTXO.OutPoint { + case *outpointA: + switch ntfn.Type { + case chainntnfs.PkScriptNotificationConfirm: + assertPkScriptNotification( + t, ntfn, + chainntnfs.PkScriptNotificationConfirm, + false, uint32(fundingHeight), 1, txidA, + fundingBlocks[0], *outpointA, + ) + seen["confirmA"] = struct{}{} + case chainntnfs.PkScriptNotificationSpend: + assertPkScriptNotification( + t, ntfn, + chainntnfs.PkScriptNotificationSpend, + false, uint32(spendHeight), 0, + spendHashA, + spendBlocks[0], *outpointA, + ) + seen["spendA"] = struct{}{} + } + + case *outpointB: + switch ntfn.Type { + case chainntnfs.PkScriptNotificationConfirm: + assertPkScriptNotification( + t, ntfn, + chainntnfs.PkScriptNotificationConfirm, + false, uint32(fundingHeight), 1, txidB, + fundingBlocks[0], *outpointB, + ) + seen["confirmB"] = struct{}{} + case chainntnfs.PkScriptNotificationSpend: + assertPkScriptNotification( + t, ntfn, + chainntnfs.PkScriptNotificationSpend, + false, uint32(spendHeight), 0, + spendHashB, + spendBlocks[0], *outpointB, + ) + seen["spendB"] = struct{}{} + } + } + } + require.Len(t, seen, 4) +} + +// testPkScriptAddRemove verifies dynamic add and remove behavior for pkScript +// notifications. +func testPkScriptAddRemove(miner *rpctest.Harness, + notifier chainntnfs.TestChainNotifier, t *testing.T) { + + pkScriptNotifier := requirePkScriptNotifier(t, notifier) + epochClient := newEpochClient(t, notifier) + defer epochClient.Cancel() + + reg, err := pkScriptNotifier.RegisterPkScriptNotifier() + require.NoError(t, err) + defer reg.Cancel() + + liveScript, livePrivKey := newPkScriptTestScript(t) + _, currentHeight, err := miner.Client.GetBestBlock() + require.NoError(t, err) + + _, err = reg.AddPkScripts( + [][]byte{liveScript}, + chainntnfs.WithHistoricalScanFrom(uint32(currentHeight)), + ) + require.NoError(t, err) + + liveTxid, liveOutpoint, liveOutput := sendOutputToScript( + t, miner, liveScript, 2e8, + ) + require.NoError(t, chainntnfs.WaitForMempoolTx(miner, liveTxid)) + + liveConfBlocks, err := miner.Client.Generate(1) + require.NoError(t, err) + waitForEpochs(t, epochClient, 1) + _, liveConfHeight, err := miner.Client.GetBestBlock() + require.NoError(t, err) + + assertPkScriptNotification( + t, recvPkScriptNotification(t, reg), + chainntnfs.PkScriptNotificationConfirm, false, + uint32(liveConfHeight), 1, liveTxid, liveConfBlocks[0], + *liveOutpoint, + ) + + err = reg.RemovePkScripts([][]byte{liveScript}) + require.NoError(t, err) + + liveSpendTx := chainntnfs.CreateSpendTx( + t, liveOutpoint, liveOutput, livePrivKey, + ) + liveSpendHash, err := miner.Client.SendRawTransaction(liveSpendTx, true) + require.NoError(t, err) + require.NoError(t, chainntnfs.WaitForMempoolTx( + miner, liveSpendHash, + )) + + _, err = miner.Client.Generate(1) + require.NoError(t, err) + waitForEpochs(t, epochClient, 1) + + assertNoPkScriptNotification(t, reg, 2*time.Second) + + historicalScript, historicalPrivKey := newPkScriptTestScript(t) + historicalTxid, historicalOutpoint, historicalOutput := + sendOutputToScript(t, miner, historicalScript, 2e8) + require.NoError(t, chainntnfs.WaitForMempoolTx(miner, historicalTxid)) + + historicalConfBlocks, err := miner.Client.Generate(1) + require.NoError(t, err) + waitForEpochs(t, epochClient, 1) + _, historicalConfHeight, err := miner.Client.GetBestBlock() + require.NoError(t, err) + + _, err = reg.AddPkScripts( + [][]byte{historicalScript}, + chainntnfs.WithHistoricalScanFrom(uint32(historicalConfHeight)), + ) + require.NoError(t, err) + + assertPkScriptNotification( + t, recvPkScriptNotification(t, reg), + chainntnfs.PkScriptNotificationConfirm, false, + uint32(historicalConfHeight), 1, historicalTxid, + historicalConfBlocks[0], *historicalOutpoint, + ) + + historicalSpendTx := chainntnfs.CreateSpendTx( + t, historicalOutpoint, historicalOutput, historicalPrivKey, + ) + historicalSpendHash, err := miner.Client.SendRawTransaction( + historicalSpendTx, true, + ) + require.NoError(t, err) + require.NoError(t, chainntnfs.WaitForMempoolTx( + miner, historicalSpendHash, + )) + + historicalSpendBlocks, err := miner.Client.Generate(1) + require.NoError(t, err) + waitForEpochs(t, epochClient, 1) + _, historicalSpendHeight, err := miner.Client.GetBestBlock() + require.NoError(t, err) + + assertPkScriptNotification( + t, recvPkScriptNotification(t, reg), + chainntnfs.PkScriptNotificationSpend, false, + uint32(historicalSpendHeight), 0, historicalSpendHash, + historicalSpendBlocks[0], *historicalOutpoint, + ) +} + +// testPkScriptAddMultipleScriptsHistorical verifies historical replay for +// multiple scripts added to an existing registration. +func testPkScriptAddMultipleScriptsHistorical(miner *rpctest.Harness, + notifier chainntnfs.TestChainNotifier, t *testing.T) { + + pkScriptNotifier := requirePkScriptNotifier(t, notifier) + epochClient := newEpochClient(t, notifier) + defer epochClient.Cancel() + + reg, err := pkScriptNotifier.RegisterPkScriptNotifier() + require.NoError(t, err) + defer reg.Cancel() + + scriptA, privKeyA := newPkScriptTestScript(t) + scriptB, privKeyB := newPkScriptTestScript(t) + + txidA, outpointA, outputA := sendOutputToScript(t, miner, scriptA, 2e8) + require.NoError(t, chainntnfs.WaitForMempoolTx(miner, txidA)) + txidB, outpointB, outputB := sendOutputToScript(t, miner, scriptB, 3e8) + require.NoError(t, chainntnfs.WaitForMempoolTx(miner, txidB)) + + fundingBlocks, err := miner.Client.Generate(1) + require.NoError(t, err) + waitForEpochs(t, epochClient, 1) + _, fundingHeight, err := miner.Client.GetBestBlock() + require.NoError(t, err) + + _, err = reg.AddPkScripts( + [][]byte{scriptA, scriptB}, + chainntnfs.WithHistoricalScanFrom(uint32(fundingHeight)), + ) + require.NoError(t, err) + + confirmNtfns := recvPkScriptNotifications(t, reg, 2) + seenConfirm := make(map[wire.OutPoint]struct{}, 2) + for _, ntfn := range confirmNtfns { + switch ntfn.UTXO.OutPoint { + case *outpointA: + assertPkScriptNotification( + t, ntfn, chainntnfs.PkScriptNotificationConfirm, + false, uint32(fundingHeight), 1, txidA, + fundingBlocks[0], *outpointA, + ) + seenConfirm[*outpointA] = struct{}{} + + case *outpointB: + assertPkScriptNotification( + t, ntfn, chainntnfs.PkScriptNotificationConfirm, + false, uint32(fundingHeight), 1, txidB, + fundingBlocks[0], *outpointB, + ) + seenConfirm[*outpointB] = struct{}{} + } + } + require.Len(t, seenConfirm, 2) + + spendTxA := chainntnfs.CreateSpendTx(t, outpointA, outputA, privKeyA) + spendHashA, err := miner.Client.SendRawTransaction(spendTxA, true) + require.NoError(t, err) + require.NoError(t, chainntnfs.WaitForMempoolTx(miner, spendHashA)) + + spendTxB := chainntnfs.CreateSpendTx(t, outpointB, outputB, privKeyB) + spendHashB, err := miner.Client.SendRawTransaction(spendTxB, true) + require.NoError(t, err) + require.NoError(t, chainntnfs.WaitForMempoolTx(miner, spendHashB)) + + spendBlocks, err := miner.Client.Generate(1) + require.NoError(t, err) + waitForEpochs(t, epochClient, 1) + _, spendHeight, err := miner.Client.GetBestBlock() + require.NoError(t, err) + + spendNtfns := recvPkScriptNotifications(t, reg, 2) + seenSpend := make(map[wire.OutPoint]struct{}, 2) + for _, ntfn := range spendNtfns { + switch ntfn.UTXO.OutPoint { + case *outpointA: + assertPkScriptNotification( + t, ntfn, chainntnfs.PkScriptNotificationSpend, + false, uint32(spendHeight), 0, spendHashA, + spendBlocks[0], *outpointA, + ) + seenSpend[*outpointA] = struct{}{} + + case *outpointB: + assertPkScriptNotification( + t, ntfn, chainntnfs.PkScriptNotificationSpend, + false, uint32(spendHeight), 0, spendHashB, + spendBlocks[0], *outpointB, + ) + seenSpend[*outpointB] = struct{}{} + } + } + require.Len(t, seenSpend, 2) +} + +// testPkScriptMultipleEventsPerBlock verifies multiple pkScript notifications +// can be emitted from the same block. +func testPkScriptMultipleEventsPerBlock(miner *rpctest.Harness, + notifier chainntnfs.TestChainNotifier, t *testing.T) { + + pkScriptNotifier := requirePkScriptNotifier(t, notifier) + scriptA, privKeyA := newPkScriptTestScript(t) + scriptB, privKeyB := newPkScriptTestScript(t) + + _, currentHeight, err := miner.Client.GetBestBlock() + require.NoError(t, err) + + reg := registerPkScriptNotifier( + t, pkScriptNotifier, + [][]byte{scriptA, scriptB}, + chainntnfs.PkScriptEventConfirm| + chainntnfs.PkScriptEventSpend, + 1, uint32(currentHeight), + ) + defer reg.Cancel() + + txidA, outpointA, outputA := sendOutputToScript(t, miner, scriptA, 2e8) + require.NoError(t, chainntnfs.WaitForMempoolTx(miner, txidA)) + txidB, outpointB, outputB := sendOutputToScript(t, miner, scriptB, 2e8) + require.NoError(t, chainntnfs.WaitForMempoolTx(miner, txidB)) + + confBlocks, err := miner.Client.Generate(1) + require.NoError(t, err) + _, confHeight, err := miner.Client.GetBestBlock() + require.NoError(t, err) + + confNtfns := recvPkScriptNotifications(t, reg, 2) + seenConf := make(map[wire.OutPoint]struct{}, 2) + for _, ntfn := range confNtfns { + require.Equal( + t, chainntnfs.PkScriptNotificationConfirm, ntfn.Type, + ) + require.False(t, ntfn.Disconnected) + require.Equal(t, uint32(confHeight), ntfn.Height) + require.Equal(t, uint32(1), ntfn.NumConfirmations) + + switch ntfn.UTXO.OutPoint { + case *outpointA: + require.True(t, ntfn.TxHash.IsEqual(txidA)) + require.True(t, ntfn.BlockHash.IsEqual(confBlocks[0])) + seenConf[*outpointA] = struct{}{} + + case *outpointB: + require.True(t, ntfn.TxHash.IsEqual(txidB)) + require.True(t, ntfn.BlockHash.IsEqual(confBlocks[0])) + seenConf[*outpointB] = struct{}{} + + default: + t.Fatalf("unexpected confirm outpoint: %v", + ntfn.UTXO.OutPoint) + } + } + require.Len(t, seenConf, 2) + + spendTxA := chainntnfs.CreateSpendTx(t, outpointA, outputA, privKeyA) + spendHashA, err := miner.Client.SendRawTransaction(spendTxA, true) + require.NoError(t, err) + require.NoError(t, chainntnfs.WaitForMempoolTx(miner, spendHashA)) + + spendTxB := chainntnfs.CreateSpendTx(t, outpointB, outputB, privKeyB) + spendHashB, err := miner.Client.SendRawTransaction(spendTxB, true) + require.NoError(t, err) + require.NoError(t, chainntnfs.WaitForMempoolTx(miner, spendHashB)) + + spendBlocks, err := miner.Client.Generate(1) + require.NoError(t, err) + _, spendHeight, err := miner.Client.GetBestBlock() + require.NoError(t, err) + + spendNtfns := recvPkScriptNotifications(t, reg, 2) + seenSpend := make(map[wire.OutPoint]struct{}, 2) + for _, ntfn := range spendNtfns { + require.Equal( + t, chainntnfs.PkScriptNotificationSpend, ntfn.Type, + ) + require.False(t, ntfn.Disconnected) + require.Equal(t, uint32(spendHeight), ntfn.Height) + + switch ntfn.UTXO.OutPoint { + case *outpointA: + require.True(t, ntfn.TxHash.IsEqual(spendHashA)) + require.True(t, ntfn.BlockHash.IsEqual(spendBlocks[0])) + seenSpend[*outpointA] = struct{}{} + + case *outpointB: + require.True(t, ntfn.TxHash.IsEqual(spendHashB)) + require.True(t, ntfn.BlockHash.IsEqual(spendBlocks[0])) + seenSpend[*outpointB] = struct{}{} + + default: + t.Fatalf("unexpected spend outpoint: %v", + ntfn.UTXO.OutPoint) + } + } + require.Len(t, seenSpend, 2) +} + +// testPkScriptReorg verifies pkScript notifications are invalidated and +// redelivered across reorgs. +func testPkScriptReorg(miner *rpctest.Harness, + notifier chainntnfs.TestChainNotifier, t *testing.T) { + + pkScriptNotifier := requirePkScriptNotifier(t, notifier) + pkScript, privKey := newPkScriptTestScript(t) + + miner2 := unittest.NewMiner( + t, unittest.NetParams, []string{"--txindex"}, false, 0, + ) + + err := rpctest.ConnectNode(miner, miner2) + require.NoError(t, err) + err = rpctest.JoinNodes([]*rpctest.Harness{miner, miner2}, rpctest.Blocks) + require.NoError(t, err) + + err = miner.Client.AddNode(miner2.P2PAddress(), rpcclient.ANRemove) + require.NoError(t, err) + + _, currentHeight, err := miner.Client.GetBestBlock() + require.NoError(t, err) + + reg := registerPkScriptNotifier( + t, pkScriptNotifier, + [][]byte{pkScript}, + chainntnfs.PkScriptEventConfirm| + chainntnfs.PkScriptEventSpend, + 1, uint32(currentHeight), + ) + defer reg.Cancel() + + txid, outpoint, output := sendOutputToScript(t, miner, pkScript, 2e8) + require.NoError(t, chainntnfs.WaitForMempoolTx(miner, txid)) + + fundingTx, err := miner.Client.GetRawTransaction(txid) + require.NoError(t, err) + + fundingBlocks, err := miner.Client.Generate(1) + require.NoError(t, err) + _, fundingHeight, err := miner.Client.GetBestBlock() + require.NoError(t, err) + + assertPkScriptNotification( + t, recvPkScriptNotification(t, reg), + chainntnfs.PkScriptNotificationConfirm, false, + uint32(fundingHeight), 1, txid, fundingBlocks[0], *outpoint, + ) + + spendTx := chainntnfs.CreateSpendTx(t, outpoint, output, privKey) + spendHash, err := miner.Client.SendRawTransaction(spendTx, true) + require.NoError(t, err) + require.NoError(t, chainntnfs.WaitForMempoolTx(miner, spendHash)) + + spendBlocks, err := miner.Client.Generate(1) + require.NoError(t, err) + _, spendHeight, err := miner.Client.GetBestBlock() + require.NoError(t, err) + + assertPkScriptNotification( + t, recvPkScriptNotification(t, reg), + chainntnfs.PkScriptNotificationSpend, false, + uint32(spendHeight), 0, spendHash, spendBlocks[0], *outpoint, + ) + + _, err = miner2.Client.Generate(3) + require.NoError(t, err) + + err = rpctest.ConnectNode(miner, miner2) + require.NoError(t, err) + err = rpctest.JoinNodes([]*rpctest.Harness{miner, miner2}, rpctest.Blocks) + require.NoError(t, err) + + disconnectedNtfns := recvPkScriptNotifications(t, reg, 2) + seenDisconnected := make( + map[chainntnfs.PkScriptNotificationType]struct{}, 2, + ) + spendType := chainntnfs.PkScriptNotificationSpend + confirmType := chainntnfs.PkScriptNotificationConfirm + for _, ntfn := range disconnectedNtfns { + require.True(t, ntfn.Disconnected) + + switch ntfn.Type { + case spendType: + assertPkScriptNotification( + t, ntfn, spendType, + true, uint32(spendHeight), 0, spendHash, + spendBlocks[0], *outpoint, + ) + seenDisconnected[spendType] = struct{}{} + + case confirmType: + assertPkScriptNotification( + t, ntfn, confirmType, + true, uint32(fundingHeight), 1, txid, + fundingBlocks[0], *outpoint, + ) + seenDisconnected[confirmType] = struct{}{} + + default: + t.Fatalf( + "unexpected disconnected ntfn: %v", + ntfn.Type, + ) + } + } + require.Len(t, seenDisconnected, 2) + + _, err = miner2.Client.SendRawTransaction(fundingTx.MsgTx(), false) + require.NoError(t, err) + require.NoError(t, chainntnfs.WaitForMempoolTx(miner, txid)) + + refundingBlocks, err := miner2.Client.Generate(1) + require.NoError(t, err) + _, refundingHeight, err := miner2.Client.GetBestBlock() + require.NoError(t, err) + + assertPkScriptNotification( + t, recvPkScriptNotification(t, reg), + chainntnfs.PkScriptNotificationConfirm, false, + uint32(refundingHeight), 1, txid, refundingBlocks[0], *outpoint, + ) + + _, err = miner2.Client.SendRawTransaction(spendTx, true) + require.NoError(t, err) + require.NoError(t, chainntnfs.WaitForMempoolTx(miner, spendHash)) + + respendBlocks, err := miner2.Client.Generate(1) + require.NoError(t, err) + _, respendHeight, err := miner2.Client.GetBestBlock() + require.NoError(t, err) + + assertPkScriptNotification( + t, recvPkScriptNotification(t, reg), + chainntnfs.PkScriptNotificationSpend, false, + uint32(respendHeight), 0, spendHash, respendBlocks[0], + *outpoint, + ) +} + +// testPkScriptHistoricalAndLiveSpend verifies historical replay catches live +// spends that arrive while a script is still being scanned. +func testPkScriptHistoricalAndLiveSpend(miner *rpctest.Harness, + notifier chainntnfs.TestChainNotifier, t *testing.T) { + + pkScriptNotifier := requirePkScriptNotifier(t, notifier) + epochClient := newEpochClient(t, notifier) + defer epochClient.Cancel() + + liveScript, livePrivKey := newPkScriptTestScript(t) + historicalScript, historicalPrivKey := newPkScriptTestScript(t) + + _, currentHeight, err := miner.Client.GetBestBlock() + require.NoError(t, err) + + reg := registerPkScriptNotifier( + t, pkScriptNotifier, + [][]byte{liveScript}, chainntnfs.PkScriptEventSpend, + 0, uint32(currentHeight), + ) + defer reg.Cancel() + + historicalTxid, historicalOutpoint, historicalOutput := sendOutputToScript( + t, miner, historicalScript, 2e8, + ) + require.NoError(t, chainntnfs.WaitForMempoolTx(miner, historicalTxid)) + + historicalFundingBlocks, err := miner.Client.Generate(1) + require.NoError(t, err) + waitForEpochs(t, epochClient, 1) + _, historicalFundingHeight, err := miner.Client.GetBestBlock() + require.NoError(t, err) + historicalStart := uint32(historicalFundingHeight) + + const fillerBlocks = 60 + _, err = miner.Client.Generate(fillerBlocks) + require.NoError(t, err) + waitForEpochs(t, epochClient, fillerBlocks) + + historicalSpendTx := chainntnfs.CreateSpendTx( + t, historicalOutpoint, historicalOutput, historicalPrivKey, + ) + historicalSpendHash, err := miner.Client.SendRawTransaction( + historicalSpendTx, true, + ) + require.NoError(t, err) + require.NoError(t, chainntnfs.WaitForMempoolTx( + miner, historicalSpendHash, + )) + + historicalSpendBlocks, err := miner.Client.Generate(1) + require.NoError(t, err) + waitForEpochs(t, epochClient, 1) + historicalSpendHeight := historicalStart + fillerBlocks + 1 + + _, err = reg.AddPkScripts( + [][]byte{historicalScript}, + chainntnfs.WithEvents(chainntnfs.PkScriptEventSpend), + chainntnfs.WithHistoricalScanFrom(historicalStart), + ) + require.NoError(t, err) + + liveTxid, liveOutpoint, liveOutput := sendOutputToScript( + t, miner, liveScript, 2e8, + ) + require.NoError(t, chainntnfs.WaitForMempoolTx(miner, liveTxid)) + + _, err = miner.Client.Generate(1) + require.NoError(t, err) + waitForEpochs(t, epochClient, 1) + + liveSpendTx := chainntnfs.CreateSpendTx( + t, liveOutpoint, liveOutput, livePrivKey, + ) + liveSpendHash, err := miner.Client.SendRawTransaction(liveSpendTx, true) + require.NoError(t, err) + require.NoError(t, chainntnfs.WaitForMempoolTx(miner, liveSpendHash)) + + liveSpendBlocks, err := miner.Client.Generate(1) + require.NoError(t, err) + waitForEpochs(t, epochClient, 1) + _, liveSpendHeight, err := miner.Client.GetBestBlock() + require.NoError(t, err) + + spendNtfns := recvPkScriptNotifications(t, reg, 2) + seenSpend := make(map[wire.OutPoint]struct{}, 2) + for _, ntfn := range spendNtfns { + switch ntfn.UTXO.OutPoint { + case *liveOutpoint: + assertPkScriptNotification( + t, ntfn, chainntnfs.PkScriptNotificationSpend, + false, uint32(liveSpendHeight), 0, + liveSpendHash, liveSpendBlocks[0], + *liveOutpoint, + ) + seenSpend[*liveOutpoint] = struct{}{} + + case *historicalOutpoint: + assertPkScriptNotification( + t, ntfn, chainntnfs.PkScriptNotificationSpend, + false, + historicalSpendHeight, 0, + historicalSpendHash, historicalSpendBlocks[0], + *historicalOutpoint, + ) + + // Replay should report the original spend block + // while another live script is watched concurrently. + require.NotEqual( + t, *historicalFundingBlocks[0], *ntfn.BlockHash, + ) + seenSpend[*historicalOutpoint] = struct{}{} + + default: + t.Fatalf( + "unexpected spend outpoint: %v", + ntfn.UTXO.OutPoint, + ) + } + } + require.Len(t, seenSpend, 2) +} + type txNtfnTestCase struct { name string test func(node *rpctest.Harness, notifier chainntnfs.TestChainNotifier, scriptDispatch bool, t *testing.T) } +type pkScriptNtfnTestCase struct { + name string + test func(node *rpctest.Harness, notifier chainntnfs.TestChainNotifier, + t *testing.T) +} + type blockNtfnTestCase struct { name string test func(node *rpctest.Harness, notifier chainntnfs.TestChainNotifier, @@ -1853,6 +2810,37 @@ var txNtfnTests = []txNtfnTestCase{ }, } +var pkScriptNtfnTests = []pkScriptNtfnTestCase{ + { + name: "pkScript historical confirm spend", + test: testPkScriptHistoricalConfirmSpend, + }, + { + name: "pkScript historical multi-script register", + test: testPkScriptHistoricalMultiScriptRegister, + }, + { + name: "pkScript add remove", + test: testPkScriptAddRemove, + }, + { + name: "pkScript add multi-script historical", + test: testPkScriptAddMultipleScriptsHistorical, + }, + { + name: "pkScript multiple events per block", + test: testPkScriptMultipleEventsPerBlock, + }, + { + name: "pkScript reorg", + test: testPkScriptReorg, + }, + { + name: "pkScript historical and live spend", + test: testPkScriptHistoricalAndLiveSpend, + }, +} + var blockNtfnTests = []blockNtfnTestCase{ { name: "block epoch", @@ -1900,7 +2888,8 @@ func TestInterfaces(t *testing.T, targetBackEnd string) { p2pAddr := miner.P2PAddress() log.Printf("Running %v ChainNotifier interface tests", - 2*len(txNtfnTests)+len(blockNtfnTests)+len(blockCatchupTests)) + 2*len(txNtfnTests)+len(pkScriptNtfnTests)+len(blockNtfnTests)+ + len(blockCatchupTests)) for _, notifierDriver := range chainntnfs.RegisteredNotifiers() { notifierType := notifierDriver.NotifierType @@ -2018,6 +3007,17 @@ func TestInterfaces(t *testing.T, targetBackEnd string) { } } + for _, pkScriptNtfnTest := range pkScriptNtfnTests { + testName := fmt.Sprintf("%v %v", notifierType, + pkScriptNtfnTest.name) + success := t.Run(testName, func(t *testing.T) { + pkScriptNtfnTest.test(miner, notifier, t) + }) + if !success { + break + } + } + notifier.Stop() // Run catchup tests separately since they require restarting diff --git a/chainntnfs/txnotifier.go b/chainntnfs/txnotifier.go index af85c298086..4cedeb88b25 100644 --- a/chainntnfs/txnotifier.go +++ b/chainntnfs/txnotifier.go @@ -154,6 +154,8 @@ type spendNtfnSet struct { details *SpendDetail } +type pkScriptHeightIndex map[uint32]map[uint64]map[wire.OutPoint]struct{} + // newSpendNtfnSet constructs a new spend notification set. func newSpendNtfnSet() *spendNtfnSet { return &spendNtfnSet{ @@ -480,8 +482,10 @@ type SpendRegistration struct { // The TxNotifier will watch the blockchain as new blocks come in, in order to // satisfy its client requests. type TxNotifier struct { - confClientCounter uint64 // To be used atomically. - spendClientCounter uint64 // To be used atomically. + confClientCounter uint64 // To be used atomically. + spendClientCounter uint64 // To be used atomically. + pkScriptClientCounter uint64 // To be used atomically. + pkScriptHistoricalScanCounter uint64 // To be used atomically. // currentHeight is the height of the tracked blockchain. It is used to // determine the number of confirmations a tx has and ensure blocks are @@ -520,6 +524,55 @@ type TxNotifier struct { // per outpoint/output script. spendNotifications map[SpendRequest]*spendNtfnSet + // pkScriptNotifications is an index of all active pkScript subscription + // requests. + pkScriptNotifications map[uint64]*pkScriptSubscription + + // pkScriptHistoricalDispatchMtx serializes historical pkScript scans + // globally so they cannot overwhelm the backend or starve live notifier + // work. + pkScriptHistoricalDispatchMtx sync.Mutex + + // pkScriptByScript maps a pkScript to a set of pkScript subscriptions. + pkScriptByScript map[string]map[uint64]struct{} + + // numPkScriptWatches tracks the total number of pkScript watches + // across all active pkScript subscriptions. + numPkScriptWatches int + + // numPkScriptWatchBytes tracks the total byte size of pkScript + // watches across all active pkScript subscriptions. + numPkScriptWatchBytes uint64 + + // pkScriptByOutpoint maps an outpoint to the set of subscriptions that + // currently track the UTXO for future spend detection. + pkScriptByOutpoint map[wire.OutPoint]map[uint64]struct{} + + // pkScriptConfsByHeight tracks outputs by the height at which they will + // reach the configured confirmation depth. + pkScriptConfsByHeight pkScriptHeightIndex + + // pkScriptConfirmedByHeight tracks pkScript confirmation notifications by + // the block height at which they were dispatched so they can be + // invalidated during reorgs. + pkScriptConfirmedByHeight pkScriptHeightIndex + + // pkScriptConfUpdatesByHeight tracks partial confirmation updates that + // should be dispatched at a future height. + pkScriptConfUpdatesByHeight pkScriptHeightIndex + + // pkScriptConfUpdatesDispatchedByHeight tracks dispatched partial + // confirmation updates so they can be invalidated during reorgs. + pkScriptConfUpdatesDispatchedByHeight pkScriptHeightIndex + + // pkScriptReceivesByHeight tracks new UTXOs created at each height for + // reorg handling. + pkScriptReceivesByHeight pkScriptHeightIndex + + // pkScriptSpendsByHeight tracks spends at each height for reorg + // handling. + pkScriptSpendsByHeight pkScriptHeightIndex + // spendsByHeight is an index that keeps tracks of the spending height // of outpoints/output scripts we are currently tracking notifications // for. This is used in order to recover from spending transactions @@ -553,16 +606,37 @@ func NewTxNotifier(startHeight uint32, reorgSafetyLimit uint32, spendHintCache SpendHintCache) *TxNotifier { return &TxNotifier{ - currentHeight: startHeight, - reorgSafetyLimit: reorgSafetyLimit, - confNotifications: make(map[ConfRequest]*confNtfnSet), - confsByInitialHeight: make(map[uint32]map[ConfRequest]struct{}), + currentHeight: startHeight, + reorgSafetyLimit: reorgSafetyLimit, + confNotifications: make(map[ConfRequest]*confNtfnSet), + confsByInitialHeight: make( + map[uint32]map[ConfRequest]struct{}, + ), ntfnsByConfirmHeight: make(map[uint32]map[*ConfNtfn]struct{}), spendNotifications: make(map[SpendRequest]*spendNtfnSet), - spendsByHeight: make(map[uint32]map[SpendRequest]struct{}), - confirmHintCache: confirmHintCache, - spendHintCache: spendHintCache, - quit: make(chan struct{}), + spendsByHeight: make( + map[uint32]map[SpendRequest]struct{}, + ), + pkScriptNotifications: make(map[uint64]*pkScriptSubscription), + pkScriptByScript: make(map[string]map[uint64]struct{}), + pkScriptByOutpoint: make( + map[wire.OutPoint]map[uint64]struct{}, + ), + pkScriptConfsByHeight: make(pkScriptHeightIndex), + pkScriptConfirmedByHeight: make( + pkScriptHeightIndex, + ), + pkScriptConfUpdatesByHeight: make( + pkScriptHeightIndex, + ), + pkScriptConfUpdatesDispatchedByHeight: make( + pkScriptHeightIndex, + ), + pkScriptReceivesByHeight: make(pkScriptHeightIndex), + pkScriptSpendsByHeight: make(pkScriptHeightIndex), + confirmHintCache: confirmHintCache, + spendHintCache: spendHintCache, + quit: make(chan struct{}), } } @@ -1465,20 +1539,40 @@ func (n *TxNotifier) ConnectTip(block *btcutil.Block, // First, we'll iterate over all the transactions found in this block to // determine if it includes any relevant transactions to the TxNotifier. + var blockHash *chainhash.Hash + processPkScriptEvents := n.numPkScriptWatches > 0 if block != nil { Log.Debugf("Filtering %d txns for %d spend requests at "+ "height %d", len(block.Transactions()), len(n.spendNotifications), blockHeight) - for _, tx := range block.Transactions() { + blockHash = block.Hash() + + for txIdx, tx := range block.Transactions() { n.filterTx( block, tx, blockHeight, n.handleConfDetailsAtTip, n.handleSpendDetailsAtTip, ) + + if processPkScriptEvents { + err := n.processPkScriptTxAtTip( + tx, uint32(txIdx), blockHeight, block, + ) + if err != nil { + return err + } + } } } + err := n.dispatchPkScriptNotificationsAtTip( + blockHeight, blockHash, block, + ) + if err != nil { + return err + } + // Now that we've determined which requests were confirmed and spent // within the new block, we can update their entries in their respective // caches, along with all of our unconfirmed and unspent requests. @@ -1518,6 +1612,8 @@ func (n *TxNotifier) ConnectTip(block *btcutil.Block, delete(n.spendNotifications, spendRequest) } delete(n.spendsByHeight, matureBlockHeight) + + n.pruneMaturePkScriptState(matureBlockHeight) } return nil @@ -1931,6 +2027,11 @@ func (n *TxNotifier) DisconnectTip(blockHeight uint32) error { delete(n.confsByInitialHeight, blockHeight) delete(n.spendsByHeight, blockHeight) + err := n.disconnectPkScriptTip(blockHeight) + if err != nil { + return err + } + return nil } @@ -2134,6 +2235,12 @@ func (n *TxNotifier) TearDown() { delete(spendSet.ntfns, spendID) } } + + for subID := range n.pkScriptNotifications { + n.cancelPkScriptLocked(subID) + } + n.numPkScriptWatches = 0 + n.numPkScriptWatchBytes = 0 } // notifyNumConfsLeft sends the number of confirmations left to the diff --git a/chainreg/no_chain_backend.go b/chainreg/no_chain_backend.go index 2a3f7bf9975..c92a45588a0 100644 --- a/chainreg/no_chain_backend.go +++ b/chainreg/no_chain_backend.go @@ -44,6 +44,7 @@ var ( // NoChainBackend is a mock implementation of the following interfaces: // - chainview.FilteredChainView // - chainntnfs.ChainNotifier +// - chainntnfs.PkScriptNotifier // - chainfee.Estimator type NoChainBackend struct { } @@ -71,6 +72,14 @@ func (n *NoChainBackend) RegisterSpendNtfn(*wire.OutPoint, []byte, return nil, errNotImplemented } +// RegisterPkScriptNotifier returns an error because the no-chain backend cannot +// deliver pkScript notifications. +func (n *NoChainBackend) RegisterPkScriptNotifier() ( + *chainntnfs.PkScriptNotificationRegistration, error) { + + return nil, errNotImplemented +} + func (n *NoChainBackend) RegisterBlockEpochNtfn( *chainntnfs.BlockEpoch) (*chainntnfs.BlockEpochEvent, error) { @@ -115,6 +124,7 @@ func (n *NoChainBackend) Stop() error { var _ chainview.FilteredChainView = (*NoChainBackend)(nil) var _ chainntnfs.ChainNotifier = (*NoChainBackend)(nil) +var _ chainntnfs.PkScriptNotifier = (*NoChainBackend)(nil) var _ chainfee.Estimator = (*NoChainBackend)(nil) // NoChainSource is a mock implementation of chain.Interface. diff --git a/docs/pkscriptnotifier.md b/docs/pkscriptnotifier.md new file mode 100644 index 00000000000..4849ef9fe20 --- /dev/null +++ b/docs/pkscriptnotifier.md @@ -0,0 +1,291 @@ +# PkScript Notifier + +The pkScript notifier is a chain notifier stream for clients that need to watch +all confirmed on-chain activity for one or more output scripts. It is intended +for external protocols and services that know the scripts they care about, but +do not necessarily know the funding transaction or outpoint ahead of time. + +Clients that already know the exact transaction to confirm or exact outpoint to +spend should continue using `RegisterConfirmationsNtfn` or `RegisterSpendNtfn`. +Those APIs track a single target. `RegisterPkScriptNtfn` tracks a mutable set of +scripts and reports every matching output and spend on one stream. + +## Capabilities + +`RegisterPkScriptNtfn` is a bidirectional `chainrpc.ChainNotifier` stream. The +first client message must be a `register` request. After the stream is +registered, clients can send `add` and `remove` requests over the same stream. + +For each added pkScript, clients can choose: + +* confirmation notifications, +* spend notifications, +* both confirmation and spend notifications, +* the confirmation depth required before final confirmation notifications, +* partial confirmation updates before the final confirmation depth, +* whether relevant raw transactions are included, +* whether relevant raw blocks are included, +* whether historical blocks should be scanned from a requested height. + +The server sends three kinds of response events: + +* `ack`: a mutation was accepted. +* `notification`: a watched output confirmed, reached partial confirmation + progress, or was spent. +* `historical_scan`: a requested historical scan completed or failed. + +Reorgs are reported on the same notification stream by setting +`disconnected=true` on the event that is being invalidated. + +## In-Process Go Example + +The backend notifier exposes the same stream through the `chainntnfs` +`PkScriptNotifier` interface. The example below registers a stream, defers +cleanup, adds pkScripts with every option enabled, and reads notifications until +the caller's context is canceled or the stream closes. + +```go +package main + +import ( + "context" + "fmt" + "log" + + "github.com/lightningnetwork/lnd/chainntnfs" +) + +func watchPkScripts(ctx context.Context, notifier chainntnfs.PkScriptNotifier, + pkScripts [][]byte) error { + + reg, err := notifier.RegisterPkScriptNotifier() + if err != nil { + return err + } + defer reg.Cancel() + + result, err := reg.AddPkScripts( + pkScripts, + chainntnfs.WithEvents( + chainntnfs.PkScriptEventConfirm| + chainntnfs.PkScriptEventSpend, + ), + chainntnfs.WithNumConfs(6), + chainntnfs.WithHistoricalScanFrom(800000), + chainntnfs.WithIncludeConfirmationUpdates(), + chainntnfs.WithIncludeTx(), + chainntnfs.WithIncludeBlock(), + ) + if err != nil { + return err + } + + if result.HistoricalScanQueued { + log.Printf("queued historical pkScript scan id=%d range=%d-%d", + result.HistoricalScanID, + result.HistoricalScanStartHeight, + result.HistoricalScanEndHeight) + } + + for { + select { + case ntfn, ok := <-reg.Notifications: + if !ok { + if reg.Err != nil && reg.Err() != nil { + return reg.Err() + } + + return nil + } + + switch ntfn.Type { + case chainntnfs.PkScriptNotificationConfirmUpdate: + log.Printf("pkScript update tx=%v confs=%d/%d "+ + "disconnected=%v", ntfn.TxHash, + ntfn.NumConfirmations, ntfn.RequiredConfs, + ntfn.Disconnected) + + case chainntnfs.PkScriptNotificationConfirm: + log.Printf("pkScript confirmed outpoint=%v height=%d "+ + "disconnected=%v", ntfn.UTXO.OutPoint, + ntfn.Height, ntfn.Disconnected) + + case chainntnfs.PkScriptNotificationSpend: + log.Printf("pkScript spent outpoint=%v spend_tx=%v "+ + "input=%d disconnected=%v", ntfn.UTXO.OutPoint, + ntfn.TxHash, ntfn.InputIndex, + ntfn.Disconnected) + + case chainntnfs.PkScriptNotificationHistoricalScanComplete: + scan := ntfn.HistoricalScan + if scan.Error != "" { + return fmt.Errorf("historical pkScript scan "+ + "%d failed at height %d: %s", + scan.ScanID, scan.CompletedHeight, + scan.Error) + } + + log.Printf("historical pkScript scan %d complete "+ + "through height %d", scan.ScanID, + scan.CompletedHeight) + } + + case <-ctx.Done(): + return ctx.Err() + } + } +} +``` + +## Reorg Handling Example + +`Disconnected` is not a separate event type. It is set on the same notification +type that is being invalidated. A client should therefore key its local state by +the watched UTXO outpoint and undo the prior confirmation, confirmation update, +or spend effect when `Disconnected` is true. + +The example below is intentionally small. In production, persist each state +change in one database transaction with any application-specific side effects. + +```go +package main + +import ( + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/chainntnfs" +) + +type trackedOutput struct { + UTXO *chainntnfs.PkScriptUTXO + + Confirmations uint32 + Confirmed bool + + Spent bool + SpendTx *chainhash.Hash + SpendHeight uint32 +} + +func applyPkScriptNotification(ntfn *chainntnfs.PkScriptNotification, + outputs map[wire.OutPoint]*trackedOutput) { + + if ntfn == nil || ntfn.UTXO == nil { + return + } + + outpoint := ntfn.UTXO.OutPoint + output := outputs[outpoint] + if output == nil { + output = &trackedOutput{} + outputs[outpoint] = output + } + output.UTXO = ntfn.UTXO + + switch ntfn.Type { + case chainntnfs.PkScriptNotificationConfirmUpdate: + if ntfn.Disconnected { + // This invalidates a previously delivered partial + // confirmation update. If you persist updates by height, + // delete that exact update here instead. + if ntfn.NumConfirmations > 0 { + output.Confirmations = ntfn.NumConfirmations - 1 + } + return + } + + output.Confirmations = ntfn.NumConfirmations + + case chainntnfs.PkScriptNotificationConfirm: + if ntfn.Disconnected { + // The final confirmation is no longer valid. The output may + // confirm again later, and the notifier will send a new + // confirmation notification if it reaches the target again. + output.Confirmed = false + if ntfn.RequiredConfs > 0 { + output.Confirmations = ntfn.RequiredConfs - 1 + } + return + } + + output.Confirmed = true + output.Confirmations = ntfn.RequiredConfs + + case chainntnfs.PkScriptNotificationSpend: + if ntfn.Disconnected { + // The spending transaction was reorged out. Treat the output + // as unspent until another spend notification arrives. + output.Spent = false + output.SpendTx = nil + output.SpendHeight = 0 + return + } + + output.Spent = true + output.SpendTx = copyHash(ntfn.TxHash) + output.SpendHeight = ntfn.Height + } +} + +func copyHash(hash *chainhash.Hash) *chainhash.Hash { + if hash == nil { + return nil + } + + hashCopy := *hash + return &hashCopy +} +``` + +## Historical Scans + +An add request can set `historical_scan_from`. When set, the backend scans from +that height through its current best known tip for the newly added scripts. +Setting it to zero explicitly scans from genesis. Omitting it means future-only +watching. + +Add acknowledgements only mean that the scripts were accepted and any historical +scan was queued. They do not mean the historical scan has completed. A later +`historical_scan` event reports the scan result. + +Historical scans are queued per add request. All newly accepted scripts in that +add request are scanned together. The notifier serializes historical pkScript +scans so live chain processing is not overwhelmed. + +## Notification Semantics + +Confirmation notifications are sent when a matched output reaches the requested +confirmation depth. If partial confirmation updates are enabled, the stream also +sends progress notifications before the final confirmation depth is reached. + +Spend notifications are sent when a previously matched watched output is spent +by a confirmed transaction. The notification includes the watched UTXO metadata, +the spending transaction hash, the transaction index, and the input index. + +When `include_tx` is set, confirmation events include the funding transaction +and spend events include the spending transaction. When `include_block` is set, +events include the block relevant to the notification. + +## Resource Bounds + +The notifier applies limits to protect the node from unbounded streams: + +* maximum active pkScript streams, +* maximum scripts per mutation, +* maximum script bytes per mutation, +* maximum scripts and script bytes per registration, +* maximum scripts and script bytes across all registrations, +* maximum queued notifications per registration, +* maximum queued historical scans per backend. + +A client that does not read responses fast enough can have its registration +canceled. RPC clients receive `ResourceExhausted` when this happens. + +## Backend Support + +The pkScript notifier is implemented by the `bitcoind`, `btcd`, and `neutrino` +chain notifier backends. + +The `neutrino` backend must be able to convert watched scripts into compact +filter addresses. Scripts that cannot be represented in neutrino's filter watch +set are rejected. diff --git a/docs/release-notes/release-notes-0.22.0.md b/docs/release-notes/release-notes-0.22.0.md index fb31bad5662..fb090667ad8 100644 --- a/docs/release-notes/release-notes-0.22.0.md +++ b/docs/release-notes/release-notes-0.22.0.md @@ -32,6 +32,12 @@ ## RPC Additions +* The chain notifier subserver + [now exposes `RegisterPkScriptNtfn`](https://github.com/lightningnetwork/lnd/pull/10807), + a bidirectional stream that lets clients dynamically add and remove watched + pkScripts and receive confirmation, spend, partial confirmation, historical + scan, and reorg notifications for matching outputs. + ## lncli Additions # Improvements @@ -67,7 +73,13 @@ ## Tooling and Documentation +* Added + [`docs/pkscriptnotifier.md`](https://github.com/lightningnetwork/lnd/pull/10807), + which documents the pkScript notifier RPC semantics, historical scans, + reorg handling, resource bounds, and backend support. + # Contributors (Alphabetical Order) * Boris Nagaev * Erick Cestari +* hieblmi diff --git a/itest/list_on_test.go b/itest/list_on_test.go index c8e4244e7e3..1ae15a49760 100644 --- a/itest/list_on_test.go +++ b/itest/list_on_test.go @@ -50,6 +50,42 @@ var allTestCases = []*lntest.TestCase{ Name: "reorg notifications", TestFunc: testReorgNotifications, }, + { + Name: "pkScript notifier historical registration rpc", + TestFunc: testPkScriptNotifierHistoricalRegistrationRPC, + }, + { + Name: "pkScript notifier historical add rpc", + TestFunc: testPkScriptNotifierHistoricalAddRPC, + }, + { + Name: "pkScript notifier remove rpc", + TestFunc: testPkScriptNotifierRemoveRPC, + }, + { + Name: "pkScript notifier confirm only rpc", + TestFunc: testPkScriptNotifierConfirmOnlyRPC, + }, + { + Name: "pkScript notifier partial confirmation rpc", + TestFunc: testPkScriptNotifierPartialConfirmationRPC, + }, + { + Name: "pkScript notifier multi stream rpc", + TestFunc: testPkScriptNotifierMultiStreamRPC, + }, + { + Name: "pkScript notifier skip history rpc", + TestFunc: testPkScriptNotifierSkipHistoryRPC, + }, + { + Name: "pkScript notifier validation rpc", + TestFunc: testPkScriptNotifierValidationRPC, + }, + { + Name: "pkScript notifier reorg rpc", + TestFunc: testPkScriptNotifierReorgRPC, + }, { Name: "disconnecting target peer", TestFunc: testDisconnectingTargetPeer, diff --git a/itest/lnd_chain_notifier_pkscript_test.go b/itest/lnd_chain_notifier_pkscript_test.go new file mode 100644 index 00000000000..8e1e8561902 --- /dev/null +++ b/itest/lnd_chain_notifier_pkscript_test.go @@ -0,0 +1,1305 @@ +package itest + +import ( + "bytes" + "context" + "errors" + "fmt" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnrpc/chainrpc" + "github.com/lightningnetwork/lnd/lntest" + "github.com/lightningnetwork/lnd/lntest/node" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// errPkScriptEventTimeout is returned when no pkScript stream event arrives +// before the caller's timeout expires. +var errPkScriptEventTimeout = errors.New("timed out waiting for pkScript event") + +// pkScriptStreamEvent couples a received pkScript stream event with the receive +// error returned by the RPC client. +type pkScriptStreamEvent struct { + event *chainrpc.PkScriptEvent + err error +} + +// pkScriptStreamHarness wraps the bidirectional pkScript notification RPC stream +// and buffers out-of-order event types for focused test assertions. +type pkScriptStreamHarness struct { + ht *lntest.HarnessTest + + cancel context.CancelFunc + client chainrpc.ChainNotifier_RegisterPkScriptNtfnClient + events chan pkScriptStreamEvent + + pending []*chainrpc.PkScriptNotification + scans []*chainrpc.PkScriptHistoricalScan +} + +type pkHistScan = chainrpc.PkScriptHistoricalScan + +// newPkScriptStreamHarness opens a pkScript notification stream for the target +// node and starts a receive loop for stream events. +func newPkScriptStreamHarness(ht *lntest.HarnessTest, + hn *node.HarnessNode) *pkScriptStreamHarness { + + ctx, cancel := context.WithCancel(ht.Context()) + client, err := hn.RPC.ChainClient.RegisterPkScriptNtfn(ctx) + require.NoError(ht, err) + + a := &pkScriptStreamHarness{ + ht: ht, + cancel: cancel, + client: client, + events: make(chan pkScriptStreamEvent, 100), + } + + go a.recvEvents() + + return a +} + +// recvEvents forwards RPC stream receive results into the harness event queue +// until the stream returns a terminal error. +func (a *pkScriptStreamHarness) recvEvents() { + for { + event, err := a.client.Recv() + a.events <- pkScriptStreamEvent{ + event: event, + err: err, + } + + if err != nil { + close(a.events) + return + } + } +} + +// close closes the send side of the RPC stream and cancels its context. +func (a *pkScriptStreamHarness) close() { + _ = a.client.CloseSend() + a.cancel() +} + +// send sends a request on the pkScript stream and fails the test on error. +func (a *pkScriptStreamHarness) send(req *chainrpc.PkScriptRequest) { + err := a.client.Send(req) + require.NoError(a.ht, err) +} + +// pkScriptRegisterReq builds the initial registration request required by the +// pkScript notification stream. +func pkScriptRegisterReq() *chainrpc.PkScriptRequest { + return &chainrpc.PkScriptRequest{ + Request: &chainrpc.PkScriptRequest_Register{ + Register: &chainrpc.PkScriptRegisterRequest{}, + }, + } +} + +// pkScriptAddReq builds an add request without partial confirmation updates. +func pkScriptAddReq(pkScripts [][]byte, events []chainrpc.PkScriptEventType, + numConfs uint32, historicalScanFrom *uint32, includeBlock, + includeTx bool) *chainrpc.PkScriptRequest { + + return pkScriptAddReqWithConfUpdates( + pkScripts, events, numConfs, historicalScanFrom, includeBlock, + includeTx, false, + ) +} + +// pkScriptAddReqWithConfUpdates builds an add request, optionally enabling +// partial confirmation update notifications. +func pkScriptAddReqWithConfUpdates(pkScripts [][]byte, + events []chainrpc.PkScriptEventType, numConfs uint32, + historicalScanFrom *uint32, includeBlock, includeTx, + includeConfirmationUpdates bool) *chainrpc.PkScriptRequest { + + add := &chainrpc.AddPkScriptRequest{ + PkScripts: pkScripts, + Events: events, + NumConfs: numConfs, + IncludeBlock: includeBlock, + IncludeTx: includeTx, + IncludeConfirmationUpdates: includeConfirmationUpdates, + } + if historicalScanFrom != nil { + add.HistoricalScan = &chainrpc.AddPkScriptRequest_HistoricalScanFrom{ + HistoricalScanFrom: *historicalScanFrom, + } + } + + return &chainrpc.PkScriptRequest{ + Request: &chainrpc.PkScriptRequest_Add{Add: add}, + } +} + +// pkScriptRemoveReq builds a remove request for the provided pkScripts. +func pkScriptRemoveReq(pkScripts [][]byte) *chainrpc.PkScriptRequest { + return &chainrpc.PkScriptRequest{ + Request: &chainrpc.PkScriptRequest_Remove{ + Remove: &chainrpc.RemovePkScriptRequest{ + PkScripts: pkScripts, + }, + }, + } +} + +// recvEvent returns the next queued stream event or a timeout error. +func (a *pkScriptStreamHarness) recvEvent( + timeout time.Duration) (*chainrpc.PkScriptEvent, error) { + + timer := time.NewTimer(timeout) + defer timer.Stop() + + select { + case <-timer.C: + return nil, errPkScriptEventTimeout + + case event, ok := <-a.events: + if !ok { + return nil, fmt.Errorf("pkScript event stream closed") + } + + return event.event, event.err + } +} + +// requireAck waits for a mutation ack, buffering notifications and historical +// scan events that arrive first. +func (a *pkScriptStreamHarness) requireAck( + action chainrpc.PkScriptMutationAction) *chainrpc.PkScriptMutationAck { + + a.ht.Helper() + + for { + event, err := a.recvEvent(defaultTimeout) + require.NoError(a.ht, err) + + if ack := event.GetAck(); ack != nil { + require.Equal(a.ht, action, ack.Action) + return ack + } + if scan := event.GetHistoricalScan(); scan != nil { + a.scans = append(a.scans, scan) + continue + } + + ntfn := event.GetNotification() + require.NotNil(a.ht, ntfn) + a.pending = append(a.pending, ntfn) + } +} + +// requireNotification returns the next notification, buffering historical scan +// events and failing the test if a mutation ack arrives unexpectedly. +func (a *pkScriptStreamHarness) requireNotification() *chainrpc.PkScriptNotification { + a.ht.Helper() + + if len(a.pending) > 0 { + ntfn := a.pending[0] + a.pending = a.pending[1:] + return ntfn + } + + for { + event, err := a.recvEvent(defaultTimeout) + require.NoError(a.ht, err) + + if ack := event.GetAck(); ack != nil { + require.Failf( + a.ht, "unexpected pkScript ack", "action=%v", + ack.Action, + ) + } + if scan := event.GetHistoricalScan(); scan != nil { + a.scans = append(a.scans, scan) + continue + } + + ntfn := event.GetNotification() + require.NotNil(a.ht, ntfn) + return ntfn + } +} + +// requireNotifications returns count notifications from the pkScript stream. +func (a *pkScriptStreamHarness) requireNotifications( + count int) []*chainrpc.PkScriptNotification { + + a.ht.Helper() + + ntfns := make([]*chainrpc.PkScriptNotification, 0, count) + for i := 0; i < count; i++ { + ntfns = append(ntfns, a.requireNotification()) + } + + return ntfns +} + +// assertNoNotification asserts that no notification arrives before the timeout, +// while preserving historical scan events for later assertions. +func (a *pkScriptStreamHarness) assertNoNotification(timeout time.Duration) { + a.ht.Helper() + + if len(a.pending) > 0 { + require.Failf( + a.ht, "unexpected buffered pkScript notification", + "count=%d", len(a.pending), + ) + } + + for { + event, err := a.recvEvent(timeout) + if errors.Is(err, errPkScriptEventTimeout) { + return + } + require.NoError(a.ht, err) + + if scan := event.GetHistoricalScan(); scan != nil { + a.scans = append(a.scans, scan) + continue + } + + require.Failf(a.ht, "unexpected pkScript event", "%v", event) + } +} + +// requireHistoricalScan returns the next historical scan completion event, +// buffering notifications that arrive first. +func (a *pkScriptStreamHarness) requireHistoricalScan() *pkHistScan { + + a.ht.Helper() + + if len(a.scans) > 0 { + scan := a.scans[0] + a.scans = a.scans[1:] + return scan + } + + for { + event, err := a.recvEvent(defaultTimeout) + require.NoError(a.ht, err) + + if ack := event.GetAck(); ack != nil { + require.Failf( + a.ht, "unexpected pkScript ack", "action=%v", + ack.Action, + ) + } + + if scan := event.GetHistoricalScan(); scan != nil { + return scan + } + + ntfn := event.GetNotification() + require.NotNil(a.ht, ntfn) + a.pending = append(a.pending, ntfn) + } +} + +// newPkScriptTestAddress creates a fresh P2WPKH test address, its pkScript, and the +// corresponding private key. +func newPkScriptTestAddress(ht *lntest.HarnessTest) (btcutil.Address, []byte, + *btcec.PrivateKey) { + + ht.Helper() + + privKey, err := btcec.NewPrivateKey() + require.NoError(ht, err) + + pubKeyHash := btcutil.Hash160(privKey.PubKey().SerializeCompressed()) + addr, err := btcutil.NewAddressWitnessPubKeyHash( + pubKeyHash, harnessNetParams, + ) + require.NoError(ht, err) + + pkScript, err := txscript.PayToAddrScript(addr) + require.NoError(ht, err) + + return addr, pkScript, privKey +} + +// fundAddress sends coins to addr and returns the matching mempool output data. +func fundAddress(ht *lntest.HarnessTest, sender *node.HarnessNode, + addr btcutil.Address, pkScript []byte, + value int64) (*chainhash.Hash, *wire.OutPoint, *wire.TxOut, *wire.MsgTx) { + + ht.Helper() + + txidStr := sender.RPC.SendCoins(&lnrpc.SendCoinsRequest{ + Addr: addr.EncodeAddress(), + Amount: value, + SatPerVbyte: 2, + }).Txid + + txid, err := chainhash.NewHashFromStr(txidStr) + require.NoError(ht, err) + + tx := ht.AssertTxInMempool(*txid) + + for i, txOut := range tx.TxOut { + if txOut.Value != value { + continue + } + if !bytes.Equal(txOut.PkScript, pkScript) { + continue + } + + outpoint := &wire.OutPoint{ + Hash: *txid, + Index: uint32(i), + } + output := &wire.TxOut{ + Value: txOut.Value, + PkScript: txOut.PkScript, + } + + return txid, outpoint, output, tx + } + + require.Fail(ht, "funding output not found") + return nil, nil, nil, nil +} + +// createSpendTx creates and signs a transaction spending the provided output to +// a fresh test address. +func createSpendTx(ht *lntest.HarnessTest, prevOutPoint *wire.OutPoint, + prevOutput *wire.TxOut, privKey *btcec.PrivateKey) *wire.MsgTx { + + ht.Helper() + + spendAddr, _, _ := newPkScriptTestAddress(ht) + spendScript, err := txscript.PayToAddrScript(spendAddr) + require.NoError(ht, err) + + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(prevOutPoint, nil, nil)) + tx.AddTxOut(wire.NewTxOut(prevOutput.Value-10_000, spendScript)) + + sigHashes := input.NewTxSigHashesV0Only(tx) + witnessScript, err := txscript.WitnessSignature( + tx, sigHashes, 0, prevOutput.Value, prevOutput.PkScript, + txscript.SigHashAll, privKey, true, + ) + require.NoError(ht, err) + + tx.TxIn[0].Witness = witnessScript + + return tx +} + +// notificationKey returns a stable key for a notification's watched outpoint. +func notificationKey(ntfn *chainrpc.PkScriptNotification) string { + return fmt.Sprintf( + "%x:%d", ntfn.Utxo.Outpoint.Hash, ntfn.Utxo.Outpoint.Index, + ) +} + +// pkScriptNotificationMap indexes notifications by outpoint and event type. +func pkScriptNotificationMap(ht *lntest.HarnessTest, + ntfns []*chainrpc.PkScriptNotification, +) map[string]*chainrpc.PkScriptNotification { + + ht.Helper() + + ntfnMap := make(map[string]*chainrpc.PkScriptNotification, len(ntfns)) + for _, ntfn := range ntfns { + require.NotNil(ht, ntfn) + + ntfnKey := notificationKey(ntfn) + key := fmt.Sprintf("%s:%d", ntfnKey, ntfn.EventType) + require.NotContains(ht, ntfnMap, key) + + ntfnMap[key] = ntfn + } + + return ntfnMap +} + +// wireOutpointKey returns a stable string key for a wire outpoint. +func wireOutpointKey(outpoint *wire.OutPoint) string { + return fmt.Sprintf("%x:%d", outpoint.Hash[:], outpoint.Index) +} + +// pkScriptNotificationExpectations captures optional fields asserted for a +// pkScript notification. +type pkScriptNotificationExpectations struct { + value *int64 + pkScript []byte + utxoHeight *uint32 + utxoBlockHash *chainhash.Hash + utxoTxIndex *uint32 + txIndex *uint32 + inputIndex *uint32 +} + +// pkScriptNotificationOption configures optional notification expectations. +type pkScriptNotificationOption func(*pkScriptNotificationExpectations) + +// withPkScriptUTXO expects the notification to include UTXO metadata. +func withPkScriptUTXO(output *wire.TxOut, height uint32, + blockHash *chainhash.Hash) pkScriptNotificationOption { + + return func(e *pkScriptNotificationExpectations) { + e.value = &output.Value + e.pkScript = output.PkScript + e.utxoHeight = &height + e.utxoBlockHash = blockHash + } +} + +// withPkScriptTxIndex expects the notification's top-level transaction index. +func withPkScriptTxIndex(txIndex uint32) pkScriptNotificationOption { + return func(e *pkScriptNotificationExpectations) { + e.txIndex = &txIndex + } +} + +// withPkScriptUTXOTxIndex expects the notification UTXO's transaction index. +func withPkScriptUTXOTxIndex(txIndex uint32) pkScriptNotificationOption { + return func(e *pkScriptNotificationExpectations) { + e.utxoTxIndex = &txIndex + } +} + +// withPkScriptInputIndex expects the notification's spend input index. +func withPkScriptInputIndex(inputIndex uint32) pkScriptNotificationOption { + return func(e *pkScriptNotificationExpectations) { + e.inputIndex = &inputIndex + } +} + +// assertPkScriptNotification verifies the common RPC notification fields, +// optional index fields, and optional raw transaction/block payloads. +func assertPkScriptNotification(ht *lntest.HarnessTest, + ntfn *chainrpc.PkScriptNotification, expectedType chainrpc.PkScriptEventType, + disconnected bool, height uint32, blockHash, txHash *chainhash.Hash, + outpoint *wire.OutPoint, numConfs uint32, expectRaw bool, + opts ...pkScriptNotificationOption) { + + ht.Helper() + + expectations := &pkScriptNotificationExpectations{} + for _, opt := range opts { + opt(expectations) + } + + require.NotNil(ht, ntfn) + require.Equal(ht, expectedType, ntfn.EventType) + require.Equal(ht, disconnected, ntfn.Disconnected) + require.Equal(ht, height, ntfn.Height) + require.Equal(ht, numConfs, ntfn.NumConfirmations) + require.NotNil(ht, ntfn.Utxo) + require.NotNil(ht, ntfn.Utxo.Outpoint) + require.Equal(ht, outpoint.Hash[:], ntfn.Utxo.Outpoint.Hash) + require.Equal(ht, outpoint.Index, ntfn.Utxo.Outpoint.Index) + require.Equal(ht, txHash[:], ntfn.TxHash) + require.Equal(ht, blockHash[:], ntfn.BlockHash) + + if expectations.value != nil { + require.Equal(ht, *expectations.value, ntfn.Utxo.Value) + } + if expectations.pkScript != nil { + require.Equal(ht, expectations.pkScript, ntfn.Utxo.PkScript) + } + if expectations.utxoHeight != nil { + require.Equal(ht, *expectations.utxoHeight, ntfn.Utxo.BlockHeight) + } + if expectations.utxoBlockHash != nil { + require.Equal(ht, expectations.utxoBlockHash[:], ntfn.Utxo.BlockHash) + } + if expectations.utxoTxIndex != nil { + require.Equal(ht, *expectations.utxoTxIndex, ntfn.Utxo.TxIndex) + } + if expectations.txIndex != nil { + require.Equal(ht, *expectations.txIndex, ntfn.TxIndex) + } + if expectations.inputIndex != nil { + require.Equal(ht, *expectations.inputIndex, ntfn.InputIndex) + } + + if expectRaw { + require.NotEmpty(ht, ntfn.RawTx) + require.NotEmpty(ht, ntfn.RawBlock) + + var rawTx wire.MsgTx + err := rawTx.Deserialize(bytes.NewReader(ntfn.RawTx)) + require.NoError(ht, err) + require.Equal(ht, *txHash, rawTx.TxHash()) + + var rawBlock wire.MsgBlock + err = rawBlock.Deserialize(bytes.NewReader(ntfn.RawBlock)) + require.NoError(ht, err) + require.Equal(ht, *blockHash, rawBlock.BlockHash()) + + if expectedType == + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_SPEND { + + block := btcutil.NewBlock(&rawBlock) + indexedHash, err := block.TxHash(int(ntfn.TxIndex)) + require.NoError(ht, err) + require.True(ht, indexedHash.IsEqual(txHash)) + } + + return + } + + require.Empty(ht, ntfn.RawTx) + require.Empty(ht, ntfn.RawBlock) +} + +// requirePkScriptStreamError sends a request sequence and waits for the pkScript +// stream to fail with the expected RPC status code. +func requirePkScriptStreamError(ht *lntest.HarnessTest, hn *node.HarnessNode, + code codes.Code, reqs ...*chainrpc.PkScriptRequest) { + + ht.Helper() + + stream := newPkScriptStreamHarness(ht, hn) + defer stream.close() + + for _, req := range reqs { + err := stream.client.Send(req) + if err != nil { + require.Equal(ht, code, status.Code(err)) + return + } + } + + for { + _, err := stream.recvEvent(defaultTimeout) + if err != nil { + require.Equal(ht, code, status.Code(err)) + return + } + } +} + +// newRegisteredPkScriptStream opens a pkScript stream and completes the initial +// register handshake. +func newRegisteredPkScriptStream(ht *lntest.HarnessTest, + hn *node.HarnessNode) *pkScriptStreamHarness { + + stream := newPkScriptStreamHarness(ht, hn) + stream.send(pkScriptRegisterReq()) + stream.requireAck( + chainrpc.PkScriptMutationAction_PK_SCRIPT_MUTATION_ACTION_REGISTER, + ) + + return stream +} + +// newPkScriptNotifierRPCScenario creates funded sender/receiver nodes and a +// registered pkScript stream on the receiver. +func newPkScriptNotifierRPCScenario(ht *lntest.HarnessTest) ( + *node.HarnessNode, *node.HarnessNode, *pkScriptStreamHarness) { + + alice := ht.NewNodeWithCoins("Alice", nil) + bob := ht.NewNode("Bob", nil) + + stream := newRegisteredPkScriptStream(ht, bob) + + return alice, bob, stream +} + +// assertHistoricalScanComplete checks that a historical scan completion event +// matches the add acknowledgement that queued it. +func assertHistoricalScanComplete(ht *lntest.HarnessTest, + stream *pkScriptStreamHarness, ack *chainrpc.PkScriptMutationAck, + minCompletedHeight uint32) *chainrpc.PkScriptHistoricalScan { + + ht.Helper() + + require.True(ht, ack.HistoricalScanQueued) + require.NotZero(ht, ack.HistoricalScanId) + + scan := stream.requireHistoricalScan() + require.Equal(ht, ack.HistoricalScanId, scan.ScanId) + require.Equal(ht, ack.HistoricalScanStartHeight, scan.StartHeight) + require.Equal(ht, ack.HistoricalScanEndHeight, scan.EndHeight) + require.Equal(ht, scan.EndHeight, scan.CompletedHeight) + require.GreaterOrEqual(ht, scan.CompletedHeight, minCompletedHeight) + require.Empty(ht, scan.Error) + + return scan +} + +// testPkScriptNotifierHistoricalRegistrationRPC ensures a newly registered +// stream can replay historical confirmations and spends for multiple scripts. +func testPkScriptNotifierHistoricalRegistrationRPC(ht *lntest.HarnessTest) { + alice := ht.NewNodeWithCoins("Alice", nil) + bob := ht.NewNode("Bob", nil) + + addrA, scriptA, keyA := newPkScriptTestAddress(ht) + addrB, scriptB, keyB := newPkScriptTestAddress(ht) + startHeightAB := ht.CurrentHeight() + + txidA, outpointA, outputA, _ := fundAddress( + ht, alice, addrA, scriptA, 1_000_111, + ) + txidB, outpointB, outputB, _ := fundAddress( + ht, alice, addrB, scriptB, 1_000_222, + ) + + ht.MineBlocksAndAssertNumTxes(1, 2) + confirmBlockAB := ht.MineEmptyBlocks(1)[0] + confirmHeightAB := ht.CurrentHeight() + confirmBlockHashAB := confirmBlockAB.BlockHash() + + spendTxA := createSpendTx(ht, outpointA, outputA, keyA) + spendHashA, err := ht.SendRawTransaction(spendTxA, true) + require.NoError(ht, err) + ht.AssertTxInMempool(spendHashA) + + spendTxB := createSpendTx(ht, outpointB, outputB, keyB) + spendHashB, err := ht.SendRawTransaction(spendTxB, true) + require.NoError(ht, err) + ht.AssertTxInMempool(spendHashB) + + spendBlockAB := ht.MineBlocksAndAssertNumTxes(1, 2)[0] + spendHeightAB := ht.CurrentHeight() + spendBlockHashAB := spendBlockAB.BlockHash() + + stream := newRegisteredPkScriptStream(ht, bob) + defer stream.close() + + stream.send(pkScriptAddReq( + [][]byte{scriptA, scriptB}, []chainrpc.PkScriptEventType{ + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_CONFIRM, + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_SPEND, + }, 2, &startHeightAB, true, true, + )) + scanAckAB := stream.requireAck( + chainrpc.PkScriptMutationAction_PK_SCRIPT_MUTATION_ACTION_ADD, + ) + require.True(ht, scanAckAB.HistoricalScanQueued) + require.NotZero(ht, scanAckAB.HistoricalScanId) + require.Equal(ht, startHeightAB, scanAckAB.HistoricalScanStartHeight) + + regMap := pkScriptNotificationMap(ht, stream.requireNotifications(4)) + + assertPkScriptNotification( + ht, + regMap[fmt.Sprintf("%s:%d", wireOutpointKey(outpointA), + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_CONFIRM)], + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_CONFIRM, false, + confirmHeightAB, &confirmBlockHashAB, txidA, outpointA, 2, true, + ) + assertPkScriptNotification( + ht, + regMap[fmt.Sprintf("%s:%d", wireOutpointKey(outpointB), + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_CONFIRM)], + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_CONFIRM, false, + confirmHeightAB, &confirmBlockHashAB, txidB, outpointB, 2, true, + ) + assertPkScriptNotification( + ht, + regMap[fmt.Sprintf("%s:%d", wireOutpointKey(outpointA), + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_SPEND)], + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_SPEND, false, + spendHeightAB, &spendBlockHashAB, &spendHashA, outpointA, 0, true, + ) + assertPkScriptNotification( + ht, + regMap[fmt.Sprintf("%s:%d", wireOutpointKey(outpointB), + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_SPEND)], + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_SPEND, false, + spendHeightAB, &spendBlockHashAB, &spendHashB, outpointB, 0, true, + ) + + assertHistoricalScanComplete(ht, stream, scanAckAB, spendHeightAB) +} + +// testPkScriptNotifierHistoricalAddRPC ensures a registered stream can add +// multiple scripts later and receive historical replay plus scan completion. +func testPkScriptNotifierHistoricalAddRPC(ht *lntest.HarnessTest) { + alice, _, stream := newPkScriptNotifierRPCScenario(ht) + defer stream.close() + + addrC, scriptC, keyC := newPkScriptTestAddress(ht) + addrD, scriptD, _ := newPkScriptTestAddress(ht) + startHeightCD := ht.CurrentHeight() + + txidC, outpointC, outputC, _ := fundAddress( + ht, alice, addrC, scriptC, 1_000_333, + ) + txidD, outpointD, _, _ := fundAddress( + ht, alice, addrD, scriptD, 1_000_444, + ) + + ht.MineBlocksAndAssertNumTxes(1, 2) + confirmBlockCD := ht.MineEmptyBlocks(1)[0] + confirmHeightCD := ht.CurrentHeight() + confirmBlockHashCD := confirmBlockCD.BlockHash() + + spendTxC := createSpendTx(ht, outpointC, outputC, keyC) + spendHashC, err := ht.SendRawTransaction(spendTxC, true) + require.NoError(ht, err) + ht.AssertTxInMempool(spendHashC) + + // D is left unspent so the add-path replay covers multiple confirms and a + // historical spend in the same request. + spendBlockC := ht.MineBlocksAndAssertNumTxes(1, 1)[0] + spendHeightC := ht.CurrentHeight() + spendBlockHashC := spendBlockC.BlockHash() + + stream.send(pkScriptAddReq( + [][]byte{scriptC, scriptD}, nil, 2, &startHeightCD, true, + true, + )) + scanAckCD := stream.requireAck( + chainrpc.PkScriptMutationAction_PK_SCRIPT_MUTATION_ACTION_ADD, + ) + + addMap := pkScriptNotificationMap(ht, stream.requireNotifications(3)) + + assertPkScriptNotification( + ht, + addMap[fmt.Sprintf("%s:%d", wireOutpointKey(outpointC), + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_CONFIRM)], + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_CONFIRM, false, + confirmHeightCD, &confirmBlockHashCD, txidC, outpointC, 2, true, + ) + assertPkScriptNotification( + ht, + addMap[fmt.Sprintf("%s:%d", wireOutpointKey(outpointD), + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_CONFIRM)], + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_CONFIRM, false, + confirmHeightCD, &confirmBlockHashCD, txidD, outpointD, 2, true, + ) + assertPkScriptNotification( + ht, + addMap[fmt.Sprintf("%s:%d", wireOutpointKey(outpointC), + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_SPEND)], + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_SPEND, false, + spendHeightC, &spendBlockHashC, &spendHashC, outpointC, 0, true, + ) + + assertHistoricalScanComplete(ht, stream, scanAckCD, spendHeightC) +} + +// testPkScriptNotifierRemoveRPC ensures removing a watched script stops later +// spend notifications for outputs that were already tracked. +func testPkScriptNotifierRemoveRPC(ht *lntest.HarnessTest) { + alice, _, stream := newPkScriptNotifierRPCScenario(ht) + defer stream.close() + + addrE, scriptE, keyE := newPkScriptTestAddress(ht) + + stream.send(pkScriptAddReq( + [][]byte{scriptE}, nil, 2, nil, true, true, + )) + addAck := stream.requireAck( + chainrpc.PkScriptMutationAction_PK_SCRIPT_MUTATION_ACTION_ADD, + ) + require.False(ht, addAck.HistoricalScanQueued) + + txidE, outpointE, outputE, _ := fundAddress( + ht, alice, addrE, scriptE, 1_000_555, + ) + ht.MineBlocksAndAssertNumTxes(1, 1) + confirmBlockE := ht.MineEmptyBlocks(1)[0] + confirmHeightE := ht.CurrentHeight() + confirmBlockHashE := confirmBlockE.BlockHash() + + assertPkScriptNotification( + ht, stream.requireNotification(), + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_CONFIRM, false, + confirmHeightE, &confirmBlockHashE, txidE, outpointE, 2, true, + ) + + stream.send(pkScriptRemoveReq([][]byte{scriptE})) + stream.requireAck( + chainrpc.PkScriptMutationAction_PK_SCRIPT_MUTATION_ACTION_REMOVE, + ) + + spendTxE := createSpendTx(ht, outpointE, outputE, keyE) + _, err := ht.SendRawTransaction(spendTxE, true) + require.NoError(ht, err) + ht.MineBlocksAndAssertNumTxes(1, 1) + stream.assertNoNotification(2 * time.Second) +} + +// testPkScriptNotifierConfirmOnlyRPC ensures confirm-only subscriptions omit raw +// payloads and do not receive spend notifications. +func testPkScriptNotifierConfirmOnlyRPC(ht *lntest.HarnessTest) { + alice, _, confirmOnlyStream := newPkScriptNotifierRPCScenario(ht) + defer confirmOnlyStream.close() + + addrG, scriptG, keyG := newPkScriptTestAddress(ht) + confirmOnlyStream.send(pkScriptAddReq( + [][]byte{scriptG}, []chainrpc.PkScriptEventType{ + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_CONFIRM, + }, 1, nil, false, false, + )) + confirmOnlyStream.requireAck( + chainrpc.PkScriptMutationAction_PK_SCRIPT_MUTATION_ACTION_ADD, + ) + + txidG, outpointG, outputG, _ := fundAddress( + ht, alice, addrG, scriptG, 1_000_777, + ) + fundingBlockG := ht.MineBlocksAndAssertNumTxes(1, 1)[0] + fundingHeightG := ht.CurrentHeight() + fundingBlockHashG := fundingBlockG.BlockHash() + + assertPkScriptNotification( + ht, confirmOnlyStream.requireNotification(), + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_CONFIRM, false, + fundingHeightG, &fundingBlockHashG, txidG, outpointG, 1, false, + withPkScriptUTXO(outputG, fundingHeightG, &fundingBlockHashG), + withPkScriptTxIndex(1), withPkScriptUTXOTxIndex(1), + withPkScriptInputIndex(0), + ) + + spendTxG := createSpendTx(ht, outpointG, outputG, keyG) + _, err := ht.SendRawTransaction(spendTxG, true) + require.NoError(ht, err) + ht.MineBlocksAndAssertNumTxes(1, 1) + confirmOnlyStream.assertNoNotification(2 * time.Second) +} + +// testPkScriptNotifierPartialConfirmationRPC ensures partial confirmation +// updates are delivered before the final confirmation when requested. +func testPkScriptNotifierPartialConfirmationRPC(ht *lntest.HarnessTest) { + alice, _, updatesStream := newPkScriptNotifierRPCScenario(ht) + defer updatesStream.close() + + addrUpdates, scriptUpdates, _ := newPkScriptTestAddress(ht) + updatesStream.send(pkScriptAddReqWithConfUpdates( + [][]byte{scriptUpdates}, []chainrpc.PkScriptEventType{ + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_CONFIRM, + }, 3, nil, false, false, true, + )) + updatesAck := updatesStream.requireAck( + chainrpc.PkScriptMutationAction_PK_SCRIPT_MUTATION_ACTION_ADD, + ) + require.False(ht, updatesAck.HistoricalScanQueued) + + txidUpdates, outpointUpdates, outputUpdates, _ := fundAddress( + ht, alice, addrUpdates, scriptUpdates, 1_000_800, + ) + fundingBlockUpdates := ht.MineBlocksAndAssertNumTxes(1, 1)[0] + fundingHeightUpdates := ht.CurrentHeight() + fundingBlockHashUpdates := fundingBlockUpdates.BlockHash() + + updateOne := updatesStream.requireNotification() + assertPkScriptNotification( + ht, updateOne, + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_CONFIRMATION_UPDATE, + false, fundingHeightUpdates, &fundingBlockHashUpdates, + txidUpdates, outpointUpdates, 1, false, + withPkScriptUTXO( + outputUpdates, fundingHeightUpdates, + &fundingBlockHashUpdates, + ), + withPkScriptTxIndex(1), withPkScriptUTXOTxIndex(1), + ) + require.Equal(ht, uint32(3), updateOne.RequiredConfirmations) + + updateBlockTwo := ht.MineEmptyBlocks(1)[0] + updateHeightTwo := ht.CurrentHeight() + updateBlockHashTwo := updateBlockTwo.BlockHash() + + updateTwo := updatesStream.requireNotification() + assertPkScriptNotification( + ht, updateTwo, + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_CONFIRMATION_UPDATE, + false, updateHeightTwo, &updateBlockHashTwo, txidUpdates, + outpointUpdates, 2, false, + withPkScriptUTXO( + outputUpdates, fundingHeightUpdates, + &fundingBlockHashUpdates, + ), + withPkScriptTxIndex(1), withPkScriptUTXOTxIndex(1), + ) + require.Equal(ht, uint32(3), updateTwo.RequiredConfirmations) + + confirmBlockUpdates := ht.MineEmptyBlocks(1)[0] + confirmHeightUpdates := ht.CurrentHeight() + confirmBlockHashUpdates := confirmBlockUpdates.BlockHash() + + finalUpdateConfirm := updatesStream.requireNotification() + assertPkScriptNotification( + ht, finalUpdateConfirm, + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_CONFIRM, false, + confirmHeightUpdates, &confirmBlockHashUpdates, txidUpdates, + outpointUpdates, 3, false, + withPkScriptUTXO( + outputUpdates, fundingHeightUpdates, + &fundingBlockHashUpdates, + ), + withPkScriptTxIndex(1), withPkScriptUTXOTxIndex(1), + ) + require.Equal( + ht, uint32(3), finalUpdateConfirm.RequiredConfirmations, + ) +} + +// testPkScriptNotifierMultiStreamRPC ensures multiple streams can watch the same +// script with different event/raw-payload options, and re-adds are no-ops. +func testPkScriptNotifierMultiStreamRPC(ht *lntest.HarnessTest) { + alice, bob, stream := newPkScriptNotifierRPCScenario(ht) + defer stream.close() + + addrH, scriptH, keyH := newPkScriptTestAddress(ht) + startHeightH := ht.CurrentHeight() + 1 + + stream.send(pkScriptAddReq( + [][]byte{scriptH}, nil, 2, nil, true, true, + )) + firstAck := stream.requireAck( + chainrpc.PkScriptMutationAction_PK_SCRIPT_MUTATION_ACTION_ADD, + ) + require.False(ht, firstAck.HistoricalScanQueued) + require.Equal(ht, uint32(1), firstAck.NumAdded) + + stream.send(pkScriptAddReq( + [][]byte{scriptH}, nil, 2, &startHeightH, true, true, + )) + reAddAck := stream.requireAck( + chainrpc.PkScriptMutationAction_PK_SCRIPT_MUTATION_ACTION_ADD, + ) + require.False(ht, reAddAck.HistoricalScanQueued) + require.Zero(ht, reAddAck.NumAdded) + + confirmOnlyStream := newRegisteredPkScriptStream(ht, bob) + defer confirmOnlyStream.close() + + confirmOnlyStream.send(pkScriptAddReq( + [][]byte{scriptH}, []chainrpc.PkScriptEventType{ + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_CONFIRM, + }, 1, nil, false, false, + )) + confirmOnlyStream.requireAck( + chainrpc.PkScriptMutationAction_PK_SCRIPT_MUTATION_ACTION_ADD, + ) + + spendOnlyStream := newRegisteredPkScriptStream(ht, bob) + defer spendOnlyStream.close() + + spendOnlyStream.send(pkScriptAddReq( + [][]byte{scriptH}, []chainrpc.PkScriptEventType{ + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_SPEND, + }, 0, nil, false, false, + )) + spendOnlyStream.requireAck( + chainrpc.PkScriptMutationAction_PK_SCRIPT_MUTATION_ACTION_ADD, + ) + + txidH, outpointH, outputH, _ := fundAddress( + ht, alice, addrH, scriptH, 1_000_888, + ) + fundingBlockH := ht.MineBlocksAndAssertNumTxes(1, 1)[0] + fundingHeightH := ht.CurrentHeight() + fundingBlockHashH := fundingBlockH.BlockHash() + confirmBlockH := ht.MineEmptyBlocks(1)[0] + confirmHeightH := ht.CurrentHeight() + confirmBlockHashH := confirmBlockH.BlockHash() + + assertPkScriptNotification( + ht, confirmOnlyStream.requireNotification(), + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_CONFIRM, false, + fundingHeightH, &fundingBlockHashH, txidH, outpointH, 1, false, + withPkScriptUTXO(outputH, fundingHeightH, &fundingBlockHashH), + withPkScriptTxIndex(1), withPkScriptUTXOTxIndex(1), + withPkScriptInputIndex(0), + ) + assertPkScriptNotification( + ht, stream.requireNotification(), + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_CONFIRM, false, + confirmHeightH, &confirmBlockHashH, txidH, outpointH, 2, true, + withPkScriptUTXO(outputH, fundingHeightH, &fundingBlockHashH), + withPkScriptTxIndex(1), withPkScriptUTXOTxIndex(1), + withPkScriptInputIndex(0), + ) + spendOnlyStream.assertNoNotification(2 * time.Second) + + spendTxH := createSpendTx(ht, outpointH, outputH, keyH) + spendHashH, err := ht.SendRawTransaction(spendTxH, true) + require.NoError(ht, err) + ht.AssertTxInMempool(spendHashH) + + spendBlockH := ht.MineBlocksAndAssertNumTxes(1, 1)[0] + spendHeightH := ht.CurrentHeight() + spendBlockHashH := spendBlockH.BlockHash() + + assertPkScriptNotification( + ht, stream.requireNotification(), + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_SPEND, false, + spendHeightH, &spendBlockHashH, &spendHashH, outpointH, 0, true, + withPkScriptUTXO(outputH, fundingHeightH, &fundingBlockHashH), + withPkScriptTxIndex(1), withPkScriptUTXOTxIndex(1), + withPkScriptInputIndex(0), + ) + assertPkScriptNotification( + ht, spendOnlyStream.requireNotification(), + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_SPEND, false, + spendHeightH, &spendBlockHashH, &spendHashH, outpointH, 0, false, + withPkScriptUTXO(outputH, fundingHeightH, &fundingBlockHashH), + withPkScriptTxIndex(1), withPkScriptUTXOTxIndex(1), + withPkScriptInputIndex(0), + ) + confirmOnlyStream.assertNoNotification(2 * time.Second) +} + +// testPkScriptNotifierSkipHistoryRPC ensures skip-history mode ignores old +// outputs and spends while still tracking future receives and spends. +func testPkScriptNotifierSkipHistoryRPC(ht *lntest.HarnessTest) { + alice := ht.NewNodeWithCoins("Alice", nil) + bob := ht.NewNode("Bob", nil) + + addrI, scriptI, keyI := newPkScriptTestAddress(ht) + _, oldOutpointI, oldOutputI, _ := fundAddress( + ht, alice, addrI, scriptI, 1_000_999, + ) + ht.MineBlocksAndAssertNumTxes(1, 1) + + skipHistoryStream := newRegisteredPkScriptStream(ht, bob) + defer skipHistoryStream.close() + + skipHistoryStream.send(pkScriptAddReq( + [][]byte{scriptI}, []chainrpc.PkScriptEventType{ + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_CONFIRM, + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_SPEND, + }, 1, nil, false, false, + )) + skipHistoryStream.requireAck( + chainrpc.PkScriptMutationAction_PK_SCRIPT_MUTATION_ACTION_ADD, + ) + skipHistoryStream.assertNoNotification(2 * time.Second) + + oldSpendTxI := createSpendTx(ht, oldOutpointI, oldOutputI, keyI) + oldSpendHashI, err := ht.SendRawTransaction(oldSpendTxI, true) + require.NoError(ht, err) + ht.AssertTxInMempool(oldSpendHashI) + ht.MineBlocksAndAssertNumTxes(1, 1) + skipHistoryStream.assertNoNotification(2 * time.Second) + + txidI, outpointI, outputI, _ := fundAddress( + ht, alice, addrI, scriptI, 1_001_111, + ) + fundingBlockI := ht.MineBlocksAndAssertNumTxes(1, 1)[0] + fundingHeightI := ht.CurrentHeight() + fundingBlockHashI := fundingBlockI.BlockHash() + + assertPkScriptNotification( + ht, skipHistoryStream.requireNotification(), + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_CONFIRM, false, + fundingHeightI, &fundingBlockHashI, txidI, outpointI, 1, false, + withPkScriptUTXO(outputI, fundingHeightI, &fundingBlockHashI), + withPkScriptTxIndex(1), withPkScriptUTXOTxIndex(1), + withPkScriptInputIndex(0), + ) + + spendTxI := createSpendTx(ht, outpointI, outputI, keyI) + spendHashI, err := ht.SendRawTransaction(spendTxI, true) + require.NoError(ht, err) + ht.AssertTxInMempool(spendHashI) + + spendBlockI := ht.MineBlocksAndAssertNumTxes(1, 1)[0] + spendHeightI := ht.CurrentHeight() + spendBlockHashI := spendBlockI.BlockHash() + + assertPkScriptNotification( + ht, skipHistoryStream.requireNotification(), + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_SPEND, false, + spendHeightI, &spendBlockHashI, &spendHashI, outpointI, 0, + false, withPkScriptUTXO(outputI, fundingHeightI, &fundingBlockHashI), + withPkScriptTxIndex(1), withPkScriptUTXOTxIndex(1), + withPkScriptInputIndex(0), + ) + + addrJ, scriptJ, _ := newPkScriptTestAddress(ht) + _, _, _, _ = fundAddress(ht, alice, addrJ, scriptJ, 1_001_222) + ht.MineBlocksAndAssertNumTxes(1, 1) + + skipHistoryStream.send(pkScriptAddReq( + [][]byte{scriptJ}, nil, 0, nil, false, false, + )) + skipHistoryStream.requireAck( + chainrpc.PkScriptMutationAction_PK_SCRIPT_MUTATION_ACTION_ADD, + ) + skipHistoryStream.assertNoNotification(2 * time.Second) + + txidJ, outpointJ, outputJ, _ := fundAddress( + ht, alice, addrJ, scriptJ, 1_001_333, + ) + fundingBlockJ := ht.MineBlocksAndAssertNumTxes(1, 1)[0] + fundingHeightJ := ht.CurrentHeight() + fundingBlockHashJ := fundingBlockJ.BlockHash() + + assertPkScriptNotification( + ht, skipHistoryStream.requireNotification(), + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_CONFIRM, false, + fundingHeightJ, &fundingBlockHashJ, txidJ, outpointJ, 1, false, + withPkScriptUTXO(outputJ, fundingHeightJ, &fundingBlockHashJ), + withPkScriptTxIndex(1), withPkScriptUTXOTxIndex(1), + withPkScriptInputIndex(0), + ) +} + +// testPkScriptNotifierValidationRPC ensures invalid request sequences and event +// options fail the stream with invalid argument errors. +func testPkScriptNotifierValidationRPC(ht *lntest.HarnessTest) { + bob := ht.NewNode("Bob", nil) + _, script, _ := newPkScriptTestAddress(ht) + + requirePkScriptStreamError( + ht, bob, codes.InvalidArgument, + pkScriptAddReq( + [][]byte{script}, nil, 0, nil, false, false, + ), + ) + + validRegister := pkScriptRegisterReq() + requirePkScriptStreamError( + ht, bob, codes.InvalidArgument, validRegister, validRegister, + ) + + requirePkScriptStreamError( + ht, bob, codes.InvalidArgument, + pkScriptRegisterReq(), + pkScriptAddReq( + [][]byte{script}, []chainrpc.PkScriptEventType{ + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_CONFIRMATION_UPDATE, + }, 1, nil, false, false, + ), + ) +} + +// testPkScriptNotifierReorgRPC ensures confirmations and spends are invalidated +// and redelivered across a block reorg. +func testPkScriptNotifierReorgRPC(ht *lntest.HarnessTest) { + alice, _, stream := newPkScriptNotifierRPCScenario(ht) + defer stream.close() + + addrF, scriptF, keyF := newPkScriptTestAddress(ht) + + stream.send(pkScriptAddReq( + [][]byte{scriptF}, nil, 2, nil, true, true, + )) + stream.requireAck( + chainrpc.PkScriptMutationAction_PK_SCRIPT_MUTATION_ACTION_ADD, + ) + + txidF, outpointF, outputF, _ := fundAddress( + ht, alice, addrF, scriptF, 1_000_666, + ) + ht.MineBlocksAndAssertNumTxes(1, 1) + confirmBlockF := ht.MineEmptyBlocks(1)[0] + confirmHeightF := ht.CurrentHeight() + confirmBlockHashF := confirmBlockF.BlockHash() + + assertPkScriptNotification( + ht, stream.requireNotification(), + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_CONFIRM, false, + confirmHeightF, &confirmBlockHashF, txidF, outpointF, 2, true, + ) + + spendTxF := createSpendTx(ht, outpointF, outputF, keyF) + spendHashF, err := ht.SendRawTransaction(spendTxF, true) + require.NoError(ht, err) + ht.AssertTxInMempool(spendHashF) + + spendBlockF := ht.MineBlocksAndAssertNumTxes(1, 1)[0] + spendHeightF := ht.CurrentHeight() + spendBlockHashF := spendBlockF.BlockHash() + + assertPkScriptNotification( + ht, stream.requireNotification(), + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_SPEND, false, + spendHeightF, &spendBlockHashF, &spendHashF, outpointF, 0, true, + ) + + require.NoError(ht, ht.Miner().InvalidateBlock(&spendBlockHashF)) + require.NoError(ht, ht.Miner().InvalidateBlock(&confirmBlockHashF)) + + // Mine a distinct first replacement block. MineEmptyBlocks uses a + // deterministic empty block template for btcd, which can recreate the + // invalidated empty confirmation block and be rejected as already known. + fillerAddr, _, _ := newPkScriptTestAddress(ht) + fillerScript, err := txscript.PayToAddrScript(fillerAddr) + require.NoError(ht, err) + fillerTxID := ht.SendOutputsWithoutChange( + []*wire.TxOut{wire.NewTxOut(10_000, fillerScript)}, 10, + ) + fillerTx := ht.AssertTxInMempool(*fillerTxID) + + newBlocks := []*wire.MsgBlock{ht.MineBlockWithTx(fillerTx)} + newBlocks = append(newBlocks, ht.MineEmptyBlocks(1)...) + reconfirmBlockHashF := newBlocks[0].BlockHash() + + reorgNtfns := stream.requireNotifications(3) + var ( + seenSpendReorg bool + seenConfirmReorg bool + seenReconfirm bool + ) + for _, ntfn := range reorgNtfns { + switch { + case ntfn.EventType == + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_SPEND && + ntfn.Disconnected: + + assertPkScriptNotification( + ht, ntfn, + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_SPEND, + true, spendHeightF, &spendBlockHashF, &spendHashF, + outpointF, 0, true, + ) + seenSpendReorg = true + + case ntfn.EventType == + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_CONFIRM && + ntfn.Disconnected: + + assertPkScriptNotification( + ht, ntfn, + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_CONFIRM, + true, confirmHeightF, &confirmBlockHashF, txidF, + outpointF, 2, true, + ) + seenConfirmReorg = true + + case ntfn.EventType == + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_CONFIRM && + !ntfn.Disconnected: + + assertPkScriptNotification( + ht, ntfn, + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_CONFIRM, + false, confirmHeightF, &reconfirmBlockHashF, txidF, + outpointF, 2, true, + ) + seenReconfirm = true + } + } + require.True(ht, seenSpendReorg) + require.True(ht, seenConfirmReorg) + require.True(ht, seenReconfirm) + + ht.AssertTxInMempool(spendTxF.TxHash()) + respendBlockF := ht.MineBlocksAndAssertNumTxes(1, 1)[0] + respendHeightF := ht.CurrentHeight() + respendBlockHashF := respendBlockF.BlockHash() + + assertPkScriptNotification( + ht, stream.requireNotification(), + chainrpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_SPEND, false, + respendHeightF, &respendBlockHashF, &spendHashF, outpointF, 0, + true, + ) +} diff --git a/lnrpc/chainrpc/chain_server.go b/lnrpc/chainrpc/chain_server.go index e42a6843c01..1ac6bc8dc63 100644 --- a/lnrpc/chainrpc/chain_server.go +++ b/lnrpc/chainrpc/chain_server.go @@ -7,9 +7,11 @@ import ( "bytes" "context" "errors" + "io" "os" "path/filepath" "sync" + "time" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" @@ -29,6 +31,15 @@ const ( // SubServerConfigDispatcher instance recognize this as the name of the // config file that we need. subServerName = "ChainRPC" + + // pkScriptRPCSendQueueSize is the maximum number of pkScript events that + // may wait for the stream writer before the RPC applies slow-client + // backpressure. + pkScriptRPCSendQueueSize = 100 + + // pkScriptRPCSendTimeout is the maximum time a pkScript RPC send may wait + // for a client to accept response events. + pkScriptRPCSendTimeout = 30 * time.Second ) var ( @@ -71,6 +82,10 @@ var ( Entity: "onchain", Action: "read", }}, + "/chainrpc.ChainNotifier/RegisterPkScriptNtfn": {{ + Entity: "onchain", + Action: "read", + }}, } // DefaultChainNotifierMacFilename is the default name of the chain @@ -589,6 +604,638 @@ func (s *Server) RegisterSpendNtfn(in *SpendRequest, } } +// rpcPkScriptEventTypesToNotifier converts RPC event type filters into +// chainntnfs pkScript event flags. +func rpcPkScriptEventTypesToNotifier(eventTypes []PkScriptEventType) ( + chainntnfs.PkScriptEventType, error) { + + var ntfnType chainntnfs.PkScriptEventType + for _, eventType := range eventTypes { + switch eventType { + case PkScriptEventType_PK_SCRIPT_EVENT_TYPE_CONFIRM: + ntfnType |= chainntnfs.PkScriptEventConfirm + + case PkScriptEventType_PK_SCRIPT_EVENT_TYPE_SPEND: + ntfnType |= chainntnfs.PkScriptEventSpend + + case PkScriptEventType_PK_SCRIPT_EVENT_TYPE_CONFIRMATION_UPDATE: + return 0, status.Error( + codes.InvalidArgument, + "confirmation updates are requested with "+ + "include_confirmation_updates", + ) + + default: + return 0, status.Errorf( + codes.InvalidArgument, + "unknown pkScript event type: %v", eventType, + ) + } + } + + return ntfnType, nil +} + +// rpcAddPkScriptOptions converts an RPC add request into notifier options. +func rpcAddPkScriptOptions(add *AddPkScriptRequest) ( + []chainntnfs.NotifierOption, error) { + + if add == nil { + return nil, status.Error( + codes.InvalidArgument, "add request must be set", + ) + } + + var opts []chainntnfs.NotifierOption + + if len(add.Events) > 0 { + events, err := rpcPkScriptEventTypesToNotifier(add.Events) + if err != nil { + return nil, err + } + opts = append(opts, chainntnfs.WithEvents(events)) + } + if add.NumConfs != 0 { + opts = append(opts, chainntnfs.WithNumConfs(add.NumConfs)) + } + if add.IncludeBlock { + opts = append(opts, chainntnfs.WithIncludeBlock()) + } + if add.IncludeTx { + opts = append(opts, chainntnfs.WithIncludeTx()) + } + if add.IncludeConfirmationUpdates { + opts = append(opts, chainntnfs.WithIncludeConfirmationUpdates()) + } + scanReq := add.HistoricalScan + historicalScan, ok := scanReq.(*AddPkScriptRequest_HistoricalScanFrom) + if ok { + opts = append( + opts, chainntnfs.WithHistoricalScanFrom( + historicalScan.HistoricalScanFrom, + ), + ) + } + + return opts, nil +} + +// rpcPkScriptNotification converts a chain notifier pkScript notification into +// its RPC representation. +func rpcPkScriptNotification(ntfn *chainntnfs.PkScriptNotification) ( + *PkScriptNotification, error) { + + if ntfn == nil { + return nil, nil + } + + rpcNtfn := &PkScriptNotification{ + Height: ntfn.Height, + TxIndex: ntfn.TxIndex, + InputIndex: ntfn.InputIndex, + NumConfirmations: ntfn.NumConfirmations, + RequiredConfirmations: ntfn.RequiredConfs, + Disconnected: ntfn.Disconnected, + } + + switch ntfn.Type { + case chainntnfs.PkScriptNotificationConfirm: + rpcNtfn.EventType = PkScriptEventType_PK_SCRIPT_EVENT_TYPE_CONFIRM + + case chainntnfs.PkScriptNotificationSpend: + rpcNtfn.EventType = PkScriptEventType_PK_SCRIPT_EVENT_TYPE_SPEND + + case chainntnfs.PkScriptNotificationConfirmUpdate: + rpcNtfn.EventType = + PkScriptEventType_PK_SCRIPT_EVENT_TYPE_CONFIRMATION_UPDATE + + default: + return nil, status.Errorf( + codes.Internal, "unknown pkScript notification type: %v", + ntfn.Type, + ) + } + + if ntfn.BlockHash != nil { + rpcNtfn.BlockHash = ntfn.BlockHash[:] + } + if ntfn.TxHash != nil { + rpcNtfn.TxHash = ntfn.TxHash[:] + } + if ntfn.UTXO != nil { + rpcNtfn.Utxo = &PkScriptUtxo{ + Outpoint: &Outpoint{ + Hash: ntfn.UTXO.OutPoint.Hash[:], + Index: ntfn.UTXO.OutPoint.Index, + }, + Value: int64(ntfn.UTXO.Value), + PkScript: ntfn.UTXO.PkScript, + BlockHeight: ntfn.UTXO.BlockHeight, + TxIndex: ntfn.UTXO.TxIndex, + } + if ntfn.UTXO.BlockHash != nil { + rpcNtfn.Utxo.BlockHash = ntfn.UTXO.BlockHash[:] + } + } + + if ntfn.Tx != nil { + var txBuf bytes.Buffer + err := ntfn.Tx.Serialize(&txBuf) + if err != nil { + return nil, err + } + rpcNtfn.RawTx = txBuf.Bytes() + } + + if ntfn.Block != nil { + var blockBuf bytes.Buffer + err := ntfn.Block.Serialize(&blockBuf) + if err != nil { + return nil, err + } + rpcNtfn.RawBlock = blockBuf.Bytes() + } + + return rpcNtfn, nil +} + +// rpcPkScriptHistoricalScan converts historical scan lifecycle data into its +// RPC representation. +func rpcPkScriptHistoricalScan( + scan *chainntnfs.PkScriptHistoricalScan) *PkScriptHistoricalScan { + + if scan == nil { + return nil + } + + return &PkScriptHistoricalScan{ + ScanId: scan.ScanID, + StartHeight: scan.StartHeight, + EndHeight: scan.EndHeight, + CompletedHeight: scan.CompletedHeight, + Error: scan.Error, + } +} + +// rpcPkScriptAck converts mutation result metadata into an RPC acknowledgement. +func rpcPkScriptAck(action PkScriptMutationAction, + result *chainntnfs.PkScriptAddResult) *PkScriptMutationAck { + + ack := &PkScriptMutationAck{ + Action: action, + } + if result == nil { + return ack + } + + ack.NumAdded = result.NumAdded + ack.HistoricalScanQueued = result.HistoricalScanQueued + ack.HistoricalScanId = result.HistoricalScanID + ack.HistoricalScanStartHeight = result.HistoricalScanStartHeight + ack.HistoricalScanEndHeight = result.HistoricalScanEndHeight + + return ack +} + +type pkScriptRPCSendRequest struct { + event *PkScriptEvent + result chan error +} + +type pkScriptRPCEventSender struct { + stream ChainNotifier_RegisterPkScriptNtfnServer + quit <-chan struct{} + done chan struct{} + stopOnce sync.Once + + queue chan *pkScriptRPCSendRequest + err chan error + + timeout time.Duration +} + +// newPkScriptRPCEventSender creates a serialized sender for pkScript RPC stream +// events. +func newPkScriptRPCEventSender( + stream ChainNotifier_RegisterPkScriptNtfnServer, + quit <-chan struct{}, timeout time.Duration) *pkScriptRPCEventSender { + + sender := &pkScriptRPCEventSender{ + stream: stream, + quit: quit, + done: make(chan struct{}), + queue: make(chan *pkScriptRPCSendRequest, pkScriptRPCSendQueueSize), + err: make(chan error, 1), + timeout: timeout, + } + + go sender.run() + + return sender +} + +// stop terminates the RPC event sender. +func (s *pkScriptRPCEventSender) stop() { + s.stopOnce.Do(func() { + close(s.done) + }) +} + +// reportErr stores the first RPC send error observed by the sender. +func (s *pkScriptRPCEventSender) reportErr(err error) { + if err == nil { + return + } + + select { + case s.err <- err: + default: + } +} + +// run serializes queued stream.Send calls. +func (s *pkScriptRPCEventSender) run() { + for { + select { + case req := <-s.queue: + select { + case <-s.done: + return + default: + } + + err := s.stream.Send(req.event) + s.reportErr(err) + + select { + case req.result <- err: + case <-s.done: + return + } + + if err != nil { + return + } + + case <-s.done: + return + } + } +} + +// send queues an event and waits for it to be sent or timed out. +func (s *pkScriptRPCEventSender) send(event *PkScriptEvent) error { + req := &pkScriptRPCSendRequest{ + event: event, + result: make(chan error, 1), + } + + timer := time.NewTimer(s.timeout) + defer timer.Stop() + + select { + case s.queue <- req: + + case err := <-s.err: + return err + + case <-timer.C: + return status.Error( + codes.ResourceExhausted, + "pkScript notification client is not reading responses", + ) + + case <-s.stream.Context().Done(): + return s.stream.Context().Err() + + case <-s.quit: + return ErrChainNotifierServerShuttingDown + } + + select { + case err := <-req.result: + return err + + case err := <-s.err: + return err + + case <-timer.C: + return status.Error( + codes.ResourceExhausted, + "pkScript notification client is not reading responses", + ) + + case <-s.stream.Context().Done(): + return s.stream.Context().Err() + + case <-s.quit: + return ErrChainNotifierServerShuttingDown + } +} + +// pkScriptNotificationStreamClosedErr maps the notifier-side terminal reason for +// a closed pkScript notification stream into the RPC error returned to the +// client. +func pkScriptNotificationStreamClosedErr( + reg *chainntnfs.PkScriptNotificationRegistration) error { + + if reg != nil && reg.Err != nil { + err := reg.Err() + if errors.Is(err, chainntnfs.ErrPkScriptNotificationQueueFull) { + return status.Error( + codes.ResourceExhausted, + "pkScript notification client is not reading responses", + ) + } + if err != nil && !errors.Is(err, chainntnfs.ErrTxNotifierExiting) { + return err + } + } + + return chainntnfs.ErrChainNotifierShuttingDown +} + +// RegisterPkScriptNtfn is a bidirectional streaming RPC that registers a +// pkScript notification stream and allows the caller to add/remove pkScripts on +// the fly over the same gRPC stream. +// +// NOTE: This is part of the chainrpc.ChainNotifierServer interface. +func (s *Server) RegisterPkScriptNtfn( + stream ChainNotifier_RegisterPkScriptNtfnServer) error { + + if !s.cfg.ChainNotifier.Started() { + return ErrChainNotifierServerNotActive + } + + firstReq, err := stream.Recv() + if err != nil { + if errors.Is(err, io.EOF) { + return status.Error( + codes.InvalidArgument, + "first pkScript request must be a register action", + ) + } + + return err + } + + registerReq := firstReq.GetRegister() + if registerReq == nil { + return status.Error( + codes.InvalidArgument, + "first pkScript request must be a register action", + ) + } + + reg, err := s.cfg.PkScriptNotifier.RegisterPkScriptNotifier() + if err != nil { + return err + } + defer reg.Cancel() + + eventSender := newPkScriptRPCEventSender( + stream, s.quit, pkScriptRPCSendTimeout, + ) + defer eventSender.stop() + + err = eventSender.send(&PkScriptEvent{ + Event: &PkScriptEvent_Ack{ + Ack: rpcPkScriptRegisterAck(), + }, + }) + if err != nil { + return err + } + + ackChan := make(chan *PkScriptMutationAck, 10) + errChan := make(chan error, 1) + + go s.recvPkScriptMutations(stream, reg, ackChan, errChan) + + toRPCScan := rpcPkScriptHistoricalScan + scanComplete := + chainntnfs.PkScriptNotificationHistoricalScanComplete + for { + select { + case ntfn, ok := <-reg.Notifications: + if !ok { + return pkScriptNotificationStreamClosedErr(reg) + } + + if ntfn.Type == scanComplete { + + scan := toRPCScan(ntfn.HistoricalScan) + err := eventSender.send(&PkScriptEvent{ + Event: &PkScriptEvent_HistoricalScan{ + HistoricalScan: scan, + }, + }) + if err != nil { + return err + } + + continue + } + + rpcNtfn, err := rpcPkScriptNotification(ntfn) + if err != nil { + return err + } + + err = eventSender.send(&PkScriptEvent{ + Event: &PkScriptEvent_Notification{ + Notification: rpcNtfn, + }, + }) + if err != nil { + return err + } + + case ack := <-ackChan: + err := eventSender.send(&PkScriptEvent{ + Event: &PkScriptEvent_Ack{Ack: ack}, + }) + if err != nil { + return err + } + + case err := <-errChan: + return err + + case <-stream.Context().Done(): + if errors.Is(stream.Context().Err(), context.Canceled) { + return nil + } + return stream.Context().Err() + + case <-s.quit: + return ErrChainNotifierServerShuttingDown + } + } +} + +// recvPkScriptMutations receives add/remove requests from a pkScript stream and +// forwards mutation acknowledgments or terminal errors to the main send loop. +func (s *Server) recvPkScriptMutations( + stream ChainNotifier_RegisterPkScriptNtfnServer, + reg *chainntnfs.PkScriptNotificationRegistration, + ackChan chan<- *PkScriptMutationAck, + errChan chan<- error) { + + for { + req, err := stream.Recv() + if err != nil { + if !errors.Is(err, io.EOF) { + sendPkScriptStreamErr(errChan, err) + } + + return + } + + switch update := req.Request.(type) { + case *PkScriptRequest_Register: + err := duplicatePkScriptRegisterErr() + sendPkScriptStreamErr(errChan, err) + + return + + case *PkScriptRequest_Add: + result, err := addPkScriptsFromRPC(reg, update.Add) + if err != nil { + sendPkScriptStreamErr(errChan, err) + + return + } + + if !sendPkScriptAddAck( + stream.Context(), s.quit, + ackChan, result, + ) { + return + } + + case *PkScriptRequest_Remove: + err := removePkScriptsFromRPC(reg, update.Remove) + if err != nil { + sendPkScriptStreamErr(errChan, err) + + return + } + + if !sendPkScriptRemoveAck( + stream.Context(), s.quit, ackChan, + ) { + return + } + + default: + err := invalidPkScriptRequestErr() + sendPkScriptStreamErr(errChan, err) + + return + } + } +} + +// sendPkScriptStreamErr forwards a terminal stream error without blocking if +// another terminal error has already been reported. +func sendPkScriptStreamErr(errChan chan<- error, err error) { + select { + case errChan <- err: + default: + } +} + +// addPkScriptsFromRPC applies an RPC add mutation to the notifier. +func addPkScriptsFromRPC(reg *chainntnfs.PkScriptNotificationRegistration, + add *AddPkScriptRequest) (*chainntnfs.PkScriptAddResult, error) { + + opts, err := rpcAddPkScriptOptions(add) + if err != nil { + return nil, err + } + + return reg.AddPkScripts(add.PkScripts, opts...) +} + +// removePkScriptsFromRPC applies an RPC remove mutation to the notifier. +func removePkScriptsFromRPC(reg *chainntnfs.PkScriptNotificationRegistration, + remove *RemovePkScriptRequest) error { + + if remove == nil { + return status.Error( + codes.InvalidArgument, "remove request must be set", + ) + } + + return reg.RemovePkScripts(remove.PkScripts) +} + +func rpcPkScriptRegisterAck() *PkScriptMutationAck { + return rpcPkScriptAck( + PkScriptMutationAction_PK_SCRIPT_MUTATION_ACTION_REGISTER, + nil, + ) +} + +func duplicatePkScriptRegisterErr() error { + return status.Error( + codes.InvalidArgument, + "pkScript register action may only be sent once", + ) +} + +func invalidPkScriptRequestErr() error { + return status.Error( + codes.InvalidArgument, + "pkScript request must contain register, add, or remove", + ) +} + +func sendPkScriptAddAck( + ctx context.Context, quit <-chan struct{}, + ackChan chan<- *PkScriptMutationAck, + result *chainntnfs.PkScriptAddResult, +) bool { + + return sendPkScriptMutationAck( + ctx, quit, ackChan, + PkScriptMutationAction_PK_SCRIPT_MUTATION_ACTION_ADD, + result, + ) +} + +func sendPkScriptRemoveAck( + ctx context.Context, quit <-chan struct{}, + ackChan chan<- *PkScriptMutationAck, +) bool { + + return sendPkScriptMutationAck( + ctx, quit, ackChan, + PkScriptMutationAction_PK_SCRIPT_MUTATION_ACTION_REMOVE, + nil, + ) +} + +// sendPkScriptMutationAck forwards a mutation acknowledgment unless the stream +// or server has exited. +func sendPkScriptMutationAck( + ctx context.Context, quit <-chan struct{}, + ackChan chan<- *PkScriptMutationAck, + action PkScriptMutationAction, + result *chainntnfs.PkScriptAddResult, +) bool { + + select { + case ackChan <- rpcPkScriptAck(action, result): + return true + case <-ctx.Done(): + return false + case <-quit: + return false + } +} + // RegisterBlockEpochNtfn is a synchronous response-streaming RPC that registers // an intent for a client to be notified of blocks in the chain. The stream will // return a hash and height tuple of a block for each new/stale block in the diff --git a/lnrpc/chainrpc/chain_server_test.go b/lnrpc/chainrpc/chain_server_test.go new file mode 100644 index 00000000000..e6065745097 --- /dev/null +++ b/lnrpc/chainrpc/chain_server_test.go @@ -0,0 +1,228 @@ +//go:build chainrpc +// +build chainrpc + +package chainrpc + +import ( + "context" + "io" + "testing" + "time" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +// TestRPCPkScriptNotificationIndexes ensures pkScript notification indexes and +// partial confirmation fields are preserved when converted to RPC messages. +func TestRPCPkScriptNotificationIndexes(t *testing.T) { + var ( + fundingHash chainhash.Hash + spendHash chainhash.Hash + blockHash chainhash.Hash + ) + fundingHash[0] = 1 + spendHash[0] = 2 + blockHash[0] = 3 + + ntfn := &chainntnfs.PkScriptNotification{ + Type: chainntnfs.PkScriptNotificationSpend, + Height: 42, + BlockHash: &blockHash, + TxHash: &spendHash, + TxIndex: 2, + InputIndex: 1, + UTXO: &chainntnfs.PkScriptUTXO{ + OutPoint: wire.OutPoint{ + Hash: fundingHash, + Index: 7, + }, + Value: btcutil.Amount(1000), + PkScript: []byte{0x51}, + BlockHeight: 41, + BlockHash: &blockHash, + TxIndex: 3, + }, + } + + rpcNtfn, err := rpcPkScriptNotification(ntfn) + require.NoError(t, err) + require.Equal(t, uint32(2), rpcNtfn.TxIndex) + require.Equal(t, uint32(1), rpcNtfn.InputIndex) + require.Equal(t, uint32(3), rpcNtfn.Utxo.TxIndex) + require.Equal(t, blockHash[:], rpcNtfn.Utxo.BlockHash) + + updateNtfn := &chainntnfs.PkScriptNotification{ + Type: chainntnfs.PkScriptNotificationConfirmUpdate, + Height: 43, + BlockHash: &blockHash, + TxHash: &fundingHash, + NumConfirmations: 2, + RequiredConfs: 3, + } + + rpcUpdate, err := rpcPkScriptNotification(updateNtfn) + require.NoError(t, err) + require.Equal( + t, PkScriptEventType_PK_SCRIPT_EVENT_TYPE_CONFIRMATION_UPDATE, + rpcUpdate.EventType, + ) + require.Equal(t, uint32(2), rpcUpdate.NumConfirmations) + require.Equal(t, uint32(3), rpcUpdate.RequiredConfirmations) +} + +// TestRPCPkScriptHistoricalScan ensures historical scan lifecycle data is +// copied into RPC scan events and mutation acknowledgements. +func TestRPCPkScriptHistoricalScan(t *testing.T) { + scan := rpcPkScriptHistoricalScan(&chainntnfs.PkScriptHistoricalScan{ + ScanID: 9, + StartHeight: 10, + EndHeight: 12, + CompletedHeight: 11, + Error: "scan failed", + }) + + require.Equal(t, uint64(9), scan.ScanId) + require.Equal(t, uint32(10), scan.StartHeight) + require.Equal(t, uint32(12), scan.EndHeight) + require.Equal(t, uint32(11), scan.CompletedHeight) + require.Equal(t, "scan failed", scan.Error) + + ack := rpcPkScriptAck( + PkScriptMutationAction_PK_SCRIPT_MUTATION_ACTION_ADD, + &chainntnfs.PkScriptAddResult{ + NumAdded: 2, + HistoricalScanQueued: true, + HistoricalScanID: 9, + HistoricalScanStartHeight: 10, + HistoricalScanEndHeight: 12, + }, + ) + require.Equal(t, uint32(2), ack.NumAdded) + require.True(t, ack.HistoricalScanQueued) + require.Equal(t, uint64(9), ack.HistoricalScanId) + require.Equal(t, uint32(10), ack.HistoricalScanStartHeight) + require.Equal(t, uint32(12), ack.HistoricalScanEndHeight) +} + +type blockingPkScriptStream struct { + contextFn func() context.Context + sendStarted chan struct{} + releaseSend chan struct{} +} + +// Send blocks until releaseSend is closed to simulate a slow RPC client. +func (b *blockingPkScriptStream) Send(*PkScriptEvent) error { + select { + case b.sendStarted <- struct{}{}: + default: + } + + <-b.releaseSend + + return nil +} + +// Recv satisfies the pkScript stream interface. +func (b *blockingPkScriptStream) Recv() (*PkScriptRequest, error) { + return nil, io.EOF +} + +// SetHeader satisfies the grpc.ServerStream interface. +func (b *blockingPkScriptStream) SetHeader(metadata.MD) error { + return nil +} + +// SendHeader satisfies the grpc.ServerStream interface. +func (b *blockingPkScriptStream) SendHeader(metadata.MD) error { + return nil +} + +// SetTrailer satisfies the grpc.ServerStream interface. +func (b *blockingPkScriptStream) SetTrailer(metadata.MD) { +} + +// Context returns the stream context. +func (b *blockingPkScriptStream) Context() context.Context { + return b.contextFn() +} + +// SendMsg satisfies the grpc.ServerStream interface. +func (b *blockingPkScriptStream) SendMsg(interface{}) error { + return nil +} + +// RecvMsg satisfies the grpc.ServerStream interface. +func (b *blockingPkScriptStream) RecvMsg(interface{}) error { + return io.EOF +} + +// TestPkScriptRPCEventSenderTimeout ensures slow RPC stream sends fail with a +// resource exhaustion error instead of blocking indefinitely. +func TestPkScriptRPCEventSenderTimeout(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + stream := &blockingPkScriptStream{ + contextFn: func() context.Context { return ctx }, + sendStarted: make(chan struct{}, 1), + releaseSend: make(chan struct{}), + } + sender := newPkScriptRPCEventSender( + stream, make(chan struct{}), 25*time.Millisecond, + ) + defer sender.stop() + defer close(stream.releaseSend) + + errChan := make(chan error, 1) + go func() { + errChan <- sender.send(&PkScriptEvent{}) + }() + + select { + case <-stream.sendStarted: + + case <-time.After(time.Second): + t.Fatal("stream send did not start") + } + + select { + case err := <-errChan: + require.Error(t, err) + rpcStatus, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, codes.ResourceExhausted, rpcStatus.Code()) + + case <-time.After(time.Second): + t.Fatal("send did not time out") + } +} + +// TestPkScriptNotificationStreamClosedErr ensures notifier-side slow-consumer +// cancellation is reported to RPC clients as resource exhaustion. +func TestPkScriptNotificationStreamClosedErr(t *testing.T) { + t.Parallel() + + reg := &chainntnfs.PkScriptNotificationRegistration{ + Err: func() error { + return chainntnfs.ErrPkScriptNotificationQueueFull + }, + } + + err := pkScriptNotificationStreamClosedErr(reg) + rpcStatus, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, codes.ResourceExhausted, rpcStatus.Code()) + + reg.Err = func() error { + return chainntnfs.ErrTxNotifierExiting + } + err = pkScriptNotificationStreamClosedErr(reg) + require.ErrorIs(t, err, chainntnfs.ErrChainNotifierShuttingDown) +} diff --git a/lnrpc/chainrpc/chainnotifier.pb.go b/lnrpc/chainrpc/chainnotifier.pb.go index 4fe0938af18..25a361f917f 100644 --- a/lnrpc/chainrpc/chainnotifier.pb.go +++ b/lnrpc/chainrpc/chainnotifier.pb.go @@ -21,6 +21,104 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +type PkScriptEventType int32 + +const ( + PkScriptEventType_PK_SCRIPT_EVENT_TYPE_CONFIRM PkScriptEventType = 0 + PkScriptEventType_PK_SCRIPT_EVENT_TYPE_SPEND PkScriptEventType = 1 + PkScriptEventType_PK_SCRIPT_EVENT_TYPE_CONFIRMATION_UPDATE PkScriptEventType = 2 +) + +// Enum value maps for PkScriptEventType. +var ( + PkScriptEventType_name = map[int32]string{ + 0: "PK_SCRIPT_EVENT_TYPE_CONFIRM", + 1: "PK_SCRIPT_EVENT_TYPE_SPEND", + 2: "PK_SCRIPT_EVENT_TYPE_CONFIRMATION_UPDATE", + } + PkScriptEventType_value = map[string]int32{ + "PK_SCRIPT_EVENT_TYPE_CONFIRM": 0, + "PK_SCRIPT_EVENT_TYPE_SPEND": 1, + "PK_SCRIPT_EVENT_TYPE_CONFIRMATION_UPDATE": 2, + } +) + +func (x PkScriptEventType) Enum() *PkScriptEventType { + p := new(PkScriptEventType) + *p = x + return p +} + +func (x PkScriptEventType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (PkScriptEventType) Descriptor() protoreflect.EnumDescriptor { + return file_chainrpc_chainnotifier_proto_enumTypes[0].Descriptor() +} + +func (PkScriptEventType) Type() protoreflect.EnumType { + return &file_chainrpc_chainnotifier_proto_enumTypes[0] +} + +func (x PkScriptEventType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use PkScriptEventType.Descriptor instead. +func (PkScriptEventType) EnumDescriptor() ([]byte, []int) { + return file_chainrpc_chainnotifier_proto_rawDescGZIP(), []int{0} +} + +type PkScriptMutationAction int32 + +const ( + PkScriptMutationAction_PK_SCRIPT_MUTATION_ACTION_REGISTER PkScriptMutationAction = 0 + PkScriptMutationAction_PK_SCRIPT_MUTATION_ACTION_ADD PkScriptMutationAction = 1 + PkScriptMutationAction_PK_SCRIPT_MUTATION_ACTION_REMOVE PkScriptMutationAction = 2 +) + +// Enum value maps for PkScriptMutationAction. +var ( + PkScriptMutationAction_name = map[int32]string{ + 0: "PK_SCRIPT_MUTATION_ACTION_REGISTER", + 1: "PK_SCRIPT_MUTATION_ACTION_ADD", + 2: "PK_SCRIPT_MUTATION_ACTION_REMOVE", + } + PkScriptMutationAction_value = map[string]int32{ + "PK_SCRIPT_MUTATION_ACTION_REGISTER": 0, + "PK_SCRIPT_MUTATION_ACTION_ADD": 1, + "PK_SCRIPT_MUTATION_ACTION_REMOVE": 2, + } +) + +func (x PkScriptMutationAction) Enum() *PkScriptMutationAction { + p := new(PkScriptMutationAction) + *p = x + return p +} + +func (x PkScriptMutationAction) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (PkScriptMutationAction) Descriptor() protoreflect.EnumDescriptor { + return file_chainrpc_chainnotifier_proto_enumTypes[1].Descriptor() +} + +func (PkScriptMutationAction) Type() protoreflect.EnumType { + return &file_chainrpc_chainnotifier_proto_enumTypes[1] +} + +func (x PkScriptMutationAction) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use PkScriptMutationAction.Descriptor instead. +func (PkScriptMutationAction) EnumDescriptor() ([]byte, []int) { + return file_chainrpc_chainnotifier_proto_rawDescGZIP(), []int{1} +} + type ConfRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The transaction hash for which we should request a confirmation notification @@ -664,127 +762,1068 @@ func (x *BlockEpoch) GetHeight() uint32 { return 0 } -var File_chainrpc_chainnotifier_proto protoreflect.FileDescriptor +type PkScriptRegisterRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} -const file_chainrpc_chainnotifier_proto_rawDesc = "" + - "\n" + - "\x1cchainrpc/chainnotifier.proto\x12\bchainrpc\"\x9c\x01\n" + - "\vConfRequest\x12\x12\n" + - "\x04txid\x18\x01 \x01(\fR\x04txid\x12\x16\n" + - "\x06script\x18\x02 \x01(\fR\x06script\x12\x1b\n" + - "\tnum_confs\x18\x03 \x01(\rR\bnumConfs\x12\x1f\n" + - "\vheight_hint\x18\x04 \x01(\rR\n" + - "heightHint\x12#\n" + - "\rinclude_block\x18\x05 \x01(\bR\fincludeBlock\"\x9e\x01\n" + - "\vConfDetails\x12\x15\n" + - "\x06raw_tx\x18\x01 \x01(\fR\x05rawTx\x12\x1d\n" + - "\n" + - "block_hash\x18\x02 \x01(\fR\tblockHash\x12!\n" + - "\fblock_height\x18\x03 \x01(\rR\vblockHeight\x12\x19\n" + - "\btx_index\x18\x04 \x01(\rR\atxIndex\x12\x1b\n" + - "\traw_block\x18\x05 \x01(\fR\brawBlock\"\a\n" + - "\x05Reorg\"j\n" + - "\tConfEvent\x12+\n" + - "\x04conf\x18\x01 \x01(\v2\x15.chainrpc.ConfDetailsH\x00R\x04conf\x12'\n" + - "\x05reorg\x18\x02 \x01(\v2\x0f.chainrpc.ReorgH\x00R\x05reorgB\a\n" + - "\x05event\"4\n" + - "\bOutpoint\x12\x12\n" + - "\x04hash\x18\x01 \x01(\fR\x04hash\x12\x14\n" + - "\x05index\x18\x02 \x01(\rR\x05index\"w\n" + - "\fSpendRequest\x12.\n" + - "\boutpoint\x18\x01 \x01(\v2\x12.chainrpc.OutpointR\boutpoint\x12\x16\n" + - "\x06script\x18\x02 \x01(\fR\x06script\x12\x1f\n" + - "\vheight_hint\x18\x03 \x01(\rR\n" + - "heightHint\"\xfc\x01\n" + - "\fSpendDetails\x12?\n" + - "\x11spending_outpoint\x18\x01 \x01(\v2\x12.chainrpc.OutpointR\x10spendingOutpoint\x12&\n" + - "\x0fraw_spending_tx\x18\x02 \x01(\fR\rrawSpendingTx\x12(\n" + - "\x10spending_tx_hash\x18\x03 \x01(\fR\x0espendingTxHash\x120\n" + - "\x14spending_input_index\x18\x04 \x01(\rR\x12spendingInputIndex\x12'\n" + - "\x0fspending_height\x18\x05 \x01(\rR\x0espendingHeight\"n\n" + - "\n" + - "SpendEvent\x12.\n" + - "\x05spend\x18\x01 \x01(\v2\x16.chainrpc.SpendDetailsH\x00R\x05spend\x12'\n" + - "\x05reorg\x18\x02 \x01(\v2\x0f.chainrpc.ReorgH\x00R\x05reorgB\a\n" + - "\x05event\"8\n" + - "\n" + - "BlockEpoch\x12\x12\n" + - "\x04hash\x18\x01 \x01(\fR\x04hash\x12\x16\n" + - "\x06height\x18\x02 \x01(\rR\x06height2\xe7\x01\n" + - "\rChainNotifier\x12I\n" + - "\x19RegisterConfirmationsNtfn\x12\x15.chainrpc.ConfRequest\x1a\x13.chainrpc.ConfEvent0\x01\x12C\n" + - "\x11RegisterSpendNtfn\x12\x16.chainrpc.SpendRequest\x1a\x14.chainrpc.SpendEvent0\x01\x12F\n" + - "\x16RegisterBlockEpochNtfn\x12\x14.chainrpc.BlockEpoch\x1a\x14.chainrpc.BlockEpoch0\x01B0Z.github.com/lightningnetwork/lnd/lnrpc/chainrpcb\x06proto3" +func (x *PkScriptRegisterRequest) Reset() { + *x = PkScriptRegisterRequest{} + mi := &file_chainrpc_chainnotifier_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} -var ( - file_chainrpc_chainnotifier_proto_rawDescOnce sync.Once - file_chainrpc_chainnotifier_proto_rawDescData []byte -) +func (x *PkScriptRegisterRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} -func file_chainrpc_chainnotifier_proto_rawDescGZIP() []byte { - file_chainrpc_chainnotifier_proto_rawDescOnce.Do(func() { - file_chainrpc_chainnotifier_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_chainrpc_chainnotifier_proto_rawDesc), len(file_chainrpc_chainnotifier_proto_rawDesc))) - }) - return file_chainrpc_chainnotifier_proto_rawDescData +func (*PkScriptRegisterRequest) ProtoMessage() {} + +func (x *PkScriptRegisterRequest) ProtoReflect() protoreflect.Message { + mi := &file_chainrpc_chainnotifier_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) } -var file_chainrpc_chainnotifier_proto_msgTypes = make([]protoimpl.MessageInfo, 9) -var file_chainrpc_chainnotifier_proto_goTypes = []any{ - (*ConfRequest)(nil), // 0: chainrpc.ConfRequest - (*ConfDetails)(nil), // 1: chainrpc.ConfDetails - (*Reorg)(nil), // 2: chainrpc.Reorg - (*ConfEvent)(nil), // 3: chainrpc.ConfEvent - (*Outpoint)(nil), // 4: chainrpc.Outpoint - (*SpendRequest)(nil), // 5: chainrpc.SpendRequest - (*SpendDetails)(nil), // 6: chainrpc.SpendDetails - (*SpendEvent)(nil), // 7: chainrpc.SpendEvent - (*BlockEpoch)(nil), // 8: chainrpc.BlockEpoch +// Deprecated: Use PkScriptRegisterRequest.ProtoReflect.Descriptor instead. +func (*PkScriptRegisterRequest) Descriptor() ([]byte, []int) { + return file_chainrpc_chainnotifier_proto_rawDescGZIP(), []int{9} } -var file_chainrpc_chainnotifier_proto_depIdxs = []int32{ - 1, // 0: chainrpc.ConfEvent.conf:type_name -> chainrpc.ConfDetails - 2, // 1: chainrpc.ConfEvent.reorg:type_name -> chainrpc.Reorg - 4, // 2: chainrpc.SpendRequest.outpoint:type_name -> chainrpc.Outpoint - 4, // 3: chainrpc.SpendDetails.spending_outpoint:type_name -> chainrpc.Outpoint - 6, // 4: chainrpc.SpendEvent.spend:type_name -> chainrpc.SpendDetails - 2, // 5: chainrpc.SpendEvent.reorg:type_name -> chainrpc.Reorg - 0, // 6: chainrpc.ChainNotifier.RegisterConfirmationsNtfn:input_type -> chainrpc.ConfRequest - 5, // 7: chainrpc.ChainNotifier.RegisterSpendNtfn:input_type -> chainrpc.SpendRequest - 8, // 8: chainrpc.ChainNotifier.RegisterBlockEpochNtfn:input_type -> chainrpc.BlockEpoch - 3, // 9: chainrpc.ChainNotifier.RegisterConfirmationsNtfn:output_type -> chainrpc.ConfEvent - 7, // 10: chainrpc.ChainNotifier.RegisterSpendNtfn:output_type -> chainrpc.SpendEvent - 8, // 11: chainrpc.ChainNotifier.RegisterBlockEpochNtfn:output_type -> chainrpc.BlockEpoch - 9, // [9:12] is the sub-list for method output_type - 6, // [6:9] is the sub-list for method input_type - 6, // [6:6] is the sub-list for extension type_name - 6, // [6:6] is the sub-list for extension extendee - 0, // [0:6] is the sub-list for field type_name + +type AddPkScriptRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Additional pkScripts to watch on this stream. Scripts already watched by + // this stream are ignored and keep their original options. + PkScripts [][]byte `protobuf:"bytes,1,rep,name=pk_scripts,json=pkScripts,proto3" json:"pk_scripts,omitempty"` + // The event types to watch for the added pkScripts. If omitted, both + // confirmations and spends are watched. + Events []PkScriptEventType `protobuf:"varint,2,rep,packed,name=events,proto3,enum=chainrpc.PkScriptEventType" json:"events,omitempty"` + // The number of confirmations matched outputs should reach before + // confirmation notifications are dispatched. If unset, the default is 1. + NumConfs uint32 `protobuf:"varint,3,opt,name=num_confs,json=numConfs,proto3" json:"num_confs,omitempty"` + // If true, then the relevant block for each pkScript notification will be + // included in the event payload. + IncludeBlock bool `protobuf:"varint,4,opt,name=include_block,json=includeBlock,proto3" json:"include_block,omitempty"` + // If true, then the relevant transaction for each pkScript notification will + // be included in the event payload. + IncludeTx bool `protobuf:"varint,5,opt,name=include_tx,json=includeTx,proto3" json:"include_tx,omitempty"` + // If set, the notifier scans historical blocks from this height through the + // current chain tip for the added pkScripts. If omitted, only future chain + // activity is watched. Setting this to 0 explicitly scans from genesis. + // + // Historical scans run per add request, not per pkScript: all newly accepted + // scripts in this add request are scanned together. Backend notifiers execute + // one historical pkScript scan at a time, so large backfills can delay later + // historical add requests. + // + // Types that are valid to be assigned to HistoricalScan: + // + // *AddPkScriptRequest_HistoricalScanFrom + HistoricalScan isAddPkScriptRequest_HistoricalScan `protobuf_oneof:"historical_scan"` + // If true, partial confirmation progress events will be sent before the final + // confirmation notification reaches num_confs. + IncludeConfirmationUpdates bool `protobuf:"varint,7,opt,name=include_confirmation_updates,json=includeConfirmationUpdates,proto3" json:"include_confirmation_updates,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AddPkScriptRequest) Reset() { + *x = AddPkScriptRequest{} + mi := &file_chainrpc_chainnotifier_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } -func init() { file_chainrpc_chainnotifier_proto_init() } -func file_chainrpc_chainnotifier_proto_init() { - if File_chainrpc_chainnotifier_proto != nil { - return +func (x *AddPkScriptRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddPkScriptRequest) ProtoMessage() {} + +func (x *AddPkScriptRequest) ProtoReflect() protoreflect.Message { + mi := &file_chainrpc_chainnotifier_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms } - file_chainrpc_chainnotifier_proto_msgTypes[3].OneofWrappers = []any{ - (*ConfEvent_Conf)(nil), - (*ConfEvent_Reorg)(nil), + return mi.MessageOf(x) +} + +// Deprecated: Use AddPkScriptRequest.ProtoReflect.Descriptor instead. +func (*AddPkScriptRequest) Descriptor() ([]byte, []int) { + return file_chainrpc_chainnotifier_proto_rawDescGZIP(), []int{10} +} + +func (x *AddPkScriptRequest) GetPkScripts() [][]byte { + if x != nil { + return x.PkScripts } - file_chainrpc_chainnotifier_proto_msgTypes[7].OneofWrappers = []any{ - (*SpendEvent_Spend)(nil), - (*SpendEvent_Reorg)(nil), + return nil +} + +func (x *AddPkScriptRequest) GetEvents() []PkScriptEventType { + if x != nil { + return x.Events + } + return nil +} + +func (x *AddPkScriptRequest) GetNumConfs() uint32 { + if x != nil { + return x.NumConfs + } + return 0 +} + +func (x *AddPkScriptRequest) GetIncludeBlock() bool { + if x != nil { + return x.IncludeBlock + } + return false +} + +func (x *AddPkScriptRequest) GetIncludeTx() bool { + if x != nil { + return x.IncludeTx + } + return false +} + +func (x *AddPkScriptRequest) GetHistoricalScan() isAddPkScriptRequest_HistoricalScan { + if x != nil { + return x.HistoricalScan + } + return nil +} + +func (x *AddPkScriptRequest) GetHistoricalScanFrom() uint32 { + if x != nil { + if x, ok := x.HistoricalScan.(*AddPkScriptRequest_HistoricalScanFrom); ok { + return x.HistoricalScanFrom + } + } + return 0 +} + +func (x *AddPkScriptRequest) GetIncludeConfirmationUpdates() bool { + if x != nil { + return x.IncludeConfirmationUpdates + } + return false +} + +type isAddPkScriptRequest_HistoricalScan interface { + isAddPkScriptRequest_HistoricalScan() +} + +type AddPkScriptRequest_HistoricalScanFrom struct { + HistoricalScanFrom uint32 `protobuf:"varint,6,opt,name=historical_scan_from,json=historicalScanFrom,proto3,oneof"` +} + +func (*AddPkScriptRequest_HistoricalScanFrom) isAddPkScriptRequest_HistoricalScan() {} + +type RemovePkScriptRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // PkScripts to stop watching on this stream. + PkScripts [][]byte `protobuf:"bytes,1,rep,name=pk_scripts,json=pkScripts,proto3" json:"pk_scripts,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemovePkScriptRequest) Reset() { + *x = RemovePkScriptRequest{} + mi := &file_chainrpc_chainnotifier_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemovePkScriptRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemovePkScriptRequest) ProtoMessage() {} + +func (x *RemovePkScriptRequest) ProtoReflect() protoreflect.Message { + mi := &file_chainrpc_chainnotifier_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemovePkScriptRequest.ProtoReflect.Descriptor instead. +func (*RemovePkScriptRequest) Descriptor() ([]byte, []int) { + return file_chainrpc_chainnotifier_proto_rawDescGZIP(), []int{11} +} + +func (x *RemovePkScriptRequest) GetPkScripts() [][]byte { + if x != nil { + return x.PkScripts + } + return nil +} + +type PkScriptRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Request: + // + // *PkScriptRequest_Register + // *PkScriptRequest_Add + // *PkScriptRequest_Remove + Request isPkScriptRequest_Request `protobuf_oneof:"request"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PkScriptRequest) Reset() { + *x = PkScriptRequest{} + mi := &file_chainrpc_chainnotifier_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PkScriptRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PkScriptRequest) ProtoMessage() {} + +func (x *PkScriptRequest) ProtoReflect() protoreflect.Message { + mi := &file_chainrpc_chainnotifier_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PkScriptRequest.ProtoReflect.Descriptor instead. +func (*PkScriptRequest) Descriptor() ([]byte, []int) { + return file_chainrpc_chainnotifier_proto_rawDescGZIP(), []int{12} +} + +func (x *PkScriptRequest) GetRequest() isPkScriptRequest_Request { + if x != nil { + return x.Request + } + return nil +} + +func (x *PkScriptRequest) GetRegister() *PkScriptRegisterRequest { + if x != nil { + if x, ok := x.Request.(*PkScriptRequest_Register); ok { + return x.Register + } + } + return nil +} + +func (x *PkScriptRequest) GetAdd() *AddPkScriptRequest { + if x != nil { + if x, ok := x.Request.(*PkScriptRequest_Add); ok { + return x.Add + } + } + return nil +} + +func (x *PkScriptRequest) GetRemove() *RemovePkScriptRequest { + if x != nil { + if x, ok := x.Request.(*PkScriptRequest_Remove); ok { + return x.Remove + } + } + return nil +} + +type isPkScriptRequest_Request interface { + isPkScriptRequest_Request() +} + +type PkScriptRequest_Register struct { + Register *PkScriptRegisterRequest `protobuf:"bytes,1,opt,name=register,proto3,oneof"` +} + +type PkScriptRequest_Add struct { + Add *AddPkScriptRequest `protobuf:"bytes,2,opt,name=add,proto3,oneof"` +} + +type PkScriptRequest_Remove struct { + Remove *RemovePkScriptRequest `protobuf:"bytes,3,opt,name=remove,proto3,oneof"` +} + +func (*PkScriptRequest_Register) isPkScriptRequest_Request() {} + +func (*PkScriptRequest_Add) isPkScriptRequest_Request() {} + +func (*PkScriptRequest_Remove) isPkScriptRequest_Request() {} + +type PkScriptMutationAck struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The mutation that was accepted for this stream. + Action PkScriptMutationAction `protobuf:"varint,1,opt,name=action,proto3,enum=chainrpc.PkScriptMutationAction" json:"action,omitempty"` + // The number of new scripts accepted by this mutation. + NumAdded uint32 `protobuf:"varint,2,opt,name=num_added,json=numAdded,proto3" json:"num_added,omitempty"` + // True if this add mutation queued a historical scan. + HistoricalScanQueued bool `protobuf:"varint,3,opt,name=historical_scan_queued,json=historicalScanQueued,proto3" json:"historical_scan_queued,omitempty"` + // The ID of the queued historical scan, if any. + HistoricalScanId uint64 `protobuf:"varint,4,opt,name=historical_scan_id,json=historicalScanId,proto3" json:"historical_scan_id,omitempty"` + // The first height scanned by the queued historical scan. + HistoricalScanStartHeight uint32 `protobuf:"varint,5,opt,name=historical_scan_start_height,json=historicalScanStartHeight,proto3" json:"historical_scan_start_height,omitempty"` + // The best-known final height of the queued historical scan at queue time. + HistoricalScanEndHeight uint32 `protobuf:"varint,6,opt,name=historical_scan_end_height,json=historicalScanEndHeight,proto3" json:"historical_scan_end_height,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PkScriptMutationAck) Reset() { + *x = PkScriptMutationAck{} + mi := &file_chainrpc_chainnotifier_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PkScriptMutationAck) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PkScriptMutationAck) ProtoMessage() {} + +func (x *PkScriptMutationAck) ProtoReflect() protoreflect.Message { + mi := &file_chainrpc_chainnotifier_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PkScriptMutationAck.ProtoReflect.Descriptor instead. +func (*PkScriptMutationAck) Descriptor() ([]byte, []int) { + return file_chainrpc_chainnotifier_proto_rawDescGZIP(), []int{13} +} + +func (x *PkScriptMutationAck) GetAction() PkScriptMutationAction { + if x != nil { + return x.Action + } + return PkScriptMutationAction_PK_SCRIPT_MUTATION_ACTION_REGISTER +} + +func (x *PkScriptMutationAck) GetNumAdded() uint32 { + if x != nil { + return x.NumAdded + } + return 0 +} + +func (x *PkScriptMutationAck) GetHistoricalScanQueued() bool { + if x != nil { + return x.HistoricalScanQueued + } + return false +} + +func (x *PkScriptMutationAck) GetHistoricalScanId() uint64 { + if x != nil { + return x.HistoricalScanId + } + return 0 +} + +func (x *PkScriptMutationAck) GetHistoricalScanStartHeight() uint32 { + if x != nil { + return x.HistoricalScanStartHeight + } + return 0 +} + +func (x *PkScriptMutationAck) GetHistoricalScanEndHeight() uint32 { + if x != nil { + return x.HistoricalScanEndHeight + } + return 0 +} + +type PkScriptUtxo struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The outpoint paying to the watched script. + Outpoint *Outpoint `protobuf:"bytes,1,opt,name=outpoint,proto3" json:"outpoint,omitempty"` + // The value of the watched output in satoshis. + Value int64 `protobuf:"varint,2,opt,name=value,proto3" json:"value,omitempty"` + // The watched output script. + PkScript []byte `protobuf:"bytes,3,opt,name=pk_script,json=pkScript,proto3" json:"pk_script,omitempty"` + // The height at which the watched output was created. + BlockHeight uint32 `protobuf:"varint,4,opt,name=block_height,json=blockHeight,proto3" json:"block_height,omitempty"` + // The hash of the block that created the watched output. + BlockHash []byte `protobuf:"bytes,5,opt,name=block_hash,json=blockHash,proto3" json:"block_hash,omitempty"` + // The transaction index of the funding transaction in block_hash. + TxIndex uint32 `protobuf:"varint,6,opt,name=tx_index,json=txIndex,proto3" json:"tx_index,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PkScriptUtxo) Reset() { + *x = PkScriptUtxo{} + mi := &file_chainrpc_chainnotifier_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PkScriptUtxo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PkScriptUtxo) ProtoMessage() {} + +func (x *PkScriptUtxo) ProtoReflect() protoreflect.Message { + mi := &file_chainrpc_chainnotifier_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PkScriptUtxo.ProtoReflect.Descriptor instead. +func (*PkScriptUtxo) Descriptor() ([]byte, []int) { + return file_chainrpc_chainnotifier_proto_rawDescGZIP(), []int{14} +} + +func (x *PkScriptUtxo) GetOutpoint() *Outpoint { + if x != nil { + return x.Outpoint + } + return nil +} + +func (x *PkScriptUtxo) GetValue() int64 { + if x != nil { + return x.Value + } + return 0 +} + +func (x *PkScriptUtxo) GetPkScript() []byte { + if x != nil { + return x.PkScript + } + return nil +} + +func (x *PkScriptUtxo) GetBlockHeight() uint32 { + if x != nil { + return x.BlockHeight + } + return 0 +} + +func (x *PkScriptUtxo) GetBlockHash() []byte { + if x != nil { + return x.BlockHash + } + return nil +} + +func (x *PkScriptUtxo) GetTxIndex() uint32 { + if x != nil { + return x.TxIndex + } + return 0 +} + +type PkScriptNotification struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The kind of notification being delivered. + EventType PkScriptEventType `protobuf:"varint,1,opt,name=event_type,json=eventType,proto3,enum=chainrpc.PkScriptEventType" json:"event_type,omitempty"` + // The height relevant to this event. + Height uint32 `protobuf:"varint,2,opt,name=height,proto3" json:"height,omitempty"` + // The hash of the relevant block for this event. + BlockHash []byte `protobuf:"bytes,3,opt,name=block_hash,json=blockHash,proto3" json:"block_hash,omitempty"` + // The hash of the relevant transaction for this event. + TxHash []byte `protobuf:"bytes,4,opt,name=tx_hash,json=txHash,proto3" json:"tx_hash,omitempty"` + // The index of the relevant transaction in the block that mined it. For + // confirmation notifications this is the funding transaction's index in the + // UTXO block. For spend notifications this is the spending transaction's + // index in block_hash. + TxIndex uint32 `protobuf:"varint,5,opt,name=tx_index,json=txIndex,proto3" json:"tx_index,omitempty"` + // The number of confirmations reached by the watched output. + NumConfirmations uint32 `protobuf:"varint,6,opt,name=num_confirmations,json=numConfirmations,proto3" json:"num_confirmations,omitempty"` + // True if this notification invalidates a previously sent event. + Disconnected bool `protobuf:"varint,7,opt,name=disconnected,proto3" json:"disconnected,omitempty"` + // The watched output associated with this event. + Utxo *PkScriptUtxo `protobuf:"bytes,8,opt,name=utxo,proto3" json:"utxo,omitempty"` + // The raw bytes of the relevant transaction, if include_tx was requested. + // For confirmation notifications this is the funding transaction. For spend + // notifications this is the spending transaction. + RawTx []byte `protobuf:"bytes,9,opt,name=raw_tx,json=rawTx,proto3" json:"raw_tx,omitempty"` + // The raw bytes of the relevant block, if include_block was requested. For + // confirmation notifications this is the block at which the output reached + // the requested confirmation depth. For spend notifications this is the + // block containing the spend. + RawBlock []byte `protobuf:"bytes,10,opt,name=raw_block,json=rawBlock,proto3" json:"raw_block,omitempty"` + // The spending input index. This is only set for spend notifications. + InputIndex uint32 `protobuf:"varint,11,opt,name=input_index,json=inputIndex,proto3" json:"input_index,omitempty"` + // The requested confirmation target. This is set for final confirmation and + // partial confirmation progress notifications. + RequiredConfirmations uint32 `protobuf:"varint,12,opt,name=required_confirmations,json=requiredConfirmations,proto3" json:"required_confirmations,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PkScriptNotification) Reset() { + *x = PkScriptNotification{} + mi := &file_chainrpc_chainnotifier_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PkScriptNotification) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PkScriptNotification) ProtoMessage() {} + +func (x *PkScriptNotification) ProtoReflect() protoreflect.Message { + mi := &file_chainrpc_chainnotifier_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PkScriptNotification.ProtoReflect.Descriptor instead. +func (*PkScriptNotification) Descriptor() ([]byte, []int) { + return file_chainrpc_chainnotifier_proto_rawDescGZIP(), []int{15} +} + +func (x *PkScriptNotification) GetEventType() PkScriptEventType { + if x != nil { + return x.EventType + } + return PkScriptEventType_PK_SCRIPT_EVENT_TYPE_CONFIRM +} + +func (x *PkScriptNotification) GetHeight() uint32 { + if x != nil { + return x.Height + } + return 0 +} + +func (x *PkScriptNotification) GetBlockHash() []byte { + if x != nil { + return x.BlockHash + } + return nil +} + +func (x *PkScriptNotification) GetTxHash() []byte { + if x != nil { + return x.TxHash + } + return nil +} + +func (x *PkScriptNotification) GetTxIndex() uint32 { + if x != nil { + return x.TxIndex + } + return 0 +} + +func (x *PkScriptNotification) GetNumConfirmations() uint32 { + if x != nil { + return x.NumConfirmations + } + return 0 +} + +func (x *PkScriptNotification) GetDisconnected() bool { + if x != nil { + return x.Disconnected + } + return false +} + +func (x *PkScriptNotification) GetUtxo() *PkScriptUtxo { + if x != nil { + return x.Utxo + } + return nil +} + +func (x *PkScriptNotification) GetRawTx() []byte { + if x != nil { + return x.RawTx + } + return nil +} + +func (x *PkScriptNotification) GetRawBlock() []byte { + if x != nil { + return x.RawBlock + } + return nil +} + +func (x *PkScriptNotification) GetInputIndex() uint32 { + if x != nil { + return x.InputIndex + } + return 0 +} + +func (x *PkScriptNotification) GetRequiredConfirmations() uint32 { + if x != nil { + return x.RequiredConfirmations + } + return 0 +} + +type PkScriptHistoricalScan struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The ID assigned to the historical scan when it was queued. + ScanId uint64 `protobuf:"varint,1,opt,name=scan_id,json=scanId,proto3" json:"scan_id,omitempty"` + // The first height requested for the historical scan. + StartHeight uint32 `protobuf:"varint,2,opt,name=start_height,json=startHeight,proto3" json:"start_height,omitempty"` + // The final height scanned before completion or failure. + EndHeight uint32 `protobuf:"varint,3,opt,name=end_height,json=endHeight,proto3" json:"end_height,omitempty"` + // The last height processed successfully. + CompletedHeight uint32 `protobuf:"varint,4,opt,name=completed_height,json=completedHeight,proto3" json:"completed_height,omitempty"` + // A non-empty error means the historical scan failed before completion. + Error string `protobuf:"bytes,5,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PkScriptHistoricalScan) Reset() { + *x = PkScriptHistoricalScan{} + mi := &file_chainrpc_chainnotifier_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PkScriptHistoricalScan) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PkScriptHistoricalScan) ProtoMessage() {} + +func (x *PkScriptHistoricalScan) ProtoReflect() protoreflect.Message { + mi := &file_chainrpc_chainnotifier_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PkScriptHistoricalScan.ProtoReflect.Descriptor instead. +func (*PkScriptHistoricalScan) Descriptor() ([]byte, []int) { + return file_chainrpc_chainnotifier_proto_rawDescGZIP(), []int{16} +} + +func (x *PkScriptHistoricalScan) GetScanId() uint64 { + if x != nil { + return x.ScanId + } + return 0 +} + +func (x *PkScriptHistoricalScan) GetStartHeight() uint32 { + if x != nil { + return x.StartHeight + } + return 0 +} + +func (x *PkScriptHistoricalScan) GetEndHeight() uint32 { + if x != nil { + return x.EndHeight + } + return 0 +} + +func (x *PkScriptHistoricalScan) GetCompletedHeight() uint32 { + if x != nil { + return x.CompletedHeight + } + return 0 +} + +func (x *PkScriptHistoricalScan) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type PkScriptEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Event: + // + // *PkScriptEvent_Ack + // *PkScriptEvent_Notification + // *PkScriptEvent_HistoricalScan + Event isPkScriptEvent_Event `protobuf_oneof:"event"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PkScriptEvent) Reset() { + *x = PkScriptEvent{} + mi := &file_chainrpc_chainnotifier_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PkScriptEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PkScriptEvent) ProtoMessage() {} + +func (x *PkScriptEvent) ProtoReflect() protoreflect.Message { + mi := &file_chainrpc_chainnotifier_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PkScriptEvent.ProtoReflect.Descriptor instead. +func (*PkScriptEvent) Descriptor() ([]byte, []int) { + return file_chainrpc_chainnotifier_proto_rawDescGZIP(), []int{17} +} + +func (x *PkScriptEvent) GetEvent() isPkScriptEvent_Event { + if x != nil { + return x.Event + } + return nil +} + +func (x *PkScriptEvent) GetAck() *PkScriptMutationAck { + if x != nil { + if x, ok := x.Event.(*PkScriptEvent_Ack); ok { + return x.Ack + } + } + return nil +} + +func (x *PkScriptEvent) GetNotification() *PkScriptNotification { + if x != nil { + if x, ok := x.Event.(*PkScriptEvent_Notification); ok { + return x.Notification + } + } + return nil +} + +func (x *PkScriptEvent) GetHistoricalScan() *PkScriptHistoricalScan { + if x != nil { + if x, ok := x.Event.(*PkScriptEvent_HistoricalScan); ok { + return x.HistoricalScan + } + } + return nil +} + +type isPkScriptEvent_Event interface { + isPkScriptEvent_Event() +} + +type PkScriptEvent_Ack struct { + // Acknowledges that a stream mutation was accepted. + Ack *PkScriptMutationAck `protobuf:"bytes,1,opt,name=ack,proto3,oneof"` +} + +type PkScriptEvent_Notification struct { + // A notification for a watched output script. + Notification *PkScriptNotification `protobuf:"bytes,2,opt,name=notification,proto3,oneof"` +} + +type PkScriptEvent_HistoricalScan struct { + // A historical scan lifecycle event. + HistoricalScan *PkScriptHistoricalScan `protobuf:"bytes,3,opt,name=historical_scan,json=historicalScan,proto3,oneof"` +} + +func (*PkScriptEvent_Ack) isPkScriptEvent_Event() {} + +func (*PkScriptEvent_Notification) isPkScriptEvent_Event() {} + +func (*PkScriptEvent_HistoricalScan) isPkScriptEvent_Event() {} + +var File_chainrpc_chainnotifier_proto protoreflect.FileDescriptor + +const file_chainrpc_chainnotifier_proto_rawDesc = "" + + "\n" + + "\x1cchainrpc/chainnotifier.proto\x12\bchainrpc\"\x9c\x01\n" + + "\vConfRequest\x12\x12\n" + + "\x04txid\x18\x01 \x01(\fR\x04txid\x12\x16\n" + + "\x06script\x18\x02 \x01(\fR\x06script\x12\x1b\n" + + "\tnum_confs\x18\x03 \x01(\rR\bnumConfs\x12\x1f\n" + + "\vheight_hint\x18\x04 \x01(\rR\n" + + "heightHint\x12#\n" + + "\rinclude_block\x18\x05 \x01(\bR\fincludeBlock\"\x9e\x01\n" + + "\vConfDetails\x12\x15\n" + + "\x06raw_tx\x18\x01 \x01(\fR\x05rawTx\x12\x1d\n" + + "\n" + + "block_hash\x18\x02 \x01(\fR\tblockHash\x12!\n" + + "\fblock_height\x18\x03 \x01(\rR\vblockHeight\x12\x19\n" + + "\btx_index\x18\x04 \x01(\rR\atxIndex\x12\x1b\n" + + "\traw_block\x18\x05 \x01(\fR\brawBlock\"\a\n" + + "\x05Reorg\"j\n" + + "\tConfEvent\x12+\n" + + "\x04conf\x18\x01 \x01(\v2\x15.chainrpc.ConfDetailsH\x00R\x04conf\x12'\n" + + "\x05reorg\x18\x02 \x01(\v2\x0f.chainrpc.ReorgH\x00R\x05reorgB\a\n" + + "\x05event\"4\n" + + "\bOutpoint\x12\x12\n" + + "\x04hash\x18\x01 \x01(\fR\x04hash\x12\x14\n" + + "\x05index\x18\x02 \x01(\rR\x05index\"w\n" + + "\fSpendRequest\x12.\n" + + "\boutpoint\x18\x01 \x01(\v2\x12.chainrpc.OutpointR\boutpoint\x12\x16\n" + + "\x06script\x18\x02 \x01(\fR\x06script\x12\x1f\n" + + "\vheight_hint\x18\x03 \x01(\rR\n" + + "heightHint\"\xfc\x01\n" + + "\fSpendDetails\x12?\n" + + "\x11spending_outpoint\x18\x01 \x01(\v2\x12.chainrpc.OutpointR\x10spendingOutpoint\x12&\n" + + "\x0fraw_spending_tx\x18\x02 \x01(\fR\rrawSpendingTx\x12(\n" + + "\x10spending_tx_hash\x18\x03 \x01(\fR\x0espendingTxHash\x120\n" + + "\x14spending_input_index\x18\x04 \x01(\rR\x12spendingInputIndex\x12'\n" + + "\x0fspending_height\x18\x05 \x01(\rR\x0espendingHeight\"n\n" + + "\n" + + "SpendEvent\x12.\n" + + "\x05spend\x18\x01 \x01(\v2\x16.chainrpc.SpendDetailsH\x00R\x05spend\x12'\n" + + "\x05reorg\x18\x02 \x01(\v2\x0f.chainrpc.ReorgH\x00R\x05reorgB\a\n" + + "\x05event\"8\n" + + "\n" + + "BlockEpoch\x12\x12\n" + + "\x04hash\x18\x01 \x01(\fR\x04hash\x12\x16\n" + + "\x06height\x18\x02 \x01(\rR\x06height\"\x19\n" + + "\x17PkScriptRegisterRequest\"\xd2\x02\n" + + "\x12AddPkScriptRequest\x12\x1d\n" + + "\n" + + "pk_scripts\x18\x01 \x03(\fR\tpkScripts\x123\n" + + "\x06events\x18\x02 \x03(\x0e2\x1b.chainrpc.PkScriptEventTypeR\x06events\x12\x1b\n" + + "\tnum_confs\x18\x03 \x01(\rR\bnumConfs\x12#\n" + + "\rinclude_block\x18\x04 \x01(\bR\fincludeBlock\x12\x1d\n" + + "\n" + + "include_tx\x18\x05 \x01(\bR\tincludeTx\x122\n" + + "\x14historical_scan_from\x18\x06 \x01(\rH\x00R\x12historicalScanFrom\x12@\n" + + "\x1cinclude_confirmation_updates\x18\a \x01(\bR\x1aincludeConfirmationUpdatesB\x11\n" + + "\x0fhistorical_scan\"6\n" + + "\x15RemovePkScriptRequest\x12\x1d\n" + + "\n" + + "pk_scripts\x18\x01 \x03(\fR\tpkScripts\"\xca\x01\n" + + "\x0fPkScriptRequest\x12?\n" + + "\bregister\x18\x01 \x01(\v2!.chainrpc.PkScriptRegisterRequestH\x00R\bregister\x120\n" + + "\x03add\x18\x02 \x01(\v2\x1c.chainrpc.AddPkScriptRequestH\x00R\x03add\x129\n" + + "\x06remove\x18\x03 \x01(\v2\x1f.chainrpc.RemovePkScriptRequestH\x00R\x06removeB\t\n" + + "\arequest\"\xce\x02\n" + + "\x13PkScriptMutationAck\x128\n" + + "\x06action\x18\x01 \x01(\x0e2 .chainrpc.PkScriptMutationActionR\x06action\x12\x1b\n" + + "\tnum_added\x18\x02 \x01(\rR\bnumAdded\x124\n" + + "\x16historical_scan_queued\x18\x03 \x01(\bR\x14historicalScanQueued\x12,\n" + + "\x12historical_scan_id\x18\x04 \x01(\x04R\x10historicalScanId\x12?\n" + + "\x1chistorical_scan_start_height\x18\x05 \x01(\rR\x19historicalScanStartHeight\x12;\n" + + "\x1ahistorical_scan_end_height\x18\x06 \x01(\rR\x17historicalScanEndHeight\"\xce\x01\n" + + "\fPkScriptUtxo\x12.\n" + + "\boutpoint\x18\x01 \x01(\v2\x12.chainrpc.OutpointR\boutpoint\x12\x14\n" + + "\x05value\x18\x02 \x01(\x03R\x05value\x12\x1b\n" + + "\tpk_script\x18\x03 \x01(\fR\bpkScript\x12!\n" + + "\fblock_height\x18\x04 \x01(\rR\vblockHeight\x12\x1d\n" + + "\n" + + "block_hash\x18\x05 \x01(\fR\tblockHash\x12\x19\n" + + "\btx_index\x18\x06 \x01(\rR\atxIndex\"\xc6\x03\n" + + "\x14PkScriptNotification\x12:\n" + + "\n" + + "event_type\x18\x01 \x01(\x0e2\x1b.chainrpc.PkScriptEventTypeR\teventType\x12\x16\n" + + "\x06height\x18\x02 \x01(\rR\x06height\x12\x1d\n" + + "\n" + + "block_hash\x18\x03 \x01(\fR\tblockHash\x12\x17\n" + + "\atx_hash\x18\x04 \x01(\fR\x06txHash\x12\x19\n" + + "\btx_index\x18\x05 \x01(\rR\atxIndex\x12+\n" + + "\x11num_confirmations\x18\x06 \x01(\rR\x10numConfirmations\x12\"\n" + + "\fdisconnected\x18\a \x01(\bR\fdisconnected\x12*\n" + + "\x04utxo\x18\b \x01(\v2\x16.chainrpc.PkScriptUtxoR\x04utxo\x12\x15\n" + + "\x06raw_tx\x18\t \x01(\fR\x05rawTx\x12\x1b\n" + + "\traw_block\x18\n" + + " \x01(\fR\brawBlock\x12\x1f\n" + + "\vinput_index\x18\v \x01(\rR\n" + + "inputIndex\x125\n" + + "\x16required_confirmations\x18\f \x01(\rR\x15requiredConfirmations\"\xb4\x01\n" + + "\x16PkScriptHistoricalScan\x12\x17\n" + + "\ascan_id\x18\x01 \x01(\x04R\x06scanId\x12!\n" + + "\fstart_height\x18\x02 \x01(\rR\vstartHeight\x12\x1d\n" + + "\n" + + "end_height\x18\x03 \x01(\rR\tendHeight\x12)\n" + + "\x10completed_height\x18\x04 \x01(\rR\x0fcompletedHeight\x12\x14\n" + + "\x05error\x18\x05 \x01(\tR\x05error\"\xde\x01\n" + + "\rPkScriptEvent\x121\n" + + "\x03ack\x18\x01 \x01(\v2\x1d.chainrpc.PkScriptMutationAckH\x00R\x03ack\x12D\n" + + "\fnotification\x18\x02 \x01(\v2\x1e.chainrpc.PkScriptNotificationH\x00R\fnotification\x12K\n" + + "\x0fhistorical_scan\x18\x03 \x01(\v2 .chainrpc.PkScriptHistoricalScanH\x00R\x0ehistoricalScanB\a\n" + + "\x05event*\x83\x01\n" + + "\x11PkScriptEventType\x12 \n" + + "\x1cPK_SCRIPT_EVENT_TYPE_CONFIRM\x10\x00\x12\x1e\n" + + "\x1aPK_SCRIPT_EVENT_TYPE_SPEND\x10\x01\x12,\n" + + "(PK_SCRIPT_EVENT_TYPE_CONFIRMATION_UPDATE\x10\x02*\x89\x01\n" + + "\x16PkScriptMutationAction\x12&\n" + + "\"PK_SCRIPT_MUTATION_ACTION_REGISTER\x10\x00\x12!\n" + + "\x1dPK_SCRIPT_MUTATION_ACTION_ADD\x10\x01\x12$\n" + + " PK_SCRIPT_MUTATION_ACTION_REMOVE\x10\x022\xb7\x02\n" + + "\rChainNotifier\x12I\n" + + "\x19RegisterConfirmationsNtfn\x12\x15.chainrpc.ConfRequest\x1a\x13.chainrpc.ConfEvent0\x01\x12C\n" + + "\x11RegisterSpendNtfn\x12\x16.chainrpc.SpendRequest\x1a\x14.chainrpc.SpendEvent0\x01\x12F\n" + + "\x16RegisterBlockEpochNtfn\x12\x14.chainrpc.BlockEpoch\x1a\x14.chainrpc.BlockEpoch0\x01\x12N\n" + + "\x14RegisterPkScriptNtfn\x12\x19.chainrpc.PkScriptRequest\x1a\x17.chainrpc.PkScriptEvent(\x010\x01B0Z.github.com/lightningnetwork/lnd/lnrpc/chainrpcb\x06proto3" + +var ( + file_chainrpc_chainnotifier_proto_rawDescOnce sync.Once + file_chainrpc_chainnotifier_proto_rawDescData []byte +) + +func file_chainrpc_chainnotifier_proto_rawDescGZIP() []byte { + file_chainrpc_chainnotifier_proto_rawDescOnce.Do(func() { + file_chainrpc_chainnotifier_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_chainrpc_chainnotifier_proto_rawDesc), len(file_chainrpc_chainnotifier_proto_rawDesc))) + }) + return file_chainrpc_chainnotifier_proto_rawDescData +} + +var file_chainrpc_chainnotifier_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_chainrpc_chainnotifier_proto_msgTypes = make([]protoimpl.MessageInfo, 18) +var file_chainrpc_chainnotifier_proto_goTypes = []any{ + (PkScriptEventType)(0), // 0: chainrpc.PkScriptEventType + (PkScriptMutationAction)(0), // 1: chainrpc.PkScriptMutationAction + (*ConfRequest)(nil), // 2: chainrpc.ConfRequest + (*ConfDetails)(nil), // 3: chainrpc.ConfDetails + (*Reorg)(nil), // 4: chainrpc.Reorg + (*ConfEvent)(nil), // 5: chainrpc.ConfEvent + (*Outpoint)(nil), // 6: chainrpc.Outpoint + (*SpendRequest)(nil), // 7: chainrpc.SpendRequest + (*SpendDetails)(nil), // 8: chainrpc.SpendDetails + (*SpendEvent)(nil), // 9: chainrpc.SpendEvent + (*BlockEpoch)(nil), // 10: chainrpc.BlockEpoch + (*PkScriptRegisterRequest)(nil), // 11: chainrpc.PkScriptRegisterRequest + (*AddPkScriptRequest)(nil), // 12: chainrpc.AddPkScriptRequest + (*RemovePkScriptRequest)(nil), // 13: chainrpc.RemovePkScriptRequest + (*PkScriptRequest)(nil), // 14: chainrpc.PkScriptRequest + (*PkScriptMutationAck)(nil), // 15: chainrpc.PkScriptMutationAck + (*PkScriptUtxo)(nil), // 16: chainrpc.PkScriptUtxo + (*PkScriptNotification)(nil), // 17: chainrpc.PkScriptNotification + (*PkScriptHistoricalScan)(nil), // 18: chainrpc.PkScriptHistoricalScan + (*PkScriptEvent)(nil), // 19: chainrpc.PkScriptEvent +} +var file_chainrpc_chainnotifier_proto_depIdxs = []int32{ + 3, // 0: chainrpc.ConfEvent.conf:type_name -> chainrpc.ConfDetails + 4, // 1: chainrpc.ConfEvent.reorg:type_name -> chainrpc.Reorg + 6, // 2: chainrpc.SpendRequest.outpoint:type_name -> chainrpc.Outpoint + 6, // 3: chainrpc.SpendDetails.spending_outpoint:type_name -> chainrpc.Outpoint + 8, // 4: chainrpc.SpendEvent.spend:type_name -> chainrpc.SpendDetails + 4, // 5: chainrpc.SpendEvent.reorg:type_name -> chainrpc.Reorg + 0, // 6: chainrpc.AddPkScriptRequest.events:type_name -> chainrpc.PkScriptEventType + 11, // 7: chainrpc.PkScriptRequest.register:type_name -> chainrpc.PkScriptRegisterRequest + 12, // 8: chainrpc.PkScriptRequest.add:type_name -> chainrpc.AddPkScriptRequest + 13, // 9: chainrpc.PkScriptRequest.remove:type_name -> chainrpc.RemovePkScriptRequest + 1, // 10: chainrpc.PkScriptMutationAck.action:type_name -> chainrpc.PkScriptMutationAction + 6, // 11: chainrpc.PkScriptUtxo.outpoint:type_name -> chainrpc.Outpoint + 0, // 12: chainrpc.PkScriptNotification.event_type:type_name -> chainrpc.PkScriptEventType + 16, // 13: chainrpc.PkScriptNotification.utxo:type_name -> chainrpc.PkScriptUtxo + 15, // 14: chainrpc.PkScriptEvent.ack:type_name -> chainrpc.PkScriptMutationAck + 17, // 15: chainrpc.PkScriptEvent.notification:type_name -> chainrpc.PkScriptNotification + 18, // 16: chainrpc.PkScriptEvent.historical_scan:type_name -> chainrpc.PkScriptHistoricalScan + 2, // 17: chainrpc.ChainNotifier.RegisterConfirmationsNtfn:input_type -> chainrpc.ConfRequest + 7, // 18: chainrpc.ChainNotifier.RegisterSpendNtfn:input_type -> chainrpc.SpendRequest + 10, // 19: chainrpc.ChainNotifier.RegisterBlockEpochNtfn:input_type -> chainrpc.BlockEpoch + 14, // 20: chainrpc.ChainNotifier.RegisterPkScriptNtfn:input_type -> chainrpc.PkScriptRequest + 5, // 21: chainrpc.ChainNotifier.RegisterConfirmationsNtfn:output_type -> chainrpc.ConfEvent + 9, // 22: chainrpc.ChainNotifier.RegisterSpendNtfn:output_type -> chainrpc.SpendEvent + 10, // 23: chainrpc.ChainNotifier.RegisterBlockEpochNtfn:output_type -> chainrpc.BlockEpoch + 19, // 24: chainrpc.ChainNotifier.RegisterPkScriptNtfn:output_type -> chainrpc.PkScriptEvent + 21, // [21:25] is the sub-list for method output_type + 17, // [17:21] is the sub-list for method input_type + 17, // [17:17] is the sub-list for extension type_name + 17, // [17:17] is the sub-list for extension extendee + 0, // [0:17] is the sub-list for field type_name +} + +func init() { file_chainrpc_chainnotifier_proto_init() } +func file_chainrpc_chainnotifier_proto_init() { + if File_chainrpc_chainnotifier_proto != nil { + return + } + file_chainrpc_chainnotifier_proto_msgTypes[3].OneofWrappers = []any{ + (*ConfEvent_Conf)(nil), + (*ConfEvent_Reorg)(nil), + } + file_chainrpc_chainnotifier_proto_msgTypes[7].OneofWrappers = []any{ + (*SpendEvent_Spend)(nil), + (*SpendEvent_Reorg)(nil), + } + file_chainrpc_chainnotifier_proto_msgTypes[10].OneofWrappers = []any{ + (*AddPkScriptRequest_HistoricalScanFrom)(nil), + } + file_chainrpc_chainnotifier_proto_msgTypes[12].OneofWrappers = []any{ + (*PkScriptRequest_Register)(nil), + (*PkScriptRequest_Add)(nil), + (*PkScriptRequest_Remove)(nil), + } + file_chainrpc_chainnotifier_proto_msgTypes[17].OneofWrappers = []any{ + (*PkScriptEvent_Ack)(nil), + (*PkScriptEvent_Notification)(nil), + (*PkScriptEvent_HistoricalScan)(nil), } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_chainrpc_chainnotifier_proto_rawDesc), len(file_chainrpc_chainnotifier_proto_rawDesc)), - NumEnums: 0, - NumMessages: 9, + NumEnums: 2, + NumMessages: 18, NumExtensions: 0, NumServices: 1, }, GoTypes: file_chainrpc_chainnotifier_proto_goTypes, DependencyIndexes: file_chainrpc_chainnotifier_proto_depIdxs, + EnumInfos: file_chainrpc_chainnotifier_proto_enumTypes, MessageInfos: file_chainrpc_chainnotifier_proto_msgTypes, }.Build() File_chainrpc_chainnotifier_proto = out.File diff --git a/lnrpc/chainrpc/chainnotifier.pb.gw.go b/lnrpc/chainrpc/chainnotifier.pb.gw.go index 24265eab5b6..890b6b2c36e 100644 --- a/lnrpc/chainrpc/chainnotifier.pb.gw.go +++ b/lnrpc/chainrpc/chainnotifier.pb.gw.go @@ -106,6 +106,49 @@ func request_ChainNotifier_RegisterBlockEpochNtfn_0(ctx context.Context, marshal } +func request_ChainNotifier_RegisterPkScriptNtfn_0(ctx context.Context, marshaler runtime.Marshaler, client ChainNotifierClient, req *http.Request, pathParams map[string]string) (ChainNotifier_RegisterPkScriptNtfnClient, runtime.ServerMetadata, error) { + var metadata runtime.ServerMetadata + stream, err := client.RegisterPkScriptNtfn(ctx) + if err != nil { + grpclog.Infof("Failed to start streaming: %v", err) + return nil, metadata, err + } + dec := marshaler.NewDecoder(req.Body) + handleSend := func() error { + var protoReq PkScriptRequest + err := dec.Decode(&protoReq) + if err == io.EOF { + return err + } + if err != nil { + grpclog.Infof("Failed to decode request: %v", err) + return err + } + if err := stream.Send(&protoReq); err != nil { + grpclog.Infof("Failed to send request: %v", err) + return err + } + return nil + } + go func() { + for { + if err := handleSend(); err != nil { + break + } + } + if err := stream.CloseSend(); err != nil { + grpclog.Infof("Failed to terminate client stream: %v", err) + } + }() + header, err := stream.Header() + if err != nil { + grpclog.Infof("Failed to get header from client: %v", err) + return nil, metadata, err + } + metadata.HeaderMD = header + return stream, metadata, nil +} + // RegisterChainNotifierHandlerServer registers the http handlers for service ChainNotifier to "mux". // UnaryRPC :call ChainNotifierServer directly. // StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906. @@ -133,6 +176,13 @@ func RegisterChainNotifierHandlerServer(ctx context.Context, mux *runtime.ServeM return }) + mux.Handle("POST", pattern_ChainNotifier_RegisterPkScriptNtfn_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + err := status.Error(codes.Unimplemented, "streaming calls are not yet supported in the in-process transport") + _, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + }) + return nil } @@ -240,6 +290,28 @@ func RegisterChainNotifierHandlerClient(ctx context.Context, mux *runtime.ServeM }) + mux.Handle("POST", pattern_ChainNotifier_RegisterPkScriptNtfn_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/chainrpc.ChainNotifier/RegisterPkScriptNtfn", runtime.WithHTTPPathPattern("/v2/chainnotifier/register/pkscript")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_ChainNotifier_RegisterPkScriptNtfn_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_ChainNotifier_RegisterPkScriptNtfn_0(annotatedContext, mux, outboundMarshaler, w, req, func() (proto.Message, error) { return resp.Recv() }, mux.GetForwardResponseOptions()...) + + }) + return nil } @@ -249,6 +321,8 @@ var ( pattern_ChainNotifier_RegisterSpendNtfn_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"v2", "chainnotifier", "register", "spends"}, "")) pattern_ChainNotifier_RegisterBlockEpochNtfn_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"v2", "chainnotifier", "register", "blocks"}, "")) + + pattern_ChainNotifier_RegisterPkScriptNtfn_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"v2", "chainnotifier", "register", "pkscript"}, "")) ) var ( @@ -257,4 +331,6 @@ var ( forward_ChainNotifier_RegisterSpendNtfn_0 = runtime.ForwardResponseStream forward_ChainNotifier_RegisterBlockEpochNtfn_0 = runtime.ForwardResponseStream + + forward_ChainNotifier_RegisterPkScriptNtfn_0 = runtime.ForwardResponseStream ) diff --git a/lnrpc/chainrpc/chainnotifier.proto b/lnrpc/chainrpc/chainnotifier.proto index 36e66a279ef..43666e10f68 100644 --- a/lnrpc/chainrpc/chainnotifier.proto +++ b/lnrpc/chainrpc/chainnotifier.proto @@ -42,6 +42,26 @@ service ChainNotifier { missing processing a single block within the chain. */ rpc RegisterBlockEpochNtfn (BlockEpoch) returns (stream BlockEpoch); + + /* + RegisterPkScriptNtfn is a bidirectional streaming RPC that registers an + intent for a client to receive spend and/or confirmation notifications for + outputs paying to watched pkScripts. + + The first client message must be an empty registration request. After the + stream is established, the client can dynamically add or remove pkScripts by + sending mutation requests on the same stream. Notifications are delivered on + the response stream and reorg invalidations are surfaced by setting + disconnected=true on the notification. + + Add acknowledgments only mean the scripts were accepted and any requested + historical scan was queued; they do not mean historical backfill has + completed. Confirmation notifications are only sent once the requested + confirmation depth is reached. Reorg invalidations are sent on the same + stream with disconnected=true. + */ + rpc RegisterPkScriptNtfn (stream PkScriptRequest) + returns (stream PkScriptEvent); } message ConfRequest { @@ -198,3 +218,208 @@ message BlockEpoch { // The height of the block. uint32 height = 2; } + +enum PkScriptEventType { + PK_SCRIPT_EVENT_TYPE_CONFIRM = 0; + PK_SCRIPT_EVENT_TYPE_SPEND = 1; + PK_SCRIPT_EVENT_TYPE_CONFIRMATION_UPDATE = 2; +} + +message PkScriptRegisterRequest { +} + +message AddPkScriptRequest { + /* + Additional pkScripts to watch on this stream. Scripts already watched by + this stream are ignored and keep their original options. + */ + repeated bytes pk_scripts = 1; + + /* + The event types to watch for the added pkScripts. If omitted, both + confirmations and spends are watched. + */ + repeated PkScriptEventType events = 2; + + /* + The number of confirmations matched outputs should reach before + confirmation notifications are dispatched. If unset, the default is 1. + */ + uint32 num_confs = 3; + + /* + If true, then the relevant block for each pkScript notification will be + included in the event payload. + */ + bool include_block = 4; + + /* + If true, then the relevant transaction for each pkScript notification will + be included in the event payload. + */ + bool include_tx = 5; + + /* + If set, the notifier scans historical blocks from this height through the + current chain tip for the added pkScripts. If omitted, only future chain + activity is watched. Setting this to 0 explicitly scans from genesis. + + Historical scans run per add request, not per pkScript: all newly accepted + scripts in this add request are scanned together. Backend notifiers execute + one historical pkScript scan at a time, so large backfills can delay later + historical add requests. + */ + oneof historical_scan { + uint32 historical_scan_from = 6; + } + + /* + If true, partial confirmation progress events will be sent before the final + confirmation notification reaches num_confs. + */ + bool include_confirmation_updates = 7; +} + +message RemovePkScriptRequest { + // PkScripts to stop watching on this stream. + repeated bytes pk_scripts = 1; +} + +message PkScriptRequest { + oneof request { + PkScriptRegisterRequest register = 1; + AddPkScriptRequest add = 2; + RemovePkScriptRequest remove = 3; + } +} + +enum PkScriptMutationAction { + PK_SCRIPT_MUTATION_ACTION_REGISTER = 0; + PK_SCRIPT_MUTATION_ACTION_ADD = 1; + PK_SCRIPT_MUTATION_ACTION_REMOVE = 2; +} + +message PkScriptMutationAck { + // The mutation that was accepted for this stream. + PkScriptMutationAction action = 1; + + // The number of new scripts accepted by this mutation. + uint32 num_added = 2; + + // True if this add mutation queued a historical scan. + bool historical_scan_queued = 3; + + // The ID of the queued historical scan, if any. + uint64 historical_scan_id = 4; + + // The first height scanned by the queued historical scan. + uint32 historical_scan_start_height = 5; + + // The best-known final height of the queued historical scan at queue time. + uint32 historical_scan_end_height = 6; +} + +message PkScriptUtxo { + // The outpoint paying to the watched script. + Outpoint outpoint = 1; + + // The value of the watched output in satoshis. + int64 value = 2; + + // The watched output script. + bytes pk_script = 3; + + // The height at which the watched output was created. + uint32 block_height = 4; + + // The hash of the block that created the watched output. + bytes block_hash = 5; + + // The transaction index of the funding transaction in block_hash. + uint32 tx_index = 6; +} + +message PkScriptNotification { + // The kind of notification being delivered. + PkScriptEventType event_type = 1; + + // The height relevant to this event. + uint32 height = 2; + + // The hash of the relevant block for this event. + bytes block_hash = 3; + + // The hash of the relevant transaction for this event. + bytes tx_hash = 4; + + /* + The index of the relevant transaction in the block that mined it. For + confirmation notifications this is the funding transaction's index in the + UTXO block. For spend notifications this is the spending transaction's + index in block_hash. + */ + uint32 tx_index = 5; + + // The number of confirmations reached by the watched output. + uint32 num_confirmations = 6; + + // True if this notification invalidates a previously sent event. + bool disconnected = 7; + + // The watched output associated with this event. + PkScriptUtxo utxo = 8; + + /* + The raw bytes of the relevant transaction, if include_tx was requested. + For confirmation notifications this is the funding transaction. For spend + notifications this is the spending transaction. + */ + bytes raw_tx = 9; + + /* + The raw bytes of the relevant block, if include_block was requested. For + confirmation notifications this is the block at which the output reached + the requested confirmation depth. For spend notifications this is the + block containing the spend. + */ + bytes raw_block = 10; + + // The spending input index. This is only set for spend notifications. + uint32 input_index = 11; + + /* + The requested confirmation target. This is set for final confirmation and + partial confirmation progress notifications. + */ + uint32 required_confirmations = 12; +} + +message PkScriptHistoricalScan { + // The ID assigned to the historical scan when it was queued. + uint64 scan_id = 1; + + // The first height requested for the historical scan. + uint32 start_height = 2; + + // The final height scanned before completion or failure. + uint32 end_height = 3; + + // The last height processed successfully. + uint32 completed_height = 4; + + // A non-empty error means the historical scan failed before completion. + string error = 5; +} + +message PkScriptEvent { + oneof event { + // Acknowledges that a stream mutation was accepted. + PkScriptMutationAck ack = 1; + + // A notification for a watched output script. + PkScriptNotification notification = 2; + + // A historical scan lifecycle event. + PkScriptHistoricalScan historical_scan = 3; + } +} diff --git a/lnrpc/chainrpc/chainnotifier.swagger.json b/lnrpc/chainrpc/chainnotifier.swagger.json index b8e2924cdb7..1ce8dd5e584 100644 --- a/lnrpc/chainrpc/chainnotifier.swagger.json +++ b/lnrpc/chainrpc/chainnotifier.swagger.json @@ -102,6 +102,50 @@ ] } }, + "/v2/chainnotifier/register/pkscript": { + "post": { + "summary": "RegisterPkScriptNtfn is a bidirectional streaming RPC that registers an\nintent for a client to receive spend and/or confirmation notifications for\noutputs paying to watched pkScripts.", + "description": "The first client message must be an empty registration request. After the\nstream is established, the client can dynamically add or remove pkScripts by\nsending mutation requests on the same stream. Notifications are delivered on\nthe response stream and reorg invalidations are surfaced by setting\ndisconnected=true on the notification.\n\nAdd acknowledgments only mean the scripts were accepted and any requested\nhistorical scan was queued; they do not mean historical backfill has\ncompleted. Confirmation notifications are only sent once the requested\nconfirmation depth is reached. Reorg invalidations are sent on the same\nstream with disconnected=true.", + "operationId": "ChainNotifier_RegisterPkScriptNtfn", + "responses": { + "200": { + "description": "A successful response.(streaming responses)", + "schema": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/chainrpcPkScriptEvent" + }, + "error": { + "$ref": "#/definitions/rpcStatus" + } + }, + "title": "Stream result of chainrpcPkScriptEvent" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "body", + "description": " (streaming inputs)", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/chainrpcPkScriptRequest" + } + } + ], + "tags": [ + "ChainNotifier" + ] + } + }, "/v2/chainnotifier/register/spends": { "post": { "summary": "RegisterSpendNtfn is a synchronous response-streaming RPC that registers an\nintent for a client to be notification once a spend request has been spent\nby a transaction that has confirmed on-chain.", @@ -147,6 +191,47 @@ } }, "definitions": { + "chainrpcAddPkScriptRequest": { + "type": "object", + "properties": { + "pk_scripts": { + "type": "array", + "items": { + "type": "string", + "format": "byte" + }, + "description": "Additional pkScripts to watch on this stream. Scripts already watched by\nthis stream are ignored and keep their original options." + }, + "events": { + "type": "array", + "items": { + "$ref": "#/definitions/chainrpcPkScriptEventType" + }, + "description": "The event types to watch for the added pkScripts. If omitted, both\nconfirmations and spends are watched." + }, + "num_confs": { + "type": "integer", + "format": "int64", + "description": "The number of confirmations matched outputs should reach before\nconfirmation notifications are dispatched. If unset, the default is 1." + }, + "include_block": { + "type": "boolean", + "description": "If true, then the relevant block for each pkScript notification will be\nincluded in the event payload." + }, + "include_tx": { + "type": "boolean", + "description": "If true, then the relevant transaction for each pkScript notification will\nbe included in the event payload." + }, + "historical_scan_from": { + "type": "integer", + "format": "int64" + }, + "include_confirmation_updates": { + "type": "boolean", + "description": "If true, partial confirmation progress events will be sent before the final\nconfirmation notification reaches num_confs." + } + } + }, "chainrpcBlockEpoch": { "type": "object", "properties": { @@ -249,6 +334,229 @@ } } }, + "chainrpcPkScriptEvent": { + "type": "object", + "properties": { + "ack": { + "$ref": "#/definitions/chainrpcPkScriptMutationAck", + "description": "Acknowledges that a stream mutation was accepted." + }, + "notification": { + "$ref": "#/definitions/chainrpcPkScriptNotification", + "description": "A notification for a watched output script." + }, + "historical_scan": { + "$ref": "#/definitions/chainrpcPkScriptHistoricalScan", + "description": "A historical scan lifecycle event." + } + } + }, + "chainrpcPkScriptEventType": { + "type": "string", + "enum": [ + "PK_SCRIPT_EVENT_TYPE_CONFIRM", + "PK_SCRIPT_EVENT_TYPE_SPEND", + "PK_SCRIPT_EVENT_TYPE_CONFIRMATION_UPDATE" + ], + "default": "PK_SCRIPT_EVENT_TYPE_CONFIRM" + }, + "chainrpcPkScriptHistoricalScan": { + "type": "object", + "properties": { + "scan_id": { + "type": "string", + "format": "uint64", + "description": "The ID assigned to the historical scan when it was queued." + }, + "start_height": { + "type": "integer", + "format": "int64", + "description": "The first height requested for the historical scan." + }, + "end_height": { + "type": "integer", + "format": "int64", + "description": "The final height scanned before completion or failure." + }, + "completed_height": { + "type": "integer", + "format": "int64", + "description": "The last height processed successfully." + }, + "error": { + "type": "string", + "description": "A non-empty error means the historical scan failed before completion." + } + } + }, + "chainrpcPkScriptMutationAck": { + "type": "object", + "properties": { + "action": { + "$ref": "#/definitions/chainrpcPkScriptMutationAction", + "description": "The mutation that was accepted for this stream." + }, + "num_added": { + "type": "integer", + "format": "int64", + "description": "The number of new scripts accepted by this mutation." + }, + "historical_scan_queued": { + "type": "boolean", + "description": "True if this add mutation queued a historical scan." + }, + "historical_scan_id": { + "type": "string", + "format": "uint64", + "description": "The ID of the queued historical scan, if any." + }, + "historical_scan_start_height": { + "type": "integer", + "format": "int64", + "description": "The first height scanned by the queued historical scan." + }, + "historical_scan_end_height": { + "type": "integer", + "format": "int64", + "description": "The best-known final height of the queued historical scan at queue time." + } + } + }, + "chainrpcPkScriptMutationAction": { + "type": "string", + "enum": [ + "PK_SCRIPT_MUTATION_ACTION_REGISTER", + "PK_SCRIPT_MUTATION_ACTION_ADD", + "PK_SCRIPT_MUTATION_ACTION_REMOVE" + ], + "default": "PK_SCRIPT_MUTATION_ACTION_REGISTER" + }, + "chainrpcPkScriptNotification": { + "type": "object", + "properties": { + "event_type": { + "$ref": "#/definitions/chainrpcPkScriptEventType", + "description": "The kind of notification being delivered." + }, + "height": { + "type": "integer", + "format": "int64", + "description": "The height relevant to this event." + }, + "block_hash": { + "type": "string", + "format": "byte", + "description": "The hash of the relevant block for this event." + }, + "tx_hash": { + "type": "string", + "format": "byte", + "description": "The hash of the relevant transaction for this event." + }, + "tx_index": { + "type": "integer", + "format": "int64", + "description": "The index of the relevant transaction in the block that mined it. For\nconfirmation notifications this is the funding transaction's index in the\nUTXO block. For spend notifications this is the spending transaction's\nindex in block_hash." + }, + "num_confirmations": { + "type": "integer", + "format": "int64", + "description": "The number of confirmations reached by the watched output." + }, + "disconnected": { + "type": "boolean", + "description": "True if this notification invalidates a previously sent event." + }, + "utxo": { + "$ref": "#/definitions/chainrpcPkScriptUtxo", + "description": "The watched output associated with this event." + }, + "raw_tx": { + "type": "string", + "format": "byte", + "description": "The raw bytes of the relevant transaction, if include_tx was requested.\nFor confirmation notifications this is the funding transaction. For spend\nnotifications this is the spending transaction." + }, + "raw_block": { + "type": "string", + "format": "byte", + "description": "The raw bytes of the relevant block, if include_block was requested. For\nconfirmation notifications this is the block at which the output reached\nthe requested confirmation depth. For spend notifications this is the\nblock containing the spend." + }, + "input_index": { + "type": "integer", + "format": "int64", + "description": "The spending input index. This is only set for spend notifications." + }, + "required_confirmations": { + "type": "integer", + "format": "int64", + "description": "The requested confirmation target. This is set for final confirmation and\npartial confirmation progress notifications." + } + } + }, + "chainrpcPkScriptRegisterRequest": { + "type": "object" + }, + "chainrpcPkScriptRequest": { + "type": "object", + "properties": { + "register": { + "$ref": "#/definitions/chainrpcPkScriptRegisterRequest" + }, + "add": { + "$ref": "#/definitions/chainrpcAddPkScriptRequest" + }, + "remove": { + "$ref": "#/definitions/chainrpcRemovePkScriptRequest" + } + } + }, + "chainrpcPkScriptUtxo": { + "type": "object", + "properties": { + "outpoint": { + "$ref": "#/definitions/chainrpcOutpoint", + "description": "The outpoint paying to the watched script." + }, + "value": { + "type": "string", + "format": "int64", + "description": "The value of the watched output in satoshis." + }, + "pk_script": { + "type": "string", + "format": "byte", + "description": "The watched output script." + }, + "block_height": { + "type": "integer", + "format": "int64", + "description": "The height at which the watched output was created." + }, + "block_hash": { + "type": "string", + "format": "byte", + "description": "The hash of the block that created the watched output." + }, + "tx_index": { + "type": "integer", + "format": "int64", + "description": "The transaction index of the funding transaction in block_hash." + } + } + }, + "chainrpcRemovePkScriptRequest": { + "type": "object", + "properties": { + "pk_scripts": { + "type": "array", + "items": { + "type": "string", + "format": "byte" + }, + "description": "PkScripts to stop watching on this stream." + } + } + }, "chainrpcReorg": { "type": "object", "description": "TODO(wilmer): need to know how the client will use this first." diff --git a/lnrpc/chainrpc/chainnotifier.yaml b/lnrpc/chainrpc/chainnotifier.yaml index 8f67806e618..5266c72cec8 100644 --- a/lnrpc/chainrpc/chainnotifier.yaml +++ b/lnrpc/chainrpc/chainnotifier.yaml @@ -12,3 +12,6 @@ http: - selector: chainrpc.ChainNotifier.RegisterBlockEpochNtfn post: "/v2/chainnotifier/register/blocks" body: "*" + - selector: chainrpc.ChainNotifier.RegisterPkScriptNtfn + post: "/v2/chainnotifier/register/pkscript" + body: "*" diff --git a/lnrpc/chainrpc/chainnotifier_grpc.pb.go b/lnrpc/chainrpc/chainnotifier_grpc.pb.go index a50ac59abb7..8ca4baff753 100644 --- a/lnrpc/chainrpc/chainnotifier_grpc.pb.go +++ b/lnrpc/chainrpc/chainnotifier_grpc.pb.go @@ -45,6 +45,22 @@ type ChainNotifierClient interface { // point. This allows clients to be idempotent by ensuring that they do not // missing processing a single block within the chain. RegisterBlockEpochNtfn(ctx context.Context, in *BlockEpoch, opts ...grpc.CallOption) (ChainNotifier_RegisterBlockEpochNtfnClient, error) + // RegisterPkScriptNtfn is a bidirectional streaming RPC that registers an + // intent for a client to receive spend and/or confirmation notifications for + // outputs paying to watched pkScripts. + // + // The first client message must be an empty registration request. After the + // stream is established, the client can dynamically add or remove pkScripts by + // sending mutation requests on the same stream. Notifications are delivered on + // the response stream and reorg invalidations are surfaced by setting + // disconnected=true on the notification. + // + // Add acknowledgments only mean the scripts were accepted and any requested + // historical scan was queued; they do not mean historical backfill has + // completed. Confirmation notifications are only sent once the requested + // confirmation depth is reached. Reorg invalidations are sent on the same + // stream with disconnected=true. + RegisterPkScriptNtfn(ctx context.Context, opts ...grpc.CallOption) (ChainNotifier_RegisterPkScriptNtfnClient, error) } type chainNotifierClient struct { @@ -151,6 +167,37 @@ func (x *chainNotifierRegisterBlockEpochNtfnClient) Recv() (*BlockEpoch, error) return m, nil } +func (c *chainNotifierClient) RegisterPkScriptNtfn(ctx context.Context, opts ...grpc.CallOption) (ChainNotifier_RegisterPkScriptNtfnClient, error) { + stream, err := c.cc.NewStream(ctx, &ChainNotifier_ServiceDesc.Streams[3], "/chainrpc.ChainNotifier/RegisterPkScriptNtfn", opts...) + if err != nil { + return nil, err + } + x := &chainNotifierRegisterPkScriptNtfnClient{stream} + return x, nil +} + +type ChainNotifier_RegisterPkScriptNtfnClient interface { + Send(*PkScriptRequest) error + Recv() (*PkScriptEvent, error) + grpc.ClientStream +} + +type chainNotifierRegisterPkScriptNtfnClient struct { + grpc.ClientStream +} + +func (x *chainNotifierRegisterPkScriptNtfnClient) Send(m *PkScriptRequest) error { + return x.ClientStream.SendMsg(m) +} + +func (x *chainNotifierRegisterPkScriptNtfnClient) Recv() (*PkScriptEvent, error) { + m := new(PkScriptEvent) + if err := x.ClientStream.RecvMsg(m); err != nil { + return nil, err + } + return m, nil +} + // ChainNotifierServer is the server API for ChainNotifier service. // All implementations must embed UnimplementedChainNotifierServer // for forward compatibility @@ -182,6 +229,22 @@ type ChainNotifierServer interface { // point. This allows clients to be idempotent by ensuring that they do not // missing processing a single block within the chain. RegisterBlockEpochNtfn(*BlockEpoch, ChainNotifier_RegisterBlockEpochNtfnServer) error + // RegisterPkScriptNtfn is a bidirectional streaming RPC that registers an + // intent for a client to receive spend and/or confirmation notifications for + // outputs paying to watched pkScripts. + // + // The first client message must be an empty registration request. After the + // stream is established, the client can dynamically add or remove pkScripts by + // sending mutation requests on the same stream. Notifications are delivered on + // the response stream and reorg invalidations are surfaced by setting + // disconnected=true on the notification. + // + // Add acknowledgments only mean the scripts were accepted and any requested + // historical scan was queued; they do not mean historical backfill has + // completed. Confirmation notifications are only sent once the requested + // confirmation depth is reached. Reorg invalidations are sent on the same + // stream with disconnected=true. + RegisterPkScriptNtfn(ChainNotifier_RegisterPkScriptNtfnServer) error mustEmbedUnimplementedChainNotifierServer() } @@ -198,6 +261,9 @@ func (UnimplementedChainNotifierServer) RegisterSpendNtfn(*SpendRequest, ChainNo func (UnimplementedChainNotifierServer) RegisterBlockEpochNtfn(*BlockEpoch, ChainNotifier_RegisterBlockEpochNtfnServer) error { return status.Errorf(codes.Unimplemented, "method RegisterBlockEpochNtfn not implemented") } +func (UnimplementedChainNotifierServer) RegisterPkScriptNtfn(ChainNotifier_RegisterPkScriptNtfnServer) error { + return status.Errorf(codes.Unimplemented, "method RegisterPkScriptNtfn not implemented") +} func (UnimplementedChainNotifierServer) mustEmbedUnimplementedChainNotifierServer() {} // UnsafeChainNotifierServer may be embedded to opt out of forward compatibility for this service. @@ -274,6 +340,32 @@ func (x *chainNotifierRegisterBlockEpochNtfnServer) Send(m *BlockEpoch) error { return x.ServerStream.SendMsg(m) } +func _ChainNotifier_RegisterPkScriptNtfn_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(ChainNotifierServer).RegisterPkScriptNtfn(&chainNotifierRegisterPkScriptNtfnServer{stream}) +} + +type ChainNotifier_RegisterPkScriptNtfnServer interface { + Send(*PkScriptEvent) error + Recv() (*PkScriptRequest, error) + grpc.ServerStream +} + +type chainNotifierRegisterPkScriptNtfnServer struct { + grpc.ServerStream +} + +func (x *chainNotifierRegisterPkScriptNtfnServer) Send(m *PkScriptEvent) error { + return x.ServerStream.SendMsg(m) +} + +func (x *chainNotifierRegisterPkScriptNtfnServer) Recv() (*PkScriptRequest, error) { + m := new(PkScriptRequest) + if err := x.ServerStream.RecvMsg(m); err != nil { + return nil, err + } + return m, nil +} + // ChainNotifier_ServiceDesc is the grpc.ServiceDesc for ChainNotifier service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -297,6 +389,12 @@ var ChainNotifier_ServiceDesc = grpc.ServiceDesc{ Handler: _ChainNotifier_RegisterBlockEpochNtfn_Handler, ServerStreams: true, }, + { + StreamName: "RegisterPkScriptNtfn", + Handler: _ChainNotifier_RegisterPkScriptNtfn_Handler, + ServerStreams: true, + ClientStreams: true, + }, }, Metadata: "chainrpc/chainnotifier.proto", } diff --git a/lnrpc/chainrpc/config_active.go b/lnrpc/chainrpc/config_active.go index c84e1ba49e9..c09a7f44a0a 100644 --- a/lnrpc/chainrpc/config_active.go +++ b/lnrpc/chainrpc/config_active.go @@ -34,6 +34,10 @@ type Config struct { // simply to proxy valid requests to the active chain notifier instance. ChainNotifier chainntnfs.ChainNotifier + // PkScriptNotifier is the pkScript notifier instance that backs the + // pkScript notification RPC stream. + PkScriptNotifier chainntnfs.PkScriptNotifier + // Chain provides access to the most up-to-date blockchain data. Chain lnwallet.BlockChainIO } diff --git a/lnrpc/chainrpc/driver.go b/lnrpc/chainrpc/driver.go index 89a6a116a4d..32c9f479d6b 100644 --- a/lnrpc/chainrpc/driver.go +++ b/lnrpc/chainrpc/driver.go @@ -50,6 +50,10 @@ func createNewSubServer(configRegistry lnrpc.SubServerConfigDispatcher) ( return nil, nil, fmt.Errorf("ChainNotifier must be set to " + "create chainrpc") + case config.PkScriptNotifier == nil: + return nil, nil, fmt.Errorf("PkScriptNotifier must be set to " + + "create chainrpc") + case config.Chain == nil: return nil, nil, fmt.Errorf("field Chain must be set to " + "create chainrpc") diff --git a/lntest/rpc/chain_notifier.go b/lntest/rpc/chain_notifier.go index 4f5a815f87c..a67699ecc63 100644 --- a/lntest/rpc/chain_notifier.go +++ b/lntest/rpc/chain_notifier.go @@ -42,3 +42,14 @@ func (h *HarnessRPC) RegisterSpendNtfn(req *chainrpc.SpendRequest) SpendClient { return client } + +type PkScriptClient chainrpc.ChainNotifier_RegisterPkScriptNtfnClient + +// RegisterPkScriptNtfn creates a bidirectional notification stream to watch a +// set of pkScripts for spends and/or confirmations. +func (h *HarnessRPC) RegisterPkScriptNtfn() PkScriptClient { + client, err := h.ChainClient.RegisterPkScriptNtfn(h.runCtx) + h.NoError(err, "RegisterPkScriptNtfn") + + return client +} diff --git a/subrpcserver_config.go b/subrpcserver_config.go index 856553c38fd..d777113fe54 100644 --- a/subrpcserver_config.go +++ b/subrpcserver_config.go @@ -232,6 +232,9 @@ func (s *subRPCServerConfigs) PopulateDependencies(cfg *Config, subCfgValue.FieldByName("ChainNotifier").Set( reflect.ValueOf(cc.ChainNotifier), ) + subCfgValue.FieldByName("PkScriptNotifier").Set( + reflect.ValueOf(cc.ChainNotifier), + ) subCfgValue.FieldByName("Chain").Set( reflect.ValueOf(cc.ChainIO), )