Skip to content

fix(sdk): sign user Sig params at primary covenant input for stateful contracts#111

Open
E-Jacko wants to merge 1 commit into
icellan:mainfrom
E-Jacko:fix/stateful-primary-input-sig
Open

fix(sdk): sign user Sig params at primary covenant input for stateful contracts#111
E-Jacko wants to merge 1 commit into
icellan:mainfrom
E-Jacko:fix/stateful-primary-input-sig

Conversation

@E-Jacko

@E-Jacko E-Jacko commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Summary

build_stateful_unlock in prepare_call's second pass signs Sig-typed user-params only for extra inputs (input_idx > 0), leaving the primary covenant input's signature slots filled with the 72-byte all-zero placeholder. Stateful contracts whose method body verifies a user check_sig at the primary input fail on-chain script evaluation and get rejected at the relay.

Problem

packages/runar-rs/src/sdk/contract.rs:883-897 on upstream HEAD (a8187ab4):

// Only sign Sig params for extra inputs, not the primary
if input_idx > 0 {
    // In stateful contracts, user checkSig is AFTER OP_CODESEPARATOR — trim.
    let mut sig_subscript = subscript.to_string();
    if code_sep_idx >= 0 {
        let trim_pos = ((code_sep_idx as usize) + 1) * 2;
        if trim_pos <= sig_subscript.len() {
            sig_subscript = sig_subscript[trim_pos..].to_string();
        }
    }
    for &idx in sig_indices {
        let real_sig = signer.sign(tx, input_idx, &sig_subscript, sats, None)?;
        resolved_args[idx] = SdkValue::Bytes(real_sig);
    }
}

The if input_idx > 0 guard is a correctness bug for contracts that look like:

class MyContract extends StatefulSmartContract {
    issuer: PubKey;
    @method
    public fn issue(sig: Sig, ...) {
        // OP_PUSH_TX guard (handled by compute_op_push_tx_with_code_sep)
        // ... compute hashOutputs, hashPrevouts, etc.

        // User check_sig — REQUIRES a real signature at input 0
        assert!(check_sig(sig, &self.issuer));
    }
}

The method body has TWO signature checks at input 0:

  1. The OP_PUSH_TX preimage check (k=1 signature on a magic constant secret key) — works correctly today via compute_op_push_tx_with_code_sep.
  2. The user check_sig(sig, &self.issuer) against the signer's pubkey — this needs the SDK to sign a BIP-143 sighash for the unlock at input_idx == 0, which the guard prevents.

Pre-fix, on-chain check_sig(<72 zero bytes>, issuer_pubkey) returns false, the surrounding assert! fails, the script evaluation returns false, and the relay rejects the transaction.

How this surfaced

Surfaced when building a stateful covenant contract whose method body asserts check_sig(sig, &self.issuer) directly against a constructor-pinned issuer pubkey. The transaction built cleanly client-side, the OP_PUSH_TX preimage was structurally valid (verified via byte-level decode — hashOutputs / hashPrevouts / hashSequence / scriptCode all matched the contract-side recompute), but the unlock script for input 0 contained 72 contiguous zero bytes at the sig placeholder offset. ARC rejected with "Script failed" / sticky SEEN_IN_ORPHAN_MEMPOOL status.

The contract pattern (OP_PUSH_TX guard + user check_sig at the primary covenant input) is a natural shape for any stateful covenant where the contract author wants to bind a specific signing identity into the state transition — issuer-signed mints, owner-required state updates, role-gated method dispatch, etc.

Fix

Remove the if input_idx > 0 guard. Sign Sig user-params for ALL inputs including input_idx == 0. The BIP-143 sighash signed for the primary input is identical to the OP_PUSH_TX sighash for that input (same tx, same input_idx, same trimmed subscript), so both checks in the unlock context see consistent message bytes — no preimage divergence risk.

// Sign Sig params for ALL inputs. Stateful contracts that mix
// OP_PUSH_TX preimage verification with user `check_sig` calls
// require a real signature at the primary covenant input
// (input_idx == 0) too — leaving the placeholder zero bytes
// there fails on-chain `check_sig` and the relay rejects the
// transaction. The BIP-143 sighash for input 0 is identical
// to the OP_PUSH_TX sighash for that input (same tx, same
// input_idx, same trimmed subscript), so both checks in the
// unlock context see consistent message bytes.
//
// In stateful contracts, user checkSig is AFTER OP_CODESEPARATOR — trim.
let mut sig_subscript = subscript.to_string();
if code_sep_idx >= 0 {
    let trim_pos = ((code_sep_idx as usize) + 1) * 2;
    if trim_pos <= sig_subscript.len() {
        sig_subscript = sig_subscript[trim_pos..].to_string();
    }
}
for &idx in sig_indices {
    let real_sig = signer.sign(tx, input_idx, &sig_subscript, sats, None)?;
    resolved_args[idx] = SdkValue::Bytes(real_sig);
}

Pure guard removal — no other behavioural changes. Contracts that don't declare a user Sig param see no change (the inner for &idx in sig_indices loop body is skipped when sig_indices is empty).

Verification

Reproducible in 60 seconds:

git clone --depth 1 https://github.com/icellan/runar /tmp/runar-primary-sig
cd /tmp/runar-primary-sig
git apply <this patch>
cd packages/runar-rs
cargo check                              # patch compiles cleanly
cargo test --test wallet_client_integration    # existing integration suite remains green

Mainnet evidence (pre-fix): Diagnostic byte-level decode of a rejected stateful-contract broadcast showed the unlock script for input 0 contained 72 contiguous zero bytes at the sig placeholder offset. ARC's rejection diagnostic confirmed the script eval failed at the user check_sig assertion (Script failed / SEEN_IN_ORPHAN_MEMPOOL sticky status). With the fix applied, the same transaction shape mines successfully.

On dedicated regression test: the cleanest test shape would extract a small pub fn sign_stateful_sig_params(signer, tx, input_idx, subscript, sats, code_sep_idx, sig_indices, resolved_args) helper (same pattern as PR #105's resolve_continuation_satoshis extraction) so the test can assert directly that calling the helper with input_idx == 0 populates resolved_args[idx] with a non-placeholder signature. Happy to author the helper extraction + inline test in a follow-up PR if the maintainer prefers that shape — the 4-line guard removal in the current PR is mechanical and visually inspectable from the diff, but the explicit regression coverage is a reasonable ask.

Backwards compatibility

Behaviour change is narrowly scoped:

  • Methods that declare NO Sig user-params: sig_indices is empty; the loop body is skipped; no change for input 0 or any other input.
  • Methods that declare Sig user-params and are called WITHOUT mixing OP_PUSH_TX preimage verification (rare for stateful contracts but possible for stateless): no on-chain effect — the OP_PUSH_TX path isn't exercising input 0's checkSig anyway.
  • Methods that declare Sig user-params AND use them in check_sig at the primary input: now the unlock script contains a real signature instead of zero bytes; previously these methods could not broadcast.

No existing successful broadcast path is invalidated.

Related

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