Parse the OP_RETURN push opcode instead of assuming OP_PUSHDATA1 - #1042
Open
Chaitanya-Keyal wants to merge 3 commits into
Open
Chaitanya-Keyal wants to merge 3 commits into
Chaitanya-Keyal wants to merge 3 commits into
Conversation
This was referenced Sep 19, 2026
Chaitanya-Keyal
force-pushed
the
op-return-push-opcode
branch
2 times, most recently
from
September 20, 2026 10:21
a0723c6 to
1b2aaab
Compare
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>
Chaitanya-Keyal
force-pushed
the
op-return-push-opcode
branch
from
September 23, 2026 06:28
1b2aaab to
38db4a4
Compare
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.
Closes #963 and supersedes #965 and #1003. Both fix the same bug and can be closed once this lands. @ruipereira1 and @kwsantiago have been added as co-authors on the relevant commits.
Description
Problem or Issue being addressed
PSBTParser._parse_outputs()read the OP_RETURN payload at a fixed offset:That comment is only true for payloads of 76 bytes and up. Script push opcodes are variable width:
0x01-0x4b)OP_PUSHDATA1OP_PUSHDATA2OP_PUSHDATA4Bitcoin Core emits the shortest encoding that fits, so most real OP_RETURNs are a direct push and
[3:]eats their first byte:Why the test suite did not catch it
Every existing fixture hard-coded
OP_PUSHDATA1, the one encoding the parser assumed:tests/screenshot_generator/generator.py:85-89concatenated it unconditionally. Its text payload is 50 bytes, which Core would push directly, so the screenshot was right for the wrong reason. Its other payload is 80 bytes, whereOP_PUSHDATA1really is minimal. No fixture ever produced a direct push.test_parse_op_return_contentwas built the same way:2b 6a 4c 28 43 68 61 6e ....Solution
Read the push opcode and slice to the length it declares, in a static
PSBTParser._parse_op_return_payload()helper instead of inline in the loop. All four encodings are handled.Two judgement calls, both explained in the docstring:
b""would hide committed data from a review screen.Also fixed on the same lines:
op_return_datais reset in_parse_outputs. It was only set in__init__, so re-parsing an instance kept a stale payload.b""instead of raisingIndexError.Additional Information
Left for a follow-up PR (#1043), to keep this one to a single change. None of these depend on push encoding; they are separate defects in the same area:
op_return_datais onebytesthe loop overwrites. Core v30 dropped the one-per-transaction relay limit.inputs = spend + change + feedoes not balance when sats are burned on one.PSBTOpReturnScreendraws the whole payload into one unboundedTextArea. Not just cosmetic: it reflows the entire payload before drawing anything, at quadratic cost. 1 KB takes 83ms, 4 KB takes 1.2s, 16 KB takes 15.8s on a dev machine, and the device is much slower. At Core's new default carrier size the screen is effectively unreachable.Screenshots
No new or modified screens, however the existing screenshot receives the correct bytes now.
This pull request is categorized as a:
Checklist
I ran
pytestlocallyI included screenshots of any new or modified screens
I added or updated tests
I tested this PR hands-on on the following platform(s):
Tested with my fork of Keith's testqrs.com (kdmukAI-bot/btc-datagen#2)
I have reviewed these notes: