Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion packages/runar-rs/src/sdk/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -447,9 +447,35 @@ fn encode_num2bin(n: i64, width: usize) -> String {
}

/// Wrap a hex-encoded byte string in a Bitcoin Script push data opcode.
pub(crate) fn encode_push_data(data_hex: &str) -> String {
///
/// 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`. Non-minimal direct
/// pushes are rejected by ARC, TAAL ARC, and WhatsOnChain at the relay
/// layer with the error:
/// `non-mandatory-script-verify-flag (Data push larger than necessary)`
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` (which short-circuits
// to `OP_N` opcodes for n in 1..=16). The encoder for `SdkValue::Bytes`
// did not previously honor this rule, so an arbitrary ByteString that
// happened to be a single byte in this range produced a relay-rejected
// direct push.
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 {
Expand Down
90 changes: 90 additions & 0 deletions packages/runar-rs/tests/encode_push_data_minimaldata.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
//! Regression test for MINIMALDATA-correct encoding of single-byte payloads
//! in `encode_push_data`.
//!
//! BSV consensus + relay policy (`SCRIPT_VERIFY_MINIMALDATA`) require that
//! a 1-byte data push whose payload 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`. Non-minimal pushes
//! are rejected by ARC, TAAL ARC, and WhatsOnChain at the relay layer with:
//! `non-mandatory-script-verify-flag (Data push larger than necessary)`
//!
//! Before the fix, `encode_push_data` always emitted the direct push form
//! for any payload of length 1, regardless of byte value. This worked for
//! the common case (ByteString args of length >= 2: sigs, pubkeys, hashes,
//! preimages, etc.) but tripped for any byte-string args that happened to
//! land on a single byte in the OP_N range — for example certain padding
//! patterns in Rabin signature schemes, or any user-supplied ByteString
//! that the consumer didn't pre-normalize.
//!
//! The encoder for `SdkValue::Int` (`encode_script_number`) already enforces
//! this rule; the fix brings `encode_push_data` for `SdkValue::Bytes` to
//! the same standard.

use runar_lang::sdk::state::encode_push_data;

#[test]
fn encode_push_data_minimaldata_op_0() {
// Payload 0x00 (single zero byte) must encode as OP_0 (one byte: 0x00),
// not direct push (two bytes: 0x01 0x00).
assert_eq!(encode_push_data("00"), "00");
}

#[test]
fn encode_push_data_minimaldata_op_1_through_16() {
// Payloads 0x01..=0x10 must encode as OP_1..OP_16 (opcodes 0x51..0x60).
let cases: [(u8, &str); 16] = [
(0x01, "51"), (0x02, "52"), (0x03, "53"), (0x04, "54"),
(0x05, "55"), (0x06, "56"), (0x07, "57"), (0x08, "58"),
(0x09, "59"), (0x0a, "5a"), (0x0b, "5b"), (0x0c, "5c"),
(0x0d, "5d"), (0x0e, "5e"), (0x0f, "5f"), (0x10, "60"),
];
for (byte, expected) in cases {
let hex = format!("{:02x}", byte);
let got = encode_push_data(&hex);
assert_eq!(
got, expected,
"encode_push_data({:02x}) should emit {} (OP_{}), got {}",
byte, expected, byte, got
);
}
}

#[test]
fn encode_push_data_minimaldata_op_1negate() {
// Payload 0x81 (BSV's signed-magnitude representation of -1) must
// encode as OP_1NEGATE (opcode 0x4f).
assert_eq!(encode_push_data("81"), "4f");
}

#[test]
fn encode_push_data_single_byte_outside_op_n_range_still_direct_push() {
// Bytes outside {0x00, 0x01..=0x10, 0x81} must still use the direct
// push form (01 NN). This locks the regression boundary: the fix
// should ONLY short-circuit the consensus-required cases.
for byte in [0x11u8, 0x4f, 0x50, 0x60, 0x61, 0x80, 0x82, 0xff] {
let hex = format!("{:02x}", byte);
let expected = format!("01{:02x}", byte);
let got = encode_push_data(&hex);
assert_eq!(
got, expected,
"encode_push_data({:02x}) should emit direct push {}, got {}",
byte, expected, got
);
}
}

#[test]
fn encode_push_data_multi_byte_unchanged() {
// Payloads of length >= 2 are not affected by MINIMALDATA single-byte
// rule. Encoding must match the pre-fix behaviour exactly.
assert_eq!(encode_push_data("0001"), "020001");
assert_eq!(encode_push_data("deadbeef"), "04deadbeef");
// 75-byte payload: still direct push (max for direct push)
let payload = "aa".repeat(75);
let expected = format!("4b{}", payload);
assert_eq!(encode_push_data(&payload), expected);
// 76-byte payload: switches to OP_PUSHDATA1
let payload = "bb".repeat(76);
let expected = format!("4c4c{}", payload);
assert_eq!(encode_push_data(&payload), expected);
}
Loading