From 9800ee38912c6c6a373d436f2e76efb2abc35e22 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 25 Feb 2026 09:24:54 +0100 Subject: [PATCH 01/16] go.mod: go mod tidy --- go.mod | 5 +++++ go.sum | 13 +++++++++++++ 2 files changed, 18 insertions(+) diff --git a/go.mod b/go.mod index db1d50f2e..cbcdf4b59 100644 --- a/go.mod +++ b/go.mod @@ -57,6 +57,7 @@ require ( github.com/Yawning/aez v0.0.0-20211027044916-e49e68abd344 // indirect github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da // indirect github.com/aead/siphash v1.0.1 // indirect + github.com/andybalholm/brotli v1.0.4 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/btcsuite/btclog v0.0.0-20241003133417-09c4e92e319c // indirect github.com/btcsuite/btcwallet/wallet/txauthor v1.3.5 // indirect @@ -112,6 +113,7 @@ require ( github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackpal/gateway v1.0.5 // indirect github.com/jackpal/go-nat-pmp v0.0.0-20170405195558-28a68d0c24ad // indirect + github.com/jedib0t/go-pretty/v6 v6.2.7 // indirect github.com/jonboulle/clockwork v0.2.2 // indirect github.com/jrick/logrotate v1.1.2 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -130,6 +132,7 @@ require ( github.com/lightningnetwork/lnd/sqldb v1.0.12-0.20260113193010-8565d12e40b1 // indirect github.com/ltcsuite/ltcd v0.0.0-20190101042124-f37f8bf35796 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.13 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect github.com/mholt/acmez v1.0.4 // indirect github.com/miekg/dns v1.1.50 // indirect @@ -149,6 +152,7 @@ require ( github.com/prometheus/common v0.37.0 // indirect github.com/prometheus/procfs v0.8.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rivo/uniseg v0.2.0 // indirect github.com/rogpeppe/fastuuid v1.2.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect @@ -158,6 +162,7 @@ require ( github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 // indirect github.com/tmc/grpc-websocket-proxy v0.0.0-20201229170055-e5319fda7802 // indirect github.com/tv42/zbase32 v0.0.0-20160707012821-501572607d02 // indirect + github.com/urfave/cli v1.22.14 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect diff --git a/go.sum b/go.sum index e8f4379c9..87c3958ec 100644 --- a/go.sum +++ b/go.sum @@ -600,6 +600,7 @@ git.sr.ht/~sbinet/gg v0.3.1/go.mod h1:KGYtlADtqsqANL9ueOFkWymvzUvLMQllU5Ixo+8v3p github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs= github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= @@ -630,6 +631,7 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= +github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY= github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/apache/arrow/go/v10 v10.0.1/go.mod h1:YvhnlEePVnBS4+0z3fhPfUy7W1Ikj0Ih0vcRo/gZ1M0= @@ -1040,6 +1042,8 @@ github.com/jackpal/gateway v1.0.5 h1:qzXWUJfuMdlLMtt0a3Dgt+xkWQiA5itDEITVJtuSwMc github.com/jackpal/gateway v1.0.5/go.mod h1:lTpwd4ACLXmpyiCTRtfiNyVnUmqT9RivzCDQetPfnjA= github.com/jackpal/go-nat-pmp v0.0.0-20170405195558-28a68d0c24ad h1:heFfj7z0pGsNCekUlsFhO2jstxO4b5iQ665LjwM5mDc= github.com/jackpal/go-nat-pmp v0.0.0-20170405195558-28a68d0c24ad/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= +github.com/jedib0t/go-pretty/v6 v6.2.7 h1:4823Lult/tJ0VI1PgW3aSKw59pMWQ6Kzv9b3Bj6MwY0= +github.com/jedib0t/go-pretty/v6 v6.2.7/go.mod h1:FMkOpgGD3EZ91cW8g/96RfxoV7bdeJyzXPYgz1L1ln0= github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jessevdk/go-flags v1.4.0 h1:4IU2WS7AumrZ/40jfhf4QVDMsQwqA7VEHozFRrGARJA= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= @@ -1159,6 +1163,8 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 h1:I0XW9+e1XWDxdcEniV4rQAIOPUGDq67JSCiRCgGCZLI= @@ -1221,6 +1227,7 @@ github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/profile v1.6.0/go.mod h1:qBsxPvzyUincmltOk6iyRVxHYg4adc0OFOv72ZdLa18= github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -1254,6 +1261,8 @@ github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0ua github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/rogpeppe/fastuuid v1.2.0 h1:Ppwyp6VYCF1nvBTXL3trRso7mXMlRrw9ooo375wvi2s= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= @@ -1305,6 +1314,7 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= @@ -1313,6 +1323,8 @@ github.com/tmc/grpc-websocket-proxy v0.0.0-20201229170055-e5319fda7802 h1:uruHq4 github.com/tmc/grpc-websocket-proxy v0.0.0-20201229170055-e5319fda7802/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/tv42/zbase32 v0.0.0-20160707012821-501572607d02 h1:tcJ6OjwOMvExLlzrAVZute09ocAGa7KqOON60++Gz4E= github.com/tv42/zbase32 v0.0.0-20160707012821-501572607d02/go.mod h1:tHlrkM198S068ZqfrO6S8HsoJq2bF3ETfTL+kt4tInY= +github.com/urfave/cli v1.22.14 h1:ebbhrRiGK2i4naQJr+1Xj92HXZCrK7MsyTS/ob3HnAk= +github.com/urfave/cli v1.22.14/go.mod h1:X0eDS6pD6Exaclxm99NJ3FiCDRED7vIHpx2mDOHLvkA= github.com/urfave/cli-docs/v3 v3.1.1-0.20251020101624-bec07369b4f6 h1:cm0BrTu3Q0CNo+vB5kErCcVMZqD2D1z7y3YVIiBlr+o= github.com/urfave/cli-docs/v3 v3.1.1-0.20251020101624-bec07369b4f6/go.mod h1:59d+5Hz1h6GSGJ10cvcEkbIe3j233t4XDqI72UIx7to= github.com/urfave/cli/v3 v3.4.1 h1:1M9UOCy5bLmGnuu1yn3t3CB4rG79Rtoxuv1sPhnm6qM= @@ -1611,6 +1623,7 @@ golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20180816055513-1c9583448a9c/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= From f1c138abf54b901a0314f4dcf40066c04198a2db Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 19 Feb 2026 09:55:22 +0100 Subject: [PATCH 02/16] deposit: remove dead code in reconcileDeposits Remove unreachable error check after filterNewDeposits which does not return an error. The err variable was already handled from the ListUnspent call above and could never be non-nil at this point. --- staticaddr/deposit/manager.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/staticaddr/deposit/manager.go b/staticaddr/deposit/manager.go index ccce718ba..13cc4c8d7 100644 --- a/staticaddr/deposit/manager.go +++ b/staticaddr/deposit/manager.go @@ -239,10 +239,6 @@ func (m *Manager) reconcileDeposits(ctx context.Context) error { } newDeposits := m.filterNewDeposits(utxos) - if err != nil { - return fmt.Errorf("unable to filter new deposits: %w", err) - } - if len(newDeposits) == 0 { log.Tracef("No new deposits...") return nil From 81012de948c8429b1476c76c916aad94566e4a66 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 4 Feb 2026 20:15:17 +0100 Subject: [PATCH 03/16] staticaddr: replace block-based deposit detection with polling Block-based deposit fetching from the internal lnd wallet was susceptible to wallet syncing issues. Replace it with interval-based polling. Reconciliation errors are now logged instead of being fatal, improving resilience during transient failures. --- staticaddr/deposit/manager.go | 49 +++++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/staticaddr/deposit/manager.go b/staticaddr/deposit/manager.go index 13cc4c8d7..72375edbe 100644 --- a/staticaddr/deposit/manager.go +++ b/staticaddr/deposit/manager.go @@ -32,6 +32,10 @@ const ( // DefaultTransitionTimeout is the default timeout for transitions in // the deposit state machine. DefaultTransitionTimeout = 5 * time.Second + + // PollInterval is the interval in which we poll for new deposits to our + // static address. + PollInterval = 10 * time.Second ) // ManagerConfig holds the configuration for the address manager. @@ -116,14 +120,16 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error { return err } - // Initially reconcile new deposits after a restart, so we catch up with - // missed deposits while we were offline. - if err = m.reconcileDeposits(ctx); err != nil { + // Reconcile immediately on startup so deposits are available + // before the first ticker fires. + err = m.reconcileDeposits(ctx) + if err != nil { log.Errorf("unable to reconcile deposits: %v", err) - - return err } + // Start the deposit notifier. + m.pollDeposits(ctx) + // Communicate to the caller that the address manager has completed its // initialization. close(initChan) @@ -151,15 +157,6 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error { } } - // Reconcile new deposits that might have just gotten - // confirmed. - if err = m.reconcileDeposits(ctx); err != nil { - log.Errorf("unable to reconcile deposits: %v", - err) - - return err - } - case outpoint := <-m.finalizedDepositChan: // If deposits notify us about their finalization, flush // the finalized deposit from memory. @@ -224,6 +221,30 @@ func (m *Manager) recoverDeposits(ctx context.Context) error { return nil } +// pollDeposits polls new deposits to our static address and notifies the +// manager's event loop about them. +func (m *Manager) pollDeposits(ctx context.Context) { + log.Debugf("Waiting for new static address deposits...") + + go func() { + ticker := time.NewTicker(PollInterval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + err := m.reconcileDeposits(ctx) + if err != nil { + log.Errorf("unable to reconcile "+ + "deposits: %v", err) + } + + case <-ctx.Done(): + return + } + } + }() +} + // reconcileDeposits fetches all spends to our static addresses from our lnd // wallet and matches it against the deposits in our memory that we've seen so // far. It picks the newly identified deposits and starts a state machine per From d12663ea7c5f3fe2d37fa2d5c6ee1fd24545704e Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 21 May 2025 14:16:27 +0200 Subject: [PATCH 04/16] staticaddr: channel open states for deposit and fsm --- staticaddr/deposit/deposit.go | 3 ++- staticaddr/deposit/fsm.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/staticaddr/deposit/deposit.go b/staticaddr/deposit/deposit.go index 56dc318d2..4cb64bc95 100644 --- a/staticaddr/deposit/deposit.go +++ b/staticaddr/deposit/deposit.go @@ -70,7 +70,8 @@ func (d *Deposit) IsInFinalState() bool { defer d.Unlock() return d.state == Expired || d.state == Withdrawn || - d.state == LoopedIn || d.state == HtlcTimeoutSwept + d.state == LoopedIn || d.state == HtlcTimeoutSwept || + d.state == ChannelPublished } func (d *Deposit) IsExpired(currentHeight, expiry uint32) bool { diff --git a/staticaddr/deposit/fsm.go b/staticaddr/deposit/fsm.go index 0a78b86ca..029f7f63d 100644 --- a/staticaddr/deposit/fsm.go +++ b/staticaddr/deposit/fsm.go @@ -35,6 +35,8 @@ var ( fsm.OnError: {}, OnWithdrawInitiated: {}, OnWithdrawn: {}, + OnOpeningChannel: {}, + OnChannelPublished: {}, } ) @@ -51,6 +53,15 @@ var ( // Withdrawn signals that the withdrawal transaction has been confirmed. Withdrawn = fsm.StateType("Withdrawn") + // OpeningChannel signals that the open channel transaction has been + // broadcast. + OpeningChannel = fsm.StateType("OpeningChannel") + + // ChannelPublished signals that the open channel transaction has been + // published and that the channel should be managed from lnd from now + // on. + ChannelPublished = fsm.StateType("ChannelPublished") + // LoopingIn signals that the deposit is locked for a loop in swap. LoopingIn = fsm.StateType("LoopingIn") @@ -93,6 +104,15 @@ var ( // OnWithdrawn is sent to the fsm when a withdrawal has been confirmed. OnWithdrawn = fsm.EventType("OnWithdrawn") + // OnOpeningChannel is sent to the fsm when a channel open has been + // initiated. + OnOpeningChannel = fsm.EventType("OnOpeningChannel") + + // OnChannelPublished is sent to the fsm when a channel open has been + // published. Loop has done its work here and the channel should now be + // managed from lnd. + OnChannelPublished = fsm.EventType("OnChannelPublished") + // OnLoopInInitiated is sent to the fsm when a loop in has been // initiated. OnLoopInInitiated = fsm.EventType("OnLoopInInitiated") @@ -253,6 +273,7 @@ func (f *FSM) DepositStatesV0() fsm.States { OnExpiry: PublishExpirySweep, OnWithdrawInitiated: Withdrawing, OnLoopInInitiated: LoopingIn, + OnOpeningChannel: OpeningChannel, // We encounter OnSweepingHtlcTimeout if the // server published the htlc tx without paying // us. We then need to monitor for the timeout @@ -366,6 +387,17 @@ func (f *FSM) DepositStatesV0() fsm.States { }, Action: f.FinalizeDepositAction, }, + OpeningChannel: fsm.State{ + Transitions: fsm.Transitions{ + fsm.OnError: Deposited, + OnChannelPublished: ChannelPublished, + OnRecover: OpeningChannel, + }, + Action: fsm.NoOpAction, + }, + ChannelPublished: fsm.State{ + Action: f.FinalizeDepositAction, + }, } } From dfc75f2e1ade87d8bfc82551071d8b4b4c00bc74 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 21 May 2025 14:17:14 +0200 Subject: [PATCH 05/16] staticaddr: open channel manager --- staticaddr/log.go | 2 + staticaddr/openchannel/interface.go | 50 ++ staticaddr/openchannel/log.go | 24 + staticaddr/openchannel/manager.go | 796 +++++++++++++++++++++ staticaddr/openchannel/manager_test.go | 314 ++++++++ staticaddr/staticutil/utils.go | 79 +- staticaddr/staticutil/utils_test.go | 336 +++++++++ staticaddr/withdraw/funding_values_test.go | 265 +++++++ staticaddr/withdraw/manager.go | 80 ++- 9 files changed, 1890 insertions(+), 56 deletions(-) create mode 100644 staticaddr/openchannel/interface.go create mode 100644 staticaddr/openchannel/log.go create mode 100644 staticaddr/openchannel/manager.go create mode 100644 staticaddr/openchannel/manager_test.go create mode 100644 staticaddr/withdraw/funding_values_test.go diff --git a/staticaddr/log.go b/staticaddr/log.go index 70dccc874..26b43bded 100644 --- a/staticaddr/log.go +++ b/staticaddr/log.go @@ -5,6 +5,7 @@ import ( "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/loopin" + "github.com/lightninglabs/loop/staticaddr/openchannel" "github.com/lightninglabs/loop/staticaddr/withdraw" "github.com/lightningnetwork/lnd/build" ) @@ -29,4 +30,5 @@ func UseLogger(logger btclog.Logger) { deposit.UseLogger(log) withdraw.UseLogger(log) loopin.UseLogger(log) + openchannel.UseLogger(log) } diff --git a/staticaddr/openchannel/interface.go b/staticaddr/openchannel/interface.go new file mode 100644 index 000000000..e8e206911 --- /dev/null +++ b/staticaddr/openchannel/interface.go @@ -0,0 +1,50 @@ +package openchannel + +import ( + "context" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/fsm" + "github.com/lightninglabs/loop/staticaddr/address" + "github.com/lightninglabs/loop/staticaddr/deposit" + "github.com/lightninglabs/loop/staticaddr/script" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" +) + +// AddressManager handles fetching of address parameters. +type AddressManager interface { + // GetStaticAddressParameters returns the static address parameters. + GetStaticAddressParameters(ctx context.Context) (*address.Parameters, + error) + + // GetStaticAddress returns the deposit address for the given + // client and server public keys. + GetStaticAddress(ctx context.Context) (*script.StaticAddress, error) +} + +type DepositManager interface { + // AllOutpointsActiveDeposits returns all deposits that are in the + // given state. If the state filter is fsm.StateTypeNone, all deposits + // are returned. + AllOutpointsActiveDeposits(outpoints []wire.OutPoint, + stateFilter fsm.StateType) ([]*deposit.Deposit, bool) + + // GetActiveDepositsInState returns all deposits that are in the + // given state. + GetActiveDepositsInState(stateFilter fsm.StateType) ([]*deposit.Deposit, + error) + + // TransitionDeposits transitions the deposits to the given state. + TransitionDeposits(ctx context.Context, deposits []*deposit.Deposit, + event fsm.EventType, expectedFinalState fsm.StateType) error +} + +type WithdrawalManager interface { + CreateFinalizedWithdrawalTx(ctx context.Context, + deposits []*deposit.Deposit, withdrawalAddress btcutil.Address, + feeRate chainfee.SatPerKWeight, + selectedWithdrawalAmount int64, + commitmentType lnrpc.CommitmentType) (*wire.MsgTx, []byte, error) +} diff --git a/staticaddr/openchannel/log.go b/staticaddr/openchannel/log.go new file mode 100644 index 000000000..855bafbae --- /dev/null +++ b/staticaddr/openchannel/log.go @@ -0,0 +1,24 @@ +package openchannel + +import ( + "github.com/btcsuite/btclog/v2" + "github.com/lightningnetwork/lnd/build" +) + +// Subsystem defines the sub system name of this package. +const Subsystem = "SCHOPEN" + +// log is a logger that is initialized with no output filters. This means the +// package will not perform any logging by default until the caller requests it. +var log btclog.Logger + +// The default amount of logging is none. +func init() { + UseLogger(build.NewSubLogger(Subsystem, nil)) +} + +// UseLogger uses a specified Logger to output package logging info. This should +// be used in preference to SetLogWriter if the caller is also using btclog. +func UseLogger(logger btclog.Logger) { + log = logger +} diff --git a/staticaddr/openchannel/manager.go b/staticaddr/openchannel/manager.go new file mode 100644 index 000000000..e4d306beb --- /dev/null +++ b/staticaddr/openchannel/manager.go @@ -0,0 +1,796 @@ +package openchannel + +import ( + "bytes" + "context" + "crypto/rand" + "errors" + "fmt" + "io" + "strings" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/lndclient" + "github.com/lightninglabs/loop/fsm" + "github.com/lightninglabs/loop/staticaddr/deposit" + "github.com/lightninglabs/loop/staticaddr/staticutil" + "github.com/lightninglabs/loop/staticaddr/withdraw" + serverrpc "github.com/lightninglabs/loop/swapserverrpc" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/lightningnetwork/lnd/lnwallet/chanfunding" +) + +const ( + // The minimum number of confirmations lnd requires inputs to have for a + // channel opening. + defaultUtxoMinConf = 1 + + // Is the default confirmation target for a channel open transaction. + defaultConfTarget int32 = 3 +) + +var ( + ErrOpeningChannelUnavailableDeposits = errors.New("some deposits are " + + "not usable to open a channel with") + + // errPsbtFinalized is returned when the PSBT finalize step was already + // sent to lnd. After this point the funding transaction may have been + // broadcast, so deposits must not be rolled back to Deposited. + errPsbtFinalized = errors.New("PSBT finalize already sent") +) + +// Config is the configuration struct for the open channel manager. +type Config struct { + // StaticAddressServerClient is the client that calls the swap server + // rpcs to negotiate static address withdrawals. + Server serverrpc.StaticAddressServerClient + + // AddressManager gives the withdrawal manager access to static address + // parameters. + AddressManager AddressManager + + // DepositManager gives the withdrawal manager access to the deposits + // enabling it to create and manage withdrawals. + DepositManager DepositManager + + // WithdrawalManager is used to create the withdrawal transaction into + // the channel funding address. + WithdrawalManager WithdrawalManager + + // WalletKit is the wallet client that is used to derive new keys from + // lnd's wallet. + WalletKit lndclient.WalletKitClient + + // ChainParams is the chain configuration(mainnet, testnet...) this + // manager uses. + ChainParams *chaincfg.Params + + // ChainNotifier is the chain notifier that is used to listen for new + // blocks. + ChainNotifier lndclient.ChainNotifierClient + + // Signer is the signer client that is used to sign transactions. + Signer lndclient.SignerClient + + // LightningClient is the lnd client that is used to open channels. + LightningClient lndclient.LightningClient +} + +type newOpenChannelRequest struct { + request *lnrpc.OpenChannelRequest + respChan chan *newOpenChannelResponse +} + +type newOpenChannelResponse struct { + // ChanTxHash is the transaction hash of the channel open transaction. + ChanTxHash *chainhash.Hash + + // Err is the error that occurred during the channel open process. + err error +} + +// Manager is the main struct that handles the open channel manager. +type Manager struct { + cfg *Config + + newOpenChannelRequestChan chan newOpenChannelRequest + + // exitChan signals subroutines that the open channel is exiting. + exitChan chan struct{} + + // errChan forwards errors from the open channel to the server. + errChan chan error +} + +// NewManager creates a new manager instance. +func NewManager(cfg *Config) *Manager { + m := &Manager{ + cfg: cfg, + exitChan: make(chan struct{}), + newOpenChannelRequestChan: make(chan newOpenChannelRequest), + errChan: make(chan error), + } + + return m +} + +// Run runs the open channel manager. +func (m *Manager) Run(ctx context.Context) error { + err := m.recoverOpeningChannelDeposits(ctx) + if err != nil { + return err + } + + for { + select { + case req := <-m.newOpenChannelRequestChan: + chanTxHash, err := m.OpenChannel(ctx, req.request) + resp := &newOpenChannelResponse{ + ChanTxHash: chanTxHash, + err: err, + } + + select { + case req.respChan <- resp: + + case <-ctx.Done(): + // Notify subroutines that the main loop has + // been canceled. + close(m.exitChan) + + return ctx.Err() + } + + case <-ctx.Done(): + // Signal subroutines that the manager is exiting. + close(m.exitChan) + + return ctx.Err() + } + } +} + +// recoverOpeningChannelDeposits resolves deposits that were left in +// OpeningChannel after a client restart. If a deposit input is still unspent, +// the channel open did not publish and we move back to Deposited. If the input +// is no longer unspent, it was spent on-chain and we finalize it as +// ChannelPublished. +func (m *Manager) recoverOpeningChannelDeposits(ctx context.Context) error { + openingDeposits, err := m.cfg.DepositManager.GetActiveDepositsInState( + deposit.OpeningChannel, + ) + if err != nil { + return fmt.Errorf("unable to fetch opening channel deposits: %w", + err) + } + + if len(openingDeposits) == 0 { + return nil + } + + log.Infof("Recovering %d deposits in OpeningChannel state", + len(openingDeposits)) + + utxos, err := m.cfg.WalletKit.ListUnspent( + ctx, 0, 0, + ) + if err != nil { + return fmt.Errorf("unable to list unspent outputs for recovery: %w", + err) + } + + unspentOutpoints := make(map[wire.OutPoint]struct{}, len(utxos)) + for _, utxo := range utxos { + unspentOutpoints[utxo.OutPoint] = struct{}{} + } + + var ( + deposited []*deposit.Deposit + channelPublished []*deposit.Deposit + ) + + for _, d := range openingDeposits { + _, stillUnspent := unspentOutpoints[d.OutPoint] + if stillUnspent { + deposited = append(deposited, d) + continue + } + + channelPublished = append(channelPublished, d) + } + + if len(deposited) > 0 { + err = m.cfg.DepositManager.TransitionDeposits( + ctx, deposited, fsm.OnError, deposit.Deposited, + ) + if err != nil { + return fmt.Errorf("unable to recover unspent opening "+ + "deposits: %w", err) + } + } + + if len(channelPublished) > 0 { + err = m.cfg.DepositManager.TransitionDeposits( + ctx, channelPublished, deposit.OnChannelPublished, + deposit.ChannelPublished, + ) + if err != nil { + return fmt.Errorf("unable to recover spent opening "+ + "deposits: %w", err) + } + } + + log.Infof("Recovered opening channel deposits: %d returned to Deposited, "+ + "%d marked ChannelPublished", len(deposited), + len(channelPublished)) + + return nil +} + +// OpenChannel transitions the requested deposits into the OpeningChannel state +// and then starts the open channel psbt flow between the client's lnd instance +// and the server. +func (m *Manager) OpenChannel(ctx context.Context, + req *lnrpc.OpenChannelRequest) (*chainhash.Hash, error) { + + var ( + outpoints []wire.OutPoint + deposits []*deposit.Deposit + allActive bool + feeRate chainfee.SatPerKWeight + err error + ) + + if req.LocalFundingAmount == 0 && !req.FundMax { + return nil, fmt.Errorf("either local funding amount or " + + "fundmax must be set") + } + + if req.LocalFundingAmount != 0 && req.FundMax { + return nil, fmt.Errorf("local funding amount and fundmax " + + "cannot be set at the same time") + } + + // Validate PSBT-incompatible flags early, before locking deposits. + // We accept MinConfs=0 here because that's the proto default for unset + // values and normalize to defaultUtxoMinConf later. + if err := validateInitialPsbtFlags(req); err != nil { + return nil, err + } + + // Determine the commitment type for the channel. + chanCommitmentType, err := resolveCommitmentType(req.CommitmentType) + if err != nil { + return nil, err + } + + // Estimate the fee rate before deposit selection so that we can verify + // the selected deposits cover the funding amount plus fees. + if req.SatPerVbyte == 0 { + feeRate, err = m.cfg.WalletKit.EstimateFeeRate( + ctx, defaultConfTarget, + ) + if err != nil { + return nil, fmt.Errorf("error estimating fee rate: %w", + err) + } + } else { + feeRate = chainfee.SatPerKVByte( + req.SatPerVbyte * 1000, + ).FeePerKWeight() + } + + // There are three ways in which we select deposits to open a channel + // with. 1.) The user manually selects the deposits. 2.) The user only + // selects a local channel amount in which case we coin-select deposits + // to cover for it. 3.) The user selects the fundmax flag, in which case + // we select all deposits to fund the channel. + if len(req.Outpoints) > 0 { + // Ensure that the deposits are in a state in which they are + // available for a channel open. + outpoints, err = staticutil.ToWireOutpoints(req.Outpoints) + if err != nil { + return nil, fmt.Errorf("error parsing outpoints: %w", + err) + } + + deposits, allActive = + m.cfg.DepositManager.AllOutpointsActiveDeposits( + outpoints, deposit.Deposited, + ) + if !allActive { + return nil, ErrOpeningChannelUnavailableDeposits + } + } else { + // We have to select the deposits that are used to fund the + // channel. + deposits, err = m.cfg.DepositManager.GetActiveDepositsInState( + deposit.Deposited, + ) + if err != nil { + return nil, err + } + + if req.LocalFundingAmount != 0 { + deposits, err = staticutil.SelectDeposits( + deposits, req.LocalFundingAmount, + feeRate, chanCommitmentType, + ) + if err != nil { + return nil, fmt.Errorf("error selecting "+ + "deposits: %w", err) + } + } else { + // The fundmax flag is set, hence we select all deposits + // for funding the channel. + } + } + + // Pre-check: calculate the channel funding amount and the optional + // change before locking deposits. This ensures the selected deposits + // can cover the funding amount plus fees. + chanFundingAmt, _, calcErr := withdraw.CalculateWithdrawalTxValues( + deposits, btcutil.Amount(req.LocalFundingAmount), feeRate, nil, + chanCommitmentType, + ) + if calcErr != nil { + return nil, fmt.Errorf("error calculating funding tx "+ + "values: %w", calcErr) + } + + // We need to transition the deposits to the opening channel state + // before we start the channel open process. This is important to + // ensure that the deposits are not used for other purposes while we + // are opening the channel. + err = m.cfg.DepositManager.TransitionDeposits( + ctx, deposits, deposit.OnOpeningChannel, deposit.OpeningChannel, + ) + if err != nil { + return nil, err + } + + openChanRequest := &lnrpc.OpenChannelRequest{ + NodePubkey: req.NodePubkey, + LocalFundingAmount: int64(chanFundingAmt), + PushSat: req.PushSat, + Private: req.Private, + MinHtlcMsat: req.MinHtlcMsat, + RemoteCsvDelay: req.RemoteCsvDelay, + MinConfs: defaultUtxoMinConf, + SpendUnconfirmed: false, + CloseAddress: req.CloseAddress, + RemoteMaxValueInFlightMsat: req.RemoteMaxValueInFlightMsat, + RemoteMaxHtlcs: req.RemoteMaxHtlcs, + MaxLocalCsv: req.MaxLocalCsv, + CommitmentType: chanCommitmentType, + ZeroConf: req.ZeroConf, + ScidAlias: req.ScidAlias, + BaseFee: req.BaseFee, + FeeRate: req.FeeRate, + UseBaseFee: req.UseBaseFee, + UseFeeRate: req.UseFeeRate, + RemoteChanReserveSat: req.RemoteChanReserveSat, + Memo: req.Memo, + } + + chanTxHash, err := m.openChannelPsbt( + ctx, openChanRequest, deposits, feeRate, + ) + if err != nil { + log.Infof("error opening channel: %v", err) + + // If the PSBT was already finalized and sent to lnd, the + // funding transaction may have been broadcast. In that case + // we must not roll back the deposits to Deposited as they + // may already be spent on-chain. + if !errors.Is(err, errPsbtFinalized) { + err2 := m.cfg.DepositManager.TransitionDeposits( + ctx, deposits, fsm.OnError, + deposit.Deposited, + ) + if err2 != nil { + log.Errorf("failed transitioning deposits "+ + "after failed channel open: %v", + err2) + } + } + + return nil, err + } + + return chanTxHash, nil +} + +// openChannelPsbt starts an interactive channel open protocol that uses a +// partially signed bitcoin transaction (PSBT) to fund the channel output. The +// protocol involves several steps between the loop client and the server: +// +// RPC server CLI client +// +// | | +// | |<------open channel (stream)-----| +// | |-------ready for funding----->| | +// | |------------------------------| | create psbt from deposits +// | |<------PSBT verify------------| | +// | |-------ready for signing----->| | +// | |------------------------------| | request server co-sig +// | |------------------------------| | sign psbt with combined sig +// | |<------PSBT finalize----------| | +// | |-------channel pending------->| | +// | |-------channel open------------->| +// | | +func (m *Manager) openChannelPsbt(ctx context.Context, + req *lnrpc.OpenChannelRequest, deposits []*deposit.Deposit, + feeRate chainfee.SatPerKWeight) (*chainhash.Hash, error) { + + var ( + pendingChanID [32]byte + shimPending = true + psbtFinalized bool + basePsbtBytes []byte + quit = make(chan struct{}) + srvMsg = make(chan *lnrpc.OpenStatusUpdate, 1) + srvErr = make(chan error, 1) + ) + + // Make sure the user didn't supply any command line flags that are + // incompatible with PSBT funding. + err := checkPsbtFlags(req) + if err != nil { + return nil, err + } + + // Generate a new, random pending channel ID that we'll use as the main + // identifier when sending update messages to the RPC server. + if _, err := rand.Read(pendingChanID[:]); err != nil { + return nil, fmt.Errorf("unable to generate random chan ID: "+ + "%w", err) + } + log.Infof("Starting PSBT funding flow with pending channel ID %x.\n", + pendingChanID) + + // maybeCancelShim is a helper function that cancels the funding shim + // with the RPC server in case we end up aborting early. + maybeCancelShim := func() { + // If the user canceled while there was still a shim registered + // with the wallet, release the resources now. + if shimPending { + log.Infof("Canceling PSBT funding flow for pending "+ + "channel ID %x.\n", pendingChanID) + + cancelMsg := &lnrpc.FundingTransitionMsg{ + Trigger: &lnrpc.FundingTransitionMsg_ShimCancel{ + ShimCancel: &lnrpc.FundingShimCancel{ + PendingChanId: pendingChanID[:], + }, + }, + } + _, err := m.cfg.LightningClient.FundingStateStep( + ctx, cancelMsg, + ) + if err != nil { + log.Errorf("Error canceling shim: %v\n", err) + } + shimPending = false + } + } + defer maybeCancelShim() + + // Create the PSBT funding shim that will tell the funding manager we + // want to use a PSBT. + req.FundingShim = &lnrpc.FundingShim{ + Shim: &lnrpc.FundingShim_PsbtShim{ + PsbtShim: &lnrpc.PsbtShim{ + PendingChanId: pendingChanID[:], + BasePsbt: basePsbtBytes, + // Setting this to false since we don't batch + // open channels. + NoPublish: false, + }, + }, + } + + // Start the interactive process by opening the stream connection to the + // daemon. If the user cancels by pressing we need to cancel + // the shim. To not just kill the process on interrupt, we need to + // explicitly capture the signal. + rawCtx, _, rawClient := m.cfg.LightningClient.RawClientWithMacAuth(ctx) + stream, err := rawClient.OpenChannel(rawCtx, req) + if err != nil { + return nil, fmt.Errorf("opening stream to server "+ + "failed: %w", err) + } + + // We also need to spawn a goroutine that reads from the server. This + // will copy the messages to the channel as long as they come in or add + // exactly one error to the error stream and then bail out. + go func() { + for { + // Recv blocks until a message or error arrives. + resp, err := stream.Recv() + if err == io.EOF { + srvErr <- fmt.Errorf("loop shutting down: %w", + err) + + return + } else if err != nil { + srvErr <- fmt.Errorf("got error from server: "+ + "%v", err) + + return + } + + // Don't block on sending in case of shutting down. + select { + case srvMsg <- resp: + case <-quit: + + return + } + } + }() + + // Spawn another goroutine that only handles loop server shutdown or + // errors from the lnd server. Both will trigger an attempt to cancel + // the shim with the server. + go func() { + select { + case <-ctx.Done(): + log.Infof("OpenChannel context cancel.") + close(quit) + + case err := <-srvErr: + log.Errorf("OpenChannel lnd server error received: "+ + "%v\n", err) + + // If the remote peer canceled on us, the reservation + // has already been deleted. We don't need to try to + // remove it again, this would just produce another + // error. + cancelErr := chanfunding.ErrRemoteCanceled.Error() + if err != nil && strings.Contains( + err.Error(), cancelErr, + ) { + + shimPending = false + } + close(quit) + + case <-quit: + } + }() + + for { + var srvResponse *lnrpc.OpenStatusUpdate + select { + case srvResponse = <-srvMsg: + case <-quit: + cancelErr := fmt.Errorf("open channel flow canceled") + if psbtFinalized { + return nil, fmt.Errorf("%w: %v", + errPsbtFinalized, cancelErr) + } + return nil, cancelErr + } + + switch update := srvResponse.Update.(type) { + case *lnrpc.OpenStatusUpdate_PsbtFund: + fundingAmount := update.PsbtFund.FundingAmount + if req.LocalFundingAmount != fundingAmount { + err := fmt.Errorf("funding amount "+ + "%v doesn't match local "+ + "funding amount %v", + fundingAmount, + req.LocalFundingAmount) + + return nil, err + } + + addr := update.PsbtFund.FundingAddress + + log.Infof("PSBT funding initiated with peer "+ + "%x, funding amount %v, funding "+ + "address %v", req.NodePubkey, + fundingAmount, addr) + + // Create the psbt funding transaction for the + // channel. Ensure the selected deposits amount + // to the psbt funding amount. + channelFundingAddress, err := btcutil.DecodeAddress( + addr, m.cfg.ChainParams, + ) + if err != nil { + return nil, fmt.Errorf("decoding funding "+ + "address: %w", err) + } + + //nolint:ll + signedTx, unsignedPsbt, err := m.cfg.WithdrawalManager.CreateFinalizedWithdrawalTx( + ctx, deposits, channelFundingAddress, feeRate, + fundingAmount, req.CommitmentType, + ) + if err != nil { + return nil, fmt.Errorf("creating PSBT "+ + "failed: %w", err) + } + + // Verify that the psbt contains the correct outputs. + verifyMsg := &lnrpc.FundingTransitionMsg{ + Trigger: &lnrpc.FundingTransitionMsg_PsbtVerify{ + PsbtVerify: &lnrpc.FundingPsbtVerify{ + FundedPsbt: unsignedPsbt, + PendingChanId: pendingChanID[:], + }, + }, + } + _, err = m.cfg.LightningClient.FundingStateStep( + ctx, verifyMsg, + ) + if err != nil { + return nil, fmt.Errorf("verifying PSBT by lnd "+ + "failed: %v", err) + } + + // Now that we have the final transaction, we can + // finalize the PSBT and publish the channel open + // transaction. + var buffer bytes.Buffer + err = signedTx.Serialize(&buffer) + if err != nil { + return nil, fmt.Errorf("error serializing "+ + "tx: %w", err) + } + transitionMsg := &lnrpc.FundingTransitionMsg{ + Trigger: &lnrpc.FundingTransitionMsg_PsbtFinalize{ + PsbtFinalize: &lnrpc.FundingPsbtFinalize{ + FinalRawTx: buffer.Bytes(), + PendingChanId: pendingChanID[:], + }, + }, + } + _, err = m.cfg.LightningClient.FundingStateStep( + ctx, transitionMsg, + ) + if err != nil { + return nil, fmt.Errorf("finalizing PSBT "+ + "funding flow failed: %v", err) + } + + // The finalize step succeeded. From this point + // on the funding tx may have been broadcast, so + // deposits must not be rolled back. + psbtFinalized = true + + case *lnrpc.OpenStatusUpdate_ChanPending: + // As soon as the channel is pending, there is no more + // shim that needs to be canceled. If the user + // interrupts now, we don't need to clean up anything. + shimPending = false + + hash, err := chainhash.NewHash( + update.ChanPending.Txid, + ) + if err != nil { + log.Infof("Error creating hash for channel "+ + "open tx: %v", err) + } + + log.Infof("Channel transaction pending: %v", + hash.String()) + log.Infof("Please monitor the channel from lnd") + + err = m.cfg.DepositManager.TransitionDeposits( + ctx, deposits, deposit.OnChannelPublished, + deposit.ChannelPublished, + ) + if err != nil { + log.Errorf("error transitioning deposits to "+ + "ChannelPublished: %v", err) + } + + // We can now close the quit channel to stop the + // goroutine that reads from the server. + close(quit) + + // Nil indicates that the channel was successfully + // published. + return hash, nil + } + } +} + +// validateInitialPsbtFlags validates request fields that are incompatible with +// the interactive PSBT channel funding flow. +func validateInitialPsbtFlags(req *lnrpc.OpenChannelRequest) error { + if req.MinConfs != 0 && req.MinConfs != defaultUtxoMinConf { + return fmt.Errorf("custom MinConfs not supported for PSBT " + + "funding, only the default is allowed") + } + + if req.SpendUnconfirmed { + return fmt.Errorf("SpendUnconfirmed is not supported " + + "for PSBT funding") + } + + return nil +} + +// resolveCommitmentType validates supported channel commitment types and +// normalizes unknown/default to STATIC_REMOTE_KEY. +func resolveCommitmentType(commitmentType lnrpc.CommitmentType) ( + lnrpc.CommitmentType, error) { + + switch commitmentType { + case lnrpc.CommitmentType_UNKNOWN_COMMITMENT_TYPE, + lnrpc.CommitmentType_STATIC_REMOTE_KEY: + + return lnrpc.CommitmentType_STATIC_REMOTE_KEY, nil + + case lnrpc.CommitmentType_ANCHORS: + return lnrpc.CommitmentType_ANCHORS, nil + + case lnrpc.CommitmentType_SIMPLE_TAPROOT: + return lnrpc.CommitmentType_SIMPLE_TAPROOT, nil + + default: + return lnrpc.CommitmentType_UNKNOWN_COMMITMENT_TYPE, fmt.Errorf( + "unsupported commitment type %v", commitmentType, + ) + } +} + +// checkPsbtFlags make sure a request to open a channel doesn't set any +// parameters that are incompatible with the PSBT funding flow. +func checkPsbtFlags(req *lnrpc.OpenChannelRequest) error { + if req.MinConfs != defaultUtxoMinConf || req.SpendUnconfirmed { + return fmt.Errorf("specifying minimum confirmations for PSBT " + + "funding is not supported") + } + if req.TargetConf != 0 || req.SatPerByte != 0 || req.SatPerVbyte != 0 { // nolint:staticcheck + return fmt.Errorf("setting fee estimation parameters not " + + "supported for PSBT funding") + } + return nil +} + +// DeliverOpenChannelRequest forwards a open channel request to the manager main +// loop. +func (m *Manager) DeliverOpenChannelRequest(ctx context.Context, + req *lnrpc.OpenChannelRequest) (*chainhash.Hash, error) { + + request := newOpenChannelRequest{ + request: req, + respChan: make(chan *newOpenChannelResponse), + } + + // Send the open channel request to the manager run loop. + select { + case m.newOpenChannelRequestChan <- request: + + case <-m.exitChan: + return nil, fmt.Errorf("open channel manager has been " + + "canceled") + + case <-ctx.Done(): + return nil, fmt.Errorf("context canceled while opening " + + "channel") + } + + // Wait for the response from the manager run loop. + select { + case resp := <-request.respChan: + return resp.ChanTxHash, resp.err + + case <-m.exitChan: + return nil, fmt.Errorf("open channel manager has been " + + "canceled") + + case <-ctx.Done(): + return nil, fmt.Errorf("context canceled while waiting " + + "for open channel response") + } +} diff --git a/staticaddr/openchannel/manager_test.go b/staticaddr/openchannel/manager_test.go new file mode 100644 index 000000000..807d275f1 --- /dev/null +++ b/staticaddr/openchannel/manager_test.go @@ -0,0 +1,314 @@ +package openchannel + +import ( + "context" + "errors" + "testing" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/lndclient" + "github.com/lightninglabs/loop/fsm" + "github.com/lightninglabs/loop/staticaddr/deposit" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/stretchr/testify/require" +) + +type transitionCall struct { + event fsm.EventType + expectedState fsm.StateType + outpoints []wire.OutPoint +} + +type mockDepositManager struct { + openingDeposits []*deposit.Deposit + getErr error + transitionErrs map[fsm.EventType]error + calls []transitionCall +} + +func (m *mockDepositManager) AllOutpointsActiveDeposits([]wire.OutPoint, + fsm.StateType) ([]*deposit.Deposit, bool) { + + return nil, false +} + +func (m *mockDepositManager) GetActiveDepositsInState(stateFilter fsm.StateType) ( + []*deposit.Deposit, error) { + + if stateFilter != deposit.OpeningChannel { + return nil, nil + } + + if m.getErr != nil { + return nil, m.getErr + } + + return m.openingDeposits, nil +} + +func (m *mockDepositManager) TransitionDeposits(_ context.Context, + deposits []*deposit.Deposit, event fsm.EventType, + expectedFinalState fsm.StateType) error { + + call := transitionCall{ + event: event, + expectedState: expectedFinalState, + outpoints: make([]wire.OutPoint, len(deposits)), + } + for i, d := range deposits { + call.outpoints[i] = d.OutPoint + } + m.calls = append(m.calls, call) + + if err, ok := m.transitionErrs[event]; ok { + return err + } + + return nil +} + +type mockWalletKit struct { + lndclient.WalletKitClient + + utxos []*lnwallet.Utxo + err error + calls int +} + +func (m *mockWalletKit) ListUnspent(_ context.Context, _, _ int32, + _ ...lndclient.ListUnspentOption) ([]*lnwallet.Utxo, error) { + + m.calls++ + + if m.err != nil { + return nil, m.err + } + + return m.utxos, nil +} + +func TestRecoverOpeningChannelDepositsMixed(t *testing.T) { + t.Parallel() + + unspentDeposit := &deposit.Deposit{OutPoint: testOutPoint(1)} + spentDeposit := &deposit.Deposit{OutPoint: testOutPoint(2)} + + depositManager := &mockDepositManager{ + openingDeposits: []*deposit.Deposit{unspentDeposit, spentDeposit}, + } + walletKit := &mockWalletKit{ + utxos: []*lnwallet.Utxo{ + {OutPoint: unspentDeposit.OutPoint}, + {OutPoint: testOutPoint(99)}, + }, + } + + manager := &Manager{ + cfg: &Config{ + DepositManager: depositManager, + WalletKit: walletKit, + }, + } + + err := manager.recoverOpeningChannelDeposits(context.Background()) + require.NoError(t, err) + require.Equal(t, 1, walletKit.calls) + require.Len(t, depositManager.calls, 2) + + require.Equal(t, fsm.OnError, depositManager.calls[0].event) + require.Equal(t, deposit.Deposited, depositManager.calls[0].expectedState) + require.Equal( + t, []wire.OutPoint{unspentDeposit.OutPoint}, + depositManager.calls[0].outpoints, + ) + + require.Equal(t, deposit.OnChannelPublished, depositManager.calls[1].event) + require.Equal( + t, deposit.ChannelPublished, + depositManager.calls[1].expectedState, + ) + require.Equal( + t, []wire.OutPoint{spentDeposit.OutPoint}, + depositManager.calls[1].outpoints, + ) +} + +func TestRecoverOpeningChannelDepositsNoDeposits(t *testing.T) { + t.Parallel() + + depositManager := &mockDepositManager{} + walletKit := &mockWalletKit{} + manager := &Manager{ + cfg: &Config{ + DepositManager: depositManager, + WalletKit: walletKit, + }, + } + + err := manager.recoverOpeningChannelDeposits(context.Background()) + require.NoError(t, err) + require.Zero(t, walletKit.calls) + require.Empty(t, depositManager.calls) +} + +func TestRecoverOpeningChannelDepositsListUnspentError(t *testing.T) { + t.Parallel() + + depositManager := &mockDepositManager{ + openingDeposits: []*deposit.Deposit{ + {OutPoint: testOutPoint(1)}, + }, + } + walletKit := &mockWalletKit{ + err: errors.New("list unspent failed"), + } + manager := &Manager{ + cfg: &Config{ + DepositManager: depositManager, + WalletKit: walletKit, + }, + } + + err := manager.recoverOpeningChannelDeposits(context.Background()) + require.ErrorContains(t, err, "unable to list unspent outputs") + require.Empty(t, depositManager.calls) +} + +func TestRecoverOpeningChannelDepositsTransitionError(t *testing.T) { + t.Parallel() + + unspentDeposit := &deposit.Deposit{OutPoint: testOutPoint(1)} + depositManager := &mockDepositManager{ + openingDeposits: []*deposit.Deposit{unspentDeposit}, + transitionErrs: map[fsm.EventType]error{ + fsm.OnError: errors.New("transition failed"), + }, + } + walletKit := &mockWalletKit{ + utxos: []*lnwallet.Utxo{ + {OutPoint: unspentDeposit.OutPoint}, + }, + } + manager := &Manager{ + cfg: &Config{ + DepositManager: depositManager, + WalletKit: walletKit, + }, + } + + err := manager.recoverOpeningChannelDeposits(context.Background()) + require.ErrorContains(t, err, "unable to recover unspent opening deposits") + require.Len(t, depositManager.calls, 1) +} + +func testOutPoint(b byte) wire.OutPoint { + return wire.OutPoint{ + Hash: chainhash.Hash{b}, + Index: uint32(b), + } +} + +func TestValidateInitialPsbtFlags(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + minConfs int32 + spendUnconfirmed bool + expectedErrSubstr string + }{ + { + name: "default min confs accepted", + minConfs: 0, + spendUnconfirmed: false, + }, + { + name: "explicit default min confs accepted", + minConfs: defaultUtxoMinConf, + spendUnconfirmed: false, + }, + { + name: "custom min confs rejected", + minConfs: defaultUtxoMinConf + 1, + spendUnconfirmed: false, + expectedErrSubstr: "custom MinConfs not supported", + }, + { + name: "spend unconfirmed rejected", + minConfs: defaultUtxoMinConf, + spendUnconfirmed: true, + expectedErrSubstr: "SpendUnconfirmed is not supported", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req := &lnrpc.OpenChannelRequest{ + MinConfs: tc.minConfs, + SpendUnconfirmed: tc.spendUnconfirmed, + } + + err := validateInitialPsbtFlags(req) + if tc.expectedErrSubstr == "" { + require.NoError(t, err) + return + } + + require.ErrorContains(t, err, tc.expectedErrSubstr) + }) + } +} + +func TestResolveCommitmentType(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + commitmentType lnrpc.CommitmentType + expectedType lnrpc.CommitmentType + expectedErrSubstr string + }{ + { + name: "unknown defaults to static remote key", + commitmentType: lnrpc.CommitmentType_UNKNOWN_COMMITMENT_TYPE, + expectedType: lnrpc.CommitmentType_STATIC_REMOTE_KEY, + }, + { + name: "static remote key supported", + commitmentType: lnrpc.CommitmentType_STATIC_REMOTE_KEY, + expectedType: lnrpc.CommitmentType_STATIC_REMOTE_KEY, + }, + { + name: "anchors supported", + commitmentType: lnrpc.CommitmentType_ANCHORS, + expectedType: lnrpc.CommitmentType_ANCHORS, + }, + { + name: "simple taproot supported", + commitmentType: lnrpc.CommitmentType_SIMPLE_TAPROOT, + expectedType: lnrpc.CommitmentType_SIMPLE_TAPROOT, + }, + { + name: "legacy rejected", + commitmentType: lnrpc.CommitmentType_LEGACY, + expectedErrSubstr: "unsupported commitment type", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + commitmentType, err := resolveCommitmentType( + tc.commitmentType, + ) + if tc.expectedErrSubstr == "" { + require.NoError(t, err) + require.Equal(t, tc.expectedType, commitmentType) + return + } + + require.ErrorContains(t, err, tc.expectedErrSubstr) + }) + } +} diff --git a/staticaddr/staticutil/utils.go b/staticaddr/staticutil/utils.go index 0d4b8c5c3..3c6637358 100644 --- a/staticaddr/staticutil/utils.go +++ b/staticaddr/staticutil/utils.go @@ -8,6 +8,7 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop/staticaddr/address" @@ -15,7 +16,9 @@ import ( "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/swapserverrpc" "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lnwallet" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" ) // ToPrevOuts converts a slice of deposits to a map of outpoints to TxOuts. @@ -166,20 +169,23 @@ func bip69inputLess(input1, input2 *swapserverrpc.PrevoutInfo) bool { } // SelectDeposits sorts the deposits by amount in descending order. It then -// selects the deposits that are needed to cover the amount requested without -// leaving a dust change. It returns an error if the sum of deposits minus dust -// is less than the requested amount. -func SelectDeposits(deposits []*deposit.Deposit, amount int64) ( - []*deposit.Deposit, error) { - - // Check that sum of deposits covers the swap amount while leaving no - // dust change. +// selects the deposits that are needed to cover the requested amount plus +// transaction fees and dust. The fee rate and commitment type are used to +// estimate the transaction fee for the current selection, since each +// additional input increases the fee. +func SelectDeposits(deposits []*deposit.Deposit, amount int64, + feeRate chainfee.SatPerKWeight, + commitmentType lnrpc.CommitmentType) ([]*deposit.Deposit, error) { + dustLimit := lnwallet.DustLimitForSize(input.P2TRSize) + + // Quick check: if total deposits can't even cover amount + dust + // (ignoring fees), there's no way to succeed. var depositSum btcutil.Amount - for _, deposit := range deposits { - depositSum += deposit.Value + for _, d := range deposits { + depositSum += d.Value } - if depositSum-dustLimit < btcutil.Amount(amount) { + if depositSum < btcutil.Amount(amount)+dustLimit { return nil, fmt.Errorf("insufficient funds to cover swap " + "amount, try manually selecting deposits") } @@ -189,17 +195,52 @@ func SelectDeposits(deposits []*deposit.Deposit, amount int64) ( return deposits[i].Value > deposits[j].Value }) - // Select the deposits that are needed to cover the swap amount without - // leaving a dust change. + // Select deposits until the total covers the requested amount plus + // the estimated fee and dust reserve. We estimate the fee + // pessimistically with a change output to ensure we always select + // enough. var selectedDeposits []*deposit.Deposit var selectedAmount btcutil.Amount - for _, deposit := range deposits { - if selectedAmount >= btcutil.Amount(amount)+dustLimit { - break + for _, d := range deposits { + selectedDeposits = append(selectedDeposits, d) + selectedAmount += d.Value + + fee := estimateFee( + len(selectedDeposits), feeRate, commitmentType, + ) + + if selectedAmount >= btcutil.Amount(amount)+fee+dustLimit { + return selectedDeposits, nil } - selectedDeposits = append(selectedDeposits, deposit) - selectedAmount += deposit.Value } - return selectedDeposits, nil + // We exhausted all deposits without meeting the threshold. + return nil, fmt.Errorf("insufficient funds to cover swap " + + "amount plus fees, try manually selecting deposits") +} + +// estimateFee returns the estimated fee for a transaction with the given +// number of taproot keyspend inputs and a single output determined by +// the commitment type. It includes a change output in the estimate to +// be conservative. +func estimateFee(numInputs int, feeRate chainfee.SatPerKWeight, + commitmentType lnrpc.CommitmentType) btcutil.Amount { + + var we input.TxWeightEstimator + for i := 0; i < numInputs; i++ { + we.AddTaprootKeySpendInput(txscript.SigHashDefault) + } + + // Add the funding output based on commitment type. + switch commitmentType { + case lnrpc.CommitmentType_SIMPLE_TAPROOT: + we.AddP2TROutput() + default: + we.AddP2WSHOutput() + } + + // Add a change output (P2TR) to be conservative. + we.AddP2TROutput() + + return feeRate.FeeForWeight(we.Weight()) } diff --git a/staticaddr/staticutil/utils_test.go b/staticaddr/staticutil/utils_test.go index 43da817f7..0694f8c84 100644 --- a/staticaddr/staticutil/utils_test.go +++ b/staticaddr/staticutil/utils_test.go @@ -16,6 +16,9 @@ import ( looptest "github.com/lightninglabs/loop/test" "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" "github.com/stretchr/testify/require" ) @@ -234,3 +237,336 @@ func TestCreateMusig2Sessions_Multiple(t *testing.T) { require.True(t, bytes.Equal(nonces[i], sessions[i].PublicNonce[:])) } } + +// makeDeposit creates a deposit with the given value for testing. +func makeDeposit(value btcutil.Amount) *deposit.Deposit { + return &deposit.Deposit{Value: value} +} + +// makeDeposits creates a slice of deposits with the given values. +func makeDeposits(values ...btcutil.Amount) []*deposit.Deposit { + deps := make([]*deposit.Deposit, len(values)) + for i, v := range values { + deps[i] = makeDeposit(v) + } + return deps +} + +// depositSum returns the total value of the given deposits. +func depositSum(deps []*deposit.Deposit) btcutil.Amount { + var total btcutil.Amount + for _, d := range deps { + total += d.Value + } + return total +} + +func TestSelectDeposits(t *testing.T) { + t.Parallel() + + dustLimit := lnwallet.DustLimitForSize(input.P2TRSize) + + // Standard fee rate: 1 sat/vbyte = 250 sat/kw. + lowFeeRate := chainfee.SatPerKVByte(1000).FeePerKWeight() + + // High fee rate: 100 sat/vbyte = 25000 sat/kw. + highFeeRate := chainfee.SatPerKVByte(100_000).FeePerKWeight() + + anchors := lnrpc.CommitmentType_ANCHORS + taproot := lnrpc.CommitmentType_SIMPLE_TAPROOT + + tests := []struct { + name string + deposits []*deposit.Deposit + amount int64 + feeRate chainfee.SatPerKWeight + commitmentType lnrpc.CommitmentType + wantErr string + wantCount int + // validate runs extra assertions on the result. + validate func(t *testing.T, selected []*deposit.Deposit) + }{ + { + name: "insufficient total funds", + deposits: makeDeposits(1_000, 2_000), + amount: 1_000_000, + feeRate: lowFeeRate, + commitmentType: anchors, + wantErr: "insufficient funds", + }, + { + name: "total equals amount but no room for dust", + deposits: makeDeposits(100_000), + amount: 100_000, + feeRate: lowFeeRate, + commitmentType: anchors, + wantErr: "insufficient funds", + }, + { + name: "total covers amount and dust but not fees", + // 1 input high fee = 15400. Need 50k + 15400 + + // 330 = 65730. Deposit = 51k passes the early + // check (51k >= 50k + 330) but the loop finds + // 51k < 65730 and returns an error. + deposits: makeDeposits(51_000), + amount: 50_000, + feeRate: highFeeRate, + commitmentType: anchors, + wantErr: "insufficient funds", + }, + { + name: "many tiny deposits don't block large selection", + // Two large deposits easily cover 400k + fees. + // Many tiny deposits should not cause a false + // rejection in the early check. + deposits: append( + makeDeposits(300_000, 200_000), + makeDeposits( + 100, 100, 100, 100, 100, + 100, 100, 100, 100, 100, + )..., + ), + amount: 400_000, + feeRate: highFeeRate, + commitmentType: anchors, + wantCount: 2, + validate: func(t *testing.T, selected []*deposit.Deposit) { + require.Equal( + t, btcutil.Amount(300_000), + selected[0].Value, + ) + require.Equal( + t, btcutil.Amount(200_000), + selected[1].Value, + ) + }, + }, + { + name: "single deposit covers amount plus fee and dust", + deposits: makeDeposits(500_000), + amount: 100_000, + feeRate: lowFeeRate, + commitmentType: anchors, + wantCount: 1, + }, + { + name: "two deposits needed when first is insufficient", + deposits: makeDeposits(60_000, 60_000), + amount: 100_000, + feeRate: lowFeeRate, + commitmentType: anchors, + wantCount: 2, + }, + { + name: "selects largest deposits first", + deposits: makeDeposits(10_000, 200_000, 50_000), + amount: 100_000, + feeRate: lowFeeRate, + commitmentType: anchors, + wantCount: 1, + validate: func(t *testing.T, selected []*deposit.Deposit) { + // Should pick the 200k deposit. + require.Equal( + t, btcutil.Amount(200_000), + selected[0].Value, + ) + }, + }, + { + name: "fee-awareness selects extra deposit", + // With 2 inputs at high fee: need 50k + 21150 + + // 330 = 71480. Two deposits of 36k = 72k which + // is just above. But a single 36k deposit = 36k + // < 50k + 15400 + 330 = 65730, so 1 is not + // enough. Now make it tighter: amount = 50k, + // deposits = [35_500, 35_500, 10_000]. + // 1 input: need 50k + 15400 + 330 = 65730. 35.5k + // < 65730 -> not enough. + // 2 inputs: need 50k + 21150 + 330 = 71480. + // 35.5k + 35.5k = 71k < 71480 -> not enough! + // 3 inputs: need 50k + 26900 + 330 = 77230. + // 35.5k + 35.5k + 10k = 81k >= 77230 -> enough. + deposits: makeDeposits(35_500, 35_500, 10_000), + amount: 50_000, + feeRate: highFeeRate, + commitmentType: anchors, + wantCount: 3, + validate: func(t *testing.T, selected []*deposit.Deposit) { + total := depositSum(selected) + fee := estimateFee( + len(selected), highFeeRate, + anchors, + ) + require.GreaterOrEqual( + t, total, + btcutil.Amount(50_000)+fee+dustLimit, + ) + }, + }, + { + name: "all deposits selected when all are needed", + deposits: makeDeposits(40_000, 40_000, 40_000), + amount: 100_000, + feeRate: lowFeeRate, + commitmentType: anchors, + wantCount: 3, + }, + { + name: "zero fee rate means only dust matters", + deposits: makeDeposits(100_000, 50_000), + amount: 99_000, + feeRate: 0, + commitmentType: anchors, + wantCount: 1, + validate: func(t *testing.T, selected []*deposit.Deposit) { + // With zero fee, 100k covers 99k + 0 + dust. + require.Equal( + t, btcutil.Amount(100_000), + selected[0].Value, + ) + }, + }, + { + name: "high fee rate forces more deposits", + deposits: makeDeposits(200_000, 100_000, 50_000), + amount: 100_000, + feeRate: highFeeRate, + commitmentType: anchors, + validate: func(t *testing.T, selected []*deposit.Deposit) { + total := depositSum(selected) + fee := estimateFee( + len(selected), highFeeRate, + anchors, + ) + require.GreaterOrEqual( + t, total, + btcutil.Amount(100_000)+fee+dustLimit, + ) + }, + }, + { + name: "taproot commitment type", + deposits: makeDeposits(500_000), + amount: 100_000, + feeRate: lowFeeRate, + commitmentType: taproot, + wantCount: 1, + }, + { + name: "many small deposits accumulate", + deposits: makeDeposits( + 10_000, 10_000, 10_000, 10_000, 10_000, + 10_000, 10_000, 10_000, 10_000, 10_000, + ), + amount: 50_000, + feeRate: lowFeeRate, + commitmentType: anchors, + validate: func(t *testing.T, selected []*deposit.Deposit) { + total := depositSum(selected) + fee := estimateFee( + len(selected), lowFeeRate, + anchors, + ) + require.GreaterOrEqual( + t, total, + btcutil.Amount(50_000)+fee+dustLimit, + ) + // With 10k each and low fees, we need at + // least 6 (50k + dust + fee). + require.GreaterOrEqual( + t, len(selected), 6, + ) + }, + }, + { + name: "selection result satisfies fee invariant", + deposits: makeDeposits( + 80_000, 70_000, 60_000, 50_000, + ), + amount: 150_000, + feeRate: lowFeeRate, + commitmentType: anchors, + validate: func(t *testing.T, selected []*deposit.Deposit) { + total := depositSum(selected) + fee := estimateFee( + len(selected), lowFeeRate, + anchors, + ) + // Core invariant: selected amount covers + // requested amount + fee + dust. + require.GreaterOrEqual( + t, total, + btcutil.Amount(150_000)+fee+dustLimit, + ) + }, + }, + { + name: "deposits sorted descending before selection", + // Give deposits in ascending order; verify largest + // are picked first. + deposits: makeDeposits(10_000, 20_000, 300_000), + amount: 100_000, + feeRate: lowFeeRate, + commitmentType: anchors, + wantCount: 1, + validate: func(t *testing.T, selected []*deposit.Deposit) { + require.Equal( + t, btcutil.Amount(300_000), + selected[0].Value, + ) + }, + }, + { + name: "high fee eats into margin requiring extra deposit", + // Two deposits of 60k each = 120k total. + // Amount = 50k. With low fee: 60k > 50k + fee + + // dust, so 1 deposit suffices. + // With high fee: 60k < 50k + ~10k fee + dust, + // so 2 deposits needed. + deposits: makeDeposits(60_000, 60_000), + amount: 50_000, + feeRate: highFeeRate, + commitmentType: anchors, + wantCount: 2, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + selected, err := SelectDeposits( + tc.deposits, tc.amount, tc.feeRate, + tc.commitmentType, + ) + + if tc.wantErr != "" { + require.Error(t, err) + require.ErrorContains(t, err, tc.wantErr) + return + } + + require.NoError(t, err) + require.NotEmpty(t, selected) + + if tc.wantCount > 0 { + require.Len(t, selected, tc.wantCount) + } + + // Universal invariant: selected deposits must + // cover amount + fee + dust. + total := depositSum(selected) + fee := estimateFee( + len(selected), tc.feeRate, + tc.commitmentType, + ) + require.GreaterOrEqual( + t, total, + btcutil.Amount(tc.amount)+fee+dustLimit, + "selection must cover amount + fee + dust", + ) + + if tc.validate != nil { + tc.validate(t, selected) + } + }) + } +} diff --git a/staticaddr/withdraw/funding_values_test.go b/staticaddr/withdraw/funding_values_test.go new file mode 100644 index 000000000..97e5fd149 --- /dev/null +++ b/staticaddr/withdraw/funding_values_test.go @@ -0,0 +1,265 @@ +package withdraw + +import ( + "testing" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/lightninglabs/loop/staticaddr/deposit" + "github.com/lightningnetwork/lnd/funding" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/stretchr/testify/require" +) + +// TestCalculateFundingTxValues tests the CalculateWithdrawalTxValues function +// with various channel funding scenarios. +func TestCalculateFundingTxValues(t *testing.T) { + var ( + dustLimit = lnwallet.DustLimitForSize(input.P2TRSize) + satPerVbyte = 1 + allDeposits = []*deposit.Deposit{ + { + Value: 100_000, + }, + { + Value: 200_000, + }, + { + Value: funding.MinChanFundingSize - 1, + }, + } + + chanOpenFeeRate = chainfee.SatPerKVByte( + satPerVbyte * 1000, + ).FeePerKWeight() + + deposits = func(idxs ...int) []*deposit.Deposit { + var selectedDeposits []*deposit.Deposit + for _, i := range idxs { + selectedDeposits = append( + selectedDeposits, allDeposits[i-1], + ) + } + + return selectedDeposits + } + + sum = func(idxs ...int) btcutil.Amount { + var total btcutil.Amount + for _, i := range idxs { + total += allDeposits[i-1].Value + } + + return total + } + ) + + weightWithoutChange, err := WithdrawalTxWeight( + len(allDeposits), nil, lnrpc.CommitmentType_ANCHORS, false, + ) + require.NoError(t, err) + + feeWithoutChange := chanOpenFeeRate.FeeForWeight( + weightWithoutChange, + ) + + weightWithChange, err := WithdrawalTxWeight( + len(allDeposits), nil, lnrpc.CommitmentType_ANCHORS, true, + ) + require.NoError(t, err) + + feeWithChange := chanOpenFeeRate.FeeForWeight( + weightWithChange, + ) + + cases := []struct { + name string + deposits []*deposit.Deposit + localAmount btcutil.Amount + fundMax bool + satPerVbyte uint64 + commitmentType lnrpc.CommitmentType + wantFundingAmt btcutil.Amount + wantChangeAmt btcutil.Amount + wantErr string + }{ + { + name: "fundmax", + deposits: deposits(1, 2, 3), + fundMax: true, + satPerVbyte: 1, + commitmentType: lnrpc.CommitmentType_ANCHORS, + wantFundingAmt: sum(1, 2, 3) - feeWithoutChange, + wantChangeAmt: 0, + wantErr: "", + }, + { + name: "local_amt", + deposits: deposits(1, 2, 3), + localAmount: sum(1, 2, 3) - 10_000, + satPerVbyte: 1, + commitmentType: lnrpc.CommitmentType_ANCHORS, + wantFundingAmt: sum(1, 2, 3) - 10_000, + wantChangeAmt: 10_000 - feeWithChange, + wantErr: "", + }, + { + name: "change to miners", + deposits: deposits(1, 2), + localAmount: sum(1, 2) - dustLimit + 1, + fundMax: false, + satPerVbyte: 1, + commitmentType: lnrpc.CommitmentType_ANCHORS, + wantFundingAmt: sum(1, 2) - dustLimit + 1, + wantChangeAmt: 0, + wantErr: "", + }, + { + name: "change doesn't cover for fees", + deposits: deposits(1, 2), + localAmount: sum(1, 2), + fundMax: false, + satPerVbyte: 1, + commitmentType: lnrpc.CommitmentType_ANCHORS, + wantFundingAmt: 0, + wantChangeAmt: 0, + wantErr: "the change doesn't cover for fees", + }, + { + name: "minimum channel funding size", + deposits: deposits(3), + fundMax: true, + satPerVbyte: 1, + commitmentType: lnrpc.CommitmentType_ANCHORS, + wantFundingAmt: 0, + wantChangeAmt: 0, + wantErr: "minimum channel funding size", + }, + { + name: "satPerVbyte = 0 means no fee", + deposits: deposits(1, 2), + localAmount: sum(1, 2) - 50_000, + satPerVbyte: 0, + commitmentType: lnrpc.CommitmentType_ANCHORS, + wantFundingAmt: sum(1, 2) - 50_000, + wantChangeAmt: 50_000, + wantErr: "", + }, + { + name: "change >= input triggers efficiency error", + deposits: []*deposit.Deposit{ + {Value: 40_000}, + {Value: 60_000}, + }, + localAmount: 40_000, + satPerVbyte: 1, + commitmentType: lnrpc.CommitmentType_ANCHORS, + wantFundingAmt: 40_000, + wantChangeAmt: 60_000 - feeWithChange, + wantErr: "is higher than an input value", + }, + { + name: "channel funding below minimum", + deposits: []*deposit.Deposit{ + {Value: 30_000}, + }, + localAmount: 20_000 - 1, + satPerVbyte: 1, + commitmentType: lnrpc.CommitmentType_ANCHORS, + wantFundingAmt: 0, + wantChangeAmt: 0, + wantErr: "is lower than the minimum", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + feeRate := chainfee.SatPerKVByte( + tc.satPerVbyte * 1000, + ).FeePerKWeight() + fundingAmt, changeAmt, err := CalculateWithdrawalTxValues( + tc.deposits, tc.localAmount, feeRate, nil, + tc.commitmentType, + ) + if tc.wantErr != "" { + require.Error(t, err) + require.ErrorContains(t, err, tc.wantErr) + } else { + require.NoError(t, err) + require.Equal(t, tc.wantFundingAmt, fundingAmt) + require.Equal(t, tc.wantChangeAmt, changeAmt) + } + }) + } +} + +// TestCalculateWithdrawalTxValuesCommitmentTypeParity ensures channel funding +// value calculations are identical whether we derive output type from +// commitment type or from an equivalent funding address type. +func TestCalculateWithdrawalTxValuesCommitmentTypeParity(t *testing.T) { + t.Parallel() + + feeRate := chainfee.SatPerKVByte(1000).FeePerKWeight() + deposits := []*deposit.Deposit{ + {Value: 500_000}, + {Value: 300_000}, + } + + p2wshAddr, err := btcutil.NewAddressWitnessScriptHash( + make([]byte, 32), &chaincfg.RegressionNetParams, + ) + require.NoError(t, err) + + taprootAddr, err := btcutil.NewAddressTaproot( + make([]byte, 32), &chaincfg.RegressionNetParams, + ) + require.NoError(t, err) + + type testCase struct { + name string + commitmentType lnrpc.CommitmentType + addr btcutil.Address + } + + cases := []testCase{ + { + name: "anchors and p2wsh", + commitmentType: lnrpc.CommitmentType_ANCHORS, + addr: p2wshAddr, + }, + { + name: "simple taproot and p2tr", + commitmentType: lnrpc.CommitmentType_SIMPLE_TAPROOT, + addr: taprootAddr, + }, + } + + selectedAmounts := []btcutil.Amount{ + 0, // fundmax/no change path + 600_000, // selected amount with potential change path + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + for _, selected := range selectedAmounts { + fundingByType, changeByType, err := CalculateWithdrawalTxValues( + deposits, selected, feeRate, nil, + tc.commitmentType, + ) + require.NoError(t, err) + + fundingByAddr, changeByAddr, err := CalculateWithdrawalTxValues( + deposits, selected, feeRate, tc.addr, + lnrpc.CommitmentType_UNKNOWN_COMMITMENT_TYPE, + ) + require.NoError(t, err) + + require.Equal(t, fundingByType, fundingByAddr) + require.Equal(t, changeByType, changeByAddr) + } + }) + } +} diff --git a/staticaddr/withdraw/manager.go b/staticaddr/withdraw/manager.go index e71f492d3..00de15a0b 100644 --- a/staticaddr/withdraw/manager.go +++ b/staticaddr/withdraw/manager.go @@ -410,8 +410,24 @@ func (m *Manager) WithdrawDeposits(ctx context.Context, } } + var withdrawFeeRate chainfee.SatPerKWeight + if satPerVbyte == 0 { + withdrawFeeRate, err = m.cfg.WalletKit.EstimateFeeRate( + ctx, defaultConfTarget, + ) + if err != nil { + return "", "", fmt.Errorf("error estimating fee "+ + "rate: %w", err) + } + } else { + withdrawFeeRate = chainfee.SatPerKVByte( + satPerVbyte * 1000, + ).FeePerKWeight() + } + finalizedTx, _, err := m.CreateFinalizedWithdrawalTx( - ctx, deposits, withdrawalAddress, satPerVbyte, amount, + ctx, deposits, withdrawalAddress, withdrawFeeRate, amount, + lnrpc.CommitmentType_UNKNOWN_COMMITMENT_TYPE, ) if err != nil { return "", "", err @@ -510,8 +526,9 @@ func (m *Manager) WithdrawDeposits(ctx context.Context, // signed *wire.MsgTx representation and the unsigned psbt. func (m *Manager) CreateFinalizedWithdrawalTx(ctx context.Context, deposits []*deposit.Deposit, withdrawalAddress btcutil.Address, - satPerVbyte int64, selectedWithdrawalAmount int64) (*wire.MsgTx, []byte, - error) { + feeRate chainfee.SatPerKWeight, + selectedWithdrawalAmount int64, + commitmentType lnrpc.CommitmentType) (*wire.MsgTx, []byte, error) { // Create a musig2 session for each deposit. addrParams, err := m.cfg.AddressManager.GetStaticAddressParameters(ctx) @@ -531,21 +548,6 @@ func (m *Manager) CreateFinalizedWithdrawalTx(ctx context.Context, return nil, nil, err } - var withdrawalSweepFeeRate chainfee.SatPerKWeight - if satPerVbyte == 0 { - // Get the fee rate for the withdrawal sweep. - withdrawalSweepFeeRate, err = m.cfg.WalletKit.EstimateFeeRate( - ctx, defaultConfTarget, - ) - if err != nil { - return nil, nil, err - } - } else { - withdrawalSweepFeeRate = chainfee.SatPerKVByte( - satPerVbyte * 1000, - ).FeePerKWeight() - } - params, err := m.cfg.AddressManager.GetStaticAddressParameters(ctx) if err != nil { return nil, nil, fmt.Errorf("couldn't get confirmation "+ @@ -561,7 +563,7 @@ func (m *Manager) CreateFinalizedWithdrawalTx(ctx context.Context, withdrawalTx, unsignedPsbt, err := m.createWithdrawalTx( ctx, outpoints, deposits, prevOuts, btcutil.Amount(selectedWithdrawalAmount), withdrawalAddress, - withdrawalSweepFeeRate, + feeRate, commitmentType, ) if err != nil { return nil, nil, err @@ -850,7 +852,8 @@ func (m *Manager) createWithdrawalTx(ctx context.Context, outpoints []wire.OutPoint, deposits []*deposit.Deposit, prevOuts map[wire.OutPoint]*wire.TxOut, selectedWithdrawalAmount btcutil.Amount, withdrawAddr btcutil.Address, - feeRate chainfee.SatPerKWeight) (*wire.MsgTx, []byte, error) { + feeRate chainfee.SatPerKWeight, + commitmentType lnrpc.CommitmentType) (*wire.MsgTx, []byte, error) { // First Create the tx. msgTx := wire.NewMsgTx(2) @@ -865,7 +868,7 @@ func (m *Manager) createWithdrawalTx(ctx context.Context, withdrawalAmount, changeAmount, err := CalculateWithdrawalTxValues( deposits, selectedWithdrawalAmount, feeRate, - withdrawAddr, lnrpc.CommitmentType_UNKNOWN_COMMITMENT_TYPE, + withdrawAddr, commitmentType, ) if err != nil { return nil, nil, fmt.Errorf("error calculating funding tx "+ @@ -952,8 +955,11 @@ func (m *Manager) createWithdrawalTx(ctx context.Context, return msgTx, psbtBuf.Bytes(), nil } +// CalculateWithdrawalTxValues calculates the values of the withdrawal +// transaction. It returns the withdrawal amount, the change amount, and an +// error if any. func CalculateWithdrawalTxValues(deposits []*deposit.Deposit, - localAmount btcutil.Amount, feeRate chainfee.SatPerKWeight, + selectedAmount btcutil.Amount, feeRate chainfee.SatPerKWeight, withdrawalAddress btcutil.Address, commitmentType lnrpc.CommitmentType) (btcutil.Amount, btcutil.Amount, error) { @@ -978,7 +984,7 @@ func CalculateWithdrawalTxValues(deposits []*deposit.Deposit, totalDepositAmount += d.Value } - // Estimate the open channel transaction fee without change. + // Estimate the withdrawal transaction fee without change. hasChange := false weight, err := WithdrawalTxWeight( len(deposits), withdrawalAddress, commitmentType, hasChange, @@ -988,9 +994,9 @@ func CalculateWithdrawalTxValues(deposits []*deposit.Deposit, } feeWithoutChange := feeRate.FeeForWeight(weight) - // If the user selected a local amount for the channel, check if a - // change output is needed. - if localAmount > 0 { + // If the user selected an amount to withdraw, check if a change output + // is needed. + if selectedAmount > 0 { // Estimate the transaction weight with change. hasChange = true weightWithChange, err := WithdrawalTxWeight( @@ -1003,30 +1009,30 @@ func CalculateWithdrawalTxValues(deposits []*deposit.Deposit, feeWithChange := feeRate.FeeForWeight(weightWithChange) // The available change that can cover fees is the total - // selected deposit amount minus the local channel amount. - change := totalDepositAmount - localAmount + // selected deposit amount minus the selected amount. + change := totalDepositAmount - selectedAmount switch { case change-feeWithChange >= dustLimit: // If the change can cover the fees without turning into // dust, add a non-dust change output. changeAmount = change - feeWithChange - withdrawalFundingAmt = localAmount + withdrawalFundingAmt = selectedAmount case change-feeWithoutChange >= 0: // If the change is dust, we give it to the miners. - withdrawalFundingAmt = localAmount + withdrawalFundingAmt = selectedAmount default: - // If the fees eat into our local channel amount, we - // fail to open the channel. + // If the fees eat into our selected amount, we fail the + // withdrawal. return 0, 0, fmt.Errorf("the change doesn't " + "cover for fees. Consider lowering the fee " + - "rate or decrease the local amount") + "rate or decrease the selected amount") } } else { - // If the user wants to open the channel with the total value of - // deposits, we don't need a change output. + // If the user wants to withdraw the total value of deposits, we + // don't need a change output. withdrawalFundingAmt = totalDepositAmount - feeWithoutChange } @@ -1038,8 +1044,8 @@ func CalculateWithdrawalTxValues(deposits []*deposit.Deposit, return 0, 0, fmt.Errorf("change amount is negative") } - // Ensure that the channel funding amount is at least in the amount of - // lnd's minimum channel size. + // In case of a channel open, ensure that the channel funding amount is + // at least in the amount of lnd's minimum channel size. if isChannelOpen && withdrawalFundingAmt < funding.MinChanFundingSize { return 0, 0, fmt.Errorf("channel funding amount %v is lower "+ "than the minimum channel funding size %v", From 63097ee220e14cc1d045a135cc0112ff5906f20e Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 7 Jan 2026 17:03:33 +0100 Subject: [PATCH 06/16] staticaddr: close quit channel only once --- staticaddr/openchannel/manager.go | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/staticaddr/openchannel/manager.go b/staticaddr/openchannel/manager.go index e4d306beb..8f509d4cd 100644 --- a/staticaddr/openchannel/manager.go +++ b/staticaddr/openchannel/manager.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "strings" + "sync" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" @@ -433,10 +434,19 @@ func (m *Manager) openChannelPsbt(ctx context.Context, psbtFinalized bool basePsbtBytes []byte quit = make(chan struct{}) + closeQuitOnce sync.Once srvMsg = make(chan *lnrpc.OpenStatusUpdate, 1) srvErr = make(chan error, 1) ) + // closeQuit safely closes the quit channel using sync.Once to prevent + // panic from closing an already-closed channel. + closeQuit := func() { + closeQuitOnce.Do(func() { + close(quit) + }) + } + // Make sure the user didn't supply any command line flags that are // incompatible with PSBT funding. err := checkPsbtFlags(req) @@ -541,7 +551,7 @@ func (m *Manager) openChannelPsbt(ctx context.Context, select { case <-ctx.Done(): log.Infof("OpenChannel context cancel.") - close(quit) + closeQuit() case err := <-srvErr: log.Errorf("OpenChannel lnd server error received: "+ @@ -558,7 +568,7 @@ func (m *Manager) openChannelPsbt(ctx context.Context, shimPending = false } - close(quit) + closeQuit() case <-quit: } @@ -694,7 +704,7 @@ func (m *Manager) openChannelPsbt(ctx context.Context, // We can now close the quit channel to stop the // goroutine that reads from the server. - close(quit) + closeQuit() // Nil indicates that the channel was successfully // published. From b74dab8af4d8f2425ff1fc7cb564fe27b15f5e1f Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 21 May 2025 14:08:54 +0200 Subject: [PATCH 07/16] loopd: instantiate static address open channel manager --- loopd/daemon.go | 31 ++++++++++++++++ loopd/swapclient_server.go | 60 +++++++++++++++++++++++++++---- staticaddr/openchannel/manager.go | 33 +++++++++-------- 3 files changed, 102 insertions(+), 22 deletions(-) diff --git a/loopd/daemon.go b/loopd/daemon.go index be05421fc..786baf4e5 100644 --- a/loopd/daemon.go +++ b/loopd/daemon.go @@ -24,6 +24,7 @@ import ( "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/loopin" + "github.com/lightninglabs/loop/staticaddr/openchannel" "github.com/lightninglabs/loop/staticaddr/withdraw" loop_swaprpc "github.com/lightninglabs/loop/swapserverrpc" "github.com/lightninglabs/loop/sweepbatcher" @@ -584,6 +585,7 @@ func (d *Daemon) initialize(withMacaroonService bool) error { staticAddressManager *address.Manager depositManager *deposit.Manager withdrawalManager *withdraw.Manager + openChannelManager *openchannel.Manager staticLoopInManager *loopin.Manager ) @@ -642,6 +644,20 @@ func (d *Daemon) initialize(withMacaroonService bool) error { err) } + // Static address deposit open channel manager setup. + openChannelCfg := &openchannel.Config{ + Server: staticAddressClient, + AddressManager: staticAddressManager, + DepositManager: depositManager, + WithdrawalManager: withdrawalManager, + WalletKit: d.lnd.WalletKit, + ChainParams: d.lnd.ChainParams, + ChainNotifier: d.lnd.ChainNotifier, + Signer: d.lnd.Signer, + LightningClient: d.lnd.Client, + } + openChannelManager = openchannel.NewManager(openChannelCfg) + // Static address loop-in manager setup. staticAddressLoopInStore := loopin.NewSqlStore( loopdb.NewTypedStore[loopin.Querier](baseDb), @@ -752,6 +768,7 @@ func (d *Daemon) initialize(withMacaroonService bool) error { depositManager: depositManager, withdrawalManager: withdrawalManager, staticLoopInManager: staticLoopInManager, + openChannelManager: openChannelManager, assetClient: d.assetClient, stopDaemon: d.Stop, } @@ -988,6 +1005,20 @@ func (d *Daemon) initialize(withMacaroonService bool) error { cancel() } } + // Start the static address open channel manager. + if openChannelManager != nil { + d.wg.Add(1) + go func() { + defer d.wg.Done() + + infof("Starting static address open channel manager") + err := openChannelManager.Run(d.mainCtx) + if err != nil && !errors.Is(context.Canceled, err) { + d.internalErrChan <- err + } + infof("Static address open channel manager stopped") + }() + } // Start the static address loop-in manager. if staticLoopInManager != nil { diff --git a/loopd/swapclient_server.go b/loopd/swapclient_server.go index 3623ac6ff..2cc4f5c62 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -32,6 +32,7 @@ import ( "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/loopin" + "github.com/lightninglabs/loop/staticaddr/openchannel" "github.com/lightninglabs/loop/staticaddr/staticutil" "github.com/lightninglabs/loop/staticaddr/withdraw" "github.com/lightninglabs/loop/swap" @@ -98,6 +99,7 @@ type swapClientServer struct { depositManager *deposit.Manager withdrawalManager *withdraw.Manager staticLoopInManager *loopin.Manager + openChannelManager *openchannel.Manager assetClient *assets.TapdClient swaps map[lntypes.Hash]loop.SwapInfo subscribers map[int]chan<- interface{} @@ -2009,13 +2011,14 @@ func (s *swapClientServer) GetStaticAddressSummary(ctx context.Context, } var ( - totalNumDeposits = len(allDeposits) - valueUnconfirmed int64 - valueDeposited int64 - valueExpired int64 - valueWithdrawn int64 - valueLoopedIn int64 - htlcTimeoutSwept int64 + totalNumDeposits = len(allDeposits) + valueUnconfirmed int64 + valueDeposited int64 + valueExpired int64 + valueWithdrawn int64 + valueLoopedIn int64 + valueChannelsOpened int64 + htlcTimeoutSwept int64 ) // Value unconfirmed. @@ -2047,6 +2050,9 @@ func (s *swapClientServer) GetStaticAddressSummary(ctx context.Context, case deposit.HtlcTimeoutSwept: htlcTimeoutSwept += value + + case deposit.ChannelPublished: + valueChannelsOpened += value } } @@ -2071,6 +2077,7 @@ func (s *swapClientServer) GetStaticAddressSummary(ctx context.Context, ValueExpiredSatoshis: valueExpired, ValueWithdrawnSatoshis: valueWithdrawn, ValueLoopedInSatoshis: valueLoopedIn, + ValueChannelsOpened: valueChannelsOpened, ValueHtlcTimeoutSweepsSatoshis: htlcTimeoutSwept, }, nil } @@ -2180,6 +2187,33 @@ func (s *swapClientServer) populateBlocksUntilExpiry(ctx context.Context, return nil } +// StaticOpenChannel initiates an open channel request using static address +// deposits. +func (s *swapClientServer) StaticOpenChannel(ctx context.Context, + req *looprpc.StaticOpenChannelRequest) (*looprpc.StaticOpenChannelResponse, + error) { + + infof("Static open channel request received") + + if req == nil || req.OpenChannelRequest == nil { + return &looprpc.StaticOpenChannelResponse{}, + fmt.Errorf("missing open channel request") + } + + chanOutpoint, err := s.openChannelManager.DeliverOpenChannelRequest( + ctx, req.OpenChannelRequest, + ) + + var outpointStr string + if chanOutpoint != nil { + outpointStr = chanOutpoint.String() + } + + return &looprpc.StaticOpenChannelResponse{ + ChannelOpenOutpoint: outpointStr, + }, err +} + type filterFunc func(deposits *deposit.Deposit) bool func filter(deposits []*deposit.Deposit, f filterFunc) []*looprpc.Deposit { @@ -2233,6 +2267,12 @@ func toClientDepositState(state fsm.StateType) looprpc.DepositState { case deposit.LoopedIn: return looprpc.DepositState_LOOPED_IN + case deposit.OpeningChannel: + return looprpc.DepositState_OPENING_CHANNEL + + case deposit.ChannelPublished: + return looprpc.DepositState_CHANNEL_PUBLISHED + case deposit.SweepHtlcTimeout: return looprpc.DepositState_SWEEP_HTLC_TIMEOUT @@ -2312,6 +2352,12 @@ func toServerState(state looprpc.DepositState) fsm.StateType { case looprpc.DepositState_LOOPED_IN: return deposit.LoopedIn + case looprpc.DepositState_OPENING_CHANNEL: + return deposit.OpeningChannel + + case looprpc.DepositState_CHANNEL_PUBLISHED: + return deposit.ChannelPublished + case looprpc.DepositState_SWEEP_HTLC_TIMEOUT: return deposit.SweepHtlcTimeout diff --git a/staticaddr/openchannel/manager.go b/staticaddr/openchannel/manager.go index 8f509d4cd..af2f5162b 100644 --- a/staticaddr/openchannel/manager.go +++ b/staticaddr/openchannel/manager.go @@ -87,8 +87,8 @@ type newOpenChannelRequest struct { } type newOpenChannelResponse struct { - // ChanTxHash is the transaction hash of the channel open transaction. - ChanTxHash *chainhash.Hash + // ChanOutpoint is the outpoint of the channel open transaction. + ChanOutpoint *wire.OutPoint // Err is the error that occurred during the channel open process. err error @@ -129,10 +129,10 @@ func (m *Manager) Run(ctx context.Context) error { for { select { case req := <-m.newOpenChannelRequestChan: - chanTxHash, err := m.OpenChannel(ctx, req.request) + chanOutpoint, err := m.OpenChannel(ctx, req.request) resp := &newOpenChannelResponse{ - ChanTxHash: chanTxHash, - err: err, + ChanOutpoint: chanOutpoint, + err: err, } select { @@ -236,7 +236,7 @@ func (m *Manager) recoverOpeningChannelDeposits(ctx context.Context) error { // and then starts the open channel psbt flow between the client's lnd instance // and the server. func (m *Manager) OpenChannel(ctx context.Context, - req *lnrpc.OpenChannelRequest) (*chainhash.Hash, error) { + req *lnrpc.OpenChannelRequest) (*wire.OutPoint, error) { var ( outpoints []wire.OutPoint @@ -378,7 +378,7 @@ func (m *Manager) OpenChannel(ctx context.Context, Memo: req.Memo, } - chanTxHash, err := m.openChannelPsbt( + chanOutpoint, err := m.openChannelPsbt( ctx, openChanRequest, deposits, feeRate, ) if err != nil { @@ -403,7 +403,7 @@ func (m *Manager) OpenChannel(ctx context.Context, return nil, err } - return chanTxHash, nil + return chanOutpoint, nil } // openChannelPsbt starts an interactive channel open protocol that uses a @@ -426,7 +426,7 @@ func (m *Manager) OpenChannel(ctx context.Context, // | | func (m *Manager) openChannelPsbt(ctx context.Context, req *lnrpc.OpenChannelRequest, deposits []*deposit.Deposit, - feeRate chainfee.SatPerKWeight) (*chainhash.Hash, error) { + feeRate chainfee.SatPerKWeight) (*wire.OutPoint, error) { var ( pendingChanID [32]byte @@ -689,8 +689,13 @@ func (m *Manager) openChannelPsbt(ctx context.Context, "open tx: %v", err) } + chanOutpoint := &wire.OutPoint{ + Hash: *hash, + Index: update.ChanPending.OutputIndex, + } + log.Infof("Channel transaction pending: %v", - hash.String()) + chanOutpoint) log.Infof("Please monitor the channel from lnd") err = m.cfg.DepositManager.TransitionDeposits( @@ -706,9 +711,7 @@ func (m *Manager) openChannelPsbt(ctx context.Context, // goroutine that reads from the server. closeQuit() - // Nil indicates that the channel was successfully - // published. - return hash, nil + return chanOutpoint, nil } } } @@ -770,7 +773,7 @@ func checkPsbtFlags(req *lnrpc.OpenChannelRequest) error { // DeliverOpenChannelRequest forwards a open channel request to the manager main // loop. func (m *Manager) DeliverOpenChannelRequest(ctx context.Context, - req *lnrpc.OpenChannelRequest) (*chainhash.Hash, error) { + req *lnrpc.OpenChannelRequest) (*wire.OutPoint, error) { request := newOpenChannelRequest{ request: req, @@ -793,7 +796,7 @@ func (m *Manager) DeliverOpenChannelRequest(ctx context.Context, // Wait for the response from the manager run loop. select { case resp := <-request.respChan: - return resp.ChanTxHash, resp.err + return resp.ChanOutpoint, resp.err case <-m.exitChan: return nil, fmt.Errorf("open channel manager has been " + From d2d8e71ea05e24fb81208b6ee54d651e50ef05e6 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 19 Nov 2025 09:10:21 +0100 Subject: [PATCH 08/16] notifications: fix test race --- notifications/manager_test.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/notifications/manager_test.go b/notifications/manager_test.go index 19da9c162..e0d0e01d3 100644 --- a/notifications/manager_test.go +++ b/notifications/manager_test.go @@ -142,9 +142,11 @@ func TestManager_ReservationNotification(t *testing.T) { return mockClient.timesCalled > 0 }, time.Second*5, 10*time.Millisecond) - mockClient.Lock() - require.Equal(t, 1, mockClient.timesCalled) - mockClient.Unlock() + require.Eventually(t, func() bool { + mockClient.Lock() + defer mockClient.Unlock() + return mockClient.timesCalled == 1 + }, time.Second*5, 10*time.Millisecond) // Send a test notification testNotif := getTestNotification(testReservationId) From eaf7883bcf7bf353d8adfe8ea89a38957e9b6d23 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 19 Feb 2026 09:48:23 +0100 Subject: [PATCH 09/16] openchannel: fix shimPending data race Protect the shimPending variable with a sync.Mutex since it is accessed from multiple goroutines: the main loop goroutine and the server error handling goroutine. Without synchronization this is a data race. --- staticaddr/openchannel/manager.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/staticaddr/openchannel/manager.go b/staticaddr/openchannel/manager.go index af2f5162b..36ccdc75f 100644 --- a/staticaddr/openchannel/manager.go +++ b/staticaddr/openchannel/manager.go @@ -430,6 +430,7 @@ func (m *Manager) openChannelPsbt(ctx context.Context, var ( pendingChanID [32]byte + shimMu sync.Mutex shimPending = true psbtFinalized bool basePsbtBytes []byte @@ -466,6 +467,9 @@ func (m *Manager) openChannelPsbt(ctx context.Context, // maybeCancelShim is a helper function that cancels the funding shim // with the RPC server in case we end up aborting early. maybeCancelShim := func() { + shimMu.Lock() + defer shimMu.Unlock() + // If the user canceled while there was still a shim registered // with the wallet, release the resources now. if shimPending { @@ -566,7 +570,9 @@ func (m *Manager) openChannelPsbt(ctx context.Context, err.Error(), cancelErr, ) { + shimMu.Lock() shimPending = false + shimMu.Unlock() } closeQuit() @@ -679,7 +685,9 @@ func (m *Manager) openChannelPsbt(ctx context.Context, // As soon as the channel is pending, there is no more // shim that needs to be canceled. If the user // interrupts now, we don't need to clean up anything. + shimMu.Lock() shimPending = false + shimMu.Unlock() hash, err := chainhash.NewHash( update.ChanPending.Txid, From 52d884cec1f880d810e5761e59ec532464241f76 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 19 Feb 2026 09:48:54 +0100 Subject: [PATCH 10/16] openchannel: return error on hash creation failure Return an error instead of just logging when chainhash.NewHash fails in the ChanPending handler. The hash variable would be nil and crash on the subsequent String() call. --- staticaddr/openchannel/manager.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/staticaddr/openchannel/manager.go b/staticaddr/openchannel/manager.go index 36ccdc75f..b7472c576 100644 --- a/staticaddr/openchannel/manager.go +++ b/staticaddr/openchannel/manager.go @@ -693,8 +693,8 @@ func (m *Manager) openChannelPsbt(ctx context.Context, update.ChanPending.Txid, ) if err != nil { - log.Infof("Error creating hash for channel "+ - "open tx: %v", err) + return nil, fmt.Errorf("error creating "+ + "hash for channel open tx: %w", err) } chanOutpoint := &wire.OutPoint{ From dbac12ed7869c4acc7c17a81ca4dd1b153522907 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 19 Feb 2026 09:53:55 +0100 Subject: [PATCH 11/16] deposit: add OnExpiry self-transitions to OpeningChannel and ChannelPublished Both OpeningChannel and ChannelPublished lacked OnExpiry transitions. handleBlockNotification fires OnExpiry on every new block once the deposit is expired, regardless of the current state. Since both states use NoOpAction or FinalizeDepositAction which release the FSM mutex briefly, an OnExpiry SendEvent can sneak in. Add self-transitions so the event is safely absorbed. --- staticaddr/deposit/fsm.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/staticaddr/deposit/fsm.go b/staticaddr/deposit/fsm.go index 029f7f63d..341283a31 100644 --- a/staticaddr/deposit/fsm.go +++ b/staticaddr/deposit/fsm.go @@ -392,10 +392,22 @@ func (f *FSM) DepositStatesV0() fsm.States { fsm.OnError: Deposited, OnChannelPublished: ChannelPublished, OnRecover: OpeningChannel, + // OnExpiry can arrive on every block once + // the deposit is expired. Since the channel + // open is still in progress we absorb the + // event as a self-transition. + OnExpiry: OpeningChannel, }, Action: fsm.NoOpAction, }, ChannelPublished: fsm.State{ + Transitions: fsm.Transitions{ + // OnExpiry can arrive on every block once + // the deposit is expired. Since the channel + // is already published we absorb the event + // as a self-transition. + OnExpiry: ChannelPublished, + }, Action: f.FinalizeDepositAction, }, } From e710ea5e8f34fd2b591d5468e0d509a8d2656f16 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 19 Feb 2026 09:54:58 +0100 Subject: [PATCH 12/16] openchannel: reject duplicate outpoints in open channel request Duplicate outpoints in the request lead to fee miscalculation and an invalid PSBT with the same input listed twice. Validate early and return a clear error message. --- staticaddr/openchannel/manager.go | 12 +++++++++++ staticaddr/openchannel/manager_test.go | 28 ++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/staticaddr/openchannel/manager.go b/staticaddr/openchannel/manager.go index b7472c576..acf18732e 100644 --- a/staticaddr/openchannel/manager.go +++ b/staticaddr/openchannel/manager.go @@ -299,6 +299,18 @@ func (m *Manager) OpenChannel(ctx context.Context, err) } + // Check for duplicate outpoints which would lead to fee + // miscalculation and an invalid PSBT with the same input + // listed twice. + seen := make(map[wire.OutPoint]struct{}, len(outpoints)) + for _, op := range outpoints { + if _, ok := seen[op]; ok { + return nil, fmt.Errorf("duplicate outpoint "+ + "%v in request", op) + } + seen[op] = struct{}{} + } + deposits, allActive = m.cfg.DepositManager.AllOutpointsActiveDeposits( outpoints, deposit.Deposited, diff --git a/staticaddr/openchannel/manager_test.go b/staticaddr/openchannel/manager_test.go index 807d275f1..db330e012 100644 --- a/staticaddr/openchannel/manager_test.go +++ b/staticaddr/openchannel/manager_test.go @@ -210,6 +210,34 @@ func testOutPoint(b byte) wire.OutPoint { } } +func TestOpenChannelDuplicateOutpoints(t *testing.T) { + t.Parallel() + + op := testOutPoint(1) + manager := &Manager{ + cfg: &Config{}, + } + + req := &lnrpc.OpenChannelRequest{ + NodePubkey: make([]byte, 33), + LocalFundingAmount: 100000, + SatPerVbyte: 10, + Outpoints: []*lnrpc.OutPoint{ + { + TxidStr: op.Hash.String(), + OutputIndex: op.Index, + }, + { + TxidStr: op.Hash.String(), + OutputIndex: op.Index, + }, + }, + } + + _, err := manager.OpenChannel(context.Background(), req) + require.ErrorContains(t, err, "duplicate outpoint") +} + func TestValidateInitialPsbtFlags(t *testing.T) { t.Parallel() From cd377f35f868f9055d338bdfb55d8ec16a34739e Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 19 Feb 2026 09:56:10 +0100 Subject: [PATCH 13/16] openchannel: recover deposits at runtime after PSBT finalize failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If the PSBT finalize step succeeds but the stream fails before ChanPending, deposits would remain stuck in OpeningChannel until the next daemon restart. Run the recovery logic immediately so deposits are resolved without requiring a restart. Also, add tests for the following edge cases requested in review: - Reorg: channel tx reorged, UTXOs reappear as unspent, deposits return to Deposited state. - Daemon restart during channel opening: deposits in OpeningChannel recovered based on UTXO status (spent → ChannelPublished, unspent → Deposited). - Mempool eviction: tx evicted, UTXOs unspent, deposits return to Deposited. - Mempool rejection: tx never accepted, same recovery as eviction. - Stream errors: lnd stream fails before PSBT finalize, error returned without errPsbtFinalized so deposits can be safely rolled back. - PSBT finalize then stream abort: finalize succeeds but stream dies before ChanPending, error wrapped with errPsbtFinalized so caller triggers recovery instead of blind rollback. - Duplicate outpoints: already covered by TestOpenChannelDuplicateOutpoints. --- staticaddr/openchannel/manager.go | 14 +- staticaddr/openchannel/manager_test.go | 474 +++++++++++++++++++++++++ 2 files changed, 485 insertions(+), 3 deletions(-) diff --git a/staticaddr/openchannel/manager.go b/staticaddr/openchannel/manager.go index acf18732e..58dd4fb03 100644 --- a/staticaddr/openchannel/manager.go +++ b/staticaddr/openchannel/manager.go @@ -398,9 +398,17 @@ func (m *Manager) OpenChannel(ctx context.Context, // If the PSBT was already finalized and sent to lnd, the // funding transaction may have been broadcast. In that case - // we must not roll back the deposits to Deposited as they - // may already be spent on-chain. - if !errors.Is(err, errPsbtFinalized) { + // we must not blindly roll back. Instead, try to recover + // the deposits now so they don't remain stuck in + // OpeningChannel until the next restart. + if errors.Is(err, errPsbtFinalized) { + recoverErr := m.recoverOpeningChannelDeposits(ctx) + if recoverErr != nil { + log.Errorf("failed recovering deposits "+ + "after PSBT finalize: %v", + recoverErr) + } + } else { err2 := m.cfg.DepositManager.TransitionDeposits( ctx, deposits, fsm.OnError, deposit.Deposited, diff --git a/staticaddr/openchannel/manager_test.go b/staticaddr/openchannel/manager_test.go index db330e012..f22884cec 100644 --- a/staticaddr/openchannel/manager_test.go +++ b/staticaddr/openchannel/manager_test.go @@ -3,8 +3,12 @@ package openchannel import ( "context" "errors" + "sync" "testing" + "time" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/lndclient" @@ -12,7 +16,10 @@ import ( "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lnwallet" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" ) type transitionCall struct { @@ -89,6 +96,9 @@ func (m *mockWalletKit) ListUnspent(_ context.Context, _, _ int32, return m.utxos, nil } +// TestRecoverOpeningChannelDepositsMixed verifies that recovery correctly +// classifies deposits based on UTXO status: unspent deposits are moved back to +// Deposited and spent deposits are transitioned to ChannelPublished. func TestRecoverOpeningChannelDepositsMixed(t *testing.T) { t.Parallel() @@ -135,6 +145,8 @@ func TestRecoverOpeningChannelDepositsMixed(t *testing.T) { ) } +// TestRecoverOpeningChannelDepositsNoDeposits verifies that recovery is a +// no-op when there are no deposits in the OpeningChannel state. func TestRecoverOpeningChannelDepositsNoDeposits(t *testing.T) { t.Parallel() @@ -153,6 +165,8 @@ func TestRecoverOpeningChannelDepositsNoDeposits(t *testing.T) { require.Empty(t, depositManager.calls) } +// TestRecoverOpeningChannelDepositsListUnspentError verifies that a +// ListUnspent failure during recovery is propagated to the caller. func TestRecoverOpeningChannelDepositsListUnspentError(t *testing.T) { t.Parallel() @@ -176,6 +190,8 @@ func TestRecoverOpeningChannelDepositsListUnspentError(t *testing.T) { require.Empty(t, depositManager.calls) } +// TestRecoverOpeningChannelDepositsTransitionError verifies that a transition +// failure when moving unspent deposits back to Deposited is propagated. func TestRecoverOpeningChannelDepositsTransitionError(t *testing.T) { t.Parallel() @@ -203,6 +219,213 @@ func TestRecoverOpeningChannelDepositsTransitionError(t *testing.T) { require.Len(t, depositManager.calls, 1) } +// TestRecoverAfterReorg simulates a reorg where a channel funding transaction +// was confirmed but then reorged out. After the reorg the deposit UTXOs +// reappear as unspent, so recovery should move all deposits back to Deposited. +func TestRecoverAfterReorg(t *testing.T) { + t.Parallel() + + d1 := &deposit.Deposit{OutPoint: testOutPoint(1)} + d2 := &deposit.Deposit{OutPoint: testOutPoint(2)} + + depositManager := &mockDepositManager{ + openingDeposits: []*deposit.Deposit{d1, d2}, + } + walletKit := &mockWalletKit{ + utxos: []*lnwallet.Utxo{ + // After reorg both UTXOs are unspent again. + {OutPoint: d1.OutPoint}, + {OutPoint: d2.OutPoint}, + }, + } + + manager := &Manager{ + cfg: &Config{ + DepositManager: depositManager, + WalletKit: walletKit, + }, + } + + err := manager.recoverOpeningChannelDeposits(context.Background()) + require.NoError(t, err) + require.Len(t, depositManager.calls, 1) + + // Both deposits should transition back to Deposited. + require.Equal(t, fsm.OnError, depositManager.calls[0].event) + require.Equal( + t, deposit.Deposited, depositManager.calls[0].expectedState, + ) + require.ElementsMatch( + t, + []wire.OutPoint{d1.OutPoint, d2.OutPoint}, + depositManager.calls[0].outpoints, + ) +} + +// TestRecoverAfterMempoolEviction simulates the case where the channel funding +// transaction was evicted from the mempool. The deposit UTXOs reappear as +// unspent, so recovery should move them back to Deposited. +func TestRecoverAfterMempoolEviction(t *testing.T) { + t.Parallel() + + d := &deposit.Deposit{OutPoint: testOutPoint(1)} + depositManager := &mockDepositManager{ + openingDeposits: []*deposit.Deposit{d}, + } + walletKit := &mockWalletKit{ + utxos: []*lnwallet.Utxo{ + // UTXO reappears after mempool eviction. + {OutPoint: d.OutPoint}, + }, + } + + manager := &Manager{ + cfg: &Config{ + DepositManager: depositManager, + WalletKit: walletKit, + }, + } + + err := manager.recoverOpeningChannelDeposits(context.Background()) + require.NoError(t, err) + require.Len(t, depositManager.calls, 1) + require.Equal(t, fsm.OnError, depositManager.calls[0].event) + require.Equal( + t, deposit.Deposited, depositManager.calls[0].expectedState, + ) +} + +// TestRecoverAfterMempoolRejection simulates the case where the channel +// funding transaction was rejected from the mempool (e.g. fee too low). The +// deposit UTXOs were never spent, so recovery should move them back to +// Deposited. +func TestRecoverAfterMempoolRejection(t *testing.T) { + t.Parallel() + + d1 := &deposit.Deposit{OutPoint: testOutPoint(1)} + d2 := &deposit.Deposit{OutPoint: testOutPoint(2)} + d3 := &deposit.Deposit{OutPoint: testOutPoint(3)} + + depositManager := &mockDepositManager{ + openingDeposits: []*deposit.Deposit{d1, d2, d3}, + } + walletKit := &mockWalletKit{ + utxos: []*lnwallet.Utxo{ + // All UTXOs still unspent since tx was never accepted. + {OutPoint: d1.OutPoint}, + {OutPoint: d2.OutPoint}, + {OutPoint: d3.OutPoint}, + }, + } + + manager := &Manager{ + cfg: &Config{ + DepositManager: depositManager, + WalletKit: walletKit, + }, + } + + err := manager.recoverOpeningChannelDeposits(context.Background()) + require.NoError(t, err) + require.Len(t, depositManager.calls, 1) + require.Equal(t, fsm.OnError, depositManager.calls[0].event) + require.Equal( + t, deposit.Deposited, depositManager.calls[0].expectedState, + ) + require.Len(t, depositManager.calls[0].outpoints, 3) +} + +// TestRecoverDaemonRestartChannelPublished simulates a daemon restart where +// the channel funding tx was successfully broadcast and the deposit UTXOs are +// all spent. Recovery should move them to ChannelPublished. +func TestRecoverDaemonRestartChannelPublished(t *testing.T) { + t.Parallel() + + d1 := &deposit.Deposit{OutPoint: testOutPoint(1)} + d2 := &deposit.Deposit{OutPoint: testOutPoint(2)} + + depositManager := &mockDepositManager{ + openingDeposits: []*deposit.Deposit{d1, d2}, + } + walletKit := &mockWalletKit{ + // No UTXOs returned - all deposit outpoints have been spent. + utxos: []*lnwallet.Utxo{}, + } + + manager := &Manager{ + cfg: &Config{ + DepositManager: depositManager, + WalletKit: walletKit, + }, + } + + err := manager.recoverOpeningChannelDeposits(context.Background()) + require.NoError(t, err) + require.Len(t, depositManager.calls, 1) + require.Equal( + t, deposit.OnChannelPublished, + depositManager.calls[0].event, + ) + require.Equal( + t, deposit.ChannelPublished, + depositManager.calls[0].expectedState, + ) + require.ElementsMatch( + t, + []wire.OutPoint{d1.OutPoint, d2.OutPoint}, + depositManager.calls[0].outpoints, + ) +} + +// TestRecoverChannelPublishedTransitionError verifies that an error +// transitioning deposits to ChannelPublished during recovery is returned. +func TestRecoverChannelPublishedTransitionError(t *testing.T) { + t.Parallel() + + d := &deposit.Deposit{OutPoint: testOutPoint(1)} + depositManager := &mockDepositManager{ + openingDeposits: []*deposit.Deposit{d}, + transitionErrs: map[fsm.EventType]error{ + deposit.OnChannelPublished: errors.New( + "transition failed", + ), + }, + } + walletKit := &mockWalletKit{ + // UTXO is spent, so recovery tries ChannelPublished transition. + utxos: []*lnwallet.Utxo{}, + } + + manager := &Manager{ + cfg: &Config{ + DepositManager: depositManager, + WalletKit: walletKit, + }, + } + + err := manager.recoverOpeningChannelDeposits(context.Background()) + require.ErrorContains(t, err, "unable to recover spent opening deposits") +} + +// TestRecoverGetActiveDepositsError verifies that a failure to fetch opening +// channel deposits is surfaced. +func TestRecoverGetActiveDepositsError(t *testing.T) { + t.Parallel() + + depositManager := &mockDepositManager{ + getErr: errors.New("db connection lost"), + } + + manager := &Manager{ + cfg: &Config{ + DepositManager: depositManager, + }, + } + + err := manager.recoverOpeningChannelDeposits(context.Background()) + require.ErrorContains(t, err, "unable to fetch opening channel deposits") +} + func testOutPoint(b byte) wire.OutPoint { return wire.OutPoint{ Hash: chainhash.Hash{b}, @@ -210,6 +433,9 @@ func testOutPoint(b byte) wire.OutPoint { } } +// TestOpenChannelDuplicateOutpoints verifies that OpenChannel rejects requests +// containing duplicate outpoints, which would cause fee miscalculation and an +// invalid PSBT with the same input listed twice. func TestOpenChannelDuplicateOutpoints(t *testing.T) { t.Parallel() @@ -238,6 +464,8 @@ func TestOpenChannelDuplicateOutpoints(t *testing.T) { require.ErrorContains(t, err, "duplicate outpoint") } +// TestValidateInitialPsbtFlags verifies that request fields incompatible with +// PSBT funding are rejected early, before any deposits are locked. func TestValidateInitialPsbtFlags(t *testing.T) { t.Parallel() @@ -289,6 +517,8 @@ func TestValidateInitialPsbtFlags(t *testing.T) { } } +// TestResolveCommitmentType verifies that supported commitment types are +// resolved correctly and unsupported types are rejected. func TestResolveCommitmentType(t *testing.T) { t.Parallel() @@ -340,3 +570,247 @@ func TestResolveCommitmentType(t *testing.T) { }) } } + +// --------------------------------------------------------------------------- +// Mock types for PSBT channel open flow tests. +// --------------------------------------------------------------------------- + +// mockLndClient implements lndclient.LightningClient for testing. Embedding +// the interface means unimplemented methods panic if called, which is +// desirable in tests to surface unexpected interactions. +type mockLndClient struct { + lndclient.LightningClient + + rawClient lnrpc.LightningClient + + mu sync.Mutex + fundingStepIdx int + fundingStepErr error +} + +func (m *mockLndClient) RawClientWithMacAuth( + ctx context.Context) (context.Context, time.Duration, + lnrpc.LightningClient) { + + return ctx, 0, m.rawClient +} + +func (m *mockLndClient) FundingStateStep(_ context.Context, + _ *lnrpc.FundingTransitionMsg) (*lnrpc.FundingStateStepResp, error) { + + m.mu.Lock() + defer m.mu.Unlock() + + m.fundingStepIdx++ + + return &lnrpc.FundingStateStepResp{}, m.fundingStepErr +} + +// mockRawLnrpcClient implements the raw gRPC lnrpc.LightningClient. +type mockRawLnrpcClient struct { + lnrpc.LightningClient + + stream lnrpc.Lightning_OpenChannelClient + openErr error +} + +func (m *mockRawLnrpcClient) OpenChannel(_ context.Context, + _ *lnrpc.OpenChannelRequest, + _ ...grpc.CallOption) (lnrpc.Lightning_OpenChannelClient, error) { + + return m.stream, m.openErr +} + +// mockClientStream implements grpc.ClientStream for embedding in +// mockOpenChanStream. +type mockClientStream struct{} + +func (m *mockClientStream) Header() (metadata.MD, error) { + return nil, nil +} +func (m *mockClientStream) Trailer() metadata.MD { return nil } +func (m *mockClientStream) CloseSend() error { return nil } +func (m *mockClientStream) Context() context.Context { + return context.Background() +} +func (m *mockClientStream) SendMsg(_ interface{}) error { return nil } +func (m *mockClientStream) RecvMsg(_ interface{}) error { return nil } + +// mockOpenChanStream implements lnrpc.Lightning_OpenChannelClient. It returns +// queued messages from Recv(), then returns finalErr once the queue is +// exhausted. +type mockOpenChanStream struct { + *mockClientStream + + mu sync.Mutex + msgs []*lnrpc.OpenStatusUpdate + finalErr error + idx int +} + +func (m *mockOpenChanStream) Recv() (*lnrpc.OpenStatusUpdate, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if m.idx >= len(m.msgs) { + return nil, m.finalErr + } + + msg := m.msgs[m.idx] + m.idx++ + + return msg, nil +} + +// mockWithdrawManager implements the WithdrawalManager interface. +type mockWithdrawManager struct { + tx *wire.MsgTx + psbt []byte + err error +} + +func (m *mockWithdrawManager) CreateFinalizedWithdrawalTx( + _ context.Context, _ []*deposit.Deposit, + _ btcutil.Address, _ chainfee.SatPerKWeight, _ int64, + _ lnrpc.CommitmentType) (*wire.MsgTx, []byte, error) { + + return m.tx, m.psbt, m.err +} + +// testFundingAddress returns a valid regtest P2WPKH address for use in tests. +func testFundingAddress() string { + addr, _ := btcutil.NewAddressWitnessPubKeyHash( + make([]byte, 20), &chaincfg.RegressionNetParams, + ) + + return addr.EncodeAddress() +} + +// --------------------------------------------------------------------------- +// Stream-level tests for the PSBT channel open flow. +// --------------------------------------------------------------------------- + +// TestStreamOpenError verifies that when the lnd OpenChannel stream fails to +// open, the error is returned and the shim is cleaned up. +func TestStreamOpenError(t *testing.T) { + t.Parallel() + + mockRaw := &mockRawLnrpcClient{ + openErr: errors.New("connection refused"), + } + lnClient := &mockLndClient{rawClient: mockRaw} + + manager := &Manager{ + cfg: &Config{ + LightningClient: lnClient, + ChainParams: &chaincfg.RegressionNetParams, + }, + } + + req := &lnrpc.OpenChannelRequest{ + LocalFundingAmount: 100000, + MinConfs: defaultUtxoMinConf, + } + + _, err := manager.openChannelPsbt( + context.Background(), req, nil, 0, + ) + require.ErrorContains(t, err, "opening stream to server failed") + require.False(t, errors.Is(err, errPsbtFinalized)) + + // Verify that the shim was canceled via FundingStateStep. + lnClient.mu.Lock() + require.Equal(t, 1, lnClient.fundingStepIdx) + lnClient.mu.Unlock() +} + +// TestStreamErrorBeforePsbtFinalize verifies that when the lnd stream returns +// an error before the PSBT is finalized, deposits are NOT wrapped in +// errPsbtFinalized so the caller can safely roll them back. +func TestStreamErrorBeforePsbtFinalize(t *testing.T) { + t.Parallel() + + stream := &mockOpenChanStream{ + mockClientStream: &mockClientStream{}, + finalErr: errors.New("peer disconnected"), + } + mockRaw := &mockRawLnrpcClient{stream: stream} + lnClient := &mockLndClient{rawClient: mockRaw} + + manager := &Manager{ + cfg: &Config{ + LightningClient: lnClient, + ChainParams: &chaincfg.RegressionNetParams, + }, + } + + req := &lnrpc.OpenChannelRequest{ + LocalFundingAmount: 100000, + MinConfs: defaultUtxoMinConf, + } + + _, err := manager.openChannelPsbt( + context.Background(), req, nil, 0, + ) + require.Error(t, err) + require.False(t, errors.Is(err, errPsbtFinalized)) +} + +// TestPsbtFinalizeThenStreamAbort verifies that when the PSBT finalize step +// succeeds but the stream dies before ChanPending, the error is wrapped with +// errPsbtFinalized so that the caller knows deposits must not be blindly +// rolled back. +func TestPsbtFinalizeThenStreamAbort(t *testing.T) { + t.Parallel() + + fundingAmt := int64(100000) + fundingAddr := testFundingAddress() + + stream := &mockOpenChanStream{ + mockClientStream: &mockClientStream{}, + msgs: []*lnrpc.OpenStatusUpdate{ + { + Update: &lnrpc.OpenStatusUpdate_PsbtFund{ + PsbtFund: &lnrpc.ReadyForPsbtFunding{ + FundingAmount: fundingAmt, + FundingAddress: fundingAddr, + }, + }, + }, + }, + finalErr: errors.New("stream died after finalize"), + } + mockRaw := &mockRawLnrpcClient{stream: stream} + lnClient := &mockLndClient{rawClient: mockRaw} + + // Provide a minimal transaction that can be serialized. + withdrawMgr := &mockWithdrawManager{ + tx: wire.NewMsgTx(2), + psbt: []byte("unsigned-psbt"), + } + + manager := &Manager{ + cfg: &Config{ + LightningClient: lnClient, + WithdrawalManager: withdrawMgr, + ChainParams: &chaincfg.RegressionNetParams, + }, + } + + req := &lnrpc.OpenChannelRequest{ + LocalFundingAmount: fundingAmt, + MinConfs: defaultUtxoMinConf, + } + + _, err := manager.openChannelPsbt( + context.Background(), req, nil, 0, + ) + require.Error(t, err) + require.True(t, errors.Is(err, errPsbtFinalized)) + + // FundingStateStep should have been called 3 times: verify, finalize, + // and shim cancel (from defer). + lnClient.mu.Lock() + require.Equal(t, 3, lnClient.fundingStepIdx) + lnClient.mu.Unlock() +} From 6cb54edf064894642a080696a6cca3de42e582d8 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Mon, 23 Feb 2026 12:43:16 +0100 Subject: [PATCH 14/16] openchannel: optimize open channel request creation and err handling --- staticaddr/openchannel/manager.go | 128 ++++++++++++++----------- staticaddr/openchannel/manager_test.go | 65 +++++++++---- 2 files changed, 118 insertions(+), 75 deletions(-) diff --git a/staticaddr/openchannel/manager.go b/staticaddr/openchannel/manager.go index 58dd4fb03..347351289 100644 --- a/staticaddr/openchannel/manager.go +++ b/staticaddr/openchannel/manager.go @@ -23,6 +23,7 @@ import ( "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lnwallet/chainfee" "github.com/lightningnetwork/lnd/lnwallet/chanfunding" + "google.golang.org/protobuf/proto" ) const ( @@ -366,64 +367,55 @@ func (m *Manager) OpenChannel(ctx context.Context, return nil, err } - openChanRequest := &lnrpc.OpenChannelRequest{ - NodePubkey: req.NodePubkey, - LocalFundingAmount: int64(chanFundingAmt), - PushSat: req.PushSat, - Private: req.Private, - MinHtlcMsat: req.MinHtlcMsat, - RemoteCsvDelay: req.RemoteCsvDelay, - MinConfs: defaultUtxoMinConf, - SpendUnconfirmed: false, - CloseAddress: req.CloseAddress, - RemoteMaxValueInFlightMsat: req.RemoteMaxValueInFlightMsat, - RemoteMaxHtlcs: req.RemoteMaxHtlcs, - MaxLocalCsv: req.MaxLocalCsv, - CommitmentType: chanCommitmentType, - ZeroConf: req.ZeroConf, - ScidAlias: req.ScidAlias, - BaseFee: req.BaseFee, - FeeRate: req.FeeRate, - UseBaseFee: req.UseBaseFee, - UseFeeRate: req.UseFeeRate, - RemoteChanReserveSat: req.RemoteChanReserveSat, - Memo: req.Memo, - } - - chanOutpoint, err := m.openChannelPsbt( - ctx, openChanRequest, deposits, feeRate, - ) - if err != nil { - log.Infof("error opening channel: %v", err) - - // If the PSBT was already finalized and sent to lnd, the - // funding transaction may have been broadcast. In that case - // we must not blindly roll back. Instead, try to recover - // the deposits now so they don't remain stuck in - // OpeningChannel until the next restart. - if errors.Is(err, errPsbtFinalized) { - recoverErr := m.recoverOpeningChannelDeposits(ctx) - if recoverErr != nil { - log.Errorf("failed recovering deposits "+ - "after PSBT finalize: %v", - recoverErr) - } - } else { - err2 := m.cfg.DepositManager.TransitionDeposits( - ctx, deposits, fsm.OnError, - deposit.Deposited, - ) - if err2 != nil { - log.Errorf("failed transitioning deposits "+ - "after failed channel open: %v", - err2) - } + // Clone the request message and set mandatory parameters. + reqClone := proto.Clone(req).(*lnrpc.OpenChannelRequest) + + // Override fields consumed locally or incompatible with PSBT funding. + reqClone.LocalFundingAmount = int64(chanFundingAmt) + + // TODO: Once lnd's PSBT channel open flow supports fundmax natively, + // we can pass FundMax through to lnd and remove Loop's local coin + // selection for the fundmax case. + reqClone.FundMax = false + reqClone.MinConfs = defaultUtxoMinConf + reqClone.SpendUnconfirmed = false + // In the lnd PSBT flow, fee estimation params on the request are + // explicitly disallowed. + reqClone.SatPerVbyte = 0 + reqClone.CommitmentType = chanCommitmentType + + chanOutpoint, err := m.openChannelPsbt(ctx, reqClone, deposits, feeRate) + if err == nil { + return chanOutpoint, nil + } + + log.Infof("error opening channel: %v", err) + + // If the PSBT was already finalized and sent to lnd, the + // funding transaction may have been broadcast. In that case + // we must not blindly roll back. Instead, try to recover + // the deposits now so they don't remain stuck in + // OpeningChannel until the next restart. + if errors.Is(err, errPsbtFinalized) { + recoverErr := m.recoverOpeningChannelDeposits(ctx) + if recoverErr != nil { + log.Errorf("failed recovering deposits "+ + "after PSBT finalize: %v", + recoverErr) + } + } else { + err2 := m.cfg.DepositManager.TransitionDeposits( + ctx, deposits, fsm.OnError, + deposit.Deposited, + ) + if err2 != nil { + log.Errorf("failed transitioning deposits "+ + "after failed channel open: %v", + err2) } - - return nil, err } - return chanOutpoint, nil + return nil, err } // openChannelPsbt starts an interactive channel open protocol that uses a @@ -455,6 +447,7 @@ func (m *Manager) openChannelPsbt(ctx context.Context, psbtFinalized bool basePsbtBytes []byte quit = make(chan struct{}) + quitCause error closeQuitOnce sync.Once srvMsg = make(chan *lnrpc.OpenStatusUpdate, 1) srvErr = make(chan error, 1) @@ -594,6 +587,8 @@ func (m *Manager) openChannelPsbt(ctx context.Context, shimPending = false shimMu.Unlock() } + + quitCause = err closeQuit() case <-quit: @@ -606,6 +601,9 @@ func (m *Manager) openChannelPsbt(ctx context.Context, case srvResponse = <-srvMsg: case <-quit: cancelErr := fmt.Errorf("open channel flow canceled") + if quitCause != nil { + cancelErr = quitCause + } if psbtFinalized { return nil, fmt.Errorf("%w: %v", errPsbtFinalized, cancelErr) @@ -757,6 +755,26 @@ func validateInitialPsbtFlags(req *lnrpc.OpenChannelRequest) error { "for PSBT funding") } + if req.TargetConf != 0 { + return fmt.Errorf("TargetConf is not supported for PSBT " + + "funding, use SatPerVbyte to specify fee rate") + } + + if req.SatPerByte != 0 { //nolint:staticcheck + return fmt.Errorf("SatPerByte is deprecated and not " + + "supported for PSBT funding, use SatPerVbyte") + } + + if req.NodePubkeyString != "" { //nolint:staticcheck + return fmt.Errorf("NodePubkeyString is not supported, " + + "use NodePubkey instead") + } + + if req.FundingShim != nil { + return fmt.Errorf("FundingShim is not supported, it is " + + "managed internally for PSBT funding") + } + return nil } diff --git a/staticaddr/openchannel/manager_test.go b/staticaddr/openchannel/manager_test.go index f22884cec..ccb302371 100644 --- a/staticaddr/openchannel/manager_test.go +++ b/staticaddr/openchannel/manager_test.go @@ -471,42 +471,67 @@ func TestValidateInitialPsbtFlags(t *testing.T) { tests := []struct { name string - minConfs int32 - spendUnconfirmed bool + req *lnrpc.OpenChannelRequest expectedErrSubstr string }{ { - name: "default min confs accepted", - minConfs: 0, - spendUnconfirmed: false, + name: "default min confs accepted", + req: &lnrpc.OpenChannelRequest{}, }, { - name: "explicit default min confs accepted", - minConfs: defaultUtxoMinConf, - spendUnconfirmed: false, + name: "explicit default min confs accepted", + req: &lnrpc.OpenChannelRequest{ + MinConfs: defaultUtxoMinConf, + }, }, { - name: "custom min confs rejected", - minConfs: defaultUtxoMinConf + 1, - spendUnconfirmed: false, + name: "custom min confs rejected", + req: &lnrpc.OpenChannelRequest{ + MinConfs: defaultUtxoMinConf + 1, + }, expectedErrSubstr: "custom MinConfs not supported", }, { - name: "spend unconfirmed rejected", - minConfs: defaultUtxoMinConf, - spendUnconfirmed: true, + name: "spend unconfirmed rejected", + req: &lnrpc.OpenChannelRequest{ + MinConfs: defaultUtxoMinConf, + SpendUnconfirmed: true, + }, expectedErrSubstr: "SpendUnconfirmed is not supported", }, + { + name: "target conf rejected", + req: &lnrpc.OpenChannelRequest{ + TargetConf: 6, + }, + expectedErrSubstr: "TargetConf is not supported", + }, + { + name: "sat per byte rejected", + req: &lnrpc.OpenChannelRequest{ + SatPerByte: 10, //nolint:staticcheck + }, + expectedErrSubstr: "SatPerByte is deprecated", + }, + { + name: "node pubkey string rejected", + req: &lnrpc.OpenChannelRequest{ + NodePubkeyString: "abc", //nolint:staticcheck + }, + expectedErrSubstr: "NodePubkeyString is not supported", + }, + { + name: "funding shim rejected", + req: &lnrpc.OpenChannelRequest{ + FundingShim: &lnrpc.FundingShim{}, + }, + expectedErrSubstr: "FundingShim is not supported", + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - req := &lnrpc.OpenChannelRequest{ - MinConfs: tc.minConfs, - SpendUnconfirmed: tc.spendUnconfirmed, - } - - err := validateInitialPsbtFlags(req) + err := validateInitialPsbtFlags(tc.req) if tc.expectedErrSubstr == "" { require.NoError(t, err) return From 79120cc4bc00a372eab03c5fee0557bf24f659ed Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 21 May 2025 14:08:54 +0200 Subject: [PATCH 15/16] cmd: open channel command --- cmd/loop/openchannel.go | 366 ++++++++++++++++++++++++++++++++++++++++ cmd/loop/staticaddr.go | 57 ++----- 2 files changed, 377 insertions(+), 46 deletions(-) create mode 100644 cmd/loop/openchannel.go diff --git a/cmd/loop/openchannel.go b/cmd/loop/openchannel.go new file mode 100644 index 000000000..e7b18c60d --- /dev/null +++ b/cmd/loop/openchannel.go @@ -0,0 +1,366 @@ +package main + +import ( + "context" + "encoding/hex" + "fmt" + "strconv" + + "github.com/lightninglabs/loop/looprpc" + lndcommands "github.com/lightningnetwork/lnd/cmd/commands" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/urfave/cli/v3" +) + +const ( + defaultUtxoMinConf = 1 +) + +var ( + channelTypeTweakless = "tweakless" + channelTypeAnchors = "anchors" + channelTypeSimpleTaproot = "taproot" +) + +var openChannelCommand = &cli.Command{ + Name: "openchannel", + Usage: "Open a channel to a an existing peer.", + Description: ` + Attempt to open a new channel to an existing peer with the key + node-key. + + The channel will be initialized with local-amt satoshis locally and + push-amt satoshis for the remote node. Note that the push-amt is + deducted from the specified local-amt which implies that the local-amt + must be greater than the push-amt. Also note that specifying push-amt + means you give that amount to the remote node as part of the channel + opening. Once the channel is open, a channelPoint (txid:vout) of the + funding output is returned. + + If the remote peer supports the option upfront shutdown feature bit + (query listpeers to see their supported feature bits), an address to + enforce payout of funds on cooperative close can optionally be provided. + Note that if you set this value, you will not be able to cooperatively + close out to another address. + + One can also specify a short string memo to record some useful + information about the channel using the --memo argument. This is stored + locally only, and is purely for reference. It has no bearing on the + channel's operation. Max allowed length is 500 characters.`, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "node_key", + Usage: "the identity public key of the target " + + "node/peer serialized in compressed format", + }, + &cli.IntFlag{ + Name: "local_amt", + Usage: "the number of satoshis the wallet should " + + "commit to the channel", + }, + &cli.Uint64Flag{ + Name: "base_fee_msat", + Usage: "the base fee in milli-satoshis that will " + + "be charged for each forwarded HTLC, " + + "regardless of payment size", + }, + &cli.Uint64Flag{ + Name: "fee_rate_ppm", + Usage: "the fee rate ppm (parts per million) that " + + "will be charged proportionally based on the " + + "value of each forwarded HTLC, the lowest " + + "possible rate is 0 with a granularity of " + + "0.000001 (millionths)", + }, + &cli.IntFlag{ + Name: "push_amt", + Usage: "the number of satoshis to give the remote " + + "side as part of the initial commitment " + + "state, this is equivalent to first opening " + + "a channel and sending the remote party " + + "funds, but done all in one step", + }, + &cli.Int64Flag{ + Name: "sat_per_byte", + Usage: "Deprecated, use sat_per_vbyte instead.", + Hidden: true, + }, + &cli.Uint64Flag{ + Name: "sat_per_vbyte", + Usage: "(optional) a manual fee expressed in " + + "sat/vbyte that should be used when crafting " + + "the transaction", + }, + &cli.BoolFlag{ + Name: "private", + Usage: "make the channel private, such that it won't " + + "be announced to the greater network, and " + + "nodes other than the two channel endpoints " + + "must be explicitly told about it to be able " + + "to route through it", + }, + &cli.Int64Flag{ + Name: "min_htlc_msat", + Usage: "(optional) the minimum value we will require " + + "for incoming HTLCs on the channel", + }, + &cli.Uint64Flag{ + Name: "remote_csv_delay", + Usage: "(optional) the number of blocks we will " + + "require our channel counterparty to wait " + + "before accessing its funds in case of " + + "unilateral close. If this is not set, we " + + "will scale the value according to the " + + "channel size", + }, + &cli.Uint64Flag{ + Name: "max_local_csv", + Usage: "(optional) the maximum number of blocks that " + + "we will allow the remote peer to require we " + + "wait before accessing our funds in the case " + + "of a unilateral close.", + }, + &cli.StringFlag{ + Name: "close_address", + Usage: "(optional) an address to enforce payout of " + + "our funds to on cooperative close. Note " + + "that if this value is set on channel open, " + + "you will *not* be able to cooperatively " + + "close to a different address.", + }, + &cli.Uint64Flag{ + Name: "remote_max_value_in_flight_msat", + Usage: "(optional) the maximum value in msat that " + + "can be pending within the channel at any " + + "given time", + }, + &cli.StringFlag{ + Name: "channel_type", + Usage: fmt.Sprintf("(optional) the type of channel to "+ + "propose to the remote peer (%q, %q, %q)", + channelTypeTweakless, channelTypeAnchors, + channelTypeSimpleTaproot), + }, + &cli.BoolFlag{ + Name: "zero_conf", + Usage: "(optional) whether a zero-conf channel open " + + "should be attempted.", + }, + &cli.BoolFlag{ + Name: "scid_alias", + Usage: "(optional) whether a scid-alias channel type" + + " should be negotiated.", + }, + &cli.Uint64Flag{ + Name: "remote_reserve_sats", + Usage: "(optional) the minimum number of satoshis we " + + "require the remote node to keep as a direct " + + "payment. If not specified, a default of 1% " + + "of the channel capacity will be used.", + }, + &cli.StringFlag{ + Name: "memo", + Usage: `(optional) a note-to-self containing some useful + information about the channel. This is stored + locally only, and is purely for reference. It + has no bearing on the channel's operation. Max + allowed length is 500 characters`, + }, + &cli.BoolFlag{ + Name: "fundmax", + Usage: "if set, the wallet will attempt to commit " + + "the maximum possible local amount to the " + + "channel. This must not be set at the same " + + "time as local_amt", + }, + &cli.StringSliceFlag{ + Name: "utxo", + Usage: "a utxo specified as outpoint(tx:idx) which " + + "will be used to fund a channel. This flag " + + "can be repeatedly used to fund a channel " + + "with a selection of utxos. The selected " + + "funds can either be entirely spent by " + + "specifying the fundmax flag or partially by " + + "selecting a fraction of the sum of the " + + "outpoints in local_amt", + }, + }, + Action: openChannel, +} + +func openChannel(ctx context.Context, cmd *cli.Command) error { + var ( + args = cmd.Args() + remaining []string + err error + ) + + client, cleanup, err := getClient(cmd) + if err != nil { + return err + } + defer cleanup() + + // Show command help if no arguments provided + if cmd.NArg() == 0 && cmd.NumFlags() == 0 { + _ = cli.ShowCommandHelp(ctx, cmd, "openchannel") + return nil + } + + // Check that only the field sat_per_vbyte or the deprecated field + // sat_per_byte is used. + feeRateFlag, err := checkNotBothSet( + cmd, "sat_per_vbyte", "sat_per_byte", + ) + if err != nil { + return err + } + + var feeRateSatPerVbyte uint64 + switch feeRateFlag { + case "sat_per_vbyte": + feeRateSatPerVbyte = cmd.Uint64(feeRateFlag) + + case "sat_per_byte": + feeRateSatPerByte := cmd.Int64(feeRateFlag) + if feeRateSatPerByte < 0 { + return fmt.Errorf("%v must be non-negative", feeRateFlag) + } + feeRateSatPerVbyte = uint64(feeRateSatPerByte) + } + + minConfs := defaultUtxoMinConf + req := &lnrpc.OpenChannelRequest{ + SatPerVbyte: feeRateSatPerVbyte, + FundMax: cmd.Bool("fundmax"), + MinHtlcMsat: cmd.Int64("min_htlc_msat"), + RemoteCsvDelay: uint32(cmd.Uint64("remote_csv_delay")), + MinConfs: int32(minConfs), + SpendUnconfirmed: minConfs == 0, + CloseAddress: cmd.String("close_address"), + RemoteMaxValueInFlightMsat: cmd.Uint64("remote_max_value_in_flight_msat"), + MaxLocalCsv: uint32(cmd.Uint64("max_local_csv")), + ZeroConf: cmd.Bool("zero_conf"), + ScidAlias: cmd.Bool("scid_alias"), + RemoteChanReserveSat: cmd.Uint64("remote_reserve_sats"), + Memo: cmd.String("memo"), + } + + switch { + case cmd.IsSet("node_key"): + nodePubHex, err := hex.DecodeString(cmd.String("node_key")) + if err != nil { + return fmt.Errorf("unable to decode node "+ + "public key: %w", err) + } + req.NodePubkey = nodePubHex + + case args.Present(): + nodePubHex, err := hex.DecodeString(args.First()) + if err != nil { + return fmt.Errorf("unable to decode node "+ + "public key: %w", err) + } + remaining = args.Tail() + req.NodePubkey = nodePubHex + + default: + return fmt.Errorf("node id argument missing") + } + + if cmd.IsSet("utxo") { + utxos := cmd.StringSlice("utxo") + + outpoints, err := lndcommands.UtxosToOutpoints(utxos) + if err != nil { + return fmt.Errorf("unable to decode utxos: %w", err) + } + + req.Outpoints = outpoints + } + + // The fundmax flag is NOT allowed to be combined with local_amt above. + // It is allowed to be combined with push_amt, but only if explicitly + // set. + if cmd.Bool("fundmax") && cmd.IsSet("local_amt") { + return fmt.Errorf("local_amt and fundmax are mutually " + + "exclusive") + } + + switch { + case cmd.IsSet("local_amt"): + req.LocalFundingAmount = int64(cmd.Int("local_amt")) + + case !cmd.Bool("fundmax"): + return fmt.Errorf("either local_amt or fundmax must be " + + "specified") + } + + if cmd.IsSet("push_amt") { + req.PushSat = int64(cmd.Int("push_amt")) + } else if len(remaining) > 0 { + req.PushSat, err = strconv.ParseInt(remaining[0], 10, 64) + if err != nil { + return fmt.Errorf("unable to decode push amt: %w", err) + } + } + + if cmd.IsSet("base_fee_msat") { + req.BaseFee = cmd.Uint64("base_fee_msat") + req.UseBaseFee = true + } + + if cmd.IsSet("fee_rate_ppm") { + req.FeeRate = cmd.Uint64("fee_rate_ppm") + req.UseFeeRate = true + } + + req.Private = cmd.Bool("private") + + // Parse the channel type and map it to its RPC representation. + channelType := cmd.String("channel_type") + switch channelType { + case "": + break + case channelTypeTweakless: + req.CommitmentType = lnrpc.CommitmentType_STATIC_REMOTE_KEY + + case channelTypeAnchors: + req.CommitmentType = lnrpc.CommitmentType_ANCHORS + + case channelTypeSimpleTaproot: + req.CommitmentType = lnrpc.CommitmentType_SIMPLE_TAPROOT + default: + return fmt.Errorf("unsupported channel type %v", channelType) + } + + wrappedReq := &looprpc.StaticOpenChannelRequest{ + OpenChannelRequest: req, + } + + resp, err := client.StaticOpenChannel(ctx, wrappedReq) + if err != nil { + return err + } + + printRespJSON(resp) + + return nil +} + +// checkNotBothSet accepts two flag names, a and b, and checks that only flag a +// or flag b can be set, but not both. It returns the name of the flag or an +// error. +func checkNotBothSet(cmd *cli.Command, a, b string) (string, error) { + if cmd.IsSet(a) && cmd.IsSet(b) { + return "", fmt.Errorf( + "either %s or %s should be set, but not both", a, b, + ) + } + + if cmd.IsSet(a) { + return a, nil + } + + return b, nil +} diff --git a/cmd/loop/staticaddr.go b/cmd/loop/staticaddr.go index 8ce2d538b..0e0ded7fd 100644 --- a/cmd/loop/staticaddr.go +++ b/cmd/loop/staticaddr.go @@ -2,18 +2,15 @@ package main import ( "context" - "encoding/hex" "errors" "fmt" - "strconv" - "strings" - "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/lightninglabs/loop/labels" "github.com/lightninglabs/loop/looprpc" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/loopin" "github.com/lightninglabs/loop/swapserverrpc" + lndcommands "github.com/lightningnetwork/lnd/cmd/commands" "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/routing/route" "github.com/urfave/cli/v3" @@ -36,6 +33,7 @@ var staticAddressCommands = &cli.Command{ withdrawalCommand, summaryCommand, staticAddressLoopInCommand, + openChannelCommand, }, } @@ -194,7 +192,7 @@ func withdraw(ctx context.Context, cmd *cli.Command) error { case isAllSelected: case isUtxoSelected: utxos := cmd.StringSlice("utxo") - outpoints, err = utxosToOutpoints(utxos) + outpoints, err = lndcommands.UtxosToOutpoints(utxos) if err != nil { return err } @@ -239,6 +237,7 @@ var listDepositsCommand = &cli.Command{ "of the following: \n" + "deposited\nwithdrawing\nwithdrawn\n" + "looping_in\nlooped_in\n" + + "opening_channel\nchannel_published\n" + "publish_expired_deposit\n" + "sweep_htlc_timeout\nhtlc_timeout_swept\n" + "wait_for_expiry_sweep\nexpired\nfailed\n.", @@ -278,6 +277,12 @@ func listDeposits(ctx context.Context, cmd *cli.Command) error { case "looped_in": filterState = looprpc.DepositState_LOOPED_IN + case "opening_channel": + filterState = looprpc.DepositState_OPENING_CHANNEL + + case "channel_published": + filterState = looprpc.DepositState_CHANNEL_PUBLISHED + case "publish_expired_deposit": filterState = looprpc.DepositState_PUBLISH_EXPIRED @@ -379,7 +384,7 @@ var summaryCommand = &cli.Command{ Usage: "Display a summary of static address related information.", Description: ` Displays various static address related information about deposits, - withdrawals and swaps. + withdrawals, swaps and channel openings. `, Action: summary, } @@ -407,46 +412,6 @@ func summary(ctx context.Context, cmd *cli.Command) error { return nil } -func utxosToOutpoints(utxos []string) ([]*lnrpc.OutPoint, error) { - outpoints := make([]*lnrpc.OutPoint, 0, len(utxos)) - if len(utxos) == 0 { - return nil, fmt.Errorf("no utxos specified") - } - - for _, utxo := range utxos { - outpoint, err := NewProtoOutPoint(utxo) - if err != nil { - return nil, err - } - outpoints = append(outpoints, outpoint) - } - - return outpoints, nil -} - -// NewProtoOutPoint parses an OutPoint into its corresponding lnrpc.OutPoint -// type. -func NewProtoOutPoint(op string) (*lnrpc.OutPoint, error) { - parts := strings.Split(op, ":") - if len(parts) != 2 { - return nil, errors.New("outpoint should be of the form " + - "txid:index") - } - txid := parts[0] - if hex.DecodedLen(len(txid)) != chainhash.HashSize { - return nil, fmt.Errorf("invalid hex-encoded txid %v", txid) - } - outputIndex, err := strconv.Atoi(parts[1]) - if err != nil { - return nil, fmt.Errorf("invalid output index: %v", err) - } - - return &lnrpc.OutPoint{ - TxidStr: txid, - OutputIndex: uint32(outputIndex), - }, nil -} - var staticAddressLoopInCommand = &cli.Command{ Name: "in", Usage: "Loop in funds from static address deposits.", From c7006500e2a1fbc99839d4a1071e21503713653c Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Mon, 20 Oct 2025 13:08:52 +0200 Subject: [PATCH 16/16] docs: update loop.md --- docs/loop.1 | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/loop.md | 47 +++++++++++++++++++++++++++++++---- 2 files changed, 112 insertions(+), 5 deletions(-) diff --git a/docs/loop.1 b/docs/loop.1 index 20b4f7a58..7df39c987 100644 --- a/docs/loop.1 +++ b/docs/loop.1 @@ -486,6 +486,8 @@ withdrawing withdrawn looping_in looped_in +opening_channel +channel_published publish_expired_deposit sweep_htlc_timeout htlc_timeout_swept @@ -580,3 +582,71 @@ Loop in funds from static address deposits. .PP \fB--verbose, -v\fP: show expanded details +.SS openchannel +.PP +Open a channel to a an existing peer. + +.PP +\fB--base_fee_msat\fP="": the base fee in milli-satoshis that will be charged for each forwarded HTLC, regardless of payment size (default: 0) + +.PP +\fB--channel_type\fP="": (optional) the type of channel to propose to the remote peer ("tweakless", "anchors", "taproot") + +.PP +\fB--close_address\fP="": (optional) an address to enforce payout of our funds to on cooperative close. Note that if this value is set on channel open, you will \fInot\fP be able to cooperatively close to a different address. + +.PP +\fB--fee_rate_ppm\fP="": the fee rate ppm (parts per million) that will be charged proportionally based on the value of each forwarded HTLC, the lowest possible rate is 0 with a granularity of 0.000001 (millionths) (default: 0) + +.PP +\fB--fundmax\fP: if set, the wallet will attempt to commit the maximum possible local amount to the channel. This must not be set at the same time as local_amt + +.PP +\fB--help, -h\fP: show help + +.PP +\fB--local_amt\fP="": the number of satoshis the wallet should commit to the channel (default: 0) + +.PP +\fB--max_local_csv\fP="": (optional) the maximum number of blocks that we will allow the remote peer to require we wait before accessing our funds in the case of a unilateral close. (default: 0) + +.PP +\fB--memo\fP="": (optional) a note-to-self containing some useful + information about the channel. This is stored + locally only, and is purely for reference. It + has no bearing on the channel's operation. Max + allowed length is 500 characters + +.PP +\fB--min_htlc_msat\fP="": (optional) the minimum value we will require for incoming HTLCs on the channel (default: 0) + +.PP +\fB--node_key\fP="": the identity public key of the target node/peer serialized in compressed format + +.PP +\fB--private\fP: make the channel private, such that it won't be announced to the greater network, and nodes other than the two channel endpoints must be explicitly told about it to be able to route through it + +.PP +\fB--push_amt\fP="": the number of satoshis to give the remote side as part of the initial commitment state, this is equivalent to first opening a channel and sending the remote party funds, but done all in one step (default: 0) + +.PP +\fB--remote_csv_delay\fP="": (optional) the number of blocks we will require our channel counterparty to wait before accessing its funds in case of unilateral close. If this is not set, we will scale the value according to the channel size (default: 0) + +.PP +\fB--remote_max_value_in_flight_msat\fP="": (optional) the maximum value in msat that can be pending within the channel at any given time (default: 0) + +.PP +\fB--remote_reserve_sats\fP="": (optional) the minimum number of satoshis we require the remote node to keep as a direct payment. If not specified, a default of 1% of the channel capacity will be used. (default: 0) + +.PP +\fB--sat_per_vbyte\fP="": (optional) a manual fee expressed in sat/vbyte that should be used when crafting the transaction (default: 0) + +.PP +\fB--scid_alias\fP: (optional) whether a scid-alias channel type should be negotiated. + +.PP +\fB--utxo\fP="": a utxo specified as outpoint(tx:idx) which will be used to fund a channel. This flag can be repeatedly used to fund a channel with a selection of utxos. The selected funds can either be entirely spent by specifying the fundmax flag or partially by selecting a fraction of the sum of the outpoints in local_amt (default: []) + +.PP +\fB--zero_conf\fP: (optional) whether a zero-conf channel open should be attempted. + diff --git a/docs/loop.md b/docs/loop.md index 5d15fb179..7fbb76c85 100644 --- a/docs/loop.md +++ b/docs/loop.md @@ -577,10 +577,10 @@ $ loop [GLOBAL FLAGS] static listdeposits [COMMAND FLAGS] [ARGUMENTS...] The following flags are supported: -| Name | Description | Type | Default value | -|-----------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------|:-------------:| -| `--filter="…"` | specify a filter to only display deposits in the specified state. Leaving out the filter returns all deposits. The state can be one of the following: deposited withdrawing withdrawn looping_in looped_in publish_expired_deposit sweep_htlc_timeout htlc_timeout_swept wait_for_expiry_sweep expired failed | string | -| `--help` (`-h`) | show help | bool | `false` | +| Name | Description | Type | Default value | +|-----------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------|:-------------:| +| `--filter="…"` | specify a filter to only display deposits in the specified state. Leaving out the filter returns all deposits. The state can be one of the following: deposited withdrawing withdrawn looping_in looped_in opening_channel channel_published publish_expired_deposit sweep_htlc_timeout htlc_timeout_swept wait_for_expiry_sweep expired failed | string | +| `--help` (`-h`) | show help | bool | `false` | ### `static listwithdrawals` subcommand @@ -641,7 +641,7 @@ The following flags are supported: Display a summary of static address related information. -Displays various static address related information about deposits, withdrawals and swaps. +Displays various static address related information about deposits, withdrawals, swaps and channel openings. Usage: @@ -684,3 +684,40 @@ The following flags are supported: | `--verbose` (`-v`) | show expanded details | bool | `false` | | `--help` (`-h`) | show help | bool | `false` | +### `static openchannel` subcommand + +Open a channel to a an existing peer. + +Attempt to open a new channel to an existing peer with the key node-key. The channel will be initialized with local-amt satoshis locally and push-amt satoshis for the remote node. Note that the push-amt is deducted from the specified local-amt which implies that the local-amt must be greater than the push-amt. Also note that specifying push-amt means you give that amount to the remote node as part of the channel opening. Once the channel is open, a channelPoint (txid:vout) of the funding output is returned. If the remote peer supports the option upfront shutdown feature bit (query listpeers to see their supported feature bits), an address to enforce payout of funds on cooperative close can optionally be provided. Note that if you set this value, you will not be able to cooperatively close out to another address. One can also specify a short string memo to record some useful information about the channel using the --memo argument. This is stored locally only, and is purely for reference. It has no bearing on the channel's operation. Max allowed length is 500 characters. + +Usage: + +```bash +$ loop [GLOBAL FLAGS] static openchannel [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | +|-----------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------|:-------------:| +| `--node_key="…"` | the identity public key of the target node/peer serialized in compressed format | string | +| `--local_amt="…"` | the number of satoshis the wallet should commit to the channel | int | `0` | +| `--base_fee_msat="…"` | the base fee in milli-satoshis that will be charged for each forwarded HTLC, regardless of payment size | uint | `0` | +| `--fee_rate_ppm="…"` | the fee rate ppm (parts per million) that will be charged proportionally based on the value of each forwarded HTLC, the lowest possible rate is 0 with a granularity of 0.000001 (millionths) | uint | `0` | +| `--push_amt="…"` | the number of satoshis to give the remote side as part of the initial commitment state, this is equivalent to first opening a channel and sending the remote party funds, but done all in one step | int | `0` | +| `--sat_per_vbyte="…"` | (optional) a manual fee expressed in sat/vbyte that should be used when crafting the transaction | uint | `0` | +| `--private` | make the channel private, such that it won't be announced to the greater network, and nodes other than the two channel endpoints must be explicitly told about it to be able to route through it | bool | `false` | +| `--min_htlc_msat="…"` | (optional) the minimum value we will require for incoming HTLCs on the channel | int | `0` | +| `--remote_csv_delay="…"` | (optional) the number of blocks we will require our channel counterparty to wait before accessing its funds in case of unilateral close. If this is not set, we will scale the value according to the channel size | uint | `0` | +| `--max_local_csv="…"` | (optional) the maximum number of blocks that we will allow the remote peer to require we wait before accessing our funds in the case of a unilateral close | uint | `0` | +| `--close_address="…"` | (optional) an address to enforce payout of our funds to on cooperative close. Note that if this value is set on channel open, you will *not* be able to cooperatively close to a different address | string | +| `--remote_max_value_in_flight_msat="…"` | (optional) the maximum value in msat that can be pending within the channel at any given time | uint | `0` | +| `--channel_type="…"` | (optional) the type of channel to propose to the remote peer ("tweakless", "anchors", "taproot") | string | +| `--zero_conf` | (optional) whether a zero-conf channel open should be attempted | bool | `false` | +| `--scid_alias` | (optional) whether a scid-alias channel type should be negotiated | bool | `false` | +| `--remote_reserve_sats="…"` | (optional) the minimum number of satoshis we require the remote node to keep as a direct payment. If not specified, a default of 1% of the channel capacity will be used | uint | `0` | +| `--memo="…"` | (optional) a note-to-self containing some useful information about the channel. This is stored locally only, and is purely for reference. It has no bearing on the channel's operation. Max allowed length is 500 characters | string | +| `--fundmax` | if set, the wallet will attempt to commit the maximum possible local amount to the channel. This must not be set at the same time as local_amt | bool | `false` | +| `--utxo="…"` | a utxo specified as outpoint(tx:idx) which will be used to fund a channel. This flag can be repeatedly used to fund a channel with a selection of utxos. The selected funds can either be entirely spent by specifying the fundmax flag or partially by selecting a fraction of the sum of the outpoints in local_amt | string | `[]` | +| `--help` (`-h`) | show help | bool | `false` | +