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:
- SDK fills both sig slots with Alice's 72-byte signature.
- On chain:
checkSig(sigA, alicePk) passes; checkSig(sigB, bobPk) fails — but sigB is non-empty.
- NULLFAIL trips. ARC HTTP 461.
With the proof-of-concept variant, calling with [Auto, EmptySig, ...]:
- SDK signs only the
Auto slot. EmptySig slot stays as OP_0.
- On chain:
checkSig(sigA, alicePk) passes; checkSig(sigB, bobPk) fails with empty sig — NULLFAIL satisfied.
- 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.
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:
Implementing these at the script level is straightforward —
OP_BOOLORover twoOP_CHECKSIGbranches, 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
Sigresolution modes that exist today (explicit pre-signedSdkValue::Bytes(...)andSdkValue::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
Autoresolver 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 asSdkValue::Bytes("00")— which defeats theAutoconvention 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(SdkValueenum) — current variants relevant to Sig handling:packages/runar-rs/src/sdk/contract.rs:553-580(the inline scan insideprepare_call_terminal):Every
Sig-typed slot that arrives asAutogets a 72-byte placeholder + an entry insig_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-58documents that Sig params arrive asnull(the SDK auto-computes), andpackages/runar-sdk/src/contract.ts:894does 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 newSdkValue::EmptySigvariant that:OP_0(one byte0x00) at theencode_arglayer.prepare_call_terminal(no signing pass touches it).SdkValue::Autoat the same call: caller passesAutofor the matching branch (SDK signs) andEmptySigfor non-matching branches.A minimal contract example (two-party OR-CHECKSIG):
Today, calling
executewith[Auto, Auto, ...]over a transaction Alice can sign:checkSig(sigA, alicePk)passes;checkSig(sigB, bobPk)fails — butsigBis non-empty.With the proof-of-concept variant, calling with
[Auto, EmptySig, ...]:Autoslot.EmptySigslot stays asOP_0.checkSig(sigA, alicePk)passes;checkSig(sigB, bobPk)fails with empty sig — NULLFAIL satisfied.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 suggestingEmptySigfor 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:
SigBranchenum (more strongly-typed than a flatEmptySigvariant — could carry per-branch metadata).@checkSigBranch(matching: 0)or similar) so the SDK can infer the empty slot from the contract definition rather than the caller's args.CallOptions::sig_topology: Vec<SigBranchHint>).SdkValue::Bytes("")pattern with an explicit recipe in the README, and leave the SDK shape untouched.Alternatives considered
SdkValue::EmptySig(PoC)Auto; one-byte wire encoding.SigBranchenum[Auto, Auto, ...]); compiler/SDK injects the OP_0 push for the right slot.SdkValue::Bytes("")for failing branches."sig_indicesand 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:
[Auto, Auto, ...]to single-key CHECKSIG contracts continue to work — no SDK-side warning, no behaviour change.[Bytes(real_sig), Bytes(real_sig)]continue to work — explicit bytes are still the override.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
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 anEmptySig-class surface becomes obvious from logs alone.runar-r6-emptysig-variant-clean.patchis 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) forEmptySig/OR_CHECKSIG/BOOLOR(outside codegen/opcode tables) /SigBranch/OptionalSigreturns zero hits in user-facing SDK API surface. A search acrossbsv-blockchain/ts-stackreturns 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.