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
//! # nucleus-substrate-sdk
//!
//! Demand-side SDK for the Nucleus substrate. Composes:
//!
//!   * [`nucleus_substrate_core`] — Session, Receipt, Projection
//!   * [`nucleus_identity_projection`] — JWT-SVID lifter
//!   * [`nucleus_flow_projection`] — Denning-lattice lifter
//!   * [`nucleus_mechanism_vcg`] — Pigouvian-VCG lifter
//!
//! Two top-level affordances:
//!
//!   * [`Client`] — async HTTP wrapper over the hub's REST surface
//!     (agent card, JWKS, list auctions, post auction, submit bid,
//!     match, receipts, counters).
//!   * [`verify_receipt_fully`] — *the* "did this happen and is it
//!     consistent?" entry point. Verifies the receipt's top-level
//!     Ed25519 signature AND walks each projection through its
//!     lifter's structural verifier.
//!
//! ## Example: full verify against a live hub
//!
//! ```no_run
//! use nucleus_substrate_sdk::{Client, verify_receipt_fully};
//!
//! # async fn ex() -> anyhow::Result<()> {
//! let client = Client::new("https://nucleus-auction-hub.fly.dev")?;
//! let receipt = client.fetch_receipt("auction-id").await?;
//! let jwks    = client.jwks().await?;
//! let report  = verify_receipt_fully(&receipt, &jwks)?;
//! println!("✓ verified {} projections", report.projection_kinds.len());
//! # Ok(()) }
//! ```

pub mod client;

pub use client::{Client, HubError};

// Re-export everything an SDK consumer might need so a downstream
// `cargo add nucleus-substrate-sdk` is the only line they need.
pub use nucleus_substrate_core::{Projection, Receipt, ReceiptError, Session};
pub use nucleus_identity_projection::{
    IdentityBody, IdentityVerifyError, JwtSvidClaims, identity_projection,
    verify_identity_projection,
};
pub use nucleus_flow_projection::{
    FlowBody, FlowVerifyError, flow_projection, verify_flow_projection_shape,
};
pub use nucleus_mechanism_vcg::{
    EconomicBody, EconomicVerifyError, vickrey_projection,
    vcg_knapsack_projection, verify_economic_projection_shape,
    VickreyBody, VickreyOutcome, VcgKnapsackBody, VcgKnapsackOutcome,
};
pub use nucleus_substrate_core::mechanism::vcg::{
    AgentBid, ExternalityProfile, MatchResult, OpaqueSignedClaim,
    PostedAuction, ResourceDim, VcgMatchResult,
};

/// Result of [`verify_receipt_fully`]. Holds the list of projection
/// kinds that successfully verified — clients can sanity-check this
/// against the set they expected to be present.
#[derive(Debug, Clone)]
pub struct VerifyReport {
    pub projection_kinds: Vec<String>,
    /// Verified identity claims, when an Identity projection was
    /// present and verified.
    pub identity_subject: Option<String>,
    /// Whether a Flow projection was present AND its consistency
    /// invariants held.
    pub flow_clean: bool,
    /// True iff a Flow projection reports `has_adversarial_bid` —
    /// downstream consumers should refuse to trust the clearing.
    pub has_adversarial_bid: bool,
}

/// **The composite verifier.** Runs:
///
///   1. [`Receipt::verify`] against the issuer's Ed25519 verifying key
///      pulled from `jwks` by matching `kid`.
///   2. For each projection in the receipt:
///      - `Identity` → [`verify_identity_projection`]
///      - `Flow`     → [`verify_flow_projection_shape`]
///      - `Economic` → [`verify_economic_projection_shape`]
///      - `Capability` → no lifter shipped yet; skipped in v0.1
///
/// Any failure → [`SubstrateVerifyError`].
pub fn verify_receipt_fully(
    receipt: &Receipt,
    jwks: &serde_json::Value,
) -> Result<VerifyReport, SubstrateVerifyError> {
    let vk_bytes = extract_ed25519_vk(jwks, &receipt.session.issuer_kid).ok_or_else(|| {
        SubstrateVerifyError::JwksMissingKid(receipt.session.issuer_kid.clone())
    })?;
    receipt
        .verify(&vk_bytes)
        .map_err(SubstrateVerifyError::Receipt)?;

    let mut projection_kinds = Vec::new();
    let mut identity_subject = None;
    let mut flow_clean = false;
    let mut has_adversarial_bid = false;

    for p in &receipt.projections {
        projection_kinds.push(p.kind().to_string());
        match p {
            Projection::Identity(body) => {
                let typed: IdentityBody = serde_json::from_value(body.clone())
                    .map_err(|e| SubstrateVerifyError::ProjectionParse {
                        kind: "identity",
                        error: e.to_string(),
                    })?;
                let claims = verify_identity_projection(&typed, jwks)
                    .map_err(SubstrateVerifyError::Identity)?;
                identity_subject = Some(claims.claims.sub);
            }
            Projection::Flow(body) => {
                let typed: FlowBody = serde_json::from_value(body.clone()).map_err(|e| {
                    SubstrateVerifyError::ProjectionParse {
                        kind: "flow",
                        error: e.to_string(),
                    }
                })?;
                verify_flow_projection_shape(&typed)
                    .map_err(SubstrateVerifyError::Flow)?;
                flow_clean = true;
                if typed.has_adversarial_bid {
                    has_adversarial_bid = true;
                }
            }
            Projection::Economic(body) => {
                verify_economic_projection_shape(body)
                    .map_err(SubstrateVerifyError::Economic)?;
            }
            Projection::Capability(_) => {
                // v0.1: no capability lifter shipped; skip.
            }
            _ => {
                // `Projection` is #[non_exhaustive]; v0.2 variants
                // get ignored here until a lifter is shipped.
            }
        }
    }

    Ok(VerifyReport {
        projection_kinds,
        identity_subject,
        flow_clean,
        has_adversarial_bid,
    })
}

#[derive(Debug, thiserror::Error)]
pub enum SubstrateVerifyError {
    #[error("JWKS missing key with kid {0}")]
    JwksMissingKid(String),
    #[error("receipt signature/hash check failed: {0}")]
    Receipt(ReceiptError),
    #[error("identity projection failed: {0}")]
    Identity(IdentityVerifyError),
    #[error("flow projection failed: {0}")]
    Flow(FlowVerifyError),
    #[error("economic projection failed: {0}")]
    Economic(EconomicVerifyError),
    #[error("could not parse {kind} projection body: {error}")]
    ProjectionParse {
        kind: &'static str,
        error: String,
    },
}

// ── JWKS helper ───────────────────────────────────────────────

fn extract_ed25519_vk(jwks: &serde_json::Value, kid: &str) -> Option<[u8; 32]> {
    use base64::Engine;
    let keys = jwks.get("keys")?.as_array()?;
    for k in keys {
        if k.get("kid")?.as_str()? == kid
            && k.get("kty")?.as_str()? == "OKP"
            && k.get("crv")?.as_str()? == "Ed25519"
        {
            let x = k.get("x")?.as_str()?;
            let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
                .decode(x)
                .ok()?;
            return bytes.try_into().ok();
        }
    }
    None
}

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

    fn build_jwks(sk: &SigningKey, kid: &str) -> serde_json::Value {
        use base64::Engine;
        let vk_bytes = sk.verifying_key().to_bytes();
        serde_json::json!({
            "keys": [{
                "kty": "OKP",
                "crv": "Ed25519",
                "kid": kid,
                "alg": "EdDSA",
                "x": base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&vk_bytes),
            }]
        })
    }

    #[test]
    fn verify_receipt_fully_walks_flow_and_economic() {
        let sk = SigningKey::from_bytes(&[5u8; 32]);
        let session = Session {
            session_id: "spiffe://test/agent".into(),
            issuer_kid: "kid-1".into(),
            issued_at_micros: 1_717_000_000_000_000,
            parent_chain: vec![],
        };
        let flow = flow_projection(
            3,
            "internal",
            "trusted",
            "informational",
            "user_derived",
            false,
            false,
            true,
        );
        let auction = PostedAuction {
            auction_id: "a1".into(),
            required_capabilities: Default::default(),
            reward_micro_usd: 1_000_000,
            pigouvian_rates: vec![],
            scale: 1_000_000,
        };
        let bid = AgentBid {
            agent_spiffe_id: "spiffe://test/agent".into(),
            auction_id: "a1".into(),
            effective_value_micro_usd: 500_000,
            externality_profile: None,
        };
        let mr = MatchResult {
            auction_id: "a1".into(),
            winner_spiffe_id: Some("spiffe://test/agent".into()),
            clearing_price_micro_usd: 250_000,
        };
        let economic = vickrey_projection(auction, vec![bid], mr);
        let receipt = Receipt::sign(session, vec![flow, economic], &sk);
        let jwks = build_jwks(&sk, "kid-1");
        let report = verify_receipt_fully(&receipt, &jwks).expect("happy path");
        assert_eq!(report.projection_kinds, vec!["flow", "economic"]);
        assert!(report.flow_clean);
        assert!(!report.has_adversarial_bid);
    }

    #[test]
    fn tampered_receipt_fails_top_level_verify() {
        let sk = SigningKey::from_bytes(&[5u8; 32]);
        let session = Session {
            session_id: "spiffe://test/agent".into(),
            issuer_kid: "kid-1".into(),
            issued_at_micros: 1_717_000_000_000_000,
            parent_chain: vec![],
        };
        let mut receipt = Receipt::sign(session, vec![], &sk);
        receipt.session.session_id = "spiffe://attacker".into();
        let jwks = build_jwks(&sk, "kid-1");
        let err = verify_receipt_fully(&receipt, &jwks).unwrap_err();
        assert!(matches!(err, SubstrateVerifyError::Receipt(_)));
    }

    #[test]
    fn missing_kid_in_jwks_short_circuits() {
        let sk = SigningKey::from_bytes(&[5u8; 32]);
        let session = Session {
            session_id: "spiffe://test/agent".into(),
            issuer_kid: "kid-1".into(),
            issued_at_micros: 1_717_000_000_000_000,
            parent_chain: vec![],
        };
        let receipt = Receipt::sign(session, vec![], &sk);
        let empty_jwks = serde_json::json!({"keys": []});
        let err = verify_receipt_fully(&receipt, &empty_jwks).unwrap_err();
        assert!(matches!(err, SubstrateVerifyError::JwksMissingKid(_)));
    }

    #[test]
    fn adversarial_flow_propagates_to_report() {
        let sk = SigningKey::from_bytes(&[5u8; 32]);
        let session = Session {
            session_id: "spiffe://test/agent".into(),
            issuer_kid: "kid-1".into(),
            issued_at_micros: 1_717_000_000_000_000,
            parent_chain: vec![],
        };
        let flow = flow_projection(
            2,
            "internal",
            "adversarial",
            "informational",
            "user_derived",
            true,
            false,
            true,
        );
        let receipt = Receipt::sign(session, vec![flow], &sk);
        let jwks = build_jwks(&sk, "kid-1");
        let report = verify_receipt_fully(&receipt, &jwks).expect("happy path");
        assert!(report.has_adversarial_bid);
    }
}