walletrpc: expose configurable output leases
What changed, and why it matters
This commit adds a new optional feature to LND's wallet RPC that lets callers lock a coin (a 'UTXO lease') until the transaction spending it reaches a chosen number of confirmations, instead of relying only on a clock-time expiration. It also exposes more lease information in responses and lists. The change is defensive: it checks wallet support before locking, rejects unsupported wallets, requires a caller-chosen lock ID for the new mode, and preserves the old time-based behavior when the new option is not used. There is no direct evidence in the commit of a fixed vulnerability; it reads like a capability addition with safety checks.
Treat as a feature commit with embedded hardening rather than a security patch. Reviewers should verify that the new confirmation-depth lease logic in the underlying lnwallet package correctly handles reorgs, maturity resets, and explicit release semantics, since this commit only wires RPC behavior and tests around it. Operators using the new option should ensure they persist the custom lock ID and understand that abandoned unconfirmed PSBTs must be released manually.
Security signals we found
New RPC option changes lease lifecycle from time-based to confirmation-based
Fail-closed capability check before acquiring any input lease
Custom lock ID required and validated for confirmation-controlled FundPsbt leases
Unsupported wallet backends rejected instead of silently falling back to time-only leases
Partial lock acquisition rollback path retained and tested
Response fields added to expose effective depth and confirmed spend height
Evidence from the diff
The patch extends LeaseOutput and FundPsbt with confirmation-depth-controlled output leases (release_after_spend_confs / input_release_after_spend_confs). When non-zero, the lease is held until the spending transaction reaches the requested confirmation depth or until explicitly released. The implementation resolves the wallet’s OutputLeaserWithOptions capability before acquiring inputs, failing closed if the backend does not support the option. For FundPsbt, a custom lock ID is required and validated (non-zero, not the reserved LND internal ID). ListLeases now echoes persisted spend height and confirmation depth. Tests cover option forwarding, zero-depth legacy compatibility, unsupported wallets, partial acquisition rollback, and response marshalling.
Changed components
lnrpc/walletrpc/psbt.golnrpc/walletrpc/walletkit_server.golnrpc/walletrpc/walletkit.protolnrpc/walletrpc/walletkit.pb.golnrpc/walletrpc/walletkit_grpc.pb.golnrpc/walletrpc/walletkit.swagger.jsonInspect captured patch +732 / −75
### lnrpc/walletrpc/psbt.go
@@ -19,6 +19,10 @@ const (
defaultMaxConf = math.MaxInt32
)
+var errOutputLeaseOptionsUnsupported = fmt.Errorf(
+ "wallet does not support release-after-spend output leases",
+)
+
// verifyInputsUnspent checks that all inputs are contained in the list of
// known, non-locked UTXOs given.
func verifyInputsUnspent(inputs []*wire.TxIn, utxos []*lnwallet.Utxo) error {
@@ -45,9 +49,22 @@ 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) {
+ var leaser lnwallet.OutputLeaserWithOptions
+ if releaseAfterSpendConfs > 0 {
+ var ok bool
+ leaser, ok = lnwallet.ResolveOutputLeaser(w)
+ if !ok {
+ return nil, fmt.Errorf(
+ "lock inputs: %w",
+ errOutputLeaseOptionsUnsupported,
+ )
+ }
+ }
+
locks := make(
[]*base.ListLeasedOutputResult, len(outpoints),
)
@@ -74,9 +91,20 @@ 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 {
+ 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
### lnrpc/walletrpc/psbt_test.go
@@ -0,0 +1,161 @@
+//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"
+)
+
+// unsupportedLeaseOptionsErr is returned when a wallet cannot apply a
+// requested confirmation-controlled lease.
+const unsupportedLeaseOptionsErr = "wallet does not support " +
+ "release-after-spend output leases"
+
+// leaseOptionsWallet records the optional lease settings passed by lockInputs.
+type leaseOptionsWallet struct {
+ *mock.WalletController
+
+ leaseCalls []lnwallet.LeaseOutputOptions
+ legacyCalls int
+ 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
+}
+
+// LeaseOutput records calls to the zero-option lease path.
+func (w *leaseOptionsWallet) LeaseOutput(_ wtxmgr.LockID, _ wire.OutPoint,
+ _ time.Duration) (time.Time, error) {
+
+ w.legacyCalls++
+
+ 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)
+ }
+}
+
+// TestLockInputsUsesLegacyPathForZeroDepth verifies that the zero value keeps
+// the existing time-only lease path even when the wallet supports options.
+func TestLockInputsUsesLegacyPathForZeroDepth(t *testing.T) {
+ t.Parallel()
+
+ wallet := &leaseOptionsWallet{
+ WalletController: &mock.WalletController{},
+ }
+
+ locks, err := lockInputs(
+ wallet, []wire.OutPoint{{Index: 1}}, nil, time.Hour, 0,
+ )
+ require.NoError(t, err)
+ require.Len(t, locks, 1)
+ require.Equal(t, 1, wallet.legacyCalls)
+ require.Empty(t, wallet.leaseCalls)
+}
+
+// TestLockInputsRejectsUnsupportedLeaseOptions verifies an option-bearing
+// FundPsbt lease fails before falling back to a time-only wallet lease.
+func TestLockInputsRejectsUnsupportedLeaseOptions(t *testing.T) {
+ t.Parallel()
+
+ controller := &legacyLeaseWallet{
+ WalletController: &mock.WalletController{},
+ }
+ wallet := &lnwallet.LightningWallet{
+ WalletController: controller,
+ }
+
+ _, err := lockInputs(
+ wallet, []wire.OutPoint{{Index: 1}}, nil, time.Hour, 6,
+ )
+ require.ErrorContains(
+ t, err, unsupportedLeaseOptionsErr,
+ )
+ require.Zero(t, controller.leaseCalls,
+ "unsupported options must not create a shorter legacy lease")
+}
+// TestLockInputsRejectsUnsupportedOptionsWithoutInputs verifies capability is
+// checked even when FundPsbt does not need to acquire a new input lease.
+func TestLockInputsRejectsUnsupportedOptionsWithoutInputs(t *testing.T) {
+ t.Parallel()
+
+ controller := &legacyLeaseWallet{
+ WalletController: &mock.WalletController{},
+ }
+ wallet := &lnwallet.LightningWallet{
+ WalletController: controller,
+ }
+
+ _, err := lockInputs(wallet, nil, nil, time.Hour, 6)
+ require.ErrorContains(t, err, unsupportedLeaseOptionsErr)
+ require.Zero(t, controller.leaseCalls)
+}
### lnrpc/walletrpc/walletkit.pb.go
@@ -554,11 +554,18 @@ type LeaseOutputRequest struct {
Id []byte `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
// The identifying outpoint of the output being leased.
Outpoint *lnrpc.OutPoint `protobuf:"bytes,2,opt,name=outpoint,proto3" json:"outpoint,omitempty"`
- // The time in seconds before the lock expires. If set to zero, the default
- // lock duration is used.
+ // The time in seconds before a time-controlled lock expires. If set to
+ // zero, the default lock duration is used. A non-zero
+ // release_after_spend_confs makes this deadline informational only.
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 or the owner explicitly releases it. A reorganization
+ // that disconnects the spending block resets maturity progress. A non-zero
+ // value ignores expiration_seconds; zero preserves the time-controlled
+ // 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 +619,26 @@ 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
+ // The absolute expiration of a time-controlled output lease represented as a
+ // unix timestamp. Confirmation-controlled leases return the stored value for
+ // compatibility but do not apply it.
+ Expiration uint64 `protobuf:"varint,1,opt,name=expiration,proto3" json:"expiration,omitempty"`
+ // The effective persisted spend maturity depth. A zero-depth renewal
+ // returns the retained non-zero depth of an existing
+ // confirmation-controlled lease. If that informational lookup fails after
+ // the lease succeeds, this field is zero and ListLeases can refresh it.
+ 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 +678,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.
@@ -3942,14 +3970,23 @@ type FundPsbtRequest struct {
MaxFeeRatio float64 `protobuf:"fixed64,12,opt,name=max_fee_ratio,json=maxFeeRatio,proto3" json:"max_fee_ratio,omitempty"`
// The custom lock ID to use for the inputs in the funded PSBT. The value
// if set must be exactly 32 bytes long. If empty, the default lock ID will
- // be used.
+ // be used. This field is required when input_release_after_spend_confs is
+ // non-zero. In that mode it must not be all zero or LND's reserved
+ // internal lock ID. The caller must persist this ID before funding and use
+ // ReleaseOutput to unlock an abandoned PSBT whose spend never confirms.
CustomLockId []byte `protobuf:"bytes,13,opt,name=custom_lock_id,json=customLockId,proto3" json:"custom_lock_id,omitempty"`
- // If set, then the inputs in the funded PSBT will be locked for the
- // specified duration. The lock duration is specified in seconds. If not
- // set, the default lock duration will be used.
+ // If set, then time-controlled input leases in the funded PSBT will be
+ // locked for the specified duration in seconds. If not set, the default
+ // lock duration is used. A non-zero input_release_after_spend_confs makes
+ // this deadline informational only.
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 or the owner explicitly releases it. A
+ // reorganization resets maturity progress. A non-zero value ignores
+ // lock_expiration_seconds; zero preserves time-controlled lease behavior.
+ 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 +4143,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()
}
@@ -4417,14 +4461,25 @@ type UtxoLease struct {
Id []byte `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
// The identifying outpoint of the output being leased.
Outpoint *lnrpc.OutPoint `protobuf:"bytes,2,opt,name=outpoint,proto3" json:"outpoint,omitempty"`
- // The absolute expiration of the output lease represented as a unix timestamp.
+ // The absolute expiration of a time-controlled output lease represented as a
+ // unix timestamp. Confirmation-controlled leases retain this value for
+ // compatibility but do not apply it.
Expiration uint64 `protobuf:"varint,3,opt,name=expiration,proto3" json:"expiration,omitempty"`
// 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 depth recorded for this lease. FundPsbt returns it
+ // only after the wallet successfully applies the requested option.
+ ReleaseAfterSpendConfs uint32 `protobuf:"varint,6,opt,name=release_after_spend_confs,json=releaseAfterSpendConfs,proto3" json:"release_after_spend_confs,omitempty"`
+ // The block height where the spending transaction first confirmed. Zero
+ // means no confirmed spend was observed. A negative value means a
+ // previously observed spend was disconnected and is awaiting
+ // reconfirmation. The only negative value emitted is -1; its magnitude
+ // carries no additional information.
+ ConfirmedSpendHeight int32 `protobuf:"varint,7,opt,name=confirmed_spend_height,json=confirmedSpendHeight,proto3" json:"confirmed_spend_height,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
}
func (x *UtxoLease) Reset() {
@@ -4492,6 +4547,20 @@ func (x *UtxoLease) GetValue() uint64 {
return 0
}
+func (x *UtxoLease) GetReleaseAfterSpendConfs() uint32 {
+ if x != nil {
+ return x.ReleaseAfterSpendConfs
+ }
+ return 0
+}
+
+func (x *UtxoLease) GetConfirmedSpendHeight() int32 {
+ if x != nil {
+ return x.ConfirmedSpendHeight
+ }
+ 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 +4911,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 +5130,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 +5150,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,15 +5171,17 @@ 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\"\x8c\x02\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" +
"\n" +
"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\x124\n" +
+ "\x16confirmed_spend_height\x18\a \x01(\x05R\x14confirmedSpendHeight\"2\n" +
"\x0fSignPsbtRequest\x12\x1f\n" +
"\vfunded_psbt\x18\x01 \x01(\fR\n" +
"fundedPsbt\"X\n" +
### lnrpc/walletrpc/walletkit.proto
@@ -42,13 +42,20 @@ service WalletKit {
lock's expiration is returned. The expiration of the lock can be extended by
successive invocations of this RPC. Outputs can be unlocked before their
expiration through `ReleaseOutput`.
+
+ A non-zero release_after_spend_confs selects a confirmation-controlled
+ lifetime. Such a lease ignores its wall-clock expiration and releases at
+ the requested spend depth or through ReleaseOutput. The RPC fails if the
+ wallet cannot apply this option. Renewing the same lease with the same ID
+ and a zero depth preserves an existing non-zero depth.
*/
rpc LeaseOutput (LeaseOutputRequest) returns (LeaseOutputResponse);
/* lncli: `wallet releaseoutput`
ReleaseOutput unlocks an output, allowing it to be available for coin
selection if it remains unspent. The ID should match the one used to
- originally lock the output.
+ originally lock the output. A retained release-after-spend lease can also be
+ released after its spending transaction has been observed.
*/
rpc ReleaseOutput (ReleaseOutputRequest) returns (ReleaseOutputResponse);
@@ -392,6 +399,14 @@ service WalletKit {
After either selecting or verifying the inputs, all input UTXOs are locked
with an internal app ID.
+ A non-zero input_release_after_spend_confs applies a
+ confirmation-controlled lifetime to every input lease this RPC acquires.
+ Those leases ignore wall-clock expiration and release at the requested
+ spend depth or through ReleaseOutput. The RPC fails if the wallet cannot
+ apply this option. Wallet capability is checked even when coin selection
+ does not need to acquire an input lease. A custom_lock_id is required for a
+ confirmation-controlled lease.
+
NOTE: If this method returns without an error, it is the caller's
responsibility to either spend the locked UTXOs (by finalizing and then
publishing the transaction) or to unlock/release the locked UTXOs in case of
@@ -464,16 +479,32 @@ message LeaseOutputRequest {
// The identifying outpoint of the output being leased.
lnrpc.OutPoint outpoint = 2;
- // The time in seconds before the lock expires. If set to zero, the default
- // lock duration is used.
+ // The time in seconds before a time-controlled lock expires. If set to
+ // zero, the default lock duration is used. A non-zero
+ // release_after_spend_confs makes this deadline informational only.
uint64 expiration_seconds = 3;
+
+ // Keep the lease until the transaction spending the output reaches this
+ // confirmation count or the owner explicitly releases it. A reorganization
+ // that disconnects the spending block resets maturity progress. A non-zero
+ // value ignores expiration_seconds; zero preserves the time-controlled
+ // lease behavior.
+ uint32 release_after_spend_confs = 4;
}
message LeaseOutputResponse {
/*
- The absolute expiration of the output lease represented as a unix timestamp.
+ The absolute expiration of a time-controlled output lease represented as a
+ unix timestamp. Confirmation-controlled leases return the stored value for
+ compatibility but do not apply it.
*/
uint64 expiration = 1;
+
+ // The effective persisted spend maturity depth. A zero-depth renewal
+ // returns the retained non-zero depth of an existing
+ // confirmation-controlled lease. If that informational lookup fails after
+ // the lease succeeds, this field is zero and ListLeases can refresh it.
+ uint32 release_after_spend_confs = 2;
}
message ReleaseOutputRequest {
@@ -1642,13 +1673,23 @@ message FundPsbtRequest {
// The custom lock ID to use for the inputs in the funded PSBT. The value
// if set must be exactly 32 bytes long. If empty, the default lock ID will
- // be used.
+ // be used. This field is required when input_release_after_spend_confs is
+ // non-zero. In that mode it must not be all zero or LND's reserved
+ // internal lock ID. The caller must persist this ID before funding and use
+ // ReleaseOutput to unlock an abandoned PSBT whose spend never confirms.
bytes custom_lock_id = 13;
- // If set, then the inputs in the funded PSBT will be locked for the
- // specified duration. The lock duration is specified in seconds. If not
- // set, the default lock duration will be used.
+ // If set, then time-controlled input leases in the funded PSBT will be
+ // locked for the specified duration in seconds. If not set, the default
+ // lock duration is used. A non-zero input_release_after_spend_confs makes
+ // this deadline informational only.
uint64 lock_expiration_seconds = 14;
+
+ // Keep each acquired input lease until its spending transaction reaches
+ // this confirmation count or the owner explicitly releases it. A
+ // reorganization resets maturity progress. A non-zero value ignores
+ // lock_expiration_seconds; zero preserves time-controlled lease behavior.
+ uint32 input_release_after_spend_confs = 15;
}
message FundPsbtResponse {
/*
@@ -1729,7 +1770,9 @@ message UtxoLease {
lnrpc.OutPoint outpoint = 2;
/*
- The absolute expiration of the output lease represented as a unix timestamp.
+ The absolute expiration of a time-controlled output lease represented as a
+ unix timestamp. Confirmation-controlled leases retain this value for
+ compatibility but do not apply it.
*/
uint64 expiration = 3;
@@ -1742,6 +1785,17 @@ message UtxoLease {
The value of the leased output in satoshis.
*/
uint64 value = 5;
+
+ // The spend maturity depth recorded for this lease. FundPsbt returns it
+ // only after the wallet successfully applies the requested option.
+ uint32 release_after_spend_confs = 6;
+
+ // The block height where the spending transaction first confirmed. Zero
+ // means no confirmed spend was observed. A negative value means a
+ // previously observed spend was disconnected and is awaiting
+ // reconfirmation. The only negative value emitted is -1; its magnitude
+ // carries no additional information.
+ int32 confirmed_spend_height = 7;
}
message SignPsbtRequest {
### lnrpc/walletrpc/walletkit.swagger.json
@@ -508,7 +508,7 @@
"/v2/wallet/psbt/fund": {
"post": {
"summary": "lncli: `wallet psbt fund`\nFundPsbt creates a fully populated PSBT that contains enough inputs to fund\nthe outputs specified in the template. There are three ways a user can\nspecify what we call the template (a list of inputs and outputs to use in\nthe PSBT): Either as a PSBT packet directly with no coin selection (using\nthe legacy \"psbt\" field), a PSBT with advanced coin selection support (using\nthe new \"coin_select\" field) or as a raw RPC message (using the \"raw\"\nfield).\nThe legacy \"psbt\" and \"raw\" modes, the following restrictions apply:\n1. If there are no inputs specified in the template, coin selection is\nperformed automatically.\n2. If the template does contain any inputs, it is assumed that full\ncoin selection happened externally and no additional inputs are added. If\nthe specified inputs aren't enough to fund the outputs with the given fee\nrate, an error is returned.",
- "description": "The new \"coin_select\" mode does not have these restrictions and allows the\nuser to specify a PSBT with inputs and outputs and still perform coin\nselection on top of that.\nFor all modes this RPC requires any inputs that are specified to be locked\nby the user (if they belong to this node in the first place).\n\nAfter either selecting or verifying the inputs, all input UTXOs are locked\nwith an internal app ID.\n\nNOTE: If this method returns without an error, it is the caller's\nresponsibility to either spend the locked UTXOs (by finalizing and then\npublishing the transaction) or to unlock/release the locked UTXOs in case of\nan error on the caller's side.",
+ "description": "The new \"coin_select\" mode does not have these restrictions and allows the\nuser to specify a PSBT with inputs and outputs and still perform coin\nselection on top of that.\nFor all modes this RPC requires any inputs that are specified to be locked\nby the user (if they belong to this node in the first place).\n\nAfter either selecting or verifying the inputs, all input UTXOs are locked\nwith an internal app ID.\n\nA non-zero input_release_after_spend_confs applies a\nconfirmation-controlled lifetime to every input lease this RPC acquires.\nThose leases ignore wall-clock expiration and release at the requested\nspend depth or through ReleaseOutput. The RPC fails if the wallet cannot\napply this option. Wallet capability is checked even when coin selection\ndoes not need to acquire an input lease. A custom_lock_id is required for a\nconfirmation-controlled lease.\n\nNOTE: If this method returns without an error, it is the caller's\nresponsibility to either spend the locked UTXOs (by finalizing and then\npublishing the transaction) or to unlock/release the locked UTXOs in case of\nan error on the caller's side.",
"operationId": "WalletKit_FundPsbt",
"responses": {
"200": {
@@ -935,6 +935,7 @@
"/v2/wallet/utxos/lease": {
"post": {
"summary": "lncli: `wallet leaseoutput`\nLeaseOutput locks an output to the given ID, preventing it from being\navailable for any future coin selection attempts. The absolute time of the\nlock's expiration is returned. The expiration of the lock can be extended by\nsuccessive invocations of this RPC. Outputs can be unlocked before their\nexpiration through `ReleaseOutput`.",
+ "description": "A non-zero release_after_spend_confs selects a confirmation-controlled\nlifetime. Such a lease ignores its wall-clock expiration and releases at\nthe requested spend depth or through ReleaseOutput. The RPC fails if the\nwallet cannot apply this option. Renewing the same lease with the same ID\nand a zero depth preserves an existing non-zero depth.",
"operationId": "WalletKit_LeaseOutput",
"responses": {
"200": {
@@ -990,7 +991,7 @@
},
"/v2/wallet/utxos/release": {
"post": {
- "summary": "lncli: `wallet releaseoutput`\nReleaseOutput unlocks an output, allowing it to be available for coin\nselection if it remains unspent. The ID should match the one used to\noriginally lock the output.",
+ "summary": "lncli: `wallet releaseoutput`\nReleaseOutput unlocks an output, allowing it to be available for coin\nselection if it remains unspent. The ID should match the one used to\noriginally lock the output. A retained release-after-spend lease can also be\nreleased after its spending transaction has been observed.",
"operationId": "WalletKit_ReleaseOutput",
"responses": {
"200": {
@@ -1687,12 +1688,17 @@
"custom_lock_id": {
"type": "string",
"format": "byte",
- "description": "The custom lock ID to use for the inputs in the funded PSBT. The value\nif set must be exactly 32 bytes long. If empty, the default lock ID will\nbe used."
+ "description": "The custom lock ID to use for the inputs in the funded PSBT. The value\nif set must be exactly 32 bytes long. If empty, the default lock ID will\nbe used. This field is required when input_release_after_spend_confs is\nnon-zero. In that mode it must not be all zero or LND's reserved\ninternal lock ID. The caller must persist this ID before funding and use\nReleaseOutput to unlock an abandoned PSBT whose spend never confirms."
},
"lock_expiration_seconds": {
"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."
+ "description": "If set, then time-controlled input leases in the funded PSBT will be\nlocked for the specified duration in seconds. If not set, the default\nlock duration is used. A non-zero input_release_after_spend_confs makes\nthis deadline informational only."
+ },
+ "input_release_after_spend_confs": {
+ "type": "integer",
+ "format": "int64",
+ "description": "Keep each acquired input lease until its spending transaction reaches\nthis confirmation count or the owner explicitly releases it. A\nreorganization resets maturity progress. A non-zero value ignores\nlock_expiration_seconds; zero preserves time-controlled lease behavior."
}
}
},
@@ -1893,7 +1899,12 @@
"expiration_seconds": {
"type": "string",
"format": "uint64",
- "description": "The time in seconds before the lock expires. If set to zero, the default\nlock duration is used."
+ "description": "The time in seconds before a time-controlled lock expires. If set to\nzero, the default lock duration is used. A non-zero\nrelease_after_spend_confs makes this deadline informational only."
+ },
+ "release_after_spend_confs": {
+ "type": "integer",
+ "format": "int64",
+ "description": "Keep the lease until the transaction spending the output reaches this\nconfirmation count or the owner explicitly releases it. A reorganization\nthat disconnects the spending block resets maturity progress. A non-zero\nvalue ignores expiration_seconds; zero preserves the time-controlled\nlease behavior."
}
}
},
@@ -1903,7 +1914,12 @@
"expiration": {
"type": "string",
"format": "uint64",
- "description": "The absolute expiration of the output lease represented as a unix timestamp."
+ "description": "The absolute expiration of a time-controlled output lease represented as a\nunix timestamp. Confirmation-controlled leases return the stored value for\ncompatibility but do not apply it."
+ },
+ "release_after_spend_confs": {
+ "type": "integer",
+ "format": "int64",
+ "description": "The effective persisted spend maturity depth. A zero-depth renewal\nreturns the retained non-zero depth of an existing\nconfirmation-controlled lease. If that informational lookup fails after\nthe lease succeeds, this field is zero and ListLeases can refresh it."
}
}
},
@@ -2397,7 +2413,7 @@
"expiration": {
"type": "string",
"format": "uint64",
- "description": "The absolute expiration of the output lease represented as a unix timestamp."
+ "description": "The absolute expiration of a time-controlled output lease represented as a\nunix timestamp. Confirmation-controlled leases retain this value for\ncompatibility but do not apply it."
},
"pk_script": {
"type": "string",
@@ -2408,6 +2424,16 @@
"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 depth recorded for this lease. FundPsbt returns it\nonly after the wallet successfully applies the requested option."
+ },
+ "confirmed_spend_height": {
+ "type": "integer",
+ "format": "int32",
+ "description": "The block height where the spending transaction first confirmed. Zero\nmeans no confirmed spend was observed. A negative value means a\npreviously observed spend was disconnected and is awaiting\nreconfirmation. The only negative value emitted is -1; its magnitude\ncarries no additional information."
}
}
},
### lnrpc/walletrpc/walletkit_grpc.pb.go
@@ -31,11 +31,18 @@ type WalletKitClient interface {
// lock's expiration is returned. The expiration of the lock can be extended by
// successive invocations of this RPC. Outputs can be unlocked before their
// expiration through `ReleaseOutput`.
+ //
+ // A non-zero release_after_spend_confs selects a confirmation-controlled
+ // lifetime. Such a lease ignores its wall-clock expiration and releases at
+ // the requested spend depth or through ReleaseOutput. The RPC fails if the
+ // wallet cannot apply this option. Renewing the same lease with the same ID
+ // and a zero depth preserves an existing non-zero depth.
LeaseOutput(ctx context.Context, in *LeaseOutputRequest, opts ...grpc.CallOption) (*LeaseOutputResponse, error)
// lncli: `wallet releaseoutput`
// ReleaseOutput unlocks an output, allowing it to be available for coin
// selection if it remains unspent. The ID should match the one used to
- // originally lock the output.
+ // originally lock the output. A retained release-after-spend lease can also be
+ // released after its spending transaction has been observed.
ReleaseOutput(ctx context.Context, in *ReleaseOutputRequest, opts ...grpc.CallOption) (*ReleaseOutputResponse, error)
// lncli: `wallet listleases`
// ListLeases lists all currently locked utxos.
@@ -316,6 +323,14 @@ type WalletKitClient interface {
// After either selecting or verifying the inputs, all input UTXOs are locked
// with an internal app ID.
//
+ // A non-zero input_release_after_spend_confs applies a
+ // confirmation-controlled lifetime to every input lease this RPC acquires.
+ // Those leases ignore wall-clock expiration and release at the requested
+ // spend depth or through ReleaseOutput. The RPC fails if the wallet cannot
+ // apply this option. Wallet capability is checked even when coin selection
+ // does not need to acquire an input lease. A custom_lock_id is required for a
+ // confirmation-controlled lease.
+ //
// NOTE: If this method returns without an error, it is the caller's
// responsibility to either spend the locked UTXOs (by finalizing and then
// publishing the transaction) or to unlock/release the locked UTXOs in case of
@@ -642,11 +657,18 @@ type WalletKitServer interface {
// lock's expiration is returned. The expiration of the lock can be extended by
// successive invocations of this RPC. Outputs can be unlocked before their
// expiration through `ReleaseOutput`.
+ //
+ // A non-zero release_after_spend_confs selects a confirmation-controlled
+ // lifetime. Such a lease ignores its wall-clock expiration and releases at
+ // the requested spend depth or through ReleaseOutput. The RPC fails if the
+ // wallet cannot apply this option. Renewing the same lease with the same ID
+ // and a zero depth preserves an existing non-zero depth.
LeaseOutput(context.Context, *LeaseOutputRequest) (*LeaseOutputResponse, error)
// lncli: `wallet releaseoutput`
// ReleaseOutput unlocks an output, allowing it to be available for coin
// selection if it remains unspent. The ID should match the one used to
- // originally lock the output.
+ // originally lock the output. A retained release-after-spend lease can also be
+ // released after its spending transaction has been observed.
ReleaseOutput(context.Context, *ReleaseOutputRequest) (*ReleaseOutputResponse, error)
// lncli: `wallet listleases`
// ListLeases lists all currently locked utxos.
@@ -927,6 +949,14 @@ type WalletKitServer interface {
// After either selecting or verifying the inputs, all input UTXOs are locked
// with an internal app ID.
//
+ // A non-zero input_release_after_spend_confs applies a
+ // confirmation-controlled lifetime to every input lease this RPC acquires.
+ // Those leases ignore wall-clock expiration and release at the requested
+ // spend depth or through ReleaseOutput. The RPC fails if the wallet cannot
+ // apply this option. Wallet capability is checked even when coin selection
+ // does not need to acquire an input lease. A custom_lock_id is required for a
+ // confirmation-controlled lease.
+ //
// NOTE: If this method returns without an error, it is the caller's
// responsibility to either spend the locked UTXOs (by finalizing and then
// publishing the transaction) or to unlock/release the locked UTXOs in case of
### lnrpc/walletrpc/walletkit_server.go
@@ -510,25 +510,78 @@ 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 {
+ leaser, ok := lnwallet.ResolveOutputLeaser(w.cfg.Wallet)
+ if !ok {
+ return fmt.Errorf(
+ "lease output: %w",
+ errOutputLeaseOptionsUnsupported,
+ )
+ }
+
+ 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 {
return nil, err
}
+ // A zero-depth same-owner renewal preserves any existing confirmation
+ // depth. Read it after the lease succeeds so this informational field
+ // cannot prevent a legacy lease or extend the coin selection lock.
+ if releaseAfterSpendConfs == 0 {
+ depth, err := storedLeaseDepth(w.cfg.Wallet, lockID, *op)
+ if err != nil {
+ log.Warnf("Unable to report retained confirmation "+
+ "depth for lease %v: %v", op, err)
+ } else {
+ releaseAfterSpendConfs = depth
+ }
+ }
+
return &LeaseOutputResponse{
- Expiration: uint64(expiration.Unix()),
+ Expiration: uint64(expiration.Unix()),
+ ReleaseAfterSpendConfs: releaseAfterSpendConfs,
}, nil
}
+// storedLeaseDepth returns the confirmation depth of an existing lease owned
+// by lockID. It lets a zero-depth renewal report the depth that the legacy
+// wallet lease method preserves.
+func storedLeaseDepth(wallet lnwallet.WalletController, lockID wtxmgr.LockID,
+ op wire.OutPoint) (uint32, error) {
+
+ leases, err := wallet.ListLeasedOutputs()
+ if err != nil {
+ return 0, fmt.Errorf("list existing output leases: %w", err)
+ }
+
+ for _, lease := range leases {
+ if lease.Outpoint == op && lease.LockID == lockID {
+ return lease.ReleaseAfterSpendConfs, nil
+ }
+ }
+
+ return 0, nil
+}
+
// ReleaseOutput unlocks an output, allowing it to be available for coin
// selection if it remains unspent. The ID should match the one used to
// originally lock the output.
@@ -1641,6 +1694,38 @@ func (w *WalletKit) LabelTransaction(ctx context.Context,
func (w *WalletKit) FundPsbt(_ context.Context,
req *FundPsbtRequest) (*FundPsbtResponse, error) {
+ var customLockID *wtxmgr.LockID
+ if len(req.CustomLockId) > 0 {
+ lockID := wtxmgr.LockID{}
+ if len(req.CustomLockId) != len(lockID) {
+ return nil, fmt.Errorf("custom lock ID must be " +
+ "exactly 32 bytes")
+ }
+
+ copy(lockID[:], req.CustomLockId)
+ customLockID = &lockID
+ }
+
+ if req.InputReleaseAfterSpendConfs > 0 {
+ switch {
+ case customLockID == nil:
+ return nil, errors.New("custom lock ID required for " +
+ "confirmation-controlled input leases")
+
+ case *customLockID == (wtxmgr.LockID{}):
+ return nil, errors.New(
+ "custom lock ID must not be all zeros for " +
+ "confirmation-controlled input leases",
+ )
+
+ case *customLockID == chanfunding.LndInternalLockID:
+ return nil, errors.New(
+ "reserved custom lock ID cannot be used for " +
+ "confirmation-controlled input leases",
+ )
+ }
+ }
+
coinSelectionStrategy, err := lnrpc.UnmarshallCoinSelectionStrategy(
req.CoinSelectionStrategy, w.cfg.CoinSelectionStrategy,
)
@@ -1698,18 +1783,6 @@ func (w *WalletKit) FundPsbt(_ context.Context,
account = req.Account
}
- var customLockID *wtxmgr.LockID
- if len(req.CustomLockId) > 0 {
- lockID := wtxmgr.LockID{}
- if len(req.CustomLockId) != len(lockID) {
- return nil, fmt.Errorf("custom lock ID must be " +
- "exactly 32 bytes")
- }
-
- copy(lockID[:], req.CustomLockId)
- customLockID = &lockID
- }
-
var customLockDuration time.Duration
if req.LockExpirationSeconds != 0 {
customLockDuration = time.Duration(req.LockExpirationSeconds) *
@@ -1736,6 +1809,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 +1893,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 +1952,7 @@ func (w *WalletKit) FundPsbt(_ context.Context,
account, keyScopeFromChangeAddressType(req.ChangeType),
packet, minConfs, feeSatPerKW, coinSelectionStrategy,
customLockID, customLockDuration,
+ req.InputReleaseAfterSpendConfs,
)
default:
@@ -1890,7 +1966,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 +2083,7 @@ func (w *WalletKit) fundPsbtInternalWallet(account string,
response, err = w.lockAndCreateFundingResponse(
packet, outpoints, changeIndex, customLockID,
- customLockDuration,
+ customLockDuration, releaseAfterSpendConfs,
)
return err
@@ -2026,7 +2103,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 +2221,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 +2295,7 @@ func (w *WalletKit) fundPsbtCoinSelect(account string, changeIndex int32,
response, err = w.lockAndCreateFundingResponse(
packet, addedOutpoints, changeIndex, customLockID,
- customLockDuration,
+ customLockDuration, releaseAfterSpendConfs,
)
return err
@@ -2263,7 +2341,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,13 +2356,17 @@ 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)
}
// 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 +2450,15 @@ 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,
+ ConfirmedSpendHeight: lock.ConfirmedSpendHeight,
}
}
### lnrpc/walletrpc/walletkit_server_test.go
@@ -5,9 +5,11 @@ package walletrpc
import (
"bytes"
+ "errors"
"fmt"
"strings"
"testing"
+ "time"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcec/v2/schnorr"
@@ -17,14 +19,50 @@ 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/input"
+ "github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/lntest/mock"
"github.com/lightningnetwork/lnd/lnwallet"
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
"github.com/lightningnetwork/lnd/lnwallet/chanfunding"
"github.com/stretchr/testify/require"
)
+// TestMarshallLeasesIncludesSpendProgress verifies that ListLeases exposes
+// each confirmation-controlled lease's persisted spend progress.
+func TestMarshallLeasesIncludesSpendProgress(t *testing.T) {
+ t.Parallel()
+
+ testCases := []int32{0, 123, -1}
+ for _, spendHeight := range testCases {
+ testName := fmt.Sprintf("height %d", spendHeight)
+ t.Run(testName, func(t *testing.T) {
+ t.Parallel()
+
+ lockedOutput := &wtxmgr.LockedOutput{
+ Outpoint: wire.OutPoint{Index: 2},
+ LockID: wtxmgr.LockID{1},
+ Expiration: time.Unix(123, 0),
+ ReleaseAfterSpendConfs: 6,
+ ConfirmedSpendHeight: spendHeight,
+ }
+ locks := []*wallet.ListLeasedOutputResult{{
+ LockedOutput: lockedOutput,
+ Value: 1000,
+ PkScript: []byte{0x51},
+ }}
+
+ rpcLocks := marshallLeases(locks)
+ require.Len(t, rpcLocks, 1)
+ require.Equal(
+ t, spendHeight,
+ rpcLocks[0].ConfirmedSpendHeight,
+ )
+ })
+ }
+}
+
// TestWitnessTypeMapping tests that the two witness type enums in the `input`
// package and the `walletrpc` package remain equal.
func TestWitnessTypeMapping(t *testing.T) {
@@ -66,6 +104,23 @@ type mockCoinSelectionLocker struct {
fail bool
}
+// renewalWallet returns a persisted lease after extending it through the
+// legacy lease method.
+type renewalWallet struct {
+ *leaseOptionsWallet
+
+ leases []*wallet.ListLeasedOutputResult
+ listErr error
+}
+
+// ListLeasedOutputs returns the persisted leases visible after renewal.
+func (w *renewalWallet) ListLeasedOutputs() (
+ []*wallet.ListLeasedOutputResult, error) {
+
+ return w.leases, w.listErr
+}
+
+// WithCoinSelectLock runs the callback and optionally returns a test error.
func (m *mockCoinSelectionLocker) WithCoinSelectLock(f func() error) error {
if err := f(); err != nil {
return err
@@ -78,6 +133,148 @@ 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: &lnwallet.LightningWallet{
+ WalletController: 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.ErrorIs(t, err, errOutputLeaseOptionsUnsupported)
+ require.Zero(t, wallet.leaseCalls,
+ "unsupported options must not create a shorter legacy lease")
+}
+
+// TestLeaseOutputReturnsEffectiveRenewalDepth verifies that renewing an
+// existing confirmation-controlled lease through the legacy zero-depth path
+// reports the non-zero depth retained by the wallet.
+func TestLeaseOutputReturnsEffectiveRenewalDepth(t *testing.T) {
+ t.Parallel()
+
+ lockID := wtxmgr.LockID{1}
+ outpoint := wire.OutPoint{Index: 1}
+ controller := &renewalWallet{
+ leaseOptionsWallet: &leaseOptionsWallet{
+ WalletController: &mock.WalletController{},
+ },
+ leases: []*wallet.ListLeasedOutputResult{{
+ LockedOutput: &wtxmgr.LockedOutput{
+ Outpoint: outpoint,
+ LockID: lockID,
+ ReleaseAfterSpendConfs: 6,
+ },
+ }},
+ }
+ rpcServer, _, err := New(&Config{
+ Wallet: controller,
+ CoinSelectionLocker: &mockCoinSelectionLocker{},
+ })
+ require.NoError(t, err)
+
+ resp, err := rpcServer.LeaseOutput(t.Context(), &LeaseOutputRequest{
+ Id: lockID[:],
+ Outpoint: &lnrpc.OutPoint{
+ TxidBytes: make([]byte, 32),
+ OutputIndex: outpoint.Index,
+ },
+ ExpirationSeconds: 60,
+ })
+ require.NoError(t, err)
+ require.Equal(t, uint32(6), resp.ReleaseAfterSpendConfs)
+ require.Equal(t, 1, controller.legacyCalls)
+}
+
+// TestLeaseOutputIgnoresRenewalDepthReadError verifies an informational depth
+// lookup cannot prevent a successful legacy lease.
+func TestLeaseOutputIgnoresRenewalDepthReadError(t *testing.T) {
+ t.Parallel()
+
+ lockID := wtxmgr.LockID{1}
+ controller := &renewalWallet{
+ leaseOptionsWallet: &leaseOptionsWallet{
+ WalletController: &mock.WalletController{},
+ },
+ listErr: errors.New("list leases failed"),
+ }
+ rpcServer, _, err := New(&Config{
+ Wallet: controller,
+ CoinSelectionLocker: &mockCoinSelectionLocker{},
+ })
+ require.NoError(t, err)
+
+ resp, err := rpcServer.LeaseOutput(t.Context(), &LeaseOutputRequest{
+ Id: lockID[:],
+ Outpoint: &lnrpc.OutPoint{
+ TxidBytes: make([]byte, 32),
+ },
+ ExpirationSeconds: 60,
+ })
+ require.NoError(t, err)
+ require.Zero(t, resp.ReleaseAfterSpendConfs)
+ require.Equal(t, 1, controller.legacyCalls)
+}
+
+// TestFundPsbtRequiresCustomLockID verifies confirmation-controlled input
+// leases require an external, caller-specific owner ID.
+func TestFundPsbtRequiresCustomLockID(t *testing.T) {
+ t.Parallel()
+
+ testCases := []struct {
+ name string
+ lockID []byte
+ expected string
+ }{
+ {
+ name: "missing",
+ expected: "custom lock ID required for " +
+ "confirmation-controlled",
+ },
+ {
+ name: "all zero",
+ lockID: make([]byte, 32),
+ expected: "custom lock ID must not be all zeros",
+ },
+ {
+ name: "reserved internal",
+ lockID: chanfunding.LndInternalLockID[:],
+ expected: "reserved custom lock ID cannot be used",
+ },
+ }
+
+ for _, testCase := range testCases {
+ t.Run(testCase.name, func(t *testing.T) {
+ t.Parallel()
+
+ server := &WalletKit{cfg: &Config{}}
+ req := &FundPsbtRequest{
+ InputReleaseAfterSpendConfs: 6,
+ }
+ req.CustomLockId = testCase.lockID
+
+ _, err := server.FundPsbt(t.Context(), req)
+ require.ErrorContains(t, err, testCase.expected)
+ })
+ }
+}
+
// TestFundPsbtCoinSelect tests that the coin selection for a PSBT template
// works as expected.
func TestFundPsbtCoinSelect(t *testing.T) {
@@ -657,7 +854,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 {Why this scored 24/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.