From b63614ec01013ad5b5d3810575c07eebb53f1815 Mon Sep 17 00:00:00 2001 From: sputn1ck Date: Mon, 24 Aug 2026 16:17:35 +0200 Subject: [PATCH 1/4] build: Pin modular lnd runtime Consume the reviewed lnd fork that exposes external funding and lifecycle barriers without embedding a second daemon. --- .golangci.yml | 2 ++ go.mod | 7 +++++-- go.sum | 4 ++-- 3 files changed, 9 insertions(+), 4 deletions(-) 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/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= From 8ecef70282209f1290319bb49270bd9aacad5ed3 Mon Sep 17 00:00:00 2001 From: sputn1ck Date: Mon, 24 Aug 2026 16:17:35 +0200 Subject: [PATCH 2/4] lnruntime: Compose native Ark channel node Instantiate lnd channel, link, invoice, payment, funding, and close state machines behind one process-owned runtime. --- lnruntime/ark_funding.go | 182 ++ lnruntime/close.go | 117 ++ lnruntime/cooperative_close.go | 619 ++++++ lnruntime/cooperative_close_test.go | 59 + lnruntime/external_close.go | 252 +++ lnruntime/funding.go | 704 +++++++ lnruntime/funding_flow_test.go | 1843 ++++++++++++++++++ lnruntime/funding_test.go | 127 ++ lnruntime/funding_wire.go | 610 ++++++ lnruntime/intent_acceptor.go | 142 ++ lnruntime/intent_acceptor_test.go | 250 +++ lnruntime/link_config.go | 51 + lnruntime/mailbox_transport.go | 278 +++ lnruntime/mailbox_transport_test.go | 179 ++ lnruntime/negotiator.go | 670 +++++++ lnruntime/node.go | 979 ++++++++++ lnruntime/node_test.go | 63 + lnruntime/onchain.go | 505 +++++ lnruntime/payments.go | 284 +++ lnruntime/payments_test.go | 225 +++ lnruntime/peer.go | 223 +++ lnruntime/peer_ingress.go | 260 +++ lnruntime/peer_ingress_test.go | 241 +++ lnruntime/peer_test.go | 121 ++ lnruntime/process_cooperative_close.go | 906 +++++++++ lnruntime/process_cooperative_close_rpc.go | 656 +++++++ lnruntime/process_cooperative_close_test.go | 182 ++ lnruntime/process_funding_rpc.go | 1914 +++++++++++++++++++ lnruntime/process_funding_rpc_test.go | 60 + lnruntime/recovery.go | 84 + lnruntime/runtime.go | 978 ++++++++++ lnruntime/runtime_test.go | 494 +++++ lnruntime/virtual_notifier.go | 365 ++++ lnruntime/virtual_notifier_test.go | 173 ++ lnruntime/witness_beacon.go | 95 + 35 files changed, 14891 insertions(+) create mode 100644 lnruntime/ark_funding.go create mode 100644 lnruntime/close.go create mode 100644 lnruntime/cooperative_close.go create mode 100644 lnruntime/cooperative_close_test.go create mode 100644 lnruntime/external_close.go create mode 100644 lnruntime/funding.go create mode 100644 lnruntime/funding_flow_test.go create mode 100644 lnruntime/funding_test.go create mode 100644 lnruntime/funding_wire.go create mode 100644 lnruntime/intent_acceptor.go create mode 100644 lnruntime/intent_acceptor_test.go create mode 100644 lnruntime/link_config.go create mode 100644 lnruntime/mailbox_transport.go create mode 100644 lnruntime/mailbox_transport_test.go create mode 100644 lnruntime/negotiator.go create mode 100644 lnruntime/node.go create mode 100644 lnruntime/node_test.go create mode 100644 lnruntime/onchain.go create mode 100644 lnruntime/payments.go create mode 100644 lnruntime/payments_test.go create mode 100644 lnruntime/peer.go create mode 100644 lnruntime/peer_ingress.go create mode 100644 lnruntime/peer_ingress_test.go create mode 100644 lnruntime/peer_test.go create mode 100644 lnruntime/process_cooperative_close.go create mode 100644 lnruntime/process_cooperative_close_rpc.go create mode 100644 lnruntime/process_cooperative_close_test.go create mode 100644 lnruntime/process_funding_rpc.go create mode 100644 lnruntime/process_funding_rpc_test.go create mode 100644 lnruntime/recovery.go create mode 100644 lnruntime/runtime.go create mode 100644 lnruntime/runtime_test.go create mode 100644 lnruntime/virtual_notifier.go create mode 100644 lnruntime/virtual_notifier_test.go create mode 100644 lnruntime/witness_beacon.go diff --git a/lnruntime/ark_funding.go b/lnruntime/ark_funding.go new file mode 100644 index 000000000..ec83b25ad --- /dev/null +++ b/lnruntime/ark_funding.go @@ -0,0 +1,182 @@ +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/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 after Ark released virtual confirmation. +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) + } + + return !channel.IsPending, 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..85a43d4a1 --- /dev/null +++ b/lnruntime/funding.go @@ -0,0 +1,704 @@ +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 + + 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, + 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..e2a49a55d --- /dev/null +++ b/lnruntime/funding_flow_test.go @@ -0,0 +1,1843 @@ +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) + + 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) From 5e0f7c6a9f04fcdbd782a1095929386f396f982d Mon Sep 17 00:00:00 2001 From: sputn1ck Date: Mon, 24 Aug 2026 16:17:35 +0200 Subject: [PATCH 3/4] multi: Adapt wallet backends for channel runtime Expose chain notification and fee boundaries required by the native channel runtime over the existing wallet backends. --- chainbackends/backend_notifier.go | 292 +++++++++++++++++++++++++ chainbackends/backend_notifier_test.go | 94 ++++++++ chainfees/backend.go | 73 +++++++ chainfees/backend_test.go | 67 ++++++ lwwallet/esplora_chain.go | 21 +- lwwallet/esplora_filterblocks_test.go | 6 + 6 files changed, 552 insertions(+), 1 deletion(-) create mode 100644 chainbackends/backend_notifier.go create mode 100644 chainbackends/backend_notifier_test.go create mode 100644 chainfees/backend.go create mode 100644 chainfees/backend_test.go 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/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) } From 13aac6ee94e8b0d47b496ccfe2323e3506a77b48 Mon Sep 17 00:00:00 2001 From: sputn1ck Date: Mon, 24 Aug 2026 17:17:31 +0200 Subject: [PATCH 4/4] lnruntime: Gate channel activation on link readiness The durable Ark channel FSM must not expose an active channel until the native lnd link for its reserved SCID can actually carry HTLCs. Requiring both the open channel row and switch link removes a race where the first payment could terminally fail immediately after promotion. --- lnruntime/ark_funding.go | 18 ++++++++++++++++-- lnruntime/funding.go | 2 ++ lnruntime/funding_flow_test.go | 9 +++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/lnruntime/ark_funding.go b/lnruntime/ark_funding.go index ec83b25ad..0a20a7413 100644 --- a/lnruntime/ark_funding.go +++ b/lnruntime/ark_funding.go @@ -11,6 +11,7 @@ import ( "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" ) @@ -95,7 +96,7 @@ func (f *FundingRuntime) FundingFinalized(ctx context.Context, } // ChannelActive reports whether the exact finalized channel has left lnd's -// pending-open state after Ark released virtual confirmation. +// 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) { @@ -110,8 +111,21 @@ func (f *FundingRuntime) ChannelActive(ctx context.Context, 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 !channel.IsPending, nil + return true, nil } // channelParties resolves this runtime's role and expected remote identity. diff --git a/lnruntime/funding.go b/lnruntime/funding.go index 85a43d4a1..3975030b5 100644 --- a/lnruntime/funding.go +++ b/lnruntime/funding.go @@ -87,6 +87,7 @@ type FundingRuntime struct { wallet *lnwallet.LightningWallet notifier *VirtualFundingNotifier aliases *aliasmgr.Manager + switcher *htlcswitch.Switch feeEstimator chainfee.Estimator netParams *chaincfg.Params @@ -233,6 +234,7 @@ func newFundingRuntime(runtimeCfg RuntimeConfig, switcher *htlcswitch.Switch, wallet: lightningWallet, notifier: virtualNotifier, aliases: aliasManager, + switcher: switcher, feeEstimator: runtimeCfg.FeeEstimator, netParams: cfg.NetParams, chain: runtimeCfg.Chain, diff --git a/lnruntime/funding_flow_test.go b/lnruntime/funding_flow_test.go index e2a49a55d..9373be031 100644 --- a/lnruntime/funding_flow_test.go +++ b/lnruntime/funding_flow_test.go @@ -820,6 +820,15 @@ func TestNativeFundingFlowRestoresQuiescedLinks(t *testing.T) { 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,