#![cfg(feature = "native")]
use std::process::{Command, Stdio};
use std::time::Duration;
use sha2::{Digest, Sha256};
use webylib::wallet_rgb::RgbWallet;
use webylib::wallet_voucher::VoucherWallet;
use webylib::wallet_webcash::WebcashWallet;
const PORT_WEBCASH: u16 = 8181;
const PORT_RGB_FUNGIBLE: u16 = 8182;
const PORT_VOUCHER: u16 = 8183;
const PORT_RGB_COLLECTIBLE: u16 = 8184;
fn docker_available() -> bool {
Command::new("docker")
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn server_reachable(port: u16) -> bool {
std::net::TcpStream::connect_timeout(
&format!("127.0.0.1:{port}").parse().unwrap(),
Duration::from_millis(500),
)
.is_ok()
}
fn ensure_compose() -> bool {
docker_available()
&& server_reachable(PORT_WEBCASH)
&& server_reachable(PORT_RGB_FUNGIBLE)
&& server_reachable(PORT_VOUCHER)
&& server_reachable(PORT_RGB_COLLECTIBLE)
}
fn webyc_path() -> std::path::PathBuf {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("target")
.join("debug")
.join("webyca")
}
fn sha256_hex(s: &str) -> String {
hex::encode(Sha256::digest(s.as_bytes()))
}
fn unique_secret(prefix: u8) -> String {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let mut s = format!("{prefix:02x}{nanos:032x}");
s.truncate(64);
while s.len() < 64 {
s.push('0');
}
s
}
fn find_pow(template: &str, bits: u32) -> String {
for nonce in 0..200_000u64 {
let p = template.replace("__N__", &nonce.to_string());
let lz = leading_zero_bits(&Sha256::digest(p.as_bytes()));
if lz >= bits {
return p;
}
}
panic!("could not satisfy difficulty {bits}");
}
fn leading_zero_bits(hash: &[u8]) -> u32 {
let zeros = hash.iter().take_while(|&&b| b == 0).count() as u32;
hash.get(zeros as usize).map_or(0, |b| b.leading_zeros()) + zeros * 8
}
fn run_webyca(args: &[&str]) -> (bool, String, String) {
let webyc = webyc_path();
let out = Command::new(&webyc)
.args(args)
.output()
.expect("spawn webyca");
(
out.status.success(),
String::from_utf8_lossy(&out.stdout).to_string(),
String::from_utf8_lossy(&out.stderr).to_string(),
)
}
#[test]
fn rgb_collectible_read_only_via_webyca() {
if !ensure_compose() || !webyc_path().exists() {
return;
}
let url = format!("http://127.0.0.1:{PORT_RGB_COLLECTIBLE}");
let (ok, stdout, stderr) = run_webyca(&["--server", &url, "target"]);
assert!(ok, "target failed:\nstdout: {stdout}\nstderr: {stderr}");
assert!(stdout.contains("difficulty_target_bits"));
let issuer = "aabbccddeeff00112233445566778899aabbccdd";
let contract = "rgb21-art-e2e";
let novel_hash = sha256_hex(&unique_secret(0x91));
let token = format!("public:{novel_hash}:{contract}:{issuer}");
let (ok, stdout, _) = run_webyca(&["--server", &url, "check", "--tokens", &token]);
assert!(ok);
assert!(
stdout.contains(r#""spent": null"#),
"expected null for novel collectible: {stdout}"
);
}
#[test]
fn rgb20_burn_via_webyca() {
if !ensure_compose() || !webyc_path().exists() {
return;
}
let url = format!("http://127.0.0.1:{PORT_RGB_FUNGIBLE}");
let wallet = RgbWallet::new(url.clone());
let issuer = "aabbccddeeff00112233445566778899aabbccdd";
let contract = "rgb20-burn-e2e";
let secret = unique_secret(0xa1);
let template = format!(
r#"{{"webcash":["e10.0:secret:{secret}:{contract}:{issuer}"],"subsidy":[],"timestamp":1714003200,"difficulty":4,"nonce":__N__}}"#
);
let preimage = find_pow(&template, 4);
wallet.server().mining_report(&preimage).expect("mine");
let (ok, _, stderr) = run_webyca(&[
"--server",
&url,
"burn",
"--secret",
&format!("e10.0:secret:{secret}:{contract}:{issuer}"),
]);
assert!(ok, "rgb20 burn failed: {stderr}");
let h = sha256_hex(&secret);
let body = wallet
.server()
.health_check(&[format!("e10.0:public:{h}:{contract}:{issuer}")])
.expect("hc");
assert!(
body.contains(r#""spent": true"#),
"rgb20 burn didn't mark spent: {body}"
);
}
#[test]
fn voucher_burn_via_webyca() {
if !ensure_compose() || !webyc_path().exists() {
return;
}
let url = format!("http://127.0.0.1:{PORT_VOUCHER}");
let wallet = VoucherWallet::new(url.clone());
let issuer = "aabbccddeeff00112233445566778899aabbccdd";
let contract = "credits-burn-e2e";
let secret = unique_secret(0xb1);
let template = format!(
r#"{{"webcash":["e25.0:secret:{secret}:{contract}:{issuer}"],"subsidy":[],"timestamp":1714003200,"difficulty":4,"nonce":__N__}}"#
);
let preimage = find_pow(&template, 4);
wallet.server().mining_report(&preimage).expect("mine");
let (ok, _, stderr) = run_webyca(&[
"--server",
&url,
"burn",
"--secret",
&format!("e25.0:secret:{secret}:{contract}:{issuer}"),
]);
assert!(ok, "voucher burn failed: {stderr}");
let h = sha256_hex(&secret);
let body = wallet
.server()
.health_check(&[format!("e25.0:public:{h}:{contract}:{issuer}")])
.expect("hc");
assert!(
body.contains(r#""spent": true"#),
"voucher burn didn't mark spent: {body}"
);
}
#[test]
fn rgb_cross_namespace_replace_rejected_via_webyca() {
if !ensure_compose() || !webyc_path().exists() {
return;
}
let url = format!("http://127.0.0.1:{PORT_RGB_FUNGIBLE}");
let issuer = "aabbccddeeff00112233445566778899aabbccdd";
let s_a = unique_secret(0xc1);
let s_b = unique_secret(0xc2);
let (ok, _, stderr) = run_webyca(&[
"--server",
&url,
"rgb",
"transfer",
"--inputs",
&format!("e1.0:secret:{s_a}:contract-A:{issuer}"),
"--outputs",
&format!("e1.0:secret:{s_b}:contract-B:{issuer}"),
]);
assert!(!ok, "cross-namespace must reject; got success");
assert!(
stderr.contains("HTTP error: 500") || stderr.contains("Error"),
"expected error in stderr, got: {stderr}"
);
}
#[test]
fn derive_public_and_verify_match_real_server_state() {
if !ensure_compose() || !webyc_path().exists() {
return;
}
let url = format!("http://127.0.0.1:{PORT_WEBCASH}");
let wallet = WebcashWallet::new(url.clone());
let secret = unique_secret(0xd1);
let subsidy = unique_secret(0xd2);
let template = format!(
r#"{{"webcash":["e1.0:secret:{secret}"],"subsidy":["e0.5:secret:{subsidy}"],"timestamp":1714003200,"difficulty":4,"nonce":__N__}}"#
);
let preimage = find_pow(&template, 4);
wallet.server().mining_report(&preimage).expect("mine");
let (ok, stdout_a, stderr) = run_webyca(&[
"derive-public",
"--secret",
&format!("e1.0:secret:{secret}"),
]);
assert!(ok, "derive-public failed: {stderr}");
let derived = stdout_a.trim();
let expected = format!("e1.0:public:{}", sha256_hex(&secret));
assert_eq!(derived, expected, "derived != sha256(secret)");
let (ok, _, _) = run_webyca(&[
"verify",
"--secret",
&format!("e1.0:secret:{secret}"),
"--public",
derived,
]);
assert!(ok, "verify should match");
let webyc = webyc_path();
let out = Command::new(&webyc)
.args([
"verify",
"--secret",
&format!("e1.0:secret:{secret}"),
"--public",
"e1.0:public:wrong",
])
.output()
.expect("spawn");
assert_eq!(
out.status.code(),
Some(2),
"verify mismatch should exit 2, got {:?}",
out.status.code()
);
let body = wallet
.server()
.health_check(&[derived.to_string()])
.expect("hc");
assert!(
body.contains(r#""spent": false"#),
"server doesn't see derived public as unspent: {body}"
);
}
#[test]
fn stats_total_circulation_grows_after_mining() {
if !ensure_compose() || !webyc_path().exists() {
return;
}
let url = format!("http://127.0.0.1:{PORT_WEBCASH}");
let wallet = WebcashWallet::new(url.clone());
let (ok, before_stdout, _) = run_webyca(&["--server", &url, "stats"]);
assert!(ok);
let before_count = extract_mining_reports_count(&before_stdout);
let secret = unique_secret(0xe1);
let subsidy = unique_secret(0xe2);
let template = format!(
r#"{{"webcash":["e1.0:secret:{secret}"],"subsidy":["e0.5:secret:{subsidy}"],"timestamp":1714003200,"difficulty":4,"nonce":__N__}}"#
);
let preimage = find_pow(&template, 4);
wallet.server().mining_report(&preimage).expect("mine");
let (ok, after_stdout, _) = run_webyca(&["--server", &url, "stats"]);
assert!(ok);
let after_count = extract_mining_reports_count(&after_stdout);
assert!(
after_count > before_count,
"mining_reports_count didn't grow: before={before_count}, after={after_count}",
);
}
fn extract_mining_reports_count(stats_json: &str) -> i64 {
serde_json::from_str::<serde_json::Value>(stats_json)
.ok()
.and_then(|v| v.get("mining_reports_count").and_then(|n| n.as_i64()))
.unwrap_or(0)
}