Skip to content

walletrpc: retain input leases through spend confirmation - #11125

Open
bhandras wants to merge 5 commits into
lightningnetwork:masterfrom
bhandras:codex/reorg-aware-output-lock-20260825
Open

walletrpc: retain input leases through spend confirmation#11125
bhandras wants to merge 5 commits into
lightningnetwork:masterfrom
bhandras:codex/reorg-aware-output-lock-20260825

Conversation

@bhandras

@bhandras bhandras commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Change Description

Add opt-in release-after-spend semantics to WalletKit output leases and PSBT
funding.

Some transaction protocols need a selected input to remain unavailable until a
previously broadcast spend is buried by a configured number of blocks. A normal
wall-clock lease prevents concurrent selection while a transaction is assembled,
but it ends too early for that invariant: the wallet removes the lease when the
spend first confirms. If that block is disconnected, the input can become
available for a different transaction.

This PR exposes the wallet behavior added by
btcwallet#1351 without changing
existing callers.

RPC surface

  • LeaseOutputRequest.release_after_spend_confs applies the behavior to an
    explicitly leased output.
  • FundPsbtRequest.input_release_after_spend_confs applies it to every input
    selected and leased by FundPsbt.
  • LeaseOutputResponse and UtxoLease echo the requested depth after the wallet
    successfully applies it. A non-zero echo lets callers detect servers that do not
    understand the option.

A zero value preserves the current wall-clock lease behavior.

Wallet boundary

lnwallet.OutputLeaserWithOptions is an optional capability. It avoids widening
the existing WalletController.LeaseOutput method and keeps other wallet
implementations source-compatible. ResolveOutputLeaser unwraps LightningWallet to inspect its concrete
controller, and the btcwallet controller maps the request to the persisted
btcwallet option.

When a non-zero depth is requested from a wallet that does not implement the
capability, the RPC returns an error. Silently falling back to an ordinary lease
would violate the caller's input-reuse invariant.

Partial-lock rollback fix

The fourth commit fixes an existing rollback bug exposed by the new test seam.
If leasing several inputs failed after a caller supplied a custom lock ID,
lockInputs attempted to release earlier inputs with LND's internal lock ID.
Those releases could not match the acquired leases. The rollback now uses each
lease's actual LockID.

Dependency state

The root module pins the canonical upstream pseudo-versions at btcwallet merge
5c2f9a351a0a56cd4ac9de2cf1224b0cbfbe8add. The temporary fork replacements
are gone. The pins can move to tagged btcwallet and wtxmgr releases once those
are published.

Steps to Test

go test -tags=walletrpc ./lnrpc/walletrpc
go test ./lnwallet/btcwallet -run '^$'
go test ./lnwallet -run '^$'
make tidy-module-check
make unit-module

Local result: all five commands pass.

TestLockInputsForwardsReleaseAfterSpend verifies the requested depth reaches
every selected input. TestLockInputsRejectsUnsupportedLeaseOptions,
TestLockInputsRejectsUnsupportedOptionsWithoutInputs, and
TestLeaseOutputRejectsUnsupportedOptions prove both RPC entry points fail
before calling the legacy lease method, including when FundPsbt acquires no
new input. TestLockInputsRollbackUsesActualLockID
covers the custom lock ID rollback regression.

Pull Request Checklist

Testing

  • Your PR passes all CI checks.
  • Positive and error-path tests are included.
  • The partial-lock rollback bug has a regression test.

Code Style and Documentation

  • The change is scoped to output lease lifetime and RPC plumbing.
  • Exported additions and proto fields are documented.
  • Generated RPC and Swagger files were produced with make rpc.
  • Commits are small and signed.
  • No logging statements or lncli commands were added.
  • Release note added with the upstream btcwallet PR link.

@github-actions github-actions Bot added the severity-critical Requires expert review - security/consensus critical label Aug 26, 2026
@github-actions

Copy link
Copy Markdown

🔴 PR Severity: CRITICAL

gh pr view | 13 files | 368 lines changed

🔴 Critical (3 files)
  • lnwallet/btcwallet/btcwallet.go - wallet backend implementation (lnwallet/*)
  • lnwallet/interface.go - core wallet interface definitions (lnwallet/*)
  • lnwallet/wallet.go - wallet operations (lnwallet/*)
🟠 High (5 files)
  • lnrpc/walletrpc/psbt.go - PSBT RPC handling (lnrpc/*)
  • lnrpc/walletrpc/walletkit.proto - RPC/API definition (lnrpc/*)
  • lnrpc/walletrpc/walletkit.swagger.json - generated API doc (lnrpc/*)
  • lnrpc/walletrpc/walletkit.pb.go - generated from proto (lnrpc/*)
  • lnrpc/walletrpc/walletkit_server.go - RPC server implementation (lnrpc/*)
🟡 Medium (2 files)
  • go.mod - dependency change
  • go.sum - dependency checksums
🟢 Low (3 files)
  • docs/release-notes/release-notes-0.22.0.md - release notes
  • lnrpc/walletrpc/psbt_test.go - test-only change
  • lnrpc/walletrpc/walletkit_server_test.go - test-only change

Analysis

The highest-severity files touched are in lnwallet/* (btcwallet.go, interface.go, wallet.go), which cover core wallet operations — this drives the overall severity to CRITICAL regardless of the other changes. The PR also extends the walletrpc PSBT-related RPC surface (lnrpc/*, HIGH) with corresponding proto/server/generated-code updates, plus a routine dependency bump (go.mod/go.sum, MEDIUM). File and line counts, excluding tests and auto-generated code, are well under the bump thresholds (10 files, ~196 lines), so no additional severity bump applies beyond the CRITICAL baseline set by the wallet package changes. Given the wallet-code and signing-adjacent surface area, this warrants expert review.


To override, add a severity-override-{critical,high,medium,low} label.

@bhandras
bhandras force-pushed the codex/reorg-aware-output-lock-20260825 branch 3 times, most recently from 8f28756 to 34267bd Compare August 26, 2026 13:43
@bhandras
bhandras marked this pull request as ready for review August 26, 2026 15:14
@bhandras
bhandras force-pushed the codex/reorg-aware-output-lock-20260825 branch 3 times, most recently from d276ae4 to f543cac Compare August 31, 2026 14:18
@bhandras

Copy link
Copy Markdown
Collaborator Author

/gateway review

Review target: #11125, base master at 1f324ebda, head f543cac3c3a5c16ef90a78d0ef29fca4afd5f057.

Purpose and invariant: a caller may request that wallet inputs remain leased until their confirmed spend reaches a chosen depth. A shallow reorganization must not make those inputs selectable for a different transaction. Zero must preserve the existing wall-clock lease behavior. A non-zero request must fail closed if the wallet cannot install the stronger lease.

Verify these concrete paths:

  1. LeaseOutput forwards the requested depth, returns the accepted depth, and rejects unsupported non-zero options before falling back to the legacy method.
  2. FundPsbt applies the depth to every selected input, reports it through lease responses, and releases partial acquisitions with each lease's actual owner ID.
  3. The optional OutputLeaserWithOptions capability preserves source compatibility for other wallet backends while the btcwallet backend maps the request to persisted release-after-spend behavior.
  4. Generated protobuf and Swagger output match the source schema, and zero-value wire compatibility is preserved.
  5. The direct module requirements resolve to merged btcwallet commit 5c2f9a351a0a56cd4ac9de2cf1224b0cbfbe8add; no temporary fork replacement remains.

Risk discipline: for every finding, give exact preconditions, an execution trace, consequence, realistic likelihood, existing guards or recovery, whether this PR introduces or worsens it, the smallest failing regression, and the lowest-risk fix. Classify it as blocker, follow-up, or non-actionable. Do not block on speculative states, pre-existing debt, comments, or broad redesigns without a reachable material failure.

Validation already passed locally: the focused WalletKit test suite, lnwallet and btcwallet-controller compile checks, make tidy-module-check, and make unit-module, including PostgreSQL, SQLite, and etcd submodules. Full GitHub CI is running on this head.

Stop when the intended invariant is proved and no concrete PR-introduced blocker remains. In the summary, state one final assessment: BLOCKED, READY WITH FOLLOW-UPS, or READY.

@bhandras
bhandras force-pushed the codex/reorg-aware-output-lock-20260825 branch from f543cac to 6e51c49 Compare August 31, 2026 14:33

@lightninglabs-gateway lightninglabs-gateway Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gateway review — 4 findings

🔴 0 Blocker · 🟠 0 Major · 🟡 4 Minor · 🔵 0 Nit

Summary

The change threads an opt-in release_after_spend_confs from two RPC entry points down to btcwallet's persisted lease option, and the plumbing is sound: the depth reaches every input FundPsbt selects, both entry points reject a non-zero request before touching the legacy lease method, and LeaseOutputOptions / OutputLeaserWithOptions keep other WalletController implementations source-compatible. The partial-lock rollback fix is correct and is a strict improvement — when no custom lock ID is supplied, locks[i].LockID is chanfunding.LndInternalLockID, so the default path is unchanged while the custom-ID path now releases with the ID that acquired the lease.

Wire compatibility holds. All four new fields take previously unused tag numbers (LeaseOutputRequest 4, LeaseOutputResponse 2, FundPsbtRequest 15, UtxoLease 6), are uint32, and default to zero, which preserves the wall-clock path. The generated .pb.go raw descriptor and the Swagger definitions match the .proto source, including the size-prefix updates on the four affected messages. Both direct requirements resolve to pseudo-versions carrying 5c2f9a351a0a, and go.mod has no btcwallet replace left.

Nothing here is a blocker. What I found is one stale lint exemption that contradicts the PR's own dependency claim, a response-echo that does not deliver the fail-closed signal the description promises, and two boundary details worth tightening. Two of the findings turn on btcwallet internals that are not in the reviewed context; I have flagged those as uncertainty rather than asserting a defect.

Bot commands
  • /gateway re-review — re-run after pushing changes (maintainers)
  • /gateway dismiss <id> — silence a finding (maintainers)
  • /gateway explain <id> — elaborate on a finding (anyone)

Comment thread .golangci.yml Outdated
- github.com/lightningnetwork/lnd/sqldb
- github.com/lightningnetwork/lightning-onion
# Temporary pins for configurable persisted output leases.
- github.com/btcsuite/btcwallet

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F1 (Minor) — Lint exemption permits btcwallet replaces the PR just removed · .golangci.yml:176

This PR removes the temporary fork replacements and go.mod contains no replace for github.com/btcsuite/btcwallet or its wtxmgr submodule, yet these two entries are added to gomoddirectives' replace-allow-list with a comment describing them as temporary pins. They are dead configuration today and permanently exempt any future btcwallet replace from the lint that would otherwise surface it for review.

@bhandras bhandras Aug 31, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The temporary btcwallet and wtxmgr allowlist entries are removed with the fork replacements. The current branch has no .golangci.yml diff.


// Convert the lock leases to the RPC format.
rpcLocks := marshallLeases(locks)
for _, lock := range rpcLocks {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F2 (Minor) — Echoed depth reflects the request, not the installed lease · lnrpc/walletrpc/walletkit_server.go:2313

This loop overwrites every UtxoLease.ReleaseAfterSpendConfs that marshallLeases derived from the wallet's ListLeasedOutputResult, and LeaseOutput at lnrpc/walletrpc/walletkit_server.go:547 returns req.ReleaseAfterSpendConfs directly, so both responses are tautological echoes of the input. The proto documents these as the "accepted" and "applied" depth, so a caller cannot use them to fail closed if the wallet installed something other than what was asked for — the value is equal to the request whatever the wallet did.

@bhandras bhandras Aug 31, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clarified the contract and RPC docs. OutputLeaserWithOptions implementations must apply every non-zero option exactly or return an error. The response reports the depth only after that call succeeds. The docs now describe the non-zero echo as server capability confirmation, not an independent backend readback.

Comment thread lnwallet/btcwallet/btcwallet.go Outdated
func (b *BtcWallet) LeaseOutput(id wtxmgr.LockID, op wire.OutPoint,
duration time.Duration) (time.Time, error) {

return b.LeaseOutputWithOptions(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F3 (Minor) — Every existing lease caller is rerouted to a new wallet method · lnwallet/btcwallet/btcwallet.go:1147

BtcWallet.LeaseOutput now routes all pre-existing lease callers — channel funding, the sweeper, the plain LeaseOutput RPC — through b.wallet.LeaseOutputWithOptions with an empty option slice rather than b.wallet.LeaseOutput. I cannot tell from this diff whether those two btcwallet entry points are equivalent for zero options, since btcwallet is not in the reviewed context; keeping the no-option path on b.wallet.LeaseOutput and calling the WithOptions variant only when opts.ReleaseAfterSpendConfs > 0 would remove the question without changing the new behavior.

@bhandras bhandras Aug 31, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No code change needed. I verified the merged btcwallet implementation: Wallet.LeaseOutput delegates directly to LeaseOutputWithOptions with no options. The existing zero-option path is therefore identical, and the LND adapter preserves that delegation.

Comment thread lnwallet/wallet.go Outdated
op wire.OutPoint, duration time.Duration,
opts LeaseOutputOptions) (time.Time, error) {

leaser, ok := l.WalletController.(OutputLeaserWithOptions)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F4 (Minor) — Capability probe always succeeds for LightningWallet · lnwallet/wallet.go:645

Defining this method on the wrapper makes *LightningWallet satisfy OutputLeaserWithOptions unconditionally, whether or not the embedded WalletController supports options, so the w.(lnwallet.OutputLeaserWithOptions) probe used by both new RPC call sites is not a reliable capability test for that type — rejection falls through to this forwarder's separate error string instead. Behavior stays fail-closed either way, but both new rejection tests pass a bare controller rather than a LightningWallet, so whichever of the two gates production actually hits is untested.

@bhandras bhandras Aug 31, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Added direct LightningWallet wrapper coverage. It verifies that supported controllers receive the exact options and that unsupported concrete controllers fail closed through the production forwarding path.

@lightninglabs-gateway

lightninglabs-gateway Bot commented Aug 31, 2026

Copy link
Copy Markdown

🤖 gateway audit metadata for this PR — auto-generated, please don't edit.

@bhandras
bhandras force-pushed the codex/reorg-aware-output-lock-20260825 branch from 6e51c49 to ce6eddc Compare August 31, 2026 14:47
@bhandras

Copy link
Copy Markdown
Collaborator Author

/gateway re-review

Head: ce6eddc

All four prior findings have individual replies. The stale lint allowlist is gone. The option contract and RPC wording are explicit. The zero-option btcwallet delegation was verified against the merged implementation. Direct LightningWallet forwarding and fail-closed tests were added.

Please re-check only concrete defects introduced or worsened by this PR. Classify each remaining item as blocker, follow-up, or not worth changing.

@lightninglabs-gateway lightninglabs-gateway Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gateway review — 9 findings

🔴 0 Blocker · 🟠 1 Major · 🟡 8 Minor · 🔵 0 Nit

Summary

The four prior findings are all answered in substance: the stale lint allowlist is gone, the option contract and RPC field wording are now explicit, the zero-option btcwallet delegation was verified upstream, and direct *LightningWallet forwarding coverage was added for both the supported and unsupported cases. The partial-lock rollback fix and its regression test are correct — releasing with each lease's acquiring LockID is right for both the custom and internal ID cases, and the i < idx bound cannot touch an unassigned slot.

One new blocker-adjacent gap remains, and it is the same invariant the PR sets out to establish. Both the capability probe and the option application live inside lockInputs' per-outpoint loop, so a FundPsbt request carrying a non-zero depth that ends up leasing no new inputs is neither honoured nor rejected: capability detection becomes a function of how many inputs coin selection happened to add, not of what the wallet supports. Everything else new is minor — a pre-existing rollback hole in the function this PR rewrites, an untested zero-depth branch, an unbounded depth with no unit in the field name, and documentation that only describes the option at field level.

Two prior findings stay open at minor because the code did not change: the depth reported to clients is still request-derived rather than read back from the lease record, and the RPC-level capability probe is still vacuous for the type lnd actually passes in production, which means the error string the new RPC test asserts is not the one a real client sees.


Status of prior findings

  • F1 addressed: The .golangci.yml diff is gone at this head — the file is not in the changed-file set, so the dead replace-allow-list entries for btcwallet/wtxmgr no longer exist alongside a go.mod that carries no matching replace.
Bot commands
  • /gateway re-review — re-run after pushing changes (maintainers)
  • /gateway dismiss <id> — silence a finding (maintainers)
  • /gateway explain <id> — elaborate on a finding (anyone)

Comment thread lnrpc/walletrpc/psbt.go Outdated
)
var expiration time.Time
if releaseAfterSpendConfs > 0 {
leaser, ok := w.(lnwallet.OutputLeaserWithOptions)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 F5 (Major) — FundPsbt accepts a lease depth it never applies · lnrpc/walletrpc/psbt.go:80

A FundPsbt request with a non-zero input_release_after_spend_confs returns success without installing any release-after-spend lease, and without the unsupported-wallet error, whenever the call leases no new inputs. A caller that wired the field once and checks only err == nil believes spend-maturity leases are in force when none exist, and the same request against the same wallet succeeds or fails on capability depending only on whether coin selection had to add an input.

Why this matters

Both enforcement points are scoped to for idx := range outpoints: the w.(lnwallet.OutputLeaserWithOptions) assertion on this line and the LeaseOutputWithOptions call below it. fundPsbtCoinSelect calls lockAndCreateFundingResponse(packet, nil, changeIndex, ...) on the branch taken when the template already carries enough inputs, so outpoints is nil, the loop body never executes, and lockInputs returns nil, nil. The overwrite loop in lockAndCreateFundingResponse then has an empty rpcLocks to stamp, so the response carries an empty locked_utxos list — which is also the one response shape where a new client cannot distinguish a new server from an old server that ignored unknown field 15. A wallet with no OutputLeaserWithOptions support is likewise never probed on this path, so the fail-closed rule stated in the OutputLeaserWithOptions doc comment is not honoured for it. Hoisting the capability probe and the releaseAfterSpendConfs > 0 decision above the loop — or validating once at the FundPsbt entry point before template dispatch — makes the outcome depend on the wallet rather than on the input count. Note the defect is the absent error, not a forged lease: no weaker lease is installed on this path.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Capability resolution now happens before the outpoint loop. A non-zero request against an unsupported concrete controller fails even when outpoints is empty. Added TestLockInputsRejectsUnsupportedOptionsWithoutInputs for this exact path.

Comment thread lnrpc/walletrpc/psbt.go
@@ -74,9 +75,29 @@ func lockInputs(w lnwallet.WalletController, outpoints []wire.OutPoint,
return nil, fmt.Errorf("fetch outpoint info: %w", err)

@lightninglabs-gateway lightninglabs-gateway Bot Aug 31, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 Dismissed by @bhandras

🟡 F6 (Minor) — Lease rollback skipped on the outpoint-info error path · lnrpc/walletrpc/psbt.go:75

lockInputs returns on a FetchOutpointInfo failure without running the compensating release loop that sits a few statements below it, so at idx > 0 the leases already taken stay in place while the caller receives an error and no locks slice telling it which outpoints to release. The leak predates this PR, but the fourth commit rewrites the sibling rollback in this same function, and a non-zero depth may extend how long the orphaned leases survive — I cannot tell from the diff whether the bumped wtxmgr still honours duration as an upper bound for a spend-gated lease, which is why this is minor rather than major.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not worth changing in this PR. The fetch-error rollback gap predates this change. With no funded transaction returned, the new spend-gated state is not activated, so the already acquired lease still clears at its normal wall-clock expiration. This PR does not worsen the reachable outcome.


// TestLockInputsForwardsReleaseAfterSpend verifies that FundPsbt's lease helper
// passes the requested confirmation depth to every selected input.
func TestLockInputsForwardsReleaseAfterSpend(t *testing.T) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F7 (Minor) — Zero-depth legacy lease path is untested · lnrpc/walletrpc/psbt_test.go:72

The new if releaseAfterSpendConfs > 0 / else branch is only tested on the option-bearing side; nothing asserts that a zero depth against an options-capable wallet still routes to w.LeaseOutput instead of installing an options lease, which is the opt-in guarantee the PR leads with. leaseOptionsWallet already records leaseCalls, so a third case calling lockInputs(wallet, outpoints, &lockID, time.Hour, 0) and asserting require.Empty(t, wallet.leaseCalls) closes it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Added TestLockInputsUsesLegacyPathForZeroDepth. It verifies one legacy LeaseOutput call and zero option-bearing calls against an options-capable wallet.


// Keep each acquired input lease until its spending transaction reaches
// this confirmation count. A reorganization resets maturity progress.
uint32 input_release_after_spend_confs = 15;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F8 (Minor) — New lease option is undocumented at the RPC level · lnrpc/walletrpc/walletkit.proto:1664

The LeaseOutput, FundPsbt and ReleaseOutput service comments are unchanged, so the option is discoverable only from the field comments, and no method-level text states that a non-zero depth against an incapable wallet fails the whole call rather than degrading. Two interactions are also unstated: the precedence between expiration_seconds and the new depth, though the server applies both to the same lease, and whether ReleaseOutput — documented to unlock an output "if it remains unspent" — can reclaim a lease whose spending transaction has already been broadcast, which is exactly the question FundPsbt's closing NOTE about releasing locked UTXOs now raises.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Added method-level docs for LeaseOutput, FundPsbt, and ReleaseOutput. They now state capability failure, wall-clock versus confirmed-spend precedence, the no-new-input capability check, and explicit release of a retained spent-output lease.

if req.ExpirationSeconds != 0 {
duration = time.Duration(req.ExpirationSeconds) * time.Second
}
releaseAfterSpendConfs := req.ReleaseAfterSpendConfs

@lightninglabs-gateway lightninglabs-gateway Bot Aug 31, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 Dismissed by @bhandras

🟡 F9 (Minor) — Lease depth accepted with no ceiling or unit · lnrpc/walletrpc/walletkit_server.go:513

The requested depth is forwarded with no upper bound, and neither the field name nor the type carries a unit, directly beneath expiration_seconds: transposing the two (expiration_seconds: 6, release_after_spend_confs: 600) marshals cleanly and yields a 600-block hold where a ten-minute lock was intended. An unbounded lease lifetime is already reachable today through expiration_seconds, so this is not a new class of exposure, but a sanity ceiling rejected before the coin-select lock is taken would keep the transposition diagnosable rather than silently persistent.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not worth changing. The depth is caller-controlled policy, just like the existing duration. A universal ceiling would be arbitrary and could reject legitimate long-lived protocols. The field and method docs now state that the value is a confirmation count.

)
}

// LeaseOutputWithOptions locks an output and applies optional persisted lease

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F10 (Minor) — New lease method drops its locking and error contract · lnwallet/btcwallet/btcwallet.go:1152

BtcWallet.LeaseOutputWithOptions is now the method that actually takes the lease and returns wtxmgr.ErrOutputAlreadyLocked, but its doc comment restates neither the global-coin-selection-lock requirement nor the wtxmgr.ErrUnknownOutput / wtxmgr.ErrOutputAlreadyLocked sentinels that BtcWallet.LeaseOutput and WalletController.LeaseOutput both document. Both current callers do hold the lock, so this is a documentation gap rather than a live race, but OutputLeaserWithOptions is a new public capability and the next implementor has nothing in scope stating either obligation.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The optional interface and btcwallet implementation now document the global coin-selection-lock requirement and the same unknown-output and already-locked sentinel behavior as the legacy method.


// Convert the lock leases to the RPC format.
rpcLocks := marshallLeases(locks)
for _, lock := range rpcLocks {

@lightninglabs-gateway lightninglabs-gateway Bot Aug 31, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 Dismissed by @bhandras

🟡 F2 (Minor) · lnrpc/walletrpc/walletkit_server.go:2313 · partially_addressed

The contract and field wording now match what the code actually guarantees — OutputLeaserWithOptions requires implementations to apply every non-zero option exactly or error, so a successful return does imply the depth was applied, and the echo is documented as capability confirmation rather than a backend readback. What remains is the field's split provenance: marshallLeases now reads lock.ReleaseAfterSpendConfs from the wallet's lease record, but lockInputs never sets it on the results it fabricates, so on the FundPsbt path the marshalled value is always zero and this loop patches the request value back over it. One struct field is therefore fed from two different sources depending on which RPC produced it, and a later refactor that drops this loop turns the FundPsbt echo silently into zero. Setting lock.ReleaseAfterSpendConfs = releaseAfterSpendConfs inside lockInputs next to lock.Expiration, and deleting this loop, makes the single assignment in marshallLeases meaningful for both callers.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not worth changing. The response is a capability acknowledgement after a successful call, not an independent integrity readback. The owning interface now requires every non-zero option to be applied exactly or return an error. A second query path would add another source of truth without a reachable in-tree implementation that can silently install a different value.

Comment thread lnwallet/btcwallet/btcwallet.go Outdated
func (b *BtcWallet) LeaseOutput(id wtxmgr.LockID, op wire.OutPoint,
duration time.Duration) (time.Time, error) {

return b.LeaseOutputWithOptions(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F3 (Minor) · lnwallet/btcwallet/btcwallet.go:1147 · unresolved

Still present as written: BtcWallet.LeaseOutput remains a thin delegation, so every pre-existing zero-option caller — channel funding, the sweeper, the plain LeaseOutput RPC — now reaches b.wallet.LeaseOutputWithOptions with an empty option slice instead of b.wallet.LeaseOutput. Your verification that upstream Wallet.LeaseOutput itself delegates to LeaseOutputWithOptions is recorded and I have no evidence against it, but it is not checkable from the review context (the module cache is unreadable here and btcwallet.go's blame came back fetch_failed), and nothing in this tree pins that equivalence against future upstream drift on a path the feature was never meant to touch. Keeping the no-option call on b.wallet.LeaseOutput and routing only opts.ReleaseAfterSpendConfs > 0 through the new method removes the question at no cost to the new behavior.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The legacy LND adapter path now calls b.wallet.LeaseOutput directly. Only the option-bearing path calls LeaseOutputWithOptions, so this no longer depends on proving the two upstream entry points equivalent.

Comment thread lnwallet/wallet.go Outdated
func (l *LightningWallet) LeaseOutputWithOptions(id wtxmgr.LockID,
op wire.OutPoint, duration time.Duration,
opts LeaseOutputOptions) (time.Time, error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F4 (Minor) · lnwallet/wallet.go:645 · partially_addressed

TestLightningWalletLeaseOutputWithOptions closes the coverage gap — the unsupported subtest now exercises the real production forwarding path and confirms it fails closed. The structural half is unchanged: because lnd passes *LightningWallet as the walletrpc Config.Wallet, and this method exists unconditionally, the w.(lnwallet.OutputLeaserWithOptions) probes in both RPC call sites always succeed in production, so the fail-fast-before-any-lease check is unreachable and the message a real client sees is this method's "wallet controller does not support output lease options" — not the "wallet does not support release-after-spend output leases" string that TestLeaseOutputRejectsUnsupportedOptions asserts against a bare legacyLeaseWallet. In lockInputs that error is additionally wrapped as "could not lease a lock on UTXO", attributing a wallet-capability problem to a specific outpoint. Collapsing the two messages into one shared sentinel would make the tested string and the production string the same one.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. ResolveOutputLeaser unwraps LightningWallet and tests the concrete controller. Both RPC paths use it. The rejection tests now pass the production wrapper shape, so unsupported controllers fail at the same gate clients use.

@bhandras
bhandras force-pushed the codex/reorg-aware-output-lock-20260825 branch 2 times, most recently from 4cc4d19 to c6262a6 Compare August 31, 2026 15:24
@bhandras

Copy link
Copy Markdown
Collaborator Author

/gateway dismiss F2

The response is intentionally a capability acknowledgement after the owning interface succeeds. Every non-zero option must be applied exactly or return an error. An independent readback would expand the interface and create a second source of truth without a concrete silent-mismatch path.

@lightninglabs-gateway

Copy link
Copy Markdown

🚫 Dismissed F2 (minor) by @bhandrasno reason given

Open findings on this PR: 🟡 F3 (minor) · 🟡 F4 (minor) · 🟠 F5 (major) · 🟡 F6 (minor) · 🟡 F7 (minor) · 🟡 F8 (minor) · 🟡 F9 (minor) · 🟡 F10 (minor)

@bhandras

Copy link
Copy Markdown
Collaborator Author

/gateway dismiss F6

This is pre-existing. A fetch failure returns no funded transaction, so the spend-gated state is not activated and prior acquisitions still clear at their ordinary wall-clock expiry. The PR does not worsen the reachable outcome.

@lightninglabs-gateway

Copy link
Copy Markdown

🚫 Dismissed F6 (minor) by @bhandrasno reason given

Open findings on this PR: 🟡 F3 (minor) · 🟡 F4 (minor) · 🟠 F5 (major) · 🟡 F7 (minor) · 🟡 F8 (minor) · 🟡 F9 (minor) · 🟡 F10 (minor)

@bhandras

Copy link
Copy Markdown
Collaborator Author

/gateway dismiss F9

The confirmation depth is caller-controlled lease policy, like the existing duration. A universal ceiling would be arbitrary and reject valid long-lived protocols. The field and method docs identify the unit and lifecycle.

@lightninglabs-gateway

Copy link
Copy Markdown

🚫 Dismissed F9 (minor) by @bhandrasno reason given

Open findings on this PR: 🟡 F3 (minor) · 🟡 F4 (minor) · 🟠 F5 (major) · 🟡 F7 (minor) · 🟡 F8 (minor) · 🟡 F10 (minor)

@bhandras

Copy link
Copy Markdown
Collaborator Author

/gateway re-review

Head: c6262a6

Review only the changes since ce6eddc.

F5 is fixed by resolving concrete-controller capability before the outpoint loop, with a no-input regression. F3 now keeps the exact legacy adapter call. F4 uses the same concrete-controller resolver in both production RPC paths and wrapper-shaped tests. F7, F8, and F10 have tests or docs. F2, F6, and F9 were dismissed with reachability and scope reasons.

Stop if no concrete correctness, funds-safety, security, or severe reliability defect introduced or worsened by this PR remains. Final assessment: BLOCKED, READY WITH FOLLOW-UPS, or READY.

@lightninglabs-gateway

lightninglabs-gateway Bot commented Aug 31, 2026

Copy link
Copy Markdown

❌ gateway re-review failed: code-review skill failed across all auth modes: You've hit your session limit · resets 4:50pm (UTC)

See workflow logs for details.

@bhandras

Copy link
Copy Markdown
Collaborator Author

/gateway re-review

@lightninglabs-gateway lightninglabs-gateway Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gateway review — 2 findings

🔴 0 Blocker · 🟠 1 Major · 🟡 1 Minor · 🔵 0 Nit

Summary

All seven live prior findings are addressed at c6262a64. The .golangci.yml allowlist is gone, the capability probe in lockInputs is hoisted above the outpoint loop with a no-input regression test, ResolveOutputLeaser gives both RPC entry points a real concrete-controller gate that the rejection tests now exercise through the production LightningWallet shape, the zero-depth legacy route has a test, and the proto method docs and LeaseOutputWithOptions contract now state the fail-closed rule, the wall-clock/confirmed-spend precedence and the lock/sentinel obligations. F2, F6 and F9 were dismissed and are not restated.

The F3 remediation introduced one regression: splitting the option-bearing path out of BtcWallet.LeaseOutput left the in-memory double-lock guard behind in the new method, so the legacy path every pre-existing lease caller uses — channel funding, the plain LeaseOutput RPC, the zero-depth FundPsbt route — no longer performs it. Restoring it is a three-line change and does not touch the new behavior. Separately, the wall-clock expiry still governs a retained lease until its spend confirms, so a caller that extends the lease while waiting may need to repeat the depth; that interaction is undocumented.

Final assessment: BLOCKED on F11. F12 is a follow-up.


Status of prior findings

  • F1 addressed: No .golangci.yml in files[] at this head and no replace for btcwallet or wtxmgr in go.mod — the stale allowlist entries and the fork replacements are both gone.
  • F3 addressed: lnwallet/btcwallet/btcwallet.go:1147 — the no-option path now calls b.wallet.LeaseOutput(id, op, duration) directly, so the change no longer depends on the two upstream entry points being equivalent. See F11 for what the split left behind.
  • F4 addressed: The unconditional forwarder is replaced by ResolveOutputLeaser (lnwallet/wallet.go:640), which unwraps *LightningWallet and asserts on the concrete controller; both RPC call sites use it, and TestResolveOutputLeaser, TestLockInputsRejectsUnsupportedLeaseOptions and TestLeaseOutputRejectsUnsupportedOptions now drive the production wrapper shape.
  • F5 addressed: lnrpc/walletrpc/psbt.go:51-62 resolves the capability before for idx := range outpoints, so a non-zero depth against an incapable wallet now errors even when coin selection adds no input; TestLockInputsRejectsUnsupportedOptionsWithoutInputs covers exactly that call (lockInputs(wallet, nil, nil, time.Hour, 6)).
  • F7 addressed: TestLockInputsUsesLegacyPathForZeroDepth asserts legacyCalls == 1 and require.Empty(wallet.leaseCalls) against an options-capable wallet, pinning the opt-in guarantee.
  • F8 addressed: lnrpc/walletrpc/walletkit.proto now carries method-level text on LeaseOutput, ReleaseOutput and FundPsbt stating the capability failure, the wall-clock-until-confirmed-spend precedence, the no-new-input capability check, and that a retained lease can still be released after its spend is observed; the generated _grpc.pb.go and Swagger copies match.
  • F10 addressed: lnwallet/btcwallet/btcwallet.go:1150-1155 documents wtxmgr.ErrUnknownOutput, wtxmgr.ErrOutputAlreadyLocked and the global coin-selection-lock requirement, and OutputLeaserWithOptions in lnwallet/interface.go:232 repeats both obligations for future implementors.
Bot commands
  • /gateway re-review — re-run after pushing changes (maintainers)
  • /gateway dismiss <id> — silence a finding (maintainers)
  • /gateway explain <id> — elaborate on a finding (anyone)

func (b *BtcWallet) LeaseOutput(id wtxmgr.LockID, op wire.OutPoint,
duration time.Duration) (time.Time, error) {

return b.wallet.LeaseOutput(id, op, duration)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 F11 (Major) — LeaseOutput no longer rejects in-memory-locked outputs · lnwallet/btcwallet/btcwallet.go:1147

BtcWallet.LeaseOutput no longer refuses to lease an outpoint that is locked by btcwallet's in-memory locker, so every pre-existing caller — channel funding through chanfunding's CoinLeaser, the plain LeaseOutput RPC, the zero-depth lockInputs route — now receives a successful persisted lease where the pre-PR code returned wtxmgr.ErrOutputAlreadyLocked. Two subsystems can then each believe they hold that input.

Why this matters

The guard was not deleted, it was relocated: the diff's context lines show if b.wallet.LockedOutpoint(op) { return time.Time{}, wtxmgr.ErrOutputAlreadyLocked } sitting inside LeaseOutput at the base SHA, and after the split it belongs to LeaseOutputWithOptions while LeaseOutput is reduced to a bare return b.wallet.LeaseOutput(id, op, duration). This was not part of the F3 remediation as described ("the legacy LND adapter path now calls b.wallet.LeaseOutput directly") — routing zero-option calls past LeaseOutputWithOptions did not require dropping the check, and the option-bearing path is unaffected either way.

How bad it is depends on whether anything sets the in-memory lock on this wallet: nothing in the loaded btcwallet.go or lnwallet/wallet.go calls LockOutpoint (LightningWallet.LockedOutpoints reads lnd's own lockedOutPoints map, a separate mechanism), and I cannot see the btcwallet dependency or the remote-signing wrapper from this context. If no caller sets it today, this is a silently disarmed guard rather than a live conflict — but the comment states the invariant it was protecting, and LeaseOutput is high-reach, so the next in-memory locker inherits the bug. Restoring the three guard lines at the top of LeaseOutput (or having LeaseOutput delegate to a shared helper that keeps them) returns the exact pre-PR behavior. A regression test asserting ErrOutputAlreadyLocked from LeaseOutput when the mock reports LockedOutpoint(op) == true would pin it.

@bhandras bhandras Aug 31, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed and folded into eeb239f. Restored the LockedOutpoint guard in the legacy BtcWallet.LeaseOutput path before calling btcwallet. Added TestLeaseOutputRejectsInMemoryLock, which asserts wtxmgr.ErrOutputAlreadyLocked and verifies the persisted lease call is not reached. The full lnwallet/btcwallet package passes.

lockID, *op, duration, leaseOpts,
)
} else {
expiration, err = w.cfg.Wallet.LeaseOutput(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F12 (Minor) — Extending a lease can drop its spend-maturity depth · lnrpc/walletrpc/walletkit_server.go:533

Because the wall-clock expiration still governs a retained lease until its spend confirms, a caller holding an input across a long broadcast-to-confirmation window must re-invoke LeaseOutput to extend it, and an extension that omits release_after_spend_confs takes this zero-depth branch and re-locks through the option-less path — I cannot tell from the reviewed context whether wtxmgr's re-lock preserves or clears the previously persisted release-after-spend flag, and if it clears it the extension silently reverts the input to ordinary wall-clock semantics, which is the exact invariant the PR exists to protect (this would be a major in that case). The cheap fix is a sentence on the LeaseOutput method doc stating that successive invocations must repeat the depth, next to the existing "expiration of the lock can be extended" text.

@bhandras bhandras Aug 31, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 27feb02. The merged wtxmgr behavior already preserves the existing confirmation depth and spend height for a same-owner renewal when the option is omitted (releaseAfterSpendConfsSet is false). I updated the LeaseOutput RPC documentation to state that same-ID renewal with zero depth preserves an existing non-zero depth, then regenerated the RPC outputs. Callers do not need to repeat the depth.

@bhandras
bhandras force-pushed the codex/reorg-aware-output-lock-20260825 branch from c6262a6 to cd3310b Compare August 31, 2026 17:09
@bhandras

Copy link
Copy Markdown
Collaborator Author

/gateway re-review

@lightninglabs-gateway lightninglabs-gateway Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gateway review

No findings.


Status of prior findings

  • F1 addressed: The stale gomoddirectives allowlist entries are gone — .golangci.yml is no longer in the changed-file set, and go.mod contains no replace for btcwallet or its wtxmgr submodule.
  • F3 addressed: Fixed at lnwallet/btcwallet/btcwallet.go:1153 — the legacy path is now a direct return b.wallet.LeaseOutput(id, op, duration), so no claim about upstream zero-option equivalence is load-bearing.
  • F4 addressed: ResolveOutputLeaser at lnwallet/wallet.go:637 unwraps LightningWallet and probes the concrete controller, replacing the unconditional wrapper forwarder; both rejection tests now pass the production &lnwallet.LightningWallet{WalletController: ...} shape, so the gate under test is the one clients hit.
  • F5 addressed: The capability probe and the releaseAfterSpendConfs > 0 decision are hoisted above for idx := range outpoints in lnrpc/walletrpc/psbt.go:51, so the outcome depends on the wallet rather than the input count. TestLockInputsRejectsUnsupportedOptionsWithoutInputs pins the nil-outpoints path that fundPsbtCoinSelect reaches via lockAndCreateFundingResponse(packet, nil, ...).
  • F7 addressed: TestLockInputsUsesLegacyPathForZeroDepth asserts one legacyCalls and require.Empty(t, wallet.leaseCalls) against an options-capable wallet, pinning the opt-in guarantee.
  • F8 addressed: lnrpc/walletrpc/walletkit.proto now carries method-level text on LeaseOutput, FundPsbt and ReleaseOutput covering fail-closed behavior, wall-clock-until-confirmed-spend precedence, the no-new-input capability check, and release of a retained spent-output lease.
  • F10 addressed: Both OutputLeaserWithOptions in lnwallet/interface.go and BtcWallet.LeaseOutputWithOptions now restate the global coin-selection-lock requirement and the ErrUnknownOutput / ErrOutputAlreadyLocked sentinels.
  • F11 addressed: The guard is restored: lnwallet/btcwallet/btcwallet.go:1150 retains if b.wallet.LockedOutpoint(op) { return time.Time{}, wtxmgr.ErrOutputAlreadyLocked } ahead of the direct delegation, and LeaseOutputWithOptions carries the same check, so no pre-existing lease caller had its double-lock refusal disarmed.

TestLeaseOutputRejectsInMemoryLock pins it from both directions — require.ErrorIs(t, err, wtxmgr.ErrOutputAlreadyLocked) plus require.False(t, backend.leaseCalled) proving the persisted lease call is never reached.

  • F12 addressed: lnrpc/walletrpc/walletkit.proto now states "Renewing the same lease with the same ID and a zero depth preserves an existing non-zero depth," regenerated consistently into walletkit.pb.go, walletkit_grpc.pb.go and the swagger description. That resolves the ambiguity as documentation, which is what the finding asked for; note the underlying releaseAfterSpendConfsSet behavior is in wtxmgr and is not verifiable from the reviewed context, so this rests on the author's stated check against the merged implementation.
Bot commands
  • /gateway re-review — re-run after pushing changes (maintainers)
  • /gateway dismiss <id> — silence a finding (maintainers)
  • /gateway explain <id> — elaborate on a finding (anyone)

@bhandras

Copy link
Copy Markdown
Collaborator Author

/gateway approve

@lightninglabs-gateway lightninglabs-gateway Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Approved — all prior findings addressed.

Findings recap

Findings:

  • F1 (minor) — addressed: This PR removes the temporary fork replacements and go.mod contains no replace for github.com/btcsuite/btcwallet or its wtxmgr submodu…
  • F2 (minor) — partially_addressed: This loop overwrites every UtxoLease.ReleaseAfterSpendConfs that marshallLeases derived from the wallet's ListLeasedOutputResult, and …
  • F3 (minor) — addressed: BtcWallet.LeaseOutput now routes all pre-existing lease callers — channel funding, the sweeper, the plain LeaseOutput RPC — through `b.w…
  • F4 (minor) — addressed: Defining this method on the wrapper makes *LightningWallet satisfy OutputLeaserWithOptions unconditionally, whether or not the embedded …
  • F5 (major) — addressed: A FundPsbt request with a non-zero input_release_after_spend_confs returns success without installing any release-after-spend lease, and…
  • F6 (minor) — unresolved: lockInputs returns on a FetchOutpointInfo failure without running the compensating release loop that sits a few statements below it, so …
  • F7 (minor) — addressed: The new if releaseAfterSpendConfs > 0 / else branch is only tested on the option-bearing side; nothing asserts that a zero depth against a…
  • F8 (minor) — addressed: The LeaseOutput, FundPsbt and ReleaseOutput service comments are unchanged, so the option is discoverable only from the field comments…
  • F9 (minor) — unresolved: The requested depth is forwarded with no upper bound, and neither the field name nor the type carries a unit, directly beneath `expiration_s…
  • F10 (minor) — addressed: BtcWallet.LeaseOutputWithOptions is now the method that actually takes the lease and returns wtxmgr.ErrOutputAlreadyLocked, but its doc …
  • F11 (major) — addressed: BtcWallet.LeaseOutput no longer refuses to lease an outpoint that is locked by btcwallet's in-memory locker, so every pre-existing caller …
  • F12 (minor) — addressed: Because the wall-clock expiration still governs a retained lease until its spend confirms, a caller holding an input across a long broadcast…

Dismissed:


Approved by @bhandras via /gateway approve. Last reviewed at cd3310b. Skill v0.5.0, model claude-opus-5.

@bhandras

Copy link
Copy Markdown
Collaborator Author

Repinned to merged btcwallet, folded the review fixes, and replied to each finding. Gateway has approved the final head and CI is green after rerunning transient failures. Ready for maintainer re-review.

@ziggie1984 ziggie1984 added this to the v0.21.4 milestone Aug 31, 2026
@ziggie1984 ziggie1984 added this to v0.21 Sep 1, 2026
@ziggie1984 ziggie1984 moved this to In review in v0.21 Sep 1, 2026
Comment thread lnrpc/walletrpc/psbt.go
@bhandras
bhandras force-pushed the codex/reorg-aware-output-lock-20260825 branch from cd3310b to cfaf1e8 Compare September 1, 2026 16:29
Pin btcwallet and wtxmgr to the upstream merge.

This provides persisted release-after-spend lease behavior.

Temporary fork replacements are no longer needed.
Define an optional output-leaser interface for persisted lease behavior
beyond the existing WalletController contract. Forward it through
LightningWallet and translate release-after-spend confirmation depth
into btcwallet's lock option.

Callers that use the existing LeaseOutput method keep the current
behavior.
Add optional confirmation-depth fields to LeaseOutput and FundPsbt.

Forward requests to configurable wallets and echo the accepted depth.

Zero keeps existing behavior. Unsupported options fail closed.
Release each acquired lease with its actual owner ID.

This fixes rollback for caller-provided lock IDs.
Document the new LeaseOutput and FundPsbt confirmation-depth option in
the 0.22.0 release notes.
@bhandras
bhandras force-pushed the codex/reorg-aware-output-lock-20260825 branch from cfaf1e8 to f4787f0 Compare September 1, 2026 16:41
@bhandras
bhandras requested a review from ziggie1984 September 1, 2026 20:12
@ziggie1984 ziggie1984 added the backport-v0.21.x-branch This label triggers a backport to branch `v0.21.x-branch ` label Sep 2, 2026
@ziggie1984

Copy link
Copy Markdown
Collaborator

Should we also bound release_after_spend_confs and input_release_after_spend_confs, similar to the spend-depth validation in #11054?

At the moment, this PR accepts any non-zero uint32. A value such as math.MaxUint32 would make a lease effectively non-maturing after a confirmed spend is observed, unless it is explicitly released. PR #11054 instead limits its spend-confirmation policy to chainntnfs.MaxNumConfs (ReorgSafetyLimit, currently 144).

For this API, 0 should continue to mean the legacy timed lease, but values above the selected maximum could be rejected. Ideally the validation would also live below the RPC boundary so future internal callers cannot bypass it. Suggested boundary coverage: 0, 1, MaxNumConfs, and MaxNumConfs+1.

The two mechanisms do not necessarily need the same technical maximum, but if leases intentionally support a larger depth, it would be useful to document the reason for that policy divergence.

@ziggie1984

Copy link
Copy Markdown
Collaborator

As a follow-up, could we consider using this reorg-aware lease policy for internal wallet-input consumers as well, especially regular and batch channel funding?

Today the behavior remains effectively external opt-in: batch channel funding calls WalletKit.FundPsbt internally but leaves input_release_after_spend_confs at zero, while the regular chanfunding.WalletAssembler continues to call the legacy timed LeaseOutput. These seem like the highest-suitability internal adopters because their selected inputs should remain reserved until the funding spend is sufficiently deep, including across a shallow reorg.

This likely does not need to expand the scope of this PR, since internal adoption needs an explicit lifecycle policy:

  • derive the confirmation depth (for a batch, possibly the maximum negotiated requirement);
  • renew the timed lease until the first confirmation, so it cannot expire in the pre-confirmation gap;
  • explicitly release on funding cancellation;
  • preserve and test the behavior across restart, reorg, and reconfirmation.

A focused follow-up issue/PR for regular and batch channel funding would help prevent the RPC path and internal subsystems from evolving different reorg-protection designs.

@ziggie1984 ziggie1984 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM (left some non-blocking comments, feel free to addres in a follow-up)

"github.com/stretchr/testify/require"
)

type lockedOutpointWallet struct {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: missing godoc

Comment thread lnwallet/wallet_test.go
"github.com/stretchr/testify/require"
)

type leaseOptionsController struct {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: missing godocs

Comment thread lnrpc/walletrpc/psbt.go
@@ -45,9 +45,22 @@ func verifyInputsUnspent(inputs []*wire.TxIn, utxos []*lnwallet.Utxo) error {
// (the passed outpoints), using either the optional custom lock ID and duration

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: can you create a more detailed commit message it is hard to digest


## RPC Additions

* WalletKit output leases can now [remain active until their spending

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

move to 21.4 ?

"github.com/stretchr/testify/require"
)

const unsupportedLeaseOptionsErr = "wallet does not support " +

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: godoc

@Roasbeef Roasbeef left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, main req is not to hard code 6 confs, instead expose it as an option to the caller.

Comment thread lnrpc/walletrpc/psbt.go
[]*base.ListLeasedOutputResult, error) {

var leaser lnwallet.OutputLeaserWithOptions
if releaseAfterSpendConfs > 0 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than this bool, with if we did something like releaseAfter fn.Option[uint8]? That way we don't hard code 6 confs everywhere, the caller picks, and in the future if we want to extend (eg: 1k blocks or w/e on testnet, we can do so).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Totally misread this, doesn't hard code the confs, just uses -1 as a sentinel value. Ignore the comment.

Comment thread lnwallet/wallet.go
// ResolveOutputLeaser returns the optional output lease capability implemented
// by a wallet controller. It unwraps LightningWallet so the result reflects the
// concrete controller rather than the wrapper's method set.
func ResolveOutputLeaser(wallet WalletController) (

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So as is, this can't be used with a remote signer instance.

Eg:

 ResolveOutputLeaser(&LightningWallet{WalletController: &RPCKeyRing{WalletController: &BtcWallet{}}})

(remote signer embeds WalletController not the raw lnd btcwallet pointer)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should add a test that uses the rpc key ring.

Expiration: uint64(lock.Expiration.Unix()),
PkScript: lock.PkScript,
Value: uint64(lock.Value),
ReleaseAfterSpendConfs: lock.ReleaseAfterSpendConfs,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would adding a confirmation height be useful here?

@Roasbeef Roasbeef left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport-v0.21.x-branch This label triggers a backport to branch `v0.21.x-branch ` gateway-active gateway-approved severity-critical Requires expert review - security/consensus critical

Projects

Status: In review

Development

Successfully merging this pull request may close these issues.

3 participants