walletrpc: release partial locks by owner ID
What changed, and why it matters
This change fixes a bug in LND's wallet RPC code that handles locking multiple bitcoin inputs at once. Previously, if locking one input failed after earlier inputs were already locked, the rollback tried to unlock the earlier inputs using LND's internal lock ID instead of the caller's actual lock ID. That meant the earlier locks might not get released, leaving funds stuck or unavailable. The patch makes the rollback use the real lock ID recorded for each acquired lease, and also rolls back on metadata lookup failures, not just lease failures.
Treat as a bug-fix patch with moderate operational/security relevance. Review and merge. Operators using FundPsbt with custom lock IDs should upgrade to avoid stuck UTXO leases on partial failures. No emergency response is indicated absent a disclosed exploit.
Security signals we found
Incorrect lock ID during rollback could leave UTXO leases unreleased
Partial multi-input lease acquisition was not all-or-nothing
Metadata lookup failure did not trigger rollback of already-acquired leases
New rollback helper improves observability by reporting surviving leases with owner ID
Evidence from the diff
In lnrpc/walletrpc/psbt.go, lockInputs now appends successful leases to a slice and, on any FetchOutpointInfo or LeaseOutput failure, calls a new rollbackInputLeases helper. That helper releases each acquired output using the recorded lock.LockID rather than the hard-coded chanfunding.LndInternalLockID. It also uses errors.Join to preserve both the original cause and any release failures, including outpoint and owner ID in the error. Tests cover both modern and legacy lease paths, custom and internal lock IDs, and metadata-failure-triggered rollback.
Changed components
lnrpc/walletrpc/psbt.golnrpc/walletrpc/psbt_test.goFundPsbt input leasing flowInspect captured patch +185 / −32
### lnrpc/walletrpc/psbt.go
@@ -4,6 +4,7 @@
package walletrpc
import (
+ "errors"
"fmt"
"math"
"time"
@@ -66,8 +67,9 @@ func lockInputs(w lnwallet.WalletController, outpoints []wire.OutPoint,
}
locks := make(
- []*base.ListLeasedOutputResult, len(outpoints),
+ []*base.ListLeasedOutputResult, 0, len(outpoints),
)
+
for idx := range outpoints {
lock := &base.ListLeasedOutputResult{
LockedOutput: &wtxmgr.LockedOutput{
@@ -88,7 +90,9 @@ func lockInputs(w lnwallet.WalletController, outpoints []wire.OutPoint,
// Get the details about this outpoint.
utxo, err := w.FetchOutpointInfo(&lock.Outpoint)
if err != nil {
- return nil, fmt.Errorf("fetch outpoint info: %w", err)
+ cause := fmt.Errorf("fetch outpoint info: %w", err)
+
+ return nil, rollbackInputLeases(w, locks, cause)
}
var expiration time.Time
@@ -106,29 +110,44 @@ func lockInputs(w lnwallet.WalletController, outpoints []wire.OutPoint,
)
}
if err != nil {
- // If we run into a problem with locking one output, we
- // should try to unlock those that we successfully
- // locked so far. If that fails as well, there's not
- // much we can do.
- for i := 0; i < idx; i++ {
- op := locks[i].Outpoint
- if err := w.ReleaseOutput(
- chanfunding.LndInternalLockID, op,
- ); err != nil {
- log.Errorf("could not release the "+
- "lock on %v: %v", op, err)
- }
- }
+ cause := fmt.Errorf("could not lease UTXO: %w", err)
- return nil, fmt.Errorf("could not lease a lock on "+
- "UTXO: %v", err)
+ return nil, rollbackInputLeases(w, locks, cause)
}
lock.Expiration = expiration
lock.PkScript = utxo.PkScript
lock.Value = int64(utxo.Value)
- locks[idx] = lock
+ locks = append(locks, lock)
}
return locks, nil
}
+
+// rollbackInputLeases releases every lease acquired before cause interrupted
+// the current FundPsbt attempt. If a release fails, the returned error includes
+// its outpoint and owner ID so the caller can identify and recover the lease.
+func rollbackInputLeases(w lnwallet.WalletController,
+ locks []*base.ListLeasedOutputResult, cause error) error {
+
+ var releaseErrs []error
+ for _, lock := range locks {
+ err := w.ReleaseOutput(lock.LockID, lock.Outpoint)
+ if err == nil {
+ continue
+ }
+
+ releaseErr := fmt.Errorf(
+ "could not release lease %v with lock ID %x: %w",
+ lock.Outpoint, lock.LockID[:], err,
+ )
+ log.Errorf("%v", releaseErr)
+ releaseErrs = append(releaseErrs, releaseErr)
+ }
+
+ if len(releaseErrs) == 0 {
+ return cause
+ }
+
+ return errors.Join(append([]error{cause}, releaseErrs...)...)
+}
### lnrpc/walletrpc/psbt_test.go
@@ -4,6 +4,7 @@
package walletrpc
import (
+ "encoding/hex"
"errors"
"testing"
"time"
@@ -12,22 +13,28 @@ import (
"github.com/btcsuite/btcwallet/wtxmgr"
"github.com/lightningnetwork/lnd/lntest/mock"
"github.com/lightningnetwork/lnd/lnwallet"
+ "github.com/lightningnetwork/lnd/lnwallet/chanfunding"
"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"
+var (
+ errTestFetchLeaseOutput = errors.New("injected fetch failure")
+ errTestLeaseOutput = errors.New("lease failed")
+ errTestReleaseOutput = errors.New("release failed")
+)
// leaseOptionsWallet records the optional lease settings passed by lockInputs.
type leaseOptionsWallet struct {
*mock.WalletController
- leaseCalls []lnwallet.LeaseOutputOptions
- legacyCalls int
- releasedIDs []wtxmgr.LockID
- failCall int
+ leaseCalls []lnwallet.LeaseOutputOptions
+ legacyCalls int
+ releasedIDs []wtxmgr.LockID
+ releaseErr error
+ failCall int
+ legacyFailCall int
+ fetchCalls int
+ failFetchCall int
}
// legacyLeaseWallet records calls to the original lease method but does not
@@ -55,7 +62,7 @@ func (w *leaseOptionsWallet) LeaseOutputWithOptions(_ wtxmgr.LockID,
w.leaseCalls = append(w.leaseCalls, opts)
if w.failCall > 0 && len(w.leaseCalls) == w.failCall {
- return time.Time{}, errors.New("lease failed")
+ return time.Time{}, errTestLeaseOutput
}
return time.Unix(123, 0), nil
@@ -66,6 +73,9 @@ func (w *leaseOptionsWallet) LeaseOutput(_ wtxmgr.LockID, _ wire.OutPoint,
_ time.Duration) (time.Time, error) {
w.legacyCalls++
+ if w.legacyFailCall > 0 && w.legacyCalls == w.legacyFailCall {
+ return time.Time{}, errTestLeaseOutput
+ }
return time.Unix(123, 0), nil
}
@@ -76,7 +86,20 @@ func (w *leaseOptionsWallet) ReleaseOutput(id wtxmgr.LockID,
w.releasedIDs = append(w.releasedIDs, id)
- return nil
+ return w.releaseErr
+}
+
+// FetchOutpointInfo records metadata lookups and can fail one call to verify
+// that lockInputs rolls back leases acquired before the lookup failed.
+func (w *leaseOptionsWallet) FetchOutpointInfo(
+ outpoint *wire.OutPoint) (*lnwallet.Utxo, error) {
+
+ w.fetchCalls++
+ if w.failFetchCall > 0 && w.fetchCalls == w.failFetchCall {
+ return nil, errTestFetchLeaseOutput
+ }
+
+ return w.WalletController.FetchOutpointInfo(outpoint)
}
// TestLockInputsForwardsReleaseAfterSpend verifies that FundPsbt's lease helper
@@ -137,12 +160,11 @@ func TestLockInputsRejectsUnsupportedLeaseOptions(t *testing.T) {
_, err := lockInputs(
wallet, []wire.OutPoint{{Index: 1}}, nil, time.Hour, 6,
)
- require.ErrorContains(
- t, err, unsupportedLeaseOptionsErr,
- )
+ require.ErrorIs(t, err, errOutputLeaseOptionsUnsupported)
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) {
@@ -156,6 +178,118 @@ func TestLockInputsRejectsUnsupportedOptionsWithoutInputs(t *testing.T) {
}
_, err := lockInputs(wallet, nil, nil, time.Hour, 6)
- require.ErrorContains(t, err, unsupportedLeaseOptionsErr)
+ require.ErrorIs(t, err, errOutputLeaseOptionsUnsupported)
require.Zero(t, controller.leaseCalls)
}
+
+// 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.ErrorIs(t, err, errTestLeaseOutput)
+ require.Equal(t, []wtxmgr.LockID{lockID}, wallet.releasedIDs)
+}
+
+// TestLockInputsLegacyRollbackUsesActualLockID verifies the zero-depth path
+// releases earlier inputs with the custom ID that acquired them.
+func TestLockInputsLegacyRollbackUsesActualLockID(t *testing.T) {
+ t.Parallel()
+
+ wallet := &leaseOptionsWallet{
+ WalletController: &mock.WalletController{},
+ legacyFailCall: 2,
+ }
+ lockID := wtxmgr.LockID{9, 8, 7}
+ outpoints := []wire.OutPoint{
+ {Index: 1},
+ {Index: 2},
+ }
+
+ _, err := lockInputs(wallet, outpoints, &lockID, time.Hour, 0)
+ require.ErrorIs(t, err, errTestLeaseOutput)
+ require.Equal(t, []wtxmgr.LockID{lockID}, wallet.releasedIDs)
+}
+
+// TestLockInputsReportsRollbackFailure verifies that a lease which survives a
+// failed rollback remains attributable by outpoint and owner ID to the caller.
+func TestLockInputsReportsRollbackFailure(t *testing.T) {
+ t.Parallel()
+
+ wallet := &leaseOptionsWallet{
+ WalletController: &mock.WalletController{},
+ failCall: 2,
+ releaseErr: errTestReleaseOutput,
+ }
+ lockID := wtxmgr.LockID{9, 8, 7}
+ outpoint := wire.OutPoint{Index: 1}
+
+ _, err := lockInputs(
+ wallet, []wire.OutPoint{outpoint, {Index: 2}}, &lockID,
+ time.Hour, 6,
+ )
+ require.ErrorIs(t, err, errTestLeaseOutput)
+ require.ErrorIs(t, err, errTestReleaseOutput)
+ require.ErrorContains(t, err, outpoint.String())
+ require.ErrorContains(t, err, hex.EncodeToString(lockID[:]))
+}
+
+// TestLockInputsRollbackOnFetchFailure verifies that a metadata failure after
+// one successful lease releases that lease with the ID that acquired it.
+func TestLockInputsRollbackOnFetchFailure(t *testing.T) {
+ t.Parallel()
+
+ customLockID := wtxmgr.LockID{9, 8, 7}
+ testCases := []struct {
+ name string
+ lockID *wtxmgr.LockID
+ expectedID wtxmgr.LockID
+ }{
+ {
+ name: "internal lock ID",
+ expectedID: chanfunding.LndInternalLockID,
+ },
+ {
+ name: "custom lock ID",
+ lockID: &customLockID,
+ expectedID: customLockID,
+ },
+ }
+
+ for _, testCase := range testCases {
+ t.Run(testCase.name, func(t *testing.T) {
+ t.Parallel()
+
+ wallet := &leaseOptionsWallet{
+ WalletController: &mock.WalletController{},
+ failFetchCall: 2,
+ }
+ outpoints := []wire.OutPoint{
+ {Index: 1},
+ {Index: 2},
+ }
+
+ _, err := lockInputs(
+ wallet, outpoints, testCase.lockID,
+ time.Hour, 6,
+ )
+ require.ErrorIs(t, err, errTestFetchLeaseOutput)
+ require.Len(t, wallet.leaseCalls, 1)
+ require.Equal(
+ t, []wtxmgr.LockID{testCase.expectedID},
+ wallet.releasedIDs,
+ )
+ })
+ }
+}Why this scored 47/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.