diff --git a/.github/workflows/maintenance.yml b/.github/workflows/maintenance.yml new file mode 100644 index 0000000..e891c07 --- /dev/null +++ b/.github/workflows/maintenance.yml @@ -0,0 +1,34 @@ +name: Maintenance + +on: + schedule: + - cron: "41 7 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + credentials: + runs-on: ubuntu-latest + steps: + - name: Check release credentials + env: + TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + run: | + headers=$(curl -sS -o /dev/null -D - -w '%{http_code}' \ + -H "Authorization: Bearer $TOKEN" \ + https://api.github.com/repos/BlockIo/homebrew-tap) + status=$(printf '%s' "$headers" | tail -n 1) + if [ "$status" != "200" ]; then + echo "::error::HOMEBREW_TAP_TOKEN was rejected (HTTP $status); replace it" + exit 1 + fi + expires=$(printf '%s' "$headers" | tr -d '\r' \ + | sed -n 's/^github-authentication-token-expiration: //Ip') + [ -z "$expires" ] && exit 0 + days=$(( ($(date -d "$expires" +%s) - $(date +%s)) / 86400 )) + if [ "$days" -lt 30 ]; then + echo "::error::HOMEBREW_TAP_TOKEN expires in $days days; replace it" + exit 1 + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 60ca284..4d6b057 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,15 @@ names, environment variables, exit codes, error codes, and the shape of ## [Unreleased] +## [0.6.0] - 2026-09-29 + +### Added + +- Dollar values at the time, back to December 2013: `tx` shows what the + amount sent and the fee were worth, `block` the DOGE price and what the + reward was worth, and `address` each history entry's value. `--json` is + unchanged. + ## [0.5.2] - 2026-09-29 ### Changed @@ -123,7 +132,8 @@ names, environment variables, exit codes, error codes, and the shape of - Installers: Homebrew (`blockio/tap/dogechain`), npm (`dogechain`), shell script, PowerShell, and prebuilt binaries for macOS, Linux and Windows. -[Unreleased]: https://github.com/BlockIo/dogechain-cli/compare/v0.5.2...HEAD +[Unreleased]: https://github.com/BlockIo/dogechain-cli/compare/v0.6.0...HEAD +[0.6.0]: https://github.com/BlockIo/dogechain-cli/compare/v0.5.2...v0.6.0 [0.5.2]: https://github.com/BlockIo/dogechain-cli/compare/v0.5.1...v0.5.2 [0.5.1]: https://github.com/BlockIo/dogechain-cli/compare/v0.5.0...v0.5.1 [0.5.0]: https://github.com/BlockIo/dogechain-cli/compare/v0.4.0...v0.5.0 diff --git a/Cargo.lock b/Cargo.lock index 76affa4..524fe1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -342,7 +342,7 @@ dependencies = [ [[package]] name = "dogechain-cli" -version = "0.5.2" +version = "0.6.0" dependencies = [ "assert_cmd", "clap", diff --git a/Cargo.toml b/Cargo.toml index 4179c1e..273d6e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dogechain-cli" -version = "0.5.2" +version = "0.6.0" edition = "2024" rust-version = "1.88" description = "Command-line interface for Dogechain.com" diff --git a/src/commands.rs b/src/commands.rs index 1a8e5be..c0ac170 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -216,6 +216,14 @@ fn show_block(out: &mut dyn Write, b: &Block) -> Result<()> { b.fees, b.value_out.display(2) )?; + if let Some(p) = b.price.as_ref().and_then(Price::usd) { + writeln!( + out, + "Price then {} per DOGE · reward worth about {}", + format_usd_price(p), + format_usd(doge_to_f64(b.reward) * p) + )?; + } writeln!( out, "Confirmations {}", @@ -260,6 +268,17 @@ fn show_tx(out: &mut dyn Write, r: &TxReply, explain_only: bool) -> Result<()> { } writeln!(out, "{}", r.explain.headline)?; writeln!(out, "{}", r.explain.detail)?; + let usd = r.transaction.price.as_ref().and_then(Price::usd); + if let (Some(sent), Some(p)) = (r.explain.sent, usd) + && sent.0 > 0 + { + writeln!( + out, + "Worth about {} at the time (1 DOGE = {})", + format_usd(doge_to_f64(sent) * p), + format_usd_price(p) + )?; + } if explain_only { return Ok(()); } @@ -293,8 +312,11 @@ fn show_tx(out: &mut dyn Write, r: &TxReply, explain_only: bool) -> Result<()> { } writeln!( out, - "Fee {} DOGE · {} bytes", + "Fee {} DOGE{} · {} bytes", tx.fee, + usd.filter(|_| tx.fee.0 > 0) + .map(|p| format!(" ({})", format_usd(doge_to_f64(tx.fee) * p))) + .unwrap_or_default(), group_thousands(tx.size.into()) )?; writeln!(out, "Transaction id {}", tx.hash)?; @@ -362,9 +384,15 @@ fn show_address(out: &mut dyn Write, r: &AddressReply) -> Result<()> { } else { "" }; + let worth = h + .price + .as_ref() + .and_then(Price::usd) + .map(|p| format!(" ({})", format_usd(doge_to_f64(h.balance_change.abs()) * p))) + .unwrap_or_default(); writeln!( out, - " {sign}{} DOGE {when} {}{p2pk}", + " {sign}{} DOGE{worth} {when} {}{p2pk}", h.balance_change.abs(), h.hash )?; @@ -764,6 +792,33 @@ fn plural(n: u64, noun: &str) -> String { ) } +fn doge_to_f64(d: Doge) -> f64 { + d.0 as f64 / KOINU_PER_DOGE +} + +/// A dollar amount: `$0.52`, `$1,234.56`, `$33,100`; `<$0.01` for dust. +fn format_usd(v: f64) -> String { + if v > 0.0 && v < 0.01 { + "<$0.01".into() + } else if v < 10_000.0 { + let cents = (v * 100.0).round() as i128; + format!("${}.{:02}", group_thousands(cents / 100), cents % 100) + } else { + format!("${}", group_thousands(v.round() as i128)) + } +} + +/// The price of 1 DOGE with enough digits to be meaningful: `$0.0939`, and +/// for early prices four significant digits, e.g. `$0.001565`. +fn format_usd_price(p: f64) -> String { + if p >= 0.01 || p <= 0.0 { + return format!("${p:.4}"); + } + let decimals = (4 - p.log10().floor() as i32 - 1).clamp(4, 10) as usize; + let s = format!("{p:.decimals$}"); + format!("${}", s.trim_end_matches('0')) +} + /// Koinu per byte to DOGE per kB (1,000 bytes), the unit wallets show. fn doge_per_kb(koinu_per_byte: f64) -> String { let doge = koinu_per_byte * 1000.0 / KOINU_PER_DOGE; @@ -785,6 +840,18 @@ fn format_number(v: f64) -> String { mod tests { use super::*; + #[test] + fn formats_dollars() { + assert_eq!(format_usd(33_100.4), "$33,100"); + assert_eq!(format_usd(1234.567), "$1,234.57"); + assert_eq!(format_usd(0.52), "$0.52"); + assert_eq!(format_usd(0.001), "<$0.01"); + assert_eq!(format_usd(0.0), "$0.00"); + assert_eq!(format_usd_price(0.0938771), "$0.0939"); + assert_eq!(format_usd_price(0.00156501), "$0.001565"); + assert_eq!(format_usd_price(0.000183), "$0.000183"); + } + #[test] fn formats_hashrate_and_difficulty() { assert_eq!(format_hashrate("2789460872749466"), "2.79 PH/s"); diff --git a/src/model.rs b/src/model.rs index 67c995b..274514e 100644 --- a/src/model.rs +++ b/src/model.rs @@ -13,6 +13,28 @@ use serde::Deserialize; use crate::amount::Doge; +/// The DOGE price when a block was mined (or, for unconfirmed transactions, +/// now). `value` is the price of 1 DOGE, as a decimal string. +#[derive(Debug, Clone, Deserialize)] +pub struct Price { + pub value: String, + pub currency: String, +} + +impl Price { + /// USD per DOGE, if this is a USD price. + pub fn usd(&self) -> Option { + if self.currency != "USD" { + return None; + } + self.value + .trim() + .parse() + .ok() + .filter(|p: &f64| p.is_finite() && *p >= 0.0) + } +} + #[derive(Debug, Deserialize)] pub struct Block { pub height: u64, @@ -29,6 +51,8 @@ pub struct Block { pub difficulty: String, pub previous_block_hash: Option, pub next_block_hash: Option, + #[serde(default)] + pub price: Option, } #[derive(Debug, Deserialize)] @@ -63,6 +87,8 @@ pub struct Transaction { pub size: u64, pub inputs: Vec, pub outputs: Vec, + #[serde(default)] + pub price: Option, } #[derive(Debug, Deserialize)] @@ -83,6 +109,9 @@ pub struct Output { pub struct Explain { pub headline: String, pub detail: String, + /// What the sender paid out, excluding change and fee. + #[serde(default)] + pub sent: Option, #[serde(default)] pub change_outputs: Vec, } @@ -119,6 +148,8 @@ pub struct AddressTx { pub block: Option, pub time: i64, pub balance_change: Doge, + #[serde(default)] + pub price: Option, /// The coins were paid to the address's public key (P2PK), not to the /// address itself. The API sends it only when true. #[serde(default)] diff --git a/tests/cli.rs b/tests/cli.rs index 5e389df..de32082 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1004,3 +1004,27 @@ fn mcp_print_changes_nothing() { "codex mcp add dogechain --url https://dogechain.com/mcp" ); } + +#[test] +fn shows_dollar_values_at_the_time() { + let mut priced = tx(json!([])); + priced["transaction"]["price"] = json!({ "currency": "USD", "value": "0.00156501" }); + dogechain(&mock(vec![ok(priced)])) + .args(["tx", TXID]) + .assert() + .success() + .stdout(predicate::str::contains( + "Worth about $0.09 at the time (1 DOGE = $0.001565)", + )) + .stdout(predicate::str::contains("Fee 0.01 DOGE (<$0.01)")); + + let mut b = block(); + b["price"] = json!({ "currency": "USD", "value": "0.09387710" }); + dogechain(&mock(vec![ok(b)])) + .args(["block", "latest"]) + .assert() + .success() + .stdout(predicate::str::contains( + "Price then $0.0939 per DOGE · reward worth about $938.77", + )); +}