lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 8de30640731b40dc3135fdae781efc3a096047b9
parent d4cf0b41c681051489c3fc3c8501ba7ecbacf690
Author: triesap <tyson@radroots.org>
Date:   Fri, 17 Jul 2026 22:00:25 +0000

nostr_connect: add current NIP-46 conformance vectors

- check in deterministic current-session protocol cases
- execute vectors across requests URIs responses and failures
- document portable signer and relay-client lifecycle behavior
- qualify the combined public crate regression surface

Diffstat:
Acontracts/conformance/vectors/nip46/current_session.v1.json | 350+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/nostr/README | 8++++++++
Mcrates/nostr_connect/README | 14++++++++++++++
Mcrates/nostr_connect/tests/protocol.rs | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/nostr_signer/README | 11+++++++++++
5 files changed, 543 insertions(+), 0 deletions(-)

diff --git a/contracts/conformance/vectors/nip46/current_session.v1.json b/contracts/conformance/vectors/nip46/current_session.v1.json @@ -0,0 +1,350 @@ +{ + "suite": "nip46_current_session", + "contract_version": "0.1.0", + "vectors": [ + { + "id": "nip46_connect_legacy_one_parameter_001", + "kind": "nip46.request.valid", + "input": { + "message": { + "id": "connect-legacy-1", + "method": "connect", + "params": [ + "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df" + ] + } + }, + "expected": { + "normalized_message": { + "id": "connect-legacy-1", + "method": "connect", + "params": [ + "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df" + ] + } + } + }, + { + "id": "nip46_connect_legacy_two_parameters_002", + "kind": "nip46.request.valid", + "input": { + "message": { + "id": "connect-legacy-2", + "method": "connect", + "params": [ + "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "fixture-client-secret" + ] + } + }, + "expected": { + "normalized_message": { + "id": "connect-legacy-2", + "method": "connect", + "params": [ + "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "fixture-client-secret" + ] + } + } + }, + { + "id": "nip46_connect_legacy_three_parameters_003", + "kind": "nip46.request.valid", + "input": { + "message": { + "id": "connect-legacy-3", + "method": "connect", + "params": [ + "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "fixture-client-secret", + "nip44_encrypt,sign_event:1" + ] + } + }, + "expected": { + "normalized_message": { + "id": "connect-legacy-3", + "method": "connect", + "params": [ + "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "fixture-client-secret", + "nip44_encrypt,sign_event:1" + ] + } + } + }, + { + "id": "nip46_connect_current_metadata_004", + "kind": "nip46.request.valid", + "input": { + "message": { + "id": "connect-current", + "method": "connect", + "params": [ + "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "fixture-client-secret", + "nip44_encrypt,sign_event:1", + "{\"name\":\" Example Client \",\"url\":\"https://app.example.com\",\"image\":\"https://cdn.example.com/logo.png\"}" + ] + } + }, + "expected": { + "normalized_message": { + "id": "connect-current", + "method": "connect", + "params": [ + "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "fixture-client-secret", + "nip44_encrypt,sign_event:1", + "{\"name\":\"Example Client\",\"url\":\"https://app.example.com/\",\"image\":\"https://cdn.example.com/logo.png\"}" + ] + } + } + }, + { + "id": "nip46_metadata_name_bound_005", + "kind": "nip46.metadata.invalid", + "input": { + "field": "name", + "repeat": "a", + "count": 129 + }, + "expected": { + "error": "invalid_client_metadata" + } + }, + { + "id": "nip46_connect_malformed_metadata_006", + "kind": "nip46.request.invalid", + "input": { + "message": { + "id": "connect-invalid-metadata", + "method": "connect", + "params": [ + "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "", + "", + "{\"name\":\"\"}" + ] + } + }, + "expected": { + "error": "invalid_client_metadata" + } + }, + { + "id": "nip46_nostrconnect_ordered_relays_007", + "kind": "nip46.uri.valid", + "input": { + "uri": "nostrconnect://585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df?relay=wss%3A%2F%2Frelay.example.com&relay=wss%3A%2F%2Frelay-2.example.com&secret=fixture-client-secret&perms=nip44_encrypt%2Csign_event%3A1&name=+Example+Client+&url=https%3A%2F%2Fapp.example.com&image=https%3A%2F%2Fcdn.example.com%2Flogo.png" + }, + "expected": { + "variant": "nostrconnect", + "relays": [ + "wss://relay.example.com", + "wss://relay-2.example.com" + ], + "secret": "fixture-client-secret", + "metadata": { + "name": "Example Client", + "url": "https://app.example.com/", + "image": "https://cdn.example.com/logo.png", + "permissions": "nip44_encrypt,sign_event:1" + } + } + }, + { + "id": "nip46_nostrconnect_secret_required_008", + "kind": "nip46.uri.invalid", + "input": { + "uri": "nostrconnect://585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df?relay=wss%3A%2F%2Frelay.example.com" + }, + "expected": { + "error": "missing_secret" + } + }, + { + "id": "nip46_bunker_secret_optional_009", + "kind": "nip46.uri.valid", + "input": { + "uri": "bunker://585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df?relay=wss%3A%2F%2Frelay.example.com" + }, + "expected": { + "variant": "bunker", + "relays": [ + "wss://relay.example.com" + ], + "secret": null + } + }, + { + "id": "nip46_bunker_secret_present_010", + "kind": "nip46.uri.valid", + "input": { + "uri": "bunker://585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df?relay=wss%3A%2F%2Frelay.example.com&secret=fixture-bunker-secret" + }, + "expected": { + "variant": "bunker", + "relays": [ + "wss://relay.example.com" + ], + "secret": "fixture-bunker-secret" + } + }, + { + "id": "nip46_connect_secret_echo_011", + "kind": "nip46.response.valid", + "input": { + "method": "connect", + "envelope": { + "id": "connect-current", + "result": "fixture-client-secret" + } + }, + "expected": { + "normalized_envelope": { + "id": "connect-current", + "result": "fixture-client-secret" + } + } + }, + { + "id": "nip46_switch_relays_ordered_012", + "kind": "nip46.response.valid", + "input": { + "method": "switch_relays", + "envelope": { + "id": "switch-relays", + "result": [ + "wss://relay-2.example.com", + "wss://relay-3.example.com" + ] + } + }, + "expected": { + "normalized_envelope": { + "id": "switch-relays", + "result": [ + "wss://relay-2.example.com", + "wss://relay-3.example.com" + ] + } + } + }, + { + "id": "nip46_switch_relays_unchanged_013", + "kind": "nip46.response.valid", + "input": { + "method": "switch_relays", + "envelope": { + "id": "switch-relays-unchanged", + "result": null + } + }, + "expected": { + "normalized_envelope": { + "id": "switch-relays-unchanged", + "result": null + } + } + }, + { + "id": "nip46_auth_url_continuation_014", + "kind": "nip46.response.valid", + "input": { + "method": "sign_event", + "envelope": { + "id": "sign-with-auth", + "result": "auth_url", + "error": "https://auth.example.com/challenge" + } + }, + "expected": { + "normalized_envelope": { + "id": "sign-with-auth", + "result": "auth_url", + "error": "https://auth.example.com/challenge" + } + } + }, + { + "id": "nip46_logout_request_015", + "kind": "nip46.request.valid", + "input": { + "message": { + "id": "logout-current", + "method": "logout", + "params": [] + } + }, + "expected": { + "normalized_message": { + "id": "logout-current", + "method": "logout", + "params": [] + } + } + }, + { + "id": "nip46_logout_acknowledgement_016", + "kind": "nip46.response.valid", + "input": { + "method": "logout", + "envelope": { + "id": "logout-current", + "result": "ack" + } + }, + "expected": { + "normalized_envelope": { + "id": "logout-current", + "result": "ack" + } + } + }, + { + "id": "nip46_logout_rejects_parameters_017", + "kind": "nip46.request.invalid", + "input": { + "message": { + "id": "logout-invalid", + "method": "logout", + "params": [ + "unexpected" + ] + } + }, + "expected": { + "error": "invalid_params" + } + }, + { + "id": "nip46_malformed_response_result_018", + "kind": "nip46.response.invalid", + "input": { + "method": "ping", + "envelope": { + "id": "ping-invalid", + "result": 7 + } + }, + "expected": { + "error": "invalid_response_payload" + } + }, + { + "id": "nip46_malformed_response_missing_result_019", + "kind": "nip46.response.invalid", + "input": { + "method": "logout", + "envelope": { + "id": "logout-missing-result" + } + }, + "expected": { + "error": "missing_result" + } + } + ] +} diff --git a/crates/nostr/README b/crates/nostr/README @@ -12,6 +12,14 @@ primitives for the `radroots` core libraries. representations; * optional NIP-11 and NIP-17 support alongside `std` and `no_std` builds. +## Portable relay-client lifecycle + +With the `client` feature, callers can subscribe and publish to selected relay +sets and explicitly unsubscribe through Radroots types. The wrapper does not +create detached listeners or take ownership of subscription lifetime. The +`client,events` feature combination is qualified for native targets and +`wasm32-unknown-unknown`. + ## Copyright Except as otherwise noted, all files in the `radroots_nostr` distribution are diff --git a/crates/nostr_connect/README b/crates/nostr_connect/README @@ -11,6 +11,20 @@ connection and URI models for the `radroots` core libraries. * portable shared models with optional serialization and TypeScript export support. +## Current NIP-46 behavior + +`connect` accepts the current one-to-four-parameter wire shape. The fourth +parameter carries bounded, normalized display metadata (`name`, `url`, and +`image`), while requested permissions remain the third parameter. Legacy +one-, two-, and three-parameter requests continue to decode. `nostrconnect://` +requires an ordered relay set and a non-empty secret; `bunker://` preserves +ordered relays and keeps its secret optional. + +The typed client models support secret-echo connection responses, +`auth_url` continuation, relay switching, and zero-parameter `logout` with an +`ack` response. Deterministic protocol cases live in +`contracts/conformance/vectors/nip46/current_session.v1.json`. + ## Copyright Except as otherwise noted, all files in the `radroots_nostr_connect` diff --git a/crates/nostr_connect/tests/protocol.rs b/crates/nostr_connect/tests/protocol.rs @@ -423,3 +423,163 @@ fn sign_event_response_roundtrips_signed_event_json_string() { assert_eq!(parsed, RadrootsNostrConnectResponse::SignedEvent(event)); } + +#[test] +fn checked_in_current_session_vectors_match_protocol_behavior() { + let vectors = + include_str!("../../../contracts/conformance/vectors/nip46/current_session.v1.json"); + let document: Value = serde_json::from_str(vectors).expect("NIP-46 vector JSON"); + assert_eq!(document["suite"], "nip46_current_session"); + assert_eq!(document["contract_version"], "0.1.0"); + let entries = document["vectors"].as_array().expect("NIP-46 vectors"); + + for entry in entries { + let id = entry["id"].as_str().expect("vector id"); + let kind = entry["kind"].as_str().expect("vector kind"); + let input = &entry["input"]; + let expected = &entry["expected"]; + + match kind { + "nip46.request.valid" => { + let message: RadrootsNostrConnectRequestMessage = + serde_json::from_value(input["message"].clone()) + .unwrap_or_else(|error| panic!("{id}: parse request: {error}")); + let normalized = serde_json::to_value(message) + .unwrap_or_else(|error| panic!("{id}: serialize request: {error}")); + assert_eq!(normalized, expected["normalized_message"], "{id}"); + } + "nip46.request.invalid" => { + let error = serde_json::from_value::<RadrootsNostrConnectRequestMessage>( + input["message"].clone(), + ) + .expect_err("invalid request vector"); + assert_vector_error(id, expected, error); + } + "nip46.metadata.invalid" => { + let count = input["count"].as_u64().expect("metadata repeat count") as usize; + let repeat = input["repeat"].as_str().expect("metadata repeat value"); + let metadata = RadrootsNostrConnectClientMetadata { + name: Some(repeat.repeat(count)), + ..RadrootsNostrConnectClientMetadata::default() + }; + let error = metadata.normalized().expect_err("invalid metadata vector"); + assert_vector_error(id, expected, error); + } + "nip46.uri.valid" => { + let uri = input["uri"].as_str().expect("NIP-46 URI"); + let parsed = RadrootsNostrConnectUri::parse(uri) + .unwrap_or_else(|error| panic!("{id}: parse URI: {error}")); + assert_uri_vector(id, parsed, expected); + } + "nip46.uri.invalid" => { + let uri = input["uri"].as_str().expect("NIP-46 URI"); + let error = RadrootsNostrConnectUri::parse(uri).expect_err("invalid URI vector"); + assert_vector_error(id, expected, error); + } + "nip46.response.valid" => { + let method = input["method"] + .as_str() + .expect("response method") + .parse::<RadrootsNostrConnectMethod>() + .expect("typed response method"); + let envelope: RadrootsNostrConnectResponseEnvelope = + serde_json::from_value(input["envelope"].clone()) + .unwrap_or_else(|error| panic!("{id}: parse envelope: {error}")); + let request_id = envelope.id.clone(); + let response = RadrootsNostrConnectResponse::from_envelope(&method, envelope) + .unwrap_or_else(|error| panic!("{id}: parse response: {error}")); + let normalized = response + .into_envelope(request_id) + .unwrap_or_else(|error| panic!("{id}: serialize response: {error}")); + let normalized = serde_json::to_value(normalized) + .unwrap_or_else(|error| panic!("{id}: serialize envelope: {error}")); + assert_eq!(normalized, expected["normalized_envelope"], "{id}"); + } + "nip46.response.invalid" => { + let method = input["method"] + .as_str() + .expect("response method") + .parse::<RadrootsNostrConnectMethod>() + .expect("typed response method"); + let envelope: RadrootsNostrConnectResponseEnvelope = + serde_json::from_value(input["envelope"].clone()) + .unwrap_or_else(|error| panic!("{id}: parse envelope: {error}")); + let error = RadrootsNostrConnectResponse::from_envelope(&method, envelope) + .expect_err("invalid response vector"); + assert_vector_error(id, expected, error); + } + other => panic!("{id}: unknown NIP-46 vector kind {other}"), + } + } +} + +fn assert_uri_vector(id: &str, parsed: RadrootsNostrConnectUri, expected: &Value) { + let expected_relays = expected["relays"] + .as_array() + .expect("expected relays") + .iter() + .map(|relay| relay.as_str().expect("expected relay")) + .collect::<Vec<_>>(); + + match parsed { + RadrootsNostrConnectUri::Bunker(uri) => { + assert_eq!(expected["variant"], "bunker", "{id}"); + let relays = uri + .relays + .iter() + .map(ToString::to_string) + .collect::<Vec<_>>(); + assert_eq!(relays, expected_relays, "{id}"); + assert_eq!(uri.secret.as_deref(), expected["secret"].as_str(), "{id}"); + } + RadrootsNostrConnectUri::Client(uri) => { + assert_eq!(expected["variant"], "nostrconnect", "{id}"); + let relays = uri + .relays + .iter() + .map(ToString::to_string) + .collect::<Vec<_>>(); + assert_eq!(relays, expected_relays, "{id}"); + assert_eq!(uri.secret, expected["secret"].as_str().expect("secret")); + assert_eq!( + uri.metadata.name.as_deref(), + expected["metadata"]["name"].as_str(), + "{id}" + ); + assert_eq!( + uri.metadata.url.as_deref(), + expected["metadata"]["url"].as_str(), + "{id}" + ); + assert_eq!( + uri.metadata.image.as_deref(), + expected["metadata"]["image"].as_str(), + "{id}" + ); + assert_eq!( + uri.metadata.requested_permissions.to_string(), + expected["metadata"]["permissions"] + .as_str() + .expect("permissions"), + "{id}" + ); + } + } +} + +fn assert_vector_error(id: &str, expected: &Value, error: impl ToString) { + let class = expected["error"].as_str().expect("expected error class"); + let needle = match class { + "invalid_client_metadata" => "invalid NIP-46 client metadata", + "invalid_params" => "invalid parameter count", + "invalid_response_payload" => "invalid response payload", + "missing_result" => "missing response result", + "missing_secret" => "missing secret", + other => panic!("{id}: unknown expected error class {other}"), + }; + let message = error.to_string(); + assert!( + message.contains(needle), + "{id}: expected {class}, got {message}" + ); +} diff --git a/crates/nostr_signer/README b/crates/nostr_signer/README @@ -15,6 +15,17 @@ libraries. * file, memory, and native SQLite stores for persisted signer state; * optional native SQLite support for persistent signer services. +## Current NIP-46 behavior + +Connection proposals, drafts, and records preserve optional validated client +display metadata without treating it as an authorization input. JSON written +before metadata support remains readable, and the native SQLite migration +adds the same optional state without replacing existing connections. + +Connection revocation is explicit and idempotent. A transport service handling +`logout` is responsible for publishing the typed `ack` before it revokes the +session; storage transitions do not hide or reorder transport publication. + ## Copyright Except as otherwise noted, all files in the `radroots_nostr_signer`