diff --git a/.golangci.yml b/.golangci.yml index c7f697679..12dc613d4 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -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. diff --git a/chainbackends/backend_notifier.go b/chainbackends/backend_notifier.go new file mode 100644 index 000000000..9c1a9bf06 --- /dev/null +++ b/chainbackends/backend_notifier.go @@ -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) + + 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: + 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: + 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) diff --git a/chainbackends/backend_notifier_test.go b/chainbackends/backend_notifier_test.go new file mode 100644 index 000000000..84e45b912 --- /dev/null +++ b/chainbackends/backend_notifier_test.go @@ -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") + } +} diff --git a/chainfees/backend.go b/chainfees/backend.go new file mode 100644 index 000000000..d62fdda2f --- /dev/null +++ b/chainfees/backend.go @@ -0,0 +1,73 @@ +package chainfees + +import ( + "context" + "fmt" + + "github.com/lightninglabs/wavelength/chainsource" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" +) + +// BackendEstimator adapts Wavelength's chain fee source to lnd's channel fee +// estimator interface. +type BackendEstimator struct { + backend chainsource.ChainBackend + relayFee chainfee.SatPerKWeight +} + +// NewBackendEstimator constructs a live estimator with a fixed relay floor. +func NewBackendEstimator(backend chainsource.ChainBackend, + relayFee chainfee.SatPerKWeight) (*BackendEstimator, error) { + + if backend == nil { + return nil, fmt.Errorf("chain backend is required") + } + if relayFee < chainfee.FeePerKwFloor { + relayFee = chainfee.FeePerKwFloor + } + + return &BackendEstimator{ + backend: backend, relayFee: relayFee, + }, nil +} + +// EstimateFeePerKW obtains a current sat/vbyte estimate and converts it to +// lnd's sat/kw unit while enforcing the relay floor. +func (e *BackendEstimator) EstimateFeePerKW(confTarget uint32) ( + chainfee.SatPerKWeight, error) { + + rate, err := e.backend.EstimateFee(context.Background(), confTarget) + if err != nil { + + // A fee-source outage must not disable channel operation. The + // configured relay floor is a conservative usable estimate. + //nolint:nilerr + return e.relayFee, nil + } + if rate <= 0 { + return e.relayFee, nil + } + feeRate := chainfee.SatPerVByte(rate).FeePerKWeight() + if feeRate < e.relayFee { + feeRate = e.relayFee + } + + return feeRate, nil +} + +// RelayFeePerKW returns the configured minimum relay fee. +func (e *BackendEstimator) RelayFeePerKW() chainfee.SatPerKWeight { + return e.relayFee +} + +// Start leaves lifecycle ownership with Wavelength's shared chain backend. +func (*BackendEstimator) Start() error { + return nil +} + +// Stop leaves lifecycle ownership with Wavelength's shared chain backend. +func (*BackendEstimator) Stop() error { + return nil +} + +var _ chainfee.Estimator = (*BackendEstimator)(nil) diff --git a/chainfees/backend_test.go b/chainfees/backend_test.go new file mode 100644 index 000000000..7b538e9bd --- /dev/null +++ b/chainfees/backend_test.go @@ -0,0 +1,67 @@ +package chainfees + +import ( + "context" + "errors" + "testing" + + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/lightninglabs/wavelength/chainsource" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/stretchr/testify/require" +) + +type backendEstimateStub struct { + chainsource.ChainBackend + + rate btcutil.Amount + err error +} + +// EstimateFee returns the configured fee estimate result. +func (b *backendEstimateStub) EstimateFee(context.Context, uint32) ( + btcutil.Amount, error) { + + return b.rate, b.err +} + +// TestBackendEstimatorFallback verifies that a fresh or unavailable fee +// backend cannot prevent channel negotiation from using the relay floor. +func TestBackendEstimatorFallback(t *testing.T) { + t.Parallel() + + relayFee := chainfee.SatPerKWeight(500) + testCases := []struct { + name string + backend *backendEstimateStub + }{ + { + name: "backend unavailable", + backend: &backendEstimateStub{ + err: errors.New("no fee estimates available"), + }, + }, + { + name: "non-positive estimate", + backend: &backendEstimateStub{ + rate: 0, + }, + }, + } + + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + estimator, err := NewBackendEstimator( + testCase.backend, relayFee, + ) + require.NoError(t, err) + + rate, err := estimator.EstimateFeePerKW(6) + require.NoError(t, err) + require.Equal(t, relayFee, rate) + }) + } +} diff --git a/go.mod b/go.mod index 38f0e3d6a..902712a97 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,9 @@ go 1.26.0 // Use the forked migrate with custom functionality. replace github.com/golang-migrate/migrate/v4 => github.com/lightninglabs/migrate/v4 v4.18.2-9023d66a-fork-pr-2 +// Build the modular channel runtime against the reviewed lnd fork commit. +replace github.com/lightningnetwork/lnd => github.com/sputn1ck/lnd v0.4.2-beta.0.20260812173519-3963ad13611a + require ( github.com/btcsuite/btcd v0.26.0 github.com/btcsuite/btcd/btcec/v2 v2.5.0 @@ -145,12 +148,12 @@ require ( github.com/klauspost/compress v1.18.0 // indirect github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf // indirect github.com/lightninglabs/lightning-node-connect/hashmailrpc v1.0.4-0.20250610182311-2f1d46ef18b7 // indirect - github.com/lightningnetwork/lnd/actor v0.0.6 // indirect + github.com/lightningnetwork/lnd/actor v0.0.6 github.com/lightningnetwork/lnd/cert v1.2.2 github.com/lightningnetwork/lnd/healthcheck v1.2.6 // indirect github.com/lightningnetwork/lnd/queue v1.2.0 // indirect github.com/lightningnetwork/lnd/sqldb v1.0.13 - github.com/lightningnetwork/lnd/ticker v1.1.1 // indirect + github.com/lightningnetwork/lnd/ticker v1.1.1 github.com/lightningnetwork/lnd/tor v1.2.0 // indirect github.com/ltcsuite/ltcd v0.0.0-20190101042124-f37f8bf35796 // indirect github.com/mattn/go-isatty v0.0.20 // indirect diff --git a/go.sum b/go.sum index 7115439e2..f5720824c 100644 --- a/go.sum +++ b/go.sum @@ -1064,8 +1064,6 @@ github.com/lightninglabs/taproot-assets/taprpc v1.1.1-0.20260706193822-2adfadc58 github.com/lightninglabs/taproot-assets/taprpc v1.1.1-0.20260706193822-2adfadc58e3c/go.mod h1:nOjUfSstCfHYY0QNnxlhN+N915Vcl5ziyHiOD8hVoJI= github.com/lightningnetwork/lightning-onion v1.4.0 h1:qWE1icOH4AKXRcq1KCzt6P/TesqptgBTP++V7wowTc0= github.com/lightningnetwork/lightning-onion v1.4.0/go.mod h1:YDPkvVTVQ6FBBE6Yj93tDd7zA3iTSrryi9xq46i7bKE= -github.com/lightningnetwork/lnd v0.21.0-beta.rc2.0.20260630214209-40c64f9db30d h1:mDcB9mwuwJXRbDmxps6mBgGs318Iz5ZbOrt0ieEx0os= -github.com/lightningnetwork/lnd v0.21.0-beta.rc2.0.20260630214209-40c64f9db30d/go.mod h1:LWfQaNNXlZghUJqIIh+JsgfxiVtC+ecZkrIe5bm9o4U= github.com/lightningnetwork/lnd/actor v0.0.6 h1:Ge8N2wivARG+27qJBwTlB0vwsypStZYZy8vk4Zl38sU= github.com/lightningnetwork/lnd/actor v0.0.6/go.mod h1:YAsoniSbY/cAM9HTVNfZLvt7RI6swDxy6wzPspTcMZg= github.com/lightningnetwork/lnd/cert v1.2.2 h1:71YK6hogeJtxSxw2teq3eGeuy4rHGKcFf0d0Uy4qBjI= @@ -1215,6 +1213,8 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/sputn1ck/lnd v0.4.2-beta.0.20260812173519-3963ad13611a h1:7FffhsL+B3cWTHat4Hem78O8TihAyFpg8TUY8FIAHaY= +github.com/sputn1ck/lnd v0.4.2-beta.0.20260812173519-3963ad13611a/go.mod h1:LWfQaNNXlZghUJqIIh+JsgfxiVtC+ecZkrIe5bm9o4U= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= diff --git a/lnruntime/ark_funding.go b/lnruntime/ark_funding.go new file mode 100644 index 000000000..0a20a7413 --- /dev/null +++ b/lnruntime/ark_funding.go @@ -0,0 +1,196 @@ +package lnruntime + +import ( + "bytes" + "context" + "errors" + "fmt" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/wavelength/arkchannel" + "github.com/lightningnetwork/lnd/channeldb" + lndfunding "github.com/lightningnetwork/lnd/funding" + "github.com/lightningnetwork/lnd/htlcswitch" + "github.com/lightningnetwork/lnd/lnwire" +) + +// RestoreBacking registers one durable Ark backing transaction before the +// native funding manager restores pending channels from lnd's database. +func (f *FundingRuntime) RestoreBacking(terms arkchannel.Terms, + backing arkchannel.Backing) error { + + funding, err := virtualFundingFromBacking(terms, backing) + if err != nil { + return err + } + + return f.RegisterBacking(funding) +} + +// FundingFinalized reports whether lnd's database contains the exact channel +// described by durable Ark terms and backing. +func (f *FundingRuntime) FundingFinalized(ctx context.Context, + terms arkchannel.Terms, backing arkchannel.Backing) (bool, error) { + + select { + case <-ctx.Done(): + return false, ctx.Err() + + default: + } + + funding, err := virtualFundingFromBacking(terms, backing) + if err != nil { + return false, err + } + channel, err := f.stateDB.FetchChannel(backing.ChannelPoint) + if errors.Is(err, channeldb.ErrChannelNotFound) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("find finalized lnd channel: %w", err) + } + if channel.FundingOutpoint != backing.ChannelPoint || + channel.Capacity != terms.Capacity { + return false, fmt.Errorf("finalized lnd channel does not " + + "match Ark backing") + } + + localParty, remoteKey, err := f.channelParties(terms) + if err != nil { + return false, err + } + if !channel.IdentityPub.IsEqual(remoteKey) { + return false, fmt.Errorf("finalized lnd channel peer does " + + "not match Ark terms") + } + expectedInitiator := terms.FundingInitiator() == localParty + if channel.IsInitiator != expectedInitiator { + return false, fmt.Errorf("finalized lnd channel funder does " + + "not match Ark terms") + } + clientBalance := channel.LocalCommitment.RemoteBalance.ToSatoshis() + if localParty == arkchannel.PartyClient { + clientBalance = channel.LocalCommitment.LocalBalance. + ToSatoshis() + } + if terms.Kind == arkchannel.KindReceiveIntent && + clientBalance != 0 { + return false, fmt.Errorf("client received spendable initial " + + "channel liquidity") + } + + fundingScript, err := lndfunding.MakeFundingScript(channel) + if err != nil { + return false, fmt.Errorf("derive finalized funding script: %w", + err) + } + output := funding.Transaction.TxOut[funding.OutputIndex] + if !bytes.Equal(output.PkScript, fundingScript) { + return false, fmt.Errorf("signed backing does not fund lnd's " + + "negotiated channel script") + } + + return true, nil +} + +// ChannelActive reports whether the exact finalized channel has left lnd's +// pending-open state and its reserved-SCID link can carry payments. +func (f *FundingRuntime) ChannelActive(ctx context.Context, + terms arkchannel.Terms, backing arkchannel.Backing) (bool, error) { + + finalized, err := f.FundingFinalized(ctx, terms, backing) + if err != nil || !finalized { + return false, err + } + channel, err := f.stateDB.FetchChannel(backing.ChannelPoint) + if errors.Is(err, channeldb.ErrChannelNotFound) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("find active lnd channel: %w", err) + } + if channel.IsPending { + return false, nil + } + _, err = f.switcher.GetLinkByShortID( + lnwire.NewShortChanIDFromInt(terms.ReservedSCID), + ) + if errors.Is(err, htlcswitch.ErrChannelLinkNotFound) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("find active lnd channel link: %w", + err) + } + + return true, nil +} + +// channelParties resolves this runtime's role and expected remote identity. +func (f *FundingRuntime) channelParties(terms arkchannel.Terms) ( + arkchannel.Party, *btcec.PublicKey, error) { + + clientKey, err := btcec.ParsePubKey(terms.ClientNodeKey[:]) + if err != nil { + return 0, nil, fmt.Errorf("parse client node key: %w", err) + } + hubKey, err := btcec.ParsePubKey(terms.HubNodeKey[:]) + if err != nil { + return 0, nil, fmt.Errorf("parse hub node key: %w", err) + } + switch { + case bytes.Equal(f.identityKey[:], terms.ClientNodeKey[:]): + return arkchannel.PartyClient, hubKey, nil + + case bytes.Equal(f.identityKey[:], terms.HubNodeKey[:]): + return arkchannel.PartyHub, clientKey, nil + + default: + return 0, nil, fmt.Errorf("runtime identity is not an Ark " + + "channel party") + } +} + +// virtualFundingFromBacking converts durable Ark facts into the in-memory +// virtual confirmation record expected by lnd. +func virtualFundingFromBacking(terms arkchannel.Terms, + backing arkchannel.Backing) (VirtualFunding, error) { + + if err := terms.Validate(); err != nil { + return VirtualFunding{}, err + } + tx := wire.NewMsgTx(2) + if err := tx.Deserialize( + bytes.NewReader(backing.Transaction), + ); err != nil { + return VirtualFunding{}, fmt.Errorf("decode Ark backing: %w", + err) + } + if tx.TxHash() != backing.ChannelPoint.Hash { + return VirtualFunding{}, fmt.Errorf("Ark backing transaction " + + "ID does not match channel point") + } + if backing.ChannelPoint.Index >= uint32(len(tx.TxOut)) { + return VirtualFunding{}, fmt.Errorf("Ark backing output is " + + "out of range") + } + if tx.TxOut[backing.ChannelPoint.Index].Value != int64(terms.Capacity) { + return VirtualFunding{}, fmt.Errorf("Ark backing capacity " + + "does not match channel terms") + } + scid := lnwire.NewShortChanIDFromInt(terms.ReservedSCID) + if scid.TxPosition != uint16(backing.ChannelPoint.Index) { + return VirtualFunding{}, fmt.Errorf("reserved SCID does not " + + "match channel point output") + } + + return VirtualFunding{ + Transaction: tx, + OutputIndex: backing.ChannelPoint.Index, + SCID: scid, + }, nil +} + +var _ arkchannel.FundingFinalizationSource = (*FundingRuntime)(nil) diff --git a/lnruntime/close.go b/lnruntime/close.go new file mode 100644 index 000000000..63ee82c0f --- /dev/null +++ b/lnruntime/close.go @@ -0,0 +1,117 @@ +package lnruntime + +import ( + "fmt" + + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/lightningnetwork/lnd/lnwallet/chancloser" +) + +// CooperativeCloseRequest contains the application-owned edges around lnd's +// native cooperative-close state machine. Ark materialization must complete +// before the BroadcastTx callback publishes the resulting closing transaction. +type CooperativeCloseRequest struct { + ChannelPoint wire.OutPoint + DeliveryAddress chancloser.DeliveryAddrWithKey + IdealFeeRate chainfee.SatPerKWeight + MaxFeeRate chainfee.SatPerKWeight + NegotiationHeight uint32 + Closer lntypes.ChannelParty + MusigSession chancloser.MusigSession + + BroadcastTx func(*wire.MsgTx, string) error + DisableChannel func(wire.OutPoint) error + Disconnect func() error +} + +// NewCooperativeClose reconstructs a channel from lnd's database and returns +// lnd's native cooperative-close state machine. The caller transports the +// resulting Shutdown and ClosingSigned messages to the counterparty. +func (r *Runtime) NewCooperativeClose(req CooperativeCloseRequest) ( + *chancloser.ChanCloser, error) { + + if req.ChannelPoint == (wire.OutPoint{}) { + return nil, fmt.Errorf("channel point is required") + } + if len(req.DeliveryAddress.DeliveryAddress) == 0 { + return nil, fmt.Errorf("cooperative close delivery address " + + "is required") + } + if req.IdealFeeRate <= 0 { + return nil, fmt.Errorf("cooperative close fee rate is required") + } + if req.Closer != lntypes.Local && req.Closer != lntypes.Remote { + return nil, fmt.Errorf("cooperative close party is invalid") + } + if req.BroadcastTx == nil { + return nil, fmt.Errorf("cooperative close broadcaster is " + + "required") + } + if r.funding == nil || r.funding.netParams == nil { + return nil, fmt.Errorf("cooperative close requires funding " + + "runtime") + } + + channelState, err := r.cfg.DB.ChannelStateDB().FetchChannel( + req.ChannelPoint, + ) + if err != nil { + return nil, fmt.Errorf("find channel for cooperative close: %w", + err) + } + if channelState.IsPending { + return nil, fmt.Errorf("cannot cooperatively close a pending " + + "channel") + } + if channelState.ChanType.IsTaproot() && req.MusigSession == nil { + return nil, fmt.Errorf("taproot cooperative close requires a " + + "MuSig2 session") + } + + channel, err := lnwallet.NewLightningChannel( + r.cfg.Signer, channelState, r.sigPool, + ) + if err != nil { + return nil, fmt.Errorf("restore channel for cooperative "+ + "close: %w", err) + } + + disableChannel := req.DisableChannel + if disableChannel == nil { + disableChannel = func(channelPoint wire.OutPoint) error { + r.RemoveLink(channelPoint) + + return nil + } + } + disconnect := req.Disconnect + if disconnect == nil { + disconnect = func() error { return nil } + } + maxFeeRate := req.MaxFeeRate + if maxFeeRate == 0 { + maxFeeRate = req.IdealFeeRate + } + if maxFeeRate < req.IdealFeeRate { + return nil, fmt.Errorf("maximum cooperative close fee rate %v "+ + "is below ideal rate %v", maxFeeRate, req.IdealFeeRate) + } + + return chancloser.NewChanCloser( + chancloser.ChanCloseCfg{ + Channel: channel, + MusigSession: req.MusigSession, + BroadcastTx: req.BroadcastTx, + DisableChannel: disableChannel, + Disconnect: disconnect, + MaxFee: maxFeeRate, + ChainParams: r.funding.netParams, + Quit: make(chan struct{}), + FeeEstimator: &chancloser.SimpleCoopFeeEstimator{}, + }, req.DeliveryAddress, req.IdealFeeRate, + req.NegotiationHeight, nil, req.Closer, + ), nil +} diff --git a/lnruntime/cooperative_close.go b/lnruntime/cooperative_close.go new file mode 100644 index 000000000..b8bf0a9a6 --- /dev/null +++ b/lnruntime/cooperative_close.go @@ -0,0 +1,619 @@ +package lnruntime + +import ( + "bytes" + "context" + "fmt" + "sync" + + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/wavelength/arkchannel" + "github.com/lightninglabs/wavelength/lib/tx/psbtutil" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/keychain" +) + +// CooperativeCloseStateSink is the durable barrier surface needed to store an +// irreversible artifact at both endpoints before either executes the action it +// implies. +type CooperativeCloseStateSink interface { + arkchannel.ChannelEventSink + + RequestCooperativeClose(context.Context, arkchannel.ID, + arkchannel.CooperativeCloseRequest) (arkchannel.Record, error) + + RecordChannelEvent(context.Context, arkchannel.ID, + arkchannel.Event) (arkchannel.Record, error) + + ResumeChannelAction(context.Context, + arkchannel.ID) (arkchannel.Record, error) + + GetChannel(context.Context, arkchannel.ID) (arkchannel.Record, error) +} + +// CooperativeClosePublisher executes the exact 3-of-3 OOR transfer and waits +// until the ordinary OOR actor durably finalizes it. +type CooperativeClosePublisher interface { + SettleCooperativeClose(context.Context, arkchannel.ID, arkchannel.Terms, + arkchannel.VTXOBinding, arkchannel.CooperativeCloseRequest, + arkchannel.CooperativeClose) error +} + +// CooperativeClosePublisherFunc adapts a process-owned OOR settlement function +// to the cooperative close protocol. +type CooperativeClosePublisherFunc func(context.Context, arkchannel.ID, + arkchannel.Terms, arkchannel.VTXOBinding, + arkchannel.CooperativeCloseRequest, arkchannel.CooperativeClose) error + +// SettleCooperativeClose invokes the wrapped OOR settlement function. +func (f CooperativeClosePublisherFunc) SettleCooperativeClose( + ctx context.Context, id arkchannel.ID, terms arkchannel.Terms, + source arkchannel.VTXOBinding, + request arkchannel.CooperativeCloseRequest, + settlement arkchannel.CooperativeClose) error { + + return f(ctx, id, terms, source, request, settlement) +} + +// CooperativeCloseObserver verifies that the hub's ordinary Ark account has +// durably accepted the expected incoming OOR transfer before channel archival. +type CooperativeCloseObserver interface { + WaitForCooperativeClose(context.Context, chainhash.Hash, + btcutil.Amount) error +} + +// CooperativeCloseObserverFunc adapts a daemon session lookup to the close +// process. +type CooperativeCloseObserverFunc func(context.Context, chainhash.Hash, + btcutil.Amount) error + +// WaitForCooperativeClose invokes the wrapped session observer. +func (f CooperativeCloseObserverFunc) WaitForCooperativeClose( + ctx context.Context, sessionID chainhash.Hash, + amount btcutil.Amount) error { + + return f(ctx, sessionID, amount) +} + +// CooperativeCloseDefender asks the ordinary Ark wallet to unroll one exact +// replacement VTXO when the closed channel's former source ancestry appears +// on chain. +type CooperativeCloseDefender interface { + DefendCooperativeClose(context.Context, wire.OutPoint) error +} + +// CooperativeCloseDefenderFunc adapts an ordinary wallet unroll request to +// the cooperative-close process. +type CooperativeCloseDefenderFunc func(context.Context, wire.OutPoint) error + +// DefendCooperativeClose invokes the wrapped wallet recovery function. +func (f CooperativeCloseDefenderFunc) DefendCooperativeClose( + ctx context.Context, outpoint wire.OutPoint) error { + + return f(ctx, outpoint) +} + +// CooperativeCloseDeliveryValidator proves one endpoint owns the replacement +// VTXO key assigned to its role before the hub signs or traffic is disabled. +type CooperativeCloseDeliveryValidator interface { + ValidateCooperativeCloseDelivery(context.Context, arkchannel.ID, + []byte) error +} + +// CooperativeCloseDeliveryValidatorFunc adapts a wallet ownership check to the +// cooperative-close endpoint. +type CooperativeCloseDeliveryValidatorFunc func(context.Context, + arkchannel.ID, []byte) error + +// ValidateCooperativeCloseDelivery invokes the wrapped ownership check. +func (f CooperativeCloseDeliveryValidatorFunc) ValidateCooperativeCloseDelivery( + ctx context.Context, id arkchannel.ID, script []byte) error { + + return f(ctx, id, script) +} + +// NativeCooperativeCloseEndpoint owns one endpoint's lnd database and Ark +// policy signing key. +type NativeCooperativeCloseEndpoint struct { + party arkchannel.Party + runtime *Runtime + signer input.Signer + keyDesc keychain.KeyDescriptor + delivery CooperativeCloseDeliveryValidator + + mu sync.RWMutex + sink CooperativeCloseStateSink +} + +// NewNativeCooperativeCloseEndpoint constructs a role-bound close endpoint. +func NewNativeCooperativeCloseEndpoint(party arkchannel.Party, runtime *Runtime, + signer input.Signer, keyDesc keychain.KeyDescriptor, + delivery CooperativeCloseDeliveryValidator) ( + *NativeCooperativeCloseEndpoint, error) { + + if party != arkchannel.PartyClient && party != arkchannel.PartyHub { + return nil, fmt.Errorf("cooperative close party is required") + } + if runtime == nil { + return nil, fmt.Errorf("native lnd runtime is required") + } + if party == arkchannel.PartyHub && signer == nil { + return nil, fmt.Errorf("hub cooperative close signer is " + + "required") + } + if party == arkchannel.PartyHub && keyDesc.PubKey == nil { + return nil, fmt.Errorf("hub cooperative close Ark key is " + + "required") + } + if delivery == nil { + return nil, fmt.Errorf("cooperative close delivery validator " + + "is required") + } + + return &NativeCooperativeCloseEndpoint{ + party: party, + runtime: runtime, + signer: signer, + keyDesc: keyDesc, + delivery: delivery, + }, nil +} + +// BindChannelEventSink attaches the endpoint to its local durable channel +// service. +func (e *NativeCooperativeCloseEndpoint) BindChannelEventSink( + sink arkchannel.ChannelEventSink) error { + + barrier, ok := sink.(CooperativeCloseStateSink) + if !ok { + return fmt.Errorf("channel event sink lacks close barriers") + } + e.mu.Lock() + defer e.mu.Unlock() + if e.sink != nil { + return nil + } + e.sink = barrier + + return nil +} + +// QuiesceCooperativeClose returns this endpoint's authoritative clean lnd +// state after validating the channel identity and funding role. +func (e *NativeCooperativeCloseEndpoint) QuiesceCooperativeClose( + ctx context.Context, id arkchannel.ID, terms arkchannel.Terms, + source arkchannel.VTXOBinding, backing arkchannel.Backing, + request arkchannel.CooperativeCloseRequest) (CleanChannelState, error) { + + if err := validateCooperativeCloseChannel( + id, terms, source, backing, + ); err != nil { + return CleanChannelState{}, err + } + record, err := e.GetChannel(ctx, id) + if err != nil { + return CleanChannelState{}, err + } + if err := validateLocalCooperativeClose( + record, terms, source, backing, request, + ); err != nil { + return CleanChannelState{}, err + } + deliveryScript := request.ClientDeliveryScript + if e.party == arkchannel.PartyHub { + deliveryScript = request.HubDeliveryScript + } + if err := e.delivery.ValidateCooperativeCloseDelivery( + ctx, id, deliveryScript, + ); err != nil { + return CleanChannelState{}, fmt.Errorf("validate %s "+ + "cooperative close payout: %w", e.party, err) + } + state, err := e.runtime.QuiesceChannel(ctx, backing.ChannelPoint) + if err != nil { + return CleanChannelState{}, err + } + if err := validateCleanChannelState( + e.party, terms, backing, state, + ); err != nil { + + e.runtime.ResumeChannel(backing.ChannelPoint) + + return CleanChannelState{}, err + } + + return state, nil +} + +// SignHubCooperativeClose re-reads the clean lnd state, reconstructs the +// canonical proposal, and signs only the hub's immediate Ark policy role. +func (e *NativeCooperativeCloseEndpoint) SignHubCooperativeClose( + ctx context.Context, id arkchannel.ID, terms arkchannel.Terms, + source arkchannel.VTXOBinding, backing arkchannel.Backing, + request arkchannel.CooperativeCloseRequest, + proposal arkchannel.CooperativeCloseProposal) (input.Signature, error) { + + if e.party != arkchannel.PartyHub { + return nil, fmt.Errorf("only hub can authorize cooperative " + + "close") + } + + state, err := e.QuiesceCooperativeClose( + ctx, id, terms, source, backing, request, + ) + if err != nil { + return nil, err + } + clientBalance, hubBalance := mapCleanBalances(e.party, state) + if err := validateCooperativeCloseState( + e.party, state, proposal, + ); err != nil { + return nil, err + } + template, err := arkchannel.NewCooperativeCloseTemplate( + terms, source, request, clientBalance, hubBalance, + state.CommitmentHeight, + ) + if err != nil { + return nil, err + } + if err := proposal.Validate(terms, source, request); err != nil { + return nil, err + } + desc, err := template.SignDescriptor(terms, e.party, e.keyDesc) + if err != nil { + return nil, err + } + tx, err := decodeCloseProposal(proposal) + if err != nil { + return nil, err + } + + return e.signer.SignOutputRaw(tx, desc) +} + +// ResumeCooperativeClose re-enables traffic after a close aborts before the +// hub-authorized OOR package is durable at both endpoints. +func (e *NativeCooperativeCloseEndpoint) ResumeCooperativeClose( + backing arkchannel.Backing) { + + e.runtime.ResumeChannel(backing.ChannelPoint) +} + +// RecordChannelEvent persists one barrier fact through the bound service. +func (e *NativeCooperativeCloseEndpoint) RecordChannelEvent(ctx context.Context, + id arkchannel.ID, event arkchannel.Event) (arkchannel.Record, error) { + + sink, err := e.stateSink() + if err != nil { + return arkchannel.Record{}, err + } + + return sink.RecordChannelEvent(ctx, id, event) +} + +// ResumeChannelAction executes this endpoint's already durable close action. +func (e *NativeCooperativeCloseEndpoint) ResumeChannelAction( + ctx context.Context, id arkchannel.ID) (arkchannel.Record, error) { + + sink, err := e.stateSink() + if err != nil { + return arkchannel.Record{}, err + } + + return sink.ResumeChannelAction(ctx, id) +} + +// GetChannel returns this endpoint's latest durable close facts. +func (e *NativeCooperativeCloseEndpoint) GetChannel(ctx context.Context, + id arkchannel.ID) (arkchannel.Record, error) { + + sink, err := e.stateSink() + if err != nil { + return arkchannel.Record{}, err + } + + return sink.GetChannel(ctx, id) +} + +// finalize archives this endpoint's ordinary lnd channel state. +func (e *NativeCooperativeCloseEndpoint) finalize(terms arkchannel.Terms, + backing arkchannel.Backing, source arkchannel.VTXOBinding, + request arkchannel.CooperativeCloseRequest, + settlement arkchannel.CooperativeClose) error { + + if err := settlement.Validate(terms, source, request); err != nil { + return err + } + settledBalance := settlement.Proposal.ClientBalance + if e.party == arkchannel.PartyHub { + settledBalance = settlement.Proposal.HubBalance + } + + return e.runtime.FinalizeExternalCooperativeClose( + backing.ChannelPoint, settlement.TxID, settledBalance, + request.Initiator == e.party, + ) +} + +// stateSink returns the bound durable channel service. +func (e *NativeCooperativeCloseEndpoint) stateSink() (CooperativeCloseStateSink, + error) { + + e.mu.RLock() + defer e.mu.RUnlock() + if e.sink == nil { + return nil, fmt.Errorf("cooperative close event sink is not " + + "bound") + } + + return e.sink, nil +} + +// completeCooperativeClose verifies the proposal against the hub's clean lnd +// state, adds only the hub's channel-policy signature, and stores that exact +// OOR authorization before returning it. The Ark operator and client sign in +// the ordinary OOR protocol. +func completeCooperativeClose(ctx context.Context, + local *NativeCooperativeCloseEndpoint, id arkchannel.ID, + terms arkchannel.Terms, source arkchannel.VTXOBinding, + backing arkchannel.Backing, request arkchannel.CooperativeCloseRequest, + proposal arkchannel.CooperativeCloseProposal) ( + arkchannel.CooperativeClose, error) { + + if local.party != arkchannel.PartyHub { + return arkchannel.CooperativeClose{}, fmt.Errorf("only hub " + + "can complete cooperative close") + } + record, err := local.GetChannel(ctx, id) + if err != nil { + return arkchannel.CooperativeClose{}, err + } + if err := validateLocalCooperativeClose( + record, terms, source, backing, request, + ); err != nil { + return arkchannel.CooperativeClose{}, err + } + if record.Snapshot.CooperativeClose != nil { + settlement := record.Snapshot.CooperativeClose.Clone() + if !cooperativeCloseProposalsEqual( + settlement.Proposal, proposal, + ) { + return arkchannel.CooperativeClose{}, fmt.Errorf( + "hub stored another cooperative close proposal") + } + if err := settlement.Validate( + terms, source, request, + ); err != nil { + return arkchannel.CooperativeClose{}, err + } + + return settlement, nil + } + if err := proposal.Validate(terms, source, request); err != nil { + return arkchannel.CooperativeClose{}, err + } + template, err := arkchannel.NewCooperativeCloseTemplate( + terms, source, request, proposal.ClientBalance, + proposal.HubBalance, proposal.CommitmentHeight, + ) + if err != nil { + return arkchannel.CooperativeClose{}, err + } + hubSig, err := local.SignHubCooperativeClose( + ctx, id, terms, source, backing, request, proposal, + ) + if err != nil { + return arkchannel.CooperativeClose{}, err + } + if err := template.VerifySignature( + terms, arkchannel.PartyHub, hubSig, + ); err != nil { + return arkchannel.CooperativeClose{}, err + } + settlement, err := template.Complete( + terms, source, request, hubSig, + ) + if err != nil { + return arkchannel.CooperativeClose{}, fmt.Errorf("complete "+ + "cooperative close: %w", err) + } + _, err = local.RecordChannelEvent( + ctx, id, &arkchannel.CooperativeCloseSigned{ + Close: settlement, + Party: arkchannel.PartyHub, + }, + ) + if err != nil { + return arkchannel.CooperativeClose{}, fmt.Errorf("store "+ + "complete cooperative close at hub: %w", err) + } + + return settlement, nil +} + +// reconcileCleanChannelStates maps relative lnd balances to stable Ark roles +// and requires both endpoint databases to describe the same commitment. +func reconcileCleanChannelStates(localParty arkchannel.Party, local, + remote CleanChannelState) (btcutil.Amount, btcutil.Amount, error) { + + if local.ChannelPoint != remote.ChannelPoint || + local.Capacity != remote.Capacity || + local.CommitmentHeight != remote.CommitmentHeight || + local.LocalInitiator == remote.LocalInitiator { + return 0, 0, fmt.Errorf("lnd endpoints disagree on clean " + + "channel state") + } + clientLocal, hubLocal := mapCleanBalances(localParty, local) + clientRemote, hubRemote := mapCleanBalances( + otherChannelParty(localParty), remote, + ) + if clientLocal != clientRemote || hubLocal != hubRemote { + return 0, 0, fmt.Errorf("lnd endpoints disagree on " + + "cooperative balances") + } + + return clientLocal, hubLocal, nil +} + +// validateCleanChannelState binds one relative lnd view to Ark channel terms. +func validateCleanChannelState(party arkchannel.Party, terms arkchannel.Terms, + backing arkchannel.Backing, state CleanChannelState) error { + + if state.ChannelPoint != backing.ChannelPoint || + state.Capacity != terms.Capacity { + return fmt.Errorf("clean lnd state does not match Ark channel") + } + if state.LocalInitiator != (terms.FundingInitiator() == party) { + return fmt.Errorf("clean lnd state has unexpected channel " + + "funder") + } + if state.LocalBalance < 0 || state.RemoteBalance < 0 || + state.LocalBalance+state.RemoteBalance != terms.Capacity { + return fmt.Errorf("clean lnd balances do not match capacity") + } + + return nil +} + +// mapCleanBalances converts a runtime-relative view into stable Ark roles. +func mapCleanBalances(party arkchannel.Party, + state CleanChannelState) (btcutil.Amount, btcutil.Amount) { + + if party == arkchannel.PartyClient { + return state.LocalBalance, state.RemoteBalance + } + + return state.RemoteBalance, state.LocalBalance +} + +// validateCooperativeCloseState rejects a signed settlement whose role-stable +// balance split no longer matches the endpoint's clean lnd commitment. +func validateCooperativeCloseState(party arkchannel.Party, + state CleanChannelState, + proposal arkchannel.CooperativeCloseProposal) error { + + clientBalance, hubBalance := mapCleanBalances(party, state) + if proposal.CommitmentHeight != state.CommitmentHeight || + proposal.ClientBalance != clientBalance || + proposal.HubBalance != hubBalance { + return fmt.Errorf("cooperative close proposal does not match " + + "local clean lnd state") + } + + return nil +} + +// otherChannelParty returns the only counterparty role. +func otherChannelParty(party arkchannel.Party) arkchannel.Party { + if party == arkchannel.PartyClient { + return arkchannel.PartyHub + } + + return arkchannel.PartyClient +} + +// validateCooperativeCloseChannel checks immutable identities before lnd state +// changes. +func validateCooperativeCloseChannel(id arkchannel.ID, terms arkchannel.Terms, + source arkchannel.VTXOBinding, backing arkchannel.Backing) error { + + if id != terms.ID { + return fmt.Errorf("cooperative close channel ID does not match") + } + if err := terms.Validate(); err != nil { + return err + } + if err := source.Validate(terms); err != nil { + return err + } + if err := backing.Validate(terms, source); err != nil { + return err + } + + return nil +} + +// validateLocalCooperativeClose proves this signer already persisted the exact +// cross-endpoint request and channel artifacts before traffic is disabled. +func validateLocalCooperativeClose(record arkchannel.Record, + terms arkchannel.Terms, source arkchannel.VTXOBinding, + backing arkchannel.Backing, + request arkchannel.CooperativeCloseRequest) error { + + snapshot := record.Snapshot + if snapshot.Terms != terms || snapshot.Source == nil || + snapshot.Backing == nil { + return fmt.Errorf("local cooperative close channel facts do " + + "not match") + } + localSource := snapshot.Source + if localSource.OORSessionID != source.OORSessionID || + localSource.OutPoint != source.OutPoint || + localSource.Amount != source.Amount || + !bytes.Equal( + localSource.ArkTransaction, source.ArkTransaction, + ) || !bytes.Equal( + localSource.PolicyTemplate, source.PolicyTemplate, + ) || + !bytes.Equal(localSource.PkScript, source.PkScript) { + return fmt.Errorf("local cooperative close VTXO does not match") + } + localBacking := snapshot.Backing + if localBacking.ChannelPoint != backing.ChannelPoint || + !bytes.Equal(localBacking.Transaction, backing.Transaction) { + return fmt.Errorf("local cooperative close backing does not " + + "match") + } + localRequest := snapshot.CooperativeCloseRequest + if localRequest == nil || localRequest.Initiator != request.Initiator || + !bytes.Equal( + localRequest.ClientDeliveryScript, + request.ClientDeliveryScript, + ) || !bytes.Equal( + localRequest.HubDeliveryScript, + request.HubDeliveryScript, + ) { + return fmt.Errorf("local cooperative close request does not " + + "match") + } + switch snapshot.Phase { + case arkchannel.PhaseCoopClosing, + arkchannel.PhaseCoopCloseSigned, + arkchannel.PhaseCoopClosePublished: + + default: + return fmt.Errorf("local channel cannot quiesce close from %s", + snapshot.Phase) + } + + return nil +} + +// decodeCloseProposal decodes the already validated unsigned checkpoint PSBT. +func decodeCloseProposal(proposal arkchannel.CooperativeCloseProposal) ( + *wire.MsgTx, error) { + + packet, err := psbtutil.Parse(proposal.Transaction) + if err != nil { + return nil, fmt.Errorf("decode cooperative close proposal: %w", + err) + } + + return packet.UnsignedTx, nil +} + +// cooperativeCloseProposalsEqual compares every field that identifies one +// exact unsigned OOR checkpoint authorization. +func cooperativeCloseProposalsEqual( + a, b arkchannel.CooperativeCloseProposal) bool { + + return a.CommitmentHeight == b.CommitmentHeight && + a.ClientBalance == b.ClientBalance && + a.HubBalance == b.HubBalance && + a.ClientOutput == b.ClientOutput && + a.HubOutput == b.HubOutput && + bytes.Equal(a.Transaction, b.Transaction) +} diff --git a/lnruntime/cooperative_close_test.go b/lnruntime/cooperative_close_test.go new file mode 100644 index 000000000..cbf82d892 --- /dev/null +++ b/lnruntime/cooperative_close_test.go @@ -0,0 +1,59 @@ +package lnruntime + +import ( + "testing" + + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/lightninglabs/wavelength/arkchannel" + "github.com/stretchr/testify/require" +) + +// TestValidateCooperativeCloseState verifies a restart cannot settle a signed +// balance split after lnd's clean commitment state has changed. +func TestValidateCooperativeCloseState(t *testing.T) { + t.Parallel() + + state := CleanChannelState{ + CommitmentHeight: 7, + LocalBalance: btcutil.Amount(40_000), + RemoteBalance: btcutil.Amount(60_000), + } + proposal := arkchannel.CooperativeCloseProposal{ + CommitmentHeight: 7, + ClientBalance: btcutil.Amount(40_000), + HubBalance: btcutil.Amount(60_000), + } + require.NoError( + t, validateCooperativeCloseState( + arkchannel.PartyClient, state, proposal, + ), + ) + + staleHeight := proposal + staleHeight.CommitmentHeight-- + require.ErrorContains( + t, validateCooperativeCloseState( + arkchannel.PartyClient, state, staleHeight, + ), + "does not match local clean lnd state", + ) + + staleBalance := proposal + staleBalance.ClientBalance-- + staleBalance.HubBalance++ + require.ErrorContains( + t, validateCooperativeCloseState( + arkchannel.PartyClient, state, staleBalance, + ), + "does not match local clean lnd state", + ) + + hubProposal := proposal + hubProposal.ClientBalance = state.RemoteBalance + hubProposal.HubBalance = state.LocalBalance + require.NoError( + t, validateCooperativeCloseState( + arkchannel.PartyHub, state, hubProposal, + ), + ) +} diff --git a/lnruntime/external_close.go b/lnruntime/external_close.go new file mode 100644 index 000000000..bb528e41e --- /dev/null +++ b/lnruntime/external_close.go @@ -0,0 +1,252 @@ +package lnruntime + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightningnetwork/lnd/channeldb" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/htlcswitch" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/lightningnetwork/lnd/lnwire" +) + +const cleanChannelPollInterval = 25 * time.Millisecond + +// CleanChannelState is the settled zero-fee allocation at one clean lnd +// commitment height. Balances are relative to the runtime returning it. +type CleanChannelState struct { + ChannelPoint wire.OutPoint + LocalBalance btcutil.Amount + RemoteBalance btcutil.Amount + Capacity btcutil.Amount + CommitmentHeight uint64 + LocalInitiator bool +} + +// QuiesceChannel prevents new HTLC adds in either direction and waits until +// the native lnd channel has no HTLCs, pending commitments, or unsigned +// updates. Existing settles and failures continue while the link drains. +func (r *Runtime) QuiesceChannel(ctx context.Context, + channelPoint wire.OutPoint) (CleanChannelState, error) { + + channelID := lnwire.NewChanIDFromOutPoint(channelPoint) + link, err := r.switcher.GetLink(channelID) + switch { + case err == nil: + link.DisableAdds(htlcswitch.Incoming) + link.DisableAdds(htlcswitch.Outgoing) + + flushed := make(chan struct{}, 1) + link.OnFlushedOnce(func() { + flushed <- struct{}{} + }) + select { + case <-ctx.Done(): + r.resumeChannelLink(channelPoint) + + return CleanChannelState{}, ctx.Err() + + case <-flushed: + } + + case errors.Is(err, htlcswitch.ErrChannelLinkNotFound): + // A restart may replay the close before links are restored. + // With no live link, the persisted channel cannot accept new + // updates and is safe to inspect directly. + + default: + return CleanChannelState{}, fmt.Errorf("find channel link: %w", + err) + } + + ticker := time.NewTicker(cleanChannelPollInterval) + defer ticker.Stop() + for { + state, clean, err := r.cleanChannelState(channelPoint) + if err != nil { + r.resumeChannelLink(channelPoint) + + return CleanChannelState{}, err + } + if clean { + return state, nil + } + + select { + case <-ctx.Done(): + r.resumeChannelLink(channelPoint) + + return CleanChannelState{}, ctx.Err() + + case <-ticker.C: + } + } +} + +// ResumeChannel re-enables both directions after a cooperative close attempt +// fails before the fully signed VTXO spend becomes durable. +func (r *Runtime) ResumeChannel(channelPoint wire.OutPoint) { + r.resumeChannelLink(channelPoint) +} + +// resumeChannelLink restores adds when the channel link still exists. +func (r *Runtime) resumeChannelLink(channelPoint wire.OutPoint) { + link, err := r.switcher.GetLink( + lnwire.NewChanIDFromOutPoint(channelPoint), + ) + if err != nil { + return + } + link.EnableAdds(htlcswitch.Incoming) + link.EnableAdds(htlcswitch.Outgoing) +} + +// cleanChannelState reconstructs lnd's channel state and returns the exact +// zero-fee cooperative balances once both commitment chains are synchronized. +func (r *Runtime) cleanChannelState(channelPoint wire.OutPoint) ( + CleanChannelState, bool, error) { + + state, err := r.cfg.DB.ChannelStateDB().FetchChannel(channelPoint) + if err != nil { + return CleanChannelState{}, false, fmt.Errorf("find channel "+ + "for cooperative close: %w", err) + } + if state.IsPending { + return CleanChannelState{}, false, fmt.Errorf("cannot " + + "cooperatively close a pending channel") + } + channel, err := lnwallet.NewLightningChannel( + r.cfg.Signer, state, r.sigPool, + ) + if err != nil { + return CleanChannelState{}, false, fmt.Errorf("restore "+ + "channel for cooperative close: %w", err) + } + if !channel.IsChannelClean() { + return CleanChannelState{}, false, nil + } + if state.LocalCommitment.CommitHeight != + state.RemoteCommitment.CommitHeight { + return CleanChannelState{}, false, nil + } + localBalance, remoteBalance, err := lnwallet.CoopCloseBalance( + state.ChanType, state.IsInitiator, 0, + state.LocalCommitment.LocalBalance.ToSatoshis(), + state.LocalCommitment.RemoteBalance.ToSatoshis(), + state.LocalCommitment.CommitFee, + fn.None[lntypes.ChannelParty](), + ) + if err != nil { + return CleanChannelState{}, false, fmt.Errorf("derive "+ + "cooperative close balances: %w", err) + } + if localBalance+remoteBalance != state.Capacity { + return CleanChannelState{}, false, fmt.Errorf("cooperative "+ + "balances %d + %d do not match capacity %d", + localBalance, remoteBalance, state.Capacity) + } + + return CleanChannelState{ + ChannelPoint: channelPoint, + LocalBalance: localBalance, + RemoteBalance: remoteBalance, + Capacity: state.Capacity, + CommitmentHeight: state.LocalCommitment.CommitHeight, + LocalInitiator: state.IsInitiator, + }, true, nil +} + +// FinalizeExternalCooperativeClose archives a channel whose clean balance was +// settled by an application-owned transaction. The close is idempotent across +// a crash after the open-channel record was removed. +func (r *Runtime) FinalizeExternalCooperativeClose(channelPoint wire.OutPoint, + closingTxID chainhash.Hash, settledBalance btcutil.Amount, + localInitiated bool) error { + + stateDB := r.cfg.DB.ChannelStateDB() + channel, err := stateDB.FetchChannel(channelPoint) + if errors.Is(err, channeldb.ErrChannelNotFound) { + closed, closedErr := stateDB.FetchClosedChannel(&channelPoint) + if closedErr != nil { + return fmt.Errorf("find externally closed channel: %w", + closedErr) + } + if closed.CloseType != channeldb.CooperativeClose || + closed.ClosingTXID != closingTxID || + closed.SettledBalance != settledBalance { + return fmt.Errorf("channel was archived with another " + + "close") + } + + return r.forgetExternallyClosedChannel(channelPoint) + } + if err != nil { + return fmt.Errorf("find channel for external close: %w", err) + } + if channel.IsPending { + return fmt.Errorf("cannot archive a pending channel") + } + if settledBalance < 0 || settledBalance > channel.Capacity { + return fmt.Errorf("settled balance %d exceeds channel "+ + "capacity %d", settledBalance, channel.Capacity) + } + + r.RemoveLink(channelPoint) + _, height, err := r.cfg.Chain.GetBestBlock() + if err != nil { + return fmt.Errorf("read height for external close: %w", err) + } + if height < 0 { + return fmt.Errorf("invalid external close height %d", height) + } + closeSummary := &channeldb.ChannelCloseSummary{ + ChanPoint: channel.FundingOutpoint, + ShortChanID: channel.ShortChannelID, + ChainHash: channel.ChainHash, + ClosingTXID: closingTxID, + RemotePub: channel.IdentityPub, + Capacity: channel.Capacity, + CloseHeight: uint32(height), + SettledBalance: settledBalance, + CloseType: channeldb.CooperativeClose, + IsPending: false, + RemoteCurrentRevocation: channel.RemoteCurrentRevocation, + RemoteNextRevocation: channel.RemoteNextRevocation, + LocalChanConfig: channel.LocalChanCfg, + } + if chanSync, syncErr := channel.ChanSyncMsg(); syncErr == nil { + closeSummary.LastChanSyncMsg = chanSync + } + status := channeldb.ChanStatusRemoteCloseInitiator + if localInitiated { + status = channeldb.ChanStatusLocalCloseInitiator + } + if err := channel.CloseChannel(closeSummary, status); err != nil { + return fmt.Errorf("archive externally closed channel: %w", err) + } + + return r.forgetExternallyClosedChannel(channelPoint) +} + +// forgetExternallyClosedChannel retires the pre-registered on-chain watcher. +// The Ark cooperative-close FSM is the terminal durability barrier here, so +// this cleanup intentionally does not invoke the force-close resolution hook. +func (r *Runtime) forgetExternallyClosedChannel( + channelPoint wire.OutPoint) error { + + if r.onchain == nil { + return nil + } + if err := r.onchain.ForgetChannel(channelPoint); err != nil { + return fmt.Errorf("retire externally closed channel: %w", err) + } + + return nil +} diff --git a/lnruntime/funding.go b/lnruntime/funding.go new file mode 100644 index 000000000..3975030b5 --- /dev/null +++ b/lnruntime/funding.go @@ -0,0 +1,706 @@ +package lnruntime + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chaincfg/v2" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/psbt/v2" + "github.com/btcsuite/btcd/wire/v2" + basewallet "github.com/btcsuite/btcwallet/wallet" + "github.com/lightningnetwork/lnd/actor" + "github.com/lightningnetwork/lnd/aliasmgr" + "github.com/lightningnetwork/lnd/chanacceptor" + "github.com/lightningnetwork/lnd/channeldb" + "github.com/lightningnetwork/lnd/chanstate" + "github.com/lightningnetwork/lnd/discovery" + lndfunding "github.com/lightningnetwork/lnd/funding" + "github.com/lightningnetwork/lnd/graph/db/models" + "github.com/lightningnetwork/lnd/htlcswitch" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lnpeer" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/lightningnetwork/lnd/lnwallet/chanfunding" + "github.com/lightningnetwork/lnd/lnwire" +) + +const ( + defaultFundingTargetConfs = uint32(6) + defaultMaxLocalCSVDelay = uint16(2016) + defaultMaxPendingChannels = 10 +) + +// FundingConfig contains the application-owned dependencies needed by lnd's +// native funding manager. Protocol defaults remain private and opinionated so +// wallet callers do not need to configure Lightning daemon policy. +type FundingConfig struct { + WalletController lnwallet.WalletController + KeyRing keychain.SecretKeyRing + NetParams *chaincfg.Params + IdentityKey keychain.KeyDescriptor + ChannelAcceptor chanacceptor.ChannelAcceptor + RoutingPolicy models.ForwardingPolicy + + NotifyWhenOnline func([33]byte, chan<- lnpeer.Peer) + WatchNewChannel func(*chanstate.OpenChannel, *btcec.PublicKey) error + + NotifyPendingOpen func(wire.OutPoint, *chanstate.OpenChannel, + *btcec.PublicKey) + NotifyOpen func(wire.OutPoint, *btcec.PublicKey) + NotifyFundingTimeout func(wire.OutPoint, *btcec.PublicKey) + ReportShortChannelID func(wire.OutPoint) error + + MinChannelSize btcutil.Amount + MaxChannelSize btcutil.Amount +} + +// FundingOpenRequest starts an externally funded private channel with an +// explicit initial push allocation. +type FundingOpenRequest struct { + Peer lnpeer.Peer + PendingChannelID lndfunding.PendingChanID + Capacity btcutil.Amount + PushAmount btcutil.Amount + BasePSBT *psbt.Packet +} + +// FundingFlow exposes lnd's native asynchronous funding progress. +type FundingFlow struct { + PendingChannelID lndfunding.PendingChanID + Updates <-chan *lnrpc.OpenStatusUpdate + Errors <-chan error +} + +// FundingRuntime owns lnd's LightningWallet reservation worker and funding +// manager without owning the embedding process's base wallet lifecycle. +type FundingRuntime struct { + manager *lndfunding.Manager + wallet *lnwallet.LightningWallet + notifier *VirtualFundingNotifier + aliases *aliasmgr.Manager + switcher *htlcswitch.Switch + + feeEstimator chainfee.Estimator + netParams *chaincfg.Params + chain lnwallet.BlockChainIO + stateDB chanstate.Store + identityKey [33]byte + minChanSize btcutil.Amount + maxChanSize btcutil.Amount + + mu sync.Mutex + started bool + stopped bool +} + +// newFundingRuntime composes lnd's normal funding manager around Wavelength's +// already-running wallet and virtual chain notifier. +func newFundingRuntime(runtimeCfg RuntimeConfig, switcher *htlcswitch.Switch, + cfg FundingConfig) (*FundingRuntime, error) { + + if err := validateFundingConfig(runtimeCfg, cfg); err != nil { + return nil, err + } + + virtualNotifier, ok := runtimeCfg.Notifier.(*VirtualFundingNotifier) + if !ok { + return nil, fmt.Errorf("funding runtime requires a virtual " + + "funding notifier") + } + + lightningWallet, err := lnwallet.NewLightningWallet(lnwallet.Config{ + Database: runtimeCfg.DB.ChannelStateDB(), + Notifier: virtualNotifier, + SecretKeyRing: cfg.KeyRing, + WalletController: cfg.WalletController, + Signer: runtimeCfg.Signer, + FeeEstimator: runtimeCfg.FeeEstimator, + ChainIO: runtimeCfg.Chain, + NetParams: *cfg.NetParams, + CoinSelectionStrategy: basewallet. + CoinSelectionLargest, + ExternallyManagedWalletController: true, + }) + if err != nil { + return nil, fmt.Errorf("create lnd lightning wallet: %w", err) + } + + linkUpdater := func(shortID lnwire.ShortChannelID) error { + link, err := switcher.GetLinkByShortID(shortID) + if err != nil { + return err + } + switcher.UpdateLinkAliases(link) + + return nil + } + aliasManager, err := aliasmgr.NewManager( + runtimeCfg.DB, linkUpdater, + ) + if err != nil { + return nil, fmt.Errorf("create lnd alias manager: %w", err) + } + + minChanSize := cfg.MinChannelSize + if minChanSize == 0 { + minChanSize = lndfunding.MinChanFundingSize + } + maxChanSize := cfg.MaxChannelSize + if maxChanSize == 0 { + maxChanSize = lndfunding.MaxBtcFundingAmountWumbo + } + + manager, err := lndfunding.NewFundingManager(lndfunding.Config{ + NoWumboChans: false, + IDKey: cfg.IdentityKey.PubKey, + IDKeyLoc: cfg.IdentityKey.KeyLocator, + Wallet: lightningWallet, + PublishTransaction: rejectInternalFundingPublish, + UpdateLabel: func(chainhash.Hash, string) error { + return nil + }, + FeeEstimator: runtimeCfg.FeeEstimator, + Notifier: virtualNotifier, + ChannelDB: runtimeCfg.DB.ChannelStateDB(), + SignMessage: cfg.KeyRing.SignMessage, + CurrentNodeAnnouncement: emptyNodeAnnouncement, + SendAnnouncement: ignoreAnnouncement, + NotifyWhenOnline: cfg.NotifyWhenOnline, + FindChannel: func(node *btcec.PublicKey, + chanID lnwire.ChannelID) (*chanstate.OpenChannel, + error) { + + return findOpenChannel( + runtimeCfg.DB.ChannelStateDB(), node, chanID, + ) + }, + DefaultRoutingPolicy: cfg.RoutingPolicy, + DefaultMinHtlcIn: 1, + NumRequiredConfs: func(btcutil.Amount, + lnwire.MilliSatoshi) uint16 { + + return 1 + }, + RequiredRemoteDelay: func(btcutil.Amount) uint16 { + return lndfunding.MinBtcRemoteDelay + }, + RequiredRemoteChanReserve: defaultRemoteReserve, + RequiredRemoteMaxValue: defaultRemoteMaxValue, + RequiredRemoteMaxHTLCs: func(btcutil.Amount) uint16 { + return uint16(input.MaxHTLCNumber / 2) + }, + WatchNewChannel: cfg.WatchNewChannel, + ReportShortChanID: optionalReportSCID(cfg), + ZombieSweeperInterval: time.Hour, + ReservationTimeout: chanfunding. + DefaultReservationTimeout, + MinChanSize: minChanSize, + MaxChanSize: maxChanSize, + MaxPendingChannels: defaultMaxPendingChannels, + RejectPush: false, + MaxLocalCSVDelay: defaultMaxLocalCSVDelay, + NotifyOpenChannelEvent: optionalNotifyOpen(cfg), + OpenChannelPredicate: cfg.ChannelAcceptor, + NotifyPendingOpenChannelEvent: cfg.NotifyPendingOpen, + NotifyFundingTimeout: optionalNotifyTimeout(cfg), + MaxAnchorsCommitFeeRate: runtimeCfg.FeeEstimator. + RelayFeePerKW(), + DeleteAliasEdge: func(lnwire.ShortChannelID) ( + *models.ChannelEdgePolicy, error) { + + return nil, nil + }, + AliasManager: aliasManager, + IsSweeperOutpoint: func(wire.OutPoint) bool { return false }, + }) + if err != nil { + return nil, fmt.Errorf("create lnd funding manager: %w", err) + } + + var identityKey [33]byte + copy(identityKey[:], cfg.IdentityKey.PubKey.SerializeCompressed()) + + return &FundingRuntime{ + manager: manager, + wallet: lightningWallet, + notifier: virtualNotifier, + aliases: aliasManager, + switcher: switcher, + feeEstimator: runtimeCfg.FeeEstimator, + netParams: cfg.NetParams, + chain: runtimeCfg.Chain, + stateDB: runtimeCfg.DB.ChannelStateDB(), + identityKey: identityKey, + minChanSize: minChanSize, + maxChanSize: maxChanSize, + }, nil +} + +// validateFundingConfig rejects missing application-owned policy and safety +// callbacks before lnd starts any funding goroutine. +func validateFundingConfig(runtimeCfg RuntimeConfig, cfg FundingConfig) error { + switch { + case cfg.WalletController == nil: + return fmt.Errorf("funding wallet controller is required") + + case cfg.KeyRing == nil: + return fmt.Errorf("funding key ring is required") + + case cfg.NetParams == nil: + return fmt.Errorf("funding network parameters are required") + + case cfg.IdentityKey.PubKey == nil: + return fmt.Errorf("funding identity key is required") + + case cfg.ChannelAcceptor == nil: + return fmt.Errorf("channel intent acceptor is required") + + case cfg.NotifyWhenOnline == nil: + return fmt.Errorf("online peer notifier is required") + + case cfg.WatchNewChannel == nil: + return fmt.Errorf("new channel watcher is required") + + case cfg.NotifyPendingOpen == nil: + return fmt.Errorf("pending channel callback is required") + + case cfg.MinChannelSize < 0: + return fmt.Errorf("minimum channel size cannot be negative") + + case cfg.MaxChannelSize < 0: + return fmt.Errorf("maximum channel size cannot be negative") + + case cfg.MinChannelSize > 0 && cfg.MaxChannelSize > 0 && + cfg.MinChannelSize > cfg.MaxChannelSize: + return fmt.Errorf("minimum channel size exceeds maximum") + + case runtimeCfg.DB == nil: + return fmt.Errorf("channel database is required") + + default: + } + + identityKey, err := cfg.KeyRing.DeriveKey( + cfg.IdentityKey.KeyLocator, + ) + if err != nil { + return fmt.Errorf("derive funding identity key: %w", err) + } + if !identityKey.PubKey.IsEqual(cfg.IdentityKey.PubKey) { + return fmt.Errorf("funding identity key does not match locator") + } + + return nil +} + +// Start starts lnd's wallet reservation handler before the funding manager. +func (f *FundingRuntime) Start() error { + f.mu.Lock() + defer f.mu.Unlock() + + if f.started { + return nil + } + if f.stopped { + return fmt.Errorf("funding runtime already stopped") + } + + if err := f.wallet.Startup(); err != nil { + return fmt.Errorf("start lnd lightning wallet: %w", err) + } + if err := f.manager.Start(); err != nil { + _ = f.wallet.Shutdown() + + return fmt.Errorf("start lnd funding manager: %w", err) + } + + f.started = true + + return nil +} + +// Stop stops lnd's funding manager before its wallet reservation handler. +func (f *FundingRuntime) Stop() error { + f.mu.Lock() + defer f.mu.Unlock() + + if f.stopped { + return nil + } + f.stopped = true + if !f.started { + return nil + } + + return errors.Join(f.manager.Stop(), f.wallet.Shutdown()) +} + +// OpenChannel starts lnd's PSBT funding flow. The PSBT update reveals the +// negotiated funding output; Ark then builds and signs the VTXO backing spend. +func (f *FundingRuntime) OpenChannel(req FundingOpenRequest) (*FundingFlow, + error) { + + f.mu.Lock() + started := f.started + stopped := f.stopped + f.mu.Unlock() + if !started || stopped { + return nil, fmt.Errorf("funding runtime is not active") + } + + if req.Peer == nil { + return nil, fmt.Errorf("funding peer is required") + } + if req.PendingChannelID == (lndfunding.PendingChanID{}) { + return nil, fmt.Errorf("pending channel ID is required") + } + if req.Capacity < f.minChanSize || req.Capacity > f.maxChanSize { + return nil, fmt.Errorf("channel capacity %d outside [%d, %d]", + req.Capacity, f.minChanSize, f.maxChanSize) + } + if req.PushAmount < 0 || req.PushAmount >= req.Capacity { + return nil, fmt.Errorf("channel push amount %d outside [0, %d)", + req.PushAmount, req.Capacity) + } + + feeRate, err := f.feeEstimator.EstimateFeePerKW( + defaultFundingTargetConfs, + ) + if err != nil { + return nil, fmt.Errorf("estimate channel funding fee: %w", err) + } + + updates := make(chan *lnrpc.OpenStatusUpdate, 8) + errors := make(chan error, 1) + assembler := chanfunding.NewPsbtAssembler( + req.Capacity, req.BasePSBT, f.netParams, false, + ) + f.manager.InitFundingWorkflow(&lndfunding.InitFundingMsg{ + Peer: req.Peer, + TargetPubkey: req.Peer.IdentityKey(), + ChainHash: *f.netParams.GenesisHash, + LocalFundingAmt: req.Capacity, + PushAmt: lnwire.NewMSatFromSatoshis( + req.PushAmount, + ), + FundingFeePerKw: feeRate, + Private: true, + MinHtlcIn: 1, + MaxValueInFlight: lnwire.NewMSatFromSatoshis(req.Capacity), + MaxHtlcs: uint16(input.MaxHTLCNumber / 2), + MaxLocalCsv: defaultMaxLocalCSVDelay, + ChanFunder: assembler, + PendingChanID: req.PendingChannelID, + Updates: updates, + Err: errors, + }) + + return &FundingFlow{ + PendingChannelID: req.PendingChannelID, + Updates: updates, + Errors: errors, + }, nil +} + +// ExpectedFundingOutput returns the exact output derived by this endpoint's +// native lnd reservation. Both endpoints validate this independently before +// signing the prepared OOR channel's backing transaction. +func (f *FundingRuntime) ExpectedFundingOutput( + pendingID lndfunding.PendingChanID) (*wire.TxOut, error) { + + for _, reservation := range f.wallet.ActiveReservations() { + if reservation.PendingChanID() != pendingID { + continue + } + + output, err := reservation.FundingOutput() + if err != nil { + return nil, fmt.Errorf("read lnd funding output: %w", + err) + } + + return output, nil + } + + return nil, fmt.Errorf("lnd funding reservation %x not found", + pendingID[:]) +} + +// FinalizeBacking verifies lnd's negotiated funding output, registers the +// fully signed backing transaction before lnd can watch it, and resumes the +// native funding state machine. +func (f *FundingRuntime) FinalizeBacking(pendingID lndfunding.PendingChanID, + packet *psbt.Packet, funding VirtualFunding) error { + + if packet == nil { + return fmt.Errorf("funding PSBT is required") + } + // Keep the intent paused after verification. Passing skipFinalize would + // wake funding.Manager before the notifier knows the backing txid. + if err := f.wallet.PsbtFundingVerify( + pendingID, packet, false, + ); err != nil { + return fmt.Errorf("verify virtual channel funding PSBT: %w", + err) + } + if err := f.notifier.RegisterVirtualFunding(funding); err != nil { + return err + } + if err := f.wallet.PsbtFundingFinalize( + pendingID, nil, funding.Transaction, + ); err != nil { + + _ = f.notifier.UnregisterVirtualFunding( + funding.Transaction.TxHash(), + ) + + return fmt.Errorf("finalize virtual channel funding PSBT: %w", + err) + } + + return nil +} + +// RegisterBacking lets the responder install the same fully signed backing +// transaction before the initiator resumes lnd's funding exchange. +func (f *FundingRuntime) RegisterBacking(funding VirtualFunding) error { + return f.notifier.RegisterVirtualFunding(funding) +} + +// CancelBacking cancels either an in-memory PSBT intent or the pending lnd +// channel that replaced it after commitment finalization. +func (f *FundingRuntime) CancelBacking(pendingID lndfunding.PendingChanID, + channelPoint *wire.OutPoint) error { + + if channelPoint != nil { + channel, err := f.stateDB.FetchChannel(*channelPoint) + switch { + case err == nil: + return f.abandonPendingBacking(channel) + + case !errors.Is(err, channeldb.ErrChannelNotFound): + return fmt.Errorf("find pending lnd channel: %w", err) + } + } + + cancelErr := f.wallet.CancelFundingIntent(pendingID) + if cancelErr == nil { + if channelPoint == nil { + return nil + } + + return f.notifier.UnregisterVirtualFunding(channelPoint.Hash) + } + if channelPoint == nil { + return fmt.Errorf("cancel lnd funding intent: %w", cancelErr) + } + + // CompleteReservation removes the PSBT intent before it persists the + // pending channel. Re-read the channel DB to close that race. + channel, err := f.stateDB.FetchChannel(*channelPoint) + if err == nil { + return f.abandonPendingBacking(channel) + } + if !errors.Is(err, channeldb.ErrChannelNotFound) { + return fmt.Errorf("reconcile pending lnd channel: %w", err) + } + _, err = f.stateDB.FetchClosedChannel(channelPoint) + if err == nil { + return nil + } + if !errors.Is(err, channeldb.ErrClosedChannelNotFound) { + return fmt.Errorf("find canceled lnd channel: %w", err) + } + + return fmt.Errorf("cancel lnd funding intent: %w", cancelErr) +} + +// abandonPendingBacking removes finalized channel state before Ark commits the +// prepared OOR transfer. +func (f *FundingRuntime) abandonPendingBacking( + channel *chanstate.OpenChannel) error { + + if !channel.IsPending { + return fmt.Errorf("cannot cancel active lnd channel %v", + channel.FundingOutpoint) + } + if err := f.notifier.CancelVirtualFunding( + channel.FundingOutpoint.Hash, + ); err != nil { + return err + } + _, height, err := f.chain.GetBestBlock() + if err != nil { + return fmt.Errorf("read height for channel cancellation: %w", + err) + } + if err := f.stateDB.AbandonChannel( + &channel.FundingOutpoint, uint32(height), + ); err != nil { + return fmt.Errorf("abandon pending lnd channel: %w", err) + } + + return nil +} + +// ConfirmBacking opens lnd's channel only after the Ark FSM's durable OOR and +// backing-signature gates are satisfied. +func (f *FundingRuntime) ConfirmBacking(txid chainhash.Hash) error { + return f.notifier.ConfirmVirtualFunding(txid) +} + +// ReorgBacking retracts activation when the channel's Ark ancestry reorgs. +func (f *FundingRuntime) ReorgBacking(txid chainhash.Hash, depth int32) error { + return f.notifier.ReorgVirtualFunding(txid, depth) +} + +// ProcessMessage dispatches one native funding message received over the +// application peer transport. +func (f *FundingRuntime) ProcessMessage(message lnwire.Message, + peer lnpeer.Peer) error { + + switch message.(type) { + case *lnwire.OpenChannel, *lnwire.AcceptChannel, + *lnwire.FundingCreated, *lnwire.FundingSigned, + *lnwire.ChannelReady, *lnwire.Warning, *lnwire.Error: + + f.manager.ProcessFundingMsg(message, peer) + + return nil + + default: + return fmt.Errorf("unsupported funding message %T", message) + } +} + +// ProcessMessageSync dispatches one native funding message and waits until +// lnd's funding coordinator has handled it. Durable ingress uses this before +// acknowledging the application transport envelope. +func (f *FundingRuntime) ProcessMessageSync(ctx context.Context, + message lnwire.Message, peer lnpeer.Peer) error { + + switch message.(type) { + case *lnwire.OpenChannel, *lnwire.AcceptChannel, + *lnwire.FundingCreated, *lnwire.FundingSigned, + *lnwire.ChannelReady, *lnwire.Warning, *lnwire.Error: + return f.manager.ProcessFundingMsgSync(ctx, message, peer) + + default: + return fmt.Errorf("unsupported funding message %T", message) + } +} + +// IsPendingChannel reports whether lnd's funding manager still owns the +// message's temporary channel ID for this peer. +func (f *FundingRuntime) IsPendingChannel(channelID lnwire.ChannelID, + peer lnpeer.Peer) bool { + + return f.manager.IsPendingChannel(channelID, peer) +} + +// AddLocalAlias associates a reserved future SCID with an active virtual +// channel before an intercepted HTLC is resumed. +func (f *FundingRuntime) AddLocalAlias( + alias, base lnwire.ShortChannelID) error { + + return f.aliases.AddLocalAlias(alias, base, false, true) +} + +// findOpenChannel resolves funding channel IDs without constructing a graph. +func findOpenChannel(store chanstate.Store, node *btcec.PublicKey, + chanID lnwire.ChannelID) (*chanstate.OpenChannel, error) { + + channels, err := store.FetchOpenChannels(node) + if err != nil { + return nil, err + } + for _, channel := range channels { + if chanID.IsChanPoint(&channel.FundingOutpoint) { + return channel, nil + } + } + + return nil, fmt.Errorf("channel %v not found", chanID) +} + +// defaultRemoteReserve preserves lnd's standard one-percent remote reserve. +func defaultRemoteReserve(capacity, dustLimit btcutil.Amount) btcutil.Amount { + reserve := capacity / 100 + if reserve < dustLimit { + return dustLimit + } + + return reserve +} + +// defaultRemoteMaxValue allows all remote liquidity except its reserve. +func defaultRemoteMaxValue(capacity btcutil.Amount) lnwire.MilliSatoshi { + reserve := lnwire.NewMSatFromSatoshis(capacity / 100) + + return lnwire.NewMSatFromSatoshis(capacity) - reserve +} + +// rejectInternalFundingPublish makes accidental use of lnd's wallet-funded +// path visible instead of broadcasting outside the Ark materializer. +func rejectInternalFundingPublish(*wire.MsgTx, string) error { + return fmt.Errorf("internal funding publication is disabled") +} + +// emptyNodeAnnouncement satisfies private-channel funding without starting a +// public graph or gossiper. +func emptyNodeAnnouncement() (lnwire.NodeAnnouncement1, error) { + return lnwire.NodeAnnouncement1{ + Features: lnwire.NewRawFeatureVector(), + }, nil +} + +// ignoreAnnouncement completes private funding gossip calls locally. +func ignoreAnnouncement(lnwire.Message, + ...discovery.OptionalMsgField) actor.Future[error] { + + promise := actor.NewPromise[error]() + actor.CompleteWith(promise, nil) + + return promise.Future() +} + +// optionalReportSCID supplies a no-op for runtimes with no confirmed-SCID +// callback. +func optionalReportSCID(cfg FundingConfig) func(wire.OutPoint) error { + if cfg.ReportShortChannelID != nil { + return cfg.ReportShortChannelID + } + + return func(wire.OutPoint) error { return nil } +} + +// optionalNotifyOpen supplies lnd's required open callback. +func optionalNotifyOpen( + cfg FundingConfig) func(wire.OutPoint, *btcec.PublicKey) { + + if cfg.NotifyOpen != nil { + return cfg.NotifyOpen + } + + return func(wire.OutPoint, *btcec.PublicKey) {} +} + +// optionalNotifyTimeout supplies lnd's required funding-timeout callback. +func optionalNotifyTimeout( + cfg FundingConfig) func(wire.OutPoint, *btcec.PublicKey) { + + if cfg.NotifyFundingTimeout != nil { + return cfg.NotifyFundingTimeout + } + + return func(wire.OutPoint, *btcec.PublicKey) {} +} diff --git a/lnruntime/funding_flow_test.go b/lnruntime/funding_flow_test.go new file mode 100644 index 000000000..9373be031 --- /dev/null +++ b/lnruntime/funding_flow_test.go @@ -0,0 +1,1852 @@ +package lnruntime + +import ( + "bytes" + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/btcsuite/btcd/address/v2" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chaincfg/v2" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/psbt/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/wavelength/arkchannel" + clientdb "github.com/lightninglabs/wavelength/db" + mailboxrpc "github.com/lightninglabs/wavelength/mailbox/rpc" + "github.com/lightninglabs/wavelength/rpc/arkchannelrpc" + "github.com/lightningnetwork/lnd/channeldb" + "github.com/lightningnetwork/lnd/chanstate" + "github.com/lightningnetwork/lnd/clock" + lndfunding "github.com/lightningnetwork/lnd/funding" + "github.com/lightningnetwork/lnd/graph/db/models" + "github.com/lightningnetwork/lnd/htlcswitch" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/invoices" + "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lnpeer" + "github.com/lightningnetwork/lnd/lntest/mock" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/lightningnetwork/lnd/lnwallet/chancloser" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/routing/route" + "github.com/stretchr/testify/require" +) + +const testFundingCapacity = btcutil.Amount(200_000) + +type fundingFinalization struct { + channelPoint wire.OutPoint +} + +// fundingFlowNode contains one composed runtime and the callbacks used to +// observe native lnd funding and channel-link activation. +type fundingFlowNode struct { + key *btcec.PrivateKey + db *channeldb.DB + runtime *Runtime + notifier *VirtualFundingNotifier + peer *Peer + fundingWire *FundingWire + finalized chan fundingFinalization + links chan *chanstate.OpenChannel + failures chan error + intents *staticIntentSource + + restoreAddsDisabled atomic.Bool +} + +// fundingNegotiationSink models the already-tested durable channel FSM while +// this component test exercises the production native funding coordinator. +type fundingNegotiationSink struct { + mu sync.Mutex + node *fundingFlowNode + party arkchannel.Party + record arkchannel.Record +} + +// fundingWireTestSink keeps the service's backing fact current for wire-side +// validation while the existing funding sink models the remaining barriers. +type fundingWireTestSink struct { + service *arkchannel.Service + mirror *fundingNegotiationSink +} + +// Apply records immutable backing in the service queried by FundingWire and +// delegates the lnd finalization and activation barriers to the test sink. +func (s *fundingWireTestSink) Apply(ctx context.Context, id arkchannel.ID, + event arkchannel.Event) (arkchannel.Record, error) { + + if _, ok := event.(*arkchannel.BackingSigned); ok { + if _, err := s.service.Apply(ctx, id, event); err != nil { + return arkchannel.Record{}, err + } + } + + return s.mirror.Apply(ctx, id, event) +} + +// noOpChannelRecoveryManager satisfies composition in lnd-focused tests. The +// durable recovery archive and activation barrier are exercised separately. +type noOpChannelRecoveryManager struct{} + +// ExportRecoveryPackage returns an unused package in these funding tests. +func (*noOpChannelRecoveryManager) ExportRecoveryPackage(context.Context, + arkchannel.ID, arkchannel.Terms, arkchannel.VTXOBinding) ( + arkchannel.RecoveryPackage, error) { + + return arkchannel.RecoveryPackage{}, nil +} + +// InstallRecoveryPackage accepts the unused package in these funding tests. +func (*noOpChannelRecoveryManager) InstallRecoveryPackage(context.Context, + arkchannel.ID, arkchannel.Terms, arkchannel.VTXOBinding, + arkchannel.RecoveryPackage) error { + + return nil +} + +// activeFundingFlow is one fully activated unpublished channel and the two +// endpoint snapshots that authorized it. +type activeFundingFlow struct { + hubChannel *chanstate.OpenChannel + clientChannel *chanstate.OpenChannel + hubSink *fundingNegotiationSink + clientSink *fundingNegotiationSink +} + +// cooperativeCloseActionExecutor keeps this composed test focused on the +// direct-close actions while using the production process and services. +type cooperativeCloseActionExecutor struct { + closer arkchannel.ChannelCooperativeCloser +} + +// BindChannelEventSink connects the local endpoint to its durable service. +func (e *cooperativeCloseActionExecutor) BindChannelEventSink( + sink arkchannel.ChannelEventSink) error { + + binder, ok := e.closer.(arkchannel.ChannelEventSinkBinder) + if !ok { + return fmt.Errorf("cooperative close process cannot bind " + + "event sink") + } + + return binder.BindChannelEventSink(sink) +} + +// Execute dispatches the three durable cooperative-close actions. +func (e *cooperativeCloseActionExecutor) Execute(ctx context.Context, + id arkchannel.ID, action arkchannel.Action) error { + + switch action := action.(type) { + case *arkchannel.NegotiateCooperativeClose: + return e.closer.NegotiateCooperativeClose( + ctx, id, action.Terms, action.Source, action.Backing, + action.Request, + ) + + case *arkchannel.PublishCooperativeClose: + return e.closer.PublishCooperativeClose( + ctx, id, action.Terms, action.Source, action.Close, + ) + + case *arkchannel.FinalizeCooperativeClose: + return e.closer.FinalizeCooperativeClose( + ctx, id, action.Terms, action.Backing, action.Source, + action.Request, action.Close, + ) + + default: + return fmt.Errorf("unexpected cooperative close action %T", + action) + } +} + +// blockingCooperativeClosePublisher models the ordinary OOR completion barrier +// so the test can prove lnd remains open until replacement VTXOs finalize. +type blockingCooperativeClosePublisher struct { + published chan arkchannel.CooperativeClose + confirm chan struct{} +} + +// cooperativeCloseSigningOrder records which policy role signs the exact +// settlement transaction first. +type cooperativeCloseSigningOrder struct { + mu sync.Mutex + calls []string +} + +// recordingCooperativeCloseSigner wraps an ordinary lnd signer without +// changing any signing behavior. +type recordingCooperativeCloseSigner struct { + input.Signer + + label string + order *cooperativeCloseSigningOrder +} + +// SignOutputRaw records the role and delegates the signature operation. +func (s *recordingCooperativeCloseSigner) SignOutputRaw(tx *wire.MsgTx, + desc *input.SignDescriptor) (input.Signature, error) { + + s.order.mu.Lock() + s.order.calls = append(s.order.calls, s.label) + s.order.mu.Unlock() + + return s.Signer.SignOutputRaw(tx, desc) +} + +// snapshot returns an isolated signing order. +func (o *cooperativeCloseSigningOrder) snapshot() []string { + o.mu.Lock() + defer o.mu.Unlock() + + return append([]string(nil), o.calls...) +} + +// exactCooperativeCloseDeliveryValidator models a wallet ownership lookup for +// one expected payout script. +func exactCooperativeCloseDeliveryValidator( + expected []byte) CooperativeCloseDeliveryValidator { + + return CooperativeCloseDeliveryValidatorFunc(func(_ context.Context, + _ arkchannel.ID, script []byte) error { + + if !bytes.Equal(script, expected) { + return fmt.Errorf("payout script is not wallet owned") + } + + return nil + }) +} + +// SettleCooperativeClose validates the exact OOR authorization and waits for +// the simulated durable OOR completion. +func (p *blockingCooperativeClosePublisher) SettleCooperativeClose( + ctx context.Context, _ arkchannel.ID, _ arkchannel.Terms, + _ arkchannel.VTXOBinding, _ arkchannel.CooperativeCloseRequest, + settlement arkchannel.CooperativeClose) error { + + if settlement.TxID == (chainhash.Hash{}) { + return fmt.Errorf("cooperative close OOR session ID is empty") + } + + select { + case p.published <- settlement: + case <-ctx.Done(): + return ctx.Err() + } + select { + case <-p.confirm: + return nil + + case <-ctx.Done(): + return ctx.Err() + } +} + +// Apply records funding facts and releases virtual confirmation only after +// both native lnd endpoints have finalized their initial commitments. +func (s *fundingNegotiationSink) Apply(_ context.Context, id arkchannel.ID, + event arkchannel.Event) (arkchannel.Record, error) { + + s.mu.Lock() + defer s.mu.Unlock() + if id != s.record.Snapshot.Terms.ID { + return arkchannel.Record{}, fmt.Errorf("unexpected channel ID") + } + + snapshot := &s.record.Snapshot + switch event := event.(type) { + case *arkchannel.BackingSigned: + backing := event.Backing.Clone() + snapshot.Backing = &backing + + case *arkchannel.FundingFinalized: + if event.Party == arkchannel.PartyClient { + snapshot.ClientFinalized = true + } else { + snapshot.HubFinalized = true + } + if snapshot.ClientFinalized && snapshot.HubFinalized && + snapshot.Terms.Funder == s.party { + + snapshot.OORFinalized = true + snapshot.RecoveryReady = true + if err := s.node.runtime.Funding().ConfirmBacking( + snapshot.Backing.ChannelPoint.Hash, + ); err != nil { + return arkchannel.Record{}, err + } + } + + case *arkchannel.OORFinalized: + if event.SessionID != snapshot.Source.OORSessionID { + return arkchannel.Record{}, fmt.Errorf("unexpected " + + "OOR session") + } + snapshot.OORFinalized = true + snapshot.RecoveryReady = true + if err := s.node.runtime.Funding().ConfirmBacking( + snapshot.Backing.ChannelPoint.Hash, + ); err != nil { + return arkchannel.Record{}, err + } + + case *arkchannel.ChannelActive: + snapshot.Phase = arkchannel.PhaseActive + + default: + return arkchannel.Record{}, fmt.Errorf("unexpected channel "+ + "event %T", event) + } + s.record.Revision++ + + return s.record, nil +} + +// fundingFlowTransport preserves lnd wire ordering over an in-memory version +// of the application transport used between Wavelength and swapdk-server. +type fundingFlowTransport struct { + remote *fundingFlowNode + queue chan []lnwire.Message + quit chan struct{} + wg sync.WaitGroup + stop sync.Once +} + +// newFundingFlowTransport starts a FIFO delivery loop that models the +// production mailbox boundary: durable admission completes before the remote +// lnd subsystem handles the message. +func newFundingFlowTransport(remote *fundingFlowNode) *fundingFlowTransport { + transport := &fundingFlowTransport{ + remote: remote, + queue: make(chan []lnwire.Message, 100), + quit: make(chan struct{}), + } + transport.wg.Add(1) + go transport.deliver() + + return transport +} + +// SendMessages admits one ordered message batch without synchronously calling +// into the remote lnd runtime. +func (t *fundingFlowTransport) SendMessages(_ bool, + messages ...lnwire.Message) error { + + batch := append([]lnwire.Message(nil), messages...) + select { + case t.queue <- batch: + return nil + + case <-t.quit: + return fmt.Errorf("funding flow transport stopped") + } +} + +// Stop terminates the delivery loop. +func (t *fundingFlowTransport) Stop() { + t.stop.Do(func() { + close(t.quit) + t.wg.Wait() + }) +} + +// deliver handles admitted batches in FIFO order on the remote side. +func (t *fundingFlowTransport) deliver() { + defer t.wg.Done() + + for { + select { + case messages := <-t.queue: + if err := t.dispatch(messages...); err != nil { + select { + case t.remote.failures <- err: + case <-t.quit: + } + } + + case <-t.quit: + return + } + } +} + +// dispatch routes funding messages to funding.Manager and commitment updates +// to the native channel link. +func (t *fundingFlowTransport) dispatch(messages ...lnwire.Message) error { + for _, message := range messages { + switch message := message.(type) { + case *lnwire.OpenChannel, *lnwire.AcceptChannel, + *lnwire.FundingCreated, *lnwire.FundingSigned, + *lnwire.ChannelReady, *lnwire.Warning, *lnwire.Error: + + if err := t.remote.runtime.Funding().ProcessMessage( + message, t.remote.peer, + ); err != nil { + return err + } + + case lnwire.LinkUpdater: + if err := t.remote.runtime.HandleChannelMessage( + message, + ); err != nil { + return err + } + + case *lnwire.ChannelReestablish: + go t.deliverReestablish(message) + + case *lnwire.Custom: + if t.remote.fundingWire == nil || + !t.remote.fundingWire.Handles(message) { + return fmt.Errorf("unknown custom funding " + + "message") + } + + if err := t.remote.fundingWire.Handle( + context.Background(), message, + ); err != nil { + return err + } + + case *lnwire.NodeAnnouncement1, *lnwire.ChannelAnnouncement1, + *lnwire.ChannelUpdate1: + + // Private runtimes have no graph or gossiper. + + default: + return fmt.Errorf("unexpected lnd message %T", message) + } + } + + return nil +} + +// noOpFundingActionExecutor keeps this wire test focused on recording the +// peer-readiness barrier without dispatching channel negotiation. +type noOpFundingActionExecutor struct{} + +// ValidatePreparedOOR accepts the fixture after its normal state validation. +func (*noOpFundingActionExecutor) ValidatePreparedOOR(context.Context, + arkchannel.Terms, arkchannel.VTXOBinding) error { + + return nil +} + +// Execute accepts an unused action. +func (*noOpFundingActionExecutor) Execute(context.Context, arkchannel.ID, + arkchannel.Action) error { + + return nil +} + +// TestFundingWireRecordsPeerReadiness proves the hub-to-client reverse +// transport durably records readiness without synchronously dispatching the +// client's channel action. +func TestFundingWireRecordsPeerReadiness(t *testing.T) { + t.Parallel() + + hub := newFundingFlowNode(t, arkchannel.PartyHub) + client := newFundingFlowNode(t, arkchannel.PartyClient) + connectFundingFlowNodes(t, hub, client) + + rawStore := clientdb.NewTestDB(t) + store := clientdb.NewStore( + rawStore.DB, rawStore.Queries, rawStore.Backend(), + btclog.Disabled, + ).NewArkChannelStore(clock.NewDefaultClock()) + coordinator, err := arkchannel.NewCoordinator(store) + require.NoError(t, err) + service, err := arkchannel.NewService( + coordinator, &noOpFundingActionExecutor{}, + ) + require.NoError(t, err) + + record := fundingIntentRecord( + t, hub, client, lndfunding.PendingChanID{2, 4, 6, 8}, + ) + _, err = service.RegisterReceiveIntent( + t.Context(), record.Snapshot.Terms, + ) + require.NoError(t, err) + _, err = service.BindPreparedOOR( + t.Context(), record.Snapshot.Terms.ID, *record.Snapshot.Source, + ) + require.NoError(t, err) + + clientEndpoint, err := NewNativeFundingEndpoint( + arkchannel.PartyClient, client.runtime.Funding(), + input.NewMockSigner( + []*btcec.PrivateKey{client.key}, nil, + ), + keychain.KeyDescriptor{ + PubKey: client.key.PubKey(), + }, + ) + require.NoError(t, err) + clientWire, err := NewFundingWire(client.peer) + require.NoError(t, err) + client.fundingWire = clientWire + t.Cleanup(clientWire.Close) + require.NoError( + t, + clientWire.BindServer( + FundingWireServerConfig{ + Service: service, + Funding: clientEndpoint, + }, + ), + ) + + hubWire, err := NewFundingWire(hub.peer) + require.NoError(t, err) + hub.fundingWire = hubWire + t.Cleanup(hubWire.Close) + _, err = hubWire.Counterparty().ApplyChannelEvent( + t.Context(), record.Snapshot.Terms.ID, + &arkchannel.FundingPeerReady{}, + ) + require.NoError(t, err) + + stored, err := service.GetChannel( + t.Context(), record.Snapshot.Terms.ID, + ) + require.NoError(t, err) + require.Equal(t, arkchannel.PhaseNegotiating, stored.Snapshot.Phase) +} + +// TestFundingWireNegotiatesHubFundedChannel proves the production reverse +// transport supports the complete hub-initiated lnd funding exchange. +func TestFundingWireNegotiatesHubFundedChannel(t *testing.T) { + t.Parallel() + + hub := newFundingFlowNode(t, arkchannel.PartyHub) + client := newFundingFlowNode(t, arkchannel.PartyClient) + connectFundingFlowNodes(t, hub, client) + require.NoError(t, hub.runtime.Start()) + require.NoError(t, client.runtime.Start()) + t.Cleanup(func() { + require.NoError(t, hub.runtime.Stop()) + require.NoError(t, client.runtime.Stop()) + }) + + record := fundingIntentRecord( + t, hub, client, lndfunding.PendingChanID{2, 4, 6, 9}, + ) + hub.intents.record = record + client.intents.record = record + + rawStore := clientdb.NewTestDB(t) + store := clientdb.NewStore( + rawStore.DB, rawStore.Queries, rawStore.Backend(), + btclog.Disabled, + ).NewArkChannelStore(clock.NewDefaultClock()) + coordinator, err := arkchannel.NewCoordinator(store) + require.NoError(t, err) + service, err := arkchannel.NewService( + coordinator, &noOpFundingActionExecutor{}, + ) + require.NoError(t, err) + _, err = service.RegisterReceiveIntent( + t.Context(), record.Snapshot.Terms, + ) + require.NoError(t, err) + _, err = service.BindPreparedOOR( + t.Context(), record.Snapshot.Terms.ID, *record.Snapshot.Source, + ) + require.NoError(t, err) + _, err = service.RecordChannelEvent( + t.Context(), record.Snapshot.Terms.ID, + &arkchannel.FundingPeerReady{}, + ) + require.NoError(t, err) + + hubEndpoint, err := NewNativeFundingEndpoint( + arkchannel.PartyHub, hub.runtime.Funding(), + input.NewMockSigner( + []*btcec.PrivateKey{hub.key}, nil, + ), + keychain.KeyDescriptor{ + PubKey: hub.key.PubKey(), + }, + ) + require.NoError(t, err) + clientEndpoint, err := NewNativeFundingEndpoint( + arkchannel.PartyClient, client.runtime.Funding(), + input.NewMockSigner( + []*btcec.PrivateKey{client.key}, nil, + ), + keychain.KeyDescriptor{ + PubKey: client.key.PubKey(), + }, + ) + require.NoError(t, err) + hubSink := &fundingNegotiationSink{ + node: hub, party: arkchannel.PartyHub, record: record, + } + clientSink := &fundingNegotiationSink{ + node: client, party: arkchannel.PartyClient, record: record, + } + require.NoError(t, hubEndpoint.BindChannelEventSink(hubSink)) + require.NoError( + t, + clientEndpoint.BindChannelEventSink( + &fundingWireTestSink{ + service: service, + mirror: clientSink, + }, + ), + ) + + clientWire, err := NewFundingWire(client.peer) + require.NoError(t, err) + client.fundingWire = clientWire + t.Cleanup(clientWire.Close) + require.NoError( + t, + clientWire.BindServer( + FundingWireServerConfig{ + Service: service, + Funding: clientEndpoint, + }, + ), + ) + hubWire, err := NewFundingWire(hub.peer) + require.NoError(t, err) + hub.fundingWire = hubWire + t.Cleanup(hubWire.Close) + + negotiator, err := NewChannelNegotiator( + hubEndpoint, hubWire.Counterparty(), hub.peer, + &noOpChannelRecoveryManager{}, + ) + require.NoError(t, err) + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + require.NoError( + t, negotiator.NegotiateChannel( + ctx, record.Snapshot.Terms.ID, record.Snapshot.Terms, + *record.Snapshot.Source, + ), + ) + + require.Equal(t, arkchannel.PhaseActive, hubSink.record.Snapshot.Phase) + require.Equal( + t, arkchannel.PhaseActive, clientSink.record.Snapshot.Phase, + ) + hubChannel := awaitLink(t, hub) + clientChannel := awaitLink(t, client) + require.Equal( + t, hubChannel.FundingOutpoint, clientChannel.FundingOutpoint, + ) + require.Positive(t, hubChannel.LocalCommitment.LocalBalance) + require.Zero(t, clientChannel.LocalCommitment.LocalBalance) +} + +// deliverReestablish models durable mailbox retry while the remote runtime is +// still rebuilding its link during a simultaneous restart. +func (t *fundingFlowTransport) deliverReestablish( + message *lnwire.ChannelReestablish) { + + deadline := time.After(5 * time.Second) + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + + for { + err := t.remote.runtime.HandleChannelReestablish( + message, t.remote.peer, + func(*chanstate.OpenChannel) (LinkConfig, error) { + cfg := testLinkConfig( + t.remote.peer, t.remote.failures, + ) + cfg.AddsDisabled = t.remote. + restoreAddsDisabled.Load() + + return cfg, nil + }, + ) + if err == nil { + return + } + if !errors.Is(err, htlcswitch.ErrChannelLinkNotFound) { + t.remote.failures <- err + + return + } + + select { + case <-ticker.C: + case <-deadline: + t.remote.failures <- err + + return + } + } +} + +// TestNativeFundingFlowPaysBothDirections proves the composed funding manager +// negotiates an unpublished channel and hands it to the same native links used +// by lnd's invoice and payment lifecycles. +func TestNativeFundingFlowPaysBothDirections(t *testing.T) { + t.Parallel() + + alice := newFundingFlowNode(t, arkchannel.PartyHub) + bob := newFundingFlowNode(t, arkchannel.PartyClient) + connectFundingFlowNodes(t, alice, bob) + require.NoError(t, alice.runtime.Start()) + require.NoError(t, bob.runtime.Start()) + t.Cleanup(func() { + require.NoError(t, alice.runtime.Stop()) + require.NoError(t, bob.runtime.Stop()) + }) + + pendingID := lndfunding.PendingChanID{1, 3, 3, 7} + record := fundingIntentRecord(t, alice, bob, pendingID) + flow := activateFundingFlowChannel(t, alice, bob, record) + aliceSink := flow.hubSink + bobSink := flow.clientSink + require.NotNil(t, aliceSink.record.Snapshot.Backing) + require.Equal( + t, arkchannel.PhaseActive, aliceSink.record.Snapshot.Phase, + ) + require.Equal(t, arkchannel.PhaseActive, + bobSink.record.Snapshot.Phase) + aliceChannel := flow.hubChannel + bobChannel := flow.clientChannel + require.Equal( + t, aliceChannel.FundingOutpoint, bobChannel.FundingOutpoint, + ) + require.False(t, aliceChannel.IsPending) + require.False(t, bobChannel.IsPending) + require.Positive(t, aliceChannel.LocalCommitment.LocalBalance) + require.Zero(t, bobChannel.LocalCommitment.LocalBalance) + bobLink, err := bob.runtime.GetLink(bobChannel.ShortChanID()) + require.NoError(t, err) + require.Zero(t, bobLink.Bandwidth()) + aliceLink, err := alice.runtime.GetLink(aliceChannel.ShortChanID()) + require.NoError(t, err) + require.GreaterOrEqual( + t, aliceLink.Bandwidth(), lnwire.MilliSatoshi(30_000_000), + ) + + const firstAmount = lnwire.MilliSatoshi(30_000_000) + payRuntimeInvoice(t, alice, bob, firstAmount, lntypes.Preimage{1, 2, 3}) + for _, node := range []*fundingFlowNode{alice, bob} { + _, err := node.runtime.QuiesceChannel( + t.Context(), aliceChannel.FundingOutpoint, + ) + require.NoError(t, err) + } + alice.runtime.ResumeChannel(aliceChannel.FundingOutpoint) + bob.runtime.ResumeChannel(bobChannel.FundingOutpoint) + + // Restart only Bob's link. Alice recycles its still-live link when the + // mailbox delivers Bob's new channel_reestablish handshake. + bob.runtime.RemoveLink(bobChannel.FundingOutpoint) + restored, err := bob.runtime.RestorePeerLinks( + bob.peer, func(*chanstate.OpenChannel) (LinkConfig, error) { + return testLinkConfig(bob.peer, bob.failures), nil + }, + ) + require.NoError(t, err) + require.Len(t, restored, 1) + recycleDeadline := time.After(5 * time.Second) + recycleTicker := time.NewTicker(10 * time.Millisecond) + defer recycleTicker.Stop() + for { + newAliceLink, linkErr := alice.runtime.GetLink( + aliceChannel.ShortChanID(), + ) + if linkErr == nil && newAliceLink != aliceLink { + break + } + select { + case failure := <-alice.failures: + require.NoError(t, failure) + + case failure := <-bob.failures: + require.NoError(t, failure) + + case <-recycleTicker.C: + case <-recycleDeadline: + t.Fatal("live peer did not recycle its channel link") + } + } + + const returnAmount = lnwire.MilliSatoshi(10_000_000) + payRuntimeInvoice( + t, bob, alice, returnAmount, lntypes.Preimage{7, 8, 9}, + ) + + closeTx := cooperativelyCloseFundingFlowChannel( + t, alice, bob, aliceChannel.FundingOutpoint, + ) + require.Equal( + t, aliceChannel.FundingOutpoint, + closeTx.TxIn[0].PreviousOutPoint, + ) +} + +// TestNativeFundingFlowRestoresQuiescedLinks proves a restart in any durable +// cooperative-close phase installs both links with new HTLC adds disabled. +func TestNativeFundingFlowRestoresQuiescedLinks(t *testing.T) { + t.Parallel() + + hub := newFundingFlowNode(t, arkchannel.PartyHub) + client := newFundingFlowNode(t, arkchannel.PartyClient) + connectFundingFlowNodes(t, hub, client) + require.NoError(t, hub.runtime.Start()) + require.NoError(t, client.runtime.Start()) + t.Cleanup(func() { + require.NoError(t, hub.runtime.Stop()) + require.NoError(t, client.runtime.Stop()) + }) + + record := fundingIntentRecord( + t, hub, client, lndfunding.PendingChanID{8, 1, 7, 2}, + ) + flow := activateFundingFlowChannel(t, hub, client, record) + hub.restoreAddsDisabled.Store(true) + client.restoreAddsDisabled.Store(true) + client.runtime.RemoveLink(flow.clientChannel.FundingOutpoint) + active, err := client.runtime.Funding().ChannelActive( + t.Context(), flow.clientSink.record.Snapshot.Terms, + *flow.clientSink.record.Snapshot.Backing, + ) + require.NoError(t, err) + require.False( + t, active, + "an open database row without a link is not payment-ready", + ) + + restored, err := client.runtime.RestorePeerLinks( + client.peer, + func(*chanstate.OpenChannel) (LinkConfig, error) { + cfg := testLinkConfig(client.peer, client.failures) + cfg.AddsDisabled = true + + return cfg, nil + }, + ) + require.NoError(t, err) + require.Len(t, restored, 1) + + require.Eventually(t, func() bool { + for _, node := range []*fundingFlowNode{hub, client} { + channels, err := node.db.ChannelStateDB(). + FetchAllOpenChannels() + if err != nil || len(channels) != 1 { + return false + } + link, err := node.runtime.GetLink( + channels[0].ShortChanID(), + ) + if err != nil || + !link.IsFlushing(htlcswitch.Incoming) || + !link.IsFlushing(htlcswitch.Outgoing) { + return false + } + } + + return true + }, 5*time.Second, 10*time.Millisecond) +} + +// TestNativeFundingFlowInArkCooperativeClose proves an active unpublished +// channel can carry payments in both directions and then settle its clean lnd +// balances with an ordinary OOR package over the channel VTXO's 3-of-3 path. +// The backing channel point never becomes an input to the close package. +func TestNativeFundingFlowInArkCooperativeClose(t *testing.T) { + t.Parallel() + + hub := newFundingFlowNode(t, arkchannel.PartyHub) + client := newFundingFlowNode(t, arkchannel.PartyClient) + connectFundingFlowNodes(t, hub, client) + require.NoError(t, hub.runtime.Start()) + require.NoError(t, client.runtime.Start()) + t.Cleanup(func() { + require.NoError(t, hub.runtime.Stop()) + require.NoError(t, client.runtime.Stop()) + }) + + clientArkKey := testIntentKey(t) + hubArkKey := testIntentKey(t) + operatorArkKey := testIntentKey(t) + pendingID := lndfunding.PendingChanID{4, 8, 1, 5} + record := fundingIntentRecord(t, hub, client, pendingID) + record.Snapshot.Terms.Kind = arkchannel.KindPromotion + record.Snapshot.Terms.Funder = arkchannel.PartyClient + record.Snapshot.Terms.PaymentHash = [32]byte{} + record.Snapshot.Terms.VTXO.ClientArkKey = compressedIntentKey( + clientArkKey, + ) + record.Snapshot.Terms.VTXO.HubArkKey = compressedIntentKey(hubArkKey) + record.Snapshot.Terms.VTXO.ArkOperatorKey = compressedIntentKey( + operatorArkKey, + ) + record.Snapshot.Source = testIntentBinding( + t, record.Snapshot.Terms, testFundingCapacity+1_000, 1, + ) + request := arkchannel.CooperativeCloseRequest{ + Initiator: arkchannel.PartyClient, + ClientDeliveryScript: client.key.PubKey().SerializeCompressed(), + HubDeliveryScript: hub.key.PubKey().SerializeCompressed(), + } + flow := activateFundingFlowChannel(t, hub, client, record) + channelPoint := flow.hubChannel.FundingOutpoint + require.Equal(t, channelPoint, flow.clientChannel.FundingOutpoint) + require.Zero(t, flow.hubChannel.LocalCommitment.LocalBalance) + require.Positive(t, flow.clientChannel.LocalCommitment.LocalBalance) + + payRuntimeInvoice( + t, client, hub, lnwire.MilliSatoshi(30_000_000), + lntypes.Preimage{1, 4, 1, 5}, + ) + payRuntimeInvoice( + t, hub, client, lnwire.MilliSatoshi(10_000_000), + lntypes.Preimage{9, 2, 6, 5}, + ) + + hubRawStore := clientdb.NewTestDB(t) + clientRawStore := clientdb.NewTestDB(t) + hubStore := clientdb.NewStore( + hubRawStore.DB, hubRawStore.Queries, hubRawStore.Backend(), + btclog.Disabled, + ).NewArkChannelStore(clock.NewDefaultClock()) + clientStore := clientdb.NewStore( + clientRawStore.DB, clientRawStore.Queries, + clientRawStore.Backend(), btclog.Disabled, + ).NewArkChannelStore(clock.NewDefaultClock()) + _, err := hubStore.Create(t.Context(), flow.hubSink.record.Snapshot) + require.NoError(t, err) + _, err = clientStore.Create( + t.Context(), flow.clientSink.record.Snapshot, + ) + require.NoError(t, err) + hubFSM, err := arkchannel.NewCoordinator(hubStore) + require.NoError(t, err) + clientFSM, err := arkchannel.NewCoordinator(clientStore) + require.NoError(t, err) + + signingOrder := &cooperativeCloseSigningOrder{} + hubEndpoint, err := NewNativeCooperativeCloseEndpoint( + arkchannel.PartyHub, hub.runtime, + &recordingCooperativeCloseSigner{ + Signer: input.NewMockSigner( + []*btcec.PrivateKey{hubArkKey}, nil, + ), + label: "hub", + order: signingOrder, + }, + keychain.KeyDescriptor{PubKey: hubArkKey.PubKey()}, + exactCooperativeCloseDeliveryValidator( + request.HubDeliveryScript, + ), + ) + require.NoError(t, err) + clientEndpoint, err := NewNativeCooperativeCloseEndpoint( + arkchannel.PartyClient, client.runtime, nil, + keychain.KeyDescriptor{}, + exactCooperativeCloseDeliveryValidator( + request.ClientDeliveryScript, + ), + ) + require.NoError(t, err) + publisher := &blockingCooperativeClosePublisher{ + published: make(chan arkchannel.CooperativeClose, 1), + confirm: make(chan struct{}), + } + defended := make(chan wire.OutPoint, 1) + hubClose, err := NewHubCooperativeCloseProcess( + hubEndpoint, + &stableCooperativeCloseDelivery{ + script: request.HubDeliveryScript, + }, + CooperativeCloseObserverFunc(func(context.Context, + chainhash.Hash, btcutil.Amount) error { + + return nil + }), + CooperativeCloseDefenderFunc(func(_ context.Context, + outpoint wire.OutPoint) error { + + defended <- outpoint + + return nil + }), + ) + require.NoError(t, err) + hubExecutor := &HubCooperativeCloseExecutor{ + HubCooperativeCloseProcess: hubClose, + } + hubService, err := arkchannel.NewService( + hubFSM, &cooperativeCloseActionExecutor{ + closer: hubExecutor, + }, + ) + require.NoError(t, err) + hubRPC, err := NewCooperativeClosePeerRPCServer( + record.Snapshot.Terms.ClientNodeKey, hubClose, + ) + require.NoError(t, err) + mux := mailboxrpc.NewServeMux() + arkchannelrpc.RegisterArkChannelPeerServiceMailboxServer(mux, hubRPC) + mailboxPeer, err := NewMailboxCooperativeClosePeer( + newLoopbackCooperativeCloseRPC(mux), + ) + require.NoError(t, err) + lossyPeer := &lossyCooperativeClosePeer{ + ProcessCooperativeClosePeer: mailboxPeer, + loseBegin: true, + loseComplete: true, + } + clientDelivery := &stableCooperativeCloseDelivery{ + script: request.ClientDeliveryScript, + } + clientClose, err := NewClientCooperativeCloseProcess( + clientEndpoint, lossyPeer, publisher, clientDelivery, + ) + require.NoError(t, err) + clientService, err := arkchannel.NewService( + clientFSM, &cooperativeCloseActionExecutor{ + closer: clientClose, + }, + ) + require.NoError(t, err) + + _, err = clientClose.RequestCooperativeClose( + t.Context(), record.Snapshot.Terms.ID, + ) + require.ErrorContains(t, err, "lost begin response") + require.Equal(t, 1, clientDelivery.callCount()) + + _, err = clientClose.RequestCooperativeClose( + t.Context(), record.Snapshot.Terms.ID, + ) + require.ErrorContains(t, err, "lost complete response") + require.Equal(t, 2, clientDelivery.callCount()) + + // The client received nothing, but the hub must already hold the + // exact hub authorization. OOR signing cannot start until the client + // recovers it and completes the durable acknowledgement barrier. + hubCompleted, err := hubStore.Get( + t.Context(), record.Snapshot.Terms.ID, + ) + require.NoError(t, err) + clientWaiting, err := clientStore.Get( + t.Context(), record.Snapshot.Terms.ID, + ) + require.NoError(t, err) + require.NotNil(t, hubCompleted.Snapshot.CooperativeClose) + require.True(t, hubCompleted.Snapshot.HubCloseSigned) + require.False(t, hubCompleted.Snapshot.ClientCloseSigned) + require.Nil(t, clientWaiting.Snapshot.CooperativeClose) + require.Equal( + t, []string{"hub"}, + signingOrder.snapshot(), + ) + select { + case <-publisher.published: + t.Fatal("cooperative close OOR started before client recovery") + + default: + } + + closeResult := make(chan error, 1) + go func() { + _, closeErr := clientClose.RequestCooperativeClose( + t.Context(), record.Snapshot.Terms.ID, + ) + closeResult <- closeErr + }() + + var settlement arkchannel.CooperativeClose + select { + case settlement = <-publisher.published: + case err := <-closeResult: + require.NoError(t, err) + t.Fatal("cooperative close completed without OOR settlement") + + case <-time.After(10 * time.Second): + t.Fatal("timeout waiting for cooperative OOR settlement") + } + require.Equal( + t, []string{"hub"}, + signingOrder.snapshot(), + ) + require.NoError( + t, settlement.Validate( + record.Snapshot.Terms, *record.Snapshot.Source, request, + ), + ) + require.Len(t, settlement.Transaction, schnorr.SignatureSize) + require.NotEqual(t, channelPoint.Hash, settlement.TxID) + + // OOR submission has started, but lnd must retain both open-channel + // records until the OOR actor reports durable completion. + for _, node := range []*fundingFlowNode{hub, client} { + _, err := node.db.ChannelStateDB().FetchChannel(channelPoint) + require.NoError(t, err) + } + hubSigned, err := hubStore.Get(t.Context(), record.Snapshot.Terms.ID) + require.NoError(t, err) + clientSigned, err := clientStore.Get( + t.Context(), record.Snapshot.Terms.ID, + ) + require.NoError(t, err) + require.Equal( + t, arkchannel.PhaseCoopCloseSigned, hubSigned.Snapshot.Phase, + ) + require.Equal( + t, arkchannel.PhaseCoopCloseSigned, clientSigned.Snapshot.Phase, + ) + + close(publisher.confirm) + select { + case err := <-closeResult: + require.NoError(t, err) + + case <-time.After(10 * time.Second): + t.Fatal("timeout finalizing cooperative OOR close") + } + require.Equal(t, 2, clientDelivery.callCount()) + closed, err := clientClose.RequestCooperativeClose( + t.Context(), record.Snapshot.Terms.ID, + ) + require.NoError(t, err) + require.Equal(t, arkchannel.PhaseClosed, closed.Snapshot.Phase) + + for party, state := range map[arkchannel.Party]struct { + node *fundingFlowNode + store *clientdb.ArkChannelStoreDB + }{ + arkchannel.PartyHub: { + node: hub, store: hubStore, + }, + arkchannel.PartyClient: { + node: client, store: clientStore, + }, + } { + closedRecord, err := state.store.Get( + t.Context(), record.Snapshot.Terms.ID, + ) + require.NoError(t, err) + require.Equal( + t, arkchannel.PhaseClosed, closedRecord.Snapshot.Phase, + ) + _, err = state.node.db.ChannelStateDB().FetchChannel( + channelPoint, + ) + require.ErrorIs(t, err, channeldb.ErrChannelNotFound) + closedChannel, err := state.node.db. + ChannelStateDB(). + FetchClosedChannel(&channelPoint) + require.NoError(t, err) + require.Equal( + t, channeldb.CooperativeClose, closedChannel.CloseType, + ) + require.Equal(t, settlement.TxID, closedChannel.ClosingTXID) + expectedBalance := settlement.Proposal.ClientBalance + if party == arkchannel.PartyHub { + expectedBalance = settlement.Proposal.HubBalance + } + require.Equal(t, expectedBalance, closedChannel.SettledBalance) + } + expectedHubReplacement, err := settlement.ReplacementOutPoint( + record.Snapshot.Terms, *record.Snapshot.Source, request, + arkchannel.PartyHub, + ) + require.NoError(t, err) + _, err = hubService.Apply( + t.Context(), record.Snapshot.Terms.ID, + &arkchannel.SourceSpent{ + OutPoint: record.Snapshot.Source.OutPoint, + SpendingTxID: chainhash.Hash{9, 9, 9}, + }, + ) + require.NoError(t, err) + select { + case outpoint := <-defended: + require.Equal(t, expectedHubReplacement, outpoint) + + case <-time.After(time.Second): + t.Fatal("hub replacement was not defended") + } + _ = clientService +} + +// TestNativeFundingFlowPromotesClientVTXO proves the same prepared-OOR flow +// opens a client-funded channel with all initial liquidity on the client side. +func TestNativeFundingFlowPromotesClientVTXO(t *testing.T) { + t.Parallel() + testNativeFundingFlowPromotesClientVTXO(t) +} + +// testNativeFundingFlowPromotesClientVTXO exercises ordinary wallet-VTXO +// promotion independently from the hub-funded receive-intent flow. +func testNativeFundingFlowPromotesClientVTXO(t *testing.T) { + t.Helper() + + hub := newFundingFlowNode(t, arkchannel.PartyHub) + client := newFundingFlowNode(t, arkchannel.PartyClient) + connectFundingFlowNodes(t, hub, client) + require.NoError(t, hub.runtime.Start()) + require.NoError(t, client.runtime.Start()) + t.Cleanup(func() { + require.NoError(t, hub.runtime.Stop()) + require.NoError(t, client.runtime.Stop()) + }) + + pendingID := lndfunding.PendingChanID{7, 2, 0, 4} + record := fundingIntentRecord(t, hub, client, pendingID) + record.Snapshot.Terms.Kind = arkchannel.KindPromotion + record.Snapshot.Terms.Funder = arkchannel.PartyClient + record.Snapshot.Terms.Capacity = testFundingCapacity + record.Snapshot.Terms.PaymentHash = [32]byte{} + record.Snapshot.Source = testIntentBinding( + t, record.Snapshot.Terms, testFundingCapacity+1_000, 1, + ) + hub.intents.record = record + client.intents.record = record + + hubEndpoint, err := NewNativeFundingEndpoint( + arkchannel.PartyHub, hub.runtime.Funding(), + input.NewMockSigner( + []*btcec.PrivateKey{hub.key}, nil, + ), + keychain.KeyDescriptor{ + PubKey: hub.key.PubKey(), + }, + ) + require.NoError(t, err) + clientEndpoint, err := NewNativeFundingEndpoint( + arkchannel.PartyClient, client.runtime.Funding(), + input.NewMockSigner( + []*btcec.PrivateKey{client.key}, nil, + ), + keychain.KeyDescriptor{ + PubKey: client.key.PubKey(), + }, + ) + require.NoError(t, err) + hubSink := &fundingNegotiationSink{ + node: hub, party: arkchannel.PartyHub, record: record, + } + clientSink := &fundingNegotiationSink{ + node: client, party: arkchannel.PartyClient, record: record, + } + require.NoError(t, hubEndpoint.BindChannelEventSink(hubSink)) + require.NoError(t, clientEndpoint.BindChannelEventSink(clientSink)) + negotiator, err := NewChannelNegotiator( + clientEndpoint, hubEndpoint, client.peer, + &noOpChannelRecoveryManager{}, + ) + require.NoError(t, err) + require.NoError( + t, + negotiator.NegotiateChannel( + t.Context(), record.Snapshot.Terms.ID, + record.Snapshot.Terms, *record.Snapshot.Source, + ), + ) + + hubChannel := awaitLink(t, hub) + clientChannel := awaitLink(t, client) + require.Equal( + t, clientChannel.FundingOutpoint, hubChannel.FundingOutpoint, + ) + require.Positive(t, clientChannel.LocalCommitment.LocalBalance) + require.Zero(t, hubChannel.LocalCommitment.LocalBalance) +} + +// activateFundingFlowChannel negotiates and activates one native lnd channel +// against the exact prepared OOR channel-policy output in record. +func activateFundingFlowChannel(t *testing.T, hub, client *fundingFlowNode, + record arkchannel.Record) activeFundingFlow { + + t.Helper() + + hub.intents.record = record + client.intents.record = record + hubEndpoint, err := NewNativeFundingEndpoint( + arkchannel.PartyHub, hub.runtime.Funding(), + input.NewMockSigner( + []*btcec.PrivateKey{hub.key}, nil, + ), + keychain.KeyDescriptor{ + PubKey: hub.key.PubKey(), + }, + ) + require.NoError(t, err) + clientEndpoint, err := NewNativeFundingEndpoint( + arkchannel.PartyClient, client.runtime.Funding(), + input.NewMockSigner( + []*btcec.PrivateKey{client.key}, nil, + ), + keychain.KeyDescriptor{ + PubKey: client.key.PubKey(), + }, + ) + require.NoError(t, err) + hubSink := &fundingNegotiationSink{ + node: hub, party: arkchannel.PartyHub, record: record, + } + clientSink := &fundingNegotiationSink{ + node: client, party: arkchannel.PartyClient, record: record, + } + require.NoError(t, hubEndpoint.BindChannelEventSink(hubSink)) + require.NoError(t, clientEndpoint.BindChannelEventSink(clientSink)) + initiator := hubEndpoint + responder := clientEndpoint + initiatorPeer := hub.peer + if record.Snapshot.Terms.FundingInitiator() == + arkchannel.PartyClient { + + initiator = clientEndpoint + responder = hubEndpoint + initiatorPeer = client.peer + } + negotiator, err := NewChannelNegotiator( + initiator, responder, initiatorPeer, + &noOpChannelRecoveryManager{}, + ) + require.NoError(t, err) + require.NoError( + t, + negotiator.NegotiateChannel( + t.Context(), record.Snapshot.Terms.ID, + record.Snapshot.Terms, *record.Snapshot.Source, + ), + ) + require.Equal(t, arkchannel.PhaseActive, hubSink.record.Snapshot.Phase) + require.Equal( + t, arkchannel.PhaseActive, clientSink.record.Snapshot.Phase, + ) + hubChannel := awaitLink(t, hub) + clientChannel := awaitLink(t, client) + require.Equal( + t, hubChannel.FundingOutpoint, clientChannel.FundingOutpoint, + ) + require.False(t, hubChannel.IsPending) + require.False(t, clientChannel.IsPending) + + return activeFundingFlow{ + hubChannel: hubChannel, + clientChannel: clientChannel, + hubSink: hubSink, + clientSink: clientSink, + } +} + +// cooperativelyCloseFundingFlowChannel drives lnd's native cooperative-close +// FSM at both endpoints over the same logical peer boundary used for funding. +func cooperativelyCloseFundingFlowChannel(t *testing.T, alice, + bob *fundingFlowNode, channelPoint wire.OutPoint) *wire.MsgTx { + + t.Helper() + + aliceBroadcast := make(chan *wire.MsgTx, 1) + bobBroadcast := make(chan *wire.MsgTx, 1) + feeRate := chainfee.SatPerKWeight(1_000) + aliceCloser, err := alice.runtime.NewCooperativeClose( + CooperativeCloseRequest{ + ChannelPoint: channelPoint, + DeliveryAddress: testCloseDeliveryAddress(alice.key), + IdealFeeRate: feeRate, + Closer: lntypes.Local, + BroadcastTx: func(tx *wire.MsgTx, _ string) error { + aliceBroadcast <- tx.Copy() + + return nil + }, + }, + ) + require.NoError(t, err) + bobCloser, err := bob.runtime.NewCooperativeClose( + CooperativeCloseRequest{ + ChannelPoint: channelPoint, + DeliveryAddress: testCloseDeliveryAddress(bob.key), + IdealFeeRate: feeRate, + Closer: lntypes.Remote, + BroadcastTx: func(tx *wire.MsgTx, _ string) error { + bobBroadcast <- tx.Copy() + + return nil + }, + }, + ) + require.NoError(t, err) + + shutdown, err := aliceCloser.ShutdownChan() + require.NoError(t, err) + bobShutdown, err := bobCloser.ReceiveShutdown(*shutdown) + require.NoError(t, err) + bobOffer, err := bobCloser.BeginNegotiation() + require.NoError(t, err) + require.True(t, bobOffer.IsNone()) + + _, err = aliceCloser.ReceiveShutdown(bobShutdown.UnwrapOrFail(t)) + require.NoError(t, err) + aliceOffer, err := aliceCloser.BeginNegotiation() + require.NoError(t, err) + require.True(t, aliceOffer.IsSome()) + + message := aliceOffer.UnwrapOrFail(t) + fromAlice := true + for i := 0; i < 10; i++ { + if fromAlice { + next, err := bobCloser.ReceiveClosingSigned(message) + require.NoError(t, err) + if next.IsNone() { + break + } + message = next.UnwrapOrFail(t) + } else { + next, err := aliceCloser.ReceiveClosingSigned(message) + require.NoError(t, err) + if next.IsNone() { + break + } + message = next.UnwrapOrFail(t) + } + + fromAlice = !fromAlice + } + + aliceTx, err := aliceCloser.ClosingTx() + require.NoError(t, err) + bobTx, err := bobCloser.ClosingTx() + require.NoError(t, err) + require.Equal(t, aliceTx.TxHash(), bobTx.TxHash()) + require.Equal(t, aliceTx.TxHash(), (<-aliceBroadcast).TxHash()) + require.Equal(t, bobTx.TxHash(), (<-bobBroadcast).TxHash()) + + return aliceTx +} + +// testCloseDeliveryAddress returns a valid P2WPKH script for co-op close. +func testCloseDeliveryAddress( + key *btcec.PrivateKey) chancloser.DeliveryAddrWithKey { + + keyHash := address.Hash160(key.PubKey().SerializeCompressed()) + pkScript := append([]byte{0x00, 0x14}, keyHash...) + + return chancloser.DeliveryAddrWithKey{ + DeliveryAddress: lnwire.DeliveryAddress(pkScript), + } +} + +// TestNativeFundingCancellationAfterFinalization proves a prepared OOR +// transfer can abort after both lnd databases persist the channel but before +// Ark commits it. +func TestNativeFundingCancellationAfterFinalization(t *testing.T) { + t.Parallel() + + alice := newFundingFlowNode(t, arkchannel.PartyHub) + bob := newFundingFlowNode(t, arkchannel.PartyClient) + connectFundingFlowNodes(t, alice, bob) + require.NoError(t, alice.runtime.Start()) + require.NoError(t, bob.runtime.Start()) + t.Cleanup(func() { + require.NoError(t, alice.runtime.Stop()) + require.NoError(t, bob.runtime.Stop()) + }) + + pendingID := lndfunding.PendingChanID{8, 6, 7, 5} + record := fundingIntentRecord(t, alice, bob, pendingID) + alice.intents.record = record + bob.intents.record = record + + flow, err := alice.runtime.Funding().OpenChannel(FundingOpenRequest{ + Peer: alice.peer, + PendingChannelID: pendingID, + Capacity: testFundingCapacity, + PushAmount: record.Snapshot.Terms.InitialPushAmount(), + }) + require.NoError(t, err) + packet := awaitFundingPSBT(t, flow) + funding, _ := completeChannelBacking( + t, packet, record, alice, bob, + ) + require.NoError(t, bob.runtime.Funding().RegisterBacking(funding)) + require.NoError( + t, alice.runtime.Funding().FinalizeBacking( + pendingID, packet, funding, + ), + ) + _ = awaitFinalized(t, alice, flow.Errors) + _ = awaitFinalized(t, bob, nil) + + channelPoint := wire.OutPoint{ + Hash: funding.Transaction.TxHash(), + Index: funding.OutputIndex, + } + for _, node := range []*fundingFlowNode{alice, bob} { + require.NoError( + t, node.runtime.Funding().CancelBacking( + pendingID, &channelPoint, + ), + ) + _, err := node.db.ChannelStateDB().FetchChannel(channelPoint) + require.ErrorIs(t, err, channeldb.ErrChannelNotFound) + _, err = node.db.ChannelStateDB().FetchClosedChannel( + &channelPoint, + ) + require.NoError(t, err) + require.NoError( + t, node.runtime.Funding().CancelBacking( + pendingID, &channelPoint, + ), + ) + require.Error( + t, node.runtime.Funding().ConfirmBacking( + channelPoint.Hash, + ), + ) + } +} + +// newFundingFlowNode constructs one runtime before its transport-backed peer +// is connected. +func newFundingFlowNode(t *testing.T, + localParty arkchannel.Party) *fundingFlowNode { + + t.Helper() + + nodeKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + db := channeldb.OpenForTesting(t, t.TempDir()) + t.Cleanup(func() { + require.NoError(t, db.Close()) + }) + node := &fundingFlowNode{ + key: nodeKey, + db: db, + finalized: make(chan fundingFinalization, 1), + links: make(chan *chanstate.OpenChannel, 1), + failures: make(chan error, 2), + intents: &staticIntentSource{}, + } + baseNotifier := newRuntimeNotifier(800_000) + node.notifier, err = NewVirtualFundingNotifier(baseNotifier) + require.NoError(t, err) + keyRing := &mock.SecretKeyRing{RootKey: nodeKey} + walletController := &mock.WalletController{RootKey: nodeKey} + signer := mock.NewSingleSigner(nodeKey) + intentAcceptor, err := NewIntentAcceptor(localParty, node.intents) + require.NoError(t, err) + node.runtime, err = NewRuntime(RuntimeConfig{ + DB: db, + Chain: fixedHeightChain{height: 800_000}, + Notifier: node.notifier, + OnionKey: &keychain.PrivKeyECDH{PrivKey: nodeKey}, + Signer: signer, + FeeEstimator: chainfee.NewStaticEstimator(1_250, 253), + WitnessBeacon: &runtimeWitnessBeacon{ + cache: db.NewWitnessCache(), + }, + SelfNode: route.NewVertex(nodeKey.PubKey()), + Funding: &FundingConfig{ + WalletController: walletController, + KeyRing: keyRing, + NetParams: &chaincfg.RegressionNetParams, + IdentityKey: keychain.KeyDescriptor{ + KeyLocator: keychain.KeyLocator{ + Family: keychain.KeyFamilyNodeKey, + }, + PubKey: nodeKey.PubKey(), + }, + ChannelAcceptor: intentAcceptor, + RoutingPolicy: models.ForwardingPolicy{ + MinHTLCOut: 1, + TimeLockDelta: 18, + }, + NotifyWhenOnline: func(_ [33]byte, + peerChan chan<- lnpeer.Peer) { + + peerChan <- node.peer + }, + WatchNewChannel: func(*chanstate.OpenChannel, + *btcec.PublicKey) error { + + return nil + }, + NotifyPendingOpen: func(channelPoint wire.OutPoint, + _ *chanstate.OpenChannel, _ *btcec.PublicKey) { + + node.finalized <- fundingFinalization{ + channelPoint: channelPoint, + } + }, + }, + }) + require.NoError(t, err) + + return node +} + +// connectFundingFlowNodes creates each endpoint's view of the single logical +// peer and installs newly opened channels into its native switch. +func connectFundingFlowNodes(t *testing.T, alice, bob *fundingFlowNode) { + t.Helper() + + alice.peer = newFundingFlowPeer(t, alice, bob) + bob.peer = newFundingFlowPeer(t, bob, alice) +} + +// newFundingFlowPeer adapts one side of the direct application transport. +func newFundingFlowPeer(t *testing.T, local, remote *fundingFlowNode) *Peer { + t.Helper() + + transport := newFundingFlowTransport(remote) + t.Cleanup(transport.Stop) + + var peer *Peer + peer, err := NewPeer(PeerConfig{ + RemoteKey: remote.key.PubKey(), + Transport: transport, + AddChannel: func(channel *lnpeer.NewChannel, + _ <-chan struct{}) error { + + _, err := local.runtime.AddLink( + channel.OpenChannel, + testLinkConfig(peer, local.failures), + ) + if err != nil { + return err + } + local.links <- channel.OpenChannel + + return nil + }, + }) + require.NoError(t, err) + + return peer +} + +// awaitFundingPSBT waits until lnd has negotiated both multisig keys and asks +// Ark to construct the external backing transaction. +func awaitFundingPSBT(t *testing.T, flow *FundingFlow) *psbt.Packet { + t.Helper() + + for { + select { + case update := <-flow.Updates: + psbtUpdate := update.GetPsbtFund() + if psbtUpdate == nil { + continue + } + packet, err := psbt.NewFromRawBytes( + bytes.NewReader(psbtUpdate.Psbt), false, + ) + require.NoError(t, err) + + return packet + + case err := <-flow.Errors: + require.NoError(t, err) + + case <-time.After(10 * time.Second): + t.Fatal("timeout waiting for lnd funding PSBT") + } + } +} + +// fundingIntentRecord binds the lnd node and materialization keys to one exact +// prepared OOR output. +func fundingIntentRecord(t *testing.T, hub, client *fundingFlowNode, + pendingID lndfunding.PendingChanID) arkchannel.Record { + + t.Helper() + + record, _ := testReceiveIntentRecord(t) + record.Snapshot.Terms.PendingChannelID = pendingID + record.Snapshot.Terms.Capacity = testFundingCapacity + record.Snapshot.Terms.HubNodeKey = compressedIntentKey(hub.key) + record.Snapshot.Terms.ClientNodeKey = compressedIntentKey(client.key) + record.Snapshot.Terms.VTXO.HubChannelKey = compressedIntentKey(hub.key) + record.Snapshot.Terms.VTXO.ClientChannelKey = compressedIntentKey( + client.key, + ) + record.Snapshot.Source = testIntentBinding( + t, record.Snapshot.Terms, testFundingCapacity+1_000, 1, + ) + + return record +} + +// completeChannelBacking has both parties validate their local lnd funding +// reservation, then signs the real channel-policy VTXO spend. +func completeChannelBacking(t *testing.T, packet *psbt.Packet, + record arkchannel.Record, hub, + client *fundingFlowNode) (VirtualFunding, arkchannel.Backing) { + + t.Helper() + terms := record.Snapshot.Terms + template, err := arkchannel.NewBackingTemplate( + packet, terms, *record.Snapshot.Source, + ) + require.NoError(t, err) + + for _, node := range []*fundingFlowNode{hub, client} { + expected, err := node.runtime.Funding().ExpectedFundingOutput( + terms.PendingChannelID, + ) + require.NoError(t, err) + require.NoError(t, template.ValidateFundingOutput(expected)) + } + + sign := func(party arkchannel.Party, + key *btcec.PrivateKey) input.Signature { + + desc, err := template.SignDescriptor( + terms, party, keychain.KeyDescriptor{ + PubKey: key.PubKey(), + }, + ) + require.NoError(t, err) + sig, err := input.NewMockSigner( + []*btcec.PrivateKey{key}, nil, + ).SignOutputRaw(template.Packet().UnsignedTx, desc) + require.NoError(t, err) + + return sig + } + backing, err := template.Complete( + terms, *record.Snapshot.Source, + sign(arkchannel.PartyClient, client.key), + sign(arkchannel.PartyHub, hub.key), + ) + require.NoError(t, err) + funding, err := virtualFundingFromBacking(terms, backing) + require.NoError(t, err) + + return funding, backing +} + +// awaitFinalized waits for lnd's commitment-signature safety barrier. +func awaitFinalized(t *testing.T, node *fundingFlowNode, + fundingErrors <-chan error) fundingFinalization { + + t.Helper() + + select { + case pendingID := <-node.finalized: + return pendingID + + case err := <-node.failures: + require.NoError(t, err) + + case err := <-fundingErrors: + require.NoError(t, err) + + case <-time.After(10 * time.Second): + t.Fatal("timeout waiting for lnd funding finalization") + } + + return fundingFinalization{} +} + +// awaitLink waits until funding.Manager hands the open channel to the peer. +func awaitLink(t *testing.T, node *fundingFlowNode) *chanstate.OpenChannel { + t.Helper() + + select { + case channel := <-node.links: + return channel + + case err := <-node.failures: + require.NoError(t, err) + + case <-time.After(10 * time.Second): + t.Fatal("timeout waiting for lnd channel link") + } + + return nil +} + +// payRuntimeInvoice sends a fixed one-hop payment through lnd's native +// control tower, switch, channel links, and invoice registry. +func payRuntimeInvoice(t *testing.T, payer, payee *fundingFlowNode, + amount lnwire.MilliSatoshi, preimage lntypes.Preimage) { + + t.Helper() + + _, err := payee.runtime.Invoices().AddInvoice( + t.Context(), &invoices.Invoice{ + CreationDate: time.Now(), + Terms: invoices.ContractTerm{ + FinalCltvDelta: 18, + Expiry: time.Hour, + PaymentPreimage: &preimage, + Value: amount, + Features: emptyFeatureVector(), + }, + }, preimage.Hash(), + ) + require.NoError(t, err) + + channels, err := payer.db.ChannelStateDB().FetchAllOpenChannels() + require.NoError(t, err) + require.Len(t, channels, 1) + scid := channels[0].ShortChanID().ToUint64() + paymentRoute := &route.Route{ + TotalTimeLock: 800_040, + TotalAmount: amount, + SourcePubKey: route.NewVertex(payer.key.PubKey()), + Hops: []*route.Hop{ + { + PubKeyBytes: route.NewVertex( + payee.key.PubKey(), + ), + ChannelID: scid, + OutgoingTimeLock: 800_040, + AmtToForward: amount, + LegacyPayload: true, + }, + }, + } + + result := make(chan error, 1) + go func() { + attempt, sendErr := payer.runtime.Payments().SendToOperator( + t.Context(), preimage.Hash(), paymentRoute, nil, + ) + if sendErr == nil && attempt.Settle == nil { + sendErr = fmt.Errorf("payment attempt did not settle") + } + result <- sendErr + }() + + select { + case err := <-result: + require.NoError(t, err) + + case err := <-payer.failures: + require.NoError(t, err) + + case err := <-payee.failures: + require.NoError(t, err) + + case <-time.After(10 * time.Second): + t.Fatal("native lnd channel payment did not complete") + } + + invoice, err := payee.runtime.Invoices().LookupInvoice( + t.Context(), preimage.Hash(), + ) + require.NoError(t, err) + require.Equal(t, invoices.ContractSettled, invoice.State) +} diff --git a/lnruntime/funding_test.go b/lnruntime/funding_test.go new file mode 100644 index 000000000..be015d4ae --- /dev/null +++ b/lnruntime/funding_test.go @@ -0,0 +1,127 @@ +package lnruntime + +import ( + "bytes" + "sync/atomic" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/chaincfg/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightningnetwork/lnd/chanacceptor" + "github.com/lightningnetwork/lnd/channeldb" + "github.com/lightningnetwork/lnd/chanstate" + "github.com/lightningnetwork/lnd/graph/db/models" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lnpeer" + "github.com/lightningnetwork/lnd/lntest/mock" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/lightningnetwork/lnd/routing/route" + "github.com/stretchr/testify/require" +) + +// externalWalletController records accidental ownership transfers while its +// embedded interface supplies methods unused by the lifecycle test. +type externalWalletController struct { + lnwallet.WalletController + + starts atomic.Int32 + stops atomic.Int32 +} + +// TestEmptyNodeAnnouncementSerializes verifies the private-only funding +// callback still returns a valid wire message without running a gossiper. +func TestEmptyNodeAnnouncementSerializes(t *testing.T) { + t.Parallel() + + announcement, err := emptyNodeAnnouncement() + require.NoError(t, err) + require.NotNil(t, announcement.Features) + + var buf bytes.Buffer + require.NoError(t, announcement.Encode(&buf, 0)) +} + +// Start records an unexpected start by lnd. +func (c *externalWalletController) Start() error { + c.starts.Add(1) + + return nil +} + +// Stop records an unexpected stop by lnd. +func (c *externalWalletController) Stop() error { + c.stops.Add(1) + + return nil +} + +// TestRuntimeStartsNativeFunding verifies Wavelength composes lnd's funding +// manager without transferring ownership of its already-running base wallet. +func TestRuntimeStartsNativeFunding(t *testing.T) { + t.Parallel() + + db := channeldb.OpenForTesting(t, t.TempDir()) + t.Cleanup(func() { + require.NoError(t, db.Close()) + }) + + nodeKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + keyRing := &mock.SecretKeyRing{RootKey: nodeKey} + controller := &externalWalletController{} + baseNotifier := newRuntimeNotifier(800_000) + notifier, err := NewVirtualFundingNotifier(baseNotifier) + require.NoError(t, err) + + runtime, err := NewRuntime(RuntimeConfig{ + DB: db, + Chain: fixedHeightChain{height: 800_000}, + Notifier: notifier, + OnionKey: &keychain.PrivKeyECDH{PrivKey: nodeKey}, + Signer: input.NewMockSigner( + []*btcec.PrivateKey{nodeKey}, + &chaincfg.RegressionNetParams, + ), + FeeEstimator: chainfee.NewStaticEstimator(1_250, 253), + WitnessBeacon: &runtimeWitnessBeacon{ + cache: db.NewWitnessCache(), + }, + SelfNode: route.NewVertex(nodeKey.PubKey()), + Funding: &FundingConfig{ + WalletController: controller, + KeyRing: keyRing, + NetParams: &chaincfg.RegressionNetParams, + IdentityKey: keychain.KeyDescriptor{ + KeyLocator: keychain.KeyLocator{ + Family: keychain.KeyFamilyNodeKey, + }, + PubKey: nodeKey.PubKey(), + }, + ChannelAcceptor: chanacceptor.NewChainedAcceptor(), + RoutingPolicy: models.ForwardingPolicy{ + MinHTLCOut: 1, + TimeLockDelta: 18, + }, + NotifyWhenOnline: func([33]byte, chan<- lnpeer.Peer) {}, + WatchNewChannel: func(*chanstate.OpenChannel, + *btcec.PublicKey) error { + + return nil + }, + NotifyPendingOpen: func(wire.OutPoint, + *chanstate.OpenChannel, *btcec.PublicKey) { + }, + }, + }) + require.NoError(t, err) + require.NotNil(t, runtime.Funding()) + require.NoError(t, runtime.Start()) + require.NoError(t, runtime.Stop()) + require.Zero(t, controller.starts.Load()) + require.Zero(t, controller.stops.Load()) +} + +var _ lnwallet.WalletController = (*externalWalletController)(nil) diff --git a/lnruntime/funding_wire.go b/lnruntime/funding_wire.go new file mode 100644 index 000000000..cfc8fbcc0 --- /dev/null +++ b/lnruntime/funding_wire.go @@ -0,0 +1,610 @@ +package lnruntime + +import ( + "bytes" + "context" + "crypto/sha256" + "errors" + "fmt" + "sync" + + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/psbt/v2" + "github.com/lightninglabs/wavelength/arkchannel" + "github.com/lightninglabs/wavelength/rpc/arkchannelrpc" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/lnwire" + "google.golang.org/protobuf/proto" +) + +const ( + fundingWireMessageType = lnwire.MessageType(42069) + + fundingWireRequest = arkchannelrpc. + FundingWireKind_FUNDING_WIRE_KIND_REQUEST + fundingWireResponse = arkchannelrpc. + FundingWireKind_FUNDING_WIRE_KIND_RESPONSE + // The generated enum identifiers cannot be wrapped as Go selectors. + fundingWireSignBacking = arkchannelrpc.FundingWireMethod_FUNDING_WIRE_METHOD_SIGN_BACKING //nolint:ll + fundingWireInstallBacking = arkchannelrpc.FundingWireMethod_FUNDING_WIRE_METHOD_INSTALL_BACKING //nolint:ll + fundingWireFundingFinalized = arkchannelrpc.FundingWireMethod_FUNDING_WIRE_METHOD_FUNDING_FINALIZED //nolint:ll + fundingWireChannelActive = arkchannelrpc.FundingWireMethod_FUNDING_WIRE_METHOD_CHANNEL_ACTIVE //nolint:ll + fundingWireApplyChannelEvent = arkchannelrpc.FundingWireMethod_FUNDING_WIRE_METHOD_APPLY_CHANNEL_EVENT //nolint:ll +) + +var errFundingWireClosed = errors.New("funding wire is closed") + +// FundingWireServerConfig contains the client-local objects exposed to the +// hub's funding coordinator over the authenticated peer transport. +type FundingWireServerConfig struct { + Service *arkchannel.Service + Funding *NativeFundingEndpoint +} + +// FundingWire carries a small idempotent request/response protocol alongside +// BOLT funding messages. The application owns Ark coordination while lnd owns +// the ordinary channel-open stream. +type FundingWire struct { + peer *Peer + + mu sync.Mutex + server *FundingWireServerConfig + pending map[[32]byte]chan fundingWireResult + closed chan struct{} +} + +type fundingWireResult struct { + body []byte + err error +} + +// NewFundingWire constructs a reverse funding transport for one logical peer. +func NewFundingWire(peer *Peer) (*FundingWire, error) { + if peer == nil { + return nil, fmt.Errorf("funding wire peer is required") + } + + return &FundingWire{ + peer: peer, pending: make(map[[32]byte]chan fundingWireResult), + closed: make(chan struct{}), + }, nil +} + +// BindServer installs the client-side request handler exactly once. +func (w *FundingWire) BindServer(cfg FundingWireServerConfig) error { + if cfg.Service == nil || cfg.Funding == nil { + return fmt.Errorf("funding wire server is incomplete") + } + w.mu.Lock() + defer w.mu.Unlock() + if w.server != nil { + return fmt.Errorf("funding wire server is already bound") + } + w.server = &cfg + + return nil +} + +// Counterparty exposes the hub-side funding coordinator interface. +func (w *FundingWire) Counterparty() FundingCounterparty { + return &wireFundingCounterparty{wire: w} +} + +// Handles reports whether a custom message belongs to this protocol. +func (w *FundingWire) Handles(message lnwire.Message) bool { + custom, ok := message.(*lnwire.Custom) + + return ok && custom.Type == fundingWireMessageType +} + +// Handle processes one funding request or correlated response. +func (w *FundingWire) Handle(ctx context.Context, + message lnwire.Message) error { + + custom, ok := message.(*lnwire.Custom) + if !ok || custom.Type != fundingWireMessageType { + return fmt.Errorf("message is not an Ark funding wire message") + } + envelope := &arkchannelrpc.FundingWireEnvelope{} + if err := proto.Unmarshal(custom.Data, envelope); err != nil { + return fmt.Errorf("decode funding wire envelope: %w", err) + } + requestID, err := fundingWireRequestID(envelope.GetRequestId()) + if err != nil { + return err + } + + switch envelope.GetKind() { + case fundingWireResponse: + return w.deliverResponse(requestID, envelope) + + case fundingWireRequest: + return w.handleRequest(ctx, requestID, envelope) + + default: + return fmt.Errorf("unknown funding wire kind %d", + envelope.GetKind()) + } +} + +// Close releases callers waiting on a stopped endpoint. +func (w *FundingWire) Close() { + w.mu.Lock() + select { + case <-w.closed: + w.mu.Unlock() + + return + + default: + close(w.closed) + } + for id, response := range w.pending { + delete(w.pending, id) + response <- fundingWireResult{err: errFundingWireClosed} + } + w.mu.Unlock() +} + +func (w *FundingWire) call(ctx context.Context, + method arkchannelrpc.FundingWireMethod, + request, response proto.Message) error { + + body, err := proto.MarshalOptions{Deterministic: true}.Marshal(request) + if err != nil { + return err + } + requestID := sha256.Sum256(append( + []byte{byte(method)}, body..., + )) + result := make(chan fundingWireResult, 1) + w.mu.Lock() + select { + case <-w.closed: + w.mu.Unlock() + + return errFundingWireClosed + + default: + } + if _, ok := w.pending[requestID]; ok { + w.mu.Unlock() + + return fmt.Errorf("funding wire request is already pending") + } + w.pending[requestID] = result + w.mu.Unlock() + defer func() { + w.mu.Lock() + delete(w.pending, requestID) + w.mu.Unlock() + }() + + if err := w.send(&arkchannelrpc.FundingWireEnvelope{ + RequestId: requestID[:], + Kind: fundingWireRequest, + Method: method, Body: body, + }); err != nil { + return err + } + + select { + case result := <-result: + if result.err != nil { + return result.err + } + if err := proto.Unmarshal(result.body, response); err != nil { + return fmt.Errorf("decode funding wire response: %w", + err) + } + + return nil + + case <-ctx.Done(): + return ctx.Err() + + case <-w.closed: + return errFundingWireClosed + } +} + +func (w *FundingWire) handleRequest(ctx context.Context, requestID [32]byte, + envelope *arkchannelrpc.FundingWireEnvelope) error { + + w.mu.Lock() + server := w.server + w.mu.Unlock() + if server == nil { + return fmt.Errorf("funding wire request handler is unavailable") + } + body, handleErr := server.handle( + ctx, envelope.GetMethod(), envelope.GetBody(), + ) + response := &arkchannelrpc.FundingWireEnvelope{ + RequestId: requestID[:], + Kind: fundingWireResponse, + Method: envelope.GetMethod(), Body: body, + } + if handleErr != nil { + response.Error = handleErr.Error() + } + + return w.send(response) +} + +func (w *FundingWire) deliverResponse(requestID [32]byte, + envelope *arkchannelrpc.FundingWireEnvelope) error { + + w.mu.Lock() + result := w.pending[requestID] + w.mu.Unlock() + if result == nil { + + // A duplicate response can arrive after a caller completed. The + // original operation was idempotent, so it is safe to + // acknowledge. + return nil + } + response := fundingWireResult{ + body: append([]byte(nil), envelope.GetBody()...), + } + if envelope.GetError() != "" { + response.err = errors.New(envelope.GetError()) + } + select { + case result <- response: + default: + } + + return nil +} + +func (w *FundingWire) send(envelope *arkchannelrpc.FundingWireEnvelope) error { + payload, err := proto.MarshalOptions{ + Deterministic: true, + }.Marshal( + envelope, + ) + if err != nil { + return err + } + message, err := lnwire.NewCustom(fundingWireMessageType, payload) + if err != nil { + return err + } + + return w.peer.SendMessage(true, message) +} + +func (c *FundingWireServerConfig) handle(ctx context.Context, + method arkchannelrpc.FundingWireMethod, body []byte) ([]byte, error) { + + marshal := func(message proto.Message, err error) ([]byte, error) { + if err != nil { + return nil, err + } + + return proto.MarshalOptions{ + Deterministic: true, + }.Marshal( + message, + ) + } + + switch method { + case fundingWireSignBacking: + request := &arkchannelrpc.SignBackingRequest{} + if err := proto.Unmarshal(body, request); err != nil { + return nil, err + } + id, terms, binding, err := c.fundingRequest( + ctx, request.GetChannelId(), request.GetTerms(), + request.GetBinding(), + ) + if err != nil { + return nil, err + } + packet, err := psbt.NewFromRawBytes( + bytes.NewReader( + request.GetFundingPsbt(), + ), + false, + ) + if err != nil { + return nil, fmt.Errorf("decode lnd funding PSBT: %w", + err) + } + signature, err := c.Funding.SignBacking( + ctx, id, terms, binding, packet, + ) + + return marshal(&arkchannelrpc.SignBackingResponse{ + Signature: signatureBytes(signature, err), + }, err) + + case fundingWireInstallBacking: + request := &arkchannelrpc.InstallBackingRequest{} + if err := proto.Unmarshal(body, request); err != nil { + return nil, err + } + id, terms, binding, err := c.fundingRequest( + ctx, request.GetChannelId(), request.GetTerms(), + request.GetBinding(), + ) + if err != nil { + return nil, err + } + backing, err := channelBackingFromRPC(request.GetBacking()) + if err != nil { + return nil, err + } + err = c.Funding.InstallBacking( + ctx, id, terms, binding, backing, + ) + + return marshal(&arkchannelrpc.InstallBackingResponse{}, err) + + case fundingWireFundingFinalized, fundingWireChannelActive: + request := &arkchannelrpc.FundingStatusRequest{} + if err := proto.Unmarshal(body, request); err != nil { + return nil, err + } + terms, backing, err := c.fundingStatus(ctx, request) + if err != nil { + return nil, err + } + var ready bool + if method == fundingWireFundingFinalized { + ready, err = c.Funding.FundingFinalized( + ctx, terms, backing, + ) + } else { + ready, err = c.Funding.ChannelActive( + ctx, terms, backing, + ) + } + + return marshal(&arkchannelrpc.FundingStatusResponse{ + Ready: ready, + }, err) + + case fundingWireApplyChannelEvent: + request := &arkchannelrpc.ApplyChannelEventRequest{} + if err := proto.Unmarshal(body, request); err != nil { + return nil, err + } + id, err := rpcChannelID(request.GetChannelId()) + if err != nil { + return nil, err + } + if _, err := c.Service.GetChannel(ctx, id); err != nil { + return nil, err + } + event, err := channelEventFromRPC(request) + if err != nil { + return nil, err + } + var record arkchannel.Record + if _, ready := event.(*arkchannel.FundingPeerReady); ready { + record, err = c.Service.RecordChannelEvent( + ctx, id, event, + ) + } else { + record, err = c.Funding.ApplyChannelEvent( + ctx, id, event, + ) + } + if err != nil { + return nil, err + } + + return marshal(&arkchannelrpc.ApplyChannelEventResponse{ + Channel: ArkChannelRecordToRPC(record), + }, nil) + + default: + return nil, fmt.Errorf("unsupported funding wire method %d", + method) + } +} + +func (c *FundingWireServerConfig) fundingRequest(ctx context.Context, + rawID []byte, termsRPC *arkchannelrpc.ChannelTerms, + bindingRPC *arkchannelrpc.ChannelVTXOBinding) (arkchannel.ID, + arkchannel.Terms, arkchannel.VTXOBinding, error) { + + id, err := rpcChannelID(rawID) + if err != nil { + return arkchannel.ID{}, arkchannel.Terms{}, + arkchannel.VTXOBinding{}, err + } + terms, err := channelTermsFromRPC(termsRPC) + if err != nil { + return arkchannel.ID{}, arkchannel.Terms{}, + arkchannel.VTXOBinding{}, err + } + binding, err := channelBindingFromRPC(bindingRPC) + if err != nil { + return arkchannel.ID{}, arkchannel.Terms{}, + arkchannel.VTXOBinding{}, err + } + record, err := c.Service.GetChannel(ctx, id) + if err != nil { + return arkchannel.ID{}, arkchannel.Terms{}, + arkchannel.VTXOBinding{}, err + } + if id != terms.ID || record.Snapshot.Terms != terms || + record.Snapshot.Source == nil || + !sameFundingWireBinding(*record.Snapshot.Source, binding) { + return arkchannel.ID{}, arkchannel.Terms{}, + arkchannel.VTXOBinding{}, fmt.Errorf("funding " + + "request does not match channel FSM") + } + + return id, terms, binding, nil +} + +func (c *FundingWireServerConfig) fundingStatus(ctx context.Context, + request *arkchannelrpc.FundingStatusRequest) (arkchannel.Terms, + arkchannel.Backing, error) { + + terms, err := channelTermsFromRPC(request.GetTerms()) + if err != nil { + return arkchannel.Terms{}, arkchannel.Backing{}, err + } + backing, err := channelBackingFromRPC(request.GetBacking()) + if err != nil { + return arkchannel.Terms{}, arkchannel.Backing{}, err + } + record, err := c.Service.GetChannel(ctx, terms.ID) + if err != nil { + return arkchannel.Terms{}, arkchannel.Backing{}, err + } + if record.Snapshot.Terms != terms || record.Snapshot.Backing == nil || + record.Snapshot.Backing.ChannelPoint != backing.ChannelPoint || + !bytes.Equal( + record.Snapshot.Backing.Transaction, + backing.Transaction, + ) { + return arkchannel.Terms{}, arkchannel.Backing{}, fmt.Errorf( + "funding status does not match channel " + + "FSM") + } + + return terms, backing, nil +} + +func sameFundingWireBinding(a, b arkchannel.VTXOBinding) bool { + return a.OORSessionID == b.OORSessionID && a.OutPoint == b.OutPoint && + a.Amount == b.Amount && bytes.Equal( + a.ArkTransaction, b.ArkTransaction, + ) && bytes.Equal(a.PolicyTemplate, + b.PolicyTemplate) && bytes.Equal(a.PkScript, b.PkScript) +} + +func fundingWireRequestID(raw []byte) ([32]byte, error) { + var id [32]byte + if len(raw) != len(id) { + return id, fmt.Errorf("funding wire request ID must be 32 " + + "bytes") + } + copy(id[:], raw) + + return id, nil +} + +func signatureBytes(signature input.Signature, err error) []byte { + if err != nil || signature == nil { + return nil + } + + return signature.Serialize() +} + +type wireFundingCounterparty struct { + wire *FundingWire +} + +func (c *wireFundingCounterparty) SignBacking(ctx context.Context, + id arkchannel.ID, terms arkchannel.Terms, + binding arkchannel.VTXOBinding, packet *psbt.Packet) (input.Signature, + error) { + + if packet == nil { + return nil, fmt.Errorf("lnd funding PSBT is required") + } + var encoded bytes.Buffer + if err := packet.Serialize(&encoded); err != nil { + return nil, err + } + response := &arkchannelrpc.SignBackingResponse{} + err := c.wire.call( + ctx, + fundingWireSignBacking, + &arkchannelrpc.SignBackingRequest{ + ChannelId: id[:], Terms: channelTermsToRPC(terms), + Binding: channelBindingToRPC(binding), + FundingPsbt: encoded.Bytes(), + }, response, + ) + if err != nil { + return nil, err + } + + return schnorr.ParseSignature(response.GetSignature()) +} + +func (c *wireFundingCounterparty) InstallBacking(ctx context.Context, + id arkchannel.ID, terms arkchannel.Terms, + binding arkchannel.VTXOBinding, backing arkchannel.Backing) error { + + return c.wire.call( + ctx, + fundingWireInstallBacking, + &arkchannelrpc.InstallBackingRequest{ + ChannelId: id[:], Terms: channelTermsToRPC(terms), + Binding: channelBindingToRPC(binding), + Backing: channelBackingToRPC(backing), + }, &arkchannelrpc.InstallBackingResponse{}, + ) +} + +func (c *wireFundingCounterparty) FundingFinalized(ctx context.Context, + terms arkchannel.Terms, backing arkchannel.Backing) (bool, error) { + + return c.status( + ctx, + fundingWireFundingFinalized, + terms, backing, + ) +} + +func (c *wireFundingCounterparty) ChannelActive(ctx context.Context, + terms arkchannel.Terms, backing arkchannel.Backing) (bool, error) { + + return c.status( + ctx, + fundingWireChannelActive, + terms, backing, + ) +} + +func (c *wireFundingCounterparty) status(ctx context.Context, + method arkchannelrpc.FundingWireMethod, terms arkchannel.Terms, + backing arkchannel.Backing) (bool, error) { + + response := &arkchannelrpc.FundingStatusResponse{} + err := c.wire.call( + ctx, method, fundingStatusRequest(terms, backing), response, + ) + if err != nil { + return false, err + } + + return response.GetReady(), nil +} + +func (c *wireFundingCounterparty) ApplyChannelEvent(ctx context.Context, + id arkchannel.ID, event arkchannel.Event) (arkchannel.Record, error) { + + request, _, err := channelEventToRPC(id, event) + if err != nil { + return arkchannel.Record{}, err + } + response := &arkchannelrpc.ApplyChannelEventResponse{} + if err := c.wire.call( + ctx, fundingWireApplyChannelEvent, request, response, + ); err != nil { + return arkchannel.Record{}, err + } + if response.GetChannel() == nil { + return arkchannel.Record{}, fmt.Errorf("funding wire " + + "returned an empty channel") + } + + return arkchannel.Record{ + Revision: response.GetChannel().GetRevision(), + }, nil +} + +var _ FundingCounterparty = (*wireFundingCounterparty)(nil) diff --git a/lnruntime/intent_acceptor.go b/lnruntime/intent_acceptor.go new file mode 100644 index 000000000..b3ff58ab1 --- /dev/null +++ b/lnruntime/intent_acceptor.go @@ -0,0 +1,142 @@ +package lnruntime + +import ( + "bytes" + "context" + "fmt" + + "github.com/lightninglabs/wavelength/arkchannel" + "github.com/lightningnetwork/lnd/chanacceptor" + "github.com/lightningnetwork/lnd/lnwire" +) + +// ChannelIntentSource resolves the durable Ark intent named by lnd's pending +// channel ID. +type ChannelIntentSource interface { + FindByPendingChannelID(context.Context, + [32]byte) (arkchannel.Record, error) +} + +type channelAcceptResponse = chanacceptor.ChannelAcceptResponse + +// IntentAcceptor admits only inbound lnd funding messages authorized by a +// source-bound Ark channel intent. +type IntentAcceptor struct { + localParty arkchannel.Party + intents ChannelIntentSource +} + +// NewIntentAcceptor constructs an intent-backed native lnd channel acceptor. +func NewIntentAcceptor(localParty arkchannel.Party, + intents ChannelIntentSource) (*IntentAcceptor, error) { + + if localParty != arkchannel.PartyClient && + localParty != arkchannel.PartyHub { + return nil, fmt.Errorf("local channel party is required") + } + if intents == nil { + return nil, fmt.Errorf("channel intent source is required") + } + + return &IntentAcceptor{ + localParty: localParty, + intents: intents, + }, nil +} + +// Accept validates an incoming single-funder channel against the durable Ark +// intent before lnd allocates any responder state. +func (a *IntentAcceptor) Accept( + req *chanacceptor.ChannelAcceptRequest) *channelAcceptResponse { + + err := a.validate(req) + if err != nil { + return chanacceptor.NewChannelAcceptResponse( + false, err, nil, 0, 0, 0, 0, 0, 0, false, + ) + } + + return chanacceptor.NewChannelAcceptResponse( + true, nil, nil, 0, 0, 1, 0, 0, 0, false, + ) +} + +// validate checks the cross-system facts lnd cannot infer by itself. +func (a *IntentAcceptor) validate( + req *chanacceptor.ChannelAcceptRequest) error { + + _, err := a.terms(req) + + return err +} + +// terms validates an inbound request and returns its durable channel terms. +func (a *IntentAcceptor) terms(req *chanacceptor.ChannelAcceptRequest) ( + arkchannel.Terms, error) { + + if req == nil || req.Node == nil || req.OpenChanMsg == nil { + return arkchannel.Terms{}, fmt.Errorf("complete channel " + + "request is required") + } + open := req.OpenChanMsg + record, err := a.intents.FindByPendingChannelID( + context.Background(), open.PendingChannelID, + ) + if err != nil { + return arkchannel.Terms{}, fmt.Errorf("find channel intent: %w", + err) + } + snapshot := record.Snapshot + if snapshot.Source == nil { + return arkchannel.Terms{}, fmt.Errorf("channel intent has no " + + "bound VTXO") + } + if snapshot.Phase < arkchannel.PhaseNegotiating || + snapshot.Phase > arkchannel.PhaseBackingReady { + return arkchannel.Terms{}, fmt.Errorf("channel intent phase "+ + "%s cannot accept funding", snapshot.Phase) + } + terms := snapshot.Terms + if terms.FundingInitiator() == a.localParty { + return arkchannel.Terms{}, fmt.Errorf("local opener must " + + "initiate the channel") + } + + var expectedPeer [33]byte + switch terms.FundingInitiator() { + case arkchannel.PartyClient: + expectedPeer = terms.ClientNodeKey + + case arkchannel.PartyHub: + expectedPeer = terms.HubNodeKey + + default: + return arkchannel.Terms{}, fmt.Errorf("unknown channel "+ + "opener %d", terms.FundingInitiator()) + } + if !bytes.Equal( + req.Node.SerializeCompressed(), expectedPeer[:], + ) { + return arkchannel.Terms{}, fmt.Errorf("channel initiator " + + "does not match intent") + } + if open.FundingAmount != terms.Capacity { + return arkchannel.Terms{}, fmt.Errorf("channel capacity %d "+ + "does not match intent %d", open.FundingAmount, + terms.Capacity) + } + expectedPush := lnwire.NewMSatFromSatoshis(terms.InitialPushAmount()) + if open.PushAmount != expectedPush { + return arkchannel.Terms{}, fmt.Errorf("channel push amount %d "+ + "does not match intent %d", open.PushAmount, + expectedPush) + } + if open.ChannelFlags&lnwire.FFAnnounceChannel != 0 { + return arkchannel.Terms{}, fmt.Errorf("Ark channel must be " + + "private") + } + + return terms, nil +} + +var _ chanacceptor.ChannelAcceptor = (*IntentAcceptor)(nil) diff --git a/lnruntime/intent_acceptor_test.go b/lnruntime/intent_acceptor_test.go new file mode 100644 index 000000000..dd1947754 --- /dev/null +++ b/lnruntime/intent_acceptor_test.go @@ -0,0 +1,250 @@ +package lnruntime + +import ( + "bytes" + "context" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/wavelength/arkchannel" + "github.com/lightningnetwork/lnd/chanacceptor" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/stretchr/testify/require" +) + +// staticIntentSource returns one durable intent for acceptor tests. +type staticIntentSource struct { + record arkchannel.Record + err error +} + +// FindByPendingChannelID implements ChannelIntentSource. +func (s *staticIntentSource) FindByPendingChannelID(_ context.Context, + pendingID [32]byte) (arkchannel.Record, error) { + + if s.err != nil { + return arkchannel.Record{}, s.err + } + if s.record.Snapshot.Terms.PendingChannelID != pendingID { + return arkchannel.Record{}, arkchannel.ErrNotFound + } + + return s.record, nil +} + +// TestIntentAcceptorRejectsUnregisteredFunding verifies lnd cannot accept a +// channel by merely presenting plausible channel parameters. +func TestIntentAcceptorRejectsUnregisteredFunding(t *testing.T) { + t.Parallel() + + record, hubKey := testReceiveIntentRecord(t) + source := &staticIntentSource{record: record} + acceptor, err := NewIntentAcceptor(arkchannel.PartyClient, source) + require.NoError(t, err) + + validRequest := func() *chanacceptor.ChannelAcceptRequest { + return &chanacceptor.ChannelAcceptRequest{ + Node: hubKey, + OpenChanMsg: &lnwire.OpenChannel{ + PendingChannelID: record.Snapshot.Terms. + PendingChannelID, + FundingAmount: record.Snapshot.Terms.Capacity, + PushAmount: lnwire.NewMSatFromSatoshis( + record.Snapshot.Terms. + InitialPushAmount(), + ), + }, + } + } + + require.False(t, acceptor.Accept(validRequest()).RejectChannel()) + + tests := []struct { + name string + mutate func(*chanacceptor.ChannelAcceptRequest) + }{ + { + name: "unknown pending channel", + mutate: func(req *chanacceptor.ChannelAcceptRequest) { + req.OpenChanMsg.PendingChannelID[0] ^= 1 + }, + }, + { + name: "wrong initiator", + mutate: func(req *chanacceptor.ChannelAcceptRequest) { + key, keyErr := btcec.NewPrivateKey() + require.NoError(t, keyErr) + req.Node = key.PubKey() + }, + }, + { + name: "wrong capacity", + mutate: func(req *chanacceptor.ChannelAcceptRequest) { + req.OpenChanMsg.FundingAmount++ + }, + }, + { + name: "wrong initial push", + mutate: func(req *chanacceptor.ChannelAcceptRequest) { + req.OpenChanMsg.PushAmount++ + }, + }, + { + name: "public channel", + mutate: func(req *chanacceptor.ChannelAcceptRequest) { + req.OpenChanMsg.ChannelFlags = + lnwire.FFAnnounceChannel + }, + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + req := validRequest() + test.mutate(req) + + require.True(t, acceptor.Accept(req).RejectChannel()) + }) + } +} + +// TestIntentAcceptorRequiresBoundVTXO verifies registration alone cannot +// allocate lnd responder state before the prepared OOR transfer fixes the +// source output. +func TestIntentAcceptorRequiresBoundVTXO(t *testing.T) { + t.Parallel() + + record, hubKey := testReceiveIntentRecord(t) + record.Snapshot.Source = nil + record.Snapshot.Phase = arkchannel.PhaseRequested + acceptor, err := NewIntentAcceptor( + arkchannel.PartyClient, &staticIntentSource{ + record: record, + }, + ) + require.NoError(t, err) + pendingID := record.Snapshot.Terms.PendingChannelID + + response := acceptor.Accept(&chanacceptor.ChannelAcceptRequest{ + Node: hubKey, + OpenChanMsg: &lnwire.OpenChannel{ + PendingChannelID: pendingID, + FundingAmount: record.Snapshot.Terms.Capacity, + }, + }) + require.True(t, response.RejectChannel()) +} + +// testReceiveIntentRecord creates one source-bound hub-funded intent. +func testReceiveIntentRecord(t *testing.T) (arkchannel.Record, + *btcec.PublicKey) { + + t.Helper() + clientNode := testIntentKey(t) + hubNode := testIntentKey(t) + terms := arkchannel.Terms{ + ID: arkchannel.ID{ + 1, + }, + Kind: arkchannel.KindReceiveIntent, + Funder: arkchannel.PartyHub, + PendingChannelID: [32]byte{ + 2, + }, + ReservedSCID: lnwire.ShortChannelID{ + BlockHeight: 16_000_001, + TxIndex: 1, + }.ToUint64(), + Capacity: btcutil.Amount(100_000), + ClientNodeKey: compressedIntentKey(clientNode), + HubNodeKey: compressedIntentKey(hubNode), + PaymentHash: [32]byte{ + 3, + }, + VTXO: arkchannel.VTXOTerms{ + ClientArkKey: compressedIntentKey(testIntentKey(t)), + HubArkKey: compressedIntentKey(testIntentKey(t)), + ArkOperatorKey: compressedIntentKey(testIntentKey(t)), + ClientChannelKey: compressedIntentKey(testIntentKey(t)), + HubChannelKey: compressedIntentKey(testIntentKey(t)), + FunderKey: compressedIntentKey(testIntentKey(t)), + ChannelDelay: 144, + FunderDelay: 576, + MinExitDelay: 144, + }, + } + + return arkchannel.Record{ + Revision: 2, + Snapshot: arkchannel.Snapshot{ + Terms: terms, + Phase: arkchannel.PhaseNegotiating, + Source: testIntentBinding(t, terms, terms.Capacity, 0), + }, + }, hubNode.PubKey() +} + +// testIntentBinding creates a canonical unsigned Ark transaction containing +// the exact channel-policy output. +func testIntentBinding(t *testing.T, terms arkchannel.Terms, + amount btcutil.Amount, outputIndex uint32) *arkchannel.VTXOBinding { + + t.Helper() + policy, pkScript, err := terms.VTXO.Artifacts() + require.NoError(t, err) + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{Hash: chainhash.Hash{4, 2}}, + }) + for i := uint32(0); i < outputIndex; i++ { + tx.AddTxOut( + &wire.TxOut{ + Value: int64(i + 1), + PkScript: []byte{0x51}, + }, + ) + } + tx.AddTxOut(&wire.TxOut{ + Value: int64(amount), + PkScript: pkScript, + }) + var raw bytes.Buffer + require.NoError(t, tx.Serialize(&raw)) + sessionID := [32]byte(tx.TxHash()) + + return &arkchannel.VTXOBinding{ + OORSessionID: sessionID, + OutPoint: wire.OutPoint{ + Hash: tx.TxHash(), + Index: outputIndex, + }, + Amount: amount, + ArkTransaction: raw.Bytes(), + PolicyTemplate: policy, + PkScript: pkScript, + } +} + +// testIntentKey creates one policy or node key. +func testIntentKey(t *testing.T) *btcec.PrivateKey { + t.Helper() + + key, err := btcec.NewPrivateKey() + require.NoError(t, err) + + return key +} + +// compressedIntentKey converts one test key to the persisted representation. +func compressedIntentKey(key *btcec.PrivateKey) [33]byte { + var serialized [33]byte + copy(serialized[:], key.PubKey().SerializeCompressed()) + + return serialized +} + +var _ ChannelIntentSource = (*staticIntentSource)(nil) diff --git a/lnruntime/link_config.go b/lnruntime/link_config.go new file mode 100644 index 000000000..5683bdab1 --- /dev/null +++ b/lnruntime/link_config.go @@ -0,0 +1,51 @@ +package lnruntime + +import ( + "fmt" + + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightningnetwork/lnd/graph/db/models" + "github.com/lightningnetwork/lnd/htlcswitch" + "github.com/lightningnetwork/lnd/lnpeer" + "github.com/lightningnetwork/lnd/lnwire" +) + +// LinkFailureHandler receives a terminal native channel-link failure. +type LinkFailureHandler func(lnwire.ChannelID, lnwire.ShortChannelID, + htlcswitch.LinkFailureError) + +// NewOnchainLinkConfig returns lnd's real chain event and contract callbacks. +// An unpublished channel point remains dormant until Ark materializes it. +func (r *Runtime) NewOnchainLinkConfig(peer lnpeer.Peer, + channelPoint wire.OutPoint, onFailure LinkFailureHandler) (LinkConfig, + error) { + + if r.onchain == nil { + return LinkConfig{}, fmt.Errorf("on-chain lifecycle is " + + "disabled") + } + if onFailure == nil { + onFailure = func(lnwire.ChannelID, lnwire.ShortChannelID, + htlcswitch.LinkFailureError) { + } + } + events, updateSignals, notifyUpdate, err := r.onchain.LinkConfig( + channelPoint, + ) + if err != nil { + return LinkConfig{}, err + } + + return LinkConfig{ + Peer: peer, + Policy: models.ForwardingPolicy{ + MinHTLCOut: 1, TimeLockDelta: 18, + }, + ChainEvents: events, + SyncStates: true, + MaxAnchorFeeRate: 2_500, + OnChannelFailure: onFailure, + UpdateContractSignals: updateSignals, + NotifyContractUpdate: notifyUpdate, + }, nil +} diff --git a/lnruntime/mailbox_transport.go b/lnruntime/mailbox_transport.go new file mode 100644 index 000000000..73db16978 --- /dev/null +++ b/lnruntime/mailbox_transport.go @@ -0,0 +1,278 @@ +package lnruntime + +import ( + "bytes" + "context" + "fmt" + "sync" + + "github.com/google/uuid" + "github.com/lightninglabs/wavelength/baselib/actor" + mailboxpb "github.com/lightninglabs/wavelength/mailbox/pb" + mailboxrpc "github.com/lightninglabs/wavelength/mailbox/rpc" + "github.com/lightninglabs/wavelength/serverconn" + fn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/lnwire" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +const ( + // PeerMessageService is the mailbox route for native lnd peer traffic. + PeerMessageService = "lnruntime.v1.PeerService" + + // PeerMessageMethod is the mailbox method for one ordered wire message. + PeerMessageMethod = "Message" + + arkChannelClientMailboxPrefix = "ark-channel-client" + arkChannelHubMailboxPrefix = "ark-channel-hub" +) + +// ArkChannelClientMailboxID returns the isolated client reply mailbox for one +// authenticated Wavelength identity. +func ArkChannelClientMailboxID(clientIdentity string) string { + return serverconn.CompoundMailboxID( + arkChannelClientMailboxPrefix, clientIdentity, + ) +} + +// ArkChannelHubMailboxID returns the operator mailbox paired with one +// authenticated Wavelength identity. +func ArkChannelHubMailboxID(clientIdentity string) string { + return serverconn.CompoundMailboxID( + arkChannelHubMailboxPrefix, clientIdentity, + ) +} + +// PeerEvent is one native lnd wire message prepared for durable delivery. +type PeerEvent struct { + Identity string + CorrelationKey string + Payload []byte +} + +// PeerEventSender persists one peer event in the application's authenticated +// transport before returning. +type PeerEventSender interface { + SendPeerEvent(context.Context, PeerEvent) error +} + +// PeerEventHandler processes one decoded lnd wire message received from the +// authenticated peer. +type PeerEventHandler func(context.Context, lnwire.Message) error + +// DurablePeerTransportConfig configures ordered lnd traffic over a durable +// application transport. +type DurablePeerTransportConfig struct { + Sender PeerEventSender + CorrelationKey string + NewIdentity func() (string, error) +} + +// DurablePeerTransport serializes all messages for one logical lnd peer and +// persists each as a distinct mailbox event. +type DurablePeerTransport struct { + cfg DurablePeerTransportConfig + + mu sync.Mutex +} + +// NewDurablePeerTransport constructs an ordered transport for one logical +// peer. +func NewDurablePeerTransport(cfg DurablePeerTransportConfig) ( + *DurablePeerTransport, error) { + + if cfg.Sender == nil { + return nil, fmt.Errorf("peer event sender is required") + } + if cfg.CorrelationKey == "" { + return nil, fmt.Errorf("peer correlation key is required") + } + if cfg.NewIdentity == nil { + cfg.NewIdentity = newPeerEventIdentity + } + + return &DurablePeerTransport{cfg: cfg}, nil +} + +// SendMessages durably enqueues each lnd message in per-peer FIFO order. The +// transport always waits for durable admission, so the sync hint does not +// change its behavior. +func (t *DurablePeerTransport) SendMessages(_ bool, + messages ...lnwire.Message) error { + + t.mu.Lock() + defer t.mu.Unlock() + + for i, message := range messages { + if message == nil { + return fmt.Errorf("lnd peer message %d is nil", i) + } + + payload, err := MarshalPeerMessage(message) + if err != nil { + return fmt.Errorf("marshal lnd peer message %d: %w", i, + err) + } + identity, err := t.cfg.NewIdentity() + if err != nil { + return fmt.Errorf("allocate lnd peer message "+ + "identity: %w", err) + } + if identity == "" { + return fmt.Errorf("lnd peer message identity is empty") + } + + event := PeerEvent{ + Identity: identity, + CorrelationKey: t.cfg.CorrelationKey, + Payload: payload, + } + if err := t.cfg.Sender.SendPeerEvent( + context.Background(), event, + ); err != nil { + return fmt.Errorf("persist lnd peer message %d: %w", i, + err) + } + } + + return nil +} + +// MarshalPeerMessage encodes one lnd wire message including its BOLT message +// type. +func MarshalPeerMessage(message lnwire.Message) ([]byte, error) { + if message == nil { + return nil, fmt.Errorf("lnd peer message is nil") + } + + var payload bytes.Buffer + if _, err := lnwire.WriteMessage(&payload, message, 0); err != nil { + return nil, err + } + + return payload.Bytes(), nil +} + +// UnmarshalPeerMessage decodes exactly one lnd wire message and rejects +// trailing bytes so one mailbox event cannot smuggle an unordered batch. +func UnmarshalPeerMessage(payload []byte) (lnwire.Message, error) { + reader := bytes.NewReader(payload) + message, err := lnwire.ReadMessage(reader, 0) + if err != nil { + return nil, err + } + if reader.Len() != 0 { + return nil, fmt.Errorf("lnd peer message has %d trailing bytes", + reader.Len()) + } + + return message, nil +} + +// PeerMessageRoute returns the mailbox service and method used for native lnd +// peer traffic. +func PeerMessageRoute() mailboxrpc.ServiceMethod { + return mailboxrpc.ServiceMethod{ + Service: PeerMessageService, + Method: PeerMessageMethod, + } +} + +// NewPeerMessageDispatcher constructs a mailbox ingress route that decodes +// and processes one native lnd wire message before the envelope is acked. +func NewPeerMessageDispatcher( + handler PeerEventHandler) serverconn.EnvelopeDispatcher { + + return func(ctx context.Context, env *mailboxpb.Envelope) error { + if handler == nil { + return fmt.Errorf("lnd peer event handler is required") + } + if env == nil || env.Body == nil { + return fmt.Errorf("lnd peer event body is required") + } + + body := &wrapperspb.BytesValue{} + if err := env.Body.UnmarshalTo(body); err != nil { + return fmt.Errorf("decode lnd peer event body: %w", err) + } + message, err := UnmarshalPeerMessage(body.Value) + if err != nil { + return fmt.Errorf("decode lnd peer message: %w", err) + } + + return handler(ctx, message) + } +} + +// ServerConnPeerSender persists client-to-operator peer messages through the +// existing Wavelength server connection actor. +type ServerConnPeerSender struct { + server actor.TellOnlyRef[serverconn.ServerConnMsg] +} + +// NewServerConnPeerSender constructs a peer sender backed by serverconn. +func NewServerConnPeerSender( + server actor.TellOnlyRef[serverconn.ServerConnMsg]) ( + *ServerConnPeerSender, error) { + + if server == nil { + return nil, fmt.Errorf("server connection is required") + } + + return &ServerConnPeerSender{server: server}, nil +} + +// SendPeerEvent commits one native lnd event to serverconn's durable egress +// mailbox. +func (s *ServerConnPeerSender) SendPeerEvent(ctx context.Context, + event PeerEvent) error { + + message := &peerServerMessage{event: event} + + return s.server.Tell(ctx, &serverconn.SendClientEventRequest{ + Message: message, + MsgID: event.Identity, + IdempotencyKey: event.Identity, + }) +} + +// peerServerMessage adapts a peer event to serverconn's protobuf boundary. +type peerServerMessage struct { + event PeerEvent +} + +// ToProto wraps the ordinary BOLT bytes in a registered protobuf scalar. +func (m *peerServerMessage) ToProto() fn.Result[proto.Message] { + return fn.Ok[proto.Message](&wrapperspb.BytesValue{ + Value: append([]byte(nil), m.event.Payload...), + }) +} + +// ServiceMethod returns the native lnd peer mailbox route. +func (m *peerServerMessage) ServiceMethod() mailboxrpc.ServiceMethod { + return PeerMessageRoute() +} + +// CorrelationKey keeps every message for one logical peer in FIFO order. +func (m *peerServerMessage) CorrelationKey() string { + return m.event.CorrelationKey +} + +// newPeerEventIdentity returns a unique mailbox identity. It is deliberately +// independent of the body because identical BOLT messages can be distinct +// protocol events. +func newPeerEventIdentity() (string, error) { + id, err := uuid.NewV7() + if err != nil { + return "", err + } + + return id.String(), nil +} + +var _ MessageTransport = (*DurablePeerTransport)(nil) + +var _ PeerEventSender = (*ServerConnPeerSender)(nil) + +var _ serverconn.ServerMessage = (*peerServerMessage)(nil) diff --git a/lnruntime/mailbox_transport_test.go b/lnruntime/mailbox_transport_test.go new file mode 100644 index 000000000..214a3e369 --- /dev/null +++ b/lnruntime/mailbox_transport_test.go @@ -0,0 +1,179 @@ +package lnruntime + +import ( + "context" + "fmt" + "sync" + "testing" + + mailboxpb "github.com/lightninglabs/wavelength/mailbox/pb" + "github.com/lightninglabs/wavelength/serverconn" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/anypb" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +// recordingPeerEventSender captures durably admitted peer events. +type recordingPeerEventSender struct { + mu sync.Mutex + events []PeerEvent +} + +// SendPeerEvent records one event. +func (s *recordingPeerEventSender) SendPeerEvent(_ context.Context, + event PeerEvent) error { + + s.mu.Lock() + defer s.mu.Unlock() + + event.Payload = append([]byte(nil), event.Payload...) + s.events = append(s.events, event) + + return nil +} + +// recordingServerConnRef captures messages told to serverconn. +type recordingServerConnRef struct { + messages []serverconn.ServerConnMsg +} + +// ID returns the test actor identifier. +func (r *recordingServerConnRef) ID() string { + return "recording-serverconn" +} + +// Tell records one serverconn message. +func (r *recordingServerConnRef) Tell(_ context.Context, + message serverconn.ServerConnMsg) error { + + r.messages = append(r.messages, message) + + return nil +} + +// TryTell records one serverconn message without blocking. +func (r *recordingServerConnRef) TryTell(ctx context.Context, + message serverconn.ServerConnMsg) error { + + return r.Tell(ctx, message) +} + +// TestPeerMessageCodec verifies each mailbox payload contains exactly one +// ordinary lnd wire message. +func TestPeerMessageCodec(t *testing.T) { + t.Parallel() + + original := lnwire.NewPing(17) + payload, err := MarshalPeerMessage(original) + require.NoError(t, err) + + decoded, err := UnmarshalPeerMessage(payload) + require.NoError(t, err) + decodedPing, ok := decoded.(*lnwire.Ping) + require.True(t, ok) + require.Equal(t, original.NumPongBytes, decodedPing.NumPongBytes) + + _, err = UnmarshalPeerMessage(append(payload, 1)) + require.ErrorContains(t, err, "trailing bytes") +} + +// TestDurablePeerTransportPreservesDistinctEvents verifies identical BOLT +// messages retain distinct mailbox identities while sharing one FIFO lane. +func TestDurablePeerTransportPreservesDistinctEvents(t *testing.T) { + t.Parallel() + + sender := &recordingPeerEventSender{} + nextIdentity := 0 + transport, err := NewDurablePeerTransport( + DurablePeerTransportConfig{ + Sender: sender, + CorrelationKey: "lnpeer/operator", + NewIdentity: func() (string, error) { + nextIdentity++ + + identity := fmt.Sprintf("event-%d", + nextIdentity) + + return identity, nil + }, + }, + ) + require.NoError(t, err) + + message := lnwire.NewPing(9) + require.NoError(t, transport.SendMessages(false, message, message)) + require.Len(t, sender.events, 2) + require.Equal(t, "event-1", sender.events[0].Identity) + require.Equal(t, "event-2", sender.events[1].Identity) + require.Equal(t, "lnpeer/operator", + sender.events[0].CorrelationKey) + require.Equal(t, sender.events[0].Payload, sender.events[1].Payload) +} + +// TestServerConnPeerSender verifies peer identity, ordering, and payload are +// retained at Wavelength's durable client-to-operator boundary. +func TestServerConnPeerSender(t *testing.T) { + t.Parallel() + + serverRef := &recordingServerConnRef{} + sender, err := NewServerConnPeerSender(serverRef) + require.NoError(t, err) + + payload, err := MarshalPeerMessage(lnwire.NewPing(5)) + require.NoError(t, err) + event := PeerEvent{ + Identity: "peer-event-id", + CorrelationKey: "lnpeer/operator", + Payload: payload, + } + require.NoError(t, sender.SendPeerEvent(t.Context(), event)) + require.Len(t, serverRef.messages, 1) + + serverMessage := serverRef.messages[0] + request, ok := serverMessage.(*serverconn.SendClientEventRequest) + require.True(t, ok) + require.Equal(t, event.Identity, request.MsgID) + require.Equal(t, event.Identity, request.IdempotencyKey) + require.Equal(t, event.CorrelationKey, request.CorrelationKey()) + require.Equal(t, PeerMessageRoute(), request.Message.ServiceMethod()) + + message, err := request.Message.ToProto().Unpack() + require.NoError(t, err) + body, ok := message.(*wrapperspb.BytesValue) + require.True(t, ok) + require.Equal(t, payload, body.Value) +} + +// TestPeerMessageDispatcher verifies mailbox ingress decodes and handles the +// lnd message before returning. +func TestPeerMessageDispatcher(t *testing.T) { + t.Parallel() + + payload, err := MarshalPeerMessage(lnwire.NewPing(3)) + require.NoError(t, err) + body, err := anypb.New(&wrapperspb.BytesValue{Value: payload}) + require.NoError(t, err) + + var handled lnwire.Message + dispatch := NewPeerMessageDispatcher( + func(_ context.Context, message lnwire.Message) error { + handled = message + + return nil + }, + ) + require.NoError( + t, + dispatch( + t.Context(), &mailboxpb.Envelope{ + Body: body, + }, + ), + ) + handledPing, ok := handled.(*lnwire.Ping) + require.True(t, ok) + require.EqualValues(t, 3, handledPing.NumPongBytes) +} + +var _ PeerEventSender = (*recordingPeerEventSender)(nil) diff --git a/lnruntime/negotiator.go b/lnruntime/negotiator.go new file mode 100644 index 000000000..c96ca1a85 --- /dev/null +++ b/lnruntime/negotiator.go @@ -0,0 +1,670 @@ +package lnruntime + +import ( + "bytes" + "context" + "fmt" + "sync" + "time" + + "github.com/btcsuite/btcd/psbt/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/wavelength/arkchannel" + lndfunding "github.com/lightningnetwork/lnd/funding" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/keychain" +) + +const defaultFundingPollInterval = 25 * time.Millisecond + +// NativeFundingBackend is the narrow native lnd surface required by channel +// negotiation. It keeps the coordinator independent of lnd's internal wallet +// and funding-manager types. +type NativeFundingBackend interface { + OpenChannel(FundingOpenRequest) (*FundingFlow, error) + + ExpectedFundingOutput(lndfunding.PendingChanID) (*wire.TxOut, error) + + RegisterBacking(VirtualFunding) error + + FinalizeBacking(lndfunding.PendingChanID, *psbt.Packet, + VirtualFunding) error + + FundingFinalized(context.Context, arkchannel.Terms, + arkchannel.Backing) (bool, error) + + ChannelActive(context.Context, arkchannel.Terms, + arkchannel.Backing) (bool, error) + + CancelBacking(lndfunding.PendingChanID, *wire.OutPoint) error +} + +// FundingCounterparty is the application transport boundary used for the +// small amount of Ark-specific coordination outside the ordinary BOLT stream. +type FundingCounterparty interface { + SignBacking(context.Context, arkchannel.ID, arkchannel.Terms, + arkchannel.VTXOBinding, *psbt.Packet) (input.Signature, error) + + InstallBacking(context.Context, arkchannel.ID, arkchannel.Terms, + arkchannel.VTXOBinding, arkchannel.Backing) error + + FundingFinalized(context.Context, arkchannel.Terms, + arkchannel.Backing) (bool, error) + + ChannelActive(context.Context, arkchannel.Terms, + arkchannel.Backing) (bool, error) + + ApplyChannelEvent(context.Context, arkchannel.ID, + arkchannel.Event) (arkchannel.Record, error) +} + +// RecoveryCounterparty extends the funding transport with installation of the +// endpoint-neutral source package. Local lnd funding endpoints deliberately do +// not implement this application-level archive operation. +type RecoveryCounterparty interface { + FundingCounterparty + + InstallRecoveryPackage(context.Context, arkchannel.ID, arkchannel.Terms, + arkchannel.VTXOBinding, arkchannel.RecoveryPackage) error +} + +// RecoveryExportCounterparty exposes the funder's finalized source package to +// the lnd opener when Ark funding is owned by the remote endpoint. +type RecoveryExportCounterparty interface { + FundingCounterparty + + ExportRecoveryPackage(context.Context, + arkchannel.ID) (arkchannel.RecoveryPackage, error) +} + +// ChannelRecoveryManager exports a finalized funder package and installs the +// endpoint-local recovery-only descriptor, OOR artifacts, and ancestry +// watches. Installation must be idempotent for an identical package. +type ChannelRecoveryManager interface { + ExportRecoveryPackage(context.Context, arkchannel.ID, arkchannel.Terms, + arkchannel.VTXOBinding) (arkchannel.RecoveryPackage, error) + + InstallRecoveryPackage(context.Context, arkchannel.ID, arkchannel.Terms, + arkchannel.VTXOBinding, arkchannel.RecoveryPackage) error +} + +// NativeFundingEndpoint validates and signs one endpoint's view using its own +// lnd reservation and materialization key. +type NativeFundingEndpoint struct { + party arkchannel.Party + funding NativeFundingBackend + signer input.Signer + keyDesc keychain.KeyDescriptor + + mu sync.RWMutex + sink arkchannel.ChannelEventSink +} + +// NewNativeFundingEndpoint constructs one local or remotely adapted endpoint. +func NewNativeFundingEndpoint(party arkchannel.Party, + funding NativeFundingBackend, signer input.Signer, + keyDesc keychain.KeyDescriptor) (*NativeFundingEndpoint, error) { + + if party != arkchannel.PartyClient && party != arkchannel.PartyHub { + return nil, fmt.Errorf("channel funding party is required") + } + if funding == nil { + return nil, fmt.Errorf("native funding backend is required") + } + if signer == nil { + return nil, fmt.Errorf("channel backing signer is required") + } + if keyDesc.PubKey == nil { + return nil, fmt.Errorf("channel backing key is required") + } + + return &NativeFundingEndpoint{ + party: party, + funding: funding, + signer: signer, + keyDesc: keyDesc, + }, nil +} + +// BindChannelEventSink attaches the endpoint to its durable channel service. +func (e *NativeFundingEndpoint) BindChannelEventSink( + sink arkchannel.ChannelEventSink) error { + + if sink == nil { + return fmt.Errorf("channel event sink is required") + } + e.mu.Lock() + defer e.mu.Unlock() + if e.sink != nil { + return nil + } + e.sink = sink + + return nil +} + +// SignBacking independently reconstructs the backing template and verifies +// that it pays this endpoint's exact native lnd reservation before signing. +func (e *NativeFundingEndpoint) SignBacking(ctx context.Context, + id arkchannel.ID, terms arkchannel.Terms, source arkchannel.VTXOBinding, + packet *psbt.Packet) (input.Signature, error) { + + if err := validateFundingRequest(ctx, id, terms, source); err != nil { + return nil, err + } + packetCopy, err := cloneFundingPSBT(packet) + if err != nil { + return nil, err + } + template, err := arkchannel.NewBackingTemplate( + packetCopy, terms, source, + ) + if err != nil { + return nil, err + } + expected, err := e.funding.ExpectedFundingOutput( + terms.PendingChannelID, + ) + if err != nil { + return nil, err + } + if err := template.ValidateFundingOutput(expected); err != nil { + return nil, err + } + desc, err := template.SignDescriptor(terms, e.party, e.keyDesc) + if err != nil { + return nil, err + } + + return e.signer.SignOutputRaw(template.Packet().UnsignedTx, desc) +} + +// InstallBacking registers the immutable signed transaction before lnd starts +// watching it, then records the same fact in the durable channel FSM. +func (e *NativeFundingEndpoint) InstallBacking(ctx context.Context, + id arkchannel.ID, terms arkchannel.Terms, source arkchannel.VTXOBinding, + backing arkchannel.Backing) error { + + if err := validateFundingRequest(ctx, id, terms, source); err != nil { + return err + } + if err := backing.Validate(terms, source); err != nil { + return err + } + funding, err := virtualFundingFromBacking(terms, backing) + if err != nil { + return err + } + if err := e.funding.RegisterBacking(funding); err != nil { + return err + } + _, err = e.ApplyChannelEvent(ctx, id, &arkchannel.BackingSigned{ + Backing: backing, + }) + + return err +} + +// FundingFinalized reports the native lnd pending-open durability barrier. +func (e *NativeFundingEndpoint) FundingFinalized(ctx context.Context, + terms arkchannel.Terms, backing arkchannel.Backing) (bool, error) { + + return e.funding.FundingFinalized(ctx, terms, backing) +} + +// ChannelActive reports whether native lnd moved the exact channel out of its +// pending state after virtual confirmation. +func (e *NativeFundingEndpoint) ChannelActive(ctx context.Context, + terms arkchannel.Terms, backing arkchannel.Backing) (bool, error) { + + return e.funding.ChannelActive(ctx, terms, backing) +} + +// ApplyChannelEvent records a peer-observed fact in this endpoint's durable +// channel FSM. +func (e *NativeFundingEndpoint) ApplyChannelEvent(ctx context.Context, + id arkchannel.ID, event arkchannel.Event) (arkchannel.Record, error) { + + e.mu.RLock() + sink := e.sink + e.mu.RUnlock() + if sink == nil { + return arkchannel.Record{}, fmt.Errorf("channel event sink " + + "is not bound") + } + + return sink.Apply(ctx, id, event) +} + +// ChannelNegotiator coordinates only the cross-system funding barriers. lnd +// continues to own the BOLT funding exchange and both commitment states. +type ChannelNegotiator struct { + local *NativeFundingEndpoint + remote FundingCounterparty + peer *Peer + recovery ChannelRecoveryManager + + pollInterval time.Duration +} + +// NewChannelNegotiator constructs the funder-side coordinator. +func NewChannelNegotiator(local *NativeFundingEndpoint, + remote FundingCounterparty, peer *Peer, + recovery ChannelRecoveryManager) (*ChannelNegotiator, error) { + + if local == nil { + return nil, fmt.Errorf("local funding endpoint is required") + } + if remote == nil { + return nil, fmt.Errorf("remote funding endpoint is required") + } + if peer == nil { + return nil, fmt.Errorf("native lnd peer is required") + } + if recovery == nil { + return nil, fmt.Errorf("channel recovery manager is required") + } + + return &ChannelNegotiator{ + local: local, + remote: remote, + peer: peer, + recovery: recovery, + pollInterval: defaultFundingPollInterval, + }, nil +} + +// BindChannelEventSink attaches the local endpoint to its service. +func (n *ChannelNegotiator) BindChannelEventSink( + sink arkchannel.ChannelEventSink) error { + + if err := n.local.BindChannelEventSink(sink); err != nil { + return err + } + if binder, ok := n.recovery.(arkchannel.ChannelEventSinkBinder); ok { + return binder.BindChannelEventSink(sink) + } + + return nil +} + +// NegotiateChannel runs only on lnd's funding initiator. The Ark funder is also +// the lnd opener, so every channel begins with its entire spendable balance on +// the endpoint that supplied the prepared OOR transfer. +func (n *ChannelNegotiator) NegotiateChannel(ctx context.Context, + id arkchannel.ID, terms arkchannel.Terms, + source arkchannel.VTXOBinding) error { + + if err := validateFundingRequest(ctx, id, terms, source); err != nil { + return err + } + if terms.FundingInitiator() != n.local.party { + return nil + } + + flow, err := n.local.funding.OpenChannel(FundingOpenRequest{ + Peer: n.peer, + PendingChannelID: terms.PendingChannelID, + Capacity: terms.Capacity, + PushAmount: terms.InitialPushAmount(), + }) + if err != nil { + return err + } + basePacket, err := awaitNegotiatedPSBT(ctx, flow) + if err != nil { + return err + } + localPacket, err := cloneFundingPSBT(basePacket) + if err != nil { + return err + } + template, err := arkchannel.NewBackingTemplate( + localPacket, terms, source, + ) + if err != nil { + return err + } + expected, err := n.local.funding.ExpectedFundingOutput( + terms.PendingChannelID, + ) + if err != nil { + return err + } + if err := template.ValidateFundingOutput(expected); err != nil { + return err + } + localSig, err := n.local.SignBacking( + ctx, id, terms, source, basePacket, + ) + if err != nil { + return err + } + remoteSig, err := n.remote.SignBacking( + ctx, id, terms, source, basePacket, + ) + if err != nil { + return err + } + + var clientSig, hubSig input.Signature + if n.local.party == arkchannel.PartyClient { + clientSig, hubSig = localSig, remoteSig + } else { + clientSig, hubSig = remoteSig, localSig + } + backing, err := template.Complete( + terms, source, clientSig, hubSig, + ) + if err != nil { + return err + } + if err := n.remote.InstallBacking( + ctx, id, terms, source, backing, + ); err != nil { + return err + } + if err := n.local.InstallBacking( + ctx, id, terms, source, backing, + ); err != nil { + return err + } + funding, err := virtualFundingFromBacking(terms, backing) + if err != nil { + return err + } + if err := n.local.funding.FinalizeBacking( + terms.PendingChannelID, template.Packet(), funding, + ); err != nil { + return err + } + if err := n.waitForFundingFinalized(ctx, terms, backing); err != nil { + return err + } + + var localRecord arkchannel.Record + for _, party := range []arkchannel.Party{ + arkchannel.PartyClient, arkchannel.PartyHub, + } { + if _, err := n.remote.ApplyChannelEvent( + ctx, id, &arkchannel.FundingFinalized{ + Party: party, + }, + ); err != nil { + return err + } + localRecord, err = n.local.ApplyChannelEvent( + ctx, id, &arkchannel.FundingFinalized{ + Party: party, + }, + ) + if err != nil { + return err + } + } + if terms.Funder == n.local.party { + if !localRecord.Snapshot.OORFinalized { + return fmt.Errorf("funder OOR did not finalize after " + + "lnd safety barrier") + } + if _, err := n.remote.ApplyChannelEvent( + ctx, id, &arkchannel.OORFinalized{ + SessionID: source.OORSessionID, + }, + ); err != nil { + return err + } + } else { + // The remote FundingFinalized call above is a synchronous + // durable barrier. On the Ark funder it does not return until + // CommitOOR has completed and OORFinalized has been recorded in + // its channel FSM. + if _, err := n.local.ApplyChannelEvent( + ctx, id, &arkchannel.OORFinalized{ + SessionID: source.OORSessionID, + }, + ); err != nil { + return err + } + } + if err := n.waitForChannelActive(ctx, terms, backing); err != nil { + return err + } + activeEvent := &arkchannel.ChannelActive{ + ChannelPointHash: backing.ChannelPoint.Hash, + ChannelPointIndex: backing.ChannelPoint.Index, + } + if _, err := n.remote.ApplyChannelEvent( + ctx, id, activeEvent, + ); err != nil { + return err + } + _, err = n.local.ApplyChannelEvent(ctx, id, activeEvent) + + return err +} + +// PrepareChannelRecovery copies the finalized source package to both +// endpoints before recording the activation barrier. The lnd opener drives +// this exchange and fetches the package from the remote endpoint when the hub +// owns the OOR source. +func (n *ChannelNegotiator) PrepareChannelRecovery(ctx context.Context, + id arkchannel.ID, terms arkchannel.Terms, + source arkchannel.VTXOBinding) error { + + if err := validateFundingRequest(ctx, id, terms, source); err != nil { + return err + } + if terms.FundingInitiator() != n.local.party { + return nil + } + var recovery arkchannel.RecoveryPackage + var err error + if terms.Funder == n.local.party { + recovery, err = n.recovery.ExportRecoveryPackage( + ctx, id, terms, source, + ) + } else { + exporter, ok := n.remote.(RecoveryExportCounterparty) + if !ok { + return fmt.Errorf("remote channel recovery export is " + + "unavailable") + } + recovery, err = exporter.ExportRecoveryPackage(ctx, id) + } + if err != nil { + return fmt.Errorf("export channel recovery package: %w", err) + } + if err := n.recovery.InstallRecoveryPackage( + ctx, id, terms, source, recovery, + ); err != nil { + return fmt.Errorf("install local channel recovery: %w", err) + } + remote, ok := n.remote.(RecoveryCounterparty) + if !ok { + if terms.Funder == n.local.party { + + // Receive-channel recovery is fetched by the client + // over its existing authenticated control RPC. The hub + // installs its own package first, then waits for the + // client's common FSM event. + return nil + } + + return fmt.Errorf("remote channel recovery transport is " + + "unavailable") + } + if err := remote.InstallRecoveryPackage( + ctx, id, terms, source, recovery, + ); err != nil { + return fmt.Errorf("install remote channel recovery: %w", err) + } + event := &arkchannel.RecoveryPackageInstalled{} + if _, err := n.remote.ApplyChannelEvent(ctx, id, event); err != nil { + return err + } + _, err = n.local.ApplyChannelEvent(ctx, id, event) + + return err +} + +// CancelChannel removes this endpoint's native lnd reservation after its +// funder's prepared OOR session has durably aborted. +func (n *ChannelNegotiator) CancelChannel(ctx context.Context, id arkchannel.ID, + terms arkchannel.Terms, source arkchannel.VTXOBinding, + backing *arkchannel.Backing, reason string) error { + + if terms.Funder == n.local.party { + if _, err := n.remote.ApplyChannelEvent( + ctx, id, &arkchannel.Fail{ + Reason: reason, + }, + ); err != nil { + return fmt.Errorf("fail remote channel funding: %w", + err) + } + if _, err := n.remote.ApplyChannelEvent( + ctx, id, &arkchannel.OORAborted{ + SessionID: source.OORSessionID, + Reason: reason, + }, + ); err != nil { + return fmt.Errorf("confirm remote OOR abort: %w", err) + } + } + + var channelPoint *wire.OutPoint + if backing != nil { + channelPoint = &backing.ChannelPoint + } + if err := n.local.funding.CancelBacking( + terms.PendingChannelID, channelPoint, + ); err != nil { + return err + } + _, err := n.local.ApplyChannelEvent( + ctx, id, &arkchannel.FundingCanceled{}, + ) + + return err +} + +// waitForFundingFinalized polls the authoritative lnd databases rather than +// relying on an edge-triggered callback from either endpoint. +func (n *ChannelNegotiator) waitForFundingFinalized(ctx context.Context, + terms arkchannel.Terms, backing arkchannel.Backing) error { + + return n.waitForBoth(ctx, func(ctx context.Context, + endpoint FundingCounterparty) (bool, error) { + + return endpoint.FundingFinalized(ctx, terms, backing) + }, func(ctx context.Context) (bool, error) { + return n.local.FundingFinalized(ctx, terms, backing) + }) +} + +// waitForChannelActive waits until virtual confirmation moved both native lnd +// channel records out of pending state. +func (n *ChannelNegotiator) waitForChannelActive(ctx context.Context, + terms arkchannel.Terms, backing arkchannel.Backing) error { + + return n.waitForBoth(ctx, func(ctx context.Context, + endpoint FundingCounterparty) (bool, error) { + + return endpoint.ChannelActive(ctx, terms, backing) + }, func(ctx context.Context) (bool, error) { + return n.local.ChannelActive(ctx, terms, backing) + }) +} + +// waitForBoth evaluates local and remote durable facts until both hold. +func (n *ChannelNegotiator) waitForBoth(ctx context.Context, + remoteCheck func(context.Context, FundingCounterparty) (bool, error), + localCheck func(context.Context) (bool, error)) error { + + ticker := time.NewTicker(n.pollInterval) + defer ticker.Stop() + for { + localReady, err := localCheck(ctx) + if err != nil { + return err + } + remoteReady, err := remoteCheck(ctx, n.remote) + if err != nil { + return err + } + if localReady && remoteReady { + return nil + } + + select { + case <-ctx.Done(): + return ctx.Err() + + case <-ticker.C: + } + } +} + +// awaitNegotiatedPSBT waits for lnd to finish exchanging channel parameters. +func awaitNegotiatedPSBT(ctx context.Context, + flow *FundingFlow) (*psbt.Packet, error) { + + for { + select { + case update := <-flow.Updates: + psbtUpdate := update.GetPsbtFund() + if psbtUpdate == nil { + continue + } + + return psbt.NewFromRawBytes( + bytes.NewReader(psbtUpdate.Psbt), false, + ) + + case err := <-flow.Errors: + return nil, err + + case <-ctx.Done(): + return nil, ctx.Err() + } + } +} + +// cloneFundingPSBT isolates endpoint validation from caller mutation. +func cloneFundingPSBT(packet *psbt.Packet) (*psbt.Packet, error) { + if packet == nil { + return nil, fmt.Errorf("lnd funding PSBT is required") + } + var encoded bytes.Buffer + if err := packet.Serialize(&encoded); err != nil { + return nil, fmt.Errorf("serialize lnd funding PSBT: %w", err) + } + + return psbt.NewFromRawBytes(bytes.NewReader(encoded.Bytes()), false) +} + +// validateFundingRequest checks immutable cross-system facts before invoking +// either lnd or a remote endpoint. +func validateFundingRequest(ctx context.Context, id arkchannel.ID, + terms arkchannel.Terms, source arkchannel.VTXOBinding) error { + + select { + case <-ctx.Done(): + return ctx.Err() + + default: + } + if id != terms.ID { + return fmt.Errorf("channel ID does not match funding terms") + } + if err := terms.Validate(); err != nil { + return err + } + + return source.Validate(terms) +} + +var _ arkchannel.FundingNegotiator = (*ChannelNegotiator)(nil) +var _ arkchannel.ChannelEventSinkBinder = (*ChannelNegotiator)(nil) +var _ FundingCounterparty = (*NativeFundingEndpoint)(nil) diff --git a/lnruntime/node.go b/lnruntime/node.go new file mode 100644 index 000000000..f5c6be91d --- /dev/null +++ b/lnruntime/node.go @@ -0,0 +1,979 @@ +package lnruntime + +import ( + "context" + "crypto/rand" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chaincfg/v2" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/wavelength/arkchannel" + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/lightningnetwork/lnd/channeldb" + "github.com/lightningnetwork/lnd/chanstate" + "github.com/lightningnetwork/lnd/graph/db/models" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/invoices" + "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/kvdb" + "github.com/lightningnetwork/lnd/lnpeer" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/lightningnetwork/lnd/lnwire" + paymentsdb "github.com/lightningnetwork/lnd/payments/db" + "github.com/lightningnetwork/lnd/routing/route" +) + +const ( + channelDBFileName = "channel.db" + channelDBTimeout = 30 * time.Second + privatePaymentCLTVDelta = uint32(40) + maximumBlockHeight = ^uint32(0) +) + +// TerminalPaymentError means lnd's control tower has durably terminated a +// payment without a preimage. Retrying the same hash cannot create a new +// attempt, but a higher-level atomic bridge may still choose another rail. +type TerminalPaymentError struct { + Reason string +} + +// Error returns the terminal lnd payment reason. +func (e *TerminalPaymentError) Error() string { + if e == nil || e.Reason == "" { + return "native payment failed" + } + + return "native payment failed: " + e.Reason +} + +// PaymentNotStartedError means lnd rejected a route and its control tower +// confirms that no attempt owns the payment hash. A caller may safely choose +// another delivery rail because this node cannot reveal the preimage later. +type PaymentNotStartedError struct { + Err error +} + +// Error returns the underlying route admission failure. +func (e *PaymentNotStartedError) Error() string { + if e == nil || e.Err == nil { + return "native payment was not started" + } + + return "native payment was not started: " + e.Err.Error() +} + +// Unwrap exposes the underlying route admission failure. +func (e *PaymentNotStartedError) Unwrap() error { + if e == nil { + return nil + } + + return e.Err +} + +// NativeNodeConfig contains the process-owned dependencies for one modular +// lnd channel endpoint. +type NativeNodeConfig struct { + DataDir string + DB *channeldb.DB + + Party arkchannel.Party + Chain lnwallet.BlockChainIO + Notifier chainntnfs.ChainNotifier + WalletController lnwallet.WalletController + KeyRing keychain.SecretKeyRing + Signer input.Signer + FeeEstimator chainfee.Estimator + NetParams *chaincfg.Params + IdentityKey keychain.KeyDescriptor + BackingKey keychain.KeyDescriptor + RemoteNodeKey *btcec.PublicKey + Transport MessageTransport + Intents ChannelIntentSource + + OnChannelOpened func(*chanstate.OpenChannel) + OnChannelFailure LinkFailureHandler + ShouldWatchChannel func(wire.OutPoint) (bool, error) + ShouldDisableChannelAdds func(wire.OutPoint) (bool, error) + BeforeCommitmentPublish func(wire.OutPoint) error + RecordChannelFullyResolved func(wire.OutPoint) error +} + +// NativeNode owns one persistent channel database and the native lnd runtime, +// logical peer, and funding endpoint attached to it. +type NativeNode struct { + cfg NativeNodeConfig + + db *channeldb.DB + closeDB bool + notifier *VirtualFundingNotifier + runtime *Runtime + peer *Peer + fundingEndpoint *NativeFundingEndpoint + + mu sync.Mutex + started bool + stopped bool +} + +// NewNativeNode composes one endpoint without starting its lnd goroutines. +func NewNativeNode(cfg NativeNodeConfig) (*NativeNode, error) { + if err := validateNativeNodeConfig(cfg); err != nil { + return nil, err + } + + db := cfg.DB + closeDB := false + if db == nil { + var err error + db, err = openNativeChannelDB(cfg.DataDir) + if err != nil { + return nil, err + } + closeDB = true + } + + notifier, err := NewVirtualFundingNotifier(cfg.Notifier) + if err != nil { + if closeDB { + _ = db.Close() + } + + return nil, err + } + witnessBeacon, err := NewWitnessBeacon(db) + if err != nil { + if closeDB { + _ = db.Close() + } + + return nil, err + } + acceptor, err := NewIntentAcceptor(cfg.Party, cfg.Intents) + if err != nil { + if closeDB { + _ = db.Close() + } + + return nil, err + } + + var peer *Peer + runtime, err := NewRuntime(RuntimeConfig{ + DB: db, + Chain: cfg.Chain, + Notifier: notifier, + OnionKey: keychain.NewPubKeyECDH( + cfg.IdentityKey, cfg.KeyRing, + ), + Signer: cfg.Signer, + FeeEstimator: cfg.FeeEstimator, + WitnessBeacon: witnessBeacon, + SelfNode: route.NewVertex(cfg.IdentityKey.PubKey), + Funding: &FundingConfig{ + WalletController: cfg.WalletController, + KeyRing: cfg.KeyRing, + NetParams: cfg.NetParams, + IdentityKey: cfg.IdentityKey, + ChannelAcceptor: acceptor, + RoutingPolicy: models.ForwardingPolicy{ + MinHTLCOut: 1, + TimeLockDelta: 18, + }, + NotifyWhenOnline: func(remote [33]byte, + peerChan chan<- lnpeer.Peer) { + + if peer != nil && remote == peer.PubKey() { + peerChan <- peer + } + }, + WatchNewChannel: func(*chanstate.OpenChannel, + *btcec.PublicKey) error { + + return nil + }, + NotifyPendingOpen: func(wirePoint wire.OutPoint, + _ *chanstate.OpenChannel, _ *btcec.PublicKey) { + + _ = wirePoint + }, + }, + Onchain: &OnchainConfig{ + ShouldWatchChannel: cfg.ShouldWatchChannel, + BeforeCommitmentPublish: cfg.BeforeCommitmentPublish, + RecordFullyResolved: cfg.RecordChannelFullyResolved, + }, + }) + if err != nil { + if closeDB { + _ = db.Close() + } + + return nil, err + } + + peer, err = NewPeer(PeerConfig{ + RemoteKey: cfg.RemoteNodeKey, + Transport: cfg.Transport, + AddChannel: func(channel *lnpeer.NewChannel, + _ <-chan struct{}) error { + + if err := runtime.WatchChannel( + channel.OpenChannel, + ); err != nil { + return err + } + linkConfig, err := runtime.NewOnchainLinkConfig( + peer, channel.OpenChannel.FundingOutpoint, + cfg.OnChannelFailure, + ) + if err != nil { + return err + } + if _, err := runtime.AddLink( + channel.OpenChannel, linkConfig, + ); err != nil { + return err + } + if cfg.OnChannelOpened != nil { + cfg.OnChannelOpened(channel.OpenChannel) + } + + return nil + }, + }) + if err != nil { + if closeDB { + _ = db.Close() + } + + return nil, err + } + + fundingEndpoint, err := NewNativeFundingEndpoint( + cfg.Party, runtime.Funding(), cfg.Signer, cfg.BackingKey, + ) + if err != nil { + if closeDB { + _ = db.Close() + } + + return nil, err + } + + return &NativeNode{ + cfg: cfg, db: db, closeDB: closeDB, notifier: notifier, + runtime: runtime, peer: peer, + fundingEndpoint: fundingEndpoint, + }, nil +} + +// validateNativeNodeConfig rejects incomplete process composition before a +// channel database is opened. +func validateNativeNodeConfig(cfg NativeNodeConfig) error { + switch { + case cfg.Party != arkchannel.PartyClient && + cfg.Party != arkchannel.PartyHub: + return fmt.Errorf("channel party is required") + + case cfg.DB == nil && cfg.DataDir == "": + return fmt.Errorf("channel data directory is required") + + case cfg.Chain == nil: + return fmt.Errorf("channel chain IO is required") + + case cfg.Notifier == nil: + return fmt.Errorf("channel notifier is required") + + case cfg.WalletController == nil: + return fmt.Errorf("channel wallet controller is required") + + case cfg.KeyRing == nil: + return fmt.Errorf("channel key ring is required") + + case cfg.Signer == nil: + return fmt.Errorf("channel signer is required") + + case cfg.FeeEstimator == nil: + return fmt.Errorf("channel fee estimator is required") + + case cfg.NetParams == nil: + return fmt.Errorf("channel network is required") + + case cfg.IdentityKey.PubKey == nil: + return fmt.Errorf("channel identity key is required") + + case cfg.BackingKey.PubKey == nil: + return fmt.Errorf("channel backing key is required") + + case cfg.RemoteNodeKey == nil: + return fmt.Errorf("remote channel node key is required") + + case cfg.Transport == nil: + return fmt.Errorf("channel peer transport is required") + + case cfg.Intents == nil: + return fmt.Errorf("channel intent source is required") + + case cfg.ShouldWatchChannel == nil: + return fmt.Errorf("on-chain channel admission is required") + + case cfg.BeforeCommitmentPublish == nil: + return fmt.Errorf("commitment publication barrier is required") + + case cfg.RecordChannelFullyResolved == nil: + return fmt.Errorf("channel resolution recorder is required") + + default: + return nil + } +} + +// openNativeChannelDB opens the persistent lnd state owned by one Wavelength +// channel endpoint. +func openNativeChannelDB(dataDir string) (*channeldb.DB, error) { + if err := os.MkdirAll(dataDir, 0o700); err != nil { + return nil, fmt.Errorf("create channel data directory: %w", err) + } + backend, err := kvdb.GetBoltBackend(&kvdb.BoltBackendConfig{ + DBPath: filepath.Clean(dataDir), + DBFileName: channelDBFileName, + DBTimeout: channelDBTimeout, + }) + if err != nil { + return nil, fmt.Errorf("open channel database backend: %w", err) + } + db, err := channeldb.CreateWithBackend(backend) + if err != nil { + _ = backend.Close() + + return nil, fmt.Errorf("open channel database: %w", err) + } + + return db, nil +} + +// Start starts the native lnd channel and payment subsystems. +func (n *NativeNode) Start() error { + n.mu.Lock() + defer n.mu.Unlock() + if n.started { + return nil + } + if n.stopped { + return fmt.Errorf("native channel node already stopped") + } + if err := n.runtime.Start(); err != nil { + return err + } + _, err := n.runtime.RestorePeerLinks(n.peer, n.restoredLinkConfig) + if err != nil { + _ = n.runtime.Stop() + + return fmt.Errorf("restore native channel links: %w", err) + } + n.started = true + + return nil +} + +// restoredLinkConfig applies durable Ark admission and cooperative-close state +// whenever lnd reconstructs one persisted channel link. +func (n *NativeNode) restoredLinkConfig(state *chanstate.OpenChannel) ( + LinkConfig, error) { + + watch, err := n.cfg.ShouldWatchChannel(state.FundingOutpoint) + if err != nil { + return LinkConfig{}, err + } + if !watch { + return LinkConfig{}, fmt.Errorf("active channel %v is not "+ + "admitted to the on-chain lifecycle", + state.FundingOutpoint) + } + + linkConfig, err := n.runtime.NewOnchainLinkConfig( + n.peer, state.FundingOutpoint, n.cfg.OnChannelFailure, + ) + if err != nil { + return LinkConfig{}, err + } + if n.cfg.ShouldDisableChannelAdds != nil { + disabled, err := n.cfg.ShouldDisableChannelAdds( + state.FundingOutpoint, + ) + if err != nil { + return LinkConfig{}, err + } + linkConfig.AddsDisabled = disabled + } + + return linkConfig, nil +} + +// Stop stops native lnd state before closing a node-owned channel database. +func (n *NativeNode) Stop() error { + n.mu.Lock() + defer n.mu.Unlock() + if n.stopped { + return nil + } + n.stopped = true + + var stopErr error + if n.started { + stopErr = n.runtime.Stop() + } + if n.closeDB { + if err := n.db.Close(); stopErr == nil { + stopErr = err + } + } + + return stopErr +} + +// Runtime returns the native lnd subsystem composition. +func (n *NativeNode) Runtime() *Runtime { + return n.runtime +} + +// Peer returns the transport-backed logical lnd peer. +func (n *NativeNode) Peer() *Peer { + return n.peer +} + +// FundingEndpoint returns this process's validating funding counterparty. +func (n *NativeNode) FundingEndpoint() *NativeFundingEndpoint { + return n.fundingEndpoint +} + +// FundingActivator exposes synthetic confirmation for the signed backing. +func (n *NativeNode) FundingActivator() arkchannel.VirtualFundingActivator { + return n.runtime.Funding() +} + +// RestoreBacking registers one durable virtual funding transaction before the +// native funding manager restores pending channels from lnd's database. +func (n *NativeNode) RestoreBacking(terms arkchannel.Terms, + backing arkchannel.Backing) error { + + n.mu.Lock() + defer n.mu.Unlock() + if n.started { + return fmt.Errorf("cannot restore backing after native node " + + "start") + } + if n.stopped { + return fmt.Errorf("native channel node already stopped") + } + + return n.runtime.Funding().RestoreBacking(terms, backing) +} + +// HandoffChannel verifies lnd owns the chain lifecycle before Ark publishes +// one channel's backing transaction. +func (n *NativeNode) HandoffChannel(channelPoint wire.OutPoint) error { + return n.runtime.HandoffChannel(channelPoint) +} + +// ForceCloseChannel asks lnd to close and resolve one handed-off channel. +func (n *NativeNode) ForceCloseChannel(channelPoint wire.OutPoint) (*wire.MsgTx, + error) { + + return n.runtime.ForceCloseChannel(channelPoint) +} + +// WaitForceCloseResult returns the commitment transaction lnd durably +// classified after either endpoint won a force-close publication race. +func (n *NativeNode) WaitForceCloseResult(ctx context.Context, + channelPoint wire.OutPoint) (chainhash.Hash, error) { + + return n.runtime.WaitForceCloseResult(ctx, channelPoint) +} + +// ResumeForceCloseChannel reconciles an already materialized channel with +// lnd's durable commitment-broadcast state after restart. +func (n *NativeNode) ResumeForceCloseChannel(channelPoint wire.OutPoint) error { + return n.runtime.ResumeForceCloseChannel(channelPoint) +} + +// NewNegotiator constructs the funder-side coordinator for this node. +func (n *NativeNode) NewNegotiator(remote FundingCounterparty, + recovery ChannelRecoveryManager) (*ChannelNegotiator, error) { + + return NewChannelNegotiator( + n.fundingEndpoint, remote, n.peer, recovery, + ) +} + +// PeerMessageHandler returns the authenticated ingress for native BOLT +// messages. +func (n *NativeNode) PeerMessageHandler() PeerEventHandler { + return func(ctx context.Context, message lnwire.Message) error { + reestablish, ok := message.(*lnwire.ChannelReestablish) + if ok { + return n.runtime.HandleChannelReestablish( + reestablish, n.peer, n.restoredLinkConfig, + ) + } + + return n.runtime.HandlePeerMessage(ctx, message, n.peer) + } +} + +// AddInvoice creates a native lnd invoice and returns its preimage and hash. +func (n *NativeNode) AddInvoice(ctx context.Context, amount btcutil.Amount) ( + lntypes.Preimage, lntypes.Hash, error) { + + if amount <= 0 { + return lntypes.Preimage{}, lntypes.Hash{}, fmt.Errorf( + "invoice amount must be positive") + } + var preimage lntypes.Preimage + if _, err := rand.Read(preimage[:]); err != nil { + return lntypes.Preimage{}, lntypes.Hash{}, err + } + hash, err := n.AddInvoiceWithPreimage(ctx, amount, preimage, false) + if err != nil { + return lntypes.Preimage{}, lntypes.Hash{}, err + } + + return preimage, hash, nil +} + +// AddInvoiceWithPreimage adds or validates one deterministic native invoice. +// A retry never changes amount, preimage, or hold semantics. +func (n *NativeNode) AddInvoiceWithPreimage(ctx context.Context, + amount btcutil.Amount, preimage lntypes.Preimage, hold bool) ( + lntypes.Hash, error) { + + if amount <= 0 { + return lntypes.Hash{}, fmt.Errorf("invoice amount must be " + + "positive") + } + hash := preimage.Hash() + invoice := &invoices.Invoice{ + CreationDate: time.Now(), + HodlInvoice: hold, + Terms: invoices.ContractTerm{ + FinalCltvDelta: 18, + Expiry: time.Hour, + PaymentPreimage: &preimage, + Value: lnwire.NewMSatFromSatoshis( + amount, + ), + Features: emptyFeatureVector(), + }, + } + _, err := n.runtime.Invoices().AddInvoice(ctx, invoice, hash) + if errors.Is(err, invoices.ErrDuplicateInvoice) { + existing, lookupErr := n.runtime.Invoices().LookupInvoice( + ctx, hash, + ) + if lookupErr != nil { + return lntypes.Hash{}, lookupErr + } + if err := validateNativeInvoice( + existing, amount, hold, + ); err != nil { + return lntypes.Hash{}, err + } + + return hash, nil + } + if err != nil { + return lntypes.Hash{}, err + } + + return hash, nil +} + +// AddHoldInvoice adds or validates a deterministic hash-only hold invoice. +func (n *NativeNode) AddHoldInvoice(ctx context.Context, hash lntypes.Hash, + amount btcutil.Amount) error { + + if hash == (lntypes.Hash{}) { + return fmt.Errorf("payment hash is required") + } + if amount <= 0 { + return fmt.Errorf("invoice amount must be positive") + } + _, err := n.runtime.Invoices().AddInvoice(ctx, &invoices.Invoice{ + CreationDate: time.Now(), + HodlInvoice: true, + Terms: invoices.ContractTerm{ + FinalCltvDelta: 18, + Expiry: time.Hour, + Value: lnwire.NewMSatFromSatoshis( + amount, + ), + Features: emptyFeatureVector(), + }, + }, hash) + if errors.Is(err, invoices.ErrDuplicateInvoice) { + existing, lookupErr := n.runtime.Invoices().LookupInvoice( + ctx, hash, + ) + if lookupErr != nil { + return lookupErr + } + + return validateNativeInvoice(existing, amount, true) + } + + return err +} + +// validateNativeInvoice rejects hash reuse with different immutable terms. +func validateNativeInvoice(invoice invoices.Invoice, amount btcutil.Amount, + hold bool) error { + + if invoice.Terms.Value != lnwire.NewMSatFromSatoshis(amount) { + return fmt.Errorf("native invoice already has another amount") + } + if invoice.HodlInvoice != hold { + return fmt.Errorf("native invoice hold semantics changed") + } + if invoice.State == invoices.ContractCanceled { + return fmt.Errorf("native invoice is canceled") + } + + return nil +} + +// WaitInvoiceAccepted waits for a hold invoice to own at least one accepted +// HTLC. A settled invoice also satisfies this recovery barrier. +func (n *NativeNode) WaitInvoiceAccepted(ctx context.Context, + hash lntypes.Hash) error { + + return n.waitInvoiceState(ctx, hash, func(invoice *invoices.Invoice) ( + bool, error) { + + switch invoice.State { + case invoices.ContractAccepted, invoices.ContractSettled: + return true, nil + + case invoices.ContractCanceled: + return false, fmt.Errorf("native invoice was canceled") + + default: + return false, nil + } + }) +} + +// WaitInvoiceSettled waits until the native invoice registry has accepted a +// valid preimage for this hash. +func (n *NativeNode) WaitInvoiceSettled(ctx context.Context, + hash lntypes.Hash) error { + + return n.waitInvoiceState(ctx, hash, func(invoice *invoices.Invoice) ( + bool, error) { + + switch invoice.State { + case invoices.ContractSettled: + return true, nil + + case invoices.ContractCanceled: + return false, fmt.Errorf("native invoice was canceled") + + default: + return false, nil + } + }) +} + +// waitInvoiceState subscribes before waiting so no accepted or settled update +// can be lost between a lookup and subscription. +func (n *NativeNode) waitInvoiceState(ctx context.Context, hash lntypes.Hash, + terminal func(*invoices.Invoice) (bool, error)) error { + + subscription, err := n.runtime.Invoices().SubscribeSingleInvoice( + ctx, hash, + ) + if err != nil { + return err + } + defer subscription.Cancel() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + + case invoice, ok := <-subscription.Updates: + if !ok { + return fmt.Errorf("native invoice " + + "subscription closed") + } + done, err := terminal(invoice) + if err != nil { + return err + } + if done { + return nil + } + } + } +} + +// SettleHoldInvoice releases a native hold invoice with its matching preimage. +func (n *NativeNode) SettleHoldInvoice(ctx context.Context, + preimage lntypes.Preimage) error { + + err := n.runtime.Invoices().SettleHodlInvoice(ctx, preimage) + if errors.Is(err, invoices.ErrInvoiceAlreadySettled) { + return nil + } + + return err +} + +// CancelInvoice fails a native hold invoice while no preimage is known. +func (n *NativeNode) CancelInvoice(ctx context.Context, + hash lntypes.Hash) error { + + err := n.runtime.Invoices().CancelInvoice(ctx, hash) + if errors.Is(err, invoices.ErrInvoiceAlreadyCanceled) { + return nil + } + + return err +} + +// ChannelBalance returns the latest local and remote balances for an active +// native channel owned by this endpoint. +func (n *NativeNode) ChannelBalance(record arkchannel.Record) (btcutil.Amount, + btcutil.Amount, error) { + + if record.Snapshot.Phase != arkchannel.PhaseActive || + record.Snapshot.Backing == nil { + return 0, 0, fmt.Errorf("Ark channel is not active") + } + terms := record.Snapshot.Terms + if _, err := n.runtime.GetLink( + lnwire.NewShortChanIDFromInt(terms.ReservedSCID), + ); err != nil { + return 0, 0, fmt.Errorf("Ark channel link is inactive: %w", err) + } + channel, err := n.db.ChannelStateDB().FetchChannel( + record.Snapshot.Backing.ChannelPoint, + ) + if err != nil { + return 0, 0, err + } + if err := channel.Refresh(); err != nil { + return 0, 0, err + } + local, _, err := channel.LatestCommitments() + if err != nil { + return 0, 0, err + } + + return local.LocalBalance.ToSatoshis(), + local.RemoteBalance.ToSatoshis(), nil +} + +// PayInvoiceResult sends or resumes one fixed one-hop payment and returns the +// destination preimage recorded by lnd's control tower. +func (n *NativeNode) PayInvoiceResult(ctx context.Context, + record arkchannel.Record, hash lntypes.Hash, amount btcutil.Amount) ( + lntypes.Preimage, error) { + + preimage, found, err := n.existingPaymentResult(ctx, hash) + if err != nil { + return lntypes.Preimage{}, err + } + if found { + return preimage, nil + } + + attempt, err := n.sendInvoiceAttempt(ctx, record, hash, amount) + if err == nil && attempt.Settle != nil { + return attempt.Settle.Preimage, nil + } + if err == nil { + return lntypes.Preimage{}, fmt.Errorf("native lnd payment " + + "did not settle") + } + + // SendToRoute reports an already-known payment on replay. The control + // tower remains authoritative for whether that attempt settled. + preimage, found, lookupErr := n.existingPaymentResult(ctx, hash) + if lookupErr != nil { + return lntypes.Preimage{}, fmt.Errorf("determine native "+ + "payment state after dispatch: %w", lookupErr) + } + if found { + return preimage, nil + } + + return lntypes.Preimage{}, &PaymentNotStartedError{Err: err} +} + +// existingPaymentResult returns a terminal preimage or waits for an in-flight +// payment that survived process restart. +func (n *NativeNode) existingPaymentResult(ctx context.Context, + hash lntypes.Hash) (lntypes.Preimage, bool, error) { + + control := n.runtime.Payments().ControlTower() + payment, err := control.FetchPayment(ctx, hash) + if errors.Is(err, paymentsdb.ErrPaymentNotInitiated) { + return lntypes.Preimage{}, false, nil + } + if err != nil { + return lntypes.Preimage{}, false, err + } + if payment.Terminated() { + return terminalPaymentPreimage(payment) + } + subscriber, err := control.SubscribePayment(hash) + if err != nil { + return lntypes.Preimage{}, false, err + } + defer subscriber.Close() + + for { + select { + case <-ctx.Done(): + return lntypes.Preimage{}, false, ctx.Err() + + case update, ok := <-subscriber.Updates(): + if !ok { + return lntypes.Preimage{}, false, fmt.Errorf( + "native payment subscription " + + "closed") + } + payment, ok := update.(paymentsdb.DBMPPayment) + if !ok || !payment.Terminated() { + continue + } + + return terminalPaymentPreimage(payment) + } + } +} + +// terminalPaymentPreimage extracts the successful terminal attempt. +func terminalPaymentPreimage(payment paymentsdb.DBMPPayment) (lntypes.Preimage, + bool, error) { + + attempt, failure := payment.TerminalInfo() + if failure != nil { + return lntypes.Preimage{}, false, &TerminalPaymentError{ + Reason: failure.String(), + } + } + if attempt == nil || attempt.Settle == nil { + return lntypes.Preimage{}, false, fmt.Errorf("native payment " + + "terminated without preimage") + } + + return attempt.Settle.Preimage, true, nil +} + +// sendInvoiceAttempt constructs and dispatches one private one-hop route. +func (n *NativeNode) sendInvoiceAttempt(ctx context.Context, + record arkchannel.Record, hash lntypes.Hash, amount btcutil.Amount) ( + *paymentsdb.HTLCAttempt, error) { + + if record.Snapshot.Phase != arkchannel.PhaseActive || + record.Snapshot.Backing == nil { + return nil, fmt.Errorf("Ark channel is not active") + } + if amount <= 0 { + return nil, fmt.Errorf("payment amount must be positive") + } + _, height, err := n.cfg.Chain.GetBestBlock() + if err != nil { + return nil, fmt.Errorf("read payment height: %w", err) + } + if height < 0 { + return nil, fmt.Errorf("invalid payment height %d", height) + } + + terms := record.Snapshot.Terms + remoteNode := terms.HubNodeKey + if n.cfg.Party == arkchannel.PartyHub { + remoteNode = terms.ClientNodeKey + } + lockTime, err := arkChannelPaymentLockTime( + uint32(height), terms.VTXO, + ) + if err != nil { + return nil, err + } + msat := lnwire.NewMSatFromSatoshis(amount) + paymentRoute := &route.Route{ + TotalTimeLock: lockTime, + TotalAmount: msat, + SourcePubKey: route.NewVertex(n.cfg.IdentityKey.PubKey), + Hops: []*route.Hop{{ + PubKeyBytes: route.Vertex(remoteNode), + ChannelID: terms.ReservedSCID, + OutgoingTimeLock: lockTime, + AmtToForward: msat, + LegacyPayload: true, + }}, + } + + return n.runtime.Payments().SendToOperator( + ctx, hash, paymentRoute, nil, + ) +} + +// arkChannelPaymentLockTime keeps a private HTLC enforceable while the Ark +// source follows its non-interactive recovery path onto the chain. The funder +// delay contains both the channel materialization delay and its reaction +// window, so the ordinary Lightning margin starts after that entire horizon. +func arkChannelPaymentLockTime(height uint32, + terms arkchannel.VTXOTerms) (uint32, error) { + + if terms.FunderDelay < terms.ChannelDelay { + return 0, fmt.Errorf("channel funder delay %d is shorter than "+ + "materialization delay %d", terms.FunderDelay, + terms.ChannelDelay) + } + if terms.FunderDelay > maximumBlockHeight-privatePaymentCLTVDelta { + return 0, fmt.Errorf("channel payment CLTV delta overflows") + } + delta := terms.FunderDelay + privatePaymentCLTVDelta + if height > maximumBlockHeight-delta { + return 0, fmt.Errorf("channel payment locktime overflows") + } + + return height + delta, nil +} + +// PayInvoice sends one fixed one-hop payment over an active Ark channel. +func (n *NativeNode) PayInvoice(ctx context.Context, record arkchannel.Record, + hash lntypes.Hash, amount btcutil.Amount) error { + + _, err := n.PayInvoiceResult(ctx, record, hash, amount) + + return err +} + +// InvoiceSettled reports whether the native invoice registry has accepted the +// preimage for one payment hash. +func (n *NativeNode) InvoiceSettled(ctx context.Context, hash lntypes.Hash) ( + bool, error) { + + invoice, err := n.runtime.Invoices().LookupInvoice(ctx, hash) + if err != nil { + return false, err + } + + return invoice.State == invoices.ContractSettled, nil +} diff --git a/lnruntime/node_test.go b/lnruntime/node_test.go new file mode 100644 index 000000000..18ec4348f --- /dev/null +++ b/lnruntime/node_test.go @@ -0,0 +1,63 @@ +package lnruntime + +import ( + "testing" + + "github.com/lightninglabs/wavelength/arkchannel" + "github.com/stretchr/testify/require" +) + +// TestArkChannelPaymentLockTime proves an unmaterialized channel reserves the +// complete source-recovery horizon before the ordinary Lightning CLTV margin. +func TestArkChannelPaymentLockTime(t *testing.T) { + lockTime, err := arkChannelPaymentLockTime( + 100, arkchannel.VTXOTerms{ + ChannelDelay: 144, + FunderDelay: 576, + }, + ) + require.NoError(t, err) + require.Equal(t, uint32(716), lockTime) +} + +// TestArkChannelPaymentLockTimeRejectsInvalidTerms proves malformed delays and +// arithmetic overflow cannot silently shorten the recovery window. +func TestArkChannelPaymentLockTimeRejectsInvalidTerms(t *testing.T) { + testCases := []struct { + name string + height uint32 + terms arkchannel.VTXOTerms + }{ + { + name: "funder before channel", + terms: arkchannel.VTXOTerms{ + ChannelDelay: 144, + FunderDelay: 143, + }, + }, + { + name: "delta overflow", + terms: arkchannel.VTXOTerms{ + ChannelDelay: 1, + FunderDelay: maximumBlockHeight, + }, + }, + { + name: "height overflow", + height: maximumBlockHeight - 10, + terms: arkchannel.VTXOTerms{ + ChannelDelay: 1, + FunderDelay: 1, + }, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + _, err := arkChannelPaymentLockTime( + testCase.height, testCase.terms, + ) + require.Error(t, err) + }) + } +} diff --git a/lnruntime/onchain.go b/lnruntime/onchain.go new file mode 100644 index 000000000..7869c419d --- /dev/null +++ b/lnruntime/onchain.go @@ -0,0 +1,505 @@ +package lnruntime + +import ( + "errors" + "fmt" + "sync" + + "github.com/btcsuite/btcd/chaincfg/v2" + "github.com/btcsuite/btcd/txscript/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightningnetwork/lnd/chainio" + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/lightningnetwork/lnd/channeldb" + "github.com/lightningnetwork/lnd/chanstate" + "github.com/lightningnetwork/lnd/contractcourt" + fn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/graph/db/models" + "github.com/lightningnetwork/lnd/lncfg" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/lightningnetwork/lnd/sweep" +) + +type ( + sweepScriptGenerator = func() fn.Result[lnwallet.AddrWithKey] + optionalOutgoingResolution = fn.Option[lnwallet.OutgoingHtlcResolution] + optionalIncomingResolution = fn.Option[lnwallet.IncomingHtlcResolution] +) + +// OnchainConfig contains the application-owned admission and observation +// boundaries around lnd's standard channel resolution subsystems. +type OnchainConfig struct { + Mempool chainntnfs.MempoolWatcher + + ShouldWatchChannel func(wire.OutPoint) (bool, error) + BeforeCommitmentPublish func(wire.OutPoint) error + RecordFullyResolved func(wire.OutPoint) error +} + +// OnchainRuntime composes lnd's chain arbitrator, breach handling, nursery, +// sweeper, transaction publisher, and block dispatcher around the same channel +// database used by the off-chain runtime. +type OnchainRuntime struct { + chainArbitrator *contractcourt.ChainArbitrator + breachArbitrator *contractcourt.BreachArbitrator + nursery *contractcourt.UtxoNursery + sweeper *sweep.UtxoSweeper + txPublisher *sweep.TxPublisher + dispatcher *chainio.BlockbeatDispatcher + + chain lnwallet.BlockChainIO + quit chan struct{} + + mu sync.Mutex + started bool + stopped bool +} + +// newOnchainRuntime wires lnd's existing on-chain components to the modular +// runtime. The funding runtime supplies the LightningWallet used by the normal +// sweeper, avoiding a second wallet or a parallel output-resolution engine. +func newOnchainRuntime(runtime *Runtime, + cfg OnchainConfig) (*OnchainRuntime, error) { + + if runtime == nil || runtime.funding == nil || + runtime.funding.wallet == nil { + return nil, fmt.Errorf("on-chain lifecycle requires lnd " + + "funding wallet") + } + if cfg.ShouldWatchChannel == nil { + return nil, fmt.Errorf("on-chain channel admission is required") + } + if cfg.BeforeCommitmentPublish == nil { + return nil, fmt.Errorf("commitment publication barrier is " + + "required") + } + if cfg.RecordFullyResolved == nil { + return nil, fmt.Errorf("channel resolution recorder is " + + "required") + } + + stateDB := runtime.cfg.DB.ChannelStateDB() + chainHash := runtime.funding.netParams.GenesisHash + nurseryStore, err := contractcourt.NewNurseryStore( + chainHash, runtime.cfg.DB, + ) + if err != nil { + return nil, fmt.Errorf("create lnd nursery store: %w", err) + } + sweeperStore, err := sweep.NewSweeperStore( + runtime.cfg.DB.Backend, chainHash, + ) + if err != nil { + return nil, fmt.Errorf("create lnd sweeper store: %w", err) + } + + lightningWallet := runtime.funding.wallet + noAuxSweeper := fn.None[sweep.AuxSweeper]() + aggregator := sweep.NewBudgetAggregator( + runtime.cfg.FeeEstimator, sweep.DefaultMaxInputsPerTx, + noAuxSweeper, + ) + txPublisher := sweep.NewTxPublisher(sweep.TxPublisherConfig{ + Signer: runtime.cfg.Signer, Wallet: lightningWallet, + Estimator: runtime.cfg.FeeEstimator, + Notifier: runtime.cfg.Notifier, + AuxSweeper: noAuxSweeper, + }) + genSweepScript := newSweepScriptGenerator( + runtime.funding.wallet.WalletController, + runtime.funding.netParams, + ) + utxoSweeper := sweep.New(&sweep.UtxoSweeperConfig{ + FeeEstimator: runtime.cfg.FeeEstimator, + GenSweepScript: genSweepScript, + Signer: runtime.cfg.Signer, + Wallet: lightningWallet, + Mempool: cfg.Mempool, + Notifier: runtime.cfg.Notifier, + Store: sweeperStore, + MaxInputsPerTx: sweep.DefaultMaxInputsPerTx, + MaxFeeRate: sweep.DefaultMaxFeeRate, + Aggregator: aggregator, + Publisher: txPublisher, + NoDeadlineConfTarget: uint32( + sweep.DefaultDeadlineDelta, + ), + }) + budget := contractcourt.DefaultBudgetConfig() + nursery := contractcourt.NewUtxoNursery(&contractcourt.NurseryConfig{ + ChainIO: runtime.cfg.Chain, ConfDepth: 1, + FetchClosedChannels: stateDB.FetchClosedChannels, + FetchClosedChannel: stateDB.FetchClosedChannel, + Notifier: runtime.cfg.Notifier, + PublishTransaction: lightningWallet.PublishTransaction, + Store: nurseryStore, + SweepInput: utxoSweeper.SweepInput, + Budget: budget, + }) + + contractBreaches := make(chan *contractcourt.ContractBreachEvent, 1) + breachArbitrator := contractcourt.NewBreachArbitrator( + &contractcourt.BreachConfig{ + CloseLink: func(channelPoint *wire.OutPoint, + _ contractcourt.ChannelCloseType) { + + runtime.RemoveLink(*channelPoint) + }, + DB: stateDB, Estimator: runtime.cfg.FeeEstimator, + GenSweepScript: genSweepScript, + Notifier: runtime.cfg.Notifier, + PublishTransaction: lightningWallet. + PublishTransaction, + ContractBreaches: contractBreaches, + Signer: runtime.cfg.Signer, + Store: contractcourt.NewRetributionStore( + runtime.cfg.DB.Backend, + ), + AuxSweeper: noAuxSweeper, + }, + ) + + onchain := &OnchainRuntime{ + breachArbitrator: breachArbitrator, nursery: nursery, + sweeper: utxoSweeper, txPublisher: txPublisher, + dispatcher: chainio.NewBlockbeatDispatcher( + runtime.cfg.Notifier, + ), + chain: runtime.cfg.Chain, quit: make(chan struct{}), + } + onchain.chainArbitrator = newChainArbitrator( + runtime, cfg, onchain, lightningWallet, contractBreaches, + budget, genSweepScript, + ) + onchain.dispatcher.RegisterQueue([]chainio.Consumer{ + onchain.chainArbitrator, utxoSweeper, txPublisher, + }) + + return onchain, nil +} + +// newChainArbitrator composes lnd's contract owner around the already-created +// sweeper, nursery, and breach arbitrator. +func newChainArbitrator(runtime *Runtime, cfg OnchainConfig, + onchain *OnchainRuntime, lightningWallet *lnwallet.LightningWallet, + contractBreaches chan<- *contractcourt.ContractBreachEvent, + budget *contractcourt.BudgetConfig, + genSweepScript sweepScriptGenerator) *contractcourt.ChainArbitrator { + + stateDB := runtime.cfg.DB.ChannelStateDB() + noAuxLeafStore := fn.None[lnwallet.AuxLeafStore]() + noAuxSigner := fn.None[lnwallet.AuxSigner]() + noAuxResolver := fn.None[lnwallet.AuxContractResolver]() + noAuxCloser := fn.None[contractcourt.AuxChanCloser]() + noCloseConfs := fn.None[uint32]() + noCustomHtlcChecker := fn.None[contractcourt.CustomHtlcChecker]() + processResolution := runtime.switcher.ProcessContractResolution + + return contractcourt.NewChainArbitrator( + contractcourt.ChainArbitratorConfig{ + ChainHash: *runtime.funding.netParams.GenesisHash, + IncomingBroadcastDelta: lncfg. + DefaultIncomingBroadcastDelta, + OutgoingBroadcastDelta: lncfg. + DefaultOutgoingBroadcastDelta, + CustomHtlcChecker: noCustomHtlcChecker, + NewSweepAddr: func() ([]byte, error) { + addr, err := genSweepScript().Unpack() + if err != nil { + return nil, err + } + + return addr.DeliveryAddress, nil + }, + PublishTx: lightningWallet.PublishTransaction, + BeforeCommitmentPublish: cfg. + BeforeCommitmentPublish, + DeliverResolutionMsg: func( + messages ...contractcourt.ResolutionMsg) error { + + for _, message := range messages { + err := processResolution(message) + if err != nil { + return err + } + } + + return nil + }, + MarkLinkInactive: func( + channelPoint wire.OutPoint) error { + + runtime.RemoveLink(channelPoint) + + return nil + }, + ContractBreach: func(channelPoint wire.OutPoint, + retribution *lnwallet.BreachRetribution) error { + + return onchain.handOffBreach( + contractBreaches, channelPoint, + retribution, + ) + }, + IsOurAddress: lightningWallet.IsOurAddress, + IncubateOutputs: func(channelPoint wire.OutPoint, + outgoing optionalOutgoingResolution, + incoming optionalIncomingResolution, + broadcastHeight uint32, + deadline fn.Option[int32], + opts ...contractcourt.IncubateOption) error { + + return onchain.nursery.IncubateOutputs( + channelPoint, outgoing, incoming, + broadcastHeight, deadline, opts..., + ) + }, + PreimageDB: runtime.cfg.WitnessBeacon, + Notifier: runtime.cfg.Notifier, + Mempool: cfg.Mempool, + Signer: runtime.cfg.Signer, + FeeEstimator: runtime.cfg.FeeEstimator, + ChainIO: runtime.cfg.Chain, + DisableChannel: func(wire.OutPoint) error { + return nil + }, + Sweeper: onchain.sweeper, + Registry: runtime.invoices, + NotifyClosedChannel: func(wire.OutPoint) {}, + NotifyEarlyClosedChannel: func( + *channeldb.ChannelCloseSummary) { + }, + BeforeFullyResolvedChannel: cfg.RecordFullyResolved, + OnionProcessor: runtime.onionProcessor, + IsForwardedHTLC: runtime.switcher. + IsForwardedHTLC, + Clock: runtime.cfg.Clock, + SubscribeBreachComplete: onchain.breachArbitrator. + SubscribeBreachComplete, + PutFinalHtlcOutcome: stateDB.PutOnchainFinalHtlcOutcome, + HtlcNotifier: runtime.htlcNotifier, + Budget: *budget, + QueryIncomingCircuit: func( + key models.CircuitKey) *models.CircuitKey { + + circuit := runtime.switcher.CircuitLookup(). + LookupOpenCircuit(key) + if circuit == nil { + return nil + } + + return &circuit.Incoming + }, + AuxLeafStore: noAuxLeafStore, + AuxSigner: noAuxSigner, + AuxResolver: noAuxResolver, + AuxCloser: noAuxCloser, + ChannelCloseConfs: noCloseConfs, + ShouldWatchChannel: func( + channel *chanstate.OpenChannel) (bool, error) { + + return cfg.ShouldWatchChannel( + channel.FundingOutpoint, + ) + }, + }, runtime.cfg.DB, + ) +} + +// newSweepScriptGenerator derives one wallet-owned taproot output for lnd's +// sweeper and includes the internal key metadata used by auxiliary sweepers. +func newSweepScriptGenerator(wallet lnwallet.WalletController, + netParams *chaincfg.Params) sweepScriptGenerator { + + return func() fn.Result[lnwallet.AddrWithKey] { + addr, err := wallet.NewAddress( + lnwallet.TaprootPubkey, false, + lnwallet.DefaultAccountName, + ) + if err != nil { + return fn.Err[lnwallet.AddrWithKey](err) + } + pkScript, err := txscript.PayToAddrScript(addr) + if err != nil { + return fn.Err[lnwallet.AddrWithKey](err) + } + internalKey, err := lnwallet.InternalKeyForAddr( + wallet, netParams, pkScript, + ) + if err != nil { + return fn.Err[lnwallet.AddrWithKey](err) + } + + return fn.Ok(lnwallet.AddrWithKey{ + DeliveryAddress: pkScript, InternalKey: internalKey, + }) + } +} + +// handOffBreach waits until the breach arbitrator has durably accepted one +// retribution before allowing the channel arbitrator to advance. +func (o *OnchainRuntime) handOffBreach( + breaches chan<- *contractcourt.ContractBreachEvent, + channelPoint wire.OutPoint, + retribution *lnwallet.BreachRetribution) error { + + result := make(chan error, 1) + event := &contractcourt.ContractBreachEvent{ + ChanPoint: channelPoint, BreachRetribution: retribution, + ProcessACK: func(err error) { + result <- err + }, + } + select { + case breaches <- event: + case <-o.quit: + return fmt.Errorf("on-chain runtime stopped") + } + select { + case err := <-result: + return err + + case <-o.quit: + return fmt.Errorf("on-chain runtime stopped") + } +} + +// Start starts lnd's on-chain components and block dispatcher in dependency +// order after the HTLC switch is available for resolver messages. +func (o *OnchainRuntime) Start() error { + o.mu.Lock() + defer o.mu.Unlock() + if o.started { + return nil + } + if o.stopped { + return fmt.Errorf("on-chain runtime already stopped") + } + + blockHash, height, err := o.chain.GetBestBlock() + if err != nil { + return fmt.Errorf("read on-chain runtime tip: %w", err) + } + beat := chainio.NewBeat(chainntnfs.BlockEpoch{ + Hash: blockHash, Height: height, + }) + if err := o.txPublisher.Start(beat); err != nil { + return fmt.Errorf("start lnd transaction publisher: %w", err) + } + if err := o.sweeper.Start(beat); err != nil { + _ = o.txPublisher.Stop() + + return fmt.Errorf("start lnd sweeper: %w", err) + } + if err := o.nursery.Start(); err != nil { + _ = o.sweeper.Stop() + _ = o.txPublisher.Stop() + + return fmt.Errorf("start lnd nursery: %w", err) + } + if err := o.breachArbitrator.Start(); err != nil { + _ = o.nursery.Stop() + _ = o.sweeper.Stop() + _ = o.txPublisher.Stop() + + return fmt.Errorf("start lnd breach arbitrator: %w", err) + } + if err := o.chainArbitrator.Start(beat); err != nil { + _ = o.breachArbitrator.Stop() + _ = o.nursery.Stop() + _ = o.sweeper.Stop() + _ = o.txPublisher.Stop() + + return fmt.Errorf("start lnd chain arbitrator: %w", err) + } + if err := o.dispatcher.Start(); err != nil { + _ = o.chainArbitrator.Stop() + _ = o.breachArbitrator.Stop() + _ = o.nursery.Stop() + _ = o.sweeper.Stop() + _ = o.txPublisher.Stop() + + return fmt.Errorf("start lnd block dispatcher: %w", err) + } + + o.started = true + + return nil +} + +// Stop stops block delivery before shutting down lnd's resolution components +// in reverse dependency order. +func (o *OnchainRuntime) Stop() error { + o.mu.Lock() + defer o.mu.Unlock() + if o.stopped { + return nil + } + o.stopped = true + if !o.started { + return nil + } + + o.dispatcher.Stop() + close(o.quit) + + return errors.Join( + o.chainArbitrator.Stop(), o.breachArbitrator.Stop(), + o.nursery.Stop(), o.sweeper.Stop(), o.txPublisher.Stop(), + ) +} + +// WatchChannel admits one materializing channel to lnd's standard chain +// watcher and contract arbitrator. Repeated calls are idempotent. +func (o *OnchainRuntime) WatchChannel(channel *chanstate.OpenChannel) error { + if channel == nil { + return fmt.Errorf("open channel state is required") + } + + return o.chainArbitrator.WatchNewChannel(channel) +} + +// ForgetChannel retires the watcher and resolver state after an +// application-owned cooperative close is confirmed and archived in lnd. +func (o *OnchainRuntime) ForgetChannel(channelPoint wire.OutPoint) error { + return o.chainArbitrator.ResolveContract(channelPoint) +} + +// LinkConfig returns the real chain event and contract callbacks for a channel +// whose funding outpoint may still be unpublished. +func (o *OnchainRuntime) LinkConfig(channelPoint wire.OutPoint) ( + *contractcourt.ChainEventSubscription, + func(*contractcourt.ContractSignals) error, + func(*contractcourt.ContractUpdate) error, error) { + + events, err := o.chainArbitrator.SubscribeChannelEvents(channelPoint) + if err != nil { + return nil, nil, nil, err + } + + return events, func(signals *contractcourt.ContractSignals) error { + return o.chainArbitrator.UpdateContractSignals( + channelPoint, signals, + ) + }, func(update *contractcourt.ContractUpdate) error { + return o.chainArbitrator.NotifyContractUpdate( + channelPoint, update, + ) + }, nil +} + +// ForceClose asks lnd's channel arbitrator to publish the latest commitment +// and retain ownership of every resulting contract until fully resolved. +func (o *OnchainRuntime) ForceClose(channelPoint wire.OutPoint) (*wire.MsgTx, + error) { + + return o.chainArbitrator.ForceCloseContract(channelPoint) +} + +// ResumeForceClose retries a commitment publication that stopped at the Ark +// backing barrier and is idempotent after lnd has progressed beyond it. +func (o *OnchainRuntime) ResumeForceClose(channelPoint wire.OutPoint) ( + *wire.MsgTx, error) { + + return o.chainArbitrator.ResumeForceCloseContract(channelPoint) +} diff --git a/lnruntime/payments.go b/lnruntime/payments.go new file mode 100644 index 000000000..3ed6d995d --- /dev/null +++ b/lnruntime/payments.go @@ -0,0 +1,284 @@ +// Package lnruntime composes lnd's Lightning subsystems without starting an +// lnd daemon. +package lnruntime + +import ( + "context" + "errors" + "fmt" + "sync" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/lightningnetwork/lnd/channeldb" + "github.com/lightningnetwork/lnd/clock" + fn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/graph/db/models" + "github.com/lightningnetwork/lnd/htlcswitch" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/lightningnetwork/lnd/lnwire" + paymentsdb "github.com/lightningnetwork/lnd/payments/db" + "github.com/lightningnetwork/lnd/routing" + "github.com/lightningnetwork/lnd/routing/route" + "github.com/lightningnetwork/lnd/tlv" +) + +var ( + // ErrPathfindingDisabled is returned when a caller tries to obtain a + // route from the client runtime. Public routing belongs to the channel + // operator. + ErrPathfindingDisabled = errors.New("client pathfinding is disabled") + + // ErrOneHopRouteRequired is returned when a payment route does not + // point directly at the channel operator. + ErrOneHopRouteRequired = errors.New("operator route must contain one " + + "hop") +) + +// LinkLookup finds an active lnd channel link by its short channel ID. +type LinkLookup func(lnwire.ShortChannelID) (htlcswitch.ChannelLink, error) + +// FixedRoutePaymentsConfig contains the native lnd dependencies needed to run +// durable payments over the single client-to-operator channel leg. +type FixedRoutePaymentsConfig struct { + DB *channeldb.DB + Chain lnwallet.BlockChainIO + Payer routing.PaymentAttemptDispatcher + GetLink LinkLookup + SelfNode route.Vertex + Clock clock.Clock + ClosedSCIDs map[lnwire.ShortChannelID]struct{} + + // ApplyChannelUpdate lets an embedding runtime retain private policy + // updates learned from failures. A nil callback safely ignores them. + ApplyChannelUpdate func(*lnwire.ChannelUpdate1) bool + + KeepFailedPaymentAttempts bool +} + +// FixedRoutePayments owns lnd's normal payment control tower and payment +// lifecycle while intentionally omitting graph construction and pathfinding. +type FixedRoutePayments struct { + router *routing.ChannelRouter + control routing.ControlTower + missionController *routing.MissionController + + mu sync.Mutex + started bool + stopped bool +} + +// NewFixedRoutePayments composes lnd's existing payment components around the +// supplied channel switch or another PaymentAttemptDispatcher. +func NewFixedRoutePayments(cfg FixedRoutePaymentsConfig) (*FixedRoutePayments, + error) { + + if cfg.DB == nil { + return nil, fmt.Errorf("channel database is required") + } + if cfg.Chain == nil { + return nil, fmt.Errorf("chain backend is required") + } + if cfg.Payer == nil { + return nil, fmt.Errorf("payment dispatcher is required") + } + if cfg.GetLink == nil { + return nil, fmt.Errorf("channel link lookup is required") + } + + paymentDB, err := paymentsdb.NewKVStore(cfg.DB) + if err != nil { + return nil, fmt.Errorf("open lnd payment store: %w", err) + } + control := routing.NewControlTower(paymentDB) + + sequencer, err := htlcswitch.NewPersistentSequencer(cfg.DB) + if err != nil { + return nil, fmt.Errorf("open lnd payment sequencer: %w", err) + } + + estimator, err := routing.NewAprioriEstimator( + routing.DefaultAprioriConfig(), + ) + if err != nil { + return nil, fmt.Errorf("create lnd payment estimator: %w", err) + } + + noConfigUpdate := fn.None[func(*routing.MissionControlConfig)]() + missionController, err := routing.NewMissionController( + cfg.DB, cfg.SelfNode, &routing.MissionControlConfig{ + Estimator: estimator, + OnConfigUpdate: noConfigUpdate, + MaxMcHistory: routing.DefaultMaxMcHistory, + McFlushInterval: routing.DefaultMcFlushInterval, + MinFailureRelaxInterval: routing. + DefaultMinFailureRelaxInterval, + }, + ) + if err != nil { + return nil, fmt.Errorf("create lnd mission control: %w", err) + } + + missionControl, err := missionController.GetNamespacedStore( + routing.DefaultMissionControlNamespace, + ) + if err != nil { + return nil, fmt.Errorf("open lnd mission control: %w", err) + } + + runtimeClock := cfg.Clock + if runtimeClock == nil { + runtimeClock = clock.NewDefaultClock() + } + applyChannelUpdate := cfg.ApplyChannelUpdate + if applyChannelUpdate == nil { + applyChannelUpdate = func(*lnwire.ChannelUpdate1) bool { + return false + } + } + closedSCIDs := cfg.ClosedSCIDs + if closedSCIDs == nil { + closedSCIDs = make(map[lnwire.ShortChannelID]struct{}) + } + + noTrafficShaper := fn.None[htlcswitch.AuxTrafficShaper]() + router, err := routing.New(routing.Config{ + SelfNode: cfg.SelfNode, + Chain: cfg.Chain, + Payer: cfg.Payer, + Control: control, + MissionControl: missionControl, + SessionSource: fixedRouteSessionSource{}, + GetLink: func(scid lnwire.ShortChannelID) ( + htlcswitch.ChannelLink, error) { + + return cfg.GetLink(scid) + }, + NextPaymentID: sequencer.NextID, + Clock: runtimeClock, + ApplyChannelUpdate: applyChannelUpdate, + ClosedSCIDs: closedSCIDs, + TrafficShaper: noTrafficShaper, + KeepFailedPaymentAttempts: cfg.KeepFailedPaymentAttempts, + }) + if err != nil { + return nil, fmt.Errorf("create lnd payment lifecycle: %w", err) + } + + return &FixedRoutePayments{ + router: router, + control: control, + missionController: missionController, + }, nil +} + +// Start reloads in-flight attempts through lnd's normal payment lifecycle. +func (p *FixedRoutePayments) Start() error { + p.mu.Lock() + defer p.mu.Unlock() + + if p.started { + return nil + } + if p.stopped { + return fmt.Errorf("fixed-route payments already stopped") + } + + p.missionController.RunStoreTickers() + if err := p.router.Start(); err != nil { + p.missionController.StopStoreTickers() + + return fmt.Errorf("start lnd payment lifecycle: %w", err) + } + + p.started = true + + return nil +} + +// Stop shuts down payment result collectors before stopping mission-control +// persistence. +func (p *FixedRoutePayments) Stop() error { + p.mu.Lock() + defer p.mu.Unlock() + + if p.stopped { + return nil + } + p.stopped = true + + var stopErr error + if p.started { + stopErr = p.router.Stop() + } + p.missionController.StopStoreTickers() + + return stopErr +} + +// SendToOperator executes one payment attempt over the private operator leg +// and persists its result in lnd's control tower. +func (p *FixedRoutePayments) SendToOperator(ctx context.Context, + paymentHash lntypes.Hash, paymentRoute *route.Route, + firstHopRecords lnwire.CustomRecords) (*paymentsdb.HTLCAttempt, error) { + + if paymentRoute == nil || len(paymentRoute.Hops) != 1 { + return nil, ErrOneHopRouteRequired + } + + return p.router.SendToRoute( + ctx, paymentHash, paymentRoute, firstHopRecords, + ) +} + +// ControlTower exposes lnd's read and subscription interface for payment +// accounting without exposing the graph-capable channel router. +func (p *FixedRoutePayments) ControlTower() routing.ControlTower { + return p.control +} + +// fixedRouteSessionSource only supplies the empty session lnd needs when it +// resumes already-dispatched attempts after a restart. +type fixedRouteSessionSource struct{} + +// NewPaymentSession rejects graph-based payment requests. +func (fixedRouteSessionSource) NewPaymentSession(*routing.LightningPayment, + fn.Option[tlv.Blob], fn.Option[htlcswitch.AuxTrafficShaper]) ( + routing.PaymentSession, error) { + + return nil, ErrPathfindingDisabled +} + +// NewPaymentSessionEmpty returns a session that cannot create another shard. +func (fixedRouteSessionSource) NewPaymentSessionEmpty() routing.PaymentSession { + return emptyPaymentSession{} +} + +// emptyPaymentSession supports result collection for attempts lnd already +// persisted, but cannot select another route. +type emptyPaymentSession struct{} + +// RequestRoute rejects any attempt to create a new route. +func (emptyPaymentSession) RequestRoute(lnwire.MilliSatoshi, + lnwire.MilliSatoshi, uint32, uint32, lnwire.CustomRecords) ( + *route.Route, error) { + + return nil, ErrPathfindingDisabled +} + +// UpdateAdditionalEdge ignores policy updates because no graph is present. +func (emptyPaymentSession) UpdateAdditionalEdge(*lnwire.ChannelUpdate1, + *btcec.PublicKey, *models.CachedEdgePolicy) bool { + + return false +} + +// GetAdditionalEdgePolicy reports that no graph policy is available. +func (emptyPaymentSession) GetAdditionalEdgePolicy(*btcec.PublicKey, + uint64) *models.CachedEdgePolicy { + + return nil +} + +var _ routing.PaymentSessionSource = fixedRouteSessionSource{} +var _ routing.PaymentSession = emptyPaymentSession{} diff --git a/lnruntime/payments_test.go b/lnruntime/payments_test.go new file mode 100644 index 000000000..335464e7f --- /dev/null +++ b/lnruntime/payments_test.go @@ -0,0 +1,225 @@ +package lnruntime + +import ( + "sync" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightningnetwork/lnd/channeldb" + "github.com/lightningnetwork/lnd/htlcswitch" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/lightningnetwork/lnd/lnwire" + paymentsdb "github.com/lightningnetwork/lnd/payments/db" + "github.com/lightningnetwork/lnd/routing" + "github.com/lightningnetwork/lnd/routing/route" + "github.com/stretchr/testify/require" +) + +// TestFixedRoutePaymentsUsesNativeLifecycle verifies a one-hop payment is +// recorded and settled by lnd's normal control tower. +func TestFixedRoutePaymentsUsesNativeLifecycle(t *testing.T) { + t.Parallel() + + db := channeldb.OpenForTesting(t, t.TempDir()) + t.Cleanup(func() { + require.NoError(t, db.Close()) + }) + + preimage := lntypes.Preimage{1, 2, 3} + payer := newSettlingPayer(preimage) + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + operatorKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + payments, err := NewFixedRoutePayments(FixedRoutePaymentsConfig{ + DB: db, + Chain: fixedHeightChain{height: 800_000}, + Payer: payer, + SelfNode: route.NewVertex(clientKey.PubKey()), + GetLink: func(lnwire.ShortChannelID) (htlcswitch.ChannelLink, + error) { + + return nil, htlcswitch.ErrChannelLinkNotFound + }, + }) + require.NoError(t, err) + require.NoError(t, payments.Start()) + t.Cleanup(func() { + require.NoError(t, payments.Stop()) + }) + + const ( + channelID = uint64(9_001) + amount = lnwire.MilliSatoshi(25_000) + ) + paymentRoute := &route.Route{ + TotalTimeLock: 800_040, + TotalAmount: amount, + SourcePubKey: route.NewVertex(clientKey.PubKey()), + Hops: []*route.Hop{ + { + PubKeyBytes: route.NewVertex( + operatorKey.PubKey(), + ), + ChannelID: channelID, + OutgoingTimeLock: 800_040, + AmtToForward: amount, + LegacyPayload: true, + }, + }, + } + + attempt, err := payments.SendToOperator( + t.Context(), preimage.Hash(), paymentRoute, nil, + ) + require.NoError(t, err) + require.NotNil(t, attempt.Settle) + require.Equal(t, preimage, attempt.Settle.Preimage) + require.Equal( + t, lnwire.NewShortChanIDFromInt(channelID), payer.firstHop, + ) + + payment, err := payments.ControlTower().FetchPayment( + t.Context(), preimage.Hash(), + ) + require.NoError(t, err) + require.Equal(t, paymentsdb.StatusSucceeded, payment.GetStatus()) +} + +// TestFixedRoutePaymentsRejectsNonOperatorRoute verifies pathfinding cannot be +// smuggled into the client through a multi-hop route. +func TestFixedRoutePaymentsRejectsNonOperatorRoute(t *testing.T) { + t.Parallel() + + payments := &FixedRoutePayments{} + _, err := payments.SendToOperator( + t.Context(), lntypes.Hash{}, &route.Route{ + Hops: []*route.Hop{{}, {}}, + }, nil, + ) + require.ErrorIs(t, err, ErrOneHopRouteRequired) +} + +// settlingPayer is a deterministic PaymentAttemptDispatcher used to prove the +// lnd lifecycle owns attempt persistence and settlement. +type settlingPayer struct { + mu sync.Mutex + preimage lntypes.Preimage + results map[uint64]chan *htlcswitch.PaymentResult + firstHop lnwire.ShortChannelID +} + +// newSettlingPayer creates a dispatcher that settles every sent attempt. +func newSettlingPayer(preimage lntypes.Preimage) *settlingPayer { + return &settlingPayer{ + preimage: preimage, + results: make(map[uint64]chan *htlcswitch.PaymentResult), + } +} + +// SendHTLC records the first hop and makes a settlement result durable enough +// for the lifecycle's subsequent result lookup. +func (p *settlingPayer) SendHTLC(firstHop lnwire.ShortChannelID, + attemptID uint64, htlc *lnwire.UpdateAddHTLC) error { + + p.mu.Lock() + defer p.mu.Unlock() + + result := make(chan *htlcswitch.PaymentResult, 1) + result <- &htlcswitch.PaymentResult{Preimage: p.preimage} + p.results[attemptID] = result + p.firstHop = firstHop + + if htlc.PaymentHash != p.preimage.Hash() { + return errUnexpectedPaymentHash + } + + return nil +} + +// GetAttemptResult returns the result created by SendHTLC. +func (p *settlingPayer) GetAttemptResult(attemptID uint64, _ lntypes.Hash, + _ htlcswitch.ErrorDecrypter) (<-chan *htlcswitch.PaymentResult, error) { + + p.mu.Lock() + defer p.mu.Unlock() + + result, ok := p.results[attemptID] + if !ok { + return nil, htlcswitch.ErrPaymentIDNotFound + } + + return result, nil +} + +// CleanStore is a no-op because test results live only for one test. +func (*settlingPayer) CleanStore(map[uint64]struct{}) error { + return nil +} + +// HasAttemptResult reports whether this dispatcher has a stored result. +func (p *settlingPayer) HasAttemptResult(attemptID uint64) (bool, error) { + p.mu.Lock() + defer p.mu.Unlock() + + _, ok := p.results[attemptID] + + return ok, nil +} + +// fixedHeightChain supplies the current height needed by resumed lnd payment +// lifecycles. Fixed-route sends do not query any other chain data. +type fixedHeightChain struct { + height int32 +} + +// GetBestBlock returns the configured test height. +func (c fixedHeightChain) GetBestBlock() (*chainhash.Hash, int32, error) { + return &chainhash.Hash{}, c.height, nil +} + +// GetUtxo is unused by fixed-route payment execution. +func (fixedHeightChain) GetUtxo(*wire.OutPoint, []byte, uint32, + <-chan struct{}) (*wire.TxOut, error) { + + return nil, errUnexpectedChainQuery +} + +// GetBlockHash is unused by fixed-route payment execution. +func (fixedHeightChain) GetBlockHash(int64) (*chainhash.Hash, error) { + return nil, errUnexpectedChainQuery +} + +// GetBlock is unused by fixed-route payment execution. +func (fixedHeightChain) GetBlock(*chainhash.Hash) (*wire.MsgBlock, error) { + return nil, errUnexpectedChainQuery +} + +// GetBlockHeader is unused by fixed-route payment execution. +func (fixedHeightChain) GetBlockHeader(*chainhash.Hash) (*wire.BlockHeader, + error) { + + return nil, errUnexpectedChainQuery +} + +var ( + errUnexpectedPaymentHash = &runtimeTestError{"unexpected payment hash"} + errUnexpectedChainQuery = &runtimeTestError{"unexpected chain query"} +) + +// runtimeTestError avoids string matching in test-only adapters. +type runtimeTestError struct { + message string +} + +// Error returns the test adapter failure message. +func (e *runtimeTestError) Error() string { + return e.message +} + +var _ routing.PaymentAttemptDispatcher = (*settlingPayer)(nil) +var _ lnwallet.BlockChainIO = fixedHeightChain{} diff --git a/lnruntime/peer.go b/lnruntime/peer.go new file mode 100644 index 000000000..aaa1315ff --- /dev/null +++ b/lnruntime/peer.go @@ -0,0 +1,223 @@ +package lnruntime + +import ( + "fmt" + "net" + "sync" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightningnetwork/lnd/lnpeer" + "github.com/lightningnetwork/lnd/lnwire" +) + +// MessageTransport carries lnd wire messages over the embedding application's +// authenticated connection instead of opening a BOLT 8 socket. +type MessageTransport interface { + SendMessages(sync bool, messages ...lnwire.Message) error +} + +// NewChannelHandler installs a channel link after lnd's funding manager has +// completed channel opening. +type NewChannelHandler func(*lnpeer.NewChannel, <-chan struct{}) error + +// PeerConfig describes one virtual lnd peer backed by an application +// transport. +type PeerConfig struct { + RemoteKey *btcec.PublicKey + Address net.Addr + Transport MessageTransport + LocalFeatures *lnwire.FeatureVector + RemoteFeatures *lnwire.FeatureVector + AddChannel NewChannelHandler + WipeChannel func(*wire.OutPoint) + OnDisconnect func(error) +} + +// Peer adapts an authenticated swapdk connection to lnd's existing peer +// interface. It deliberately owns no socket, handshake, channel state, or +// message journal. +type Peer struct { + cfg PeerConfig + + mu sync.RWMutex + pending map[lnwire.ChannelID]struct{} + + quit chan struct{} + stopOnce sync.Once +} + +// NewPeer validates and creates a transport-backed lnd peer. +func NewPeer(cfg PeerConfig) (*Peer, error) { + if cfg.RemoteKey == nil { + return nil, fmt.Errorf("remote peer key is required") + } + if cfg.Transport == nil { + return nil, fmt.Errorf("peer message transport is required") + } + if cfg.AddChannel == nil { + return nil, fmt.Errorf("new channel handler is required") + } + + if cfg.Address == nil { + // lnd persists this diagnostic field in its channel record and + // its schema only supports standard peer address encodings. + // Message delivery still uses Transport; no TCP connection is + // opened. + cfg.Address = &net.TCPAddr{IP: net.IPv4zero} + } + if cfg.LocalFeatures == nil { + cfg.LocalFeatures = emptyFeatureVector() + } + if cfg.RemoteFeatures == nil { + cfg.RemoteFeatures = emptyFeatureVector() + } + + return &Peer{ + cfg: cfg, + pending: make(map[lnwire.ChannelID]struct{}), + quit: make(chan struct{}), + }, nil +} + +// SendMessage sends high-priority lnd messages through the application +// transport. +func (p *Peer) SendMessage(sync bool, messages ...lnwire.Message) error { + return p.send(sync, messages...) +} + +// SendMessageLazy sends low-priority lnd messages through the same ordered +// transport. Transport implementations may choose a lower delivery priority +// when sync is false. +func (p *Peer) SendMessageLazy(sync bool, messages ...lnwire.Message) error { + return p.send(sync, messages...) +} + +// send rejects writes after disconnect before handing messages to the +// transport. +func (p *Peer) send(sync bool, messages ...lnwire.Message) error { + select { + case <-p.quit: + return fmt.Errorf("virtual peer disconnected") + + default: + } + + return p.cfg.Transport.SendMessages(sync, messages...) +} + +// AddNewChannel delegates link installation to the composed channel runtime. +func (p *Peer) AddNewChannel(channel *lnpeer.NewChannel, + cancel <-chan struct{}) error { + + select { + case <-cancel: + return fmt.Errorf("new channel installation canceled") + + case <-p.quit: + return fmt.Errorf("virtual peer disconnected") + + default: + } + + return p.cfg.AddChannel(channel, cancel) +} + +// AddPendingChannel records the temporary channel identifier used by lnd's +// funding manager. +func (p *Peer) AddPendingChannel(channelID lnwire.ChannelID, + cancel <-chan struct{}) error { + + select { + case <-cancel: + return fmt.Errorf("pending channel registration canceled") + + case <-p.quit: + return fmt.Errorf("virtual peer disconnected") + + default: + } + + p.mu.Lock() + p.pending[channelID] = struct{}{} + p.mu.Unlock() + + return nil +} + +// RemovePendingChannel clears a completed or failed funding reservation. +func (p *Peer) RemovePendingChannel(channelID lnwire.ChannelID) error { + p.mu.Lock() + delete(p.pending, channelID) + p.mu.Unlock() + + return nil +} + +// HasPendingChannel reports whether lnd currently associates the temporary +// identifier with this peer. +func (p *Peer) HasPendingChannel(channelID lnwire.ChannelID) bool { + p.mu.RLock() + defer p.mu.RUnlock() + + _, ok := p.pending[channelID] + + return ok +} + +// WipeChannel removes application indexes after lnd abandons a channel. +func (p *Peer) WipeChannel(channelPoint *wire.OutPoint) { + if p.cfg.WipeChannel != nil { + p.cfg.WipeChannel(channelPoint) + } +} + +// PubKey returns the compressed remote node key. +func (p *Peer) PubKey() [33]byte { + var serialized [33]byte + copy(serialized[:], p.cfg.RemoteKey.SerializeCompressed()) + + return serialized +} + +// IdentityKey returns the remote node key. +func (p *Peer) IdentityKey() *btcec.PublicKey { + return p.cfg.RemoteKey +} + +// Address returns a logical address for diagnostics only. +func (p *Peer) Address() net.Addr { + return p.cfg.Address +} + +// QuitSignal closes when the application transport disconnects the peer. +func (p *Peer) QuitSignal() <-chan struct{} { + return p.quit +} + +// LocalFeatures returns the features offered by this composed runtime. +func (p *Peer) LocalFeatures() *lnwire.FeatureVector { + return p.cfg.LocalFeatures +} + +// RemoteFeatures returns the negotiated operator feature vector. +func (p *Peer) RemoteFeatures() *lnwire.FeatureVector { + return p.cfg.RemoteFeatures +} + +// Disconnect terminates the logical peer and notifies the transport owner. +func (p *Peer) Disconnect(reason error) { + p.stopOnce.Do(func() { + close(p.quit) + if p.cfg.OnDisconnect != nil { + p.cfg.OnDisconnect(reason) + } + }) +} + +// emptyFeatureVector returns a feature vector with no optional behavior. +func emptyFeatureVector() *lnwire.FeatureVector { + return lnwire.NewFeatureVector(lnwire.NewRawFeatureVector(), nil) +} + +var _ lnpeer.Peer = (*Peer)(nil) diff --git a/lnruntime/peer_ingress.go b/lnruntime/peer_ingress.go new file mode 100644 index 000000000..af517109a --- /dev/null +++ b/lnruntime/peer_ingress.go @@ -0,0 +1,260 @@ +package lnruntime + +import ( + "context" + "fmt" + "io" + "time" + + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/wavelength/baselib/actor" + mailboxpb "github.com/lightninglabs/wavelength/mailbox/pb" + "github.com/lightninglabs/wavelength/serverconn" + fn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/tlv" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +const ( + peerMessageMaxRetryDelay = time.Minute + peerMessageMaxAttempts = 1<<31 - 1 +) + +const ( + // PeerMessageIngressTLVType identifies one durably staged inbound BOLT + // message. The 0x72xx range is reserved for the modular lnd runtime. + PeerMessageIngressTLVType tlv.Type = 0x7200 + + peerMessagePayloadRecord tlv.Type = 1 + peerMessageLaneRecord tlv.Type = 3 +) + +// PeerMessageIngressConfig contains the durable boundary between mailbox +// delivery and native lnd message processing. +type PeerMessageIngressConfig struct { + ActorID string + Store actor.DeliveryStore + Handler PeerEventHandler + Log btclog.Logger +} + +// PeerMessageIngress persists inbound BOLT messages before invoking native +// lnd outside the mailbox ingress transaction. +type PeerMessageIngress struct { + actorID string + durable *actor.DurableActor[*peerMessageIngressMsg, struct{}] +} + +// peerMessageIngressMsg is one ordered BOLT message in the durable ingress +// mailbox. +type peerMessageIngressMsg struct { + actor.BaseMessage + + Payload []byte + Lane string +} + +// MessageType returns the stable actor message name. +func (*peerMessageIngressMsg) MessageType() string { + return "lnruntime.PeerMessageIngress" +} + +// CorrelationKey keeps every message for the logical peer in FIFO order. +func (m *peerMessageIngressMsg) CorrelationKey() string { + return m.Lane +} + +// TLVType returns the durable message type identifier. +func (*peerMessageIngressMsg) TLVType() tlv.Type { + return PeerMessageIngressTLVType +} + +// Encode serializes the BOLT payload and its FIFO lane as a TLV stream. +func (m *peerMessageIngressMsg) Encode(w io.Writer) error { + lane := []byte(m.Lane) + stream, err := tlv.NewStream( + tlv.MakePrimitiveRecord(peerMessagePayloadRecord, &m.Payload), + tlv.MakePrimitiveRecord(peerMessageLaneRecord, &lane), + ) + if err != nil { + return err + } + + return stream.Encode(w) +} + +// Decode restores the BOLT payload and FIFO lane from a TLV stream. +func (m *peerMessageIngressMsg) Decode(r io.Reader) error { + var lane []byte + stream, err := tlv.NewStream( + tlv.MakePrimitiveRecord(peerMessagePayloadRecord, &m.Payload), + tlv.MakePrimitiveRecord(peerMessageLaneRecord, &lane), + ) + if err != nil { + return err + } + if _, err := stream.DecodeWithParsedTypes(r); err != nil { + return err + } + + m.Lane = string(lane) + + return nil +} + +// peerMessageIngressBehavior invokes lnd between the durable actor's short +// database transactions. LND may synchronously emit a reply, so running the +// handler while a SQLite writer is held would deadlock its durable sender. +type peerMessageIngressBehavior struct { + handler PeerEventHandler +} + +// Receive decodes and dispatches one BOLT message, then atomically consumes +// its durable mailbox row. +func (b *peerMessageIngressBehavior) Receive(ctx context.Context, + msg *peerMessageIngressMsg, + ax actor.Exec[struct{}]) fn.Result[struct{}] { + + message, err := UnmarshalPeerMessage(msg.Payload) + if err != nil { + return fn.Err[struct{}]( + fmt.Errorf("decode durable lnd peer message: %w", err), + ) + } + if err := b.handler(ctx, message); err != nil { + return fn.Err[struct{}]( + fmt.Errorf("handle durable lnd peer message: %w", err), + ) + } + if err := ax.Commit(ctx, func(context.Context, struct{}) error { + return nil + }); err != nil { + return fn.Err[struct{}](err) + } + + return fn.Ok(struct{}{}) +} + +// NewPeerMessageIngress creates and starts one durable inbound BOLT queue. +func NewPeerMessageIngress(cfg PeerMessageIngressConfig) (*PeerMessageIngress, + error) { + + return newPeerMessageIngress(cfg, parkPeerMessageRetryPolicy) +} + +// parkPeerMessageRetryPolicy keeps an ordered BOLT lane blocked until its +// endpoint can process the message. Advancing past a failed CommitSig or +// RevokeAndAck would silently desynchronize the channel. +func parkPeerMessageRetryPolicy(_ error, attempts int) (bool, time.Duration) { + if attempts < 0 { + attempts = 0 + } + if attempts > 6 { + attempts = 6 + } + delay := time.Second << uint(attempts) + if delay > peerMessageMaxRetryDelay { + delay = peerMessageMaxRetryDelay + } + + return true, delay +} + +// newPeerMessageIngress accepts a retry policy so tests can exercise repeated +// endpoint failures without waiting for production backoff intervals. +func newPeerMessageIngress(cfg PeerMessageIngressConfig, + retryPolicy actor.TellRetryPolicy) (*PeerMessageIngress, error) { + + if cfg.ActorID == "" { + return nil, fmt.Errorf("peer ingress actor id is required") + } + if cfg.Store == nil { + return nil, fmt.Errorf("peer ingress delivery store is " + + "required") + } + if cfg.Handler == nil { + return nil, fmt.Errorf("peer ingress handler is required") + } + if retryPolicy == nil { + return nil, fmt.Errorf("peer ingress retry policy is required") + } + + codec := actor.NewMessageCodec() + codec.MustRegister(PeerMessageIngressTLVType, func() actor.TLVMessage { + return &peerMessageIngressMsg{} + }) + behavior := &peerMessageIngressBehavior{handler: cfg.Handler} + durableCfg := actor.DefaultDurableTxActorConfig[ + *peerMessageIngressMsg, struct{}, struct{}, + ]( + cfg.ActorID, behavior, + func(context.Context, actor.DeliveryStore) struct{} { + return struct{}{} + }, + cfg.Store, codec, + ) + if cfg.Log != nil { + durableCfg.Log = fn.Some(cfg.Log) + } + durableCfg.TellRetryPolicy = retryPolicy + durableCfg.MaxAttempts = peerMessageMaxAttempts + durable, err := actor.NewDurableActor(durableCfg).Unpack() + if err != nil { + return nil, fmt.Errorf("create peer ingress actor: %w", err) + } + durable.Start() + + return &PeerMessageIngress{ + actorID: cfg.ActorID, + durable: durable, + }, nil +} + +// Dispatcher returns the mailbox route that durably stages one valid BOLT +// message. The Tell joins the caller's ingress transaction; LND processing +// starts only after that transaction commits and wakes the durable actor. +func (i *PeerMessageIngress) Dispatcher() serverconn.EnvelopeDispatcher { + return func(ctx context.Context, env *mailboxpb.Envelope) error { + if i == nil || i.durable == nil { + return fmt.Errorf("lnd peer ingress is not initialized") + } + if env == nil || env.Body == nil { + return fmt.Errorf("lnd peer event body is required") + } + + body := &wrapperspb.BytesValue{} + if err := env.Body.UnmarshalTo(body); err != nil { + return fmt.Errorf("decode lnd peer event body: %w", err) + } + if _, err := UnmarshalPeerMessage(body.Value); err != nil { + return fmt.Errorf("decode lnd peer message: %w", err) + } + + return i.durable.Ref().Tell(ctx, &peerMessageIngressMsg{ + Payload: append([]byte(nil), body.Value...), + Lane: i.actorID, + }) + } +} + +// StopAndWait stops the durable ingress actor and waits for its worker. +func (i *PeerMessageIngress) StopAndWait(ctx context.Context) error { + if i == nil || i.durable == nil { + return nil + } + + return i.durable.StopAndWait(ctx) +} + +// Stop stops the durable ingress actor without waiting for its worker. +func (i *PeerMessageIngress) Stop() { + if i == nil || i.durable == nil { + return + } + + i.durable.Stop() +} + +var _ actor.TxBehavior[ + *peerMessageIngressMsg, struct{}, struct{}, +] = (*peerMessageIngressBehavior)(nil) diff --git a/lnruntime/peer_ingress_test.go b/lnruntime/peer_ingress_test.go new file mode 100644 index 000000000..77f08646b --- /dev/null +++ b/lnruntime/peer_ingress_test.go @@ -0,0 +1,241 @@ +package lnruntime + +import ( + "context" + "database/sql" + "fmt" + "testing" + "time" + + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/db" + "github.com/lightninglabs/wavelength/db/actordelivery" + adsqlc "github.com/lightninglabs/wavelength/db/actordelivery/sqlc" + mailboxpb "github.com/lightninglabs/wavelength/mailbox/pb" + "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/anypb" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +// TestPeerMessageIngressDefersLNDUntilCommit proves mailbox ingress only +// stages BOLT traffic in the outer transaction. Native lnd may synchronously +// produce a durable reply, so its handler must not run while that transaction +// still owns SQLite's writer lock. +func TestPeerMessageIngressDefersLNDUntilCommit(t *testing.T) { + t.Parallel() + + rawStore := db.NewTestDB(t) + actorQueries := adsqlc.New(rawStore.DB) + actorDB := db.NewTransactionExecutor( + rawStore.BaseDB, + func(tx *sql.Tx) actordelivery.ActorDeliveryQueries { + return actorQueries.WithTx(tx) + }, + btclog.Disabled, + ) + store := actordelivery.NewTxAwareActorDeliveryStore( + actorDB, rawStore.BaseDB, clock.NewDefaultClock(), + ) + + handled := make(chan *lnwire.Ping, 1) + ingress, err := NewPeerMessageIngress(PeerMessageIngressConfig{ + ActorID: "test-peer-ingress", + Store: store, + Handler: func(_ context.Context, message lnwire.Message) error { + ping, ok := message.(*lnwire.Ping) + if !ok { + return nil + } + handled <- ping + + return nil + }, + }) + require.NoError(t, err) + t.Cleanup(func() { + ctx, cancel := context.WithTimeout( + context.Background(), 5*time.Second, + ) + defer cancel() + + require.NoError(t, ingress.StopAndWait(ctx)) + }) + + payload, err := MarshalPeerMessage(lnwire.NewPing(11)) + require.NoError(t, err) + body, err := anypb.New(&wrapperspb.BytesValue{Value: payload}) + require.NoError(t, err) + envelope := &mailboxpb.Envelope{Body: body} + + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + staged := make(chan struct{}) + releaseCommit := make(chan struct{}) + dispatchResult := make(chan error, 1) + dispatch := ingress.Dispatcher() + go func() { + dispatchResult <- store.ExecTx( + ctx, false, func(txCtx context.Context, + _ actor.DeliveryStore) error { + + if err := dispatch( + txCtx, envelope, + ); err != nil { + return err + } + close(staged) + + select { + case <-releaseCommit: + return nil + + case <-txCtx.Done(): + return txCtx.Err() + } + }, + ) + }() + + select { + case <-staged: + case <-ctx.Done(): + t.Fatal("peer message was not staged") + } + + select { + case <-handled: + t.Fatal("lnd handler ran before ingress transaction committed") + + case <-time.After(100 * time.Millisecond): + } + + close(releaseCommit) + select { + case err := <-dispatchResult: + require.NoError(t, err) + + case <-ctx.Done(): + t.Fatal("ingress transaction did not commit") + } + + select { + case ping := <-handled: + require.EqualValues(t, 11, ping.NumPongBytes) + + case <-ctx.Done(): + t.Fatal("committed peer message was not handled") + } +} + +// TestPeerMessageIngressParksOrderedLane proves a temporarily unavailable lnd +// endpoint cannot dead-letter one message and advance to later BOLT traffic. +func TestPeerMessageIngressParksOrderedLane(t *testing.T) { + t.Parallel() + + rawStore := db.NewTestDB(t) + actorQueries := adsqlc.New(rawStore.DB) + actorDB := db.NewTransactionExecutor( + rawStore.BaseDB, + func(tx *sql.Tx) actordelivery.ActorDeliveryQueries { + return actorQueries.WithTx(tx) + }, + btclog.Disabled, + ) + store := actordelivery.NewTxAwareActorDeliveryStore( + actorDB, rawStore.BaseDB, clock.NewDefaultClock(), + ) + + const failuresBeforeReady = 7 + attempts := 0 + handled := make(chan uint16, 2) + ingress, err := newPeerMessageIngress( + PeerMessageIngressConfig{ + ActorID: "test-peer-ingress-park", + Store: store, + Handler: func(_ context.Context, + message lnwire.Message) error { + + ping, ok := message.(*lnwire.Ping) + if !ok { + return nil + } + attempts++ + if attempts <= failuresBeforeReady { + return fmt.Errorf("endpoint " + + "unavailable") + } + handled <- ping.NumPongBytes + + return nil + }, + }, + func(error, int) (bool, time.Duration) { + return true, time.Millisecond + }, + ) + require.NoError(t, err) + t.Cleanup(func() { + ctx, cancel := context.WithTimeout( + context.Background(), 5*time.Second, + ) + defer cancel() + + require.NoError(t, ingress.StopAndWait(ctx)) + }) + + dispatch := ingress.Dispatcher() + for _, pongBytes := range []uint16{11, 12} { + payload, err := MarshalPeerMessage(lnwire.NewPing(pongBytes)) + require.NoError(t, err) + body, err := anypb.New(&wrapperspb.BytesValue{Value: payload}) + require.NoError(t, err) + err = store.ExecTx( + t.Context(), false, + func(txCtx context.Context, + _ actor.DeliveryStore) error { + + return dispatch(txCtx, &mailboxpb.Envelope{ + Body: body, + }) + }, + ) + require.NoError(t, err) + } + + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + for _, expected := range []uint16{11, 12} { + select { + case actual := <-handled: + require.Equal(t, expected, actual) + + case <-ctx.Done(): + t.Fatal("parked peer message did not resume") + } + } + require.Equal(t, failuresBeforeReady+2, attempts) + deadLetters, err := store.ListDeadLetters( + t.Context(), + "test-peer-ingress-park", 10, + ) + require.NoError(t, err) + require.Empty(t, deadLetters) +} + +// TestPeerMessageRetryPolicyNeverAdvances verifies the production policy +// always parks, including after attempt counts that previously dead-lettered. +func TestPeerMessageRetryPolicyNeverAdvances(t *testing.T) { + t.Parallel() + + for _, attempts := range []int{0, 5, 100, 1_000_000} { + retry, delay := parkPeerMessageRetryPolicy( + fmt.Errorf("endpoint unavailable"), attempts, + ) + require.True(t, retry) + require.Positive(t, delay) + require.LessOrEqual(t, delay, peerMessageMaxRetryDelay) + } +} diff --git a/lnruntime/peer_test.go b/lnruntime/peer_test.go new file mode 100644 index 000000000..c7e1f8f0a --- /dev/null +++ b/lnruntime/peer_test.go @@ -0,0 +1,121 @@ +package lnruntime + +import ( + "errors" + "sync" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/lightningnetwork/lnd/lnpeer" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/stretchr/testify/require" +) + +// TestPeerCarriesWireMessages verifies lnd messages are delegated without a +// network peer implementation. +func TestPeerCarriesWireMessages(t *testing.T) { + t.Parallel() + + remoteKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + transport := &recordingTransport{} + peer, err := NewPeer(PeerConfig{ + RemoteKey: remoteKey.PubKey(), + Transport: transport, + AddChannel: func(*lnpeer.NewChannel, <-chan struct{}) error { + return nil + }, + }) + require.NoError(t, err) + + message := &lnwire.Ping{NumPongBytes: 4} + require.NoError(t, peer.SendMessage(true, message)) + require.Equal(t, []lnwire.Message{message}, transport.messages) + require.True(t, transport.sync) + require.Equal(t, "tcp", peer.Address().Network()) + serializedPeerKey := peer.PubKey() + require.Equal( + t, remoteKey.PubKey().SerializeCompressed(), + serializedPeerKey[:], + ) +} + +// TestPeerTracksFundingLifecycle verifies pending IDs and cancellation remain +// visible to lnd's funding manager. +func TestPeerTracksFundingLifecycle(t *testing.T) { + t.Parallel() + + remoteKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + peer, err := NewPeer(PeerConfig{ + RemoteKey: remoteKey.PubKey(), + Transport: &recordingTransport{}, + AddChannel: func(*lnpeer.NewChannel, <-chan struct{}) error { + return nil + }, + }) + require.NoError(t, err) + + channelID := lnwire.ChannelID{1, 2, 3} + cancel := make(chan struct{}) + require.NoError(t, peer.AddPendingChannel(channelID, cancel)) + require.True(t, peer.HasPendingChannel(channelID)) + require.NoError(t, peer.RemovePendingChannel(channelID)) + require.False(t, peer.HasPendingChannel(channelID)) + + close(cancel) + require.Error(t, peer.AddPendingChannel(channelID, cancel)) +} + +// TestPeerDisconnectIsIdempotent verifies the logical peer owns one shutdown +// signal even when independent lnd components report failure. +func TestPeerDisconnectIsIdempotent(t *testing.T) { + t.Parallel() + + remoteKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + var disconnects int + peer, err := NewPeer(PeerConfig{ + RemoteKey: remoteKey.PubKey(), + Transport: &recordingTransport{}, + AddChannel: func(*lnpeer.NewChannel, <-chan struct{}) error { + return nil + }, + OnDisconnect: func(error) { + disconnects++ + }, + }) + require.NoError(t, err) + + peer.Disconnect(errors.New("first")) + peer.Disconnect(errors.New("second")) + require.Equal(t, 1, disconnects) + select { + case <-peer.QuitSignal(): + default: + t.Fatal("peer quit signal not closed") + } + require.Error(t, peer.SendMessage(false, &lnwire.Ping{})) +} + +// recordingTransport records the last lnd message batch. +type recordingTransport struct { + mu sync.Mutex + sync bool + messages []lnwire.Message +} + +// SendMessages records one ordered message batch. +func (t *recordingTransport) SendMessages(syncSend bool, + messages ...lnwire.Message) error { + + t.mu.Lock() + defer t.mu.Unlock() + + t.sync = syncSend + t.messages = append([]lnwire.Message(nil), messages...) + + return nil +} + +var _ MessageTransport = (*recordingTransport)(nil) diff --git a/lnruntime/process_cooperative_close.go b/lnruntime/process_cooperative_close.go new file mode 100644 index 000000000..a3044c3a6 --- /dev/null +++ b/lnruntime/process_cooperative_close.go @@ -0,0 +1,906 @@ +package lnruntime + +import ( + "bytes" + "context" + "errors" + "fmt" + "sync" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/lightninglabs/wavelength/arkchannel" +) + +// CooperativeCloseDeliverySource returns a wallet-owned settlement script. +// Repeated calls for one channel ID must return the same script so a client can +// recover after the hub stored the request but its response was lost. +type CooperativeCloseDeliverySource interface { + CooperativeCloseDelivery(context.Context, arkchannel.ID) ([]byte, error) +} + +// CooperativeCloseDeliverySourceFunc adapts an idempotent wallet address +// allocator to the cooperative-close process. +type CooperativeCloseDeliverySourceFunc func(context.Context, arkchannel.ID) ( + []byte, error) + +// CooperativeCloseDelivery invokes the wrapped wallet allocator. +func (f CooperativeCloseDeliverySourceFunc) CooperativeCloseDelivery( + ctx context.Context, id arkchannel.ID) ([]byte, error) { + + return f(ctx, id) +} + +// cooperativeCloseOperationLock serializes public and peer operations per +// channel while allowing unrelated channels to make progress concurrently. +type cooperativeCloseOperationLock struct { + mu sync.Mutex + locks map[arkchannel.ID]*cooperativeCloseOperation +} + +// cooperativeCloseOperation owns one channel's process-local mutex and tracks +// references so idle entries can be removed. +type cooperativeCloseOperation struct { + mu sync.Mutex + refs uint32 +} + +// lock acquires one channel's operation mutex and returns its release function. +func (l *cooperativeCloseOperationLock) lock(id arkchannel.ID) func() { + l.mu.Lock() + if l.locks == nil { + l.locks = make(map[arkchannel.ID]*cooperativeCloseOperation) + } + operation := l.locks[id] + if operation == nil { + operation = &cooperativeCloseOperation{} + l.locks[id] = operation + } + operation.refs++ + l.mu.Unlock() + + operation.mu.Lock() + + return func() { + operation.mu.Unlock() + + l.mu.Lock() + defer l.mu.Unlock() + operation.refs-- + if operation.refs == 0 { + delete(l.locks, id) + } + } +} + +// ProcessCooperativeClosePeer is the one-way authenticated session driven by +// the client. The hub never calls back into the client's mailbox while a +// request is in flight. +type ProcessCooperativeClosePeer interface { + BeginCooperativeClose(context.Context, arkchannel.ID, []byte) ( + arkchannel.CooperativeCloseRequest, CleanChannelState, + *arkchannel.CooperativeClose, error) + + CompleteCooperativeClose(context.Context, arkchannel.ID, + arkchannel.CooperativeCloseProposal) ( + arkchannel.CooperativeClose, error) + + AcknowledgeCooperativeCloseSigned(context.Context, arkchannel.ID, + arkchannel.CooperativeClose) error + + PublishCooperativeClose(context.Context, arkchannel.ID, + chainhash.Hash) (chainhash.Hash, error) + + AcknowledgeCooperativeCloseFinalized(context.Context, + arkchannel.ID) error + + AbortCooperativeClose(context.Context, arkchannel.ID) error +} + +// ClientCooperativeCloseProcess drives an in-Ark OOR close from the client. +// Its peer calls are strictly client-to-hub, which avoids nested +// request deadlocks on a per-client mailbox ingress loop. +type ClientCooperativeCloseProcess struct { + local *NativeCooperativeCloseEndpoint + peer ProcessCooperativeClosePeer + publisher CooperativeClosePublisher + delivery CooperativeCloseDeliverySource + + mu sync.RWMutex + service CooperativeCloseStateSink + locks cooperativeCloseOperationLock +} + +// NewClientCooperativeCloseProcess constructs the client-owned close process. +func NewClientCooperativeCloseProcess(local *NativeCooperativeCloseEndpoint, + peer ProcessCooperativeClosePeer, publisher CooperativeClosePublisher, + delivery CooperativeCloseDeliverySource) ( + *ClientCooperativeCloseProcess, error) { + + if local == nil || local.party != arkchannel.PartyClient { + return nil, fmt.Errorf("client cooperative close endpoint is " + + "required") + } + if peer == nil { + return nil, fmt.Errorf("cooperative close peer is required") + } + if publisher == nil { + return nil, fmt.Errorf("client cooperative close publisher " + + "is required") + } + if delivery == nil { + return nil, fmt.Errorf("client cooperative close delivery " + + "source is required") + } + + return &ClientCooperativeCloseProcess{ + local: local, peer: peer, publisher: publisher, + delivery: delivery, + }, nil +} + +// BindChannelEventSink attaches the process to its durable channel service. +func (p *ClientCooperativeCloseProcess) BindChannelEventSink( + sink arkchannel.ChannelEventSink) error { + + service, ok := sink.(CooperativeCloseStateSink) + if !ok { + return fmt.Errorf("channel event sink lacks close barriers") + } + if err := p.local.BindChannelEventSink(sink); err != nil { + return err + } + + p.mu.Lock() + defer p.mu.Unlock() + if p.service == nil { + p.service = service + } + + return nil +} + +// RequestCooperativeClose allocates the client's payout, lets the hub fix its +// own payout, then persists and executes the local durable request. +func (p *ClientCooperativeCloseProcess) RequestCooperativeClose( + ctx context.Context, id arkchannel.ID) (arkchannel.Record, error) { + + unlock := p.locks.lock(id) + defer unlock() + + service, err := p.stateService() + if err != nil { + return arkchannel.Record{}, err + } + record, err := service.GetChannel(ctx, id) + if err != nil { + return arkchannel.Record{}, err + } + if record.Snapshot.CooperativeClose != nil && + record.Snapshot.ClientCloseFinalized { + + if err := p.peer.AcknowledgeCooperativeCloseFinalized( + ctx, id, + ); err != nil { + return arkchannel.Record{}, err + } + + return record, nil + } + + if record.Snapshot.CooperativeCloseRequest != nil { + return service.ResumeChannelAction(ctx, id) + } + clientScript, err := p.delivery.CooperativeCloseDelivery(ctx, id) + if err != nil { + return arkchannel.Record{}, fmt.Errorf("allocate client close "+ + "delivery: %w", err) + } + request, _, _, err := p.peer.BeginCooperativeClose( + ctx, id, clientScript, + ) + if err != nil { + return arkchannel.Record{}, err + } + if !bytes.Equal(request.ClientDeliveryScript, clientScript) { + return arkchannel.Record{}, fmt.Errorf("hub changed client " + + "cooperative close terms") + } + + return service.RequestCooperativeClose(ctx, id, request) +} + +// GetChannel returns the local durable channel record. +func (p *ClientCooperativeCloseProcess) GetChannel(ctx context.Context, + id arkchannel.ID) (arkchannel.Record, error) { + + service, err := p.stateService() + if err != nil { + return arkchannel.Record{}, err + } + + return service.GetChannel(ctx, id) +} + +// NegotiateCooperativeClose quiesces both endpoints, reconciles their native +// lnd state, and completes the two-database hub-authorization barrier. +func (p *ClientCooperativeCloseProcess) NegotiateCooperativeClose( + ctx context.Context, id arkchannel.ID, terms arkchannel.Terms, + source arkchannel.VTXOBinding, backing arkchannel.Backing, + request arkchannel.CooperativeCloseRequest) error { + + service, err := p.stateService() + if err != nil { + return err + } + localRecord, err := service.GetChannel(ctx, id) + if err != nil { + return err + } + remoteRequest, remoteState, remoteSettlement, err := + p.peer.BeginCooperativeClose( + ctx, id, request.ClientDeliveryScript, + ) + if err != nil { + return err + } + if !processCooperativeCloseRequestsEqual(request, remoteRequest) { + return fmt.Errorf("hub stored another cooperative close " + + "request") + } + + settlement := localRecord.Snapshot.CooperativeClose + if settlement == nil { + settlement = remoteSettlement + } + if settlement == nil { + localState, err := p.local.QuiesceCooperativeClose( + ctx, id, terms, source, backing, request, + ) + if err != nil { + return p.abort(ctx, id, backing, err) + } + clientBalance, hubBalance, err := reconcileCleanChannelStates( + arkchannel.PartyClient, localState, remoteState, + ) + if err != nil { + return p.abort(ctx, id, backing, err) + } + template, err := arkchannel.NewCooperativeCloseTemplate( + terms, source, request, clientBalance, hubBalance, + localState.CommitmentHeight, + ) + if err != nil { + return p.abort(ctx, id, backing, err) + } + proposal := template.Proposal() + completed, err := p.peer.CompleteCooperativeClose( + ctx, id, proposal, + ) + if err != nil { + + // The hub may have stored the signed transaction before + // its response was lost. Keep both links quiesced for + // replay. + return fmt.Errorf("hub complete cooperative close: %w", + err) + } + settlement = &completed + } + if err := settlement.Validate(terms, source, request); err != nil { + return fmt.Errorf("validate hub cooperative close: %w", err) + } + + if _, err := service.RecordChannelEvent( + ctx, id, &arkchannel.CooperativeCloseSigned{ + Close: settlement.Clone(), + Party: arkchannel.PartyClient, + }, + ); err != nil { + return err + } + if err := p.peer.AcknowledgeCooperativeCloseSigned( + ctx, id, settlement.Clone(), + ); err != nil { + return err + } + if _, err := service.RecordChannelEvent( + ctx, id, &arkchannel.CooperativeCloseSigned{ + Close: settlement.Clone(), Party: arkchannel.PartyHub, + }, + ); err != nil { + return err + } + + return p.publishAndFinalize( + ctx, id, terms, backing, source, request, settlement.Clone(), + ) +} + +// PublishCooperativeClose resumes a client that crashed after both databases +// stored the hub authorization but before ordinary OOR settlement completed. +func (p *ClientCooperativeCloseProcess) PublishCooperativeClose( + ctx context.Context, id arkchannel.ID, terms arkchannel.Terms, + source arkchannel.VTXOBinding, + settlement arkchannel.CooperativeClose) error { + + service, err := p.stateService() + if err != nil { + return err + } + record, err := service.GetChannel(ctx, id) + if err != nil { + return err + } + if record.Snapshot.Backing == nil || + record.Snapshot.CooperativeCloseRequest == nil { + return fmt.Errorf("client cooperative close artifacts are " + + "incomplete") + } + + return p.publishAndFinalize( + ctx, id, terms, *record.Snapshot.Backing, source, + *record.Snapshot.CooperativeCloseRequest, settlement, + ) +} + +// FinalizeCooperativeClose resumes local archival after OOR finalization was +// already recorded by the durable channel FSM. +func (p *ClientCooperativeCloseProcess) FinalizeCooperativeClose( + ctx context.Context, id arkchannel.ID, terms arkchannel.Terms, + backing arkchannel.Backing, source arkchannel.VTXOBinding, + request arkchannel.CooperativeCloseRequest, + settlement arkchannel.CooperativeClose) error { + + return p.finalizeClient( + ctx, id, terms, backing, source, request, settlement, + ) +} + +// publishAndFinalize starts the ordinary OOR actor only after both durable +// authorization barriers exist, then asks the hub to archive its copy. +func (p *ClientCooperativeCloseProcess) publishAndFinalize(ctx context.Context, + id arkchannel.ID, terms arkchannel.Terms, backing arkchannel.Backing, + source arkchannel.VTXOBinding, + request arkchannel.CooperativeCloseRequest, + settlement arkchannel.CooperativeClose) error { + + state, err := p.local.QuiesceCooperativeClose( + ctx, id, terms, source, backing, request, + ) + if err != nil { + return fmt.Errorf("revalidate cooperative close state: %w", err) + } + if err := validateCooperativeCloseState( + arkchannel.PartyClient, state, settlement.Proposal, + ); err != nil { + return fmt.Errorf("revalidate signed cooperative close: %w", + err) + } + + if err := p.publisher.SettleCooperativeClose( + ctx, id, terms, source, request, settlement, + ); err != nil { + return err + } + service, err := p.stateService() + if err != nil { + return err + } + if _, err := service.RecordChannelEvent( + ctx, id, &arkchannel.CooperativeClosePublished{ + TxID: settlement.TxID, + }, + ); err != nil { + return err + } + txID, err := p.peer.PublishCooperativeClose( + ctx, id, settlement.TxID, + ) + if err != nil { + return err + } + if txID != settlement.TxID { + return fmt.Errorf("hub observed another cooperative close") + } + if _, err := service.RecordChannelEvent( + ctx, id, &arkchannel.CooperativeCloseFinalized{ + Party: arkchannel.PartyHub, + }, + ); err != nil { + return err + } + + return p.finalizeClient( + ctx, id, terms, backing, source, request, settlement, + ) +} + +// finalizeClient archives the local lnd channel and acknowledges that durable +// fact to the hub. +func (p *ClientCooperativeCloseProcess) finalizeClient(ctx context.Context, + id arkchannel.ID, terms arkchannel.Terms, backing arkchannel.Backing, + source arkchannel.VTXOBinding, + request arkchannel.CooperativeCloseRequest, + settlement arkchannel.CooperativeClose) error { + + if err := p.local.finalize( + terms, backing, source, request, settlement, + ); err != nil { + return err + } + service, err := p.stateService() + if err != nil { + return err + } + if _, err := service.RecordChannelEvent( + ctx, id, &arkchannel.CooperativeCloseFinalized{ + Party: arkchannel.PartyClient, + }, + ); err != nil { + return err + } + + return p.peer.AcknowledgeCooperativeCloseFinalized(ctx, id) +} + +// abort restores both quiesced links before ordinary OOR settlement starts. +func (p *ClientCooperativeCloseProcess) abort(ctx context.Context, + id arkchannel.ID, backing arkchannel.Backing, cause error) error { + + remoteErr := p.peer.AbortCooperativeClose(ctx, id) + p.local.ResumeCooperativeClose(backing) + service, serviceErr := p.stateService() + if serviceErr == nil { + _, serviceErr = service.RecordChannelEvent( + ctx, id, &arkchannel.CooperativeCloseAborted{}, + ) + } + + return errors.Join(cause, remoteErr, serviceErr) +} + +// stateService returns the bound durable channel FSM service. +func (p *ClientCooperativeCloseProcess) stateService() ( + CooperativeCloseStateSink, error) { + + p.mu.RLock() + defer p.mu.RUnlock() + if p.service == nil { + return nil, fmt.Errorf("client cooperative close service is " + + "not bound") + } + + return p.service, nil +} + +// HubCooperativeCloseProcess owns the operator endpoint's local actions. It +// never calls the client while serving a client request. +type HubCooperativeCloseProcess struct { + local *NativeCooperativeCloseEndpoint + delivery CooperativeCloseDeliverySource + observer CooperativeCloseObserver + defender CooperativeCloseDefender + + mu sync.RWMutex + service CooperativeCloseStateSink + locks cooperativeCloseOperationLock +} + +// NewHubCooperativeCloseProcess constructs the hub-owned close process. +func NewHubCooperativeCloseProcess(local *NativeCooperativeCloseEndpoint, + delivery CooperativeCloseDeliverySource, + observer CooperativeCloseObserver, + defender CooperativeCloseDefender) (*HubCooperativeCloseProcess, + error) { + + if local == nil || local.party != arkchannel.PartyHub { + return nil, fmt.Errorf("hub cooperative close endpoint is " + + "required") + } + if delivery == nil { + return nil, fmt.Errorf("hub cooperative close delivery " + + "source is required") + } + if observer == nil { + return nil, fmt.Errorf("hub cooperative close observer is " + + "required") + } + if defender == nil { + return nil, fmt.Errorf("hub cooperative close defender is " + + "required") + } + + return &HubCooperativeCloseProcess{ + local: local, delivery: delivery, observer: observer, + defender: defender, + }, nil +} + +// BindChannelEventSink attaches the process to its durable channel service. +func (p *HubCooperativeCloseProcess) BindChannelEventSink( + sink arkchannel.ChannelEventSink) error { + + service, ok := sink.(CooperativeCloseStateSink) + if !ok { + return fmt.Errorf("channel event sink lacks close barriers") + } + if err := p.local.BindChannelEventSink(sink); err != nil { + return err + } + p.mu.Lock() + defer p.mu.Unlock() + if p.service == nil { + p.service = service + } + + return nil +} + +// BeginCooperativeClose allocates the hub payout exactly once, persists the +// request, and returns the hub's authoritative clean channel state. +func (p *HubCooperativeCloseProcess) BeginCooperativeClose(ctx context.Context, + id arkchannel.ID, clientScript []byte) ( + arkchannel.CooperativeCloseRequest, CleanChannelState, + *arkchannel.CooperativeClose, error) { + + unlock := p.locks.lock(id) + defer unlock() + + service, err := p.stateService() + if err != nil { + return arkchannel.CooperativeCloseRequest{}, + CleanChannelState{}, nil, err + } + record, err := service.GetChannel(ctx, id) + if err != nil { + return arkchannel.CooperativeCloseRequest{}, + CleanChannelState{}, nil, err + } + var request arkchannel.CooperativeCloseRequest + if record.Snapshot.CooperativeCloseRequest != nil { + request = record.Snapshot.CooperativeCloseRequest.Clone() + if !bytes.Equal(request.ClientDeliveryScript, clientScript) { + return arkchannel.CooperativeCloseRequest{}, + CleanChannelState{}, nil, fmt.Errorf( + "channel already has another " + + "cooperative close request") + } + } else { + hubScript, err := p.delivery.CooperativeCloseDelivery(ctx, id) + if err != nil { + return arkchannel.CooperativeCloseRequest{}, + CleanChannelState{}, nil, fmt.Errorf( + "allocate hub close delivery: %w", err) + } + request = arkchannel.CooperativeCloseRequest{ + Initiator: arkchannel.PartyClient, + ClientDeliveryScript: append( + []byte(nil), clientScript..., + ), + HubDeliveryScript: append([]byte(nil), hubScript...), + } + if _, err := service.RequestCooperativeClose( + ctx, id, request, + ); err != nil { + return arkchannel.CooperativeCloseRequest{}, + CleanChannelState{}, nil, err + } + record, err = service.GetChannel(ctx, id) + if err != nil { + return arkchannel.CooperativeCloseRequest{}, + CleanChannelState{}, nil, err + } + } + if record.Snapshot.Source == nil || record.Snapshot.Backing == nil { + return arkchannel.CooperativeCloseRequest{}, CleanChannelState{}, //nolint:ll + nil, fmt.Errorf("hub cooperative close artifacts are " + + "incomplete") + } + cleanState, err := p.local.QuiesceCooperativeClose( + ctx, id, record.Snapshot.Terms, *record.Snapshot.Source, + *record.Snapshot.Backing, request, + ) + if err != nil { + return arkchannel.CooperativeCloseRequest{}, CleanChannelState{}, //nolint:ll + nil, err + } + var settlement *arkchannel.CooperativeClose + if record.Snapshot.CooperativeClose != nil { + closeCopy := record.Snapshot.CooperativeClose.Clone() + settlement = &closeCopy + } + + return request, cleanState, settlement, nil +} + +// CompleteCooperativeClose assembles and stores the three-signature spend. +func (p *HubCooperativeCloseProcess) CompleteCooperativeClose( + ctx context.Context, id arkchannel.ID, + proposal arkchannel.CooperativeCloseProposal) ( + arkchannel.CooperativeClose, error) { + + unlock := p.locks.lock(id) + defer unlock() + + service, err := p.stateService() + if err != nil { + return arkchannel.CooperativeClose{}, err + } + record, err := service.GetChannel(ctx, id) + if err != nil { + return arkchannel.CooperativeClose{}, err + } + if record.Snapshot.Source == nil || record.Snapshot.Backing == nil || + record.Snapshot.CooperativeCloseRequest == nil { + return arkchannel.CooperativeClose{}, fmt.Errorf("hub " + + "cooperative close artifacts are incomplete") + } + + return completeCooperativeClose( + ctx, p.local, id, record.Snapshot.Terms, + *record.Snapshot.Source, *record.Snapshot.Backing, + *record.Snapshot.CooperativeCloseRequest, proposal, + ) +} + +// AcknowledgeCooperativeCloseSigned records that the client durably stored the +// same hub-authorized OOR close. +func (p *HubCooperativeCloseProcess) AcknowledgeCooperativeCloseSigned( + ctx context.Context, id arkchannel.ID, + settlement arkchannel.CooperativeClose) error { + + unlock := p.locks.lock(id) + defer unlock() + + service, err := p.stateService() + if err != nil { + return err + } + _, err = service.RecordChannelEvent( + ctx, id, &arkchannel.CooperativeCloseSigned{ + Close: settlement, Party: arkchannel.PartyClient, + }, + ) + + return err +} + +// PublishCooperativeClose accepts the client-confirmed OOR session and archives +// the hub's native channel before replying. +func (p *HubCooperativeCloseProcess) PublishCooperativeClose( + ctx context.Context, id arkchannel.ID, txID chainhash.Hash) ( + chainhash.Hash, error) { + + unlock := p.locks.lock(id) + defer unlock() + + service, err := p.stateService() + if err != nil { + return chainhash.Hash{}, err + } + record, err := service.GetChannel(ctx, id) + if err != nil { + return chainhash.Hash{}, err + } + if record.Snapshot.CooperativeClose == nil { + return chainhash.Hash{}, fmt.Errorf("hub has no signed " + + "cooperative close") + } + if record.Snapshot.CooperativeClose.TxID != txID { + return chainhash.Hash{}, fmt.Errorf("client finalized " + + "another cooperative close") + } + if record.Snapshot.CooperativeClose.Proposal.HubOutput > 0 { + if err := p.observer.WaitForCooperativeClose( + ctx, txID, + record.Snapshot.CooperativeClose.Proposal.HubOutput, + ); err != nil { + return chainhash.Hash{}, fmt.Errorf("observe "+ + "cooperative close OOR: %w", err) + } + } + if record.Snapshot.Phase == arkchannel.PhaseCoopCloseSigned { + if _, err := service.RecordChannelEvent( + ctx, id, &arkchannel.CooperativeClosePublished{ + TxID: txID, + }, + ); err != nil { + return chainhash.Hash{}, err + } + } + record, err = service.GetChannel(ctx, id) + if err != nil { + return chainhash.Hash{}, err + } + if !record.Snapshot.HubCloseFinalized { + if _, err := service.ResumeChannelAction(ctx, id); err != nil { + return chainhash.Hash{}, err + } + } + + return txID, nil +} + +// AcknowledgeCooperativeCloseFinalized records client-side channel archival. +func (p *HubCooperativeCloseProcess) AcknowledgeCooperativeCloseFinalized( + ctx context.Context, id arkchannel.ID) error { + + unlock := p.locks.lock(id) + defer unlock() + + service, err := p.stateService() + if err != nil { + return err + } + _, err = service.RecordChannelEvent( + ctx, id, &arkchannel.CooperativeCloseFinalized{ + Party: arkchannel.PartyClient, + }, + ) + + return err +} + +// AbortCooperativeClose restores the hub link before signatures exist. +func (p *HubCooperativeCloseProcess) AbortCooperativeClose(ctx context.Context, + id arkchannel.ID) error { + + unlock := p.locks.lock(id) + defer unlock() + + service, err := p.stateService() + if err != nil { + return err + } + record, err := service.GetChannel(ctx, id) + if err != nil { + return err + } + if record.Snapshot.CooperativeClose != nil { + return fmt.Errorf("cannot abort a signed cooperative close") + } + if record.Snapshot.Backing == nil { + return fmt.Errorf("hub cooperative close backing is missing") + } + p.local.ResumeCooperativeClose(*record.Snapshot.Backing) + _, err = service.RecordChannelEvent( + ctx, id, &arkchannel.CooperativeCloseAborted{}, + ) + + return err +} + +// NegotiateCooperativeClose performs only the hub-local quiescence action. +func (p *HubCooperativeCloseProcess) NegotiateCooperativeClose( + ctx context.Context, id arkchannel.ID, terms arkchannel.Terms, + source arkchannel.VTXOBinding, backing arkchannel.Backing, + request arkchannel.CooperativeCloseRequest) error { + + _, err := p.local.QuiesceCooperativeClose( + ctx, id, terms, source, backing, request, + ) + + return err +} + +// FinalizeCooperativeClose archives the hub-local channel and records its +// acknowledgement without a nested client RPC. +func (p *HubCooperativeCloseProcess) FinalizeCooperativeClose( + ctx context.Context, id arkchannel.ID, terms arkchannel.Terms, + backing arkchannel.Backing, source arkchannel.VTXOBinding, + request arkchannel.CooperativeCloseRequest, + settlement arkchannel.CooperativeClose) error { + + if err := p.local.finalize( + terms, backing, source, request, settlement, + ); err != nil { + return err + } + service, err := p.stateService() + if err != nil { + return err + } + _, err = service.RecordChannelEvent( + ctx, id, &arkchannel.CooperativeCloseFinalized{ + Party: arkchannel.PartyHub, + }, + ) + + return err +} + +// DefendCooperativeClose starts ordinary wallet recovery for the exact hub +// replacement VTXO after a closed channel's source ancestry is spent. +func (p *HubCooperativeCloseProcess) DefendCooperativeClose(ctx context.Context, + id arkchannel.ID, terms arkchannel.Terms, source arkchannel.VTXOBinding, + settlement arkchannel.CooperativeClose) error { + + unlock := p.locks.lock(id) + defer unlock() + + service, err := p.stateService() + if err != nil { + return err + } + record, err := service.GetChannel(ctx, id) + if err != nil { + return err + } + snapshot := record.Snapshot + if snapshot.SourceConflict == nil { + return nil + } + if snapshot.Phase != arkchannel.PhaseClosed || + snapshot.CooperativeCloseRequest == nil || + snapshot.CooperativeClose == nil { + return fmt.Errorf("cooperative close defense state is " + + "incomplete") + } + if snapshot.CooperativeClose.TxID != settlement.TxID { + return fmt.Errorf("cooperative close defense settlement " + + "changed") + } + if settlement.Proposal.HubOutput == 0 { + return nil + } + outpoint, err := settlement.ReplacementOutPoint( + terms, source, *snapshot.CooperativeCloseRequest, + arkchannel.PartyHub, + ) + if err != nil { + return fmt.Errorf("derive hub cooperative close "+ + "replacement: %w", err) + } + if err := p.defender.DefendCooperativeClose(ctx, outpoint); err != nil { + return fmt.Errorf("defend hub cooperative close "+ + "replacement: %w", err) + } + + return nil +} + +// stateService returns the bound durable channel FSM service. +func (p *HubCooperativeCloseProcess) stateService() (CooperativeCloseStateSink, + error) { + + p.mu.RLock() + defer p.mu.RUnlock() + if p.service == nil { + return nil, fmt.Errorf("hub cooperative close service is not " + + "bound") + } + + return p.service, nil +} + +// HubCooperativeCloseExecutor adapts the process to NativeExecutor without +// overloading the legacy peer-facing PublishCooperativeClose method name. +type HubCooperativeCloseExecutor struct { + *HubCooperativeCloseProcess +} + +// PublishCooperativeClose defers initial OOR submission to the client. If the +// same durable action is emitted after the close because old source ancestry +// appeared, it instead protects the hub's replacement through its wallet. +func (e *HubCooperativeCloseExecutor) PublishCooperativeClose( + ctx context.Context, id arkchannel.ID, terms arkchannel.Terms, + source arkchannel.VTXOBinding, + settlement arkchannel.CooperativeClose) error { + + return e.DefendCooperativeClose( + ctx, id, terms, source, settlement, + ) +} + +var _ arkchannel.ChannelCooperativeCloser = (*ClientCooperativeCloseProcess)(nil) //nolint:ll + +var _ arkchannel.ChannelCooperativeCloser = (*HubCooperativeCloseExecutor)(nil) + +// processCooperativeCloseRequestsEqual compares every negotiated close term. +func processCooperativeCloseRequestsEqual( + a, b arkchannel.CooperativeCloseRequest) bool { + + return a.Initiator == b.Initiator && + bytes.Equal(a.ClientDeliveryScript, b.ClientDeliveryScript) && + bytes.Equal(a.HubDeliveryScript, b.HubDeliveryScript) +} diff --git a/lnruntime/process_cooperative_close_rpc.go b/lnruntime/process_cooperative_close_rpc.go new file mode 100644 index 000000000..a06011322 --- /dev/null +++ b/lnruntime/process_cooperative_close_rpc.go @@ -0,0 +1,656 @@ +package lnruntime + +import ( + "context" + "encoding/hex" + "fmt" + + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/wavelength/arkchannel" + mailboxrpc "github.com/lightninglabs/wavelength/mailbox/rpc" + "github.com/lightninglabs/wavelength/rpc/arkchannelrpc" +) + +const ( + cooperativeClosePeerService = "arkchannelrpc.ArkChannelPeerService" + + closeAckClientSigned = arkchannelrpc.CooperativeCloseAcknowledgement_COOPERATIVE_CLOSE_ACKNOWLEDGEMENT_CLIENT_SIGNED //nolint:ll + closeAckClientFinalized = arkchannelrpc.CooperativeCloseAcknowledgement_COOPERATIVE_CLOSE_ACKNOWLEDGEMENT_CLIENT_FINALIZED //nolint:ll +) + +var cooperativeClosePeerMethods = map[string]struct{}{ + "BeginCooperativeClose": {}, + "CompleteCooperativeClose": {}, + "AcknowledgeCooperativeClose": {}, + "PublishCooperativeClose": {}, + "AbortCooperativeClose": {}, +} + +// IsCooperativeClosePeerRoute reports whether one mailbox route belongs to the +// narrow direct-close peer protocol. +func IsCooperativeClosePeerRoute(service, method string) bool { + if service != cooperativeClosePeerService { + return false + } + + _, ok := cooperativeClosePeerMethods[method] + + return ok +} + +// MailboxCooperativeClosePeer adapts the generated mailbox client to the +// process-owned cooperative-close protocol. +type MailboxCooperativeClosePeer struct { + client *arkchannelrpc.ArkChannelPeerServiceMailboxClient +} + +// NewMailboxCooperativeClosePeer constructs a typed close peer. +func NewMailboxCooperativeClosePeer(client mailboxrpc.RPCClient) ( + *MailboxCooperativeClosePeer, error) { + + if client == nil { + return nil, fmt.Errorf("cooperative close mailbox client is " + + "required") + } + + return &MailboxCooperativeClosePeer{ + client: arkchannelrpc.NewArkChannelPeerServiceMailboxClient( + client, + ), + }, nil +} + +// BeginCooperativeClose asks the hub to persist and quiesce its endpoint. +func (p *MailboxCooperativeClosePeer) BeginCooperativeClose(ctx context.Context, + id arkchannel.ID, clientScript []byte) ( + arkchannel.CooperativeCloseRequest, CleanChannelState, + *arkchannel.CooperativeClose, error) { + + resp, err := p.client.BeginCooperativeClose( + ctx, &arkchannelrpc.BeginCooperativeCloseRequest{ + ChannelId: id[:], + ClientDeliveryScript: append( + []byte(nil), clientScript..., + ), + }, closeRPCOptions(id, "begin"), + ) + if err != nil { + return arkchannel.CooperativeCloseRequest{}, CleanChannelState{}, //nolint:ll + nil, err + } + request, err := cooperativeCloseRequestFromRPC(resp.GetRequest()) + if err != nil { + return arkchannel.CooperativeCloseRequest{}, CleanChannelState{}, //nolint:ll + nil, err + } + state, err := cleanChannelStateFromRPC(resp.GetCleanState()) + if err != nil { + return arkchannel.CooperativeCloseRequest{}, CleanChannelState{}, //nolint:ll + nil, err + } + var settlement *arkchannel.CooperativeClose + if resp.GetSettlement() != nil { + closeValue, err := cooperativeCloseFromRPC(resp.GetSettlement()) + if err != nil { + return arkchannel.CooperativeCloseRequest{}, + CleanChannelState{}, nil, err + } + settlement = &closeValue + } + + return request, state, settlement, nil +} + +// CompleteCooperativeClose submits the exact OOR proposal to the hub. +func (p *MailboxCooperativeClosePeer) CompleteCooperativeClose( + ctx context.Context, id arkchannel.ID, + proposal arkchannel.CooperativeCloseProposal) ( + arkchannel.CooperativeClose, error) { + + resp, err := p.client.CompleteCooperativeClose( + ctx, &arkchannelrpc.CompleteCooperativeCloseRequest{ + ChannelId: id[:], + Proposal: cooperativeCloseProposalToRPC( + proposal, + ), + }, closeRPCOptions(id, "complete"), + ) + if err != nil { + return arkchannel.CooperativeClose{}, err + } + + return cooperativeCloseFromRPC(resp.GetSettlement()) +} + +// AcknowledgeCooperativeCloseSigned records the client's durable authorization +// barrier at the hub. +func (p *MailboxCooperativeClosePeer) AcknowledgeCooperativeCloseSigned( + ctx context.Context, id arkchannel.ID, + settlement arkchannel.CooperativeClose) error { + + _, err := p.client.AcknowledgeCooperativeClose( + ctx, &arkchannelrpc.AcknowledgeCooperativeCloseRequest{ + ChannelId: id[:], + Acknowledgement: closeAckClientSigned, + Settlement: cooperativeCloseToRPC(settlement), + }, closeRPCOptions(id, "signed"), + ) + + return err +} + +// PublishCooperativeClose tells the hub which client-finalized OOR session was +// selected, then waits for hub-local archival. +func (p *MailboxCooperativeClosePeer) PublishCooperativeClose( + ctx context.Context, id arkchannel.ID, txID chainhash.Hash) ( + chainhash.Hash, error) { + + resp, err := p.client.PublishCooperativeClose( + ctx, &arkchannelrpc.PublishCooperativeCloseRequest{ + ChannelId: id[:], SettlementTxid: txID[:], + }, closeRPCOptions(id, "publish"), + ) + if err != nil { + return chainhash.Hash{}, err + } + if !resp.GetHubFinalized() { + return chainhash.Hash{}, fmt.Errorf("hub did not finalize " + + "cooperative close") + } + + return rpcHash("settlement txid", resp.GetSettlementTxid()) +} + +// AcknowledgeCooperativeCloseFinalized records local archival at the hub. +func (p *MailboxCooperativeClosePeer) AcknowledgeCooperativeCloseFinalized( + ctx context.Context, id arkchannel.ID) error { + + _, err := p.client.AcknowledgeCooperativeClose( + ctx, &arkchannelrpc.AcknowledgeCooperativeCloseRequest{ + ChannelId: id[:], + Acknowledgement: closeAckClientFinalized, + }, closeRPCOptions(id, "finalized"), + ) + + return err +} + +// AbortCooperativeClose releases the remote pre-signature barrier. +func (p *MailboxCooperativeClosePeer) AbortCooperativeClose(ctx context.Context, + id arkchannel.ID) error { + + _, err := p.client.AbortCooperativeClose( + ctx, &arkchannelrpc.AbortCooperativeCloseRequest{ + ChannelId: id[:], + }, closeRPCOptions(id, "abort"), + ) + + return err +} + +// CooperativeClosePeerRPCServer binds one authenticated client identity to a +// hub process. Channel IDs belonging to another client are rejected before a +// local lnd or signing side effect. +type CooperativeClosePeerRPCServer struct { + clientNode [33]byte + hub *HubCooperativeCloseProcess +} + +// NewCooperativeClosePeerRPCServer constructs a client-bound mailbox server. +func NewCooperativeClosePeerRPCServer(clientNode [33]byte, + hub *HubCooperativeCloseProcess) (*CooperativeClosePeerRPCServer, + error) { + + if clientNode == ([33]byte{}) { + return nil, fmt.Errorf("cooperative close client node is " + + "required") + } + if hub == nil { + return nil, fmt.Errorf("hub cooperative close process is " + + "required") + } + + return &CooperativeClosePeerRPCServer{ + clientNode: clientNode, + hub: hub, + }, nil +} + +// BeginCooperativeClose persists the request before returning clean lnd state. +func (s *CooperativeClosePeerRPCServer) BeginCooperativeClose( + ctx context.Context, req *arkchannelrpc.BeginCooperativeCloseRequest) ( + *arkchannelrpc.BeginCooperativeCloseResponse, error) { + + id, err := s.channelID(ctx, req.GetChannelId()) + if err != nil { + return nil, err + } + request, state, settlement, err := s.hub.BeginCooperativeClose( + ctx, id, req.GetClientDeliveryScript(), + ) + if err != nil { + return nil, err + } + resp := &arkchannelrpc.BeginCooperativeCloseResponse{ + Request: cooperativeCloseRequestToRPC(request), + CleanState: cleanChannelStateToRPC(state), + } + if settlement != nil { + resp.Settlement = cooperativeCloseToRPC(*settlement) + } + + return resp, nil +} + +// CompleteCooperativeClose validates the proposal and returns the hub's 3-of-3 +// OOR authorization. +func (s *CooperativeClosePeerRPCServer) CompleteCooperativeClose( + ctx context.Context, + req *arkchannelrpc.CompleteCooperativeCloseRequest) ( + *arkchannelrpc.CompleteCooperativeCloseResponse, error) { + + id, err := s.channelID(ctx, req.GetChannelId()) + if err != nil { + return nil, err + } + proposal, err := cooperativeCloseProposalFromRPC(req.GetProposal()) + if err != nil { + return nil, err + } + settlement, err := s.hub.CompleteCooperativeClose( + ctx, id, proposal, + ) + if err != nil { + return nil, err + } + + return &arkchannelrpc.CompleteCooperativeCloseResponse{ + Settlement: cooperativeCloseToRPC(settlement), + }, nil +} + +// AcknowledgeCooperativeClose accepts only the two client-owned barrier facts. +func (s *CooperativeClosePeerRPCServer) AcknowledgeCooperativeClose( + ctx context.Context, + req *arkchannelrpc.AcknowledgeCooperativeCloseRequest) ( + *arkchannelrpc.AcknowledgeCooperativeCloseResponse, error) { + + id, err := s.channelID(ctx, req.GetChannelId()) + if err != nil { + return nil, err + } + switch req.GetAcknowledgement() { + case closeAckClientSigned: + settlement, err := cooperativeCloseFromRPC(req.GetSettlement()) + if err != nil { + return nil, err + } + if err := s.hub.AcknowledgeCooperativeCloseSigned( + ctx, id, settlement, + ); err != nil { + return nil, err + } + + case closeAckClientFinalized: + if req.GetSettlement() != nil { + return nil, fmt.Errorf("finalized acknowledgement " + + "must not carry a settlement") + } + if err := s.hub.AcknowledgeCooperativeCloseFinalized( + ctx, id, + ); err != nil { + return nil, err + } + + default: + return nil, fmt.Errorf("unknown cooperative close " + + "acknowledgement") + } + record, err := s.hubRecord(ctx, id) + if err != nil { + return nil, err + } + + return &arkchannelrpc.AcknowledgeCooperativeCloseResponse{ + Channel: ArkChannelRecordToRPC(record), + }, nil +} + +// PublishCooperativeClose waits for OOR finalization and hub-local archival. +func (s *CooperativeClosePeerRPCServer) PublishCooperativeClose( + ctx context.Context, + req *arkchannelrpc.PublishCooperativeCloseRequest) ( + *arkchannelrpc.PublishCooperativeCloseResponse, error) { + + id, err := s.channelID(ctx, req.GetChannelId()) + if err != nil { + return nil, err + } + txID, err := rpcHash( + "settlement txid", req.GetSettlementTxid(), + ) + if err != nil { + return nil, err + } + txID, err = s.hub.PublishCooperativeClose(ctx, id, txID) + if err != nil { + return nil, err + } + + return &arkchannelrpc.PublishCooperativeCloseResponse{ + SettlementTxid: txID[:], + HubFinalized: true, + }, nil +} + +// AbortCooperativeClose restores the hub only before a signed artifact exists. +func (s *CooperativeClosePeerRPCServer) AbortCooperativeClose( + ctx context.Context, req *arkchannelrpc.AbortCooperativeCloseRequest) ( + *arkchannelrpc.AbortCooperativeCloseResponse, error) { + + id, err := s.channelID(ctx, req.GetChannelId()) + if err != nil { + return nil, err + } + if err := s.hub.AbortCooperativeClose(ctx, id); err != nil { + return nil, err + } + record, err := s.hubRecord(ctx, id) + if err != nil { + return nil, err + } + + return &arkchannelrpc.AbortCooperativeCloseResponse{ + Channel: ArkChannelRecordToRPC(record), + }, nil +} + +// channelID parses the request ID and verifies its authenticated client owner. +func (s *CooperativeClosePeerRPCServer) channelID(ctx context.Context, + raw []byte) (arkchannel.ID, error) { + + id, err := rpcChannelID(raw) + if err != nil { + return arkchannel.ID{}, err + } + record, err := s.hubRecord(ctx, id) + if err != nil { + return arkchannel.ID{}, err + } + if record.Snapshot.Terms.ClientNodeKey != s.clientNode { + return arkchannel.ID{}, fmt.Errorf("channel belongs to " + + "another client") + } + + return id, nil +} + +// hubRecord loads one channel from the hub's durable FSM service. +func (s *CooperativeClosePeerRPCServer) hubRecord(ctx context.Context, + id arkchannel.ID) (arkchannel.Record, error) { + + service, err := s.hub.stateService() + if err != nil { + return arkchannel.Record{}, err + } + + return service.GetChannel(ctx, id) +} + +// ArkChannelRecordToRPC converts the compact durable channel summary exposed +// by local and peer process RPCs. +func ArkChannelRecordToRPC(record arkchannel.Record) *arkchannelrpc.ArkChannel { + snapshot := record.Snapshot + channel := &arkchannelrpc.ArkChannel{ + ChannelId: snapshot.Terms.ID[:], + Kind: snapshot.Terms.Kind.String(), + Phase: snapshot.Phase.String(), + Funder: snapshot.Terms.Funder.String(), + CapacitySat: int64(snapshot.Terms.Capacity), + Revision: record.Revision, + ReservedScid: snapshot.Terms.ReservedSCID, + } + if snapshot.Source != nil { + channel.SourceOutpoint = snapshot.Source.OutPoint.String() + } + if snapshot.Backing != nil { + channel.ChannelPoint = snapshot.Backing.ChannelPoint.String() + } + if snapshot.CooperativeClose != nil { + channel.CooperativeCloseTxid = + snapshot.CooperativeClose.TxID[:] + } + + return channel +} + +// closeRPCOptions derives a stable idempotency key for one channel step. +func closeRPCOptions(id arkchannel.ID, step string) mailboxrpc.RPCOptions { + return mailboxrpc.RPCOptions{ + IdempotencyKey: "ark-close/" + hex.EncodeToString(id[:]) + "/" + + step, + } +} + +// rpcChannelID parses a fixed-width channel identifier. +func rpcChannelID(raw []byte) (arkchannel.ID, error) { + var id arkchannel.ID + if len(raw) != len(id) { + return id, fmt.Errorf("channel id must be %d bytes", len(id)) + } + copy(id[:], raw) + + return id, nil +} + +// rpcAmount rejects negative wire amounts before converting them. +func rpcAmount(name string, value int64) (btcutil.Amount, error) { + if value < 0 { + return 0, fmt.Errorf("%s must not be negative", name) + } + + return btcutil.Amount(value), nil +} + +// rpcHash parses one fixed-width transaction hash. +func rpcHash(name string, raw []byte) (chainhash.Hash, error) { + var hash chainhash.Hash + if len(raw) != len(hash) { + return hash, fmt.Errorf("%s must be %d bytes", name, len(hash)) + } + copy(hash[:], raw) + + return hash, nil +} + +// cooperativeCloseRequestToRPC serializes immutable negotiated close terms. +func cooperativeCloseRequestToRPC( + request arkchannel.CooperativeCloseRequest) *arkchannelrpc.CooperativeCloseRequest { //nolint:ll + + return &arkchannelrpc.CooperativeCloseRequest{ + ClientDeliveryScript: append( + []byte(nil), request.ClientDeliveryScript..., + ), + HubDeliveryScript: append( + []byte(nil), request.HubDeliveryScript..., + ), + } +} + +// cooperativeCloseRequestFromRPC validates negotiated close terms from RPC. +func cooperativeCloseRequestFromRPC( + request *arkchannelrpc.CooperativeCloseRequest) ( + arkchannel.CooperativeCloseRequest, error) { + + if request == nil { + return arkchannel.CooperativeCloseRequest{}, fmt.Errorf( + "cooperative close request is required") + } + result := arkchannel.CooperativeCloseRequest{ + Initiator: arkchannel.PartyClient, + ClientDeliveryScript: append( + []byte(nil), request.GetClientDeliveryScript()..., + ), + HubDeliveryScript: append( + []byte(nil), request.GetHubDeliveryScript()..., + ), + } + if err := result.Validate(); err != nil { + return arkchannel.CooperativeCloseRequest{}, err + } + + return result, nil +} + +// cleanChannelStateToRPC serializes one quiesced lnd channel snapshot. +func cleanChannelStateToRPC( + state CleanChannelState) *arkchannelrpc.CleanChannelState { + + return &arkchannelrpc.CleanChannelState{ + ChannelPointTxid: state.ChannelPoint.Hash[:], + ChannelPointIndex: state.ChannelPoint.Index, + LocalBalanceSat: int64(state.LocalBalance), + RemoteBalanceSat: int64(state.RemoteBalance), + CapacitySat: int64(state.Capacity), + CommitmentHeight: state.CommitmentHeight, + LocalInitiator: state.LocalInitiator, + } +} + +// cleanChannelStateFromRPC validates one quiesced lnd channel snapshot. +func cleanChannelStateFromRPC(state *arkchannelrpc.CleanChannelState) ( + CleanChannelState, error) { + + if state == nil { + return CleanChannelState{}, fmt.Errorf("clean channel state " + + "is required") + } + hash, err := rpcHash("channel point txid", state.GetChannelPointTxid()) + if err != nil { + return CleanChannelState{}, err + } + local, err := rpcAmount("local balance", state.GetLocalBalanceSat()) + if err != nil { + return CleanChannelState{}, err + } + remote, err := rpcAmount("remote balance", state.GetRemoteBalanceSat()) + if err != nil { + return CleanChannelState{}, err + } + capacity, err := rpcAmount("capacity", state.GetCapacitySat()) + if err != nil { + return CleanChannelState{}, err + } + + return CleanChannelState{ + ChannelPoint: wire.OutPoint{ + Hash: hash, Index: state.GetChannelPointIndex(), + }, + LocalBalance: local, + RemoteBalance: remote, + Capacity: capacity, + CommitmentHeight: state.GetCommitmentHeight(), + LocalInitiator: state.GetLocalInitiator(), + }, nil +} + +// cooperativeCloseProposalToRPC serializes the unsigned OOR checkpoint. +func cooperativeCloseProposalToRPC( + proposal arkchannel.CooperativeCloseProposal) *arkchannelrpc.CooperativeCloseProposal { //nolint:ll + + return &arkchannelrpc.CooperativeCloseProposal{ + Transaction: append([]byte(nil), proposal.Transaction...), + CommitmentHeight: proposal.CommitmentHeight, + ClientBalanceSat: int64(proposal.ClientBalance), + HubBalanceSat: int64(proposal.HubBalance), + ClientOutputSat: int64(proposal.ClientOutput), + HubOutputSat: int64(proposal.HubOutput), + } +} + +// cooperativeCloseProposalFromRPC validates the unsigned OOR checkpoint. +func cooperativeCloseProposalFromRPC( + proposal *arkchannelrpc.CooperativeCloseProposal) ( + arkchannel.CooperativeCloseProposal, error) { + + if proposal == nil { + return arkchannel.CooperativeCloseProposal{}, fmt.Errorf( + "cooperative close proposal is required") + } + clientBalance, err := rpcAmount( + "client balance", proposal.GetClientBalanceSat(), + ) + if err != nil { + return arkchannel.CooperativeCloseProposal{}, err + } + hubBalance, err := rpcAmount("hub balance", proposal.GetHubBalanceSat()) + if err != nil { + return arkchannel.CooperativeCloseProposal{}, err + } + clientOutput, err := rpcAmount( + "client output", proposal.GetClientOutputSat(), + ) + if err != nil { + return arkchannel.CooperativeCloseProposal{}, err + } + hubOutput, err := rpcAmount("hub output", proposal.GetHubOutputSat()) + if err != nil { + return arkchannel.CooperativeCloseProposal{}, err + } + + return arkchannel.CooperativeCloseProposal{ + Transaction: append( + []byte(nil), proposal.GetTransaction()..., + ), + CommitmentHeight: proposal.GetCommitmentHeight(), + ClientBalance: clientBalance, + HubBalance: hubBalance, + ClientOutput: clientOutput, + HubOutput: hubOutput, + }, nil +} + +// cooperativeCloseToRPC serializes the hub-authorized OOR close. +func cooperativeCloseToRPC( + settlement arkchannel.CooperativeClose) *arkchannelrpc.CooperativeClose { //nolint:ll + + return &arkchannelrpc.CooperativeClose{ + Proposal: cooperativeCloseProposalToRPC(settlement.Proposal), + Transaction: append([]byte(nil), settlement.Transaction...), + Txid: settlement.TxID[:], + } +} + +// cooperativeCloseFromRPC validates the complete settlement artifact. +func cooperativeCloseFromRPC(settlement *arkchannelrpc.CooperativeClose) ( + arkchannel.CooperativeClose, error) { + + if settlement == nil { + return arkchannel.CooperativeClose{}, fmt.Errorf( + "cooperative close settlement is required") + } + proposal, err := cooperativeCloseProposalFromRPC( + settlement.GetProposal(), + ) + if err != nil { + return arkchannel.CooperativeClose{}, err + } + txID, err := rpcHash("cooperative close txid", settlement.GetTxid()) + if err != nil { + return arkchannel.CooperativeClose{}, err + } + + return arkchannel.CooperativeClose{ + Proposal: proposal, + Transaction: append( + []byte(nil), settlement.GetTransaction()..., + ), + TxID: txID, + }, nil +} + +var _ ProcessCooperativeClosePeer = (*MailboxCooperativeClosePeer)(nil) + +var _ arkchannelrpc.ArkChannelPeerServiceMailboxServer = (*CooperativeClosePeerRPCServer)(nil) //nolint:ll diff --git a/lnruntime/process_cooperative_close_test.go b/lnruntime/process_cooperative_close_test.go new file mode 100644 index 000000000..605d02862 --- /dev/null +++ b/lnruntime/process_cooperative_close_test.go @@ -0,0 +1,182 @@ +package lnruntime + +import ( + "context" + "fmt" + "sync" + + "github.com/lightninglabs/wavelength/arkchannel" + mailboxrpc "github.com/lightninglabs/wavelength/mailbox/rpc" + "google.golang.org/protobuf/proto" +) + +type loopbackCooperativeCloseRPC struct { + mux *mailboxrpc.ServeMux + + mu sync.Mutex + responses map[string]loopbackCooperativeCloseResponse +} + +type loopbackCooperativeCloseResponse struct { + message proto.Message + err error +} + +// newLoopbackCooperativeCloseRPC constructs an in-process generated RPC edge. +func newLoopbackCooperativeCloseRPC( + mux *mailboxrpc.ServeMux) *loopbackCooperativeCloseRPC { + + return &loopbackCooperativeCloseRPC{ + mux: mux, + responses: make(map[string]loopbackCooperativeCloseResponse), + } +} + +// SendRPC invokes the generated mailbox mux and buffers its typed response. +func (c *loopbackCooperativeCloseRPC) SendRPC(ctx context.Context, + method mailboxrpc.ServiceMethod, request proto.Message, + options mailboxrpc.RPCOptions) (mailboxrpc.SendResult, error) { + + correlationID := options.CorrelationID + if correlationID == "" { + correlationID = options.IdempotencyKey + } + if correlationID == "" { + return mailboxrpc.SendResult{}, fmt.Errorf("correlation id " + + "is required") + } + payload, err := proto.Marshal(request) + if err != nil { + return mailboxrpc.SendResult{}, err + } + response, serveErr := c.mux.ServeRPC( + ctx, method.Service, method.Method, payload, + ) + + c.mu.Lock() + c.responses[correlationID] = loopbackCooperativeCloseResponse{ + message: response, + err: serveErr, + } + c.mu.Unlock() + + return mailboxrpc.SendResult{ + CorrelationID: correlationID, + IdempotencyKey: options.IdempotencyKey, + }, nil +} + +// AwaitRPC returns the correlated generated response to the caller. +func (c *loopbackCooperativeCloseRPC) AwaitRPC(_ context.Context, + correlationID string, response proto.Message) error { + + c.mu.Lock() + result, ok := c.responses[correlationID] + delete(c.responses, correlationID) + c.mu.Unlock() + if !ok { + return fmt.Errorf("response %q not found", correlationID) + } + if result.err != nil { + return result.err + } + payload, err := proto.Marshal(result.message) + if err != nil { + return err + } + + return (proto.UnmarshalOptions{ + DiscardUnknown: true, + }).Unmarshal(payload, + response, + ) +} + +type lossyCooperativeClosePeer struct { + ProcessCooperativeClosePeer + + mu sync.Mutex + loseBegin bool + loseComplete bool +} + +// BeginCooperativeClose can lose one response after the hub persisted it. +func (p *lossyCooperativeClosePeer) BeginCooperativeClose(ctx context.Context, + id arkchannel.ID, clientScript []byte) ( + arkchannel.CooperativeCloseRequest, CleanChannelState, + *arkchannel.CooperativeClose, error) { + + request, state, settlement, err := + p.ProcessCooperativeClosePeer.BeginCooperativeClose( + ctx, id, clientScript, + ) + if err != nil { + return request, state, settlement, err + } + p.mu.Lock() + lose := p.loseBegin + p.loseBegin = false + p.mu.Unlock() + if lose { + return arkchannel.CooperativeCloseRequest{}, CleanChannelState{}, //nolint:ll + nil, fmt.Errorf("injected lost begin response") + } + + return request, state, settlement, nil +} + +// CompleteCooperativeClose can lose one fully signed response after storage. +func (p *lossyCooperativeClosePeer) CompleteCooperativeClose( + ctx context.Context, id arkchannel.ID, + proposal arkchannel.CooperativeCloseProposal) ( + arkchannel.CooperativeClose, error) { + + settlement, err := + p.ProcessCooperativeClosePeer.CompleteCooperativeClose( + ctx, id, proposal, + ) + if err != nil { + return arkchannel.CooperativeClose{}, err + } + p.mu.Lock() + lose := p.loseComplete + p.loseComplete = false + p.mu.Unlock() + if lose { + return arkchannel.CooperativeClose{}, fmt.Errorf("injected " + + "lost complete response") + } + + return settlement, nil +} + +type stableCooperativeCloseDelivery struct { + mu sync.Mutex + script []byte + calls int +} + +// CooperativeCloseDelivery returns the same wallet script for every retry. +func (s *stableCooperativeCloseDelivery) CooperativeCloseDelivery( + _ context.Context, _ arkchannel.ID) ([]byte, error) { + + s.mu.Lock() + defer s.mu.Unlock() + s.calls++ + + return append([]byte(nil), s.script...), nil +} + +// callCount returns the number of payout allocation attempts. +func (s *stableCooperativeCloseDelivery) callCount() int { + s.mu.Lock() + defer s.mu.Unlock() + + return s.calls +} + +var _ mailboxrpc.RPCClient = (*loopbackCooperativeCloseRPC)(nil) + +var _ ProcessCooperativeClosePeer = (*lossyCooperativeClosePeer)(nil) + +var _ CooperativeCloseDeliverySource = (*stableCooperativeCloseDelivery)(nil) diff --git a/lnruntime/process_funding_rpc.go b/lnruntime/process_funding_rpc.go new file mode 100644 index 000000000..e6976adc7 --- /dev/null +++ b/lnruntime/process_funding_rpc.go @@ -0,0 +1,1914 @@ +package lnruntime + +import ( + "bytes" + "context" + "encoding/hex" + "fmt" + "math" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/psbt/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/wavelength/arkchannel" + mailboxrpc "github.com/lightninglabs/wavelength/mailbox/rpc" + "github.com/lightninglabs/wavelength/rpc/arkchannelrpc" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/lntypes" +) + +const fundingPeerService = "arkchannelrpc.ArkChannelFundingPeerService" + +const recoveryPackageInstalledEvent = arkchannelrpc.ChannelEventType_CHANNEL_EVENT_TYPE_RECOVERY_PACKAGE_INSTALLED //nolint:ll + +var fundingPeerMethods = map[string]struct{}{ + "GetPeerInfo": {}, + "RegisterPromotion": {}, + "RegisterReceiveIntent": {}, + "GetFundingChannel": {}, + "BindPreparedOOR": {}, + "SignBacking": {}, + "InstallBacking": {}, + "InstallRecoveryPackage": {}, + "ExportRecoveryPackage": {}, + "FundingFinalized": {}, + "ChannelActive": {}, + "ApplyChannelEvent": {}, + "CreateInvoice": {}, + "PayInvoice": {}, + "PrepareOutgoingPayment": {}, + "CancelOutgoingPayment": {}, + "RegisterIncomingPayment": {}, +} + +// OutgoingPaymentPreparation fixes the private source amount and active +// channel before the client dispatches its held same-hash HTLC. +type OutgoingPaymentPreparation struct { + PaymentHash lntypes.Hash + PrivateAmount btcutil.Amount + Fee btcutil.Amount + ChannelID arkchannel.ID + ReservedSCID uint64 +} + +// PaymentBridgeCoordinator is the narrow swapserver-owned public Lightning +// boundary exposed to one authenticated private channel endpoint. +type PaymentBridgeCoordinator interface { + PrepareOutgoingPayment(context.Context, [33]byte, string, + btcutil.Amount) (OutgoingPaymentPreparation, error) + + CancelOutgoingPayment(context.Context, [33]byte, lntypes.Hash, + string) error + + RegisterIncomingPayment(context.Context, [33]byte, lntypes.Hash, + btcutil.Amount, uint64) (btcutil.Amount, error) +} + +// FundingPeerInfo contains immutable hub policy needed to construct a client +// funded channel intent. +type FundingPeerInfo struct { + HubNodeKey [33]byte + HubArkKey [33]byte + HubChannelKey [33]byte + HubFunderKey [33]byte + ArkOperatorKey [33]byte + ChannelDelay uint32 + FunderDelay uint32 + MinimumExitDelay uint32 +} + +// Validate rejects incomplete channel policy before terms are registered. +func (i FundingPeerInfo) Validate() error { + for name, key := range map[string][33]byte{ + "hub node": i.HubNodeKey, + "hub Ark": i.HubArkKey, + "hub channel": i.HubChannelKey, + "hub funder": i.HubFunderKey, + "Ark operator": i.ArkOperatorKey, + } { + if _, err := parseCompressedKey(name, key); err != nil { + return err + } + } + if i.ChannelDelay == 0 || i.FunderDelay <= i.ChannelDelay || + i.MinimumExitDelay == 0 { + return fmt.Errorf("invalid Ark channel delay policy") + } + + return nil +} + +// ProcessFundingPeer is the authenticated coordination surface used by one +// channel-creation process. Native lnd funding and payment dispatch use their +// own narrower interfaces even though the mailbox adapter implements all +// three. +type ProcessFundingPeer interface { + GetPeerInfo(context.Context) (FundingPeerInfo, error) + + RegisterPromotion(context.Context, + arkchannel.Terms) (arkchannel.Record, error) + + RegisterReceiveIntent(context.Context, + arkchannel.Terms) (arkchannel.Record, error) + + GetFundingChannel(context.Context, + arkchannel.ID) (FundingChannelState, error) + + BindPreparedOOR(context.Context, arkchannel.ID, + arkchannel.VTXOBinding) (arkchannel.Record, error) + + ExportRecoveryPackage(context.Context, + arkchannel.ID) (arkchannel.RecoveryPackage, error) + + ApplyChannelEvent(context.Context, arkchannel.ID, + arkchannel.Event) (arkchannel.Record, error) +} + +// ProcessPaymentPeer is the private-channel payment surface exposed by the +// authenticated hub endpoint. +type ProcessPaymentPeer interface { + CreateInvoice(context.Context, arkchannel.ID, + btcutil.Amount) (lntypes.Hash, error) + + PayInvoice(context.Context, arkchannel.ID, lntypes.Hash, + btcutil.Amount) error + + PrepareOutgoingPayment(context.Context, string, + btcutil.Amount) (OutgoingPaymentPreparation, error) + + CancelOutgoingPayment(context.Context, lntypes.Hash, string) error + + RegisterIncomingPayment(context.Context, lntypes.Hash, btcutil.Amount, + uint64) (btcutil.Amount, error) +} + +// FundingChannelState is the minimal remote channel-FSM view needed to bind a +// hub-prepared source and mirror terminal pre-commit failure. +type FundingChannelState struct { + Terms arkchannel.Terms + Source *arkchannel.VTXOBinding + Backing *arkchannel.Backing + Phase arkchannel.Phase + OORFinalized bool + OORAborted bool + RecoveryReady bool + Failure string + Revision uint64 +} + +// IsFundingPeerRoute reports whether one mailbox route belongs to the channel +// creation and private payment protocol. +func IsFundingPeerRoute(service, method string) bool { + if service != fundingPeerService { + return false + } + + _, ok := fundingPeerMethods[method] + + return ok +} + +// MailboxFundingPeer adapts generated mailbox calls to ProcessFundingPeer. +type MailboxFundingPeer struct { + client *arkchannelrpc.ArkChannelFundingPeerServiceMailboxClient +} + +// NewMailboxFundingPeer constructs a typed funding peer. +func NewMailboxFundingPeer(client mailboxrpc.RPCClient) (*MailboxFundingPeer, + error) { + + if client == nil { + return nil, fmt.Errorf("Ark channel funding mailbox client " + + "is required") + } + + return &MailboxFundingPeer{ + client: arkchannelrpc. + NewArkChannelFundingPeerServiceMailboxClient( + client, + ), + }, nil +} + +// GetPeerInfo loads the hub's immutable channel policy. +func (p *MailboxFundingPeer) GetPeerInfo(ctx context.Context) (FundingPeerInfo, + error) { + + response, err := p.client.GetPeerInfo( + ctx, &arkchannelrpc.GetPeerInfoRequest{}, + ) + if err != nil { + return FundingPeerInfo{}, err + } + + return fundingPeerInfoFromRPC(response) +} + +// RegisterPromotion registers the responder before the client prepares value. +func (p *MailboxFundingPeer) RegisterPromotion(ctx context.Context, + terms arkchannel.Terms) (arkchannel.Record, error) { + + response, err := p.client.RegisterPromotion( + ctx, &arkchannelrpc.RegisterPromotionRequest{ + Terms: channelTermsToRPC(terms), + }, fundingRPCOptions(terms.ID, "register"), + ) + if err != nil { + return arkchannel.Record{}, err + } + + return recordSummaryFromRPC(terms, response.GetChannel()) +} + +// RegisterReceiveIntent registers a hub-funded channel before public routing. +func (p *MailboxFundingPeer) RegisterReceiveIntent(ctx context.Context, + terms arkchannel.Terms) (arkchannel.Record, error) { + + response, err := p.client.RegisterReceiveIntent( + ctx, &arkchannelrpc.RegisterReceiveIntentRequest{ + Terms: channelTermsToRPC(terms), + }, fundingRPCOptions(terms.ID, "register-receive-intent"), + ) + if err != nil { + return arkchannel.Record{}, err + } + + return recordSummaryFromRPC(terms, response.GetChannel()) +} + +// GetFundingChannel returns the remote channel coordination facts. +func (p *MailboxFundingPeer) GetFundingChannel(ctx context.Context, + id arkchannel.ID) (FundingChannelState, error) { + + response, err := p.client.GetFundingChannel( + ctx, &arkchannelrpc.GetFundingChannelRequest{ + ChannelId: id[:], + }, fundingRPCOptions(id, "get-funding-channel"), + ) + if err != nil { + return FundingChannelState{}, err + } + terms, err := channelTermsFromRPC(response.GetTerms()) + if err != nil { + return FundingChannelState{}, err + } + if terms.ID != id { + return FundingChannelState{}, fmt.Errorf("funding peer " + + "returned another channel") + } + state := FundingChannelState{ + Terms: terms, + Phase: arkchannel.Phase(response.GetPhase()), + OORFinalized: response.GetOorFinalized(), + OORAborted: response.GetOorAborted(), + RecoveryReady: response.GetRecoveryReady(), + Failure: response.GetFailure(), + Revision: response.GetRevision(), + } + if response.GetBinding() != nil { + binding, err := channelBindingFromRPC(response.GetBinding()) + if err != nil { + return FundingChannelState{}, err + } + state.Source = &binding + } + if response.GetBacking() != nil { + backing, err := channelBackingFromRPC(response.GetBacking()) + if err != nil { + return FundingChannelState{}, err + } + state.Backing = &backing + } + + return state, nil +} + +// BindPreparedOOR installs the exact prepared output at the responder before +// the funder emits an lnd open-channel message. +func (p *MailboxFundingPeer) BindPreparedOOR(ctx context.Context, + id arkchannel.ID, binding arkchannel.VTXOBinding) (arkchannel.Record, + error) { + + response, err := p.client.BindPreparedOOR( + ctx, &arkchannelrpc.BindPreparedOORRequest{ + ChannelId: id[:], Binding: channelBindingToRPC(binding), + }, fundingRPCOptions(id, "bind"), + ) + if err != nil { + return arkchannel.Record{}, err + } + if response.GetChannel() == nil { + return arkchannel.Record{}, fmt.Errorf("funding peer " + + "returned an empty channel") + } + + return arkchannel.Record{ + Revision: response.GetChannel().GetRevision(), + }, nil +} + +// SignBacking asks the remote endpoint to validate and sign the exact funding +// PSBT against its own lnd reservation. +func (p *MailboxFundingPeer) SignBacking(ctx context.Context, id arkchannel.ID, + terms arkchannel.Terms, binding arkchannel.VTXOBinding, + packet *psbt.Packet) (input.Signature, error) { + + if packet == nil { + return nil, fmt.Errorf("lnd funding PSBT is required") + } + var encoded bytes.Buffer + if err := packet.Serialize(&encoded); err != nil { + return nil, err + } + response, err := p.client.SignBacking( + ctx, &arkchannelrpc.SignBackingRequest{ + ChannelId: id[:], Terms: channelTermsToRPC(terms), + Binding: channelBindingToRPC(binding), + FundingPsbt: encoded.Bytes(), + }, fundingRPCOptions(id, "sign-backing"), + ) + if err != nil { + return nil, err + } + + return schnorr.ParseSignature(response.GetSignature()) +} + +// InstallBacking registers the fully signed transaction at the remote lnd +// endpoint and its durable Ark FSM. +func (p *MailboxFundingPeer) InstallBacking(ctx context.Context, + id arkchannel.ID, terms arkchannel.Terms, + binding arkchannel.VTXOBinding, backing arkchannel.Backing) error { + + _, err := p.client.InstallBacking( + ctx, &arkchannelrpc.InstallBackingRequest{ + ChannelId: id[:], Terms: channelTermsToRPC(terms), + Binding: channelBindingToRPC(binding), + Backing: channelBackingToRPC(backing), + }, fundingRPCOptions(id, "install-backing"), + ) + + return err +} + +// InstallRecoveryPackage installs the complete recovery-only source package +// and arms its ancestry watches at the remote endpoint. +func (p *MailboxFundingPeer) InstallRecoveryPackage(ctx context.Context, + id arkchannel.ID, terms arkchannel.Terms, + binding arkchannel.VTXOBinding, + recovery arkchannel.RecoveryPackage) error { + + message, err := ChannelRecoveryToRPC(recovery) + if err != nil { + return err + } + _, err = p.client.InstallRecoveryPackage( + ctx, &arkchannelrpc.InstallRecoveryPackageRequest{ + ChannelId: id[:], Terms: channelTermsToRPC(terms), + Binding: channelBindingToRPC(binding), + Recovery: message, + }, fundingRPCOptions(id, "install-recovery"), + ) + + return err +} + +// ExportRecoveryPackage fetches the hub-funded channel source lineage. +func (p *MailboxFundingPeer) ExportRecoveryPackage(ctx context.Context, + id arkchannel.ID) (arkchannel.RecoveryPackage, error) { + + response, err := p.client.ExportRecoveryPackage( + ctx, &arkchannelrpc.ExportRecoveryPackageRequest{ + ChannelId: id[:], + }, fundingRPCOptions(id, "export-recovery-package"), + ) + if err != nil { + return arkchannel.RecoveryPackage{}, err + } + // The recovery message includes and validates its exact source binding. + // Reconstruct that binding from the response rather than introducing a + // separate channel-state query into the funding protocol. + binding, err := channelBindingFromRPC(response.GetBinding()) + if err != nil { + return arkchannel.RecoveryPackage{}, err + } + recovery, err := ChannelRecoveryFromRPC( + response.GetRecovery(), binding, + ) + if err != nil { + return arkchannel.RecoveryPackage{}, err + } + + return recovery, nil +} + +// FundingFinalized queries the remote lnd durability barrier. +func (p *MailboxFundingPeer) FundingFinalized(ctx context.Context, + terms arkchannel.Terms, backing arkchannel.Backing) (bool, error) { + + response, err := p.client.FundingFinalized( + ctx, fundingStatusRequest(terms, backing), + ) + if err != nil { + return false, err + } + + return response.GetReady(), nil +} + +// ChannelActive queries the remote lnd link state. +func (p *MailboxFundingPeer) ChannelActive(ctx context.Context, + terms arkchannel.Terms, backing arkchannel.Backing) (bool, error) { + + response, err := p.client.ChannelActive( + ctx, fundingStatusRequest(terms, backing), + ) + if err != nil { + return false, err + } + + return response.GetReady(), nil +} + +// ApplyChannelEvent records one cross-endpoint funding barrier. +func (p *MailboxFundingPeer) ApplyChannelEvent(ctx context.Context, + id arkchannel.ID, event arkchannel.Event) (arkchannel.Record, error) { + + request, step, err := channelEventToRPC(id, event) + if err != nil { + return arkchannel.Record{}, err + } + response, err := p.client.ApplyChannelEvent( + ctx, request, fundingRPCOptions(id, step), + ) + if err != nil { + return arkchannel.Record{}, err + } + if response.GetChannel() == nil { + return arkchannel.Record{}, fmt.Errorf("funding peer " + + "returned an empty channel") + } + + return arkchannel.Record{ + Revision: response.GetChannel().GetRevision(), + }, nil +} + +// CreateInvoice creates an invoice in the remote native lnd registry. +func (p *MailboxFundingPeer) CreateInvoice(ctx context.Context, + id arkchannel.ID, amount btcutil.Amount) (lntypes.Hash, error) { + + response, err := p.client.CreateInvoice( + ctx, &arkchannelrpc.PeerInvoiceRequest{ + ChannelId: id[:], AmountSat: int64(amount), + }, + ) + if err != nil { + return lntypes.Hash{}, err + } + + return rpcPaymentHash(response.GetPaymentHash()) +} + +// PayInvoice pays one remote invoice over the active private channel. +func (p *MailboxFundingPeer) PayInvoice(ctx context.Context, id arkchannel.ID, + hash lntypes.Hash, amount btcutil.Amount) error { + + response, err := p.client.PayInvoice( + ctx, &arkchannelrpc.PeerPayInvoiceRequest{ + ChannelId: id[:], PaymentHash: hash[:], + AmountSat: int64(amount), + }, fundingRPCOptions(id, "pay/"+hex.EncodeToString(hash[:])), + ) + if err != nil { + return err + } + if !response.GetSettled() { + return fmt.Errorf("remote native lnd payment did not settle") + } + + return nil +} + +// PrepareOutgoingPayment asks the operator to hold and dispatch one public +// invoice using a private source HTLC. +func (p *MailboxFundingPeer) PrepareOutgoingPayment(ctx context.Context, + paymentRequest string, maxFee btcutil.Amount) ( + OutgoingPaymentPreparation, error) { + + if maxFee < 0 { + return OutgoingPaymentPreparation{}, fmt.Errorf("maximum fee " + + "cannot be negative") + } + response, err := p.client.PrepareOutgoingPayment( + ctx, &arkchannelrpc.PrepareOutgoingPaymentRequest{ + PaymentRequest: paymentRequest, + MaxFeeSat: uint64(maxFee), + }, + ) + if err != nil { + return OutgoingPaymentPreparation{}, err + } + hash, err := rpcPaymentHash(response.GetPaymentHash()) + if err != nil { + return OutgoingPaymentPreparation{}, err + } + amount, err := positiveRPCAmount( + "private payment amount", response.GetPrivateAmountSat(), + ) + if err != nil { + return OutgoingPaymentPreparation{}, err + } + if response.GetFeeSat() < 0 { + return OutgoingPaymentPreparation{}, fmt.Errorf("payment fee " + + "cannot be negative") + } + id, err := rpcChannelID(response.GetChannelId()) + if err != nil { + return OutgoingPaymentPreparation{}, err + } + if response.GetReservedScid() == 0 { + return OutgoingPaymentPreparation{}, fmt.Errorf("private " + + "payment SCID is required") + } + + return OutgoingPaymentPreparation{ + PaymentHash: hash, PrivateAmount: amount, + Fee: btcutil.Amount(response.GetFeeSat()), ChannelID: id, + ReservedSCID: response.GetReservedScid(), + }, nil +} + +// CancelOutgoingPayment releases an outgoing hold invoice that the client +// could not lock before the operator dispatched its public destination. +func (p *MailboxFundingPeer) CancelOutgoingPayment(ctx context.Context, + hash lntypes.Hash, reason string) error { + + if reason == "" { + return fmt.Errorf("outgoing payment cancellation reason is " + + "required") + } + response, err := p.client.CancelOutgoingPayment( + ctx, &arkchannelrpc.CancelOutgoingPaymentRequest{ + PaymentHash: hash[:], Reason: reason, + }, fundingRPCOptions( + arkchannel.ID(hash), + "cancel-outgoing-payment", + ), + ) + if err != nil { + return err + } + if !response.GetCancelled() { + return fmt.Errorf("outgoing payment was not cancelled") + } + + return nil +} + +// RegisterIncomingPayment binds a future public route to this authenticated +// client after its native invoice exists. +func (p *MailboxFundingPeer) RegisterIncomingPayment(ctx context.Context, + hash lntypes.Hash, amount btcutil.Amount, reservedSCID uint64) ( + btcutil.Amount, error) { + + if amount <= 0 || reservedSCID == 0 { + return 0, fmt.Errorf("incoming payment amount and SCID are " + + "required") + } + response, err := p.client.RegisterIncomingPayment( + ctx, &arkchannelrpc.RegisterIncomingPaymentRequest{ + PaymentHash: hash[:], AmountSat: int64(amount), + ReservedScid: reservedSCID, + }, + ) + if err != nil { + return 0, err + } + if !response.GetRegistered() { + return 0, fmt.Errorf("incoming payment was not registered") + } + capacity, err := positiveRPCAmount( + "incoming channel capacity", response.GetChannelCapacitySat(), + ) + if err != nil { + return 0, err + } + + return capacity, nil +} + +// FundingPeerRPCServerConfig contains one authenticated remote endpoint and +// the local native lnd process it may coordinate with. +type FundingPeerRPCServerConfig struct { + RemoteNode [33]byte + Info FundingPeerInfo + Service *arkchannel.Service + Funding *NativeFundingEndpoint + Node *NativeNode + Recovery ChannelRecoveryManager + Bridge PaymentBridgeCoordinator +} + +// FundingPeerRPCServer serves one client-bound funding process. +type FundingPeerRPCServer struct { + cfg FundingPeerRPCServerConfig +} + +// NewFundingPeerRPCServer constructs one authenticated process service. +func NewFundingPeerRPCServer(cfg FundingPeerRPCServerConfig) ( + *FundingPeerRPCServer, error) { + + if _, err := parseCompressedKey( + "remote node", cfg.RemoteNode, + ); err != nil { + return nil, err + } + if err := cfg.Info.Validate(); err != nil { + return nil, err + } + if cfg.Service == nil || cfg.Funding == nil || cfg.Node == nil || + cfg.Recovery == nil { + return nil, fmt.Errorf("complete native channel process is " + + "required") + } + + return &FundingPeerRPCServer{cfg: cfg}, nil +} + +// GetPeerInfo returns immutable hub channel policy. +func (s *FundingPeerRPCServer) GetPeerInfo(context.Context, + *arkchannelrpc.GetPeerInfoRequest) (*arkchannelrpc.GetPeerInfoResponse, + error) { + + i := s.cfg.Info + + return &arkchannelrpc.GetPeerInfoResponse{ + HubNodeKey: i.HubNodeKey[:], HubArkKey: i.HubArkKey[:], + HubChannelKey: i.HubChannelKey[:], + HubFunderKey: i.HubFunderKey[:], + ArkOperatorKey: i.ArkOperatorKey[:], + ChannelDelay: i.ChannelDelay, FunderDelay: i.FunderDelay, + MinExitDelay: i.MinimumExitDelay, + }, nil +} + +// RegisterReceiveIntent registers immutable hub-funded receive terms. +func (s *FundingPeerRPCServer) RegisterReceiveIntent(ctx context.Context, + request *arkchannelrpc.RegisterReceiveIntentRequest) ( + *arkchannelrpc.RegisterReceiveIntentResponse, error) { + + terms, err := channelTermsFromRPC(request.GetTerms()) + if err != nil { + return nil, err + } + if err := s.validateTerms(terms); err != nil { + return nil, err + } + record, err := s.cfg.Service.RegisterReceiveIntent(ctx, terms) + if err != nil { + return nil, err + } + + return &arkchannelrpc.RegisterReceiveIntentResponse{ + Channel: ArkChannelRecordToRPC(record), + }, nil +} + +// GetFundingChannel returns the authenticated channel's durable facts. +func (s *FundingPeerRPCServer) GetFundingChannel(ctx context.Context, + request *arkchannelrpc.GetFundingChannelRequest) ( + *arkchannelrpc.GetFundingChannelResponse, error) { + + _, record, err := s.channel(ctx, request.GetChannelId()) + if err != nil { + return nil, err + } + snapshot := record.Snapshot + response := &arkchannelrpc.GetFundingChannelResponse{ + Terms: channelTermsToRPC(snapshot.Terms), + Phase: uint32(snapshot.Phase), + OorFinalized: snapshot.OORFinalized, + OorAborted: snapshot.OORAborted, + RecoveryReady: snapshot.RecoveryReady, + Failure: snapshot.Failure, Revision: record.Revision, + } + if snapshot.Source != nil { + response.Binding = channelBindingToRPC(*snapshot.Source) + } + if snapshot.Backing != nil { + response.Backing = channelBackingToRPC(*snapshot.Backing) + } + + return response, nil +} + +// RegisterPromotion registers immutable terms for the authenticated client. +func (s *FundingPeerRPCServer) RegisterPromotion(ctx context.Context, + request *arkchannelrpc.RegisterPromotionRequest) ( + *arkchannelrpc.RegisterPromotionResponse, error) { + + terms, err := channelTermsFromRPC(request.GetTerms()) + if err != nil { + return nil, err + } + if err := s.validateTerms(terms); err != nil { + return nil, err + } + record, err := s.cfg.Service.RegisterPromotion(ctx, terms) + if err != nil { + return nil, err + } + + return &arkchannelrpc.RegisterPromotionResponse{ + Channel: ArkChannelRecordToRPC(record), + }, nil +} + +// BindPreparedOOR binds the exact prepared source before funding starts. +func (s *FundingPeerRPCServer) BindPreparedOOR(ctx context.Context, + request *arkchannelrpc.BindPreparedOORRequest) ( + *arkchannelrpc.BindPreparedOORResponse, error) { + + id, record, err := s.channel(ctx, request.GetChannelId()) + if err != nil { + return nil, err + } + binding, err := channelBindingFromRPC(request.GetBinding()) + if err != nil { + return nil, err + } + if err := binding.Validate(record.Snapshot.Terms); err != nil { + return nil, err + } + record, err = s.cfg.Service.BindPreparedOOR(ctx, id, binding) + if err != nil { + return nil, err + } + + return &arkchannelrpc.BindPreparedOORResponse{ + Channel: ArkChannelRecordToRPC(record), + }, nil +} + +// SignBacking validates and signs the remote funding PSBT. +func (s *FundingPeerRPCServer) SignBacking(ctx context.Context, + request *arkchannelrpc.SignBackingRequest) ( + *arkchannelrpc.SignBackingResponse, error) { + + id, err := rpcChannelID(request.GetChannelId()) + if err != nil { + return nil, err + } + terms, err := channelTermsFromRPC(request.GetTerms()) + if err != nil { + return nil, err + } + if id != terms.ID { + return nil, fmt.Errorf("channel ID does not match terms") + } + if err := s.validateTerms(terms); err != nil { + return nil, err + } + binding, err := channelBindingFromRPC(request.GetBinding()) + if err != nil { + return nil, err + } + packet, err := psbt.NewFromRawBytes( + bytes.NewReader( + request.GetFundingPsbt(), + ), + false, + ) + if err != nil { + return nil, fmt.Errorf("decode lnd funding PSBT: %w", err) + } + signature, err := s.cfg.Funding.SignBacking( + ctx, id, terms, binding, packet, + ) + if err != nil { + return nil, err + } + + return &arkchannelrpc.SignBackingResponse{ + Signature: signature.Serialize(), + }, nil +} + +// InstallBacking installs the exact fully signed channel transaction. +func (s *FundingPeerRPCServer) InstallBacking(ctx context.Context, + request *arkchannelrpc.InstallBackingRequest) ( + *arkchannelrpc.InstallBackingResponse, error) { + + id, err := rpcChannelID(request.GetChannelId()) + if err != nil { + return nil, err + } + terms, err := channelTermsFromRPC(request.GetTerms()) + if err != nil { + return nil, err + } + if id != terms.ID { + return nil, fmt.Errorf("channel ID does not match terms") + } + if err := s.validateTerms(terms); err != nil { + return nil, err + } + binding, err := channelBindingFromRPC(request.GetBinding()) + if err != nil { + return nil, err + } + backing, err := channelBackingFromRPC(request.GetBacking()) + if err != nil { + return nil, err + } + if err := s.cfg.Funding.InstallBacking( + ctx, id, terms, binding, backing, + ); err != nil { + return nil, err + } + + return &arkchannelrpc.InstallBackingResponse{}, nil +} + +// InstallRecoveryPackage persists and watches the exact finalized source +// package before acknowledging the activation barrier. +func (s *FundingPeerRPCServer) InstallRecoveryPackage(ctx context.Context, + request *arkchannelrpc.InstallRecoveryPackageRequest) ( + *arkchannelrpc.InstallRecoveryPackageResponse, error) { + + id, err := rpcChannelID(request.GetChannelId()) + if err != nil { + return nil, err + } + terms, err := channelTermsFromRPC(request.GetTerms()) + if err != nil { + return nil, err + } + if id != terms.ID { + return nil, fmt.Errorf("channel ID does not match terms") + } + if err := s.validateTerms(terms); err != nil { + return nil, err + } + binding, err := channelBindingFromRPC(request.GetBinding()) + if err != nil { + return nil, err + } + recovery, err := ChannelRecoveryFromRPC( + request.GetRecovery(), binding, + ) + if err != nil { + return nil, err + } + if err := s.cfg.Recovery.InstallRecoveryPackage( + ctx, id, terms, binding, recovery, + ); err != nil { + return nil, err + } + + return &arkchannelrpc.InstallRecoveryPackageResponse{}, nil +} + +// ExportRecoveryPackage returns the finalized hub-funded source lineage. +func (s *FundingPeerRPCServer) ExportRecoveryPackage(ctx context.Context, + request *arkchannelrpc.ExportRecoveryPackageRequest) ( + *arkchannelrpc.ExportRecoveryPackageResponse, error) { + + _, record, err := s.channel(ctx, request.GetChannelId()) + if err != nil { + return nil, err + } + terms := record.Snapshot.Terms + if terms.Kind != arkchannel.KindReceiveIntent || + terms.Funder != arkchannel.PartyHub { + return nil, fmt.Errorf("channel is not a hub-funded receive " + + "intent") + } + if record.Snapshot.Source == nil || !record.Snapshot.OORFinalized { + return nil, fmt.Errorf("channel source is not finalized") + } + recovery, err := s.cfg.Recovery.ExportRecoveryPackage( + ctx, terms.ID, terms, *record.Snapshot.Source, + ) + if err != nil { + return nil, err + } + if err := recovery.Validate(*record.Snapshot.Source); err != nil { + return nil, err + } + recoveryMessage, err := ChannelRecoveryToRPC(recovery) + if err != nil { + return nil, err + } + + return &arkchannelrpc.ExportRecoveryPackageResponse{ + Binding: channelBindingToRPC(*record.Snapshot.Source), + Recovery: recoveryMessage, + }, nil +} + +// FundingFinalized reports the remote native lnd durability barrier. +func (s *FundingPeerRPCServer) FundingFinalized(ctx context.Context, + request *arkchannelrpc.FundingStatusRequest) ( + *arkchannelrpc.FundingStatusResponse, error) { + + terms, backing, err := s.fundingStatus(request) + if err != nil { + return nil, err + } + ready, err := s.cfg.Funding.FundingFinalized(ctx, terms, backing) + if err != nil { + return nil, err + } + + return &arkchannelrpc.FundingStatusResponse{Ready: ready}, nil +} + +// ChannelActive reports whether the remote native lnd link is active. +func (s *FundingPeerRPCServer) ChannelActive(ctx context.Context, + request *arkchannelrpc.FundingStatusRequest) ( + *arkchannelrpc.FundingStatusResponse, error) { + + terms, backing, err := s.fundingStatus(request) + if err != nil { + return nil, err + } + ready, err := s.cfg.Funding.ChannelActive(ctx, terms, backing) + if err != nil { + return nil, err + } + + return &arkchannelrpc.FundingStatusResponse{Ready: ready}, nil +} + +// ApplyChannelEvent records one authenticated cross-endpoint barrier. +func (s *FundingPeerRPCServer) ApplyChannelEvent(ctx context.Context, + request *arkchannelrpc.ApplyChannelEventRequest) ( + *arkchannelrpc.ApplyChannelEventResponse, error) { + + id, _, err := s.channel(ctx, request.GetChannelId()) + if err != nil { + return nil, err + } + event, err := channelEventFromRPC(request) + if err != nil { + return nil, err + } + var record arkchannel.Record + switch event.(type) { + case *arkchannel.Materialize: + record, err = s.cfg.Service.Materialize(ctx, id) + + case *arkchannel.FundingPeerReady: + record, err = s.cfg.Service.RecordChannelEvent(ctx, id, event) + + default: + record, err = s.cfg.Funding.ApplyChannelEvent(ctx, id, event) + } + if err != nil { + return nil, err + } + + return &arkchannelrpc.ApplyChannelEventResponse{ + Channel: ArkChannelRecordToRPC(record), + }, nil +} + +// CreateInvoice creates a native invoice for one active owned channel. +func (s *FundingPeerRPCServer) CreateInvoice(ctx context.Context, + request *arkchannelrpc.PeerInvoiceRequest) ( + *arkchannelrpc.PeerInvoiceResponse, error) { + + _, _, err := s.channel(ctx, request.GetChannelId()) + if err != nil { + return nil, err + } + amount, err := positiveRPCAmount( + "invoice amount", request.GetAmountSat(), + ) + if err != nil { + return nil, err + } + _, hash, err := s.cfg.Node.AddInvoice(ctx, amount) + if err != nil { + return nil, err + } + + return &arkchannelrpc.PeerInvoiceResponse{ + PaymentHash: hash[:], + }, nil +} + +// PayInvoice sends one native fixed-route payment for an active channel. +func (s *FundingPeerRPCServer) PayInvoice(ctx context.Context, + request *arkchannelrpc.PeerPayInvoiceRequest) ( + *arkchannelrpc.PeerPayInvoiceResponse, error) { + + _, record, err := s.channel(ctx, request.GetChannelId()) + if err != nil { + return nil, err + } + hash, err := rpcPaymentHash(request.GetPaymentHash()) + if err != nil { + return nil, err + } + amount, err := positiveRPCAmount( + "payment amount", request.GetAmountSat(), + ) + if err != nil { + return nil, err + } + if err := s.cfg.Node.PayInvoice(ctx, record, hash, amount); err != nil { + return nil, err + } + + return &arkchannelrpc.PeerPayInvoiceResponse{Settled: true}, nil +} + +// PrepareOutgoingPayment delegates public invoice admission to the operator's +// durable bridge manager while retaining authenticated client identity here. +func (s *FundingPeerRPCServer) PrepareOutgoingPayment(ctx context.Context, + request *arkchannelrpc.PrepareOutgoingPaymentRequest) ( + *arkchannelrpc.PrepareOutgoingPaymentResponse, error) { + + if s.cfg.Bridge == nil { + return nil, fmt.Errorf("payment bridge is not configured") + } + if request.GetPaymentRequest() == "" { + return nil, fmt.Errorf("payment request is required") + } + if request.GetMaxFeeSat() > uint64(btcutil.MaxSatoshi) { + return nil, fmt.Errorf("maximum fee exceeds maximum money") + } + preparation, err := s.cfg.Bridge.PrepareOutgoingPayment( + ctx, s.cfg.RemoteNode, request.GetPaymentRequest(), + btcutil.Amount( + request.GetMaxFeeSat(), + ), + ) + if err != nil { + return nil, err + } + + return &arkchannelrpc.PrepareOutgoingPaymentResponse{ + PaymentHash: preparation.PaymentHash[:], + PrivateAmountSat: int64(preparation.PrivateAmount), + FeeSat: int64(preparation.Fee), + ChannelId: preparation.ChannelID[:], + ReservedScid: preparation.ReservedSCID, + }, nil +} + +// CancelOutgoingPayment records source failure before public dispatch and +// causes the operator worker to cancel its private hold invoice. +func (s *FundingPeerRPCServer) CancelOutgoingPayment(ctx context.Context, + request *arkchannelrpc.CancelOutgoingPaymentRequest) ( + *arkchannelrpc.CancelOutgoingPaymentResponse, error) { + + if s.cfg.Bridge == nil { + return nil, fmt.Errorf("payment bridge is not configured") + } + hash, err := rpcPaymentHash(request.GetPaymentHash()) + if err != nil { + return nil, err + } + if request.GetReason() == "" || len(request.GetReason()) > 256 { + return nil, fmt.Errorf("valid payment cancellation reason is " + + "required") + } + if err := s.cfg.Bridge.CancelOutgoingPayment( + ctx, s.cfg.RemoteNode, hash, request.GetReason(), + ); err != nil { + return nil, err + } + + return &arkchannelrpc.CancelOutgoingPaymentResponse{ + Cancelled: true, + }, nil +} + +// RegisterIncomingPayment records an authenticated future-SCID reservation +// before the client returns its BOLT 11 invoice. +func (s *FundingPeerRPCServer) RegisterIncomingPayment(ctx context.Context, + request *arkchannelrpc.RegisterIncomingPaymentRequest) ( + *arkchannelrpc.RegisterIncomingPaymentResponse, error) { + + if s.cfg.Bridge == nil { + return nil, fmt.Errorf("payment bridge is not configured") + } + hash, err := rpcPaymentHash(request.GetPaymentHash()) + if err != nil { + return nil, err + } + amount, err := positiveRPCAmount( + "incoming payment amount", request.GetAmountSat(), + ) + if err != nil { + return nil, err + } + if request.GetReservedScid() == 0 { + return nil, fmt.Errorf("incoming payment SCID is required") + } + capacity, err := s.cfg.Bridge.RegisterIncomingPayment( + ctx, s.cfg.RemoteNode, hash, amount, request.GetReservedScid(), + ) + if err != nil { + return nil, err + } + + return &arkchannelrpc.RegisterIncomingPaymentResponse{ + Registered: true, ChannelCapacitySat: int64(capacity), + }, nil +} + +// fundingStatus parses and validates one status query. +func (s *FundingPeerRPCServer) fundingStatus( + request *arkchannelrpc.FundingStatusRequest) (arkchannel.Terms, + arkchannel.Backing, error) { + + terms, err := channelTermsFromRPC(request.GetTerms()) + if err != nil { + return arkchannel.Terms{}, arkchannel.Backing{}, err + } + if err := s.validateTerms(terms); err != nil { + return arkchannel.Terms{}, arkchannel.Backing{}, err + } + backing, err := channelBackingFromRPC(request.GetBacking()) + if err != nil { + return arkchannel.Terms{}, arkchannel.Backing{}, err + } + + return terms, backing, nil +} + +// channel loads one channel and enforces authenticated ownership. +func (s *FundingPeerRPCServer) channel(ctx context.Context, rawID []byte) ( + arkchannel.ID, arkchannel.Record, error) { + + id, err := rpcChannelID(rawID) + if err != nil { + return arkchannel.ID{}, arkchannel.Record{}, err + } + record, err := s.cfg.Service.GetChannel(ctx, id) + if err != nil { + return arkchannel.ID{}, arkchannel.Record{}, err + } + if err := s.validateTerms(record.Snapshot.Terms); err != nil { + return arkchannel.ID{}, arkchannel.Record{}, err + } + + return id, record, nil +} + +// validateTerms binds all operations to the authenticated remote node. +func (s *FundingPeerRPCServer) validateTerms(terms arkchannel.Terms) error { + if err := terms.Validate(); err != nil { + return err + } + if terms.ClientNodeKey != s.cfg.RemoteNode || + terms.HubNodeKey != s.cfg.Info.HubNodeKey { + return fmt.Errorf("channel belongs to another peer") + } + if terms.VTXO.HubArkKey != s.cfg.Info.HubArkKey || + terms.VTXO.HubChannelKey != s.cfg.Info.HubChannelKey || + terms.VTXO.ArkOperatorKey != s.cfg.Info.ArkOperatorKey || + terms.VTXO.ChannelDelay != s.cfg.Info.ChannelDelay || + terms.VTXO.FunderDelay != s.cfg.Info.FunderDelay || + terms.VTXO.MinExitDelay != s.cfg.Info.MinimumExitDelay { + return fmt.Errorf("channel terms do not match hub policy") + } + if terms.Kind == arkchannel.KindReceiveIntent && + terms.VTXO.FunderKey != s.cfg.Info.HubFunderKey { + return fmt.Errorf("receive intent does not use the hub " + + "funder key") + } + + return nil +} + +// fundingRPCOptions derives stable idempotency for one mutating channel step. +func fundingRPCOptions(id arkchannel.ID, step string) mailboxrpc.RPCOptions { + idempotencyKey := "ark-funding/" + hex.EncodeToString(id[:]) + "/" + + step + + return mailboxrpc.RPCOptions{ + IdempotencyKey: idempotencyKey, + } +} + +// fundingStatusRequest serializes one authoritative lnd status query. +func fundingStatusRequest(terms arkchannel.Terms, + backing arkchannel.Backing) *arkchannelrpc.FundingStatusRequest { + + return &arkchannelrpc.FundingStatusRequest{ + Terms: channelTermsToRPC(terms), + Backing: channelBackingToRPC(backing), + } +} + +// fundingPeerInfoFromRPC validates the hub's advertised policy. +func fundingPeerInfoFromRPC(response *arkchannelrpc.GetPeerInfoResponse) ( + FundingPeerInfo, error) { + + if response == nil { + return FundingPeerInfo{}, fmt.Errorf("funding peer info is " + + "required") + } + var info FundingPeerInfo + for name, source := range map[string]struct { + destination *[33]byte + value []byte + }{ + "hub node": { + &info.HubNodeKey, + response.GetHubNodeKey(), + }, + "hub Ark": { + &info.HubArkKey, + response.GetHubArkKey(), + }, + "hub channel": { + &info.HubChannelKey, response.GetHubChannelKey(), + }, + "hub funder": { + &info.HubFunderKey, response.GetHubFunderKey(), + }, + "Ark operator": { + &info.ArkOperatorKey, response.GetArkOperatorKey(), + }, + } { + if err := copyFixed( + name, source.destination[:], source.value, + ); err != nil { + return FundingPeerInfo{}, err + } + } + info.ChannelDelay = response.GetChannelDelay() + info.FunderDelay = response.GetFunderDelay() + info.MinimumExitDelay = response.GetMinExitDelay() + + return info, info.Validate() +} + +// channelTermsToRPC serializes immutable Ark channel terms. +func channelTermsToRPC(terms arkchannel.Terms) *arkchannelrpc.ChannelTerms { + return &arkchannelrpc.ChannelTerms{ + ChannelId: terms.ID[:], Kind: uint32(terms.Kind), + Funder: uint32(terms.Funder), + PendingChannelId: terms.PendingChannelID[:], + ReservedScid: terms.ReservedSCID, CapacitySat: int64( + terms.Capacity, + ), + ClientNodeKey: terms.ClientNodeKey[:], + HubNodeKey: terms.HubNodeKey[:], + PaymentHash: terms.PaymentHash[:], + Vtxo: &arkchannelrpc.ChannelVTXOTerms{ + ClientArkKey: terms.VTXO.ClientArkKey[:], + HubArkKey: terms.VTXO.HubArkKey[:], + ArkOperatorKey: terms.VTXO.ArkOperatorKey[:], + ClientChannelKey: terms.VTXO.ClientChannelKey[:], + HubChannelKey: terms.VTXO.HubChannelKey[:], + FunderKey: terms.VTXO.FunderKey[:], + ChannelDelay: terms.VTXO.ChannelDelay, + FunderDelay: terms.VTXO.FunderDelay, + MinExitDelay: terms.VTXO.MinExitDelay, + }, + } +} + +// ChannelTermsToRPC serializes immutable channel terms for daemon adapters. +func ChannelTermsToRPC(terms arkchannel.Terms) *arkchannelrpc.ChannelTerms { + return channelTermsToRPC(terms) +} + +// channelTermsFromRPC parses and validates immutable Ark channel terms. +func channelTermsFromRPC(message *arkchannelrpc.ChannelTerms) (arkchannel.Terms, + error) { + + if message == nil || message.GetVtxo() == nil { + return arkchannel.Terms{}, fmt.Errorf("complete channel " + + "terms are required") + } + capacity, err := positiveRPCAmount( + "channel capacity", message.GetCapacitySat(), + ) + if err != nil { + return arkchannel.Terms{}, err + } + var terms arkchannel.Terms + fields := map[string]struct { + destination []byte + value []byte + }{ + "channel ID": { + terms.ID[:], + message.GetChannelId(), + }, + "pending channel ID": { + terms.PendingChannelID[:], + message.GetPendingChannelId(), + }, + "client node key": { + terms.ClientNodeKey[:], message.GetClientNodeKey(), + }, + "hub node key": { + terms.HubNodeKey[:], + message.GetHubNodeKey(), + }, + "payment hash": { + terms.PaymentHash[:], + message.GetPaymentHash(), + }, + "client Ark key": { + terms.VTXO.ClientArkKey[:], message. + GetVtxo(). + GetClientArkKey(), + }, + "hub Ark key": { + terms.VTXO.HubArkKey[:], message. + GetVtxo(). + GetHubArkKey(), + }, + "Ark operator key": { + terms.VTXO.ArkOperatorKey[:], + message.GetVtxo().GetArkOperatorKey(), + }, + "client channel key": { + terms.VTXO.ClientChannelKey[:], + message.GetVtxo().GetClientChannelKey(), + }, + "hub channel key": { + terms.VTXO.HubChannelKey[:], + message.GetVtxo().GetHubChannelKey(), + }, + "funder key": { + terms.VTXO.FunderKey[:], message. + GetVtxo(). + GetFunderKey(), + }, + } + for name, field := range fields { + if err := copyFixed( + name, field.destination, field.value, + ); err != nil { + return arkchannel.Terms{}, err + } + } + terms.Kind = arkchannel.Kind(message.GetKind()) + terms.Funder = arkchannel.Party(message.GetFunder()) + terms.ReservedSCID = message.GetReservedScid() + terms.Capacity = capacity + terms.VTXO.ChannelDelay = message.GetVtxo().GetChannelDelay() + terms.VTXO.FunderDelay = message.GetVtxo().GetFunderDelay() + terms.VTXO.MinExitDelay = message.GetVtxo().GetMinExitDelay() + + return terms, terms.Validate() +} + +// ChannelTermsFromRPC parses immutable channel terms for daemon adapters. +func ChannelTermsFromRPC(message *arkchannelrpc.ChannelTerms) (arkchannel.Terms, + error) { + + return channelTermsFromRPC(message) +} + +// channelBindingToRPC serializes the exact prepared OOR output. +func channelBindingToRPC( + binding arkchannel.VTXOBinding) *arkchannelrpc.ChannelVTXOBinding { + + return &arkchannelrpc.ChannelVTXOBinding{ + OorSessionId: binding.OORSessionID[:], + OutpointTxid: binding.OutPoint.Hash[:], + OutpointIndex: binding.OutPoint.Index, + AmountSat: int64(binding.Amount), + ArkTransaction: append([]byte(nil), binding.ArkTransaction...), + PolicyTemplate: append([]byte(nil), binding.PolicyTemplate...), + PkScript: append([]byte(nil), binding.PkScript...), + } +} + +// ChannelBindingToRPC serializes a prepared channel-policy VTXO binding. +func ChannelBindingToRPC( + binding arkchannel.VTXOBinding) *arkchannelrpc.ChannelVTXOBinding { + + return channelBindingToRPC(binding) +} + +// channelBindingFromRPC parses one prepared OOR binding. +func channelBindingFromRPC(message *arkchannelrpc.ChannelVTXOBinding) ( + arkchannel.VTXOBinding, error) { + + if message == nil { + return arkchannel.VTXOBinding{}, fmt.Errorf("channel VTXO " + + "binding is required") + } + amount, err := positiveRPCAmount( + "channel VTXO amount", message.GetAmountSat(), + ) + if err != nil { + return arkchannel.VTXOBinding{}, err + } + hash, err := rpcHash("channel VTXO txid", message.GetOutpointTxid()) + if err != nil { + return arkchannel.VTXOBinding{}, err + } + var session [32]byte + if err := copyFixed( + "OOR session ID", session[:], message.GetOorSessionId(), + ); err != nil { + return arkchannel.VTXOBinding{}, err + } + + return arkchannel.VTXOBinding{ + OORSessionID: session, + OutPoint: wire.OutPoint{ + Hash: hash, Index: message.GetOutpointIndex(), + }, + Amount: amount, + ArkTransaction: append( + []byte(nil), message.GetArkTransaction()..., + ), + PolicyTemplate: append( + []byte(nil), message.GetPolicyTemplate()..., + ), + PkScript: append([]byte(nil), message.GetPkScript()...), + }, nil +} + +// ChannelBindingFromRPC parses a prepared channel-policy VTXO binding. +func ChannelBindingFromRPC(message *arkchannelrpc.ChannelVTXOBinding) ( + arkchannel.VTXOBinding, error) { + + return channelBindingFromRPC(message) +} + +// channelBackingToRPC serializes a signed VTXO-to-channel transaction. +func channelBackingToRPC( + backing arkchannel.Backing) *arkchannelrpc.ChannelBacking { + + return &arkchannelrpc.ChannelBacking{ + Transaction: append([]byte(nil), backing.Transaction...), + ChannelPointTxid: backing.ChannelPoint.Hash[:], + ChannelPointIndex: backing.ChannelPoint.Index, + } +} + +// channelBackingFromRPC parses a signed VTXO-to-channel transaction. +func channelBackingFromRPC(message *arkchannelrpc.ChannelBacking) ( + arkchannel.Backing, error) { + + if message == nil { + return arkchannel.Backing{}, fmt.Errorf("channel backing is " + + "required") + } + hash, err := rpcHash( + "channel point txid", message.GetChannelPointTxid(), + ) + if err != nil { + return arkchannel.Backing{}, err + } + + return arkchannel.Backing{ + Transaction: append([]byte(nil), message.GetTransaction()...), + ChannelPoint: wire.OutPoint{ + Hash: hash, Index: message.GetChannelPointIndex(), + }, + }, nil +} + +// channelEventToRPC serializes the funding barriers used by the negotiator. +func channelEventToRPC(id arkchannel.ID, event arkchannel.Event) ( + *arkchannelrpc.ApplyChannelEventRequest, string, error) { + + request := &arkchannelrpc.ApplyChannelEventRequest{ChannelId: id[:]} + switch event := event.(type) { + case *arkchannel.FundingPeerReady: + request.EventType = arkchannelrpc. + ChannelEventType_CHANNEL_EVENT_TYPE_FUNDING_PEER_READY + + return request, "funding-peer-ready", nil + + case *arkchannel.FundingFinalized: + request.EventType = arkchannelrpc. + ChannelEventType_CHANNEL_EVENT_TYPE_FUNDING_FINALIZED + request.Party = uint32(event.Party) + + return request, "funding-finalized/" + event.Party.String(), nil + + case *arkchannel.OORFinalized: + request.EventType = arkchannelrpc. + ChannelEventType_CHANNEL_EVENT_TYPE_OOR_FINALIZED + request.OorSessionId = event.SessionID[:] + + return request, "oor-finalized", nil + + case *arkchannel.OORAborted: + if event.Reason == "" { + return nil, "", fmt.Errorf("OOR abort reason is " + + "required") + } + request.EventType = arkchannelrpc. + ChannelEventType_CHANNEL_EVENT_TYPE_OOR_ABORTED + request.OorSessionId = event.SessionID[:] + request.FailureReason = event.Reason + + return request, "oor-aborted", nil + + case *arkchannel.RecoveryPackageInstalled: + request.EventType = recoveryPackageInstalledEvent + + return request, "recovery-package-installed", nil + + case *arkchannel.ChannelActive: + request.EventType = arkchannelrpc. + ChannelEventType_CHANNEL_EVENT_TYPE_CHANNEL_ACTIVE + request.ChannelPointTxid = event.ChannelPointHash[:] + request.ChannelPointIndex = event.ChannelPointIndex + + return request, "channel-active", nil + + case *arkchannel.Materialize: + request.EventType = arkchannelrpc. + ChannelEventType_CHANNEL_EVENT_TYPE_MATERIALIZE + + return request, "materialize", nil + + case *arkchannel.BackingPublished: + request.EventType = arkchannelrpc. + ChannelEventType_CHANNEL_EVENT_TYPE_BACKING_PUBLISHED + request.ChannelPointTxid = event.TxID[:] + + return request, "backing-published", nil + + case *arkchannel.Fail: + if event.Reason == "" { + return nil, "", fmt.Errorf("channel failure reason " + + "is required") + } + request.EventType = arkchannelrpc. + ChannelEventType_CHANNEL_EVENT_TYPE_FAILED + request.FailureReason = event.Reason + + return request, "fail", nil + + default: + return nil, "", fmt.Errorf("unsupported remote channel "+ + "event %T", event) + } +} + +// channelEventFromRPC parses one supported funding barrier. +func channelEventFromRPC(request *arkchannelrpc.ApplyChannelEventRequest) ( + arkchannel.Event, error) { + + switch request.GetEventType() { + case arkchannelrpc. + ChannelEventType_CHANNEL_EVENT_TYPE_FUNDING_PEER_READY: + return &arkchannel.FundingPeerReady{}, nil + + case arkchannelrpc. + ChannelEventType_CHANNEL_EVENT_TYPE_FUNDING_FINALIZED: + + party := arkchannel.Party(request.GetParty()) + if party != arkchannel.PartyClient && + party != arkchannel.PartyHub { + return nil, fmt.Errorf("invalid funding party %d", + party) + } + + return &arkchannel.FundingFinalized{Party: party}, nil + + case arkchannelrpc.ChannelEventType_CHANNEL_EVENT_TYPE_OOR_FINALIZED: + var session [32]byte + if err := copyFixed( + "OOR session ID", session[:], request.GetOorSessionId(), + ); err != nil { + return nil, err + } + + return &arkchannel.OORFinalized{SessionID: session}, nil + + case arkchannelrpc.ChannelEventType_CHANNEL_EVENT_TYPE_OOR_ABORTED: + var session [32]byte + if err := copyFixed( + "OOR session ID", session[:], request.GetOorSessionId(), + ); err != nil { + return nil, err + } + if request.GetFailureReason() == "" { + return nil, fmt.Errorf("OOR abort reason is required") + } + + return &arkchannel.OORAborted{ + SessionID: session, + Reason: request.GetFailureReason(), + }, nil + + case arkchannelrpc. + ChannelEventType_CHANNEL_EVENT_TYPE_RECOVERY_PACKAGE_INSTALLED: + return &arkchannel.RecoveryPackageInstalled{}, nil + + case arkchannelrpc.ChannelEventType_CHANNEL_EVENT_TYPE_CHANNEL_ACTIVE: + hash, err := rpcHash( + "channel point txid", request.GetChannelPointTxid(), + ) + if err != nil { + return nil, err + } + + return &arkchannel.ChannelActive{ + ChannelPointHash: hash, + ChannelPointIndex: request.GetChannelPointIndex(), + }, nil + + case arkchannelrpc.ChannelEventType_CHANNEL_EVENT_TYPE_MATERIALIZE: + return &arkchannel.Materialize{}, nil + + case arkchannelrpc. + ChannelEventType_CHANNEL_EVENT_TYPE_BACKING_PUBLISHED: + + hash, err := rpcHash( + "channel point txid", request.GetChannelPointTxid(), + ) + if err != nil { + return nil, err + } + + return &arkchannel.BackingPublished{TxID: hash}, nil + + case arkchannelrpc.ChannelEventType_CHANNEL_EVENT_TYPE_FAILED: + if request.GetFailureReason() == "" { + return nil, fmt.Errorf("channel failure reason is " + + "required") + } + + return &arkchannel.Fail{Reason: request.GetFailureReason()}, nil + + default: + return nil, fmt.Errorf("unsupported channel event type %d", + request.GetEventType()) + } +} + +// ChannelRecoveryToRPC serializes the endpoint-neutral recovery package. +func ChannelRecoveryToRPC(recovery arkchannel.RecoveryPackage) ( + *arkchannelrpc.ChannelRecoveryPackage, error) { + + if recovery.Descriptor.ChainDepth > math.MaxInt32 { + return nil, fmt.Errorf("channel recovery chain depth is out " + + "of range") + } + desc := recovery.Descriptor + message := &arkchannelrpc.ChannelRecoveryPackage{ + SourceDescriptor: &arkchannelrpc.ChannelRecoveryDescriptor{ + RoundId: desc.RoundID, + CommitmentTxid: desc.CommitmentTxID[:], + BatchExpiry: desc.BatchExpiry, + ChainDepth: int32(desc.ChainDepth), + CreatedHeight: desc.CreatedHeight, + ConstructionVersion: desc.ConstructionVersion, + }, + OorPackages: make( + []*arkchannelrpc.ChannelRecoveryOORPackage, 0, + len(recovery.Packages), + ), + } + for i := range desc.Ancestry { + entry := desc.Ancestry[i] + message.SourceDescriptor.Ancestry = append( + message.SourceDescriptor.Ancestry, + &arkchannelrpc.ChannelRecoveryAncestry{ + TreePath: entry.TreePath, + CommitmentTxid: entry.CommitmentTxID[:], + InputIndices: entry.InputIndices, + TreeDepth: entry.TreeDepth, + CommitmentHeight: entry.CommitmentHeight, + }, + ) + } + for i := range recovery.Packages { + entry := recovery.Packages[i] + message.OorPackages = append( + message.OorPackages, + &arkchannelrpc.ChannelRecoveryOORPackage{ + SessionId: entry.SessionID[:], + Direction: entry.Direction, + ArkPsbt: entry.ArkPSBT, + CheckpointPsbts: entry.Checkpoints, + }, + ) + } + + return message, nil +} + +// ChannelRecoveryFromRPC parses one recovery package and validates its target +// binding before storage sees any artifact. +func ChannelRecoveryFromRPC(message *arkchannelrpc.ChannelRecoveryPackage, + source arkchannel.VTXOBinding) (arkchannel.RecoveryPackage, error) { + + if message == nil || message.GetSourceDescriptor() == nil { + return arkchannel.RecoveryPackage{}, fmt.Errorf("channel " + + "recovery package is required") + } + descMessage := message.GetSourceDescriptor() + commitmentTxID, err := rpcHash( + "recovery commitment txid", descMessage.GetCommitmentTxid(), + ) + if err != nil { + return arkchannel.RecoveryPackage{}, err + } + recovery := arkchannel.RecoveryPackage{ + Descriptor: arkchannel.RecoveryDescriptor{ + RoundID: descMessage.GetRoundId(), + CommitmentTxID: commitmentTxID, + BatchExpiry: descMessage.GetBatchExpiry(), + ChainDepth: int(descMessage.GetChainDepth()), + CreatedHeight: descMessage.GetCreatedHeight(), + ConstructionVersion: descMessage. + GetConstructionVersion(), + }, + } + for i, entry := range descMessage.GetAncestry() { + if entry == nil { + return arkchannel.RecoveryPackage{}, fmt.Errorf( + "channel recovery ancestry %d is nil", i) + } + txID, err := rpcHash( + "recovery ancestry commitment txid", + entry.GetCommitmentTxid(), + ) + if err != nil { + return arkchannel.RecoveryPackage{}, err + } + recovery.Descriptor.Ancestry = append( + recovery.Descriptor.Ancestry, + arkchannel.RecoveryAncestry{ + TreePath: append( + []byte(nil), entry.GetTreePath()..., + ), + CommitmentTxID: txID, + InputIndices: append( + []uint32(nil), + entry.GetInputIndices()..., + ), + TreeDepth: entry.GetTreeDepth(), + CommitmentHeight: entry.GetCommitmentHeight(), + }, + ) + } + for i, entry := range message.GetOorPackages() { + if entry == nil { + return arkchannel.RecoveryPackage{}, fmt.Errorf( + "channel recovery OOR package %d is nil", i) + } + sessionID, err := rpcHash( + "recovery OOR session ID", entry.GetSessionId(), + ) + if err != nil { + return arkchannel.RecoveryPackage{}, err + } + checkpoints := make([][]byte, len(entry.GetCheckpointPsbts())) + for j := range entry.GetCheckpointPsbts() { + checkpoints[j] = append( + []byte(nil), entry.GetCheckpointPsbts()[j]..., + ) + } + recovery.Packages = append( + recovery.Packages, arkchannel.RecoveryOORPackage{ + SessionID: sessionID, + Direction: entry.GetDirection(), + ArkPSBT: append( + []byte(nil), entry.GetArkPsbt()..., + ), + Checkpoints: checkpoints, + }, + ) + } + if err := recovery.Validate(source); err != nil { + return arkchannel.RecoveryPackage{}, err + } + + return recovery, nil +} + +// OORRecoverySourceToRPC serializes one exact finalized OOR output. +func OORRecoverySourceToRPC(source arkchannel.OORRecoverySource) ( + *arkchannelrpc.OORRecoverySource, error) { + + if err := source.Validate(); err != nil { + return nil, err + } + + return &arkchannelrpc.OORRecoverySource{ + Txid: source.OutPoint.Hash[:], + OutputIndex: source.OutPoint.Index, AmountSat: int64( + source.Amount, + ), + PkScript: append([]byte(nil), source.PkScript...), + }, nil +} + +// OORRecoverySourceFromRPC parses one exact finalized OOR output. +func OORRecoverySourceFromRPC(message *arkchannelrpc.OORRecoverySource) ( + arkchannel.OORRecoverySource, error) { + + if message == nil { + return arkchannel.OORRecoverySource{}, fmt.Errorf("OOR " + + "recovery source is required") + } + txID, err := rpcHash("OOR recovery txid", message.GetTxid()) + if err != nil { + return arkchannel.OORRecoverySource{}, err + } + source := arkchannel.OORRecoverySource{ + OutPoint: wire.OutPoint{ + Hash: txID, Index: message.GetOutputIndex(), + }, + Amount: btcutil.Amount(message.GetAmountSat()), + PkScript: append([]byte(nil), message.GetPkScript()...), + } + if err := source.Validate(); err != nil { + return arkchannel.OORRecoverySource{}, err + } + + return source, nil +} + +// recordSummaryFromRPC validates the immutable identity in a peer response. +func recordSummaryFromRPC(terms arkchannel.Terms, + message *arkchannelrpc.ArkChannel) (arkchannel.Record, error) { + + if message == nil { + return arkchannel.Record{}, fmt.Errorf("funding peer " + + "returned an empty channel") + } + id, err := rpcChannelID(message.GetChannelId()) + if err != nil { + return arkchannel.Record{}, err + } + if id != terms.ID { + return arkchannel.Record{}, fmt.Errorf("funding peer " + + "returned another channel") + } + + return arkchannel.Record{ + Snapshot: arkchannel.Snapshot{ + Terms: terms, + }, + Revision: message.GetRevision(), + }, nil +} + +// rpcPaymentHash parses one fixed-width Lightning payment hash. +func rpcPaymentHash(raw []byte) (lntypes.Hash, error) { + var hash lntypes.Hash + if err := copyFixed("payment hash", hash[:], raw); err != nil { + return lntypes.Hash{}, err + } + + return hash, nil +} + +// positiveRPCAmount parses a strictly positive signed wire amount. +func positiveRPCAmount(name string, value int64) (btcutil.Amount, error) { + amount, err := rpcAmount(name, value) + if err != nil { + return 0, err + } + if amount == 0 { + return 0, fmt.Errorf("%s must be positive", name) + } + + return amount, nil +} + +// copyFixed copies one exact-width byte field. +func copyFixed(name string, destination, source []byte) error { + if len(source) != len(destination) { + return fmt.Errorf("%s must be %d bytes", name, len(destination)) + } + copy(destination, source) + + return nil +} + +// parseCompressedKey validates one serialized secp256k1 public key. +func parseCompressedKey(name string, key [33]byte) (*btcec.PublicKey, error) { + parsed, err := btcec.ParsePubKey(key[:]) + if err != nil { + return nil, fmt.Errorf("invalid %s key: %w", name, err) + } + + return parsed, nil +} + +var _ ProcessFundingPeer = (*MailboxFundingPeer)(nil) +var _ ProcessPaymentPeer = (*MailboxFundingPeer)(nil) +var _ FundingCounterparty = (*MailboxFundingPeer)(nil) + +//nolint:ll // Keeping the generated interface name explicit aids API audits. +var _ arkchannelrpc.ArkChannelFundingPeerServiceMailboxServer = (*FundingPeerRPCServer)(nil) diff --git a/lnruntime/process_funding_rpc_test.go b/lnruntime/process_funding_rpc_test.go new file mode 100644 index 000000000..aa160c0d6 --- /dev/null +++ b/lnruntime/process_funding_rpc_test.go @@ -0,0 +1,60 @@ +package lnruntime + +import ( + "testing" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/lightninglabs/wavelength/arkchannel" + "github.com/stretchr/testify/require" +) + +// TestMaterializationChannelEventRPC verifies the durable handoff and backing +// publication facts survive the authenticated mailbox codec exactly. +func TestMaterializationChannelEventRPC(t *testing.T) { + t.Parallel() + + id := arkchannel.ID{1, 2, 3} + materialize, _, err := channelEventToRPC( + id, &arkchannel.Materialize{}, + ) + require.NoError(t, err) + decoded, err := channelEventFromRPC(materialize) + require.NoError(t, err) + require.IsType(t, &arkchannel.Materialize{}, decoded) + + txID := chainhash.Hash{9, 8, 7} + published, _, err := channelEventToRPC( + id, &arkchannel.BackingPublished{ + TxID: txID, + }, + ) + require.NoError(t, err) + decoded, err = channelEventFromRPC(published) + require.NoError(t, err) + publishedEvent, ok := decoded.(*arkchannel.BackingPublished) + require.True(t, ok) + require.Equal(t, txID, publishedEvent.TxID) +} + +// TestOORAbortedChannelEventRPC proves peer cancellation carries the exact +// prepared session and stable failure reason. +func TestOORAbortedChannelEventRPC(t *testing.T) { + t.Parallel() + + id := arkchannel.ID{1, 2, 3} + expected := &arkchannel.OORAborted{ + SessionID: [32]byte{ + 9, + 8, + 7, + }, + Reason: "pre-PONR channel negotiation expired", + } + message, _, err := channelEventToRPC(id, expected) + require.NoError(t, err) + decoded, err := channelEventFromRPC(message) + require.NoError(t, err) + actual, ok := decoded.(*arkchannel.OORAborted) + require.True(t, ok) + require.Equal(t, expected, actual) +} diff --git a/lnruntime/recovery.go b/lnruntime/recovery.go new file mode 100644 index 000000000..4402f89c0 --- /dev/null +++ b/lnruntime/recovery.go @@ -0,0 +1,84 @@ +package lnruntime + +import ( + "errors" + "fmt" + + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightningnetwork/lnd/chanstate" + "github.com/lightningnetwork/lnd/htlcswitch" + "github.com/lightningnetwork/lnd/lnpeer" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/lightningnetwork/lnd/lnwire" +) + +// LinkConfigSource supplies application-owned chain and materialization +// callbacks while restoring a channel from lnd's database. +type LinkConfigSource func(*chanstate.OpenChannel) (LinkConfig, error) + +// RestorePeerLinks rebuilds every non-pending channel for one peer from lnd's +// database and enables the normal channel-reestablishment exchange. Existing +// links are left intact, making repeated recovery calls idempotent. +func (r *Runtime) RestorePeerLinks(peer lnpeer.Peer, + configSource LinkConfigSource) ([]*lnwallet.LightningChannel, error) { + + if peer == nil { + return nil, fmt.Errorf("channel peer is required") + } + if configSource == nil { + return nil, fmt.Errorf("link config source is required") + } + + states, err := r.cfg.DB.ChannelStateDB().FetchOpenChannels( + peer.IdentityKey(), + ) + if err != nil { + return nil, fmt.Errorf("fetch lnd peer channels: %w", err) + } + + restored := make([]*lnwallet.LightningChannel, 0, len(states)) + restoredPoints := make([]wire.OutPoint, 0, len(states)) + rollback := func() { + for _, channelPoint := range restoredPoints { + r.RemoveLink(channelPoint) + } + } + + for _, state := range states { + if state.IsPending { + continue + } + + channelID := lnwire.NewChanIDFromOutPoint( + state.FundingOutpoint, + ) + if _, err := r.switcher.GetLink(channelID); err == nil { + continue + } else if !errors.Is(err, htlcswitch.ErrChannelLinkNotFound) { + rollback() + + return nil, fmt.Errorf("inspect lnd channel link: %w", + err) + } + + linkConfig, err := configSource(state) + if err != nil { + rollback() + + return nil, fmt.Errorf("build lnd link config: %w", err) + } + linkConfig.Peer = peer + linkConfig.SyncStates = true + + channel, err := r.AddLink(state, linkConfig) + if err != nil { + rollback() + + return nil, err + } + restored = append(restored, channel) + restoredPoints = append(restoredPoints, state.FundingOutpoint) + } + + return restored, nil +} diff --git a/lnruntime/runtime.go b/lnruntime/runtime.go new file mode 100644 index 000000000..a0f4e7083 --- /dev/null +++ b/lnruntime/runtime.go @@ -0,0 +1,978 @@ +package lnruntime + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/btcsuite/btcd/btcec/v2/ecdsa" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + sphinx "github.com/lightningnetwork/lightning-onion" + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/lightningnetwork/lnd/channeldb" + "github.com/lightningnetwork/lnd/chanstate" + "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/contractcourt" + fn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/graph/db/models" + "github.com/lightningnetwork/lnd/htlcswitch" + "github.com/lightningnetwork/lnd/htlcswitch/hop" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/invoices" + "github.com/lightningnetwork/lnd/lnpeer" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/routing/route" + "github.com/lightningnetwork/lnd/ticker" +) + +const ( + defaultCommitInterval = 50 * time.Millisecond + defaultCommitBatchSize = uint32(10) + defaultOutgoingRejectDelta = uint32(3) + defaultQuiescenceTimeout = time.Minute + forceCloseResultPollInterval = 100 * time.Millisecond +) + +// durablePeerPendingCommitTicker disables lnd's socket-liveness timeout for a +// peer whose ordered messages remain durably queued while either endpoint is +// unavailable. HTLC deadlines remain enforced by lnd's contract lifecycle. +type durablePeerPendingCommitTicker struct{} + +// Ticks returns a permanently parked channel, as permitted by ticker.Ticker. +func (durablePeerPendingCommitTicker) Ticks() <-chan time.Time { + return nil +} + +// Resume leaves the durable peer's disconnect timer parked. +func (durablePeerPendingCommitTicker) Resume() {} + +// Pause leaves the durable peer's disconnect timer parked. +func (durablePeerPendingCommitTicker) Pause() {} + +// Stop has no resources to release. +func (durablePeerPendingCommitTicker) Stop() {} + +// RuntimeConfig contains the shared dependencies for lnd's native channel and +// payment subsystems. The caller continues to own the chain notifier and DB. +type RuntimeConfig struct { + DB *channeldb.DB + Chain lnwallet.BlockChainIO + Notifier chainntnfs.ChainNotifier + OnionKey sphinx.SingleKeyECDH + Signer input.Signer + FeeEstimator chainfee.Estimator + WitnessBeacon contractcourt.WitnessBeacon + SelfNode route.Vertex + Clock clock.Clock + Funding *FundingConfig + Onchain *OnchainConfig + + LocalChannelClose func([]byte, *htlcswitch.ChanClose) + FetchLastChannelUpdate func(lnwire.ShortChannelID) ( + *lnwire.ChannelUpdate1, error) + SignAliasUpdate func(*lnwire.ChannelUpdate1) (*ecdsa.Signature, error) + IsAlias func(lnwire.ShortChannelID) bool +} + +// Runtime owns the lifecycle and wiring of native lnd subsystems without an +// lnd server, RPC surface, graph, or network peer manager. +type Runtime struct { + cfg RuntimeConfig + + onionProcessor *hop.OnionProcessor + htlcNotifier *htlcswitch.HtlcNotifier + invoices *invoices.InvoiceRegistry + switcher *htlcswitch.Switch + interceptor *htlcswitch.InterceptableSwitch + payments *FixedRoutePayments + funding *FundingRuntime + onchain *OnchainRuntime + sigPool *lnwallet.SigPool + + mu sync.Mutex + started bool + stopped bool + + reestablishMu sync.Mutex + awaitingReestablish map[lnwire.ChannelID]struct{} + + forceCloseMu sync.Mutex + forceCloseCalls map[wire.OutPoint]*forceCloseCall +} + +// forceCloseCall owns one in-process force-close result. LND's force-close +// request can block while Ark materializes the channel point, so duplicate RPC +// delivery must join the existing request instead of entering the channel +// arbitrator a second time. +type forceCloseCall struct { + done chan struct{} + tx *wire.MsgTx + err error +} + +// NewRuntime composes lnd's existing invoice, switch, link-signing, and +// fixed-route payment components. +func NewRuntime(cfg RuntimeConfig) (*Runtime, error) { + if err := validateRuntimeConfig(cfg); err != nil { + return nil, err + } + + runtimeClock := cfg.Clock + if runtimeClock == nil { + runtimeClock = clock.NewDefaultClock() + } + cfg.Clock = runtimeClock + + blockHash, blockHeight, err := cfg.Chain.GetBestBlock() + if err != nil { + return nil, fmt.Errorf("read channel runtime chain tip: %w", + err) + } + + expiryWatcher := invoices.NewInvoiceExpiryWatcher( + runtimeClock, defaultOutgoingRejectDelta, uint32(blockHeight), + blockHash, cfg.Notifier, + ) + invoiceRegistry := invoices.NewRegistry( + cfg.DB, expiryWatcher, &invoices.RegistryConfig{ + FinalCltvRejectDelta: int32(defaultOutgoingRejectDelta), + HtlcHoldDuration: invoices.DefaultHtlcHoldDuration, + Clock: runtimeClock, + HtlcInterceptor: invoices. + NewHtlcModificationInterceptor(), + }, + ) + + replayLog := htlcswitch.NewDecayedLog(cfg.DB, cfg.Notifier) + onionRouter := sphinx.NewRouter(cfg.OnionKey, replayLog) + onionProcessor := hop.NewOnionProcessor(onionRouter) + htlcNotifier := htlcswitch.NewHtlcNotifier(runtimeClock.Now) + + stateDB := cfg.DB.ChannelStateDB() + localChannelClose := cfg.LocalChannelClose + if localChannelClose == nil { + localChannelClose = func([]byte, *htlcswitch.ChanClose) {} + } + fetchLastUpdate := cfg.FetchLastChannelUpdate + if fetchLastUpdate == nil { + fetchLastUpdate = unavailableChannelUpdate + } + cfg.FetchLastChannelUpdate = fetchLastUpdate + signAliasUpdate := cfg.SignAliasUpdate + if signAliasUpdate == nil { + signAliasUpdate = unavailableAliasSignature + } + cfg.SignAliasUpdate = signAliasUpdate + isAlias := cfg.IsAlias + if isAlias == nil { + isAlias = func(lnwire.ShortChannelID) bool { + return false + } + } + cfg.IsAlias = isAlias + + mailboxTimeout := htlcswitch.DefaultMailboxDeliveryTimeout + switcher, err := htlcswitch.New(htlcswitch.Config{ + FwdingLog: cfg.DB.ForwardingLog(), + LocalChannelClose: localChannelClose, + DB: cfg.DB, + FetchAllOpenChannels: stateDB.FetchAllOpenChannels, + FetchAllChannels: stateDB.FetchAllChannels, + FetchClosedChannels: stateDB.FetchClosedChannels, + SwitchPackager: channeldb.NewSwitchPackager(), + ExtractErrorEncrypter: onionProcessor.ExtractErrorEncrypter, + FetchLastChannelUpdate: fetchLastUpdate, + Notifier: cfg.Notifier, + HtlcNotifier: htlcNotifier, + FwdEventTicker: ticker.New( + htlcswitch.DefaultFwdEventInterval, + ), + LogEventTicker: ticker.New( + htlcswitch.DefaultLogInterval, + ), + AckEventTicker: ticker.New( + htlcswitch.DefaultAckInterval, + ), + Clock: runtimeClock, + MailboxDeliveryTimeout: mailboxTimeout, + MaxFeeExposure: htlcswitch.DefaultMaxFeeExposure, + SignAliasUpdate: signAliasUpdate, + IsAlias: isAlias, + }, uint32(blockHeight)) + if err != nil { + return nil, fmt.Errorf("create lnd HTLC switch: %w", err) + } + interceptor, err := htlcswitch.NewInterceptableSwitch( + &htlcswitch.InterceptableSwitchConfig{ + Switch: switcher, + Notifier: cfg.Notifier, + CltvRejectDelta: defaultOutgoingRejectDelta, + CltvInterceptDelta: defaultOutgoingRejectDelta * 2, + }, + ) + if err != nil { + return nil, fmt.Errorf("create lnd interceptable switch: %w", + err) + } + + payments, err := NewFixedRoutePayments(FixedRoutePaymentsConfig{ + DB: cfg.DB, + Chain: cfg.Chain, + Payer: switcher, + SelfNode: cfg.SelfNode, + Clock: runtimeClock, + GetLink: switcher.GetLinkByShortID, + }) + if err != nil { + return nil, err + } + + var fundingRuntime *FundingRuntime + if cfg.Funding != nil { + fundingRuntime, err = newFundingRuntime( + cfg, switcher, *cfg.Funding, + ) + if err != nil { + return nil, err + } + } + + runtime := &Runtime{ + cfg: cfg, + onionProcessor: onionProcessor, + htlcNotifier: htlcNotifier, + invoices: invoiceRegistry, + switcher: switcher, + interceptor: interceptor, + payments: payments, + funding: fundingRuntime, + sigPool: lnwallet.NewSigPool(1, cfg.Signer), + awaitingReestablish: make(map[lnwire.ChannelID]struct{}), + forceCloseCalls: make(map[wire.OutPoint]*forceCloseCall), + } + if cfg.Onchain != nil { + runtime.onchain, err = newOnchainRuntime( + runtime, *cfg.Onchain, + ) + if err != nil { + return nil, err + } + } + + return runtime, nil +} + +// validateRuntimeConfig rejects missing stateful dependencies before any lnd +// component starts a goroutine. +func validateRuntimeConfig(cfg RuntimeConfig) error { + switch { + case cfg.DB == nil: + return fmt.Errorf("channel database is required") + + case cfg.Chain == nil: + return fmt.Errorf("chain backend is required") + + case cfg.Notifier == nil: + return fmt.Errorf("chain notifier is required") + + case cfg.OnionKey == nil: + return fmt.Errorf("onion key is required") + + case cfg.Signer == nil: + return fmt.Errorf("channel signer is required") + + case cfg.FeeEstimator == nil: + return fmt.Errorf("fee estimator is required") + + case cfg.WitnessBeacon == nil: + return fmt.Errorf("witness beacon is required") + + default: + return nil + } +} + +// Start starts native lnd components in dependency order. +func (r *Runtime) Start() error { + r.mu.Lock() + defer r.mu.Unlock() + + if r.started { + return nil + } + if r.stopped { + return fmt.Errorf("channel runtime already stopped") + } + + if err := r.sigPool.Start(); err != nil { + return fmt.Errorf("start lnd signature pool: %w", err) + } + if err := r.htlcNotifier.Start(); err != nil { + _ = r.sigPool.Stop() + + return fmt.Errorf("start lnd HTLC notifier: %w", err) + } + if err := r.onionProcessor.Start(); err != nil { + _ = r.htlcNotifier.Stop() + _ = r.sigPool.Stop() + + return fmt.Errorf("start lnd onion processor: %w", err) + } + if err := r.invoices.Start(); err != nil { + _ = r.onionProcessor.Stop() + _ = r.htlcNotifier.Stop() + _ = r.sigPool.Stop() + + return fmt.Errorf("start lnd invoice registry: %w", err) + } + if err := r.switcher.Start(); err != nil { + _ = r.invoices.Stop() + _ = r.onionProcessor.Stop() + _ = r.htlcNotifier.Stop() + _ = r.sigPool.Stop() + + return fmt.Errorf("start lnd HTLC switch: %w", err) + } + if err := r.interceptor.Start(); err != nil { + _ = r.switcher.Stop() + _ = r.invoices.Stop() + _ = r.onionProcessor.Stop() + _ = r.htlcNotifier.Stop() + _ = r.sigPool.Stop() + + return fmt.Errorf("start lnd interceptable switch: %w", err) + } + if err := r.payments.Start(); err != nil { + _ = r.interceptor.Stop() + _ = r.switcher.Stop() + _ = r.invoices.Stop() + _ = r.onionProcessor.Stop() + _ = r.htlcNotifier.Stop() + _ = r.sigPool.Stop() + + return err + } + if r.funding != nil { + if err := r.funding.Start(); err != nil { + _ = r.payments.Stop() + _ = r.interceptor.Stop() + _ = r.switcher.Stop() + _ = r.invoices.Stop() + _ = r.onionProcessor.Stop() + _ = r.htlcNotifier.Stop() + _ = r.sigPool.Stop() + + return err + } + } + if r.onchain != nil { + if err := r.onchain.Start(); err != nil { + if r.funding != nil { + _ = r.funding.Stop() + } + _ = r.payments.Stop() + _ = r.interceptor.Stop() + _ = r.switcher.Stop() + _ = r.invoices.Stop() + _ = r.onionProcessor.Stop() + _ = r.htlcNotifier.Stop() + _ = r.sigPool.Stop() + + return err + } + } + + r.started = true + + return nil +} + +// Stop shuts native lnd components down in reverse dependency order. +func (r *Runtime) Stop() error { + r.mu.Lock() + defer r.mu.Unlock() + + if r.stopped { + return nil + } + r.stopped = true + if !r.started { + return nil + } + + var onchainErr error + if r.onchain != nil { + onchainErr = r.onchain.Stop() + } + var fundingErr error + if r.funding != nil { + fundingErr = r.funding.Stop() + } + + return errors.Join( + onchainErr, fundingErr, r.payments.Stop(), r.interceptor.Stop(), + r.switcher.Stop(), r.invoices.Stop(), r.onionProcessor.Stop(), + r.htlcNotifier.Stop(), r.sigPool.Stop(), + ) +} + +// LinkConfig contains per-channel callbacks owned by the Ark coordinator or +// its on-chain materializer. +type LinkConfig struct { + Peer lnpeer.Peer + Policy models.ForwardingPolicy + ChainEvents *contractcourt.ChainEventSubscription + SyncStates bool + AddsDisabled bool + Aliases []lnwire.ShortChannelID + MaxAnchorFeeRate chainfee.SatPerKWeight + + OnChannelFailure func(lnwire.ChannelID, lnwire.ShortChannelID, + htlcswitch.LinkFailureError) + UpdateContractSignals func(*contractcourt.ContractSignals) error + NotifyContractUpdate func(*contractcourt.ContractUpdate) error + NotifyActive func() + NotifyInactive func() +} + +// AddLink reconstructs lnd's LightningChannel from its persisted open-channel +// state and installs the normal channel link in the native HTLC switch. +func (r *Runtime) AddLink(state *chanstate.OpenChannel, cfg LinkConfig) ( + *lnwallet.LightningChannel, error) { + + if state == nil { + return nil, fmt.Errorf("open channel state is required") + } + if cfg.Peer == nil { + return nil, fmt.Errorf("channel peer is required") + } + if cfg.ChainEvents == nil { + return nil, fmt.Errorf("channel chain events are required") + } + if cfg.OnChannelFailure == nil { + return nil, fmt.Errorf("channel failure callback is required") + } + if cfg.UpdateContractSignals == nil { + return nil, fmt.Errorf("contract signal callback is required") + } + if cfg.NotifyContractUpdate == nil { + return nil, fmt.Errorf("contract update callback is required") + } + + channel, err := lnwallet.NewLightningChannel( + r.cfg.Signer, state, r.sigPool, + ) + if err != nil { + return nil, fmt.Errorf("restore lnd lightning channel: %w", err) + } + + aliases := append([]lnwire.ShortChannelID(nil), cfg.Aliases...) + noTrafficShaper := fn.None[htlcswitch.AuxTrafficShaper]() + noChannelNegotiator := fn.None[lnwallet.AuxChannelNegotiator]() + minUpdateTimeout := htlcswitch.DefaultMinLinkFeeUpdateTimeout + maxUpdateTimeout := htlcswitch.DefaultMaxLinkFeeUpdateTimeout + getAliases := func(lnwire.ShortChannelID) []lnwire.ShortChannelID { + return append([]lnwire.ShortChannelID(nil), aliases...) + } + linkCfg := htlcswitch.ChannelLinkConfig{ + FwrdingPolicy: cfg.Policy, + Circuits: r.switcher.CircuitModifier(), + BestHeight: r.switcher.BestHeight, + ForwardPackets: r.interceptor.ForwardPackets, + DecodeHopIterators: r.onionProcessor.DecodeHopIterators, + ExtractErrorEncrypter: r.onionProcessor.ExtractErrorEncrypter, + FetchLastChannelUpdate: r.cfg.FetchLastChannelUpdate, + Peer: cfg.Peer, + Registry: r.invoices, + PreimageCache: r.cfg.WitnessBeacon, + OnChannelFailure: cfg.OnChannelFailure, + UpdateContractSignals: cfg.UpdateContractSignals, + NotifyContractUpdate: cfg.NotifyContractUpdate, + ChainEvents: cfg.ChainEvents, + FeeEstimator: r.cfg.FeeEstimator, + SyncStates: cfg.SyncStates, + BatchTicker: ticker.New(defaultCommitInterval), + FwdPkgGCTicker: ticker.New(time.Hour), + PendingCommitTicker: durablePeerPendingCommitTicker{}, + BatchSize: defaultCommitBatchSize, + MinUpdateTimeout: minUpdateTimeout, + MaxUpdateTimeout: maxUpdateTimeout, + OutgoingCltvRejectDelta: defaultOutgoingRejectDelta, + MaxOutgoingCltvExpiry: htlcswitch. + DefaultMaxOutgoingCltvExpiry, + MaxFeeAllocation: htlcswitch.DefaultMaxLinkFeeAllocation, + MaxAnchorsCommitFeeRate: cfg.MaxAnchorFeeRate, + NotifyActiveLink: func(_ wire.OutPoint) { + if cfg.NotifyActive != nil { + cfg.NotifyActive() + } + }, + NotifyActiveChannel: func(_ wire.OutPoint) {}, + NotifyInactiveChannel: func(_ wire.OutPoint) { + if cfg.NotifyInactive != nil { + cfg.NotifyInactive() + } + }, + NotifyInactiveLinkEvent: func(_ wire.OutPoint) {}, + NotifyChannelUpdate: func(*chanstate.OpenChannel) {}, + HtlcNotifier: r.htlcNotifier, + GetAliases: getAliases, + PreviouslySentShutdown: fn.None[lnwire.Shutdown](), + DisallowRouteBlinding: true, + DisallowQuiescence: true, + MaxFeeExposure: htlcswitch.DefaultMaxFeeExposure, + ShouldFwdExpAccountability: func() bool { + return false + }, + AuxTrafficShaper: noTrafficShaper, + AuxChannelNegotiator: noChannelNegotiator, + QuiescenceTimeout: defaultQuiescenceTimeout, + } + + link := htlcswitch.NewChannelLink(linkCfg, channel) + if cfg.AddsDisabled { + link.DisableAdds(htlcswitch.Incoming) + link.DisableAdds(htlcswitch.Outgoing) + } + channelID := lnwire.NewChanIDFromOutPoint(state.FundingOutpoint) + r.setAwaitingReestablish(channelID, cfg.SyncStates) + if err := r.switcher.AddLink(link); err != nil { + r.setAwaitingReestablish(channelID, false) + + return nil, fmt.Errorf("add lnd channel link: %w", err) + } + + return channel, nil +} + +// HandleChannelReestablish dispatches an expected startup handshake or +// recycles one live link when only the remote endpoint restarted. +func (r *Runtime) HandleChannelReestablish(message *lnwire.ChannelReestablish, + peer lnpeer.Peer, configSource LinkConfigSource) error { + + if message == nil { + return fmt.Errorf("channel reestablish message is required") + } + if peer == nil { + return fmt.Errorf("channel peer is required") + } + if configSource == nil { + return fmt.Errorf("link config source is required") + } + if r.claimExpectedReestablish(message.ChanID) { + return r.handleChannelMessage(message.ChanID, message) + } + + state, err := r.openChannelByID(peer, message.ChanID) + if err != nil { + return err + } + r.RemoveLink(state.FundingOutpoint) + linkConfig, err := configSource(state) + if err != nil { + return fmt.Errorf("build reestablished lnd link config: %w", + err) + } + linkConfig.Peer = peer + linkConfig.SyncStates = true + if _, err := r.AddLink(state, linkConfig); err != nil { + return fmt.Errorf("recycle lnd channel link: %w", err) + } + if !r.claimExpectedReestablish(message.ChanID) { + return fmt.Errorf("recycled lnd channel did not await " + + "reestablish") + } + + return r.handleChannelMessage(message.ChanID, message) +} + +// openChannelByID resolves one authenticated peer's durable channel state. +func (r *Runtime) openChannelByID(peer lnpeer.Peer, + channelID lnwire.ChannelID) (*chanstate.OpenChannel, error) { + + states, err := r.cfg.DB.ChannelStateDB().FetchOpenChannels( + peer.IdentityKey(), + ) + if err != nil { + return nil, fmt.Errorf("fetch lnd peer channels: %w", err) + } + for _, state := range states { + if lnwire.NewChanIDFromOutPoint( + state.FundingOutpoint, + ) == channelID { + return state, nil + } + } + + return nil, fmt.Errorf("find lnd channel link: %w", + htlcswitch.ErrChannelLinkNotFound) +} + +// setAwaitingReestablish records whether a newly installed link expects the +// next channel_reestablish instead of treating it as a remote restart signal. +func (r *Runtime) setAwaitingReestablish(channelID lnwire.ChannelID, + expected bool) { + + r.reestablishMu.Lock() + defer r.reestablishMu.Unlock() + if expected { + r.awaitingReestablish[channelID] = struct{}{} + + return + } + delete(r.awaitingReestablish, channelID) +} + +// claimExpectedReestablish consumes one startup handshake expectation. +func (r *Runtime) claimExpectedReestablish(channelID lnwire.ChannelID) bool { + r.reestablishMu.Lock() + defer r.reestablishMu.Unlock() + if _, ok := r.awaitingReestablish[channelID]; !ok { + return false + } + delete(r.awaitingReestablish, channelID) + + return true +} + +// HandleChannelMessage dispatches one incoming commitment or HTLC update to +// the native lnd link selected by the message's channel ID. +func (r *Runtime) HandleChannelMessage(message lnwire.LinkUpdater) error { + return r.handleChannelMessage(message.TargetChanID(), message) +} + +// HandlePeerMessage routes one authenticated BOLT message to the native lnd +// subsystem that owns it. +func (r *Runtime) HandlePeerMessage(ctx context.Context, message lnwire.Message, + peer lnpeer.Peer) error { + + if message == nil { + return fmt.Errorf("lnd peer message is required") + } + if peer == nil { + return fmt.Errorf("lnd peer is required") + } + + switch message := message.(type) { + case *lnwire.OpenChannel, *lnwire.AcceptChannel, + *lnwire.FundingCreated, *lnwire.FundingSigned, + *lnwire.ChannelReady: + + if r.funding == nil { + return fmt.Errorf("lnd funding runtime is disabled") + } + + return r.funding.ProcessMessageSync(ctx, message, peer) + + case *lnwire.Warning: + return r.handlePeerWarningOrError( + ctx, message.ChanID, message, peer, + ) + + case *lnwire.Error: + return r.handlePeerWarningOrError( + ctx, message.ChanID, message, peer, + ) + + case lnwire.LinkUpdater: + return r.HandleChannelMessage(message) + + case *lnwire.ChannelReestablish: + return r.handleChannelMessage(message.ChanID, message) + + case *lnwire.Ping: + if message.NumPongBytes > lnwire.MaxPongBytes { + return nil + } + + pong := lnwire.NewPong(make([]byte, message.NumPongBytes)) + + return peer.SendMessage(false, pong) + + case *lnwire.Pong, *lnwire.NodeAnnouncement1, + *lnwire.ChannelAnnouncement1, *lnwire.ChannelUpdate1: + return nil + + default: + return fmt.Errorf("unsupported lnd peer message %T", message) + } +} + +// PeerMessageHandler binds authenticated mailbox ingress to one logical lnd +// peer. +func (r *Runtime) PeerMessageHandler(peer lnpeer.Peer) PeerEventHandler { + return func(ctx context.Context, message lnwire.Message) error { + return r.HandlePeerMessage(ctx, message, peer) + } +} + +// handlePeerWarningOrError routes errors for pending channels through the +// funding coordinator and active-channel errors through the channel link. +func (r *Runtime) handlePeerWarningOrError(ctx context.Context, + channelID lnwire.ChannelID, message lnwire.Message, + peer lnpeer.Peer) error { + + if r.funding != nil && r.funding.IsPendingChannel(channelID, peer) { + return r.funding.ProcessMessageSync(ctx, message, peer) + } + + return r.handleChannelMessage(channelID, message) +} + +// handleChannelMessage finds the native link and queues one channel update. +func (r *Runtime) handleChannelMessage(channelID lnwire.ChannelID, + message lnwire.Message) error { + + link, err := r.switcher.GetLink(channelID) + if err != nil { + return fmt.Errorf("find lnd channel link: %w", err) + } + + link.HandleChannelUpdate(message) + + return nil +} + +// Invoices exposes lnd's native registry for invoice creation and +// subscriptions. +func (r *Runtime) Invoices() *invoices.InvoiceRegistry { + return r.invoices +} + +// Payments exposes fixed-route execution and lnd control-tower accounting. +func (r *Runtime) Payments() *FixedRoutePayments { + return r.payments +} + +// Funding exposes lnd's native external funding lifecycle when configured. +func (r *Runtime) Funding() *FundingRuntime { + return r.funding +} + +// GetLink returns an active native lnd channel link by short channel ID. +func (r *Runtime) GetLink(scid lnwire.ShortChannelID) (htlcswitch.ChannelLink, + error) { + + return r.switcher.GetLinkByShortID(scid) +} + +// RemoveLink stops and removes the native lnd link for a channel point. +func (r *Runtime) RemoveLink(channelPoint wire.OutPoint) { + channelID := lnwire.NewChanIDFromOutPoint(channelPoint) + r.setAwaitingReestablish(channelID, false) + r.switcher.RemoveLink(channelID) +} + +// WatchChannel admits an active channel to lnd's standard chain and contract +// lifecycle before the unpublished channel point can be materialized. +func (r *Runtime) WatchChannel(channel *chanstate.OpenChannel) error { + if r.onchain == nil { + return fmt.Errorf("on-chain lifecycle is disabled") + } + + return r.onchain.WatchChannel(channel) +} + +// HandoffChannel verifies lnd's standard chain lifecycle is armed before Ark +// publishes an active channel's backing transaction. WatchChannel is +// idempotent, so this also repairs a missed registration after restart. +func (r *Runtime) HandoffChannel(channelPoint wire.OutPoint) error { + if r.onchain == nil { + return fmt.Errorf("on-chain lifecycle is disabled") + } + state, err := r.cfg.DB.ChannelStateDB().FetchChannel(channelPoint) + if err != nil { + return fmt.Errorf("find channel for on-chain handoff: %w", err) + } + if state.IsPending { + return fmt.Errorf("cannot hand off a pending channel") + } + if err := r.WatchChannel(state); err != nil { + return fmt.Errorf("watch materialized channel: %w", err) + } + + return nil +} + +// ForceCloseChannel asks the standard lnd chain arbitrator to publish the +// latest commitment and own all output resolutions through completion. +func (r *Runtime) ForceCloseChannel(channelPoint wire.OutPoint) (*wire.MsgTx, + error) { + + if r.onchain == nil { + return nil, fmt.Errorf("on-chain lifecycle is disabled") + } + + return r.runForceClose(channelPoint, func() (*wire.MsgTx, error) { + return r.onchain.ForceClose(channelPoint) + }) +} + +// WaitForceCloseResult waits until lnd has durably classified the channel as +// locally or remotely force closed. This lets an Ark close request converge +// when both endpoints race to spend the newly materialized channel point and +// the peer commitment wins. +func (r *Runtime) WaitForceCloseResult(ctx context.Context, + channelPoint wire.OutPoint) (chainhash.Hash, error) { + + ticker := time.NewTicker(forceCloseResultPollInterval) + defer ticker.Stop() + + stateDB := r.cfg.DB.ChannelStateDB() + for { + summary, err := stateDB.FetchClosedChannel(&channelPoint) + switch { + case err == nil: + return forceCloseSummaryTxID(summary, channelPoint) + + case !errors.Is(err, channeldb.ErrClosedChannelNotFound): + return chainhash.Hash{}, fmt.Errorf("find force-close "+ + "result: %w", err) + } + + select { + case <-ctx.Done(): + return chainhash.Hash{}, ctx.Err() + + case <-ticker.C: + } + } +} + +// ResumeForceCloseChannel re-drives the publication edge after Ark has +// durably materialized a channel. A channel already broadcast or moved to the +// closed bucket needs no second force-close request. +func (r *Runtime) ResumeForceCloseChannel(channelPoint wire.OutPoint) error { + // A normal force close can be waiting inside the Ark backing barrier + // when the durable channel FSM records materialization. That transition + // emits the same resume action used by external parent-spend recovery. + // Let the original request continue instead of recursively waiting on + // its channel arbitrator. + if r.forceCloseIsActive(channelPoint) { + return nil + } + + stateDB := r.cfg.DB.ChannelStateDB() + channel, err := stateDB.FetchChannel(channelPoint) + if errors.Is(err, channeldb.ErrChannelNotFound) { + if _, closedErr := stateDB.FetchClosedChannel( + &channelPoint, + ); closedErr != nil { + return fmt.Errorf("find materialized channel: %w", + closedErr) + } + + return nil + } + if err != nil { + return fmt.Errorf("find materialized channel: %w", err) + } + if channel.HasChanStatus(channeldb.ChanStatusCommitBroadcasted) { + return nil + } + if _, err := r.onchain.ResumeForceClose(channelPoint); err != nil { + return fmt.Errorf("resume lnd force close: %w", err) + } + + return nil +} + +// runForceClose coalesces concurrent close requests for one channel point. A +// successful close stays cached for the runtime lifetime, while a failed close +// is removed so the durable lifecycle can retry it. +func (r *Runtime) runForceClose(channelPoint wire.OutPoint, + forceClose func() (*wire.MsgTx, error)) (*wire.MsgTx, error) { + + r.forceCloseMu.Lock() + if r.forceCloseCalls == nil { + r.forceCloseCalls = make(map[wire.OutPoint]*forceCloseCall) + } + call, ok := r.forceCloseCalls[channelPoint] + if !ok { + call = &forceCloseCall{done: make(chan struct{})} + r.forceCloseCalls[channelPoint] = call + } + r.forceCloseMu.Unlock() + + if ok { + <-call.done + + return call.tx, call.err + } + + call.tx, call.err = forceClose() + + r.forceCloseMu.Lock() + if call.err != nil { + delete(r.forceCloseCalls, channelPoint) + } + close(call.done) + r.forceCloseMu.Unlock() + + return call.tx, call.err +} + +// forceCloseIsActive reports whether the original LND force-close request is +// already responsible for advancing this channel. +func (r *Runtime) forceCloseIsActive(channelPoint wire.OutPoint) bool { + r.forceCloseMu.Lock() + defer r.forceCloseMu.Unlock() + + call, ok := r.forceCloseCalls[channelPoint] + if !ok { + return false + } + select { + case <-call.done: + return false + + default: + return true + } +} + +// forceCloseSummaryTxID validates that a closed-channel record proves the +// exact force-close outcome awaited by the caller. +func forceCloseSummaryTxID(summary *channeldb.ChannelCloseSummary, + channelPoint wire.OutPoint) (chainhash.Hash, error) { + + if summary == nil { + return chainhash.Hash{}, fmt.Errorf("force-close summary is " + + "nil") + } + if summary.ChanPoint != channelPoint { + return chainhash.Hash{}, fmt.Errorf("force-close summary is "+ + "for %v, not %v", summary.ChanPoint, channelPoint) + } + switch summary.CloseType { + case channeldb.LocalForceClose, channeldb.RemoteForceClose: + default: + return chainhash.Hash{}, fmt.Errorf("channel closed with type "+ + "%v, not a force close", summary.CloseType) + } + if summary.ClosingTXID == (chainhash.Hash{}) { + return chainhash.Hash{}, fmt.Errorf("force-close transaction " + + "ID is missing") + } + + return summary.ClosingTXID, nil +} + +// unavailableChannelUpdate is the default for a private runtime with no graph. +func unavailableChannelUpdate(lnwire.ShortChannelID) (*lnwire.ChannelUpdate1, + error) { + + return nil, fmt.Errorf("channel graph is disabled") +} + +// unavailableAliasSignature rejects graph updates in the private runtime. +func unavailableAliasSignature(*lnwire.ChannelUpdate1) (*ecdsa.Signature, + error) { + + return nil, fmt.Errorf("channel graph is disabled") +} diff --git a/lnruntime/runtime_test.go b/lnruntime/runtime_test.go new file mode 100644 index 000000000..8e1a11aef --- /dev/null +++ b/lnruntime/runtime_test.go @@ -0,0 +1,494 @@ +package lnruntime + +import ( + "fmt" + "sync/atomic" + "testing" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/chaincfg/v2" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/lightningnetwork/lnd/channeldb" + "github.com/lightningnetwork/lnd/chanstate" + "github.com/lightningnetwork/lnd/contractcourt" + "github.com/lightningnetwork/lnd/graph/db/models" + "github.com/lightningnetwork/lnd/htlcswitch" + "github.com/lightningnetwork/lnd/htlcswitch/hop" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/invoices" + "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lnpeer" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/routing/route" + "github.com/stretchr/testify/require" +) + +// TestDurablePeerPendingCommitTickerStaysParked proves mailbox delay cannot be +// misclassified as a broken socket and fail an otherwise recoverable link. +func TestDurablePeerPendingCommitTickerStaysParked(t *testing.T) { + t.Parallel() + + var pendingCommitTicker durablePeerPendingCommitTicker + pendingCommitTicker.Resume() + require.Nil(t, pendingCommitTicker.Ticks()) + pendingCommitTicker.Pause() + require.Nil(t, pendingCommitTicker.Ticks()) + pendingCommitTicker.Stop() + + select { + case <-pendingCommitTicker.Ticks(): + t.Fatal("durable peer pending-commit ticker fired") + + case <-time.After(time.Millisecond): + } +} + +// TestRuntimeStartsNativeSubsystems verifies the composed lifecycle starts and +// stops without constructing an lnd daemon or graph. +func TestRuntimeStartsNativeSubsystems(t *testing.T) { + t.Parallel() + + db := channeldb.OpenForTesting(t, t.TempDir()) + t.Cleanup(func() { + require.NoError(t, db.Close()) + }) + + nodeKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + notifier := newRuntimeNotifier(800_000) + runtime, err := NewRuntime(RuntimeConfig{ + DB: db, + Chain: fixedHeightChain{height: 800_000}, + Notifier: notifier, + OnionKey: &keychain.PrivKeyECDH{PrivKey: nodeKey}, + Signer: input.NewMockSigner( + []*btcec.PrivateKey{nodeKey}, + &chaincfg.RegressionNetParams, + ), + FeeEstimator: chainfee.NewStaticEstimator(1_250, 253), + WitnessBeacon: &runtimeWitnessBeacon{ + cache: db.NewWitnessCache(), + }, + SelfNode: route.NewVertex(nodeKey.PubKey()), + }) + require.NoError(t, err) + require.NoError(t, runtime.Start()) + require.NoError(t, runtime.Start()) + require.NoError(t, runtime.Stop()) + require.NoError(t, runtime.Stop()) + require.GreaterOrEqual(t, notifier.epochRegistrations.Load(), int32(2)) +} + +// TestForceCloseSingleFlight verifies duplicate RPC delivery joins the request +// already blocked in Ark materialization instead of entering lnd twice. +func TestForceCloseSingleFlight(t *testing.T) { + t.Parallel() + + channelPoint := wire.OutPoint{Index: 7} + runtime := &Runtime{} + started := make(chan struct{}) + release := make(chan struct{}) + closeTx := wire.NewMsgTx(2) + var calls atomic.Int32 + forceClose := func() (*wire.MsgTx, error) { + calls.Add(1) + close(started) + <-release + + return closeTx, nil + } + type result struct { + tx *wire.MsgTx + err error + } + results := make(chan result, 2) + request := func() { + tx, err := runtime.runForceClose(channelPoint, forceClose) + results <- result{tx: tx, err: err} + } + + go request() + <-started + go request() + + require.NoError(t, runtime.ResumeForceCloseChannel(channelPoint)) + require.True(t, runtime.forceCloseIsActive(channelPoint)) + close(release) + for range 2 { + outcome := <-results + require.NoError(t, outcome.err) + require.Same(t, closeTx, outcome.tx) + } + require.Equal(t, int32(1), calls.Load()) + require.False(t, runtime.forceCloseIsActive(channelPoint)) +} + +// TestForceCloseSummaryTxID accepts only an exact local or remote force-close +// record as proof that a competing endpoint completed the close. +func TestForceCloseSummaryTxID(t *testing.T) { + t.Parallel() + + channelPoint := wire.OutPoint{Index: 7} + closingTxID := chainhash.Hash{1} + for _, closeType := range []channeldb.ClosureType{ + channeldb.LocalForceClose, channeldb.RemoteForceClose, + } { + summary := &channeldb.ChannelCloseSummary{ + ChanPoint: channelPoint, + ClosingTXID: closingTxID, + CloseType: closeType, + } + result, err := forceCloseSummaryTxID(summary, channelPoint) + require.NoError(t, err) + require.Equal(t, closingTxID, result) + } + + _, err := forceCloseSummaryTxID(&channeldb.ChannelCloseSummary{ + ChanPoint: channelPoint, + ClosingTXID: closingTxID, + CloseType: channeldb.CooperativeClose, + }, channelPoint) + require.ErrorContains(t, err, "not a force close") + + _, err = forceCloseSummaryTxID(&channeldb.ChannelCloseSummary{ + ChanPoint: wire.OutPoint{Index: 8}, + ClosingTXID: closingTxID, + CloseType: channeldb.RemoteForceClose, + }, channelPoint) + require.ErrorContains(t, err, "summary is for") + + _, err = forceCloseSummaryTxID(&channeldb.ChannelCloseSummary{ + ChanPoint: channelPoint, + CloseType: channeldb.LocalForceClose, + }, channelPoint) + require.ErrorContains(t, err, "transaction ID is missing") +} + +// TestRuntimePaysOverNativeChannelLinks proves Wavelength can run lnd's +// channel and payment state machines without the lnd daemon or peer manager. +func TestRuntimePaysOverNativeChannelLinks(t *testing.T) { + t.Parallel() + + aliceState, bobState, err := lnwallet.CreateTestChannels( + t, channeldb.SingleFunderTweaklessBit, + ) + require.NoError(t, err) + + aliceNode, err := btcec.NewPrivateKey() + require.NoError(t, err) + bobNode, err := btcec.NewPrivateKey() + require.NoError(t, err) + + alice := newTestRuntime( + t, aliceNode, aliceState.Signer, + ) + bob := newTestRuntime(t, bobNode, bobState.Signer) + require.NoError(t, alice.runtime.Start()) + require.NoError(t, bob.runtime.Start()) + t.Cleanup(func() { + require.NoError(t, alice.runtime.Stop()) + require.NoError(t, bob.runtime.Stop()) + }) + + aliceTransport := &runtimeMessageTransport{remote: bob.runtime} + bobTransport := &runtimeMessageTransport{remote: alice.runtime} + alicePeer := newRuntimePeer(t, bobNode.PubKey(), aliceTransport) + bobPeer := newRuntimePeer(t, aliceNode.PubKey(), bobTransport) + + failures := make(chan error, 2) + aliceLinkCfg := testLinkConfig(alicePeer, failures) + bobLinkCfg := testLinkConfig(bobPeer, failures) + _, err = alice.runtime.AddLink(aliceState.State(), aliceLinkCfg) + require.NoError(t, err) + _, err = bob.runtime.AddLink(bobState.State(), bobLinkCfg) + require.NoError(t, err) + + preimage := lntypes.Preimage{9, 8, 7, 6} + const amount = lnwire.MilliSatoshi(25_000) + _, err = bob.runtime.Invoices().AddInvoice( + t.Context(), &invoices.Invoice{ + CreationDate: time.Now(), + Terms: invoices.ContractTerm{ + FinalCltvDelta: 18, + Expiry: time.Hour, + PaymentPreimage: &preimage, + Value: amount, + Features: emptyFeatureVector(), + }, + }, preimage.Hash(), + ) + require.NoError(t, err) + + scid := aliceState.ShortChanID().ToUint64() + paymentRoute := &route.Route{ + TotalTimeLock: 800_040, + TotalAmount: amount, + SourcePubKey: route.NewVertex(aliceNode.PubKey()), + Hops: []*route.Hop{ + { + PubKeyBytes: route.NewVertex( + bobNode.PubKey(), + ), + ChannelID: scid, + OutgoingTimeLock: 800_040, + AmtToForward: amount, + LegacyPayload: true, + }, + }, + } + + result := make(chan error, 1) + go func() { + attempt, sendErr := alice.runtime.Payments().SendToOperator( + t.Context(), preimage.Hash(), paymentRoute, nil, + ) + if sendErr == nil && attempt.Settle == nil { + sendErr = fmt.Errorf("payment attempt did not settle") + } + result <- sendErr + }() + + select { + case err := <-result: + require.NoError(t, err) + + case err := <-failures: + require.NoError(t, err) + + case <-time.After(10 * time.Second): + t.Fatal("native lnd channel payment did not complete") + } + + invoice, err := bob.runtime.Invoices().LookupInvoice( + t.Context(), preimage.Hash(), + ) + require.NoError(t, err) + require.Equal(t, invoices.ContractSettled, invoice.State) +} + +// testRuntime owns one runtime and its independent lnd database. +type testRuntime struct { + runtime *Runtime +} + +// newTestRuntime creates a native component runtime around one channel signer. +func newTestRuntime(t *testing.T, nodeKey *btcec.PrivateKey, + signer input.Signer) *testRuntime { + + t.Helper() + + db := channeldb.OpenForTesting(t, t.TempDir()) + t.Cleanup(func() { + require.NoError(t, db.Close()) + }) + notifier := newRuntimeNotifier(800_000) + runtime, err := NewRuntime(RuntimeConfig{ + DB: db, + Chain: fixedHeightChain{height: 800_000}, + Notifier: notifier, + OnionKey: &keychain.PrivKeyECDH{PrivKey: nodeKey}, + Signer: signer, + FeeEstimator: chainfee.NewStaticEstimator(1_250, 253), + WitnessBeacon: &runtimeWitnessBeacon{ + cache: db.NewWitnessCache(), + }, + SelfNode: route.NewVertex(nodeKey.PubKey()), + }) + require.NoError(t, err) + + return &testRuntime{runtime: runtime} +} + +// newRuntimePeer creates the peer adapter used by one test link. +func newRuntimePeer(t *testing.T, remoteKey *btcec.PublicKey, + transport MessageTransport) *Peer { + + t.Helper() + + peer, err := NewPeer(PeerConfig{ + RemoteKey: remoteKey, + Transport: transport, + AddChannel: func(*lnpeer.NewChannel, <-chan struct{}) error { + return nil + }, + }) + require.NoError(t, err) + + return peer +} + +// testLinkConfig returns callbacks suitable for an unpublished test channel. +func testLinkConfig(peer lnpeer.Peer, failures chan<- error) LinkConfig { + chainEvents := &contractcourt.ChainEventSubscription{ + RemoteUnilateralClosure: make( + chan *contractcourt.RemoteUnilateralCloseInfo, + ), + LocalUnilateralClosure: make( + chan *contractcourt.LocalUnilateralCloseInfo, + ), + CooperativeClosure: make( + chan *contractcourt.CooperativeCloseInfo, + ), + ContractBreach: make(chan *contractcourt.BreachCloseInfo), + Cancel: func() {}, + } + notifyContractUpdate := func(*contractcourt.ContractUpdate) error { + return nil + } + + return LinkConfig{ + Peer: peer, + Policy: models.ForwardingPolicy{ + MinHTLCOut: 1, + TimeLockDelta: 18, + }, + ChainEvents: chainEvents, + MaxAnchorFeeRate: 2_500, + OnChannelFailure: func(_ lnwire.ChannelID, + _ lnwire.ShortChannelID, + failure htlcswitch.LinkFailureError) { + + failures <- failure + }, + UpdateContractSignals: func( + *contractcourt.ContractSignals) error { + + return nil + }, + NotifyContractUpdate: notifyContractUpdate, + } +} + +// runtimeMessageTransport directly hands each lnd link update to the remote +// runtime. Production uses the same interface over swapdk transport. +type runtimeMessageTransport struct { + remote *Runtime +} + +// SendMessages preserves message order while dispatching to remote links. +func (t *runtimeMessageTransport) SendMessages(_ bool, + messages ...lnwire.Message) error { + + for _, message := range messages { + update, ok := message.(lnwire.LinkUpdater) + if !ok { + return fmt.Errorf("unexpected link message %T", message) + } + if err := t.remote.HandleChannelMessage(update); err != nil { + return err + } + } + + return nil +} + +// runtimeNotifier supplies independent block streams to native lnd +// components. +type runtimeNotifier struct { + height int32 + started atomic.Bool + epochRegistrations atomic.Int32 +} + +// newRuntimeNotifier creates a started notifier at a fixed height. +func newRuntimeNotifier(height int32) *runtimeNotifier { + notifier := &runtimeNotifier{height: height} + notifier.started.Store(true) + + return notifier +} + +// RegisterConfirmationsNtfn returns an idle confirmation subscription. +func (*runtimeNotifier) RegisterConfirmationsNtfn(*chainhash.Hash, []byte, + uint32, uint32, ...chainntnfs.NotifierOption) ( + *chainntnfs.ConfirmationEvent, error) { + + return chainntnfs.NewConfirmationEvent(1, func() {}), nil +} + +// RegisterSpendNtfn returns an idle spend subscription. +func (*runtimeNotifier) RegisterSpendNtfn(*wire.OutPoint, []byte, uint32) ( + *chainntnfs.SpendEvent, error) { + + return chainntnfs.NewSpendEvent(func() {}), nil +} + +// RegisterBlockEpochNtfn returns a private stream seeded with the current tip. +func (n *runtimeNotifier) RegisterBlockEpochNtfn(*chainntnfs.BlockEpoch) ( + *chainntnfs.BlockEpochEvent, error) { + + n.epochRegistrations.Add(1) + epochs := make(chan *chainntnfs.BlockEpoch, 1) + epochs <- &chainntnfs.BlockEpoch{ + Height: n.height, + Hash: &chainhash.Hash{}, + } + + return &chainntnfs.BlockEpochEvent{ + Epochs: epochs, + Cancel: func() {}, + }, nil +} + +// Start marks the notifier active. +func (n *runtimeNotifier) Start() error { + n.started.Store(true) + + return nil +} + +// Started reports notifier state. +func (n *runtimeNotifier) Started() bool { + return n.started.Load() +} + +// Stop marks the notifier inactive. +func (n *runtimeNotifier) Stop() error { + n.started.Store(false) + + return nil +} + +// runtimeWitnessBeacon persists preimages for native channel links. Tests do +// not exercise on-chain subscriptions. +type runtimeWitnessBeacon struct { + cache *channeldb.WitnessCache +} + +// SubscribeUpdates returns an idle subscription for an on-chain resolver. +func (*runtimeWitnessBeacon) SubscribeUpdates(lnwire.ShortChannelID, + *chanstate.HTLC, *hop.Payload, []byte) ( + *contractcourt.WitnessSubscription, error) { + + updates := make(chan lntypes.Preimage) + + return &contractcourt.WitnessSubscription{ + WitnessUpdates: updates, + CancelSubscription: func() {}, + }, nil +} + +// LookupPreimage reads lnd's persistent witness cache. +func (b *runtimeWitnessBeacon) LookupPreimage(hash lntypes.Hash) ( + lntypes.Preimage, bool) { + + preimage, err := b.cache.LookupSha256Witness(hash) + + return preimage, err == nil +} + +// AddPreimages writes lnd's persistent witness cache. +func (b *runtimeWitnessBeacon) AddPreimages( + preimages ...lntypes.Preimage) error { + + return b.cache.AddSha256Witnesses(preimages...) +} + +var _ chainntnfs.ChainNotifier = (*runtimeNotifier)(nil) +var _ contractcourt.WitnessBeacon = (*runtimeWitnessBeacon)(nil) diff --git a/lnruntime/virtual_notifier.go b/lnruntime/virtual_notifier.go new file mode 100644 index 000000000..257676400 --- /dev/null +++ b/lnruntime/virtual_notifier.go @@ -0,0 +1,365 @@ +package lnruntime + +import ( + "bytes" + "fmt" + "sync" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/lightningnetwork/lnd/lnwire" +) + +// VirtualFunding identifies an unpublished Lightning funding transaction and +// the stable SCID lnd should use after its prepared OOR transfer finalizes. +type VirtualFunding struct { + Transaction *wire.MsgTx + OutputIndex uint32 + SCID lnwire.ShortChannelID +} + +// virtualFundingRecord stores one immutable backing transaction and all lnd +// confirmation subscriptions waiting for Ark activation. +type virtualFundingRecord struct { + funding VirtualFunding + confirmed bool + canceled bool + registrations map[uint64]*chainntnfs.ConfirmationEvent +} + +// VirtualFundingNotifier delegates normal chain events while treating a +// fully signed backing transaction as virtually confirmed after its prepared +// OOR transfer finalizes. RegisterVirtualFunding must run before +// funding.Manager starts watching the channel point. +type VirtualFundingNotifier struct { + chainntnfs.ChainNotifier + + mu sync.Mutex + nextID uint64 + virtualFunding map[chainhash.Hash]*virtualFundingRecord +} + +// NewVirtualFundingNotifier wraps an ordinary notifier with Ark channel +// activation semantics. +func NewVirtualFundingNotifier(chainNotifier chainntnfs.ChainNotifier) ( + *VirtualFundingNotifier, error) { + + if chainNotifier == nil { + return nil, fmt.Errorf("chain notifier is required") + } + + return &VirtualFundingNotifier{ + ChainNotifier: chainNotifier, + virtualFunding: make( + map[chainhash.Hash]*virtualFundingRecord, + ), + }, nil +} + +// RegisterVirtualFunding installs an immutable backing transaction before lnd +// registers its confirmation subscription. Re-registration is idempotent only +// when every funding detail is identical. +func (n *VirtualFundingNotifier) RegisterVirtualFunding( + funding VirtualFunding) error { + + if funding.Transaction == nil { + return fmt.Errorf("virtual funding transaction is required") + } + if len(funding.Transaction.TxIn) == 0 { + return fmt.Errorf("virtual funding transaction has no inputs") + } + for index, txIn := range funding.Transaction.TxIn { + if len(txIn.Witness) == 0 && len(txIn.SignatureScript) == 0 { + return fmt.Errorf("virtual funding input %d is "+ + "not signed", index) + } + } + if funding.OutputIndex >= uint32(len(funding.Transaction.TxOut)) { + return fmt.Errorf("virtual funding output %d is out of range", + funding.OutputIndex) + } + if funding.SCID.TxPosition != uint16(funding.OutputIndex) { + return fmt.Errorf("virtual SCID output %d does not match "+ + "funding output %d", funding.SCID.TxPosition, + funding.OutputIndex) + } + if funding.SCID.BlockHeight == 0 { + return fmt.Errorf("virtual funding SCID height is required") + } + + tx := funding.Transaction.Copy() + txid := tx.TxHash() + funding.Transaction = tx + + n.mu.Lock() + defer n.mu.Unlock() + + existing, ok := n.virtualFunding[txid] + if ok { + if existing.canceled { + return fmt.Errorf("virtual funding transaction %s was "+ + "canceled", txid) + } + + return sameVirtualFunding(existing.funding, funding) + } + + n.virtualFunding[txid] = &virtualFundingRecord{ + funding: funding, + registrations: make(map[uint64]*chainntnfs.ConfirmationEvent), + } + + return nil +} + +// UnregisterVirtualFunding rolls back a registration if lnd rejects the PSBT +// before it creates a confirmation subscription. Active or confirmed funding +// records cannot be removed. +func (n *VirtualFundingNotifier) UnregisterVirtualFunding( + txid chainhash.Hash) error { + + n.mu.Lock() + defer n.mu.Unlock() + + record, ok := n.virtualFunding[txid] + if !ok { + return nil + } + if record.confirmed || len(record.registrations) != 0 { + return fmt.Errorf("virtual funding transaction %s is active", + txid) + } + delete(n.virtualFunding, txid) + + return nil +} + +// CancelVirtualFunding removes an unconfirmed virtual registration and wakes +// lnd confirmation waiters so a durable Ark cancellation can complete. +func (n *VirtualFundingNotifier) CancelVirtualFunding( + txid chainhash.Hash) error { + + n.mu.Lock() + record, ok := n.virtualFunding[txid] + if !ok { + n.mu.Unlock() + + return nil + } + if record.confirmed { + n.mu.Unlock() + + return fmt.Errorf("virtual funding transaction %s is confirmed", + txid) + } + record.canceled = true + events := make( + []*chainntnfs.ConfirmationEvent, 0, len(record.registrations), + ) + for _, event := range record.registrations { + events = append(events, event) + } + record.registrations = make(map[uint64]*chainntnfs.ConfirmationEvent) + n.mu.Unlock() + + for _, event := range events { + close(event.Confirmed) + } + + return nil +} + +// RegisterConfirmationsNtfn intercepts only registered virtual funding txids. +// Every other confirmation remains owned by the underlying chain notifier. +func (n *VirtualFundingNotifier) RegisterConfirmationsNtfn(txid *chainhash.Hash, + pkScript []byte, numConfs, heightHint uint32, + opts ...chainntnfs.NotifierOption) (*chainntnfs.ConfirmationEvent, + error) { + + if txid == nil { + return n.ChainNotifier.RegisterConfirmationsNtfn( + txid, pkScript, numConfs, heightHint, opts..., + ) + } + + n.mu.Lock() + record, ok := n.virtualFunding[*txid] + if !ok { + n.mu.Unlock() + + return n.ChainNotifier.RegisterConfirmationsNtfn( + txid, pkScript, numConfs, heightHint, opts..., + ) + } + + fundingOutput := record.funding.Transaction.TxOut[record. + funding. + OutputIndex] + if !bytes.Equal(fundingOutput.PkScript, pkScript) { + n.mu.Unlock() + + return nil, fmt.Errorf("virtual funding script does not " + + "match registered channel output") + } + + n.nextID++ + registrationID := n.nextID + event := chainntnfs.NewConfirmationEvent(numConfs, func() { + n.cancelRegistration(*txid, registrationID) + }) + if record.canceled { + n.mu.Unlock() + close(event.Confirmed) + + return event, nil + } + record.registrations[registrationID] = event + confirmed := record.confirmed + funding := record.funding + n.mu.Unlock() + + if confirmed { + n.notifyConfirmed(event, funding) + } + + return event, nil +} + +// ConfirmVirtualFunding activates lnd's ordinary funding-confirmation path. +// The Ark FSM must call this only after both the backing signatures and the +// finalized OOR transfer are durable. +func (n *VirtualFundingNotifier) ConfirmVirtualFunding( + txid chainhash.Hash) error { + + n.mu.Lock() + record, ok := n.virtualFunding[txid] + if !ok { + n.mu.Unlock() + + return fmt.Errorf("virtual funding transaction %s is not "+ + "registered", txid) + } + if record.canceled { + n.mu.Unlock() + + return fmt.Errorf("virtual funding transaction %s was canceled", + txid) + } + if record.confirmed { + n.mu.Unlock() + + return nil + } + record.confirmed = true + funding := record.funding + events := make( + []*chainntnfs.ConfirmationEvent, 0, len(record.registrations), + ) + for _, event := range record.registrations { + events = append(events, event) + } + n.mu.Unlock() + + for _, event := range events { + n.notifyConfirmed(event, funding) + } + + return nil +} + +// ReorgVirtualFunding retracts a prior virtual confirmation when its Ark +// ancestry is invalidated. A later ConfirmVirtualFunding call can reactivate +// the same subscriptions. +func (n *VirtualFundingNotifier) ReorgVirtualFunding(txid chainhash.Hash, + depth int32) error { + + n.mu.Lock() + record, ok := n.virtualFunding[txid] + if !ok { + n.mu.Unlock() + + return fmt.Errorf("virtual funding transaction %s is not "+ + "registered", txid) + } + if !record.confirmed { + n.mu.Unlock() + + return nil + } + record.confirmed = false + events := make( + []*chainntnfs.ConfirmationEvent, 0, len(record.registrations), + ) + for _, event := range record.registrations { + events = append(events, event) + } + n.mu.Unlock() + + for _, event := range events { + event.NegativeConf <- depth + } + + return nil +} + +// cancelRegistration removes a virtual confirmation subscription. +func (n *VirtualFundingNotifier) cancelRegistration(txid chainhash.Hash, + registrationID uint64) { + + n.mu.Lock() + defer n.mu.Unlock() + + record, ok := n.virtualFunding[txid] + if !ok { + return + } + delete(record.registrations, registrationID) +} + +// notifyConfirmed reports the reserved SCID coordinates while attaching the +// actual backing transaction so lnd can validate its channel output. +func (n *VirtualFundingNotifier) notifyConfirmed( + event *chainntnfs.ConfirmationEvent, funding VirtualFunding) { + + height := funding.SCID.BlockHeight + if cap(event.Updates) > 0 { + event.Updates <- chainntnfs.TxUpdateInfo{ + BlockHeight: height, + NumConfsLeft: 0, + } + } + event.Confirmed <- &chainntnfs.TxConfirmation{ + BlockHash: &chainhash.Hash{}, + BlockHeight: height, + TxIndex: funding.SCID.TxIndex, + Tx: funding.Transaction.Copy(), + } +} + +// sameVirtualFunding checks idempotent registration without retaining a +// caller-owned mutable transaction pointer. +func sameVirtualFunding(a, b VirtualFunding) error { + if a.OutputIndex != b.OutputIndex || a.SCID != b.SCID { + return fmt.Errorf("virtual funding transaction already " + + "registered with different channel coordinates") + } + + var aBytes, bBytes bytes.Buffer + if err := a.Transaction.Serialize(&aBytes); err != nil { + return fmt.Errorf("serialize registered funding "+ + "transaction: %w", err) + } + if err := b.Transaction.Serialize(&bBytes); err != nil { + return fmt.Errorf("serialize repeated funding transaction: %w", + err) + } + if !bytes.Equal(aBytes.Bytes(), bBytes.Bytes()) { + return fmt.Errorf("virtual funding transaction already " + + "registered with different bytes") + } + + return nil +} + +var _ chainntnfs.ChainNotifier = (*VirtualFundingNotifier)(nil) diff --git a/lnruntime/virtual_notifier_test.go b/lnruntime/virtual_notifier_test.go new file mode 100644 index 000000000..197897765 --- /dev/null +++ b/lnruntime/virtual_notifier_test.go @@ -0,0 +1,173 @@ +package lnruntime + +import ( + "sync/atomic" + "testing" + "time" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/stretchr/testify/require" +) + +// countingNotifier records confirmation registrations delegated to the real +// chain backend. +type countingNotifier struct { + *runtimeNotifier + + confirmations atomic.Int32 +} + +// RegisterConfirmationsNtfn records and returns an idle real-chain event. +func (n *countingNotifier) RegisterConfirmationsNtfn(*chainhash.Hash, []byte, + uint32, uint32, ...chainntnfs.NotifierOption) ( + *chainntnfs.ConfirmationEvent, error) { + + n.confirmations.Add(1) + + return chainntnfs.NewConfirmationEvent(1, func() {}), nil +} + +// TestVirtualFundingNotifierActivatesAfterOORFinalization verifies lnd sees no +// funding confirmation before the Ark coordinator opens the activation gate. +func TestVirtualFundingNotifierActivatesAfterOORFinalization(t *testing.T) { + t.Parallel() + + base := &countingNotifier{runtimeNotifier: newRuntimeNotifier(800_000)} + notifier, err := NewVirtualFundingNotifier(base) + require.NoError(t, err) + + fundingTx := testVirtualFundingTx() + scid := lnwire.ShortChannelID{ + BlockHeight: 16_000_123, + TxIndex: 42, + TxPosition: 0, + } + require.NoError( + t, + notifier.RegisterVirtualFunding( + VirtualFunding{ + Transaction: fundingTx, + OutputIndex: 0, + SCID: scid, + }, + ), + ) + + txid := fundingTx.TxHash() + event, err := notifier.RegisterConfirmationsNtfn( + &txid, fundingTx.TxOut[0].PkScript, 1, 800_000, + ) + require.NoError(t, err) + require.Zero(t, base.confirmations.Load()) + + select { + case <-event.Confirmed: + t.Fatal("virtual funding confirmed before OOR finalization") + + case <-time.After(20 * time.Millisecond): + } + + require.NoError(t, notifier.ConfirmVirtualFunding(txid)) + update := <-event.Updates + require.Equal(t, scid.BlockHeight, update.BlockHeight) + confirmation := <-event.Confirmed + require.Equal(t, scid.BlockHeight, confirmation.BlockHeight) + require.Equal(t, scid.TxIndex, confirmation.TxIndex) + require.Equal(t, txid, confirmation.Tx.TxHash()) + + require.NoError(t, notifier.ReorgVirtualFunding(txid, 2)) + require.EqualValues(t, 2, <-event.NegativeConf) + require.NoError(t, notifier.ConfirmVirtualFunding(txid)) + <-event.Updates + confirmation = <-event.Confirmed + require.Equal(t, txid, confirmation.Tx.TxHash()) +} + +// TestVirtualFundingNotifierDelegatesUnknownTransactions verifies normal chain +// notifications keep their existing backend semantics. +func TestVirtualFundingNotifierDelegatesUnknownTransactions(t *testing.T) { + t.Parallel() + + base := &countingNotifier{runtimeNotifier: newRuntimeNotifier(800_000)} + notifier, err := NewVirtualFundingNotifier(base) + require.NoError(t, err) + + txid := chainhash.Hash{1, 2, 3} + _, err = notifier.RegisterConfirmationsNtfn(&txid, []byte{0x51}, 1, 0) + require.NoError(t, err) + require.EqualValues(t, 1, base.confirmations.Load()) +} + +// TestVirtualFundingNotifierRejectsMismatchedFunding verifies the reserved +// SCID and channel output cannot silently diverge. +func TestVirtualFundingNotifierRejectsMismatchedFunding(t *testing.T) { + t.Parallel() + + notifier, err := NewVirtualFundingNotifier( + newRuntimeNotifier(800_000), + ) + require.NoError(t, err) + + fundingTx := testVirtualFundingTx() + err = notifier.RegisterVirtualFunding(VirtualFunding{ + Transaction: fundingTx, + OutputIndex: 0, + SCID: lnwire.ShortChannelID{ + BlockHeight: 16_000_123, + TxPosition: 1, + }, + }) + require.ErrorContains(t, err, "does not match funding output") +} + +// TestVirtualFundingNotifierCancellationFencesLateWaiters verifies a pending +// lnd channel cannot escape cancellation by registering after the Ark event. +func TestVirtualFundingNotifierCancellationFencesLateWaiters(t *testing.T) { + t.Parallel() + + base := &countingNotifier{runtimeNotifier: newRuntimeNotifier(800_000)} + notifier, err := NewVirtualFundingNotifier(base) + require.NoError(t, err) + fundingTx := testVirtualFundingTx() + funding := VirtualFunding{ + Transaction: fundingTx, + OutputIndex: 0, + SCID: lnwire.ShortChannelID{ + BlockHeight: 16_000_123, + TxIndex: 42, + }, + } + require.NoError(t, notifier.RegisterVirtualFunding(funding)) + txid := fundingTx.TxHash() + require.NoError(t, notifier.CancelVirtualFunding(txid)) + + event, err := notifier.RegisterConfirmationsNtfn( + &txid, fundingTx.TxOut[0].PkScript, 1, 800_000, + ) + require.NoError(t, err) + _, ok := <-event.Confirmed + require.False(t, ok) + require.Zero(t, base.confirmations.Load()) + require.ErrorContains( + t, notifier.ConfirmVirtualFunding(txid), + "was canceled", + ) +} + +// testVirtualFundingTx creates an immutable witness funding transaction. +func testVirtualFundingTx() *wire.MsgTx { + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{Hash: chainhash.Hash{9}}, + Witness: wire.TxWitness{[]byte{1}}, + }) + tx.AddTxOut(&wire.TxOut{ + Value: 100_000, + PkScript: []byte{0x00, 0x20, 1, 2, 3}, + }) + + return tx +} diff --git a/lnruntime/witness_beacon.go b/lnruntime/witness_beacon.go new file mode 100644 index 000000000..b9a7ee1ba --- /dev/null +++ b/lnruntime/witness_beacon.go @@ -0,0 +1,95 @@ +package lnruntime + +import ( + "fmt" + "sync" + + "github.com/lightningnetwork/lnd/channeldb" + "github.com/lightningnetwork/lnd/chanstate" + "github.com/lightningnetwork/lnd/contractcourt" + "github.com/lightningnetwork/lnd/htlcswitch/hop" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/lnwire" +) + +// WitnessBeacon persists invoice preimages in lnd's channel database and +// provides the subscription surface expected by native channel links. +type WitnessBeacon struct { + cache *channeldb.WitnessCache + + mu sync.Mutex + nextID uint64 + subscribers map[uint64]chan lntypes.Preimage +} + +// NewWitnessBeacon constructs a persistent witness beacon. +func NewWitnessBeacon(db *channeldb.DB) (*WitnessBeacon, error) { + if db == nil { + return nil, fmt.Errorf("channel database is required") + } + + return &WitnessBeacon{ + cache: db.NewWitnessCache(), + subscribers: make( + map[uint64]chan lntypes.Preimage, + ), + }, nil +} + +// SubscribeUpdates registers a resolver for future preimages. A subscriber +// can always recover a missed update through LookupPreimage. +func (b *WitnessBeacon) SubscribeUpdates(lnwire.ShortChannelID, *chanstate.HTLC, + *hop.Payload, []byte) (*contractcourt.WitnessSubscription, error) { + + b.mu.Lock() + b.nextID++ + id := b.nextID + updates := make(chan lntypes.Preimage, 16) + b.subscribers[id] = updates + b.mu.Unlock() + + var once sync.Once + + return &contractcourt.WitnessSubscription{ + WitnessUpdates: updates, + CancelSubscription: func() { + once.Do(func() { + b.mu.Lock() + delete(b.subscribers, id) + close(updates) + b.mu.Unlock() + }) + }, + }, nil +} + +// LookupPreimage reads lnd's persistent witness cache. +func (b *WitnessBeacon) LookupPreimage(hash lntypes.Hash) (lntypes.Preimage, + bool) { + + preimage, err := b.cache.LookupSha256Witness(hash) + + return preimage, err == nil +} + +// AddPreimages persists preimages before notifying live subscribers. +func (b *WitnessBeacon) AddPreimages(preimages ...lntypes.Preimage) error { + if err := b.cache.AddSha256Witnesses(preimages...); err != nil { + return err + } + + b.mu.Lock() + defer b.mu.Unlock() + for _, preimage := range preimages { + for _, updates := range b.subscribers { + select { + case updates <- preimage: + default: + } + } + } + + return nil +} + +var _ contractcourt.WitnessBeacon = (*WitnessBeacon)(nil) diff --git a/lwwallet/esplora_chain.go b/lwwallet/esplora_chain.go index 1420ddc8d..603acb0cd 100644 --- a/lwwallet/esplora_chain.go +++ b/lwwallet/esplora_chain.go @@ -812,6 +812,10 @@ func (s *EsploraChainService) Rescan(startHash *chainhash.Hash, if err != nil { return fmt.Errorf("get tip height for rescan: %w", err) } + if startHeight > tipHeight { + return fmt.Errorf("rescan start height %d exceeds tip %d", + startHeight, tipHeight) + } s.log.InfoS(ctx, "Starting chain rescan", slog.Int("start_height", int(startHeight)), @@ -822,7 +826,11 @@ func (s *EsploraChainService) Rescan(startHash *chainhash.Hash, // Collect all notifications first, then flush them // asynchronously. See method doc for deadlock rationale. - var pending []interface{} + var ( + pending []interface{} + finalHash chainhash.Hash + finalTime time.Time + ) // Ask the address index which transactions in this range are // relevant, instead of downloading every block and scanning it. @@ -871,6 +879,8 @@ func (s *EsploraChainService) Rescan(startHash *chainhash.Hash, }, Time: time.Unix(blockHeader.Timestamp, 0), } + finalHash = blockHash + finalTime = blockMeta.Time // Build records for whatever the index attributed to this // canonical block. An inconsistent index answer falls back to @@ -941,6 +951,15 @@ func (s *EsploraChainService) Rescan(startHash *chainhash.Hash, ) } + // btcwallet only marks its chain view synchronized after this terminal + // notification. Advancing SyncedTo without it leaves channel funding + // disabled even though every block has been processed. + pending = append(pending, &chain.RescanFinished{ + Hash: &finalHash, + Height: tipHeight, + Time: finalTime, + }) + s.log.InfoS(ctx, "Chain rescan complete", slog.Int("tip_height", int(tipHeight)), slog.Int("pending_notifications", len(pending)), diff --git a/lwwallet/esplora_filterblocks_test.go b/lwwallet/esplora_filterblocks_test.go index e7c018540..a140c4808 100644 --- a/lwwallet/esplora_filterblocks_test.go +++ b/lwwallet/esplora_filterblocks_test.go @@ -957,6 +957,12 @@ func TestRescanUsesIndexAndOrdersDependencies(t *testing.T) { t, svc.notifications, ) require.Equal(t, meta.Hash, wtxmgr.BlockMeta(connected).Hash) + + finished := requireNotification[*chain.RescanFinished]( + t, svc.notifications, + ) + require.Equal(t, meta.Hash, *finished.Hash) + require.Equal(t, meta.Height, finished.Height) require.Zero(t, idx.rawBlockFetches) }