diff --git a/chainntnfs/bitcoindnotify/bitcoind.go b/chainntnfs/bitcoindnotify/bitcoind.go index 5028c78df1d..cc80ffbee68 100644 --- a/chainntnfs/bitcoindnotify/bitcoind.go +++ b/chainntnfs/bitcoindnotify/bitcoind.go @@ -953,6 +953,34 @@ 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) { + + _, addedScripts, err := reg.AddPkScripts(pkScripts, opts...) + if err != nil { + return nil, err + } + + return chainntnfs.NewPkScriptAddResult(addedScripts), 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 ebe2c62b8d7..2018ecab792 100644 --- a/chainntnfs/btcdnotify/btcd.go +++ b/chainntnfs/btcdnotify/btcd.go @@ -1014,6 +1014,34 @@ func (b *BtcdNotifier) RegisterConfirmationsNtfn(txid *chainhash.Hash, } } +// 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) { + + _, addedScripts, err := reg.AddPkScripts(pkScripts, opts...) + if err != nil { + return nil, err + } + + return chainntnfs.NewPkScriptAddResult(addedScripts), 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 9dc9fd7b94f..157be989f85 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,32 @@ 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 + + // 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 +114,47 @@ 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 + } +} + +// 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 +238,26 @@ 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 for future +// chain activity. 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 +443,123 @@ 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 +) + +// 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 +} + +// PkScriptAddResult describes the result of adding pkScripts to a registration. +type PkScriptAddResult struct { + 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. Scripts + // already watched by this stream 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 +570,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 06752f059c2..d0eed5f433f 100644 --- a/chainntnfs/neutrinonotify/neutrino.go +++ b/chainntnfs/neutrinonotify/neutrino.go @@ -79,6 +79,14 @@ type NeutrinoNotifier struct { // blockCache is an LRU block cache. blockCache *blockcache.BlockCache + // backendPkScripts tracks scripts that have been installed in the + // rescan's append-only filter. Entries are retained for the lifetime of + // the notifier so removing and re-adding a local watch doesn't grow the + // backend filter with duplicates. + backendPkScriptsMtx sync.Mutex + backendPkScripts map[string]struct{} + backendPkScriptsBytes uint64 + wg sync.WaitGroup quit chan struct{} } @@ -115,6 +123,8 @@ func New(node *neutrino.ChainService, spendHintCache chainntnfs.SpendHintCache, blockCache: blockCache, + backendPkScripts: make(map[string]struct{}), + quit: make(chan struct{}), } } @@ -525,9 +535,7 @@ func (n *NeutrinoNotifier) notificationDispatcher() { // Drain the chainUpdates chan so the caller // listening on errChan can be sure that // updates after receiving the error will have - // the filter applied. This allows the caller - // to update their EndHeight if they're - // performing a historical scan. + // the filter applied. n.drainChainUpdates() // After draining, send the error to the @@ -918,6 +926,164 @@ func (n *NeutrinoNotifier) RegisterSpendNtfn(outpoint *wire.OutPoint, return ntfn.Event, nil } +// pkScriptsToInputs converts scripts into exact neutrino filter entries. +// AddInputs places each PkScript verbatim in the filter watch list, avoiding +// lossy conversions through standard address types. The zero outpoint also +// lets neutrino identify future spends by script. +func pkScriptsToInputs(pkScripts [][]byte) ([]neutrino.InputWithScript, error) { + dedup := make(map[string]struct{}, len(pkScripts)) + inputs := make([]neutrino.InputWithScript, 0, len(pkScripts)) + + for _, pkScript := range pkScripts { + if len(pkScript) == 0 { + return nil, chainntnfs.ErrNoScript + } + + // Basic compact filters deliberately omit OP_RETURN outputs, + // so they cannot provide a reliable notification for these + // scripts. + if pkScript[0] == txscript.OP_RETURN { + return nil, fmt.Errorf("%w: OP_RETURN scripts are not "+ + "included in neutrino compact filters", + chainntnfs.ErrUnsupportedPkScript) + } + + key := string(pkScript) + if _, ok := dedup[key]; ok { + continue + } + dedup[key] = struct{}{} + + inputs = append(inputs, neutrino.InputWithScript{ + OutPoint: chainntnfs.ZeroOutPoint, + PkScript: pkScript, + }) + } + + return inputs, nil +} + +// updatePkScriptFilter updates neutrino's rescan filter to watch for the given +// pkScripts. +func (n *NeutrinoNotifier) updatePkScriptFilter(pkScripts [][]byte, + rewindHeight uint32) error { + + inputs, err := pkScriptsToInputs(pkScripts) + if err != nil { + return err + } + + // Neutrino's rescan filter is append-only. Serialize updates and retain + // successful entries so concurrent registrations and later remove/add + // cycles don't install the same script more than once. + n.backendPkScriptsMtx.Lock() + defer n.backendPkScriptsMtx.Unlock() + + if n.backendPkScripts == nil { + n.backendPkScripts = make(map[string]struct{}) + } + + newInputs := make([]neutrino.InputWithScript, 0, len(inputs)) + var newBytes uint64 + for _, input := range inputs { + key := string(input.PkScript) + if _, ok := n.backendPkScripts[key]; ok { + continue + } + + newInputs = append(newInputs, input) + newBytes += uint64(len(input.PkScript)) + } + + if len(newInputs) == 0 { + return nil + } + + newCount := len(n.backendPkScripts) + len(newInputs) + if newCount > chainntnfs.MaxPkScriptWatches { + return fmt.Errorf("%w: neutrino backend would watch %d "+ + "scripts, limit is %d", chainntnfs.ErrTooManyPkScripts, + newCount, chainntnfs.MaxPkScriptWatches) + } + + totalBytes := n.backendPkScriptsBytes + newBytes + if totalBytes > chainntnfs.MaxPkScriptWatchBytes { + return fmt.Errorf("%w: neutrino backend would watch %d "+ + "script bytes, limit is %d", + chainntnfs.ErrTooManyPkScripts, totalBytes, + chainntnfs.MaxPkScriptWatchBytes) + } + + errChan := make(chan error, 1) + select { + case n.notificationRegistry <- &rescanFilterUpdate{ + updateOptions: []neutrino.UpdateOption{ + neutrino.AddInputs(newInputs...), + 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) + } + + for _, input := range newInputs { + n.backendPkScripts[string(input.PkScript)] = struct{}{} + } + n.backendPkScriptsBytes = totalBytes + + 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 := pkScriptsToInputs(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 +1173,74 @@ func (n *NeutrinoNotifier) RegisterConfirmationsNtfn(txid *chainhash.Hash, return ntfn.Event, nil } +// 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: %w", + 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 + } + + var mutationMtx sync.Mutex + reg.Event.AddPkScripts = func( + pkScripts [][]byte, opts ...chainntnfs.NotifierOption, + ) (*chainntnfs.PkScriptAddResult, error) { + + mutationMtx.Lock() + defer mutationMtx.Unlock() + + err := n.validatePkScriptFilterUpdate(pkScripts) + if err != nil { + return nil, err + } + + addHeight, addedScripts, err := reg.AddPkScripts( + pkScripts, opts..., + ) + if err != nil { + return nil, err + } + + result := chainntnfs.NewPkScriptAddResult(addedScripts) + + if len(addedScripts) > 0 { + err := n.updatePkScriptFilter(addedScripts, addHeight) + if err != nil { + removeErr := reg.RemovePkScripts(addedScripts) + if removeErr != nil { + return nil, pkScriptRollbackErr( + "update filter", + err, removeErr, + ) + } + + return nil, err + } + } + + return result, nil + } + + reg.Event.RemovePkScripts = func(pkScripts [][]byte) error { + mutationMtx.Lock() + defer mutationMtx.Unlock() + + 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..225938d73a2 --- /dev/null +++ b/chainntnfs/neutrinonotify/neutrino_test.go @@ -0,0 +1,350 @@ +package neutrinonotify + +import ( + "errors" + "sync" + "testing" + "time" + + "github.com/btcsuite/btcd/btcutil/v2/gcs/builder" + "github.com/btcsuite/btcd/txscript/v2" + "github.com/btcsuite/btcd/wire/v2" + "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, +} + +// testBareMultisigScript returns a standard bare multisig script. Converting +// this script into its extracted pubkey address would produce a different +// pay-to-pubkey script, so it is a useful regression case for exact matching. +func testBareMultisigScript(t *testing.T) []byte { + t.Helper() + + pubKey := []byte{ + 0x02, 0x79, 0xbe, 0x66, 0x7e, 0xf9, 0xdc, 0xbb, + 0xac, 0x55, 0xa0, 0x62, 0x95, 0xce, 0x87, 0x0b, + 0x07, 0x02, 0x9b, 0xfc, 0xdb, 0x2d, 0xce, 0x28, + 0xd9, 0x59, 0xf2, 0x81, 0x5b, 0x16, 0xf8, 0x17, + 0x98, + } + + pkScript, err := txscript.NewScriptBuilder(). + AddOp(txscript.OP_1). + AddData(pubKey). + AddOp(txscript.OP_1). + AddOp(txscript.OP_CHECKMULTISIG). + Script() + require.NoError(t, err) + + return pkScript +} + +// TestPkScriptsToInputsUsesExactScripts ensures all scripts included in basic +// compact filters are installed without an address conversion. +func TestPkScriptsToInputsUsesExactScripts(t *testing.T) { + t.Parallel() + + pkScripts := [][]byte{ + {txscript.OP_TRUE}, + testBareMultisigScript(t), + testP2SHPkScript, + } + inputs, err := pkScriptsToInputs(pkScripts) + require.NoError(t, err) + require.Len(t, inputs, len(pkScripts)) + + block := &wire.MsgBlock{} + tx := wire.NewMsgTx(2) + for i, pkScript := range pkScripts { + require.Equal(t, chainntnfs.ZeroOutPoint, inputs[i].OutPoint) + require.Equal(t, pkScript, inputs[i].PkScript) + + tx.AddTxOut(&wire.TxOut{PkScript: pkScript}) + } + require.NoError(t, block.AddTransaction(tx)) + + filter, err := builder.BuildBasicFilter(block, nil) + require.NoError(t, err) + + blockHash := block.BlockHash() + key := builder.DeriveKey(&blockHash) + for _, pkScript := range pkScripts { + match, err := filter.Match(key, pkScript) + require.NoError(t, err) + require.True(t, match) + } +} + +// TestPkScriptsToInputsRejectsOpReturn ensures scripts omitted from basic +// compact filters are rejected before mutating the local registration. +func TestPkScriptsToInputsRejectsOpReturn(t *testing.T) { + t.Parallel() + + _, err := pkScriptsToInputs([][]byte{{ + txscript.OP_RETURN, txscript.OP_DATA_1, 0x01, + }}) + require.ErrorIs(t, err, chainntnfs.ErrUnsupportedPkScript) +} + +// TestPkScriptsToInputsDeduplicatesScripts ensures duplicate scripts only +// produce one exact neutrino filter input. +func TestPkScriptsToInputsDeduplicatesScripts(t *testing.T) { + t.Parallel() + + inputs, err := pkScriptsToInputs( + [][]byte{testP2SHPkScript, testP2SHPkScript}, + ) + require.NoError(t, err) + require.Len(t, inputs, 1) +} + +func newTestNeutrinoNotifier() *NeutrinoNotifier { + return &NeutrinoNotifier{ + notificationRegistry: make(chan interface{}), + backendPkScripts: make(map[string]struct{}), + quit: make(chan struct{}), + } +} + +func updateFilterAsync(n *NeutrinoNotifier, pkScripts [][]byte) <-chan error { + errChan := make(chan error, 1) + go func() { + errChan <- n.updatePkScriptFilter(pkScripts, 1) + }() + + return errChan +} + +func receiveFilterUpdate(t *testing.T, + n *NeutrinoNotifier) *rescanFilterUpdate { + + t.Helper() + + select { + case msg := <-n.notificationRegistry: + update, ok := msg.(*rescanFilterUpdate) + require.True(t, ok) + + return update + + case <-time.After(5 * time.Second): + t.Fatal("filter update not received") + return nil + } +} + +// TestUpdatePkScriptFilterFailureCanRetry verifies a failed backend update is +// not remembered as installed and a retry reaches the backend again. +func TestUpdatePkScriptFilterFailureCanRetry(t *testing.T) { + t.Parallel() + + n := newTestNeutrinoNotifier() + backendErr := errors.New("backend update failed") + + result := updateFilterAsync(n, [][]byte{{txscript.OP_TRUE}}) + update := receiveFilterUpdate(t, n) + update.errChan <- backendErr + require.ErrorIs(t, <-result, backendErr) + require.Empty(t, n.backendPkScripts) + require.Zero(t, n.backendPkScriptsBytes) + + result = updateFilterAsync(n, [][]byte{{txscript.OP_TRUE}}) + update = receiveFilterUpdate(t, n) + update.errChan <- nil + require.NoError(t, <-result) + require.Len(t, n.backendPkScripts, 1) + require.Equal(t, uint64(1), n.backendPkScriptsBytes) +} + +// TestUpdatePkScriptFilterConcurrentDedup verifies concurrent additions are +// serialized around the backend update and only install one filter entry. +func TestUpdatePkScriptFilterConcurrentDedup(t *testing.T) { + t.Parallel() + + n := newTestNeutrinoNotifier() + start := make(chan struct{}) + results := make(chan error, 2) + var callers sync.WaitGroup + callers.Add(2) + + for i := 0; i < 2; i++ { + go func() { + defer callers.Done() + <-start + results <- n.updatePkScriptFilter( + [][]byte{{txscript.OP_TRUE}}, 1, + ) + }() + } + close(start) + + update := receiveFilterUpdate(t, n) + update.errChan <- nil + callers.Wait() + close(results) + for err := range results { + require.NoError(t, err) + } + + select { + case <-n.notificationRegistry: + t.Fatal("duplicate backend filter update") + default: + } + require.Len(t, n.backendPkScripts, 1) +} + +// TestConcurrentRegistrationAddsRollbackIndependently verifies one failed add +// cannot roll back a concurrent successful add on the same registration. +func TestConcurrentRegistrationAddsRollbackIndependently(t *testing.T) { + t.Parallel() + + n := newTestNeutrinoNotifier() + n.txNotifier = chainntnfs.NewTxNotifier( + 1, chainntnfs.ReorgSafetyLimit, nil, nil, + ) + defer n.txNotifier.TearDown() + + reg, err := n.RegisterPkScriptNotifier() + require.NoError(t, err) + + type addResponse struct { + result *chainntnfs.PkScriptAddResult + err error + } + add := func() <-chan addResponse { + response := make(chan addResponse, 1) + go func() { + result, err := reg.AddPkScripts( + [][]byte{{txscript.OP_TRUE}}, + ) + response <- addResponse{result: result, err: err} + }() + + return response + } + + first := add() + firstUpdate := receiveFilterUpdate(t, n) + second := add() + + backendErr := errors.New("backend update failed") + firstUpdate.errChan <- backendErr + firstResponse := <-first + require.ErrorIs(t, firstResponse.err, backendErr) + + secondUpdate := receiveFilterUpdate(t, n) + secondUpdate.errChan <- nil + secondResponse := <-second + require.NoError(t, secondResponse.err) + require.Equal(t, uint32(1), secondResponse.result.NumAdded) + require.Len(t, n.backendPkScripts, 1) +} + +// TestRegistrationCancelDoesNotWaitForFilterUpdate ensures cancellation stays +// responsive even while an add is waiting for the backend. +func TestRegistrationCancelDoesNotWaitForFilterUpdate(t *testing.T) { + t.Parallel() + + n := newTestNeutrinoNotifier() + n.txNotifier = chainntnfs.NewTxNotifier( + 1, chainntnfs.ReorgSafetyLimit, nil, nil, + ) + defer n.txNotifier.TearDown() + + reg, err := n.RegisterPkScriptNotifier() + require.NoError(t, err) + + addResult := make(chan error, 1) + go func() { + _, err := reg.AddPkScripts([][]byte{{txscript.OP_TRUE}}) + addResult <- err + }() + update := receiveFilterUpdate(t, n) + + canceled := make(chan struct{}) + go func() { + reg.Cancel() + close(canceled) + }() + + select { + case <-canceled: + case <-time.After(time.Second): + t.Fatal("cancel blocked on backend filter update") + } + + update.errChan <- nil + require.NoError(t, <-addResult) +} + +// TestUpdatePkScriptFilterLifetimeByteLimit ensures unique add/remove churn +// cannot grow neutrino's append-only filter beyond the existing global limit. +func TestUpdatePkScriptFilterLifetimeByteLimit(t *testing.T) { + t.Parallel() + + n := newTestNeutrinoNotifier() + n.backendPkScriptsBytes = chainntnfs.MaxPkScriptWatchBytes + + err := n.updatePkScriptFilter([][]byte{{txscript.OP_TRUE}}, 1) + require.ErrorIs(t, err, chainntnfs.ErrTooManyPkScripts) + require.Empty(t, n.backendPkScripts) + + select { + case <-n.notificationRegistry: + t.Fatal("over-limit backend filter update") + default: + } +} + +// TestPkScriptFilterDedupSurvivesRemoval verifies local removal and stream +// cancellation don't cause the append-only backend filter to grow on re-add. +func TestPkScriptFilterDedupSurvivesRemoval(t *testing.T) { + t.Parallel() + + n := newTestNeutrinoNotifier() + n.txNotifier = chainntnfs.NewTxNotifier( + 1, chainntnfs.ReorgSafetyLimit, nil, nil, + ) + defer n.txNotifier.TearDown() + + reg, err := n.RegisterPkScriptNotifier() + require.NoError(t, err) + + result := make(chan error, 1) + go func() { + _, err := reg.AddPkScripts([][]byte{{txscript.OP_TRUE}}) + result <- err + }() + update := receiveFilterUpdate(t, n) + update.errChan <- nil + require.NoError(t, <-result) + + require.NoError(t, reg.RemovePkScripts( + [][]byte{{txscript.OP_TRUE}}, + )) + addResult, err := reg.AddPkScripts([][]byte{{txscript.OP_TRUE}}) + require.NoError(t, err) + require.Equal(t, uint32(1), addResult.NumAdded) + + reg.Cancel() + reg, err = n.RegisterPkScriptNotifier() + require.NoError(t, err) + addResult, err = reg.AddPkScripts([][]byte{{txscript.OP_TRUE}}) + require.NoError(t, err) + require.Equal(t, uint32(1), addResult.NumAdded) + + select { + case <-n.notificationRegistry: + t.Fatal("duplicate backend filter update after re-add") + default: + } +} diff --git a/chainntnfs/pkscriptnotifier.go b/chainntnfs/pkscriptnotifier.go new file mode 100644 index 00000000000..ab588c4af78 --- /dev/null +++ b/chainntnfs/pkscriptnotifier.go @@ -0,0 +1,2221 @@ +package chainntnfs + +import ( + "container/list" + "errors" + "fmt" + "sync" + "sync/atomic" + + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/txscript/v2" + "github.com/btcsuite/btcd/wire/v2" +) + +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 + + // 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 + + // MaxPkScriptMatchesPerRegistration is the maximum number of matched + // outputs retained by a single pkScript notification registration. + MaxPkScriptMatchesPerRegistration = 100000 + + // MaxPkScriptMatchBytesPerRegistration is the approximate maximum + // memory footprint of matched outputs retained by one registration. + MaxPkScriptMatchBytesPerRegistration = 64 << 20 + + // MaxPkScriptMatches is the maximum number of matched outputs retained + // across all active pkScript notification registrations. + MaxPkScriptMatches = 500000 + + // MaxPkScriptMatchBytes is the approximate maximum memory footprint of + // matched outputs retained across all active registrations. + MaxPkScriptMatchBytes = 256 << 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") + + // ErrPkScriptMatchLimit is the terminal error for a registration whose + // retained matched-output state would exceed a resource limit. + ErrPkScriptMatchLimit = errors.New("pkScript matched output limit " + + "exceeded") + + // 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") + + // 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 + + // retainedBytes is this match's contribution to the notifier's retained + // match byte accounting. A zero value means the match was not + // accounted, which is useful for manually assembled test state. + retainedBytes uint64 + + 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 +} + +// pkScriptPendingSpend holds a spend discovered while scanning a connected +// block. Spends are dispatched after receives and confirmations for that block +// so same-block parent/child activity is delivered in causal order. +type pkScriptPendingSpend struct { + subID uint64 + outpoint wire.OutPoint + txHash *chainhash.Hash + txIndex uint32 + inputIndex uint32 + height uint32 + blockHash *chainhash.Hash + tx *wire.MsgTx + block *wire.MsgBlock +} + +// pkScriptSubscription tracks pkScript notifications for a single client. +type pkScriptSubscription struct { + id uint64 + + // 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 + + // matches stores all outputs currently tracked by this subscription. + matches map[wire.OutPoint]*pkScriptMatch + + // numMatches and matchBytes account for the matched output state + // retained by this subscription. + numMatches int + matchBytes uint64 + + 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.stopWithError(ErrTxNotifierExiting) +} + +// stopWithError terminates the queue with the given terminal error and waits +// for its worker to exit. +func (q *pkScriptNotificationQueue) stopWithError(err error) { + q.mtx.Lock() + q.stopLocked(err) + 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 + q.pending.Init() + q.pendingBytes = 0 + 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 +} + +// 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 + // 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) (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) { + n.Lock() + defer n.Unlock() + + select { + case <-n.quit: + return nil, ErrTxNotifierExiting + default: + } + + 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), + 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, + ) (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) (uint32, [][]byte, error) { + + select { + case <-n.quit: + return 0, nil, ErrTxNotifierExiting + default: + } + + err := validatePkScripts(pkScripts) + if err != nil { + return 0, nil, err + } + opts := DefaultNotifierOptions() + for _, optFunc := range optFuncs { + optFunc(opts) + } + + err = n.validatePkScriptOptions(opts) + if err != nil { + return 0, nil, err + } + + n.Lock() + defer n.Unlock() + + sub, ok := n.pkScriptNotifications[id] + if !ok { + return 0, nil, fmt.Errorf( + "pkScript subscription %d not found", id, + ) + } + err = n.validatePkScriptResourceLimits(sub, pkScripts) + if err != nil { + return 0, nil, err + } + + cfg := pkScriptWatchConfigFromOptions(opts) + addedScripts := n.addPkScripts(sub, pkScripts, cfg) + + return 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) + 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) { + 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) + } + if sub.numMatches > 0 { + if n.numPkScriptMatches >= sub.numMatches { + n.numPkScriptMatches -= sub.numMatches + } else { + n.numPkScriptMatches = 0 + } + sub.numMatches = 0 + } + if sub.matchBytes > 0 { + if n.numPkScriptMatchBytes >= sub.matchBytes { + n.numPkScriptMatchBytes -= sub.matchBytes + } else { + n.numPkScriptMatchBytes = 0 + } + sub.matchBytes = 0 + } + + if sub.notificationQueue != nil { + 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) [][]byte { + + var addedScripts [][]byte + + for _, pkScript := range pkScripts { + key := string(pkScript) + if _, ok := sub.scripts[key]; ok { + continue + } + + sub.scripts[key] = cfg + sub.scriptBytes += uint64(len(pkScript)) + 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{}{} + } + + return addedScripts +} + +// NewPkScriptAddResult returns caller-facing metadata for an AddPkScripts +// mutation. +func NewPkScriptAddResult(addedScripts [][]byte) *PkScriptAddResult { + return &PkScriptAddResult{ + NumAdded: uint32(len(addedScripts)), + } +} + +// 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 +} + +// copyPkScriptUTXO returns a detached copy of utxo. +func copyPkScriptUTXO(utxo *PkScriptUTXO) *PkScriptUTXO { + if utxo == nil { + return nil + } + + utxoCopy := *utxo + utxoCopy.PkScript = copyBytes(utxo.PkScript) + utxoCopy.BlockHash = copyHash(utxo.BlockHash) + + return &utxoCopy +} + +// 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() +} + +// retainMsgBlock returns one detached block copy shared by notifier-internal +// state for the duration of processing a connected block. +func retainMsgBlock(block *wire.MsgBlock, + retained **wire.MsgBlock) *wire.MsgBlock { + + if block == nil { + return nil + } + if *retained == nil { + *retained = copyMsgBlock(block) + } + + return *retained +} + +// 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()) + } + + return size +} + +// pkScriptMatchSize estimates the memory retained for a matched output. Shared +// transaction and block payloads are deliberately charged to every match that +// references them, keeping the resource guard conservative. +func pkScriptMatchSize(match *pkScriptMatch) uint64 { + if match == nil { + return 0 + } + + var size uint64 = 256 + if match.utxo != nil { + size += uint64(len(match.utxo.PkScript)) + 128 + } + if match.fundingTx != nil { + size += uint64(match.fundingTx.SerializeSize()) + } + if match.confirmBlock != nil { + size += uint64(match.confirmBlock.SerializeSize()) + } + if match.spendTx != nil { + size += uint64(match.spendTx.SerializeSize()) + } + if match.spendBlock != nil { + size += uint64(match.spendBlock.SerializeSize()) + } + for _, update := range match.confirmUpdates { + size += 128 + if update.block != nil { + size += uint64(update.block.SerializeSize()) + } + } + + return size +} + +// pkScriptMatchBytesExceeded returns true if adding delta to current would +// exceed limit without relying on overflowing unsigned addition. +func pkScriptMatchBytesExceeded(current, delta, limit uint64) bool { + return current > limit || delta > limit-current +} + +// validatePkScriptMatchResourceLimits checks whether a new match can be +// retained by a subscription and the notifier. +// +// NOTE: This method must be called with the TxNotifier's lock held. +func (n *TxNotifier) validatePkScriptMatchResourceLimits( + sub *pkScriptSubscription, size uint64) error { + + if sub.numMatches >= MaxPkScriptMatchesPerRegistration { + return fmt.Errorf("%w: registration would retain more than %d "+ + "matched outputs", ErrPkScriptMatchLimit, + MaxPkScriptMatchesPerRegistration) + } + if n.numPkScriptMatches >= MaxPkScriptMatches { + return fmt.Errorf("%w: notifier would retain more than %d "+ + "matched outputs", ErrPkScriptMatchLimit, + MaxPkScriptMatches) + } + if pkScriptMatchBytesExceeded( + sub.matchBytes, size, MaxPkScriptMatchBytesPerRegistration, + ) { + + return fmt.Errorf("%w: registration matched output state "+ + "would exceed %d bytes", ErrPkScriptMatchLimit, + MaxPkScriptMatchBytesPerRegistration) + } + if pkScriptMatchBytesExceeded( + n.numPkScriptMatchBytes, size, MaxPkScriptMatchBytes, + ) { + + return fmt.Errorf("%w: notifier matched output state would "+ + "exceed %d bytes", ErrPkScriptMatchLimit, + MaxPkScriptMatchBytes) + } + + return nil +} + +// validatePkScriptMatchByteGrowth checks whether an existing match can retain +// an additional payload. +// +// NOTE: This method must be called with the TxNotifier's lock held. +func (n *TxNotifier) validatePkScriptMatchByteGrowth( + sub *pkScriptSubscription, delta uint64) error { + + if pkScriptMatchBytesExceeded( + sub.matchBytes, delta, MaxPkScriptMatchBytesPerRegistration, + ) { + + return fmt.Errorf("%w: registration matched output state "+ + "would exceed %d bytes", ErrPkScriptMatchLimit, + MaxPkScriptMatchBytesPerRegistration) + } + if pkScriptMatchBytesExceeded( + n.numPkScriptMatchBytes, delta, MaxPkScriptMatchBytes, + ) { + + return fmt.Errorf("%w: notifier matched output state would "+ + "exceed %d bytes", ErrPkScriptMatchLimit, + MaxPkScriptMatchBytes) + } + + return nil +} + +// failPkScriptMatchLimit cancels a subscription with a terminal resource +// error. Other subscriptions and block processing remain unaffected. +// +// NOTE: This method must be called with the TxNotifier's lock held. +func (n *TxNotifier) failPkScriptMatchLimit( + sub *pkScriptSubscription, err error) { + + Log.Warnf("Canceling pkScript notification registration %d: %v", + sub.id, err) + if sub.notificationQueue != nil { + sub.notificationQueue.stopWithError(err) + } + n.cancelPkScriptLocked(sub.id) +} + +// reservePkScriptMatchSize grows a match's byte accounting before a payload is +// exposed to the subscriber. It returns false if growth canceled the +// subscription. +// +// NOTE: This method must be called with the TxNotifier's lock held. +func (n *TxNotifier) reservePkScriptMatchSize( + sub *pkScriptSubscription, match *pkScriptMatch, newSize uint64) bool { + + if match.retainedBytes == 0 || newSize <= match.retainedBytes { + return true + } + + delta := newSize - match.retainedBytes + err := n.validatePkScriptMatchByteGrowth(sub, delta) + if err != nil { + n.failPkScriptMatchLimit(sub, err) + + return false + } + + sub.matchBytes += delta + n.numPkScriptMatchBytes += delta + match.retainedBytes = newSize + + return true +} + +// releasePkScriptMatchSize rolls back a prior reservation. +// +// NOTE: This method must be called with the TxNotifier's lock held. +func (n *TxNotifier) releasePkScriptMatchSize( + sub *pkScriptSubscription, match *pkScriptMatch, oldSize uint64) { + + if match.retainedBytes == 0 || oldSize >= match.retainedBytes { + return + } + + delta := match.retainedBytes - oldSize + sub.matchBytes -= delta + n.numPkScriptMatchBytes -= delta + match.retainedBytes = oldSize +} + +// updatePkScriptMatchSize refreshes byte accounting after a retained payload +// is added or released. It returns false if growth canceled the subscription. +// +// NOTE: This method must be called with the TxNotifier's lock held. +func (n *TxNotifier) updatePkScriptMatchSize( + sub *pkScriptSubscription, match *pkScriptMatch) bool { + + // Manually assembled test matches are not part of resource accounting. + if match.retainedBytes == 0 { + return true + } + + newSize := pkScriptMatchSize(match) + oldSize := match.retainedBytes + if newSize > oldSize { + delta := newSize - oldSize + err := n.validatePkScriptMatchByteGrowth(sub, delta) + if err != nil { + n.failPkScriptMatchLimit(sub, err) + + return false + } + + sub.matchBytes += delta + n.numPkScriptMatchBytes += delta + } else { + delta := oldSize - newSize + if sub.matchBytes >= delta { + sub.matchBytes -= delta + } else { + sub.matchBytes = 0 + } + if n.numPkScriptMatchBytes >= delta { + n.numPkScriptMatchBytes -= delta + } else { + n.numPkScriptMatchBytes = 0 + } + } + match.retainedBytes = newSize + + return true +} + +// 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, + ) + n.updatePkScriptMatchSize(sub, match) +} + +// 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) + if match.retainedBytes > 0 { + if sub.numMatches > 0 { + sub.numMatches-- + } + if n.numPkScriptMatches > 0 { + n.numPkScriptMatches-- + } + if sub.matchBytes >= match.retainedBytes { + sub.matchBytes -= match.retainedBytes + } else { + sub.matchBytes = 0 + } + if n.numPkScriptMatchBytes >= match.retainedBytes { + n.numPkScriptMatchBytes -= match.retainedBytes + } else { + n.numPkScriptMatchBytes = 0 + } + } + 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, + fundingTx *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 && cfg.events.Has(PkScriptEventConfirm) { + match.fundingTx = fundingTx + } + + matchSize := pkScriptMatchSize(match) + err := n.validatePkScriptMatchResourceLimits(sub, matchSize) + if err != nil { + n.failPkScriptMatchLimit(sub, err) + + return + } + match.retainedBytes = matchSize + sub.matches[outpoint] = match + sub.numMatches++ + sub.matchBytes += matchSize + n.numPkScriptMatches++ + n.numPkScriptMatchBytes += matchSize + + 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, + retainedBlock **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 + internalBlock *wire.MsgBlock + ) + if cfg.includeTx { + txCopy = copyMsgTx(match.fundingTx) + } + if cfg.includeBlock { + internalBlock = retainMsgBlock(block, retainedBlock) + blockCopy = copyMsgBlock(internalBlock) + } + + oldSize := match.retainedBytes + newSize := oldSize + 128 + if internalBlock != nil { + newSize += uint64(internalBlock.SerializeSize()) + } + if !n.reservePkScriptMatchSize(sub, match, newSize) { + return nil + } + + 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: copyPkScriptUTXO(match.utxo), + Tx: txCopy, + Block: blockCopy, + }) + if err != nil { + if errors.Is(err, ErrPkScriptNotificationQueueFull) { + return nil + } + n.releasePkScriptMatchSize(sub, match, oldSize) + + return err + } + + removePkScriptHeightIndex( + n.pkScriptConfUpdatesByHeight, blockHeight, sub.id, outpoint, + ) + n.addDispatchedPkScriptConfirmUpdate( + sub, match, outpoint, &pkScriptConfirmUpdate{ + blockHeight: blockHeight, + blockHash: copyHash(blockHash), + block: internalBlock, + 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: copyPkScriptUTXO(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, + retainedBlock **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 + internalBlock *wire.MsgBlock + ) + if cfg.includeTx { + txCopy = copyMsgTx(match.fundingTx) + } + if cfg.includeBlock { + internalBlock = retainMsgBlock(block, retainedBlock) + blockCopy = copyMsgBlock(internalBlock) + } + + oldSize := match.retainedBytes + newSize := oldSize + if internalBlock != nil { + newSize += uint64(internalBlock.SerializeSize()) + } + if !n.reservePkScriptMatchSize(sub, match, newSize) { + return nil + } + + 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: copyPkScriptUTXO(match.utxo), + Tx: txCopy, + Block: blockCopy, + }) + if err != nil { + if errors.Is(err, ErrPkScriptNotificationQueueFull) { + return nil + } + n.releasePkScriptMatchSize(sub, match, oldSize) + + return err + } + + match.confirmDispatched = true + match.confirmBlockHash = copyHash(blockHash) + if cfg.includeBlock { + match.confirmBlock = internalBlock + } + 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: copyPkScriptUTXO(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 + match.confirmBlock = nil + n.updatePkScriptMatchSize(sub, match) + 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) + } + + oldSize := match.retainedBytes + newSize := oldSize + if tx != nil && cfg.includeTx { + newSize += uint64(tx.SerializeSize()) + } + if block != nil && cfg.includeBlock { + newSize += uint64(block.SerializeSize()) + } + if !n.reservePkScriptMatchSize(sub, match, newSize) { + return nil + } + + err := n.dispatchPkScriptNotification(sub, &PkScriptNotification{ + Type: PkScriptNotificationSpend, + Height: blockHeight, + BlockHash: copyHash(blockHash), + TxHash: copyHash(txHash), + TxIndex: txIndex, + InputIndex: inputIdx, + UTXO: copyPkScriptUTXO(match.utxo), + Tx: txCopy, + Block: blockCopy, + }) + if err != nil { + if errors.Is(err, ErrPkScriptNotificationQueueFull) { + return nil + } + n.releasePkScriptMatchSize(sub, match, oldSize) + + return err + } + + match.spendTxHash = copyHash(txHash) + match.spendBlockHash = copyHash(blockHash) + if cfg.includeTx { + match.spendTx = tx + } + if cfg.includeBlock { + match.spendBlock = 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: copyPkScriptUTXO(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 + n.updatePkScriptMatchSize(sub, match) + + 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() + } + var retainedBlock *wire.MsgBlock + + if len(n.pkScriptConfUpdatesByHeight[blockHeight]) > 0 { + err := n.dispatchPkScriptConfirmUpdatesAtTip( + blockHeight, blockHash, msgBlock, &retainedBlock, + ) + if err != nil { + return err + } + } + + if len(n.pkScriptConfsByHeight[blockHeight]) > 0 { + err := n.dispatchPkScriptConfsAtTip( + blockHeight, blockHash, msgBlock, &retainedBlock, + ) + 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, + retainedBlock **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 + } + err := n.dispatchPkScriptConfirmUpdate( + sub, match, outpoint, blockHeight, blockHash, + block, retainedBlock, + ) + 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 +} + +// 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, + retainedBlock **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 + } + err := n.dispatchPkScriptConfirm( + sub, match, outpoint, blockHeight, blockHash, + block, retainedBlock, + ) + 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 +} + +// 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, + pendingSpends *[]pkScriptPendingSpend) error { + + txHash := tx.Hash() + var ( + blockHash *chainhash.Hash + msgBlock *wire.MsgBlock + ) + if block != nil { + blockHash = block.Hash() + msgBlock = block.MsgBlock() + } + var ( + retainedFundingTx *wire.MsgTx + retainedSpendTx *wire.MsgTx + retainedSpendBlock *wire.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 + } + + var spendTx *wire.MsgTx + if match.watchConfig.includeTx { + if retainedSpendTx == nil { + retainedSpendTx = copyMsgTx(tx.MsgTx()) + } + spendTx = retainedSpendTx + } + var spendBlock *wire.MsgBlock + if match.watchConfig.includeBlock { + if retainedSpendBlock == nil { + retainedSpendBlock = copyMsgBlock( + msgBlock, + ) + } + spendBlock = retainedSpendBlock + } + + *pendingSpends = append( + *pendingSpends, pkScriptPendingSpend{ + subID: subID, + outpoint: prevOut, + txHash: copyHash(txHash), + txIndex: txIndex, + inputIndex: uint32(inputIdx), + height: blockHeight, + blockHash: copyHash(blockHash), + tx: spendTx, + block: spendBlock, + }) + } + } + + 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 + } + cfg, ok := sub.scripts[string(txOut.PkScript)] + if !ok { + continue + } + if cfg.includeTx && + cfg.events.Has(PkScriptEventConfirm) && + retainedFundingTx == nil { + + retainedFundingTx = copyMsgTx(tx.MsgTx()) + } + + n.trackPkScriptReceive( + sub, outpoint, btcutil.Amount(txOut.Value), + txOut.PkScript, retainedFundingTx, blockHeight, + blockHash, txIndex, cfg, + ) + } + } + + return nil +} + +// dispatchPendingPkScriptSpends dispatches spends found while scanning a block +// after confirmation/update notifications for the same block have been sent. +// +// NOTE: This method must be called with the TxNotifier's lock held. +func (n *TxNotifier) dispatchPendingPkScriptSpends( + pendingSpends []pkScriptPendingSpend) error { + + for _, spend := range pendingSpends { + sub := n.pkScriptNotifications[spend.subID] + if sub == nil { + continue + } + + match := sub.matches[spend.outpoint] + if match == nil { + continue + } + + err := n.dispatchPkScriptSpend( + sub, match, spend.outpoint, spend.txHash, spend.txIndex, + spend.inputIndex, spend.height, spend.blockHash, + spend.tx, spend.block, + ) + if err != nil { + return err + } + } + + 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) + + confirmedIndex := n.pkScriptConfirmedByHeight[matureBlockHeight] + for subID, outpoints := range confirmedIndex { + sub := n.pkScriptNotifications[subID] + if sub == nil { + continue + } + + for outpoint := range outpoints { + match := sub.matches[outpoint] + if match == nil { + continue + } + + // Confirmation reorg data is no longer useful once + // the confirmation height is beyond the reorg horizon. + // Release it even if an unspent match remains for spend + // detection. + match.fundingTx = nil + match.confirmBlockHash = nil + match.confirmBlock = nil + n.updatePkScriptMatchSize(sub, match) + + if !n.shouldRetainPkScriptMatch(sub, match) { + n.removePkScriptMatch(sub, outpoint) + } + } + } + delete(n.pkScriptConfirmedByHeight, matureBlockHeight) + + spendIndex := n.pkScriptSpendsByHeight[matureBlockHeight] + for subID, outpoints := range spendIndex { + sub := n.pkScriptNotifications[subID] + if sub == nil { + continue + } + + for outpoint := range outpoints { + match := sub.matches[outpoint] + if match == nil { + continue + } + + // Retain the fact that this output was spent, but + // release the raw reorg payload once the spend cannot + // be disconnected. + match.spendBlockHash = nil + match.spendTx = nil + match.spendBlock = nil + n.updatePkScriptMatchSize(sub, match) + + if !n.shouldRetainPkScriptMatch(sub, match) { + n.removePkScriptMatch(sub, outpoint) + } + } + } + delete(n.pkScriptSpendsByHeight, matureBlockHeight) +} + +// 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 { + 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) + + 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) + + 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) + + 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..12da9927844 --- /dev/null +++ b/chainntnfs/pkscriptnotifier_internal_test.go @@ -0,0 +1,871 @@ +package chainntnfs + +import ( + "container/list" + "errors" + "sync" + "testing" + "time" + + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/txscript/v2" + "github.com/btcsuite/btcd/wire/v2" +) + +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, +} + +func registerInternalPkScript(t *testing.T, n *TxNotifier, pkScript []byte, + opts ...NotifierOption) *PkScriptRegistration { + + t.Helper() + + reg, err := n.RegisterPkScriptNotifier() + if err != nil { + t.Fatalf("unable to register pkScript notifier: %v", err) + } + _, _, err = reg.AddPkScripts([][]byte{pkScript}, opts...) + if err != nil { + t.Fatalf("unable to add pkScript: %v", err) + } + + return reg +} + +func receiveInternalPkScriptNotification(t *testing.T, + reg *PkScriptRegistration) *PkScriptNotification { + + t.Helper() + + select { + case ntfn, ok := <-reg.Event.Notifications: + if !ok { + t.Fatal("pkScript notification stream closed") + } + + return ntfn + + case <-time.After(time.Second): + t.Fatal("pkScript notification not received") + + return nil + } +} + +func assertNoPkScriptMatches(t *testing.T, n *TxNotifier) { + t.Helper() + + if n.numPkScriptMatches != 0 { + t.Fatalf("expected zero matches, got %d", n.numPkScriptMatches) + } + if n.numPkScriptMatchBytes != 0 { + t.Fatalf("expected zero match bytes, got %d", + n.numPkScriptMatchBytes) + } +} + +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) + } +} + +// TestTxNotifierPkScriptMatchResourceLimits ensures both subscription and +// notifier match count and byte limits are enforced without constructing large +// blocks in the test. +func TestTxNotifierPkScriptMatchResourceLimits(t *testing.T) { + t.Parallel() + + newNotifier := func() (*TxNotifier, *pkScriptSubscription) { + hintCache := internalHintCache{} + n := NewTxNotifier(10, ReorgSafetyLimit, hintCache, hintCache) + + return n, &pkScriptSubscription{} + } + + tests := []struct { + name string + setup func(*TxNotifier, *pkScriptSubscription) + }{ + { + name: "subscription count", + setup: func(_ *TxNotifier, sub *pkScriptSubscription) { + sub.numMatches = + MaxPkScriptMatchesPerRegistration + }, + }, + { + name: "notifier count", + setup: func(n *TxNotifier, _ *pkScriptSubscription) { + n.numPkScriptMatches = MaxPkScriptMatches + }, + }, + { + name: "subscription bytes", + setup: func(_ *TxNotifier, sub *pkScriptSubscription) { + sub.matchBytes = + MaxPkScriptMatchBytesPerRegistration + }, + }, + { + name: "notifier bytes", + setup: func(n *TxNotifier, _ *pkScriptSubscription) { + n.numPkScriptMatchBytes = MaxPkScriptMatchBytes + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + n, sub := newNotifier() + test.setup(n, sub) + + err := n.validatePkScriptMatchResourceLimits(sub, 1) + if !errors.Is(err, ErrPkScriptMatchLimit) { + t.Fatalf("expected match limit error, "+ + "got %v", err) + } + }) + } +} + +// TestTxNotifierPkScriptMatchLimitCancelsOnlySubscription ensures retained +// match exhaustion terminates the offending stream without failing block +// processing or another active stream. +func TestTxNotifierPkScriptMatchLimitCancelsOnlySubscription(t *testing.T) { + t.Parallel() + + hintCache := internalHintCache{} + n := NewTxNotifier(10, ReorgSafetyLimit, hintCache, hintCache) + + otherScript := copyBytes(internalTestPkScript) + otherScript[2] ^= 1 + limitedReg := registerInternalPkScript( + t, n, internalTestPkScript, WithEvents(PkScriptEventConfirm), + ) + healthyReg := registerInternalPkScript( + t, n, otherScript, WithEvents(PkScriptEventConfirm), + ) + + limitedSub := n.pkScriptNotifications[1] + limitedSub.numMatches = MaxPkScriptMatchesPerRegistration + n.numPkScriptMatches = MaxPkScriptMatchesPerRegistration + + limitedTx := wire.NewMsgTx(2) + limitedTx.AddTxOut(&wire.TxOut{PkScript: internalTestPkScript}) + err := n.ConnectTip(btcutil.NewBlock(&wire.MsgBlock{ + Transactions: []*wire.MsgTx{limitedTx}, + }), 11) + if err != nil { + t.Fatalf("match exhaustion failed ConnectTip: %v", err) + } + + select { + case _, ok := <-limitedReg.Event.Notifications: + if ok { + t.Fatal("limited notification stream remained open") + } + + case <-time.After(time.Second): + t.Fatal("limited notification stream did not close") + } + if !errors.Is(limitedReg.Event.Err(), ErrPkScriptMatchLimit) { + t.Fatalf("expected terminal match limit error, got %v", + limitedReg.Event.Err()) + } + if n.pkScriptNotifications[2] == nil { + t.Fatal("healthy notification stream was canceled") + } + + healthyTx := wire.NewMsgTx(2) + healthyTx.AddTxOut(&wire.TxOut{PkScript: otherScript}) + err = n.ConnectTip(btcutil.NewBlock(&wire.MsgBlock{ + Transactions: []*wire.MsgTx{healthyTx}, + }), 12) + if err != nil { + t.Fatalf("healthy ConnectTip failed: %v", err) + } + receiveInternalPkScriptNotification(t, healthyReg) + + healthyReg.Event.Cancel() + assertNoPkScriptMatches(t, n) +} + +// TestTxNotifierPkScriptCancelAfterQuit ensures a client can still release its +// registration after shutdown begins. +func TestTxNotifierPkScriptCancelAfterQuit(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) + } + + close(n.quit) + reg.Event.Cancel() + + if len(n.pkScriptNotifications) != 0 { + t.Fatalf("expected registration cleanup after quit") + } + select { + case _, ok := <-reg.Event.Notifications: + if ok { + t.Fatal("notification stream remained open") + } + + default: + t.Fatal("notification stream was not closed") + } +} + +// TestTxNotifierPkScriptRegisterTearDownRace ensures every registration that +// succeeds while teardown is starting is included in the teardown sweep. +func TestTxNotifierPkScriptRegisterTearDownRace(t *testing.T) { + t.Parallel() + + const ( + iterations = 200 + registrars = 16 + ) + + type registerResult struct { + reg *PkScriptRegistration + err error + } + + for i := 0; i < iterations; i++ { + hintCache := internalHintCache{} + n := NewTxNotifier(10, ReorgSafetyLimit, hintCache, hintCache) + + start := make(chan struct{}) + results := make(chan registerResult, registrars) + var wg sync.WaitGroup + wg.Add(registrars) + for j := 0; j < registrars; j++ { + go func() { + defer wg.Done() + <-start + + reg, err := n.RegisterPkScriptNotifier() + results <- registerResult{reg: reg, err: err} + }() + } + + tearDownDone := make(chan struct{}) + go func() { + <-start + n.TearDown() + close(tearDownDone) + }() + + close(start) + <-tearDownDone + wg.Wait() + close(results) + + for result := range results { + if result.err != nil { + if !errors.Is( + result.err, ErrTxNotifierExiting, + ) { + + t.Fatalf( + "unexpected registration "+ + "error: %v", + result.err, + ) + } + + continue + } + + select { + case _, ok := <-result.reg.Event.Notifications: + if ok { + t.Fatal("open stream after teardown") + } + + default: + t.Fatalf( + "registration remained open after "+ + "teardown in iteration %d", i, + ) + } + } + } +} + +// 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, + }, + }, + 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("height-zero confirmation index remains") + } + 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") + } +} + +// TestTxNotifierPkScriptFundingTxRetention ensures spend-only watches do not +// retain an unused funding transaction and confirmation watches share one +// detached transaction copy across matches from the same transaction. +func TestTxNotifierPkScriptFundingTxRetention(t *testing.T) { + t.Parallel() + + t.Run("spend only", func(t *testing.T) { + hintCache := internalHintCache{} + n := NewTxNotifier(0, ReorgSafetyLimit, hintCache, hintCache) + reg := registerInternalPkScript( + t, n, internalTestPkScript, + WithEvents(PkScriptEventSpend), WithIncludeTx(), + ) + + tx := wire.NewMsgTx(2) + tx.AddTxOut(&wire.TxOut{PkScript: internalTestPkScript}) + err := n.ConnectTip(btcutil.NewBlock(&wire.MsgBlock{ + Transactions: []*wire.MsgTx{tx}, + }), 1) + if err != nil { + t.Fatalf("unable to connect receive: %v", err) + } + + sub := n.pkScriptNotifications[1] + for _, match := range sub.matches { + if match.fundingTx != nil { + t.Fatal("spend-only match retained funding tx") + } + } + reg.Event.Cancel() + assertNoPkScriptMatches(t, n) + }) + + t.Run("shared detached copy", func(t *testing.T) { + hintCache := internalHintCache{} + n := NewTxNotifier(0, ReorgSafetyLimit, hintCache, hintCache) + reg := registerInternalPkScript( + t, n, internalTestPkScript, + WithEvents(PkScriptEventConfirm), WithIncludeTx(), + ) + + tx := wire.NewMsgTx(2) + tx.AddTxOut(&wire.TxOut{PkScript: internalTestPkScript}) + tx.AddTxOut(&wire.TxOut{PkScript: internalTestPkScript}) + err := n.ConnectTip(btcutil.NewBlock(&wire.MsgBlock{ + Transactions: []*wire.MsgTx{tx}, + }), 1) + if err != nil { + t.Fatalf("unable to connect receives: %v", err) + } + + sub := n.pkScriptNotifications[1] + var retainedTx *wire.MsgTx + for _, match := range sub.matches { + if match.fundingTx == tx { + t.Fatal("funding tx aliases caller-owned " + + "transaction") + } + if retainedTx == nil { + retainedTx = match.fundingTx + continue + } + if match.fundingTx != retainedTx { + t.Fatal("matches did not share retained " + + "funding tx") + } + } + if retainedTx == nil { + t.Fatal("confirmation match did not retain funding tx") + } + + reg.Event.Cancel() + assertNoPkScriptMatches(t, n) + }) +} + +// TestTxNotifierPkScriptMatchAccountingLifecycle ensures every match removal +// path balances notifier-level count and byte accounting. +func TestTxNotifierPkScriptMatchAccountingLifecycle(t *testing.T) { + t.Parallel() + + for _, action := range []string{"remove", "cancel", "reorg", "prune"} { + t.Run(action, func(t *testing.T) { + hintCache := internalHintCache{} + n := NewTxNotifier(0, 2, hintCache, hintCache) + reg := registerInternalPkScript( + t, n, internalTestPkScript, + WithEvents(PkScriptEventConfirm), + ) + + tx := wire.NewMsgTx(2) + tx.AddTxOut(&wire.TxOut{PkScript: internalTestPkScript}) + err := n.ConnectTip(btcutil.NewBlock(&wire.MsgBlock{ + Transactions: []*wire.MsgTx{tx}, + }), 1) + if err != nil { + t.Fatalf("unable to connect receive: %v", err) + } + receiveInternalPkScriptNotification(t, reg) + if n.numPkScriptMatches != 1 || + n.numPkScriptMatchBytes == 0 { + + t.Fatalf("match was not accounted: count=%d "+ + "bytes=%d", + n.numPkScriptMatches, + n.numPkScriptMatchBytes) + } + + canceled := false + switch action { + case "remove": + err = reg.RemovePkScripts( + [][]byte{internalTestPkScript}, + ) + if err != nil { + t.Fatalf("unable to remove pkScript: "+ + "%v", err) + } + + case "cancel": + reg.Event.Cancel() + canceled = true + + case "reorg": + err = n.DisconnectTip(1) + if err != nil { + t.Fatalf("unable to disconnect "+ + "receive: %v", err) + } + + case "prune": + err = n.ConnectTip( + btcutil.NewBlock(&wire.MsgBlock{}), 2, + ) + if err != nil { + t.Fatalf("unable to connect height 2: "+ + "%v", err) + } + err = n.ConnectTip( + btcutil.NewBlock(&wire.MsgBlock{}), 3, + ) + if err != nil { + t.Fatalf("unable to connect height 3: "+ + "%v", err) + } + } + + assertNoPkScriptMatches(t, n) + if !canceled { + reg.Event.Cancel() + } + }) + } +} + +// TestTxNotifierPkScriptConfirmPayloadPruned ensures confirmation reorg +// payloads are released at maturity while an unspent output remains tracked. +func TestTxNotifierPkScriptConfirmPayloadPruned(t *testing.T) { + t.Parallel() + + hintCache := internalHintCache{} + n := NewTxNotifier(0, 2, hintCache, hintCache) + reg := registerInternalPkScript( + t, n, internalTestPkScript, + WithEvents(PkScriptEventConfirm|PkScriptEventSpend), + WithIncludeTx(), WithIncludeBlock(), + ) + + tx := wire.NewMsgTx(2) + tx.AddTxOut(&wire.TxOut{PkScript: internalTestPkScript}) + err := n.ConnectTip(btcutil.NewBlock(&wire.MsgBlock{ + Transactions: []*wire.MsgTx{tx}, + }), 1) + if err != nil { + t.Fatalf("unable to connect receive: %v", err) + } + receiveInternalPkScriptNotification(t, reg) + + sub := n.pkScriptNotifications[1] + var match *pkScriptMatch + for _, candidate := range sub.matches { + match = candidate + } + if match == nil || match.fundingTx == nil || match.confirmBlock == nil { + t.Fatal("confirmation reorg payload was not retained") + } + retainedBefore := match.retainedBytes + + for height := uint32(2); height <= 3; height++ { + err = n.ConnectTip(btcutil.NewBlock(&wire.MsgBlock{}), height) + if err != nil { + t.Fatalf("unable to connect height %d: %v", height, err) + } + } + + if len(sub.matches) != 1 { + t.Fatal("unspent match was not retained for spend detection") + } + if match.fundingTx != nil || match.confirmBlock != nil || + match.confirmBlockHash != nil { + + t.Fatal("mature confirmation payload was retained") + } + if match.retainedBytes >= retainedBefore { + t.Fatalf("confirmation bytes not released: %d >= %d", + match.retainedBytes, retainedBefore) + } + + reg.Event.Cancel() + assertNoPkScriptMatches(t, n) +} + +// TestTxNotifierPkScriptSpendPayloadPruned ensures spend reorg payloads are +// released at maturity while a later confirmation remains reorg-sensitive. +func TestTxNotifierPkScriptSpendPayloadPruned(t *testing.T) { + t.Parallel() + + hintCache := internalHintCache{} + n := NewTxNotifier(0, 4, hintCache, hintCache) + reg := registerInternalPkScript( + t, n, internalTestPkScript, + WithEvents(PkScriptEventConfirm|PkScriptEventSpend), + WithNumConfs(4), WithIncludeTx(), WithIncludeBlock(), + ) + + receiveTx := wire.NewMsgTx(2) + receiveTx.AddTxOut(&wire.TxOut{PkScript: internalTestPkScript}) + spendTx := wire.NewMsgTx(2) + spendTx.AddTxIn(&wire.TxIn{PreviousOutPoint: wire.OutPoint{ + Hash: receiveTx.TxHash(), + }}) + err := n.ConnectTip(btcutil.NewBlock(&wire.MsgBlock{ + Transactions: []*wire.MsgTx{receiveTx, spendTx}, + }), 1) + if err != nil { + t.Fatalf("unable to connect receive and spend: %v", err) + } + receiveInternalPkScriptNotification(t, reg) + + sub := n.pkScriptNotifications[1] + var match *pkScriptMatch + for _, candidate := range sub.matches { + match = candidate + } + if match == nil || match.spendTx == nil || match.spendBlock == nil { + t.Fatal("spend reorg payload was not retained") + } + + for height := uint32(2); height <= 5; height++ { + err = n.ConnectTip(btcutil.NewBlock(&wire.MsgBlock{}), height) + if err != nil { + t.Fatalf("unable to connect height %d: %v", height, err) + } + if height == 4 { + receiveInternalPkScriptNotification(t, reg) + } + } + + if len(sub.matches) != 1 { + t.Fatal("confirmation-sensitive match was removed") + } + if match.spendTx == nil && match.spendBlock == nil && + match.spendBlockHash == nil { + // Expected payload release. + } else { + t.Fatal("mature spend payload was retained") + } + if match.spendTxHash == nil || !match.spendDispatched { + t.Fatal("mature spend fact was not retained") + } + + reg.Event.Cancel() + assertNoPkScriptMatches(t, n) +} diff --git a/chainntnfs/pkscriptnotifier_test.go b/chainntnfs/pkscriptnotifier_test.go new file mode 100644 index 00000000000..c152013d5ae --- /dev/null +++ b/chainntnfs/pkscriptnotifier_test.go @@ -0,0 +1,1089 @@ +package chainntnfs_test + +import ( + "errors" + "testing" + "time" + + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "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 + } +} + +// 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") + } +} + +// TestTxNotifierPkScriptAddRemove ensures pkScripts can be added and removed on +// the fly. +func TestTxNotifierPkScriptAddRemove(t *testing.T) { + t.Parallel() + + hintCache := newMockHintCache() + n := chainntnfs.NewTxNotifier( + 10, chainntnfs.ReorgSafetyLimit, hintCache, hintCache, + ) + + reg, err := n.RegisterPkScriptNotifier() + require.NoError(t, err) + + _, _, err = reg.AddPkScripts( + [][]byte{testRawScript}, + ) + require.NoError(t, err) + + 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) + + requirePkScriptNotification( + t, recvPkScriptNotificationTimeout(t, reg.Event), + 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(btcutil.NewBlock(&wire.MsgBlock{}), 12) + require.NoError(t, err) + err = n.ConnectTip(block13, 13) + require.NoError(t, err) + + select { + case ntfn := <-reg.Event.Notifications: + t.Fatalf("received unexpected pkScript notification: %v", ntfn) + case <-time.After(100 * time.Millisecond): + } +} + +// TestTxNotifierPkScriptAddMultipleScripts ensures AddPkScripts watches all +// newly added scripts for future blocks. +func TestTxNotifierPkScriptAddMultipleScripts(t *testing.T) { + t.Parallel() + + hintCache := newMockHintCache() + n := chainntnfs.NewTxNotifier( + 10, chainntnfs.ReorgSafetyLimit, hintCache, hintCache, + ) + + reg, err := n.RegisterPkScriptNotifier() + require.NoError(t, err) + + _, addedScripts, err := reg.AddPkScripts( + [][]byte{testRawScript, testRawScript2}, + ) + require.NoError(t, err) + require.Len(t, addedScripts, 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.ConnectTip(block11, 11) + 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) +} + +// TestTxNotifierPkScriptFutureNotifications ensures pkScript registrations +// receive future confirmation and spend notifications. +func TestTxNotifierPkScriptFutureNotifications(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, + ) +} + +// TestTxNotifierPkScriptAddFutureOnly ensures scripts added to an existing +// subscription watch future blocks. +func TestTxNotifierPkScriptAddFutureOnly(t *testing.T) { + t.Parallel() + + hintCache := newMockHintCache() + n := chainntnfs.NewTxNotifier( + 12, chainntnfs.ReorgSafetyLimit, hintCache, hintCache, + ) + + reg, err := n.RegisterPkScriptNotifier() + require.NoError(t, err) + + _, addedScripts, err := reg.AddPkScripts( + [][]byte{testRawScript}, + chainntnfs.WithEvents(chainntnfs.PkScriptEventConfirm), + ) + require.NoError(t, err) + require.Len(t, addedScripts, 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, + ) +} + +// 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(), + chainntnfs.WithIncludeBlock(), + ) + + 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) + require.NotNil(t, update2.Block) + originalNonce := update2.Block.Header.Nonce + update2.Block.Header.Nonce++ + + 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) + require.NotNil(t, update2Reorg.Block) + require.Equal(t, originalNonce, update2Reorg.Block.Header.Nonce) + + 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. +func TestTxNotifierPkScriptAddExistingScriptNoop(t *testing.T) { + t.Parallel() + + hintCache := newMockHintCache() + n := chainntnfs.NewTxNotifier( + 10, chainntnfs.ReorgSafetyLimit, hintCache, hintCache, + ) + + reg, err := n.RegisterPkScriptNotifier() + require.NoError(t, err) + + addHeight, addedScripts, err := reg.AddPkScripts( + [][]byte{testRawScript}, + chainntnfs.WithEvents(chainntnfs.PkScriptEventConfirm), + chainntnfs.WithNumConfs(1), + ) + require.NoError(t, err) + require.Equal(t, uint32(10), addHeight) + require.Len(t, addedScripts, 1) + + addHeight, addedScripts, err = reg.AddPkScripts( + [][]byte{testRawScript}, + chainntnfs.WithEvents(chainntnfs.PkScriptEventConfirm), + ) + require.NoError(t, err) + require.Equal(t, uint32(10), addHeight) + require.Empty(t, addedScripts) + + err = reg.RemovePkScripts([][]byte{testRawScript}) + require.NoError(t, err) + + addHeight, addedScripts, err = reg.AddPkScripts( + [][]byte{testRawScript}, + chainntnfs.WithEvents(chainntnfs.PkScriptEventConfirm), + ) + require.NoError(t, err) + require.Equal(t, uint32(10), addHeight) + require.Len(t, addedScripts, 1) + + 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) + + ntfn := recvPkScriptNotificationTimeout(t, reg.Event) + requirePkScriptNotification( + t, ntfn, chainntnfs.PkScriptNotificationConfirm, false, 11, + 1, 0, &receiveHash, block11.Hash(), receiveOutPoint, + ) + require.NotNil(t, ntfn.UTXO.BlockHash) + require.True(t, ntfn.UTXO.BlockHash.IsEqual(block11.Hash())) + require.Equal(t, uint32(0), ntfn.UTXO.TxIndex) +} + +// TestTxNotifierPkScriptNotificationPayloadDetached ensures callers cannot +// mutate notifier state through delivered notification payloads. +func TestTxNotifierPkScriptNotificationPayloadDetached(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, + ) + + 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) + + ntfn := recvPkScriptNotificationTimeout(t, reg.Event) + requirePkScriptNotification( + t, ntfn, chainntnfs.PkScriptNotificationConfirm, false, 11, + 1, 0, &receiveHash, block11.Hash(), receiveOutPoint, + ) + require.NotEmpty(t, ntfn.UTXO.PkScript) + + ntfn.UTXO.PkScript[0] ^= 0xff + + err = reg.RemovePkScripts([][]byte{testRawScript}) + require.NoError(t, err) + + _, _, err = reg.AddPkScripts( + [][]byte{testRawScript2}, + chainntnfs.WithEvents(chainntnfs.PkScriptEventConfirm), + chainntnfs.WithNumConfs(1), + ) + require.NoError(t, err) + + spendTx := wire.NewMsgTx(2) + spendTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: receiveOutPoint, + Witness: testWitness, + SignatureScript: testSigScript, + }) + block12 := btcutil.NewBlock(&wire.MsgBlock{ + Transactions: []*wire.MsgTx{spendTx}, + }) + + err = n.ConnectTip(block12, 12) + require.NoError(t, err) + + select { + case ntfn := <-reg.Event.Notifications: + t.Fatalf("removed script still dispatched spend: %v", ntfn) + case <-time.After(100 * time.Millisecond): + } +} + +// TestTxNotifierPkScriptSameBlockConfirmBeforeSpend ensures a same-block +// child spend is delivered after the watched output's confirmation. +func TestTxNotifierPkScriptSameBlockConfirmBeforeSpend(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, + ) + + 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, spendTx}, + }) + + err := n.ConnectTip(block11, 11) + require.NoError(t, err) + + confirmNtfn := recvPkScriptNotificationTimeout(t, reg.Event) + requirePkScriptNotification( + t, confirmNtfn, chainntnfs.PkScriptNotificationConfirm, + false, 11, 1, 0, &receiveHash, block11.Hash(), + receiveOutPoint, + ) + + spendNtfn := recvPkScriptNotificationTimeout(t, reg.Event) + requirePkScriptNotification( + t, spendNtfn, chainntnfs.PkScriptNotificationSpend, + false, 11, 0, 1, &spendHash, block11.Hash(), + receiveOutPoint, + ) + + err = n.DisconnectTip(11) + require.NoError(t, err) + + spendReorg := recvPkScriptNotificationTimeout(t, reg.Event) + requirePkScriptNotification( + t, spendReorg, chainntnfs.PkScriptNotificationSpend, + true, 11, 0, 1, &spendHash, block11.Hash(), + receiveOutPoint, + ) + + confirmReorg := recvPkScriptNotificationTimeout(t, reg.Event) + requirePkScriptNotification( + t, confirmReorg, chainntnfs.PkScriptNotificationConfirm, + true, 11, 1, 0, &receiveHash, block11.Hash(), + receiveOutPoint, + ) +} + +// 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 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("pkScript notification channel remains open") + } + + _, 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 7c899897fd4..ad2a9656c50 100644 --- a/chainntnfs/test/test_interface.go +++ b/chainntnfs/test/test_interface.go @@ -11,10 +11,13 @@ import ( "testing" "time" + "github.com/btcsuite/btcd/address/v2" + "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil/v2" "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/integration/rpctest" "github.com/btcsuite/btcd/rpcclient" + "github.com/btcsuite/btcd/txscript/v2" "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btcwallet/chain" _ "github.com/btcsuite/btcwallet/walletdb/bdb" // Required to auto-register the boltdb walletdb implementation. @@ -30,6 +33,194 @@ 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 and returns the resulting +// notification registration. +func registerPkScriptNotifier(t *testing.T, + notifier chainntnfs.PkScriptNotifier, pkScripts [][]byte, + events chainntnfs.PkScriptEventType, numConfs 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), + } + 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 := address.Hash160(privKey.PubKey().SerializeCompressed()) + addr, err := address.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 pkScript notification. +func recvPkScriptNotification(t *testing.T, + reg *chainntnfs.PkScriptNotificationRegistration, +) *chainntnfs.PkScriptNotification { + + t.Helper() + + timeout := time.After(20 * time.Second) + select { + case ntfn, ok := <-reg.Notifications: + require.True(t, ok, "pkScript notification channel closed") + + 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 pkScript notification is +// received before timeout. +func assertNoPkScriptNotification(t *testing.T, + reg *chainntnfs.PkScriptNotificationRegistration, + timeout time.Duration) { + + t.Helper() + + select { + case ntfn := <-reg.Notifications: + 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 +1975,406 @@ func testIncludeBlockAsymmetry(miner *rpctest.Harness, assertNtfns() } +// 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) + + _, err = reg.AddPkScripts([][]byte{liveScript}) + 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) +} + +// testPkScriptAddMultipleScripts verifies multiple scripts added to an existing +// registration receive future notifications. +func testPkScriptAddMultipleScripts(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) + + _, err = reg.AddPkScripts([][]byte{scriptA, scriptB}) + require.NoError(t, err) + + 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) + + 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) + + reg := registerPkScriptNotifier( + t, pkScriptNotifier, + [][]byte{scriptA, scriptB}, + chainntnfs.PkScriptEventConfirm| + chainntnfs.PkScriptEventSpend, + 1, + ) + 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) + + reg := registerPkScriptNotifier( + t, pkScriptNotifier, + [][]byte{pkScript}, + chainntnfs.PkScriptEventConfirm| + chainntnfs.PkScriptEventSpend, + 1, + ) + 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, + ) +} + 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 +2438,25 @@ var txNtfnTests = []txNtfnTestCase{ }, } +var pkScriptNtfnTests = []pkScriptNtfnTestCase{ + { + name: "pkScript add remove", + test: testPkScriptAddRemove, + }, + { + name: "pkScript add multi-script", + test: testPkScriptAddMultipleScripts, + }, + { + name: "pkScript multiple events per block", + test: testPkScriptMultipleEventsPerBlock, + }, + { + name: "pkScript reorg", + test: testPkScriptReorg, + }, +} + var blockNtfnTests = []blockNtfnTestCase{ { name: "block epoch", @@ -1900,7 +2504,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 +2623,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 3530b8a4436..c8fbcdbe089 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,9 @@ 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. // 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 +523,58 @@ 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 + + // 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 + + // numPkScriptMatches tracks the total number of matched outputs + // retained across all active pkScript subscriptions. + numPkScriptMatches int + + // numPkScriptMatchBytes tracks the approximate memory footprint of all + // matched outputs retained across active pkScript subscriptions. + numPkScriptMatchBytes 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 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 +608,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 +1541,47 @@ 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 + var pendingPkScriptSpends []pkScriptPendingSpend 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, + &pendingPkScriptSpends, + ) + if err != nil { + return err + } + } } } + err := n.dispatchPkScriptNotificationsAtTip( + blockHeight, blockHash, block, + ) + if err != nil { + return err + } + + err = n.dispatchPendingPkScriptSpends(pendingPkScriptSpends) + 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 +1621,8 @@ func (n *TxNotifier) ConnectTip(block *btcutil.Block, delete(n.spendNotifications, spendRequest) } delete(n.spendsByHeight, matureBlockHeight) + + n.pruneMaturePkScriptState(matureBlockHeight) } return nil @@ -1931,6 +2036,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 +2244,14 @@ func (n *TxNotifier) TearDown() { delete(spendSet.ntfns, spendID) } } + + for subID := range n.pkScriptNotifications { + n.cancelPkScriptLocked(subID) + } + n.numPkScriptWatches = 0 + n.numPkScriptWatchBytes = 0 + n.numPkScriptMatches = 0 + n.numPkScriptMatchBytes = 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 5e555537461..79f1419aaa6 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..fae899ad18a --- /dev/null +++ b/docs/pkscriptnotifier.md @@ -0,0 +1,260 @@ +# 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 (pkScripts, the +locking script on a transaction output). 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, a client chooses which events to receive — +confirmations, spends, or both — and can then configure: + +* how many confirmations an output must reach before the final confirmation + notification, +* whether to receive partial confirmation updates before that depth, +* whether to include the raw transaction with each event, +* whether to include the raw block with each event. + +The server sends two kinds of response events: + +* `ack`: a mutation was accepted. +* `notification`: a watched output confirmed, gained another confirmation on the + way to that depth, or was spent. + +The notifier reports reorgs on the same stream: it sets `disconnected=true` on +the event it is invalidating. + +## 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" + "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.WithIncludeConfirmationUpdates(), + chainntnfs.WithIncludeTx(), + chainntnfs.WithIncludeBlock(), + ) + if err != nil { + return err + } + + log.Printf("added %d new pkScript watches", result.NumAdded) + + 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 <-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 (unspent transaction output) 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 +} +``` + +## Future-Only Watching + +Add requests only watch chain activity after the backend accepts the mutation. +They do not scan existing blocks. Clients that need historical discovery should +scan their own history first, then add any scripts that must remain watched for +future confirmations, spends, and reorgs. + +## 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 output reaches that depth. + +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. + +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 e6e3df86b3a..c42b2a852d3 100644 --- a/docs/release-notes/release-notes-0.22.0.md +++ b/docs/release-notes/release-notes-0.22.0.md @@ -66,6 +66,12 @@ the chain backend via bitcoind's `submitpackage`, allowing a zero-fee v3/TRUC parent to be accepted together with a fee-paying CPFP child. +* 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, and reorg + notifications for matching outputs. + ## lncli Additions * The `estimateroutefee` command now supports [restricting fee estimates to @@ -136,9 +142,15 @@ the build context is a remote git repository because COPY layers are more smartly compared to cache. +* Added + [`docs/pkscriptnotifier.md`](https://github.com/lightningnetwork/lnd/pull/10807), + which documents the pkScript notifier RPC semantics, future-only watching, + reorg handling, resource bounds, and backend support. + # Contributors (Alphabetical Order) * bitromortac * Boris Nagaev * Erick Cestari +* hieblmi * Jared Tobin diff --git a/itest/list_on_test.go b/itest/list_on_test.go index f1e2eb48eca..c3fdbb568ac 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 future registration rpc", + TestFunc: testPkScriptNotifierFutureRegistrationRPC, + }, + { + Name: "pkScript notifier future add rpc", + TestFunc: testPkScriptNotifierFutureAddRPC, + }, + { + 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 future only rpc", + TestFunc: testPkScriptNotifierFutureOnlyRPC, + }, + { + 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..dc910079334 --- /dev/null +++ b/itest/lnd_chain_notifier_pkscript_test.go @@ -0,0 +1,1245 @@ +package itest + +import ( + "bytes" + "context" + "errors" + "fmt" + "time" + + "github.com/btcsuite/btcd/address/v2" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/txscript/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/lnrpc" + crpc "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" +) + +type ( + pkScriptEventType = crpc.PkScriptEventType + pkScriptNotification = crpc.PkScriptNotification + pkScriptRequest = crpc.PkScriptRequest +) + +const confirmEvent = crpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_CONFIRM +const spendEvent = crpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_SPEND +const confUpd = crpc.PkScriptEventType_PK_SCRIPT_EVENT_TYPE_CONFIRMATION_UPDATE +const regAct = crpc.PkScriptMutationAction_PK_SCRIPT_MUTATION_ACTION_REGISTER +const removeAct = crpc.PkScriptMutationAction_PK_SCRIPT_MUTATION_ACTION_REMOVE + +// 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 *crpc.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 crpc.ChainNotifier_RegisterPkScriptNtfnClient + events chan pkScriptStreamEvent + + pending []*crpc.PkScriptNotification +} + +// 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 *crpc.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() *crpc.PkScriptRequest { + return &crpc.PkScriptRequest{ + Request: &crpc.PkScriptRequest_Register{ + Register: &crpc.PkScriptRegisterRequest{}, + }, + } +} + +// pkScriptAddReq builds an add request without partial confirmation updates. +func pkScriptAddReq( + pkScripts [][]byte, events []pkScriptEventType, + numConfs uint32, includeBlock, includeTx bool) *pkScriptRequest { + + return pkScriptAddReqWithConfUpdates( + pkScripts, events, numConfs, includeBlock, includeTx, false, + ) +} + +// pkScriptAddReqWithConfUpdates builds an add request, optionally enabling +// partial confirmation update notifications. +func pkScriptAddReqWithConfUpdates(pkScripts [][]byte, + events []crpc.PkScriptEventType, numConfs uint32, + includeBlock, includeTx, includeConfirmationUpdates bool, +) *crpc.PkScriptRequest { + + add := &crpc.AddPkScriptRequest{ + PkScripts: pkScripts, + Events: events, + NumConfs: numConfs, + IncludeBlock: includeBlock, + IncludeTx: includeTx, + IncludeConfirmationUpdates: includeConfirmationUpdates, + } + + return &crpc.PkScriptRequest{ + Request: &crpc.PkScriptRequest_Add{Add: add}, + } +} + +// pkScriptRemoveReq builds a remove request for the provided pkScripts. +func pkScriptRemoveReq(pkScripts [][]byte) *crpc.PkScriptRequest { + return &crpc.PkScriptRequest{ + Request: &crpc.PkScriptRequest_Remove{ + Remove: &crpc.RemovePkScriptRequest{ + PkScripts: pkScripts, + }, + }, + } +} + +// recvEvent returns the next queued stream event or a timeout error. +func (a *pkScriptStreamHarness) recvEvent( + timeout time.Duration) (*crpc.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 that arrive +// first. +func (a *pkScriptStreamHarness) requireAck( + action crpc.PkScriptMutationAction) *crpc.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 + } + + ntfn := event.GetNotification() + require.NotNil(a.ht, ntfn) + a.pending = append(a.pending, ntfn) + } +} + +// requireNotification returns the next notification, failing the test if a +// mutation ack arrives unexpectedly. +func (a *pkScriptStreamHarness) requireNotification() *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, + ) + } + + ntfn := event.GetNotification() + require.NotNil(a.ht, ntfn) + + return ntfn + } +} + +// requireNotifications returns count notifications from the pkScript stream. +func (a *pkScriptStreamHarness) requireNotifications( + count int) []*crpc.PkScriptNotification { + + a.ht.Helper() + + ntfns := make([]*crpc.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. +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), + ) + } + + event, err := a.recvEvent(timeout) + if errors.Is(err, errPkScriptEventTimeout) { + return + } + require.NoError(a.ht, err) + + require.Failf(a.ht, "unexpected pkScript event", "%v", event) +} + +// newPkScriptTestAddress creates a fresh P2WPKH test address, its pkScript, +// and the corresponding private key. +func newPkScriptTestAddress(ht *lntest.HarnessTest) (address.Address, []byte, + *btcec.PrivateKey) { + + ht.Helper() + + privKey, err := btcec.NewPrivateKey() + require.NoError(ht, err) + + pubKeyHash := address.Hash160(privKey.PubKey().SerializeCompressed()) + addr, err := address.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 address.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 *crpc.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 []*crpc.PkScriptNotification, +) map[string]*crpc.PkScriptNotification { + + ht.Helper() + + ntfnMap := make(map[string]*crpc.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 *pkScriptNotification, + expectedType 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 == + spendEvent { + + 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 ...*crpc.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( + regAct, + ) + + 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 +} + +// testPkScriptNotifierFutureRegistrationRPC ensures a newly registered +// stream can receive future confirmations and spends for multiple scripts. +func testPkScriptNotifierFutureRegistrationRPC(ht *lntest.HarnessTest) { + alice := ht.NewNodeWithCoins("Alice", nil) + bob := ht.NewNode("Bob", nil) + + addrA, scriptA, keyA := newPkScriptTestAddress(ht) + addrB, scriptB, keyB := newPkScriptTestAddress(ht) + + stream := newRegisteredPkScriptStream(ht, bob) + defer stream.close() + + stream.send(pkScriptAddReq( + [][]byte{scriptA, scriptB}, []crpc.PkScriptEventType{ + confirmEvent, + spendEvent, + }, 2, true, true, + )) + addAckAB := stream.requireAck( + crpc.PkScriptMutationAction_PK_SCRIPT_MUTATION_ACTION_ADD, + ) + require.Equal(ht, uint32(2), addAckAB.NumAdded) + + 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() + + regMap := pkScriptNotificationMap(ht, stream.requireNotifications(4)) + + assertPkScriptNotification( + ht, + regMap[fmt.Sprintf("%s:%d", wireOutpointKey(outpointA), + confirmEvent)], + confirmEvent, false, + confirmHeightAB, &confirmBlockHashAB, txidA, outpointA, 2, true, + ) + assertPkScriptNotification( + ht, + regMap[fmt.Sprintf("%s:%d", wireOutpointKey(outpointB), + confirmEvent)], + confirmEvent, false, + confirmHeightAB, &confirmBlockHashAB, txidB, outpointB, 2, true, + ) + assertPkScriptNotification( + ht, + regMap[fmt.Sprintf("%s:%d", wireOutpointKey(outpointA), + spendEvent)], + spendEvent, false, + spendHeightAB, &spendBlockHashAB, &spendHashA, outpointA, 0, + true, + ) + assertPkScriptNotification( + ht, + regMap[fmt.Sprintf("%s:%d", wireOutpointKey(outpointB), + spendEvent)], + spendEvent, false, + spendHeightAB, &spendBlockHashAB, &spendHashB, outpointB, 0, + true, + ) +} + +// testPkScriptNotifierFutureAddRPC ensures a registered stream can add multiple +// scripts later and receive future notifications for all of them. +func testPkScriptNotifierFutureAddRPC(ht *lntest.HarnessTest) { + alice, _, stream := newPkScriptNotifierRPCScenario(ht) + defer stream.close() + + addrC, scriptC, keyC := newPkScriptTestAddress(ht) + addrD, scriptD, _ := newPkScriptTestAddress(ht) + + stream.send(pkScriptAddReq( + [][]byte{scriptC, scriptD}, nil, 2, true, true, + )) + addAckCD := stream.requireAck( + crpc.PkScriptMutationAction_PK_SCRIPT_MUTATION_ACTION_ADD, + ) + require.Equal(ht, uint32(2), addAckCD.NumAdded) + + 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 this covers multiple confirms and one spend + // in the same add request. + spendBlockC := ht.MineBlocksAndAssertNumTxes(1, 1)[0] + spendHeightC := ht.CurrentHeight() + spendBlockHashC := spendBlockC.BlockHash() + + addMap := pkScriptNotificationMap(ht, stream.requireNotifications(3)) + + assertPkScriptNotification( + ht, + addMap[fmt.Sprintf("%s:%d", wireOutpointKey(outpointC), + confirmEvent)], + confirmEvent, false, + confirmHeightCD, &confirmBlockHashCD, txidC, outpointC, 2, true, + ) + assertPkScriptNotification( + ht, + addMap[fmt.Sprintf("%s:%d", wireOutpointKey(outpointD), + confirmEvent)], + confirmEvent, false, + confirmHeightCD, &confirmBlockHashCD, txidD, outpointD, 2, true, + ) + assertPkScriptNotification( + ht, + addMap[fmt.Sprintf("%s:%d", wireOutpointKey(outpointC), + spendEvent)], + spendEvent, false, + spendHeightC, &spendBlockHashC, &spendHashC, outpointC, 0, true, + ) +} + +// 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, true, true, + )) + addAck := stream.requireAck( + crpc.PkScriptMutationAction_PK_SCRIPT_MUTATION_ACTION_ADD, + ) + require.Equal(ht, uint32(1), addAck.NumAdded) + + 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(), + confirmEvent, false, + confirmHeightE, &confirmBlockHashE, txidE, outpointE, 2, true, + ) + + stream.send(pkScriptRemoveReq([][]byte{scriptE})) + stream.requireAck( + removeAct, + ) + + 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}, []crpc.PkScriptEventType{ + confirmEvent, + }, 1, false, false, + )) + confirmOnlyStream.requireAck( + crpc.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(), + confirmEvent, 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}, []crpc.PkScriptEventType{ + confirmEvent, + }, 3, false, false, true, + )) + updatesAck := updatesStream.requireAck( + crpc.PkScriptMutationAction_PK_SCRIPT_MUTATION_ACTION_ADD, + ) + require.Equal(ht, uint32(1), updatesAck.NumAdded) + + 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, + confUpd, + 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, + confUpd, + 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, + confirmEvent, 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) + + stream.send(pkScriptAddReq( + [][]byte{scriptH}, nil, 2, true, true, + )) + firstAck := stream.requireAck( + crpc.PkScriptMutationAction_PK_SCRIPT_MUTATION_ACTION_ADD, + ) + require.Equal(ht, uint32(1), firstAck.NumAdded) + + stream.send(pkScriptAddReq( + [][]byte{scriptH}, nil, 2, true, true, + )) + reAddAck := stream.requireAck( + crpc.PkScriptMutationAction_PK_SCRIPT_MUTATION_ACTION_ADD, + ) + require.Zero(ht, reAddAck.NumAdded) + + confirmOnlyStream := newRegisteredPkScriptStream(ht, bob) + defer confirmOnlyStream.close() + + confirmOnlyStream.send(pkScriptAddReq( + [][]byte{scriptH}, []crpc.PkScriptEventType{ + confirmEvent, + }, 1, false, false, + )) + confirmOnlyStream.requireAck( + crpc.PkScriptMutationAction_PK_SCRIPT_MUTATION_ACTION_ADD, + ) + + spendOnlyStream := newRegisteredPkScriptStream(ht, bob) + defer spendOnlyStream.close() + + spendOnlyStream.send(pkScriptAddReq( + [][]byte{scriptH}, []crpc.PkScriptEventType{ + spendEvent, + }, 0, false, false, + )) + spendOnlyStream.requireAck( + crpc.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(), + confirmEvent, false, + fundingHeightH, &fundingBlockHashH, txidH, outpointH, 1, false, + withPkScriptUTXO(outputH, fundingHeightH, &fundingBlockHashH), + withPkScriptTxIndex(1), withPkScriptUTXOTxIndex(1), + withPkScriptInputIndex(0), + ) + assertPkScriptNotification( + ht, stream.requireNotification(), + confirmEvent, 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(), + spendEvent, false, + spendHeightH, &spendBlockHashH, &spendHashH, outpointH, 0, true, + withPkScriptUTXO(outputH, fundingHeightH, &fundingBlockHashH), + withPkScriptTxIndex(1), withPkScriptUTXOTxIndex(1), + withPkScriptInputIndex(0), + ) + assertPkScriptNotification( + ht, spendOnlyStream.requireNotification(), + spendEvent, false, + spendHeightH, &spendBlockHashH, &spendHashH, outpointH, 0, + false, + withPkScriptUTXO(outputH, fundingHeightH, &fundingBlockHashH), + withPkScriptTxIndex(1), withPkScriptUTXOTxIndex(1), + withPkScriptInputIndex(0), + ) + confirmOnlyStream.assertNoNotification(2 * time.Second) +} + +// testPkScriptNotifierFutureOnlyRPC ensures newly added watches ignore old +// outputs and spends while still tracking future receives and spends. +func testPkScriptNotifierFutureOnlyRPC(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}, []crpc.PkScriptEventType{ + confirmEvent, + spendEvent, + }, 1, false, false, + )) + skipHistoryStream.requireAck( + crpc.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(), + confirmEvent, 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(), + spendEvent, 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, false, false, + )) + skipHistoryStream.requireAck( + crpc.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(), + confirmEvent, 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, false, false, + ), + ) + + validRegister := pkScriptRegisterReq() + requirePkScriptStreamError( + ht, bob, codes.InvalidArgument, validRegister, validRegister, + ) + + requirePkScriptStreamError( + ht, bob, codes.InvalidArgument, + pkScriptRegisterReq(), + pkScriptAddReq( + [][]byte{script}, []crpc.PkScriptEventType{ + confUpd, + }, 1, 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, true, true, + )) + stream.requireAck( + crpc.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(), + confirmEvent, 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(), + spendEvent, 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 == + spendEvent && + ntfn.Disconnected: + + assertPkScriptNotification( + ht, ntfn, + spendEvent, + true, spendHeightF, &spendBlockHashF, + &spendHashF, + outpointF, 0, true, + ) + seenSpendReorg = true + + case ntfn.EventType == + confirmEvent && + ntfn.Disconnected: + + assertPkScriptNotification( + ht, ntfn, + confirmEvent, + true, confirmHeightF, &confirmBlockHashF, txidF, + outpointF, 2, true, + ) + seenConfirmReorg = true + + case ntfn.EventType == + confirmEvent && + !ntfn.Disconnected: + + assertPkScriptNotification( + ht, ntfn, + confirmEvent, + 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(), + spendEvent, false, + respendHeightF, &respendBlockHashF, &spendHashF, outpointF, 0, + true, + ) +} diff --git a/lnrpc/chainrpc/chain_server.go b/lnrpc/chainrpc/chain_server.go index 690cd817727..9b02dd26d52 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/chainhash/v2" "github.com/btcsuite/btcd/wire/v2" @@ -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,605 @@ 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()) + } + + 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 +} + +// 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 + + 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 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 errors.Is(err, chainntnfs.ErrPkScriptMatchLimit) { + return status.Error( + codes.ResourceExhausted, + "pkScript match resource limit exceeded", + ) + } + 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 register", + ) + } + + 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) + + for { + select { + case ntfn, ok := <-reg.Notifications: + if !ok { + return pkScriptNotificationStreamClosedErr(reg) + } + + 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..7745f1cdf27 --- /dev/null +++ b/lnrpc/chainrpc/chain_server_test.go @@ -0,0 +1,202 @@ +//go:build chainrpc +// +build chainrpc + +package chainrpc + +import ( + "context" + "io" + "testing" + "time" + + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "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) +} + +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.ErrPkScriptMatchLimit + } + 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 41785b60581..b4fe712c81d 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,6 +762,662 @@ func (x *BlockEpoch) GetHeight() uint32 { return 0 } +type PkScriptRegisterRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PkScriptRegisterRequest) Reset() { + *x = PkScriptRegisterRequest{} + mi := &file_chainrpc_chainnotifier_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PkScriptRegisterRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +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) +} + +// Deprecated: Use PkScriptRegisterRequest.ProtoReflect.Descriptor instead. +func (*PkScriptRegisterRequest) Descriptor() ([]byte, []int) { + return file_chainrpc_chainnotifier_proto_rawDescGZIP(), []int{9} +} + +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 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 (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 + } + 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 + } + 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) GetIncludeConfirmationUpdates() bool { + if x != nil { + return x.IncludeConfirmationUpdates + } + return false +} + +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"` + 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 +} + +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 PkScriptEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Event: + // + // *PkScriptEvent_Ack + // *PkScriptEvent_Notification + Event isPkScriptEvent_Event `protobuf_oneof:"event"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PkScriptEvent) Reset() { + *x = PkScriptEvent{} + mi := &file_chainrpc_chainnotifier_proto_msgTypes[16] + 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[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 PkScriptEvent.ProtoReflect.Descriptor instead. +func (*PkScriptEvent) Descriptor() ([]byte, []int) { + return file_chainrpc_chainnotifier_proto_rawDescGZIP(), []int{16} +} + +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 +} + +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"` +} + +func (*PkScriptEvent_Ack) isPkScriptEvent_Event() {} + +func (*PkScriptEvent_Notification) isPkScriptEvent_Event() {} + var File_chainrpc_chainnotifier_proto protoreflect.FileDescriptor const file_chainrpc_chainnotifier_proto_rawDesc = "" + @@ -710,11 +1464,70 @@ const file_chainrpc_chainnotifier_proto_rawDesc = "" + "\n" + "BlockEpoch\x12\x12\n" + "\x04hash\x18\x01 \x01(\fR\x04hash\x12\x16\n" + - "\x06height\x18\x02 \x01(\rR\x06height2\xe7\x01\n" + + "\x06height\x18\x02 \x01(\rR\x06height\"\x19\n" + + "\x17PkScriptRegisterRequest\"\xa7\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\x12@\n" + + "\x1cinclude_confirmation_updates\x18\a \x01(\bR\x1aincludeConfirmationUpdatesJ\x04\b\x06\x10\aR\x14historical_scan_from\"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\"\xd8\x01\n" + + "\x13PkScriptMutationAck\x128\n" + + "\x06action\x18\x01 \x01(\x0e2 .chainrpc.PkScriptMutationActionR\x06action\x12\x1b\n" + + "\tnum_added\x18\x02 \x01(\rR\bnumAddedJ\x04\b\x03\x10\aR\x16historical_scan_queuedR\x12historical_scan_idR\x1chistorical_scan_start_heightR\x1ahistorical_scan_end_height\"\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\"\xa8\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\fnotificationB\a\n" + + "\x05eventJ\x04\b\x03\x10\x04R\x0fhistorical_scan*\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\x01B0Z.github.com/lightningnetwork/lnd/lnrpc/chainrpcb\x06proto3" + "\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 @@ -728,36 +1541,59 @@ func file_chainrpc_chainnotifier_proto_rawDescGZIP() []byte { return file_chainrpc_chainnotifier_proto_rawDescData } -var file_chainrpc_chainnotifier_proto_msgTypes = make([]protoimpl.MessageInfo, 9) +var file_chainrpc_chainnotifier_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_chainrpc_chainnotifier_proto_msgTypes = make([]protoimpl.MessageInfo, 17) 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 + (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 + (*PkScriptEvent)(nil), // 18: chainrpc.PkScriptEvent } 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 + 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 + 2, // 16: chainrpc.ChainNotifier.RegisterConfirmationsNtfn:input_type -> chainrpc.ConfRequest + 7, // 17: chainrpc.ChainNotifier.RegisterSpendNtfn:input_type -> chainrpc.SpendRequest + 10, // 18: chainrpc.ChainNotifier.RegisterBlockEpochNtfn:input_type -> chainrpc.BlockEpoch + 14, // 19: chainrpc.ChainNotifier.RegisterPkScriptNtfn:input_type -> chainrpc.PkScriptRequest + 5, // 20: chainrpc.ChainNotifier.RegisterConfirmationsNtfn:output_type -> chainrpc.ConfEvent + 9, // 21: chainrpc.ChainNotifier.RegisterSpendNtfn:output_type -> chainrpc.SpendEvent + 10, // 22: chainrpc.ChainNotifier.RegisterBlockEpochNtfn:output_type -> chainrpc.BlockEpoch + 18, // 23: chainrpc.ChainNotifier.RegisterPkScriptNtfn:output_type -> chainrpc.PkScriptEvent + 20, // [20:24] is the sub-list for method output_type + 16, // [16:20] is the sub-list for method input_type + 16, // [16:16] is the sub-list for extension type_name + 16, // [16:16] is the sub-list for extension extendee + 0, // [0:16] is the sub-list for field type_name } func init() { file_chainrpc_chainnotifier_proto_init() } @@ -773,18 +1609,28 @@ func file_chainrpc_chainnotifier_proto_init() { (*SpendEvent_Spend)(nil), (*SpendEvent_Reorg)(nil), } + file_chainrpc_chainnotifier_proto_msgTypes[12].OneofWrappers = []any{ + (*PkScriptRequest_Register)(nil), + (*PkScriptRequest_Add)(nil), + (*PkScriptRequest_Remove)(nil), + } + file_chainrpc_chainnotifier_proto_msgTypes[16].OneofWrappers = []any{ + (*PkScriptEvent_Ack)(nil), + (*PkScriptEvent_Notification)(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: 17, 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..2f87add6ccf 100644 --- a/lnrpc/chainrpc/chainnotifier.proto +++ b/lnrpc/chainrpc/chainnotifier.proto @@ -42,6 +42,25 @@ 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 for future chain + activity. 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 +217,172 @@ 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 { + reserved 6; + reserved "historical_scan_from"; + + /* + 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 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 { + reserved 3 to 6; + reserved "historical_scan_queued", "historical_scan_id", + "historical_scan_start_height", "historical_scan_end_height"; + + // The mutation that was accepted for this stream. + PkScriptMutationAction action = 1; + + // The number of new scripts accepted by this mutation. + uint32 num_added = 2; +} + +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 PkScriptEvent { + reserved 3; + reserved "historical_scan"; + + oneof event { + // Acknowledges that a stream mutation was accepted. + PkScriptMutationAck ack = 1; + + // A notification for a watched output script. + PkScriptNotification notification = 2; + } +} diff --git a/lnrpc/chainrpc/chainnotifier.swagger.json b/lnrpc/chainrpc/chainnotifier.swagger.json index b8e2924cdb7..2d8f9c66632 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 for future chain\nactivity. 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,43 @@ } }, "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." + }, + "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,9 +330,179 @@ } } }, - "chainrpcReorg": { + "chainrpcPkScriptEvent": { "type": "object", - "description": "TODO(wilmer): need to know how the client will use this first." + "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." + } + } + }, + "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" + }, + "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." + } + } + }, + "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" }, "chainrpcSpendDetails": { "type": "object", 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..98900216d27 100644 --- a/lnrpc/chainrpc/chainnotifier_grpc.pb.go +++ b/lnrpc/chainrpc/chainnotifier_grpc.pb.go @@ -45,6 +45,21 @@ 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 for future chain + // activity. 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 +166,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 +228,21 @@ 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 for future chain + // activity. 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 +259,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 +338,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 +387,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 8bd4b027017..64b5d554cb1 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), )