use assert_cmd::cargo::CommandCargoExt;
use bitcoin::base58;
use std::process::Command;
const V2_84_MAIN: &str = "xpub6BmeGmRo4LosAcU21HDaGcvtaQ7GrqQcY48nBkE22qM6KVwQUjRJ1BGzk84SFVHgLcd61Vcnhr8petHexjjn5WbQ9PriVrRhphw4oCp2z6a";
const V1_48_MULTISIG: &str = "xpub6Den8YwXbKQvkwukmx7Uukicw4qDgMEPuuUkhMp3Rn557YSN2uVQnCMQNSfgDtennU9nES3Wbbmz1LAPBydhNpED8NU4mf1SFF41hM7vFrc";
fn to_slip132(xpub_str: &str, version: [u8; 4]) -> String {
let mut data = base58::decode_check(xpub_str).unwrap();
data[0..4].copy_from_slice(&version);
base58::encode_check(&data)
}
const ZPUB_V: [u8; 4] = [0x04, 0xB2, 0x47, 0x46];
const NOTE_ZPUB: &str = "note: --xpub was a SLIP-0132 zpub";
const ZPUB_MULTISIG_V: [u8; 4] = [0x02, 0xAA, 0x7E, 0xD3];
const WATCH_ONLY: &str = "note: stdout is watch-only \u{2014} public keys only, cannot spend";
fn make_card() -> Vec<String> {
let out = Command::cargo_bin("mk")
.unwrap()
.args([
"encode",
"--xpub",
V2_84_MAIN,
"--origin-path",
"m/84h/0h/0h",
"--policy-id-stub",
"deadbeef",
"--privacy-preserving",
])
.output()
.unwrap();
assert!(
out.status.success(),
"make_card: mk encode failed: stderr={}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8(out.stdout)
.unwrap()
.lines()
.map(str::to_string)
.collect()
}
fn run_encode_decode(xpub_arg: &str) -> (std::process::Output, mk_codec::KeyCard) {
let out = Command::cargo_bin("mk")
.unwrap()
.args([
"encode",
"--xpub",
xpub_arg,
"--origin-path",
"m/84h/0h/0h",
"--policy-id-stub",
"deadbeef",
"--privacy-preserving",
"--group-size",
"0",
])
.output()
.unwrap();
assert!(
out.status.success(),
"mk encode failed: stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout.clone()).unwrap();
let strings: Vec<String> = stdout.lines().map(str::to_string).collect();
assert!(!strings.is_empty(), "no mk1 strings on stdout");
let refs: Vec<&str> = strings.iter().map(|s| s.as_str()).collect();
let card = mk_codec::decode(&refs).expect("decode emitted mk1 strings");
(out, card)
}
#[test]
fn encode_accepts_zpub_with_matching_path() {
let zpub = to_slip132(V2_84_MAIN, ZPUB_V);
let (zpub_out, zpub_card) = run_encode_decode(&zpub);
let stderr = String::from_utf8(zpub_out.stderr).unwrap();
assert!(
stderr.contains(NOTE_ZPUB),
"missing SLIP-0132 note; stderr={stderr}"
);
let (_, canon_card) = run_encode_decode(V2_84_MAIN);
assert_eq!(
zpub_card.xpub, canon_card.xpub,
"zpub-derived card xpub must equal canonical xpub-derived card xpub"
);
}
#[test]
fn encode_zpub_path_mismatch_refuses() {
let zpub = to_slip132(V2_84_MAIN, ZPUB_V);
let out = Command::cargo_bin("mk")
.unwrap()
.args([
"encode",
"--xpub",
&zpub,
"--origin-path",
"m/49h/0h/0h",
"--policy-id-stub",
"deadbeef",
"--privacy-preserving",
])
.output()
.unwrap();
let code = out.status.code().unwrap();
let stderr = String::from_utf8(out.stderr.clone()).unwrap();
assert_eq!(
code, 64,
"expected exit 64 (UsageError), got {code}; stderr={stderr}"
);
assert!(
stderr.contains("SLIP-0132/origin-path mismatch"),
"expected mismatch message in stderr; stderr={stderr}"
);
assert!(
stderr.contains("expects --origin-path purpose 84'"),
"expected 'expects --origin-path purpose 84'' in stderr; stderr={stderr}"
);
}
#[test]
fn encode_zpub_multisig_match() {
let zpub_multisig = to_slip132(V1_48_MULTISIG, ZPUB_MULTISIG_V);
let out = Command::cargo_bin("mk")
.unwrap()
.args([
"encode",
"--xpub",
&zpub_multisig,
"--origin-path",
"m/48h/0h/0h/2h",
"--policy-id-stub",
"deadbeef",
"--privacy-preserving",
])
.output()
.unwrap();
let code = out.status.code().unwrap();
let stderr = String::from_utf8(out.stderr.clone()).unwrap();
assert_eq!(
code, 0,
"expected exit 0 for matching Zpub multisig; stderr={stderr}"
);
assert!(
stderr.contains("note: --xpub was a SLIP-0132 Zpub"),
"expected Zpub normalization note; stderr={stderr}"
);
}
#[test]
fn encode_zpub_multisig_index_mismatch() {
let zpub_multisig = to_slip132(V1_48_MULTISIG, ZPUB_MULTISIG_V);
let out = Command::cargo_bin("mk")
.unwrap()
.args([
"encode",
"--xpub",
&zpub_multisig,
"--origin-path",
"m/48h/0h/0h/1h",
"--policy-id-stub",
"deadbeef",
"--privacy-preserving",
])
.output()
.unwrap();
let code = out.status.code().unwrap();
let stderr = String::from_utf8(out.stderr.clone()).unwrap();
assert_eq!(
code, 64,
"expected exit 64 for index mismatch; stderr={stderr}"
);
assert!(
stderr.contains("SLIP-0132/origin-path mismatch"),
"expected mismatch message; stderr={stderr}"
);
}
#[test]
fn encode_canonical_xpub_no_note() {
let out = Command::cargo_bin("mk")
.unwrap()
.args([
"encode",
"--xpub",
V2_84_MAIN,
"--origin-path",
"m/84h/0h/0h",
"--policy-id-stub",
"deadbeef",
"--privacy-preserving",
])
.output()
.unwrap();
let code = out.status.code().unwrap();
let stderr = String::from_utf8(out.stderr.clone()).unwrap();
assert_eq!(
code, 0,
"expected exit 0 for canonical xpub; stderr={stderr}"
);
assert!(
!stderr.contains("SLIP-0132"),
"canonical xpub must not emit a SLIP-0132 note; stderr={stderr}"
);
}
#[test]
fn verify_zpub_without_path_ok() {
let card = make_card();
let zpub = to_slip132(V2_84_MAIN, ZPUB_V);
let mut cmd = Command::cargo_bin("mk").unwrap();
cmd.arg("verify");
for chunk in &card {
cmd.arg(chunk);
}
cmd.args(["--xpub", &zpub]);
let out = cmd.output().unwrap();
let code = out.status.code().unwrap();
let stderr = String::from_utf8(out.stderr.clone()).unwrap();
assert_eq!(
code, 0,
"expected exit 0 (zpub without path); stderr={stderr}"
);
assert!(
stderr.contains(NOTE_ZPUB),
"expected SLIP-0132 note in stderr; stderr={stderr}"
);
assert!(
!stderr.contains("SLIP-0132/origin-path mismatch"),
"must NOT contain mismatch message; stderr={stderr}"
);
}
#[test]
fn verify_zpub_path_mismatch_refuses() {
let card = make_card();
let zpub = to_slip132(V2_84_MAIN, ZPUB_V);
let mut cmd = Command::cargo_bin("mk").unwrap();
cmd.arg("verify");
for chunk in &card {
cmd.arg(chunk);
}
cmd.args(["--xpub", &zpub, "--origin-path", "m/49h/0h/0h"]);
let out = cmd.output().unwrap();
let code = out.status.code().unwrap();
let stderr = String::from_utf8(out.stderr.clone()).unwrap();
assert_eq!(
code, 64,
"expected exit 64 (UsageError), got {code}; stderr={stderr}"
);
assert!(
stderr.contains("SLIP-0132/origin-path mismatch"),
"expected mismatch message in stderr; stderr={stderr}"
);
}
#[test]
fn encode_emits_both_slip132_note_and_watchonly_advisory() {
let zpub = to_slip132(V2_84_MAIN, ZPUB_V);
let out = Command::cargo_bin("mk")
.unwrap()
.args([
"encode",
"--xpub",
&zpub,
"--origin-path",
"m/84h/0h/0h",
"--policy-id-stub",
"deadbeef",
"--privacy-preserving",
])
.output()
.unwrap();
let code = out.status.code().unwrap();
let stderr = String::from_utf8(out.stderr.clone()).unwrap();
assert_eq!(code, 0, "expected exit 0; stderr={stderr}");
assert!(
stderr.contains(NOTE_ZPUB),
"missing SLIP-0132 note; stderr={stderr}"
);
assert!(
stderr.contains(WATCH_ONLY),
"missing watch-only advisory; stderr={stderr}"
);
let slip_offset = stderr.find(NOTE_ZPUB).unwrap();
let watch_offset = stderr.find(WATCH_ONLY).unwrap();
assert!(
slip_offset < watch_offset,
"SLIP-0132 note (offset {slip_offset}) must precede watch-only advisory (offset {watch_offset})"
);
}