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:
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);
+}