Repository navigation
Merge upstream: input amount/script verification, OP_RETURN pushes, nested change - #485
Open
3rdIteration wants to merge 56 commits into
Open
3rdIteration wants to merge 56 commits into
3rdIteration wants to merge 56 commits into
Conversation
PSBTParser summed each input's declared amount without ever checking it, so the fee shown on the overview and math screens was only a claim from the coordinator. _verify_input_amounts() now runs in parse() and raises PSBTInputAmountVerificationError when an input has no utxo data, when its outpoint index falls outside the non_witness_utxo it claims to spend, when that non_witness_utxo does not hash to the txid it claims to spend, when a legacy or unrecognized input supplies no non_witness_utxo, or when a witness_utxo disagrees with a verified non_witness_utxo. PSBTOverviewView catches it, clears the parser, and routes to a discard-only PSBTInputAmountVerificationFailedView (approach and placement from SeedSigner#977). The check runs after the ownership checks so a psbt this seed cannot sign is reported as a mismatch rather than as a suspicious transaction, and before _parse_inputs, where amounts are first summed. Segwit inputs carrying only a witness_utxo are still accepted, since BIP-143 commits the amount they sign and many coordinators never sends both utxo fields Co-Authored-By: 0kPN1 <55192256+0kPN1@users.noreply.github.com>
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.
The nested single sig outlier in change candidacy also required the output's one derivation path entry to claim this seed's fingerprint. That kept an output paying our key under another seed's key and fingerprint out of the rebuild, so it was shown as a spend to the user's own address, while the same output with its redeem script present is refused as a contradiction. The rebuild derives our key at the entry's path whatever fingerprint it lists, so the outlier drops the condition and _is_change_candidate drops the verified derivation paths it read for it. The outlier also required exactly one derivation path entry. It only kept out bare p2sh outputs annotated with several keys while omitting their scripts, which no surveyed coordinator emits, and the single sig surplus check now refuses those. The m-of-n exclusion becomes a check that the output omits its redeem script. For an output parsed as plain p2sh the two admit the same outputs, since a supplied multisig redeem script is the only source of an m-of-n there, and the new check states what the outlier is for. New tests refuse an output that pays this seed but lists another key, and pin each of the three remaining conditions: a payment to another wallet of this seed, annotated with our key, stays a spend.
Renaming _policy_shape_matches to _is_change_candidate dropped the reason the candidacy test compares shape alone. Without it, adding the cosigners to the comparison reads as a harmless tightening. The old wording named a misannotated fingerprint as what breaks an output's cosigner resolution. Since SeedSigner#1032, _get_cosigners matches each key to a global xpub by derivation path alone and never reads the fingerprint, so the restored note names a misannotated derivation path instead.
…inglesig_change [security] Verify nested single sig change that omits its redeem script
…type() docstrings
Co-authored-by: kdmukai <934746+kdmukai@users.noreply.github.com>
Co-authored-by: kdmukai <934746+kdmukai@users.noreply.github.com>
Co-authored-by: kdmukai <934746+kdmukai@users.noreply.github.com>
Co-authored-by: kdmukai <934746+kdmukai@users.noreply.github.com>
Co-authored-by: kdmukai <934746+kdmukai@users.noreply.github.com>
[security] Verify PSBT input amounts before displaying the fee
embit computes the fee as inputs minus outputs and never looks at the sign of the result, so a psbt that spends more than it funds parses cleanly and reaches the review flow quoting a negative fee. Raise PSBTNegativeFeeError as soon as the fee is known and route it to its own warning. Malformed rather than hostile: no honest coordinator builds one and the network would refuse to relay it, so it gets a plain warning, not the attack framing. A zero fee is still accepted.
The error's docstring argued about a lying coordinator's motives and why the error exists for the user. Like the other PSBTVerificationError subclasses, it now just states the problem and that it is not treated as an attack. The one part of the reasoning that matters, that both totals are the coordinator's own claims, moves to the check that raises it.
_verify_input_amounts() now checks every input's amount before the outputs are parsed, so the input total is no longer just the coordinator's claim.
…-fee Reject psbts whose outputs exceed their inputs
The payload was read at a fixed 3-byte offset, which only holds when the script
uses OP_PUSHDATA1. Opcodes 0x01-0x4b are themselves the byte count, and that is
the minimal encoding Bitcoin Core emits, so every payload of 75 bytes or fewer
lost its first byte:
scriptPubKey 6a 28 "Chancellor on the brink of third bailout"
parsed b'hancellor on the brink of third bailout'
PSBTOpReturnView is a pre-signing review screen, so the user approved a
transaction whose OP_RETURN content was displayed wrong. When the payload is not
human-readable it renders as hex, where a one byte shift is invisible.
Read the push opcode and slice to the declared length, in a static helper so the
loop stays readable. OP_PUSHDATA2 and OP_PUSHDATA4 are handled too: Bitcoin Core
v30 raised the default -datacarriersize to 100,000 bytes, and consensus never
limited OP_RETURN data at all, so a payload past what OP_PUSHDATA1 can describe
is no longer exotic.
Several pushes after OP_RETURN are legal and are concatenated; the user is being
shown the data the transaction commits to, and the push boundaries carry no
consensus meaning. Anything that is not a data push, or a push that overruns the
script, ends the walk and the remaining bytes are surfaced verbatim rather than
dropped.
Also reset op_return_data in _parse_outputs, which only ever set it in
__init__, and compare the first byte by slice so an empty scriptPubKey does not
raise IndexError.
Fixes SeedSigner#963
Co-authored-by: ruipereira1 <159703278+ruipereira1@users.noreply.github.com>
Co-authored-by: kwsantiago <44934418+kwsantiago@users.noreply.github.com>
Every existing OP_RETURN vector hand-built OP_PUSHDATA1, which is exactly the one encoding the parser assumed, so the mis-slice never showed up. The regtest psbt behind test_parse_op_return_content carries 2b 6a 4c 28 43 68 61 6e ... and the screenshot generator concatenated OP_PUSHDATA1 by hand. Add fixture helpers that build an OP_RETURN scriptPubKey with the minimal push by default, and that can force a specific OP_PUSHDATA* so the non-minimal encodings a coordinator is free to produce are testable too. Note in the helper that the scriptPubKey and value have to be set on the OutputScope: PSBT.tx rebuilds the transaction from the scopes on every access, so assigning through psbt.tx.vout[i] is silently discarded and leaves a test asserting against the fixture it meant to replace. The new tests cover the direct-push/OP_PUSHDATA1 boundary at 75/76, OP_PUSHDATA2 and OP_PUSHDATA4, a non-minimal encoding, several pushes in one script, a bare OP_RETURN, an empty scriptPubKey, a truncated length field, an over-long declared length, and a leading opcode that is not a data push. Co-authored-by: ruipereira1 <159703278+ruipereira1@users.noreply.github.com> Co-authored-by: kwsantiago <44934418+kwsantiago@users.noreply.github.com>
The generator concatenated OP_PUSHDATA1 unconditionally, so its 49-byte payload was encoded a way Bitcoin Core would never produce. Push directly at 75 bytes or fewer and via OP_PUSHDATA1 above that, matching the parser. The rendered screenshots are unchanged; the vector behind them is no longer wrong. Co-authored-by: kwsantiago <44934418+kwsantiago@users.noreply.github.com>
`op_return_script`'s `push_opcode` and `create_op_return_psbt`'s `change_amount` and `fee_amount` were never used by any test. The non-minimal encoding cases build their bytes by hand and call `_parse_op_return_payload` directly.
Bitcoin Core treats an OP_RETURN output as nonstandard unless everything after the opcode is a push. The parser used to display whatever was left when a push ran past the end of the script, or when it met an opcode that is not a push. It now raises PSBTMalformedOpReturnError instead, and the user gets the same plain "Transaction Problem" warning used for other malformed psbts before discarding it. OP_0, OP_1NEGATE, OP_RESERVED, and OP_1 through OP_16 still count as pushes to Core, so they are kept and the walk carries on past them rather than stopping there.
…h-opcode Parse the OP_RETURN push opcode instead of assuming OP_PUSHDATA1
BIP-174 asks a signer to hash an input's redeem or witness script against the scriptPubKey being spent. We read the wallet policy (m-of-n, cosigner keys) out of those scripts and never checked them, so a coordinator's own script decided which outputs we displayed as the user's change. _verify_input_scripts runs on every input before the policy is read, and each refusal has its own screen: - A missing script raises PSBTMissingInputScriptError, graded as a correctness problem. - A script that hashes to the wrong value raises PSBTInputScriptMismatchError, graded as an attack. It is the same contradiction between a supplied script and its scriptPubKey that PSBTOutputOwnershipContradictionError grades as an attack on outputs. - An extraneous script (a witness script on a legacy multisig input, a redeem script on a native segwit input) raises PSBTExtraneousInputScriptError, left ungraded. It is refused on every input type: there is no hash to check it against, and _get_policy decides a p2sh input is nested segwit from the mere presence of a witness script. Measured across the four p2sh and p2wsh fixtures: no signature over a wrong script was valid against the real coin, because embit builds the digest from the first supplied script (witness, then redeem, then scriptPubKey). Funds were never movable this way; what the check closes is the display.
The _verify_input_scripts docstring said BIP-174 requires the psbt to supply an input's redeem or witness script. BIP-174 allows a psbt to be passed between roles before every script is known, with each Updater adding the scripts it knows. A signer still needs an input's script to produce a valid signature for that input. BIP-174's signer rules check a script once it is provided. The mismatch and extraneous checks enforce those rules. The docstring now also groups the checks by the inputs they cover. The missing and mismatch checks run only on p2sh and p2wsh inputs. The extraneous script check runs on every input. Docstring-only change.
A payjoin receiver's input arrives finalized (BIP-78), and finalizing clears its redeem and witness script fields (BIP-174). So a nested segwit payjoin is refused as missing its input script; before input script verification it was refused as "Mixed inputs". Native segwit payjoins parse either way. Two TODOs mark what payjoins and other collaborative spends would need: - recover a finalized input's script from its final scriptSig or witness - read the wallet policy only from the user's own inputs The every-input test's docstrings now describe its fixture by the rule it tests rather than as a payjoin input. It is an unfinalized input that carries its redeem script, built to check that every input's scripts are verified whoever the psbt says the input belongs to. Comment-only change.
The comment above the _get_policy call in _parse_inputs said the input's scripts could now be used "safely". The check before it binds each script to the scriptPubKey the psbt supplies, and nothing in the parse authenticates that scriptPubKey yet, so the word claims more than the check establishes. The shared test base class described itself as the base for the two ownership test classes. The input script tests are a third subclass, and not an ownership test class. Comment-only change.
TestPSBTParserInputScripts carried a copy of _foreign_multisig_script identical to the one in TestPSBTParserOutputOwnership. Both classes now inherit it from PSBTParserOwnershipTestBase. The base class docstring listed the fixtures the class body defines. Adding this helper would have meant extending that list. It now names the tests the fixtures support: how PSBTParser verifies a psbt's claims about its inputs and outputs. That covers the input script tests too, which check an input's scripts whoever owns it. Test-only change.
The docstring now lists the method's two steps in the order they run: verifying each input's scripts and determining the wallet policy. Docstring-only change.
To decide whether an input's sighash commits its amount, the amount check reads the input's script type, and telling nested segwit from legacy p2sh reads the input's redeem and witness scripts. Those scripts were verified only later, in _parse_inputs. So a witness-only nested segwit input missing one of its scripts read as legacy p2sh and was refused with PSBTInputAmountVerificationError, graded as an attack, instead of PSBTMissingInputScriptError. The script check now runs inside the amount check's loop: after the checks on an input's previous transaction, so a forged one is still refused as an attack, and before the check that reads the scripts. The method is renamed _verify_input_amounts_and_scripts to match, and the per-type utxo requirements move to the comment on UNCOMMITTED_AMOUNT_SCRIPT_TYPES. The foreign nested segwit input in the every-input test now carries a witness_utxo alone, so its missing-redeem-script case takes the witness-only path. The test fails on the previous order.
The docstring and inline comments in _verify_input_amounts_and_scripts ran to 104 columns. They are now wrapped at 90, with no wording changes apart from a closing period on the two comments that now span two lines. Comment-only change.
parse()'s docstring is a high-level overview of the steps. _verify_input_amounts_and_scripts and _verify_input_scripts each end their docstring with a list of every error they raise, and name the errors only there.
[l10n] Enable Swahili
…ripts [security] Verify input scripts against the scriptPubKey they commit to
…s, nested change Brings in SeedSigner/seedsigner SeedSigner#995 (verify input amounts), SeedSigner#1046 (nested single sig change without its redeem script), SeedSigner#1041 (reject a negative fee), SeedSigner#1042 (parse the OP_RETURN push opcode), SeedSigner#1047 (verify input scripts) and SeedSigner#1065 (enable Swahili). Conflict resolution, keeping the fork's behaviour wherever it is equal or stronger: - Input amounts (SeedSigner#995) and negative fee (SeedSigner#1041): the fork's _validate_input and NEGATIVE_FEE already cover upstream's checks and more (MAX_MONEY ranges, SIGHASH_ALL only, witness_utxo on a non-segwit prevout, the totals check, the UNVERIFIED_INPUT_AMOUNTS advisory). Upstream's _verify_input_amounts_and_scripts is dropped. Upstream's verified_input_amount name is adopted, assigned only once every input passes; input_amount stays as a read-only alias. _validate_input now also calls embit's inp.verify(), so InputScope.is_verified is set. - Input scripts (SeedSigner#1047): the fork's hash checks are kept. Adopted from upstream: a stray redeem_script on a non-p2sh input is refused (new EXTRANEOUS_REDEEM_SCRIPT), and an absent script is reported as the new MISSING_INPUT_SCRIPT rather than as a hash mismatch. Upstream's finalized-input and payjoin TODOs are carried over. - OP_RETURN (SeedSigner#1042): adopted upstream's _parse_op_return_payload. OP_0, OP_1NEGATE, OP_RESERVED and OP_1..OP_16 count as pushes, as in Core's IsPushOnly. The fork refused these, which meant every Runes runestone (OP_RETURN OP_13 ...). A malformed one raises the new MALFORMED_OP_RETURN. The fork's per-output payload list and burn handling are kept. - Nested change (SeedSigner#1046): adopted upstream's _is_change_candidate, so redeem-less p2sh-p2wpkh change is proven by rebuild instead of being shown as a payment. _get_script_type is adopted as well. - Errors: upstream's PSBT*Error classes map onto InvalidPSBTError + RejectCode, as in the previous merge. Upstream's View classes become PSBTRefusalView subclasses with the same names. A new also_codes attribute lets one view stand for more than one code. REJECT_PRESENTATION gains entries for the input amount, script, negative fee and OP_RETURN refusals, using upstream's msgids. The deceptive ones (UTXO_MISMATCH, INVALID_WITNESS_UTXO, SCRIPT_HASH_MISMATCH) are Dire / "Likely an Attack!"; the malformed ones are plain warnings. Before, they fell through to the generic "Invalid PSBT" screen with untranslated parser text. - Tests: upstream's new parser and flow tests are ported to raises_reject. Where the fork returns a more specific code, the assertion is changed and the difference is commented: INVALID_WITNESS_UTXO for legacy-without-prevtx, UTXO_MISMATCH for an out-of-range vout, MISLABELED_OUTPUT_OWNERSHIP, and EXTRANEOUS_WITNESS_SCRIPT. PSBTParserOwnershipTestBase is split back out, as upstream has it. Upstream's foreign-input helper now uses a fresh outpoint, because the fork refuses a double spend. - Swahili: upstream's locale entry is taken as is. seedsigner-translations does not ship l10n/sw yet, so it falls back to English until then. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
24 new corpus vectors, with _V2 twins where the trap is not version-specific. derive_followup_vectors.py derives each one from a vendored fixture with a single edit, and is deterministic: - OP_RETURN: a Runes runestone (OP_13), small-int pushes and a multi-push are negative controls that must be shown. A truncated push and a non-push opcode are refused as MALFORMED_OP_RETURN. - Input scripts: a stray redeem_script, a missing redeem_script or witness_script, a wrong redeem_script, and a wrong script on another party's input in a collaborative spend. - Change: nested segwit change without its redeem_script is counted as change. A redeem-less output that claims this seed but pays a stranger is refused as FORGED_OUTPUT_OWNERSHIP; before the merge it parsed as an ordinary payment. - A zero fee is accepted. Also asserts the exact payload shown for each push-only OP_RETURN vector, and records the vectors in PROVENANCE.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tors psbt_faker now builds every 2026-10-31 follow-up vector itself: same ids, traps, amounts and verdicts as before. The 24 locally derived fixtures are replaced with its output, and derive_followup_vectors.py is removed, so the whole corpus again has one source. New: four nested segwit (p2sh-p2wsh) multisig vectors at m/48h/0h/0h/1h. XTRAS.MS_NESTED_HONEST is a negative control. XTRAS.MS_NESTED_WRONG_WITNESS_SCRIPT breaks only the inner layer (witness_script to redeem_script) and XTRAS.MS_NESTED_WRONG_REDEEM_SCRIPT only the outer one (redeem_script to scriptPubKey), so both layers must be checked. XTRAS.MS_NESTED_MISSING_WITNESS_SCRIPT is MISSING_INPUT_SCRIPT. dev already refused both layer swaps, so these are regression coverage. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
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.
Merges the 53 new commits from
SeedSigner/seedsignerdev(merge-basecfaf443a):The rule throughout: follow upstream unless that would weaken security. The real merge commit holds the conflict resolution. The commits after it add the test vectors.
What was kept, what was adopted
_validate_inputalready did all of it and more: MAX_MONEY ranges, SIGHASH_ALL only, rejecting awitness_utxoon a non-segwit prevout, the totals check, and theUNVERIFIED_INPUT_AMOUNTSadvisory (upstream accepts CVE-2020-14199 silently)verified_input_amountname is adopted, andinput_amountstays as an alias.InputScope.is_verifiedis now set as well.NEGATIVE_FEEplus the totals checkredeem_scripton a non-p2sh input is refused (newEXTRANEOUS_REDEEM_SCRIPT), and an absent script is nowMISSING_INPUT_SCRIPTinstead of being reported as a hash mismatchOP_RETURN OP_13 …) was refused_parse_op_return_payload, matching Core'sIsPushOnly). Malformed scripts are refused as the newMALFORMED_OP_RETURN. The fork's multi-payload list and burn handling are unchanged._is_change_candidate). The rebuild now proves the first case is change and refuses the second asFORGED_OUTPUT_OWNERSHIP.seedsigner-translationshas nol10n/swyet, so it falls back to English until it does. Upstream is in the same position.How the conflicts were resolved
PSBT*Errorclasses map ontoInvalidPSBTError+RejectCode, as in Merge upstream: multisig output claim hardening + high fee warning #469. Upstream's View classes arePSBTRefusalViewsubclasses with the same names, so upstream's flow tests and screenshot configs resolve. A newalso_codesattribute lets one view stand for several codes.REJECT_PRESENTATIONnow has entries for them, using upstream's msgids:UTXO_MISMATCH,INVALID_WITNESS_UTXO,SCRIPT_HASH_MISMATCHMISSING_UTXO,MISSING_INPUT_SCRIPT,EXTRANEOUS_*_SCRIPT,NEGATIVE_FEE,MALFORMED_OP_RETURNraises_reject. Where the fork returns a more specific code, the assertion follows the fork and a comment explains the difference:INVALID_WITNESS_UTXO,UTXO_MISMATCHfor an out-of-range vout,MISLABELED_OUTPUT_OWNERSHIP,EXTRANEOUS_WITNESS_SCRIPT.PSBTParserOwnershipTestBaseis split back out, as upstream has it.TestPSBTParserOutputOwnershipno longer re-runs the seed-ownership tests by inheriting them.New test vectors
28 corpus fixtures, with
_V2twins where the trap isn't version-specific. All are generated by psbt_faker's new builders and vendored withgenerate.py --keep-existing. Each builder seeds its RNG from its test id, so the bytes are reproducible. PROVENANCE.md records them. (An earlier commit on this branch derived 24 of them locally; those are now replaced by the psbt_faker output.)devXTRAS.OP_RETURN_RUNESTONE,_SMALLINTUNDISPLAYABLE_OUTPUT)XTRAS.OP_RETURN_MULTI_PUSHXTRAS.OP_RETURN_TRUNCATED_PUSH,_NON_PUSHUNDISPLAYABLE_OUTPUTMALFORMED_OP_RETURNXTRAS.STRAY_REDEEM_SCRIPTEXTRANEOUS_REDEEM_SCRIPTXTRAS.MISSING_REDEEM_SCRIPT,XTRAS.MISSING_WITNESS_SCRIPTSCRIPT_HASH_MISMATCHMISSING_INPUT_SCRIPTXTRAS.WRONG_REDEEM_SCRIPT,XTRAS.FOREIGN_INPUT_BAD_SCRIPTSCRIPT_HASH_MISMATCHSCRIPT_HASH_MISMATCHXTRAS.NESTED_CHANGE_NO_REDEEMTX-21.nested_bare_p2sh_claims_us_pays_otherFORGED_OUTPUT_OWNERSHIPXTRAS.ZERO_FEEXTRAS.MS_NESTED_HONEST(2-of-3 p2sh-p2wsh atm/48h/0h/0h/1h)XTRAS.MS_NESTED_WRONG_WITNESS_SCRIPT(inner layer only) /_WRONG_REDEEM_SCRIPT(outer layer only)SCRIPT_HASH_MISMATCHSCRIPT_HASH_MISMATCH(regression coverage)XTRAS.MS_NESTED_MISSING_WITNESS_SCRIPTSCRIPT_HASH_MISMATCHMISSING_INPUT_SCRIPTTesting
test_psbt_parser.py,test_psbt_test_suite.py,test_flows_psbt.py,test_flows_psbt_malicious.py,test_psbt_refusal_screens.py,test_psbt_multisig_key_paths.py: 1257 passed. The 56 skips are all fixed-prose codes, covered bytest_table_prose_fits. The new refusal text fits on screen (test_table_prose_fits).devin this environment: 5 l10n tests (no compiled catalogs locally) andtest_real_connector_has_satodime_ndef_v2.psbt_viewsclasses it references exist and render with the intended screen class. Nothing was tested on hardware.Follow-ups
🤖 Generated with Claude Code