Problem
WalletKit.FundPsbt can lock the UTXOs it selects under a caller-supplied custom_lock_id (added so that protocol integrations own their lease namespace). But if the funding flow fails partway through — specifically if leasing one of the selected inputs fails after others were already leased — the internal rollback releases the already-locked outputs under lnd's static internal lock ID instead of the custom lock ID that was actually used to lock them. Because the wallet's unlock path enforces that the release lock ID matches the lock ID the output was locked with, the rollback release fails, the error is only logged, and the coins remain leased under the custom lock ID with no automatic recovery.
For a lightweight / custom-anchor integration (e.g. a builder that owns the BTC anchor transaction and funds it via FundPsbt with its own lock namespace), this means a single transient failure during funding can permanently strand wallet coins for the full lease duration, and the caller is never told which outpoints were stranded (the RPC returns only the top-level error, not the partial lease set). A closely related gap: after a lost/timed-out FundPsbt response, there is no reliable way to enumerate exactly the leases that FundPsbt created under a given custom lock ID and release them, so recovery is best-effort.
Current behaviour
The lease + rollback logic lives in lockInputs:
lnrpc/walletrpc/psbt.go:47 — lockInputs(w, outpoints, customLockID, customLockDuration).
lnrpc/walletrpc/psbt.go:61-64 — the lock ID for each output is set to chanfunding.LndInternalLockID by default and overridden to *customLockID when the caller supplied one.
lnrpc/walletrpc/psbt.go:77-79 — each output is leased with w.LeaseOutput(lock.LockID, lock.Outpoint, lockDuration), i.e. under the custom lock ID when set.
lnrpc/walletrpc/psbt.go:85-93 — the rollback loop that runs when a later LeaseOutput fails hardcodes w.ReleaseOutput(chanfunding.LndInternalLockID, op) for every previously-locked output, ignoring customLockID. On failure it only logs "could not release the lock on %v: %v" and continues.
The release then fails deterministically whenever a custom lock ID was used, because the wallet requires the unlock ID to match the lock ID:
lnwallet/btcwallet/btcwallet.go:1034 — BtcWallet.ReleaseOutput delegates to wallet.ReleaseOutput → wtxmgr.Store.UnlockOutput.
wtxmgr.Store.UnlockOutput (btcwallet wtxmgr@v1.5.6/tx.go:1313-1332) returns ErrOutputUnlockNotAllowed when lockedID != id. So releasing a custom-locked output under LndInternalLockID returns an error, the output stays locked, and only a log line is emitted at psbt.go:90-92.
Concretely: with custom_lock_id = X and two selected inputs A and B, if A is leased under X (psbt.go:77) and then leasing B fails (e.g. ErrOutputAlreadyLocked from a concurrent selection), the rollback calls ReleaseOutput(LndInternalLockID, A), which returns ErrOutputUnlockNotAllowed; A stays leased under X until the lease expires, and FundPsbt returns only the top-level "could not lease a lock on UTXO" error (psbt.go:95-96) with no list of the outpoints it left locked.
Note the two internal-funding rollback sites in lnwallet/wallet.go:638 and lnwallet/wallet.go:1476-1479 are correct: those paths always lock under LndInternalLockID, so releasing under the same ID is right. The defect is specific to lockInputs handling the custom-lock-ID case.
Recovery / enumeration gaps for the lost-response case:
ReleaseOutput (lnrpc/walletrpc/walletkit_server.go:524) does accept an arbitrary id, so a caller who still knows the outpoints and their custom lock ID can release them manually — but after a partial failure the caller was never told which outpoints were locked.
ListLeases (lnrpc/walletrpc/walletkit_server.go:553) takes an empty ListLeasesRequest (lnrpc/walletrpc/walletkit.proto:1638) with no lock-ID filter, so a caller must fetch every lease in the wallet and filter client-side on UtxoLease.id. The lock ID is at least exposed in the response (marshallLeases, lnrpc/walletrpc/walletkit_server.go:2273-2288, sets Id: lock.LockID[:]), so enumeration is possible but racy and coarse.
- The underlying
ListLeasedOutputs silently drops any locked outpoint whose transaction details are not found in the wallet's tx store (wallet.ListLeasedOutputs, btcwallet wallet@...:3009-3014 does continue when details == nil). For lnd-owned selected coins this is normally populated, but it means enumeration is not guaranteed to surface every retained lock.
Proposed change
-
Fix the rollback lock ID in lnrpc/walletrpc/psbt.go:85-93 to release under the same ID the outputs were locked with. The lock records already carry LockID (set at psbt.go:61-64), so the loop should release locks[i].Outpoint using locks[i].LockID (equivalently lock.LockID for the current iteration's chosen ID) rather than the hardcoded chanfunding.LndInternalLockID. This alone makes the partial-funding rollback correct for both the default and custom-lock-ID cases.
-
Surface the partial lease set on failure so the caller has a deterministic recovery handle. Two options, non-exclusive: (a) if best-effort rollback of any output fails, wrap/attach the outpoints still leased (and their lock ID) into the returned error so the caller can retry ReleaseOutput; and/or (b) ensure lockInputs never returns with outputs leased that it could not roll back without reporting them.
-
Add a reliable enumeration/release path for the lost-response scenario. Minimal: add a lock-ID filter to ListLeasesRequest (lnrpc/walletrpc/walletkit.proto:1638) so a caller can enumerate exactly the leases under its custom lock ID server-side. Stronger: add a "release all outputs under lock ID X" RPC (or a repeated-outpoint variant of ReleaseOutput) so a caller can drain its own lock namespace atomically instead of issuing N individual ReleaseOutput calls, each of which can partially fail. Confirm whether the silent continue on missing tx details in wallet.ListLeasedOutputs needs to be surfaced (e.g. logged at warn and/or returned) so retained locks cannot be invisibly omitted — this last item likely lives in btcwallet and needs confirmation.
Item 1 is the core, self-contained fix inside lnd; items 2 and 3 harden the recovery contract that lightweight integrations depend on.
Context
Filed to support lightninglabs/tap-sdk#158, an advanced custom-anchor Taproot Assets transaction builder (used by SwapDK) that owns the BTC anchor transaction and funds it via FundPsbt under its own custom_lock_id. The SDK cannot fix this from the client side: the faulty rollback runs entirely inside lnd, and the wallet rejects any attempt to release custom-locked coins under the internal lock ID. Today the SDK fails closed — it treats a FundPsbt failure as potentially having stranded coins under its lock ID and cannot cleanly reconcile which outpoints are still leased after a lost response, because ListLeases offers no lock-ID filter and the automatic rollback is unreliable.
Problem
WalletKit.FundPsbtcan lock the UTXOs it selects under a caller-suppliedcustom_lock_id(added so that protocol integrations own their lease namespace). But if the funding flow fails partway through — specifically if leasing one of the selected inputs fails after others were already leased — the internal rollback releases the already-locked outputs under lnd's static internal lock ID instead of the custom lock ID that was actually used to lock them. Because the wallet's unlock path enforces that the release lock ID matches the lock ID the output was locked with, the rollback release fails, the error is only logged, and the coins remain leased under the custom lock ID with no automatic recovery.For a lightweight / custom-anchor integration (e.g. a builder that owns the BTC anchor transaction and funds it via
FundPsbtwith its own lock namespace), this means a single transient failure during funding can permanently strand wallet coins for the full lease duration, and the caller is never told which outpoints were stranded (the RPC returns only the top-level error, not the partial lease set). A closely related gap: after a lost/timed-outFundPsbtresponse, there is no reliable way to enumerate exactly the leases that FundPsbt created under a given custom lock ID and release them, so recovery is best-effort.Current behaviour
The lease + rollback logic lives in
lockInputs:lnrpc/walletrpc/psbt.go:47—lockInputs(w, outpoints, customLockID, customLockDuration).lnrpc/walletrpc/psbt.go:61-64— the lock ID for each output is set tochanfunding.LndInternalLockIDby default and overridden to*customLockIDwhen the caller supplied one.lnrpc/walletrpc/psbt.go:77-79— each output is leased withw.LeaseOutput(lock.LockID, lock.Outpoint, lockDuration), i.e. under the custom lock ID when set.lnrpc/walletrpc/psbt.go:85-93— the rollback loop that runs when a laterLeaseOutputfails hardcodesw.ReleaseOutput(chanfunding.LndInternalLockID, op)for every previously-locked output, ignoringcustomLockID. On failure it only logs"could not release the lock on %v: %v"and continues.The release then fails deterministically whenever a custom lock ID was used, because the wallet requires the unlock ID to match the lock ID:
lnwallet/btcwallet/btcwallet.go:1034—BtcWallet.ReleaseOutputdelegates towallet.ReleaseOutput→wtxmgr.Store.UnlockOutput.wtxmgr.Store.UnlockOutput(btcwalletwtxmgr@v1.5.6/tx.go:1313-1332) returnsErrOutputUnlockNotAllowedwhenlockedID != id. So releasing a custom-locked output underLndInternalLockIDreturns an error, the output stays locked, and only a log line is emitted atpsbt.go:90-92.Concretely: with
custom_lock_id = Xand two selected inputs A and B, if A is leased under X (psbt.go:77) and then leasing B fails (e.g.ErrOutputAlreadyLockedfrom a concurrent selection), the rollback callsReleaseOutput(LndInternalLockID, A), which returnsErrOutputUnlockNotAllowed; A stays leased under X until the lease expires, andFundPsbtreturns only the top-level "could not lease a lock on UTXO" error (psbt.go:95-96) with no list of the outpoints it left locked.Note the two internal-funding rollback sites in
lnwallet/wallet.go:638andlnwallet/wallet.go:1476-1479are correct: those paths always lock underLndInternalLockID, so releasing under the same ID is right. The defect is specific tolockInputshandling the custom-lock-ID case.Recovery / enumeration gaps for the lost-response case:
ReleaseOutput(lnrpc/walletrpc/walletkit_server.go:524) does accept an arbitraryid, so a caller who still knows the outpoints and their custom lock ID can release them manually — but after a partial failure the caller was never told which outpoints were locked.ListLeases(lnrpc/walletrpc/walletkit_server.go:553) takes an emptyListLeasesRequest(lnrpc/walletrpc/walletkit.proto:1638) with no lock-ID filter, so a caller must fetch every lease in the wallet and filter client-side onUtxoLease.id. The lock ID is at least exposed in the response (marshallLeases,lnrpc/walletrpc/walletkit_server.go:2273-2288, setsId: lock.LockID[:]), so enumeration is possible but racy and coarse.ListLeasedOutputssilently drops any locked outpoint whose transaction details are not found in the wallet's tx store (wallet.ListLeasedOutputs, btcwalletwallet@...:3009-3014doescontinuewhendetails == nil). For lnd-owned selected coins this is normally populated, but it means enumeration is not guaranteed to surface every retained lock.Proposed change
Fix the rollback lock ID in
lnrpc/walletrpc/psbt.go:85-93to release under the same ID the outputs were locked with. Thelockrecords already carryLockID(set atpsbt.go:61-64), so the loop should releaselocks[i].Outpointusinglocks[i].LockID(equivalentlylock.LockIDfor the current iteration's chosen ID) rather than the hardcodedchanfunding.LndInternalLockID. This alone makes the partial-funding rollback correct for both the default and custom-lock-ID cases.Surface the partial lease set on failure so the caller has a deterministic recovery handle. Two options, non-exclusive: (a) if best-effort rollback of any output fails, wrap/attach the outpoints still leased (and their lock ID) into the returned error so the caller can retry
ReleaseOutput; and/or (b) ensurelockInputsnever returns with outputs leased that it could not roll back without reporting them.Add a reliable enumeration/release path for the lost-response scenario. Minimal: add a lock-ID filter to
ListLeasesRequest(lnrpc/walletrpc/walletkit.proto:1638) so a caller can enumerate exactly the leases under its custom lock ID server-side. Stronger: add a "release all outputs under lock ID X" RPC (or a repeated-outpoint variant ofReleaseOutput) so a caller can drain its own lock namespace atomically instead of issuing N individualReleaseOutputcalls, each of which can partially fail. Confirm whether the silentcontinueon missing tx details inwallet.ListLeasedOutputsneeds to be surfaced (e.g. logged at warn and/or returned) so retained locks cannot be invisibly omitted — this last item likely lives in btcwallet and needs confirmation.Item 1 is the core, self-contained fix inside lnd; items 2 and 3 harden the recovery contract that lightweight integrations depend on.
Context
Filed to support lightninglabs/tap-sdk#158, an advanced custom-anchor Taproot Assets transaction builder (used by SwapDK) that owns the BTC anchor transaction and funds it via
FundPsbtunder its owncustom_lock_id. The SDK cannot fix this from the client side: the faulty rollback runs entirely inside lnd, and the wallet rejects any attempt to release custom-locked coins under the internal lock ID. Today the SDK fails closed — it treats aFundPsbtfailure as potentially having stranded coins under its lock ID and cannot cleanly reconcile which outpoints are still leased after a lost response, becauseListLeasesoffers no lock-ID filter and the automatic rollback is unreliable.