#![forbid(unsafe_code)]
#![warn(missing_docs)]
#![warn(clippy::large_futures)]
#![warn(rustdoc::bare_urls)]
#![allow(clippy::arc_with_non_send_sync)]
use std::collections::HashMap;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::time::Duration;
use futures_core::Stream;
use nostr::nips::nip47::{Notification, Request, Response};
use nostr_sdk::prelude::*;
mod api;
pub mod builder;
pub mod error;
mod future;
pub mod prelude;
pub use self::api::*;
use self::builder::NostrWalletConnectBuilder;
use self::error::Error;
const NOTIFICATIONS_ID: &str = "nwc-notifications";
#[allow(missing_docs)]
#[deprecated(since = "0.45.0", note = "Use NostrWalletConnect instead")]
pub type NWC = NostrWalletConnect;
#[derive(Debug)]
struct AtomicCipher(AtomicU32);
impl From<Option<Nip47Ciphers>> for AtomicCipher {
fn from(value: Option<Nip47Ciphers>) -> Self {
match value {
Some(cipher) => Self::new(cipher),
None => Self(AtomicU32::from(0)),
}
}
}
impl AtomicCipher {
#[inline]
fn new(cipher: Nip47Ciphers) -> Self {
Self(AtomicU32::from(cipher.as_u32()))
}
#[inline]
fn load(&self) -> Option<Nip47Ciphers> {
match self.0.load(Ordering::SeqCst) {
0 => None,
cipher => Nip47Ciphers::from_u32(cipher),
}
}
}
#[derive(Debug, Clone)]
pub struct NostrWalletConnect {
uri: NostrWalletConnectUri,
client: Client,
timeout: Duration,
cipher: Arc<AtomicCipher>,
relay_opts: RelayOptions,
bootstrapped: Arc<AtomicBool>,
notifications_subscribed: Arc<AtomicBool>,
}
impl NostrWalletConnect {
#[inline]
pub fn new(uri: NostrWalletConnectUri) -> Self {
Self::builder(uri).build()
}
#[inline]
pub fn builder(uri: NostrWalletConnectUri) -> NostrWalletConnectBuilder {
NostrWalletConnectBuilder::new(uri)
}
fn from_builder(builder: NostrWalletConnectBuilder) -> Self {
let client: Client = match builder.monitor {
Some(monitor) => Client::builder().monitor(monitor).build(),
None => Client::default(),
};
Self {
uri: builder.uri,
client,
timeout: builder.timeout,
relay_opts: builder.relay,
cipher: Arc::new(AtomicCipher::from(builder.cipher)),
bootstrapped: Arc::new(AtomicBool::new(false)),
notifications_subscribed: Arc::new(AtomicBool::new(false)),
}
}
#[inline]
pub fn uri(&self) -> &NostrWalletConnectUri {
&self.uri
}
#[inline]
pub fn client(&self) -> &Client {
&self.client
}
#[deprecated(since = "0.45.0", note = "Use the client method instead")]
pub async fn status(&self) -> HashMap<RelayUrl, RelayStatus> {
let relays = self.client.relays().await;
relays.into_iter().map(|(u, r)| (u, r.status())).collect()
}
async fn get_cipher(&self) -> Nip47Ciphers {
let cipher = self.cipher.load();
match cipher {
Some(cipher) => cipher,
None => {
let cipher = self
.get_wallet_cipher()
.await
.unwrap_or(Nip47Ciphers::NIP04);
_ = self.cipher.0.compare_exchange(
0,
cipher.as_u32(),
Ordering::SeqCst,
Ordering::SeqCst,
);
cipher
}
}
}
async fn bootstrap(&self) -> Result<(), Error> {
if self.bootstrapped.load(Ordering::SeqCst) {
return Ok(());
}
for url in self.uri.relays.iter() {
self.client
.add_relay(url)
.opts(self.relay_opts.clone())
.await?;
}
self.client.connect().await;
self.bootstrapped.store(true, Ordering::SeqCst);
Ok(())
}
async fn get_wallet_cipher(&self) -> Option<Nip47Ciphers> {
let filter = Filter::new()
.kind(Kind::WalletConnectInfo)
.author(self.uri.public_key);
let info_event = self.client.fetch_events(filter).await.ok()?.first_owned()?;
info_event
.tags
.iter()
.find_map(|t| match Nip47Tag::parse(t.as_slice()).ok()? {
Nip47Tag::Encryption(et) => Some(et.latest()),
})
}
async fn send_request(&self, req: Request, timeout: Duration) -> Result<Response, Error> {
self.bootstrap().await?;
let cipher = self.get_cipher().await;
tracing::debug!(
"Sending request '{}' encrypted using '{cipher}'",
req.as_json()
);
let event: Event = req.to_event(&self.uri, cipher)?;
let filter = Filter::new()
.author(self.uri.public_key)
.kind(Kind::WalletConnectResponse)
.event(event.id);
let mut stream = self
.client
.stream_events(filter)
.timeout(timeout)
.policy(ReqExitPolicy::WaitForEvents(1))
.await?;
self.client.send_event(&event).await?;
let (_, res) = stream.next().await.ok_or_else(Error::no_response)?;
let received_event: Event = res?;
let response: Response = Response::from_event(&self.uri, &received_event, cipher)?;
Ok(response)
}
#[inline]
pub fn pay_invoice(&self, request: PayInvoiceRequest) -> PayInvoice<'_> {
PayInvoice::new(self, request)
}
#[inline]
pub fn pay_keysend(&self, request: PayKeysendRequest) -> PayKeysend<'_> {
PayKeysend::new(self, request)
}
#[inline]
pub fn make_invoice(&self, request: MakeInvoiceRequest) -> MakeInvoice<'_> {
MakeInvoice::new(self, request)
}
#[inline]
pub fn lookup_invoice(&self, request: LookupInvoiceRequest) -> LookupInvoice<'_> {
LookupInvoice::new(self, request)
}
#[inline]
pub fn list_transactions(&self, params: ListTransactionsRequest) -> ListTransactions<'_> {
ListTransactions::new(self, params)
}
#[inline]
pub fn get_balance(&self) -> GetBalance<'_> {
GetBalance::new(self)
}
#[inline]
pub fn get_info(&self) -> GetInfo<'_> {
GetInfo::new(self)
}
pub async fn subscribe_to_notifications(&self) -> Result<(), Error> {
if self.notifications_subscribed.load(Ordering::SeqCst) {
tracing::debug!("Already subscribed to notifications");
return Ok(());
}
tracing::info!("Subscribing to wallet notifications...");
self.bootstrap().await?;
let client_keys = Keys::new(self.uri.secret.clone());
let client_pubkey = client_keys.public_key();
tracing::debug!("Client pubkey: {}", client_pubkey);
tracing::debug!("Wallet service pubkey: {}", self.uri.public_key);
let notification_filter = Filter::new()
.author(self.uri.public_key)
.pubkey(client_pubkey)
.kinds([
Kind::WalletConnectNotification,
Kind::WalletConnectNotificationNip44V2,
])
.since(Timestamp::now());
tracing::debug!("Notification filter: {:?}", notification_filter);
self.client
.subscribe(notification_filter)
.with_id(SubscriptionId::new(NOTIFICATIONS_ID))
.await?;
self.notifications_subscribed.store(true, Ordering::SeqCst);
tracing::info!("Successfully subscribed to notifications");
Ok(())
}
pub async fn unsubscribe_from_notifications(&self) -> Result<(), Error> {
self.client
.unsubscribe(&SubscriptionId::new(NOTIFICATIONS_ID))
.await?;
self.notifications_subscribed.store(false, Ordering::SeqCst);
Ok(())
}
pub fn notifications(
&self,
) -> Pin<Box<dyn Stream<Item = Result<Notification, Error>> + Send + '_>> {
let notifications = self.client.notifications();
Box::pin(notifications.filter_map(move |notification| async move {
tracing::trace!("Received a client notification: {:?}", notification);
if let ClientNotification::Event {
subscription_id,
event,
..
} = notification
{
tracing::debug!(
"Received event: kind={}, author={}, id={}",
event.kind,
event.pubkey,
event.id
);
if subscription_id.as_str() != NOTIFICATIONS_ID {
tracing::trace!("Ignoring event with subscription id: {}", subscription_id);
return None;
}
if event.kind != Kind::WalletConnectNotification
|| event.kind != Kind::WalletConnectNotificationNip44V2
{
tracing::trace!("Ignoring event with kind: {}", event.kind);
return None;
}
tracing::info!("Processing wallet notification event");
match Notification::from_event(&self.uri, &event) {
Ok(nip47_notification) => {
tracing::info!(
"Successfully parsed notification: {:?}",
nip47_notification.notification_type
);
return Some(Ok(nip47_notification));
}
Err(e) => {
tracing::error!("Failed to parse notification: {}", e);
tracing::debug!("Event content: {}", event.content);
return Some(Err(Error::from(e)));
}
}
}
None
}))
}
#[deprecated(since = "0.45.0", note = "Use the client method instead")]
pub async fn reconnect_relay<'a, U>(&self, url: U) -> Result<(), Error>
where
U: Into<RelayUrlArg<'a>>,
{
if !self.bootstrapped.load(Ordering::SeqCst) {
return Ok(());
}
Ok(self.client.connect_relay(url).await?)
}
#[inline]
pub async fn shutdown(&self) {
self.client.shutdown().await
}
}
#[cfg(test)]
mod tests {
use nostr_sdk::local_relay::MockRelay;
use super::*;
const RESPONSE: Response = Response {
result_type: Method::GetBalance,
error: None,
result: Some(nip47::ResponseResult::GetBalance(GetBalanceResponse {
balance: 0xDEADBEEF,
})),
};
fn create_keys(relay_url: RelayUrl) -> (Keys, Keys, NostrWalletConnectUri) {
let wallet_keys = Keys::generate();
let client_keys = Keys::generate();
let uri = NostrWalletConnectUri::new(
wallet_keys.public_key(),
vec![relay_url],
client_keys.secret_key().clone(),
None,
);
(wallet_keys, client_keys, uri)
}
async fn run_wallet(
wkeys: Keys,
ckeys: Keys,
relay_url: RelayUrl,
advertise_ciphers: Option<Nip47Ciphers>,
expected_cipher: Nip47Ciphers,
) {
let client = Client::new();
client.add_relay(relay_url).and_connect().await.unwrap();
if let Some(ciphers) = advertise_ciphers {
let event = EventBuilder::new(Kind::WalletConnectInfo, "")
.tag(Nip47Tag::Encryption(ciphers).to_tag())
.finalize(&wkeys)
.unwrap();
client.send_event(&event).await.unwrap();
}
let request = client
.fetch_events(
Filter::new()
.kind(Kind::WalletConnectRequest)
.author(ckeys.public_key())
.pubkey(wkeys.public_key()),
)
.policy(ReqExitPolicy::WaitForEvents(1))
.await
.unwrap()
.first_owned()
.unwrap();
let cipher = request
.tags
.iter()
.find_map(|tag| match Nip47Tag::parse(tag.as_slice()).ok()? {
Nip47Tag::Encryption(ciphers) => Some(ciphers),
})
.unwrap_or(Nip47Ciphers::NIP04);
assert_eq!(
expected_cipher, cipher,
"expected: {expected_cipher}. Found {cipher}"
);
let enc_response = cipher
.encrypt(wkeys.secret_key(), &ckeys.public_key(), &RESPONSE.as_json())
.unwrap();
let response_event = EventBuilder::new(Kind::WalletConnectResponse, enc_response)
.tag(Tag::public_key(ckeys.public_key()))
.tag(Tag::event(request.id))
.finalize(&wkeys)
.unwrap();
client.send_event(&response_event).await.unwrap();
}
#[tokio::test]
async fn test_send_request_no_cipher() {
let relay = MockRelay::run().await.unwrap();
let relay_url = relay.url().await;
let (wkeys, ckeys, uri) = create_keys(relay_url.clone());
tokio::spawn(run_wallet(
wkeys,
ckeys,
relay_url,
None,
Nip47Ciphers::NIP04,
));
let client = NostrWalletConnectBuilder::new(uri).build();
let wallet_response = client
.send_request(Request::get_balance(), Duration::from_secs(3))
.await
.unwrap();
assert_eq!(RESPONSE, wallet_response);
}
#[tokio::test]
async fn test_send_request_no_cipher_but_info_single_cipher() {
let relay = MockRelay::run().await.unwrap();
let relay_url = relay.url().await;
let (wkeys, ckeys, uri) = create_keys(relay_url.clone());
tokio::spawn(run_wallet(
wkeys,
ckeys,
relay_url,
Some(Nip47Ciphers::NIP44V2),
Nip47Ciphers::NIP44V2,
));
tokio::time::sleep(Duration::from_secs(1)).await;
let client = NostrWalletConnectBuilder::new(uri).build();
let wallet_response = client
.send_request(Request::get_balance(), Duration::from_secs(3))
.await
.unwrap();
assert_eq!(RESPONSE, wallet_response);
}
#[tokio::test]
async fn test_send_request_no_cipher_but_info_single_old_cipher() {
let relay = MockRelay::run().await.unwrap();
let relay_url = relay.url().await;
let (wkeys, ckeys, uri) = create_keys(relay_url.clone());
tokio::spawn(run_wallet(
wkeys,
ckeys,
relay_url,
Some(Nip47Ciphers::NIP04),
Nip47Ciphers::NIP04,
));
tokio::time::sleep(Duration::from_secs(1)).await;
let client = NostrWalletConnectBuilder::new(uri).build();
let wallet_response = client
.send_request(Request::get_balance(), Duration::from_secs(3))
.await
.unwrap();
assert_eq!(RESPONSE, wallet_response);
}
#[tokio::test]
async fn test_send_request_no_cipher_but_info_latest_cipher() {
let relay = MockRelay::run().await.unwrap();
let relay_url = relay.url().await;
let (wkeys, ckeys, uri) = create_keys(relay_url.clone());
tokio::spawn(run_wallet(
wkeys,
ckeys,
relay_url,
Some(Nip47Ciphers::NIP04.add(Nip47Ciphers::NIP44V2)),
Nip47Ciphers::NIP44V2,
));
tokio::time::sleep(Duration::from_secs(1)).await;
let client = NostrWalletConnectBuilder::new(uri).build();
let wallet_response = client
.send_request(Request::get_balance(), Duration::from_secs(3))
.await
.unwrap();
assert_eq!(RESPONSE, wallet_response);
}
#[tokio::test]
async fn test_send_request_nip04_cipher_no_info() {
let relay = MockRelay::run().await.unwrap();
let relay_url = relay.url().await;
let (wkeys, ckeys, uri) = create_keys(relay_url.clone());
tokio::spawn(run_wallet(
wkeys,
ckeys,
relay_url,
None,
Nip47Ciphers::NIP04,
));
tokio::time::sleep(Duration::from_secs(1)).await;
let client = NostrWalletConnectBuilder::new(uri).force_nip04().build();
let wallet_response = client
.send_request(Request::get_balance(), Duration::from_secs(3))
.await
.unwrap();
assert_eq!(RESPONSE, wallet_response);
}
#[tokio::test]
async fn test_send_request_nip44_cipher_no_info() {
let relay = MockRelay::run().await.unwrap();
let relay_url = relay.url().await;
let (wkeys, ckeys, uri) = create_keys(relay_url.clone());
tokio::spawn(run_wallet(
wkeys,
ckeys,
relay_url,
None,
Nip47Ciphers::NIP44V2,
));
tokio::time::sleep(Duration::from_secs(1)).await;
let client = NostrWalletConnectBuilder::new(uri).force_nip44_v2().build();
let wallet_response = client
.send_request(Request::get_balance(), Duration::from_secs(3))
.await
.unwrap();
assert_eq!(RESPONSE, wallet_response);
}
#[tokio::test]
async fn test_send_request_nip04_cipher_with_info() {
let relay = MockRelay::run().await.unwrap();
let relay_url = relay.url().await;
let (wkeys, ckeys, uri) = create_keys(relay_url.clone());
tokio::spawn(run_wallet(
wkeys,
ckeys,
relay_url,
Some(Nip47Ciphers::NIP04.add(Nip47Ciphers::NIP44V2)),
Nip47Ciphers::NIP04,
));
tokio::time::sleep(Duration::from_secs(1)).await;
let client = NostrWalletConnectBuilder::new(uri).force_nip04().build();
let wallet_response = client
.send_request(Request::get_balance(), Duration::from_secs(3))
.await
.unwrap();
assert_eq!(RESPONSE, wallet_response);
}
}