Skip to content

fix(py-sdk): give every input a locking_script for bsv-sdk 2.4.0 - #157

Merged
icellan merged 2 commits into
mainfrom
fix/py-bsv-sdk-2.4
Aug 28, 2026
Merged

fix(py-sdk): give every input a locking_script for bsv-sdk 2.4.0#157
icellan merged 2 commits into
mainfrom
fix/py-bsv-sdk-2.4

Conversation

@icellan

@icellan icellan commented Aug 28, 2026

Copy link
Copy Markdown
Owner

Fixes the Python SDK job, currently the only red check on main.

Not a code regression — a same-day dependency release

bsv-sdk 2.4.0 was published 2026-08-28, and packages/runar-py/pyproject.toml declares bsv-sdk>=1.0 with no ceiling:

Run Resolved Result
#147 PR run ≤ 2.3.3 SUCCESS
#147 merge run on main (a3fc34d2), minutes later 2.4.0 FAILURE

Same commit content, different dependency. Nothing in that merge touched the Python SDK.

bsv/transaction_preimage.py:77
AttributeError: 'NoneType' object has no attribute 'serialize'

Five tests affected: test_c19_prepared_sighash (×3), test_continuation_satoshis, test_g1_raw_outputs_spend.

The defect is ours, so this fixes rather than pins

2.4.0 tightened the batch preimage path — _inputs_to_tuples now dereferences inp.locking_script.serialize() for every input, and its docstring calls a missing one "a caller error". That's a legitimate tightening.

_sign_with_bsv receives a serialized transaction, which carries no source-output data, so it populated locking_script only on the input it was signing. Probing the failing path shows the shape exactly:

inputs=2 signing=1
  [0] locking=None sats=None srctx=None    <-- raised here
  [1] locking=None sats=None srctx=None

Every input now gets one, with an empty Script() placeholder where unknown.

Why the placeholder cannot change a signature

BIP-143 binds only the signing input's scriptCode into its digest. Other inputs reach that digest solely through hashPrevouts and hashSequence, which hash outpoints and sequence numbers — never scripts.

Verified rather than argued. Signing the exact 2-input shape that broke yields a byte-identical signature on both versions:

2.3.3  30450221008c7345754faa4fda6ec9589734205dab67a22af441aff0eaf6583501453a5dc0...
2.4.0  30450221008c7345754faa4fda6ec9589734205dab67a22af441aff0eaf6583501453a5dc0...

Verification

  • Full packages/runar-py suite: 746 passed under 2.4.0 and 746 passed under 2.3.3 — the fix doesn't trade the new break for an old one
  • The 5 originally-failing tests pass under both

Follow-up worth considering (not in this PR)

This is the third unpinned-floor incident recently (GO-2026-5972, the Rust bsv-sdk floor, now this). packages/runar-rs/Cargo.toml already pins >=0.2.89, <0.3 with a comment explaining why. A ceiling here wouldn't have prevented this bug — the code was genuinely wrong — but it would stop CI flipping red on an unrelated PR with no code change.

CI's Python SDK job went red on main at a3fc34d with

  bsv/transaction_preimage.py:77
  AttributeError: 'NoneType' object has no attribute 'serialize'

across 5 tests. Nothing in that merge touched the Python SDK: bsv-sdk 2.4.0
was PUBLISHED THE SAME DAY (2026-08-28) and pyproject declares
'bsv-sdk>=1.0' with no ceiling, so #147's PR run resolved <=2.3.3 and passed
while the merge run resolved 2.4.0 and failed. Same commit content, different
dependency.

2.4.0 tightened the batch preimage path: _inputs_to_tuples now dereferences
inp.locking_script.serialize() for EVERY input, and its docstring calls a
missing one 'a caller error'. That is a legitimate tightening, so the fix
belongs here rather than in a version pin. _sign_with_bsv receives a
SERIALIZED tx, which carries no source-output data, so it populated
locking_script only on the input it was signing; on a 2-input call (contract
UTXO + funding UTXO) input 0 was left None.

Every input now gets one, with an empty Script() placeholder where unknown.
That is sound rather than a workaround: BIP-143 binds only the SIGNING input's
scriptCode into its digest, and the other inputs reach that digest solely
through hashPrevouts and hashSequence, which hash outpoints and sequence
numbers — never scripts.

Verified, not argued: signing the exact 2-input shape that broke produces a
BYTE-IDENTICAL signature under both versions —

  2.3.3  30450221008c7345754faa4fda6ec9589734205dab67a22af441aff0eaf65835...
  2.4.0  30450221008c7345754faa4fda6ec9589734205dab67a22af441aff0eaf65835...

so the placeholders provably do not enter the digest. Full suite: 746 passed
under 2.4.0 AND under 2.3.3, so this does not trade a new break for the old
one.
Both existing sdk-bip143 scenarios sign input 0 of a SINGLE-input transaction,
so nothing pinned what a second input changes:

  * hashPrevouts / hashSequence become digests over MORE than one outpoint and
    sequence, and
  * the signing index is non-zero, so a tier that assumed index 0 — or indexed
    the wrong input's value or scriptCode — still passed.

That gap is not hypothetical. bsv-sdk 2.4.0 began requiring a locking_script on
EVERY input and packages/runar-py populated only the one it was signing; the
break is invisible to a single-input spend and surfaced only in a real 2-input
call. This is the fixture that would have caught it.

The funding input carries a DIFFERENT sequence (0xfffffffd vs 0xffffffff) so a
tier that hashed one sequence twice diverges here rather than passing.

All seven tiers iterate the "scenarios" array generically, so the new case
extends cross-tier coverage without touching a single tier's replay code.

The two existing scenarios are byte-identical to their committed values --
verified field by field, not assumed. Regenerating wholesale would ALSO have
restamped counter_call, which is separately drifted: the generator's --check
mode re-derives preimage/digest/signature from the COMMITTED unsignedTxHex and
prevScriptHex and never recompiles the Counter contract (deliberately, so the
check "stays install-only and needs no pnpm build"). So counter_call's pinned
prevScriptHex can drift from what the compiler actually emits without CI
noticing -- write-mode regeneration on clean main changes it while --check
reports OK. That is a real gate gap, but it is not this PR's to restamp;
appending only the new scenario keeps the change reviewable.
@icellan
icellan merged commit 843b9fd into main Aug 28, 2026
42 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant