From 94c042a88ffd0a32ad39e23675187d33f79d57f2 Mon Sep 17 00:00:00 2001 From: mizuki0x <197570892+mizuki0x@users.noreply.github.com> Date: Sun, 19 Jul 2026 19:43:40 +0200 Subject: [PATCH 01/22] docs(blockrun): partnership + phase-1 integration plan --- PARTNERSHIP-PLAN-blockrun.md | 186 +++++++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 PARTNERSHIP-PLAN-blockrun.md diff --git a/PARTNERSHIP-PLAN-blockrun.md b/PARTNERSHIP-PLAN-blockrun.md new file mode 100644 index 000000000..60a9e14af --- /dev/null +++ b/PARTNERSHIP-PLAN-blockrun.md @@ -0,0 +1,186 @@ +# Covenant × BlockRun — Partnership & Integration Plan + +*Status: PLAN ONLY (local). Nothing built or pushed. Follows the partner-integration convention: nested worktree `covenant-blockrun`, branch `feat/blockrun`, new crate `agent-os/crates/covenant-blockrun`.* + +--- + +## 1. Thesis (one paragraph) + +BlockRun solved **payment trust** for the agent economy: x402 makes every agent action an atomic, onchain USDC settlement on Base and Solana — "the wallet is the identity, no accounts, no KYC," 14.65M transactions settled, 1M+ API calls/month. What BlockRun has **zero** of is **delivery trust, identity trust, and decision trust**. Every one of their flagship autonomous-money products implicitly needs a receipt binding *who acted, what they intended, what they were served, and what they paid* — and none produces one. They even market the word "provenance" while shipping only payment metering. Covenant is precisely that missing layer: verifiable agent identity, provenance, and reputation, built on Solana, x402-native on both chains, with a hard discipline of sitting **alongside** payments and never touching them. The integration turns every BlockRun x402 settlement into a Covenant-attested, portable, onchain reputation event. Covenant does 100% of the Phase 1 lifting as a client-side overlay requiring **zero code changes from BlockRun**; BlockRun co-builds the native rail in Phase 2. + +--- + +## 2. Strategic fit — why this is the cleanest partner in the pipeline + +| Dimension | BlockRun | Covenant | Fit | +|---|---|---|---| +| Rail | x402 on Base + Solana | x402-native on Base + Solana (`covenant-x402`, `-x402-signer`, `-x402-signer-evm`) | Same rail, same two chains — no bridging work | +| Settlement token | USDC | USDC (no token entanglement; $CVNT stays Solana-canonical, untouched) | No token conflict | +| Identity model | "The wallet is the identity" (no reputation) | Agent identity + reputation (`covenant-identity`, `-metaplex`, `-sns`, PROOF) | Covenant *upgrades* their primitive, doesn't replace it | +| Routing | ClawRouter/Franklin router, savings claims self-reported | `covenant-router` + attestation | Covenant makes the claim verifiable | +| Ecosystem trust slot | **Empty category. Open invitation to list.** | This is Covenant's entire product | Uncontested | +| Discipline | Payments are theirs | "Trust ALONGSIDE payments, never touch payments" | No overlap, no threat | + +BlockRun's partner roster (Circle, Coinbase/CDP, Base, Solana, x402 Foundation, thirdweb, PayAI, Surf, Exa, 0x, Tatum, ElevenLabs, Bland/Twilio) contains **no trust, identity, reputation, verification, or provenance partner**. The category is empty and their partners page ends with an open invitation. Covenant walks into an uncontested seat. + +--- + +## 3. The gap we fill (BlockRun's unsolved trust problems) + +From their own docs and repos — every autonomous-money demo needs a receipt none of them produces: + +1. **Paying agent → service honesty.** x402 proves the agent *paid*; nothing proves the service *delivered what was paid for*. An agent buying a "premium model" response or "$0.01 sentiment" cannot verify it got the claimed model, fresh data, or honest routing. +2. **Service → agent legitimacy.** The only identity is a wallet; Sybil = a new wallet. Services can't distinguish a well-behaved Franklin from a hostile fork, and have no abuse history to price against. +3. **Routing/savings claims are unfalsifiable.** "78% / 89% / 92% savings" (three different published figures) is asserted by the router, which earns the margin on whatever it routes to — a direct incentive conflict with no proof of which model actually served the request. +4. **Guardrail integrity is honor-system.** `alpha-mcp`'s "cannot be overridden" risk limits are open-source client code — trivially forkable with the limits removed. Any counterparty relying on them has no proof the unmodified guardrails ran. +5. **Decision provenance is local and mutable.** Franklin-Trading's persona debate (Analyst → Bull/Bear → Trader → Risk → Compliance) lands in `~/.blockrun/sessions/*.jsonl`; polymarket-agent's 3-model consensus lands in `/tmp` ("data lost on redeployment"). The reasoning that justifies a trade is self-reported by the same process that trades. +6. **No standard receipt.** On-chain USDC receipts exist per fill, but nothing binds `(agent wallet, intent, inputs seen, decision, payment, output hash)` into one tamper-evident artifact. + +Covenant's receipt slots into exactly this seam. + +--- + +## 4. The product — **Covenant Receipts for x402** + +One artifact, three consumers. A **Covenant Receipt** is a signed, hash-bound attestation of a single x402 interaction: + +``` +Receipt { + agent: → bound to a Covenant identity (Metaplex agent-registry / .sol / ERC-8004) + intent: hash(intent | outcome | budget) + requested: { service, model_requested, params } + served: { model_served, provider, routing_profile, savings_claimed } + io: { input_hash, output_hash } + payment: { tx_hash (from X-Payment-Receipt), amount, asset, network } + verdict: delivery_ok | mismatch | disputed + sig: agent-side (Phase 1) → co-signed service-side (Phase 2) + anchor: Solana attestation (covenant-attestation / covenant-settlement) +} +``` + +- **For the paying agent:** proof it got the model/data it paid for; a portable reputation it accrues across every marketplace, not just BlockRun. +- **For the service (and BlockRun's router):** a verifiable delivery record and a reputation signal on the counterparty — anti-Sybil, priced abuse history. +- **For third parties (LPs, enterprises, auditors):** the missing dispute/audit artifact behind every autonomous-money claim. BlockRun's Enterprise tier already advertises "audit logs" — this makes them cryptographic. + +Framing discipline: reputation is a **system property** ("BlockRun spend is verifiable"), never a chore handed to the reader ("go verify it yourself"). + +--- + +## 5. Architecture — reuse, don't reinvent + +Phase 1 is ~80% composition of crates that already ship in `agent-os/crates/`: + +| Need | Existing crate | New work | +|---|---|---| +| x402 client + signing (Base + Solana) | `covenant-x402`, `covenant-x402-signer`, `covenant-x402-signer-evm` | Parse `X-Payment-Receipt`, capture round-trip | +| The receipt attestation + Solana anchor | `covenant-attestation`, `covenant-settlement` | Receipt schema + hashing | +| Bind paying wallet → agent identity | `covenant-identity`, `covenant-metaplex`, `covenant-sns` | Wallet→identity resolver | +| Verifiable routing | `covenant-router` | Attest requested-vs-served model | +| Reputation aggregation | `covenant-proof` (worktree), `covenant-audit` | Receipt → score rollup | +| Spend/budget parity | `covenant-budget`, `covenant-spend-permission` | Map BlockRun `delegate`/`report` | +| MCP trust tools | `covenant-mcp`, `covenant-guard` (mcp.opencovenant.org) | `covenant_attest/verify/reputation` tools | + +New crate: **`covenant-blockrun`** — the thin composition layer + wrappers. The only genuinely new code is the receipt schema, the wallet→identity resolver, the SDK decorators, and the demo. + +Injection points BlockRun already exposes (so Phase 1 needs nothing from them): +- **Python:** `blockrun-llm`'s `AnthropicClient` uses a custom httpx transport for x402 — wrap the transport. +- **TypeScript:** `blockrun-llm-ts` handles x402 in the fetch layer — wrap fetch. +- **MCP:** `blockrun-mcp` is open source (MIT-ish, verify) and forkable; ship Covenant trust tools **alongside** it, keyed to the same `~/.blockrun/` wallet and the `agent_id` field already on `blockrun_chat`/`blockrun_wallet delegate`. + +--- + +## 6. Phase 1 — Covenant does ALL the lifting (zero BlockRun code change) + +**Goal:** a BlockRun agent gets Covenant receipts + reputation today, without BlockRun shipping a line. Pure client-side overlay. + +**Deliverables** +1. `covenant-blockrun` crate (`agent-os/crates/covenant-blockrun`) — receipt schema, wallet→identity resolver, x402 round-trip capture, Solana anchor via `covenant-attestation`. +2. **`@covenant-org/blockrun` (npm) + `covenant-blockrun` (Python)** decorator SDKs — wrap the BlockRun client; every call emits a receipt. Drop-in: `wrapBlockRun(client)`. +3. **Covenant trust MCP tools** alongside `blockrun-mcp`: `covenant_attest`, `covenant_verify`, `covenant_reputation` — so any Claude Code / Franklin user with `blockrun-mcp` installed gets receipts for their spend. +4. **Three x402-paid Covenant endpoints** (Covenant is a *service on BlockRun's own rail* — dogfoods x402, real USDC yield): `POST /attest`, `POST /verify`, `GET /reputation/:agent`. A BlockRun agent pays Covenant in USDC the same way it pays for a model. Covenant lists itself on BlockRun's ecosystem page as the trust provider. +5. **Verifiable-routing receipt** — the wedge. Using ClawRouter's exposed `.model` / `.routing.savings` / debug headers, attest "requested `auto`, served model X at price Y." Turns the unfalsifiable savings claim into a Covenant-verifiable receipt. Directly repairs their credibility gap. +6. **Flagship demo** (see §11). + +**Tasks (loop-seedable)** +- `bru-01` receipt schema + hashing + Solana anchor (reuse `covenant-attestation`) +- `bru-02` x402 round-trip capture + `X-Payment-Receipt` parse (verify header casing against live 402 first) +- `bru-03` wallet→Covenant-identity resolver (Metaplex/.sol/ERC-8004) +- `bru-04` TS decorator `@covenant-org/blockrun` around fetch layer +- `bru-05` Python decorator around `AnthropicClient` transport +- `bru-06` MCP trust tools alongside `blockrun-mcp` +- `bru-07` three x402-paid endpoints on covenantd +- `bru-08` verifiable-routing receipt from ClawRouter headers +- `bru-09` reputation rollup via PROOF +- `bru-10` flagship demo + live-verified receipt on mainnet + +**Exit bar (interface-correct → production-correct):** a real Franklin/Franklin-Trading run on mainnet emits a receipt whose payment `tx_hash` resolves onchain and whose identity + model attestation verify independently. `live_*` demo naming, failure-mode bullets, honest mock/live ratio. + +--- + +## 7. Phase 2 — BlockRun co-builds the native rail + +Now the lifting is shared. BlockRun adds server-side hooks so receipts become **bidirectional** (service attests delivery, not just agent asserting it): + +1. **`X-Covenant-Attestation` response header** emitted by BlockRun's gateway/CDP-facilitator path — the service co-signs delivery. Closes gap #1 (service honesty) cryptographically. +2. **Reputation-gated access/pricing** — services can require a minimum Covenant reputation or price by abuse history. Closes gap #2 (Sybil / hostile fork). +3. **Covenant as an ecosystem "Trust" family** alongside Intelligence / Routing / Creation / Trading — the empty category, filled. +4. **Guardrail attestation for `alpha-mcp`** — attest the unmodified guardrail path executed before a fill. Closes gap #4. + +--- + +## 8. Phase 3 — deep + +- **Verifiable decision provenance** for Franklin-Trading (persona debate) and polymarket-agent (3-model consensus): attest the reasoning ran *before* the fill, replacing mutable `*.jsonl` / `/tmp`. +- **Dispute / arbitration** layer over receipts (Covenant already has settlement + audit primitives). +- **Cross-marketplace portable reputation** — a receipt earned on BlockRun counts on any other x402 marketplace (PayAI, Xona, etc. already in Covenant's orbit). + +--- + +## 9. Value exchange (both sides win) + +**BlockRun gains** +- The delivery/identity/reputation layer their entire autonomous-money pitch implicitly needs. +- "Provenance" becomes real (they already market the word). +- Verifiable routing-savings — fixes the self-reported 78/89/92% credibility problem. +- Anti-Sybil + priced abuse history for services. +- Cryptographic audit logs for their Enterprise tier. +- Fills the empty ecosystem trust category with a partner, not a competitor to payments. + +**Covenant gains** +- Distribution at scale: 14.65M settled txns, 1M+ calls/month, ClawRouter (6.6k★), blockrun-mcp (475★), Franklin — each x402 call a potential Covenant attestation. +- A canonical, high-volume x402 counterparty on **both** Base and Solana. +- PROOF/reputation stack validated at real volume. +- Real USDC yield: BlockRun agents pay Covenant for verification over x402 — Covenant becomes a paid service on the largest agent-payment rail. + +--- + +## 10. Constraints honored (guardrails) + +- **Never touch payments.** Receipts overlay settlement; Covenant never intermediates USDC flow. (PayAI/Xona discipline.) +- **No new token.** USDC + attestation only. $CVNT stays Solana-canonical and untouched; BlockRun has no token — keep it that way. +- **Plan-first.** This document is the plan; no code, no worktree, no push until operator go. +- **Reuse own infra.** ~80% existing crates; no rewrites. +- **Onchain framing, contact@opencovenant.org** in any outbound copy; don't recap BlockRun's product back at them. +- **Verifiable-as-property**, never "verify it yourself." + +--- + +## 11. Flagship demo (the postworthy artifact) + +**"A verifiable Franklin run."** Franklin executes a real coding-or-trading outcome, spending USDC across several models via x402. The Covenant overlay emits one receipt chain binding: Franklin's wallet → Covenant identity, its intent/budget, each requested-vs-served model (including the router's savings claim), and every payment tx hash — all anchored on Solana, independently verifiable. Side-by-side: BlockRun's local mutable `session.jsonl` vs. the Covenant onchain receipt. One is asserted; one is verifiable. That contrast is the whole story. + +Meets the x402 bar: novel, demoable, onchain, story-driven. + +--- + +## 12. De-risking / open questions (verify before building) + +- Pin exact 402 header casing against a **live** 402 response (`X-Payment-Required` vs `PAYMENT-REQUIRED`; `PAYMENT-SIGNATURE` vs `Payment-Signature`) — docs are inconsistent. +- Confirm the `X-Payment-Receipt` schema actually carries tx hash (+ amount/timestamp?) — one research pass saw it on `/docs/x402/endpoints`, another couldn't confirm the full schema. +- Confirm `blockrun-mcp` and `circle-nanopayment-sample` licenses before forking. +- Verify Solana-side x402 receipt shape (USDC mint `EPjF…Dt1v`) matches Base-side. +- Confirm ClawRouter debug headers are stable enough to attest against. + +## 13. The one concrete outreach ask (when operator says go) + +"Covenant ships the trust receipt layer as a zero-integration overlay on your x402 rail — you don't write a line for Phase 1. Give us a live 402 endpoint to pin headers against and a slot in your ecosystem trust category, and the first verifiable Franklin run is yours to demo." (No meeting ask; states what's shipping + one concrete ask.) From 3cb42045468aa90c6e7cd39d8aea30a0ca7c85b4 Mon Sep 17 00:00:00 2001 From: mizuki0x <197570892+mizuki0x@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:18:52 +0200 Subject: [PATCH 02/22] add covenant-blockrun: x402 call receipt + verifiable-routing overlay (crate) --- agent-os/Cargo.toml | 1 + agent-os/crates/covenant-blockrun/Cargo.toml | 26 ++ .../crates/covenant-blockrun/src/challenge.rs | 173 ++++++++++++ .../crates/covenant-blockrun/src/client.rs | 255 +++++++++++++++++ .../crates/covenant-blockrun/src/config.rs | 72 +++++ agent-os/crates/covenant-blockrun/src/lib.rs | 39 +++ .../crates/covenant-blockrun/src/receipt.rs | 260 ++++++++++++++++++ .../crates/covenant-blockrun/src/tools.rs | 227 +++++++++++++++ 8 files changed, 1053 insertions(+) create mode 100644 agent-os/crates/covenant-blockrun/Cargo.toml create mode 100644 agent-os/crates/covenant-blockrun/src/challenge.rs create mode 100644 agent-os/crates/covenant-blockrun/src/client.rs create mode 100644 agent-os/crates/covenant-blockrun/src/config.rs create mode 100644 agent-os/crates/covenant-blockrun/src/lib.rs create mode 100644 agent-os/crates/covenant-blockrun/src/receipt.rs create mode 100644 agent-os/crates/covenant-blockrun/src/tools.rs diff --git a/agent-os/Cargo.toml b/agent-os/Cargo.toml index 2d1e00706..5046b00c9 100644 --- a/agent-os/Cargo.toml +++ b/agent-os/Cargo.toml @@ -32,6 +32,7 @@ members = [ "crates/covenant-sns", "crates/covenant-acedata", "crates/covenant-krexa", + "crates/covenant-blockrun", "crates/covenant-sap-bridge", "crates/covenant-stake-keeper", "crates/covenantd", diff --git a/agent-os/crates/covenant-blockrun/Cargo.toml b/agent-os/crates/covenant-blockrun/Cargo.toml new file mode 100644 index 000000000..52e9eb4da --- /dev/null +++ b/agent-os/crates/covenant-blockrun/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "covenant-blockrun" +version = "0.0.0" +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +description = "Trust overlay for BlockRun's x402 API: captures a paid call round-trip and emits a hash-bound Covenant receipt binding intent, served model, and settlement." + +[dependencies] +covenant-mcp = { path = "../covenant-mcp" } +covenant-x402 = { path = "../covenant-x402" } +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +serde_jcs = { workspace = true } +sha2 = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +reqwest = { workspace = true } +base64 = "0.22" + +[dev-dependencies] +async-trait = { workspace = true } +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +wiremock = "0.6" diff --git a/agent-os/crates/covenant-blockrun/src/challenge.rs b/agent-os/crates/covenant-blockrun/src/challenge.rs new file mode 100644 index 000000000..5e3100946 --- /dev/null +++ b/agent-os/crates/covenant-blockrun/src/challenge.rs @@ -0,0 +1,173 @@ +//! Parsing BlockRun's x402 402 challenge. +//! +//! BlockRun returns the challenge base64-encoded in the `x-payment-required` +//! response header (and mirrors it in `www-authenticate: X402 requirements="…"`), +//! carrying an `accepts` array of payment options rather than the bare +//! `Vec` some x402 sellers use. We decode it ourselves and +//! convert the chosen option into the shape `covenant_x402::Signer` expects. + +use base64::Engine; +use covenant_x402::{PaymentExtra, PaymentRequirements}; +use serde::{Deserialize, Serialize}; + +use crate::{BlockRunError, Result}; + +/// USDC is 6 decimals on both Base and Solana; used to project the atomic +/// `amount` into a USD figure for display and cap checks. +const USDC_DECIMALS: u32 = 6; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Challenge { + #[serde(rename = "x402Version", default)] + pub version: u8, + #[serde(default)] + pub accepts: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resource: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Accept { + pub scheme: String, + pub network: String, + pub amount: String, + pub asset: String, + #[serde(rename = "payTo")] + pub pay_to: String, + #[serde(rename = "maxTimeoutSeconds", default)] + pub max_timeout_seconds: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub extra: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct AcceptExtra { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Resource { + #[serde(default)] + pub url: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub description: String, +} + +impl Challenge { + /// Decode the base64 blob carried in `x-payment-required` (or the + /// `requirements="…"` field of `www-authenticate`). + pub fn from_header_b64(value: &str) -> Result { + let blob = extract_requirements(value); + let bytes = base64::engine::general_purpose::STANDARD + .decode(blob.trim()) + .map_err(|e| BlockRunError::Challenge(format!("base64: {e}")))?; + serde_json::from_slice(&bytes).map_err(|e| BlockRunError::Challenge(format!("json: {e}"))) + } + + /// Decode a challenge already in JSON form (the 402 response body also + /// carries the same shape). + pub fn from_json(value: &serde_json::Value) -> Result { + serde_json::from_value(value.clone()) + .map_err(|e| BlockRunError::Challenge(format!("json: {e}"))) + } + + /// The first option matching `network` (CAIP-2), else the first option. + pub fn pick(&self, network: Option<&str>) -> Option<&Accept> { + match network { + Some(n) => self + .accepts + .iter() + .find(|a| a.network == n) + .or_else(|| self.accepts.first()), + None => self.accepts.first(), + } + } +} + +impl Accept { + /// Atomic `amount` projected to USDC (6 decimals). + pub fn amount_usdc(&self) -> f64 { + self.amount.parse::().unwrap_or(0) as f64 / 10u128.pow(USDC_DECIMALS) as f64 + } + + /// Convert into the requirements a `covenant_x402::Signer` consumes. + pub fn to_requirements(&self) -> PaymentRequirements { + PaymentRequirements { + network: self.network.clone(), + asset: self.asset.clone(), + amount: self.amount.clone(), + amount_usdc: self.amount_usdc(), + pay_to: self.pay_to.clone(), + scheme: self.scheme.clone(), + extra: self.extra.as_ref().map(|e| PaymentExtra { + fee_payer: None, + name: e.name.clone(), + version: e.version.clone(), + }), + } + } +} + +/// Pull the base64 payload out of a `www-authenticate: X402 requirements="…"` +/// header, or return the input unchanged when it is already the bare blob (the +/// `x-payment-required` header carries it directly). +fn extract_requirements(value: &str) -> String { + if let Some(idx) = value.find("requirements=") { + let rest = &value[idx + "requirements=".len()..]; + return rest.trim().trim_matches('"').to_string(); + } + value.trim().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + // The exact base64 BlockRun returns for a default gpt-4o-mini 402. + const LIVE_B64: &str = "eyJ4NDAyVmVyc2lvbiI6MiwiYWNjZXB0cyI6W3sic2NoZW1lIjoiZXhhY3QiLCJuZXR3b3JrIjoiZWlwMTU1Ojg0NTMiLCJhbW91bnQiOiIzMDAwIiwiYXNzZXQiOiIweDgzMzU4OWZDRDZlRGI2RTA4ZjRjN0MzMkQ0ZjcxYjU0YmRBMDI5MTMiLCJwYXlUbyI6IjB4ZTkwMzAwMTRGNURBZTIxN2QwQTE1MmYwMkEwNDM1NjdiMTZjMWFCZiIsIm1heFRpbWVvdXRTZWNvbmRzIjozMDAsImV4dHJhIjp7Im5hbWUiOiJVU0QgQ29pbiIsInZlcnNpb24iOiIyIn19XSwicmVzb3VyY2UiOnsidXJsIjoiaHR0cHM6Ly9ibG9ja3J1bi5haS9hcGkvdjEvY2hhdC9jb21wbGV0aW9ucyIsImRlc2NyaXB0aW9uIjoiR1BULTRvIE1pbmkgQVBJIGNhbGwgKH4xNyBpbnB1dCwgODE5MiBtYXggb3V0cHV0IHRva2VucykiLCJtaW1lVHlwZSI6ImFwcGxpY2F0aW9uL2pzb24ifX0="; + + #[test] + fn decodes_live_header() { + let c = Challenge::from_header_b64(LIVE_B64).unwrap(); + assert_eq!(c.version, 2); + let a = c.pick(Some("eip155:8453")).unwrap(); + assert_eq!(a.scheme, "exact"); + assert_eq!(a.amount, "3000"); + assert_eq!(a.asset, "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"); + assert_eq!(a.pay_to, "0xe9030014F5DAe217d0A152f02A043567b16c1aBf"); + assert_eq!(a.max_timeout_seconds, 300); + assert_eq!(a.extra.as_ref().unwrap().name.as_deref(), Some("USD Coin")); + } + + #[test] + fn amount_projects_to_usdc() { + let c = Challenge::from_header_b64(LIVE_B64).unwrap(); + // 3000 atomic / 1e6 = 0.003 USDC + assert!((c.accepts[0].amount_usdc() - 0.003).abs() < 1e-9); + } + + #[test] + fn accept_converts_to_requirements() { + let c = Challenge::from_header_b64(LIVE_B64).unwrap(); + let req = c.accepts[0].to_requirements(); + assert_eq!(req.network, "eip155:8453"); + assert_eq!(req.amount, "3000"); + assert_eq!(req.pay_to, "0xe9030014F5DAe217d0A152f02A043567b16c1aBf"); + assert_eq!(req.extra.unwrap().name.as_deref(), Some("USD Coin")); + } + + #[test] + fn extracts_from_www_authenticate() { + let hdr = format!("X402 requirements=\"{LIVE_B64}\""); + let c = Challenge::from_header_b64(&hdr).unwrap(); + assert_eq!(c.accepts.len(), 1); + } + + #[test] + fn rejects_garbage() { + assert!(Challenge::from_header_b64("not base64 !!!").is_err()); + } +} diff --git a/agent-os/crates/covenant-blockrun/src/client.rs b/agent-os/crates/covenant-blockrun/src/client.rs new file mode 100644 index 000000000..b745eb9c1 --- /dev/null +++ b/agent-os/crates/covenant-blockrun/src/client.rs @@ -0,0 +1,255 @@ +//! The paid round-trip: hit a BlockRun endpoint, satisfy the 402, and capture +//! the whole exchange as a [`CallReceipt`]. The signing itself is delegated to a +//! `covenant_x402::Signer`, so this crate never holds a key. + +use std::sync::Arc; + +use covenant_x402::Signer; +use reqwest::StatusCode; +use serde_json::Value; + +use crate::challenge::Challenge; +use crate::receipt::{CallReceipt, PaymentInfo, RoutingClaim}; +use crate::{BlockRunError, Result}; + +/// Header BlockRun uses to carry the settlement signature on the paid response. +const RECEIPT_HEADER: &str = "x-payment-receipt"; +const REQUIRED_HEADER: &str = "x-payment-required"; +const PAYMENT_HEADER: &str = "x-payment"; + +pub struct BlockRunClient { + http: reqwest::Client, + base_url: String, + signer: Arc, +} + +/// A completed call: the provider's response plus the receipt binding it. +#[derive(Debug, Clone)] +pub struct PaidCall { + pub response: Value, + pub receipt: CallReceipt, +} + +impl BlockRunClient { + pub fn new(base_url: impl Into, signer: Arc) -> Self { + Self::with_client(covenant_x402::http_client(), base_url, signer) + } + + pub fn with_client( + http: reqwest::Client, + base_url: impl Into, + signer: Arc, + ) -> Self { + BlockRunClient { + http, + base_url: base_url.into(), + signer, + } + } + + /// POST `body` to `endpoint`, paying the x402 challenge if one is returned, + /// and return the response bound into a receipt. + pub async fn call(&self, endpoint: &str, body: Value) -> Result { + let url = format!("{}{}", self.base_url.trim_end_matches('/'), endpoint); + let first = self.http.post(&url).json(&body).send().await?; + + if first.status() == StatusCode::PAYMENT_REQUIRED { + return self.pay_and_capture(endpoint, &url, body, first).await; + } + // Free endpoint (or an error). Surface errors; otherwise emit a receipt + // with an empty payment so the exchange is still recorded. + let routing = RoutingClaim::from_headers(first.headers()); + let response = read_json(first).await?; + let payment = PaymentInfo { + network: String::new(), + asset: String::new(), + amount: "0".into(), + amount_usdc: 0.0, + pay_to: String::new(), + tx: None, + }; + let receipt = CallReceipt::from_exchange(endpoint, &body, &response, payment, routing); + Ok(PaidCall { response, receipt }) + } + + async fn pay_and_capture( + &self, + endpoint: &str, + url: &str, + body: Value, + challenge_resp: reqwest::Response, + ) -> Result { + let challenge = decode_challenge(challenge_resp).await?; + let accept = challenge + .pick(None) + .ok_or_else(|| BlockRunError::Challenge("no payment options offered".into()))? + .clone(); + + let header = self + .signer + .build_payment(&accept.to_requirements()) + .await + .map_err(|e| BlockRunError::Signer(e.to_string()))?; + + let paid = self + .http + .post(url) + .header(PAYMENT_HEADER, header) + .json(&body) + .send() + .await?; + + let routing = RoutingClaim::from_headers(paid.headers()); + let tx = paid + .headers() + .get(RECEIPT_HEADER) + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + let status = paid.status(); + let response = read_json(paid).await?; + if !status.is_success() { + return Err(BlockRunError::Api { + status: status.as_u16(), + message: response.to_string(), + }); + } + + let payment = PaymentInfo { + network: accept.network.clone(), + asset: accept.asset.clone(), + amount: accept.amount.clone(), + amount_usdc: accept.amount_usdc(), + pay_to: accept.pay_to.clone(), + tx, + }; + let receipt = CallReceipt::from_exchange(endpoint, &body, &response, payment, routing); + Ok(PaidCall { response, receipt }) + } +} + +/// Decode the 402 challenge from the `x-payment-required` header, falling back +/// to `www-authenticate`, then to the response body. +async fn decode_challenge(resp: reqwest::Response) -> Result { + let header = resp + .headers() + .get(REQUIRED_HEADER) + .or_else(|| resp.headers().get(reqwest::header::WWW_AUTHENTICATE)) + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + if let Some(h) = header { + if let Ok(c) = Challenge::from_header_b64(&h) { + return Ok(c); + } + } + let body = read_json(resp).await?; + Challenge::from_json(&body) +} + +async fn read_json(resp: reqwest::Response) -> Result { + let status = resp.status(); + let text = resp.text().await?; + serde_json::from_str(&text).map_err(|_| BlockRunError::Api { + status: status.as_u16(), + message: if text.is_empty() { + "empty response".into() + } else { + text + }, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use wiremock::matchers::{header, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + // BlockRun's real 402 header for a default gpt-4o-mini call. + const LIVE_B64: &str = "eyJ4NDAyVmVyc2lvbiI6MiwiYWNjZXB0cyI6W3sic2NoZW1lIjoiZXhhY3QiLCJuZXR3b3JrIjoiZWlwMTU1Ojg0NTMiLCJhbW91bnQiOiIzMDAwIiwiYXNzZXQiOiIweDgzMzU4OWZDRDZlRGI2RTA4ZjRjN0MzMkQ0ZjcxYjU0YmRBMDI5MTMiLCJwYXlUbyI6IjB4ZTkwMzAwMTRGNURBZTIxN2QwQTE1MmYwMkEwNDM1NjdiMTZjMWFCZiIsIm1heFRpbWVvdXRTZWNvbmRzIjozMDAsImV4dHJhIjp7Im5hbWUiOiJVU0QgQ29pbiIsInZlcnNpb24iOiIyIn19XSwicmVzb3VyY2UiOnsidXJsIjoiaHR0cHM6Ly9ibG9ja3J1bi5haS9hcGkvdjEvY2hhdC9jb21wbGV0aW9ucyIsImRlc2NyaXB0aW9uIjoiR1BULTRvIE1pbmkgQVBJIGNhbGwgKH4xNyBpbnB1dCwgODE5MiBtYXggb3V0cHV0IHRva2VucykiLCJtaW1lVHlwZSI6ImFwcGxpY2F0aW9uL2pzb24ifX0="; + + fn signer() -> Arc { + Arc::new(covenant_x402::MockSigner) + } + + #[tokio::test] + async fn pays_402_then_captures_receipt() { + let server = MockServer::start().await; + // First hit: 402 with the challenge header. + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with(ResponseTemplate::new(402).insert_header("x-payment-required", LIVE_B64)) + .up_to_n_times(1) + .mount(&server) + .await; + // Paid retry (carries x-payment): 200 with served model + settlement tx. + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .and(header("x-payment", "mock:eip155:8453:3000")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("x-payment-receipt", "0xdeadbeef") + .insert_header("x-clawrouter-model", "gpt-4o-mini") + .set_body_json(json!({"model": "gpt-4o-mini", "choices": [{"text": "hi"}]})), + ) + .mount(&server) + .await; + + let client = BlockRunClient::new(server.uri(), signer()); + let out = client + .call( + "/v1/chat/completions", + json!({"model": "gpt-4o-mini", "messages": []}), + ) + .await + .unwrap(); + + assert_eq!(out.receipt.verdict, "delivered"); + assert_eq!(out.receipt.model_served, "gpt-4o-mini"); + assert_eq!(out.receipt.payment.tx.as_deref(), Some("0xdeadbeef")); + assert_eq!(out.receipt.payment.amount, "3000"); + assert!((out.receipt.payment.amount_usdc - 0.003).abs() < 1e-9); + assert!(!out.receipt.digest().is_empty()); + } + + #[tokio::test] + async fn free_endpoint_still_yields_a_receipt() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/free")) + .respond_with( + ResponseTemplate::new(200).set_body_json(json!({"model": "x", "ok": true})), + ) + .mount(&server) + .await; + let client = BlockRunClient::new(server.uri(), signer()); + let out = client + .call("/v1/free", json!({"model": "x"})) + .await + .unwrap(); + assert_eq!(out.receipt.payment.amount, "0"); + assert_eq!(out.receipt.model_served, "x"); + } + + #[tokio::test] + async fn surfaces_api_error_after_payment() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with(ResponseTemplate::new(402).insert_header("x-payment-required", LIVE_B64)) + .up_to_n_times(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with(ResponseTemplate::new(500).set_body_json(json!({"error": "upstream"}))) + .mount(&server) + .await; + let client = BlockRunClient::new(server.uri(), signer()); + let err = client + .call("/v1/chat/completions", json!({"model": "gpt-4o-mini"})) + .await + .unwrap_err(); + assert!(matches!(err, BlockRunError::Api { status: 500, .. })); + } +} diff --git a/agent-os/crates/covenant-blockrun/src/config.rs b/agent-os/crates/covenant-blockrun/src/config.rs new file mode 100644 index 000000000..5da0c6e4a --- /dev/null +++ b/agent-os/crates/covenant-blockrun/src/config.rs @@ -0,0 +1,72 @@ +//! Non-secret configuration for the BlockRun overlay. The x402 signing key is +//! never held here; it stays with the daemon's subprocess signer. + +use serde::{Deserialize, Serialize}; + +pub const DEFAULT_BASE_URL: &str = "https://blockrun.ai/api"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct BlockRunConfig { + #[serde(default)] + pub enabled: bool, + #[serde(default = "default_base_url")] + pub base_url: String, + /// Tool allowlist. `None` = all registered tools, `Some([])` = none. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allow: Option>, +} + +fn default_base_url() -> String { + DEFAULT_BASE_URL.to_string() +} + +impl Default for BlockRunConfig { + fn default() -> Self { + BlockRunConfig { + enabled: false, + base_url: default_base_url(), + allow: None, + } + } +} + +impl BlockRunConfig { + pub fn allows(&self, tool: &str) -> bool { + match &self.allow { + None => true, + Some(list) => list.iter().any(|t| t == tool), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_is_disabled_with_prod_url() { + let c = BlockRunConfig::default(); + assert!(!c.enabled); + assert_eq!(c.base_url, DEFAULT_BASE_URL); + assert!(c.allows("blockrun.call")); + } + + #[test] + fn empty_allowlist_denies_all() { + let c = BlockRunConfig { + allow: Some(vec![]), + ..Default::default() + }; + assert!(!c.allows("blockrun.call")); + } + + #[test] + fn allowlist_gates_by_name() { + let c = BlockRunConfig { + allow: Some(vec!["blockrun.verify".into()]), + ..Default::default() + }; + assert!(c.allows("blockrun.verify")); + assert!(!c.allows("blockrun.call")); + } +} diff --git a/agent-os/crates/covenant-blockrun/src/lib.rs b/agent-os/crates/covenant-blockrun/src/lib.rs new file mode 100644 index 000000000..c51e4ef5b --- /dev/null +++ b/agent-os/crates/covenant-blockrun/src/lib.rs @@ -0,0 +1,39 @@ +//! Trust overlay for BlockRun's x402 API. +//! +//! BlockRun routes an agent's paid calls across LLMs, media, and data, settling +//! USDC over x402. It proves a payment cleared; it does not bind that payment to +//! what was requested, which model actually served the request, or what came +//! back. This crate captures the paid round-trip and emits a [`CallReceipt`]: a +//! hash-bound record tying the intent, the requested model, the model BlockRun +//! actually served, the input/output hashes, and the settlement together, so a +//! buyer gets a receipt of what was delivered, not just that money moved. +//! +//! It sits alongside BlockRun's payment flow and never intermediates it. + +pub mod challenge; +pub mod client; +pub mod config; +pub mod receipt; +pub mod tools; + +pub use challenge::{Accept, Challenge}; +pub use client::{BlockRunClient, PaidCall}; +pub use config::BlockRunConfig; +pub use receipt::{CallReceipt, PaymentInfo, RoutingClaim}; +pub use tools::{blockrun_tools, CALL_TOOL, PROVIDER, VERIFY_TOOL}; + +#[derive(Debug, thiserror::Error)] +pub enum BlockRunError { + #[error("http: {0}")] + Http(#[from] reqwest::Error), + #[error("blockrun api {status}: {message}")] + Api { status: u16, message: String }, + #[error("could not decode 402 challenge: {0}")] + Challenge(String), + #[error("payment signing failed: {0}")] + Signer(String), + #[error("unexpected: {0}")] + Unexpected(String), +} + +pub type Result = std::result::Result; diff --git a/agent-os/crates/covenant-blockrun/src/receipt.rs b/agent-os/crates/covenant-blockrun/src/receipt.rs new file mode 100644 index 000000000..539e265be --- /dev/null +++ b/agent-os/crates/covenant-blockrun/src/receipt.rs @@ -0,0 +1,260 @@ +//! The Covenant receipt for a BlockRun paid call. +//! +//! A [`CallReceipt`] binds one x402 exchange into a single hash-addressable +//! record: the endpoint and requested model, the model BlockRun actually +//! served, the input and output hashes, the routing claim, and the settlement. +//! It is unsigned here — integrity comes from the daemon's audit chain and the +//! settlement receipt that carries the on-chain payment signature — but +//! [`CallReceipt::digest`] gives a stable hash a signer or anchor can bind to. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +pub const PROVIDER: &str = "blockrun"; + +/// How the served model related to what the caller asked for. +pub const VERDICT_DELIVERED: &str = "delivered"; +pub const VERDICT_SUBSTITUTED: &str = "substituted"; +pub const VERDICT_UNVERIFIED: &str = "unverified"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PaymentInfo { + pub network: String, + pub asset: String, + pub amount: String, + #[serde(rename = "amountUsdc")] + pub amount_usdc: f64, + #[serde(rename = "payTo")] + pub pay_to: String, + /// The on-chain settlement signature, once known. BlockRun returns it in the + /// `x-payment-receipt` response header after the paid retry. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tx: Option, +} + +/// The routing claim, surfaced so "cheapest capable model" stops being an +/// unfalsifiable assertion: we record what was requested against what the router +/// reports it served, and any savings figure it advertises. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct RoutingClaim { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde( + rename = "savingsPct", + default, + skip_serializing_if = "Option::is_none" + )] + pub savings_pct: Option, +} + +impl RoutingClaim { + /// Read the routing claim ClawRouter/BlockRun expose in debug headers. Header + /// names vary across router versions, so we read the known set defensively + /// and leave fields `None` when absent rather than failing. + pub fn from_headers(headers: &reqwest::header::HeaderMap) -> Self { + let get = |names: &[&str]| -> Option { + names + .iter() + .find_map(|n| headers.get(*n)) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) + }; + RoutingClaim { + profile: get(&["x-clawrouter-profile", "x-blockrun-profile"]), + model: get(&["x-clawrouter-model", "x-blockrun-model", "x-model"]), + savings_pct: get(&["x-clawrouter-savings", "x-blockrun-savings"]) + .and_then(|s| s.trim_end_matches('%').trim().parse::().ok()), + } + } + + pub fn is_empty(&self) -> bool { + self.profile.is_none() && self.model.is_none() && self.savings_pct.is_none() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct CallReceipt { + pub provider: String, + pub endpoint: String, + #[serde(rename = "modelRequested")] + pub model_requested: String, + #[serde(rename = "modelServed")] + pub model_served: String, + pub verdict: String, + #[serde(rename = "inputSha256")] + pub input_sha256: String, + #[serde(rename = "outputSha256")] + pub output_sha256: String, + #[serde(default, skip_serializing_if = "RoutingClaim::is_empty")] + pub routing: RoutingClaim, + pub payment: PaymentInfo, +} + +impl CallReceipt { + /// Build a receipt from one exchange: the request body sent, the response + /// body received, the payment that settled it, and the routing headers. + pub fn from_exchange( + endpoint: &str, + request: &Value, + response: &Value, + payment: PaymentInfo, + routing: RoutingClaim, + ) -> Self { + let model_requested = request + .get("model") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + // The router reports the served model in the response body or, failing + // that, in the routing headers. + let model_served = response + .get("model") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| routing.model.clone()) + .unwrap_or_default(); + let verdict = verdict_for(&model_requested, &model_served); + CallReceipt { + provider: PROVIDER.to_string(), + endpoint: endpoint.to_string(), + model_requested, + model_served, + verdict, + input_sha256: canonical_sha256_hex(request), + output_sha256: canonical_sha256_hex(response), + routing, + payment, + } + } + + pub fn to_json(&self) -> Value { + serde_json::to_value(self).unwrap_or(Value::Null) + } + + /// Stable hash over the JCS-canonical receipt; the identity a signer or + /// on-chain anchor binds to. + pub fn digest(&self) -> String { + canonical_sha256_hex(&self.to_json()) + } + + /// The verdict the requested/served pair should carry. A stored `verdict` + /// that disagrees with this is a tampered receipt. + pub fn expected_verdict(&self) -> String { + verdict_for(&self.model_requested, &self.model_served) + } +} + +fn verdict_for(requested: &str, served: &str) -> String { + if served.is_empty() { + VERDICT_UNVERIFIED + } else if requested.eq_ignore_ascii_case(served) { + VERDICT_DELIVERED + } else { + VERDICT_SUBSTITUTED + } + .to_string() +} + +pub fn sha256_hex(bytes: &[u8]) -> String { + let mut h = Sha256::new(); + h.update(bytes); + h.finalize().iter().map(|b| format!("{b:02x}")).collect() +} + +/// SHA-256 over the JCS-canonical form so two structurally-equal JSON values +/// hash identically regardless of key order or whitespace. +pub fn canonical_sha256_hex(value: &Value) -> String { + match serde_jcs::to_vec(value) { + Ok(bytes) => sha256_hex(&bytes), + Err(_) => sha256_hex(value.to_string().as_bytes()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn payment() -> PaymentInfo { + PaymentInfo { + network: "eip155:8453".into(), + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913".into(), + amount: "3000".into(), + amount_usdc: 0.003, + pay_to: "0xe9030014F5DAe217d0A152f02A043567b16c1aBf".into(), + tx: Some("0xabc".into()), + } + } + + #[test] + fn delivered_when_served_matches_requested() { + let req = json!({"model": "gpt-4o-mini", "messages": []}); + let resp = json!({"model": "gpt-4o-mini", "choices": []}); + let r = CallReceipt::from_exchange( + "/api/v1/chat/completions", + &req, + &resp, + payment(), + RoutingClaim::default(), + ); + assert_eq!(r.verdict, VERDICT_DELIVERED); + assert_eq!(r.model_requested, "gpt-4o-mini"); + assert_eq!(r.model_served, "gpt-4o-mini"); + } + + #[test] + fn substituted_when_router_swaps_the_model() { + let req = json!({"model": "gpt-4o", "messages": []}); + let resp = json!({"model": "gpt-4o-mini", "choices": []}); + let r = CallReceipt::from_exchange("/x", &req, &resp, payment(), RoutingClaim::default()); + assert_eq!(r.verdict, VERDICT_SUBSTITUTED); + } + + #[test] + fn served_model_falls_back_to_routing_header() { + let req = json!({"model": "auto", "messages": []}); + let resp = json!({"choices": []}); + let routing = RoutingClaim { + profile: Some("auto".into()), + model: Some("deepseek-v3".into()), + savings_pct: Some(78.0), + }; + let r = CallReceipt::from_exchange("/x", &req, &resp, payment(), routing); + assert_eq!(r.model_served, "deepseek-v3"); + assert_eq!(r.routing.savings_pct, Some(78.0)); + } + + #[test] + fn unverified_when_no_served_model_anywhere() { + let req = json!({"model": "gpt-4o-mini"}); + let resp = json!({"choices": []}); + let r = CallReceipt::from_exchange("/x", &req, &resp, payment(), RoutingClaim::default()); + assert_eq!(r.verdict, VERDICT_UNVERIFIED); + } + + #[test] + fn digest_is_stable_across_key_order() { + let req = json!({"model": "gpt-4o-mini", "messages": []}); + let resp_a = json!({"model": "gpt-4o-mini", "choices": [], "id": "x"}); + let resp_b = json!({"id": "x", "choices": [], "model": "gpt-4o-mini"}); + let ra = + CallReceipt::from_exchange("/x", &req, &resp_a, payment(), RoutingClaim::default()); + let rb = + CallReceipt::from_exchange("/x", &req, &resp_b, payment(), RoutingClaim::default()); + assert_eq!(ra.digest(), rb.digest()); + assert_eq!(ra.output_sha256, rb.output_sha256); + } + + #[test] + fn routing_parsed_from_headers() { + let mut h = reqwest::header::HeaderMap::new(); + h.insert("x-clawrouter-model", "kimi-k2".parse().unwrap()); + h.insert("x-clawrouter-savings", "63%".parse().unwrap()); + let rc = RoutingClaim::from_headers(&h); + assert_eq!(rc.model.as_deref(), Some("kimi-k2")); + assert_eq!(rc.savings_pct, Some(63.0)); + } +} diff --git a/agent-os/crates/covenant-blockrun/src/tools.rs b/agent-os/crates/covenant-blockrun/src/tools.rs new file mode 100644 index 000000000..6379b7b16 --- /dev/null +++ b/agent-os/crates/covenant-blockrun/src/tools.rs @@ -0,0 +1,227 @@ +//! MCP tools. `blockrun.call` pays a BlockRun endpoint over x402 and returns the +//! response with its receipt attached; `blockrun.verify` re-checks a receipt's +//! digest and verdict without touching the network. + +use std::sync::Arc; + +use async_trait::async_trait; +use covenant_mcp::{Content, Tool, ToolCallResult, ToolError}; +use serde_json::{json, Value}; + +use crate::client::BlockRunClient; +use crate::config::BlockRunConfig; +use crate::receipt::CallReceipt; + +pub const PROVIDER: &str = "blockrun"; +pub const CALL_TOOL: &str = "blockrun.call"; +pub const VERIFY_TOOL: &str = "blockrun.verify"; + +pub fn blockrun_tools(client: Arc, cfg: &BlockRunConfig) -> Vec> { + if !cfg.enabled { + return Vec::new(); + } + let mut tools: Vec> = Vec::new(); + if cfg.allows(CALL_TOOL) { + tools.push(Arc::new(CallTool { client })); + } + if cfg.allows(VERIFY_TOOL) { + tools.push(Arc::new(VerifyTool)); + } + tools +} + +struct CallTool { + client: Arc, +} + +#[async_trait] +impl Tool for CallTool { + fn name(&self) -> &str { + CALL_TOOL + } + + fn description(&self) -> &str { + "Call a BlockRun endpoint, paying its x402 USDC charge, and return the response with a Covenant receipt binding the requested model, the model served, the input/output hashes, and the settlement." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "endpoint": {"type": "string", "description": "BlockRun path, e.g. /v1/chat/completions"}, + "body": {"type": "object", "description": "JSON request body for the endpoint"} + }, + "required": ["endpoint", "body"], + "additionalProperties": false + }) + } + + async fn call(&self, arguments: Value) -> Result { + let endpoint = require_str(&arguments, "endpoint")?; + let body = arguments + .get("body") + .cloned() + .filter(Value::is_object) + .ok_or_else(|| ToolError::InvalidArguments("body must be a JSON object".into()))?; + + match self.client.call(&endpoint, body).await { + Ok(paid) => Ok(ToolCallResult::ok(vec![ + Content::json(paid.response), + Content::json(json!({ "receipt": paid.receipt.to_json() })), + ])), + Err(e) => Ok(ToolCallResult::error(e.to_string())), + } + } +} + +struct VerifyTool; + +#[async_trait] +impl Tool for VerifyTool { + fn name(&self) -> &str { + VERIFY_TOOL + } + + fn description(&self) -> &str { + "Verify a BlockRun call receipt: recompute its digest and check the stated verdict matches the requested/served model pair. No network, no payment." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "receipt": {"type": "object", "description": "A receipt emitted by blockrun.call"}, + "expectedDigest": {"type": "string", "description": "Optional digest to check the receipt against"} + }, + "required": ["receipt"], + "additionalProperties": false + }) + } + + async fn call(&self, arguments: Value) -> Result { + let raw = arguments + .get("receipt") + .cloned() + .ok_or_else(|| ToolError::InvalidArguments("receipt is required".into()))?; + let receipt: CallReceipt = serde_json::from_value(raw) + .map_err(|e| ToolError::InvalidArguments(format!("not a receipt: {e}")))?; + + let digest = receipt.digest(); + let expected_verdict = receipt.expected_verdict(); + let verdict_consistent = expected_verdict == receipt.verdict; + let digest_matches = arguments + .get("expectedDigest") + .and_then(Value::as_str) + .map(|e| e == digest); + let valid = verdict_consistent && digest_matches.unwrap_or(true); + + Ok(ToolCallResult::ok(vec![Content::json(json!({ + "valid": valid, + "digest": digest, + "digestMatches": digest_matches, + "verdictConsistent": verdict_consistent, + "statedVerdict": receipt.verdict, + "expectedVerdict": expected_verdict, + }))])) + } +} + +fn require_str(args: &Value, key: &str) -> Result { + args.get(key) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .ok_or_else(|| ToolError::InvalidArguments(format!("{key} is required"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::receipt::{PaymentInfo, RoutingClaim}; + + fn sample_receipt() -> CallReceipt { + let req = json!({"model": "gpt-4o-mini", "messages": []}); + let resp = json!({"model": "gpt-4o-mini", "choices": []}); + CallReceipt::from_exchange( + "/v1/chat/completions", + &req, + &resp, + PaymentInfo { + network: "eip155:8453".into(), + asset: "0x8335".into(), + amount: "3000".into(), + amount_usdc: 0.003, + pay_to: "0xe903".into(), + tx: Some("0xabc".into()), + }, + RoutingClaim::default(), + ) + } + + #[test] + fn tools_gate_on_enabled() { + let cfg = BlockRunConfig::default(); + let client = Arc::new(BlockRunClient::new( + "http://x", + Arc::new(covenant_x402::MockSigner), + )); + assert!(blockrun_tools(client, &cfg).is_empty()); + } + + #[test] + fn enabled_registers_both_tools() { + let cfg = BlockRunConfig { + enabled: true, + ..Default::default() + }; + let client = Arc::new(BlockRunClient::new( + "http://x", + Arc::new(covenant_x402::MockSigner), + )); + let names: Vec<_> = blockrun_tools(client, &cfg) + .iter() + .map(|t| t.name().to_string()) + .collect(); + assert_eq!(names, vec![CALL_TOOL, VERIFY_TOOL]); + } + + #[tokio::test] + async fn verify_accepts_a_clean_receipt() { + let r = sample_receipt(); + let digest = r.digest(); + let out = VerifyTool + .call(json!({"receipt": r.to_json(), "expectedDigest": digest})) + .await + .unwrap(); + let v = match &out.content[0] { + Content::Json { value } => value.clone(), + _ => panic!("expected json"), + }; + assert_eq!(v["valid"], json!(true)); + assert_eq!(v["verdictConsistent"], json!(true)); + assert_eq!(v["digestMatches"], json!(true)); + } + + #[tokio::test] + async fn verify_catches_a_tampered_verdict() { + let mut r = sample_receipt(); + r.verdict = "delivered".into(); + r.model_served = "cheaper-model".into(); // now inconsistent with the stated verdict + let out = VerifyTool + .call(json!({"receipt": r.to_json()})) + .await + .unwrap(); + let v = match &out.content[0] { + Content::Json { value } => value.clone(), + _ => panic!("expected json"), + }; + assert_eq!(v["verdictConsistent"], json!(false)); + assert_eq!(v["expectedVerdict"], json!("substituted")); + } + + #[tokio::test] + async fn verify_rejects_non_receipt() { + let out = VerifyTool.call(json!({"receipt": {"foo": 1}})).await; + assert!(matches!(out, Err(ToolError::InvalidArguments(_)))); + } +} From d6c3e26b0030bb2fbd6f90bea2bc331355bcc473 Mon Sep 17 00:00:00 2001 From: mizuki0x <197570892+mizuki0x@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:26:31 +0200 Subject: [PATCH 03/22] covenantd: wire blockrun overlay (tool registration + settlement receipt on paid calls) --- agent-os/Cargo.lock | 20 ++++ agent-os/crates/covenantd/Cargo.toml | 1 + agent-os/crates/covenantd/src/lib.rs | 159 ++++++++++++++++++++++++++ agent-os/crates/covenantd/src/main.rs | 75 ++++++++++++ 4 files changed, 255 insertions(+) diff --git a/agent-os/Cargo.lock b/agent-os/Cargo.lock index 907bca300..a73dbca2d 100644 --- a/agent-os/Cargo.lock +++ b/agent-os/Cargo.lock @@ -1489,6 +1489,25 @@ dependencies = [ "uuid", ] +[[package]] +name = "covenant-blockrun" +version = "0.0.0" +dependencies = [ + "async-trait", + "base64 0.22.1", + "covenant-mcp", + "covenant-x402", + "reqwest 0.12.28", + "serde", + "serde_jcs", + "serde_json", + "sha2 0.11.0", + "thiserror 1.0.69", + "tokio", + "tracing", + "wiremock", +] + [[package]] name = "covenant-budget" version = "0.1.0" @@ -2008,6 +2027,7 @@ dependencies = [ "covenant-a2a", "covenant-acedata", "covenant-audit", + "covenant-blockrun", "covenant-budget", "covenant-circuit", "covenant-hyre", diff --git a/agent-os/crates/covenantd/Cargo.toml b/agent-os/crates/covenantd/Cargo.toml index 4624d42bd..dd930b248 100644 --- a/agent-os/crates/covenantd/Cargo.toml +++ b/agent-os/crates/covenantd/Cargo.toml @@ -42,6 +42,7 @@ covenant-hyre = { path = "../covenant-hyre" } covenant-metaplex = { path = "../covenant-metaplex" } covenant-sns = { path = "../covenant-sns" } covenant-acedata = { path = "../covenant-acedata" } +covenant-blockrun = { path = "../covenant-blockrun" } covenant-circuit = { path = "../covenant-circuit", optional = true } covenant-krexa = { path = "../covenant-krexa" } anyhow = { workspace = true } diff --git a/agent-os/crates/covenantd/src/lib.rs b/agent-os/crates/covenantd/src/lib.rs index 3f409fe5e..4af2874ce 100644 --- a/agent-os/crates/covenantd/src/lib.rs +++ b/agent-os/crates/covenantd/src/lib.rs @@ -4626,6 +4626,9 @@ impl Server { if name.starts_with("circuit.") { return self.circuit_tool_call(name, arguments, peer).await; } + if name.starts_with("blockrun.") { + return self.blockrun_tool_call(name, arguments, peer).await; + } // Validate arguments against the tool's published input schema before // invoking it. Back-compatible: only object schemas that declare @@ -4884,6 +4887,73 @@ impl Server { } } + /// Execute a BlockRun tool. `blockrun.call` returns a `{ "receipt": … }` + /// block carrying the settlement signature BlockRun issued for the paid + /// call; when present, this records a [`SettlementReceipt`] and an + /// [`AuditKind::ExternalPaymentSettled`] row against the caller, so the + /// receipt lands alongside — never inside — BlockRun's payment flow. Free + /// calls and `blockrun.verify` carry no settlement, so nothing is written. + async fn blockrun_tool_call( + &self, + name: String, + arguments: serde_json::Value, + peer: &AgentId, + ) -> Response { + match self.tools.call(&name, arguments).await { + Ok(r) => { + if !r.is_error { + if let Some(receipt) = extract_blockrun_receipt(&r.content) { + if let Some(sig) = receipt.payment.tx.clone() { + let (chain, cluster) = blockrun_chain(&receipt.payment.network); + let now = epoch_ms(); + let receipt_id = Uuid::new_v4(); + let settlement = SettlementReceipt { + id: receipt_id, + payer: peer.clone(), + resource: ResourceKind::Tool, + memory_record_id: None, + credits_consumed: 0, + settled_at: now, + chain: Some(chain.clone()), + cluster, + batch_id: None, + merkle_root: None, + tx_sig: Some(sig.clone()), + slot: None, + confirmed_at: Some(now), + onchain_sig: Some(sig), + }; + if let Err(e) = self.settlement.record(settlement).await { + warn!(error = %e, "blockrun settlement record failed"); + } + let event = AuditEvent { + id: Uuid::new_v4(), + timestamp_ms: now, + issuer: peer.clone(), + kind: AuditKind::ExternalPaymentSettled { + provider: "blockrun".into(), + endpoint: receipt.endpoint.clone(), + network: chain, + asset: "USDC".into(), + amount: receipt.payment.amount.clone(), + receipt_id, + }, + }; + self.record_peer_event(peer, event).await; + } + } + } + Response::ToolResult { + content: r.content, + is_error: r.is_error, + } + } + Err(e) => Response::Error { + message: format!("tool: {e}"), + }, + } + } + /// Scan the caller's capabilities for one that grants `action` and /// whose scope permits `model` under /// [`permission_acedata_generate_scope_allows`]. Mirrors @@ -16333,6 +16403,42 @@ fn extract_circuit_provenance(content: &[covenant_mcp::Content]) -> Option Option { + for block in content { + if let covenant_mcp::Content::Json { value } = block { + if let Some(r) = value.get("receipt") { + if let Ok(receipt) = + serde_json::from_value::(r.clone()) + { + return Some(receipt); + } + } + } + } + None +} + +/// Map an x402 CAIP-2 network id to the settlement `(chain, cluster)` labels. +/// Sepolia (84532) is checked before mainnet (8453) since the latter is a +/// prefix of the former. +fn blockrun_chain(network: &str) -> (String, Option) { + if network == "eip155:84532" { + ("base".into(), Some("sepolia".into())) + } else if network == "eip155:8453" { + ("base".into(), Some("mainnet".into())) + } else if network.starts_with("solana") { + ("solana".into(), Some("mainnet".into())) + } else { + (network.to_string(), None) + } +} + fn audit_kind_requires_persistence(kind: &AuditKind) -> bool { matches!( kind, @@ -16645,6 +16751,59 @@ mod tests { assert!(prov.spent_raw.is_none()); } + #[test] + fn extract_blockrun_receipt_reads_paid_block() { + let content = vec![ + covenant_mcp::Content::json(serde_json::json!({ "choices": [] })), + covenant_mcp::Content::json(serde_json::json!({ "receipt": { + "provider": "blockrun", + "endpoint": "/v1/chat/completions", + "modelRequested": "gpt-4o-mini", + "modelServed": "gpt-4o-mini", + "verdict": "delivered", + "inputSha256": "a".repeat(64), + "outputSha256": "b".repeat(64), + "payment": { + "network": "eip155:8453", + "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "amount": "3000", + "amountUsdc": 0.003, + "payTo": "0xe9030014F5DAe217d0A152f02A043567b16c1aBf", + "tx": "0xdeadbeef" + } + }})), + ]; + let r = extract_blockrun_receipt(&content).expect("receipt present"); + assert_eq!(r.payment.tx.as_deref(), Some("0xdeadbeef")); + assert_eq!(r.endpoint, "/v1/chat/completions"); + assert_eq!(r.payment.amount, "3000"); + } + + #[test] + fn extract_blockrun_receipt_none_for_verify_result() { + let content = vec![covenant_mcp::Content::json( + serde_json::json!({ "valid": true, "digest": "x" }), + )]; + assert!(extract_blockrun_receipt(&content).is_none()); + } + + #[test] + fn blockrun_chain_maps_networks() { + assert_eq!( + blockrun_chain("eip155:8453"), + ("base".to_string(), Some("mainnet".to_string())) + ); + assert_eq!( + blockrun_chain("eip155:84532"), + ("base".to_string(), Some("sepolia".to_string())) + ); + assert_eq!( + blockrun_chain("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"), + ("solana".to_string(), Some("mainnet".to_string())) + ); + assert_eq!(blockrun_chain("weird:1"), ("weird:1".to_string(), None)); + } + struct FakeCircuitTool { name: &'static str, block: serde_json::Value, diff --git a/agent-os/crates/covenantd/src/main.rs b/agent-os/crates/covenantd/src/main.rs index f915cecbd..d1244800b 100644 --- a/agent-os/crates/covenantd/src/main.rs +++ b/agent-os/crates/covenantd/src/main.rs @@ -241,6 +241,19 @@ async fn main() -> Result<()> { tools_vec.extend(added); } } + if let Some((client, cfg)) = blockrun_from_env() { + let added = covenant_blockrun::blockrun_tools(Arc::new(client), &cfg); + if added.is_empty() { + tracing::warn!("blockrun enabled but registered no tools"); + } else { + info!( + count = added.len(), + base_url = %cfg.base_url, + "blockrun trust overlay enabled (x402 pay-per-call; receipts alongside settlement)" + ); + tools_vec.extend(added); + } + } let mcp_cfg = covenant_mcp::config::McpConfigFile::from_path(&secrets_path) .with_context(|| format!("parse mcp config in {}", secrets_path.display()))?; for srv in mcp_cfg.servers() { @@ -986,6 +999,68 @@ fn acedata_from_env() -> Option<( } } +/// Build the BlockRun trust overlay from the environment. BlockRun always pays +/// per call over x402, so there is no API-key mode: the overlay enables only +/// when the daemon's x402 signer sidecar is configured, and every paid call +/// signs through it. Off by default. +/// +/// - `COVENANT_BLOCKRUN_ENABLED` truthy (`1`, `true`, `yes`) turns it on +/// - `COVENANT_BLOCKRUN_BASE_URL` overrides the API host (optional) +/// - `COVENANT_BLOCKRUN_ALLOW` comma-separated tool allowlist (optional) +fn blockrun_from_env() -> Option<( + covenant_blockrun::BlockRunClient, + covenant_blockrun::BlockRunConfig, +)> { + let enabled = std::env::var("COVENANT_BLOCKRUN_ENABLED") + .map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes")) + .unwrap_or(false); + if !enabled { + return None; + } + let mut cfg = covenant_blockrun::BlockRunConfig { + enabled: true, + ..Default::default() + }; + if let Ok(url) = std::env::var("COVENANT_BLOCKRUN_BASE_URL") { + if !url.trim().is_empty() { + cfg.base_url = url; + } + } + if let Ok(list) = std::env::var("COVENANT_BLOCKRUN_ALLOW") { + cfg.allow = Some( + list.split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(), + ); + } + match x402_dispatch_config_from_env() { + Some(x402) => { + let mut signer = covenantd::x402::SubprocessSigner::new(&x402.signer_binary); + for (key, value) in &x402.signer_env { + signer = signer.env(key, value); + } + let client = covenant_blockrun::BlockRunClient::new( + cfg.base_url.clone(), + std::sync::Arc::new(signer), + ); + info!( + base_url = %cfg.base_url, + signer = %x402.signer_binary.display(), + "blockrun enabled (x402 pay-per-call)" + ); + Some((client, cfg)) + } + None => { + tracing::warn!( + "COVENANT_BLOCKRUN_ENABLED set but the x402 signer (COVENANT_X402_ENABLED + \ + COVENANT_X402_SIGNER_BINARY) is not configured; blockrun disabled" + ); + None + } + } +} + /// Build the Circuit tool set from the environment: an in-process funding payer that settles /// paid Circuit calls in $CVNT (or CIRC) on Solana, bound by a spend capability (per-call /// cap, treasury pin, host allowlist, cumulative budget). The daemon holds the funding key From a757fadf28da3675a658f91b7661ea451270e690 Mon Sep 17 00:00:00 2001 From: mizuki0x <197570892+mizuki0x@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:28:15 +0200 Subject: [PATCH 04/22] add @covenant-org/blockrun (TS) + covenant-blockrun (Python) receipt SDKs --- .../crates/covenant-blockrun/src/receipt.rs | 29 +++ packages/blockrun-py/README.md | 57 ++++++ packages/blockrun-py/pyproject.toml | 22 +++ .../src/covenant_blockrun/__init__.py | 49 +++++ .../src/covenant_blockrun/challenge.py | 35 ++++ .../src/covenant_blockrun/decorator.py | 178 ++++++++++++++++++ .../blockrun-py/src/covenant_blockrun/jcs.py | 51 +++++ .../src/covenant_blockrun/receipt.py | 168 +++++++++++++++++ packages/blockrun-py/tests/test_receipt.py | 153 +++++++++++++++ packages/blockrun-ts/README.md | 79 ++++++++ packages/blockrun-ts/package.json | 45 +++++ .../blockrun-ts/src/__tests__/receipt.test.ts | 146 ++++++++++++++ packages/blockrun-ts/src/challenge.ts | 73 +++++++ packages/blockrun-ts/src/decorator.ts | 160 ++++++++++++++++ packages/blockrun-ts/src/index.ts | 31 +++ packages/blockrun-ts/src/jcs.ts | 39 ++++ packages/blockrun-ts/src/receipt.ts | 151 +++++++++++++++ packages/blockrun-ts/tsconfig.json | 16 ++ pnpm-workspace.yaml | 1 + 19 files changed, 1483 insertions(+) create mode 100644 packages/blockrun-py/README.md create mode 100644 packages/blockrun-py/pyproject.toml create mode 100644 packages/blockrun-py/src/covenant_blockrun/__init__.py create mode 100644 packages/blockrun-py/src/covenant_blockrun/challenge.py create mode 100644 packages/blockrun-py/src/covenant_blockrun/decorator.py create mode 100644 packages/blockrun-py/src/covenant_blockrun/jcs.py create mode 100644 packages/blockrun-py/src/covenant_blockrun/receipt.py create mode 100644 packages/blockrun-py/tests/test_receipt.py create mode 100644 packages/blockrun-ts/README.md create mode 100644 packages/blockrun-ts/package.json create mode 100644 packages/blockrun-ts/src/__tests__/receipt.test.ts create mode 100644 packages/blockrun-ts/src/challenge.ts create mode 100644 packages/blockrun-ts/src/decorator.ts create mode 100644 packages/blockrun-ts/src/index.ts create mode 100644 packages/blockrun-ts/src/jcs.ts create mode 100644 packages/blockrun-ts/src/receipt.ts create mode 100644 packages/blockrun-ts/tsconfig.json diff --git a/agent-os/crates/covenant-blockrun/src/receipt.rs b/agent-os/crates/covenant-blockrun/src/receipt.rs index 539e265be..ca09a27eb 100644 --- a/agent-os/crates/covenant-blockrun/src/receipt.rs +++ b/agent-os/crates/covenant-blockrun/src/receipt.rs @@ -248,6 +248,35 @@ mod tests { assert_eq!(ra.output_sha256, rb.output_sha256); } + #[test] + fn canonical_digest_matches_cross_language_vector() { + // The identical receipt hashed by the TS (@covenant-org/blockrun) and + // Python (covenant-blockrun) packages. If this hex changes, a receipt + // built by one SDK stops verifying in the others — treat as a break. + let receipt = json!({ + "provider": "blockrun", + "endpoint": "/v1/chat/completions", + "modelRequested": "gpt-4o", + "modelServed": "gpt-4o-mini", + "verdict": "substituted", + "inputSha256": "aaaa", + "outputSha256": "bbbb", + "routing": { "model": "gpt-4o-mini", "savingsPct": 78 }, + "payment": { + "network": "eip155:8453", + "asset": "0x8335", + "amount": "3000", + "amountUsdc": 0.003, + "payTo": "0xe903", + "tx": "0xabc" + } + }); + assert_eq!( + canonical_sha256_hex(&receipt), + "cff938c0db919fe334bc4e70b8ac545e2485537be66e381491af68276ad218a9" + ); + } + #[test] fn routing_parsed_from_headers() { let mut h = reqwest::header::HeaderMap::new(); diff --git a/packages/blockrun-py/README.md b/packages/blockrun-py/README.md new file mode 100644 index 000000000..90eee7d88 --- /dev/null +++ b/packages/blockrun-py/README.md @@ -0,0 +1,57 @@ +# covenant-blockrun + +A trust receipt for every [BlockRun](https://blockrun.ai) call, for Python. + +BlockRun settles your agent's paid calls over x402. It proves the money moved. It +does not bind that payment to what you asked for, which model actually served the +request, or what came back. This package wraps your BlockRun client's transport +and emits a **Covenant receipt** for each call — the same receipt the Covenant +daemon and `@covenant-org/blockrun` produce, so anyone can check it. + +It never changes how the call is made or how the money moves. + +## Install + +```bash +pip install covenant-blockrun +``` + +## Use + +BlockRun's SDK signs x402 payments inside a custom httpx transport. Wrap it: + +```python +import httpx +from covenant_blockrun import ReceiptTransport + +def on_receipt(receipt): + # plain data — persist it, log it, post it to your audit trail + print(receipt.verdict, receipt.model_served, receipt.payment.tx) + +transport = ReceiptTransport(httpx.HTTPTransport(), on_receipt=on_receipt) +http_client = httpx.Client(transport=transport) + +# hand http_client to your BlockRun / OpenAI-compatible client +``` + +Each completed call yields a `CallReceipt`. `receipt.verdict` is the point: +`delivered` when you got the model you asked for, `substituted` when the router +swapped it, `unverified` when no served model was reported — turning "cheapest +capable model" from a claim into something checkable. + +## Verify a receipt + +Offline, no network, no payment: + +```python +from covenant_blockrun import verify_receipt + +result = verify_receipt(receipt) +# {"valid": ..., "digest": ..., "verdictConsistent": ..., "expectedVerdict": ...} +``` + +`digest()` and the hashes are RFC 8785 (JCS) SHA-256, byte-identical to the Rust +`covenant-blockrun` crate and `@covenant-org/blockrun` — a receipt built here +verifies in the daemon and the TypeScript SDK, and vice versa. + +Apache-2.0. diff --git a/packages/blockrun-py/pyproject.toml b/packages/blockrun-py/pyproject.toml new file mode 100644 index 000000000..44b13d5e8 --- /dev/null +++ b/packages/blockrun-py/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "covenant-blockrun" +version = "0.1.0" +description = "Covenant trust receipts for BlockRun x402 calls: wrap your client's transport, get a verifiable receipt binding requested model, served model, and settlement for every call." +readme = "README.md" +license = "Apache-2.0" +requires-python = ">=3.9" +keywords = ["blockrun", "x402", "covenant", "agent", "receipt", "provenance"] + +[project.urls] +Homepage = "https://opencovenant.org" +Repository = "https://github.com/open-covenant/covenant" + +[tool.hatch.build.targets.wheel] +packages = ["src/covenant_blockrun"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/packages/blockrun-py/src/covenant_blockrun/__init__.py b/packages/blockrun-py/src/covenant_blockrun/__init__.py new file mode 100644 index 000000000..626191d28 --- /dev/null +++ b/packages/blockrun-py/src/covenant_blockrun/__init__.py @@ -0,0 +1,49 @@ +"""covenant-blockrun — a trust receipt for every BlockRun x402 call. + +Wrap your BlockRun client's httpx transport with :class:`ReceiptTransport` and +each paid call emits a :class:`CallReceipt` binding the requested model, the +model actually served, the input/output hashes, and the settlement — the same +receipt the Covenant daemon and ``@covenant-org/blockrun`` produce, verifiable +with :func:`verify_receipt`. +""" + +from .challenge import decode_challenge, pick_accept +from .decorator import OnReceipt, ReceiptTransport +from .jcs import canonicalize +from .receipt import ( + PROVIDER, + VERDICT_DELIVERED, + VERDICT_SUBSTITUTED, + VERDICT_UNVERIFIED, + CallReceipt, + PaymentInfo, + RoutingClaim, + build_receipt, + canonical_sha256_hex, + payment_from_accept, + verdict_for, + verify_receipt, +) + +__version__ = "0.1.0" + +__all__ = [ + "CallReceipt", + "PaymentInfo", + "RoutingClaim", + "ReceiptTransport", + "OnReceipt", + "build_receipt", + "verify_receipt", + "verdict_for", + "canonical_sha256_hex", + "canonicalize", + "payment_from_accept", + "decode_challenge", + "pick_accept", + "PROVIDER", + "VERDICT_DELIVERED", + "VERDICT_SUBSTITUTED", + "VERDICT_UNVERIFIED", + "__version__", +] diff --git a/packages/blockrun-py/src/covenant_blockrun/challenge.py b/packages/blockrun-py/src/covenant_blockrun/challenge.py new file mode 100644 index 000000000..4aeabd80c --- /dev/null +++ b/packages/blockrun-py/src/covenant_blockrun/challenge.py @@ -0,0 +1,35 @@ +"""Decoding BlockRun's x402 402 challenge (base64 in the ``x-payment-required`` +header, mirrored in ``www-authenticate: X402 requirements="…"``).""" + +from __future__ import annotations + +import base64 +import json +from typing import Any, Optional + + +def decode_challenge(header_value: str) -> dict[str, Any]: + blob = _extract_requirements(header_value) + raw = base64.b64decode(blob.strip()) + parsed = json.loads(raw.decode("utf-8")) + if not isinstance(parsed.get("accepts"), list): + parsed["accepts"] = [] + return parsed + + +def pick_accept(challenge: dict[str, Any], network: Optional[str] = None) -> Optional[dict[str, Any]]: + accepts = challenge.get("accepts") or [] + if not accepts: + return None + if network: + for a in accepts: + if a.get("network") == network: + return a + return accepts[0] + + +def _extract_requirements(value: str) -> str: + idx = value.find("requirements=") + if idx >= 0: + return value[idx + len("requirements=") :].strip().strip('"') + return value.strip() diff --git a/packages/blockrun-py/src/covenant_blockrun/decorator.py b/packages/blockrun-py/src/covenant_blockrun/decorator.py new file mode 100644 index 000000000..c62e09f8b --- /dev/null +++ b/packages/blockrun-py/src/covenant_blockrun/decorator.py @@ -0,0 +1,178 @@ +"""The drop-in wedge for Python: an httpx transport that emits a Covenant +receipt for every BlockRun call, without changing how the call is made or how +the money moves. + +BlockRun's Python SDK signs x402 payments inside a custom httpx transport +(``AnthropicClient`` wraps ``anthropic.Anthropic`` this way). Wrap that transport +with :class:`ReceiptTransport` and every settled call yields a receipt. The +transport is duck-typed: it only needs a ``handle_request(request) -> response`` +method, so it works with httpx sync transports without importing httpx. +""" + +from __future__ import annotations + +import json +from typing import Any, Callable, Optional + +from .challenge import decode_challenge, pick_accept +from .receipt import CallReceipt, PaymentInfo, RoutingClaim, build_receipt, payment_from_accept + +OnReceipt = Callable[[CallReceipt], None] + +RECEIPT_HEADER = "x-payment-receipt" +REQUIRED_HEADER = "x-payment-required" + + +class ReceiptTransport: + """Wrap an httpx-style transport. Pass the result as ``transport=`` (or the + inner transport of your BlockRun client). ``on_receipt`` is called once per + completed BlockRun exchange.""" + + def __init__( + self, + inner: Any, + on_receipt: OnReceipt, + *, + swallow_receipt_errors: bool = True, + ) -> None: + self._inner = inner + self._on_receipt = on_receipt + self._swallow = swallow_receipt_errors + # Challenge stash keyed by a request signature, so a 402 pairs with its + # paid retry. + self._pending: dict[str, dict[str, Any]] = {} + + def handle_request(self, request: Any) -> Any: + key = _request_key(request) + response = self._inner.handle_request(request) + status = _status(response) + + if status == 402: + header = _header(response, REQUIRED_HEADER) or _header(response, "www-authenticate") + if header: + try: + self._pending[key] = decode_challenge(header) + except Exception: # noqa: BLE001 - a bad challenge is not fatal + pass + return response + + try: + self._emit(request, response, key) + except Exception: # noqa: BLE001 + if not self._swallow: + raise + return response + + def _emit(self, request: Any, response: Any, key: str) -> None: + req_body = _parse_json(_request_body(request)) + resp_body = _parse_json(_response_body(response)) + routing = _read_routing(response) + tx = _header(response, RECEIPT_HEADER) + challenge = self._pending.pop(key, None) + accept = pick_accept(challenge) if challenge else None + payment: PaymentInfo = ( + payment_from_accept(accept, tx) if accept else PaymentInfo(tx=tx) + ) + receipt = build_receipt( + endpoint=_path(request), + request=req_body, + response=resp_body, + payment=payment, + routing=routing, + ) + self._on_receipt(receipt) + + def close(self) -> None: + close = getattr(self._inner, "close", None) + if callable(close): + close() + + +def _read_routing(response: Any) -> RoutingClaim: + def first(*names: str) -> Optional[str]: + for n in names: + v = _header(response, n) + if v: + return v + return None + + routing = RoutingClaim( + profile=first("x-clawrouter-profile", "x-blockrun-profile"), + model=first("x-clawrouter-model", "x-blockrun-model", "x-model"), + ) + savings = first("x-clawrouter-savings", "x-blockrun-savings") + if savings: + try: + routing.savings_pct = float(savings.rstrip("%").strip()) + except ValueError: + pass + return routing + + +# --- duck-typed httpx accessors (no httpx import required) --- + + +def _status(response: Any) -> int: + return int(getattr(response, "status_code", 0)) + + +def _header(obj: Any, name: str) -> Optional[str]: + headers = getattr(obj, "headers", None) + if headers is None: + return None + try: + val = headers.get(name) + except AttributeError: + return None + return str(val) if val is not None else None + + +def _path(request: Any) -> str: + url = getattr(request, "url", None) + if url is None: + return "" + path = getattr(url, "path", None) + return str(path) if path is not None else str(url) + + +def _request_key(request: Any) -> str: + method = str(getattr(request, "method", "GET")) + url = str(getattr(request, "url", "")) + return f"{method} {url} {_request_body(request) or ''}" + + +def _request_body(request: Any) -> Optional[str]: + content = getattr(request, "content", None) + if content is None: + read = getattr(request, "read", None) + if callable(read): + content = read() + if content is None: + return None + if isinstance(content, (bytes, bytearray)): + return content.decode("utf-8", "replace") + return str(content) + + +def _response_body(response: Any) -> Optional[str]: + # httpx buffers the body on `.content` after `.read()`. + read = getattr(response, "read", None) + if callable(read): + try: + read() + except Exception: # noqa: BLE001 + pass + content = getattr(response, "content", None) + if isinstance(content, (bytes, bytearray)): + return content.decode("utf-8", "replace") + text = getattr(response, "text", None) + return str(text) if text is not None else None + + +def _parse_json(text: Optional[str]) -> Any: + if not text: + return {} + try: + return json.loads(text) + except (ValueError, TypeError): + return {} diff --git a/packages/blockrun-py/src/covenant_blockrun/jcs.py b/packages/blockrun-py/src/covenant_blockrun/jcs.py new file mode 100644 index 000000000..8ac251db7 --- /dev/null +++ b/packages/blockrun-py/src/covenant_blockrun/jcs.py @@ -0,0 +1,51 @@ +"""RFC 8785 JSON Canonicalization, matching the Rust ``serde_jcs`` the +``covenant-blockrun`` crate hashes with and the ``@covenant-org/blockrun`` TS +package, so a receipt's digest is identical across all three. + +Object keys are sorted by UTF-16 code unit, arrays keep their order, and there +is no insignificant whitespace. For the ASCII strings and simple numbers a +BlockRun receipt carries, this coincides with the strict spec. +""" + +from __future__ import annotations + +import json +from typing import Any + + +def canonicalize(value: Any) -> str: + return _encode(value) + + +def _encode(value: Any) -> str: + if value is None: + return "null" + if value is True: + return "true" + if value is False: + return "false" + if isinstance(value, str): + return json.dumps(value, ensure_ascii=False) + if isinstance(value, int): + return str(value) + if isinstance(value, float): + if value != value or value in (float("inf"), float("-inf")): + raise ValueError("cannot canonicalize a non-finite number") + # json.dumps gives the ECMAScript-style shortest round-trip for the + # value range receipts use. + return json.dumps(value) + if isinstance(value, (list, tuple)): + return "[" + ",".join(_encode(v) for v in value) + "]" + if isinstance(value, dict): + items = [(k, v) for k, v in value.items() if v is not None] + # Code-point sort; coincides with the JS/Rust UTF-16 code-unit sort for + # the BMP keys a receipt uses. + items.sort(key=lambda kv: kv[0]) + return ( + "{" + + ",".join( + json.dumps(k, ensure_ascii=False) + ":" + _encode(v) for k, v in items + ) + + "}" + ) + raise TypeError(f"cannot canonicalize value of type {type(value).__name__}") diff --git a/packages/blockrun-py/src/covenant_blockrun/receipt.py b/packages/blockrun-py/src/covenant_blockrun/receipt.py new file mode 100644 index 000000000..85c0e2bdf --- /dev/null +++ b/packages/blockrun-py/src/covenant_blockrun/receipt.py @@ -0,0 +1,168 @@ +"""The Covenant receipt for a BlockRun paid call. Field names and hashing match +the ``covenant-blockrun`` Rust crate and ``@covenant-org/blockrun``, so a receipt +built here verifies anywhere. +""" + +from __future__ import annotations + +import hashlib +from dataclasses import dataclass, field +from typing import Any, Optional + +from .jcs import canonicalize + +PROVIDER = "blockrun" + +VERDICT_DELIVERED = "delivered" +VERDICT_SUBSTITUTED = "substituted" +VERDICT_UNVERIFIED = "unverified" + +USDC_DECIMALS = 6 + + +@dataclass +class PaymentInfo: + network: str = "" + asset: str = "" + amount: str = "0" + amount_usdc: float = 0.0 + pay_to: str = "" + tx: Optional[str] = None + + def to_json(self) -> dict[str, Any]: + out: dict[str, Any] = { + "network": self.network, + "asset": self.asset, + "amount": self.amount, + "amountUsdc": self.amount_usdc, + "payTo": self.pay_to, + } + if self.tx is not None: + out["tx"] = self.tx + return out + + +@dataclass +class RoutingClaim: + profile: Optional[str] = None + model: Optional[str] = None + savings_pct: Optional[float] = None + + def is_empty(self) -> bool: + return self.profile is None and self.model is None and self.savings_pct is None + + def to_json(self) -> dict[str, Any]: + out: dict[str, Any] = {} + if self.profile is not None: + out["profile"] = self.profile + if self.model is not None: + out["model"] = self.model + if self.savings_pct is not None: + out["savingsPct"] = self.savings_pct + return out + + +@dataclass +class CallReceipt: + endpoint: str + model_requested: str + model_served: str + verdict: str + input_sha256: str + output_sha256: str + payment: PaymentInfo + routing: RoutingClaim = field(default_factory=RoutingClaim) + provider: str = PROVIDER + + def to_json(self) -> dict[str, Any]: + out: dict[str, Any] = { + "provider": self.provider, + "endpoint": self.endpoint, + "modelRequested": self.model_requested, + "modelServed": self.model_served, + "verdict": self.verdict, + "inputSha256": self.input_sha256, + "outputSha256": self.output_sha256, + "payment": self.payment.to_json(), + } + if not self.routing.is_empty(): + out["routing"] = self.routing.to_json() + return out + + def digest(self) -> str: + return canonical_sha256_hex(self.to_json()) + + def expected_verdict(self) -> str: + return verdict_for(self.model_requested, self.model_served) + + +def canonical_sha256_hex(value: Any) -> str: + return hashlib.sha256(canonicalize(value).encode("utf-8")).hexdigest() + + +def verdict_for(requested: str, served: str) -> str: + if not served: + return VERDICT_UNVERIFIED + return VERDICT_DELIVERED if requested.lower() == served.lower() else VERDICT_SUBSTITUTED + + +def build_receipt( + endpoint: str, + request: Any, + response: Any, + payment: PaymentInfo, + routing: Optional[RoutingClaim] = None, +) -> CallReceipt: + routing = routing or RoutingClaim() + model_requested = _read_model(request) or "" + model_served = _read_model(response) or (routing.model or "") + return CallReceipt( + endpoint=endpoint, + model_requested=model_requested, + model_served=model_served, + verdict=verdict_for(model_requested, model_served), + input_sha256=canonical_sha256_hex(request), + output_sha256=canonical_sha256_hex(response), + payment=payment, + routing=routing, + ) + + +def verify_receipt(receipt: CallReceipt, expected_digest: Optional[str] = None) -> dict[str, Any]: + """Re-check a receipt offline: recompute its digest and confirm the stated + verdict matches the requested/served pair.""" + digest = receipt.digest() + expected_verdict = verdict_for(receipt.model_requested, receipt.model_served) + verdict_consistent = expected_verdict == receipt.verdict + digest_matches = None if expected_digest is None else expected_digest == digest + return { + "valid": verdict_consistent and (True if digest_matches is None else digest_matches), + "digest": digest, + "digestMatches": digest_matches, + "verdictConsistent": verdict_consistent, + "statedVerdict": receipt.verdict, + "expectedVerdict": expected_verdict, + } + + +def payment_from_accept(accept: dict[str, Any], tx: Optional[str] = None) -> PaymentInfo: + amount = str(accept.get("amount", "0")) + try: + usdc = int(amount) / (10**USDC_DECIMALS) + except ValueError: + usdc = 0.0 + return PaymentInfo( + network=str(accept.get("network", "")), + asset=str(accept.get("asset", "")), + amount=amount, + amount_usdc=usdc, + pay_to=str(accept.get("payTo", "")), + tx=tx, + ) + + +def _read_model(value: Any) -> Optional[str]: + if isinstance(value, dict): + m = value.get("model") + return m if isinstance(m, str) else None + return None diff --git a/packages/blockrun-py/tests/test_receipt.py b/packages/blockrun-py/tests/test_receipt.py new file mode 100644 index 000000000..c27f18b97 --- /dev/null +++ b/packages/blockrun-py/tests/test_receipt.py @@ -0,0 +1,153 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from covenant_blockrun import ( # noqa: E402 + ReceiptTransport, + build_receipt, + canonicalize, + decode_challenge, + payment_from_accept, + pick_accept, + verify_receipt, +) + +# The exact base64 BlockRun returns for a default gpt-4o-mini 402. +LIVE_B64 = ( + "eyJ4NDAyVmVyc2lvbiI6MiwiYWNjZXB0cyI6W3sic2NoZW1lIjoiZXhhY3QiLCJuZXR3b3JrIjoiZWlwMTU1Ojg0NTMi" + "LCJhbW91bnQiOiIzMDAwIiwiYXNzZXQiOiIweDgzMzU4OWZDRDZlRGI2RTA4ZjRjN0MzMkQ0ZjcxYjU0YmRBMDI5MTMi" + "LCJwYXlUbyI6IjB4ZTkwMzAwMTRGNURBZTIxN2QwQTE1MmYwMkEwNDM1NjdiMTZjMWFCZiIsIm1heFRpbWVvdXRTZWNv" + "bmRzIjozMDAsImV4dHJhIjp7Im5hbWUiOiJVU0QgQ29pbiIsInZlcnNpb24iOiIyIn19XSwicmVzb3VyY2UiOnsidXJs" + "IjoiaHR0cHM6Ly9ibG9ja3J1bi5haS9hcGkvdjEvY2hhdC9jb21wbGV0aW9ucyIsImRlc2NyaXB0aW9uIjoiR1BULTRv" + "IE1pbmkgQVBJIGNhbGwgKH4xNyBpbnB1dCwgODE5MiBtYXggb3V0cHV0IHRva2VucykiLCJtaW1lVHlwZSI6ImFwcGxp" + "Y2F0aW9uL2pzb24ifX0=" +) + + +def _payment(): + return payment_from_accept( + {"network": "eip155:8453", "asset": "0x8335", "amount": "3000", "payTo": "0xe903"}, + tx="0xabc", + ) + + +def test_jcs_sorts_keys(): + assert canonicalize({"b": 1, "a": 2}) == '{"a":2,"b":1}' + assert canonicalize({"x": 1, "y": [3, 2]}) == canonicalize({"y": [3, 2], "x": 1}) + + +def test_decode_live_challenge(): + c = decode_challenge(LIVE_B64) + a = pick_accept(c, "eip155:8453") + assert a["amount"] == "3000" + assert a["payTo"] == "0xe9030014F5DAe217d0A152f02A043567b16c1aBf" + + +def test_decode_from_www_authenticate(): + c = decode_challenge(f'X402 requirements="{LIVE_B64}"') + assert len(c["accepts"]) == 1 + + +def test_payment_amount_projects_to_usdc(): + p = payment_from_accept({"amount": "3000"}) + assert abs(p.amount_usdc - 0.003) < 1e-9 + + +def test_delivered_when_served_matches(): + r = build_receipt( + "/v1/chat/completions", + {"model": "gpt-4o-mini", "messages": []}, + {"model": "gpt-4o-mini", "choices": []}, + _payment(), + ) + assert r.verdict == "delivered" + + +def test_substituted_when_router_swaps(): + r = build_receipt("/x", {"model": "gpt-4o"}, {"model": "gpt-4o-mini"}, _payment()) + assert r.verdict == "substituted" + + +def test_digest_stable_across_key_order(): + a = build_receipt("/x", {"model": "m"}, {"model": "m", "id": "1", "choices": []}, _payment()) + b = build_receipt("/x", {"model": "m"}, {"choices": [], "id": "1", "model": "m"}, _payment()) + assert a.digest() == b.digest() + + +def test_verify_clean_and_tampered(): + r = build_receipt("/x", {"model": "gpt-4o"}, {"model": "cheap"}, _payment()) + ok = verify_receipt(r, r.digest()) + assert ok["valid"] is True + r.verdict = "delivered" + bad = verify_receipt(r) + assert bad["verdictConsistent"] is False + assert bad["expectedVerdict"] == "substituted" + + +# --- transport wrapper (fakes, no httpx dependency) --- + + +class _Url: + def __init__(self, url): + self._url = url + self.path = "/" + url.split("://", 1)[-1].split("/", 1)[-1] if "://" in url else url + + def __str__(self): + return self._url + + +class _Req: + def __init__(self, method, url, content): + self.method = method + self.url = _Url(url) + self.content = content.encode() if isinstance(content, str) else content + + +class _Headers(dict): + def get(self, k, default=None): + return super().get(k.lower(), default) + + +class _Resp: + def __init__(self, status, headers, body): + self.status_code = status + self.headers = _Headers({k.lower(): v for k, v in headers.items()}) + self.content = body.encode() if isinstance(body, str) else body + + def read(self): + return self.content + + +class _FakeInner: + def handle_request(self, request): + has_payment = request.headers_paid if hasattr(request, "headers_paid") else False + if not has_payment: + return _Resp(402, {"x-payment-required": LIVE_B64}, "") + return _Resp( + 200, + {"x-payment-receipt": "0xdeadbeef", "x-clawrouter-model": "gpt-4o-mini"}, + '{"model": "gpt-4o-mini", "choices": []}', + ) + + +def test_transport_pairs_402_with_paid_retry(): + received = [] + transport = ReceiptTransport(_FakeInner(), on_receipt=received.append, swallow_receipt_errors=False) + + body = '{"model": "gpt-4o-mini", "messages": []}' + url = "https://blockrun.ai/api/v1/chat/completions" + + first = _Req("POST", url, body) + transport.handle_request(first) # 402, stashes challenge + + retry = _Req("POST", url, body) + retry.headers_paid = True + transport.handle_request(retry) # paid, emits receipt + + assert len(received) == 1 + r = received[0] + assert r.verdict == "delivered" + assert r.payment.tx == "0xdeadbeef" + assert r.payment.amount == "3000" + assert r.endpoint == "/api/v1/chat/completions" diff --git a/packages/blockrun-ts/README.md b/packages/blockrun-ts/README.md new file mode 100644 index 000000000..3a6d25bde --- /dev/null +++ b/packages/blockrun-ts/README.md @@ -0,0 +1,79 @@ +# @covenant-org/blockrun + +A trust receipt for every [BlockRun](https://blockrun.ai) call. + +BlockRun settles your agent's paid calls over x402. It proves the money moved. It +does not bind that payment to what you asked for, which model actually served the +request, or what came back. This package sits around your `fetch` and emits a +**Covenant receipt** for each call — the same receipt the Covenant daemon +produces, so anyone can check it. + +It never changes how the call is made or how the money moves. + +## Install + +```bash +npm install @covenant-org/blockrun +``` + +## Use + +Wrap the `fetch` your BlockRun client uses: + +```ts +import { withCovenantReceipts } from "@covenant-org/blockrun"; + +const fetchWithReceipts = withCovenantReceipts(globalThis.fetch, { + onReceipt: (receipt) => { + // persist it, log it, post it to your audit trail — it's plain JSON + console.log(receipt.verdict, receipt.modelServed, receipt.payment.tx); + }, +}); + +// hand it to your BlockRun / OpenAI-compatible client +const client = new OpenAI({ fetch: fetchWithReceipts, /* … */ }); +``` + +Each completed call yields a `CallReceipt`: + +```jsonc +{ + "provider": "blockrun", + "endpoint": "/api/v1/chat/completions", + "modelRequested": "gpt-4o", + "modelServed": "gpt-4o-mini", // the router substituted a cheaper model + "verdict": "substituted", // delivered | substituted | unverified + "inputSha256": "…", + "outputSha256": "…", + "routing": { "model": "gpt-4o-mini", "savingsPct": 78 }, + "payment": { + "network": "eip155:8453", + "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "amount": "3000", + "amountUsdc": 0.003, + "payTo": "0xe9030014F5DAe217d0A152f02A043567b16c1aBf", + "tx": "0x…" // the on-chain settlement signature + } +} +``` + +`verdict` is the point: `delivered` when you got the model you asked for, +`substituted` when the router swapped it, `unverified` when no served model was +reported. It turns "cheapest capable model" from a claim into something checkable. + +## Verify a receipt + +Offline, no network, no payment: + +```ts +import { verifyReceipt } from "@covenant-org/blockrun"; + +const result = await verifyReceipt(receipt); +// { valid, digest, verdictConsistent, statedVerdict, expectedVerdict } +``` + +`digest` and the hashes are RFC 8785 (JCS) SHA-256, identical to the Covenant +`covenant-blockrun` crate — a receipt built here verifies in the daemon and vice +versa. + +Apache-2.0. diff --git a/packages/blockrun-ts/package.json b/packages/blockrun-ts/package.json new file mode 100644 index 000000000..44188b1ff --- /dev/null +++ b/packages/blockrun-ts/package.json @@ -0,0 +1,45 @@ +{ + "name": "@covenant-org/blockrun", + "version": "0.1.0", + "description": "Covenant trust receipts for BlockRun x402 calls: wrap your fetch, get a verifiable receipt binding requested model, served model, and settlement for every call.", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/open-covenant/covenant.git" + }, + "homepage": "https://opencovenant.org", + "keywords": [ + "blockrun", + "x402", + "covenant", + "agent", + "receipt", + "provenance" + ], + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "dev": "tsc -p tsconfig.json --watch", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run --passWithNoTests", + "clean": "rm -rf dist tsconfig.tsbuildinfo" + }, + "devDependencies": { + "typescript": "^5.7.2", + "vitest": "^2.1.8" + } +} diff --git a/packages/blockrun-ts/src/__tests__/receipt.test.ts b/packages/blockrun-ts/src/__tests__/receipt.test.ts new file mode 100644 index 000000000..a038854bd --- /dev/null +++ b/packages/blockrun-ts/src/__tests__/receipt.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "vitest"; +import { canonicalize } from "../jcs.js"; +import { decodeChallenge, pickAccept } from "../challenge.js"; +import { buildReceipt, verifyReceipt, receiptDigest } from "../receipt.js"; +import { withCovenantReceipts, type FetchLike } from "../decorator.js"; + +// The exact base64 BlockRun returns for a default gpt-4o-mini 402. +const LIVE_B64 = + "eyJ4NDAyVmVyc2lvbiI6MiwiYWNjZXB0cyI6W3sic2NoZW1lIjoiZXhhY3QiLCJuZXR3b3JrIjoiZWlwMTU1Ojg0NTMiLCJhbW91bnQiOiIzMDAwIiwiYXNzZXQiOiIweDgzMzU4OWZDRDZlRGI2RTA4ZjRjN0MzMkQ0ZjcxYjU0YmRBMDI5MTMiLCJwYXlUbyI6IjB4ZTkwMzAwMTRGNURBZTIxN2QwQTE1MmYwMkEwNDM1NjdiMTZjMWFCZiIsIm1heFRpbWVvdXRTZWNvbmRzIjozMDAsImV4dHJhIjp7Im5hbWUiOiJVU0QgQ29pbiIsInZlcnNpb24iOiIyIn19XSwicmVzb3VyY2UiOnsidXJsIjoiaHR0cHM6Ly9ibG9ja3J1bi5haS9hcGkvdjEvY2hhdC9jb21wbGV0aW9ucyIsImRlc2NyaXB0aW9uIjoiR1BULTRvIE1pbmkgQVBJIGNhbGwgKH4xNyBpbnB1dCwgODE5MiBtYXggb3V0cHV0IHRva2VucykiLCJtaW1lVHlwZSI6ImFwcGxpY2F0aW9uL2pzb24ifX0="; + +describe("jcs", () => { + it("sorts keys and drops whitespace", () => { + expect(canonicalize({ b: 1, a: 2 })).toBe('{"a":2,"b":1}'); + }); + it("is stable across key order", () => { + expect(canonicalize({ x: 1, y: [3, 2] })).toBe(canonicalize({ y: [3, 2], x: 1 })); + }); +}); + +describe("challenge", () => { + it("decodes the live header", () => { + const c = decodeChallenge(LIVE_B64); + const a = pickAccept(c, "eip155:8453")!; + expect(a.amount).toBe("3000"); + expect(a.payTo).toBe("0xe9030014F5DAe217d0A152f02A043567b16c1aBf"); + }); + it("extracts from www-authenticate", () => { + const c = decodeChallenge(`X402 requirements="${LIVE_B64}"`); + expect(c.accepts).toHaveLength(1); + }); +}); + +describe("receipt", () => { + const payment = { + network: "eip155:8453", + asset: "0x8335", + amount: "3000", + amountUsdc: 0.003, + payTo: "0xe903", + tx: "0xabc", + }; + + it("marks delivered when served matches requested", async () => { + const r = await buildReceipt({ + endpoint: "/v1/chat/completions", + request: { model: "gpt-4o-mini", messages: [] }, + response: { model: "gpt-4o-mini", choices: [] }, + payment, + }); + expect(r.verdict).toBe("delivered"); + }); + + it("marks substituted when the router swaps the model", async () => { + const r = await buildReceipt({ + endpoint: "/x", + request: { model: "gpt-4o" }, + response: { model: "gpt-4o-mini" }, + payment, + }); + expect(r.verdict).toBe("substituted"); + }); + + it("digest is stable across response key order", async () => { + const a = await buildReceipt({ + endpoint: "/x", + request: { model: "m" }, + response: { model: "m", id: "1", choices: [] }, + payment, + }); + const b = await buildReceipt({ + endpoint: "/x", + request: { model: "m" }, + response: { choices: [], id: "1", model: "m" }, + payment, + }); + expect(await receiptDigest(a)).toBe(await receiptDigest(b)); + }); + + it("verify accepts a clean receipt and its digest", async () => { + const r = await buildReceipt({ + endpoint: "/x", + request: { model: "m" }, + response: { model: "m" }, + payment, + }); + const digest = await receiptDigest(r); + const v = await verifyReceipt(r, digest); + expect(v.valid).toBe(true); + expect(v.digestMatches).toBe(true); + }); + + it("verify catches a tampered verdict", async () => { + const r = await buildReceipt({ + endpoint: "/x", + request: { model: "gpt-4o" }, + response: { model: "cheap" }, + payment, + }); + r.verdict = "delivered"; + const v = await verifyReceipt(r); + expect(v.verdictConsistent).toBe(false); + expect(v.expectedVerdict).toBe("substituted"); + }); +}); + +describe("decorator", () => { + it("pairs a 402 with its paid retry into one receipt", async () => { + const base: FetchLike = async (_input, init) => { + const paid = typeof init?.body === "string" && init.headers + ? new Headers(init.headers).has("x-payment") + : false; + if (!paid) { + return new Response("", { status: 402, headers: { "x-payment-required": LIVE_B64 } }); + } + return new Response(JSON.stringify({ model: "gpt-4o-mini", choices: [] }), { + status: 200, + headers: { "x-payment-receipt": "0xdeadbeef", "x-clawrouter-model": "gpt-4o-mini" }, + }); + }; + + const receipts: import("../receipt.js").CallReceipt[] = []; + const wrapped = withCovenantReceipts(base, { + onReceipt: (r) => { + receipts.push(r); + }, + rethrowReceiptErrors: true, + }); + + const body = JSON.stringify({ model: "gpt-4o-mini", messages: [] }); + const url = "https://blockrun.ai/api/v1/chat/completions"; + // First call: 402 (stashes the challenge, no receipt). + await wrapped(url, { method: "POST", body }); + // Paid retry: emits the receipt. + await wrapped(url, { method: "POST", body, headers: { "x-payment": "sig" } }); + // Let the fire-and-forget receipt build settle. + await new Promise((r) => setTimeout(r, 10)); + + expect(receipts).toHaveLength(1); + const r = receipts[0]; + expect(r.verdict).toBe("delivered"); + expect(r.payment.tx).toBe("0xdeadbeef"); + expect(r.payment.amount).toBe("3000"); + expect(r.payment.amountUsdc).toBeCloseTo(0.003); + expect(r.endpoint).toBe("/api/v1/chat/completions"); + }); +}); diff --git a/packages/blockrun-ts/src/challenge.ts b/packages/blockrun-ts/src/challenge.ts new file mode 100644 index 000000000..2a297e20f --- /dev/null +++ b/packages/blockrun-ts/src/challenge.ts @@ -0,0 +1,73 @@ +/** + * Decoding BlockRun's x402 402 challenge, carried base64 in the + * `x-payment-required` header (mirrored in `www-authenticate: X402 + * requirements="…"`). + */ +import type { PaymentInfo } from "./receipt.js"; + +const USDC_DECIMALS = 6; + +export interface Accept { + scheme: string; + network: string; + amount: string; + asset: string; + payTo: string; + maxTimeoutSeconds?: number; + extra?: { name?: string; version?: string }; +} + +export interface Challenge { + x402Version?: number; + accepts: Accept[]; + resource?: { url?: string; description?: string }; +} + +export function decodeChallenge(headerValue: string): Challenge { + const blob = extractRequirements(headerValue); + const json = new TextDecoder().decode(base64Decode(blob.trim())); + const parsed = JSON.parse(json) as Challenge; + if (!Array.isArray(parsed.accepts)) parsed.accepts = []; + return parsed; +} + +/** The first option matching `network`, else the first offered. */ +export function pickAccept(challenge: Challenge, network?: string): Accept | undefined { + if (network) { + return challenge.accepts.find((a) => a.network === network) ?? challenge.accepts[0]; + } + return challenge.accepts[0]; +} + +export function acceptToPayment(accept: Accept, tx?: string): PaymentInfo { + const amount = Number.parseInt(accept.amount, 10); + return { + network: accept.network, + asset: accept.asset, + amount: accept.amount, + amountUsdc: Number.isFinite(amount) ? amount / 10 ** USDC_DECIMALS : 0, + payTo: accept.payTo, + tx, + }; +} + +function extractRequirements(value: string): string { + const idx = value.indexOf("requirements="); + if (idx >= 0) { + return value + .slice(idx + "requirements=".length) + .trim() + .replace(/^"|"$/g, ""); + } + return value.trim(); +} + +function base64Decode(b64: string): Uint8Array { + if (typeof atob === "function") { + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; + } + return new Uint8Array(Buffer.from(b64, "base64")); +} diff --git a/packages/blockrun-ts/src/decorator.ts b/packages/blockrun-ts/src/decorator.ts new file mode 100644 index 000000000..6f56666ec --- /dev/null +++ b/packages/blockrun-ts/src/decorator.ts @@ -0,0 +1,160 @@ +/** + * The drop-in wedge: wrap a `fetch` so every BlockRun call emits a Covenant + * receipt, without changing how the call is made or the money moves. + * + * BlockRun's own client does the 402 → pay → retry loop through some `fetch`; + * we sit around that fetch, remember the challenge from the 402, and pair it + * with the settled retry (whose `x-payment-receipt` header carries the tx) to + * build a complete receipt. The response is passed through untouched. + */ +import { acceptToPayment, decodeChallenge, pickAccept } from "./challenge.js"; +import { buildReceipt, type CallReceipt, type PaymentInfo, type RoutingClaim } from "./receipt.js"; + +export type FetchLike = ( + input: string | URL | Request, + init?: RequestInit, +) => Promise; + +export type OnReceipt = (receipt: CallReceipt) => void | Promise; + +export interface WrapOptions { + /** Called once per settled (or free) BlockRun call with its receipt. */ + onReceipt: OnReceipt; + /** Errors thrown by onReceipt are swallowed by default; set to surface them. */ + rethrowReceiptErrors?: boolean; +} + +const RECEIPT_HEADER = "x-payment-receipt"; +const REQUIRED_HEADER = "x-payment-required"; + +/** + * Wrap a fetch. Returns a fetch with identical behaviour that additionally + * emits a receipt for each completed BlockRun exchange. + */ +export function withCovenantReceipts(baseFetch: FetchLike, opts: WrapOptions): FetchLike { + // Challenge stash, keyed by request signature, so a 402 seen on one call can + // be paired with its paid retry. Entries are short-lived and cleared on use. + const pending = new Map>(); + + return async (input, init) => { + const url = requestUrl(input); + const method = (init?.method ?? requestMethod(input) ?? "GET").toUpperCase(); + const bodyText = await readRequestBody(input, init); + const key = `${method} ${url} ${bodyText ?? ""}`; + + const res = await baseFetch(input, init); + + // 402: remember the challenge, do not emit a receipt yet. + if (res.status === 402) { + const header = res.headers.get(REQUIRED_HEADER) ?? res.headers.get("www-authenticate"); + if (header) { + try { + pending.set(key, decodeChallenge(header)); + } catch { + /* not a decodable challenge; ignore */ + } + } + return res; + } + + // Emit a receipt for the completed call. Clone so the caller still gets an + // unread body. + const clone = res.clone(); + void (async () => { + try { + const request = safeParse(bodyText); + const response = await safeJson(clone); + const routing = readRouting(res.headers); + const tx = res.headers.get(RECEIPT_HEADER) ?? undefined; + const challenge = pending.get(key); + pending.delete(key); + const accept = challenge ? pickAccept(challenge) : undefined; + const payment: PaymentInfo = accept + ? acceptToPayment(accept, tx) + : { network: "", asset: "", amount: "0", amountUsdc: 0, payTo: "", tx }; + const receipt = await buildReceipt({ + endpoint: endpointOf(url), + request, + response, + payment, + routing, + }); + await opts.onReceipt(receipt); + } catch (err) { + if (opts.rethrowReceiptErrors) throw err; + } + })(); + + return res; + }; +} + +function readRouting(headers: Headers): RoutingClaim { + const first = (...names: string[]): string | undefined => { + for (const n of names) { + const v = headers.get(n); + if (v) return v; + } + return undefined; + }; + const savings = first("x-clawrouter-savings", "x-blockrun-savings"); + const routing: RoutingClaim = { + profile: first("x-clawrouter-profile", "x-blockrun-profile"), + model: first("x-clawrouter-model", "x-blockrun-model", "x-model"), + }; + if (savings) { + const n = Number.parseFloat(savings.replace(/%$/, "").trim()); + if (Number.isFinite(n)) routing.savingsPct = n; + } + return routing; +} + +function requestUrl(input: string | URL | Request): string { + if (typeof input === "string") return input; + if (input instanceof URL) return input.toString(); + return input.url; +} + +function requestMethod(input: string | URL | Request): string | undefined { + return input instanceof Request ? input.method : undefined; +} + +async function readRequestBody( + input: string | URL | Request, + init?: RequestInit, +): Promise { + if (init?.body && typeof init.body === "string") return init.body; + if (input instanceof Request) { + try { + return await input.clone().text(); + } catch { + return undefined; + } + } + return undefined; +} + +function endpointOf(url: string): string { + try { + return new URL(url).pathname; + } catch { + return url; + } +} + +function safeParse(text: string | undefined): unknown { + if (!text) return {}; + try { + return JSON.parse(text); + } catch { + return {}; + } +} + +async function safeJson(res: Response): Promise { + try { + return await res.json(); + } catch { + return {}; + } +} diff --git a/packages/blockrun-ts/src/index.ts b/packages/blockrun-ts/src/index.ts new file mode 100644 index 000000000..75e253ec3 --- /dev/null +++ b/packages/blockrun-ts/src/index.ts @@ -0,0 +1,31 @@ +/** + * @covenant-org/blockrun — a trust receipt for every BlockRun call. + * + * Wrap your fetch with {@link withCovenantReceipts} and each paid BlockRun + * exchange emits a {@link CallReceipt} binding the requested model, the model + * actually served, the input/output hashes, and the settlement — the same + * receipt the Covenant daemon produces, verifiable with {@link verifyReceipt}. + */ +export { + buildReceipt, + canonicalSha256Hex, + receiptDigest, + verdictFor, + verifyReceipt, + PROVIDER, + VERDICT_DELIVERED, + VERDICT_SUBSTITUTED, + VERDICT_UNVERIFIED, +} from "./receipt.js"; +export type { + CallReceipt, + PaymentInfo, + RoutingClaim, + Verdict, + VerifyResult, +} from "./receipt.js"; +export { withCovenantReceipts } from "./decorator.js"; +export type { FetchLike, OnReceipt, WrapOptions } from "./decorator.js"; +export { decodeChallenge, pickAccept, acceptToPayment } from "./challenge.js"; +export type { Accept, Challenge } from "./challenge.js"; +export { canonicalize } from "./jcs.js"; diff --git a/packages/blockrun-ts/src/jcs.ts b/packages/blockrun-ts/src/jcs.ts new file mode 100644 index 000000000..edb3d5f38 --- /dev/null +++ b/packages/blockrun-ts/src/jcs.ts @@ -0,0 +1,39 @@ +/** + * RFC 8785 JSON Canonicalization Scheme, matching the Rust `serde_jcs` the + * `covenant-blockrun` crate hashes with, so a receipt's digest is identical + * whether it was built here or in the daemon. Object keys are sorted by UTF-16 + * code unit, arrays keep their order, numbers use the ECMAScript shortest + * round-trip (what `JSON.stringify` emits), and there is no insignificant + * whitespace. + */ +export function canonicalize(value: unknown): string { + if (value === null) return "null"; + if (typeof value === "boolean") return value ? "true" : "false"; + if (typeof value === "number") { + if (!Number.isFinite(value)) { + throw new Error("cannot canonicalize a non-finite number"); + } + return JSON.stringify(value); + } + if (typeof value === "string") return JSON.stringify(value); + if (Array.isArray(value)) { + return "[" + value.map(canonicalize).join(",") + "]"; + } + if (typeof value === "object") { + const obj = value as Record; + const keys = Object.keys(obj) + .filter((k) => obj[k] !== undefined) + .sort(codeUnitCompare); + return ( + "{" + + keys.map((k) => JSON.stringify(k) + ":" + canonicalize(obj[k])).join(",") + + "}" + ); + } + throw new Error(`cannot canonicalize value of type ${typeof value}`); +} + +/** Sort by UTF-16 code unit, which is what String comparison already does. */ +function codeUnitCompare(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} diff --git a/packages/blockrun-ts/src/receipt.ts b/packages/blockrun-ts/src/receipt.ts new file mode 100644 index 000000000..ef65df2a8 --- /dev/null +++ b/packages/blockrun-ts/src/receipt.ts @@ -0,0 +1,151 @@ +/** + * The Covenant receipt for a BlockRun paid call. Field names and hashing match + * the `covenant-blockrun` Rust crate exactly, so a receipt built here verifies + * in the daemon and vice versa. + */ +import { canonicalize } from "./jcs.js"; + +export const PROVIDER = "blockrun"; + +export const VERDICT_DELIVERED = "delivered"; +export const VERDICT_SUBSTITUTED = "substituted"; +export const VERDICT_UNVERIFIED = "unverified"; +export type Verdict = + | typeof VERDICT_DELIVERED + | typeof VERDICT_SUBSTITUTED + | typeof VERDICT_UNVERIFIED; + +export interface PaymentInfo { + network: string; + asset: string; + amount: string; + amountUsdc: number; + payTo: string; + /** On-chain settlement signature, from the `x-payment-receipt` header. */ + tx?: string; +} + +export interface RoutingClaim { + profile?: string; + model?: string; + savingsPct?: number; +} + +export interface CallReceipt { + provider: string; + endpoint: string; + modelRequested: string; + modelServed: string; + verdict: Verdict; + inputSha256: string; + outputSha256: string; + routing?: RoutingClaim; + payment: PaymentInfo; +} + +/** SHA-256 of the JCS-canonical form, lowercase hex. */ +export async function canonicalSha256Hex(value: unknown): Promise { + const bytes = new TextEncoder().encode(canonicalize(value)); + const digest = await sha256(bytes); + return hex(digest); +} + +export function verdictFor(requested: string, served: string): Verdict { + if (!served) return VERDICT_UNVERIFIED; + return requested.toLowerCase() === served.toLowerCase() + ? VERDICT_DELIVERED + : VERDICT_SUBSTITUTED; +} + +/** Build a receipt from one exchange. */ +export async function buildReceipt(args: { + endpoint: string; + request: unknown; + response: unknown; + payment: PaymentInfo; + routing?: RoutingClaim; +}): Promise { + const { endpoint, request, response, payment } = args; + const routing = args.routing ?? {}; + const modelRequested = readModel(request) ?? ""; + const modelServed = readModel(response) ?? routing.model ?? ""; + const receipt: CallReceipt = { + provider: PROVIDER, + endpoint, + modelRequested, + modelServed, + verdict: verdictFor(modelRequested, modelServed), + inputSha256: await canonicalSha256Hex(request), + outputSha256: await canonicalSha256Hex(response), + payment, + }; + if (routing.profile || routing.model || routing.savingsPct !== undefined) { + receipt.routing = routing; + } + return receipt; +} + +/** Stable digest over the receipt; the identity a signer or anchor binds to. */ +export async function receiptDigest(receipt: CallReceipt): Promise { + return canonicalSha256Hex(receipt); +} + +export interface VerifyResult { + valid: boolean; + digest: string; + digestMatches?: boolean; + verdictConsistent: boolean; + statedVerdict: string; + expectedVerdict: Verdict; +} + +/** + * Re-check a receipt offline: recompute its digest and confirm the stated + * verdict matches the requested/served pair. Optionally check the digest + * against an expected value. + */ +export async function verifyReceipt( + receipt: CallReceipt, + expectedDigest?: string, +): Promise { + const digest = await receiptDigest(receipt); + const expectedVerdict = verdictFor(receipt.modelRequested, receipt.modelServed); + const verdictConsistent = expectedVerdict === receipt.verdict; + const digestMatches = + expectedDigest === undefined ? undefined : expectedDigest === digest; + return { + valid: verdictConsistent && (digestMatches ?? true), + digest, + digestMatches, + verdictConsistent, + statedVerdict: receipt.verdict, + expectedVerdict, + }; +} + +function readModel(value: unknown): string | undefined { + if (value && typeof value === "object" && "model" in value) { + const m = (value as Record).model; + return typeof m === "string" ? m : undefined; + } + return undefined; +} + +async function sha256(bytes: Uint8Array): Promise { + const subtle = globalThis.crypto?.subtle; + if (subtle) { + // Copy into a plain ArrayBuffer-backed view so the type is BufferSource + // regardless of how the input Uint8Array was allocated. + const buf = new Uint8Array(bytes); + return new Uint8Array(await subtle.digest("SHA-256", buf.buffer)); + } + // Node without global WebCrypto (older runtimes). + const { createHash } = await import("node:crypto"); + return new Uint8Array(createHash("sha256").update(bytes).digest()); +} + +function hex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} diff --git a/packages/blockrun-ts/tsconfig.json b/packages/blockrun-ts/tsconfig.json new file mode 100644 index 000000000..c53f1db0b --- /dev/null +++ b/packages/blockrun-ts/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM"], + "types": ["node"], + "noEmit": false, + "tsBuildInfoFile": "tsconfig.tsbuildinfo" + }, + "include": ["src/**/*"], + "exclude": ["src/**/__tests__/**"] +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8b16e21bb..c54c7abde 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,5 @@ packages: + - packages/blockrun-ts - packages/config - packages/sap-bridge - packages/sdk From 458faf809ab629caa31a047f9e6354d4da600e29 Mon Sep 17 00:00:00 2001 From: Iko Rane Date: Mon, 20 Jul 2026 16:31:55 +0200 Subject: [PATCH 05/22] add blockrun-verify: x402-paid endpoint that verifies a BlockRun receipt + signs the result --- services/blockrun-verify/README.md | 48 + services/blockrun-verify/package-lock.json | 3185 +++++++++++++++++ services/blockrun-verify/package.json | 30 + services/blockrun-verify/render.yaml | 42 + .../src/__tests__/verify.test.ts | 103 + services/blockrun-verify/src/attest.ts | 83 + services/blockrun-verify/src/receipt.ts | 91 + services/blockrun-verify/src/server.ts | 201 ++ services/blockrun-verify/src/verify-route.ts | 34 + services/blockrun-verify/tsconfig.build.json | 7 + services/blockrun-verify/tsconfig.json | 18 + 11 files changed, 3842 insertions(+) create mode 100644 services/blockrun-verify/README.md create mode 100644 services/blockrun-verify/package-lock.json create mode 100644 services/blockrun-verify/package.json create mode 100644 services/blockrun-verify/render.yaml create mode 100644 services/blockrun-verify/src/__tests__/verify.test.ts create mode 100644 services/blockrun-verify/src/attest.ts create mode 100644 services/blockrun-verify/src/receipt.ts create mode 100644 services/blockrun-verify/src/server.ts create mode 100644 services/blockrun-verify/src/verify-route.ts create mode 100644 services/blockrun-verify/tsconfig.build.json create mode 100644 services/blockrun-verify/tsconfig.json diff --git a/services/blockrun-verify/README.md b/services/blockrun-verify/README.md new file mode 100644 index 000000000..a8d5ae33e --- /dev/null +++ b/services/blockrun-verify/README.md @@ -0,0 +1,48 @@ +# covenant-blockrun-verify + +A paid, public endpoint that verifies a [BlockRun](https://blockrun.ai) call +receipt — Covenant dogfooding x402, a trust service on the same rail the receipts +it checks were settled over. + +An agent or organization pays USDC over x402 and gets back a **Covenant-signed +statement** about their receipt: + +- its RFC 8785 (JCS) SHA-256 **digest**, +- whether the stated **verdict** matches the requested-vs-served model pair + (`delivered` / `substituted` / `unverified`), +- whether it carries a **settlement** tx, + +all wrapped in an ed25519 attestation that verifies against a published pubkey +with no trust in the server. + +## Endpoint + +``` +POST /x402/verify +{ "receipt": { … }, "expectedDigest": "…"? } +→ { "result": { valid, digest, verdictConsistent, expectedVerdict, settled }, "attestation": { … } } +``` + +Price defaults to 0.005 USDC (`BLOCKRUN_VERIFY_PRICE`, base units). The x402 +middleware fails closed: a failed settlement or a malformed receipt (>= 400) is +never charged. Discovery metadata is at `GET /.well-known/x402`. + +## Why it's trustworthy + +The digest is byte-identical to the `covenant-blockrun` Rust crate, +`@covenant-org/blockrun` (TS), and `covenant-blockrun` (Python) — a receipt built +by any of them verifies here, guarded by a shared cross-language digest test. The +verification result is signed, so the buyer pins the pubkey and checks the +statement themselves; the server is not trusted. + +## Run + +```bash +npm install && npm run build && npm start +``` + +Configuration mirrors the Covenant x402 seller (`X402_NETWORK`, +`COVENANT_BASE_PAYTO`, CDP credentials for Base mainnet settlement, +`COVENANT_ATTEST_KEYPAIR`). See `render.yaml` and `src/server.ts`. + +Apache-2.0. diff --git a/services/blockrun-verify/package-lock.json b/services/blockrun-verify/package-lock.json new file mode 100644 index 000000000..537ecc141 --- /dev/null +++ b/services/blockrun-verify/package-lock.json @@ -0,0 +1,3185 @@ +{ + "name": "covenant-blockrun-verify", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "covenant-blockrun-verify", + "version": "0.0.0", + "dependencies": { + "@coinbase/x402": "^2.1.0", + "@x402/core": "2.17.0", + "@x402/evm": "2.17.0", + "@x402/express": "2.17.0", + "@x402/extensions": "2.17.0", + "bs58": "^6.0.0", + "express": "^4.21.2" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^22.10.0", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } + }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", + "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", + "license": "MIT" + }, + "node_modules/@coinbase/cdp-sdk": { + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/@coinbase/cdp-sdk/-/cdp-sdk-1.53.0.tgz", + "integrity": "sha512-SO8pgoYXepohi18DE4XXpFuf0Tuetkus4zBpZ7tOZeJGIkaIdAkoMJXiX/rCPMfCVLObovw+213vpWkZtbNFfw==", + "license": "MIT", + "dependencies": { + "@solana-program/system": "^0.10.0", + "@solana-program/token": "^0.9.0", + "@solana/kit": "^5.5.1", + "abitype": "1.0.6", + "axios": "1.16.0", + "axios-retry": "^4.5.0", + "bs58": "^6.0.0", + "jose": "^6.2.0", + "md5": "^2.3.0", + "uncrypto": "^0.1.3", + "viem": "^2.47.0", + "zod": "^3.25.76" + }, + "peerDependencies": { + "@x402/core": "^2.16.0", + "@x402/evm": "^2.16.0", + "@x402/extensions": "^2.17.0", + "@x402/svm": "^2.16.0" + }, + "peerDependenciesMeta": { + "@x402/core": { + "optional": true + }, + "@x402/evm": { + "optional": true + }, + "@x402/extensions": { + "optional": true + }, + "@x402/svm": { + "optional": true + } + } + }, + "node_modules/@coinbase/x402": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@coinbase/x402/-/x402-2.1.0.tgz", + "integrity": "sha512-aKeM+cz//+FjzPVu/zgz7830x0KLtKarwCyxoeC71QgCn+Xcf0NhFpn3Qyw0H496y5YOuR/IQ67gP8DZ/hXFqQ==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@coinbase/cdp-sdk": "^1.29.0", + "@x402/core": "^2.0.0", + "viem": "^2.21.26", + "zod": "^3.24.2" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", + "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@signinwithethereum/siwe": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@signinwithethereum/siwe/-/siwe-4.2.0.tgz", + "integrity": "sha512-6W+oyKgMFUZRJI4o9P9mTMzrokkXfu3tq1/CfOJj9QrMqyPqujJqv1xnxUAqDQwWVyCA8TMg0Fxk/+gXrDg2Nw==", + "license": "Apache-2.0", + "dependencies": { + "@signinwithethereum/siwe-parser": "^4.2.0" + }, + "peerDependencies": { + "ethers": "^5.7.0 || ^6.13.0", + "viem": "^2.7.0" + }, + "peerDependenciesMeta": { + "ethers": { + "optional": true + }, + "viem": { + "optional": true + } + } + }, + "node_modules/@signinwithethereum/siwe-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@signinwithethereum/siwe-parser/-/siwe-parser-4.2.0.tgz", + "integrity": "sha512-e3edh8XpZrEjbzVYc0BZ4ySFOa8RKTZOQTafSf1E6ejCB5XcBH93jEpYmjzry1EEocgMNq4KlB3YunsFNCXakQ==", + "license": "Apache-2.0", + "dependencies": { + "@noble/hashes": "^1.7.0", + "apg-js": "^4.4.0" + } + }, + "node_modules/@solana-program/system": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@solana-program/system/-/system-0.10.0.tgz", + "integrity": "sha512-Go+LOEZmqmNlfr+Gjy5ZWAdY5HbYzk2RBewD9QinEU/bBSzpFfzqDRT55JjFRBGJUvMgf3C2vfXEGT4i8DSI4g==", + "license": "Apache-2.0", + "peerDependencies": { + "@solana/kit": "^5.0" + } + }, + "node_modules/@solana-program/token": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@solana-program/token/-/token-0.9.0.tgz", + "integrity": "sha512-vnZxndd4ED4Fc56sw93cWZ2djEeeOFxtaPS8SPf5+a+JZjKA/EnKqzbE1y04FuMhIVrLERQ8uR8H2h72eZzlsA==", + "license": "Apache-2.0", + "peerDependencies": { + "@solana/kit": "^5.0" + } + }, + "node_modules/@solana/accounts": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/accounts/-/accounts-5.5.1.tgz", + "integrity": "sha512-TfOY9xixg5rizABuLVuZ9XI2x2tmWUC/OoN556xwfDlhBHBjKfszicYYOyD6nbFmwTGYarCmyGIdteXxTXIdhQ==", + "license": "MIT", + "dependencies": { + "@solana/addresses": "5.5.1", + "@solana/codecs-core": "5.5.1", + "@solana/codecs-strings": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/rpc-spec": "5.5.1", + "@solana/rpc-types": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/addresses": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/addresses/-/addresses-5.5.1.tgz", + "integrity": "sha512-5xoah3Q9G30HQghu/9BiHLb5pzlPKRC3zydQDmE3O9H//WfayxTFppsUDCL6FjYUHqj/wzK6CWHySglc2RkpdA==", + "license": "MIT", + "dependencies": { + "@solana/assertions": "5.5.1", + "@solana/codecs-core": "5.5.1", + "@solana/codecs-strings": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/nominal-types": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/assertions": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/assertions/-/assertions-5.5.1.tgz", + "integrity": "sha512-YTCSWAlGwSlVPnWtWLm3ukz81wH4j2YaCveK+TjpvUU88hTy6fmUqxi0+hvAMAe4zKXpJyj3Az7BrLJRxbIm4Q==", + "license": "MIT", + "dependencies": { + "@solana/errors": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/codecs": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/codecs/-/codecs-5.5.1.tgz", + "integrity": "sha512-Vea29nJub/bXjfzEV7ZZQ/PWr1pYLZo3z0qW0LQL37uKKVzVFRQlwetd7INk3YtTD3xm9WUYr7bCvYUk3uKy2g==", + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "5.5.1", + "@solana/codecs-data-structures": "5.5.1", + "@solana/codecs-numbers": "5.5.1", + "@solana/codecs-strings": "5.5.1", + "@solana/options": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/codecs-core": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/codecs-core/-/codecs-core-5.5.1.tgz", + "integrity": "sha512-TgBt//bbKBct0t6/MpA8ElaOA3sa8eYVvR7LGslCZ84WiAwwjCY0lW/lOYsFHJQzwREMdUyuEyy5YWBKtdh8Rw==", + "license": "MIT", + "dependencies": { + "@solana/errors": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/codecs-data-structures": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/codecs-data-structures/-/codecs-data-structures-5.5.1.tgz", + "integrity": "sha512-97bJWGyUY9WvBz3mX1UV3YPWGDTez6btCfD0ip3UVEXJbItVuUiOkzcO5iFDUtQT5riKT6xC+Mzl+0nO76gd0w==", + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "5.5.1", + "@solana/codecs-numbers": "5.5.1", + "@solana/errors": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/codecs-numbers": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/codecs-numbers/-/codecs-numbers-5.5.1.tgz", + "integrity": "sha512-rllMIZAHqmtvC0HO/dc/21wDuWaD0B8Ryv8o+YtsICQBuiL/0U4AGwH7Pi5GNFySYk0/crSuwfIqQFtmxNSPFw==", + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "5.5.1", + "@solana/errors": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/codecs-strings": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/codecs-strings/-/codecs-strings-5.5.1.tgz", + "integrity": "sha512-7klX4AhfHYA+uKKC/nxRGP2MntbYQCR3N6+v7bk1W/rSxYuhNmt+FN8aoThSZtWIKwN6BEyR1167ka8Co1+E7A==", + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "5.5.1", + "@solana/codecs-numbers": "5.5.1", + "@solana/errors": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "fastestsmallesttextencoderdecoder": "^1.0.22", + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "fastestsmallesttextencoderdecoder": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/errors": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/errors/-/errors-5.5.1.tgz", + "integrity": "sha512-vFO3p+S7HoyyrcAectnXbdsMfwUzY2zYFUc2DEe5BwpiE9J1IAxPBGjOWO6hL1bbYdBrlmjNx8DXCslqS+Kcmg==", + "license": "MIT", + "dependencies": { + "chalk": "5.6.2", + "commander": "14.0.2" + }, + "bin": { + "errors": "bin/cli.mjs" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/fast-stable-stringify": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/fast-stable-stringify/-/fast-stable-stringify-5.5.1.tgz", + "integrity": "sha512-Ni7s2FN33zTzhTFgRjEbOVFO+UAmK8qi3Iu0/GRFYK4jN696OjKHnboSQH/EacQ+yGqS54bfxf409wU5dsLLCw==", + "license": "MIT", + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/functional": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/functional/-/functional-5.5.1.tgz", + "integrity": "sha512-tTHoJcEQq3gQx5qsdsDJ0LEJeFzwNpXD80xApW9o/PPoCNimI3SALkZl+zNW8VnxRrV3l3yYvfHWBKe/X3WG3w==", + "license": "MIT", + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/instruction-plans": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/instruction-plans/-/instruction-plans-5.5.1.tgz", + "integrity": "sha512-7z3CB7YMcFKuVvgcnNY8bY6IsZ8LG61Iytbz7HpNVGX2u1RthOs1tRW8luTzSG1MPL0Ox7afyAVMYeFqSPHnaQ==", + "license": "MIT", + "dependencies": { + "@solana/errors": "5.5.1", + "@solana/instructions": "5.5.1", + "@solana/keys": "5.5.1", + "@solana/promises": "5.5.1", + "@solana/transaction-messages": "5.5.1", + "@solana/transactions": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/instructions": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/instructions/-/instructions-5.5.1.tgz", + "integrity": "sha512-h0G1CG6S+gUUSt0eo6rOtsaXRBwCq1+Js2a+Ps9Bzk9q7YHNFA75/X0NWugWLgC92waRp66hrjMTiYYnLBoWOQ==", + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "5.5.1", + "@solana/errors": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/keys": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/keys/-/keys-5.5.1.tgz", + "integrity": "sha512-KRD61cL7CRL+b4r/eB9dEoVxIf/2EJ1Pm1DmRYhtSUAJD2dJ5Xw8QFuehobOGm9URqQ7gaQl+Fkc1qvDlsWqKg==", + "license": "MIT", + "dependencies": { + "@solana/assertions": "5.5.1", + "@solana/codecs-core": "5.5.1", + "@solana/codecs-strings": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/nominal-types": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/kit": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/kit/-/kit-5.5.1.tgz", + "integrity": "sha512-irKUGiV2yRoyf+4eGQ/ZeCRxa43yjFEL1DUI5B0DkcfZw3cr0VJtVJnrG8OtVF01vT0OUfYOcUn6zJW5TROHvQ==", + "license": "MIT", + "dependencies": { + "@solana/accounts": "5.5.1", + "@solana/addresses": "5.5.1", + "@solana/codecs": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/functional": "5.5.1", + "@solana/instruction-plans": "5.5.1", + "@solana/instructions": "5.5.1", + "@solana/keys": "5.5.1", + "@solana/offchain-messages": "5.5.1", + "@solana/plugin-core": "5.5.1", + "@solana/programs": "5.5.1", + "@solana/rpc": "5.5.1", + "@solana/rpc-api": "5.5.1", + "@solana/rpc-parsed-types": "5.5.1", + "@solana/rpc-spec-types": "5.5.1", + "@solana/rpc-subscriptions": "5.5.1", + "@solana/rpc-types": "5.5.1", + "@solana/signers": "5.5.1", + "@solana/sysvars": "5.5.1", + "@solana/transaction-confirmation": "5.5.1", + "@solana/transaction-messages": "5.5.1", + "@solana/transactions": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/nominal-types": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/nominal-types/-/nominal-types-5.5.1.tgz", + "integrity": "sha512-I1ImR+kfrLFxN5z22UDiTWLdRZeKtU0J/pkWkO8qm/8WxveiwdIv4hooi8pb6JnlR4mSrWhq0pCIOxDYrL9GIQ==", + "license": "MIT", + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/offchain-messages": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/offchain-messages/-/offchain-messages-5.5.1.tgz", + "integrity": "sha512-g+xHH95prTU+KujtbOzj8wn+C7ZNoiLhf3hj6nYq3MTyxOXtBEysguc97jJveUZG0K97aIKG6xVUlMutg5yxhw==", + "license": "MIT", + "dependencies": { + "@solana/addresses": "5.5.1", + "@solana/codecs-core": "5.5.1", + "@solana/codecs-data-structures": "5.5.1", + "@solana/codecs-numbers": "5.5.1", + "@solana/codecs-strings": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/keys": "5.5.1", + "@solana/nominal-types": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/options": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/options/-/options-5.5.1.tgz", + "integrity": "sha512-eo971c9iLNLmk+yOFyo7yKIJzJ/zou6uKpy6mBuyb/thKtS/haiKIc3VLhyTXty3OH2PW8yOlORJnv4DexJB8A==", + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "5.5.1", + "@solana/codecs-data-structures": "5.5.1", + "@solana/codecs-numbers": "5.5.1", + "@solana/codecs-strings": "5.5.1", + "@solana/errors": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/plugin-core": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/plugin-core/-/plugin-core-5.5.1.tgz", + "integrity": "sha512-VUZl30lDQFJeiSyNfzU1EjYt2QZvoBFKEwjn1lilUJw7KgqD5z7mbV7diJhT+dLFs36i0OsjXvq5kSygn8YJ3A==", + "license": "MIT", + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/programs": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/programs/-/programs-5.5.1.tgz", + "integrity": "sha512-7U9kn0Jsx1NuBLn5HRTFYh78MV4XN145Yc3WP/q5BlqAVNlMoU9coG5IUTJIG847TUqC1lRto3Dnpwm6T4YRpA==", + "license": "MIT", + "dependencies": { + "@solana/addresses": "5.5.1", + "@solana/errors": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/promises": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/promises/-/promises-5.5.1.tgz", + "integrity": "sha512-T9lfuUYkGykJmppEcssNiCf6yiYQxJkhiLPP+pyAc2z84/7r3UVIb2tNJk4A9sucS66pzJnVHZKcZVGUUp6wzA==", + "license": "MIT", + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc/-/rpc-5.5.1.tgz", + "integrity": "sha512-ku8zTUMrkCWci66PRIBC+1mXepEnZH/q1f3ck0kJZ95a06bOTl5KU7HeXWtskkyefzARJ5zvCs54AD5nxjQJ+A==", + "license": "MIT", + "dependencies": { + "@solana/errors": "5.5.1", + "@solana/fast-stable-stringify": "5.5.1", + "@solana/functional": "5.5.1", + "@solana/rpc-api": "5.5.1", + "@solana/rpc-spec": "5.5.1", + "@solana/rpc-spec-types": "5.5.1", + "@solana/rpc-transformers": "5.5.1", + "@solana/rpc-transport-http": "5.5.1", + "@solana/rpc-types": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-api": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc-api/-/rpc-api-5.5.1.tgz", + "integrity": "sha512-XWOQQPhKl06Vj0xi3RYHAc6oEQd8B82okYJ04K7N0Vvy3J4PN2cxeK7klwkjgavdcN9EVkYCChm2ADAtnztKnA==", + "license": "MIT", + "dependencies": { + "@solana/addresses": "5.5.1", + "@solana/codecs-core": "5.5.1", + "@solana/codecs-strings": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/keys": "5.5.1", + "@solana/rpc-parsed-types": "5.5.1", + "@solana/rpc-spec": "5.5.1", + "@solana/rpc-transformers": "5.5.1", + "@solana/rpc-types": "5.5.1", + "@solana/transaction-messages": "5.5.1", + "@solana/transactions": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-parsed-types": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc-parsed-types/-/rpc-parsed-types-5.5.1.tgz", + "integrity": "sha512-HEi3G2nZqGEsa3vX6U0FrXLaqnUCg4SKIUrOe8CezD+cSFbRTOn3rCLrUmJrhVyXlHoQVaRO9mmeovk31jWxJg==", + "license": "MIT", + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-spec": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc-spec/-/rpc-spec-5.5.1.tgz", + "integrity": "sha512-m3LX2bChm3E3by4mQrH4YwCAFY57QBzuUSWqlUw7ChuZ+oLLOq7b2czi4i6L4Vna67j3eCmB3e+4tqy1j5wy7Q==", + "license": "MIT", + "dependencies": { + "@solana/errors": "5.5.1", + "@solana/rpc-spec-types": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-spec-types": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc-spec-types/-/rpc-spec-types-5.5.1.tgz", + "integrity": "sha512-6OFKtRpIEJQs8Jb2C4OO8KyP2h2Hy1MFhatMAoXA+0Ik8S3H+CicIuMZvGZ91mIu/tXicuOOsNNLu3HAkrakrw==", + "license": "MIT", + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-subscriptions": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions/-/rpc-subscriptions-5.5.1.tgz", + "integrity": "sha512-CTMy5bt/6mDh4tc6vUJms9EcuZj3xvK0/xq8IQ90rhkpYvate91RjBP+egvjgSayUg9yucU9vNuUpEjz4spM7w==", + "license": "MIT", + "dependencies": { + "@solana/errors": "5.5.1", + "@solana/fast-stable-stringify": "5.5.1", + "@solana/functional": "5.5.1", + "@solana/promises": "5.5.1", + "@solana/rpc-spec-types": "5.5.1", + "@solana/rpc-subscriptions-api": "5.5.1", + "@solana/rpc-subscriptions-channel-websocket": "5.5.1", + "@solana/rpc-subscriptions-spec": "5.5.1", + "@solana/rpc-transformers": "5.5.1", + "@solana/rpc-types": "5.5.1", + "@solana/subscribable": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-subscriptions-api": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions-api/-/rpc-subscriptions-api-5.5.1.tgz", + "integrity": "sha512-5Oi7k+GdeS8xR2ly1iuSFkAv6CZqwG0Z6b1QZKbEgxadE1XGSDrhM2cn59l+bqCozUWCqh4c/A2znU/qQjROlw==", + "license": "MIT", + "dependencies": { + "@solana/addresses": "5.5.1", + "@solana/keys": "5.5.1", + "@solana/rpc-subscriptions-spec": "5.5.1", + "@solana/rpc-transformers": "5.5.1", + "@solana/rpc-types": "5.5.1", + "@solana/transaction-messages": "5.5.1", + "@solana/transactions": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-subscriptions-channel-websocket": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions-channel-websocket/-/rpc-subscriptions-channel-websocket-5.5.1.tgz", + "integrity": "sha512-7tGfBBrYY8TrngOyxSHoCU5shy86iA9SRMRrPSyBhEaZRAk6dnbdpmUTez7gtdVo0BCvh9nzQtUycKWSS7PnFQ==", + "license": "MIT", + "dependencies": { + "@solana/errors": "5.5.1", + "@solana/functional": "5.5.1", + "@solana/rpc-subscriptions-spec": "5.5.1", + "@solana/subscribable": "5.5.1", + "ws": "^8.19.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-subscriptions-spec": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions-spec/-/rpc-subscriptions-spec-5.5.1.tgz", + "integrity": "sha512-iq+rGq5fMKP3/mKHPNB6MC8IbVW41KGZg83Us/+LE3AWOTWV1WT20KT2iH1F1ik9roi42COv/TpoZZvhKj45XQ==", + "license": "MIT", + "dependencies": { + "@solana/errors": "5.5.1", + "@solana/promises": "5.5.1", + "@solana/rpc-spec-types": "5.5.1", + "@solana/subscribable": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-transformers": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc-transformers/-/rpc-transformers-5.5.1.tgz", + "integrity": "sha512-OsWqLCQdcrRJKvHiMmwFhp9noNZ4FARuMkHT5us3ustDLXaxOjF0gfqZLnMkulSLcKt7TGXqMhBV+HCo7z5M8Q==", + "license": "MIT", + "dependencies": { + "@solana/errors": "5.5.1", + "@solana/functional": "5.5.1", + "@solana/nominal-types": "5.5.1", + "@solana/rpc-spec-types": "5.5.1", + "@solana/rpc-types": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-transport-http": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc-transport-http/-/rpc-transport-http-5.5.1.tgz", + "integrity": "sha512-yv8GoVSHqEV0kUJEIhkdOVkR2SvJ6yoWC51cJn2rSV7plr6huLGe0JgujCmB7uZhhaLbcbP3zxXxu9sOjsi7Fg==", + "license": "MIT", + "dependencies": { + "@solana/errors": "5.5.1", + "@solana/rpc-spec": "5.5.1", + "@solana/rpc-spec-types": "5.5.1", + "undici-types": "^7.19.2" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-types": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc-types/-/rpc-types-5.5.1.tgz", + "integrity": "sha512-bibTFQ7PbHJJjGJPmfYC2I+/5CRFS4O2p9WwbFraX1Keeel+nRrt/NBXIy8veP5AEn2sVJIyJPpWBRpCx1oATA==", + "license": "MIT", + "dependencies": { + "@solana/addresses": "5.5.1", + "@solana/codecs-core": "5.5.1", + "@solana/codecs-numbers": "5.5.1", + "@solana/codecs-strings": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/nominal-types": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/signers": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/signers/-/signers-5.5.1.tgz", + "integrity": "sha512-FY0IVaBT2kCAze55vEieR6hag4coqcuJ31Aw3hqRH7mv6sV8oqwuJmUrx+uFwOp1gwd5OEAzlv6N4hOOple4sQ==", + "license": "MIT", + "dependencies": { + "@solana/addresses": "5.5.1", + "@solana/codecs-core": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/instructions": "5.5.1", + "@solana/keys": "5.5.1", + "@solana/nominal-types": "5.5.1", + "@solana/offchain-messages": "5.5.1", + "@solana/transaction-messages": "5.5.1", + "@solana/transactions": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/subscribable": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/subscribable/-/subscribable-5.5.1.tgz", + "integrity": "sha512-9K0PsynFq0CsmK1CDi5Y2vUIJpCqkgSS5yfDN0eKPgHqEptLEaia09Kaxc90cSZDZU5mKY/zv1NBmB6Aro9zQQ==", + "license": "MIT", + "dependencies": { + "@solana/errors": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/sysvars": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/sysvars/-/sysvars-5.5.1.tgz", + "integrity": "sha512-k3Quq87Mm+geGUu1GWv6knPk0ALsfY6EKSJGw9xUJDHzY/RkYSBnh0RiOrUhtFm2TDNjOailg8/m0VHmi3reFA==", + "license": "MIT", + "dependencies": { + "@solana/accounts": "5.5.1", + "@solana/codecs": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/rpc-types": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/transaction-confirmation": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/transaction-confirmation/-/transaction-confirmation-5.5.1.tgz", + "integrity": "sha512-j4mKlYPHEyu+OD7MBt3jRoX4ScFgkhZC6H65on4Fux6LMScgivPJlwnKoZMnsgxFgWds0pl+BYzSiALDsXlYtw==", + "license": "MIT", + "dependencies": { + "@solana/addresses": "5.5.1", + "@solana/codecs-strings": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/keys": "5.5.1", + "@solana/promises": "5.5.1", + "@solana/rpc": "5.5.1", + "@solana/rpc-subscriptions": "5.5.1", + "@solana/rpc-types": "5.5.1", + "@solana/transaction-messages": "5.5.1", + "@solana/transactions": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/transaction-messages": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/transaction-messages/-/transaction-messages-5.5.1.tgz", + "integrity": "sha512-aXyhMCEaAp3M/4fP0akwBBQkFPr4pfwoC5CLDq999r/FUwDax2RE/h4Ic7h2Xk+JdcUwsb+rLq85Y52hq84XvQ==", + "license": "MIT", + "dependencies": { + "@solana/addresses": "5.5.1", + "@solana/codecs-core": "5.5.1", + "@solana/codecs-data-structures": "5.5.1", + "@solana/codecs-numbers": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/functional": "5.5.1", + "@solana/instructions": "5.5.1", + "@solana/nominal-types": "5.5.1", + "@solana/rpc-types": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/transactions": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/transactions/-/transactions-5.5.1.tgz", + "integrity": "sha512-8hHtDxtqalZ157pnx6p8k10D7J/KY/biLzfgh9R09VNLLY3Fqi7kJvJCr7M2ik3oRll56pxhraAGCC9yIT6eOA==", + "license": "MIT", + "dependencies": { + "@solana/addresses": "5.5.1", + "@solana/codecs-core": "5.5.1", + "@solana/codecs-data-structures": "5.5.1", + "@solana/codecs-numbers": "5.5.1", + "@solana/codecs-strings": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/functional": "5.5.1", + "@solana/instructions": "5.5.1", + "@solana/keys": "5.5.1", + "@solana/nominal-types": "5.5.1", + "@solana/rpc-types": "5.5.1", + "@solana/transaction-messages": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.9", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.9.tgz", + "integrity": "sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/node/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@x402/core": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/@x402/core/-/core-2.17.0.tgz", + "integrity": "sha512-BBooe9Kewl5KLaKadElFwhZzZ+n/X/m2djzGtc7OY51D8fk2vHTVvDZtBFEJYdJxtQoUZMKX73hew0ss81FENQ==", + "license": "Apache-2.0", + "dependencies": { + "zod": "^3.24.2" + } + }, + "node_modules/@x402/evm": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/@x402/evm/-/evm-2.17.0.tgz", + "integrity": "sha512-MjeouRdVJ1Y20Ikk03RHlnyOuo2FRkh0Nnrxt8aku2qt5fr5M7U8pUGn5iR6aFfRK3xlh48bp0Kypv1zjKuegw==", + "license": "Apache-2.0", + "dependencies": { + "@x402/core": "~2.17.0", + "viem": "^2.48.11", + "zod": "^3.24.2" + } + }, + "node_modules/@x402/express": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/@x402/express/-/express-2.17.0.tgz", + "integrity": "sha512-7BFULgYKXsatbUMwv2I7ORTrl48CtzYIaLQxUnNGGWUAEuzp05aB4CHKt8IKRkp4tJJR5S5F3JgSP46KSOT5Wg==", + "license": "Apache-2.0", + "dependencies": { + "@x402/core": "~2.17.0", + "@x402/extensions": "~2.17.0" + }, + "peerDependencies": { + "@x402/paywall": "^2.17.0", + "express": "^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "@x402/paywall": { + "optional": true + } + } + }, + "node_modules/@x402/extensions": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/@x402/extensions/-/extensions-2.17.0.tgz", + "integrity": "sha512-oYQPLJkRWiSutEVAsyNxwKh4EyviG//s8Ms7Jwkx4J8csm1gd7TfCQTsLwDmieYA5B2Qp8ajebdTkL+bT9/sQA==", + "license": "Apache-2.0", + "dependencies": { + "@noble/curves": "^1.9.0", + "@scure/base": "^1.2.6", + "@signinwithethereum/siwe": "^4.1.0", + "@x402/core": "~2.17.0", + "ajv": "^8.17.1", + "jose": "^5.9.6", + "tweetnacl": "^1.0.3", + "viem": "^2.48.11", + "zod": "^3.24.2" + } + }, + "node_modules/@x402/extensions/node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/abitype": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.0.6.tgz", + "integrity": "sha512-MMSqYh4+C/aVqI2RQaWqbvI4Kxo5cQV40WQ4QFtDnNzCkqChm8MuENhElmynZlO0qUy/ObkEUaXtKqYnx1Kp3A==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3 >=3.22.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/apg-js": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/apg-js/-/apg-js-4.4.0.tgz", + "integrity": "sha512-fefmXFknJmtgtNEXfPwZKYkMFX4Fyeyz+fNF6JWp87biGOPslJbCBVU158zvKRZfHBKnJDy8CMM40oLFGkXT8Q==", + "license": "BSD-2-Clause" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", + "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/axios-retry": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-4.5.0.tgz", + "integrity": "sha512-aR99oXhpEDGo0UuAlYcn2iGRds30k366Zfa05XWScR9QaQD4JYiP3/1Qt1u7YlefUOK+cn0CcwoL1oefavQUlQ==", + "license": "Apache-2.0", + "dependencies": { + "is-retry-allowed": "^2.2.0" + }, + "peerDependencies": { + "axios": "0.x || 1.x" + } + }, + "node_modules/base-x": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz", + "integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bs58": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz", + "integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==", + "license": "MIT", + "dependencies": { + "base-x": "^5.0.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", + "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "license": "MIT" + }, + "node_modules/is-retry-allowed": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-2.2.0.tgz", + "integrity": "sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isows": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", + "integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "license": "BSD-3-Clause", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ox": { + "version": "0.14.30", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.30.tgz", + "integrity": "sha512-LI11uu+8iiM1B3CLckgd++YF1a0A2k5wDoM9ZeQMiL21BOzQs6L//BLS6hb1HSEKCyycdDIQLsVQx9MjpcC0hA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.11.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "1.9.1", + "@noble/hashes": "^1.8.0", + "@scure/bip32": "^1.7.0", + "@scure/bip39": "^1.6.0", + "abitype": "^1.2.3", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/ox/node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ox/node_modules/abitype": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.3.0.tgz", + "integrity": "sha512-fk6Te+bojIFrMvMZrnOO+SxCB+RUksTGOzq/60ZRvs1L+BVzvi2bqt9L3W/17ZLdZsyM1FuYf65P5nlmoiH1Bg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3.22.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.28.0.tgz", + "integrity": "sha512-LJAfY+2w6HGeT8d8J1wNQsUGUEGio6NWWpwdwurQe4f6oojzCFuGLizl1KSve4irsTxyLly1QhEeE6iapdaIvQ==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/viem": { + "version": "2.55.4", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.55.4.tgz", + "integrity": "sha512-iSVcFADHPS0GRcY+YMGubuOH0QXcLN87HVlPNaFLPzJv6OzNOql2cim+QxtTe/QxXUqbXLnzTmpFms4PLGyJ6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@noble/curves": "1.9.1", + "@noble/hashes": "1.8.0", + "@scure/bip32": "1.7.0", + "@scure/bip39": "1.6.0", + "abitype": "1.2.3", + "isows": "1.0.7", + "ox": "0.14.30", + "ws": "8.21.0" + }, + "peerDependencies": { + "typescript": ">=5.0.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/viem/node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/viem/node_modules/abitype": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", + "integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3.22.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/viem/node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/services/blockrun-verify/package.json b/services/blockrun-verify/package.json new file mode 100644 index 000000000..d1b09d0ad --- /dev/null +++ b/services/blockrun-verify/package.json @@ -0,0 +1,30 @@ +{ + "name": "covenant-blockrun-verify", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Public Covenant BlockRun-receipt verifier on Base: agents pay per call in USDC (EIP-3009) to verify a BlockRun call receipt (JCS digest + verdict consistency) and receive a Covenant-signed ed25519 attestation over the result. Settles through the Coinbase-hosted x402 facilitator and fails closed.", + "scripts": { + "build": "rm -rf dist *.tsbuildinfo && tsc -p tsconfig.build.json", + "start": "node dist/server.js", + "dev": "tsx watch src/server.ts", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "tsx --test src/__tests__/*.test.ts", + "clean": "rm -rf dist *.tsbuildinfo" + }, + "dependencies": { + "@coinbase/x402": "^2.1.0", + "@x402/core": "2.17.0", + "@x402/evm": "2.17.0", + "@x402/express": "2.17.0", + "@x402/extensions": "2.17.0", + "bs58": "^6.0.0", + "express": "^4.21.2" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^22.10.0", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } +} diff --git a/services/blockrun-verify/render.yaml b/services/blockrun-verify/render.yaml new file mode 100644 index 000000000..148ee1534 --- /dev/null +++ b/services/blockrun-verify/render.yaml @@ -0,0 +1,42 @@ +services: + - type: web + name: covenant-blockrun-verify + runtime: node + plan: starter + region: frankfurt + rootDir: services/blockrun-verify + autoDeploy: false + buildCommand: npm install && npm run build + startCommand: npm start + healthCheckPath: /health + envVars: + - key: NODE_VERSION + value: "22" + - key: X402_NETWORK + value: base-sepolia + # Unset resolves to the network's canonical USDC: Base Sepolia + # 0x036CbD53842c5426634e7929541eC2318f3dCF7e, Base mainnet + # 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913. + - key: X402_ASSET + sync: false + # payTo: the 0x address USDC revenue settles to. Required. + - key: COVENANT_BASE_PAYTO + sync: false + # Facilitator base. Leave unset to take the network-aware default: Coinbase's + # CDP facilitator on base, the public https://x402.org/facilitator on + # base-sepolia. Set it to pin a specific facilitator. + - key: X402_FACILITATOR_URL + sync: false + # CDP credentials authenticate settlement on Base mainnet (fee-free USDC). + # Required for base; unused on base-sepolia's public facilitator. + - key: CDP_API_KEY_ID + sync: false + - key: CDP_API_KEY_SECRET + sync: false + - key: X402_SYNC_FACILITATOR + value: "true" + # 64-byte JSON array seed for the ed25519 attestation signer. An ephemeral + # key is generated at boot when this is unset (fine for a smoke test, not + # for production, where responses must verify against a pinned pubkey). + - key: COVENANT_ATTEST_KEYPAIR + sync: false diff --git a/services/blockrun-verify/src/__tests__/verify.test.ts b/services/blockrun-verify/src/__tests__/verify.test.ts new file mode 100644 index 000000000..8714ae5a4 --- /dev/null +++ b/services/blockrun-verify/src/__tests__/verify.test.ts @@ -0,0 +1,103 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { Attestor, verifyAttestation } from "../attest.js"; +import { + canonicalSha256Hex, + looksLikeReceipt, + verifyReceipt, + type CallReceipt, +} from "../receipt.js"; +import { makeVerifyHandler } from "../verify-route.js"; + +function receipt(overrides: Partial = {}): CallReceipt { + return { + provider: "blockrun", + endpoint: "/v1/chat/completions", + modelRequested: "gpt-4o", + modelServed: "gpt-4o-mini", + verdict: "substituted", + inputSha256: "aaaa", + outputSha256: "bbbb", + routing: { model: "gpt-4o-mini", savingsPct: 78 }, + payment: { + network: "eip155:8453", + asset: "0x8335", + amount: "3000", + amountUsdc: 0.003, + payTo: "0xe903", + tx: "0xabc", + }, + ...overrides, + }; +} + +test("digest matches the cross-language vector (Rust + TS + Python)", () => { + // Identical to the covenant-blockrun crate canary and both SDK vectors. If this + // changes, receipts stop verifying across the stack. + assert.equal( + canonicalSha256Hex(receipt()), + "cff938c0db919fe334bc4e70b8ac545e2485537be66e381491af68276ad218a9", + ); +}); + +test("verify accepts a consistent receipt and its digest", () => { + const r = receipt(); + const digest = canonicalSha256Hex(r); + const res = verifyReceipt(r, digest); + assert.equal(res.valid, true); + assert.equal(res.verdictConsistent, true); + assert.equal(res.digestMatches, true); + assert.equal(res.settled, true); +}); + +test("verify catches a tampered verdict", () => { + const r = receipt({ verdict: "delivered" }); // served != requested, so should be substituted + const res = verifyReceipt(r); + assert.equal(res.verdictConsistent, false); + assert.equal(res.expectedVerdict, "substituted"); + assert.equal(res.valid, false); +}); + +test("looksLikeReceipt rejects junk", () => { + assert.equal(looksLikeReceipt({ foo: 1 }), false); + assert.equal(looksLikeReceipt(receipt()), true); +}); + +test("handler returns a signed attestation over the result", () => { + const attestor = Attestor.generate(); + const handler = makeVerifyHandler(attestor); + let status = 200; + let body: any; + const res: any = { + status(s: number) { + status = s; + return this; + }, + json(b: any) { + body = b; + return this; + }, + }; + handler({ body: { receipt: receipt() } } as any, res); + assert.equal(status, 200); + assert.equal(body.result.valid, true); + assert.equal(verifyAttestation(body.attestation), true); + // The attestation's subject is the receipt digest. + assert.equal(body.attestation.payload.subject, body.result.digest); +}); + +test("handler rejects a malformed receipt without charging (>=400)", () => { + const handler = makeVerifyHandler(Attestor.generate()); + let status = 200; + const res: any = { + status(s: number) { + status = s; + return this; + }, + json() { + return this; + }, + }; + handler({ body: { receipt: { not: "a receipt" } } } as any, res); + assert.equal(status, 400); +}); diff --git a/services/blockrun-verify/src/attest.ts b/services/blockrun-verify/src/attest.ts new file mode 100644 index 000000000..f96b9002f --- /dev/null +++ b/services/blockrun-verify/src/attest.ts @@ -0,0 +1,83 @@ +// Covenant-signed attestation over an arbitrary claim. Signs with a dedicated +// ed25519 key, separate from any chain identity, so the public product never +// reaches into internal infra. A verifier recomputes sha256(canonical(payload)), +// prepends the domain, and checks the signature against the published pubkey, so +// no trust in this server is required. ed25519 is chain-agnostic: the same key +// and recipe verify identically whether the payment settled on Solana or Base. + +import { createPrivateKey, createPublicKey, createHash, randomBytes, sign as edSign, verify as edVerify } from 'node:crypto'; +import bs58 from 'bs58'; + +const DOMAIN = 'covenant.attest.v1\n'; + +// Published so any consumer can verify an attestation without trusting this +// server: pin the pubkey, recompute the digest, check the signature. +export const ATTEST_DOMAIN = DOMAIN.trimEnd(); +export const ATTEST_CANONICALIZATION = 'JSON, recursively key-sorted, no insignificant whitespace, UTF-8'; +export const ATTEST_VERIFY_RECIPE = + `digest = sha256(canonical(payload)) as lowercase hex; message = "${DOMAIN.trimEnd()}\\n" + digest; ` + + 'ed25519-verify base58-decoded signature_b58 over the UTF-8 message against the published pubkey.'; + +function canonical(value: unknown): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`; + const entries = Object.entries(value as Record) + .filter(([, v]) => v !== undefined) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(',')}}`; +} + +const b64url = (b: Buffer | Uint8Array): string => Buffer.from(b).toString('base64url'); + +// DER prefix for a PKCS#8-wrapped raw Ed25519 seed; concat the 32-byte seed to +// import a private key without trusting the keypair's stored public half. +const PKCS8_ED25519_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex'); + +export interface Attestation { + alg: string; + domain: string; + canonicalization: string; + payload: { subject: string; claim: unknown; ts: number }; + digest_sha256_hex: string; + pubkey_b58: string; + signature_b58: string; +} + +export class Attestor { + private readonly key; + readonly pubkeyB58: string; + + constructor(keypair: number[]) { + const seed = Buffer.from(keypair.slice(0, 32)); + this.key = createPrivateKey({ key: Buffer.concat([PKCS8_ED25519_PREFIX, seed]), format: 'der', type: 'pkcs8' }); + const jwk = createPublicKey(this.key).export({ format: 'jwk' }) as { x: string }; + this.pubkeyB58 = bs58.encode(Buffer.from(jwk.x, 'base64url')); + } + + static generate(): Attestor { + return new Attestor([...randomBytes(32)]); + } + + attest(subject: string, claim: unknown, ts: number): Attestation { + const payload = { subject, claim, ts }; + const digest = createHash('sha256').update(canonical(payload), 'utf8').digest('hex'); + const sig = edSign(null, Buffer.from(`${DOMAIN}${digest}`, 'utf8'), this.key); + return { + alg: 'ed25519', + domain: DOMAIN.trimEnd(), + canonicalization: ATTEST_CANONICALIZATION, + payload, + digest_sha256_hex: digest, + pubkey_b58: this.pubkeyB58, + signature_b58: bs58.encode(sig), + }; + } +} + +export function verifyAttestation(att: Attestation): boolean { + const digest = createHash('sha256').update(canonical(att.payload), 'utf8').digest('hex'); + if (digest !== att.digest_sha256_hex) return false; + const pubkey = Buffer.from(bs58.decode(att.pubkey_b58)); + const key = createPublicKey({ format: 'jwk', key: { kty: 'OKP', crv: 'Ed25519', x: b64url(pubkey) } }); + return edVerify(null, Buffer.from(`${att.domain}\n${digest}`, 'utf8'), key, Buffer.from(bs58.decode(att.signature_b58))); +} diff --git a/services/blockrun-verify/src/receipt.ts b/services/blockrun-verify/src/receipt.ts new file mode 100644 index 000000000..f1ac6edac --- /dev/null +++ b/services/blockrun-verify/src/receipt.ts @@ -0,0 +1,91 @@ +// Receipt verification, mirroring the @covenant-org/blockrun and covenant-blockrun +// SDKs and the covenant-blockrun Rust crate: RFC 8785 (JCS) canonicalization, +// SHA-256 digest, and the requested-vs-served verdict. A receipt built by any of +// those verifies here and vice versa (guarded by the cross-language digest test). + +import { createHash } from "node:crypto"; + +export const VERDICT_DELIVERED = "delivered"; +export const VERDICT_SUBSTITUTED = "substituted"; +export const VERDICT_UNVERIFIED = "unverified"; + +export interface PaymentInfo { + network?: string; + asset?: string; + amount?: string; + amountUsdc?: number; + payTo?: string; + tx?: string; +} + +export interface CallReceipt { + provider?: string; + endpoint?: string; + modelRequested?: string; + modelServed?: string; + verdict?: string; + inputSha256?: string; + outputSha256?: string; + routing?: Record; + payment?: PaymentInfo; +} + +export interface VerifyResult { + valid: boolean; + digest: string; + digestMatches: boolean | null; + verdictConsistent: boolean; + statedVerdict: string; + expectedVerdict: string; + settled: boolean; +} + +export function canonicalize(value: unknown): string { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`; + const entries = Object.entries(value as Record) + .filter(([, v]) => v !== undefined) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalize(v)}`).join(",")}}`; +} + +export function canonicalSha256Hex(value: unknown): string { + return createHash("sha256").update(canonicalize(value), "utf8").digest("hex"); +} + +export function verdictFor(requested: string, served: string): string { + if (!served) return VERDICT_UNVERIFIED; + return requested.toLowerCase() === served.toLowerCase() + ? VERDICT_DELIVERED + : VERDICT_SUBSTITUTED; +} + +export function verifyReceipt(receipt: CallReceipt, expectedDigest?: string): VerifyResult { + const digest = canonicalSha256Hex(receipt); + const expectedVerdict = verdictFor(receipt.modelRequested ?? "", receipt.modelServed ?? ""); + const verdictConsistent = expectedVerdict === receipt.verdict; + const digestMatches = expectedDigest === undefined ? null : expectedDigest === digest; + const settled = Boolean(receipt.payment?.tx); + return { + valid: verdictConsistent && (digestMatches ?? true), + digest, + digestMatches, + verdictConsistent, + statedVerdict: receipt.verdict ?? "", + expectedVerdict, + settled, + }; +} + +/** Shape check for an incoming receipt: the fields the verdict/digest depend on. */ +export function looksLikeReceipt(value: unknown): value is CallReceipt { + if (!value || typeof value !== "object") return false; + const r = value as Record; + return ( + typeof r.verdict === "string" && + typeof r.modelRequested === "string" && + typeof r.modelServed === "string" && + typeof r.inputSha256 === "string" && + typeof r.outputSha256 === "string" + ); +} diff --git a/services/blockrun-verify/src/server.ts b/services/blockrun-verify/src/server.ts new file mode 100644 index 000000000..246986f4f --- /dev/null +++ b/services/blockrun-verify/src/server.ts @@ -0,0 +1,201 @@ +/** + * Covenant BlockRun-receipt verify seller, on Base. + * + * Agents and organizations pay per call in USDC (EIP-3009 transferWithAuthorization) + * to have Covenant verify a BlockRun call receipt: + * POST /x402/verify recompute the receipt's JCS digest, check the stated + * verdict against the requested/served model pair, and note + * whether it carries a settlement tx — returned with a + * Covenant-signed ed25519 attestation over the result. + * + * `@x402/express` issues the 402 challenge via a locally-registered (signer-less) + * EVM exact scheme, then verifies and settles through the Coinbase-hosted x402 + * facilitator. Revenue lands at payTo. The middleware fails closed: the buyer is + * never charged and the resource is never released if settlement fails, and a + * handler returning >= 400 also cancels settlement, so a malformed receipt is free. + * + * This is Covenant dogfooding x402: a paid trust service on the same rail the + * receipts it verifies were settled over. + * + * Env (Render vars): + * PORT listen port (Render injects it) + * X402_NETWORK base-sepolia (default), base, eip155:8453, eip155:84532 + * X402_ASSET USDC contract for the network (defaults per network) + * COVENANT_BASE_PAYTO payTo, the 0x address USDC revenue lands at (required) + * X402_FACILITATOR_URL facilitator base (verify + settle); network-aware default + * CDP_API_KEY_ID Coinbase CDP key id, enables authed Base mainnet settle + * CDP_API_KEY_SECRET Coinbase CDP key secret + * X402_SYNC_FACILITATOR "false" to skip the boot supported-kinds fetch (default on) + * COVENANT_ATTEST_KEYPAIR 64-byte JSON array seed; an ephemeral key is generated + * and its pubkey logged when this is unset + * BLOCKRUN_VERIFY_PRICE price in USDC base units (default 5000 = 0.005 USDC) + */ +import express, { type Request, type Response } from "express"; +import { paymentMiddlewareFromConfig } from "@x402/express"; +import { HTTPFacilitatorClient, type RoutesConfig } from "@x402/core/server"; +import { createFacilitatorConfig } from "@coinbase/x402"; +import { ExactEvmScheme } from "@x402/evm/exact/server"; +import { declareDiscoveryExtension } from "@x402/extensions/bazaar"; +import { Attestor, ATTEST_DOMAIN, ATTEST_CANONICALIZATION, ATTEST_VERIFY_RECIPE } from "./attest.js"; +import { makeVerifyHandler } from "./verify-route.js"; + +const NETWORKS = { + base: { + chain: "eip155:8453", + usdc: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + domain: { name: "USD Coin", version: "2" }, + }, + "base-sepolia": { + chain: "eip155:84532", + usdc: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + domain: { name: "USDC", version: "2" }, + }, +} as const; + +type NetKey = keyof typeof NETWORKS; + +const NET_ALIASES: Record = { + base: "base", + "eip155:8453": "base", + "8453": "base", + "base-sepolia": "base-sepolia", + "eip155:84532": "base-sepolia", + "84532": "base-sepolia", +}; + +function resolveNetwork(raw: string | undefined): NetKey { + const key = NET_ALIASES[(raw ?? "base-sepolia").trim().toLowerCase()]; + if (!key) { + console.error(`X402_NETWORK must be base, base-sepolia, eip155:8453, or eip155:84532 (got "${raw}")`); + process.exit(1); + } + return key; +} + +const PORT = Number(process.env.PORT ?? 10000); +const NET = resolveNetwork(process.env.X402_NETWORK); +const net = NETWORKS[NET]; +const ASSET = process.env.X402_ASSET ?? net.usdc; +const PAY_TO = process.env.COVENANT_BASE_PAYTO ?? ""; +const FACILITATOR_URL = + process.env.X402_FACILITATOR_URL ?? + (NET === "base" ? "https://api.cdp.coinbase.com/platform/v2/x402" : "https://x402.org/facilitator"); +const SYNC = process.env.X402_SYNC_FACILITATOR !== "false"; +const PRICE = process.env.BLOCKRUN_VERIFY_PRICE ?? "5000"; // 0.005 USDC (6 decimals) + +if (!/^0x[0-9a-fA-F]{40}$/.test(PAY_TO)) { + console.error("COVENANT_BASE_PAYTO is required and must be a 0x-prefixed 40-hex EVM address"); + process.exit(1); +} + +const attestor = process.env.COVENANT_ATTEST_KEYPAIR + ? new Attestor(JSON.parse(process.env.COVENANT_ATTEST_KEYPAIR) as number[]) + : Attestor.generate(); +if (!process.env.COVENANT_ATTEST_KEYPAIR) { + console.warn(`COVENANT_ATTEST_KEYPAIR unset, generated ephemeral attestation key ${attestor.pubkeyB58}`); +} + +const app = express(); +app.set("trust proxy", true); +app.use(express.json({ limit: "256kb" })); + +app.get("/health", (_req: Request, res: Response) => { + res.json({ + ok: true, + service: "covenant-blockrun-verify", + network: NET, + chain: net.chain, + asset: ASSET, + payTo: PAY_TO, + resources: ["/x402/verify"], + }); +}); + +app.get("/.well-known/x402", (req: Request, res: Response) => { + const base = `${req.protocol}://${req.get("host")}`; + res.json({ + version: 1, + resources: [`${base}/x402/verify`], + instructions: + "Covenant BlockRun-receipt verifier. Pay USDC via EIP-3009 to verify a BlockRun call receipt (POST /x402/verify with { receipt, expectedDigest? }): the response recomputes the receipt's JCS digest, checks the stated verdict against the requested/served model pair, and returns a Covenant-signed ed25519 attestation over the result. Pin the attestation key below to verify responses without trusting this server.", + attestation: { + algorithm: "ed25519", + publicKey: attestor.pubkeyB58, + domain: ATTEST_DOMAIN, + canonicalization: ATTEST_CANONICALIZATION, + verify: ATTEST_VERIFY_RECIPE, + }, + }); +}); + +const CDP_ID = process.env.CDP_API_KEY_ID; +const CDP_SECRET = process.env.CDP_API_KEY_SECRET; +const facilitatorConfig = + CDP_ID && CDP_SECRET ? createFacilitatorConfig(CDP_ID, CDP_SECRET) : { url: FACILITATOR_URL }; +const facilitator = new HTTPFacilitatorClient(facilitatorConfig); + +const gate = (amount: string, description: string, extensions: Record) => ({ + accepts: { + scheme: "exact" as const, + network: net.chain, + payTo: PAY_TO, + price: { asset: ASSET, amount, extra: { ...net.domain } }, + maxTimeoutSeconds: 300, + }, + description, + mimeType: "application/json", + serviceName: "Covenant", + tags: ["covenant", "trust", "blockrun", "receipt", "verify", "base"], + extensions, +}); + +const routes: RoutesConfig = { + "POST /x402/verify": gate( + PRICE, + "Verify a BlockRun call receipt and return a Covenant-signed result.", + declareDiscoveryExtension({ + input: { + receipt: { + provider: "blockrun", + endpoint: "/v1/chat/completions", + modelRequested: "gpt-4o", + modelServed: "gpt-4o-mini", + verdict: "substituted", + inputSha256: "…", + outputSha256: "…", + payment: { network: "eip155:8453", amount: "3000", tx: "0x…" }, + }, + }, + inputSchema: { + properties: { receipt: { type: "object" }, expectedDigest: { type: "string" } }, + required: ["receipt"], + }, + bodyType: "json", + output: { + example: { + result: { valid: true, digest: "…", verdictConsistent: true, expectedVerdict: "substituted" }, + attestation: { alg: "ed25519", signature_b58: "…", pubkey_b58: "…" }, + }, + }, + }), + ), +}; + +app.use( + paymentMiddlewareFromConfig( + routes, + facilitator, + [{ network: net.chain, server: new ExactEvmScheme() }], + undefined, + undefined, + SYNC, + ), +); + +app.post("/x402/verify", makeVerifyHandler(attestor)); + +app.listen(PORT, () => { + console.log( + `covenant-blockrun-verify on :${PORT} (${NET}, ${net.chain}), price ${PRICE} base units, payTo ${PAY_TO}`, + ); +}); diff --git a/services/blockrun-verify/src/verify-route.ts b/services/blockrun-verify/src/verify-route.ts new file mode 100644 index 000000000..e45caceb8 --- /dev/null +++ b/services/blockrun-verify/src/verify-route.ts @@ -0,0 +1,34 @@ +// The paid /x402/verify handler, extracted from server.ts so tests can exercise +// it without the payment middleware (server.ts wires env + facilitator at import +// and exits on missing config). Returning >= 400 cancels settlement, so a +// malformed request is never charged. +// +// A buyer pays USDC and gets back a Covenant-signed statement that their BlockRun +// receipt is internally consistent: its digest, whether the stated verdict matches +// the requested/served pair, and whether it carries a settlement tx. The signed +// attestation verifies against the published pubkey with no trust in this server. +import type { Request, Response } from "express"; +import type { Attestor } from "./attest.js"; +import { looksLikeReceipt, verifyReceipt } from "./receipt.js"; + +export function makeVerifyHandler(attestor: Attestor) { + return (req: Request, res: Response): void => { + const body = (req.body ?? {}) as { receipt?: unknown; expectedDigest?: unknown }; + if (!looksLikeReceipt(body.receipt)) { + res.status(400).json({ + error: + "receipt is required and must carry verdict, modelRequested, modelServed, inputSha256, outputSha256", + }); + return; + } + if (body.expectedDigest !== undefined && typeof body.expectedDigest !== "string") { + res.status(400).json({ error: "expectedDigest, if given, must be a string" }); + return; + } + const result = verifyReceipt(body.receipt, body.expectedDigest); + // Sign the verification over the receipt digest as subject, so the buyer can + // pin the pubkey and check the statement independently. + const attestation = attestor.attest(result.digest, result, Math.floor(Date.now() / 1000)); + res.json({ result, attestation }); + }; +} diff --git a/services/blockrun-verify/tsconfig.build.json b/services/blockrun-verify/tsconfig.build.json new file mode 100644 index 000000000..cf0eba474 --- /dev/null +++ b/services/blockrun-verify/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false + }, + "exclude": ["src/**/*.test.ts"] +} diff --git a/services/blockrun-verify/tsconfig.json b/services/blockrun-verify/tsconfig.json new file mode 100644 index 000000000..743109d7e --- /dev/null +++ b/services/blockrun-verify/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "types": ["node"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "outDir": "dist", + "rootDir": "src", + "declaration": false, + "sourceMap": true + }, + "include": ["src/**/*.ts"] +} From f70b0892e7f42578bfbdce69c1e468bb1623d255 Mon Sep 17 00:00:00 2001 From: mizuki0x <197570892+mizuki0x@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:39:15 +0200 Subject: [PATCH 06/22] =?UTF-8?q?blockrun:=20live=5Fverified=5Fcall=20demo?= =?UTF-8?q?=20=E2=80=94=20real=20paid=20call=20on=20Base,=20prints=20recei?= =?UTF-8?q?pt=20+=20basescan=20link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent-os/crates/covenant-blockrun/Cargo.toml | 4 + .../examples/live_verified_call.rs | 101 ++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 agent-os/crates/covenant-blockrun/examples/live_verified_call.rs diff --git a/agent-os/crates/covenant-blockrun/Cargo.toml b/agent-os/crates/covenant-blockrun/Cargo.toml index 52e9eb4da..96c8c4912 100644 --- a/agent-os/crates/covenant-blockrun/Cargo.toml +++ b/agent-os/crates/covenant-blockrun/Cargo.toml @@ -24,3 +24,7 @@ base64 = "0.22" async-trait = { workspace = true } tokio = { version = "1", features = ["rt-multi-thread", "macros"] } wiremock = "0.6" + +[[example]] +name = "live_verified_call" +path = "examples/live_verified_call.rs" diff --git a/agent-os/crates/covenant-blockrun/examples/live_verified_call.rs b/agent-os/crates/covenant-blockrun/examples/live_verified_call.rs new file mode 100644 index 000000000..83dcc40de --- /dev/null +++ b/agent-os/crates/covenant-blockrun/examples/live_verified_call.rs @@ -0,0 +1,101 @@ +//! Flagship demo: a verifiable BlockRun call on Base mainnet. +//! +//! Makes one real paid call to BlockRun over x402 (USDC on Base, EIP-3009 +//! gasless), captures the Covenant receipt, verifies it locally, and prints the +//! settlement tx with a Basescan link. This spends real USDC (BlockRun's default +//! chat call is ~0.003 USDC) — it is a `live_` example, never run by `cargo +//! test`. +//! +//! Run: +//! COVENANT_X402_EVM_KEY_HEX=<64-hex Base secp256k1 secret, funded with USDC> \ +//! cargo run -p covenant-blockrun --example live_verified_call +//! +//! Optional: +//! BLOCKRUN_MODEL model to request (default gpt-4o-mini) +//! BLOCKRUN_PROMPT prompt text (default a one-word ping) +//! BLOCKRUN_BASE_URL API base (default https://blockrun.ai/api) + +use std::sync::Arc; + +use covenant_blockrun::{BlockRunClient, CallReceipt}; +use covenant_x402::EvmSigner; +use serde_json::json; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let key_hex = std::env::var("COVENANT_X402_EVM_KEY_HEX").map_err(|_| { + "COVENANT_X402_EVM_KEY_HEX is required: a 64-hex Base secp256k1 secret, funded with USDC" + })?; + let secret = decode_key(key_hex.trim())?; + let signer = EvmSigner::from_secret_bytes(&secret)?; + let payer = signer.address_hex(); + + let base_url = + std::env::var("BLOCKRUN_BASE_URL").unwrap_or_else(|_| "https://blockrun.ai/api".to_string()); + let model = std::env::var("BLOCKRUN_MODEL").unwrap_or_else(|_| "gpt-4o-mini".to_string()); + let prompt = + std::env::var("BLOCKRUN_PROMPT").unwrap_or_else(|_| "Reply with the single word: ok".into()); + + eprintln!("payer (Base): {payer}"); + eprintln!("calling {base_url}/v1/chat/completions with model {model} — this spends real USDC"); + + let client = BlockRunClient::new(base_url, Arc::new(signer)); + let body = json!({ + "model": model, + "messages": [{ "role": "user", "content": prompt }], + "max_tokens": 16, + }); + + let paid = client.call("/v1/chat/completions", body).await?; + let receipt = &paid.receipt; + + println!("\n=== response ==="); + println!("{}", serde_json::to_string_pretty(&paid.response)?); + + println!("\n=== covenant receipt ==="); + println!("{}", serde_json::to_string_pretty(&receipt.to_json())?); + + report(receipt); + Ok(()) +} + +fn report(receipt: &CallReceipt) { + println!("\n=== verdict ==="); + println!("requested model : {}", receipt.model_requested); + println!("served model : {}", receipt.model_served); + println!("verdict : {}", receipt.verdict); + if let Some(pct) = receipt.routing.savings_pct { + println!("router savings : {pct}%"); + } + println!("receipt digest : {}", receipt.digest()); + + println!("\n=== settlement ==="); + println!("network : {}", receipt.payment.network); + println!( + "amount : {} ({} USDC)", + receipt.payment.amount, receipt.payment.amount_usdc + ); + match &receipt.payment.tx { + Some(tx) => { + println!("settlement tx : {tx}"); + println!("basescan : https://basescan.org/tx/{tx}"); + } + None => println!("settlement tx : (none reported by the provider)"), + } + + // Independent local check: the stored verdict must match the model pair. + let ok = receipt.verdict == receipt.expected_verdict(); + println!("\nlocal verify : {}", if ok { "PASS" } else { "FAIL" }); +} + +fn decode_key(hex: &str) -> Result<[u8; 32], Box> { + let hex = hex.strip_prefix("0x").unwrap_or(hex); + if hex.len() != 64 { + return Err("key must be 64 hex chars (32 bytes)".into()); + } + let mut out = [0u8; 32]; + for (i, byte) in out.iter_mut().enumerate() { + *byte = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16)?; + } + Ok(out) +} From bd996968151beebf7439d19dc6272b1720d7952f Mon Sep 17 00:00:00 2001 From: mizuki0x <197570892+mizuki0x@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:39:28 +0200 Subject: [PATCH 07/22] blockrun: rustfmt the demo example --- .../covenant-blockrun/examples/live_verified_call.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/agent-os/crates/covenant-blockrun/examples/live_verified_call.rs b/agent-os/crates/covenant-blockrun/examples/live_verified_call.rs index 83dcc40de..1e95308e9 100644 --- a/agent-os/crates/covenant-blockrun/examples/live_verified_call.rs +++ b/agent-os/crates/covenant-blockrun/examples/live_verified_call.rs @@ -30,11 +30,11 @@ async fn main() -> Result<(), Box> { let signer = EvmSigner::from_secret_bytes(&secret)?; let payer = signer.address_hex(); - let base_url = - std::env::var("BLOCKRUN_BASE_URL").unwrap_or_else(|_| "https://blockrun.ai/api".to_string()); + let base_url = std::env::var("BLOCKRUN_BASE_URL") + .unwrap_or_else(|_| "https://blockrun.ai/api".to_string()); let model = std::env::var("BLOCKRUN_MODEL").unwrap_or_else(|_| "gpt-4o-mini".to_string()); - let prompt = - std::env::var("BLOCKRUN_PROMPT").unwrap_or_else(|_| "Reply with the single word: ok".into()); + let prompt = std::env::var("BLOCKRUN_PROMPT") + .unwrap_or_else(|_| "Reply with the single word: ok".into()); eprintln!("payer (Base): {payer}"); eprintln!("calling {base_url}/v1/chat/completions with model {model} — this spends real USDC"); From bc21194346c83c6fe25fb386f68045c92c26ee0b Mon Sep 17 00:00:00 2001 From: mizuki0x <197570892+mizuki0x@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:17:04 +0200 Subject: [PATCH 08/22] blockrun: demo reads the x402 key from a file path (COVENANT_X402_EVM_KEY), not just inline hex --- .../covenant-blockrun/examples/live_verified_call.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/agent-os/crates/covenant-blockrun/examples/live_verified_call.rs b/agent-os/crates/covenant-blockrun/examples/live_verified_call.rs index 1e95308e9..b47e9b738 100644 --- a/agent-os/crates/covenant-blockrun/examples/live_verified_call.rs +++ b/agent-os/crates/covenant-blockrun/examples/live_verified_call.rs @@ -23,9 +23,15 @@ use serde_json::json; #[tokio::main] async fn main() -> Result<(), Box> { - let key_hex = std::env::var("COVENANT_X402_EVM_KEY_HEX").map_err(|_| { - "COVENANT_X402_EVM_KEY_HEX is required: a 64-hex Base secp256k1 secret, funded with USDC" - })?; + // Prefer a file path so the secret never touches the shell or its history; + // fall back to an inline hex for one-off runs. Matches the x402 signer sidecar. + let key_hex = match std::env::var("COVENANT_X402_EVM_KEY") { + Ok(path) if !path.trim().is_empty() => std::fs::read_to_string(path.trim()) + .map_err(|e| format!("reading COVENANT_X402_EVM_KEY file: {e}"))?, + _ => std::env::var("COVENANT_X402_EVM_KEY_HEX").map_err(|_| { + "set COVENANT_X402_EVM_KEY (path to a 64-hex key file) or COVENANT_X402_EVM_KEY_HEX" + })?, + }; let secret = decode_key(key_hex.trim())?; let signer = EvmSigner::from_secret_bytes(&secret)?; let payer = signer.address_hex(); From 76dedabcc007430a9788cfc1f55fee9e51754b4b Mon Sep 17 00:00:00 2001 From: mizuki0x <197570892+mizuki0x@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:13:58 +0200 Subject: [PATCH 09/22] blockrun: rewrap payment to CDP x402 v2 (accepted field) + treat provider-namespaced model as delivered --- .../crates/covenant-blockrun/src/challenge.rs | 17 +++++++++ .../crates/covenant-blockrun/src/client.rs | 35 +++++++++++++++++-- .../crates/covenant-blockrun/src/receipt.rs | 20 ++++++++++- 3 files changed, 69 insertions(+), 3 deletions(-) diff --git a/agent-os/crates/covenant-blockrun/src/challenge.rs b/agent-os/crates/covenant-blockrun/src/challenge.rs index 5e3100946..73a1bfac9 100644 --- a/agent-os/crates/covenant-blockrun/src/challenge.rs +++ b/agent-os/crates/covenant-blockrun/src/challenge.rs @@ -93,6 +93,23 @@ impl Accept { self.amount.parse::().unwrap_or(0) as f64 / 10u128.pow(USDC_DECIMALS) as f64 } + /// The `accepted` object an x402 v2 payment payload must echo back for the + /// CDP facilitator: the exact requirement being satisfied. + pub fn to_accepted_json(&self) -> serde_json::Value { + serde_json::json!({ + "scheme": self.scheme, + "network": self.network, + "amount": self.amount, + "asset": self.asset, + "payTo": self.pay_to, + "maxTimeoutSeconds": self.max_timeout_seconds, + "extra": self.extra.as_ref().map(|e| serde_json::json!({ + "name": e.name, + "version": e.version, + })), + }) + } + /// Convert into the requirements a `covenant_x402::Signer` consumes. pub fn to_requirements(&self) -> PaymentRequirements { PaymentRequirements { diff --git a/agent-os/crates/covenant-blockrun/src/client.rs b/agent-os/crates/covenant-blockrun/src/client.rs index b745eb9c1..95d91f002 100644 --- a/agent-os/crates/covenant-blockrun/src/client.rs +++ b/agent-os/crates/covenant-blockrun/src/client.rs @@ -4,11 +4,12 @@ use std::sync::Arc; +use base64::Engine; use covenant_x402::Signer; use reqwest::StatusCode; use serde_json::Value; -use crate::challenge::Challenge; +use crate::challenge::{Accept, Challenge}; use crate::receipt::{CallReceipt, PaymentInfo, RoutingClaim}; use crate::{BlockRunError, Result}; @@ -85,11 +86,17 @@ impl BlockRunClient { .ok_or_else(|| BlockRunError::Challenge("no payment options offered".into()))? .clone(); - let header = self + let signed = self .signer .build_payment(&accept.to_requirements()) .await .map_err(|e| BlockRunError::Signer(e.to_string()))?; + // The signer emits a bare v2 envelope ({x402Version, scheme, network, + // payload}); the CDP facilitator's x402V2PaymentPayload instead wants the + // full `accepted` requirement echoed back and no top-level scheme/network. + // Rewrap before sending; a header that is not a base64 JSON envelope + // (e.g. a mock) passes through unchanged. + let header = to_cdp_v2_header(&signed, &accept); let paid = self .http @@ -145,6 +152,30 @@ async fn decode_challenge(resp: reqwest::Response) -> Result { Challenge::from_json(&body) } +/// Rewrap the signer's base64 v2 envelope into the shape the CDP facilitator +/// verifies: `{ x402Version: 2, accepted: , payload }`, +/// carrying the signer's `payload` verbatim and dropping the top-level +/// scheme/network the CDP schema does not accept. A header that is not a base64 +/// JSON envelope carrying a `payload` (e.g. a mock signer) is returned unchanged. +fn to_cdp_v2_header(signed: &str, accept: &Accept) -> String { + let engine = base64::engine::general_purpose::STANDARD; + let Ok(bytes) = engine.decode(signed.trim()) else { + return signed.to_string(); + }; + let Ok(env) = serde_json::from_slice::(&bytes) else { + return signed.to_string(); + }; + let Some(payload) = env.get("payload").cloned() else { + return signed.to_string(); + }; + let v2 = serde_json::json!({ + "x402Version": 2, + "accepted": accept.to_accepted_json(), + "payload": payload, + }); + engine.encode(v2.to_string().as_bytes()) +} + async fn read_json(resp: reqwest::Response) -> Result { let status = resp.status(); let text = resp.text().await?; diff --git a/agent-os/crates/covenant-blockrun/src/receipt.rs b/agent-os/crates/covenant-blockrun/src/receipt.rs index ca09a27eb..1fbd8514d 100644 --- a/agent-os/crates/covenant-blockrun/src/receipt.rs +++ b/agent-os/crates/covenant-blockrun/src/receipt.rs @@ -150,7 +150,7 @@ impl CallReceipt { fn verdict_for(requested: &str, served: &str) -> String { if served.is_empty() { VERDICT_UNVERIFIED - } else if requested.eq_ignore_ascii_case(served) { + } else if model_base(requested).eq_ignore_ascii_case(model_base(served)) { VERDICT_DELIVERED } else { VERDICT_SUBSTITUTED @@ -158,6 +158,13 @@ fn verdict_for(requested: &str, served: &str) -> String { .to_string() } +/// The model name without a provider namespace prefix: `openai/gpt-4o-mini` and +/// `gpt-4o-mini` are the same model, so the verdict compares the part after the +/// last `/`. A genuine swap (`gpt-4o` vs `gpt-4o-mini`) still differs. +fn model_base(model: &str) -> &str { + model.rsplit('/').next().unwrap_or(model) +} + pub fn sha256_hex(bytes: &[u8]) -> String { let mut h = Sha256::new(); h.update(bytes); @@ -213,6 +220,17 @@ mod tests { assert_eq!(r.verdict, VERDICT_SUBSTITUTED); } + #[test] + fn delivered_when_served_only_adds_a_provider_namespace() { + // BlockRun echoes the model as `openai/gpt-4o-mini`; that is the same + // model, so the verdict is delivered, not substituted. + let req = json!({"model": "gpt-4o-mini", "messages": []}); + let resp = json!({"model": "openai/gpt-4o-mini", "choices": []}); + let r = CallReceipt::from_exchange("/x", &req, &resp, payment(), RoutingClaim::default()); + assert_eq!(r.verdict, VERDICT_DELIVERED); + assert_eq!(r.model_served, "openai/gpt-4o-mini"); + } + #[test] fn served_model_falls_back_to_routing_header() { let req = json!({"model": "auto", "messages": []}); From aba7715ae7d3d86610e030f8b1f40e874cfa392c Mon Sep 17 00:00:00 2001 From: mizuki0x <197570892+mizuki0x@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:13:58 +0200 Subject: [PATCH 10/22] blockrun sdks: treat a provider-namespaced served model as delivered --- packages/blockrun-py/src/covenant_blockrun/receipt.py | 7 ++++++- packages/blockrun-ts/src/receipt.ts | 8 +++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/blockrun-py/src/covenant_blockrun/receipt.py b/packages/blockrun-py/src/covenant_blockrun/receipt.py index 85c0e2bdf..edec72953 100644 --- a/packages/blockrun-py/src/covenant_blockrun/receipt.py +++ b/packages/blockrun-py/src/covenant_blockrun/receipt.py @@ -103,7 +103,12 @@ def canonical_sha256_hex(value: Any) -> str: def verdict_for(requested: str, served: str) -> str: if not served: return VERDICT_UNVERIFIED - return VERDICT_DELIVERED if requested.lower() == served.lower() else VERDICT_SUBSTITUTED + return VERDICT_DELIVERED if _model_base(requested) == _model_base(served) else VERDICT_SUBSTITUTED + + +def _model_base(model: str) -> str: + """Model name without a provider namespace: openai/gpt-4o-mini -> gpt-4o-mini.""" + return model.lower().rsplit("/", 1)[-1] def build_receipt( diff --git a/packages/blockrun-ts/src/receipt.ts b/packages/blockrun-ts/src/receipt.ts index ef65df2a8..8a59d2681 100644 --- a/packages/blockrun-ts/src/receipt.ts +++ b/packages/blockrun-ts/src/receipt.ts @@ -52,11 +52,17 @@ export async function canonicalSha256Hex(value: unknown): Promise { export function verdictFor(requested: string, served: string): Verdict { if (!served) return VERDICT_UNVERIFIED; - return requested.toLowerCase() === served.toLowerCase() + return modelBase(requested) === modelBase(served) ? VERDICT_DELIVERED : VERDICT_SUBSTITUTED; } +/** Model name without a provider namespace: `openai/gpt-4o-mini` → `gpt-4o-mini`. */ +function modelBase(model: string): string { + const parts = model.toLowerCase().split("/"); + return parts[parts.length - 1]; +} + /** Build a receipt from one exchange. */ export async function buildReceipt(args: { endpoint: string; From a024ae24bf47686a44fbedccb43bff91033fa4ed Mon Sep 17 00:00:00 2001 From: Iko Rane Date: Mon, 20 Jul 2026 19:13:58 +0200 Subject: [PATCH 11/22] blockrun-verify: treat a provider-namespaced served model as delivered --- services/blockrun-verify/src/receipt.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/services/blockrun-verify/src/receipt.ts b/services/blockrun-verify/src/receipt.ts index f1ac6edac..6b54ca792 100644 --- a/services/blockrun-verify/src/receipt.ts +++ b/services/blockrun-verify/src/receipt.ts @@ -55,9 +55,13 @@ export function canonicalSha256Hex(value: unknown): string { export function verdictFor(requested: string, served: string): string { if (!served) return VERDICT_UNVERIFIED; - return requested.toLowerCase() === served.toLowerCase() - ? VERDICT_DELIVERED - : VERDICT_SUBSTITUTED; + return modelBase(requested) === modelBase(served) ? VERDICT_DELIVERED : VERDICT_SUBSTITUTED; +} + +/** Model name without a provider namespace: `openai/gpt-4o-mini` → `gpt-4o-mini`. */ +function modelBase(model: string): string { + const parts = model.toLowerCase().split("/"); + return parts[parts.length - 1]; } export function verifyReceipt(receipt: CallReceipt, expectedDigest?: string): VerifyResult { From e8573f778b7ee647b120dbac26f27fba40a2c70c Mon Sep 17 00:00:00 2001 From: mizuki0x <197570892+mizuki0x@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:14:29 +0200 Subject: [PATCH 12/22] blockrun-ts: modelBase without unchecked index access --- packages/blockrun-ts/src/receipt.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/blockrun-ts/src/receipt.ts b/packages/blockrun-ts/src/receipt.ts index 8a59d2681..12666d07c 100644 --- a/packages/blockrun-ts/src/receipt.ts +++ b/packages/blockrun-ts/src/receipt.ts @@ -59,8 +59,8 @@ export function verdictFor(requested: string, served: string): Verdict { /** Model name without a provider namespace: `openai/gpt-4o-mini` → `gpt-4o-mini`. */ function modelBase(model: string): string { - const parts = model.toLowerCase().split("/"); - return parts[parts.length - 1]; + const lower = model.toLowerCase(); + return lower.slice(lower.lastIndexOf("/") + 1); } /** Build a receipt from one exchange. */ From 522451cf973f5aeb1fb6161158443967bef03102 Mon Sep 17 00:00:00 2001 From: Iko Rane Date: Mon, 20 Jul 2026 19:14:30 +0200 Subject: [PATCH 13/22] blockrun-verify: modelBase without unchecked index access --- services/blockrun-verify/src/receipt.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/blockrun-verify/src/receipt.ts b/services/blockrun-verify/src/receipt.ts index 6b54ca792..ae87aa554 100644 --- a/services/blockrun-verify/src/receipt.ts +++ b/services/blockrun-verify/src/receipt.ts @@ -60,8 +60,8 @@ export function verdictFor(requested: string, served: string): string { /** Model name without a provider namespace: `openai/gpt-4o-mini` → `gpt-4o-mini`. */ function modelBase(model: string): string { - const parts = model.toLowerCase().split("/"); - return parts[parts.length - 1]; + const lower = model.toLowerCase(); + return lower.slice(lower.lastIndexOf("/") + 1); } export function verifyReceipt(receipt: CallReceipt, expectedDigest?: string): VerifyResult { From 12737527bf95d87c3cc4cc19ab78cfc8d33f0120 Mon Sep 17 00:00:00 2001 From: mizuki0x <197570892+mizuki0x@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:05:58 +0200 Subject: [PATCH 14/22] blockrun: fix python JCS integral-float formatting (78.0 -> 78) so digests match rust/ts; pin whole-float cross-language canary --- .../crates/covenant-blockrun/src/receipt.rs | 29 +++++++++++++++ .../blockrun-py/src/covenant_blockrun/jcs.py | 10 ++++-- packages/blockrun-py/tests/test_receipt.py | 36 +++++++++++++++++++ .../blockrun-ts/src/__tests__/receipt.test.ts | 33 ++++++++++++++++- 4 files changed, 105 insertions(+), 3 deletions(-) diff --git a/agent-os/crates/covenant-blockrun/src/receipt.rs b/agent-os/crates/covenant-blockrun/src/receipt.rs index 1fbd8514d..54967c324 100644 --- a/agent-os/crates/covenant-blockrun/src/receipt.rs +++ b/agent-os/crates/covenant-blockrun/src/receipt.rs @@ -295,6 +295,35 @@ mod tests { ); } + #[test] + fn whole_number_float_digest_matches_cross_language_vector() { + // savingsPct and amountUsdc are whole-number floats. RFC 8785 drops the + // ".0"; the TS and Python SDKs must agree, so this hex is pinned in all + // three. It guards the number-format divergence json.dumps would cause. + let receipt = json!({ + "provider": "blockrun", + "endpoint": "/v1/chat/completions", + "modelRequested": "gpt-4o-mini", + "modelServed": "openai/gpt-4o-mini", + "verdict": "delivered", + "inputSha256": "aaaa", + "outputSha256": "bbbb", + "routing": { "model": "openai/gpt-4o-mini", "savingsPct": 78.0 }, + "payment": { + "network": "eip155:8453", + "asset": "0x8335", + "amount": "1000000", + "amountUsdc": 1.0, + "payTo": "0xe903", + "tx": "0xabc" + } + }); + assert_eq!( + canonical_sha256_hex(&receipt), + "8328699dcfa1ab8c2d8e5cfca12378999ccf94cffe68fa2ee5cefec818041baf" + ); + } + #[test] fn routing_parsed_from_headers() { let mut h = reqwest::header::HeaderMap::new(); diff --git a/packages/blockrun-py/src/covenant_blockrun/jcs.py b/packages/blockrun-py/src/covenant_blockrun/jcs.py index 8ac251db7..ebc76ebd3 100644 --- a/packages/blockrun-py/src/covenant_blockrun/jcs.py +++ b/packages/blockrun-py/src/covenant_blockrun/jcs.py @@ -31,8 +31,14 @@ def _encode(value: Any) -> str: if isinstance(value, float): if value != value or value in (float("inf"), float("-inf")): raise ValueError("cannot canonicalize a non-finite number") - # json.dumps gives the ECMAScript-style shortest round-trip for the - # value range receipts use. + # RFC 8785 uses ECMAScript number-to-string: an integral float is an + # integer (78.0 -> "78", -0.0 -> "0"), which json.dumps does not do + # (it keeps "78.0"). Rust serde_jcs and JS JSON.stringify both drop the + # ".0", so Python must too or the digest diverges. For non-integral + # values in the range a receipt carries, json.dumps already matches the + # ECMAScript shortest round-trip. + if value.is_integer(): + return str(int(value)) return json.dumps(value) if isinstance(value, (list, tuple)): return "[" + ",".join(_encode(v) for v in value) + "]" diff --git a/packages/blockrun-py/tests/test_receipt.py b/packages/blockrun-py/tests/test_receipt.py index c27f18b97..a75e37c92 100644 --- a/packages/blockrun-py/tests/test_receipt.py +++ b/packages/blockrun-py/tests/test_receipt.py @@ -6,6 +6,7 @@ from covenant_blockrun import ( # noqa: E402 ReceiptTransport, build_receipt, + canonical_sha256_hex, canonicalize, decode_challenge, payment_from_accept, @@ -37,6 +38,41 @@ def test_jcs_sorts_keys(): assert canonicalize({"x": 1, "y": [3, 2]}) == canonicalize({"y": [3, 2], "x": 1}) +def test_jcs_integral_floats_match_ecmascript(): + # RFC 8785 / Rust serde_jcs / JS all drop the ".0"; Python must too. + assert canonicalize(78.0) == "78" + assert canonicalize(1.0) == "1" + assert canonicalize(0.003) == "0.003" + + +def test_whole_float_receipt_matches_cross_language_vector(): + # A receipt whose savingsPct and amountUsdc are whole-number floats. The + # Rust crate, TS SDK, and this package must all hash it to the same digest; + # regressing Python's number formatting would break receipts silently. + r = { + "provider": "blockrun", + "endpoint": "/v1/chat/completions", + "modelRequested": "gpt-4o-mini", + "modelServed": "openai/gpt-4o-mini", + "verdict": "delivered", + "inputSha256": "aaaa", + "outputSha256": "bbbb", + "routing": {"model": "openai/gpt-4o-mini", "savingsPct": 78.0}, + "payment": { + "network": "eip155:8453", + "asset": "0x8335", + "amount": "1000000", + "amountUsdc": 1.0, + "payTo": "0xe903", + "tx": "0xabc", + }, + } + assert ( + canonical_sha256_hex(r) + == "8328699dcfa1ab8c2d8e5cfca12378999ccf94cffe68fa2ee5cefec818041baf" + ) + + def test_decode_live_challenge(): c = decode_challenge(LIVE_B64) a = pick_accept(c, "eip155:8453") diff --git a/packages/blockrun-ts/src/__tests__/receipt.test.ts b/packages/blockrun-ts/src/__tests__/receipt.test.ts index a038854bd..8c6b19c18 100644 --- a/packages/blockrun-ts/src/__tests__/receipt.test.ts +++ b/packages/blockrun-ts/src/__tests__/receipt.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { canonicalize } from "../jcs.js"; import { decodeChallenge, pickAccept } from "../challenge.js"; -import { buildReceipt, verifyReceipt, receiptDigest } from "../receipt.js"; +import { buildReceipt, verifyReceipt, receiptDigest, canonicalSha256Hex } from "../receipt.js"; import { withCovenantReceipts, type FetchLike } from "../decorator.js"; // The exact base64 BlockRun returns for a default gpt-4o-mini 402. @@ -15,6 +15,37 @@ describe("jcs", () => { it("is stable across key order", () => { expect(canonicalize({ x: 1, y: [3, 2] })).toBe(canonicalize({ y: [3, 2], x: 1 })); }); + it("drops the .0 on integral floats, matching Rust and Python", () => { + expect(canonicalize(78.0)).toBe("78"); + expect(canonicalize(1.0)).toBe("1"); + expect(canonicalize(0.003)).toBe("0.003"); + }); +}); + +describe("cross-language digest", () => { + it("hashes a whole-number-float receipt to the shared vector", async () => { + const r = { + provider: "blockrun", + endpoint: "/v1/chat/completions", + modelRequested: "gpt-4o-mini", + modelServed: "openai/gpt-4o-mini", + verdict: "delivered", + inputSha256: "aaaa", + outputSha256: "bbbb", + routing: { model: "openai/gpt-4o-mini", savingsPct: 78.0 }, + payment: { + network: "eip155:8453", + asset: "0x8335", + amount: "1000000", + amountUsdc: 1.0, + payTo: "0xe903", + tx: "0xabc", + }, + }; + expect(await canonicalSha256Hex(r)).toBe( + "8328699dcfa1ab8c2d8e5cfca12378999ccf94cffe68fa2ee5cefec818041baf", + ); + }); }); describe("challenge", () => { From 45e8fc55a0a4b4ceaa3aaec52ccc332f820f0cc7 Mon Sep 17 00:00:00 2001 From: Iko Rane Date: Mon, 20 Jul 2026 20:05:58 +0200 Subject: [PATCH 15/22] blockrun-verify: pin whole-float cross-language digest canary --- .../src/__tests__/verify.test.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/services/blockrun-verify/src/__tests__/verify.test.ts b/services/blockrun-verify/src/__tests__/verify.test.ts index 8714ae5a4..ffb71adcb 100644 --- a/services/blockrun-verify/src/__tests__/verify.test.ts +++ b/services/blockrun-verify/src/__tests__/verify.test.ts @@ -40,6 +40,33 @@ test("digest matches the cross-language vector (Rust + TS + Python)", () => { ); }); +test("whole-number-float receipt matches the shared vector", () => { + // savingsPct and amountUsdc as integral floats; the ".0" must drop to match + // Rust and the SDKs. + const r = { + provider: "blockrun", + endpoint: "/v1/chat/completions", + modelRequested: "gpt-4o-mini", + modelServed: "openai/gpt-4o-mini", + verdict: "delivered", + inputSha256: "aaaa", + outputSha256: "bbbb", + routing: { model: "openai/gpt-4o-mini", savingsPct: 78.0 }, + payment: { + network: "eip155:8453", + asset: "0x8335", + amount: "1000000", + amountUsdc: 1.0, + payTo: "0xe903", + tx: "0xabc", + }, + }; + assert.equal( + canonicalSha256Hex(r), + "8328699dcfa1ab8c2d8e5cfca12378999ccf94cffe68fa2ee5cefec818041baf", + ); +}); + test("verify accepts a consistent receipt and its digest", () => { const r = receipt(); const digest = canonicalSha256Hex(r); From 7de6573c22524bcb1f640526dd31bab3496f8771 Mon Sep 17 00:00:00 2001 From: mizuki0x <197570892+mizuki0x@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:21:10 +0200 Subject: [PATCH 16/22] blockrun: harden client - surface non-2xx, cap response size + spend, validate endpoint path, drop non-finite savings --- .../crates/covenant-blockrun/src/client.rs | 113 +++++++++++++++++- .../crates/covenant-blockrun/src/config.rs | 26 ++++ .../crates/covenant-blockrun/src/receipt.rs | 3 +- .../crates/covenant-blockrun/src/tools.rs | 30 +++++ agent-os/crates/covenantd/src/main.rs | 11 +- 5 files changed, 174 insertions(+), 9 deletions(-) diff --git a/agent-os/crates/covenant-blockrun/src/client.rs b/agent-os/crates/covenant-blockrun/src/client.rs index 95d91f002..81fce3bbb 100644 --- a/agent-os/crates/covenant-blockrun/src/client.rs +++ b/agent-os/crates/covenant-blockrun/src/client.rs @@ -22,6 +22,7 @@ pub struct BlockRunClient { http: reqwest::Client, base_url: String, signer: Arc, + max_atomic: u64, } /// A completed call: the provider's response plus the receipt binding it. @@ -45,9 +46,18 @@ impl BlockRunClient { http, base_url: base_url.into(), signer, + max_atomic: crate::config::DEFAULT_MAX_ATOMIC, } } + /// Cap the atomic amount this client will sign for. `0` keeps the default. + pub fn with_max_atomic(mut self, max_atomic: u64) -> Self { + if max_atomic != 0 { + self.max_atomic = max_atomic; + } + self + } + /// POST `body` to `endpoint`, paying the x402 challenge if one is returned, /// and return the response bound into a receipt. pub async fn call(&self, endpoint: &str, body: Value) -> Result { @@ -57,10 +67,18 @@ impl BlockRunClient { if first.status() == StatusCode::PAYMENT_REQUIRED { return self.pay_and_capture(endpoint, &url, body, first).await; } - // Free endpoint (or an error). Surface errors; otherwise emit a receipt - // with an empty payment so the exchange is still recorded. + // Not a 402: a free call (2xx) or an error. Emit a receipt only for a + // real success; a 4xx/5xx with a JSON error body must surface as an + // error, not a $0 "delivered" receipt. + let status = first.status(); let routing = RoutingClaim::from_headers(first.headers()); let response = read_json(first).await?; + if !status.is_success() { + return Err(BlockRunError::Api { + status: status.as_u16(), + message: response.to_string(), + }); + } let payment = PaymentInfo { network: String::new(), asset: String::new(), @@ -81,11 +99,27 @@ impl BlockRunClient { challenge_resp: reqwest::Response, ) -> Result { let challenge = decode_challenge(challenge_resp).await?; + // BlockRun mainnet returns a single Base USDC option today, so taking the + // first is correct. If it ever offers multiple chains, this needs to pin + // the network the configured signer can actually sign for; see `pick`. let accept = challenge .pick(None) .ok_or_else(|| BlockRunError::Challenge("no payment options offered".into()))? .clone(); + // Refuse an implausible charge before signing. The signer would authorize + // whatever value the 402 names, so a compromised or buggy challenge is + // capped here, not at the funding key. + let atomic: u64 = accept.amount.parse().map_err(|_| { + BlockRunError::Challenge(format!("non-numeric amount {:?}", accept.amount)) + })?; + if atomic > self.max_atomic { + return Err(BlockRunError::Challenge(format!( + "402 amount {atomic} exceeds the {} atomic cap", + self.max_atomic + ))); + } + let signed = self .signer .build_payment(&accept.to_requirements()) @@ -176,19 +210,49 @@ fn to_cdp_v2_header(signed: &str, accept: &Accept) -> String { engine.encode(v2.to_string().as_bytes()) } +/// A remote that answers a paid request controls its response, so an unbounded +/// read lets a hostile or broken one exhaust a worker's memory. The 120s timeout +/// bounds latency, not size; this bounds size. Mirrors `covenant_x402`'s cap. +const MAX_RESPONSE_BYTES: usize = 16 * 1024 * 1024; + async fn read_json(resp: reqwest::Response) -> Result { let status = resp.status(); - let text = resp.text().await?; - serde_json::from_str(&text).map_err(|_| BlockRunError::Api { + let bytes = read_capped(resp, MAX_RESPONSE_BYTES).await?; + serde_json::from_slice(&bytes).map_err(|_| BlockRunError::Api { status: status.as_u16(), - message: if text.is_empty() { + message: if bytes.is_empty() { "empty response".into() } else { - text + String::from_utf8_lossy(&bytes).into_owned() }, }) } +/// Read the body chunk by chunk, refusing once it exceeds `cap`. A truthful +/// `Content-Length` lets us reject up front; a lying or absent one is caught by +/// the running total. +async fn read_capped(mut resp: reqwest::Response, cap: usize) -> Result> { + if let Some(len) = resp.content_length() { + if len as usize > cap { + return Err(BlockRunError::Api { + status: resp.status().as_u16(), + message: format!("response body {len} bytes exceeds the {cap}-byte cap"), + }); + } + } + let mut buf = Vec::new(); + while let Some(chunk) = resp.chunk().await? { + if buf.len() + chunk.len() > cap { + return Err(BlockRunError::Api { + status: resp.status().as_u16(), + message: format!("response body exceeds the {cap}-byte cap"), + }); + } + buf.extend_from_slice(&chunk); + } + Ok(buf) +} + #[cfg(test)] mod tests { use super::*; @@ -262,6 +326,43 @@ mod tests { assert_eq!(out.receipt.model_served, "x"); } + #[tokio::test] + async fn surfaces_error_on_non_402_first_response() { + // A 500 with a JSON body must be an error, not a $0 "delivered" receipt. + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with( + ResponseTemplate::new(500) + .set_body_json(json!({"error": "upstream", "model": "gpt-4o-mini"})), + ) + .mount(&server) + .await; + let client = BlockRunClient::new(server.uri(), signer()); + let err = client + .call("/v1/chat/completions", json!({"model": "gpt-4o-mini"})) + .await + .unwrap_err(); + assert!(matches!(err, BlockRunError::Api { status: 500, .. })); + } + + #[tokio::test] + async fn rejects_a_402_over_the_spend_cap() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with(ResponseTemplate::new(402).insert_header("x-payment-required", LIVE_B64)) + .mount(&server) + .await; + // Cap below the challenge's 3000 atomic → refuse before signing. + let client = BlockRunClient::new(server.uri(), signer()).with_max_atomic(1000); + let err = client + .call("/v1/chat/completions", json!({"model": "gpt-4o-mini"})) + .await + .unwrap_err(); + assert!(matches!(err, BlockRunError::Challenge(m) if m.contains("cap"))); + } + #[tokio::test] async fn surfaces_api_error_after_payment() { let server = MockServer::start().await; diff --git a/agent-os/crates/covenant-blockrun/src/config.rs b/agent-os/crates/covenant-blockrun/src/config.rs index 5da0c6e4a..93b331fb4 100644 --- a/agent-os/crates/covenant-blockrun/src/config.rs +++ b/agent-os/crates/covenant-blockrun/src/config.rs @@ -5,6 +5,12 @@ use serde::{Deserialize, Serialize}; pub const DEFAULT_BASE_URL: &str = "https://blockrun.ai/api"; +/// Default per-call spend ceiling: 1 USDC in atomic units (6 decimals). A single +/// BlockRun call is a fraction of a cent, so this is a wide safety margin that +/// still refuses a 402 demanding an implausible amount before anything is signed. +/// Matches the AceData path's `DEFAULT_MAX_ATOMIC`. +pub const DEFAULT_MAX_ATOMIC: u64 = 1_000_000; + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct BlockRunConfig { #[serde(default)] @@ -14,18 +20,38 @@ pub struct BlockRunConfig { /// Tool allowlist. `None` = all registered tools, `Some([])` = none. #[serde(default, skip_serializing_if = "Option::is_none")] pub allow: Option>, + /// Reject any 402 whose atomic amount exceeds this before signing. `0` is + /// treated as the default rather than "no ceiling". + #[serde(default = "default_max_atomic")] + pub max_atomic: u64, } fn default_base_url() -> String { DEFAULT_BASE_URL.to_string() } +fn default_max_atomic() -> u64 { + DEFAULT_MAX_ATOMIC +} + impl Default for BlockRunConfig { fn default() -> Self { BlockRunConfig { enabled: false, base_url: default_base_url(), allow: None, + max_atomic: DEFAULT_MAX_ATOMIC, + } + } +} + +impl BlockRunConfig { + /// The effective ceiling, mapping `0` to the default. + pub fn max_atomic(&self) -> u64 { + if self.max_atomic == 0 { + DEFAULT_MAX_ATOMIC + } else { + self.max_atomic } } } diff --git a/agent-os/crates/covenant-blockrun/src/receipt.rs b/agent-os/crates/covenant-blockrun/src/receipt.rs index 54967c324..3a397d5de 100644 --- a/agent-os/crates/covenant-blockrun/src/receipt.rs +++ b/agent-os/crates/covenant-blockrun/src/receipt.rs @@ -66,7 +66,8 @@ impl RoutingClaim { profile: get(&["x-clawrouter-profile", "x-blockrun-profile"]), model: get(&["x-clawrouter-model", "x-blockrun-model", "x-model"]), savings_pct: get(&["x-clawrouter-savings", "x-blockrun-savings"]) - .and_then(|s| s.trim_end_matches('%').trim().parse::().ok()), + .and_then(|s| s.trim_end_matches('%').trim().parse::().ok()) + .filter(|f| f.is_finite()), } } diff --git a/agent-os/crates/covenant-blockrun/src/tools.rs b/agent-os/crates/covenant-blockrun/src/tools.rs index 6379b7b16..798d6c33d 100644 --- a/agent-os/crates/covenant-blockrun/src/tools.rs +++ b/agent-os/crates/covenant-blockrun/src/tools.rs @@ -58,6 +58,14 @@ impl Tool for CallTool { async fn call(&self, arguments: Value) -> Result { let endpoint = require_str(&arguments, "endpoint")?; + // A relative path only. This reaches a payment-signing path, so an + // absolute URL or an authority-injecting `@` must not redirect the call + // to an attacker-controlled host. + if !endpoint.starts_with('/') || endpoint.contains("//") || endpoint.contains('@') { + return Err(ToolError::InvalidArguments( + "endpoint must be a path beginning with '/'".into(), + )); + } let body = arguments .get("body") .cloned() @@ -185,6 +193,28 @@ mod tests { assert_eq!(names, vec![CALL_TOOL, VERIFY_TOOL]); } + #[tokio::test] + async fn call_rejects_a_non_path_endpoint() { + // Validated before any network call, so no server is needed. + let client = Arc::new(BlockRunClient::new( + "http://127.0.0.1:1", + Arc::new(covenant_x402::MockSigner), + )); + let tool = CallTool { client }; + for bad in [ + "v1/chat", + "https://evil.com/x", + "/@evil.com/x", + "//evil.com", + ] { + let out = tool.call(json!({"endpoint": bad, "body": {}})).await; + assert!( + matches!(out, Err(ToolError::InvalidArguments(_))), + "expected rejection for {bad:?}" + ); + } + } + #[tokio::test] async fn verify_accepts_a_clean_receipt() { let r = sample_receipt(); diff --git a/agent-os/crates/covenantd/src/main.rs b/agent-os/crates/covenantd/src/main.rs index d1244800b..513362349 100644 --- a/agent-os/crates/covenantd/src/main.rs +++ b/agent-os/crates/covenantd/src/main.rs @@ -1023,7 +1023,7 @@ fn blockrun_from_env() -> Option<( }; if let Ok(url) = std::env::var("COVENANT_BLOCKRUN_BASE_URL") { if !url.trim().is_empty() { - cfg.base_url = url; + cfg.base_url = url.trim().to_string(); } } if let Ok(list) = std::env::var("COVENANT_BLOCKRUN_ALLOW") { @@ -1034,6 +1034,11 @@ fn blockrun_from_env() -> Option<( .collect(), ); } + if let Ok(v) = std::env::var("COVENANT_BLOCKRUN_MAX_ATOMIC") { + if let Ok(n) = v.trim().parse::() { + cfg.max_atomic = n; + } + } match x402_dispatch_config_from_env() { Some(x402) => { let mut signer = covenantd::x402::SubprocessSigner::new(&x402.signer_binary); @@ -1043,9 +1048,11 @@ fn blockrun_from_env() -> Option<( let client = covenant_blockrun::BlockRunClient::new( cfg.base_url.clone(), std::sync::Arc::new(signer), - ); + ) + .with_max_atomic(cfg.max_atomic()); info!( base_url = %cfg.base_url, + max_atomic = cfg.max_atomic(), signer = %x402.signer_binary.display(), "blockrun enabled (x402 pay-per-call)" ); From 57e0f7091393f411dc2dda4cbdeeda00b459e83d Mon Sep 17 00:00:00 2001 From: mizuki0x <197570892+mizuki0x@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:21:10 +0200 Subject: [PATCH 17/22] blockrun sdks: ES-correct number/utf16 JCS, bounded stash, 2xx-gate, error sink, python async+stream-safe transport --- .../src/covenant_blockrun/decorator.py | 180 ++++++++++++++---- .../blockrun-py/src/covenant_blockrun/jcs.py | 52 +++-- packages/blockrun-py/tests/test_receipt.py | 108 +++++++++-- .../blockrun-ts/src/__tests__/receipt.test.ts | 17 +- packages/blockrun-ts/src/decorator.ts | 82 ++++---- 5 files changed, 337 insertions(+), 102 deletions(-) diff --git a/packages/blockrun-py/src/covenant_blockrun/decorator.py b/packages/blockrun-py/src/covenant_blockrun/decorator.py index c62e09f8b..a5471ce03 100644 --- a/packages/blockrun-py/src/covenant_blockrun/decorator.py +++ b/packages/blockrun-py/src/covenant_blockrun/decorator.py @@ -1,12 +1,16 @@ -"""The drop-in wedge for Python: an httpx transport that emits a Covenant -receipt for every BlockRun call, without changing how the call is made or how -the money moves. +"""The drop-in wedge for Python: an httpx transport that emits a Covenant receipt +for every settled BlockRun call, without changing how the call is made or how the +money moves. BlockRun's Python SDK signs x402 payments inside a custom httpx transport (``AnthropicClient`` wraps ``anthropic.Anthropic`` this way). Wrap that transport -with :class:`ReceiptTransport` and every settled call yields a receipt. The -transport is duck-typed: it only needs a ``handle_request(request) -> response`` -method, so it works with httpx sync transports without importing httpx. +with :class:`ReceiptTransport` and every settled call yields a receipt. Both the +sync (``handle_request``) and async (``handle_async_request``) paths are handled. + +A transport must not consume the caller's response body, so the response stream is +teed: bytes flow to the caller unchanged while a bounded copy is accumulated, and +the receipt is built when the stream closes. Streaming responses that exceed the +copy cap still pass through; their receipt simply carries no output body. """ from __future__ import annotations @@ -18,61 +22,93 @@ from .receipt import CallReceipt, PaymentInfo, RoutingClaim, build_receipt, payment_from_accept OnReceipt = Callable[[CallReceipt], None] +OnError = Callable[[BaseException], None] RECEIPT_HEADER = "x-payment-receipt" REQUIRED_HEADER = "x-payment-required" +# Cap on outstanding 402 challenges awaiting a paid retry, and on the response +# bytes copied per call for the receipt. +MAX_PENDING = 1024 +MAX_BODY_COPY = 16 * 1024 * 1024 + class ReceiptTransport: """Wrap an httpx-style transport. Pass the result as ``transport=`` (or the inner transport of your BlockRun client). ``on_receipt`` is called once per - completed BlockRun exchange.""" + settled BlockRun exchange; ``on_error``, if given, receives any exception + from building or delivering a receipt (these never affect the call).""" def __init__( self, inner: Any, on_receipt: OnReceipt, *, - swallow_receipt_errors: bool = True, + on_error: Optional[OnError] = None, ) -> None: self._inner = inner self._on_receipt = on_receipt - self._swallow = swallow_receipt_errors - # Challenge stash keyed by a request signature, so a 402 pairs with its - # paid retry. + self._on_error = on_error self._pending: dict[str, dict[str, Any]] = {} def handle_request(self, request: Any) -> Any: key = _request_key(request) response = self._inner.handle_request(request) - status = _status(response) + challenge = self._route(request, response, key) + if challenge is _SKIP: + return response + response.stream = _TeeStream( + response.stream, self._emit_cb(request, response, challenge) + ) + return response + + async def handle_async_request(self, request: Any) -> Any: + key = _request_key(request) + response = await self._inner.handle_async_request(request) + challenge = self._route(request, response, key) + if challenge is _SKIP: + return response + response.stream = _AsyncTeeStream( + response.stream, self._emit_cb(request, response, challenge) + ) + return response + def _route(self, request: Any, response: Any, key: str) -> Any: + """Stash a 402, drop the paired/failed challenge otherwise, and decide + whether this response should produce a receipt. Returns the matched + challenge (or None), or the ``_SKIP`` sentinel to pass through untouched.""" + status = _status(response) if status == 402: header = _header(response, REQUIRED_HEADER) or _header(response, "www-authenticate") if header: try: + if len(self._pending) >= MAX_PENDING: + self._pending.pop(next(iter(self._pending))) self._pending[key] = decode_challenge(header) - except Exception: # noqa: BLE001 - a bad challenge is not fatal - pass - return response + except Exception as err: # noqa: BLE001 + self._report(err) + return _SKIP + challenge = self._pending.pop(key, None) + if not (200 <= status < 300): + return _SKIP + return challenge - try: - self._emit(request, response, key) - except Exception: # noqa: BLE001 - if not self._swallow: - raise - return response + def _emit_cb(self, request: Any, response: Any, challenge: Any) -> Callable[[bytes], None]: + def done(body: bytes) -> None: + try: + self._emit(request, response, challenge, body) + except Exception as err: # noqa: BLE001 + self._report(err) + + return done - def _emit(self, request: Any, response: Any, key: str) -> None: + def _emit(self, request: Any, response: Any, challenge: Any, body: bytes) -> None: req_body = _parse_json(_request_body(request)) - resp_body = _parse_json(_response_body(response)) + resp_body = _parse_json(body.decode("utf-8", "replace")) if body else {} routing = _read_routing(response) tx = _header(response, RECEIPT_HEADER) - challenge = self._pending.pop(key, None) accept = pick_accept(challenge) if challenge else None - payment: PaymentInfo = ( - payment_from_accept(accept, tx) if accept else PaymentInfo(tx=tx) - ) + payment: PaymentInfo = payment_from_accept(accept, tx) if accept else PaymentInfo(tx=tx) receipt = build_receipt( endpoint=_path(request), request=req_body, @@ -82,11 +118,86 @@ def _emit(self, request: Any, response: Any, key: str) -> None: ) self._on_receipt(receipt) + def _report(self, err: BaseException) -> None: + if self._on_error is not None: + self._on_error(err) + def close(self) -> None: close = getattr(self._inner, "close", None) if callable(close): close() + async def aclose(self) -> None: + aclose = getattr(self._inner, "aclose", None) + if callable(aclose): + await aclose() + + +_SKIP = object() + + +class _TeeStream: + """A sync httpx byte stream that copies (up to a cap) what flows through and + runs ``on_done`` with the copy when the stream closes. Passing bytes straight + through keeps the caller's body intact.""" + + def __init__(self, inner: Any, on_done: Callable[[bytes], None]) -> None: + self._inner = inner + self._on_done = on_done + self._buf = bytearray() + self._truncated = False + self._fired = False + + def __iter__(self): + for chunk in self._inner: + self._accumulate(chunk) + yield chunk + + def _accumulate(self, chunk: bytes) -> None: + if not self._truncated and len(self._buf) + len(chunk) <= MAX_BODY_COPY: + self._buf += chunk + else: + self._truncated = True + + def close(self) -> None: + inner_close = getattr(self._inner, "close", None) + if callable(inner_close): + inner_close() + self._fire() + + def _fire(self) -> None: + if self._fired: + return + self._fired = True + self._on_done(b"" if self._truncated else bytes(self._buf)) + + +class _AsyncTeeStream: + """Async counterpart of :class:`_TeeStream`.""" + + def __init__(self, inner: Any, on_done: Callable[[bytes], None]) -> None: + self._inner = inner + self._on_done = on_done + self._buf = bytearray() + self._truncated = False + self._fired = False + + async def __aiter__(self): + async for chunk in self._inner: + if not self._truncated and len(self._buf) + len(chunk) <= MAX_BODY_COPY: + self._buf += chunk + else: + self._truncated = True + yield chunk + + async def aclose(self) -> None: + inner_aclose = getattr(self._inner, "aclose", None) + if callable(inner_aclose): + await inner_aclose() + if not self._fired: + self._fired = True + self._on_done(b"" if self._truncated else bytes(self._buf)) + def _read_routing(response: Any) -> RoutingClaim: def first(*names: str) -> Optional[str]: @@ -154,21 +265,6 @@ def _request_body(request: Any) -> Optional[str]: return str(content) -def _response_body(response: Any) -> Optional[str]: - # httpx buffers the body on `.content` after `.read()`. - read = getattr(response, "read", None) - if callable(read): - try: - read() - except Exception: # noqa: BLE001 - pass - content = getattr(response, "content", None) - if isinstance(content, (bytes, bytearray)): - return content.decode("utf-8", "replace") - text = getattr(response, "text", None) - return str(text) if text is not None else None - - def _parse_json(text: Optional[str]) -> Any: if not text: return {} diff --git a/packages/blockrun-py/src/covenant_blockrun/jcs.py b/packages/blockrun-py/src/covenant_blockrun/jcs.py index ebc76ebd3..085a04e67 100644 --- a/packages/blockrun-py/src/covenant_blockrun/jcs.py +++ b/packages/blockrun-py/src/covenant_blockrun/jcs.py @@ -10,6 +10,7 @@ from __future__ import annotations import json +from decimal import Decimal from typing import Any @@ -27,26 +28,22 @@ def _encode(value: Any) -> str: if isinstance(value, str): return json.dumps(value, ensure_ascii=False) if isinstance(value, int): + # Exact for any magnitude. JS/Rust route JSON numbers through f64 and so + # round integers above 2**53; a receipt never carries one (ids are + # strings, counts are small), so this latent gap is left documented. return str(value) if isinstance(value, float): if value != value or value in (float("inf"), float("-inf")): raise ValueError("cannot canonicalize a non-finite number") - # RFC 8785 uses ECMAScript number-to-string: an integral float is an - # integer (78.0 -> "78", -0.0 -> "0"), which json.dumps does not do - # (it keeps "78.0"). Rust serde_jcs and JS JSON.stringify both drop the - # ".0", so Python must too or the digest diverges. For non-integral - # values in the range a receipt carries, json.dumps already matches the - # ECMAScript shortest round-trip. - if value.is_integer(): - return str(int(value)) - return json.dumps(value) + return _es_number(value) if isinstance(value, (list, tuple)): return "[" + ",".join(_encode(v) for v in value) + "]" if isinstance(value, dict): items = [(k, v) for k, v in value.items() if v is not None] - # Code-point sort; coincides with the JS/Rust UTF-16 code-unit sort for - # the BMP keys a receipt uses. - items.sort(key=lambda kv: kv[0]) + # RFC 8785 sorts keys by UTF-16 code unit. Rust serde_jcs and JS both do + # this; a plain code-point sort would disagree on supplementary-plane + # keys (emoji etc.) that can appear in a hashed request/response body. + items.sort(key=lambda kv: kv[0].encode("utf-16-be")) return ( "{" + ",".join( @@ -55,3 +52,34 @@ def _encode(value: Any) -> str: + "}" ) raise TypeError(f"cannot canonicalize value of type {type(value).__name__}") + + +def _es_number(value: float) -> str: + """Format a finite float as ECMAScript Number::toString, which is what RFC + 8785 (and Rust serde_jcs via ryu_js, and JS JSON.stringify) use. Python's + json.dumps differs on integral floats ("78.0") and on the exponent + thresholds ("1e-05" vs "0.00001"), so those must be handled here.""" + if value == 0: # also -0.0, which ECMAScript renders as "0" + return "0" + sign = "-" if value < 0 else "" + # repr gives the shortest round-trip decimal; Decimal exposes its digits. + _, digits, exp = Decimal(repr(abs(value))).as_tuple() + digs = list(digits) + while len(digs) > 1 and digs[-1] == 0: # strip trailing zeros + digs.pop() + exp += 1 + while len(digs) > 1 and digs[0] == 0: # strip leading zeros + digs.pop(0) + sig = "".join(str(d) for d in digs) + k = len(sig) + n = exp + k # value == sig * 10**exp == 0.sig * 10**n + if k <= n <= 21: + return sign + sig + "0" * (n - k) + if 0 < n <= 21: + return sign + sig[:n] + "." + sig[n:] + if -6 < n <= 0: + return sign + "0." + "0" * (-n) + sig + e = n - 1 + e_str = ("+" if e >= 0 else "-") + str(abs(e)) + mantissa = sig if k == 1 else sig[0] + "." + sig[1:] + return sign + mantissa + "e" + e_str diff --git a/packages/blockrun-py/tests/test_receipt.py b/packages/blockrun-py/tests/test_receipt.py index a75e37c92..50199b43b 100644 --- a/packages/blockrun-py/tests/test_receipt.py +++ b/packages/blockrun-py/tests/test_receipt.py @@ -38,11 +38,27 @@ def test_jcs_sorts_keys(): assert canonicalize({"x": 1, "y": [3, 2]}) == canonicalize({"y": [3, 2], "x": 1}) -def test_jcs_integral_floats_match_ecmascript(): - # RFC 8785 / Rust serde_jcs / JS all drop the ".0"; Python must too. - assert canonicalize(78.0) == "78" - assert canonicalize(1.0) == "1" - assert canonicalize(0.003) == "0.003" +def test_jcs_numbers_match_ecmascript(): + # ECMAScript Number.toString (what Rust serde_jcs and JS JSON.stringify use). + cases = { + 78.0: "78", + 1.0: "1", + 0.003: "0.003", + -0.0: "0", + 1e-5: "0.00001", + 1e-6: "0.000001", + 1e-7: "1e-7", + 1e20: "100000000000000000000", + 1e21: "1e+21", + 123.456: "123.456", + } + for value, expected in cases.items(): + assert canonicalize(value) == expected, f"{value!r} -> {canonicalize(value)}" + + +def test_jcs_sorts_keys_by_utf16_code_unit(): + # A supplementary-plane key must sort before U+FFFF, matching Rust/JS. + assert canonicalize({"￿": 1, "\U0001f600": 2}) == '{"\U0001f600":2,"￿":1}' def test_whole_float_receipt_matches_cross_language_vector(): @@ -105,6 +121,39 @@ def test_substituted_when_router_swaps(): assert r.verdict == "substituted" +def test_build_path_digest_matches_literal(): + # Exercises to_json()/RoutingClaim assembly (with a whole-number float + # savings), not just a hand-built literal, so an assembly regression is caught. + from covenant_blockrun import RoutingClaim, canonical_sha256_hex + + built = build_receipt( + "/v1/chat/completions", + {"model": "gpt-4o-mini", "messages": []}, + {"model": "openai/gpt-4o-mini", "choices": []}, + _payment(), + RoutingClaim(model="openai/gpt-4o-mini", savings_pct=78.0), + ) + literal = { + "provider": "blockrun", + "endpoint": "/v1/chat/completions", + "modelRequested": "gpt-4o-mini", + "modelServed": "openai/gpt-4o-mini", + "verdict": "delivered", + "inputSha256": built.input_sha256, + "outputSha256": built.output_sha256, + "routing": {"model": "openai/gpt-4o-mini", "savingsPct": 78.0}, + "payment": { + "network": "eip155:8453", + "asset": "0x8335", + "amount": "3000", + "amountUsdc": 0.003, + "payTo": "0xe903", + "tx": "0xabc", + }, + } + assert built.digest() == canonical_sha256_hex(literal) + + def test_digest_stable_across_key_order(): a = build_receipt("/x", {"model": "m"}, {"model": "m", "id": "1", "choices": []}, _payment()) b = build_receipt("/x", {"model": "m"}, {"choices": [], "id": "1", "model": "m"}, _payment()) @@ -145,14 +194,25 @@ def get(self, k, default=None): return super().get(k.lower(), default) +class _Stream: + """A minimal sync httpx byte stream: iterable of chunks with close().""" + + def __init__(self, body: bytes): + self._chunks = [body] if body else [] + self.closed = False + + def __iter__(self): + yield from self._chunks + + def close(self): + self.closed = True + + class _Resp: def __init__(self, status, headers, body): self.status_code = status self.headers = _Headers({k.lower(): v for k, v in headers.items()}) - self.content = body.encode() if isinstance(body, str) else body - - def read(self): - return self.content + self.stream = _Stream(body.encode() if isinstance(body, str) else body) class _FakeInner: @@ -167,23 +227,47 @@ def handle_request(self, request): ) +def _drain(response): + """Mimic httpx: read the body, then close the stream (fires the receipt).""" + body = b"".join(response.stream) + response.stream.close() + return body + + def test_transport_pairs_402_with_paid_retry(): received = [] - transport = ReceiptTransport(_FakeInner(), on_receipt=received.append, swallow_receipt_errors=False) + errors = [] + transport = ReceiptTransport(_FakeInner(), on_receipt=received.append, on_error=errors.append) body = '{"model": "gpt-4o-mini", "messages": []}' url = "https://blockrun.ai/api/v1/chat/completions" first = _Req("POST", url, body) - transport.handle_request(first) # 402, stashes challenge + _drain(transport.handle_request(first)) # 402, stashes challenge retry = _Req("POST", url, body) retry.headers_paid = True - transport.handle_request(retry) # paid, emits receipt + resp = transport.handle_request(retry) + # The caller's body must still be intact through the tee. + assert _drain(resp) == b'{"model": "gpt-4o-mini", "choices": []}' + assert errors == [] assert len(received) == 1 r = received[0] assert r.verdict == "delivered" assert r.payment.tx == "0xdeadbeef" assert r.payment.amount == "3000" assert r.endpoint == "/api/v1/chat/completions" + + +def test_transport_skips_non_2xx(): + received = [] + + class _ErrInner: + def handle_request(self, request): + return _Resp(500, {}, '{"error": "upstream"}') + + transport = ReceiptTransport(_ErrInner(), on_receipt=received.append) + resp = transport.handle_request(_Req("POST", "https://blockrun.ai/api/v1/x", "{}")) + _drain(resp) + assert received == [] diff --git a/packages/blockrun-ts/src/__tests__/receipt.test.ts b/packages/blockrun-ts/src/__tests__/receipt.test.ts index 8c6b19c18..4e62e7327 100644 --- a/packages/blockrun-ts/src/__tests__/receipt.test.ts +++ b/packages/blockrun-ts/src/__tests__/receipt.test.ts @@ -150,11 +150,12 @@ describe("decorator", () => { }; const receipts: import("../receipt.js").CallReceipt[] = []; + const errors: unknown[] = []; const wrapped = withCovenantReceipts(base, { onReceipt: (r) => { receipts.push(r); }, - rethrowReceiptErrors: true, + onError: (e) => errors.push(e), }); const body = JSON.stringify({ model: "gpt-4o-mini", messages: [] }); @@ -166,6 +167,7 @@ describe("decorator", () => { // Let the fire-and-forget receipt build settle. await new Promise((r) => setTimeout(r, 10)); + expect(errors).toHaveLength(0); expect(receipts).toHaveLength(1); const r = receipts[0]; expect(r.verdict).toBe("delivered"); @@ -174,4 +176,17 @@ describe("decorator", () => { expect(r.payment.amountUsdc).toBeCloseTo(0.003); expect(r.endpoint).toBe("/api/v1/chat/completions"); }); + + it("emits no receipt for a non-2xx response", async () => { + const base: FetchLike = async () => + new Response(JSON.stringify({ error: "bad request" }), { status: 400 }); + const receipts: import("../receipt.js").CallReceipt[] = []; + const wrapped = withCovenantReceipts(base, { onReceipt: (r) => receipts.push(r) }); + await wrapped("https://blockrun.ai/api/v1/chat/completions", { + method: "POST", + body: JSON.stringify({ model: "gpt-4o-mini" }), + }); + await new Promise((r) => setTimeout(r, 10)); + expect(receipts).toHaveLength(0); + }); }); diff --git a/packages/blockrun-ts/src/decorator.ts b/packages/blockrun-ts/src/decorator.ts index 6f56666ec..d98f5824a 100644 --- a/packages/blockrun-ts/src/decorator.ts +++ b/packages/blockrun-ts/src/decorator.ts @@ -1,8 +1,8 @@ /** - * The drop-in wedge: wrap a `fetch` so every BlockRun call emits a Covenant - * receipt, without changing how the call is made or the money moves. + * The drop-in wedge: wrap a `fetch` so every settled BlockRun call emits a + * Covenant receipt, without changing how the call is made or the money moves. * - * BlockRun's own client does the 402 → pay → retry loop through some `fetch`; + * BlockRun's own client does the 402 -> pay -> retry loop through some `fetch`; * we sit around that fetch, remember the challenge from the 402, and pair it * with the settled retry (whose `x-payment-receipt` header carries the tx) to * build a complete receipt. The response is passed through untouched. @@ -20,20 +20,27 @@ export type OnReceipt = (receipt: CallReceipt) => void | Promise; export interface WrapOptions { /** Called once per settled (or free) BlockRun call with its receipt. */ onReceipt: OnReceipt; - /** Errors thrown by onReceipt are swallowed by default; set to surface them. */ - rethrowReceiptErrors?: boolean; + /** + * Notified if building or delivering a receipt throws. Receipt work never + * affects the underlying call, so errors are reported here rather than raised + * into the caller. Without this they are swallowed. + */ + onError?: (err: unknown) => void; } const RECEIPT_HEADER = "x-payment-receipt"; const REQUIRED_HEADER = "x-payment-required"; +// Cap on outstanding 402 challenges awaiting their paid retry. A 402 that is +// never paired (payment fails, caller aborts) would otherwise leak one entry +// forever; past this many we drop the oldest. +const MAX_PENDING = 1024; + /** * Wrap a fetch. Returns a fetch with identical behaviour that additionally * emits a receipt for each completed BlockRun exchange. */ export function withCovenantReceipts(baseFetch: FetchLike, opts: WrapOptions): FetchLike { - // Challenge stash, keyed by request signature, so a 402 seen on one call can - // be paired with its paid retry. Entries are short-lived and cleared on use. const pending = new Map>(); return async (input, init) => { @@ -49,41 +56,43 @@ export function withCovenantReceipts(baseFetch: FetchLike, opts: WrapOptions): F const header = res.headers.get(REQUIRED_HEADER) ?? res.headers.get("www-authenticate"); if (header) { try { + if (pending.size >= MAX_PENDING) { + pending.delete(pending.keys().next().value as string); + } pending.set(key, decodeChallenge(header)); - } catch { - /* not a decodable challenge; ignore */ + } catch (err) { + opts.onError?.(err); } } return res; } - // Emit a receipt for the completed call. Clone so the caller still gets an - // unread body. + // Only a delivered (2xx) call gets a receipt. A challenge left unpaired by a + // failed retry is cleaned up here rather than left to leak. + const challenge = pending.get(key); + pending.delete(key); + if (!res.ok) return res; + + // Clone so the caller still gets an unread body. const clone = res.clone(); void (async () => { - try { - const request = safeParse(bodyText); - const response = await safeJson(clone); - const routing = readRouting(res.headers); - const tx = res.headers.get(RECEIPT_HEADER) ?? undefined; - const challenge = pending.get(key); - pending.delete(key); - const accept = challenge ? pickAccept(challenge) : undefined; - const payment: PaymentInfo = accept - ? acceptToPayment(accept, tx) - : { network: "", asset: "", amount: "0", amountUsdc: 0, payTo: "", tx }; - const receipt = await buildReceipt({ - endpoint: endpointOf(url), - request, - response, - payment, - routing, - }); - await opts.onReceipt(receipt); - } catch (err) { - if (opts.rethrowReceiptErrors) throw err; - } - })(); + const request = safeParse(bodyText); + const response = await safeJson(clone); + const routing = readRouting(res.headers); + const tx = res.headers.get(RECEIPT_HEADER) ?? undefined; + const accept = challenge ? pickAccept(challenge) : undefined; + const payment: PaymentInfo = accept + ? acceptToPayment(accept, tx) + : { network: "", asset: "", amount: "0", amountUsdc: 0, payTo: "", tx }; + const receipt = await buildReceipt({ + endpoint: endpointOf(url), + request, + response, + payment, + routing, + }); + await opts.onReceipt(receipt); + })().catch((err) => opts.onError?.(err)); return res; }; @@ -123,7 +132,10 @@ async function readRequestBody( input: string | URL | Request, init?: RequestInit, ): Promise { - if (init?.body && typeof init.body === "string") return init.body; + const body = init?.body; + if (typeof body === "string") return body; + if (body instanceof Uint8Array) return new TextDecoder().decode(body); + if (body instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(body)); if (input instanceof Request) { try { return await input.clone().text(); From b01a5aabc772827b0a9bdb57b70673f21191d35c Mon Sep 17 00:00:00 2001 From: Iko Rane Date: Mon, 20 Jul 2026 20:21:10 +0200 Subject: [PATCH 18/22] blockrun-verify: 400 on un-canonicalizable receipt, validate price at boot, rename settled->hasSettlementTx --- services/blockrun-verify/render.yaml | 2 ++ .../src/__tests__/verify.test.ts | 28 ++++++++++++++++++- services/blockrun-verify/src/receipt.ts | 8 ++++-- services/blockrun-verify/src/server.ts | 7 +++++ services/blockrun-verify/src/verify-route.ts | 11 +++++++- 5 files changed, 51 insertions(+), 5 deletions(-) diff --git a/services/blockrun-verify/render.yaml b/services/blockrun-verify/render.yaml index 148ee1534..5f93db848 100644 --- a/services/blockrun-verify/render.yaml +++ b/services/blockrun-verify/render.yaml @@ -40,3 +40,5 @@ services: # for production, where responses must verify against a pinned pubkey). - key: COVENANT_ATTEST_KEYPAIR sync: false + - key: BLOCKRUN_VERIFY_PRICE + value: "5000" diff --git a/services/blockrun-verify/src/__tests__/verify.test.ts b/services/blockrun-verify/src/__tests__/verify.test.ts index ffb71adcb..b05f5e26f 100644 --- a/services/blockrun-verify/src/__tests__/verify.test.ts +++ b/services/blockrun-verify/src/__tests__/verify.test.ts @@ -74,7 +74,7 @@ test("verify accepts a consistent receipt and its digest", () => { assert.equal(res.valid, true); assert.equal(res.verdictConsistent, true); assert.equal(res.digestMatches, true); - assert.equal(res.settled, true); + assert.equal(res.hasSettlementTx, true); }); test("verify catches a tampered verdict", () => { @@ -128,3 +128,29 @@ test("handler rejects a malformed receipt without charging (>=400)", () => { handler({ body: { receipt: { not: "a receipt" } } } as any, res); assert.equal(status, 400); }); + +test("a pathologically nested receipt is a 400, not an uncaught crash", () => { + const handler = makeVerifyHandler(Attestor.generate()); + let deep: any = "x"; + for (let i = 0; i < 30000; i++) deep = [deep]; + const receipt = { + verdict: "x", + modelRequested: "x", + modelServed: "x", + inputSha256: "x", + outputSha256: "x", + routing: deep, + }; + let status = 200; + const res: any = { + status(s: number) { + status = s; + return this; + }, + json() { + return this; + }, + }; + handler({ body: { receipt } } as any, res); + assert.equal(status, 400); +}); diff --git a/services/blockrun-verify/src/receipt.ts b/services/blockrun-verify/src/receipt.ts index ae87aa554..e0db6703c 100644 --- a/services/blockrun-verify/src/receipt.ts +++ b/services/blockrun-verify/src/receipt.ts @@ -37,7 +37,10 @@ export interface VerifyResult { verdictConsistent: boolean; statedVerdict: string; expectedVerdict: string; - settled: boolean; + // Whether the receipt carries a settlement tx string. This is not an on-chain + // check: the service never touches the chain, it only confirms the field is + // present. Named to say exactly that. + hasSettlementTx: boolean; } export function canonicalize(value: unknown): string { @@ -69,7 +72,6 @@ export function verifyReceipt(receipt: CallReceipt, expectedDigest?: string): Ve const expectedVerdict = verdictFor(receipt.modelRequested ?? "", receipt.modelServed ?? ""); const verdictConsistent = expectedVerdict === receipt.verdict; const digestMatches = expectedDigest === undefined ? null : expectedDigest === digest; - const settled = Boolean(receipt.payment?.tx); return { valid: verdictConsistent && (digestMatches ?? true), digest, @@ -77,7 +79,7 @@ export function verifyReceipt(receipt: CallReceipt, expectedDigest?: string): Ve verdictConsistent, statedVerdict: receipt.verdict ?? "", expectedVerdict, - settled, + hasSettlementTx: Boolean(receipt.payment?.tx), }; } diff --git a/services/blockrun-verify/src/server.ts b/services/blockrun-verify/src/server.ts index 246986f4f..92882e3ce 100644 --- a/services/blockrun-verify/src/server.ts +++ b/services/blockrun-verify/src/server.ts @@ -88,6 +88,13 @@ if (!/^0x[0-9a-fA-F]{40}$/.test(PAY_TO)) { process.exit(1); } +if (!/^[1-9][0-9]*$/.test(PRICE)) { + console.error( + `BLOCKRUN_VERIFY_PRICE must be a positive integer in USDC base units (got "${PRICE}"); 5000 = 0.005 USDC`, + ); + process.exit(1); +} + const attestor = process.env.COVENANT_ATTEST_KEYPAIR ? new Attestor(JSON.parse(process.env.COVENANT_ATTEST_KEYPAIR) as number[]) : Attestor.generate(); diff --git a/services/blockrun-verify/src/verify-route.ts b/services/blockrun-verify/src/verify-route.ts index e45caceb8..452c60537 100644 --- a/services/blockrun-verify/src/verify-route.ts +++ b/services/blockrun-verify/src/verify-route.ts @@ -25,7 +25,16 @@ export function makeVerifyHandler(attestor: Attestor) { res.status(400).json({ error: "expectedDigest, if given, must be a string" }); return; } - const result = verifyReceipt(body.receipt, body.expectedDigest); + // A pathologically nested receipt can overflow the recursive canonicalizer. + // Catch it as the contracted "malformed receipt is free" 400 rather than an + // uncaught 500 (returning >= 400 cancels settlement either way). + let result; + try { + result = verifyReceipt(body.receipt, body.expectedDigest); + } catch { + res.status(400).json({ error: "receipt could not be canonicalized" }); + return; + } // Sign the verification over the receipt digest as subject, so the buyer can // pin the pubkey and check the statement independently. const attestation = attestor.attest(result.digest, result, Math.floor(Date.now() / 1000)); From 8178e2c5e2cc7f7b5cab0c7f66e910cbf12f1c4e Mon Sep 17 00:00:00 2001 From: mizuki0x <197570892+mizuki0x@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:31:34 +0200 Subject: [PATCH 19/22] blockrun: document key-file env + async/stream receipt timing + 2xx-only in demo and sdk readmes --- .../covenant-blockrun/examples/live_verified_call.rs | 6 ++++-- packages/blockrun-py/README.md | 6 ++++++ packages/blockrun-ts/README.md | 7 +++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/agent-os/crates/covenant-blockrun/examples/live_verified_call.rs b/agent-os/crates/covenant-blockrun/examples/live_verified_call.rs index b47e9b738..f8040228c 100644 --- a/agent-os/crates/covenant-blockrun/examples/live_verified_call.rs +++ b/agent-os/crates/covenant-blockrun/examples/live_verified_call.rs @@ -6,9 +6,11 @@ //! chat call is ~0.003 USDC) — it is a `live_` example, never run by `cargo //! test`. //! -//! Run: -//! COVENANT_X402_EVM_KEY_HEX=<64-hex Base secp256k1 secret, funded with USDC> \ +//! Run (the key is a 64-hex Base secp256k1 secret funded with USDC, given as a +//! file path so it never reaches the shell history, or inline): +//! COVENANT_X402_EVM_KEY=/path/to/key.hex \ //! cargo run -p covenant-blockrun --example live_verified_call +//! # or: COVENANT_X402_EVM_KEY_HEX=<64-hex> cargo run … //! //! Optional: //! BLOCKRUN_MODEL model to request (default gpt-4o-mini) diff --git a/packages/blockrun-py/README.md b/packages/blockrun-py/README.md index 90eee7d88..8d6ac5912 100644 --- a/packages/blockrun-py/README.md +++ b/packages/blockrun-py/README.md @@ -34,6 +34,12 @@ http_client = httpx.Client(transport=transport) # hand http_client to your BlockRun / OpenAI-compatible client ``` +The transport tees the response stream, so `on_receipt` fires once the response +body has been consumed and its stream closed (httpx does this for you on a normal +request). The caller's body is never touched. A call is receipted only on a 2xx +response. Pass `on_error=...` to observe any receipt-build failure; it never +affects the call. + Each completed call yields a `CallReceipt`. `receipt.verdict` is the point: `delivered` when you got the model you asked for, `substituted` when the router swapped it, `unverified` when no served model was reported — turning "cheapest diff --git a/packages/blockrun-ts/README.md b/packages/blockrun-ts/README.md index 3a6d25bde..e88b236d1 100644 --- a/packages/blockrun-ts/README.md +++ b/packages/blockrun-ts/README.md @@ -34,6 +34,13 @@ const fetchWithReceipts = withCovenantReceipts(globalThis.fetch, { const client = new OpenAI({ fetch: fetchWithReceipts, /* … */ }); ``` +`onReceipt` is called **after** the wrapped `fetch` resolves, not during it — the +receipt is built off the response clone so it never delays your call. Read +receipts in the callback (persist, log, enqueue); don't expect one to exist the +line after `await fetch(...)`. A call is only receipted on a 2xx response; a +plain 2xx with no preceding 402 gets a receipt with empty payment fields. Pass +`onError` to see any receipt-build failure (it never affects the call). + Each completed call yields a `CallReceipt`: ```jsonc From 4e183630e37f840b2daa90d63f78cd0457970033 Mon Sep 17 00:00:00 2001 From: Iko Rane Date: Mon, 20 Jul 2026 20:31:34 +0200 Subject: [PATCH 20/22] blockrun-verify: warn at boot when sync-off + no cdp creds would 500 unpaid requests; document the flag --- services/blockrun-verify/README.md | 6 ++++++ services/blockrun-verify/src/server.ts | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/services/blockrun-verify/README.md b/services/blockrun-verify/README.md index a8d5ae33e..0251c4c84 100644 --- a/services/blockrun-verify/README.md +++ b/services/blockrun-verify/README.md @@ -45,4 +45,10 @@ Configuration mirrors the Covenant x402 seller (`X402_NETWORK`, `COVENANT_BASE_PAYTO`, CDP credentials for Base mainnet settlement, `COVENANT_ATTEST_KEYPAIR`). See `render.yaml` and `src/server.ts`. +Leave `X402_SYNC_FACILITATOR` on (the default). It fetches the facilitator's +supported payment kinds at boot, which the 402 challenge is built from. Turning +it off without CDP credentials leaves the middleware unable to issue a challenge, +so unpaid requests fail with a 500 instead of a 402; the server warns at boot if +you do this. + Apache-2.0. diff --git a/services/blockrun-verify/src/server.ts b/services/blockrun-verify/src/server.ts index 92882e3ce..d8d205676 100644 --- a/services/blockrun-verify/src/server.ts +++ b/services/blockrun-verify/src/server.ts @@ -141,6 +141,16 @@ const facilitatorConfig = CDP_ID && CDP_SECRET ? createFacilitatorConfig(CDP_ID, CDP_SECRET) : { url: FACILITATOR_URL }; const facilitator = new HTTPFacilitatorClient(facilitatorConfig); +// The 402 challenge is built from the facilitator's advertised supported kinds. +// With the boot sync off and no CDP credentials to authenticate an on-demand +// fetch, the middleware never learns it can issue a challenge and throws 500 on +// every unpaid request. Warn loudly rather than fail silently at request time. +if (!SYNC && !(CDP_ID && CDP_SECRET)) { + console.warn( + "X402_SYNC_FACILITATOR=false with no CDP credentials: the facilitator's supported kinds are never fetched, so unpaid requests will 500 instead of returning a 402. Leave sync on (the default) or provide CDP_API_KEY_ID/SECRET.", + ); +} + const gate = (amount: string, description: string, extensions: Record) => ({ accepts: { scheme: "exact" as const, From 6e686fa8f8e3fa804aac640215701c34185d6e82 Mon Sep 17 00:00:00 2001 From: mizuki0x <197570892+mizuki0x@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:37:05 +0200 Subject: [PATCH 21/22] blockrun: tighten comments, drop section divider and marketing adjective --- .../covenant-blockrun/examples/live_verified_call.rs | 2 +- agent-os/crates/covenant-blockrun/src/client.rs | 7 ++----- agent-os/crates/covenant-blockrun/src/receipt.rs | 3 +-- packages/blockrun-py/src/covenant_blockrun/decorator.py | 2 +- 4 files changed, 5 insertions(+), 9 deletions(-) diff --git a/agent-os/crates/covenant-blockrun/examples/live_verified_call.rs b/agent-os/crates/covenant-blockrun/examples/live_verified_call.rs index f8040228c..bf03afdcc 100644 --- a/agent-os/crates/covenant-blockrun/examples/live_verified_call.rs +++ b/agent-os/crates/covenant-blockrun/examples/live_verified_call.rs @@ -1,4 +1,4 @@ -//! Flagship demo: a verifiable BlockRun call on Base mainnet. +//! A verifiable BlockRun call on Base mainnet, end to end. //! //! Makes one real paid call to BlockRun over x402 (USDC on Base, EIP-3009 //! gasless), captures the Covenant receipt, verifies it locally, and prints the diff --git a/agent-os/crates/covenant-blockrun/src/client.rs b/agent-os/crates/covenant-blockrun/src/client.rs index 81fce3bbb..f37f430fd 100644 --- a/agent-os/crates/covenant-blockrun/src/client.rs +++ b/agent-os/crates/covenant-blockrun/src/client.rs @@ -125,11 +125,8 @@ impl BlockRunClient { .build_payment(&accept.to_requirements()) .await .map_err(|e| BlockRunError::Signer(e.to_string()))?; - // The signer emits a bare v2 envelope ({x402Version, scheme, network, - // payload}); the CDP facilitator's x402V2PaymentPayload instead wants the - // full `accepted` requirement echoed back and no top-level scheme/network. - // Rewrap before sending; a header that is not a base64 JSON envelope - // (e.g. a mock) passes through unchanged. + // Rewrap the signer's bare v2 envelope into the CDP facilitator's shape + // (see `to_cdp_v2_header`); a non-envelope header passes through unchanged. let header = to_cdp_v2_header(&signed, &accept); let paid = self diff --git a/agent-os/crates/covenant-blockrun/src/receipt.rs b/agent-os/crates/covenant-blockrun/src/receipt.rs index 3a397d5de..e707efc71 100644 --- a/agent-os/crates/covenant-blockrun/src/receipt.rs +++ b/agent-os/crates/covenant-blockrun/src/receipt.rs @@ -109,8 +109,7 @@ impl CallReceipt { .and_then(Value::as_str) .unwrap_or_default() .to_string(); - // The router reports the served model in the response body or, failing - // that, in the routing headers. + // served model: response body, else the routing header. let model_served = response .get("model") .and_then(Value::as_str) diff --git a/packages/blockrun-py/src/covenant_blockrun/decorator.py b/packages/blockrun-py/src/covenant_blockrun/decorator.py index a5471ce03..7b6b18106 100644 --- a/packages/blockrun-py/src/covenant_blockrun/decorator.py +++ b/packages/blockrun-py/src/covenant_blockrun/decorator.py @@ -220,7 +220,7 @@ def first(*names: str) -> Optional[str]: return routing -# --- duck-typed httpx accessors (no httpx import required) --- +# duck-typed httpx accessors, so httpx need not be imported def _status(response: Any) -> int: From 9838c4db0e05cacef01203984ca5b5e8f3adf1ed Mon Sep 17 00:00:00 2001 From: Iko Rane Date: Mon, 20 Jul 2026 20:37:05 +0200 Subject: [PATCH 22/22] blockrun-verify: trim self-referential comment --- services/blockrun-verify/src/receipt.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/services/blockrun-verify/src/receipt.ts b/services/blockrun-verify/src/receipt.ts index e0db6703c..1e24fdb90 100644 --- a/services/blockrun-verify/src/receipt.ts +++ b/services/blockrun-verify/src/receipt.ts @@ -37,9 +37,8 @@ export interface VerifyResult { verdictConsistent: boolean; statedVerdict: string; expectedVerdict: string; - // Whether the receipt carries a settlement tx string. This is not an on-chain - // check: the service never touches the chain, it only confirms the field is - // present. Named to say exactly that. + // Whether the receipt carries a settlement tx string. Not an on-chain check: + // the service never touches the chain, only confirms the field is present. hasSettlementTx: boolean; }