nucleus-substrate-sdk 0.1.0

Demand-side SDK for the Nucleus substrate. Async HTTP `Client` over the hub plus a single `verify_receipt_fully` entry that walks all four projections (Identity, Capability, Flow, Economic) of a Receipt. Composes substrate-core + identity-projection + flow-projection + mechanism-vcg.
Documentation
//! Async HTTP client over the auction hub. One method per public
//! route. Returns `anyhow::Result<T>` for caller ergonomics.

use anyhow::{Context, Result, bail};
use nucleus_substrate_core::Receipt;
use serde_json::Value;
use std::time::Duration;

pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(15);

#[derive(Debug, Clone)]
pub struct Client {
    base_url: String,
    http: reqwest::Client,
}

impl Client {
    pub fn new(base_url: impl Into<String>) -> Result<Self> {
        let base_url = base_url.into().trim_end_matches('/').to_string();
        let http = reqwest::Client::builder()
            .timeout(DEFAULT_TIMEOUT)
            .build()
            .context("building reqwest client")?;
        Ok(Self { base_url, http })
    }

    fn url(&self, path: &str) -> String {
        format!("{}{path}", self.base_url)
    }

    pub async fn agent_card(&self) -> Result<Value> {
        let url = self.url("/.well-known/agent-card.json");
        let resp = self
            .http
            .get(&url)
            .send()
            .await
            .with_context(|| format!("GET {url}"))?;
        let status = resp.status();
        if !status.is_success() {
            bail!("GET {url} → {status}");
        }
        resp.json().await.with_context(|| format!("decoding AgentCard"))
    }

    pub async fn jwks(&self) -> Result<Value> {
        let url = self.url("/.well-known/jwks.json");
        let resp = self
            .http
            .get(&url)
            .send()
            .await
            .with_context(|| format!("GET {url}"))?;
        let status = resp.status();
        if !status.is_success() {
            bail!("GET {url} → {status}");
        }
        resp.json().await.with_context(|| format!("decoding JWKS"))
    }

    pub async fn fetch_receipt(&self, auction_id: &str) -> Result<Receipt> {
        let url = self.url(&format!("/v1/receipts/{auction_id}"));
        let resp = self
            .http
            .get(&url)
            .send()
            .await
            .with_context(|| format!("GET {url}"))?;
        let status = resp.status();
        if !status.is_success() {
            let text = resp.text().await.unwrap_or_default();
            bail!("GET {url} → {status}: {text}");
        }
        // Current hub still returns the v0.1 auction-receipt shape;
        // S6.1 (hub refactor) will switch to substrate Receipt. Try
        // substrate Receipt first; fall back to a synthesised
        // single-projection wrap.
        let body: Value = resp.json().await.context("decoding receipt body")?;
        if body.get("session").is_some() && body.get("projections").is_some() {
            serde_json::from_value(body).context("decoding substrate Receipt")
        } else {
            bail!("hub returned a non-substrate receipt shape; upgrade the hub or use the legacy SDK")
        }
    }

    pub async fn counters(&self) -> Result<Value> {
        let url = self.url("/v1/metrics/counters");
        let resp = self
            .http
            .get(&url)
            .send()
            .await
            .with_context(|| format!("GET {url}"))?;
        let status = resp.status();
        if !status.is_success() {
            bail!("GET {url} → {status}");
        }
        resp.json().await.with_context(|| format!("decoding counters"))
    }
}

/// Compatibility alias retained from earlier SDKs.
#[derive(Debug, thiserror::Error)]
pub enum HubError {
    #[error("HTTP transport: {0}")]
    Transport(String),
    #[error("hub returned non-success status: {0}")]
    Status(String),
    #[error("response decode: {0}")]
    Decode(String),
}