diff --git a/Cargo.lock b/Cargo.lock index cd1eee687..5168b942d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1158,6 +1158,18 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4cd1a83af159aa67994778be9070f0ae1bd732942279cabb14f86f986a21456" +[[package]] +name = "lnurl-pay" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ddf38a88792ba1e1307b97f059941442c94341e9be479080386b0410050c235" +dependencies = [ + "bech32", + "reqwest", + "serde", + "serde_json", +] + [[package]] name = "lock_api" version = "0.4.11" @@ -1435,6 +1447,7 @@ version = "0.27.0" dependencies = [ "async-utility", "async-wsocket", + "lnurl-pay", "nostr", "nostr-database", "nostr-indexeddb", @@ -1444,6 +1457,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "webln", ] [[package]] @@ -1469,6 +1483,7 @@ dependencies = [ "tracing-subscriber", "wasm-bindgen", "wasm-bindgen-futures", + "webln-js", ] [[package]] @@ -2785,6 +2800,31 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webln" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23f76df183e94dfe9e5c875c00f54539f02e1ba31c71668b9c8d83277fac599" +dependencies = [ + "js-sys", + "secp256k1", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "webln-js" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0017c99ae6460e29dd6a55166a3fa024340235c27c66743d7707934f0c5217" +dependencies = [ + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "webln", +] + [[package]] name = "webpki-roots" version = "0.25.3" diff --git a/bindings/nostr-ffi/src/nips/nip47.rs b/bindings/nostr-ffi/src/nips/nip47.rs index e1198095f..624d4bb75 100644 --- a/bindings/nostr-ffi/src/nips/nip47.rs +++ b/bindings/nostr-ffi/src/nips/nip47.rs @@ -830,7 +830,7 @@ impl NostrWalletConnectURI { Url::parse(&relay_url)?, **random_secret_key, lud16, - )? + ) .into()) } diff --git a/bindings/nostr-js/Cargo.toml b/bindings/nostr-js/Cargo.toml index 5daa185e9..274310429 100644 --- a/bindings/nostr-js/Cargo.toml +++ b/bindings/nostr-js/Cargo.toml @@ -12,7 +12,7 @@ crate-type = ["lib", "cdylib"] [dependencies] console_error_panic_hook = "0.1" js-sys.workspace = true -nostr = { workspace = true, features = ["std", "nip04", "nip05", "nip06", "nip07", "nip11", "nip44", "nip46"] } +nostr = { workspace = true, features = ["std", "all-nips"] } wasm-bindgen = { workspace = true, features = ["std"] } wasm-bindgen-futures.workspace = true diff --git a/bindings/nostr-js/src/nips/mod.rs b/bindings/nostr-js/src/nips/mod.rs index 9c91e672d..1474e54e6 100644 --- a/bindings/nostr-js/src/nips/mod.rs +++ b/bindings/nostr-js/src/nips/mod.rs @@ -10,3 +10,5 @@ pub mod nip19; pub mod nip26; pub mod nip44; pub mod nip46; +pub mod nip47; +pub mod nip57; diff --git a/bindings/nostr-js/src/nips/nip47.rs b/bindings/nostr-js/src/nips/nip47.rs new file mode 100644 index 000000000..514b313e9 --- /dev/null +++ b/bindings/nostr-js/src/nips/nip47.rs @@ -0,0 +1,76 @@ +// Copyright (c) 2022-2023 Yuki Kishimoto +// Copyright (c) 2023-2024 Rust Nostr Developers +// Distributed under the MIT software license + +use core::ops::Deref; +use core::str::FromStr; + +use nostr::nips::nip47::NostrWalletConnectURI; +use nostr::Url; +use wasm_bindgen::prelude::*; + +use crate::error::{into_err, Result}; +use crate::key::{JsPublicKey, JsSecretKey}; + +#[wasm_bindgen(js_name = NostrWalletConnectURI)] +pub struct JsNostrWalletConnectURI { + inner: NostrWalletConnectURI, +} + +impl Deref for JsNostrWalletConnectURI { + type Target = NostrWalletConnectURI; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +#[wasm_bindgen(js_class = NostrWalletConnectURI)] +impl JsNostrWalletConnectURI { + /// Create new Nostr Wallet Connect URI + pub fn new( + public_key: &JsPublicKey, + relay_url: &str, + random_secret_key: &JsSecretKey, + lud16: Option, + ) -> Result { + let relay_url = Url::parse(relay_url).map_err(into_err)?; + Ok(Self { + inner: NostrWalletConnectURI::new(**public_key, relay_url, **random_secret_key, lud16), + }) + } + + /// Parse + pub fn parse(uri: &str) -> Result { + Ok(Self { + inner: NostrWalletConnectURI::from_str(uri).map_err(into_err)?, + }) + } + + /// App Pubkey + #[wasm_bindgen(js_name = publicKey)] + pub fn public_key(&self) -> JsPublicKey { + self.inner.public_key.into() + } + + /// URL of the relay of choice where the `App` is connected and the `Signer` must send and listen for messages. + #[wasm_bindgen(js_name = relayUrl)] + pub fn relay_url(&self) -> String { + self.inner.relay_url.to_string() + } + + /// 32-byte randomly generated hex encoded string + pub fn secret(&self) -> JsSecretKey { + self.inner.secret.into() + } + + /// A lightning address that clients can use to automatically setup the lud16 field on the user's profile if they have none configured. + pub fn lud16(&self) -> Option { + self.inner.lud16.clone() + } + + #[wasm_bindgen(js_name = asString)] + pub fn as_string(&self) -> String { + self.inner.to_string() + } +} diff --git a/bindings/nostr-js/src/nips/nip57.rs b/bindings/nostr-js/src/nips/nip57.rs new file mode 100644 index 000000000..614b8feff --- /dev/null +++ b/bindings/nostr-js/src/nips/nip57.rs @@ -0,0 +1,36 @@ +// Copyright (c) 2022-2023 Yuki Kishimoto +// Copyright (c) 2023-2024 Rust Nostr Developers +// Distributed under the MIT software license + +use nostr::nips::nip57::ZapType; +use wasm_bindgen::prelude::*; + +#[wasm_bindgen(js_name = ZapType)] +pub enum JsZapType { + /// Public + Public, + /// Private + Private, + /// Anonymous + Anonymous, +} + +impl From for ZapType { + fn from(value: JsZapType) -> Self { + match value { + JsZapType::Public => Self::Public, + JsZapType::Private => Self::Private, + JsZapType::Anonymous => Self::Anonymous, + } + } +} + +impl From for JsZapType { + fn from(value: ZapType) -> Self { + match value { + ZapType::Public => Self::Public, + ZapType::Private => Self::Private, + ZapType::Anonymous => Self::Anonymous, + } + } +} diff --git a/bindings/nostr-sdk-js/Cargo.toml b/bindings/nostr-sdk-js/Cargo.toml index f7590d56d..c9029f694 100644 --- a/bindings/nostr-sdk-js/Cargo.toml +++ b/bindings/nostr-sdk-js/Cargo.toml @@ -12,11 +12,12 @@ crate-type = ["lib", "cdylib"] async-utility.workspace = true js-sys.workspace = true nostr-js = { path = "../nostr-js" } -nostr-sdk = { path = "../../crates/nostr-sdk", default-features = false, features = ["all-nips", "indexeddb"] } +nostr-sdk = { path = "../../crates/nostr-sdk", default-features = false, features = ["all-nips", "indexeddb", "webln"] } tracing.workspace = true tracing-subscriber.workspace = true wasm-bindgen.workspace = true wasm-bindgen-futures.workspace = true +webln-js = "0.1" [package.metadata.wasm-pack.profile.profiling] wasm-opt = true diff --git a/bindings/nostr-sdk-js/src/client/builder.rs b/bindings/nostr-sdk-js/src/client/builder.rs index d398fa66a..d6cf8d12e 100644 --- a/bindings/nostr-sdk-js/src/client/builder.rs +++ b/bindings/nostr-sdk-js/src/client/builder.rs @@ -10,6 +10,7 @@ use nostr_sdk::{Client, ClientBuilder}; use wasm_bindgen::prelude::*; use super::options::JsOptions; +use super::zapper::JsClientZapper; use super::{JsClient, JsClientSigner}; use crate::database::JsNostrDatabase; @@ -38,6 +39,10 @@ impl JsClientBuilder { self.inner.signer(signer.deref().clone()).into() } + pub fn zapper(self, zapper: &JsClientZapper) -> Self { + self.inner.zapper(zapper.deref().clone()).into() + } + pub fn database(self, database: &JsNostrDatabase) -> Self { let database: Arc = database.into(); self.inner.database(database).into() diff --git a/bindings/nostr-sdk-js/src/client/mod.rs b/bindings/nostr-sdk-js/src/client/mod.rs index bd120ea59..2b1854489 100644 --- a/bindings/nostr-sdk-js/src/client/mod.rs +++ b/bindings/nostr-sdk-js/src/client/mod.rs @@ -2,8 +2,6 @@ // Copyright (c) 2023-2024 Rust Nostr Developers // Distributed under the MIT software license -#![allow(non_snake_case)] - use std::ops::Deref; use std::time::Duration; @@ -20,9 +18,11 @@ use wasm_bindgen::prelude::*; pub mod builder; pub mod options; pub mod signer; +pub mod zapper; use self::options::JsOptions; pub use self::signer::JsClientSigner; +use self::zapper::{JsZapDetails, JsZapEntity}; use crate::abortable::JsAbortHandle; use crate::database::JsNostrDatabase; use crate::relay::{JsRelay, JsRelayArray}; @@ -512,6 +512,19 @@ impl JsClient { .map(|id| id.into()) } + /// Send a Zap! + pub async fn zap( + &self, + to: &JsZapEntity, + satoshi: f64, + details: Option, + ) -> Result<()> { + self.inner + .zap(**to, satoshi as u64, details.map(|d| d.into())) + .await + .map_err(into_err) + } + /// Negentropy reconciliation /// /// diff --git a/bindings/nostr-sdk-js/src/client/zapper.rs b/bindings/nostr-sdk-js/src/client/zapper.rs new file mode 100644 index 000000000..be57c4958 --- /dev/null +++ b/bindings/nostr-sdk-js/src/client/zapper.rs @@ -0,0 +1,104 @@ +// Copyright (c) 2022-2023 Yuki Kishimoto +// Copyright (c) 2023-2024 Rust Nostr Developers +// Distributed under the MIT software license + +use core::ops::Deref; + +use nostr_js::event::JsEventId; +use nostr_js::key::JsPublicKey; +use nostr_js::nips::nip47::JsNostrWalletConnectURI; +use nostr_js::nips::nip57::JsZapType; +use nostr_sdk::client::{ClientZapper, ZapDetails, ZapEntity}; +use wasm_bindgen::prelude::*; +use webln_js::JsWebLN; + +/// Zap entity +#[wasm_bindgen(js_name = ZapEntity)] +pub struct JsZapEntity { + inner: ZapEntity, +} + +impl Deref for JsZapEntity { + type Target = ZapEntity; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +#[wasm_bindgen(js_class = ZapEntity)] +impl JsZapEntity { + pub fn event(event_id: &JsEventId) -> Self { + Self { + inner: ZapEntity::Event(**event_id), + } + } + + #[wasm_bindgen(js_name = publicKey)] + pub fn public_key(public_key: &JsPublicKey) -> Self { + Self { + inner: ZapEntity::PublicKey(**public_key), + } + } +} + +/// Client Zapper +#[wasm_bindgen(js_name = ClientZapper)] +pub struct JsClientZapper { + inner: ClientZapper, +} + +impl Deref for JsClientZapper { + type Target = ClientZapper; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +#[wasm_bindgen(js_class = ClientZapper)] +impl JsClientZapper { + pub fn webln(instance: &JsWebLN) -> Self { + Self { + inner: ClientZapper::WebLN(instance.deref().clone()), + } + } + + pub fn nwc(uri: &JsNostrWalletConnectURI) -> Self { + Self { + inner: ClientZapper::NWC(uri.deref().clone()), + } + } +} + +/// Zap Details +#[wasm_bindgen(js_name = ZapDetails)] +pub struct JsZapDetails { + inner: ZapDetails, +} + +impl From for ZapDetails { + fn from(value: JsZapDetails) -> Self { + value.inner + } +} + +#[wasm_bindgen(js_class = ZapDetails)] +impl JsZapDetails { + /// Create new Zap Details + /// + /// **Note: `private` zaps are not currently supported here!** + #[wasm_bindgen(constructor)] + pub fn new(zap_type: JsZapType) -> Self { + Self { + inner: ZapDetails::new(zap_type.into()), + } + } + + /// Add message + pub fn message(self, message: String) -> Self { + Self { + inner: self.inner.message(message), + } + } +} diff --git a/contrib/scripts/check-crates.sh b/contrib/scripts/check-crates.sh index 5df654e7f..6aa4a42d5 100644 --- a/contrib/scripts/check-crates.sh +++ b/contrib/scripts/check-crates.sh @@ -32,6 +32,7 @@ buildargs=( "-p nostr-sdk" "-p nostr-sdk --no-default-features" "-p nostr-sdk --features blocking" + "-p nostr-sdk --features webln --target wasm32-unknown-unknown" "-p nostr-sdk --features indexeddb --target wasm32-unknown-unknown" "-p nostr-sdk --features sqlite" ) diff --git a/crates/nostr-sdk/Cargo.toml b/crates/nostr-sdk/Cargo.toml index 8663c4511..b4b8f1920 100644 --- a/crates/nostr-sdk/Cargo.toml +++ b/crates/nostr-sdk/Cargo.toml @@ -17,7 +17,7 @@ rustdoc-args = ["--cfg", "docsrs"] [features] default = ["all-nips"] -blocking = ["async-utility/blocking", "nostr/blocking"] +blocking = ["dep:once_cell", "async-utility/blocking", "nostr/blocking"] sqlite = ["dep:nostr-sqlite"] indexeddb = ["dep:nostr-indexeddb"] all-nips = ["nip04", "nip05", "nip06", "nip07", "nip11", "nip44", "nip46", "nip47", "nip57"] @@ -30,14 +30,15 @@ nip11 = ["nostr/nip11"] nip44 = ["nostr/nip44"] nip46 = ["nostr/nip46"] nip47 = ["nostr/nip47"] -nip57 = ["nostr/nip57"] +nip57 = ["nostr/nip57", "dep:lnurl-pay"] [dependencies] async-utility.workspace = true async-wsocket = "0.1" +lnurl-pay = { version = "0.2", features = ["api"], optional = true } nostr = { workspace = true, features = ["std"] } nostr-database.workspace = true -once_cell.workspace = true +once_cell = { workspace = true, optional = true } thiserror.workspace = true tracing = { workspace = true, features = ["std", "attributes"] } @@ -48,6 +49,7 @@ tokio = { workspace = true, features = ["rt-multi-thread", "time", "macros", "sy [target.'cfg(target_arch = "wasm32")'.dependencies] nostr-indexeddb = { version = "0.27", path = "../nostr-indexeddb", optional = true } tokio = { workspace = true, features = ["rt", "macros", "sync"] } +webln = { version = "0.1", optional = true } [dev-dependencies] tracing-subscriber = { workspace = true, features = ["env-filter"] } @@ -99,4 +101,8 @@ name = "nip47" required-features = ["nip47"] [[example]] -name = "nip65" \ No newline at end of file +name = "nip65" + +[[example]] +name = "zapper" +required-features = ["nip47", "nip57"] \ No newline at end of file diff --git a/crates/nostr-sdk/examples/nip47.rs b/crates/nostr-sdk/examples/nip47.rs index fa2449a5c..e9785a4c3 100644 --- a/crates/nostr-sdk/examples/nip47.rs +++ b/crates/nostr-sdk/examples/nip47.rs @@ -1,9 +1,14 @@ +// Copyright (c) 2023-2024 Rust Nostr Developers +// Distributed under the MIT software license + use std::str::FromStr; use nostr_sdk::prelude::*; #[tokio::main] async fn main() -> Result<()> { + tracing_subscriber::fmt::init(); + let mut nwc_uri_string = String::new(); let mut invoice = String::new(); @@ -22,9 +27,7 @@ async fn main() -> Result<()> { let nwc_uri = NostrWalletConnectURI::from_str(&nwc_uri_string).expect("Failed to parse NWC URI"); - let my_keys = Keys::new(nwc_uri.secret); - - let client = Client::new(&my_keys); + let client = Client::default(); client.add_relay(nwc_uri.relay_url.clone()).await?; client.connect().await; @@ -34,13 +37,7 @@ async fn main() -> Result<()> { method: Method::PayInvoice, params: RequestParams::PayInvoice(PayInvoiceRequestParams { invoice }), }; - - let encrypted = nip04::encrypt(&nwc_uri.secret, &nwc_uri.public_key, req.as_json()).unwrap(); - let p_tag = Tag::public_key(nwc_uri.public_key); - - let req_event = EventBuilder::new(Kind::WalletConnectRequest, encrypted, [p_tag]) - .to_event(&Keys::new(nwc_uri.secret)) - .unwrap(); + let req_event = req.to_event(&nwc_uri).unwrap(); let subscription = Filter::new() .author(nwc_uri.public_key) diff --git a/crates/nostr-sdk/examples/zapper.rs b/crates/nostr-sdk/examples/zapper.rs new file mode 100644 index 000000000..da0cd43c6 --- /dev/null +++ b/crates/nostr-sdk/examples/zapper.rs @@ -0,0 +1,52 @@ +// Copyright (c) 2022-2023 Yuki Kishimoto +// Copyright (c) 2023-2024 Rust Nostr Developers +// Distributed under the MIT software license + +use std::str::FromStr; + +use nostr_sdk::prelude::*; + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt::init(); + + let mut nwc_uri_string = String::new(); + + println!("Please enter a NWC string"); + std::io::stdin() + .read_line(&mut nwc_uri_string) + .expect("Failed to read line"); + + // Parse NWC URI + let nwc_uri = + NostrWalletConnectURI::from_str(&nwc_uri_string).expect("Failed to parse NWC URI"); + + // Compose client + let secret_key = + SecretKey::from_bech32("nsec1ufnus6pju578ste3v90xd5m2decpuzpql2295m3sknqcjzyys9ls0qlc85")?; + let keys = Keys::new(secret_key); + let client = ClientBuilder::new().signer(keys).zapper(nwc_uri).build(); + + client.add_relay("wss://relay.nostr.band").await?; + client.add_relay("wss://relay.damus.io").await?; + client.connect().await; + + let public_key = XOnlyPublicKey::from_bech32( + "npub1drvpzev3syqt0kjrls50050uzf25gehpz9vgdw08hvex7e0vgfeq0eseet", + ) + .unwrap(); + + // Send sats without zap event + client.zap(public_key, 1000, None).await?; + + // Zap profile + let details = ZapDetails::new(ZapType::Public).message("Test"); + client.zap(public_key, 1000, Some(details)).await?; + + // Zap event + let event_id = Nip19Event::from_bech32("nevent1qqsr0q447ylm3y3tvw07vt69w3kzk026vl6yn3dwm9fweay0dw0jttgpz3mhxue69uhhyetvv9ujumn0wd68ytnzvupzq6xcz9jerqgqkldy8lpg7lglcyj4g3nwzy2cs6u70wejdaj7csnjqvzqqqqqqygequ53").unwrap(); + let details = ZapDetails::new(ZapType::Anonymous).message("Anonymous Zap!"); + client.zap(event_id, 1000, Some(details)).await?; + + Ok(()) +} diff --git a/crates/nostr-sdk/src/client/builder.rs b/crates/nostr-sdk/src/client/builder.rs index 71c25dc0e..dea846732 100644 --- a/crates/nostr-sdk/src/client/builder.rs +++ b/crates/nostr-sdk/src/client/builder.rs @@ -10,12 +10,16 @@ use nostr_database::memory::MemoryDatabase; use nostr_database::{DynNostrDatabase, IntoNostrDatabase}; use super::signer::ClientSigner; +#[cfg(feature = "nip57")] +use super::zapper::ClientZapper; use crate::{Client, Options}; /// Client builder #[derive(Debug, Clone)] pub struct ClientBuilder { pub(super) signer: Option, + #[cfg(feature = "nip57")] + pub(super) zapper: Option, pub(super) database: Arc, pub(super) opts: Options, } @@ -24,6 +28,8 @@ impl Default for ClientBuilder { fn default() -> Self { Self { signer: None, + #[cfg(feature = "nip57")] + zapper: None, database: Arc::new(MemoryDatabase::default()), opts: Options::default(), } @@ -56,6 +62,17 @@ impl ClientBuilder { self } + /// Set zapper + #[cfg(feature = "nip57")] + #[allow(unused_mut, unreachable_code)] + pub fn zapper(mut self, zapper: S) -> Self + where + S: Into, + { + self.zapper = Some(zapper.into()); + self + } + /// Set database pub fn database(mut self, database: D) -> Self where diff --git a/crates/nostr-sdk/src/client/mod.rs b/crates/nostr-sdk/src/client/mod.rs index 68947bb54..a8508ee92 100644 --- a/crates/nostr-sdk/src/client/mod.rs +++ b/crates/nostr-sdk/src/client/mod.rs @@ -31,12 +31,16 @@ pub mod blocking; pub mod builder; pub mod options; pub mod signer; +#[cfg(feature = "nip57")] +pub mod zapper; pub use self::builder::ClientBuilder; pub use self::options::Options; #[cfg(feature = "nip46")] pub use self::signer::nip46::Nip46Signer; pub use self::signer::{ClientSigner, ClientSignerType}; +#[cfg(feature = "nip57")] +pub use self::zapper::{ClientZapper, ZapDetails, ZapEntity}; use crate::relay::pool::{self, Error as RelayPoolError, RelayPool}; use crate::relay::{ FilterOptions, NegentropyOptions, Relay, RelayOptions, RelayPoolNotification, RelaySendOptions, @@ -84,6 +88,10 @@ pub enum Error { /// Found client signer type found: ClientSignerType, }, + /// Zapper not configured + #[cfg(feature = "nip57")] + #[error("zapper not configured")] + ZapperNotConfigured, /// NIP04 error #[cfg(feature = "nip04")] #[error(transparent)] @@ -92,14 +100,30 @@ pub enum Error { #[cfg(all(feature = "nip07", target_arch = "wasm32"))] #[error(transparent)] NIP07(#[from] nostr::nips::nip07::Error), + /// JSON error + #[cfg(feature = "nip46")] + #[error(transparent)] + JSON(#[from] nostr::serde_json::Error), /// NIP46 error #[cfg(feature = "nip46")] #[error(transparent)] NIP46(#[from] nostr::nips::nip46::Error), - /// JSON error - #[cfg(feature = "nip46")] + /// NIP47 error + #[cfg(feature = "nip47")] #[error(transparent)] - JSON(#[from] nostr::serde_json::Error), + NIP47(#[from] nostr::nips::nip47::Error), + /// NIP57 error + #[cfg(feature = "nip57")] + #[error(transparent)] + NIP57(#[from] nostr::nips::nip57::Error), + /// LNURL Pay + #[cfg(feature = "nip57")] + #[error(transparent)] + LnUrlPay(#[from] lnurl_pay::Error), + /// WebLN error + #[cfg(all(feature = "webln", target_arch = "wasm32"))] + #[error(transparent)] + WebLN(#[from] webln::Error), /// Generic NIP46 error #[cfg(feature = "nip46")] #[error("generic error")] @@ -117,9 +141,17 @@ pub enum Error { #[error("timeout")] Timeout, /// Response not match to the request - #[cfg(feature = "nip46")] #[error("response not match to the request")] ResponseNotMatchRequest, + /// Event not found + #[error("event not found: {0}")] + EventNotFound(EventId), + /// Event not found + #[error("event not found")] + GenericEventNotFound, + /// Impossible to zap + #[error("impossible to send zap: {0}")] + ImpossibleToZap(String), } /// Nostr client @@ -127,6 +159,8 @@ pub enum Error { pub struct Client { pool: RelayPool, signer: Arc>>, + #[cfg(feature = "nip57")] + zapper: Arc>>, opts: Options, dropped: Arc, } @@ -202,6 +236,8 @@ impl Client { Self { pool: RelayPool::with_database(builder.opts.pool, builder.database), signer: Arc::new(RwLock::new(builder.signer)), + #[cfg(feature = "nip57")] + zapper: Arc::new(RwLock::new(builder.zapper)), opts: builder.opts, dropped: Arc::new(AtomicBool::new(false)), } @@ -226,6 +262,22 @@ impl Client { *s = signer; } + /// Get current client zapper + /// + /// Rise error if it not set. + #[cfg(feature = "nip57")] + pub async fn zapper(&self) -> Result { + let zapper = self.zapper.read().await; + zapper.clone().ok_or(Error::ZapperNotConfigured) + } + + /// Set client zapper + #[cfg(feature = "nip57")] + pub async fn set_zapper(&self, zapper: Option) { + let mut s = self.zapper.write().await; + *s = zapper; + } + /// Get current [`Keys`] #[deprecated(since = "0.27.0", note = "Use `client.signer().await` instead.")] pub async fn keys(&self) -> Keys { @@ -757,6 +809,21 @@ impl Client { self.send_event_to(url, event).await } + /// Get public key metadata + /// + /// + pub async fn metadata(&self, public_key: XOnlyPublicKey) -> Result { + let filter: Filter = Filter::new() + .author(public_key) + .kind(Kind::Metadata) + .limit(1); + let events: Vec = self.get_events_of(vec![filter], None).await?; + match events.first() { + Some(event) => Ok(Metadata::from_json(event.content())?), + None => Ok(Metadata::default()), + } + } + /// Update metadata /// /// diff --git a/crates/nostr-sdk/src/client/options.rs b/crates/nostr-sdk/src/client/options.rs index 1a12c397c..fb7b85f31 100644 --- a/crates/nostr-sdk/src/client/options.rs +++ b/crates/nostr-sdk/src/client/options.rs @@ -6,13 +6,17 @@ #[cfg(not(target_arch = "wasm32"))] use std::net::SocketAddr; -use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicU8, Ordering}; use std::sync::Arc; use std::time::Duration; use crate::relay::RelayPoolOptions; pub(crate) const DEFAULT_SEND_TIMEOUT: Duration = Duration::from_secs(20); +/// Default Support Rust Nostr LUD16 +pub const SUPPORT_RUST_NOSTR_LUD16: &str = "yuki@getalby.com"; // TODO: use a rust-nostr dedicated LUD16 +/// Default Support Rust Nostr basis points +pub const DEFAULT_SUPPORT_RUST_NOSTR_BSP: u64 = 500; // 5% /// Options #[derive(Debug, Clone)] @@ -49,6 +53,10 @@ pub struct Options { pub shutdown_on_drop: bool, /// Pool Options pub pool: RelayPoolOptions, + /// Support Rust Nostr in basis points (default: 5%) + /// + /// 100 bps = 1% + support_rust_nostr_bps: Arc, } impl Default for Options { @@ -68,6 +76,7 @@ impl Default for Options { proxy: None, shutdown_on_drop: false, pool: RelayPoolOptions::default(), + support_rust_nostr_bps: Arc::new(AtomicU64::new(DEFAULT_SUPPORT_RUST_NOSTR_BSP)), } } } @@ -201,4 +210,31 @@ impl Options { pub fn pool(self, opts: RelayPoolOptions) -> Self { Self { pool: opts, ..self } } + + /// Support Rust Nostr with a % of zaps (default: 5%) + /// + /// 100 bps = 1% + pub fn support_rust_nostr(mut self, bps: u64) -> Self { + self.support_rust_nostr_bps = Arc::new(AtomicU64::new(bps)); + self + } + + /// Update Support Rust Nostr basis points + /// + /// 100 bps = 1% + pub fn update_support_rust_nostr(&self, bps: u64) { + let _ = + self.support_rust_nostr_bps + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |_| Some(bps)); + } + + /// Get Support Rust Nostr percentage + pub fn get_support_rust_nostr_percentage(&self) -> Option { + let bps: u64 = self.support_rust_nostr_bps.load(Ordering::SeqCst); + if bps != 0 { + Some(bps as f64 / 10_000.0) + } else { + None + } + } } diff --git a/crates/nostr-sdk/src/client/zapper.rs b/crates/nostr-sdk/src/client/zapper.rs new file mode 100644 index 000000000..118344f7d --- /dev/null +++ b/crates/nostr-sdk/src/client/zapper.rs @@ -0,0 +1,290 @@ +// Copyright (c) 2022-2023 Yuki Kishimoto +// Copyright (c) 2023-2024 Rust Nostr Developers +// Distributed under the MIT software license + +//! Client Zapper + +use std::str::FromStr; +#[cfg(feature = "nip47")] +use std::time::Duration; + +use lnurl_pay::api::Lud06OrLud16; +use lnurl_pay::{LightningAddress, LnUrl}; +#[cfg(feature = "nip47")] +use nostr::nips::nip04; +use nostr::nips::nip19::Nip19Event; +#[cfg(feature = "nip47")] +use nostr::nips::nip47::{ + Method, NostrWalletConnectURI, PayInvoiceRequestParams, Request, RequestParams, Response, + ResponseResult, +}; +use nostr::nips::nip57::{self, ZapRequestData, ZapType}; +use nostr::secp256k1::XOnlyPublicKey; +#[cfg(feature = "nip47")] +use nostr::Kind; +use nostr::{Event, EventBuilder, EventId, Filter, JsonUtil, Metadata, UncheckedUrl}; +#[cfg(all(feature = "webln", target_arch = "wasm32"))] +use webln::WebLN; + +use super::options::SUPPORT_RUST_NOSTR_LUD16; +use super::{Client, Error}; +use crate::FilterOptions; + +/// Zap entity +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ZapEntity { + /// Zap to event + Event(EventId), + /// Zap to public key + PublicKey(XOnlyPublicKey), +} + +impl From for ZapEntity { + fn from(value: EventId) -> Self { + Self::Event(value) + } +} + +impl From for ZapEntity { + fn from(value: Nip19Event) -> Self { + Self::Event(value.event_id) + } +} + +impl From for ZapEntity { + fn from(value: XOnlyPublicKey) -> Self { + Self::PublicKey(value) + } +} + +impl ZapEntity { + fn event_id(&self) -> Option { + match self { + Self::Event(id) => Some(*id), + _ => None, + } + } +} + +/// Client Zapper +#[derive(Debug, Clone)] +pub enum ClientZapper { + /// WebLN + #[cfg(all(feature = "webln", target_arch = "wasm32"))] + WebLN(WebLN), + /// NWC + #[cfg(feature = "nip47")] + NWC(NostrWalletConnectURI), +} + +#[cfg(all(feature = "webln", target_arch = "wasm32"))] +impl From for ClientZapper { + fn from(value: WebLN) -> Self { + Self::WebLN(value) + } +} + +#[cfg(feature = "nip47")] +impl From for ClientZapper { + fn from(value: NostrWalletConnectURI) -> Self { + Self::NWC(value) + } +} + +/// Zap Details +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ZapDetails { + r#type: ZapType, + message: String, +} + +impl ZapDetails { + /// Create new Zap Details + /// + /// **Note: `private` zaps are not currently supported here!** + pub fn new(zap_type: ZapType) -> Self { + Self { + r#type: zap_type, + message: String::new(), + } + } + + /// Add message + pub fn message(mut self, message: S) -> Self + where + S: Into, + { + self.message = message.into(); + self + } +} + +impl Client { + /// Send a Zap! + pub async fn zap( + &self, + to: T, + satoshi: u64, + details: Option, + ) -> Result<(), Error> + where + T: Into, + { + // Steps + // 1. Check if zapper is set and availabe + // 2. Get metadata of pubkey/author of event + // 3. Get invoice + // 4. Send payment + + // Check zapper + let zapper: ClientZapper = self.zapper().await?; + + // Get entity metadata + let to: ZapEntity = to.into(); + let (public_key, metadata): (XOnlyPublicKey, Metadata) = match to { + ZapEntity::Event(event_id) => { + // Get event + let filter: Filter = Filter::new().id(event_id); + let events: Vec = self.get_events_of(vec![filter], None).await?; + let event: &Event = events.first().ok_or(Error::EventNotFound(event_id))?; + let public_key: XOnlyPublicKey = event.author(); + let metadata: Metadata = self.metadata(public_key).await?; + (public_key, metadata) + } + ZapEntity::PublicKey(public_key) => { + let metadata: Metadata = self.metadata(public_key).await?; + (public_key, metadata) + } + }; + + // Parse lud + let lud: Lud06OrLud16 = if let Some(lud16) = &metadata.lud16 { + LightningAddress::parse(lud16)?.into() + } else if let Some(lud06) = &metadata.lud06 { + LnUrl::from_str(lud06)?.into() + } else { + return Err(Error::ImpossibleToZap(String::from("LUD06/LUD16 not set"))); + }; + + // Compose zap request + let zap_request: Option = match details { + Some(details) => { + let mut data = ZapRequestData::new( + public_key, + [UncheckedUrl::from("wss://nostr.mutinywallet.com")], + ) + .amount(satoshi * 1000) + .message(details.message); + data.event_id = to.event_id(); + match details.r#type { + ZapType::Public => { + let builder = EventBuilder::public_zap_request(data); + Some(self.internal_sign_event_builder(builder).await?.as_json()) + } + ZapType::Private => None, + ZapType::Anonymous => Some(nip57::anonymous_zap_request(data)?.as_json()), + } + } + None => None, + }; + + let mut _invoices: Vec = Vec::with_capacity(2); + + let msats: u64 = match self.opts.get_support_rust_nostr_percentage() { + Some(percentage) => { + let rust_nostr_msats = (satoshi as f64 * percentage * 1000.0) as u64; + let rust_nostr_lud = LightningAddress::parse(SUPPORT_RUST_NOSTR_LUD16)?; + match lnurl_pay::api::get_invoice(rust_nostr_lud, rust_nostr_msats, None, None) + .await + { + Ok(invoice) => _invoices.push(invoice), + Err(e) => tracing::error!("Impossible to get invoice: {e}"), + }; + satoshi * 1000 - rust_nostr_msats + } + None => satoshi * 1000, + }; + + // Get invoice + let invoice: String = lnurl_pay::api::get_invoice(lud, msats, zap_request, None).await?; + _invoices.push(invoice); + + match zapper { + #[cfg(all(feature = "webln", target_arch = "wasm32"))] + ClientZapper::WebLN(webln) => { + webln.enable().await?; + for invoice in _invoices.into_iter() { + webln.send_payment(invoice).await?; + } + Ok(()) + } + #[cfg(feature = "nip47")] + ClientZapper::NWC(uri) => { + // Add relay and connect if not exists + if self.add_relay(uri.relay_url.clone()).await? { + self.connect_relay(uri.relay_url.clone()).await?; + } + + for invoice in _invoices.into_iter() { + // Compose NWC request event + let req = Request { + method: Method::PayInvoice, + params: RequestParams::PayInvoice(PayInvoiceRequestParams { invoice }), + }; + let event = req.to_event(&uri)?; + let event_id = event.id; + + // Send request + self.send_event_to(uri.relay_url.clone(), event).await?; + + // Get response + let relay = self.relay(uri.relay_url.clone()).await?; + let filter = Filter::new() + .author(uri.public_key) + .kind(Kind::WalletConnectResponse) + .event(event_id) + .limit(1); + match relay + .get_events_of( + vec![filter], + Duration::from_secs(10), + FilterOptions::ExitOnEOSE, + ) + .await + { + Ok(events) => match events.first() { + Some(event) => { + let decrypt_res = + nip04::decrypt(&uri.secret, &uri.public_key, &event.content)?; + let nip47_res = Response::from_json(decrypt_res)?; + if let Some(ResponseResult::PayInvoice(pay_invoice_result)) = + nip47_res.result + { + tracing::info!( + "Zap sent! Preimage: {}", + pay_invoice_result.preimage + ); + } else { + tracing::warn!( + "Unexpected NIP47 result: {}", + nip47_res.as_json() + ); + } + } + None => { + tracing::warn!( + "Zap [apparently] sent (`PayInvoice` response not received)." + ); + } + }, + Err(e) => { + tracing::error!("Impossible to get NWC response event: {e}"); + } + } + } + + Ok(()) + } + } + } +} diff --git a/crates/nostr-sdk/src/lib.rs b/crates/nostr-sdk/src/lib.rs index f4b136bbf..1f769c73f 100644 --- a/crates/nostr-sdk/src/lib.rs +++ b/crates/nostr-sdk/src/lib.rs @@ -28,6 +28,8 @@ pub use nostr_sqlite::{Error as SQLiteError, SQLiteDatabase}; use once_cell::sync::Lazy; #[cfg(feature = "blocking")] use tokio::runtime::Runtime; +#[cfg(all(feature = "webln", target_arch = "wasm32"))] +pub use webln; pub mod client; pub mod prelude; diff --git a/crates/nostr/src/nips/nip47.rs b/crates/nostr/src/nips/nip47.rs index 96e5f4f49..4410319ab 100644 --- a/crates/nostr/src/nips/nip47.rs +++ b/crates/nostr/src/nips/nip47.rs @@ -18,8 +18,11 @@ use serde_json::Value; use url_fork::form_urlencoded::byte_serialize; use url_fork::{ParseError, Url}; +#[cfg(feature = "std")] use super::nip04; use crate::JsonUtil; +#[cfg(feature = "std")] +use crate::{Event, EventBuilder, Keys, Kind, Tag}; /// NIP47 error #[derive(Debug)] @@ -31,7 +34,11 @@ pub enum Error { /// Secp256k1 error Secp256k1(secp256k1::Error), /// NIP04 error + #[cfg(feature = "std")] NIP04(nip04::Error), + /// Event Builder error + #[cfg(feature = "std")] + EventBuilder(crate::event::builder::Error), /// Unsigned event error UnsignedEvent(crate::event::unsigned::Error), /// Invalid request @@ -55,7 +62,10 @@ impl fmt::Display for Error { Self::JSON(e) => write!(f, "Json: {e}"), Self::Url(e) => write!(f, "Url: {e}"), Self::Secp256k1(e) => write!(f, "Secp256k1: {e}"), + #[cfg(feature = "std")] Self::NIP04(e) => write!(f, "NIP04: {e}"), + #[cfg(feature = "std")] + Self::EventBuilder(e) => write!(f, "Event Builder: {e}"), Self::UnsignedEvent(e) => write!(f, "Unsigned event: {e}"), Self::InvalidRequest => write!(f, "Invalid NIP47 Request"), Self::InvalidParamsLength => write!(f, "Invalid NIP47 Params length"), @@ -84,6 +94,20 @@ impl From for Error { } } +#[cfg(feature = "std")] +impl From for Error { + fn from(e: nip04::Error) -> Self { + Self::NIP04(e) + } +} + +#[cfg(feature = "std")] +impl From for Error { + fn from(e: crate::event::builder::Error) -> Self { + Self::EventBuilder(e) + } +} + /// NIP47 Response Error codes #[derive(Debug, Clone, Serialize, Deserialize)] pub enum ErrorCode { @@ -367,6 +391,19 @@ impl Request { params, }) } + + /// Create request [Event] + #[cfg(feature = "std")] + pub fn to_event(self, uri: &NostrWalletConnectURI) -> Result { + let encrypted = nip04::encrypt(&uri.secret, &uri.public_key, self.as_json())?; + let keys: Keys = Keys::new(uri.secret); + Ok(EventBuilder::new( + Kind::WalletConnectRequest, + encrypted, + [Tag::public_key(uri.public_key)], + ) + .to_event(&keys)?) + } } impl JsonUtil for Request { @@ -607,13 +644,13 @@ impl NostrWalletConnectURI { relay_url: Url, random_secret_key: SecretKey, lud16: Option, - ) -> Result { - Ok(Self { + ) -> Self { + Self { public_key, relay_url, secret: random_secret_key, lud16, - }) + } } } @@ -723,8 +760,7 @@ mod test { relay_url, secret, Some("nostr@nostr.com".to_string()), - ) - .unwrap(); + ); assert_eq!( uri.to_string(), "nostr+walletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io%2F&secret=71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c&lud16=nostr%40nostr.com".to_string() @@ -752,7 +788,6 @@ mod test { secret, Some("nostr@nostr.com".to_string()) ) - .unwrap() ); } diff --git a/crates/nostr/src/nips/nip57.rs b/crates/nostr/src/nips/nip57.rs index c50176c26..88d88ef07 100644 --- a/crates/nostr/src/nips/nip57.rs +++ b/crates/nostr/src/nips/nip57.rs @@ -108,16 +108,16 @@ impl From for Error { } } -// /// Zap Type -// #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -// pub enum ZapType { -// Public -// Public, -// Private -// Private, -// Anonymous -// Anonymous, -// } +/// Zap Type +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ZapType { + /// Public + Public, + /// Private + Private, + /// Anonymous + Anonymous, +} /// Zap Request Data #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]