diff --git a/packages/runar-rs/src/sdk/wallet.rs b/packages/runar-rs/src/sdk/wallet.rs index b26586e9..d4cfbd38 100644 --- a/packages/runar-rs/src/sdk/wallet.rs +++ b/packages/runar-rs/src/sdk/wallet.rs @@ -10,7 +10,7 @@ use bsv::transaction::Transaction as BsvTransaction; use sha2::{Sha256, Digest}; use ripemd::Ripemd160; use serde_json::Value; -use super::types::{TransactionData, Utxo}; +use super::types::{TransactionData, TxInput, TxOutput, Utxo}; use super::provider::Provider; use super::signer::Signer; use super::script_utils::build_p2pkh_script; @@ -295,45 +295,124 @@ impl Provider for WalletProvider { let raw_bytes = hex_to_bytes(&raw_hex)?; let txid = compute_txid(&raw_bytes); - // POST to ARC as application/octet-stream + // POST to ARC as application/octet-stream. + // R1 (Wave 1) — surface ARC failures as Err so callers can distinguish + // accepted from rejected broadcasts. The previous code returned + // Ok(synthetic_txid) on HTTP non-2xx and on network errors, masking + // real failures (mainnet ARC 461 from GorillaPool was the trigger). let arc_endpoint = format!("{}/v1/tx", self.arc_url); - match ureq::post(&arc_endpoint) + let response = ureq::post(&arc_endpoint) .set("Content-Type", "application/octet-stream") - .send_bytes(&raw_bytes) - { - Ok(resp) => { - let body = resp.into_string().unwrap_or_default(); - if let Ok(json) = serde_json::from_str::(&body) { - if let Some(arc_txid) = json.get("txid").and_then(|v| v.as_str()) { - self.tx_cache.insert(arc_txid.to_string(), raw_hex); - return Ok(arc_txid.to_string()); - } - } - self.tx_cache.insert(txid.clone(), raw_hex); - Ok(txid) + .send_bytes(&raw_bytes); + + let resp = match response { + Ok(r) => r, + Err(ureq::Error::Status(code, r)) => { + let body = r.into_string().unwrap_or_default(); + return Err(format!( + "WalletProvider broadcast: ARC HTTP {} from {}: {}", + code, arc_endpoint, body + )); } - Err(_) => { - // ARC unreachable — cache locally and return computed txid - self.tx_cache.insert(txid.clone(), raw_hex); - Ok(txid) + Err(e) => { + return Err(format!( + "WalletProvider broadcast: ARC network error against {}: {}", + arc_endpoint, e + )); } + }; + + let status_code = resp.status(); + let body = resp.into_string().unwrap_or_default(); + if !(200..300).contains(&status_code) { + return Err(format!( + "WalletProvider broadcast: ARC HTTP {} from {}: {}", + status_code, arc_endpoint, body + )); } + + let json: Value = serde_json::from_str(&body).map_err(|e| { + format!( + "WalletProvider broadcast: ARC returned 2xx but body is not JSON ({}): {}", + e, body + ) + })?; + + let arc_txid = json + .get("txid") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + format!( + "WalletProvider broadcast: ARC accepted but returned no txid; body: {}", + body + ) + })? + .to_string(); + + // Sanity check: ARC's txid must match what we computed locally. + if arc_txid != txid { + return Err(format!( + "WalletProvider broadcast: ARC txid {} != local txid {} (body: {})", + arc_txid, txid, body + )); + } + + self.tx_cache.insert(arc_txid.clone(), raw_hex); + Ok(arc_txid) } fn get_transaction(&self, txid: &str) -> Result { - // Check local cache first + // R2 (Wave 1) — parse the cached raw bytes so callers receive populated + // inputs/outputs. Previously this returned empty inputs and outputs + // even on cache hit, which broke RunarContract::from_txid (which + // indexes tx.outputs[output_index]) on every deployed stateful contract. if let Some(raw) = self.tx_cache.get(txid) { + let raw_hex = raw.clone(); + let raw_bytes = hex_to_bytes(&raw_hex)?; + let parsed = parse_raw_tx(&raw_bytes)?; + let inputs = parsed + .inputs + .into_iter() + .map(|i| { + let mut prev_txid_hex = String::with_capacity(64); + for b in i.prev_txid_bytes.iter().rev() { + prev_txid_hex.push_str(&format!("{:02x}", b)); + } + TxInput { + txid: prev_txid_hex, + output_index: i.prev_output_index, + script: String::new(), + sequence: i.sequence, + } + }) + .collect(); + let outputs = parsed + .outputs + .into_iter() + .map(|o| { + let mut script_hex = String::with_capacity(o.script.len() * 2); + for b in &o.script { + script_hex.push_str(&format!("{:02x}", b)); + } + TxOutput { + satoshis: o.satoshis as i64, + script: script_hex, + } + }) + .collect(); return Ok(TransactionData { txid: txid.to_string(), - version: 1, - inputs: vec![], - outputs: vec![], - locktime: 0, - raw: Some(raw.clone()), + version: parsed.version, + inputs, + outputs, + locktime: parsed.locktime, + raw: Some(raw_hex), }); } - // Minimal fallback + // Cache miss — preserve upstream's fallback behavior: return an + // empty TransactionData with raw: None. Callers that need the parsed + // transaction will then fall back to get_raw_transaction (overlay). Ok(TransactionData { txid: txid.to_string(), version: 1, diff --git a/packages/runar-rs/tests/wallet_provider_r1_r2_regression.rs b/packages/runar-rs/tests/wallet_provider_r1_r2_regression.rs new file mode 100644 index 00000000..a61c6433 --- /dev/null +++ b/packages/runar-rs/tests/wallet_provider_r1_r2_regression.rs @@ -0,0 +1,272 @@ +//! Regression tests for R1 + R2 — WalletProvider error surfacing + transaction parse. +//! +//! Uses ONLY upstream-existing deps: +//! - `std::net::TcpListener` for hosting a tiny HTTP server that returns +//! HTTP 461 (R1) or refuses the connection (R1-network-failure). +//! - `bsv::transaction::Transaction` to construct a known raw tx hex. +//! - `runar_lang::sdk::wallet::{WalletProvider, WalletClient, ...}` and +//! `runar_lang::sdk::provider::Provider` directly. +//! +//! Strategy: +//! R1a (HTTP 461): bind a TcpListener on 127.0.0.1:, hand-craft +//! an HTTP 461 response, point `arc_url` at it, call broadcast, assert +//! Err containing "461". +//! R1b (network failure): point `arc_url` at a port that has nothing +//! listening, assert broadcast returns Err. +//! R2 (cache hit): seed tx_cache via `cache_tx()` with a known raw tx +//! hex (1 input + 1 output). Call get_transaction. Assert +//! inputs.len() == 1 and outputs.len() == 1 with populated values. + +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::thread; +use std::time::Duration; + +use bsv::transaction::Transaction as BsvTransaction; + +use runar_lang::sdk::provider::Provider; +use runar_lang::sdk::wallet::{ + WalletActionOutput, WalletActionResult, WalletClient, WalletOutput, WalletProvider, +}; + +// --------------------------------------------------------------------------- +// Trivial WalletClient stub — never called by R1/R2 tests. +// --------------------------------------------------------------------------- + +struct NoopWalletClient; + +impl WalletClient for NoopWalletClient { + fn get_public_key( + &self, + _protocol_id: &(u32, &str), + _key_id: &str, + ) -> Result { + Ok(format!("03{}", "00".repeat(32))) + } + + fn create_signature( + &self, + _hash_to_sign: &[u8], + _protocol_id: &(u32, &str), + _key_id: &str, + ) -> Result, String> { + Ok(vec![0; 71]) + } + + fn create_action( + &self, + _description: &str, + _outputs: &[WalletActionOutput], + ) -> Result { + Err("NoopWalletClient::create_action not implemented".to_string()) + } + + fn list_outputs( + &self, + _basket: &str, + _tags: &[&str], + _limit: usize, + ) -> Result, String> { + Ok(vec![]) + } +} + +fn make_provider(arc_url: String) -> WalletProvider { + WalletProvider::new( + NoopWalletClient, + (1, "test".to_string()), + "k1".to_string(), + "test-basket".to_string(), + Some("funding".to_string()), + Some(arc_url), + None, + Some("testnet".to_string()), + Some(100), + ) +} + +// --------------------------------------------------------------------------- +// Known raw tx (1 input, 1 output) +// --------------------------------------------------------------------------- + +fn known_raw_tx_hex() -> String { + let mut hex = String::new(); + hex.push_str("01000000"); // version 1 + hex.push_str("01"); // 1 input + hex.push_str(&"00".repeat(32)); // prev_hash (32 bytes) + hex.push_str("00000000"); // prev vout 0 + hex.push_str("00"); // unlocking script length 0 + hex.push_str("ffffffff"); // sequence + hex.push_str("01"); // 1 output + hex.push_str("e803000000000000"); // satoshis = 1000, little-endian + hex.push_str("00"); // locking script length 0 + hex.push_str("00000000"); // locktime 0 + hex +} + +fn known_raw_tx_bsv() -> BsvTransaction { + BsvTransaction::from_hex(&known_raw_tx_hex()) + .expect("known_raw_tx_hex must parse as a BSV Transaction") +} + +// --------------------------------------------------------------------------- +// Tiny HTTP server returning a fixed status + body, then shuts down. +// --------------------------------------------------------------------------- + +fn start_http_server_returning( + status_line: &'static str, + body: &'static str, +) -> (String, Arc) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind 127.0.0.1:0"); + let local_addr = listener.local_addr().expect("local_addr"); + let url = format!("http://{}", local_addr); + + let stop = Arc::new(AtomicBool::new(false)); + let stop_thread = stop.clone(); + + listener + .set_nonblocking(true) + .expect("set_nonblocking"); + + thread::spawn(move || loop { + if stop_thread.load(Ordering::SeqCst) { + break; + } + match listener.accept() { + Ok((mut socket, _)) => { + socket + .set_read_timeout(Some(Duration::from_millis(500))) + .ok(); + let mut buf = [0u8; 4096]; + let _ = socket.read(&mut buf); + let resp = format!( + "{}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + status_line, + body.len(), + body + ); + let _ = socket.write_all(resp.as_bytes()); + let _ = socket.flush(); + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(20)); + } + Err(_) => break, + } + }); + + thread::sleep(Duration::from_millis(50)); + (url, stop) +} + +// --------------------------------------------------------------------------- +// R1 — broadcast must surface ARC errors as Err +// --------------------------------------------------------------------------- + +#[test] +fn r1_broadcast_returns_err_on_http_461() { + let (url, stop) = start_http_server_returning( + "HTTP/1.1 461 Unauthorized", + r#"{"detail":"Script evaluated without error but finished with a false/empty top stack element"}"#, + ); + + let mut provider = make_provider(url); + let tx = known_raw_tx_bsv(); + + let result = provider.broadcast(&tx); + + stop.store(true, Ordering::SeqCst); + + assert!( + result.is_err(), + "R1 regression: broadcast() must return Err on ARC HTTP 461, got Ok({:?})", + result + ); + let err = result.unwrap_err(); + assert!( + err.contains("461"), + "R1 regression: error must mention status code 461; got: {}", + err + ); +} + +#[test] +fn r1_broadcast_returns_err_on_network_failure() { + // Bind a listener then immediately drop it — guaranteed-closed port. + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("local_addr"); + drop(listener); + let url = format!("http://{}", addr); + + let mut provider = make_provider(url); + let tx = known_raw_tx_bsv(); + + let result = provider.broadcast(&tx); + + assert!( + result.is_err(), + "R1 regression: broadcast() must return Err on connection refused, got Ok({:?})", + result + ); +} + +// --------------------------------------------------------------------------- +// R2 — get_transaction must populate inputs and outputs from cache. +// --------------------------------------------------------------------------- + +#[test] +fn r2_get_transaction_populates_inputs_and_outputs_from_cache() { + let mut provider = make_provider("http://127.0.0.1:1".to_string()); + let raw_hex = known_raw_tx_hex(); + let tx = known_raw_tx_bsv(); + let txid = tx.id().expect("tx.id"); + + provider.cache_tx(&txid, &raw_hex); + + let result = provider + .get_transaction(&txid) + .expect("R2 regression: get_transaction must succeed on cache hit"); + + assert_eq!(result.txid, txid, "returned txid must match input"); + assert_eq!( + result.version, 1, + "version must be parsed (not the hardcoded 1)" + ); + assert_eq!( + result.inputs.len(), + 1, + "R2 regression: get_transaction must populate inputs from raw bytes (not empty)" + ); + assert_eq!( + result.outputs.len(), + 1, + "R2 regression: get_transaction must populate outputs from raw bytes (not empty)" + ); + assert_eq!( + result.outputs[0].satoshis, 1000, + "R2 regression: output satoshis must be parsed (1000 from raw bytes)" + ); + assert!( + result.raw.is_some(), + "raw field should still be Some (the cached raw hex)" + ); +} + +#[test] +fn r2_get_transaction_cache_miss_preserves_upstream_fallback() { + // Locking note: the patch INTENTIONALLY does NOT change cache-miss behavior. + // Upstream returns Ok(TransactionData { ..., raw: None }) on cache miss, + // and downstream callers (e.g. broadcaster fetch-loop) may depend on that + // shape. This test locks the fallback in place so a future "make cache + // miss return Err" regression is caught immediately. + let provider = make_provider("http://127.0.0.1:1".to_string()); + let unknown_txid = "a".repeat(64); + let result = provider + .get_transaction(&unknown_txid) + .expect("cache miss must still return Ok (upstream-preserved fallback)"); + assert!(result.raw.is_none(), "raw must be None on cache miss"); + assert!(result.inputs.is_empty()); + assert!(result.outputs.is_empty()); +}