From 248057f56f7468205da2c4e71f49af40d40a5bda Mon Sep 17 00:00:00 2001 From: Elis Jackson Date: Fri, 26 Jun 2026 15:08:42 +0100 Subject: [PATCH] fix(sdk): MINIMALDATA-correct encode_push_data for 1-byte payloads in OP_N range --- packages/runar-rs/src/sdk/state.rs | 28 +++++- .../tests/encode_push_data_minimaldata.rs | 90 +++++++++++++++++++ 2 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 packages/runar-rs/tests/encode_push_data_minimaldata.rs diff --git a/packages/runar-rs/src/sdk/state.rs b/packages/runar-rs/src/sdk/state.rs index ce47ba65..c9e3ce64 100644 --- a/packages/runar-rs/src/sdk/state.rs +++ b/packages/runar-rs/src/sdk/state.rs @@ -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 { diff --git a/packages/runar-rs/tests/encode_push_data_minimaldata.rs b/packages/runar-rs/tests/encode_push_data_minimaldata.rs new file mode 100644 index 00000000..ba574f4d --- /dev/null +++ b/packages/runar-rs/tests/encode_push_data_minimaldata.rs @@ -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); +}