Summary
The compiler's cleanupExcessStack() helper emits OP_NIP opcodes to remove leftover stack items at end of public method execution. Across all six compiler implementations (TS, Go, Rust, Python, Zig, Ruby), the helper is gated by a hasDeserializeState check — only contracts that emit deserialize_state get the cleanup pass invoked.
This skips a legitimate edge case: a contract that extends StatefulSmartContract but has zero mutable fields. Such a contract has no deserialize_state emit, so the gate fails. But it CAN have leftover stack items — for example, from readonly-field-binding patterns (force-embedding a readonly field in the locking script by referencing it in a method body).
The resulting script ends with extra items on the evaluation stack, violating BSV consensus rule CLEANSTACK. ARC rejects with arc error 461 — Script did not clean its stack.
Reproduction
A StatefulSmartContract subclass with all readonly fields and a terminal method that touches those readonly fields will compile to a locking script whose terminal method leaves leftover stack items. Direct ARC submission returns the Script did not clean its stack error.
Existing Runar example contracts do not surface this because they all sit in one of these categories:
extends SmartContract (stateless — no gate involved, no deserialize_state)
extends StatefulSmartContract WITH mutable fields → gate fires, cleanup runs, balanced stack
A StatefulSmartContract subclass with no mutable fields is unusual but legal per the language reference. It surfaces in practice for "all-readonly, terminal-method-only" contracts (Dutch-auction-style claim/withdraw, time-locked withdrawals, immutable price-attestation spends, etc.).
Affected files
Per CLAUDE.md's "six compilers must stay in sync" rule, the same hasDeserializeState-gated call to cleanupExcessStack() exists in all six compilers:
packages/runar-compiler/src/passes/05-stack-lower.ts (TS source)
compilers/go/codegen/stack.go
compilers/rust/src/codegen/stack.rs
compilers/python/runar_compiler/codegen/stack.py
compilers/zig/src/codegen/stack.zig
compilers/ruby/lib/codegen/stack.rb
Proposed fix
Remove the hasDeserializeState term from the gate around cleanupExcessStack() so it runs for every public method. The helper is idempotent — the TS implementation has an inner stackMap.depth > 1 guard (around packages/runar-compiler/src/passes/05-stack-lower.ts:372) that makes it a no-op when the stack is already balanced.
For compilers that fold the depth check INTO the call-site condition (Go and Rust do this), the upstream PR must KEEP the depth > 1 check at the call site and drop only the hasDeserializeState term — otherwise the cleanup would add OP_NIP opcodes to already-clean methods and break conformance.
Empirical verification against the full example-contract corpus across TS + Rust + Go (25+ contracts):
| Pattern |
Examples |
Effect after patch |
StatefulSmartContract + ≥1 mutable field |
Auction, Counter, MathDemo, MessageBoard, BoundedCounter, StateCovenant, TicTacToe, FungibleToken, NFTExample, FunctionPatterns |
NO-OP — gate previously fired; cleanup already running |
SmartContract (stateless) |
P2PKH, Escrow, OraclePriceFeed, CovenantVault, ECDemo, SchnorrZKP, ConvergenceProof, SHA-256 variants, MerkleProof, SPHINCSWallet, PostQuantumWallet, P2Blake3PKH |
NO-OP — depth check inside cleanup prevents emission |
StatefulSmartContract + 0 mutable + readonly-binding |
(none in the example corpus) |
FIX APPLIES — appropriate OP_NIP cleanup added |
No example contract in the current corpus hits the third case. Compiling every existing example with the patched compiler produces byte-identical artifacts.
Cross-compiler coordination
From CLAUDE.md:
"Six independent compiler implementations (TypeScript, Go, Rust, Python, Zig, Ruby) must produce identical output for the same input. […] The conformance suite in conformance/ has golden-file tests that all 6 compilers must pass."
A fix that lands in only one compiler will fail conformance. The fix needs to land in all six compilers, plus the conformance suite re-pinned.
Attached partial PR
I've prepared a TypeScript-compiler-source fix as a draft PR to start the conversation. It's intentionally scoped to the TS source (packages/runar-compiler/src/passes/05-stack-lower.ts) because that's the compiler I exercise; I've validated the resulting bytecode on BSV mainnet. I'm not confident enough in the Python/Zig/Ruby toolchains to port without supervision, but I'm happy to:
- Extend the patch to Go and Rust compilers myself (similar codegen shape — I can match the pattern)
- Leave Python/Zig/Ruby for a maintainer or contributor more familiar with those stacks
- Close the draft PR if you'd prefer to handle multi-compiler coordination as a single atomic change internally
Either way, the bug + diagnostic above stands.
Evidence
A patched compiler produces locking scripts that satisfy BSV's CLEANSTACK rule for the edge case. A test all-readonly stateful contract with a terminal claim() method was deployed and claimed on BSV mainnet:
Without the gate removal, the claim transaction is rejected with Script did not clean its stack.
Minimal reproduction
A self-contained contract that exhibits the shape causing the CLEANSTACK violation:
// Why this shape exists legitimately:
// - Extends StatefulSmartContract because `withdraw` asserts on
// `extractLocktime(this.txPreimage)`, and `txPreimage` is only
// auto-injected on StatefulSmartContract subclasses (matches the
// pattern in `examples/ts/auction/Auction.runar.ts`).
// - Has zero mutable fields because the contract is static-config —
// set at deploy, never mutates.
// - Uses the readonly-binding workaround `const _bind = this.tag` to
// force-embed `tag` in the locking script (the compiler optimizes
// out readonly fields that aren't otherwise referenced). This is
// the same pattern used in `examples/rust/energy/EnergyTag.runar.rs:167-196`.
class CleanstackRepro extends StatefulSmartContract {
readonly auctioneer: PubKey;
readonly deadline: bigint;
readonly tag: bigint; // contract-identity field; never naturally referenced
constructor(auctioneer: PubKey, deadline: bigint, tag: bigint) {
super(auctioneer, deadline, tag);
this.auctioneer = auctioneer;
this.deadline = deadline;
this.tag = tag;
}
@public
withdraw(sig: Sig) {
const _bind = this.tag; // force-embed readonly into locking script
assert(extractLocktime(this.txPreimage) >= this.deadline);
assert(checkSig(sig, this.auctioneer));
}
}
Compile this with the unpatched compiler, deploy, then attempt to withdraw after deadline. The script's evaluation stack ends with leftover items from the _bind binding plus the final boolean. ARC rejects with arc error 461 — Script did not clean its stack.
With the patched compiler, the call site for cleanupExcessStack() runs, the inner depth > 1 guard correctly emits the right number of OP_NIP opcodes, and the spend mines.
Alternative patterns — are they enough?
We considered restructuring to avoid the patch entirely:
- Switch to
extends SmartContract — not possible, since terminal methods that gate on extractLocktime(this.txPreimage) need txPreimage, which only StatefulSmartContract injects.
- Drop the readonly-binding workaround — would let the optimizer remove
tag from constructorSlots, losing the contract's cryptographic tie to its identity metadata. Not viable for contracts that need per-instance identity.
- Inline
tag into an assertion — contrived (e.g. assert(this.tag >= 0)). Works around the compiler quirk but doesn't fix it for the next user who hits the same shape.
The compiler fix is the right place to address this — it makes the patterns above unnecessary while preserving all existing behavior.
Summary
The compiler's
cleanupExcessStack()helper emitsOP_NIPopcodes to remove leftover stack items at end of public method execution. Across all six compiler implementations (TS, Go, Rust, Python, Zig, Ruby), the helper is gated by ahasDeserializeStatecheck — only contracts that emitdeserialize_stateget the cleanup pass invoked.This skips a legitimate edge case: a contract that extends
StatefulSmartContractbut has zero mutable fields. Such a contract has nodeserialize_stateemit, so the gate fails. But it CAN have leftover stack items — for example, from readonly-field-binding patterns (force-embedding a readonly field in the locking script by referencing it in a method body).The resulting script ends with extra items on the evaluation stack, violating BSV consensus rule
CLEANSTACK. ARC rejects witharc error 461 — Script did not clean its stack.Reproduction
A
StatefulSmartContractsubclass with all readonly fields and a terminal method that touches those readonly fields will compile to a locking script whose terminal method leaves leftover stack items. Direct ARC submission returns theScript did not clean its stackerror.Existing Runar example contracts do not surface this because they all sit in one of these categories:
extends SmartContract(stateless — no gate involved, nodeserialize_state)extends StatefulSmartContractWITH mutable fields → gate fires, cleanup runs, balanced stackA
StatefulSmartContractsubclass with no mutable fields is unusual but legal per the language reference. It surfaces in practice for "all-readonly, terminal-method-only" contracts (Dutch-auction-styleclaim/withdraw, time-locked withdrawals, immutable price-attestation spends, etc.).Affected files
Per
CLAUDE.md's "six compilers must stay in sync" rule, the samehasDeserializeState-gated call tocleanupExcessStack()exists in all six compilers:packages/runar-compiler/src/passes/05-stack-lower.ts(TS source)compilers/go/codegen/stack.gocompilers/rust/src/codegen/stack.rscompilers/python/runar_compiler/codegen/stack.pycompilers/zig/src/codegen/stack.zigcompilers/ruby/lib/codegen/stack.rbProposed fix
Remove the
hasDeserializeStateterm from the gate aroundcleanupExcessStack()so it runs for every public method. The helper is idempotent — the TS implementation has an innerstackMap.depth > 1guard (aroundpackages/runar-compiler/src/passes/05-stack-lower.ts:372) that makes it a no-op when the stack is already balanced.For compilers that fold the depth check INTO the call-site condition (Go and Rust do this), the upstream PR must KEEP the
depth > 1check at the call site and drop only thehasDeserializeStateterm — otherwise the cleanup would addOP_NIPopcodes to already-clean methods and break conformance.Empirical verification against the full example-contract corpus across TS + Rust + Go (25+ contracts):
StatefulSmartContract+ ≥1 mutable fieldSmartContract(stateless)StatefulSmartContract+ 0 mutable + readonly-bindingOP_NIPcleanup addedNo example contract in the current corpus hits the third case. Compiling every existing example with the patched compiler produces byte-identical artifacts.
Cross-compiler coordination
From
CLAUDE.md:A fix that lands in only one compiler will fail conformance. The fix needs to land in all six compilers, plus the conformance suite re-pinned.
Attached partial PR
I've prepared a TypeScript-compiler-source fix as a draft PR to start the conversation. It's intentionally scoped to the TS source (
packages/runar-compiler/src/passes/05-stack-lower.ts) because that's the compiler I exercise; I've validated the resulting bytecode on BSV mainnet. I'm not confident enough in the Python/Zig/Ruby toolchains to port without supervision, but I'm happy to:Either way, the bug + diagnostic above stands.
Evidence
A patched compiler produces locking scripts that satisfy BSV's CLEANSTACK rule for the edge case. A test all-readonly stateful contract with a terminal
claim()method was deployed and claimed on BSV mainnet:3ea48c3e…12f9block 949228580c5c0e…de71block 949230Without the gate removal, the claim transaction is rejected with
Script did not clean its stack.Minimal reproduction
A self-contained contract that exhibits the shape causing the CLEANSTACK violation:
Compile this with the unpatched compiler, deploy, then attempt to
withdrawafterdeadline. The script's evaluation stack ends with leftover items from the_bindbinding plus the final boolean. ARC rejects witharc error 461 — Script did not clean its stack.With the patched compiler, the call site for
cleanupExcessStack()runs, the innerdepth > 1guard correctly emits the right number ofOP_NIPopcodes, and the spend mines.Alternative patterns — are they enough?
We considered restructuring to avoid the patch entirely:
extends SmartContract— not possible, since terminal methods that gate onextractLocktime(this.txPreimage)needtxPreimage, which onlyStatefulSmartContractinjects.tagfromconstructorSlots, losing the contract's cryptographic tie to its identity metadata. Not viable for contracts that need per-instance identity.taginto an assertion — contrived (e.g.assert(this.tag >= 0)). Works around the compiler quirk but doesn't fix it for the next user who hits the same shape.The compiler fix is the right place to address this — it makes the patterns above unnecessary while preserving all existing behavior.