diff --git a/AGENTS.md b/AGENTS.md index 83afb99..38fefac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,7 +52,8 @@ sochain chart BTC tx_count --interval day --json ## API key -Lookups work without a key: 2,500 a day per IP address. With a key, the +Lookups work without a key: at least 2,500 a day per IP address, more when +there's room. With a key, the account's plan applies. The CLI reads `SOCHAIN_API_KEY`, or a key saved by `sochain auth login`; `sochain auth status` shows which is used. Never print the key or pass it on the command line. diff --git a/README.md b/README.md index 12490d1..cc9d4fd 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ $ sochain balance BTC bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh Balance 3.92247777 BTC $ sochain fees BTC -Suggested fee rate 0.25 sat/vB (the next block's median). +Suggested fee rate 1 sat/vB (the next block's median, rounded up). ... $ sochain transaction LTC @@ -106,7 +106,8 @@ instead of US dollars. Run `sochain --help` for details and examples. ## API key -Lookups work without a key, up to 2,500 a day per IP address. For more, get a +Lookups work without a key: at least 2,500 a day per IP address, and up to +25,000 when there's room. For more, get a key at [sochain.com/signin](https://sochain.com/signin) (plans and limits: [sochain.com/pricing](https://sochain.com/pricing)), then either: @@ -133,7 +134,9 @@ sent only to sochain.com, and never printed. `sochain schema` describes every command as JSON. - `sochain skill install` adds the SoChain skill to Claude Code and Codex. - `sochain mcp install` adds the hosted MCP server, `https://sochain.com/mcp`, - to Codex and Claude Code (`--use-api-key` to use your key). By hand: + to Codex and Claude Code. With `--use-api-key` it uses your key: Codex reads + it from `SOCHAIN_API_KEY`, and Claude Code saves it in its own config (or, with + `--project`, reads `SOCHAIN_API_KEY`). By hand: ```sh codex mcp add sochain --url https://sochain.com/mcp --bearer-token-env-var SOCHAIN_API_KEY diff --git a/src/api.rs b/src/api.rs index a670b10..8730f23 100644 --- a/src/api.rs +++ b/src/api.rs @@ -153,11 +153,19 @@ impl Api { let mut attempt = 0; let resp = loop { let resp = self.send(url, accept, true)?; - if resp.status() != StatusCode::TOO_MANY_REQUESTS { + // A 503 that says when to come back (SoChain is briefly busy) is + // waited out like a rate limit; one without Retry-After is not. + let busy = resp.status() == StatusCode::SERVICE_UNAVAILABLE; + if resp.status() != StatusCode::TOO_MANY_REQUESTS + && !(busy && retry_after(&resp).is_some()) + { break resp; } let wait = retry_after(&resp).unwrap_or(1); attempt += 1; + if busy && (attempt > RATE_LIMIT_RETRIES || wait > MAX_RETRY_WAIT_SECS) { + break resp; + } if attempt > RATE_LIMIT_RETRIES || wait > MAX_RETRY_WAIT_SECS { let message = resp .json::() @@ -174,7 +182,12 @@ impl Api { }); } self.note(&format!( - "too many requests at once; retrying in {}", + "{}; retrying in {}", + if busy { + "SoChain is busy" + } else { + "too many requests at once" + }, seconds(wait) )); sleep(Duration::from_secs(wait)); diff --git a/src/auth.rs b/src/auth.rs index 538009b..74b132e 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -231,7 +231,7 @@ pub fn status( } else { writeln!( out, - "No API key. Lookups still work, up to 2,500 a day per IP address.\n\ + "No API key. Lookups still work: at least 2,500 a day per IP address, more when there's room.\n\ Get a key at https://sochain.com/signin, then run `sochain auth login` \ or set {ENV_KEY}." )?; diff --git a/src/cli.rs b/src/cli.rs index 0cd9db5..f0a3e30 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -17,7 +17,7 @@ use crate::network::Network; LTCTEST, DOGETEST, ZECTEST), in any case.\n\n\ Output is plain English by default. Pass --json (or set SOCHAIN_JSON=1) for the \ API's JSON reply, unchanged; errors are then JSON on stderr.\n\n\ - Lookups work without an API key (2,500 a day per IP address). With a key, your \ + Lookups work without an API key (at least 2,500 a day per IP address). With a key, your \ plan's limits apply: run `sochain auth login`, or set SOCHAIN_API_KEY.\n\n\ Exit codes: 0 ok, 1 other error, 2 bad input, 3 not found, 4 SoChain unavailable, \ 5 rate limited, 6 API key missing, invalid or not allowed.", @@ -533,9 +533,10 @@ pub enum McpCommand { /// Add https://sochain.com/mcp to Codex and Claude Code #[command( after_help = "Uses each agent's own command (`codex mcp add`, `claude mcp add`); nothing\n\ - runs locally. Without --use-api-key the server is used without a key (2,500\n\ - lookups a day per IP address). With it, the agent reads your key from\n\ - SOCHAIN_API_KEY when it starts, so the key itself is not written to its config.\n\ + runs locally. Without --use-api-key the server is used without a key (at\n\ + least 2,500 lookups a day per IP address). With it, Codex reads your key from\n\ + SOCHAIN_API_KEY when it starts, and so does Claude Code with --project; for\n\ + Claude Code's user scope the key is saved in Claude Code's own config.\n\ For Cursor and other apps, --print shows what to add.\n\n\ Examples:\n sochain mcp install\n sochain mcp install --use-api-key\n sochain mcp install --print" )] diff --git a/src/commands.rs b/src/commands.rs index c51bb43..cc53e6f 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -54,6 +54,7 @@ pub fn run(cli: Cli, out: &mut dyn Write) -> Result<()> { height, time, } => { + let historic = height.is_some() || time.is_some(); let mut query = vec![]; if let Some(h) = height { query.push(("height", h.to_string())); @@ -63,7 +64,7 @@ pub fn run(cli: Cli, out: &mut dyn Write) -> Result<()> { } let env = lookup(&["balance", network.code(), &address], &query)?; emit(out, json, &env, |out, d| { - show_balance(out, network, &address, d) + show_balance(out, network, &address, historic, d) }) } Command::AddressSummary { network, address } => { @@ -120,10 +121,12 @@ pub fn run(cli: Cli, out: &mut dyn Write) -> Result<()> { }) } Command::Transaction { network, hash } => { + let hash = lower_hex(hash); let env = lookup(&["transaction", network.code(), &hash], &cur())?; emit(out, json, &env, |out, d| show_transaction(out, network, d)) } Command::TransactionStatus { network, hash } => { + let hash = lower_hex(hash); let env = lookup(&["transaction_status", network.code(), &hash], &[])?; emit(out, json, &env, |out, d| show_status(out, &hash, d)) } @@ -133,7 +136,7 @@ pub fn run(cli: Cli, out: &mut dyn Write) -> Result<()> { let env = api.get(&["best_block_hash", network.code()], &[])?; data::(&env)?.hash } else { - id + lower_hex(id) }; let env = api.get(&["block", network.code(), &id], &cur())?; emit(out, json, &env, |out, d| show_block(out, network, d)) @@ -168,11 +171,16 @@ pub fn run(cli: Cli, out: &mut dyn Write) -> Result<()> { Command::Price { network, time } => { let mut segments = vec!["price".to_owned(), network.code().to_owned()]; if let Some(t) = &time { - let ts = parse_unix_or_date(t).ok_or_else(|| { - CliError::BadInput(format!( - "invalid --time {t:?}: expected unix seconds or a date (YYYY-MM-DD)" - )) - })?; + // SoChain reads only 7 to 10 digits as a time, and answers + // anything else with the latest price. + let digits = t.bytes().all(|b| b.is_ascii_digit()); + let ts = parse_unix_or_date(t) + .filter(|_| !digits || (7..=10).contains(&t.len())) + .ok_or_else(|| { + CliError::BadInput(format!( + "invalid --time {t:?}: expected unix seconds (7 to 10 digits) or a date (YYYY-MM-DD)" + )) + })?; segments.push(ts.to_string()); } let segments: Vec<&str> = segments.iter().map(String::as_str).collect(); @@ -186,6 +194,12 @@ pub fn run(cli: Cli, out: &mut dyn Write) -> Result<()> { page, page_size, } => { + let pages = 1000 / page_size; + if page > pages { + return Err(CliError::BadInput(format!( + "--page is at most {pages} with {page_size} holders per page" + ))); + } let env = lookup( &["rich_list", network.code()], &[ @@ -251,7 +265,8 @@ pub fn run(cli: Cli, out: &mut dyn Write) -> Result<()> { to, last, } => { - let mut query = cur(); + // Only price_usd takes a currency; other series refuse it. + let mut query = if series == "price_usd" { cur() } else { vec![] }; if let Some(f) = from { query.push(("from", f.to_string())); } @@ -394,15 +409,30 @@ fn print_json(out: &mut dyn Write, v: &Value) -> Result<()> { Ok(()) } -fn show_balance(out: &mut dyn Write, n: Network, address: &str, b: Balance) -> Result<()> { - if let Some(height) = b.height { - let when = b.time.map(|t| format!(", {}", utc(t))).unwrap_or_default(); - writeln!( - out, - "{address} held {} {n} at block {}{when}", - b.confirmed, - group_thousands(height.into()) - )?; +fn show_balance( + out: &mut dyn Write, + n: Network, + address: &str, + historic: bool, + b: Balance, +) -> Result<()> { + if historic { + match b.height { + Some(height) => { + let when = b.time.map(|t| format!(", {}", utc(t))).unwrap_or_default(); + writeln!( + out, + "{address} held {} {n} at block {}{when}", + b.confirmed, + group_thousands(height.into()) + )?; + } + None => writeln!( + out, + "{address} held nothing then: that is before {}'s first block", + n.name() + )?, + } return Ok(()); } writeln!(out, "Balance {} {n}", b.confirmed)?; @@ -458,6 +488,15 @@ fn transactions( let mut query: Vec<(&str, String)> = currency .map(|c| vec![("currency", c.to_owned())]) .unwrap_or_default(); + // Flags the next page needs again: the cursor carries the filters, not + // the page size or currency. + let mut again = String::new(); + if let Some(size) = f.page_size { + again += &format!(" --page-size {size}"); + } + if let Some(c) = currency { + again += &format!(" --currency {c}"); + } if !f.filtered() { let page = f.page.unwrap_or(1); let env = api.get( @@ -471,7 +510,7 @@ fn transactions( } else if d.transactions.len() >= PAGE_SIZE { writeln!( out, - "More: sochain transactions {n} {address} --page {}", + "More: sochain transactions {n} {address} --page {}{again}", page + 1 )?; } @@ -502,7 +541,7 @@ fn transactions( if let Some(cursor) = next { // stderr, so the CSV on stdout stays clean. eprintln!( - "sochain: more rows: sochain transactions {n} {address} --csv --cursor {}", + "sochain: more rows: sochain transactions {n} {address} --csv --cursor {}{again}", crate::sanitize::clean(cursor) ); } @@ -528,7 +567,7 @@ fn transactions( if let Some(cursor) = &d.next_cursor { writeln!( out, - "More: sochain transactions {n} {address} --cursor {cursor}" + "More: sochain transactions {n} {address} --cursor {cursor}{again}" )?; } Ok(()) @@ -607,7 +646,13 @@ fn show_transaction(out: &mut dyn Write, n: Network, tx: Transaction) -> Result< writeln!(out, " (no transparent inputs)")?; } for i in &tx.inputs { - let who = i.address.as_deref().unwrap_or("newly mined coins"); + let who = match (i.address.as_deref(), i.mweb.as_deref(), &i.previous_output) { + (Some(a), _, _) => a, + (None, Some(_), _) => "MWEB pool", + (None, None, None) => "newly mined coins", + // Pay-to-public-key, bare multisig and other scripts without one. + (None, None, Some(_)) => "(no address)", + }; match i.value { Some(v) => writeln!(out, " {who} {v} {n}")?, None => writeln!(out, " {who}")?, @@ -618,10 +663,12 @@ fn show_transaction(out: &mut dyn Write, n: Network, tx: Transaction) -> Result< writeln!(out, " (no transparent outputs)")?; } for o in &tx.outputs { - let who = match (o.address.as_deref(), o.kind.as_deref()) { - (_, Some("nulldata")) | (Some("op_return"), _) => "data (OP_RETURN)", - (Some(a), _) => a, - (None, _) => "(no address)", + let who = match (o.address.as_deref(), o.kind.as_deref(), o.mweb.as_deref()) { + (_, Some("nulldata"), _) | (Some("op_return"), _, _) => "data (OP_RETURN)", + (Some(a), _, _) => a, + (None, _, Some("pegin")) => "into MWEB (peg-in)", + (None, _, Some(_)) => "MWEB pool", + (None, _, None) => "(no address)", }; writeln!(out, " {who} {} {n}", o.value)?; } @@ -723,25 +770,37 @@ fn show_block(out: &mut dyn Write, n: Network, b: Block) -> Result<()> { plural(b.num_txs, "transaction"), group_thousands(b.size.into()) )?; - if b.is_orphan { + let orphaned = b.is_orphan || b.confirmations < 0; + if orphaned { writeln!(out, "Orphaned: this block is not part of the main chain.")?; } writeln!(out, "Mined {} ({})", utc(b.time), ago(b.time, t))?; + // On Litecoin value_out includes the MWEB pool carried over from the + // previous block, which did not move. + let moved = match &b.mweb { + Some(m) => Amount(b.value_out.0 - m.pool.0), + None => b.value_out, + }; writeln!( out, "Reward {} {n} plus {} {n} in fees ยท {} {n} moved", b.reward, b.fees, - b.value_out.display(2) + moved.display(2) )?; + if let Some(m) = &b.mweb { + writeln!(out, "MWEB pool {} {n}", m.pool.display(2))?; + } if let Some(p) = b.price.as_ref().filter(|p| !p.value.is_zero()) { writeln!(out, "Price then 1 {n} = {}", format_price(p))?; } - writeln!( - out, - "Confirmations {}", - group_thousands(b.confirmations.into()) - )?; + if !orphaned { + writeln!( + out, + "Confirmations {}", + group_thousands(b.confirmations.into()) + )?; + } writeln!(out, "Difficulty {}", format_difficulty(&b.difficulty))?; writeln!(out, "Hash {}", b.hash)?; if let Some(prev) = &b.previous_block_hash { @@ -807,7 +866,9 @@ fn show_network_info(out: &mut dyn Write, n: Network, i: NetworkInfo) -> Result< /// `fees --json`: the fee part of network_info, in the same envelope. fn fees_json(n: Network, d: &Value) -> Value { let blocks = d["mempool"]["blocks"].clone(); - let suggested = blocks[0]["median_fee_rate_precise"].clone(); + // The next block's median rounded up to a whole unit, as SoChain's docs + // recommend: the precise median can be below what nodes relay. + let suggested = blocks[0]["median_fee_rate"].clone(); json!({ "network": n.code(), "unit": n.fee_rate_unit(), @@ -831,11 +892,8 @@ fn show_fees(out: &mut dyn Write, n: Network, i: &NetworkInfo) -> Result<()> { } else { writeln!( out, - "Suggested fee rate {} {unit} (the next block's median).", - rate( - next.median_fee_rate_precise.as_deref(), - next.median_fee_rate - ) + "Suggested fee rate {} {unit} (the next block's median, rounded up).", + format_number(next.median_fee_rate) )?; } } @@ -969,7 +1027,11 @@ fn show_balance_history( } else { String::new() }; - writeln!(out, "Balance of {address} by {}{range}", h.interval)?; + writeln!( + out, + "Balance of {address} at the end of each {}{range}", + h.interval + )?; if points.is_empty() { writeln!(out, "No activity in this range.")?; } @@ -990,7 +1052,12 @@ fn show_balance_history( } else { format!(" {}", moves.join(", ")) }; - writeln!(out, " {} {} {n}{moves}", utc_date(p.t), p.v)?; + writeln!( + out, + " {} {} {n}{moves}", + period_label(p.t, &h.interval), + p.v + )?; } Ok(()) } @@ -1119,6 +1186,30 @@ fn show_chart(out: &mut dyn Write, n: Network, r: Chart, last: usize) -> Result< Ok(()) } +/// A balance-history period from its start: `2026-08-03`, `week of +/// 2026-08-03`, `2026-08`. The last point is the balance now, at a time +/// that starts no period, so it is shown as its date. +fn period_label(t: i64, interval: &str) -> String { + let date = utc_date(t); + let day = t.div_euclid(86_400); + let midnight = t.rem_euclid(86_400) == 0; + match interval { + // 1970-01-01 was a Thursday, so Mondays are 4 days on. + "week" if midnight && (day - 4).rem_euclid(7) == 0 => format!("week of {date}"), + "month" if midnight && date.ends_with("-01") => date[..7].to_owned(), + _ => date, + } +} + +/// A transaction id or block hash in the lower case the API expects. +fn lower_hex(s: String) -> String { + if s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit()) { + s.to_ascii_lowercase() + } else { + s + } +} + fn point_label(p: &ChartPoint, interval: &str) -> String { match (interval, p.height) { ("block", Some(h)) => format!("block {}", group_thousands(h.into())), @@ -1140,8 +1231,16 @@ fn show_account(out: &mut dyn Write, a: &Value) -> Result<()> { group_thousands(yesterday.into()) )?; } - if let Some(month) = used["this_month"].as_u64().or(used["month"].as_u64()) { - writeln!(out, "Requests this month {}", group_thousands(month.into()))?; + if let Some(month) = used["this_month"].as_u64() { + let quota = a["plan"]["max_monthly_requests"] + .as_u64() + .map(|q| format!(" of {}", group_thousands(q.into()))) + .unwrap_or_default(); + writeln!( + out, + "Requests this month {}{quota}", + group_thousands(month.into()) + )?; } if let Some(ends) = a["plan"]["current_period_ends_at"].as_str() { let renews = if a["plan"]["will_renew_at_period_end"] == true { @@ -1396,6 +1495,15 @@ mod tests { assert_eq!(format_chart_value("tx_count", &Value::Null), "no data"); } + #[test] + fn labels_balance_periods() { + assert_eq!(period_label(1_780_272_000, "month"), "2026-06"); + assert_eq!(period_label(1_780_272_000 + 3_600, "month"), "2026-06-01"); + assert_eq!(period_label(1_785_715_200, "week"), "week of 2026-08-03"); + assert_eq!(period_label(1_785_801_600, "week"), "2026-08-04"); + assert_eq!(period_label(1_785_801_600, "day"), "2026-08-04"); + } + #[test] fn formats_numbers() { assert_eq!(format_number(1234.0), "1,234"); diff --git a/src/model.rs b/src/model.rs index d11cb5c..bc2cca4 100644 --- a/src/model.rs +++ b/src/model.rs @@ -117,6 +117,12 @@ pub struct TxInput { pub address: Option, #[serde(default)] pub value: Option, + /// Null for a coinbase input, which spends no earlier output. + #[serde(default)] + pub previous_output: Option, + /// Litecoin: `pool` or `pegin` when the input comes from the MWEB side. + #[serde(default)] + pub mweb: Option, } #[derive(Debug, Deserialize)] @@ -126,6 +132,9 @@ pub struct TxOutput { pub value: Amount, #[serde(default, rename = "type")] pub kind: Option, + /// Litecoin: `pegin`, `pool` or `pegout` for outputs touching MWEB. + #[serde(default)] + pub mweb: Option, } #[derive(Debug, Deserialize)] @@ -145,7 +154,8 @@ pub struct Block { pub height: u64, pub hash: String, pub time: i64, - pub confirmations: u64, + /// -1 for a block that is no longer part of the main chain. + pub confirmations: i64, pub num_txs: u64, pub size: u64, pub previous_block_hash: Option, @@ -158,6 +168,15 @@ pub struct Block { pub value_out: Amount, #[serde(default)] pub price: Option, + /// Litecoin: the MWEB summary. value_out includes the MWEB pool carried + /// over from block to block. + #[serde(default)] + pub mweb: Option, +} + +#[derive(Debug, Deserialize)] +pub struct BlockMweb { + pub pool: Amount, } #[derive(Debug, Deserialize)] diff --git a/src/schema.rs b/src/schema.rs index 3cdcc28..53b0cd9 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -125,12 +125,12 @@ fn commands() -> Value { }, "fees ": { "api": "GET /network_info/{network}", - "data": "network, unit (sat/vB, lit/vB, koinu/vB or zat/action), suggested_fee_rate (the next block's median, a decimal string), mempool_txs, mempool_size, blocks (as in network-info)", + "data": "network, unit (sat/vB, lit/vB, koinu/vB or zat/action), suggested_fee_rate (the next block's median rounded up to a whole unit, a number), mempool_txs, mempool_size, blocks (as in network-info)", "notes": "unlike other commands, --json reshapes the API reply to the fee fields" }, "price [--time T]": { "api": "GET /price/{network} or GET /price/{network}/{unix_timestamp}", - "data": "value, currency, as_of?, sources?; with --currency other than USD: method (native | usd_fx), fx_rate?, fx_date?", + "data": "value, currency, as_of?, sources?; with --currency: method (native | usd_fx), fx_rate?, fx_date? (usd_fx only)", "notes": "--time takes unix seconds or a date and gives that UTC day's opening price" }, "rich-list [--page N] [--page-size 25|50|100]": { @@ -146,7 +146,7 @@ fn commands() -> Value { }, "balance-history
[--interval day|week|month] [--from T] [--to T] [--last N]": { "api": "GET /balance_history/{network}/{address}?interval&from&to", - "data": "address, interval, points[{t, height, v, received, sent, tx_in, tx_out}]", + "data": "address, interval, points[{t (start of the period), height, v (balance at the end of the period), received, sent, tx_in, tx_out}]", "notes": "--last limits text output only" }, "address-poisoning
[--limit N]": { diff --git a/tests/cli.rs b/tests/cli.rs index e6f0699..89af5f4 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -699,7 +699,7 @@ fn fees_show_the_suggested_rate() { .assert() .success() .stdout(predicate::str::contains( - "Suggested fee rate 0.28 sat/vB (the next block's median).", + "Suggested fee rate 1 sat/vB (the next block's median, rounded up).", )); let out = sochain(&m, &config()) .args(["fees", "BTC", "--json"]) @@ -707,7 +707,7 @@ fn fees_show_the_suggested_rate() { .unwrap(); let v: Value = serde_json::from_slice(&out.stdout).unwrap(); assert_eq!(v["data"]["unit"], "sat/vB"); - assert_eq!(v["data"]["suggested_fee_rate"], "0.28"); + assert_eq!(v["data"]["suggested_fee_rate"], 1); assert_eq!(m.paths(), ["/api/v3/network_info/BTC"; 2]); } @@ -868,3 +868,158 @@ fn skill_files_must_be_on_the_same_site() { .stderr(predicate::str::contains("not on sochain.com")); assert_eq!(m.paths().len(), 1); } + +#[test] +fn chart_sends_currency_only_for_price_usd() { + let chart = |series: &str| ok(json!({ "series": series, "interval": "day", "points": [] })); + let m = mock(vec![chart("tx_count"), chart("price_usd")]); + for series in ["tx_count", "price_usd"] { + sochain(&m, &config()) + .args(["chart", "BTC", series, "--currency", "EUR"]) + .assert() + .success(); + } + assert_eq!( + m.paths(), + [ + "/api/v3/charts/BTC/tx_count/day", + "/api/v3/charts/BTC/price_usd/day?currency=EUR" + ] + ); +} + +#[test] +fn orphaned_blocks_say_so() { + let mut b = block(); + b["confirmations"] = json!(-1); + let m = mock(vec![ok(b)]); + sochain(&m, &config()) + .args(["block", "BTC", "968995"]) + .assert() + .success() + .stdout(predicate::str::contains("Orphaned: this block is not part")) + .stdout(predicate::str::contains("Confirmations").not()); +} + +#[test] +fn litecoin_blocks_leave_the_mweb_pool_out_of_moved() { + let mut b = block(); + b["value_out"] = json!("465500.00000000"); + b["mweb"] = json!({ "pool": "465488.20543425", "pool_change": "0.00000000", + "pegin": "0.00000000", "pegin_count": 0, "pegout": "0.00000000", + "pegout_count": 0, "fees": "0.00000000", "kernels": 0 }); + let m = mock(vec![ok(b)]); + sochain(&m, &config()) + .args(["block", "LTC", "3192467"]) + .assert() + .success() + .stdout(predicate::str::contains("11.79 LTC moved")) + .stdout(predicate::str::contains("MWEB pool 465,488.21 LTC")); +} + +#[test] +fn inputs_without_an_address_are_told_apart() { + let mut tx = transaction(); + let input = |address: Value, previous: Value, mweb: Option<&str>| { + let mut i = json!({ "index": 0, "value": "1.00000000", "address": address, + "scriptSig": { "hex": "", "asm": "" }, "witness": [], "previous_output": previous }); + if let Some(m) = mweb { + i["mweb"] = json!(m); + } + i + }; + tx["inputs"] = json!([ + input(Value::Null, Value::Null, None), + input(Value::Null, json!({ "hash": TXID, "index": 0 }), None), + input( + Value::Null, + json!({ "hash": TXID, "index": 1 }), + Some("pool") + ), + ]); + let m = mock(vec![ok(tx)]); + sochain(&m, &config()) + .args(["tx", "LTC", TXID]) + .assert() + .success() + .stdout(predicate::str::contains(" newly mined coins 1 LTC")) + .stdout(predicate::str::contains(" (no address) 1 LTC")) + .stdout(predicate::str::contains(" MWEB pool 1 LTC")); +} + +#[test] +fn hashes_are_sent_in_lower_case() { + let m = mock(vec![ok(transaction())]); + sochain(&m, &config()) + .args(["tx", "BTC", &TXID.to_ascii_uppercase()]) + .assert() + .success(); + assert_eq!(m.paths(), [format!("/api/v3/transaction/BTC/{TXID}")]); +} + +#[test] +fn price_time_must_be_seconds() { + let m = mock(vec![]); + for bad in ["1704067200000", "0"] { + sochain(&m, &config()) + .args(["price", "BTC", "--time", bad]) + .assert() + .code(2) + .stderr(predicate::str::contains("7 to 10 digits")); + } + assert!(m.paths().is_empty()); +} + +#[test] +fn balance_before_the_first_block_is_not_todays() { + let m = mock(vec![ok(json!({ + "address": ADDR, "height": null, "time": null, "block_hash": null, + "confirmed": "0.00000000", "tip_height": 970712 + }))]); + sochain(&m, &config()) + .args(["balance", "BTC", ADDR, "--time", "2008-01-01"]) + .assert() + .success() + .stdout(format!( + "{ADDR} held nothing then: that is before Bitcoin's first block\n" + )); +} + +#[test] +fn next_page_hints_keep_page_size_and_currency() { + let m = mock(vec![ok( + json!({ "transactions": [history_row("1.00000000")], "next_cursor": "c2" }), + )]); + sochain(&m, &config()) + .args(["txs", "BTC", ADDR, "--page-size", "1", "--currency", "EUR"]) + .assert() + .success() + .stdout(predicate::str::contains(format!( + "More: sochain transactions BTC {ADDR} --cursor c2 --page-size 1 --currency EUR" + ))); +} + +#[test] +fn short_busy_replies_are_retried() { + let busy = with_header(fail(503, "Busy, try again."), "retry-after", "1"); + let m = mock(vec![busy, ok(json!({ "hash": HASH }))]); + sochain(&m, &config()) + .args(["best-block-hash", "BTC"]) + .assert() + .success() + .stderr(predicate::str::contains( + "SoChain is busy; retrying in 1 second", + )); + assert_eq!(m.paths().len(), 2); +} + +#[test] +fn rich_list_pages_stop_at_the_top_1000() { + let m = mock(vec![]); + sochain(&m, &config()) + .args(["rich-list", "BTC", "--page", "11"]) + .assert() + .code(2) + .stderr(predicate::str::contains("--page is at most 10")); + assert!(m.paths().is_empty()); +}