Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ linters:
# Allow btcwallet to follow the 0.21 lnd/lndclient stack while
# taproot-assets still requires the released v0.16.17 tag.
- github.com/btcsuite/btcwallet
# Build the modular channel runtime from the reviewed lnd fork.
- github.com/lightningnetwork/lnd
disable:
# We instead use our own custom line length linter called `ll` since
# then we can ignore log lines.
Expand Down
292 changes: 292 additions & 0 deletions chainbackends/backend_notifier.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,292 @@
package chainbackends

import (
"context"
"fmt"
"sync/atomic"

"github.com/btcsuite/btcd/chainhash/v2"
"github.com/btcsuite/btcd/wire/v2"
"github.com/lightninglabs/wavelength/chainsource"
"github.com/lightningnetwork/lnd/chainntnfs"
)

// BackendChainNotifier adapts Wavelength's process-owned chain backend to the
// native lnd notifier interface. It does not start or stop the backend because
// the embedding wallet owns that lifecycle.
type BackendChainNotifier struct {
backend chainsource.ChainBackend
started atomic.Bool
}

// NewBackendChainNotifier constructs a notifier over an already-running chain
// backend.
func NewBackendChainNotifier(backend chainsource.ChainBackend) (
*BackendChainNotifier, error) {

if backend == nil {
return nil, fmt.Errorf("chain backend is required")
}

notifier := &BackendChainNotifier{backend: backend}
notifier.started.Store(true)

return notifier, nil
}

// RegisterConfirmationsNtfn forwards one confirmation lifecycle into lnd's
// notifier event shape.
func (n *BackendChainNotifier) RegisterConfirmationsNtfn(txid *chainhash.Hash,
pkScript []byte, numConfs, heightHint uint32,
opts ...chainntnfs.NotifierOption) (*chainntnfs.ConfirmationEvent,
error) {

notifierOpts := chainntnfs.DefaultNotifierOptions()
for _, opt := range opts {
opt(notifierOpts)
}

ctx, cancel := context.WithCancel(context.Background())
registration, err := n.backend.RegisterConf(
ctx, txid, pkScript, numConfs, heightHint,
notifierOpts.IncludeBlock,
)
if err != nil {
cancel()

return nil, err
}

event := chainntnfs.NewConfirmationEvent(numConfs, func() {
cancel()
registration.Cancel()
})
go forwardBackendConfirmations(ctx, registration, event)

return event, nil
}

// RegisterSpendNtfn forwards one spend lifecycle into lnd's notifier event
// shape.
func (n *BackendChainNotifier) RegisterSpendNtfn(outpoint *wire.OutPoint,
pkScript []byte, heightHint uint32) (*chainntnfs.SpendEvent, error) {

ctx, cancel := context.WithCancel(context.Background())
registration, err := n.backend.RegisterSpend(
ctx, outpoint, pkScript, heightHint,
)
if err != nil {
cancel()

return nil, err
}

event := chainntnfs.NewSpendEvent(func() {
cancel()
registration.Cancel()
})
go forwardBackendSpends(ctx, registration, event)

return event, nil
}

// RegisterBlockEpochNtfn seeds the current tip before forwarding new blocks
// from the process chain backend. Lnd consumers use that first epoch as the
// registration barrier before starting their event loops.
func (n *BackendChainNotifier) RegisterBlockEpochNtfn(
bestBlock *chainntnfs.BlockEpoch) (*chainntnfs.BlockEpochEvent, error) {

ctx, cancel := context.WithCancel(context.Background())
registration, err := n.backend.RegisterBlocks(ctx)
if err != nil {
cancel()

return nil, err
}
height, hash, err := n.backend.BestBlock(ctx)
if err != nil {
cancel()
registration.Cancel()

return nil, fmt.Errorf("read block epoch registration tip: %w",
err)
}

epochs := make(chan *chainntnfs.BlockEpoch, 10)
go func() {
defer close(epochs)

lastHeight := height
lastHash := hash
seedTip := bestBlock == nil || bestBlock.Hash == nil ||
bestBlock.Height != height || *bestBlock.Hash != hash
if seedTip {
select {
case epochs <- &chainntnfs.BlockEpoch{
Hash: &hash, Height: height,
}:
case <-ctx.Done():
return
}
}

for {
select {
case epoch, ok := <-registration.Epochs:
if !ok {
return
}
if epoch.Height == lastHeight &&
epoch.Hash == lastHash {

continue
}
Comment on lines +139 to +143

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip block epochs older than the registration snapshot

During a burst of blocks, more than one epoch can be queued after RegisterBlocks but before BestBlock returns. For example, if heights H+1 and H+2 queue while BestBlock reports H+2, the goroutine seeds H+2 and then forwards H+1 followed by H+2 because it only removes exact duplicates, producing a regressing block stream. Ignore queued epochs older than the snapshot while still allowing same-height hash changes for reorg handling.

Useful? React with 👍 / 👎.

lastHeight = epoch.Height
lastHash = epoch.Hash
hash := epoch.Hash
select {
case epochs <- &chainntnfs.BlockEpoch{
Hash: &hash, Height: epoch.Height,
}:
case <-ctx.Done():
return
}

case <-ctx.Done():
return
}
}
}()

return &chainntnfs.BlockEpochEvent{
Epochs: epochs,
Cancel: func() {
cancel()
registration.Cancel()
},
}, nil
}

// Start records notifier availability. The chain backend remains owned by the
// embedding Wavelength wallet.
func (n *BackendChainNotifier) Start() error {
n.started.Store(true)

return nil
}

// Started reports whether the adapter accepts registrations.
func (n *BackendChainNotifier) Started() bool {
return n.started.Load()
}

// Stop marks the adapter stopped without stopping the shared chain backend.
func (n *BackendChainNotifier) Stop() error {
n.started.Store(false)
Comment on lines +184 to +185

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cancel adapter-owned registrations during Stop

When this adapter is stopped while the shared backend remains running, every outstanding confirmation, spend, and block forwarder continues indefinitely because its registration context is rooted at context.Background() and Stop retains no cancellation handles. started also does not prevent new registrations after shutdown. Give the adapter a lifecycle context or track registration cancels so Stop terminates its own workers without stopping the shared backend.

AGENTS.md reference: AGENTS.md:L77-L81

Useful? React with 👍 / 👎.


return nil
}

// forwardBackendConfirmations preserves each backend lifecycle on the lnd
// event returned to native channel components.
func forwardBackendConfirmations(ctx context.Context,
registration *chainsource.ConfRegistration,
event *chainntnfs.ConfirmationEvent) {

for {
select {
case confirmation, ok := <-registration.Confirmed:
if !ok {
return
}
select {
case event.Confirmed <- &chainntnfs.TxConfirmation{
BlockHash: confirmation.BlockHash,
BlockHeight: confirmation.BlockHeight,
TxIndex: confirmation.TxIndex,
Tx: confirmation.Tx,
Block: confirmation.Block,
}:
case <-ctx.Done():
return
}

case _, ok := <-registration.Reorged:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve confirmation sequence ordering across reorg channels

When a confirmation, reorg, and reconfirmation are buffered concurrently, the select may receive the newer Confirmed event before the older Reorged event. This branch discards the reorg sequence number, so it cannot reject that stale reorg and can leave the downstream lnd watcher unconfirmed even though the transaction has already reconfirmed. Track the highest shared TxConfirmation.Seq/reorg sequence and suppress older events.

Useful? React with 👍 / 👎.

if !ok {
return
}
select {
case event.NegativeConf <- 0:
case <-ctx.Done():
return
}

case _, ok := <-registration.Done:
if !ok {
return
}
select {
case event.Done <- struct{}{}:
case <-ctx.Done():
}

return

case <-ctx.Done():
return
}
}
}

// forwardBackendSpends preserves each backend spend lifecycle on the lnd
// event returned to native channel components.
func forwardBackendSpends(ctx context.Context,
registration *chainsource.SpendRegistration,
event *chainntnfs.SpendEvent) {

for {
select {
case spend, ok := <-registration.Spend:
if !ok {
return
}
select {
case event.Spend <- &chainntnfs.SpendDetail{
SpentOutPoint: spend.SpentOutPoint,
SpenderTxHash: spend.SpenderTxHash,
SpendingTx: spend.SpendingTx,
SpenderInputIndex: spend.SpenderInputIndex,
SpendingHeight: spend.SpendingHeight,
}:
case <-ctx.Done():
return
}

case _, ok := <-registration.Reorged:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve spend sequence ordering across reorg channels

If a spend, its reorg, and a subsequent re-spend are all buffered, this select can forward the newer re-spend first and then forward the stale reorg because the shared sequence value is ignored. The downstream lnd resolver can consequently finish in the unspent state and miss the current spender; compare SpendDetail.Seq with the reorg sequence and discard stale events.

Useful? React with 👍 / 👎.

if !ok {
return
}
select {
case event.Reorg <- struct{}{}:
case <-ctx.Done():
return
}

case _, ok := <-registration.Done:
if !ok {
return
}
select {
case event.Done <- struct{}{}:
case <-ctx.Done():
}

return

case <-ctx.Done():
return
}
}
}

var _ chainntnfs.ChainNotifier = (*BackendChainNotifier)(nil)
94 changes: 94 additions & 0 deletions chainbackends/backend_notifier_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package chainbackends

import (
"context"
"sync/atomic"
"testing"
"time"

"github.com/btcsuite/btcd/chainhash/v2"
"github.com/lightninglabs/wavelength/chainsource"
"github.com/stretchr/testify/require"
)

// backendNotifierTestBackend provides the block methods exercised by the lnd
// notifier adapter. The embedded interface keeps unrelated backend methods out
// of this focused contract test.
type backendNotifierTestBackend struct {
chainsource.ChainBackend

height int32
hash chainhash.Hash
epochs chan *chainsource.BlockEpoch
canceled atomic.Bool
}

// BestBlock returns the fixed registration tip.
func (b *backendNotifierTestBackend) BestBlock(context.Context) (int32,
chainhash.Hash, error) {

return b.height, b.hash, nil
}

// RegisterBlocks returns a stream that does not seed its current tip, matching
// the production backend contract that the adapter must bridge.
func (b *backendNotifierTestBackend) RegisterBlocks(context.Context) (
*chainsource.BlockRegistration, error) {

return &chainsource.BlockRegistration{
Epochs: b.epochs,
Cancel: func() {
b.canceled.Store(true)
},
}, nil
}

// TestBackendChainNotifierSeedsCurrentTip verifies lnd can use registration as
// a startup barrier even when no new block arrives after the daemon starts.
func TestBackendChainNotifierSeedsCurrentTip(t *testing.T) {
t.Parallel()

backend := &backendNotifierTestBackend{
height: 133,
hash: chainhash.Hash{
1,
3,
3,
7,
},
epochs: make(chan *chainsource.BlockEpoch, 2),
}
notifier, err := NewBackendChainNotifier(backend)
require.NoError(t, err)
event, err := notifier.RegisterBlockEpochNtfn(nil)
require.NoError(t, err)
t.Cleanup(event.Cancel)

select {
case epoch := <-event.Epochs:
require.Equal(t, backend.height, epoch.Height)
require.Equal(t, backend.hash, *epoch.Hash)

case <-time.After(time.Second):
t.Fatal("current block epoch was not delivered")
}

// A backend may also seed the same tip. The adapter suppresses that
// duplicate while preserving the next connected block.
backend.epochs <- &chainsource.BlockEpoch{
Height: backend.height, Hash: backend.hash,
}
nextHash := chainhash.Hash{1, 3, 3, 8}
backend.epochs <- &chainsource.BlockEpoch{
Height: backend.height + 1, Hash: nextHash,
}

select {
case epoch := <-event.Epochs:
require.Equal(t, backend.height+1, epoch.Height)
require.Equal(t, nextHash, *epoch.Hash)

case <-time.After(time.Second):
t.Fatal("next block epoch was not delivered")
}
}
Loading
Loading