Skip to content

cleanupExcessStack gate skips a valid edge case — StatefulSmartContract subclass with no mutable fields #44

@E-Jacko

Description

@E-Jacko

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions