Conversation
Names the input shape: a list of child indices below parent_key, which is what the cache is keyed on. A companion that takes a psbt entry's DerivationPath object follows; the two names then say which one a caller holds.
A multisig output lists one derivation path entry per cosigner. The output check verified only the first entry claiming this seed against the committed script, so a second claim of ours whose key the script has no use for went unreported when listed behind our real entry or in the place of another cosigner's entry (which keeps the entry count at n and passes the surplus count). Every entry claiming this seed is now held to the script. To feed that, the ownership scan keeps every entry it proved per input and output, as the psbt's own DerivationPath objects, instead of the first one's path. _derive_with_cache_via_derivation_path is added beside the index-taking primitive for callers holding such an entry; it reads only the entry's path, since the single-sig rebuild and the multisig fallback derive at entries carrying a foreign fingerprint on purpose. change_data keeps the index-list path the views read.
A p2sh-p2wpkh output's scriptPubKey is a bare p2sh hash. BIP-174 leaves the redeem script optional on an output, and without it _get_policy types the output as plain p2sh, which does not match a p2sh-p2wpkh wallet policy. The output then never reaches the ownership check at all. Two things follow. The user's own change is displayed as a payment out to a stranger. And an output that keeps a genuine claim on this seed while repointing its scriptPubKey escapes the ownership-contradiction refusal by dropping one optional field. Nested single sig is the only script type whose identity depends on an optional field its proof does not read: the rebuild is p2sh(p2wpkh(K)) from our own seed at the claimed path and the inputs' policy, so the missing field is a discriminator rather than evidence. For p2sh-p2wsh the discriminator and the proof material are the same field, so its absence is genuine silence and correctly out of reach. _policy_shape_matches becomes _is_change_candidate, an instance method, since it now reads the inputs' policy and the output's verified derivation paths. Beside the unchanged shape comparison it admits a bare p2sh output under a p2sh-p2wpkh wallet that carries no m-of-n, supplies exactly one derivation path entry, and holds one verified claim on this seed. Everything below is unchanged: the rebuild decides, honest change is counted as change, and a repointed output raises PSBTOutputOwnershipContradictionError. The single-entry conditions are what keep that refusal safe. A genuine multisig output carries one derivation path entry per cosigner, so it never reaches the contradiction check by this route. The shape that would is a bare p2sh output withholding both scripts while annotating a single key of a multisig, and no surveyed coordinator emits one. Every coordinator but Bitcoin Core annotates only its own change output. Core annotates an output because a descriptor solved its script, so it holds all n key origins and writes them.
kdmukai
force-pushed
the
2026_09_psbt_nested_singlesig_change
branch
2 times, most recently
from
September 25, 2026 19:27
eb3091c to
81ae37a
Compare
18 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Depends on #1044 so this PR will remain in DRAFT until 1044 is merged.
Human-friendly overview:
The Problem
A nested single sig (p2sh-p2wpkh) output can omit its redeem script (it's optional per BIP-174). Such outputs are interpreted as plain p2sh and so they fail the initial possible change check (p2sh fails to match p2sh-p2wpkh). That creates two undesirable possible outcomes:
bip32_derivationsspecify a key that the user's seed does actually own, making it look like it really is their own change.The Solution
Allow nested single sig outputs to be considered possible change when the redeem script is omitted AND the output's
bip32_derivationsclaim a key that is derived from the user's seed. Those outputs will then be subject to our deeper validity checks.The vital insight here is that the redeem script for nested single sig is trivially reproducible. Even when the psbt supplies it, we ignore it during our change verification and just regenerate it ourselves. And once we rebuild the redeem script, we can use it to produce the output's expected scriptPubKey and see if it matches the scriptPubKey in the psbt.
In this case the scriptPubKey will not match. The deceptive
bip32_derivationsclaim will have been found and an exception will be raised.More precise description below by Claude, primarily intended to be consumed by other AIs
Mechanism
_get_policytypes an output from what that output supplies. A p2sh-p2wpkh output's scriptPubKeyis a bare p2sh hash, so the redeem script is the only field separating it from plain p2sh. Drop it
and the output's policy is
{type: p2sh}, which does not match the wallet'sp2sh-p2wpkhpolicy.Nested single sig is the only script type where this can happen, because it is the only one whose
identity depends on a field its proof never reads. The proof rebuilds
p2sh(p2wpkh(K))fromour own seed at the claimed path and from the inputs' policy, so the omitted field is a
discriminator rather than evidence. For
p2sh-p2wshthe discriminator and the proof material arethe same field, so its absence is genuine silence and correctly out of reach. For p2wpkh, p2pkh
and p2tr the scriptPubKey announces the type on its own.
Implementation
_policy_shape_matchesbecomes_is_change_candidate, an instance method, since it now reads theinputs' policy and the output's verified derivation paths alongside the output's own policy.
Beside the unchanged shape comparison it admits an output on all five of:
The middle three are how "this is plausibly a nested single sig output" gets expressed in code
that cannot see the redeem script. Everything below the candidacy test is unchanged.
Two files change:
src/seedsigner/models/psbt_parser.pyandtests/test_psbt_parser.py. Thecommit message states the same argument in prose; consult it before the diff if you want the
reasoning in one piece.
Links below are pinned to this PR's head commit, so they stay correct as the branch moves.
_is_change_candidate, L721-729parse's process docstring, L216-274_parse_outputs, L431-446p2sh(p2wpkh(K))construction_build_singlesig_script, L738-758_parse_outputs, L576-596Which coordinators emit this
The opening names BlueWallet. Source-level survey, repos read at HEAD 2026-09-24, asking one
question: does the coordinator write
PSBT_OUT_REDEEM_SCRIPTon a p2sh-p2wpkh change output?abstract-hd-electrum-wallet.ts,createTransactionFromSignatureData, fromProduceSignature'ssigdataWalletNodeOutputchangeadd_output_infosetsscript_descriptor, whose setter fillsredeem_scriptBlueWallet's
HDsegwitP2SHWalletissegwitType: "p2sh(p2wpkh)"atm/49'/0'/0'. It extendsAbstractHDElectrumWalletand overrides_addPsbtInput()but notcreateTransaction(), so itschange output takes the inherited path, which passes
bip32Derivationand noredeemScript.Two limits on that survey. Bitcoin Keeper's single-sig vault change output has the same shape
(
addOutput({ ...output, bip32Derivation }), no redeem script), but I did not verify whetherthose vaults are p2sh-p2wpkh rather than native segwit or taproot, so it is not counted; its
non-vault wallets annotate no output at all, which puts them outside this entirely. Zeus and
Caravan were not reached; Caravan is legacy multisig only in any case. This is a read of source,
not a capture of real psbts.
Design Consideration
Why the admission is narrow
script to work from.
arm raises
PSBTSurplusDerivationPathsErroron an output claiming more than one path, sowithout this clause an ordinary p2sh payment annotated with two of the recipient's own entries
ends the review on
PSBTSurplusDerivationPathsView.The first two are also what keeps the resulting refusal safe. A genuine multisig output carries
one derivation path entry per cosigner, so it never reaches the contradiction check by this route.
What this does not defend against
An output paying a script type this wallet never scans is still outside the change check, because
the type genuinely differs. Catching that would mean rebuilding the seed's key under every script
type.
Screenshots
No screen changes. The refusal reaches the existing
PSBTOutputOwnershipContradictionView.Testing
Note that testqrs.com has been updated with this PR's test scenarios.
pytestpasses at 259. Two new parser tests, one for the honest output and one for the repointedone.
Before this change, measured on a nested single sig fixture with its redeem script removed: change
0, spend 10,000, with the user's own change address listed as the destination.
Mutation checks:
_is_change_candidateThe three narrowing conditions are not pinned individually: no fixture in the suite pays a legacy
p2sh multisig or an unclaimed p2sh output under
p2sh-p2wpkhinputs.I have not tested this on hardware.
Other Notes
_parse_outputsprocess docstring's cross-reference follows the rename.This pull request is categorized as a:
Checklist
I ran
pytestlocallyI included screenshots of any new or modified screens
Should be part of the PR description above.
I added or updated tests
Any new or altered functionality should be covered in a unit test. Any new or updated sequences require FlowTests.
I tested this PR hands-on on the following platform(s):
I have reviewed these notes:
Thank you! Please join our Devs' Telegram group to get more involved.