Skip to content

[feat] SdkValue::EmptySig variant for OR-CHECKSIG branched authorization #106

Description

@E-Jacko

Use case

Contracts that authorize a single method invocation through one of several distinct keys at the same call site — i.e. OR-CHECKSIG branched authorization. Common patterns:

  • "Either party A's key OR party B's key can authorize this transfer."
  • Two-of-N delegate authority where any one delegate's signature suffices on a given call.
  • Transfer-then-act ownership models where current-owner OR new-owner can advance state at different phases.

Implementing these at the script level is straightforward — OP_BOOLOR over two OP_CHECKSIG branches, each operating on its own signature + public-key pair. The script-interpreter rule (packages/sdk/src/script/Spend.ts:1326, NULLFAIL enforcement) requires that the failing branch push an empty signature; only the matching branch supplies real signature bytes. Without empty-on-fail, BIP146 NULLFAIL fires and the transaction is rejected with HTTP 461.

The SDK today has no producer-side convention for declaring "this Sig param is the non-matching branch — push OP_0, do not sign here." Both Sig resolution modes that exist today (explicit pre-signed SdkValue::Bytes(...) and SdkValue::Auto) push real (or placeholder-then-real) signature bytes; neither can produce the empty-push the script-rule requires for the failing branch.

How this surfaced

Surfaced while building a contract that uses OR-CHECKSIG branched authorization for multi-party signature verification. The SDK's Auto resolver fills BOTH sig slots with the same single-signer signature; on broadcast the failing branch's non-empty signature trips NULLFAIL, ARC HTTP 461. Working around it required pre-computing one branch as SdkValue::Bytes("00") — which defeats the Auto convention and forces every caller to manually compute and pass a placeholder push, plus orchestrate the index mapping between Sig params and OR-branch positions.

Current behavior

packages/runar-rs/src/sdk/types.rs (SdkValue enum) — current variants relevant to Sig handling:

pub enum SdkValue {
    Auto,
    Bytes(String),
    // ... other variants ...
}

packages/runar-rs/src/sdk/contract.rs:553-580 (the inline scan inside prepare_call_terminal):

// Detect Sig/PubKey/SigHashPreimage/ByteString params that need auto-compute (user passed Auto)
let mut resolved_args: Vec<SdkValue> = args.to_vec();
let mut sig_indices: Vec<usize> = Vec::new();
let mut preimage_index: Option<usize> = None;
for (i, param) in user_params.iter().enumerate() {
    if matches!(args[i], SdkValue::Auto) {
        if param.param_type == "Sig" {
            sig_indices.push(i);
            resolved_args[i] = SdkValue::Bytes("00".repeat(72));   // 72-byte placeholder
        } else if /* ... */ {
            // ...
        }
    }
}

Every Sig-typed slot that arrives as Auto gets a 72-byte placeholder + an entry in sig_indices, which the later signing pass uses to overwrite with a real signature. There's no third state for "deliberately empty."

TS surface is analogous: packages/runar-sdk/src/codegen/common.ts:43-58 documents that Sig params arrive as null (the SDK auto-computes), and packages/runar-sdk/src/contract.ts:894 does the equivalent resolution. Same two-mode story.

The script-level NULLFAIL enforcement is well-documented (packages/sdk/src/script/Spend.ts:1285-1326) — but no SDK-level producer-side convention exists for marking a Sig slot empty-by-design. The maintainer-side language for "this branch deliberately doesn't sign" is the missing surface.

Proposed shape (design question — open for redesign)

The attached proof-of-concept patch (runar-r6-emptysig-variant-clean.patch) sketches one possible shape: a new SdkValue::EmptySig variant that:

  • Encodes as OP_0 (one byte 0x00) at the encode_arg layer.
  • Is skipped by the Auto-sig collector in prepare_call_terminal (no signing pass touches it).
  • Coexists with SdkValue::Auto at the same call: caller passes Auto for the matching branch (SDK signs) and EmptySig for non-matching branches.

A minimal contract example (two-party OR-CHECKSIG):

public method execute(sigA: Sig, sigB: Sig, /* ... */) {
  // either Alice or Bob can authorize
  bool valid = (checkSig(sigA, alicePk) || checkSig(sigB, bobPk));
  require(valid);
  // ...
}

Today, calling execute with [Auto, Auto, ...] over a transaction Alice can sign:

  1. SDK fills both sig slots with Alice's 72-byte signature.
  2. On chain: checkSig(sigA, alicePk) passes; checkSig(sigB, bobPk) fails — but sigB is non-empty.
  3. NULLFAIL trips. ARC HTTP 461.

With the proof-of-concept variant, calling with [Auto, EmptySig, ...]:

  1. SDK signs only the Auto slot. EmptySig slot stays as OP_0.
  2. On chain: checkSig(sigA, alicePk) passes; checkSig(sigB, bobPk) fails with empty sig — NULLFAIL satisfied.
  3. Transaction mines.

The proof-of-concept also adds a soft warn-guard in prepare_call_terminal: if ≥2 Auto Sig slots remain after resolution, log a warning suggesting EmptySig for non-matching branches. This is informational (legitimate AND-CHECKSIG multi-signer flows use multiple Autos too) — the SDK can't disambiguate OR vs AND at the resolution layer because the ABI doesn't encode the script topology.

The proof-of-concept is intentionally framed as "one possible shape" rather than the canonical implementation. The design call rests with maintainers. Possible alternatives include:

  • A SigBranch enum (more strongly-typed than a flat EmptySig variant — could carry per-branch metadata).
  • A method-level annotation (@checkSigBranch(matching: 0) or similar) so the SDK can infer the empty slot from the contract definition rather than the caller's args.
  • A CallOptions flag plus convention (e.g. CallOptions::sig_topology: Vec<SigBranchHint>).
  • Documentation-only — bless the manual SdkValue::Bytes("") pattern with an explicit recipe in the README, and leave the SDK shape untouched.

Alternatives considered

Shape Strengths Weaknesses
SdkValue::EmptySig (PoC) Minimal new surface; co-exists cleanly with Auto; one-byte wire encoding. Doesn't capture OR-vs-AND topology — SDK can't validate caller intent.
SigBranch enum Could carry branch index + topology hints; more amenable to compile-time validation in future. Larger surface; bigger migration cost for existing callers.
Method-level annotation Caller side stays simple ([Auto, Auto, ...]); compiler/SDK injects the OP_0 push for the right slot. Requires compiler awareness of OR-CHECKSIG topology at codegen — large scope change.
CallOptions flag Caller-side control without ABI changes. Hard to express per-call-site index mapping cleanly when a method has multiple Sig params.
Documentation only Zero SDK churn; happy path is "just pass SdkValue::Bytes("") for failing branches." Caller has to manually orchestrate placeholder bytes and the Auto resolver still adds the failing branch to sig_indices and signs it later — every consumer rediscovers this.

The proof-of-concept patch chooses the smallest shape that closes the gap. Open to any of the alternatives if maintainers prefer.

Backwards compatibility

Whatever shape lands, the design should be purely additive:

  • Existing callers passing [Auto, Auto, ...] to single-key CHECKSIG contracts continue to work — no SDK-side warning, no behaviour change.
  • Existing callers passing [Bytes(real_sig), Bytes(real_sig)] continue to work — explicit bytes are still the override.
  • New surface (variant / annotation / flag) is opt-in for contracts that exercise OR-CHECKSIG patterns.

Cross-language symmetry: any shape adopted in Rust warrants a matching TS surface to avoid divergence. The TS twin would be a parallel proposal — happy to file once the Rust shape is settled.

Related

  • PR-04 — runar broadcast and get_transaction: R1 surfaces the HTTP 461 rejections that this issue's class of contracts hit today. With R1 landed, the NULLFAIL failure is visible (instead of silent), and the need for an EmptySig-class surface becomes obvious from logs alone.
  • The proof-of-concept patch runar-r6-emptysig-variant-clean.patch is attached as a working PoC. It is not intended as the canonical implementation — only as evidence that the gap is real and one possible shape closes it without breaking existing callers. Happy to redraft against whichever direction maintainers prefer.

Search of prior art

To confirm this is new surface: a search across icellan/runar (Rust + TS + compilers + docs) for EmptySig / OR_CHECKSIG / BOOLOR (outside codegen/opcode tables) / SigBranch / OptionalSig returns zero hits in user-facing SDK API surface. A search across bsv-blockchain/ts-stack returns zero hits for the same terms. The script-interpreter NULLFAIL rule exists as the consumer of empty sigs (Spend.ts:1285-1326), but no SDK-level producer convention exists today. No prior maintainer issue or rejected PR addresses this surface.

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