Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions nostr/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
- Take an `Into<Tag>` 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

Expand Down
1 change: 1 addition & 0 deletions nostr/src/nips/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
220 changes: 220 additions & 0 deletions nostr/src/nips/nipa3.rs
Original file line number Diff line number Diff line change
@@ -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
//!
//! <https://github.com/nostr-protocol/nips/blob/master/A3.md>

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
///
/// <https://github.com/nostr-protocol/nips/blob/master/A3.md>
#[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,
Comment on lines +24 to +27

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should I extract this into PaytoTag? So I can implement to_uri on it directly.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, sounds good

},
}

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 `<type>:<address>`. For any other type,
/// the URI is formatted as `payto://<type>/<address>`.
///
/// Returns `None` only if the tag is not `payto`.
pub fn to_uri(&self) -> Option<String> {
#[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<T, S>(mut iter: T) -> Result<(String, String), Error>
where
T: Iterator<Item = S>,
S: AsRef<str>,
{
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"
);
}
}
Loading