Skip to content

Remediate 14 of 15 open issues: 9 correctness bugs across all 7 tiers + design features#136

Open
icellan wants to merge 91 commits into
mainfrom
remediation/7tier-correctness-pr1
Open

Remediate 14 of 15 open issues: 9 correctness bugs across all 7 tiers + design features#136
icellan wants to merge 91 commits into
mainfrom
remediation/7tier-correctness-pr1

Conversation

@icellan

@icellan icellan commented Jul 7, 2026

Copy link
Copy Markdown
Owner

Remediate 14 of 15 open issues: 9 correctness bugs across all 7 tiers + design features

Closes the correctness backlog filed as #106#134. Nine "tests-green / chain-wrong" divergence bugs are fixed across all seven compiler/SDK tiers (TS, Go, Rust, Python, Zig, Ruby, Java) with the conformance suite regenerated and green in both fold modes; four design/RFC features land at their reference tiers behind fail-closed guards; and the differential fuzz harness is extended to a tri-modal (interpreter / ScriptVM / @bsv/sdk Spend) gate that mechanically catches this bug class.

Staged deliverable (per maintainer decision): this PR is the verified 7-tier correctness work + reference-tier design features. A follow-up PR #2 ports the two new author-facing directives (@sighash, @embedAlways) and the EmptySig marker to the remaining tiers. Until then, non-TS compilers fail closed (error) on those directives — no silent cross-tier divergence.

Correctness bugs — fixed in all 7 tiers, conformance green

# Bug Fix
#121 non-zero-start / countdown for loops silently compiled i=0..count-1 ANF Loop node gains start+step; lowering pushes start + i*step; runtime (non-literal) bounds now reject cleanly in every tier (were a silent 0-iteration miscompile)
#130 a method param shadowing a mutable state prop compiled addOutput/state to the OLD state value register method params before body lowering; this.x still resolves to the property; stack-lowering renames colliding param slots
#116 stateful epilogue hashed a change output unconditionally; SDK omits it at change==0 → exact-cover calls never validate runtime if (_changeAmount != 0) gate around the change segment, matching the SDK boundary exactly
#119 fromUtxo/fromTxId filled constructor args with 0n placeholders parse the real args from the deployed script (extractConstructorArgs), paramIndex-ordered
#132 computeOpPushTxWithCodeSep used a placeholder-args offset on the restore path → invalid OP_PUSH_TX sigs for every method after a ctor slot byte-walk the real _codeScript for the codesep offset (mirror getSubscriptForSigning); template path is the deploy-time fallback. (Already correct in every non-TS tier — only TS had the divergent path.)
#131 all-final input sequences → CallOptions.locktime not consensus-enforced CallOptions.sequence; default 0xfffffffe when locktime set & non-zero
#133 call() swept every wallet UTXO as a funding input smallest-sufficient coin selection (reuse selectUtxos) + CallOptions.maxFundingInputs
#134 funding inputs signed with the method signer CallOptions.fundingSigner / DeployOptions.fundingSigner
#118 terminal (full-payout) txs couldn't pay a miner fee CallOptions.feeUtxo: a P2PKH input added before the OP_PUSH_TX preimage, consumed as fee

Ports also fixed 3 latent pre-existing bugs: a Java IndexOutOfBounds when the wallet had more UTXOs than selected (#133 path), a Zig <=/>= loop off-by-one, and the #132 codesep asymmetry itself.

Test infra & design features

Not in this PR

Known follow-ups (tracked, non-blocking)

Verification (full matrix, all green)

icellan added 30 commits July 7, 2026 13:07
…131)

All-0xffffffff input sequences make nLockTime a consensus no-op, so a
locktime-gated method was script-enforced (extractLocktime) but not
consensus-enforced. Add CallOptions.sequence and default every input to
0xfffffffe when a non-zero locktime is set (else keep 0xffffffff). Applied
to buildCallTransaction (all three input sites) and the terminal-path tx
builder via the shared resolveInputSequence helper. TypeScript tier only.
…TXOs (#133)

The non-terminal call path forwarded every wallet UTXO (minus the contract
UTXO) as funding, producing an (N+1)-input tx no matter how little funding
the call needed. Replace the sweep with smallest-sufficient largest-first
selection via selectUtxos (the strategy deploy already uses), covering only
the output/fee shortfall the contract's own input value does not. Add
CallOptions.maxFundingInputs to cap funding inputs and fail loudly when the
cap cannot cover outputs + fee. TypeScript tier only.
…ript

RunarContract.fromUtxo (and fromTxId, which delegates to it) filled the
constructor argument list with 0n placeholders, so every restored contract
operated on zeros: the ANF interpreter computed the wrong state continuation
(readonly ctor params feed the continuation formula) and adjustCodeSepOffset
computed a zero shift, yielding the wrong OP_CODESEPARATOR / OP_PUSH_TX offset
for methods after a ctor slot. Restored stateful spends were unspendable.

Add restoreConstructorArgs(): parse the deployed locking script via the
existing extractConstructorArgs() and order the values by ABI param
(paramIndex). Params without a constructor slot (mutable state fields) fall
back to 0n and are overwritten by extractStateFromScript.

RED->GREEN: a stateful contract with a readonly ctor slot, restored via
fromUtxo, now reports the real slot value and round-trips through @bsv/sdk
Spend for both public methods.
…er (#134)

deploy() and prepareCall()'s three funding-signing loops signed every P2PKH
funding input with the connected method signer and pushed that signer's
pubkey, so funding coins owned by a different key failed OP_EQUALVERIFY. Add
DeployOptions.fundingSigner / CallOptions.fundingSigner; the funding loops now
use fundingSigner ?? signer while the method's own Sig args keep the connected
signer. Defaults to the connected signer (zero behaviour change). TypeScript
tier only.
…ript

getSubscriptForSigning already byte-walks the real _codeScript via
findCodesepOffsets when the contract is chain-loaded (#42/#47), but its sibling
computeOpPushTxWithCodeSep still derived the OP_CODESEPARATOR offset from
adjustCodeSepOffset, which recomputes the shift from the in-memory
constructorArgs. When those args don't match the bytes baked into _codeScript
(the restore path, or any caller building a RunarContract with a real
_codeScript but wrong/placeholder args), the derived offset is wrong and the
OP_PUSH_TX signature is computed over the wrong scriptCode.

Byte-walk _codeScript for the true offset (index by method) when it is
available, mirroring getSubscriptForSigning; keep adjustCodeSepOffset only as
the deploy-time template fallback (_codeScript === null).

Independent of #119: a unit test forces placeholder constructorArgs on a
contract with a real _codeScript and asserts the OP_PUSH_TX preimage/sig are
unchanged from the real-args contract. RED before, GREEN after.
…eeUtxo (#118)

A true terminal method pays out the full contract balance, so fee == 0 and
ARC rejects; the covenant asserts its exact output set, so no change output
can absorb a fee. Add CallOptions.feeUtxo: a plain P2PKH input added to the
terminal tx BEFORE the OP_PUSH_TX preimage is computed (so hashPrevouts covers
it), consumed entirely as fee with no change output. The covenant's output
assertions are untouched — only the input side grows. Signed with
fundingSigner ?? signer, composing with #134. TypeScript tier only.
The ANF loop node carried only an iteration count and always lowered the
iterator as i = 0..count-1, so non-zero-start and countdown loops diverged
from interpreter semantics. The front-end had been rejecting those shapes
outright.

Add explicit start (bigint) and step (1|-1) to the Loop ANF node (compiler +
runar-ir-schema TS type, JSON schema, README). extractLoopShape computes the
real start/step/count; stack lowering pushes start + i*step per unrolled
iteration; the SDK ANF interpreter honors start/step. Zero-start counting-up
loops (start=0, step=1) reduce to BigInt(i), preserving byte-identical output.
Remove the now-obsolete validate/anf-lower rejections.

TS reference tier only; the other six tiers need matching ports.
…he param (#130)

A method param whose name collides with a mutable state property was
compiled to the stale deserialized on-chain state, not the witness value,
when referenced by a bare identifier (e.g. as an addOutput positional value
or an assignment RHS).

Two layers were involved:
1. ANF resolution: declared params were never registered via addParam, so a
   bare identifier fell through isProperty -> load_prop. Register method
   params so isParam wins. Also reorder the property_access lowering to check
   isProperty before isParam, so explicit this.x still resolves to the
   property once params are registered (the isParam branch is only for the
   implicit this.txPreimage member).
2. Stack lowering: deserialize_state pushes each mutable property onto the
   stack under its own name, colliding with the same-named param slot; name
   lookups pick the shallower deserialized slot, so load_param read stale
   state. Rename a colliding param's slot to a reserved name at method entry
   and map it in lowerLoadParam. Only triggers on an actual param/mutable-prop
   name collision, so all other contracts are byte-identical.

TS reference tier only; the other six tiers need matching ports.
The stateful continuation folded a P2PKH change output into the hashed
output set unconditionally, but the SDK's buildCallTransaction omits the
change output (and passes _changeAmount = 0) when change <= 0. An exact-cover
call (funding == outputs + fee, change 0) could therefore never validate: the
script demanded a change output the SDK never built.

Wrap the change segment in a runtime ANF if (_changeAmount != 0n): the hashed
set carries the P2PKH change output when non-zero and empty bytes (cat with
empty is a no-op) when zero, matching the SDK at the exact-zero boundary. The
change > 0 execution path is unchanged in behaviour (verified by the #99/#100
exec tests) but the EMITTED bytes now carry the guard.

NOTE: this necessarily changes the deployed bytes of every stateful contract
that emits a change output, so the checked-in conformance goldens
(expected-script.hex for the stateful fixtures) and the 6 non-TS frontends
must be regenerated/ported together. Not done on this TS-only branch — the
task's 'byte-identical for change>0' expectation cannot hold for a runtime
conditional. TS unit goldens (06-emit, 04-anf-lower structure) updated in
place; conformance golden FILES intentionally left untouched.
Add explicit start (big.Int) and step (+1/-1) to the Go ANF loop node,
mirroring the TS reference. extractLoopShape computes the real
start/step/count; stack lowering pushes start + i*step per unrolled
iteration; the Go SDK ANF interpreter honors start/step. Zero-start
counting-up loops (start=0, step=1) reduce to i, preserving byte-identical
output. Remove the obsolete validate/anf-lower non-zero-start and countdown
rejections.

Loop start is serialized as a bare JSON number (int64 range) or an Nn
decimal string, byte-identical to the TS ANF JSON. Verified Go IR + hex are
byte-identical to TS for bounded-loop, a countdown loop, and a non-zero-start
loop.

The bounded-loop expected-ir.json golden now legitimately mismatches (loop
node gains start/step) in both TS and Go; regeneration is a later coordinated
step. Hex golden is unchanged.
Add explicit start (bigint) and step (1|-1) to the Rust ANF Loop node,
mirroring the TS reference. extract_loop_shape computes the real
start/step/count from the for-statement init/condition/update; stack
lowering pushes start + i*step per unrolled iteration; the SDK ANF
interpreter honors start/step. Zero-start counting-up loops (start=0,
step=1) reduce to i, preserving byte-identical output. Remove the
now-obsolete validate/anf-lower countdown + non-zero-start rejections.

Verified byte-identical to TS (hex + IR) for bounded-loop, a countdown
loop, and a non-zero-start loop. bounded-loop expected-ir.json now
lags the emitted IR by the added start/step fields (script hex
unchanged); left un-regenerated pending the other 5 tiers.
…he param in Go (#130)

Two layers, mirroring the TS reference:
1. ANF resolution: register declared method param NAMES via addParam so a bare
   identifier resolves to load_param before falling through to load_prop.
   Reorder property_access lowering to check isProperty before isParam so
   explicit this.x still resolves to the property.
2. Stack lowering: deserialize_state pushes each mutable property onto the stack
   under its own name, colliding with the same-named param slot. Rename a
   colliding param's slot to a reserved __param_<name> at method entry and map
   it in lowerLoadParam so load_param reads the witness value, not stale state.

Only triggers on an actual param/mutable-prop name collision, so all other
contracts are byte-identical (63/64 goldens unchanged; bounded-loop mismatch is
the separate #121 start/step change). Verified the retire(balance) shadow case
lowers the bare RHS to load_param in both Go and TS.
…Rust

A method param whose name collides with a mutable state property compiled
to the stale deserialized on-chain state, not the witness value, when
referenced by a bare identifier. Port the two-layer TS fix:

1. ANF resolution: register declared method param NAMES via add_param so a
   bare identifier resolves to load_param before falling through to
   load_prop. Reorder PropertyAccess / lower_member_expr this.x lowering to
   check is_property before is_param, so explicit this.x still resolves to
   the property once params are registered (is_param is only for the
   implicit this.txPreimage member).
2. Stack lowering: rename a colliding param's slot to a reserved
   __param_<name> at method entry (before deserialize_state pushes the
   same-named property slot) and map it in lower_load_param. Only triggers
   on an actual param/mutable-prop name collision, so all other contracts
   are byte-identical.

Verified: bare identifier resolves to load_param, this.x to load_prop; a
readonly-shadow contract is byte-identical Rust==TS; conformance goldens
unchanged (59/64, only #121 bounded-loop stale).
#116)

Wrap the change-output segment in a runtime ANF if (_changeAmount != 0):
the hashed continuation set carries the P2PKH change output when non-zero
and empty bytes (cat with empty is a no-op) when zero, matching the SDK's
BuildCallTransaction which omits the change output on an exact-cover call
(change == 0). For any change > 0 the hashed bytes are unchanged; only the
emitted script gains the OP_IF/ELSE/ENDIF guard.

Mirrors the TS reference byte-for-byte: verified Go hex == TS hex for all 22
change-output stateful conformance fixtures (stateful-counter, token-ft,
auction, ...), each differing from the old golden by the same guard wrapper.
Updated the AddDataOutput continuation-ordering structural test to expect the
change segment as an if node (buildChangeOutput lives in its then-branch).

NOTE: this changes the deployed bytes of every stateful contract that emits a
change output. The conformance expected-script.hex goldens for those fixtures
now legitimately mismatch (in both TS and Go) and need regeneration as a later
coordinated step; not done here.
… != 0 to Rust

The stateful continuation folded a P2PKH change output into the hashed
output set unconditionally, but the SDK's build_call_transaction omits the
change output (and passes _changeAmount = 0) when change <= 0. An
exact-cover call (change 0) could therefore never validate.

Wrap the change segment in a runtime ANF if (_changeAmount != 0): the
hashed set carries the P2PKH change output when non-zero and empty bytes
(cat with empty is a no-op) when zero, matching the SDK at the exact-zero
boundary. Change > 0 behaviour is unchanged; only the emitted script gains
the guard.

Verified byte-identical Rust==TS (hex + IR) for stateful-counter, token-ft,
and a mutable-shadow repro. Updates the emit.rs inline byte golden in place
(mirroring the TS 06-emit unit-golden update). Conformance golden FILES
intentionally left untouched: this changes every stateful+change fixture's
bytes, so all 7 tiers must be regenerated together (21 hex+IR fixtures
pending; see report).
…n Go (#131)

Add CallOptions.Sequence (+ BuildCallOptions.Sequence) and a shared
resolveInputSequence helper: default every input to 0xfffffffe when a non-zero
locktime is set (so nLockTime is consensus-enforced), else 0xffffffff (final,
legacy); an explicit sequence always wins. Applied to BuildCallTransaction (all
three input sites) and the terminal-path tx builder, and threaded from
CallOptions through the non-terminal build + rebuild sites and the terminal
site. Mirrors the TS reference.
…equences to Rust

All-0xffffffff input sequences make nLockTime a consensus no-op, so a
locktime-gated method was script-enforced (extractLocktime) but not
consensus-enforced. Add CallOptions.sequence and default every input to
0xfffffffe when a non-zero locktime is set (else keep 0xffffffff). Applied
to build_call_transaction_ext (all three input sites) and the terminal-path
tx builder via a shared resolve_input_sequence helper.

Tests: non-zero locktime defaults inputs to 0xfffffffe; explicit sequence
overrides; resolve_input_sequence matches the TS default table.
…TXOs in Go (#133)

The non-terminal call path forwarded every wallet UTXO (minus the contract
UTXO) as funding, producing an (N+1)-input tx no matter how little funding the
call needed. Replace the sweep with smallest-sufficient largest-first selection
via SelectUtxos (the strategy deploy uses), covering only the output/fee
shortfall the contract's own input value does not. Add CallOptions.MaxFundingInputs
to cap funding inputs and fail loudly when the cap cannot cover outputs + fee.
Mirrors the TS reference.
… Rust

The non-terminal call path forwarded every wallet UTXO (minus the contract
UTXO) as funding, producing an (N+1)-input tx no matter how little funding
the call needed. Replace the sweep with smallest-sufficient largest-first
selection via select_utxos (the strategy deploy uses), covering only the
output/fee shortfall the contract's own input value does not. Add
CallOptions.max_funding_inputs to cap funding inputs and return an error
when the cap cannot cover outputs + fee.

Tests: a call with 3 spare 100k UTXOs builds a 2-input tx (not 4); the cap
errors when insufficient and is honored when sufficient.
… script in Go (#119)

FromUtxo and FromTxId filled the constructor argument list with int64(0)
placeholders, so every restored contract operated on zeros: the ANF interpreter
computed the wrong state continuation (readonly ctor params feed the
continuation formula) and the OP_CODESEPARATOR / OP_PUSH_TX offset was wrong for
methods after a ctor slot. Restored stateful spends were unspendable.

Add restoreConstructorArgs(): parse the deployed script via the existing
ExtractConstructorArgs and order values by ABI param (paramIndex). Params
without a constructor slot (mutable state fields) fall back to 0 and are
overwritten by ExtractStateFromScript. Applied to both FromUtxo and FromTxId
(Go's FromTxId does not delegate to FromUtxo, so both were fixed). Mirrors the
TS reference.
…er in Go (#134)

deploy() and prepareCall()'s funding-signing loops (non-terminal, rebuild, and
terminal) signed every P2PKH funding input with the connected method/deploy
signer and pushed that signer's pubkey, so funding coins owned by a different
key failed OP_EQUALVERIFY. Resolve fundingSigner = options.FundingSigner ??
signer and use it in all funding-signing loops; the method's own Sig args keep
the connected signer. Adds DeployOptions.FundingSigner / CallOptions.FundingSigner
(struct fields landed with the #131 type additions). Defaults to the connected
signer (zero behaviour change). Mirrors the TS reference.
deploy() and prepare_call()'s three funding-signing loops signed every P2PKH
funding input with the connected method signer and pushed that signer's
pubkey, so funding coins owned by a different key failed OP_EQUALVERIFY. Add
DeployOptions.funding_signer / CallOptions.funding_signer; the funding loops
now use funding_signer ?? signer while the method's own Sig args keep the
connected signer. Defaults to the connected signer (zero behaviour change).

Idiomatic divergence from TS: the signer is stored as a FundingSigner
newtype wrapping Arc<dyn Signer> (Signer is not Debug), so CallOptions /
DeployOptions keep deriving Debug/Clone/Default without a lifetime param.
DeployOptions gains a Default derive; existing struct-literal constructions
gain funding_signer: None.

Tests: the funding input's scriptSig carries the funding signer's pubkey
when funding_signer is set, and the method signer's pubkey otherwise.
The Go SDK already resolves the OP_CODESEPARATOR offset by byte-walking the real
code script (getCodeSepIndex -> findCodesepOffsets) whenever codeScript is set,
only falling back to the template adjustCodeSepOffset when codeScript == ''
(deploy-time). Unlike the TS reference, Go has no divergent public sibling that
bypassed the byte-walk, so no production change is needed — the #42/#47 port
already centralised the fix. Add a regression test that pins the invariant: with
a real codeScript present, the resolved offset is the byte-walked position and
is independent of placeholder constructor args.
…n tier (#121)

Port the TS-reference #121 fix to the Python compiler + SDK ANF interpreter.

Add explicit start/step to the ANF Loop node (ir/types.py + loader). Replace
_extract_loop_count with _extract_loop_shape (+ _extract_loop_step): computes
real start/step/count for counting-up and countdown loops. Stack lowering
pushes start + i*step per unrolled iteration. Serialize start/step in both
--emit-ir and the --ir artifact (as plain integers, matching TS after the
conformance runner's bigint reviver). SDK anf_interpreter honors start/step.
constant_fold and the value-clone carry start/step through. Drop the now-
obsolete validate/anf-lower non-zero-start + countdown rejections. Extend the
Python parser's range() to accept a negative unit step (range(a, b, -1)) ->
countdown loop.

Zero-start counting-up loops (start=0, step=1) stay byte-identical; the
bounded-loop conformance golden's ANF gains start/step (needs regen).
…he param — Python tier (#130)

Port the TS-reference #130 fix to the Python compiler.

ANF resolution: register declared method param NAMES via add_param so a bare
identifier resolves to load_param before falling through to load_prop; reorder
the property_access lowering to prefer a real property over a same-named param
(explicit this.x still loads the property).

Stack lowering: a param colliding with a MUTABLE property gets a duplicate
stack slot once deserialize_state pushes that property under the same name.
Rename the colliding param's slot to __param_<name> at method entry and target
it in _lower_load_param. Only fires on an actual collision; non-colliding
contracts are byte-identical.
…#118)

CallOptions.FeeUtxo adds a single plain P2PKH input to a terminal call tx purely
to pay the miner fee: added BEFORE the OP_PUSH_TX preimage is computed (so
hashPrevouts covers it) and consumed entirely as fee with no change output. The
covenant's terminal output assertions are untouched; only the input side grows.
Signed with fundingSigner ?? signer (composes with #134).

The FeeUtxo field + terminal merge into the shared terminal P2PKH funding-input
path (reconciling with the pre-existing FundingUtxos mechanism) landed with the
#131 type additions and the #134 terminal-path edit; this commit adds the
regression tests. Mirrors the TS reference.
…thon tier (#116)

Port the TS-reference #116 fix to the Python compiler.

Wrap the P2PKH change segment of the stateful continuation in a runtime ANF
`if (_changeAmount != 0)`: the hashed output set carries the change output when
non-zero and empty bytes (cat with empty is a no-op) when zero, matching the
SDK's build_call_transaction which omits the change output on an exact-cover
call. change > 0 behaviour is unchanged; the emitted bytes gain the OP_IF/
OP_ELSE/OP_ENDIF guard.

Necessarily changes deployed bytes of every stateful contract that emits a
change output: the checked-in conformance goldens (expected-ir.json +
expected-script.hex for those fixtures) need regen. Op-count-pinned unit tests
updated (+11 ops).
icellan added 28 commits July 7, 2026 17:08
Add an additive, non-breaking injection point so the SDK and a downstream
layer (e.g. wallet-toolbox) can broadcast through one shared Broadcaster
instance/config rather than WalletProvider's hardcoded ARC URL. When both
layers share a broadcaster, parent and child txs land at the same ARC
deployment, avoiding the SEEN_IN_ORPHAN_MEMPOOL divergence.

- New minimal sync Broadcaster trait (Result<String,String>) matching the
  SDK's blocking (ureq) transport and Provider::broadcast error shape. The
  canonical bsv::transaction::Broadcaster is async (#[async_trait]); adopting
  it would force an async runtime into the sync SDK, so we define the minimal
  injection interface instead (permitted by the issue).
- WalletProvider::with_broadcaster(Box<dyn Broadcaster>) builder method.
- Provider::broadcast delegates to the injected broadcaster when present,
  flattening failures into the existing Err(String); else the hardcoded ARC
  path is unchanged. Provider trait signature untouched.

Tests: injected broadcaster used instead of hardcoded ARC; failure flattens
to Err; no-injection path still uses the default ARC.
TypeScript twin of the Rust injection point. Add an optional broadcaster to
WalletProviderOptions so broadcast() delegates to an injected @bsv/sdk
Broadcaster when provided, letting the SDK and a downstream layer (e.g.
wallet-toolbox) share one broadcaster instance/config. Parent and child txs
then land at the same ARC deployment, avoiding SEEN_IN_ORPHAN_MEMPOOL.

- WalletProviderOptions.broadcaster?: Broadcaster (from @bsv/sdk).
- broadcastTx delegates to the injected broadcaster after EF source-tx
  assembly; a BroadcastFailure return is flattened into a thrown Error to
  preserve the existing broadcast() contract. Additive, non-breaking; the
  hardcoded ARC fetch path is unchanged when no broadcaster is injected.

Tests: injected broadcaster used instead of the hardcoded ARC fetch path;
BroadcastFailure flattens to a thrown Error; default path unchanged.
…/ @bsv/sdk Spend)

Adds runTriModalExecution: reuses the bi-modal compile + witness + interpreter
+ ScriptVM legs, then runs the SAME deployed bytes through the upstream
@bsv/sdk Spend interpreter as an independent third engine. Spend is written by
a different team against the consensus rules and enforces the clean-stack and
push-only rules the hand-rolled ScriptVM does not, so a disagreement is a
strong miscompile signal that the two in-repo engines could both mirror.

buildWitness now emits consensus minimal-push encoding (OP_1NEGATE / OP_0 /
OP_1..OP_16 for small ints) — Spend rejects non-minimal pushes; the ScriptVM
and interpreter accept both forms identically, so this is semantics-preserving.

Tests cover accept/reject agreement on arithmetic, loops (non-zero start +
countdown + post-loop param read), and byte-ops (cat/substr/len over a
ByteString param).
Add a producer-side convention for the deliberately-empty branch of an
OR-CHECKSIG method (checkSig(sigA, pkA) || checkSig(sigB, pkB)). The
failing branch must push an empty signature (OP_0) or BIP146 NULLFAIL
rejects the spend; today both Sig modes (Auto, Bytes) push non-empty
bytes.

- types.rs: add SdkValue::EmptySig unit variant
- contract.rs: encode_arg emits OP_0 for EmptySig; prepare_call skips it
  in the auto-sig collector (never signed) since it is not Auto; soft-warn
  when >=2 Auto Sig slots remain after resolution
- anf_interpreter.rs: EmptySig -> Undefined (never feeds state/data outputs)

Purely additive: existing Auto/Bytes callers unchanged. [Auto, EmptySig]
signs only the Auto slot; the EmptySig slot stays OP_0.

tests/issue_106_empty_sig.rs proves the wire-level RED->GREEN through
bsv-sdk Spend: [Auto, EmptySig] validates (branch B empty); [Auto, Auto]
duplicates Alice's sig into branch B (the non-empty invalid push NULLFAIL
rejects). Note: bsv-sdk 0.1.72 does not implement NULLFAIL, so the
interpreter-level rejection is proven on the TS tier (@bsv/sdk).
…ost-loop param reads

Adds ForStmt to the language-neutral contract IR (non-zero-start count-up +
countdown loops, matching the shapes #121 unrolls) and a dedicated arbExecCase
arbitrary for the tri-modal execution oracle. Each case is a stateless contract
combining:
  - a bounded for-loop accumulating into a mutable local,
  - substr/cat/len byte-ops over ByteString PARAMETERS (with statically-safe,
    in-range substr bounds from fixed-length ByteString inputs), and
  - a terminal assert that always reads a parameter AFTER the loop.
Cases carry concrete constructor + method inputs so fast-check property mode
shrinks both structure and inputs to a minimal repro.

ForStmt renders fully in the TypeScript renderer (the oracle is TS-only) and is
rejected loudly by the six native cross-tier renderers, which deliberately do
NOT emit loops (that path requires 7-compiler byte-identical parity, out of
scope here). A 300-case fixed-seed property test confirms all three engines
agree on the fixed base.
runTriModalDifferential drives arbExecCase through fc.check (property mode) and
runs every generated spend through all three engines (interpreter / ScriptVM /
@bsv/sdk Spend). A divergence is shrunk to a minimal (contract, inputs)
counterexample, reproduced to capture per-engine verdicts, written to
conformance/fuzz-findings-trimodal/, and exits 1. Wired as '--tri-modal' in the
fuzzer CLI (--num = property runs, --seed reproduces).

Clean over 200 (seed 424242) + 4x500 cases across seeds 1/2/999/20250707.
Add a producer-side convention for the deliberately-empty branch of an
OR-CHECKSIG method (checkSig(sigA, pkA) || checkSig(sigB, pkB), where ||
lowers to the non-lazy OP_BOOLOR). The failing branch must push an empty
signature (OP_0) or BIP146 NULLFAIL rejects the spend; today Sig params
are either null (auto-sign) or explicit hex bytes.

- contract.ts: export EMPTY_SIG (a global-registry unique symbol) +
  isEmptySig guard; encodeArg emits OP_0 for EMPTY_SIG; the resolution
  loop skips it (not null → never signed) so it reaches encodeArg as OP_0;
  soft-warn when >=2 auto-signed Sig slots remain after resolution
- index.ts: export EMPTY_SIG / isEmptySig

Purely additive: existing null/explicit-bytes callers unchanged.
call('m', [null, EMPTY_SIG]) signs only slot 0.

__tests__/issue-106-empty-sig.test.ts compiles a real OR-CHECKSIG
contract, deploys/calls via the SDK, and replays the built tx through
@bsv/sdk Spend: [null, EMPTY_SIG] validates with an OP_0 failing branch;
[null, null] duplicates Alice's sig into the failing branch (the non-empty
invalid push a NULLFAIL-enforcing node rejects). @bsv/sdk 2.0.7 does not
implement NULLFAIL, so the RED baseline is asserted at the wire level.
…issue #124)

PR gate: --tri-modal --num 200 --seed 424242 (fast-check property mode, fixed
seed, literal argv — no untrusted input). Nightly: --tri-modal --num 1000 with
the already-validated rotating $SEED. Both hard-fail (exit 1) on any
interpreter-vs-ScriptVM-vs-Spend disagreement and upload the shrunk repro under
conformance/fuzz-findings-trimodal/. TS-only oracle — no native compiler build
needed. Also gitignores the new findings dir.
…edAlways

Add optional embedAlways flag to PropertyNode in both AST definitions
(runar-compiler + runar-ir-schema, kept in sync). The .runar.ts parser
detects a /** @embedAlways */ JSDoc directive (or // @embedAlways line
comment) immediately preceding a field and sets the flag. Comment-directive
form (not a decorator) keeps it portable across all nine surface formats.
…on stripped fields

A readonly field no method references is eliminated by dead-binding DCE
(its load_prop is dead, so no constructor slot is emitted), silently
dropping deploy-time metadata authors intend to recover from the on-chain
script. Two surfaces:

- Preservation (Option 1): ANF lowering injects a load_prop + @ref alias
  for each @embedAlways readonly field into the first public method — the
  exact shape 'const _bind = this.field;' produces. The alias keeps the
  load_prop alive through DCE; stack lowering emits the constructor-slot
  placeholder and NIPs the value at method end, so the field's bytes
  remain in the deployed locking script.
- Warning (Option 4): compile() emits a warning when an un-annotated,
  unreferenced readonly field (no initializer) is eliminated, pointing at
  the @embedAlways directive. Rides the existing diagnostics channel.

Purely additive/opt-in: no existing fixture uses the directive, so ANF IR
and hex output are byte-identical (zero golden churn).
…rArgs

Drives the full path: compile -> splice real args into the deployed
locking script (RunarContract.getLockingScript) -> recover with
extractConstructorArgs. Asserts the annotated metadataId value survives
and the un-annotated control is eliminated (not recoverable).
Adds the /** @sigHash <FLAGS> */ two-surface directive (JSDoc + leading
trivia, mirroring #109's @embedAlways) on public methods, parsed into a
numeric BIP-143 sighash type on MethodNode. Flag grammar lives in the new
sighash-directive module: name->value mapping plus combo validation that
rejects unknown flags, zero/multiple base types (ALL|NONE checked on NAMES
to catch the 0x03 aliasing collision), and duplicates. Absent directive =
default ALL|FORKID (0x41), so existing contracts are unchanged.
Swaps the hardcoded 0x41 in the auto-injected preimage-type assert
(04-anf-lower) for the method's declared sighash mode, and threads that
mode into the OP_PUSH_TX binding blob (oppushtx-codegen appends the mode's
flag byte instead of a fixed 0x41) via a new optional CheckPreimage.sighashFlag
carried through 05-stack-lower. Manual checkPreimage calls pick up the mode
from the lowering context. The assembler surfaces a non-default mode as
ABIMethod.sigHashType so the SDK can build the matching preimage.

Default (ALL|FORKID) omits every new field → byte-identical ANF/script/ABI;
all 3558 runar-compiler tests incl. cross-tier golden hex pass unchanged.
New sighash-validate pass (wired into 02-validate) rejects, at compile time
with source locations, every preimage-field read / output binding that a
relaxed sighash flag makes unsound:
 - ANYONECANPAY: reject extractHashPrevouts and extractPrevOutputScript
   (hashPrevouts zeroed -> input set uncommitted -> companion-input checks
   trivially bypassable).
 - hashSequence: reject extractHashSequence unless pure SIGHASH_ALL (NONE /
   SINGLE / ANYONECANPAY all zero it).
 - NONE: reject state-continuation, addOutput/addRawOutput/addDataOutput,
   requireOutputP2PKH, extractOutputHash/extractOutputs (NONE commits to no
   outputs -> output bindings unenforceable).
 - SINGLE: reject >1 committed output (SINGLE binds only the same-index
   output; extra outputs are attacker-controllable).
Walk is transitive through private helpers. Default ALL|FORKID methods (no
directive) are never flagged.
computeOpPushTx takes an optional sigHashType (default 0x41) driving both the
@bsv/sdk BIP-143 scope (which fields get zeroed) and the appended DER sighash
byte. computeOpPushTxWithCodeSep resolves the mode from ABIMethod.sigHashType
by public-method index, so every call/finalize path picks it up with no
call-site churn. mock-preimage mirrors the BIP-143 zeroing (hashPrevouts under
ANYONECANPAY, hashSequence unless pure ALL, hashOutputs under NONE) and stamps
the declared flag into the sighashType field + signature byte.

End-to-end proofs (replayed through @bsv/sdk Spend): a SINGLE|FORKID exact-cover
continuation validates with preimage sighash byte 0x43; an ANYONECANPAY (0xC1)
crowdfund-style method validates; the default stays 0x41. All green.
… bypass, requireOutputP2PKH, mock

F1 (P1): reject the mutate-only stateful auto-continuation under @sigHash SINGLE.
Its output value is the caller-chosen _newAmount, which BIP-143 SINGLE never
pins, so a spender can dust _newAmount + zero change + append a draining output
and still pass the covenant (value skim). Allow an explicit single addOutput/
addRawOutput SINGLE covenant (the legitimate pairwise input↔output case) but
emit a value-pinning WARNING.

F2 (P2): reject any @sigHash flag set lacking FORKID. The whole OP_PUSH_TX /
BIP-143 machinery is FORKID-only on BSV, so a FORKID-less mode deploys to brick.

F3 (P2): the transitive field-read walk now covers for-loop init + condition and
the assignment target (sighash-validate.ts scanMethod), and side-effect-summary
walks the same for-header + assignment target so output intrinsics cannot hide
in a loop header.

F4 (P2): reject requireOutputP2PKH under SINGLE — a fixed output index cannot be
proven equal to this input's index, the only output SINGLE commits to.

F5 (P2): mock-preimage SINGLE now hashes ONLY output[inputIndex] (or ZERO32 when
out of range), matching BIP-143, instead of digesting the whole output set.

Each fix has a RED→GREEN test. Default 0x41 path untouched (zero golden churn).
The @sigHash (#123, per-method sighash type) and @embedAlways (#109,
readonly-field DCE opt-out) comment directives are implemented only in
the TypeScript compiler. The Go/Rust/Python/Zig/Ruby/Java frontends
ignore comments, so they would silently drop these directives and change
signing / DCE semantics (e.g. a @sigHash SINGLE contract compiled by Go
would fall back to the default ALL|FORKID).

Until the ports land, each non-TS parse entry now scans the source for
the directive markers (word-boundary matched to mirror the TS
/@sigHash\b/ and /@embedAlways\b/ scans) and errors with a clear
message pointing at the issue and telling the author to use the
TypeScript compiler. The scan runs for every format the tier accepts, so
a .runar.ts fed to a non-TS compiler is caught too.

No conformance fixture uses either directive, so the parser-only matrix
and Stack-IR / hex parity are unaffected. One guard test per tier covers
a @sigHash and an @embedAlways contract plus a word-boundary negative.

Guards:
- Go   frontend.ParseSource        (parser.go)
- Rust  parse_source                (parser.rs)
- Python parse_source               (parser_dispatch.py)
- Zig   parseSource + CLI compileFromSource (compiler_api.zig, main.zig,
        shared helper in frontend/input_limits.zig)
- Ruby  _parse_source               (compiler.rb)
- Java  ParserDispatch.parse        (ParserDispatch.java)
The #116 change-output gate added an IF/ELSE/ENDIF around the stateful
change output, altering the compiled bytecode. The main conformance
goldens (expected-ir.json / expected-script.hex) were regenerated, but
the analyzer subsuite's own goldens
(conformance/analyzer/<fixture>/expected-analyzer-report.json, verified
by packages/runar-rs tests/analyzer_fixtures.rs) were left stale, so
`auction` and `stateful_counter` failed on the clean tree.

Regenerated via the canonical TS reference generator
(conformance/analyzer/scripts/generate-goldens.ts), which reads the
current expected-script.hex and re-emits all 8 goldens; only these two
changed. The Rust analyzer now matches byte-for-byte (8/8 green),
confirming the TS and Rust analyzers agree on the new bytecode.

Delta matches the added change-output gate:
- auction         scriptSize 1782 -> 1794 (+12), offsets shifted +12
- stateful-counter scriptSize 1851 -> 1875 (+24), offsets shifted +12,
  and a 9th branch point appears (new "2^9 = 512 paths, truncated to
  256" finding) — the extra IF/ELSE branch around the change output.
The #116 change-output gate adds 24 bytes per change-emitting method. The
Python TicTacToe byte-count lock was stale at 9425; the Python and TS
compilers now both emit 9449 bytes for the canonical fixtures (matching the
Ruby tier's 9449). Update EXPECTED_BYTES and the test-name/docstring
references.
…S tiers

A for-loop whose bound is not an integer literal (runtime member access
`this.x`, or a bare identifier `const N`) cannot be unrolled into fixed
Bitcoin Script. The #121 loop port made the 6 non-TS anf-lowers PANIC on
such a bound (previously a silent 0-iteration miscompile). Move the
rejection to the validator so compilation fails gracefully with a
compile-time-constant diagnostic, matching the TS reference's observable
behavior: only literal bounds compile.

- Go/Rust/Python/Ruby/Java: tighten isCompileTimeConstant to accept only
  integer literals (drop the bare-identifier trust), so anf-lower's
  extractLoopShape is gated before it can panic.
- Zig: the parser collapses the bound to an i64, so add a bound_is_const
  flag (set in parse_ts) and reject at the validator instead of silently
  compiling a runtime bound to 0 iterations.
- Flip the stale "identifier bound accepted" tests (Rust/Go/Python) to
  assert rejection; add rejection tests for Ruby/Java/Zig.

Verified across all 7 compilers: runtime `this.x` and identifier `N`
bounds reject cleanly (no panic); literal `5n` bounds compile to
byte-identical hex. No conformance fixture uses for-loops, so no golden
churn.
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