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/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 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= 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..bd93bc7485 --- /dev/null +++ b/lnrpc/walletrpc/psbt_test.go @@ -0,0 +1,129 @@ +//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 +} + +// 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, + _ 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) + } +} + +// 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) { + 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) +} 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..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) { @@ -657,7 +688,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 { 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() {