Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ setup for other apps.

## Safety

The CLI refuses input that looks like a private key or recovery phrase (exit
2, `BAD_INPUT`) without sending it anywhere. Never pass one to it, or to any
website or API.

`tx` replies can carry `warnings`. A `lookalike_sender` warning means a
transaction likely comes from an address made to look like one the receiver
really uses (address poisoning). Surface it to the user, and never suggest
Expand Down
16 changes: 15 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,19 @@ names, environment variables, exit codes, error codes, and the shape of

## [Unreleased]

## [0.7.0] - 2026-10-02

### Added

- Lookups (`find`, `address`, `tx`, `block`, `labels`, `chart`) refuse input
that looks like a private key or recovery phrase, with exit code 2, before
anything is sent.

### Changed

- `supply` gives the yearly issuance and inflation at the past year's actual
block pace instead of assuming a block every 60 seconds.

## [0.6.1] - 2026-10-01

### Changed
Expand Down Expand Up @@ -139,7 +152,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.6.1...HEAD
[Unreleased]: https://github.com/BlockIo/dogechain-cli/compare/v0.7.0...HEAD
[0.7.0]: https://github.com/BlockIo/dogechain-cli/compare/v0.6.1...v0.7.0
[0.6.1]: https://github.com/BlockIo/dogechain-cli/compare/v0.6.0...v0.6.1
[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
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "dogechain-cli"
version = "0.6.1"
version = "0.7.0"
edition = "2024"
rust-version = "1.88"
description = "Command-line interface for Dogechain.com"
Expand Down
44 changes: 37 additions & 7 deletions src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ use crate::cli::{Cli, Command, McpCommand, SkillCommand, SkillLocation, WatchWha
use crate::error::{CliError, Result};
use crate::mcp;
use crate::model::*;
use crate::secret;
use crate::skill::{self, Scope};
use crate::time::{ago, now, utc, utc_date, utc_minute};

Expand All @@ -24,6 +25,10 @@ const ADDRESS_PAGE_SIZE: u64 = 10;
const RICHLIST_PAGE_SIZE: u64 = 100;

pub fn run(cli: Cli, out: &mut dyn Write) -> Result<()> {
// Nothing that looks like a private key or recovery phrase is ever sent.
for input in lookup_inputs(&cli.command) {
secret::refuse_secrets(input)?;
}
let json = cli.json;
let api = || Api::new(Duration::from_secs(cli.timeout));
match cli.command {
Expand Down Expand Up @@ -151,6 +156,19 @@ pub fn run(cli: Cli, out: &mut dyn Write) -> Result<()> {
}
}

/// The user-supplied identifiers a command sends to the API.
fn lookup_inputs(command: &Command) -> Vec<&str> {
match command {
Command::Find { query } => vec![query],
Command::Block { id } => vec![id],
Command::Tx { txid, .. } => vec![txid],
Command::Address { address, .. } => vec![address],
Command::Labels { addresses } => addresses.iter().map(String::as_str).collect(),
Command::Chart { series, .. } => vec![series],
_ => vec![],
}
}

fn scope(location: SkillLocation) -> Scope {
match (location.project, location.dir) {
(_, Some(dir)) => Scope::Dir(dir),
Expand Down Expand Up @@ -508,13 +526,25 @@ fn show_supply(out: &mut dyn Write, r: &SupplyReply) -> Result<()> {
r.supply.display(0),
group_thousands(r.height.into())
)?;
writeln!(
out,
"Each block adds {} DOGE, about {} DOGE a year: {:.2}% inflation over the next 12 months.",
r.per_block.display(0),
r.per_year.display(0),
r.inflation_next_12_months * 100.0
)?;
match &r.recent_pace {
// The realistic figure: the past year's actual block times.
Some(pace) => writeln!(
out,
"Each block adds {} DOGE. At the past year's pace (a block every {:.1} seconds), \
that is about {} DOGE a year: {:.2}% inflation over the next 12 months.",
r.per_block.display(0),
pace.block_seconds,
pace.per_year.display(0),
pace.inflation_next_12_months * 100.0
)?,
None => writeln!(
out,
"Each block adds {} DOGE, about {} DOGE a year: {:.2}% inflation over the next 12 months.",
r.per_block.display(0),
r.per_year.display(0),
r.inflation_next_12_months * 100.0
)?,
}
Ok(())
}

Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ mod mcp;
mod model;
mod sanitize;
mod schema;
mod secret;
mod skill;
mod time;

Expand Down
11 changes: 11 additions & 0 deletions src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,17 @@ pub struct SupplyReply {
pub supply: Doge,
pub height: u64,
pub per_block: Doge,
/// Assumes a block every 60 seconds.
pub per_year: Doge,
pub inflation_next_12_months: f64,
/// The same figures at the past 365 days' actual block pace.
#[serde(default)]
pub recent_pace: Option<RecentPace>,
}

#[derive(Debug, Deserialize)]
pub struct RecentPace {
pub block_seconds: f64,
pub per_year: Doge,
pub inflation_next_12_months: f64,
}
Expand Down
4 changes: 2 additions & 2 deletions src/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ pub fn schema() -> Value {
"does": "resolve a block height or hash, transaction id or address",
"api": "GET /api/v3/find?q=<query>",
"data": { "kind": "block | transaction | address", "value": "string or number" },
"notes": "no match exits 3; text mode then shows the matching block, transaction or address"
"notes": "no match exits 3 (data.kind none, without value); text mode shows the matching block, transaction or address. Input that looks like a private key or recovery phrase is refused locally with exit 2 and never sent; this applies to every lookup command"
},
"block <height|hash|latest>": {
"api": "GET /api/v3/block/<id>",
Expand Down Expand Up @@ -89,7 +89,7 @@ pub fn schema() -> Value {
},
"supply": {
"api": "GET /api/v3/supply",
"data": "supply, height, per_block, per_year, inflation_next_12_months (fraction)"
"data": "supply, height, per_block, per_year and inflation_next_12_months (fraction; both assume a block every 60 s), recent_pace{block_seconds, per_year, inflation_next_12_months} (at the past 365 days' actual pace; text output uses this)"
},
"richlist [--page N]": {
"aliases": ["top"],
Expand Down
93 changes: 93 additions & 0 deletions src/secret.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
//! Refuses input that looks like a private key or recovery phrase, before it
//! leaves the machine. Lookups only ever need public data (block heights and
//! hashes, transaction ids, addresses), so a secret is never sent anywhere.
//!
//! A bare 64-character hex string is not caught here: it is also what a
//! transaction id or block hash looks like. Dogechain.com rejects key-shaped
//! input of that kind itself.

use crate::error::{CliError, Result};

pub const WARNING: &str = "that looks like a private key or recovery phrase, so it wasn't \
looked up; never send one to any website or API";

const BASE58: &str = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";

/// Fails with a bad-input error (exit 2) if `input` looks like a secret.
pub fn refuse_secrets(input: &str) -> Result<()> {
if looks_secret(input) {
Err(CliError::BadInput(WARNING.into()))
} else {
Ok(())
}
}

pub fn looks_secret(input: &str) -> bool {
let s = input.trim();
is_wif(s) || is_extended_private_key(s) || is_recovery_phrase(s)
}

fn is_base58(s: &str) -> bool {
!s.is_empty() && s.chars().all(|c| BASE58.contains(c))
}

/// Wallet import format: 51 (uncompressed) or 52 (compressed) base58
/// characters. Addresses are 33–34, so the lengths never overlap.
/// Prefixes: Dogecoin 6/Q, Bitcoin 5/K/L, testnets 9/c.
fn is_wif(s: &str) -> bool {
matches!(s.len(), 51 | 52) && is_base58(s) && s.starts_with(['6', 'Q', '5', 'K', 'L', '9', 'c'])
}

/// BIP32 extended private keys (Dogecoin dgpv, Bitcoin xprv/yprv/zprv and
/// testnet tprv/uprv/vprv).
fn is_extended_private_key(s: &str) -> bool {
const PREFIXES: [&str; 7] = ["dgpv", "xprv", "yprv", "zprv", "tprv", "uprv", "vprv"];
s.len() >= 100 && is_base58(s) && PREFIXES.iter().any(|p| s.starts_with(p))
}

/// 12 to 24 short lowercase words, the shape of a BIP39 recovery phrase.
fn is_recovery_phrase(s: &str) -> bool {
let words: Vec<&str> = s.split_whitespace().collect();
(12..=24).contains(&words.len())
&& words
.iter()
.all(|w| (3..=8).contains(&w.len()) && w.bytes().all(|b| b.is_ascii_lowercase()))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn catches_keys_and_phrases() {
// Key-shaped sample strings (standard test vectors and made-up
// examples); none controls any funds.
assert!(looks_secret(
"5HueCGU8rMjxEXxiPuD5BDku4MkFqeZyd4dZ1jvhTVqvbTLvyTJ"
));
assert!(looks_secret(
"KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU73sVHnoWn"
));
assert!(looks_secret(
"6KbBFk8U7sxzfajBnm1JJWqLvdcpd97K9Sf5GPgP2z8A1nR4GZ7"
));
assert!(looks_secret(
"xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi"
));
assert!(looks_secret(
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
));
}

#[test]
fn lets_public_data_through() {
assert!(!looks_secret("DH5yaieqoZN36fDVciNyRueRGvGLR3mr7L"));
assert!(!looks_secret("A8x177ySgB3BMr3LXYHWp7dLKNAjxo4hGJ"));
assert!(!looks_secret(
"2c916c8046224e19f865f288ebc9870fc75f93b5144595b7ec270b31e4863e7f"
));
assert!(!looks_secret("6389673"));
assert!(!looks_secret("latest"));
assert!(!looks_secret("what is the dogecoin supply"));
}
}
56 changes: 56 additions & 0 deletions tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1028,3 +1028,59 @@ fn shows_dollar_values_at_the_time() {
"Price then $0.0939 per DOGE · reward worth about $938.77",
));
}

#[test]
fn never_sends_private_keys_or_recovery_phrases() {
let phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
let wif = "6KbBFk8U7sxzfajBnm1JJWqLvdcpd97K9Sf5GPgP2z8A1nR4GZ7";
for args in [
vec!["find", phrase],
vec!["address", wif],
vec!["labels", ADDR, wif],
] {
let m = mock(vec![]);
dogechain(&m)
.args(&args)
.assert()
.code(2)
.stderr(predicate::str::contains(
"looks like a private key or recovery phrase",
));
assert!(
m.paths.lock().unwrap().is_empty(),
"nothing may be sent: {args:?}"
);
}
let m = mock(vec![]);
let out = dogechain(&m)
.args(["find", wif, "--json"])
.output()
.unwrap();
let v: Value = serde_json::from_slice(&out.stderr).unwrap();
assert_eq!(v["data"]["code"], "BAD_INPUT");
}

#[test]
fn supply_uses_the_recent_pace() {
let supply = json!({
"supply": "156155592865.81425251", "height": 6398309, "per_block": "10000.00000000",
"per_year": "5259600000.00000000", "inflation_next_12_months": 0.0337,
"recent_pace": { "block_seconds": 63.4, "per_year": "4977336801.00000000",
"inflation_next_12_months": 0.031874 }
});
dogechain(&mock(vec![ok(supply)]))
.arg("supply")
.assert()
.success()
.stdout(predicate::str::contains(
"At the past year's pace (a block every 63.4 seconds), that is about 4,977,336,801 DOGE a year: 3.19% inflation",
));
}

#[test]
fn find_kind_none_without_value() {
dogechain(&mock(vec![ok(json!({ "kind": "none" }))]))
.args(["find", "0xabc"])
.assert()
.code(3);
}
Loading