Skip to content

[v0.21.x-branch] Backport #10815: lnwallet/rpcwallet: keep zero-value prev outputs in remote-sign PSBT#10822

Merged
ziggie1984 merged 3 commits into
v0.21.x-branchfrom
backport-10815-to-v0.21.x-branch
May 20, 2026
Merged

[v0.21.x-branch] Backport #10815: lnwallet/rpcwallet: keep zero-value prev outputs in remote-sign PSBT#10822
ziggie1984 merged 3 commits into
v0.21.x-branchfrom
backport-10815-to-v0.21.x-branch

Conversation

@github-actions
Copy link
Copy Markdown

Backport of #10815


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.

Roasbeef added 3 commits May 20, 2026 01:23
…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.

(cherry picked from commit 9f31668)
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.

(cherry picked from commit 6bb2c6f)
Add the bug-fix entry under 0.21.0 with a link to the PR.

(cherry picked from commit 44f2ef7)
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

@ziggie1984 ziggie1984 merged commit 16f7a47 into v0.21.x-branch May 20, 2026
1 check passed
@ziggie1984 ziggie1984 deleted the backport-10815-to-v0.21.x-branch branch May 20, 2026 01:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants