#![cfg(feature = "transaction")]
use serde_json::Value;
use std::collections::BTreeMap;
use std::path::PathBuf;
use bsv_rs::primitives::bsv::sighash::{parse_transaction, SighashCache, TxOutput};
use bsv_rs::primitives::sha256d;
use bsv_rs::script::{op, LockingScript, Script, ScriptChunk, Spend, SpendParams, UnlockingScript};
use bsv_rs::transaction::{Transaction, TransactionInput, TransactionOutput};
fn corpus_dir() -> Option<PathBuf> {
let dir = match std::env::var("BSV_CONFORMANCE_DIR") {
Ok(d) => PathBuf::from(d),
Err(_) => PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../ts-stack/conformance"),
};
if dir.join("META.json").is_file() {
Some(dir)
} else {
eprintln!(
"SKIP: conformance corpus not found at {} (set BSV_CONFORMANCE_DIR or check out \
ts-stack next to bsv-rs); conformance tests pass vacuously",
dir.display()
);
None
}
}
fn load_json(path: &PathBuf) -> Value {
let data = std::fs::read_to_string(path)
.unwrap_or_else(|e| panic!("failed to read {}: {}", path.display(), e));
serde_json::from_str(&data)
.unwrap_or_else(|e| panic!("failed to parse {}: {}", path.display(), e))
}
fn s<'a>(v: &'a Value, key: &str) -> &'a str {
v.get(key).and_then(Value::as_str).unwrap_or("")
}
fn n(v: &Value, key: &str) -> i64 {
v.get(key).and_then(Value::as_i64).unwrap_or(0)
}
fn b(v: &Value, key: &str) -> bool {
v.get(key).and_then(Value::as_bool).unwrap_or(false)
}
fn from_hex(hex: &str) -> Vec<u8> {
bsv_rs::primitives::from_hex(hex).unwrap_or_else(|e| panic!("bad hex '{}': {}", hex, e))
}
fn to_hex(bytes: &[u8]) -> String {
bsv_rs::primitives::to_hex(bytes)
}
#[derive(Default)]
struct Summary {
run: usize,
passed: usize,
unsupported: BTreeMap<&'static str, Vec<String>>,
skipped: Vec<String>,
known_failures_hit: Vec<String>,
failures: Vec<String>,
panics: Vec<String>,
}
impl Summary {
fn unsupported_total(&self) -> usize {
self.unsupported.values().map(Vec::len).sum()
}
fn print(&self, name: &str) {
println!("=== {} summary ===", name);
println!(
"total={} run={} passed={} unsupported={} skipped={} known_failures={} failures={} panics={}",
self.run + self.unsupported_total() + self.skipped.len(),
self.run,
self.passed,
self.unsupported_total(),
self.skipped.len(),
self.known_failures_hit.len(),
self.failures.len(),
self.panics.len()
);
for (class, ids) in &self.unsupported {
println!(" unsupported[{}] = {}", class, ids.len());
}
for f in &self.known_failures_hit {
println!(" KNOWN FAILURE (SDK divergence, tracked): {}", f);
}
if !self.panics.is_empty() {
println!(
" PANICS (bsv-rs robustness bugs — SDK panicked instead of Err): {} vectors, e.g. {:?}",
self.panics.len(),
&self.panics[..self.panics.len().min(5)]
);
}
for f in self.failures.iter().take(25) {
println!(" FAILURE: {}", f);
}
}
}
fn catch<T>(f: impl FnOnce() -> T) -> Option<T> {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).ok()
}
fn silence_panics() -> Box<dyn Fn(&std::panic::PanicHookInfo<'_>) + Sync + Send> {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
prev
}
fn crediting_txid(locking_script: &LockingScript, amount: u64) -> [u8; 32] {
let mut input = TransactionInput::new(
"0000000000000000000000000000000000000000000000000000000000000000".to_string(),
0xffff_ffff,
);
input.unlocking_script = Some(UnlockingScript::from_chunks(vec![
ScriptChunk { op: op::OP_0, data: None },
ScriptChunk { op: op::OP_0, data: None },
]));
input.sequence = 0xffff_ffff;
let output = TransactionOutput {
satoshis: Some(amount),
locking_script: locking_script.clone(),
change: false,
};
Transaction::with_params(1, vec![input], vec![output], 0).hash()
}
fn run_node_script(input: &Value) -> Result<bool, String> {
let locking = LockingScript::from_hex(s(input, "script_pubkey_hex"))
.map_err(|e| format!("locking script parse: {}", e))?;
let sig_hex = s(input, "script_sig_hex");
let unlocking = if sig_hex.is_empty() {
UnlockingScript::new()
} else {
UnlockingScript::from_hex(sig_hex)
.map_err(|e| format!("unlocking script parse: {}", e))?
};
let amount = n(input, "amount_satoshis") as u64;
let source_txid = crediting_txid(&locking, amount);
let mut spend = Spend::new(SpendParams {
source_txid,
source_output_index: 0,
source_satoshis: amount,
locking_script: locking,
transaction_version: n(input, "tx_version") as i32,
other_inputs: vec![],
outputs: vec![TxOutput { satoshis: amount, script: vec![] }],
input_index: 0,
unlocking_script: unlocking,
input_sequence: 0xffff_ffff,
lock_time: 0,
memory_limit: None,
});
spend.validate().map_err(|e| e.message)
}
fn classify_node_mismatch(ctx: &MismatchContext<'_>) -> Option<&'static str> {
let has = |f: &str| ctx.flags.iter().any(|x| x == f);
if !ctx.expected_valid {
if has("P2SH") && ctx.locking_is_p2sh {
return Some("no P2SH redeem-script evaluation in bsv-rs (post-genesis rules)");
}
match ctx.result_code {
"SCRIPTNUM_OVERFLOW" | "OPERAND_SIZE" | "SCRIPT_SIZE" | "PUSH_SIZE"
| "STACK_SIZE" | "OP_COUNT" | "PUBKEY_COUNT" | "SIG_COUNT" => {
Some("pre-genesis limits (bsv-rs runs post-genesis/unlimited rules)")
}
"BAD_OPCODE" | "DISABLED_OPCODE" => {
Some("flag-dependent opcode set (pre-genesis/Chronicle) not expressible in bsv-rs")
}
"INVALID_STACK_OPERATION" if has("UTXO_AFTER_CHRONICLE") => {
Some("UTXO_AFTER_CHRONICLE opcode semantics not implemented in bsv-rs")
}
"DISCOURAGE_UPGRADABLE_NOPS" => Some("no DISCOURAGE_UPGRADABLE_NOPS flag in bsv-rs"),
"MINIMALIF" => Some("no MINIMALIF flag in bsv-rs"),
"NULLFAIL" => Some("no NULLFAIL flag in bsv-rs"),
"UNSATISFIED_LOCKTIME" | "NEGATIVE_LOCKTIME" => {
Some("no CLTV/CSV enforcement in bsv-rs (post-genesis NOPs)")
}
"OP_RETURN" => {
Some("pre-genesis OP_RETURN semantics (bsv-rs implements post-genesis skip)")
}
"UNBALANCED_CONDITIONAL" => {
Some("flag-dependent ELSE/ENDIF structure rules not expressible in bsv-rs")
}
"ILLEGAL_FORKID" => {
Some("bsv-rs always requires SIGHASH_FORKID (cannot reject FORKID-bit sigs)")
}
"" if has("CHECKLOCKTIMEVERIFY") || has("CHECKSEQUENCEVERIFY") => {
Some("no CLTV/CSV enforcement in bsv-rs (post-genesis NOPs)")
}
_ => None,
}
} else {
let msg = ctx.error_message;
if has("P2SH") && ctx.locking_is_p2sh {
return Some("no P2SH redeem-script evaluation in bsv-rs (post-genesis rules)");
}
if msg.starts_with("Invalid opcode") && has("UTXO_AFTER_GENESIS") {
return Some("flag-dependent opcode set (pre-genesis/Chronicle) not expressible in bsv-rs");
}
if msg.contains("currently disabled") && has("UTXO_AFTER_CHRONICLE") {
return Some("UTXO_AFTER_CHRONICLE opcode semantics not implemented in bsv-rs");
}
if msg.contains("can only contain push operations") && !has("SIGPUSHONLY") {
return Some("bsv-rs always enforces SIGPUSHONLY (push-only unlocking scripts)");
}
if (msg.contains("not minimally-encoded") || msg.contains("Invalid script number"))
&& !has("MINIMALDATA")
{
return Some("bsv-rs always enforces MINIMALDATA (no off switch)");
}
if msg.contains("clean stack") && !has("CLEANSTACK") {
return Some("bsv-rs always enforces CLEANSTACK (no off switch)");
}
if msg.contains("SIGHASH_FORKID") && !has("SIGHASH_FORKID") {
return Some("bsv-rs always requires SIGHASH_FORKID (legacy-signature fixtures)");
}
if msg.contains("low S") && !has("LOW_S") {
return Some("bsv-rs always enforces LOW_S (no off switch)");
}
if msg.contains("dummy") && !has("NULLDUMMY") {
return Some("bsv-rs always enforces NULLDUMMY (no off switch)");
}
if (msg.contains("signature format is invalid") || msg.contains("public key"))
&& !has("STRICTENC")
&& !has("DERSIG")
{
return Some("bsv-rs always enforces strict DER/pubkey encodings (no off switch)");
}
None
}
}
struct MismatchContext<'a> {
flags: &'a [String],
expected_valid: bool,
result_code: &'a str,
error_message: &'a str,
locking_is_p2sh: bool,
}
fn is_p2sh_hex(script_pubkey_hex: &str) -> bool {
script_pubkey_hex.len() == 46
&& script_pubkey_hex.starts_with("a914")
&& script_pubkey_hex.ends_with("87")
}
const EVALUATION_KNOWN_FAILURES: &[(&str, &str)] = &[
("node.script.bitcoin-sv.0698", "eager CHECKMULTISIG encoding check (BUG 1)"),
("node.script.bitcoin-sv.0699", "eager CHECKMULTISIG encoding check (BUG 1)"),
("node.script.bitcoin-sv.1433", "eager CHECKMULTISIG encoding check (BUG 1)"),
("node.script.bitcoin-sv.1535", "eager CHECKMULTISIG encoding check (BUG 1)"),
("node.script.bitcoin-sv.1536", "eager CHECKMULTISIG encoding check (BUG 1)"),
("node.script.bitcoin-sv.1537", "eager CHECKMULTISIG encoding check (BUG 1)"),
("node.script.bitcoin-sv.1539", "eager CHECKMULTISIG encoding check (BUG 1)"),
("node.script.bitcoin-sv.1541", "eager CHECKMULTISIG encoding check (BUG 1)"),
("node.script.teranode.0666", "eager CHECKMULTISIG encoding check (BUG 1)"),
("node.script.teranode.0667", "eager CHECKMULTISIG encoding check (BUG 1)"),
("node.script.teranode.1384", "eager CHECKMULTISIG encoding check (BUG 1)"),
("node.script.teranode.1486", "eager CHECKMULTISIG encoding check (BUG 1)"),
("node.script.teranode.1487", "eager CHECKMULTISIG encoding check (BUG 1)"),
("node.script.teranode.1488", "eager CHECKMULTISIG encoding check (BUG 1)"),
("node.script.teranode.1490", "eager CHECKMULTISIG encoding check (BUG 1)"),
("node.script.teranode.1492", "eager CHECKMULTISIG encoding check (BUG 1)"),
];
#[test]
fn conformance_scripts_evaluation() {
let Some(dir) = corpus_dir() else { return };
let file = load_json(&dir.join("vectors/sdk/scripts/evaluation.json"));
let vectors = file["vectors"].as_array().expect("vectors array");
assert_eq!(
vectors.len(),
5116,
"evaluation.json vector count changed — review new vectors and update the pins"
);
let mut summary = Summary::default();
let debug = std::env::var("BSV_CONFORMANCE_DEBUG").is_ok();
let mut debug_lines: Vec<String> = Vec::new();
let prev_hook = silence_panics();
for vector in vectors {
let id = s(vector, "id").to_string();
let parity = s(vector, "parity_class");
if b(vector, "skip") || (!parity.is_empty() && parity != "required") {
summary.skipped.push(id);
continue;
}
let input = &vector["input"];
let expected = &vector["expected"];
let outcome = match s(input, "fixture_type") {
"node-script" => eval_node_script_vector(&id, input, expected, &mut summary),
"node-sighash" => eval_node_sighash_vector(&id, input, expected, &mut summary),
"node-transaction" => eval_node_transaction_vector(&id, input, expected, &mut summary),
_ => eval_misc_vector(&id, input, expected, &mut summary),
};
if debug {
if let Some(line) = outcome {
debug_lines.push(line);
}
}
}
std::panic::set_hook(prev_hook);
if debug && !debug_lines.is_empty() {
let out = std::env::var("BSV_CONFORMANCE_DEBUG").unwrap();
std::fs::write(&out, debug_lines.join("\n")).ok();
eprintln!("wrote {} debug lines to {}", debug_lines.len(), out);
}
finish_evaluation(summary);
}
fn eval_node_script_vector(
id: &str,
input: &Value,
expected: &Value,
summary: &mut Summary,
) -> Option<String> {
let expected_valid = b(expected, "valid");
let (got, error_message) = match catch(|| run_node_script(input)) {
Some(Ok(v)) => (v, String::new()),
Some(Err(msg)) => (false, msg),
None => {
summary.panics.push(id.to_string());
(false, "panicked inside bsv-rs".to_string())
}
};
if got == expected_valid {
summary.run += 1;
summary.passed += 1;
return None;
}
let flags: Vec<String> = input["flags"]
.as_array()
.map(|a| a.iter().map(|f| f.as_str().unwrap_or("").to_string()).collect())
.unwrap_or_default();
let result_code = s(expected, "result");
record_mismatch(
id,
&MismatchContext {
flags: &flags,
expected_valid,
result_code,
error_message: &error_message,
locking_is_p2sh: is_p2sh_hex(s(input, "script_pubkey_hex")),
},
got,
summary,
);
Some(format!(
"{}\tflags={}\texpected={}\tgot={}\tresult={}\terr={}\tsig={}\tpubkey={}",
id,
s(input, "flags_csv"),
expected_valid,
got,
result_code,
error_message,
s(input, "script_sig_hex"),
s(input, "script_pubkey_hex"),
))
}
fn record_mismatch(id: &str, ctx: &MismatchContext<'_>, got: bool, summary: &mut Summary) {
if let Some(class) = classify_node_mismatch(ctx) {
summary.unsupported.entry(class).or_default().push(id.to_string());
} else if let Some((_, detail)) =
EVALUATION_KNOWN_FAILURES.iter().find(|(k, _)| *k == id)
{
summary.run += 1;
summary.known_failures_hit.push(format!(
"{}: expected valid={}, got {} — {}",
id, ctx.expected_valid, got, detail
));
} else {
summary.run += 1;
summary.failures.push(format!(
"{}: expected valid={} (result={}, flags={:?}), bsv-rs got {} (err='{}')",
id, ctx.expected_valid, ctx.result_code, ctx.flags, got, ctx.error_message
));
}
}
fn eval_node_sighash_vector(
id: &str,
input: &Value,
expected: &Value,
summary: &mut Summary,
) -> Option<String> {
const SIGHASH_CHRONICLE: u32 = 0x20;
const SIGHASH_FORKID: u32 = 0x40;
let scope = n(input, "hash_type") as u32;
let ignore_chronicle = input["sources"]
.as_array()
.map(|a| a.iter().any(|v| v.as_str() == Some("teranode")))
.unwrap_or(false);
let has_forkid = scope & SIGHASH_FORKID != 0;
let has_chronicle = !ignore_chronicle && (scope & SIGHASH_CHRONICLE != 0);
if !has_forkid || has_chronicle {
summary
.unsupported
.entry("legacy/OTDA sighash not implemented in bsv-rs")
.or_default()
.push(id.to_string());
return None;
}
let computed = catch(|| -> Result<String, String> {
let raw_tx = from_hex(s(input, "tx_hex"));
let tx = parse_transaction(&raw_tx).map_err(|e| format!("tx parse failed: {}", e))?;
let subscript = from_hex(s(input, "script_hex"));
let mut sighash_cache = SighashCache::new(&tx);
let preimage = sighash_cache
.preimage(
n(input, "input_index") as usize,
&subscript,
0, scope,
)
.map_err(|e| format!("sighash preimage failed: {}", e))?;
let mut digest = sha256d(&preimage);
digest.reverse(); Ok(to_hex(&digest))
});
let got = match computed {
Some(Ok(hex)) => hex,
Some(Err(e)) => {
summary.run += 1;
summary.failures.push(format!("{}: {}", id, e));
return None;
}
None => {
summary.run += 1;
summary.panics.push(id.to_string());
summary.failures.push(format!("{}: panicked computing sighash", id));
return None;
}
};
let want = s(expected, "regular_hash");
summary.run += 1;
if got == want {
summary.passed += 1;
None
} else {
summary.failures.push(format!(
"{}: FORKID sighash mismatch: expected {}, got {}",
id, want, got
));
Some(format!("{}\tsighash expected={} got={}", id, want, got))
}
}
fn eval_node_transaction_vector(
id: &str,
input: &Value,
expected: &Value,
summary: &mut Summary,
) -> Option<String> {
let expected_valid = b(expected, "valid");
let tx_hex = s(input, "tx_hex");
let parsed = match catch(|| Transaction::from_hex(tx_hex)) {
Some(p) => p,
None => {
summary.panics.push(id.to_string());
Err(bsv_rs::Error::TransactionError("panicked".into()))
}
};
let reserialized = |t: &Transaction| {
Transaction::with_params(t.version, t.inputs.clone(), t.outputs.clone(), t.lock_time)
.to_hex()
};
let tx = match parsed {
Ok(t) if reserialized(&t) == tx_hex => t,
_ => {
if expected_valid {
summary.run += 1;
summary
.failures
.push(format!("{}: expected-valid tx failed to parse/round-trip", id));
} else {
summary.run += 1;
summary.passed += 1;
}
return None;
}
};
let empty = vec![];
let prevouts = input["prevouts"].as_array().unwrap_or(&empty);
let mut rejected = 0usize;
let mut first_error = String::new();
let mut any_p2sh_prevout = false;
for (vin, txin) in tx.inputs.iter().enumerate() {
let prevout = prevouts.iter().find(|p| {
Some(s(p, "txid")) == txin.source_txid.as_deref()
&& (n(p, "vout") as u32) == txin.source_output_index
});
let Some(prevout) = prevout else {
rejected += 1;
if first_error.is_empty() {
first_error = format!("missing prevout fixture for input {}", vin);
}
continue;
};
any_p2sh_prevout |= is_p2sh_hex(s(prevout, "script_pubkey_hex"));
match catch(|| validate_tx_input(&tx, vin, prevout)) {
Some(Ok(())) => {}
Some(Err(msg)) => {
rejected += 1;
if first_error.is_empty() {
first_error = msg;
}
}
None => {
summary.panics.push(format!("{} (input {})", id, vin));
rejected += 1;
if first_error.is_empty() {
first_error = "panicked inside bsv-rs".to_string();
}
}
}
}
let flags: Vec<String> = input["flag_strings"]
.as_array()
.map(|a| {
a.iter()
.flat_map(|fs| fs.as_str().unwrap_or("").split(','))
.filter(|f| !f.is_empty())
.map(str::to_string)
.collect()
})
.unwrap_or_default();
let got_valid = rejected == 0;
if got_valid == expected_valid {
summary.run += 1;
summary.passed += 1;
return None;
}
record_mismatch(
id,
&MismatchContext {
flags: &flags,
expected_valid,
result_code: "", error_message: &first_error,
locking_is_p2sh: any_p2sh_prevout,
},
got_valid,
summary,
);
Some(format!(
"{}\tflags={:?}\texpected={}\tgot={}\terr={}\ttx={}",
id, flags, expected_valid, got_valid, first_error, tx_hex
))
}
fn validate_tx_input(tx: &Transaction, vin: usize, prevout: &Value) -> Result<(), String> {
use bsv_rs::primitives::bsv::sighash::TxInput;
let txin = &tx.inputs[vin];
let locking = LockingScript::from_hex(s(prevout, "script_pubkey_hex"))
.map_err(|e| format!("prevout script parse: {}", e))?;
let unlocking = txin
.unlocking_script
.clone()
.ok_or_else(|| "missing unlocking script".to_string())?;
let other_inputs: Vec<TxInput> = tx
.inputs
.iter()
.enumerate()
.filter(|(i, _)| *i != vin)
.map(|(_, inp)| TxInput {
txid: inp.get_source_txid_bytes().unwrap_or([0u8; 32]),
output_index: inp.source_output_index,
script: inp
.unlocking_script
.as_ref()
.map(|u| u.to_binary())
.unwrap_or_default(),
sequence: inp.sequence,
})
.collect();
let outputs: Vec<TxOutput> = tx
.outputs
.iter()
.map(|o| TxOutput {
satoshis: o.satoshis.unwrap_or(0),
script: o.locking_script.to_binary(),
})
.collect();
let mut spend = Spend::new(SpendParams {
source_txid: txin.get_source_txid_bytes().map_err(|e| e.to_string())?,
source_output_index: txin.source_output_index,
source_satoshis: n(prevout, "amount_satoshis") as u64,
locking_script: locking,
transaction_version: tx.version as i32,
other_inputs,
outputs,
input_index: vin,
unlocking_script: unlocking,
input_sequence: txin.sequence,
lock_time: tx.lock_time,
memory_limit: None,
});
match spend.validate() {
Ok(_) => Ok(()),
Err(e) => Err(e.message),
}
}
fn eval_misc_vector(
id: &str,
input: &Value,
expected: &Value,
summary: &mut Summary,
) -> Option<String> {
let outcome = match catch(|| run_misc_vector(input, expected)) {
Some(o) => o,
None => {
summary.panics.push(id.to_string());
MiscOutcome::Fail("panicked inside bsv-rs".to_string())
}
};
match outcome {
MiscOutcome::Unsupported(class) => {
summary.unsupported.entry(class).or_default().push(id.to_string());
None
}
MiscOutcome::Pass => {
summary.run += 1;
summary.passed += 1;
None
}
MiscOutcome::Fail(msg) => {
summary.run += 1;
if let Some((_, detail)) = EVALUATION_KNOWN_FAILURES.iter().find(|(k, _)| *k == id) {
summary.known_failures_hit.push(format!("{}: {} — {}", id, msg, detail));
} else {
summary.failures.push(format!("{}: {}", id, msg));
}
Some(format!("{}\t{}", id, "misc failure"))
}
}
}
enum MiscOutcome {
Pass,
Fail(String),
Unsupported(&'static str),
}
fn check(cond: bool, msg: String) -> MiscOutcome {
if cond {
MiscOutcome::Pass
} else {
MiscOutcome::Fail(msg)
}
}
fn run_misc_vector(input: &Value, expected: &Value) -> MiscOutcome {
if input.get("script_pubkey_hex").is_some() {
if b(input, "isRelaxed") {
return MiscOutcome::Unsupported("no isRelaxed evaluation mode in bsv-rs");
}
let mut spend = Spend::new(SpendParams {
source_txid: [0u8; 32],
source_output_index: 0,
source_satoshis: 0,
locking_script: match LockingScript::from_hex(s(input, "script_pubkey_hex")) {
Ok(l) => l,
Err(e) => return MiscOutcome::Fail(format!("pubkey parse: {}", e)),
},
transaction_version: 1,
other_inputs: vec![],
outputs: vec![],
input_index: 0,
unlocking_script: {
let sig = s(input, "script_sig_hex");
if sig.is_empty() {
UnlockingScript::new()
} else {
match UnlockingScript::from_hex(sig) {
Ok(u) => u,
Err(e) => return MiscOutcome::Fail(format!("sig parse: {}", e)),
}
}
},
input_sequence: 0xffff_ffff,
lock_time: 0,
memory_limit: None,
});
let got = spend.validate().unwrap_or(false);
let want = b(expected, "valid");
return check(got == want, format!("expected valid={}, got {}", want, got));
}
if let Some(hex) = input.get("hex").and_then(Value::as_str) {
let parsed = Script::from_hex(hex);
if b(expected, "throws") {
return check(parsed.is_err(), format!("expected parse error for '{}'", hex));
}
let script = match parsed {
Ok(p) => p,
Err(e) => return MiscOutcome::Fail(format!("parse '{}': {}", hex, e)),
};
return verify_chunk_expectations(&script, expected);
}
if let Some(bin) = input.get("binary").and_then(Value::as_array) {
let bytes: Vec<u8> = bin.iter().map(|x| x.as_u64().unwrap_or(0) as u8).collect();
let script = match Script::from_binary(&bytes) {
Ok(p) => p,
Err(e) => return MiscOutcome::Fail(format!("from_binary: {}", e)),
};
return verify_chunk_expectations(&script, expected);
}
if s(input, "type") == "P2PKH_lock" {
let hash = from_hex(s(input, "pubkey_hash_hex"));
let mut bytes = vec![0x76, 0xa9, 0x14];
bytes.extend_from_slice(&hash);
bytes.extend_from_slice(&[0x88, 0xac]);
let script = match Script::from_binary(&bytes) {
Ok(p) => p,
Err(e) => return MiscOutcome::Fail(format!("from_binary: {}", e)),
};
let asm = script.to_asm();
if script.to_hex() != s(expected, "hex") {
return MiscOutcome::Fail(format!("hex: got {}", script.to_hex()));
}
if bytes.len() as i64 != n(expected, "byte_length") {
return MiscOutcome::Fail(format!("byte_length: got {}", bytes.len()));
}
return check(
asm.starts_with(s(expected, "asm_prefix")) && asm.ends_with(s(expected, "asm_suffix")),
format!("asm affixes: got '{}'", asm),
);
}
if input.get("data_length_bytes").is_some() && s(input, "operation") != "findAndDelete" {
let len = n(input, "data_length_bytes") as usize;
let fill = parse_fill_byte(s(input, "data_fill_byte"));
let mut script = Script::new();
script.write_bin(&vec![fill; len]);
let got = script.chunks()[0].op as i64;
return check(
got == n(expected, "chunk_0_op"),
format!("chunk_0_op: expected {}, got {}", n(expected, "chunk_0_op"), got),
);
}
match s(input, "operation") {
"writeBn" => {
let mut script = Script::new();
script.write_number(n(input, "value"));
let got = script.chunks()[0].op as i64;
return check(
got == n(expected, "chunk_0_op"),
format!("writeBn({}): chunk op {}", n(input, "value"), got),
);
}
"writeBn_range" => {
let values = input["values"].as_array().cloned().unwrap_or_default();
let opcodes = expected["opcodes"].as_array().cloned().unwrap_or_default();
for (value, opcode) in values.iter().zip(opcodes.iter()) {
let mut script = Script::new();
script.write_number(value.as_i64().unwrap_or(0));
if script.chunks()[0].op as i64 != opcode.as_i64().unwrap_or(-1) {
return MiscOutcome::Fail(format!(
"writeBn({}): expected op {}, got {}",
value,
opcode,
script.chunks()[0].op
));
}
}
return MiscOutcome::Pass;
}
"findAndDelete" => {
let len = n(input, "data_length_bytes") as usize;
let fill = parse_fill_byte(s(input, "fill_byte"));
let data = vec![fill; len];
let mut target = Script::new();
target.write_bin(&data);
let mut source = Script::new();
source.write_bin(&data);
if b(input, "source_has_trailing_op1") {
source.write_opcode(op::OP_1);
}
source.write_bin(&data);
source.find_and_delete(&target);
let chunks = source.chunks();
if chunks.len() as i64 != n(expected, "remaining_chunks_count") {
return MiscOutcome::Fail(format!("remaining chunks: {}", chunks.len()));
}
return check(
chunks[0].op as i64 == n(expected, "remaining_chunk_0_op"),
format!("remaining chunk op: {}", chunks[0].op),
);
}
_ => {}
}
if let Some(asm) = input.get("script_asm").and_then(Value::as_str) {
let mut script = match Script::from_asm(asm) {
Ok(p) => p,
Err(e) => return MiscOutcome::Fail(format!("from_asm: {}", e)),
};
if let Some(append) = input.get("append_asm").and_then(Value::as_str) {
let other = match Script::from_asm(append) {
Ok(p) => p,
Err(e) => return MiscOutcome::Fail(format!("from_asm append: {}", e)),
};
script.write_script(&other);
let got = script.to_asm();
return check(
got == s(expected, "result_asm"),
format!("result_asm: got '{}'", got),
);
}
if input.get("index").is_some() {
let index = n(input, "index") as usize;
let new_op = n(input, "new_op") as u8;
script.set_chunk_opcode(index, new_op);
let key = format!("chunk_{}_op", index);
let want = expected.get(&key).and_then(Value::as_i64).unwrap_or(-1);
return check(
script.chunks()[index].op as i64 == want,
format!("chunk op after set: {}", script.chunks()[index].op),
);
}
}
MiscOutcome::Fail("unrecognized misc vector shape".to_string())
}
fn verify_chunk_expectations(script: &Script, expected: &Value) -> MiscOutcome {
let chunks = script.chunks();
if let Some(count) = expected.get("chunks_count").and_then(Value::as_i64) {
if chunks.len() as i64 != count {
return MiscOutcome::Fail(format!("chunks_count: got {}", chunks.len()));
}
}
if let Some(op0) = expected.get("chunk_0_op").and_then(Value::as_i64) {
if chunks.is_empty() || chunks[0].op as i64 != op0 {
return MiscOutcome::Fail(format!("chunk_0_op: got {:?}", chunks.first().map(|c| c.op)));
}
}
if let Some(data0) = expected.get("chunk_0_data").and_then(Value::as_array) {
let want: Vec<u8> = data0.iter().map(|x| x.as_u64().unwrap_or(0) as u8).collect();
let got = chunks.first().and_then(|c| c.data.clone()).unwrap_or_default();
if got != want {
return MiscOutcome::Fail(format!("chunk_0_data: got {:?}", got));
}
}
if let Some(round) = expected.get("hex_roundtrip").and_then(Value::as_str) {
if script.to_hex() != round {
return MiscOutcome::Fail(format!("hex_roundtrip: got {}", script.to_hex()));
}
}
MiscOutcome::Pass
}
fn parse_fill_byte(text: &str) -> u8 {
let trimmed = text.trim_start_matches("0x");
u8::from_str_radix(trimmed, 16).unwrap_or(0)
}
fn finish_evaluation(summary: Summary) {
summary.print("sdk.scripts.evaluation");
assert!(
summary.failures.is_empty(),
"unexplained conformance failures:\n{}",
summary.failures.join("\n")
);
assert_eq!(
summary.known_failures_hit.len(),
EVALUATION_KNOWN_FAILURES.len(),
"KNOWN_FAILURES drift: hit={:?}, pinned={:?}",
summary.known_failures_hit,
EVALUATION_KNOWN_FAILURES
);
let pins: BTreeMap<&str, usize> = EVALUATION_UNSUPPORTED_PINS.iter().cloned().collect();
let counts: BTreeMap<&str, usize> = summary
.unsupported
.iter()
.map(|(k, v)| (*k, v.len()))
.collect();
assert_eq!(
counts, pins,
"unsupported-class drift — audit the new/removed vectors before repinning"
);
assert_eq!(
summary.run + summary.unsupported_total() + summary.skipped.len(),
5116,
"vector accounting mismatch"
);
assert_eq!(summary.skipped.len(), 37, "corpus-skipped vectors");
assert_eq!(summary.passed + summary.known_failures_hit.len(), summary.run);
assert!(
summary.panics.is_empty(),
"bsv-rs panicked on vectors: {:?}",
summary.panics
);
}
const EVALUATION_UNSUPPORTED_PINS: &[(&str, usize)] = &[
("UTXO_AFTER_CHRONICLE opcode semantics not implemented in bsv-rs", 5),
("bsv-rs always enforces CLEANSTACK (no off switch)", 195),
("bsv-rs always enforces LOW_S (no off switch)", 9),
("bsv-rs always enforces MINIMALDATA (no off switch)", 181),
("bsv-rs always enforces NULLDUMMY (no off switch)", 4),
("bsv-rs always enforces SIGPUSHONLY (push-only unlocking scripts)", 319),
("bsv-rs always enforces strict DER/pubkey encodings (no off switch)", 54),
("bsv-rs always requires SIGHASH_FORKID (cannot reject FORKID-bit sigs)", 6),
("bsv-rs always requires SIGHASH_FORKID (legacy-signature fixtures)", 36),
("flag-dependent ELSE/ENDIF structure rules not expressible in bsv-rs", 30),
("flag-dependent opcode set (pre-genesis/Chronicle) not expressible in bsv-rs", 12),
("legacy/OTDA sighash not implemented in bsv-rs", 1244),
("no CLTV/CSV enforcement in bsv-rs (post-genesis NOPs)", 8),
("no DISCOURAGE_UPGRADABLE_NOPS flag in bsv-rs", 20),
("no MINIMALIF flag in bsv-rs", 6),
("no P2SH redeem-script evaluation in bsv-rs (post-genesis rules)", 72),
("no isRelaxed evaluation mode in bsv-rs", 3),
("pre-genesis OP_RETURN semantics (bsv-rs implements post-genesis skip)", 34),
("pre-genesis limits (bsv-rs runs post-genesis/unlimited rules)", 14),
];
fn minimal_push(bytes: &[u8]) -> ScriptChunk {
if bytes.is_empty() {
return ScriptChunk { op: op::OP_0, data: None };
}
if bytes.len() == 1 {
if (1..=16).contains(&bytes[0]) {
return ScriptChunk { op: op::OP_1 + bytes[0] - 1, data: None };
}
if bytes[0] == 0x81 {
return ScriptChunk { op: op::OP_1NEGATE, data: None };
}
}
ScriptChunk { op: bytes.len() as u8, data: Some(bytes.to_vec()) }
}
fn shift_via_interpreter(value: &[u8], shift_bits: i64, shift_op: u8, want: &[u8]) -> bool {
let unlocking = UnlockingScript::from_chunks(vec![minimal_push(value)]);
let shift_num = {
let mut script = Script::new();
script.write_number(shift_bits);
script.chunks()[0].clone()
};
let locking = LockingScript::from_script(Script::from_chunks(vec![
shift_num,
ScriptChunk { op: shift_op, data: None },
minimal_push(want),
ScriptChunk { op: op::OP_EQUAL, data: None },
]));
let mut spend = Spend::new(SpendParams {
source_txid: [0u8; 32],
source_output_index: 0,
source_satoshis: 0,
locking_script: locking,
transaction_version: 1,
other_inputs: vec![],
outputs: vec![],
input_index: 0,
unlocking_script: unlocking,
input_sequence: 0xffff_ffff,
lock_time: 0,
memory_limit: None,
});
spend.validate().unwrap_or(false)
}
const REGRESSION_KNOWN_FAILURES: &[(&str, &str)] = &[
(
"regression.script.writebin-empty.0001",
"writeBin([]).toASM() must be 'OP_0'; bsv-rs renders '0' (ts-sdk ASM parity bug)",
),
];
#[test]
fn conformance_scripts_regressions() {
let Some(dir) = corpus_dir() else { return };
let mut summary = Summary::default();
let prev_hook = silence_panics();
for file in ["script-lshift-truncation.json", "script-shift-endianness.json"] {
let json = load_json(&dir.join("vectors/regressions").join(file));
for vector in json["vectors"].as_array().expect("vectors") {
let id = s(vector, "id");
let input = &vector["input"];
let expected = &vector["expected"];
let shift_op = match s(input, "operation") {
"op_lshift" => op::OP_LSHIFT,
"op_rshift" => op::OP_RSHIFT,
other => panic!("{}: unknown shift op {}", id, other),
};
let value = from_hex(s(input, "value_hex"));
let want = from_hex(s(expected, "result_hex"));
assert_eq!(
want.len() as i64,
n(expected, "result_length_bytes"),
"{}: corpus self-consistency",
id
);
summary.run += 1;
let shift_bits = n(input, "shift_bits");
let result = catch(|| shift_via_interpreter(&value, shift_bits, shift_op, &want));
if result.is_none() {
summary.panics.push(id.to_string());
}
match result {
Some(true) => summary.passed += 1,
got => {
let detail = format!(
"{}: {} value={} shift={} expected {} — bsv-rs {}",
id,
s(input, "operation"),
s(input, "value_hex"),
shift_bits,
s(expected, "result_hex"),
if got.is_none() { "PANICKED (BigNumber::to_bytes_be overflow)" } else { "produced a different result" }
);
if REGRESSION_KNOWN_FAILURES.iter().any(|(k, _)| *k == id) {
summary.known_failures_hit.push(detail);
} else {
summary.failures.push(detail);
}
}
}
}
}
let json = load_json(&dir.join("vectors/regressions/script-writebin-empty.json"));
for vector in json["vectors"].as_array().expect("vectors") {
let id = s(vector, "id");
let input = &vector["input"];
let expected = &vector["expected"];
let data = from_hex(s(input, "data_hex"));
let mut script = Script::new();
script.write_bin(&data);
summary.run += 1;
let ok = match s(input, "operation") {
"script_writeBin_toASM" => script.to_asm() == s(expected, "asm"),
"script_writeBin_toHex" => script.to_hex() == s(expected, "hex"),
other => panic!("{}: unknown operation {}", id, other),
};
if ok {
summary.passed += 1;
} else {
let detail = format!(
"{}: writeBin asm='{}' hex='{}'",
id,
script.to_asm(),
script.to_hex()
);
if REGRESSION_KNOWN_FAILURES.iter().any(|(k, _)| *k == id) {
summary.known_failures_hit.push(detail);
} else {
summary.failures.push(detail);
}
}
}
let json = load_json(&dir.join("vectors/regressions/script-fromasm-numeric-token.json"));
for vector in json["vectors"].as_array().expect("vectors") {
let id = s(vector, "id");
assert_eq!(s(&vector["input"], "operation"), "fromASM_toHex", "{}", id);
let asm = s(&vector["input"], "asm");
summary.run += 1;
match Script::from_asm(asm) {
Ok(script) if script.to_hex() == s(&vector["expected"], "hex") => summary.passed += 1,
Ok(script) => summary.failures.push(format!(
"{}: fromASM('{}') expected {}, got {}",
id,
asm,
s(&vector["expected"], "hex"),
script.to_hex()
)),
Err(e) => summary
.failures
.push(format!("{}: fromASM('{}') failed: {}", id, asm, e)),
}
}
std::panic::set_hook(prev_hook);
summary.print("script regressions");
assert!(
summary.failures.is_empty(),
"script regression failures:\n{}",
summary.failures.join("\n")
);
assert_eq!(summary.run, 11, "regression vector count changed — review");
assert_eq!(
summary.known_failures_hit.len(),
REGRESSION_KNOWN_FAILURES.len(),
"KNOWN_FAILURES drift (fixed bug? new bug?): {:?}",
summary.known_failures_hit
);
assert_eq!(summary.passed + summary.known_failures_hit.len(), summary.run);
}