chia-query 0.7.0

Query the Chia blockchain via decentralized peers with coinset.org fallback
Documentation
//! coinset.org API drift monitor.
//!
//! Probes a fixed, representative set of coinset.org endpoints, reduces each
//! response to its type-shape (see [`chia_query::drift`]), and compares the
//! result against the committed snapshot `tests/fixtures/coinset-api-snapshot.json`.
//!
//! Usage:
//!   coinset_drift check    # diff live shapes vs the snapshot; exit 1 on drift (default)
//!   coinset_drift update   # regenerate the snapshot from live responses
//!
//! Run by `.github/workflows/coinset-drift.yml` on a daily cron; it is NOT a
//! merge gate — a coinset outage must never block PRs.

use std::process::ExitCode;
use std::time::Duration;

use serde_json::{json, Map, Value};

use chia_query::coinset::CoinsetClient;

const SNAPSHOT_PATH: &str = "tests/fixtures/coinset-api-snapshot.json";
const BASE_URL: &str = "https://api.coinset.org";

/// A representative endpoint probe: a display name, the endpoint, and its body.
struct Probe {
    /// Snapshot key — the endpoint, suffixed to distinguish multiple probes of
    /// the same endpoint (e.g. the error case).
    name: &'static str,
    endpoint: &'static str,
    body: Value,
}

/// The fixed probe set: chain state, network identity, a stable block record,
/// and an error envelope (which guards the `structuredError`/`traceback`
/// fields the client now parses).
fn probes() -> Vec<Probe> {
    vec![
        Probe {
            name: "get_blockchain_state",
            endpoint: "get_blockchain_state",
            body: json!({}),
        },
        Probe {
            name: "get_network_info",
            endpoint: "get_network_info",
            body: json!({}),
        },
        Probe {
            name: "get_block_record_by_height",
            endpoint: "get_block_record_by_height",
            body: json!({ "height": 1 }),
        },
        Probe {
            // A well-formed but absent coin id — returns the error envelope so
            // its shape (error / structuredError / traceback / success) is
            // snapshotted and watched.
            name: "get_coin_record_by_name__error",
            endpoint: "get_coin_record_by_name",
            body: json!({ "name": format!("0x{}", "0".repeat(64)) }),
        },
    ]
}

#[tokio::main]
async fn main() -> ExitCode {
    let mode = std::env::args().nth(1).unwrap_or_else(|| "check".into());
    match run(&mode).await {
        Ok(code) => code,
        Err(err) => {
            eprintln!("coinset_drift: {err}");
            ExitCode::from(2)
        }
    }
}

async fn run(mode: &str) -> Result<ExitCode, Box<dyn std::error::Error>> {
    let live = collect_shapes().await?;

    match mode {
        "update" => {
            let serialized = format!("{}\n", serde_json::to_string_pretty(&live)?);
            std::fs::write(SNAPSHOT_PATH, serialized)?;
            println!("wrote {SNAPSHOT_PATH}");
            Ok(ExitCode::SUCCESS)
        }
        "check" => {
            let baseline: Value = serde_json::from_str(&std::fs::read_to_string(SNAPSHOT_PATH)?)?;
            let drifts = chia_query::drift::diff_shapes("", &baseline, &live);
            if drifts.is_empty() {
                println!("no drift: coinset.org matches the committed snapshot");
                Ok(ExitCode::SUCCESS)
            } else {
                println!("DRIFT DETECTED against {SNAPSHOT_PATH}:");
                for line in &drifts {
                    println!("- {line}");
                }
                Ok(ExitCode::FAILURE)
            }
        }
        other => Err(format!("unknown mode `{other}` (expected `check` or `update`)").into()),
    }
}

/// Probe every endpoint and return `{ probe_name: shape }`.
async fn collect_shapes() -> Result<Value, Box<dyn std::error::Error>> {
    let client = CoinsetClient::new(BASE_URL, Duration::from_secs(30))?;
    let mut shapes = Map::new();
    for probe in probes() {
        let response = client.post_raw(probe.endpoint, &probe.body).await?;
        let mut shape = chia_query::drift::shape_of(&response);
        if probe.name == "get_blockchain_state" {
            drop_volatile_peak(&mut shape);
        }
        shapes.insert(probe.name.to_string(), shape);
    }
    Ok(Value::Object(shapes))
}

/// Remove the `blockchain_state.peak` subtree from a `get_blockchain_state`
/// shape.
///
/// `peak` is the chain tip, and roughly half of Chia blocks are non-transaction
/// blocks. On a non-transaction-block peak, `fees`, `timestamp`,
/// `prev_transaction_block_hash`, and `reward_claims_incorporated` are `null`;
/// on a transaction-block peak they are an integer, integer, string, and array.
/// The shape therefore flips with every block, so snapshotting `peak` produces
/// unavoidable false-positive drift. The BlockRecord shape is watched stably by
/// the dedicated `get_block_record_by_height` probe (height 1 is a permanent
/// transaction block with every field populated), so dropping `peak` here loses
/// no coverage while eliminating the flapping.
fn drop_volatile_peak(shape: &mut Value) {
    if let Some(state) = shape
        .get_mut("blockchain_state")
        .and_then(Value::as_object_mut)
    {
        state.remove("peak");
    }
}

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

    #[test]
    fn drop_volatile_peak_removes_only_the_peak_subtree() {
        let mut shape = json!({
            "blockchain_state": {
                "difficulty": "integer",
                "peak": { "fees": "null", "height": "integer" },
                "sync": { "synced": "boolean" }
            },
            "success": "boolean"
        });
        drop_volatile_peak(&mut shape);
        let state = &shape["blockchain_state"];
        assert!(state.get("peak").is_none(), "peak must be removed");
        assert!(state.get("difficulty").is_some(), "siblings preserved");
        assert!(state.get("sync").is_some(), "siblings preserved");
    }

    #[test]
    fn drop_volatile_peak_is_a_noop_without_peak() {
        let mut shape = json!({ "blockchain_state": { "difficulty": "integer" } });
        let before = shape.clone();
        drop_volatile_peak(&mut shape);
        assert_eq!(shape, before);
    }
}