lnwallet: add configurable output lease capability
What changed, and why it matters
This commit adds a new optional wallet feature that lets LND lock a coin (UTXO) until the transaction spending it reaches a chosen number of confirmations. It is purely additive: it defines a new interface, routes calls through wallet adapter wrappers, and keeps the old zero-confirmation lease path unchanged. There is no bug fix, no reported vulnerability, and no evidence this change itself introduces a security issue.
No security action required. Treat as a normal feature review; verify downstream callers use ResolveOutputLeaser and handle the unsupported false case correctly when adopting the new lease option.
Security signals we found
New optional capability interface added without widening WalletController
Adapter-chain unwrapping exposes concrete backend for local and remote-signer wallets
Legacy LeaseOutput path preserved for zero-depth callers
Unsupported controllers fail closed via ResolveOutputLeaser returning false
No vulnerability disclosure, CVE, or security-relevant commit message present
Evidence from the diff
The patch introduces LeaseOutputOptions and OutputLeaserWithOptions in lnwallet/interface.go, a ResolveOutputLeaser helper that unwraps LightningWallet and RPCKeyRing adapters, and a BtcWallet.LeaseOutputWithOptions implementation that maps ReleaseAfterSpendConfs to btcwallet’s wtxmgr.WithReleaseAfterSpend. Unsupported controllers fail closed because ResolveOutputLeaser returns false. Tests cover adapter unwrapping for both local and remote-signer stacks and confirm the legacy LeaseOutput still rejects in-memory double locks.
Changed components
lnwallet/btcwallet/btcwallet.golnwallet/interface.golnwallet/rpcwallet/rpcwallet.golnwallet/wallet.goInspect captured patch +220 / −1
### lnwallet/btcwallet/btcwallet.go
@@ -1150,7 +1150,38 @@ func (b *BtcWallet) LeaseOutput(id wtxmgr.LockID, op wire.OutPoint,
return time.Time{}, wtxmgr.ErrOutputAlreadyLocked
}
- lockedUntil, err := b.wallet.LeaseOutput(id, op, duration)
+ return b.wallet.LeaseOutput(id, op, duration)
+}
+
+// LeaseOutputWithOptions locks an output and applies optional persisted lease
+// behavior supported by btcwallet. It returns wtxmgr.ErrUnknownOutput if the
+// output is unknown and wtxmgr.ErrOutputAlreadyLocked if another owner holds
+// its lease.
+//
+// NOTE: This method requires the global coin selection lock to be held.
+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
+ }
+
+ 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
}
### lnwallet/btcwallet/btcwallet_test.go
@@ -3,18 +3,54 @@ package btcwallet
import (
"math"
"testing"
+ "time"
"github.com/btcsuite/btcd/btcjson"
"github.com/btcsuite/btcd/rpcclient"
"github.com/btcsuite/btcd/wire/v2"
"github.com/btcsuite/btcwallet/chain"
"github.com/btcsuite/btcwallet/wallet"
+ "github.com/btcsuite/btcwallet/wtxmgr"
"github.com/lightningnetwork/lnd/lnmock"
"github.com/lightningnetwork/lnd/lnwallet"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
+// lockedOutpointWallet simulates an output held by btcwallet's memory locker.
+type lockedOutpointWallet struct {
+ wallet.Interface
+ leaseCalled bool
+}
+
+// LockedOutpoint reports that the test output is already locked in memory.
+func (w *lockedOutpointWallet) LockedOutpoint(wire.OutPoint) bool {
+ return true
+}
+
+// LeaseOutput records an unexpected attempt to lease the locked output.
+func (w *lockedOutpointWallet) LeaseOutput(wtxmgr.LockID, wire.OutPoint,
+ time.Duration) (time.Time, error) {
+
+ w.leaseCalled = true
+ return time.Time{}, nil
+}
+
+// TestLeaseOutputRejectsInMemoryLock verifies that the legacy lease path
+// preserves the in-memory double-lock guard.
+func TestLeaseOutputRejectsInMemoryLock(t *testing.T) {
+ t.Parallel()
+
+ backend := &lockedOutpointWallet{}
+ wallet := &BtcWallet{wallet: backend}
+
+ _, err := wallet.LeaseOutput(
+ wtxmgr.LockID{}, wire.OutPoint{}, time.Minute,
+ )
+ require.ErrorIs(t, err, wtxmgr.ErrOutputAlreadyLocked)
+ require.False(t, backend.leaseCalled)
+}
+
type previousOutpointsTest struct {
name string
tx *wire.MsgTx
### lnwallet/interface.go
@@ -221,6 +221,39 @@ 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.
+// Implementations must apply every non-zero option exactly or return an error.
+type OutputLeaserWithOptions interface {
+ // LeaseOutputWithOptions leases an output with the requested optional
+ // behavior. It returns the same sentinel errors as LeaseOutput and
+ // requires the global coin selection lock to be held. With a non-zero
+ // ReleaseAfterSpendConfs, the returned expiration is retained for
+ // compatibility but is not enforced.
+ LeaseOutputWithOptions(id wtxmgr.LockID, op wire.OutPoint,
+ duration time.Duration, opts LeaseOutputOptions) (
+ time.Time, error)
+}
+
+// WalletControllerWrapper exposes the controller wrapped by an adapter. The
+// nested controller is used to resolve optional capabilities that are not part
+// of the base WalletController interface.
+type WalletControllerWrapper interface {
+ // UnwrapWalletController returns the next controller in the adapter
+ // chain. Implementations must return a non-nil controller other than
+ // themselves.
+ UnwrapWalletController() WalletController
+}
+
// 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
### lnwallet/rpcwallet/rpcwallet.go
@@ -73,6 +73,12 @@ var _ input.Signer = (*RPCKeyRing)(nil)
var _ keychain.MessageSignerRing = (*RPCKeyRing)(nil)
var _ lnwallet.WalletController = (*RPCKeyRing)(nil)
+// UnwrapWalletController returns the watch-only wallet controller wrapped by
+// the remote-signing adapter.
+func (r *RPCKeyRing) UnwrapWalletController() lnwallet.WalletController {
+ return r.WalletController
+}
+
// NewRPCKeyRing creates a new remote signing secret key ring that uses the
// given watch-only base wallet to keep track of addresses and transactions but
// delegates any signing or ECDH operations to the remove signer through RPC.
### lnwallet/rpcwallet/rpcwallet_test.go
@@ -13,10 +13,12 @@ import (
"github.com/btcsuite/btcd/psbt/v2"
"github.com/btcsuite/btcd/txscript/v2"
"github.com/btcsuite/btcd/wire/v2"
+ "github.com/btcsuite/btcwallet/wtxmgr"
"github.com/lightningnetwork/lnd/input"
"github.com/lightningnetwork/lnd/keychain"
"github.com/lightningnetwork/lnd/lnrpc/signrpc"
"github.com/lightningnetwork/lnd/lnrpc/watchonlyrpc"
+ "github.com/lightningnetwork/lnd/lntest/mock"
"github.com/lightningnetwork/lnd/lnwallet"
"github.com/stretchr/testify/require"
)
@@ -25,6 +27,41 @@ import (
// just need *some* "wallet doesn't know this outpoint" sentinel.
var errNotMine = errors.New("not mine")
+// leaseOptionsController exposes the optional output lease capability through
+// a mock wallet controller.
+type leaseOptionsController struct {
+ *mock.WalletController
+}
+
+// LeaseOutputWithOptions satisfies lnwallet.OutputLeaserWithOptions.
+func (l *leaseOptionsController) LeaseOutputWithOptions(_ wtxmgr.LockID,
+ _ wire.OutPoint, _ time.Duration, _ lnwallet.LeaseOutputOptions) (
+ time.Time, error) {
+
+ return time.Time{}, nil
+}
+
+// TestResolveOutputLeaserRPCKeyRing verifies that optional wallet capabilities
+// remain available through the production LightningWallet and RPCKeyRing
+// adapter stack used by remote-signer nodes.
+func TestResolveOutputLeaserRPCKeyRing(t *testing.T) {
+ t.Parallel()
+
+ controller := &leaseOptionsController{
+ WalletController: &mock.WalletController{},
+ }
+ remoteWallet := &RPCKeyRing{
+ WalletController: controller,
+ }
+ wallet := &lnwallet.LightningWallet{
+ WalletController: remoteWallet,
+ }
+
+ leaser, ok := lnwallet.ResolveOutputLeaser(wallet)
+ require.True(t, ok)
+ require.Same(t, controller, leaser)
+}
+
// makeOutPoint returns a wire.OutPoint with a unique, deterministic hash so
// each test case can build inputs without colliding.
func makeOutPoint(t *testing.T, idx uint32) wire.OutPoint {
### lnwallet/wallet.go
@@ -634,6 +634,33 @@ func (l *LightningWallet) LockedOutpoints() []*wire.OutPoint {
return outPoints
}
+// ResolveOutputLeaser returns the optional output lease capability implemented
+// by a wallet controller. It traverses wallet adapters so the result reflects
+// the concrete controller rather than a wrapper's method set.
+func ResolveOutputLeaser(wallet WalletController) (
+ OutputLeaserWithOptions, bool) {
+
+ for {
+ leaser, ok := wallet.(OutputLeaserWithOptions)
+ if ok {
+ return leaser, true
+ }
+
+ wrapper, ok := wallet.(WalletControllerWrapper)
+ if !ok {
+ return nil, false
+ }
+
+ wallet = wrapper.UnwrapWalletController()
+ }
+}
+
+// UnwrapWalletController returns the base wallet controller wrapped by the
+// Lightning-aware wallet.
+func (l *LightningWallet) UnwrapWalletController() WalletController {
+ return l.WalletController
+}
+
// ResetReservations reset the volatile wallet state which tracks all currently
// active reservations.
func (l *LightningWallet) ResetReservations() {
### lnwallet/wallet_test.go
@@ -2,11 +2,60 @@ package lnwallet
import (
"testing"
+ "time"
+ "github.com/btcsuite/btcd/wire/v2"
+ "github.com/btcsuite/btcwallet/wtxmgr"
"github.com/lightningnetwork/lnd/chanstate"
"github.com/stretchr/testify/require"
)
+// leaseOptionsController records optional output lease settings.
+type leaseOptionsController struct {
+ *mockWalletController
+
+ leaseOpts LeaseOutputOptions
+}
+
+// LeaseOutputWithOptions records the options passed through the wallet wrapper.
+func (c *leaseOptionsController) LeaseOutputWithOptions(_ wtxmgr.LockID,
+ _ wire.OutPoint, _ time.Duration,
+ opts LeaseOutputOptions) (time.Time, error) {
+
+ c.leaseOpts = opts
+
+ return time.Unix(123, 0), nil
+}
+
+// TestResolveOutputLeaser verifies that optional lease capability detection
+// reflects the concrete controller behind a LightningWallet wrapper.
+func TestResolveOutputLeaser(t *testing.T) {
+ t.Parallel()
+
+ t.Run("supported", func(t *testing.T) {
+ controller := &leaseOptionsController{
+ mockWalletController: &mockWalletController{},
+ }
+ wallet := &LightningWallet{
+ WalletController: controller,
+ }
+
+ leaser, ok := ResolveOutputLeaser(wallet)
+ require.True(t, ok)
+ require.Same(t, controller, leaser)
+ })
+
+ t.Run("unsupported", func(t *testing.T) {
+ wallet := &LightningWallet{
+ WalletController: &mockWalletController{},
+ }
+
+ leaser, ok := ResolveOutputLeaser(wallet)
+ require.False(t, ok)
+ require.Nil(t, leaser)
+ })
+}
+
// TestHandleFundingCounterPartySigsMissingReservation tests the missing
// reservation response.
func TestHandleFundingCounterPartySigsMissingReservation(t *testing.T) {Why this scored 12/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.