Skip to content

lnwallet/rpcwallet: keep zero-value prev outputs in remote-sign PSBT#10815

Merged
Roasbeef merged 3 commits into
lightningnetwork:masterfrom
Roasbeef:bip322-fix
May 20, 2026
Merged

lnwallet/rpcwallet: keep zero-value prev outputs in remote-sign PSBT#10815
Roasbeef merged 3 commits into
lightningnetwork:masterfrom
Roasbeef:bip322-fix

Conversation

@Roasbeef
Copy link
Copy Markdown
Member

In this PR, we fix a sharp edge in RPCKeyRing.remoteSign that silently
broke any signing flow whose to_sign tx references a zero-value prev
output. The canonical hitter is BIP-322 message signing, which mandates
that the virtual to_spend output is exactly value=0 with the message
commitment as pk_script, and that output is referenced as input 0 of
every to_sign tx handed to SignOutputRaw.

Before forwarding the request, remoteSign rebuilds a PSBT from the
unsigned tx and annotates every non-signed input with a WitnessUtxo,
so the downstream walletkit.SignPsbt call can proceed (taproot sighash
needs every prev output, not just the one being signed). When the watch-
only wallet doesn't know an outpoint, the prep stage falls back to the
sign descriptor's PrevOutputFetcher. That fallback used to require
utxo.Value != 0, which silently dropped legitimate zero-value entries
on the floor. The remote signer would then reject the resulting PSBT
with input (index=N) doesn't specify any UTXO info, raised in
walletkit.SignPsbt precisely because input N had neither a
WitnessUtxo nor a NonWitnessUtxo.

The fix

We drop the Value != 0 guard. The only condition that actually matters
is that the fetched entry is usable as a WitnessUtxo: non-nil with a
non-empty PkScript. A zero-value WitnessUtxo with a well-formed
PkScript serializes cleanly into a PSBT, so there's nothing else to
validate at this layer.

We also lift the population loop out of remoteSign into a package-
level helper, populateNonSignedInputWitnessUtxos, so the resolution
policy can be exercised in unit tests without standing up a full wallet

  • remote signer pair. The helper takes a fetchOutpointInfoFn callback
    that mirrors lnwallet.WalletController.FetchOutpointInfo. No behavior
    change for the wallet-owns-it path or the no-fallback path.

Tests

New unit tests in lnwallet/rpcwallet/rpcwallet_test.go walk through
each resolution branch. The wallet-owns-it case exercises the
FetchOutpointInfo happy path and cross-checks that the signed input
itself is left untouched. The external-fallback case has the wallet
return ErrNotMine and resolves the prev via the fetcher. The zero-
value-fallback case is the BIP-322 regression: same shape as external-
fallback, but the fetcher entry has Value=0, and the test asserts
that WitnessUtxo lands on the PSBT instead of being silently skipped.
Two more cover the unhappy paths: no fetcher attached (input left bare,
warning logged) and a fetcher entry with an empty PkScript (rejected
as unusable, since a PSBT WitnessUtxo with empty script is malformed
at serialization).

Compat

No behavior change for the wallet-owns-it path, the no-fallback path,
or the non-zero-value fallback path. The only new behavior is that
legitimately zero-value entries from the supplied PrevOutputFetcher
land in the PSBT instead of being silently skipped.

@gemini-code-assist
Copy link
Copy Markdown

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request addresses a bug in the remote signing flow where zero-value previous outputs were being silently ignored. This behavior caused failures in signing flows that require zero-value inputs, such as BIP-322 message signing. By removing the incorrect value check and refactoring the logic into a testable helper, the wallet now correctly includes these outputs in the PSBT, ensuring downstream compatibility with taproot sighash requirements.

Highlights

  • Removed zero-value guard: Removed the Value != 0 restriction when populating WitnessUtxo for non-signed inputs, allowing zero-value outputs to be correctly included in PSBTs.
  • Refactored witness population: Extracted the witness population logic into a new package-level helper function populateNonSignedInputWitnessUtxos to improve testability.
  • Added regression tests: Introduced comprehensive unit tests in rpcwallet_test.go to cover various resolution scenarios, including the BIP-322 zero-value case.
New Features

🧠 You can now enable Memory (public preview) to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This PR refactors the PSBT preparation logic for remote signers into a helper function, populateNonSignedInputWitnessUtxos, and enables support for zero-value UTXO entries required for BIP-322 signing flows. The changes include comprehensive unit tests for the new helper. Feedback highlights a placeholder PR number in the release notes that needs updating and recommends a nil check on the UTXO info to prevent potential runtime panics.

[clarifies](https://github.com/lightningnetwork/lnd/issues/10568) the ZMQ
port-mismatch warnings so they no longer suggest that the connection failed.

* The [remote-signer PSBT prep](https://github.com/lightningnetwork/lnd/pull/PRNUM)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The link contains a placeholder PRNUM. Please update this with the actual pull request number once it is assigned.

}

txIn := tx.TxIn[idx]
info, err := fetchInfo(&txIn.PreviousOutPoint)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

While FetchOutpointInfo is generally expected to return a non-nil Utxo when no error occurs, it is safer to perform a nil check on info before accessing its fields to prevent potential panics.

info, err := fetchInfo(&txIn.PreviousOutPoint)
		if err == nil && info != nil {
			packet.Inputs[idx].WitnessUtxo = &wire.TxOut{
				Value:    int64(info.Value),
				PkScript: info.PkScript,
			}
			continue
		}

		// The wallet doesn't know about this outpoint. Fall
		// back to the caller-supplied PrevOutputFetcher.

@Roasbeef Roasbeef force-pushed the bip322-fix branch 2 times, most recently from 5e50e65 to c5d5fcf Compare May 19, 2026 02:15
@github-actions github-actions Bot added severity-critical Requires expert review - security/consensus critical and removed severity-critical Requires expert review - security/consensus critical labels May 19, 2026
@github-actions
Copy link
Copy Markdown

🔴 PR Severity: CRITICAL

Classified by file path | 2 files (excl. tests) | 114 lines changed (excl. tests)

🔴 Critical (1 file)
  • lnwallet/rpcwallet/rpcwallet.go - wallet operations in lnwallet/*; modifications to the remote signing wallet implementation
🟢 Low (2 files — not counted toward severity)
  • docs/release-notes/release-notes-0.21.0.md - release notes documentation
  • lnwallet/rpcwallet/rpcwallet_test.go - test file (excluded from classification)

Analysis

This PR modifies lnwallet/rpcwallet/rpcwallet.go, which falls squarely within the lnwallet/* CRITICAL package covering wallet operations, channel funding, signing, and commitment transactions. The remote signing wallet (rpcwallet) is responsible for delegating signing operations to an external signer, making correctness here essential for fund safety.

The change is focused (67 additions / 36 deletions in the non-test source file) and is accompanied by a new test file (228 lines) that provides coverage for the modified logic. No severity bump was triggered: only 2 non-test/non-generated files changed (<20 threshold) and 114 non-test lines changed (<500 threshold).

Expert review is warranted given the wallet/signing context.


To override, add a severity-override-{critical,high,medium,low} label.
<!-- pr-severity-bot -->

func populateNonSignedInputWitnessUtxos(packet *psbt.Packet, tx *wire.MsgTx,
signDesc *input.SignDescriptor, fetchInfo fetchOutpointInfoFn) {

for idx := range packet.Inputs {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Don't you think adding a quick length alignment check at the top makes sense here? It would make the loop completely defensive against out-of-bounds panics if a malformed packet ever slips through.

if len(packet.Inputs) != len(tx.TxIn) {
    return
}

Copy link
Copy Markdown
Collaborator

@ziggie1984 ziggie1984 May 19, 2026

Choose a reason for hiding this comment

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

The invariant len(packet.Inputs) == len(tx.TxIn) is guaranteed by psbt.NewFromUnsignedTx(tx) at the only call site two lines above. Validating an invariant the caller already establishes is noise, and if it ever did diverge, that's a programmer bug — panicking is the correct response, not a silent skip. The real smell is the dual-arg signature; a runtime check papers over it instead of fixing it

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ah, got it! That makes total sense. Thanks for the thorough explanation!

@TechLateef
Copy link
Copy Markdown

LGTM. Solid fix for the BIP-322 edge case, and the unit tests are very thorough. I just left a quick suggestion on the input loop regarding a defensive length check. Aside from that, logic looks great!

@levmi levmi requested review from ellemouton and ziggie1984 May 19, 2026 17:44
@ziggie1984 ziggie1984 added this to v0.21 May 19, 2026
@ziggie1984 ziggie1984 added this to the v0.21.0 milestone May 19, 2026
Copy link
Copy Markdown
Collaborator

@ziggie1984 ziggie1984 left a comment

Choose a reason for hiding this comment

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

LGTM nice bugfix, pending Linter CI

Copy link
Copy Markdown
Collaborator

@ellemouton ellemouton left a comment

Choose a reason for hiding this comment

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

nice!

@Roasbeef
Copy link
Copy Markdown
Member Author

Thx for the review, will fix CI then land.

Roasbeef added 3 commits May 19, 2026 16:31
…ign prep

Before forwarding a SignOutputRaw request to the remote signer instance,
remoteSign rebuilds a PSBT from the unsigned transaction and annotates
every input with a WitnessUtxo (so the downstream walletkit.SignPsbt
call accepts it — taproot sighash computation requires the prev output
of every input, not just the one being signed).

For non-signed inputs the prep stage first asks the watch-only wallet
about the outpoint via FetchOutpointInfo, then — when the wallet does
not own or track the outpoint — falls back to the sign descriptor's
PrevOutputFetcher. The fallback previously required `utxo.Value != 0`,
which silently dropped legitimate zero-value entries on the floor and
left the corresponding PSBT input bare.

The walletkit.SignPsbt entry point on the remote signer then rejected
the PSBT with "input (index=N) doesn't specify any UTXO info" because
input N had neither a WitnessUtxo nor a NonWitnessUtxo annotation.

BIP-322 (signing virtual transactions for message attestation) is the
canonical hitter: its to_spend output is mandated by the BIP to be
exactly value=0 with the message commitment as pk_script, and that
output is referenced as input 0 of every BIP-322 to_sign transaction.
Any caller that drives a BIP-322 sign through a remote-signer LND
deployment was failing for this reason.

The validation we actually want is that the fetched prev output is
representable as a usable WitnessUtxo: non-nil and with a non-empty
pk_script. Drop the Value check; the zero-value case is well-formed
and the resulting PSBT input will serialize cleanly. The fetched-but-
empty-pk_script case continues to be rejected (a WitnessUtxo with
empty PkScript is malformed at PSBT serialization), and the warning
log when no fallback resolves the outpoint is preserved verbatim.

Lift the WitnessUtxo-population loop out of remoteSign into a
package-level helper so the resolution policy is unit-testable without
spinning up a real wallet + remote signer pair. The helper takes a
fetchOutpointInfoFn callback that mirrors
lnwallet.WalletController.FetchOutpointInfo. No behavior change for
the wallet-owns-it path or the no-fallback path.
Cover the four resolution branches plus the BIP-322 regression case:

  - wallet-owns-it: FetchOutpointInfo returns a Utxo, helper writes
    the matching WitnessUtxo into the PSBT input.
  - external-fallback: wallet returns ErrNotMine, helper writes the
    WitnessUtxo from the sign descriptor's PrevOutputFetcher.
  - zero-value-fallback: same as above with the fetched entry's Value
    set to zero. This is the BIP-322 to_spend shape (input 0 of every
    BIP-322 to_sign references a virtual prev whose Value is mandated
    to be zero); the helper must populate the WitnessUtxo rather than
    silently skip it.
  - no-fallback: wallet returns ErrNotMine and no PrevOutputFetcher
    is provided; the helper leaves the input bare and the warning log
    fires (asserted only by absence of a populated WitnessUtxo).
  - empty-pk_script-fallback: the fetcher returns a non-nil entry
    with an empty PkScript; the helper rejects it as unusable (the
    PSBT WitnessUtxo serializer requires a non-empty script) and
    leaves the input bare.

The signed input (signDesc.InputIndex) is intentionally left untouched
by the helper — that input is the one the caller's main path will
populate later — and the tests cross-check that invariant on the
wallet-owns-it case.
Add the bug-fix entry under 0.21.0 with a link to the PR.
@Roasbeef Roasbeef added the backport-v0.21.x-branch This label triggers a backport to branch `v0.21.x-branch ` label May 19, 2026
@Roasbeef Roasbeef enabled auto-merge May 19, 2026 23:32
@Roasbeef Roasbeef disabled auto-merge May 20, 2026 01:13
@Roasbeef Roasbeef enabled auto-merge May 20, 2026 01:13
@Roasbeef Roasbeef disabled auto-merge May 20, 2026 01:13
@Roasbeef Roasbeef merged commit 5953c43 into lightningnetwork:master May 20, 2026
37 of 40 checks passed
@github-project-automation github-project-automation Bot moved this to Done in v0.21 May 20, 2026
@github-actions
Copy link
Copy Markdown

ziggie1984 added a commit that referenced this pull request May 20, 2026
…21.x-branch

[v0.21.x-branch] Backport #10815: lnwallet/rpcwallet: keep zero-value prev outputs in remote-sign PSBT
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 ` severity-critical Requires expert review - security/consensus critical

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants