Merge pull request #11125 from bhandras/codex/reorg-aware-output-lock-20260825
What changed, and why it matters
This commit adds a new optional wallet feature to LND that lets users lock UTXOs until the transaction spending them reaches a chosen confirmation depth, instead of relying on a wall-clock timeout. It is a feature addition with safety checks (required custom lock ID, capability checks, rollback on partial failure) and is not a fix for a known exploit. The main risk is that a bug in the new reorg-aware lease logic could keep coins locked longer than intended or, in a worst case, allow double-spending of locked UTXOs, but the diff itself does not show such a vulnerability.
Treat as a normal feature review. Verify the bumped btcwallet/wtxmgr pre-release commits do not introduce unrelated changes. Monitor for any follow-up fixes regarding reorg handling or lease release edge cases. No immediate security patch is indicated by this commit alone.
Security signals we found
New RPC fields release_after_spend_confs and confirmed_spend_height added to LeaseOutput and FundPsbt
Custom lock ID enforced as required for confirmation-controlled FundPsbt leases; all-zero and reserved internal IDs rejected
Capability check (ResolveOutputLeaser) prevents silent downgrade to time-only lease when confirmation control is requested
Partial-failure rollback path added in lockInputs with error attribution by outpoint and lock ID
btcwallet/wtxmgr dependency bumped to pre-release versions supporting reorg-aware lease expiry
Integration test exercises wall-clock expiry gap, shallow reorg, node restart, and spend-depth release
Evidence from the diff
The merge introduces confirmation-controlled output leases across WalletKit RPCs (LeaseOutput, FundPsbt, ListLeases). It bumps btcwallet/wtxmgr to versions that support WithReleaseAfterSpend, adds LeaseOutputOptions/OutputLeaserWithOptions interfaces, resolves capabilities through LightningWallet and RPCKeyRing wrappers, validates custom lock IDs for confirmation-controlled FundPsbt leases, rolls back partially acquired leases on failure, and exposes persisted spend height and reorg state in RPC responses. The change is defensive by design and includes integration and unit tests covering reorgs, restarts, and rollback paths.
Changed components
lnrpc/walletrpc (WalletKit RPC server and PSBT helpers)lnwallet/btcwallet (BtcWallet adapter)lnwallet/rpcwallet (RPCKeyRing remote-signer adapter)lnwallet/wallet.go (LightningWallet wrapper and capability resolver)itest/lnd_psbt_test.go (integration tests)docs/release-notes/release-notes-0.21.4.mdInspect captured patch +1293 / −100
### .golangci.yml
@@ -172,6 +172,9 @@ linters:
- google.golang.org/protobuf
- github.com/lightningnetwork/lnd/sqldb
- github.com/lightningnetwork/lightning-onion
+ # Temporary pins for confirmation-controlled lease expiry.
+ - github.com/btcsuite/btcwallet
+ - github.com/btcsuite/btcwallet/wtxmgr
replace-local: true
gosec:
### docs/release-notes/release-notes-0.21.4.md
@@ -27,6 +27,13 @@
## 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`. These
+ leases ignore wall-clock expiration and expose spend progress through
+ `ListLeases`; a zero depth preserves the existing wall-clock behavior.
+
## lncli Additions
# Improvements
@@ -60,3 +67,5 @@
## Tooling and Documentation
# Contributors (Alphabetical Order)
+
+* Andras Banki-Horvath
### go.mod
@@ -16,11 +16,11 @@ require (
github.com/btcsuite/btcd/wire/v2 v2.0.0
github.com/btcsuite/btclog v1.0.0
github.com/btcsuite/btclog/v2 v2.0.1-0.20250728225537-6090e87c6c5b
- github.com/btcsuite/btcwallet v0.18.0
+ github.com/btcsuite/btcwallet v0.18.1-0.20260903142755-a960541f35ed
github.com/btcsuite/btcwallet/wallet/txauthor v1.4.0
github.com/btcsuite/btcwallet/wallet/txrules v1.3.0
github.com/btcsuite/btcwallet/walletdb v1.6.0
- github.com/btcsuite/btcwallet/wtxmgr v1.6.0
+ github.com/btcsuite/btcwallet/wtxmgr v1.6.1-0.20260903142755-a960541f35ed
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f
github.com/davecgh/go-spew v1.1.1
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0
### go.sum
@@ -57,8 +57,8 @@ 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 v0.18.1-0.20260903142755-a960541f35ed h1:2nmty4a4/GIqP+II2NXU+O6yZyRO1wgaeIjCHIZTvv4=
+github.com/btcsuite/btcwallet v0.18.1-0.20260903142755-a960541f35ed/go.mod h1:IQrr6b13s0ZTaj9RlM/LhQ+Xvmo/gK86L9oGu2upIeQ=
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 +67,8 @@ 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/btcwallet/wtxmgr v1.6.1-0.20260903142755-a960541f35ed h1:HhSbzqX5IJUlBehUY+HLAXM1At+QygEOq0FdLTC5DYY=
+github.com/btcsuite/btcwallet/wtxmgr v1.6.1-0.20260903142755-a960541f35ed/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=
### itest/list_on_test.go
@@ -310,6 +310,10 @@ var allTestCases = []*lntest.TestCase{
Name: "fund psbt custom lock",
TestFunc: testFundPsbtCustomLock,
},
+ {
+ Name: "fund psbt confirmation lease",
+ TestFunc: testFundPsbtConfirmationLease,
+ },
{
Name: "resolution handoff",
TestFunc: testResHandoff,
### itest/lnd_psbt_test.go
@@ -3,6 +3,8 @@ package itest
import (
"bytes"
"encoding/hex"
+ "errors"
+ "fmt"
"testing"
"time"
@@ -24,6 +26,7 @@ import (
"github.com/lightningnetwork/lnd/lnrpc/walletrpc"
"github.com/lightningnetwork/lnd/lntest"
"github.com/lightningnetwork/lnd/lntest/node"
+ "github.com/lightningnetwork/lnd/lntest/wait"
"github.com/lightningnetwork/lnd/lnwallet/chanfunding"
"github.com/stretchr/testify/require"
)
@@ -2041,3 +2044,148 @@ func testFundPsbtCustomLock(ht *lntest.HarnessTest) {
leasesRespAfter := alice.RPC.ListLeases()
require.Empty(ht, leasesRespAfter.LockedUtxos)
}
+
+// testFundPsbtConfirmationLease verifies the complete persisted lease
+// lifecycle. The lease must survive wall-clock expiry before confirmation, a
+// shallow reorg, and a node restart, then release at its requested spend depth.
+func testFundPsbtConfirmationLease(ht *lntest.HarnessTest) {
+ const (
+ releaseDepth = uint32(3)
+ lockDurationSecond = uint64(1)
+ )
+
+ alice := ht.NewNodeWithCoins("Alice", nil)
+ lockID := ht.Random32Bytes()
+ aliceAddr := alice.RPC.NewAddress(&lnrpc.NewAddressRequest{
+ Type: lnrpc.AddressType_WITNESS_PUBKEY_HASH,
+ })
+
+ fundResp := alice.RPC.FundPsbt(&walletrpc.FundPsbtRequest{
+ Template: &walletrpc.FundPsbtRequest_Raw{
+ Raw: &walletrpc.TxTemplate{
+ Outputs: map[string]uint64{
+ aliceAddr.Address: 100_000,
+ },
+ },
+ },
+ Fees: &walletrpc.FundPsbtRequest_SatPerVbyte{
+ SatPerVbyte: 2,
+ },
+ MinConfs: 1,
+ CustomLockId: lockID,
+ LockExpirationSeconds: lockDurationSecond,
+ InputReleaseAfterSpendConfs: releaseDepth,
+ })
+ require.NotEmpty(ht, fundResp.LockedUtxos)
+ for _, lease := range fundResp.LockedUtxos {
+ require.Equal(ht, releaseDepth, lease.ReleaseAfterSpendConfs)
+ require.Zero(ht, lease.ConfirmedSpendHeight)
+ }
+
+ // assertLeaseState waits for every lease created by this request to
+ // report the expected persisted spend height.
+ assertLeaseState := func(expectedSpendHeight int32) {
+ err := wait.NoError(func() error {
+ leases := alice.RPC.ListLeases().LockedUtxos
+ if len(leases) != len(fundResp.LockedUtxos) {
+ return fmt.Errorf("expected %d leases, got %d",
+ len(fundResp.LockedUtxos), len(leases))
+ }
+
+ for _, lease := range leases {
+ if !bytes.Equal(lockID, lease.Id) {
+ return fmt.Errorf(
+ "lease ID %x", lease.Id,
+ )
+ }
+ if lease.Expiration == 0 {
+ return errors.New("zero expiration")
+ }
+ if lease.ReleaseAfterSpendConfs !=
+ releaseDepth {
+
+ return fmt.Errorf(
+ "depth: want %d, got %d",
+ releaseDepth,
+ lease.ReleaseAfterSpendConfs,
+ )
+ }
+ if lease.ConfirmedSpendHeight !=
+ expectedSpendHeight {
+
+ return fmt.Errorf(
+ "height: want %d, got %d",
+ expectedSpendHeight,
+ lease.ConfirmedSpendHeight,
+ )
+ }
+ }
+
+ return nil
+ }, defaultTimeout)
+ require.NoError(ht, err)
+ }
+
+ // assertLeaseReleased waits until spend maturity removes every lease
+ // created by this request.
+ assertLeaseReleased := func() {
+ err := wait.NoError(func() error {
+ leases := alice.RPC.ListLeases().LockedUtxos
+ if len(leases) != 0 {
+ return fmt.Errorf("expected no leases, got %d",
+ len(leases))
+ }
+
+ return nil
+ }, defaultTimeout)
+ require.NoError(ht, err)
+ }
+
+ // Let the wall-clock deadline pass before broadcasting. A
+ // confirmation-controlled lease must remain active for this entire gap.
+ time.Sleep(2 * time.Second)
+ assertLeaseState(0)
+
+ finalizeResp := alice.RPC.FinalizePsbt(
+ &walletrpc.FinalizePsbtRequest{
+ FundedPsbt: fundResp.FundedPsbt,
+ },
+ )
+ alice.RPC.PublishTransaction(&walletrpc.Transaction{
+ TxHex: finalizeResp.RawFinalTx,
+ })
+
+ var finalTx wire.MsgTx
+ err := finalTx.Deserialize(bytes.NewReader(finalizeResp.RawFinalTx))
+ require.NoError(ht, err)
+
+ spendBlock := ht.MineBlocksAndAssertNumTxes(1, 1)[0]
+ spendHeight := int32(ht.CurrentHeight())
+ ht.AssertTxInBlock(spendBlock, finalTx.TxHash())
+ assertLeaseState(spendHeight)
+
+ // Disconnect the spending block and extend the replacement chain
+ // without the transaction. The lease must retain its disconnected-spend
+ // marker.
+ spendBlockHash := spendBlock.BlockHash()
+ require.NoError(ht, ht.Miner().InvalidateBlock(&spendBlockHash))
+ ht.MineEmptyBlocks(2)
+ assertLeaseState(-1)
+
+ // The disconnected state is durable across process restart.
+ ht.RestartNode(alice)
+ assertLeaseState(-1)
+
+ // Reconfirm the transaction. The lease remains through depth two and is
+ // released when the third confirmation connects.
+ reconfirmedBlock := ht.MineBlocksAndAssertNumTxes(1, 1)[0]
+ reconfirmedHeight := int32(ht.CurrentHeight())
+ ht.AssertTxInBlock(reconfirmedBlock, finalTx.TxHash())
+ assertLeaseState(reconfirmedHeight)
+
+ ht.MineEmptyBlocks(1)
+ assertLeaseState(reconfirmedHeight)
+
+ ht.MineEmptyBlocks(1)
+ assertLeaseReleased()
+}
### lnrpc/walletrpc/psbt.go
@@ -4,6 +4,7 @@
package walletrpc
import (
+ "errors"
"fmt"
"math"
"time"
@@ -19,6 +20,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,12 +50,26 @@ 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),
+ []*base.ListLeasedOutputResult, 0, len(outpoints),
)
+
for idx := range outpoints {
lock := &base.ListLeasedOutputResult{
LockedOutput: &wtxmgr.LockedOutput{
@@ -71,36 +90,64 @@ 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)
}
- 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
- // 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)
- }
+ 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 {
+ 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
@@ -0,0 +1,295 @@
+//go:build walletrpc
+// +build walletrpc
+
+package walletrpc
+
+import (
+ "encoding/hex"
+ "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/lightningnetwork/lnd/lnwallet/chanfunding"
+ "github.com/stretchr/testify/require"
+)
+
+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
+ releaseErr error
+ failCall int
+ legacyFailCall int
+ fetchCalls int
+ failFetchCall 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{}, errTestLeaseOutput
+ }
+
+ 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++
+ if w.legacyFailCall > 0 && w.legacyCalls == w.legacyFailCall {
+ return time.Time{}, errTestLeaseOutput
+ }
+
+ 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 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
+// 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.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) {
+ t.Parallel()
+
+ controller := &legacyLeaseWallet{
+ WalletController: &mock.WalletController{},
+ }
+ wallet := &lnwallet.LightningWallet{
+ WalletController: controller,
+ }
+
+ _, err := lockInputs(wallet, nil, nil, time.Hour, 6)
+ 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,
+ )
+ })
+ }
+}
### 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 {
### 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 35/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.