rhi

Coordinated trade for connected markets
git clone https://radroots.dev/git/rhi.git
Log | Files | Refs | README | LICENSE

commit 9e025eabfce1c8547ed43943601406dc87381347
parent b4e083f3beb8dd59f6506a129980133cc3a12233
Author: triesap <tyson@radroots.org>
Date:   Sat, 18 Jul 2026 11:14:43 +0000

nostr: migrate RHI service presence

- publish strict kind-0 metadata through the authored Profile wire contract before NIP-89 discovery
- reject unverified media, unsupported fields, invalid NIP-05 values, and legacy service-role tags
- exact-pin Radroots 1.0.0-alpha.1 and migrate explicit SDK interoperability and logging defaults
- cover deterministic wire output, fail-closed metadata, dependency pins, and removed compatibility paths

Diffstat:
MCargo.lock | 51++++++++++++++++++++++++++++++++++++++-------------
MCargo.toml | 20++++++++++----------
Msrc/features/trade_agreement_attestation.rs | 3++-
Msrc/lib.rs | 185+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Msrc/main.rs | 1+
Mtests/source_guards.rs | 27++++++++++++++++++++++++++-
6 files changed, 235 insertions(+), 52 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1087,6 +1087,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] name = "js-sys" version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1174,6 +1180,12 @@ dependencies = [ ] [[package]] +name = "mediatype" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120fa187be19d9962f0926633453784691731018a2bf936ddb4e29101b79c4a7" + +[[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1585,14 +1597,24 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "radroots_authority" -version = "0.1.0-alpha.2" +version = "1.0.0-alpha.1" dependencies = [ "radroots_event", ] [[package]] +name = "radroots_blossom" +version = "1.0.0-alpha.1" +dependencies = [ + "mediatype", + "serde", + "sha2", + "url", +] + +[[package]] name = "radroots_core" -version = "0.1.0-alpha.2" +version = "1.0.0-alpha.1" dependencies = [ "rust_decimal", "rust_decimal_macros", @@ -1601,18 +1623,21 @@ dependencies = [ [[package]] name = "radroots_event" -version = "0.1.0-alpha.2" +version = "1.0.0-alpha.1" dependencies = [ "hex", + "jiff-tzdb", + "radroots_blossom", "radroots_core", "serde", "serde_json", "sha2", + "url", ] [[package]] name = "radroots_event_codec" -version = "0.1.0-alpha.2" +version = "1.0.0-alpha.1" dependencies = [ "nostr", "radroots_core", @@ -1623,10 +1648,9 @@ dependencies = [ [[package]] name = "radroots_identity" -version = "0.1.0-alpha.2" +version = "1.0.0-alpha.1" dependencies = [ "nostr", - "radroots_event", "radroots_protected_store", "radroots_runtime", "radroots_runtime_paths", @@ -1639,9 +1663,10 @@ dependencies = [ [[package]] name = "radroots_log" -version = "0.1.0-alpha.2" +version = "1.0.0-alpha.1" dependencies = [ "chrono", + "serde_json", "thiserror 1.0.69", "tracing", "tracing-appender", @@ -1650,7 +1675,7 @@ dependencies = [ [[package]] name = "radroots_nostr" -version = "0.1.0-alpha.2" +version = "1.0.0-alpha.1" dependencies = [ "nostr", "nostr-sdk", @@ -1665,7 +1690,7 @@ dependencies = [ [[package]] name = "radroots_protected_store" -version = "0.1.0-alpha.2" +version = "1.0.0-alpha.1" dependencies = [ "chacha20poly1305", "getrandom 0.2.17", @@ -1677,7 +1702,7 @@ dependencies = [ [[package]] name = "radroots_runtime" -version = "0.1.0-alpha.2" +version = "1.0.0-alpha.1" dependencies = [ "anyhow", "chacha20poly1305", @@ -1700,7 +1725,7 @@ dependencies = [ [[package]] name = "radroots_runtime_paths" -version = "0.1.0-alpha.2" +version = "1.0.0-alpha.1" dependencies = [ "serde", "thiserror 1.0.69", @@ -1708,11 +1733,11 @@ dependencies = [ [[package]] name = "radroots_secret_vault" -version = "0.1.0-alpha.2" +version = "1.0.0-alpha.1" [[package]] name = "radroots_trade" -version = "0.1.0-alpha.2" +version = "1.0.0-alpha.1" dependencies = [ "base64 0.22.1", "hex", diff --git a/Cargo.toml b/Cargo.toml @@ -11,15 +11,15 @@ description = "Radroots trade agreement attestation worker" resolver = "2" [workspace.dependencies] -radroots_core = { path = "../lib/crates/core" } -radroots_event = { path = "../lib/crates/event" } -radroots_event_codec = { path = "../lib/crates/event_codec" } -radroots_identity = { path = "../lib/crates/identity" } -radroots_log = { path = "../lib/crates/log" } -radroots_nostr = { path = "../lib/crates/nostr" } -radroots_runtime = { path = "../lib/crates/runtime" } -radroots_runtime_paths = { path = "../lib/crates/runtime_paths" } -radroots_trade = { path = "../lib/crates/trade" } +radroots_core = { version = "=1.0.0-alpha.1", path = "../lib/crates/core" } +radroots_event = { version = "=1.0.0-alpha.1", path = "../lib/crates/event" } +radroots_event_codec = { version = "=1.0.0-alpha.1", path = "../lib/crates/event_codec" } +radroots_identity = { version = "=1.0.0-alpha.1", path = "../lib/crates/identity" } +radroots_log = { version = "=1.0.0-alpha.1", path = "../lib/crates/log" } +radroots_nostr = { version = "=1.0.0-alpha.1", path = "../lib/crates/nostr" } +radroots_runtime = { version = "=1.0.0-alpha.1", path = "../lib/crates/runtime" } +radroots_runtime_paths = { version = "=1.0.0-alpha.1", path = "../lib/crates/runtime_paths" } +radroots_trade = { version = "=1.0.0-alpha.1", path = "../lib/crates/trade" } [features] default = [] @@ -30,7 +30,7 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } [dependencies] radroots_core = { workspace = true, features = ["std", "serde"] } radroots_event = { workspace = true, features = ["serde"] } -radroots_event_codec = { workspace = true, features = ["nostr"] } +radroots_event_codec = { workspace = true, features = ["nostr", "serde_json"] } radroots_identity = { workspace = true } radroots_log = { workspace = true } radroots_nostr = { workspace = true, features = ["client", "codec", "events", "http"] } diff --git a/src/features/trade_agreement_attestation.rs b/src/features/trade_agreement_attestation.rs @@ -722,7 +722,8 @@ pub async fn subscriber( } let subscription_id = subscribe_io(&client, filter).await?; - let mut notifications = client.notifications(); + let sdk_client = client.clone().into_inner(); + let mut notifications = sdk_client.notifications(); let mut notifications_closed = false; loop { diff --git a/src/lib.rs b/src/lib.rs @@ -10,8 +10,12 @@ pub mod rhi; pub use cli::Args as cli_args; -use anyhow::Result; -use radroots_event::kinds::TRADE_MUTATION_EVENT_KINDS; +use anyhow::{Context, Result, anyhow, bail}; +use radroots_event::{ + kinds::TRADE_MUTATION_EVENT_KINDS, + profile::{RadrootsAuthoredProfile, RadrootsNip05Identifier}, +}; +use radroots_event_codec::profile::authored::authored_profile_to_wire_parts; use std::time::Duration; use crate::features::trade_agreement_attestation::{ @@ -20,9 +24,9 @@ use crate::features::trade_agreement_attestation::{ }; use crate::identity_storage::load_service_identity; use crate::rhi::{Rhi, start_subscriber_with_policy}; -use radroots_identity::RadrootsIdentity; use radroots_nostr::prelude::{ - RadrootsNostrApplicationHandlerSpec, radroots_nostr_bootstrap_service_presence, + RadrootsNostrApplicationHandlerSpec, RadrootsNostrEventBuilder, RadrootsNostrMetadata, + radroots_nostr_build_event, radroots_nostr_publish_application_handler, }; use tracing::{info, warn}; @@ -72,28 +76,82 @@ fn take_bootstrap_hook_result() -> Option<Result<(), String>> { async fn bootstrap_presence( client: &radroots_nostr::prelude::RadrootsNostrClient, - identity: &RadrootsIdentity, - metadata: &radroots_nostr::prelude::RadrootsNostrMetadata, + metadata: &RadrootsNostrMetadata, handler_spec: &RadrootsNostrApplicationHandlerSpec, ) -> Result<()> { - let bootstrap_result: Result<()> = match take_bootstrap_hook_result() { - Some(result) => result.map_err(anyhow::Error::msg), - None => radroots_nostr_bootstrap_service_presence( - client, - identity, - None, - metadata, - handler_spec, - Duration::from_secs(5), - ) + if let Some(result) = take_bootstrap_hook_result() { + return result.map_err(anyhow::Error::msg); + } + + client.connect().await; + client.wait_for_connection(Duration::from_secs(5)).await; + + let builder = build_authored_service_profile_event(metadata)?; + client + .send_event_builder(builder) .await - .map(|_| ()) - .map_err(anyhow::Error::from), - }; - bootstrap_result?; + .context("publish strict RHI service Profile")?; + + radroots_nostr_publish_application_handler(client, handler_spec) + .await + .context("publish RHI application-handler event")?; Ok(()) } +fn build_authored_service_profile_event( + metadata: &RadrootsNostrMetadata, +) -> Result<RadrootsNostrEventBuilder> { + let profile = authored_service_profile(metadata)?; + let wire = + authored_profile_to_wire_parts(&profile).context("encode strict RHI service Profile")?; + radroots_nostr_build_event(wire.kind, wire.content, wire.tags) + .context("build strict RHI service Profile event") +} + +fn authored_service_profile(metadata: &RadrootsNostrMetadata) -> Result<RadrootsAuthoredProfile> { + if metadata.picture.is_some() || metadata.banner.is_some() { + bail!( + "RHI service Profile media requires byte-verified Blossom descriptors and proven BUD-02 upload completion" + ); + } + if metadata.website.is_some() || metadata.lud06.is_some() || metadata.lud16.is_some() { + bail!("RHI service Profile contains fields outside the strict authored contract"); + } + + let name = metadata + .name + .clone() + .ok_or_else(|| anyhow!("RHI service Profile requires metadata.name"))?; + let mut profile = + RadrootsAuthoredProfile::new(name).context("validate strict RHI service Profile name")?; + if let Some(display_name) = metadata.display_name.as_ref() { + profile = profile.with_display_name(display_name.clone()); + } + if let Some(about) = metadata.about.as_ref() { + profile = profile.with_about(about.clone()); + } + if let Some(nip05) = metadata.nip05.as_deref() { + profile = profile.with_nip05( + RadrootsNip05Identifier::parse(nip05) + .context("validate strict RHI service Profile NIP-05 identifier")?, + ); + } + + for key in metadata.custom.keys() { + if key != "bot" { + bail!("RHI service Profile contains unsupported metadata field `{key}`"); + } + } + if let Some(bot) = metadata.custom.get("bot") { + profile = profile.with_bot( + bot.as_bool() + .ok_or_else(|| anyhow!("RHI service Profile `bot` field must be a Boolean"))?, + ); + } + + Ok(profile) +} + #[cfg_attr(coverage_nightly, coverage(off))] async fn wait_for_shutdown_or_stopped(handle: crate::rhi::RhiHandle) -> RunRhiWaitOutcome { #[cfg(test)] @@ -150,7 +208,7 @@ pub async fn run_rhi(settings: &config::Settings, args: &cli_args) -> Result<()> relays: relays.clone(), nostrconnect_url: None, }; - if let Err(e) = bootstrap_presence(&client, &identity, &md, &handler_spec).await { + if let Err(e) = bootstrap_presence(&client, &md, &handler_spec).await { warn!("Failed to publish service presence on startup: {e}"); } else { info!("Published service presence on startup"); @@ -186,8 +244,9 @@ pub async fn run_rhi(settings: &config::Settings, args: &cli_args) -> Result<()> RunRhiWaitOutcome::Stopped => {} } - client.unsubscribe_all().await; - client.disconnect().await; + let sdk_client = client.into_inner(); + sdk_client.unsubscribe_all().await; + sdk_client.disconnect().await; Ok(()) } @@ -200,8 +259,9 @@ pub fn release_product_handler_kinds() -> &'static [u32] { #[cfg_attr(coverage_nightly, coverage(off))] mod tests { use super::{ - RUN_RHI_AUTO_STOP, RUN_RHI_SKIP_SUBSCRIBER, RunRhiWaitOutcome, bootstrap_presence, - release_product_handler_kinds, run_rhi, run_rhi_bootstrap_hook, run_rhi_wait_hook, + RUN_RHI_AUTO_STOP, RUN_RHI_SKIP_SUBSCRIBER, RunRhiWaitOutcome, authored_service_profile, + bootstrap_presence, build_authored_service_profile_event, release_product_handler_kinds, + run_rhi, run_rhi_bootstrap_hook, run_rhi_wait_hook, }; use crate::{cli_args, config}; use radroots_event::kinds::TRADE_MUTATION_EVENT_KINDS; @@ -336,7 +396,6 @@ mod tests { Some(Err("forced bootstrap failure".to_string())); let keys = radroots_nostr::prelude::RadrootsNostrKeys::generate(); let client = radroots_nostr::prelude::RadrootsNostrClient::new(keys.clone()); - let identity = radroots_identity::RadrootsIdentity::new(keys); let metadata: radroots_nostr::prelude::RadrootsNostrMetadata = serde_json::from_str(r#"{"name":"rhi-test"}"#).expect("metadata"); let spec = radroots_nostr::prelude::RadrootsNostrApplicationHandlerSpec { @@ -347,9 +406,81 @@ mod tests { relays: Vec::new(), nostrconnect_url: None, }; - let err = bootstrap_presence(&client, &identity, &metadata, &spec) + let err = bootstrap_presence(&client, &metadata, &spec) .await .expect_err("forced error"); assert!(format!("{err:#}").contains("forced bootstrap failure")); } + + #[test] + fn service_profile_uses_only_strict_authored_fields() { + let metadata: radroots_nostr::prelude::RadrootsNostrMetadata = serde_json::from_str( + r#"{ + "name":"rhi", + "display_name":"Radroots agreement attestation", + "about":"Attests trade agreement projections", + "nip05":"rhi@EXAMPLE.COM", + "bot":true + }"#, + ) + .expect("metadata"); + + let profile = authored_service_profile(&metadata).expect("strict profile"); + + assert_eq!(profile.name(), "rhi"); + assert_eq!( + profile.display_name(), + Some("Radroots agreement attestation") + ); + assert_eq!(profile.about(), Some("Attests trade agreement projections")); + assert_eq!( + profile.nip05().map(|identifier| identifier.as_str()), + Some("rhi@example.com") + ); + assert_eq!(profile.bot(), Some(true)); + assert!(profile.picture().is_none()); + assert!(profile.banner().is_none()); + + let keys = radroots_nostr::prelude::RadrootsNostrKeys::generate(); + let event = build_authored_service_profile_event(&metadata) + .expect("strict Profile event") + .build(keys.public_key()); + assert_eq!(event.kind.as_u16(), 0); + assert!(event.tags.is_empty()); + assert_eq!( + event.content, + r#"{"name":"rhi","display_name":"Radroots agreement attestation","about":"Attests trade agreement projections","nip05":"rhi@example.com","bot":true}"# + ); + } + + #[test] + fn service_profile_rejects_unverified_media_and_unsupported_fields() { + for (metadata, expected) in [ + ( + r#"{"name":"rhi","picture":"https://cdn.example/rhi.png"}"#, + "byte-verified Blossom descriptors", + ), + ( + r#"{"name":"rhi","website":"https://radroots.org"}"#, + "outside the strict authored contract", + ), + ( + r#"{"name":"rhi","bot":"yes"}"#, + "`bot` field must be a Boolean", + ), + ( + r#"{"name":"rhi","legacy_role":"worker"}"#, + "unsupported metadata field `legacy_role`", + ), + (r#"{}"#, "requires metadata.name"), + ( + r#"{"name":"rhi","nip05":"RHI@example.com"}"#, + "validate strict RHI service Profile NIP-05 identifier", + ), + ] { + let metadata = serde_json::from_str(metadata).expect("metadata"); + let error = authored_service_profile(&metadata).expect_err("must fail closed"); + assert!(format!("{error:#}").contains(expected), "{error:#}"); + } + } } diff --git a/src/main.rs b/src/main.rs @@ -105,6 +105,7 @@ fn init_rhi_logging(settings: &config::Settings) -> Result<()> { stdout: settings.config.logging.stdout, default_level: Some(settings.config.logging.filter.clone()), file_layout: LogFileLayout::PrefixedDate, + ..LoggingOptions::default() }) .context("initialize logging") } diff --git a/tests/source_guards.rs b/tests/source_guards.rs @@ -24,6 +24,25 @@ fn rhi_manifest_has_no_sdk_or_legacy_proof_dependency() { } #[test] +fn rhi_manifest_exact_pins_radroots_contract() { + let manifest: toml::Value = toml::from_str(&read_repo_file("Cargo.toml")).expect("manifest"); + let dependencies = manifest["workspace"]["dependencies"] + .as_table() + .expect("workspace dependencies"); + + for (name, dependency) in dependencies { + if !name.starts_with("radroots_") { + continue; + } + assert_eq!( + dependency["version"].as_str(), + Some("=1.0.0-alpha.1"), + "RHI must exact-pin {name} to the governed event contract release" + ); + } +} + +#[test] fn rhi_release_product_surface_has_no_order_or_receipt_modules() { for forbidden_path in [ "src/features/trade_listing/mod.rs", @@ -99,7 +118,13 @@ fn rhi_agreement_attestation_is_release_product_optional_infrastructure() { assert!( lib.contains("trade_mutation_subscription_kinds()") - && lib.contains("&TRADE_MUTATION_EVENT_KINDS"), + && lib.contains("&TRADE_MUTATION_EVENT_KINDS") + && lib.contains("RadrootsAuthoredProfile") + && lib.contains("authored_profile_to_wire_parts") + && lib.contains("radroots_nostr_publish_application_handler") + && !lib.contains("radroots_nostr_bootstrap_service_presence") + && !lib.contains("RadrootsProfileType") + && lib.contains("client.into_inner()"), "RHI service presence must advertise canonical release-product trade mutation kinds" ); assert!(