Skip to content

fix(sdk): MINIMALDATA-correct encode_push_data for 1-byte payloads in OP_N range#110

Open
E-Jacko wants to merge 1 commit into
icellan:mainfrom
E-Jacko:fix/encode-push-data-minimaldata
Open

fix(sdk): MINIMALDATA-correct encode_push_data for 1-byte payloads in OP_N range#110
E-Jacko wants to merge 1 commit into
icellan:mainfrom
E-Jacko:fix/encode-push-data-minimaldata

Conversation

@E-Jacko

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

Copy link
Copy Markdown
Contributor

Summary

encode_push_data in packages/runar-rs/src/sdk/state.rs does not apply BSV consensus rule SCRIPT_VERIFY_MINIMALDATA for single-byte payloads. When a 1-byte ByteString payload happens to be in the OP_N range (0x00, 0x01..=0x10, 0x81), the encoder produces a direct push (01 NN) which ARC, TAAL ARC, and WhatsOnChain all reject at the relay layer.

Problem

Current encode_push_data at packages/runar-rs/src/sdk/state.rs:450-462 (upstream HEAD a8187ab4):

pub(crate) fn encode_push_data(data_hex: &str) -> String {
    let len = data_hex.len() / 2;

    if len <= 75 {
        format!("{:02x}{}", len, data_hex)
    } else if len <= 0xff {
        format!("4c{:02x}{}", len, data_hex)
    } else if len <= 0xffff {
        format!("4d{}{}", to_little_endian_16(len), data_hex)
    } else {
        format!("4e{}{}", to_little_endian_32(len as u32), data_hex)
    }
}

For a 1-byte payload 0x01, this returns "0101" (direct push). BSV consensus requires it to be "51" (OP_1). For 0x00 it must be "00" (OP_0), not "0100". For 0x81 it must be "4f" (OP_1NEGATE), not "0181".

ARC empirical response when a non-minimal push reaches the relay:

TAAL ARC https://arc.taal.com/v1/tx:
{
  "txStatus": "REJECTED",
  "extraInfo": "transaction rejected by peer\npeer: fabriik-bsv-001.teranode.group:8333 reason: non-mandatory-script-verify-flag (Data push larger than necessary)"
}

WhatsOnChain /v1/bsv/main/tx/raw:
HTTP 400 — "unexpected response code 500: 64: non-mandatory-script-verify-flag (Data push larger than necessary)"

The Rust SDK's encode_script_number (contract.rs:1930) DOES enforce MINIMALDATA for SdkValue::Int (it short-circuits to OP_N opcodes for n ∈ 1..=16). But encode_push_data is the encoder for SdkValue::Bytes — and when an arbitrary ByteString arg happens to land on a single byte in the script-number range, the encoder produced a non-minimal direct push.

How this surfaced

Surfaced during a mainnet broadcast where a stateful contract method took a ByteString argument carrying a value whose final byte landed on a payload in the OP_N range ({0x00, 0x01..=0x10, 0x81}). The transaction built cleanly client-side, computed a valid sighash, and signed correctly. On submission to ARC, the relay rejected with the MINIMALDATA error above — the rejection happens at the script-verify stage before the tx reaches any miner.

Other common ByteString payloads (signatures ≥ 70 bytes, pubkeys at 33 bytes, hashes at 32 bytes, full preimages) never tripped this because they're always multi-byte. The defect only surfaces when a contract author or framework happens to pass a single byte through the SdkValue::Bytes path that falls in the OP_N range.

Fix

packages/runar-rs/src/sdk/state.rs:

/// Wrap a hex-encoded byte string in a Bitcoin Script push data opcode.
///
/// Applies BSV consensus rule `SCRIPT_VERIFY_MINIMALDATA` for single-byte
/// pushes: a 1-byte payload whose value is in `{0x00, 0x01..=0x10, 0x81}`
/// MUST use the corresponding minimal opcode (`OP_0` / `OP_1..OP_16` /
/// `OP_1NEGATE`) rather than the direct push `01 NN`.
pub fn encode_push_data(data_hex: &str) -> String {
    let len = data_hex.len() / 2;

    // MINIMALDATA: single-byte payloads in the OP_N range must use the
    // corresponding minimal opcode. Mirrors the same rule already enforced
    // by `encode_script_number` for `SdkValue::Int`.
    if len == 1 {
        if let Ok(byte) = u8::from_str_radix(data_hex, 16) {
            match byte {
                0x00 => return "00".to_string(),                       // OP_0
                0x01..=0x10 => return format!("{:02x}", 0x50 + byte),  // OP_1..OP_16
                0x81 => return "4f".to_string(),                       // OP_1NEGATE
                _ => {}
            }
        }
    }

    if len <= 75 {
        format!("{:02x}{}", len, data_hex)
    } else if len <= 0xff {
        format!("4c{:02x}{}", len, data_hex)
    } else if len <= 0xffff {
        format!("4d{}{}", to_little_endian_16(len), data_hex)
    } else {
        format!("4e{}{}", to_little_endian_32(len as u32), data_hex)
    }
}

Visibility raised from pub(crate) to pub so the regression test can call it directly from an integration test (matches the test-through-public-surface pattern used by other recent SDK fixes).

Cross-language parity

The TS SDK's encodeScriptNumber already applies the same rule for numeric values; this PR closes the asymmetry on the SdkValue::Bytes path in the Rust port. A TS twin may want to audit encodePushData for the same case if the bytes-vs-numeric distinction is mirrored there.

Verification

Reproducible in 60 seconds:

git clone --depth 1 https://github.com/icellan/runar /tmp/runar-mindata
cd /tmp/runar-mindata
git apply <this patch>
cd packages/runar-rs
cargo test --test encode_push_data_minimaldata

Fails without the patch (running the same test against bare HEAD, with encode_push_data made pub but the MINIMALDATA short-circuit reverted):

running 5 tests
test encode_push_data_multi_byte_unchanged ... ok
test encode_push_data_single_byte_outside_op_n_range_still_direct_push ... ok
test encode_push_data_minimaldata_op_0 ... FAILED
test encode_push_data_minimaldata_op_1_through_16 ... FAILED
test encode_push_data_minimaldata_op_1negate ... FAILED

---- encode_push_data_minimaldata_op_0 stdout ----
thread 'encode_push_data_minimaldata_op_0' panicked at tests/encode_push_data_minimaldata.rs:30:5:
assertion `left == right` failed
  left: "0100"
 right: "00"

---- encode_push_data_minimaldata_op_1_through_16 stdout ----
thread 'encode_push_data_minimaldata_op_1_through_16' panicked at tests/encode_push_data_minimaldata.rs:46:9:
assertion `left == right` failed: encode_push_data(01) should emit 51 (OP_1), got 0101
  left: "0101"
 right: "51"

---- encode_push_data_minimaldata_op_1negate stdout ----
thread 'encode_push_data_minimaldata_op_1negate' panicked at tests/encode_push_data_minimaldata.rs:56:5:
assertion `left == right` failed
  left: "0181"
 right: "4f"

test result: FAILED. 2 passed; 3 failed

Passes with the patch:

running 5 tests
test encode_push_data_minimaldata_op_1_through_16 ... ok
test encode_push_data_multi_byte_unchanged ... ok
test encode_push_data_minimaldata_op_1negate ... ok
test encode_push_data_single_byte_outside_op_n_range_still_direct_push ... ok
test encode_push_data_minimaldata_op_0 ... ok

test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Five tests cover: each of the three minimal-opcode cases (OP_0, OP_1..OP_16, OP_1NEGATE), the regression boundary (bytes outside the OP_N range still take the direct push form), and the multi-byte path (lengths 2, 4, 75, 76 — covering the direct push / OP_PUSHDATA1 boundary).

Backwards compatibility

Existing consumers see no behavioural change for multi-byte payloads (the common case — sigs, pubkeys, hashes, preimages). The only behavioural change is for single-byte payloads in the OP_N range, where the new output is the consensus-required form and the old output was being rejected at the relay anyway.

The pub(crate)pub visibility change is additive — no existing caller is affected; only new callers gain access.

Related

  • The SdkValue::Int encoding path (encode_script_number in contract.rs:1930) already enforces this rule. This PR brings the SdkValue::Bytes path to the same standard.

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