diff --git a/nostr/CHANGELOG.md b/nostr/CHANGELOG.md index 55076f9b4..3d864bde2 100644 --- a/nostr/CHANGELOG.md +++ b/nostr/CHANGELOG.md @@ -40,6 +40,7 @@ - Take an `Into` iterator instead of `Tag` iterator in `GiftWrapBuilder::extra_tags`, `PrivateDirectMessageBuilder::extra_tags` and `PrivateDirectMessageBuilder::rumor_extra_tags` (https://github.com/nostrdevkit/nostr/pull/1447) +- Support `NIP-A3` payto payment targets (https://github.com/nostrdevkit/nostr/pull/1467) ### Security diff --git a/nostr/src/nips/mod.rs b/nostr/src/nips/mod.rs index 48d89777f..064cdb02c 100644 --- a/nostr/src/nips/mod.rs +++ b/nostr/src/nips/mod.rs @@ -61,6 +61,7 @@ pub mod nip94; #[cfg(feature = "nip98")] pub mod nip98; pub mod nipa0; +pub mod nipa3; pub mod nipb0; pub mod nipb7; pub mod nipc0; diff --git a/nostr/src/nips/nipa3.rs b/nostr/src/nips/nipa3.rs new file mode 100644 index 000000000..d50d7af82 --- /dev/null +++ b/nostr/src/nips/nipa3.rs @@ -0,0 +1,220 @@ +// Copyright (c) 2022-2023 Yuki Kishimoto +// Copyright (c) 2023-2025 Rust Nostr Developers +// Distributed under the MIT software license + +//! NIP-A3: payto: Payment Targets +//! +//! + +use alloc::string::String; + +use crate::error::Error; +use crate::event::{Tag, impl_tag_codec_conversions}; +use crate::nips::util::{missing_tag_kind, missing_value, take_and_parse_from_str, unknown_tag}; + +const PAYTO: &str = "payto"; + +/// Standardized NIP-A3 tags +/// +/// +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum NipA3Tag { + /// `payto` tag + Payto { + /// payment `type` (e.g., `"bitcoin"`, `"lightning"`), always lowercase. + payment_type: String, + /// `address` (e.g., address, username) + payment_address: String, + }, +} + +impl NipA3Tag { + /// Returns the payment URI for the `payto` tag when the payment type is + /// supported. For unrecognized types, a standard `payto` URI is produced + /// per RFC 8905. + /// + /// Supported types: bitcoin, monero, lightning, litecoin, ethereum. For + /// these, the URI is formatted as `:
`. For any other type, + /// the URI is formatted as `payto:///
`. + /// + /// Returns `None` only if the tag is not `payto`. + pub fn to_uri(&self) -> Option { + #[allow(irrefutable_let_patterns)] + let Self::Payto { + payment_type, + payment_address, + } = self + else { + return None; + }; + + match payment_type.as_str() { + "bitcoin" | "monero" | "lightning" | "litecoin" | "ethereum" => { + Some(format!("{payment_type}:{payment_address}")) + } + _ => Some(format!("payto://{payment_type}/{payment_address}")), + } + } +} + +impl_tag_codec_conversions! { + NipA3Tag, + fn parse(tag) { + let mut iter = tag.into_iter(); + + let kind: S = iter.next().ok_or(missing_tag_kind())?; + + match kind.as_ref() { + PAYTO => { + let (payment_type, payment_address) = parse_payto_tag(iter)?; + Ok(Self::Payto { + payment_type, + payment_address, + }) + } + _ => Err(unknown_tag()), + } + } + + fn to_tag(&self) { + match self { + Self::Payto { + payment_type, + payment_address, + } => Tag::new(vec![ + String::from(PAYTO), + payment_type.clone(), + payment_address.clone(), + ]), + } + } +} + +/// Parse `payto` tag +#[inline] +fn parse_payto_tag(mut iter: T) -> Result<(String, String), Error> +where + T: Iterator, + S: AsRef, +{ + let payment_type = iter + .next() + .ok_or_else(|| missing_value("payment type"))? + .as_ref() + .to_lowercase(); + let payment_address = take_and_parse_from_str(&mut iter, "payment address")?; + + Ok((payment_type, payment_address)) +} + +#[cfg(test)] +mod tests { + use alloc::string::ToString; + + use super::*; + use crate::error::ErrorKind; + + #[test] + fn test_parse_unknown_tag() { + let tag = vec!["unknown"]; + assert_eq!( + NipA3Tag::parse(tag).unwrap_err().kind(), + ErrorKind::Malformed + ) + } + + #[test] + fn test_parse_payto_tag() { + let tag = vec!["payto", "bitcoin", "12cbQLTFMXRnSzktFkuoG3eHoMeFtpTu3S"]; + let parsed = NipA3Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + NipA3Tag::Payto { + payment_type: tag[1].to_string(), + payment_address: tag[2].to_string() + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_parse_payto_tag_missing_type() { + let tag = vec!["payto"]; + assert_eq!(NipA3Tag::parse(tag).unwrap_err().kind(), ErrorKind::Missing) + } + + #[test] + fn test_parse_payto_tag_missing_address() { + let tag = vec!["payto", "monero"]; + assert_eq!(NipA3Tag::parse(tag).unwrap_err().kind(), ErrorKind::Missing) + } + + #[test] + fn test_parse_payto_tag_lowercase_type() { + let tag = vec![ + "payto", + "Monero", // This is invalid. It should be lowercase + "4AcQxuMBUfJM7uAWpZP1Vs1BzQLC1QR6zZL3sMYdBuayWmpZHmaVYo7EQ3cSneyHYf2LRKJnRtrGz5ogZzjmmGygAyusEcJ", + ]; + let NipA3Tag::Payto { payment_type, .. } = NipA3Tag::parse(&tag).unwrap(); + assert_eq!(payment_type, "monero"); + } + + #[test] + fn test_payto_tag_to_uri() { + assert_eq!( + NipA3Tag::Payto { + payment_type: "bitcoin".to_string(), + payment_address: "0".to_string() + } + .to_uri() + .unwrap(), + "bitcoin:0" + ); + assert_eq!( + NipA3Tag::Payto { + payment_type: "monero".to_string(), + payment_address: "0".to_string() + } + .to_uri() + .unwrap(), + "monero:0" + ); + assert_eq!( + NipA3Tag::Payto { + payment_type: "lightning".to_string(), + payment_address: "0".to_string() + } + .to_uri() + .unwrap(), + "lightning:0" + ); + assert_eq!( + NipA3Tag::Payto { + payment_type: "litecoin".to_string(), + payment_address: "0".to_string() + } + .to_uri() + .unwrap(), + "litecoin:0" + ); + assert_eq!( + NipA3Tag::Payto { + payment_type: "ethereum".to_string(), + payment_address: "0".to_string() + } + .to_uri() + .unwrap(), + "ethereum:0" + ); + assert_eq!( + NipA3Tag::Payto { + payment_type: "unknowntype".to_string(), + payment_address: "0".to_string() + } + .to_uri() + .unwrap(), + "payto://unknowntype/0" + ); + } +}