sdk

Radroots SDK and bindings
git clone https://radroots.dev/git/sdk.git
Log | Files | Refs | README

commit d7609bf452d600f169cdfe70029df17ab91befd2
parent 0ab8382c26f3011c2239c7c9f226c49f96e73642
Author: triesap <tyson@radroots.org>
Date:   Tue, 23 Jun 2026 23:30:56 +0000

sdk: add async signer provider core

- add typed local_key and myc_nip46 signer provider APIs
- validate remote signed events against frozen drafts through authority
- expose signer status, capability, progress, and error surfaces
- cover local, Myc, auth, timeout, drift, and configured-signer paths

Diffstat:
Mcrates/sdk/Cargo.toml | 2++
Mcrates/sdk/src/error.rs | 100++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/sdk/src/lib.rs | 10++++++++++
Mcrates/sdk/src/runtime.rs | 48+++++++++++++++++++++++++++++++++++++++++++++++-
Acrates/sdk/src/signer_provider.rs | 527+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/src/sync_runtime.rs | 6+++---
Mcrates/sdk/tests/unit/runtime_tests.rs | 4++++
Acrates/sdk/tests/unit/signer_provider_tests.rs | 421+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 1111 insertions(+), 7 deletions(-)

diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml @@ -45,10 +45,12 @@ radrootsd-proxy = [ ] signer-adapters = [ "identity-models", + "local-signer", "signing", "std", "dep:radroots_nostr_connect", "dep:radroots_nostr_signer", + "radroots_nostr/events", ] runtime = [ "std", diff --git a/crates/sdk/src/error.rs b/crates/sdk/src/error.rs @@ -28,8 +28,10 @@ pub enum RadrootsSdkRecoveryAction { InspectLocalStores, RetryOperationWithSameIdempotencyKey, ConfigureRelayTargets, + ConfigureSigner, FixRequest, SelectAuthorizedActor, + CompleteSignerAuthentication, RetryAfterTransportFailure, EnableRequiredFeature, } @@ -76,6 +78,33 @@ pub enum RadrootsSdkError { expected_pubkey_prefix: String, signer_pubkey_prefix: String, }, + SignerUnavailable { + mode: String, + reason: String, + }, + SignerRequestRejected { + mode: String, + reason: String, + }, + SignerRequestTimedOut { + mode: String, + }, + SignerAuthChallengePending { + mode: String, + auth_url: Option<String>, + }, + SignerTransport { + mode: String, + reason: String, + }, + SignerProtocol { + mode: String, + reason: String, + }, + SignerReturnedEventDrift { + operation: String, + reason: String, + }, EmptyTargetRelays { operation: String, }, @@ -145,6 +174,13 @@ impl RadrootsSdkError { Self::TimestampOutOfRange { .. } => "timestamp_out_of_range", Self::UnauthorizedActor { .. } => "unauthorized_actor", Self::SignerPubkeyMismatch { .. } => "signer_pubkey_mismatch", + Self::SignerUnavailable { .. } => "signer_unavailable", + Self::SignerRequestRejected { .. } => "signer_request_rejected", + Self::SignerRequestTimedOut { .. } => "signer_request_timed_out", + Self::SignerAuthChallengePending { .. } => "signer_auth_challenge_pending", + Self::SignerTransport { .. } => "signer_transport", + Self::SignerProtocol { .. } => "signer_protocol", + Self::SignerReturnedEventDrift { .. } => "signer_returned_event_drift", Self::EmptyTargetRelays { .. } => "empty_target_relays", Self::RelayTargetLimitExceeded { .. } => "relay_target_limit_exceeded", Self::InvalidRelayUrl { .. } => "invalid_relay_url", @@ -176,20 +212,26 @@ impl RadrootsSdkError { } Self::UnauthorizedActor { .. } | Self::SignerPubkeyMismatch { .. } + | Self::SignerRequestRejected { .. } + | Self::SignerReturnedEventDrift { .. } | Self::Authority { .. } => RadrootsSdkErrorClass::Authorization, + Self::SignerUnavailable { .. } => RadrootsSdkErrorClass::Configuration, Self::EmptyTargetRelays { .. } | Self::RelayTargetLimitExceeded { .. } | Self::InvalidRelayUrl { .. } => RadrootsSdkErrorClass::Configuration, Self::IdempotencyConflict { .. } | Self::OrderStatusLimitInvalid { .. } | Self::InvalidOrderId { .. } + | Self::SignerProtocol { .. } + | Self::SignerAuthChallengePending { .. } | Self::InvalidRequest { .. } | Self::ListingDraft { .. } | Self::ListingMutation { .. } => RadrootsSdkErrorClass::Request, Self::ProductSyncUnsupported { .. } => RadrootsSdkErrorClass::Unsupported, - Self::ProductSyncRelaySetupFailure { .. } | Self::RelayTransport { .. } => { - RadrootsSdkErrorClass::Transport - } + Self::ProductSyncRelaySetupFailure { .. } + | Self::RelayTransport { .. } + | Self::SignerRequestTimedOut { .. } + | Self::SignerTransport { .. } => RadrootsSdkErrorClass::Transport, Self::PartialLocalMutation(_) => RadrootsSdkErrorClass::LocalMutation, } } @@ -202,6 +244,8 @@ impl RadrootsSdkError { | Self::EventStore { .. } | Self::Outbox { .. } | Self::RelayTransport { .. } + | Self::SignerRequestTimedOut { .. } + | Self::SignerTransport { .. } | Self::Projection { .. } | Self::PartialLocalMutation(_) ) @@ -215,7 +259,10 @@ impl RadrootsSdkError { | Self::Projection { .. } => vec![RadrootsSdkRecoveryAction::InspectLocalStores], Self::UnauthorizedActor { .. } | Self::SignerPubkeyMismatch { .. } + | Self::SignerRequestRejected { .. } + | Self::SignerReturnedEventDrift { .. } | Self::Authority { .. } => vec![RadrootsSdkRecoveryAction::SelectAuthorizedActor], + Self::SignerUnavailable { .. } => vec![RadrootsSdkRecoveryAction::ConfigureSigner], Self::EmptyTargetRelays { .. } | Self::RelayTargetLimitExceeded { .. } | Self::InvalidRelayUrl { .. } => { @@ -230,11 +277,18 @@ impl RadrootsSdkError { Self::ProductSyncRelaySetupFailure { .. } | Self::RelayTransport { .. } => { vec![RadrootsSdkRecoveryAction::RetryAfterTransportFailure] } + Self::SignerRequestTimedOut { .. } | Self::SignerTransport { .. } => { + vec![RadrootsSdkRecoveryAction::RetryAfterTransportFailure] + } + Self::SignerAuthChallengePending { .. } => { + vec![RadrootsSdkRecoveryAction::CompleteSignerAuthentication] + } Self::PartialLocalMutation(error) => vec![error.recovery], Self::ClockBeforeUnixEpoch | Self::TimestampOutOfRange { .. } | Self::OrderStatusLimitInvalid { .. } | Self::InvalidOrderId { .. } + | Self::SignerProtocol { .. } | Self::InvalidRequest { .. } | Self::ListingDraft { .. } | Self::ListingMutation { .. } => vec![RadrootsSdkRecoveryAction::FixRequest], @@ -260,6 +314,19 @@ impl RadrootsSdkError { "expected_pubkey_prefix": expected_pubkey_prefix, "signer_pubkey_prefix": signer_pubkey_prefix }), + Self::SignerUnavailable { mode, reason } + | Self::SignerRequestRejected { mode, reason } + | Self::SignerTransport { mode, reason } + | Self::SignerProtocol { mode, reason } => { + json!({ "mode": mode, "reason": reason }) + } + Self::SignerRequestTimedOut { mode } => json!({ "mode": mode }), + Self::SignerAuthChallengePending { mode, auth_url } => { + json!({ "mode": mode, "auth_url": auth_url }) + } + Self::SignerReturnedEventDrift { operation, reason } => { + json!({ "operation": operation, "reason": reason }) + } Self::EmptyTargetRelays { operation } => json!({ "operation": operation }), Self::RelayTargetLimitExceeded { max, actual } => { json!({ "max": max, "actual": actual }) @@ -397,6 +464,33 @@ impl fmt::Display for RadrootsSdkError { f, "sdk signer pubkey mismatch for {operation}: expected_pubkey_prefix={expected_pubkey_prefix}, signer_pubkey_prefix={signer_pubkey_prefix}" ), + Self::SignerUnavailable { mode, reason } => { + write!(f, "sdk {mode} signer unavailable: {reason}") + } + Self::SignerRequestRejected { mode, reason } => { + write!(f, "sdk {mode} signer rejected request: {reason}") + } + Self::SignerRequestTimedOut { mode } => { + write!(f, "sdk {mode} signer request timed out") + } + Self::SignerAuthChallengePending { mode, auth_url } => match auth_url { + Some(auth_url) => { + write!(f, "sdk {mode} signer requires authentication at {auth_url}") + } + None => write!(f, "sdk {mode} signer requires authentication"), + }, + Self::SignerTransport { mode, reason } => { + write!(f, "sdk {mode} signer transport error: {reason}") + } + Self::SignerProtocol { mode, reason } => { + write!(f, "sdk {mode} signer protocol error: {reason}") + } + Self::SignerReturnedEventDrift { operation, reason } => { + write!( + f, + "sdk signer returned event drift for {operation}: {reason}" + ) + } Self::EmptyTargetRelays { operation } => { write!(f, "sdk empty target relays for {operation}") } diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs @@ -36,6 +36,8 @@ pub mod protocol; mod relay_targets; #[cfg(feature = "runtime")] mod runtime; +#[cfg(all(feature = "runtime", feature = "signer-adapters"))] +mod signer_provider; #[cfg(feature = "runtime")] mod sync_runtime; #[cfg(feature = "runtime")] @@ -92,6 +94,14 @@ pub use crate::runtime::{ SdkOutboxStorageStatus, SdkPublishTransport, SdkRestoreState, SdkSqliteStoreStatus, SdkStorageKind, StorageStatusReceipt, StorageStatusRequest, }; +#[cfg(all(feature = "runtime", feature = "signer-adapters"))] +pub use crate::signer_provider::{ + RadrootsSdkLocalKeySigner, RadrootsSdkMycNip46Signer, RadrootsSdkNip46Transport, + RadrootsSdkNip46TransportFuture, RadrootsSdkSignReceipt, RadrootsSdkSignRequest, + RadrootsSdkSignerCapability, RadrootsSdkSignerMode, RadrootsSdkSignerProgress, + RadrootsSdkSignerProgressSink, RadrootsSdkSignerProvider, RadrootsSdkSignerState, + RadrootsSdkSignerStatus, +}; #[cfg(feature = "runtime")] pub use crate::sync_runtime::{ PUSH_OUTBOX_DEFAULT_CLAIM_TTL_MS, PUSH_OUTBOX_DEFAULT_LIMIT, diff --git a/crates/sdk/src/runtime.rs b/crates/sdk/src/runtime.rs @@ -3,6 +3,11 @@ use crate::{ FarmsClient, ListingsClient, OrdersClient, RadrootsSdkError, SdkRelayTargetSet, SdkRelayUrlPolicy, SyncClient, }; +#[cfg(all(feature = "runtime", feature = "signer-adapters"))] +use crate::{ + RadrootsSdkSignReceipt, RadrootsSdkSignRequest, RadrootsSdkSignerProvider, + RadrootsSdkSignerStatus, +}; #[cfg(feature = "runtime")] use radroots_event_store::RadrootsEventStore; #[cfg(feature = "runtime")] @@ -384,13 +389,15 @@ pub struct RestoreReceipt { } #[cfg(feature = "runtime")] -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct RadrootsSdkBuilder { storage: RadrootsSdkStorageConfig, clock: RadrootsSdkClock, relay_urls: Vec<String>, relay_url_policy: SdkRelayUrlPolicy, publish_transport: SdkPublishTransport, + #[cfg(feature = "signer-adapters")] + signer_provider: Option<RadrootsSdkSignerProvider>, } #[cfg(feature = "runtime")] @@ -402,6 +409,8 @@ impl Default for RadrootsSdkBuilder { relay_urls: Vec::new(), relay_url_policy: SdkRelayUrlPolicy::Public, publish_transport: SdkPublishTransport::DirectNostrRelay, + #[cfg(feature = "signer-adapters")] + signer_provider: None, } } } @@ -443,6 +452,12 @@ impl RadrootsSdkBuilder { self } + #[cfg(feature = "signer-adapters")] + pub fn signer_provider(mut self, signer_provider: RadrootsSdkSignerProvider) -> Self { + self.signer_provider = Some(signer_provider); + self + } + pub async fn build(self) -> Result<RadrootsSdk, RadrootsSdkError> { let storage = open_storage(&self.storage).await?; let relay_urls = @@ -454,6 +469,8 @@ impl RadrootsSdkBuilder { clock: self.clock, relay_urls, publish_transport: self.publish_transport, + #[cfg(feature = "signer-adapters")] + signer_provider: self.signer_provider, }) } } @@ -467,6 +484,8 @@ pub struct RadrootsSdk { clock: RadrootsSdkClock, relay_urls: Vec<String>, publish_transport: SdkPublishTransport, + #[cfg(feature = "signer-adapters")] + signer_provider: Option<RadrootsSdkSignerProvider>, } #[cfg(feature = "runtime")] @@ -503,6 +522,33 @@ impl RadrootsSdk { &self.publish_transport } + #[cfg(feature = "signer-adapters")] + pub fn configured_signer(&self) -> Option<&RadrootsSdkSignerProvider> { + self.signer_provider.as_ref() + } + + #[cfg(feature = "signer-adapters")] + pub fn signer_status(&self) -> Option<RadrootsSdkSignerStatus> { + self.signer_provider + .as_ref() + .map(RadrootsSdkSignerProvider::status) + } + + #[cfg(feature = "signer-adapters")] + pub async fn sign_with_configured_signer( + &self, + request: RadrootsSdkSignRequest<'_>, + ) -> Result<RadrootsSdkSignReceipt, RadrootsSdkError> { + let signer = + self.signer_provider + .as_ref() + .ok_or_else(|| RadrootsSdkError::SignerUnavailable { + mode: "configured".to_owned(), + reason: "no SDK signer provider is configured".to_owned(), + })?; + signer.sign(request).await + } + pub fn storage_paths(&self) -> Option<&RadrootsSdkStoragePaths> { self.storage_paths.as_ref() } diff --git a/crates/sdk/src/signer_provider.rs b/crates/sdk/src/signer_provider.rs @@ -0,0 +1,527 @@ +use crate::RadrootsSdkError; +#[cfg(feature = "local-signer")] +use radroots_authority::RadrootsLocalEventSigner; +use radroots_authority::{ + RadrootsActorContext, RadrootsEventSigner, RadrootsSignerError, authorize_actor_for_draft, + authorize_signer_for_draft, sign_authorized_draft, validate_signed_event_matches_draft, +}; +use radroots_events::draft::{RadrootsFrozenEventDraft, RadrootsSignedNostrEvent}; +use radroots_events::ids::RadrootsPublicKey; +use radroots_nostr::prelude::{RadrootsNostrEvent, RadrootsNostrKeys, radroots_event_from_nostr}; +use radroots_nostr_connect::prelude::{ + RadrootsNostrConnectClientRequest, RadrootsNostrConnectClientTarget, + RadrootsNostrConnectClientTransport, RadrootsNostrConnectClientTransportFuture, + RadrootsNostrConnectError, RadrootsNostrConnectRequest, RadrootsNostrConnectResponse, + execute_request_with_transport, +}; +use serde_json::json; +use std::sync::{ + Arc, + atomic::{AtomicU64, Ordering}, +}; + +pub type RadrootsSdkNip46TransportFuture<'a, T> = RadrootsNostrConnectClientTransportFuture<'a, T>; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum RadrootsSdkSignerMode { + #[cfg(feature = "local-signer")] + LocalKey, + MycNip46, +} + +impl RadrootsSdkSignerMode { + pub fn as_str(self) -> &'static str { + match self { + #[cfg(feature = "local-signer")] + Self::LocalKey => "local_key", + Self::MycNip46 => "myc_nip46", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum RadrootsSdkSignerState { + Ready, +} + +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +pub struct RadrootsSdkSignerStatus { + pub mode: RadrootsSdkSignerMode, + pub state: RadrootsSdkSignerState, + pub signer_pubkey: String, + pub remote_signer_pubkey: Option<String>, + pub relay_count: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +pub struct RadrootsSdkSignerCapability { + pub mode: RadrootsSdkSignerMode, + pub signer_pubkey: String, + pub remote_signer_pubkey: Option<String>, + pub relays: Vec<String>, + pub can_sign_events: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case", tag = "kind")] +pub enum RadrootsSdkSignerProgress { + RequestStarted { + mode: RadrootsSdkSignerMode, + }, + AuthChallenge { + mode: RadrootsSdkSignerMode, + url: String, + }, + RequestCompleted { + mode: RadrootsSdkSignerMode, + }, +} + +pub trait RadrootsSdkSignerProgressSink { + fn on_signer_progress( + &mut self, + progress: RadrootsSdkSignerProgress, + ) -> Result<(), RadrootsSdkError>; +} + +impl<F> RadrootsSdkSignerProgressSink for F +where + F: FnMut(RadrootsSdkSignerProgress) -> Result<(), RadrootsSdkError>, +{ + fn on_signer_progress( + &mut self, + progress: RadrootsSdkSignerProgress, + ) -> Result<(), RadrootsSdkError> { + self(progress) + } +} + +pub struct RadrootsSdkSignRequest<'a> { + pub operation_kind: &'a str, + pub actor: &'a RadrootsActorContext, + pub frozen_draft: &'a RadrootsFrozenEventDraft, + progress_sink: Option<&'a mut dyn RadrootsSdkSignerProgressSink>, +} + +impl<'a> RadrootsSdkSignRequest<'a> { + pub fn new( + operation_kind: &'a str, + actor: &'a RadrootsActorContext, + frozen_draft: &'a RadrootsFrozenEventDraft, + ) -> Self { + Self { + operation_kind, + actor, + frozen_draft, + progress_sink: None, + } + } + + pub fn with_progress_sink( + mut self, + progress_sink: &'a mut dyn RadrootsSdkSignerProgressSink, + ) -> Self { + self.progress_sink = Some(progress_sink); + self + } + + fn emit_progress( + &mut self, + progress: RadrootsSdkSignerProgress, + ) -> Result<(), RadrootsSdkError> { + match self.progress_sink.as_deref_mut() { + Some(progress_sink) => progress_sink.on_signer_progress(progress), + None => Ok(()), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +pub struct RadrootsSdkSignReceipt { + pub operation_kind: String, + pub mode: RadrootsSdkSignerMode, + pub signer_pubkey: String, + pub remote_signer_pubkey: Option<String>, + pub signed_event_id: String, + pub signed_event: RadrootsSignedNostrEvent, +} + +#[derive(Clone)] +pub enum RadrootsSdkSignerProvider { + #[cfg(feature = "local-signer")] + LocalKey(RadrootsSdkLocalKeySigner), + MycNip46(RadrootsSdkMycNip46Signer), +} + +impl RadrootsSdkSignerProvider { + pub fn mode(&self) -> RadrootsSdkSignerMode { + match self { + #[cfg(feature = "local-signer")] + Self::LocalKey(_) => RadrootsSdkSignerMode::LocalKey, + Self::MycNip46(_) => RadrootsSdkSignerMode::MycNip46, + } + } + + pub fn status(&self) -> RadrootsSdkSignerStatus { + match self { + #[cfg(feature = "local-signer")] + Self::LocalKey(signer) => signer.status(), + Self::MycNip46(signer) => signer.status(), + } + } + + pub fn capability(&self) -> RadrootsSdkSignerCapability { + match self { + #[cfg(feature = "local-signer")] + Self::LocalKey(signer) => signer.capability(), + Self::MycNip46(signer) => signer.capability(), + } + } + + pub async fn sign( + &self, + request: RadrootsSdkSignRequest<'_>, + ) -> Result<RadrootsSdkSignReceipt, RadrootsSdkError> { + match self { + #[cfg(feature = "local-signer")] + Self::LocalKey(signer) => signer.sign(request).await, + Self::MycNip46(signer) => signer.sign(request).await, + } + } +} + +#[cfg(feature = "local-signer")] +#[derive(Clone)] +pub struct RadrootsSdkLocalKeySigner { + signer: Arc<RadrootsLocalEventSigner>, + signer_pubkey: String, +} + +#[cfg(feature = "local-signer")] +impl RadrootsSdkLocalKeySigner { + pub fn new(keys: RadrootsNostrKeys) -> Result<Self, RadrootsSdkError> { + let signer = RadrootsLocalEventSigner::new(keys)?; + let signer_pubkey = signer.pubkey().as_str().to_owned(); + Ok(Self { + signer: Arc::new(signer), + signer_pubkey, + }) + } + + pub fn status(&self) -> RadrootsSdkSignerStatus { + RadrootsSdkSignerStatus { + mode: RadrootsSdkSignerMode::LocalKey, + state: RadrootsSdkSignerState::Ready, + signer_pubkey: self.signer_pubkey.clone(), + remote_signer_pubkey: None, + relay_count: 0, + } + } + + pub fn capability(&self) -> RadrootsSdkSignerCapability { + RadrootsSdkSignerCapability { + mode: RadrootsSdkSignerMode::LocalKey, + signer_pubkey: self.signer_pubkey.clone(), + remote_signer_pubkey: None, + relays: Vec::new(), + can_sign_events: true, + } + } + + pub async fn sign( + &self, + mut request: RadrootsSdkSignRequest<'_>, + ) -> Result<RadrootsSdkSignReceipt, RadrootsSdkError> { + request.emit_progress(RadrootsSdkSignerProgress::RequestStarted { + mode: RadrootsSdkSignerMode::LocalKey, + })?; + let signed_event = + sign_authorized_draft(request.actor, self.signer.as_ref(), request.frozen_draft)?; + request.emit_progress(RadrootsSdkSignerProgress::RequestCompleted { + mode: RadrootsSdkSignerMode::LocalKey, + })?; + Ok(sign_receipt( + request.operation_kind, + RadrootsSdkSignerMode::LocalKey, + self.signer_pubkey.clone(), + None, + signed_event, + )) + } +} + +pub trait RadrootsSdkNip46Transport: Send + Sync { + fn publish_request_event<'a>( + &'a self, + event: RadrootsNostrEvent, + ) -> RadrootsSdkNip46TransportFuture<'a, ()>; + + fn next_response_event<'a>(&'a self) + -> RadrootsSdkNip46TransportFuture<'a, RadrootsNostrEvent>; +} + +#[derive(Clone)] +pub struct RadrootsSdkMycNip46Signer { + client_keys: RadrootsNostrKeys, + target: RadrootsNostrConnectClientTarget, + user_pubkey: RadrootsPublicKey, + transport: Arc<dyn RadrootsSdkNip46Transport>, + next_request_id: Arc<AtomicU64>, +} + +impl RadrootsSdkMycNip46Signer { + pub fn new( + client_keys: RadrootsNostrKeys, + target: RadrootsNostrConnectClientTarget, + user_pubkey: impl AsRef<str>, + transport: Arc<dyn RadrootsSdkNip46Transport>, + ) -> Result<Self, RadrootsSdkError> { + let user_pubkey = RadrootsPublicKey::parse(user_pubkey.as_ref()).map_err(|error| { + RadrootsSdkError::InvalidRequest { + message: format!("myc_nip46 user pubkey is invalid: {error}"), + } + })?; + Ok(Self { + client_keys, + target, + user_pubkey, + transport, + next_request_id: Arc::new(AtomicU64::new(1)), + }) + } + + pub fn status(&self) -> RadrootsSdkSignerStatus { + RadrootsSdkSignerStatus { + mode: RadrootsSdkSignerMode::MycNip46, + state: RadrootsSdkSignerState::Ready, + signer_pubkey: self.user_pubkey.as_str().to_owned(), + remote_signer_pubkey: Some(self.target.remote_signer_public_key.to_hex()), + relay_count: self.target.relays.len(), + } + } + + pub fn capability(&self) -> RadrootsSdkSignerCapability { + RadrootsSdkSignerCapability { + mode: RadrootsSdkSignerMode::MycNip46, + signer_pubkey: self.user_pubkey.as_str().to_owned(), + remote_signer_pubkey: Some(self.target.remote_signer_public_key.to_hex()), + relays: self.target.relays.iter().map(ToString::to_string).collect(), + can_sign_events: true, + } + } + + pub async fn sign( + &self, + mut request: RadrootsSdkSignRequest<'_>, + ) -> Result<RadrootsSdkSignReceipt, RadrootsSdkError> { + request.emit_progress(RadrootsSdkSignerProgress::RequestStarted { + mode: RadrootsSdkSignerMode::MycNip46, + })?; + authorize_actor_for_draft(request.actor, request.frozen_draft)?; + let signer_identity = RadrootsSdkSignerIdentityOnly { + pubkey: self.user_pubkey.clone(), + }; + authorize_signer_for_draft(&signer_identity, request.frozen_draft)?; + let sign_event_request = sign_event_request_from_frozen_draft(request.frozen_draft)?; + let request_id = self.next_request_id(); + let mut adapter = RadrootsSdkNip46TransportAdapter { + transport: self.transport.as_ref(), + }; + let mut progress_error = None; + let response = execute_request_with_transport( + &self.client_keys, + &self.target, + RadrootsNostrConnectClientRequest::new( + request_id, + sign_event_request, + ), + &mut adapter, + |progress| { + let sdk_progress = match progress { + radroots_nostr_connect::prelude::RadrootsNostrConnectClientProgress::AuthChallenge { + url, + } => RadrootsSdkSignerProgress::AuthChallenge { + mode: RadrootsSdkSignerMode::MycNip46, + url, + }, + }; + if let Err(error) = request.emit_progress(sdk_progress) { + progress_error = Some(error); + return Err(RadrootsNostrConnectError::Transport { + reason: "SDK signer progress sink failed".to_owned(), + }); + } + Ok(()) + }, + ) + .await; + if let Some(error) = progress_error { + return Err(error); + } + let response = response.map_err(sdk_error_from_nip46_error)?; + let signed_event = signed_event_from_nip46_response(request.operation_kind, response)?; + validate_signed_event_matches_draft(&signed_event, request.frozen_draft).map_err( + |error| RadrootsSdkError::SignerReturnedEventDrift { + operation: request.operation_kind.to_owned(), + reason: error.to_string(), + }, + )?; + request.emit_progress(RadrootsSdkSignerProgress::RequestCompleted { + mode: RadrootsSdkSignerMode::MycNip46, + })?; + Ok(sign_receipt( + request.operation_kind, + RadrootsSdkSignerMode::MycNip46, + self.user_pubkey.as_str().to_owned(), + Some(self.target.remote_signer_public_key.to_hex()), + signed_event, + )) + } + + fn next_request_id(&self) -> String { + let next = self.next_request_id.fetch_add(1, Ordering::Relaxed); + format!("radroots-sdk-myc-nip46-sign-{next}") + } +} + +struct RadrootsSdkSignerIdentityOnly { + pubkey: RadrootsPublicKey, +} + +impl RadrootsEventSigner for RadrootsSdkSignerIdentityOnly { + fn pubkey(&self) -> &RadrootsPublicKey { + &self.pubkey + } + + fn sign_frozen_draft( + &self, + _draft: &RadrootsFrozenEventDraft, + ) -> Result<RadrootsSignedNostrEvent, RadrootsSignerError> { + Err(RadrootsSignerError::Unavailable) + } +} + +struct RadrootsSdkNip46TransportAdapter<'a> { + transport: &'a dyn RadrootsSdkNip46Transport, +} + +impl RadrootsNostrConnectClientTransport for RadrootsSdkNip46TransportAdapter<'_> { + fn publish_request_event<'a>( + &'a mut self, + event: RadrootsNostrEvent, + ) -> RadrootsNostrConnectClientTransportFuture<'a, ()> { + self.transport.publish_request_event(event) + } + + fn next_response_event<'a>( + &'a mut self, + ) -> RadrootsNostrConnectClientTransportFuture<'a, RadrootsNostrEvent> { + self.transport.next_response_event() + } +} + +fn sign_event_request_from_frozen_draft( + draft: &RadrootsFrozenEventDraft, +) -> Result<RadrootsNostrConnectRequest, RadrootsSdkError> { + let unsigned_event = serde_json::from_value(json!({ + "pubkey": draft.expected_pubkey, + "created_at": draft.created_at, + "kind": draft.kind, + "tags": draft.tags, + "content": draft.content, + })) + .map_err(|error| RadrootsSdkError::SignerProtocol { + mode: RadrootsSdkSignerMode::MycNip46.as_str().to_owned(), + reason: format!("failed to convert frozen draft to NIP-46 unsigned event: {error}"), + })?; + Ok(RadrootsNostrConnectRequest::SignEvent(unsigned_event)) +} + +fn signed_event_from_nip46_response( + operation_kind: &str, + response: RadrootsNostrConnectResponse, +) -> Result<RadrootsSignedNostrEvent, RadrootsSdkError> { + match response { + RadrootsNostrConnectResponse::SignedEvent(event) => { + let raw_json = serde_json::to_string(&event).map_err(|error| { + RadrootsSdkError::SignerProtocol { + mode: RadrootsSdkSignerMode::MycNip46.as_str().to_owned(), + reason: format!("failed to serialize remote signed event: {error}"), + } + })?; + RadrootsSignedNostrEvent::from_event(radroots_event_from_nostr(&event), raw_json) + .map_err(|error| RadrootsSdkError::SignerProtocol { + mode: RadrootsSdkSignerMode::MycNip46.as_str().to_owned(), + reason: format!("remote signed event is invalid: {error}"), + }) + } + RadrootsNostrConnectResponse::Error { error, .. } => { + Err(RadrootsSdkError::SignerRequestRejected { + mode: RadrootsSdkSignerMode::MycNip46.as_str().to_owned(), + reason: error, + }) + } + RadrootsNostrConnectResponse::PendingConnection => { + Err(RadrootsSdkError::SignerAuthChallengePending { + mode: RadrootsSdkSignerMode::MycNip46.as_str().to_owned(), + auth_url: None, + }) + } + other => Err(RadrootsSdkError::SignerProtocol { + mode: RadrootsSdkSignerMode::MycNip46.as_str().to_owned(), + reason: format!("unexpected NIP-46 response for {operation_kind}: {other:?}"), + }), + } +} + +fn sdk_error_from_nip46_error(error: RadrootsNostrConnectError) -> RadrootsSdkError { + match error { + RadrootsNostrConnectError::RequestTimedOut => RadrootsSdkError::SignerRequestTimedOut { + mode: RadrootsSdkSignerMode::MycNip46.as_str().to_owned(), + }, + RadrootsNostrConnectError::Transport { reason } => RadrootsSdkError::SignerTransport { + mode: RadrootsSdkSignerMode::MycNip46.as_str().to_owned(), + reason, + }, + RadrootsNostrConnectError::Encrypt { reason } + | RadrootsNostrConnectError::Decrypt { reason } + | RadrootsNostrConnectError::Sign { reason } + | RadrootsNostrConnectError::Json(reason) + | RadrootsNostrConnectError::InvalidRequestPayload { reason, .. } + | RadrootsNostrConnectError::InvalidResponsePayload { reason, .. } => { + RadrootsSdkError::SignerProtocol { + mode: RadrootsSdkSignerMode::MycNip46.as_str().to_owned(), + reason, + } + } + error => RadrootsSdkError::SignerProtocol { + mode: RadrootsSdkSignerMode::MycNip46.as_str().to_owned(), + reason: error.to_string(), + }, + } +} + +fn sign_receipt( + operation_kind: &str, + mode: RadrootsSdkSignerMode, + signer_pubkey: String, + remote_signer_pubkey: Option<String>, + signed_event: RadrootsSignedNostrEvent, +) -> RadrootsSdkSignReceipt { + RadrootsSdkSignReceipt { + operation_kind: operation_kind.to_owned(), + mode, + signer_pubkey, + remote_signer_pubkey, + signed_event_id: signed_event.id.clone(), + signed_event, + } +} + +#[cfg(test)] +#[path = "../tests/unit/signer_provider_tests.rs"] +mod tests; diff --git a/crates/sdk/src/sync_runtime.rs b/crates/sdk/src/sync_runtime.rs @@ -10,10 +10,10 @@ use radroots_event_store::RadrootsEventStoreStatusSummary; use radroots_events::ids::RadrootsEventId; #[cfg(all(feature = "runtime", feature = "relay-runtime"))] use radroots_nostr::prelude::RadrootsNostrClient; +#[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))] +use radroots_outbox::RadrootsOutboxClaimedEvent; #[cfg(feature = "runtime")] -use radroots_outbox::{ - RadrootsOutboxClaimedEvent, RadrootsOutboxEventState, RadrootsOutboxStatusSummary, -}; +use radroots_outbox::{RadrootsOutboxEventState, RadrootsOutboxStatusSummary}; #[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))] use radroots_publish_proxy_protocol::PublishDeliveryPolicy; #[cfg(all(feature = "runtime", feature = "relay-runtime"))] diff --git a/crates/sdk/tests/unit/runtime_tests.rs b/crates/sdk/tests/unit/runtime_tests.rs @@ -131,6 +131,8 @@ async fn open_storage_and_storage_kind_cover_memory_directory_and_file_failures( clock: RadrootsSdkClock::Fixed(RadrootsSdkTimestamp::from_unix_seconds(1)), relay_urls: Vec::new(), publish_transport: SdkPublishTransport::DirectNostrRelay, + #[cfg(feature = "signer-adapters")] + signer_provider: None, }; assert_eq!(memory_sdk.storage_kind(), SdkStorageKind::Memory); @@ -149,6 +151,8 @@ async fn open_storage_and_storage_kind_cover_memory_directory_and_file_failures( clock: RadrootsSdkClock::Fixed(RadrootsSdkTimestamp::from_unix_seconds(1)), relay_urls: Vec::new(), publish_transport: SdkPublishTransport::DirectNostrRelay, + #[cfg(feature = "signer-adapters")] + signer_provider: None, }; assert_eq!(directory_sdk.storage_kind(), SdkStorageKind::Directory); diff --git a/crates/sdk/tests/unit/signer_provider_tests.rs b/crates/sdk/tests/unit/signer_provider_tests.rs @@ -0,0 +1,421 @@ +use super::*; +use nostr::nips::nip44::{self, Version}; +use nostr::{EventBuilder, JsonUtil, Kind, Tag}; +use radroots_events::contract::RadrootsActorRole; +use radroots_events::kinds::{KIND_COOP, KIND_FARM}; +use radroots_events_codec::wire::{WireEventParts, to_frozen_draft}; +use radroots_nostr::prelude::{RadrootsNostrEvent, RadrootsNostrSecretKey}; +use radroots_nostr_connect::prelude::{ + RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectClientTarget, RadrootsNostrConnectError, + RadrootsNostrConnectResponse, +}; +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +const USER_SECRET_KEY_HEX: &str = + "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"; +const USER_PUBLIC_KEY_HEX: &str = + "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; +const REMOTE_SECRET_KEY_HEX: &str = + "59392e9068f66431b12f70218fb61281cb6b433d7f27c55d61f1a63fe1a96ff8"; +const CLIENT_SECRET_KEY_HEX: &str = + "4d6c20fdd86857de77ff5cfa5c545751ba2efd126e0b6642dae9764d782d6509"; + +fn keys(secret_key_hex: &str) -> RadrootsNostrKeys { + let secret_key = RadrootsNostrSecretKey::from_hex(secret_key_hex).expect("secret key"); + RadrootsNostrKeys::new(secret_key) +} + +fn user_keys() -> RadrootsNostrKeys { + keys(USER_SECRET_KEY_HEX) +} + +fn remote_keys() -> RadrootsNostrKeys { + keys(REMOTE_SECRET_KEY_HEX) +} + +fn client_keys() -> RadrootsNostrKeys { + keys(CLIENT_SECRET_KEY_HEX) +} + +fn actor() -> RadrootsActorContext { + RadrootsActorContext::test(USER_PUBLIC_KEY_HEX, [RadrootsActorRole::Farmer]).expect("actor") +} + +fn frozen_draft() -> RadrootsFrozenEventDraft { + frozen_draft_with( + "radroots.farm.profile.v1", + USER_PUBLIC_KEY_HEX, + KIND_FARM, + 1_700_000_000, + vec![vec!["d".to_owned(), "sdk-signer".to_owned()]], + "{}", + ) +} + +fn frozen_draft_with( + contract_id: &str, + pubkey: &str, + kind: u32, + created_at: u32, + tags: Vec<Vec<String>>, + content: &str, +) -> RadrootsFrozenEventDraft { + to_frozen_draft( + WireEventParts { + kind, + content: content.to_owned(), + tags, + }, + contract_id, + pubkey, + created_at, + ) + .expect("frozen draft") +} + +fn sign_event(keys: &RadrootsNostrKeys, draft: &RadrootsFrozenEventDraft) -> RadrootsNostrEvent { + let signed = + radroots_nostr::prelude::radroots_nostr_sign_frozen_draft(keys, draft).expect("signed"); + RadrootsNostrEvent::from_json(signed.raw_json.as_str()).expect("event") +} + +fn response_event( + remote_keys: &RadrootsNostrKeys, + client_public_key: nostr::PublicKey, + request_id: &str, + response: RadrootsNostrConnectResponse, +) -> RadrootsNostrEvent { + let envelope = response + .into_envelope(request_id) + .expect("response envelope"); + let payload = serde_json::to_string(&envelope).expect("payload"); + let ciphertext = nip44::encrypt( + remote_keys.secret_key(), + &client_public_key, + payload, + Version::V2, + ) + .expect("ciphertext"); + EventBuilder::new(Kind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND), ciphertext) + .tag(Tag::public_key(client_public_key)) + .sign_with_keys(remote_keys) + .expect("response event") +} + +struct MockNip46Transport { + published: Mutex<Vec<RadrootsNostrEvent>>, + inbound: Mutex<VecDeque<RadrootsNostrEvent>>, +} + +impl MockNip46Transport { + fn new(inbound: Vec<RadrootsNostrEvent>) -> Self { + Self { + published: Mutex::new(Vec::new()), + inbound: Mutex::new(inbound.into()), + } + } + + fn published(&self) -> Vec<RadrootsNostrEvent> { + self.published.lock().expect("published lock").clone() + } +} + +impl RadrootsSdkNip46Transport for MockNip46Transport { + fn publish_request_event<'a>( + &'a self, + event: RadrootsNostrEvent, + ) -> RadrootsSdkNip46TransportFuture<'a, ()> { + self.published.lock().expect("published lock").push(event); + Box::pin(async { Ok(()) }) + } + + fn next_response_event<'a>( + &'a self, + ) -> RadrootsSdkNip46TransportFuture<'a, RadrootsNostrEvent> { + let next = self.inbound.lock().expect("inbound lock").pop_front(); + Box::pin(async move { next.ok_or(RadrootsNostrConnectError::RequestTimedOut) }) + } +} + +#[tokio::test] +async fn local_key_provider_signs_authorized_frozen_draft() { + let signer = RadrootsSdkLocalKeySigner::new(user_keys()).expect("signer"); + let provider = RadrootsSdkSignerProvider::LocalKey(signer.clone()); + let draft = frozen_draft(); + let actor = actor(); + let mut progress = Vec::new(); + + let receipt = provider + .sign( + RadrootsSdkSignRequest::new("farm.publish", &actor, &draft).with_progress_sink( + &mut |event| { + progress.push(event); + Ok(()) + }, + ), + ) + .await + .expect("receipt"); + + assert_eq!(provider.mode(), RadrootsSdkSignerMode::LocalKey); + assert_eq!(provider.status(), signer.status()); + assert_eq!(receipt.mode, RadrootsSdkSignerMode::LocalKey); + assert_eq!(receipt.signer_pubkey, USER_PUBLIC_KEY_HEX); + assert_eq!(receipt.signed_event_id, draft.expected_event_id); + assert_eq!( + progress, + vec![ + RadrootsSdkSignerProgress::RequestStarted { + mode: RadrootsSdkSignerMode::LocalKey + }, + RadrootsSdkSignerProgress::RequestCompleted { + mode: RadrootsSdkSignerMode::LocalKey + } + ] + ); +} + +#[tokio::test] +async fn myc_nip46_provider_signs_and_validates_remote_event() { + let client_keys = client_keys(); + let remote_keys = remote_keys(); + let user_keys = user_keys(); + let draft = frozen_draft(); + let signed = radroots_nostr::prelude::radroots_nostr_sign_frozen_draft(&user_keys, &draft) + .expect("signed"); + let signed_event = RadrootsNostrEvent::from_json(signed.raw_json.as_str()).expect("event"); + let inbound = vec![response_event( + &remote_keys, + client_keys.public_key(), + "radroots-sdk-myc-nip46-sign-1", + RadrootsNostrConnectResponse::SignedEvent(signed_event), + )]; + let transport = Arc::new(MockNip46Transport::new(inbound)); + let target = RadrootsNostrConnectClientTarget::new( + remote_keys.public_key(), + vec![nostr::RelayUrl::parse("wss://relay.example.com").expect("relay")], + ); + let signer = + RadrootsSdkMycNip46Signer::new(client_keys, target, USER_PUBLIC_KEY_HEX, transport.clone()) + .expect("signer"); + let provider = RadrootsSdkSignerProvider::MycNip46(signer); + let actor = actor(); + let mut progress = Vec::new(); + + let receipt = provider + .sign( + RadrootsSdkSignRequest::new("farm.publish", &actor, &draft).with_progress_sink( + &mut |event| { + progress.push(event); + Ok(()) + }, + ), + ) + .await + .expect("receipt"); + + assert_eq!(receipt.mode, RadrootsSdkSignerMode::MycNip46); + assert_eq!(receipt.signer_pubkey, USER_PUBLIC_KEY_HEX); + assert_eq!( + receipt.remote_signer_pubkey, + Some(remote_keys.public_key().to_hex()) + ); + assert_eq!(receipt.signed_event, signed); + assert_eq!(transport.published().len(), 1); + assert_eq!( + progress, + vec![ + RadrootsSdkSignerProgress::RequestStarted { + mode: RadrootsSdkSignerMode::MycNip46 + }, + RadrootsSdkSignerProgress::RequestCompleted { + mode: RadrootsSdkSignerMode::MycNip46 + } + ] + ); +} + +#[tokio::test] +async fn myc_nip46_provider_reports_auth_challenge_progress_and_timeout() { + let client_keys = client_keys(); + let remote_keys = remote_keys(); + let auth = response_event( + &remote_keys, + client_keys.public_key(), + "radroots-sdk-myc-nip46-sign-1", + RadrootsNostrConnectResponse::AuthUrl("https://auth.example.com/challenge".to_owned()), + ); + let transport = Arc::new(MockNip46Transport::new(vec![auth])); + let target = RadrootsNostrConnectClientTarget::new(remote_keys.public_key(), Vec::new()); + let signer = + RadrootsSdkMycNip46Signer::new(client_keys, target, USER_PUBLIC_KEY_HEX, transport) + .expect("signer"); + let mut progress = Vec::new(); + let draft = frozen_draft(); + let actor = actor(); + + let error = signer + .sign( + RadrootsSdkSignRequest::new("farm.publish", &actor, &draft).with_progress_sink( + &mut |event| { + progress.push(event); + Ok(()) + }, + ), + ) + .await + .expect_err("timeout"); + + assert!(matches!( + error, + RadrootsSdkError::SignerRequestTimedOut { ref mode } if mode == "myc_nip46" + )); + assert_eq!( + progress, + vec![ + RadrootsSdkSignerProgress::RequestStarted { + mode: RadrootsSdkSignerMode::MycNip46 + }, + RadrootsSdkSignerProgress::AuthChallenge { + mode: RadrootsSdkSignerMode::MycNip46, + url: "https://auth.example.com/challenge".to_owned() + } + ] + ); +} + +#[tokio::test] +async fn myc_nip46_provider_rejects_returned_event_drift() { + let draft = frozen_draft(); + let wrong_user_keys = remote_keys(); + let wrong_pubkey = wrong_user_keys.public_key().to_hex(); + let cases = vec![ + ( + "pubkey", + wrong_user_keys, + frozen_draft_with( + "radroots.farm.profile.v1", + &wrong_pubkey, + KIND_FARM, + 1_700_000_000, + vec![vec!["d".to_owned(), "sdk-signer".to_owned()]], + "{}", + ), + ), + ( + "id", + user_keys(), + frozen_draft_with( + "radroots.farm.profile.v1", + USER_PUBLIC_KEY_HEX, + KIND_FARM, + 1_700_000_000, + vec![vec!["d".to_owned(), "sdk-signer-id-drift".to_owned()]], + "{}", + ), + ), + ( + "created_at", + user_keys(), + frozen_draft_with( + "radroots.farm.profile.v1", + USER_PUBLIC_KEY_HEX, + KIND_FARM, + 1_700_000_001, + vec![vec!["d".to_owned(), "sdk-signer".to_owned()]], + "{}", + ), + ), + ( + "kind", + user_keys(), + frozen_draft_with( + "radroots.farm.coop.v1", + USER_PUBLIC_KEY_HEX, + KIND_COOP, + 1_700_000_000, + vec![vec!["d".to_owned(), "sdk-signer".to_owned()]], + "{}", + ), + ), + ( + "tags", + user_keys(), + frozen_draft_with( + "radroots.farm.profile.v1", + USER_PUBLIC_KEY_HEX, + KIND_FARM, + 1_700_000_000, + vec![vec!["d".to_owned(), "sdk-signer-tags-drift".to_owned()]], + "{}", + ), + ), + ( + "content", + user_keys(), + frozen_draft_with( + "radroots.farm.profile.v1", + USER_PUBLIC_KEY_HEX, + KIND_FARM, + 1_700_000_000, + vec![vec!["d".to_owned(), "sdk-signer".to_owned()]], + "{\"drift\":true}", + ), + ), + ]; + + for (drift_kind, signing_keys, drifted_draft) in cases { + let client_keys = client_keys(); + let remote_keys = remote_keys(); + let signed_event = sign_event(&signing_keys, &drifted_draft); + let transport = Arc::new(MockNip46Transport::new(vec![response_event( + &remote_keys, + client_keys.public_key(), + "radroots-sdk-myc-nip46-sign-1", + RadrootsNostrConnectResponse::SignedEvent(signed_event), + )])); + let target = RadrootsNostrConnectClientTarget::new(remote_keys.public_key(), Vec::new()); + let signer = + RadrootsSdkMycNip46Signer::new(client_keys, target, USER_PUBLIC_KEY_HEX, transport) + .expect("signer"); + let actor = actor(); + + let error = signer + .sign(RadrootsSdkSignRequest::new("farm.publish", &actor, &draft)) + .await + .expect_err(drift_kind); + + assert!(matches!( + error, + RadrootsSdkError::SignerReturnedEventDrift { ref operation, .. } + if operation == "farm.publish" + )); + } +} + +#[tokio::test] +async fn sdk_builder_installs_configured_signer_provider() { + let signer = RadrootsSdkLocalKeySigner::new(user_keys()).expect("signer"); + let sdk = crate::RadrootsSdk::builder() + .signer_provider(RadrootsSdkSignerProvider::LocalKey(signer)) + .build() + .await + .expect("sdk"); + let draft = frozen_draft(); + + assert!(sdk.configured_signer().is_some()); + assert!(matches!( + sdk.signer_status(), + Some(RadrootsSdkSignerStatus { + mode: RadrootsSdkSignerMode::LocalKey, + .. + }) + )); + let actor = actor(); + let receipt = sdk + .sign_with_configured_signer(RadrootsSdkSignRequest::new("farm.publish", &actor, &draft)) + .await + .expect("receipt"); + assert_eq!(receipt.signed_event_id, draft.expected_event_id); +}