From 688f25eae7e004bc6e4c93d13f457a32c8e67cf8 Mon Sep 17 00:00:00 2001 From: Andras Banki-Horvath Date: Wed, 26 Aug 2026 11:04:23 +0200 Subject: [PATCH 1/6] build: pin configurable btcwallet output leases Temporarily point btcwallet and wtxmgr at the draft commits that add persisted release-after-spend lease behavior. The replaces can be removed after upstream versions containing those changes are published. --- .golangci.yml | 3 +++ go.mod | 5 +++++ go.sum | 8 ++++---- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index b132d5608c..d85dcda932 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -172,6 +172,9 @@ linters: - google.golang.org/protobuf - github.com/lightningnetwork/lnd/sqldb - github.com/lightningnetwork/lightning-onion + # Temporary pins for configurable persisted output leases. + - github.com/btcsuite/btcwallet + - github.com/btcsuite/btcwallet/wtxmgr replace-local: true gosec: diff --git a/go.mod b/go.mod index 76d940fb2f..4ececcdb2e 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,10 @@ module github.com/lightningnetwork/lnd +// TODO: drop these replaces after the btcwallet and wtxmgr changes are released. +replace github.com/btcsuite/btcwallet => github.com/bhandras/btcwallet v0.11.1-0.20260826103350-2146fd732eb2 + +replace github.com/btcsuite/btcwallet/wtxmgr => github.com/bhandras/btcwallet/wtxmgr v1.0.1-0.20260826100206-9f3be588589b + require ( github.com/NebulousLabs/go-upnp v0.0.0-20180202185039-29b680b06c82 github.com/Yawning/aez v0.0.0-20211027044916-e49e68abd344 diff --git a/go.sum b/go.sum index 18852bf4b3..9dfbcbd758 100644 --- a/go.sum +++ b/go.sum @@ -30,6 +30,10 @@ github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHG github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bhandras/btcwallet v0.11.1-0.20260826103350-2146fd732eb2 h1:+m8gBc/FLsneN8BnrzYmoOJxxuVhuHrXDVK+avHoQzs= +github.com/bhandras/btcwallet v0.11.1-0.20260826103350-2146fd732eb2/go.mod h1:fk+cTe1whukIKyp+dxPV/FFF32Uu+7bV/safqh5ekcc= +github.com/bhandras/btcwallet/wtxmgr v1.0.1-0.20260826100206-9f3be588589b h1:7QxTLjx9bZNTSawL9kDKSna+qqSBjoNrnAttXfJTxf8= +github.com/bhandras/btcwallet/wtxmgr v1.0.1-0.20260826100206-9f3be588589b/go.mod h1:Raor7IBIwHSIKE9Lr5o+R9rwX7sRMHU1zjxgEQgn9h8= github.com/btcsuite/btcd v0.26.0 h1:yntnSshlG3+H7dTwIOR4LTFXDPojVBsFORBNN5y5c/c= github.com/btcsuite/btcd v0.26.0/go.mod h1:7ft7+a/MoJHFouFopCb1zyiR9IWPlrcPVn6K/lJ1dcA= github.com/btcsuite/btcd/address/v2 v2.0.0 h1:UVu8Hal6Siu4XastFe+JX5JkeBYONbDUIY5E+SVTs6I= @@ -57,8 +61,6 @@ github.com/btcsuite/btclog v1.0.0 h1:sEkpKJMmfGiyZjADwEIgB1NSwMyfdD1FB8v6+w1T0Ns github.com/btcsuite/btclog v1.0.0/go.mod h1:w7xnGOhwT3lmrS4H3b/D1XAXxvh+tbhUm8xeHN2y3TQ= github.com/btcsuite/btclog/v2 v2.0.1-0.20250728225537-6090e87c6c5b h1:MQ+Q6sDy37V1wP1Yu79A5KqJutolqUGwA99UZWQDWZM= github.com/btcsuite/btclog/v2 v2.0.1-0.20250728225537-6090e87c6c5b/go.mod h1:XItGUfVOxotJL8kkuk2Hj3EVow5KCugXl3wWfQ6K0AE= -github.com/btcsuite/btcwallet v0.18.0 h1:VSRClNLT7NX0wmJEGALz3jOZRRjWPpUdp7VI1Akie1o= -github.com/btcsuite/btcwallet v0.18.0/go.mod h1:1ZMc1EEskov+AKKv4kCMZqN8BwVh9rpXwEyxbeWy2A4= github.com/btcsuite/btcwallet/wallet/txauthor v1.4.0 h1:oIkGj32YK1CvWaJGlVwZA1f+y/KVHkfrd2PoST0ZpQs= github.com/btcsuite/btcwallet/wallet/txauthor v1.4.0/go.mod h1:sGrBjcqQ8UPexuRajFs72+o544CJn3Pavv/5H0VAWVk= github.com/btcsuite/btcwallet/wallet/txrules v1.3.0 h1:D5aGMwWIxdqek3xEJs4eOdMoh6iga2EI2xSlaXCdnNo= @@ -67,8 +69,6 @@ github.com/btcsuite/btcwallet/wallet/txsizes v1.3.0 h1:2W9qt0edMoX8crx0Wm4Cv+eAj github.com/btcsuite/btcwallet/wallet/txsizes v1.3.0/go.mod h1:42aE6+LMZSSEisQAa15Xml25ncuJFfhCrkcpB5OmkZk= github.com/btcsuite/btcwallet/walletdb v1.6.0 h1:Yund5XbdqFxNW7+R2Sxs02bMC5fMrmORj4GN8MV55no= github.com/btcsuite/btcwallet/walletdb v1.6.0/go.mod h1:q9xif0Csp52GVb3l252BbHCuyiCnuEbrPWu/HAsvaYc= -github.com/btcsuite/btcwallet/wtxmgr v1.6.0 h1:ivSSnYCD4Kb5yAMZVyBA1VMYABIFcopPEcmHCrRZXcE= -github.com/btcsuite/btcwallet/wtxmgr v1.6.0/go.mod h1:Raor7IBIwHSIKE9Lr5o+R9rwX7sRMHU1zjxgEQgn9h8= github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd h1:R/opQEbFEy9JGkIguV40SvRY1uliPX8ifOvi6ICsFCw= github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= github.com/btcsuite/golangcrypto v0.0.0-20150304025918-53f62d9b43e8/go.mod h1:tYvUd8KLhm/oXvUeSEs2VlLghFjQt9+ZaF9ghH0JNjc= From 82b51f56f1574472a1158cf450d6f8144523e1b3 Mon Sep 17 00:00:00 2001 From: Andras Banki-Horvath Date: Wed, 26 Aug 2026 11:04:37 +0200 Subject: [PATCH 2/6] lnwallet: add configurable output lease capability Define an optional output-leaser interface for persisted lease behavior beyond the existing WalletController contract. Forward it through LightningWallet and translate release-after-spend confirmation depth into btcwallet's lock option. Callers that use the existing LeaseOutput method keep the current behavior. --- lnwallet/btcwallet/btcwallet.go | 25 ++++++++++++++++++++++++- lnwallet/interface.go | 17 +++++++++++++++++ lnwallet/wallet.go | 19 +++++++++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/lnwallet/btcwallet/btcwallet.go b/lnwallet/btcwallet/btcwallet.go index f2ae04ee74..cd97fb64c7 100644 --- a/lnwallet/btcwallet/btcwallet.go +++ b/lnwallet/btcwallet/btcwallet.go @@ -1144,13 +1144,36 @@ func (b *BtcWallet) CreateSimpleTx(inputs fn.Set[wire.OutPoint], func (b *BtcWallet) LeaseOutput(id wtxmgr.LockID, op wire.OutPoint, duration time.Duration) (time.Time, error) { + return b.LeaseOutputWithOptions( + id, op, duration, lnwallet.LeaseOutputOptions{}, + ) +} + +// LeaseOutputWithOptions locks an output and applies optional persisted lease +// behavior supported by btcwallet. +func (b *BtcWallet) LeaseOutputWithOptions(id wtxmgr.LockID, + op wire.OutPoint, duration time.Duration, + opts lnwallet.LeaseOutputOptions) (time.Time, error) { + // Make sure we don't attempt to double lock an output that's been // locked by the in-memory implementation. if b.wallet.LockedOutpoint(op) { return time.Time{}, wtxmgr.ErrOutputAlreadyLocked } - lockedUntil, err := b.wallet.LeaseOutput(id, op, duration) + var lockOpts []wtxmgr.LockOutputOption + if opts.ReleaseAfterSpendConfs > 0 { + lockOpts = append( + lockOpts, + wtxmgr.WithReleaseAfterSpend( + opts.ReleaseAfterSpendConfs, + ), + ) + } + + lockedUntil, err := b.wallet.LeaseOutputWithOptions( + id, op, duration, lockOpts..., + ) if err != nil { return time.Time{}, err } diff --git a/lnwallet/interface.go b/lnwallet/interface.go index 94d4f8c72c..03a808a744 100644 --- a/lnwallet/interface.go +++ b/lnwallet/interface.go @@ -221,6 +221,23 @@ type TransactionSubscription interface { Cancel() } +// LeaseOutputOptions controls optional output lease behavior. +type LeaseOutputOptions struct { + // ReleaseAfterSpendConfs keeps the persisted lease until the + // transaction spending the output reaches this confirmation count. + // Reorganizations reset maturity progress when they disconnect the + // spending block. + ReleaseAfterSpendConfs uint32 +} + +// OutputLeaserWithOptions is an optional wallet capability for callers that +// require output lease behavior beyond the default WalletController contract. +type OutputLeaserWithOptions interface { + LeaseOutputWithOptions(id wtxmgr.LockID, op wire.OutPoint, + duration time.Duration, opts LeaseOutputOptions) ( + time.Time, error) +} + // WalletController defines an abstract interface for controlling a local Pure // Go wallet, a local or remote wallet via an RPC mechanism, or possibly even // a daemon assisted hardware wallet. This interface serves the purpose of diff --git a/lnwallet/wallet.go b/lnwallet/wallet.go index c4c1be7072..fd54017327 100644 --- a/lnwallet/wallet.go +++ b/lnwallet/wallet.go @@ -9,6 +9,7 @@ import ( "net" "sync" "sync/atomic" + "time" "github.com/btcsuite/btcd/address/v2" "github.com/btcsuite/btcd/blockchain" @@ -22,6 +23,7 @@ import ( "github.com/btcsuite/btcd/txscript/v2" "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btcwallet/wallet" + "github.com/btcsuite/btcwallet/wtxmgr" "github.com/lightningnetwork/lnd/channeldb" "github.com/lightningnetwork/lnd/chanstate" "github.com/lightningnetwork/lnd/fn/v2" @@ -634,6 +636,23 @@ func (l *LightningWallet) LockedOutpoints() []*wire.OutPoint { return outPoints } +// LeaseOutputWithOptions forwards optional lease semantics through the +// LightningWallet wrapper to the concrete wallet controller. +func (l *LightningWallet) LeaseOutputWithOptions(id wtxmgr.LockID, + op wire.OutPoint, duration time.Duration, + opts LeaseOutputOptions) (time.Time, error) { + + leaser, ok := l.WalletController.(OutputLeaserWithOptions) + if !ok { + return time.Time{}, fmt.Errorf( + "wallet controller does not support " + + "output lease options", + ) + } + + return leaser.LeaseOutputWithOptions(id, op, duration, opts) +} + // ResetReservations reset the volatile wallet state which tracks all currently // active reservations. func (l *LightningWallet) ResetReservations() { From cc79d4eecd2c18af43a6ea935aa22e7cef8a8444 Mon Sep 17 00:00:00 2001 From: Andras Banki-Horvath Date: Wed, 26 Aug 2026 11:04:55 +0200 Subject: [PATCH 3/6] walletrpc: expose release-after-spend output leases Add optional confirmation-depth fields to LeaseOutput and FundPsbt. Forward the request to wallets that implement the configurable lease capability and echo the accepted depth in lease responses. Zero keeps the existing wall-clock behavior. A non-zero request fails closed when the configured wallet cannot provide the requested lease semantics. --- lnrpc/walletrpc/psbt.go | 31 ++++++++-- lnrpc/walletrpc/psbt_test.go | 73 ++++++++++++++++++++++ lnrpc/walletrpc/walletkit.pb.go | 77 ++++++++++++++++++------ lnrpc/walletrpc/walletkit.proto | 15 +++++ lnrpc/walletrpc/walletkit.swagger.json | 20 ++++++ lnrpc/walletrpc/walletkit_server.go | 62 ++++++++++++++----- lnrpc/walletrpc/walletkit_server_test.go | 2 +- 7 files changed, 242 insertions(+), 38 deletions(-) create mode 100644 lnrpc/walletrpc/psbt_test.go diff --git a/lnrpc/walletrpc/psbt.go b/lnrpc/walletrpc/psbt.go index 5a652aa4f6..3a2f6ee3e6 100644 --- a/lnrpc/walletrpc/psbt.go +++ b/lnrpc/walletrpc/psbt.go @@ -45,7 +45,8 @@ func verifyInputsUnspent(inputs []*wire.TxIn, utxos []*lnwallet.Utxo) error { // (the passed outpoints), using either the optional custom lock ID and duration // or the wallet's internal static lock ID with the default 10-minute duration. func lockInputs(w lnwallet.WalletController, outpoints []wire.OutPoint, - customLockID *wtxmgr.LockID, customLockDuration time.Duration) ( + customLockID *wtxmgr.LockID, customLockDuration time.Duration, + releaseAfterSpendConfs uint32) ( []*base.ListLeasedOutputResult, error) { locks := make( @@ -74,9 +75,29 @@ func lockInputs(w lnwallet.WalletController, outpoints []wire.OutPoint, return nil, fmt.Errorf("fetch outpoint info: %w", err) } - expiration, err := w.LeaseOutput( - lock.LockID, lock.Outpoint, lockDuration, - ) + var expiration time.Time + if releaseAfterSpendConfs > 0 { + leaser, ok := w.(lnwallet.OutputLeaserWithOptions) + if !ok { + return nil, fmt.Errorf( + "wallet does not support " + + "release-after-spend " + + "output leases", + ) + } + + leaseOpts := lnwallet.LeaseOutputOptions{ + ReleaseAfterSpendConfs: releaseAfterSpendConfs, + } + expiration, err = leaser.LeaseOutputWithOptions( + lock.LockID, lock.Outpoint, lockDuration, + leaseOpts, + ) + } else { + expiration, err = w.LeaseOutput( + lock.LockID, lock.Outpoint, lockDuration, + ) + } if err != nil { // If we run into a problem with locking one output, we // should try to unlock those that we successfully @@ -85,7 +106,7 @@ func lockInputs(w lnwallet.WalletController, outpoints []wire.OutPoint, for i := 0; i < idx; i++ { op := locks[i].Outpoint if err := w.ReleaseOutput( - chanfunding.LndInternalLockID, op, + locks[i].LockID, op, ); err != nil { log.Errorf("could not release the "+ "lock on %v: %v", op, err) diff --git a/lnrpc/walletrpc/psbt_test.go b/lnrpc/walletrpc/psbt_test.go new file mode 100644 index 0000000000..845e047d8d --- /dev/null +++ b/lnrpc/walletrpc/psbt_test.go @@ -0,0 +1,73 @@ +//go:build walletrpc +// +build walletrpc + +package walletrpc + +import ( + "errors" + "testing" + "time" + + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btcwallet/wtxmgr" + "github.com/lightningnetwork/lnd/lntest/mock" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/stretchr/testify/require" +) + +// leaseOptionsWallet records the optional lease settings passed by lockInputs. +type leaseOptionsWallet struct { + *mock.WalletController + + leaseCalls []lnwallet.LeaseOutputOptions + releasedIDs []wtxmgr.LockID + failCall int +} + +// LeaseOutputWithOptions records the requested behavior and optionally fails +// one call so the partial-lock rollback path can be asserted. +func (w *leaseOptionsWallet) LeaseOutputWithOptions(_ wtxmgr.LockID, + _ wire.OutPoint, _ time.Duration, + opts lnwallet.LeaseOutputOptions) (time.Time, error) { + + w.leaseCalls = append(w.leaseCalls, opts) + if w.failCall > 0 && len(w.leaseCalls) == w.failCall { + return time.Time{}, errors.New("lease failed") + } + + return time.Unix(123, 0), nil +} + +// ReleaseOutput records the lock ID used to roll back an acquired lease. +func (w *leaseOptionsWallet) ReleaseOutput(id wtxmgr.LockID, + _ wire.OutPoint) error { + + w.releasedIDs = append(w.releasedIDs, id) + + return nil +} + +// TestLockInputsForwardsReleaseAfterSpend verifies that FundPsbt's lease helper +// passes the requested confirmation depth to every selected input. +func TestLockInputsForwardsReleaseAfterSpend(t *testing.T) { + t.Parallel() + + wallet := &leaseOptionsWallet{ + WalletController: &mock.WalletController{}, + } + lockID := wtxmgr.LockID{1, 2, 3} + outpoints := []wire.OutPoint{ + {Index: 1}, + {Index: 2}, + } + + locks, err := lockInputs( + wallet, outpoints, &lockID, time.Hour, 6, + ) + require.NoError(t, err) + require.Len(t, locks, 2) + require.Len(t, wallet.leaseCalls, 2) + for _, opts := range wallet.leaseCalls { + require.Equal(t, uint32(6), opts.ReleaseAfterSpendConfs) + } +} diff --git a/lnrpc/walletrpc/walletkit.pb.go b/lnrpc/walletrpc/walletkit.pb.go index 9589532a85..a09e7de79e 100644 --- a/lnrpc/walletrpc/walletkit.pb.go +++ b/lnrpc/walletrpc/walletkit.pb.go @@ -557,8 +557,12 @@ type LeaseOutputRequest struct { // The time in seconds before the lock expires. If set to zero, the default // lock duration is used. ExpirationSeconds uint64 `protobuf:"varint,3,opt,name=expiration_seconds,json=expirationSeconds,proto3" json:"expiration_seconds,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Keep the lease until the transaction spending the output reaches this + // confirmation count. A reorganization that disconnects the spending block + // resets maturity progress. Zero preserves the default lease behavior. + ReleaseAfterSpendConfs uint32 `protobuf:"varint,4,opt,name=release_after_spend_confs,json=releaseAfterSpendConfs,proto3" json:"release_after_spend_confs,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *LeaseOutputRequest) Reset() { @@ -612,12 +616,21 @@ func (x *LeaseOutputRequest) GetExpirationSeconds() uint64 { return 0 } +func (x *LeaseOutputRequest) GetReleaseAfterSpendConfs() uint32 { + if x != nil { + return x.ReleaseAfterSpendConfs + } + return 0 +} + type LeaseOutputResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // The absolute expiration of the output lease represented as a unix timestamp. - Expiration uint64 `protobuf:"varint,1,opt,name=expiration,proto3" json:"expiration,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Expiration uint64 `protobuf:"varint,1,opt,name=expiration,proto3" json:"expiration,omitempty"` + // The spend maturity confirmation count accepted for this lease. + ReleaseAfterSpendConfs uint32 `protobuf:"varint,2,opt,name=release_after_spend_confs,json=releaseAfterSpendConfs,proto3" json:"release_after_spend_confs,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *LeaseOutputResponse) Reset() { @@ -657,6 +670,13 @@ func (x *LeaseOutputResponse) GetExpiration() uint64 { return 0 } +func (x *LeaseOutputResponse) GetReleaseAfterSpendConfs() uint32 { + if x != nil { + return x.ReleaseAfterSpendConfs + } + return 0 +} + type ReleaseOutputRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The unique ID that was used to lock the output. @@ -3948,8 +3968,11 @@ type FundPsbtRequest struct { // specified duration. The lock duration is specified in seconds. If not // set, the default lock duration will be used. LockExpirationSeconds uint64 `protobuf:"varint,14,opt,name=lock_expiration_seconds,json=lockExpirationSeconds,proto3" json:"lock_expiration_seconds,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Keep each acquired input lease until its spending transaction reaches + // this confirmation count. A reorganization resets maturity progress. + InputReleaseAfterSpendConfs uint32 `protobuf:"varint,15,opt,name=input_release_after_spend_confs,json=inputReleaseAfterSpendConfs,proto3" json:"input_release_after_spend_confs,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *FundPsbtRequest) Reset() { @@ -4106,6 +4129,13 @@ func (x *FundPsbtRequest) GetLockExpirationSeconds() uint64 { return 0 } +func (x *FundPsbtRequest) GetInputReleaseAfterSpendConfs() uint32 { + if x != nil { + return x.InputReleaseAfterSpendConfs + } + return 0 +} + type isFundPsbtRequest_Template interface { isFundPsbtRequest_Template() } @@ -4422,9 +4452,11 @@ type UtxoLease struct { // The public key script of the leased output. PkScript []byte `protobuf:"bytes,4,opt,name=pk_script,json=pkScript,proto3" json:"pk_script,omitempty"` // The value of the leased output in satoshis. - Value uint64 `protobuf:"varint,5,opt,name=value,proto3" json:"value,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Value uint64 `protobuf:"varint,5,opt,name=value,proto3" json:"value,omitempty"` + // The spend maturity confirmation count applied to this lease. + ReleaseAfterSpendConfs uint32 `protobuf:"varint,6,opt,name=release_after_spend_confs,json=releaseAfterSpendConfs,proto3" json:"release_after_spend_confs,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *UtxoLease) Reset() { @@ -4492,6 +4524,13 @@ func (x *UtxoLease) GetValue() uint64 { return 0 } +func (x *UtxoLease) GetReleaseAfterSpendConfs() uint32 { + if x != nil { + return x.ReleaseAfterSpendConfs + } + return 0 +} + type SignPsbtRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The PSBT that should be signed. The PSBT must contain all required inputs, @@ -4842,15 +4881,17 @@ const file_walletrpc_walletkit_proto_rawDesc = "" + "\aaccount\x18\x03 \x01(\tR\aaccount\x12)\n" + "\x10unconfirmed_only\x18\x04 \x01(\bR\x0funconfirmedOnly\"8\n" + "\x13ListUnspentResponse\x12!\n" + - "\x05utxos\x18\x01 \x03(\v2\v.lnrpc.UtxoR\x05utxos\"\x80\x01\n" + + "\x05utxos\x18\x01 \x03(\v2\v.lnrpc.UtxoR\x05utxos\"\xbb\x01\n" + "\x12LeaseOutputRequest\x12\x0e\n" + "\x02id\x18\x01 \x01(\fR\x02id\x12+\n" + "\boutpoint\x18\x02 \x01(\v2\x0f.lnrpc.OutPointR\boutpoint\x12-\n" + - "\x12expiration_seconds\x18\x03 \x01(\x04R\x11expirationSeconds\"5\n" + + "\x12expiration_seconds\x18\x03 \x01(\x04R\x11expirationSeconds\x129\n" + + "\x19release_after_spend_confs\x18\x04 \x01(\rR\x16releaseAfterSpendConfs\"p\n" + "\x13LeaseOutputResponse\x12\x1e\n" + "\n" + "expiration\x18\x01 \x01(\x04R\n" + - "expiration\"S\n" + + "expiration\x129\n" + + "\x19release_after_spend_confs\x18\x02 \x01(\rR\x16releaseAfterSpendConfs\"S\n" + "\x14ReleaseOutputRequest\x12\x0e\n" + "\x02id\x18\x01 \x01(\fR\x02id\x12+\n" + "\boutpoint\x18\x02 \x01(\v2\x0f.lnrpc.OutPointR\boutpoint\"/\n" + @@ -5059,7 +5100,7 @@ const file_walletrpc_walletkit_proto_rawDesc = "" + "\x05label\x18\x02 \x01(\tR\x05label\x12\x1c\n" + "\toverwrite\x18\x03 \x01(\bR\toverwrite\"2\n" + "\x18LabelTransactionResponse\x12\x16\n" + - "\x06status\x18\x01 \x01(\tR\x06status\"\x88\x05\n" + + "\x06status\x18\x01 \x01(\tR\x06status\"\xce\x05\n" + "\x0fFundPsbtRequest\x12\x14\n" + "\x04psbt\x18\x01 \x01(\fH\x00R\x04psbt\x12)\n" + "\x03raw\x18\x02 \x01(\v2\x15.walletrpc.TxTemplateH\x00R\x03raw\x12<\n" + @@ -5079,7 +5120,8 @@ const file_walletrpc_walletkit_proto_rawDesc = "" + " \x01(\x0e2\x1c.lnrpc.CoinSelectionStrategyR\x15coinSelectionStrategy\x12\"\n" + "\rmax_fee_ratio\x18\f \x01(\x01R\vmaxFeeRatio\x12$\n" + "\x0ecustom_lock_id\x18\r \x01(\fR\fcustomLockId\x126\n" + - "\x17lock_expiration_seconds\x18\x0e \x01(\x04R\x15lockExpirationSecondsB\n" + + "\x17lock_expiration_seconds\x18\x0e \x01(\x04R\x15lockExpirationSeconds\x12D\n" + + "\x1finput_release_after_spend_confs\x18\x0f \x01(\rR\x1binputReleaseAfterSpendConfsB\n" + "\n" + "\btemplateB\x06\n" + "\x04fees\"\x9c\x01\n" + @@ -5099,7 +5141,7 @@ const file_walletrpc_walletkit_proto_rawDesc = "" + "\x04psbt\x18\x01 \x01(\fR\x04psbt\x124\n" + "\x15existing_output_index\x18\x02 \x01(\x05H\x00R\x13existingOutputIndex\x12\x12\n" + "\x03add\x18\x03 \x01(\bH\x00R\x03addB\x0f\n" + - "\rchange_output\"\x9b\x01\n" + + "\rchange_output\"\xd6\x01\n" + "\tUtxoLease\x12\x0e\n" + "\x02id\x18\x01 \x01(\fR\x02id\x12+\n" + "\boutpoint\x18\x02 \x01(\v2\x0f.lnrpc.OutPointR\boutpoint\x12\x1e\n" + @@ -5107,7 +5149,8 @@ const file_walletrpc_walletkit_proto_rawDesc = "" + "expiration\x18\x03 \x01(\x04R\n" + "expiration\x12\x1b\n" + "\tpk_script\x18\x04 \x01(\fR\bpkScript\x12\x14\n" + - "\x05value\x18\x05 \x01(\x04R\x05value\"2\n" + + "\x05value\x18\x05 \x01(\x04R\x05value\x129\n" + + "\x19release_after_spend_confs\x18\x06 \x01(\rR\x16releaseAfterSpendConfs\"2\n" + "\x0fSignPsbtRequest\x12\x1f\n" + "\vfunded_psbt\x18\x01 \x01(\fR\n" + "fundedPsbt\"X\n" + diff --git a/lnrpc/walletrpc/walletkit.proto b/lnrpc/walletrpc/walletkit.proto index e46c0678a0..dca3fa258a 100644 --- a/lnrpc/walletrpc/walletkit.proto +++ b/lnrpc/walletrpc/walletkit.proto @@ -467,6 +467,11 @@ message LeaseOutputRequest { // The time in seconds before the lock expires. If set to zero, the default // lock duration is used. uint64 expiration_seconds = 3; + + // Keep the lease until the transaction spending the output reaches this + // confirmation count. A reorganization that disconnects the spending block + // resets maturity progress. Zero preserves the default lease behavior. + uint32 release_after_spend_confs = 4; } message LeaseOutputResponse { @@ -474,6 +479,9 @@ message LeaseOutputResponse { The absolute expiration of the output lease represented as a unix timestamp. */ uint64 expiration = 1; + + // The spend maturity confirmation count accepted for this lease. + uint32 release_after_spend_confs = 2; } message ReleaseOutputRequest { @@ -1649,6 +1657,10 @@ message FundPsbtRequest { // specified duration. The lock duration is specified in seconds. If not // set, the default lock duration will be used. uint64 lock_expiration_seconds = 14; + + // Keep each acquired input lease until its spending transaction reaches + // this confirmation count. A reorganization resets maturity progress. + uint32 input_release_after_spend_confs = 15; } message FundPsbtResponse { /* @@ -1742,6 +1754,9 @@ message UtxoLease { The value of the leased output in satoshis. */ uint64 value = 5; + + // The spend maturity confirmation count applied to this lease. + uint32 release_after_spend_confs = 6; } message SignPsbtRequest { diff --git a/lnrpc/walletrpc/walletkit.swagger.json b/lnrpc/walletrpc/walletkit.swagger.json index dbc17b6a5c..873b90e9ea 100644 --- a/lnrpc/walletrpc/walletkit.swagger.json +++ b/lnrpc/walletrpc/walletkit.swagger.json @@ -1693,6 +1693,11 @@ "type": "string", "format": "uint64", "description": "If set, then the inputs in the funded PSBT will be locked for the\nspecified duration. The lock duration is specified in seconds. If not\nset, the default lock duration will be used." + }, + "input_release_after_spend_confs": { + "type": "integer", + "format": "int64", + "description": "Keep each acquired input lease until its spending transaction reaches\nthis confirmation count. A reorganization resets maturity progress." } } }, @@ -1894,6 +1899,11 @@ "type": "string", "format": "uint64", "description": "The time in seconds before the lock expires. If set to zero, the default\nlock duration is used." + }, + "release_after_spend_confs": { + "type": "integer", + "format": "int64", + "description": "Keep the lease until the transaction spending the output reaches this\nconfirmation count. A reorganization that disconnects the spending block\nresets maturity progress. Zero preserves the default lease behavior." } } }, @@ -1904,6 +1914,11 @@ "type": "string", "format": "uint64", "description": "The absolute expiration of the output lease represented as a unix timestamp." + }, + "release_after_spend_confs": { + "type": "integer", + "format": "int64", + "description": "The spend maturity confirmation count accepted for this lease." } } }, @@ -2408,6 +2423,11 @@ "type": "string", "format": "uint64", "description": "The value of the leased output in satoshis." + }, + "release_after_spend_confs": { + "type": "integer", + "format": "int64", + "description": "The spend maturity confirmation count applied to this lease." } } }, diff --git a/lnrpc/walletrpc/walletkit_server.go b/lnrpc/walletrpc/walletkit_server.go index 9765c8db22..d12c569c1e 100644 --- a/lnrpc/walletrpc/walletkit_server.go +++ b/lnrpc/walletrpc/walletkit_server.go @@ -510,14 +510,32 @@ func (w *WalletKit) LeaseOutput(ctx context.Context, if req.ExpirationSeconds != 0 { duration = time.Duration(req.ExpirationSeconds) * time.Second } + releaseAfterSpendConfs := req.ReleaseAfterSpendConfs // Acquire the global coin selection lock to ensure there aren't any // other concurrent processes attempting to lease the same UTXO. var expiration time.Time err = w.cfg.CoinSelectionLocker.WithCoinSelectLock(func() error { - expiration, err = w.cfg.Wallet.LeaseOutput( - lockID, *op, duration, - ) + if releaseAfterSpendConfs > 0 { + wallet := w.cfg.Wallet + leaser, ok := wallet.(lnwallet.OutputLeaserWithOptions) + if !ok { + return fmt.Errorf("wallet does not support " + + "release-after-spend output leases") + } + + leaseOpts := lnwallet.LeaseOutputOptions{ + ReleaseAfterSpendConfs: releaseAfterSpendConfs, + } + expiration, err = leaser.LeaseOutputWithOptions( + lockID, *op, duration, leaseOpts, + ) + } else { + expiration, err = w.cfg.Wallet.LeaseOutput( + lockID, *op, duration, + ) + } + return err }) if err != nil { @@ -525,7 +543,8 @@ func (w *WalletKit) LeaseOutput(ctx context.Context, } return &LeaseOutputResponse{ - Expiration: uint64(expiration.Unix()), + Expiration: uint64(expiration.Unix()), + ReleaseAfterSpendConfs: releaseAfterSpendConfs, }, nil } @@ -1736,6 +1755,7 @@ func (w *WalletKit) FundPsbt(_ context.Context, account, keyScopeFromChangeAddressType(req.ChangeType), packet, minConfs, feeSatPerKW, coinSelectionStrategy, customLockID, customLockDuration, + req.InputReleaseAfterSpendConfs, ) // The template is specified as a PSBT with the intention to perform @@ -1819,6 +1839,7 @@ func (w *WalletKit) FundPsbt(_ context.Context, account, changeIndex, packet, minConfs, changeType, feeSatPerKW, coinSelectionStrategy, maxFeeRatio, customLockID, customLockDuration, + req.InputReleaseAfterSpendConfs, ) // The template is specified as a RPC message. We need to create a new @@ -1877,6 +1898,7 @@ func (w *WalletKit) FundPsbt(_ context.Context, account, keyScopeFromChangeAddressType(req.ChangeType), packet, minConfs, feeSatPerKW, coinSelectionStrategy, customLockID, customLockDuration, + req.InputReleaseAfterSpendConfs, ) default: @@ -1890,7 +1912,8 @@ func (w *WalletKit) FundPsbt(_ context.Context, func (w *WalletKit) fundPsbtInternalWallet(account string, keyScope *waddrmgr.KeyScope, packet *psbt.Packet, minConfs int32, feeSatPerKW chainfee.SatPerKWeight, strategy base.CoinSelectionStrategy, - customLockID *wtxmgr.LockID, customLockDuration time.Duration) ( + customLockID *wtxmgr.LockID, customLockDuration time.Duration, + releaseAfterSpendConfs uint32) ( *FundPsbtResponse, error) { // The RPC parsing part is now over. Several of the following operations @@ -2006,7 +2029,7 @@ func (w *WalletKit) fundPsbtInternalWallet(account string, response, err = w.lockAndCreateFundingResponse( packet, outpoints, changeIndex, customLockID, - customLockDuration, + customLockDuration, releaseAfterSpendConfs, ) return err @@ -2026,7 +2049,8 @@ func (w *WalletKit) fundPsbtCoinSelect(account string, changeIndex int32, changeType chanfunding.ChangeAddressType, feeRate chainfee.SatPerKWeight, strategy base.CoinSelectionStrategy, maxFeeRatio float64, customLockID *wtxmgr.LockID, - customLockDuration time.Duration) (*FundPsbtResponse, error) { + customLockDuration time.Duration, releaseAfterSpendConfs uint32) ( + *FundPsbtResponse, error) { // We want to make sure we don't select any inputs that are already // specified in the template. To do that, we require those inputs to @@ -2143,7 +2167,7 @@ func (w *WalletKit) fundPsbtCoinSelect(account string, changeIndex int32, // We're done. Let's serialize and return the updated package. return w.lockAndCreateFundingResponse( packet, nil, changeIndex, customLockID, - customLockDuration, + customLockDuration, releaseAfterSpendConfs, ) } @@ -2217,7 +2241,7 @@ func (w *WalletKit) fundPsbtCoinSelect(account string, changeIndex int32, response, err = w.lockAndCreateFundingResponse( packet, addedOutpoints, changeIndex, customLockID, - customLockDuration, + customLockDuration, releaseAfterSpendConfs, ) return err @@ -2263,7 +2287,8 @@ func (w *WalletKit) assertNotAvailable(inputs []*wire.TxIn, minConfs int32, // response with the serialized PSBT, the change index and the locked UTXOs. func (w *WalletKit) lockAndCreateFundingResponse(packet *psbt.Packet, newOutpoints []wire.OutPoint, changeIndex int32, - customLockID *wtxmgr.LockID, customLockDuration time.Duration) ( + customLockID *wtxmgr.LockID, customLockDuration time.Duration, + releaseAfterSpendConfs uint32) ( *FundPsbtResponse, error) { // Make sure we can properly serialize the packet. If this goes wrong @@ -2277,6 +2302,7 @@ func (w *WalletKit) lockAndCreateFundingResponse(packet *psbt.Packet, locks, err := lockInputs( w.cfg.Wallet, newOutpoints, customLockID, customLockDuration, + releaseAfterSpendConfs, ) if err != nil { return nil, fmt.Errorf("could not lock inputs: %w", err) @@ -2284,6 +2310,9 @@ func (w *WalletKit) lockAndCreateFundingResponse(packet *psbt.Packet, // Convert the lock leases to the RPC format. rpcLocks := marshallLeases(locks) + for _, lock := range rpcLocks { + lock.ReleaseAfterSpendConfs = releaseAfterSpendConfs + } return &FundPsbtResponse{ FundedPsbt: buf.Bytes(), @@ -2367,11 +2396,14 @@ func marshallLeases(locks []*base.ListLeasedOutputResult) []*UtxoLease { for idx, lock := range locks { rpcLocks[idx] = &UtxoLease{ - Id: lock.LockID[:], - Outpoint: lnrpc.MarshalOutPoint(&lock.Outpoint), - Expiration: uint64(lock.Expiration.Unix()), - PkScript: lock.PkScript, - Value: uint64(lock.Value), + Id: lock.LockID[:], + Outpoint: lnrpc.MarshalOutPoint( + &lock.Outpoint, + ), + Expiration: uint64(lock.Expiration.Unix()), + PkScript: lock.PkScript, + Value: uint64(lock.Value), + ReleaseAfterSpendConfs: lock.ReleaseAfterSpendConfs, } } diff --git a/lnrpc/walletrpc/walletkit_server_test.go b/lnrpc/walletrpc/walletkit_server_test.go index 803c4d1ba7..7330d790d4 100644 --- a/lnrpc/walletrpc/walletkit_server_test.go +++ b/lnrpc/walletrpc/walletkit_server_test.go @@ -657,7 +657,7 @@ func TestFundPsbtCoinSelect(t *testing.T) { "", tc.changeIndex, copiedPacket, 0, tc.changeType, tc.feeRate, rpcServer.cfg.CoinSelectionStrategy, - tc.maxFeeRatio, nil, 0, + tc.maxFeeRatio, nil, 0, 0, ) switch { From 6f2e34642bcfed378f2fa215857b992fb346cf06 Mon Sep 17 00:00:00 2001 From: Andras Banki-Horvath Date: Wed, 26 Aug 2026 11:05:28 +0200 Subject: [PATCH 4/6] walletrpc: release partial locks with their actual ID When locking several inputs fails partway through, release each earlier lease with the ID that acquired it. The previous rollback always used LND's internal ID, which cannot release leases acquired with a caller-provided custom ID. --- lnrpc/walletrpc/psbt_test.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/lnrpc/walletrpc/psbt_test.go b/lnrpc/walletrpc/psbt_test.go index 845e047d8d..3746ed15d9 100644 --- a/lnrpc/walletrpc/psbt_test.go +++ b/lnrpc/walletrpc/psbt_test.go @@ -71,3 +71,23 @@ func TestLockInputsForwardsReleaseAfterSpend(t *testing.T) { require.Equal(t, uint32(6), opts.ReleaseAfterSpendConfs) } } + +// TestLockInputsRollbackUsesActualLockID verifies that a later lease failure +// releases earlier inputs with the ID that acquired them. +func TestLockInputsRollbackUsesActualLockID(t *testing.T) { + t.Parallel() + + wallet := &leaseOptionsWallet{ + WalletController: &mock.WalletController{}, + failCall: 2, + } + lockID := wtxmgr.LockID{9, 8, 7} + outpoints := []wire.OutPoint{ + {Index: 1}, + {Index: 2}, + } + + _, err := lockInputs(wallet, outpoints, &lockID, time.Hour, 6) + require.ErrorContains(t, err, "lease failed") + require.Equal(t, []wtxmgr.LockID{lockID}, wallet.releasedIDs) +} From 34267bd1ce7e646156034ff86e8a684eede90af2 Mon Sep 17 00:00:00 2001 From: Andras Banki-Horvath Date: Wed, 26 Aug 2026 11:07:32 +0200 Subject: [PATCH 5/6] docs: note configurable WalletKit lease lifetime Document the new LeaseOutput and FundPsbt confirmation-depth option in the 0.22.0 release notes. --- docs/release-notes/release-notes-0.22.0.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/release-notes/release-notes-0.22.0.md b/docs/release-notes/release-notes-0.22.0.md index f10390991d..1c78e37f2c 100644 --- a/docs/release-notes/release-notes-0.22.0.md +++ b/docs/release-notes/release-notes-0.22.0.md @@ -61,6 +61,12 @@ ## RPC Additions +* WalletKit output leases can now [remain active until their spending + transaction reaches a requested confirmation + depth](https://github.com/lightningnetwork/lnd/pull/11125). The option is + available on both `LeaseOutput` and inputs selected by `FundPsbt`; a zero + depth preserves the existing wall-clock behavior. + * The `routerrpc.EstimateRouteFee` RPC now supports [restricting fee estimates to specific first-hop outgoing channels](https://github.com/lightningnetwork/lnd/pull/10501) via the new @@ -163,6 +169,7 @@ # Contributors (Alphabetical Order) +* Andras Banki-Horvath * bitromortac * Boris Nagaev * Erick Cestari From 7653dc06bd8935be294e09aa069446324987594e Mon Sep 17 00:00:00 2001 From: Andras Banki-Horvath Date: Thu, 27 Aug 2026 13:32:04 +0200 Subject: [PATCH 6/6] walletrpc: Test configurable lease capability failure Exercise both FundPsbt input locking and the direct LeaseOutput RPC with a wallet that only supports legacy time-based leases. Assert option-bearing requests fail before calling the legacy lease method, so a backend mismatch cannot silently shorten the requested protection. --- lnrpc/walletrpc/psbt_test.go | 36 ++++++++++++++++++++++++ lnrpc/walletrpc/walletkit_server_test.go | 31 ++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/lnrpc/walletrpc/psbt_test.go b/lnrpc/walletrpc/psbt_test.go index 3746ed15d9..bd93bc7485 100644 --- a/lnrpc/walletrpc/psbt_test.go +++ b/lnrpc/walletrpc/psbt_test.go @@ -24,6 +24,23 @@ type leaseOptionsWallet struct { failCall int } +// legacyLeaseWallet records calls to the original lease method but does not +// implement OutputLeaserWithOptions. +type legacyLeaseWallet struct { + *mock.WalletController + + leaseCalls int +} + +// LeaseOutput records any fallback to the legacy lease path. +func (w *legacyLeaseWallet) LeaseOutput(_ wtxmgr.LockID, _ wire.OutPoint, + _ time.Duration) (time.Time, error) { + + w.leaseCalls++ + + return time.Unix(123, 0), nil +} + // LeaseOutputWithOptions records the requested behavior and optionally fails // one call so the partial-lock rollback path can be asserted. func (w *leaseOptionsWallet) LeaseOutputWithOptions(_ wtxmgr.LockID, @@ -72,6 +89,25 @@ func TestLockInputsForwardsReleaseAfterSpend(t *testing.T) { } } +// TestLockInputsRejectsUnsupportedLeaseOptions verifies an option-bearing +// FundPsbt lease fails before falling back to a time-only wallet lease. +func TestLockInputsRejectsUnsupportedLeaseOptions(t *testing.T) { + t.Parallel() + + wallet := &legacyLeaseWallet{ + WalletController: &mock.WalletController{}, + } + + _, err := lockInputs( + wallet, []wire.OutPoint{{Index: 1}}, nil, time.Hour, 6, + ) + require.ErrorContains( + t, err, "wallet does not support release-after-spend output leases", + ) + require.Zero(t, wallet.leaseCalls, + "unsupported options must not create a shorter legacy lease") +} + // TestLockInputsRollbackUsesActualLockID verifies that a later lease failure // releases earlier inputs with the ID that acquired them. func TestLockInputsRollbackUsesActualLockID(t *testing.T) { diff --git a/lnrpc/walletrpc/walletkit_server_test.go b/lnrpc/walletrpc/walletkit_server_test.go index 7330d790d4..bfd2e697c6 100644 --- a/lnrpc/walletrpc/walletkit_server_test.go +++ b/lnrpc/walletrpc/walletkit_server_test.go @@ -18,6 +18,7 @@ import ( "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btcwallet/wallet" "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lntest/mock" "github.com/lightningnetwork/lnd/lnwallet" "github.com/lightningnetwork/lnd/lnwallet/chainfee" @@ -78,6 +79,36 @@ func (m *mockCoinSelectionLocker) WithCoinSelectLock(f func() error) error { return nil } +// TestLeaseOutputRejectsUnsupportedOptions verifies WalletKit does not +// silently downgrade an option-bearing request to a time-only lease. +func TestLeaseOutputRejectsUnsupportedOptions(t *testing.T) { + t.Parallel() + + wallet := &legacyLeaseWallet{ + WalletController: &mock.WalletController{}, + } + rpcServer, _, err := New(&Config{ + Wallet: wallet, + CoinSelectionLocker: &mockCoinSelectionLocker{}, + }) + require.NoError(t, err) + + _, err = rpcServer.LeaseOutput(t.Context(), &LeaseOutputRequest{ + Id: bytes.Repeat([]byte{1}, 32), + Outpoint: &lnrpc.OutPoint{ + TxidBytes: make([]byte, 32), + OutputIndex: 1, + }, + ExpirationSeconds: 60, + ReleaseAfterSpendConfs: 6, + }) + require.ErrorContains( + t, err, "wallet does not support release-after-spend output leases", + ) + require.Zero(t, wallet.leaseCalls, + "unsupported options must not create a shorter legacy lease") +} + // TestFundPsbtCoinSelect tests that the coin selection for a PSBT template // works as expected. func TestFundPsbtCoinSelect(t *testing.T) {