feat(13): live Routstr Nostr probe — no shipped code path

examples/routstr_probe.rs subscribes to the docs-cited default relays
(damus.io, nostr.band, nos.lol) for kind-38421 provider announcements
plus a #d=routstr-provider fallback filter in case the kind number
drifted, then issues at most two unauthenticated GETs against any
discovered endpoint. Spends nothing: no Cashu token is ever built or
sent, no Authorization header, no Nostr event published, ephemeral
subscription key.

Reproduces (does not import) nostr_discovery.rs::build_nostr_client's
Tor-proxy-aware client shape, since this package ships no [lib]
target and an examples/ binary cannot reach binary-crate internals.

Live run against the three default relays (60s total wait budget)
found zero matching events under either filter — recorded honestly
as NO LIVE PROVIDER OBSERVED, exit 0, per the plan's "no provider
found is a first-class outcome" requirement. Full output feeds
13-ROUTSTR-FINDINGS.md in the next commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-03 12:09:38 -04:00
co-authored by Claude Opus 5
parent 15774d266f
commit ea90ef05a5
+364
View File
@@ -0,0 +1,364 @@
//! Throwaway, hand-run probe for Routstr's live wire contract (RESEARCH Open Question 3 /
//! COVERAGE.md's three `INTEGRATE — UNCONFIRMED` rows).
//!
//! Run by hand: `cd core && cargo run --example routstr_probe [-- --endpoint <url>]`
//!
//! This is an `examples/` target: it links no daemon code path, is not built or run by
//! anything else in this repo, and is deletable at any time.
//!
//! READ-ONLY OBSERVATION ONLY:
//! - subscribes to public Nostr relays and prints kind-38421 provider-announcement events
//! verbatim (no parsing into a typed struct — the point is to see what is actually
//! published, not what a struct expects)
//! - issues at most three unauthenticated `GET`s against a discovered (or `--endpoint`
//! overridden) provider
//! - NEVER builds or sends a Cashu token, NEVER sends an `Authorization`/payment header,
//! NEVER publishes a Nostr event, NEVER sends a node-identifying header (T-13-16/17/18)
//!
//! Tor-proxy-aware client construction reproduces the SHAPE of
//! `crate::nostr_discovery::build_nostr_client` (see that file). It is not imported: this
//! package has no `[lib]` target (only `[[bin]] archipelago`), so an `examples/` binary
//! cannot reach the daemon's internal modules regardless of their visibility — reproducing the
//! pattern here is the correct way to avoid a second, divergent, un-Tor-aware client, per the
//! plan's read_first note.
use anyhow::Result;
use nostr_sdk::prelude::*;
use serde_json::Value;
use std::time::Duration;
/// Routstr provider-announcement event kind, per docs.routstr.com (CITED, MEDIUM confidence —
/// this probe exists to confirm or correct it against a live relay).
const ROUTSTR_KIND: u16 = 38421;
/// The `d`-tag value docs.routstr.com cites for a provider-announcement event. Used as a
/// second, kind-unrestricted discovery filter in case the kind number in the docs has drifted.
const ROUTSTR_D_TAG: &str = "routstr-provider";
/// Default relays cited in docs.routstr.com.
const DEFAULT_RELAYS: &[&str] = &[
"wss://relay.damus.io",
"wss://relay.nostr.band",
"wss://nos.lol",
];
/// Same SOCKS proxy address as `crate::constants::TOR_SOCKS_PROXY` — duplicated as a literal
/// because this example has no access to the daemon's private crate internals (see module doc
/// comment). Only used for `.onion` endpoints or when `ARCHIPELAGO_NOSTR_TOR_PROXY` is set.
const TOR_SOCKS_PROXY: &str = "socks5h://127.0.0.1:9050";
const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(30);
const HTTP_TIMEOUT: Duration = Duration::from_secs(15);
/// Everything this probe observed, accumulated across discovery + capability checks and
/// printed as the final five-question summary block.
#[derive(Debug, Default)]
struct Findings {
kind_38421_observed: bool,
d_tag_fallback_observed: bool,
tag_names: Vec<String>,
content_keys: Vec<String>,
model_field: Option<String>,
price_field: Option<String>,
payment_header_body: Option<String>,
models_endpoint_ok: Option<bool>,
models_endpoint_openai_shape: Option<bool>,
}
#[tokio::main]
async fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
let endpoint_override = args
.iter()
.position(|a| a == "--endpoint")
.and_then(|i| args.get(i + 1))
.cloned();
println!("=== routstr_probe: live observation, read-only, spends nothing ===");
println!("relays: {DEFAULT_RELAYS:?}");
if let Some(e) = &endpoint_override {
println!("--endpoint override given: {e}");
}
let mut findings = Findings::default();
// Ephemeral key for this subscription only — sends no node-identifying header (T-13-18).
let keys = Keys::generate();
let tor_proxy = std::env::var("ARCHIPELAGO_NOSTR_TOR_PROXY").ok();
let client = build_probe_nostr_client(keys, tor_proxy.as_deref())?;
for relay in DEFAULT_RELAYS {
let _ = client.add_relay(*relay).await;
}
if tokio::time::timeout(Duration::from_secs(10), client.connect())
.await
.is_err()
{
println!("WARNING: relay connect timed out after 10s, continuing anyway");
}
let discovered_endpoint = discover_providers(&client, &mut findings).await;
client.disconnect().await;
let endpoint = endpoint_override.or(discovered_endpoint);
match &endpoint {
Some(e) => {
println!("\n--- probing capabilities at {e} ---");
probe_capabilities(e, &mut findings).await;
}
None => {
println!("\n(no endpoint discovered and no --endpoint override given — skipping capability probe)");
}
}
let had_any_signal =
findings.kind_38421_observed || findings.d_tag_fallback_observed || endpoint.is_some();
print_summary(&findings, had_any_signal);
Ok(())
}
/// Reproduces `nostr_discovery.rs::build_nostr_client`'s Tor-proxy-aware client-construction
/// shape (see module doc comment for why this is duplicated rather than imported).
fn build_probe_nostr_client(keys: Keys, tor_proxy: Option<&str>) -> Result<Client> {
let client = if let Some(proxy_str) = tor_proxy {
let addr: std::net::SocketAddr = proxy_str
.trim()
.parse()
.map_err(|_| anyhow::anyhow!("Invalid Nostr Tor proxy: {proxy_str}"))?;
let connection = Connection::new().proxy(addr).target(ConnectionTarget::All);
let opts = ClientOptions::new().connection(connection);
Client::builder().signer(keys).opts(opts).build()
} else {
Client::new(keys)
};
Ok(client)
}
/// Subscribes to two filters over the configured relays: (1) the documented kind 38421, and
/// (2) no kind restriction but a `#d` tag of "routstr-provider", in case the kind number in
/// the docs has drifted. Prints every matching event verbatim: full tag list, full content,
/// pubkey, created_at. Does NOT parse into a typed struct — the whole point is to see what is
/// actually published rather than what a struct expects. Returns the first HTTP(S) endpoint
/// URL found in any matched event's content, if any.
async fn discover_providers(client: &Client, findings: &mut Findings) -> Option<String> {
let mut first_endpoint: Option<String> = None;
println!("\n--- subscription 1: kind {ROUTSTR_KIND} ---");
let kind_filter = Filter::new().kind(Kind::Custom(ROUTSTR_KIND)).limit(50);
let kind_events = client
.fetch_events(kind_filter, DISCOVERY_TIMEOUT)
.await
.map(|e| e.to_vec())
.unwrap_or_default();
if kind_events.is_empty() {
println!("(no events)");
}
for ev in &kind_events {
findings.kind_38421_observed = true;
print_event(ev);
record_event_shape(ev, findings);
if first_endpoint.is_none() {
first_endpoint = extract_endpoint(&ev.content);
}
}
println!(
"\n--- subscription 2: no kind filter, #d = \"{ROUTSTR_D_TAG}\" (fallback, in case the kind number drifted) ---"
);
let d_filter = Filter::new().identifier(ROUTSTR_D_TAG).limit(50);
let d_events = client
.fetch_events(d_filter, DISCOVERY_TIMEOUT)
.await
.map(|e| e.to_vec())
.unwrap_or_default();
if d_events.is_empty() {
println!("(no events)");
}
for ev in &d_events {
// Skip re-printing an event already shown by subscription 1's kind filter.
if kind_events.iter().any(|k| k.id == ev.id) {
continue;
}
findings.d_tag_fallback_observed = true;
print_event(ev);
record_event_shape(ev, findings);
if first_endpoint.is_none() {
first_endpoint = extract_endpoint(&ev.content);
}
}
first_endpoint
}
fn print_event(ev: &Event) {
println!("event id: {}", ev.id);
println!("pubkey: {}", ev.pubkey);
println!("kind: {}", ev.kind.as_u16());
println!("created_at: {}", ev.created_at.as_secs());
println!("tags:");
for tag in ev.tags.iter() {
println!(" {:?}", tag.as_slice());
}
println!("content: {}", ev.content);
println!();
}
/// Records tag names and top-level content JSON keys seen across every matched event, plus a
/// best-effort guess at which content key carries the model list vs. the price — used for the
/// summary block's questions 2/3. Never overwrites a field already found.
fn record_event_shape(ev: &Event, findings: &mut Findings) {
for tag in ev.tags.iter() {
if let Some(name) = tag.as_slice().first() {
if !findings.tag_names.contains(name) {
findings.tag_names.push(name.clone());
}
}
}
if let Ok(Value::Object(map)) = serde_json::from_str::<Value>(&ev.content) {
for key in map.keys() {
if !findings.content_keys.contains(key) {
findings.content_keys.push(key.clone());
}
}
for candidate in ["models", "model", "model_list"] {
if map.contains_key(candidate) && findings.model_field.is_none() {
findings.model_field = Some(candidate.to_string());
}
}
for candidate in ["pricing", "price", "prices", "sats_per_request", "cost"] {
if map.contains_key(candidate) && findings.price_field.is_none() {
findings.price_field = Some(candidate.to_string());
}
}
}
}
/// Best-effort extraction of an http(s) endpoint URL from a provider-announcement event's
/// content: prefers the docs-cited `endpoints` field, falls back to a generic scan of every
/// string value in the object.
fn extract_endpoint(content: &str) -> Option<String> {
let value: Value = serde_json::from_str(content).ok()?;
if let Some(endpoints) = value.get("endpoints") {
if let Some(s) = first_http_string(endpoints) {
return Some(s);
}
}
first_http_string(&value)
}
fn first_http_string(value: &Value) -> Option<String> {
match value {
Value::String(s) if s.starts_with("http://") || s.starts_with("https://") => {
Some(s.trim_end_matches('/').to_string())
}
Value::Array(arr) => arr.iter().find_map(first_http_string),
Value::Object(map) => map.values().find_map(first_http_string),
_ => None,
}
}
/// Issues unauthenticated `GET`s against `endpoint`: `/v1/models` and `/`. Sends NO Cashu
/// token, NO Authorization header, NO node-identifying header — this probe spends no money and
/// authenticates as nothing. A 401/402 body is printed verbatim and recorded: per the plan, a
/// provider naming its own expected payment header in that body is the single most valuable
/// artifact this probe can capture.
async fn probe_capabilities(endpoint: &str, findings: &mut Findings) {
let client = match build_probe_http_client(endpoint) {
Ok(c) => c,
Err(e) => {
println!("ERROR building HTTP client for {endpoint}: {e}");
return;
}
};
for path in ["/v1/models", "/"] {
let url = format!("{}{}", endpoint.trim_end_matches('/'), path);
probe_one(&client, &url, path, findings).await;
}
}
async fn probe_one(client: &reqwest::Client, url: &str, label: &str, findings: &mut Findings) {
println!("\nGET {url}");
match client.get(url).send().await {
Ok(resp) => {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
let trimmed: String = body.chars().take(2000).collect();
println!("status: {status}");
println!("body: {trimmed}");
if label == "/v1/models" {
findings.models_endpoint_ok = Some(status.is_success());
findings.models_endpoint_openai_shape = Some(
status.is_success()
&& body.contains("\"data\"")
&& body.contains("\"object\""),
);
}
if (status.as_u16() == 401 || status.as_u16() == 402)
&& findings.payment_header_body.is_none()
{
findings.payment_header_body = Some(trimmed);
}
}
Err(e) => {
println!("request failed: {e}");
}
}
}
/// Builds a plain `reqwest::Client`, routed through the Tor SOCKS proxy only when the target
/// is a `.onion` address (mandatory — plain HTTP cannot reach one) or when
/// `ARCHIPELAGO_NOSTR_TOR_PROXY` is set (opt-in, mirrors the Nostr client's own Tor gate).
fn build_probe_http_client(endpoint: &str) -> Result<reqwest::Client> {
let mut builder = reqwest::Client::builder().timeout(HTTP_TIMEOUT);
let needs_tor =
endpoint.contains(".onion") || std::env::var("ARCHIPELAGO_NOSTR_TOR_PROXY").is_ok();
if needs_tor {
let proxy = reqwest::Proxy::all(TOR_SOCKS_PROXY)?;
builder = builder.proxy(proxy);
}
Ok(builder.build()?)
}
/// Prints the final summary block. If nothing was observed at all (no kind-38421 event, no
/// #d=routstr-provider fallback event, no discovered/overridden endpoint), prints exactly
/// `NO LIVE PROVIDER OBSERVED` as a first-class, non-error outcome instead of the five-question
/// breakdown — an ecosystem being quiet is a valid result, not something this probe should
/// panic or error over.
fn print_summary(findings: &Findings, had_any_signal: bool) {
if !had_any_signal {
println!("\nNO LIVE PROVIDER OBSERVED");
return;
}
println!("\n=== SUMMARY ===");
println!(
"1. Was a live kind-{ROUTSTR_KIND} event observed? {}",
if findings.kind_38421_observed {
"YES".to_string()
} else if findings.d_tag_fallback_observed {
format!(
"NO (kind {ROUTSTR_KIND} was empty, but a #d=\"{ROUTSTR_D_TAG}\" event WAS found under a different kind — see tags/content above)"
)
} else {
"NO".to_string()
}
);
println!(
"2. Tag names observed: {:?} Content JSON keys observed: {:?}",
findings.tag_names, findings.content_keys
);
println!(
"3. Model-list field: {:?} Price field: {:?}",
findings.model_field, findings.price_field
);
println!(
"4. Payment header named in a 401/402 body: {:?}",
findings.payment_header_body
);
println!(
"5. Does /v1/models respond, and does its shape match OpenAI's (has \"data\"/\"object\")? responds={:?} openai_shaped={:?}",
findings.models_endpoint_ok, findings.models_endpoint_openai_shape
);
}