From 6e4433cc3af87c8b7da1e787eeab5518ea3a0bcb Mon Sep 17 00:00:00 2001 From: Atif Nazir Date: Mon, 12 Oct 2026 18:02:46 -0700 Subject: [PATCH] Release 0.1.1: transaction kind, monthly quota, mempool loading note --- CHANGELOG.md | 15 +++++++++++- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 2 +- src/commands.rs | 46 ++++++++++++++++++++++++++++++++++- src/model.rs | 10 ++++++++ src/schema.rs | 6 ++--- tests/cli.rs | 64 +++++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 139 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a512035..90cd073 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,18 @@ names, environment variables, exit codes, error codes, and the shape of ## [Unreleased] +## [0.1.1] - 2026-10-12 + +### Added + +- `transaction` shows what kind of transaction it is (such as a payment, + CoinJoin or inscription) when SoChain has classified it, and says when the + kind is a guess. +- `account` shows requests this month against the plan's monthly quota, and + when the count resets. +- `network-info` and `fees` say when SoChain is still reading the mempool in + and its counts may be low. + ## [0.1.0] - 2026-10-10 ### Added @@ -27,5 +39,6 @@ names, environment variables, exit codes, error codes, and the shape of - `skill` and `mcp` to set up AI agents. - `schema` and `guide` for scripts and agents. -[Unreleased]: https://github.com/BlockIo/sochain-cli/compare/v0.1.0...HEAD +[Unreleased]: https://github.com/BlockIo/sochain-cli/compare/v0.1.1...HEAD +[0.1.1]: https://github.com/BlockIo/sochain-cli/compare/v0.1.0...v0.1.1 [0.1.0]: https://github.com/BlockIo/sochain-cli/releases/tag/v0.1.0 diff --git a/Cargo.lock b/Cargo.lock index 207fdb0..ff42a90 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1452,7 +1452,7 @@ checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" [[package]] name = "sochain-cli" -version = "0.1.0" +version = "0.1.1" dependencies = [ "assert_cmd", "clap", diff --git a/Cargo.toml b/Cargo.toml index 452d3ef..d4463fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sochain-cli" -version = "0.1.0" +version = "0.1.1" edition = "2024" rust-version = "1.88" description = "Command-line interface for SoChain: Bitcoin, Litecoin, Dogecoin and Zcash blockchain data" diff --git a/README.md b/README.md index cc9d4fd..b8818da 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ Every lookup names its network first, in any case: | `transaction-counts
` | Sent, received and total (alias `counts`) | | `unspent-outputs
[--page N]` | Spendable outputs (aliases `utxos`, `unspent`) | | `is-valid-address
` | Whether an address is valid on a network (alias `validate`) | -| `transaction ` | Confirmations, inputs, outputs and fee (alias `tx`) | +| `transaction ` | Confirmations, inputs, outputs, fee and kind, such as a payment or CoinJoin (alias `tx`) | | `transaction-status ` | Pending, mined, replaced, dropped, expired or unknown (alias `status`) | | `block ` | A block | | `latest-blocks ` | The 10 newest blocks (alias `blocks`) | diff --git a/src/commands.rs b/src/commands.rs index ecb4db7..d6a42e5 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -641,6 +641,14 @@ fn show_transaction(out: &mut dyn Write, n: Network, tx: Transaction) -> Result< _ => "waiting in the mempool (unconfirmed)".into(), }; writeln!(out, "Status {status} · {}", utc(tx.time))?; + if let Some(kind) = &tx.kind { + let guess = if tx.heuristic == Some(true) { + " (likely)" + } else { + "" + }; + writeln!(out, "Kind {}{guess}", kind.replace('_', " "))?; + } writeln!(out, "From:")?; if tx.inputs.is_empty() { writeln!(out, " (no transparent inputs)")?; @@ -848,6 +856,12 @@ fn show_network_info(out: &mut dyn Write, n: Network, i: NetworkInfo) -> Result< plural(i.mempool.mempool_txs, "transaction"), group_thousands(i.mempool.mempool_size.into()) )?; + if i.mempool.loading { + writeln!( + out, + "Note: SoChain is still reading the mempool in, so these counts may be low for now." + )?; + } if let Some(next) = i.mempool.blocks.first() { writeln!( out, @@ -904,6 +918,12 @@ fn show_fees(out: &mut dyn Write, n: Network, i: &NetworkInfo) -> Result<()> { plural(i.mempool.mempool_txs, "transaction"), group_thousands(i.mempool.mempool_size.into()) )?; + if i.mempool.loading { + writeln!( + out, + "Note: SoChain is still reading the mempool in, so these counts may be low for now." + )?; + } if blocks.is_empty() { return Ok(()); } @@ -1187,6 +1207,17 @@ fn show_chart(out: &mut dyn Write, n: Network, r: Chart, last: usize) -> Result< Ok(()) } +/// `3 days 4 h`, `5 h 20 min`, `12 min`. +fn duration(s: u64) -> String { + let (d, h, m) = (s / 86_400, s % 86_400 / 3_600, s % 3_600 / 60); + match (d, h) { + (0, 0) => format!("{} min", m.max(1)), + (0, _) => format!("{h} h {m} min"), + (1, _) => format!("1 day {h} h"), + _ => format!("{d} days {h} h"), + } +} + /// 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. @@ -1237,9 +1268,13 @@ fn show_account(out: &mut dyn Write, a: &Value) -> Result<()> { .as_u64() .map(|q| format!(" of {}", group_thousands(q.into()))) .unwrap_or_default(); + let resets = a["quota_resets_in_seconds"] + .as_u64() + .map(|s| format!(" · resets in {}", duration(s))) + .unwrap_or_default(); writeln!( out, - "Requests this month {}{quota}", + "Requests this month {}{quota}{resets}", group_thousands(month.into()) )?; } @@ -1496,6 +1531,15 @@ mod tests { assert_eq!(format_chart_value("tx_count", &Value::Null), "no data"); } + #[test] + fn formats_durations() { + assert_eq!(duration(30), "1 min"); + assert_eq!(duration(720), "12 min"); + assert_eq!(duration(19_200), "5 h 20 min"); + assert_eq!(duration(100_800), "1 day 4 h"); + assert_eq!(duration(273_600), "3 days 4 h"); + } + #[test] fn labels_balance_periods() { assert_eq!(period_label(1_780_272_000, "month"), "2026-06"); diff --git a/src/model.rs b/src/model.rs index 5305d51..2596fd7 100644 --- a/src/model.rs +++ b/src/model.rs @@ -109,6 +109,13 @@ pub struct Transaction { /// the transparent side. #[serde(default)] pub shielded: Option>, + /// What kind of transaction it is, such as `coinjoin`; null when not + /// classified. + #[serde(default)] + pub kind: Option, + /// True when the kind is a guess. + #[serde(default)] + pub heuristic: Option, } #[derive(Debug, Deserialize)] @@ -212,6 +219,9 @@ pub struct NetworkInfo { pub struct Mempool { pub mempool_txs: u64, pub mempool_size: u64, + /// True while SoChain is still reading the mempool in; counts can be low. + #[serde(default)] + pub loading: bool, #[serde(default)] pub blocks: Vec, } diff --git a/src/schema.rs b/src/schema.rs index 53b0cd9..40a1ee8 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -98,7 +98,7 @@ fn commands() -> Value { "transaction ": { "aliases": ["tx"], "api": "GET /transaction/{network}/{hash}", - "data": "hash, confirmations, size, vsize, weight, version, locktime, block_hash, block_height|null, time, inputs_n, outputs_n, inputs_value, outputs_value, fee, price, inputs[{index, value, address, previous_output{hash,index}, ...}], outputs[{index, value, address, type, script, spent, ...}], shielded? (Zcash), mweb labels (Litecoin)" + "data": "hash, confirmations, size, vsize, weight, version, locktime, block_hash, block_height|null, time, inputs_n, outputs_n, inputs_value, outputs_value, fee, price, inputs[{index, value, address, previous_output{hash,index}, ...}], outputs[{index, value, address, type, script, spent, ...}], shielded? (Zcash), mweb labels (Litecoin), kind (payment, coinjoin, inscription, ... or null), heuristic (true when kind is a guess, null with no kind)" }, "transaction-status ": { "aliases": ["status"], @@ -121,7 +121,7 @@ fn commands() -> Value { "network-info ": { "aliases": ["network"], "api": "GET /network_info/{network}", - "data": "block_count, best_block_hash, hashrate, mempool{mempool_txs, mempool_size, updated_at, blocks[{block_num, num_txs, size, weight, min_fee_rate, median_fee_rate, max_fee_rate, *_precise}]}" + "data": "block_count, best_block_hash, hashrate, mempool{mempool_txs, mempool_size, updated_at, loading (true while the mempool is still being read in; counts can be low), blocks[{block_num, num_txs, size, weight, min_fee_rate, median_fee_rate, max_fee_rate, *_precise}]}" }, "fees ": { "api": "GET /network_info/{network}", @@ -166,7 +166,7 @@ fn commands() -> Value { }, "account": { "api": "GET /account_info (needs a key)", - "data": "the account object, without the envelope: plan, num_requests_used, key{name, access}, ..." + "data": "the account object, without the envelope: plan{name, max_monthly_requests, current_period_ends_at, will_renew_at_period_end, ...}, num_requests_used{today, yesterday, this_month}, quota_resets_in_seconds, key{name, access}, last_login_at" }, "auth login [--no-verify]": { "does": "read a key from a hidden prompt or stdin, check it with GET /account_info, and save it readable only by you", diff --git a/tests/cli.rs b/tests/cli.rs index 29c2443..ffa5601 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1029,3 +1029,67 @@ fn rich_list_pages_stop_at_the_top_1000() { .stderr(predicate::str::contains("--page is at most 10")); assert!(m.paths().is_empty()); } + +#[test] +fn transaction_kind_is_shown_with_its_certainty() { + let mut tx = transaction(); + tx["kind"] = json!("coinjoin"); + tx["heuristic"] = json!(true); + let mut plain = transaction(); + plain["kind"] = json!("batch_payout"); + plain["heuristic"] = json!(false); + let m = mock(vec![ok(tx), ok(plain), ok(transaction())]); + sochain(&m, &config()) + .args(["tx", "BTC", TXID]) + .assert() + .success() + .stdout(predicate::str::contains("Kind coinjoin (likely)\n")); + sochain(&m, &config()) + .args(["tx", "BTC", TXID]) + .assert() + .success() + .stdout(predicate::str::contains("Kind batch payout\n")); + sochain(&m, &config()) + .args(["tx", "BTC", TXID]) + .assert() + .success() + .stdout(predicate::str::contains("Kind").not()); +} + +#[test] +fn a_loading_mempool_is_noted() { + let mut info = network_info(); + info["mempool"]["loading"] = json!(true); + let m = mock(vec![ok(info.clone()), ok(info), ok(network_info())]); + for cmd in ["network-info", "fees"] { + sochain(&m, &config()) + .args([cmd, "BTC"]) + .assert() + .success() + .stdout(predicate::str::contains("still reading the mempool in")); + } + sochain(&m, &config()) + .args(["fees", "BTC"]) + .assert() + .success() + .stdout(predicate::str::contains("still reading").not()); +} + +#[test] +fn account_shows_the_monthly_quota() { + let mut a = account(); + a["plan"]["name"] = json!("hobby"); + a["plan"]["max_monthly_requests"] = json!(500000); + a["num_requests_used"]["this_month"] = json!(12345); + a["quota_resets_in_seconds"] = json!(273_600); + let m = mock(vec![reply(200, a)]); + sochain(&m, &config()) + .env("SOCHAIN_API_KEY", KEY) + .arg("account") + .assert() + .success() + .stdout(predicate::str::contains("Plan hobby")) + .stdout(predicate::str::contains( + "Requests this month 12,345 of 500,000 · resets in 3 days 4 h", + )); +}