-
Notifications
You must be signed in to change notification settings - Fork 11
lnruntime: Compose native Ark channel state machines #1191
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| } | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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 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: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a confirmation, reorg, and reconfirmation are buffered concurrently, the 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: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If a spend, its reorg, and a subsequent re-spend are all buffered, this 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) | ||
| 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") | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
During a burst of blocks, more than one epoch can be queued after
RegisterBlocksbut beforeBestBlockreturns. For example, if heights H+1 and H+2 queue whileBestBlockreports 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 👍 / 👎.