What changed, and why it matters
This commit only adds a new integration test for an existing feature called confirmation-controlled leases. It does not change production code, so it cannot introduce a security vulnerability or fix one directly. The test exercises how long a wallet lease stays locked under various conditions (time passing, blockchain reorganization, node restart, and multiple confirmations).
No security action required. Review the related production implementation of confirmation-controlled leases separately if assessing that feature's security.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The commit is a pure test addition: it registers testFundPsbtConfirmationLease in itest/list_on_test.go and implements it in itest/lnd_psbt_test.go. The test uses FundPsbt with InputReleaseAfterSpendConfs and LockExpirationSeconds, then verifies the lease survives wall-clock expiry, shallow reorg, and node restart, and finally releases after the requested confirmation depth. No production logic is modified.
Changed components
itest/list_on_test.goitest/lnd_psbt_test.goInspect captured patch +152 / −0
### 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()
+}Why this scored 15/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.