Skip to content

walletrpc: FundPsbt rollback releases custom-lock-ID leases under the wrong lock ID #10966

Description

@darioAnongba

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:47lockInputs(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:1034BtcWallet.ReleaseOutput delegates to wallet.ReleaseOutputwtxmgr.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

  1. 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.

  2. 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.

  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions