// Copyright (c) 2022-2023 Yuki Kishimoto
// Copyright (c) 2023-2025 Rust Nostr Developers
// Distributed under the MIT software license
//! Event builder
use alloc::boxed::Box;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use core::fmt;
use core::ops::Range;
use serde_json::{Value, json};
use crate::nips::nip58::Nip58Tag;
use crate::nips::nip62::VanishTarget;
use crate::prelude::*;
use crate::util::BoxedFuture;
/// Wrong kind error
#[derive(Debug, PartialEq, Eq)]
pub enum WrongKindError {
/// Single kind
Single(Kind),
/// Range
Range(Range<u16>),
}
impl fmt::Display for WrongKindError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Single(k) => k.fmt(f),
Self::Range(range) => write!(f, "'{} <= k <= {}'", range.start, range.end),
}
}
}
/// Event builder error
#[derive(Debug, PartialEq)]
pub enum Error {
/// Unsigned event error
Event(super::Error),
/// NIP01 error
NIP01(nip01::Error),
/// OpenTimestamps error
#[cfg(feature = "nip03")]
NIP03(String),
/// NIP04 error
#[cfg(feature = "nip04")]
NIP04(nip04::Error),
/// NIP21 error
NIP21(nip21::Error),
/// NIP44 error
#[cfg(all(feature = "std", feature = "nip44"))]
NIP44(nip44::Error),
/// NIP58 error
NIP58(nip58::Error),
/// Wrong kind
WrongKind {
/// The received wrong kind
received: Kind,
/// The expected kind (single or range)
expected: WrongKindError,
},
/// Empty tags, while at least one tag is required
EmptyTags,
}
impl core::error::Error for Error {}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Event(e) => e.fmt(f),
Self::NIP01(e) => e.fmt(f),
#[cfg(feature = "nip03")]
Self::NIP03(e) => e.fmt(f),
#[cfg(feature = "nip04")]
Self::NIP04(e) => e.fmt(f),
Self::NIP21(e) => e.fmt(f),
#[cfg(all(feature = "std", feature = "nip44"))]
Self::NIP44(e) => e.fmt(f),
Self::NIP58(e) => e.fmt(f),
Self::WrongKind { received, expected } => {
write!(f, "Wrong kind: received={received}, expected={expected}")
}
Self::EmptyTags => f.write_str("At least one tag is required"),
}
}
}
impl From<SignerError> for Error {
fn from(e: SignerError) -> Self {
Self::Event(super::Error::Signer(e.to_string()))
}
}
impl From<super::Error> for Error {
fn from(e: super::Error) -> Self {
Self::Event(e)
}
}
impl From<nip01::Error> for Error {
fn from(e: nip01::Error) -> Self {
Self::NIP01(e)
}
}
#[cfg(feature = "nip03")]
impl From<nostr_ots::Error> for Error {
fn from(e: nostr_ots::Error) -> Self {
Self::NIP03(e.to_string())
}
}
#[cfg(feature = "nip04")]
impl From<nip04::Error> for Error {
fn from(e: nip04::Error) -> Self {
Self::NIP04(e)
}
}
impl From<nip21::Error> for Error {
fn from(e: nip21::Error) -> Self {
Self::NIP21(e)
}
}
#[cfg(all(feature = "std", feature = "nip44"))]
impl From<nip44::Error> for Error {
fn from(e: nip44::Error) -> Self {
Self::NIP44(e)
}
}
impl From<nip58::Error> for Error {
fn from(e: nip58::Error) -> Self {
Self::NIP58(e)
}
}
/// Template that can be converted into a generic [`EventBuilder`].
pub trait EventBuilderTemplate: Sized {
/// Convert into the generic event builder.
fn build(self) -> EventBuilder;
}
impl<B> FinalizeUnsignedEvent for B
where
B: EventBuilderTemplate,
{
#[inline]
fn finalize_unsigned(self, public_key: PublicKey) -> UnsignedEvent {
let builder: EventBuilder = self.build();
builder.finalize_unsigned(public_key)
}
}
impl<B, S> FinalizeEvent<S> for B
where
B: EventBuilderTemplate,
S: GetPublicKey + SignEvent + ?Sized,
{
type Error = SignerError;
fn finalize(self, signer: &S) -> Result<Event, Self::Error> {
let builder: EventBuilder = self.build();
builder.finalize(signer)
}
}
/// Template that can asynchronously be converted into a generic [`EventBuilder`].
pub trait EventBuilderTemplateAsync {
/// Convert this typed builder into the generic event builder.
fn build_async<'a>(self) -> BoxedFuture<'a, EventBuilder>
where
Self: 'a;
}
impl<B> EventBuilderTemplateAsync for B
where
B: EventBuilderTemplate + Send,
{
#[inline]
fn build_async<'a>(self) -> BoxedFuture<'a, EventBuilder>
where
Self: 'a,
{
Box::pin(async move { EventBuilderTemplate::build(self) })
}
}
impl<B> FinalizeUnsignedEventAsync for B
where
B: EventBuilderTemplateAsync + Send,
{
#[inline]
fn finalize_unsigned_async<'a>(self, public_key: PublicKey) -> BoxedFuture<'a, UnsignedEvent>
where
Self: 'a,
{
Box::pin(async move {
let builder: EventBuilder = self.build_async().await;
builder.finalize_unsigned_async(public_key).await
})
}
}
impl<B, S> FinalizeEventAsync<S> for B
where
B: EventBuilderTemplateAsync + Send,
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
type Error = SignerError;
fn finalize_async<'a>(self, signer: &'a S) -> BoxedFuture<'a, Result<Event, Self::Error>>
where
Self: 'a,
S: 'a,
{
Box::pin(async move {
let builder: EventBuilder = self.build_async().await;
builder.finalize_async(signer).await
})
}
}
/// Event builder
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub struct EventBuilder {
/// Event kind.
pub kind: Kind,
/// Event tags.
pub tags: Tags,
/// Event content.
pub content: String,
/// Custom timestamp.
pub created_at: Option<Timestamp>,
}
impl EventBuilder {
/// New event builder
#[inline]
pub fn new<S>(kind: Kind, content: S) -> Self
where
S: Into<String>,
{
Self {
kind,
tags: Tags::new(),
content: content.into(),
created_at: None,
}
}
/// Add tag
#[inline]
pub fn tag(mut self, tag: Tag) -> Self {
self.tags.push(tag);
self
}
/// Add tag if `Some`.
pub fn tag_maybe(mut self, tag: Option<Tag>) -> Self {
if let Some(tag) = tag {
self.tags.push(tag);
}
self
}
/// Add tags
///
/// This method extends the current tags.
#[inline]
pub fn tags<I>(mut self, tags: I) -> Self
where
I: IntoIterator<Item = Tag>,
{
self.tags.extend(tags);
self
}
/// Set a custom `created_at` UNIX timestamp.
#[inline]
pub fn custom_created_at(mut self, created_at: Timestamp) -> Self {
self.created_at = Some(created_at);
self
}
/// Profile metadata
///
/// <https://github.com/nostr-protocol/nips/blob/master/01.md>
///
/// # Example
/// ```rust,no_run
/// use nostr::prelude::*;
///
/// let metadata = Metadata::new()
/// .name("username")
/// .display_name("My Username")
/// .about("Description")
/// .picture(Url::parse("https://example.com/avatar.png").unwrap())
/// .nip05("username@example.com")
/// .lud16("pay@yukikishimoto.com");
///
/// let builder = EventBuilder::metadata(&metadata);
/// ```
#[inline]
pub fn metadata(metadata: &Metadata) -> Self {
Self::new(Kind::Metadata, metadata.as_json())
}
/// Relay list metadata
///
/// <https://github.com/nostr-protocol/nips/blob/master/65.md>
pub fn relay_list<I>(iter: I) -> Self
where
I: IntoIterator<Item = (RelayUrl, Option<RelayMetadata>)>,
{
let tags = iter.into_iter().map(|(relay_url, metadata)| {
Nip65Tag::RelayMetadata {
relay_url,
metadata,
}
.to_tag()
});
Self::new(Kind::RelayList, "").tags(tags)
}
/// Text note
///
/// <https://github.com/nostr-protocol/nips/blob/master/01.md>
///
/// # Example
/// ```rust,no_run
/// use nostr::EventBuilder;
///
/// let builder = EventBuilder::text_note("My first text note from rust-nostr!");
/// ```
#[inline]
pub fn text_note<S>(content: S) -> Self
where
S: Into<String>,
{
Self::new(Kind::TextNote, content)
}
/// Text note reply
///
/// This adds only that most significant tags, like:
/// - `p` tag with the author of the `reply_to` and `root` events;
/// - `e` tag of the `reply_to` and `root` events.
///
/// Any additional necessary tag can be added with [`EventBuilder::tag`] or [`EventBuilder::tags`].
///
/// <https://github.com/nostr-protocol/nips/blob/master/10.md>
pub fn text_note_reply<S>(
content: S,
reply_to: &Event,
root: Option<&Event>,
relay_hint: Option<RelayUrl>,
) -> Self
where
S: Into<String>,
{
let mut tags: Vec<Tag> = Vec::with_capacity(2);
match root {
Some(root) => {
// Check if root event is different from reply event
if root.id != reply_to.id {
tags.push(
Nip10Tag::Event {
id: reply_to.id,
relay_hint: relay_hint.clone(),
marker: Some(Marker::Reply),
public_key: Some(reply_to.pubkey),
}
.to_tag(),
);
tags.push(Tag::public_key(reply_to.pubkey));
}
// ID and author
tags.push(
Nip10Tag::Event {
id: root.id,
relay_hint,
marker: Some(Marker::Root),
public_key: Some(root.pubkey),
}
.to_tag(),
);
tags.push(Tag::public_key(root.pubkey));
}
// No root tag, add only reply_to tags
None => {
tags.push(
Nip10Tag::Event {
id: reply_to.id,
relay_hint,
marker: Some(Marker::Reply),
public_key: Some(reply_to.pubkey),
}
.to_tag(),
);
tags.push(Tag::public_key(reply_to.pubkey));
}
}
// Compose event
Self::new(Kind::TextNote, content).tags(tags)
}
/// Comment
///
/// <https://github.com/nostr-protocol/nips/blob/master/22.md>
pub fn comment<'a, S, T>(content: S, comment_to: T, root: Option<T>) -> Self
where
T: Into<CommentTarget<'a>>,
S: Into<String>,
{
Self::new(Kind::Comment, content)
.tags(root.map(|c| c.into().as_vec(true)).unwrap_or_default())
.tags(comment_to.into().as_vec(false))
}
/// Long-form text note (generally referred to as "articles" or "blog posts").
///
/// <https://github.com/nostr-protocol/nips/blob/master/23.md>
///
/// # Example
/// ```rust,no_run
/// use std::str::FromStr;
///
/// use nostr::prelude::*;
///
/// let event_id = EventId::from_hex("b3e392b11f5d4f28321cedd09303a748acfd0487aea5a7450b3481c60b6e4f87").unwrap();
/// let content: &str = "Lorem [ipsum][4] dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at #[3].";
/// let tags = &[
/// Nip01Tag::Identifier("lorem-ipsum".to_string()).to_tag(),
/// Nip23Tag::Title("Lorem Ipsum".to_string()).to_tag(),
/// Nip23Tag::PublishedAt(Timestamp::from(1296962229)).to_tag(),
/// Nip23Tag::Hashtag("placeholder".to_string()).to_tag(),
/// Nip01Tag::Event { id: event_id, relay_hint: None, public_key: None }.to_tag(),
/// ];
/// let builder = EventBuilder::long_form_text_note("My first text note from rust-nostr!");
/// ```
#[inline]
pub fn long_form_text_note<S>(content: S) -> Self
where
S: Into<String>,
{
Self::new(Kind::LongFormTextNote, content)
}
/// OpenTimestamps Attestations for Events
///
/// <https://github.com/nostr-protocol/nips/blob/master/03.md>
#[cfg(feature = "nip03")]
pub fn opentimestamps(event_id: EventId, relay_hint: Option<RelayUrl>) -> Result<Self, Error> {
let ots: String = nostr_ots::timestamp_event(&event_id.to_hex())?;
Ok(Self::new(Kind::OpenTimestamps, ots).tag(
Nip01Tag::Event {
id: event_id,
relay_hint,
public_key: None,
}
.to_tag(),
))
}
/// Repost
///
/// <https://github.com/nostr-protocol/nips/blob/master/18.md>
pub fn repost(event: &Event, relay_url: Option<RelayUrl>) -> Self {
// As per NIP-18, a repost of NIP-70-protected event should have an empty content.
let content: String = if event.is_protected() {
String::new()
} else {
event.as_json()
};
if event.kind == Kind::TextNote {
Self::new(Kind::Repost, content).tags([
Nip18Tag::Event {
id: event.id,
relay_hint: relay_url,
}
.to_tag(),
Nip18Tag::PublicKey {
public_key: event.pubkey,
relay_hint: None,
}
.to_tag(),
])
} else {
Self::new(Kind::GenericRepost, content)
.tag_maybe(
event
.coordinate()
.map(|c| Tag::coordinate(c, relay_url.clone())),
)
.tags([
Nip18Tag::Event {
id: event.id,
relay_hint: relay_url,
}
.to_tag(),
Nip18Tag::PublicKey {
public_key: event.pubkey,
relay_hint: None,
}
.to_tag(),
Nip18Tag::Kind(event.kind).to_tag(),
])
}
}
/// Event deletion request
///
/// <https://github.com/nostr-protocol/nips/blob/master/09.md>
#[inline]
pub fn delete(request: EventDeletionRequest) -> Self {
request.to_event_builder()
}
/// Request to vanish
///
/// <https://github.com/nostr-protocol/nips/blob/master/62.md>
#[inline]
pub fn request_vanish(target: VanishTarget) -> Result<Self, Error> {
Self::request_vanish_with_reason(target, "")
}
/// Request to vanish with reason
///
/// <https://github.com/nostr-protocol/nips/blob/master/62.md>
pub fn request_vanish_with_reason<S>(target: VanishTarget, reason: S) -> Result<Self, Error>
where
S: Into<String>,
{
let mut builder = Self::new(Kind::RequestToVanish, reason);
match target {
VanishTarget::AllRelays => {
builder = builder.tag(Nip62Tag::AllRelays.to_tag());
}
VanishTarget::Relays(list) => {
// Check if the list is empty
if list.is_empty() {
// Empty list, return error.
return Err(Error::EmptyTags);
}
builder = builder.tags(list.into_iter().map(Nip62Tag::Relay).map(Into::into));
}
}
Ok(builder)
}
/// Voice Message
///
/// Note: This will not add `imeta` tag ([NIP-92])
///
/// <https://github.com/nostr-protocol/nips/blob/master/A0.md>
///
/// [NIP-92]: https://github.com/nostr-protocol/nips/blob/master/92.md
#[inline]
pub fn voice_message<T>(voice_url: T) -> Self
where
T: Into<Url>,
{
EventBuilder::new(Kind::VoiceMessage, voice_url.into().as_str())
}
/// Voice Message Reply
///
/// Note: This will not add `imeta` tag ([NIP-92])
///
/// <https://github.com/nostr-protocol/nips/blob/master/A0.md>
///
/// [NIP-92]: https://github.com/nostr-protocol/nips/blob/master/92.md
#[inline]
pub fn voice_message_reply<'a, T, U>(voice_url: U, root: Option<T>, parent: T) -> Self
where
T: Into<CommentTarget<'a>>,
U: Into<Url>,
{
EventBuilder::new(Kind::VoiceMessageReply, voice_url.into().as_str())
.tags(root.map(|c| c.into().as_vec(true)).unwrap_or_default())
.tags(parent.into().as_vec(false))
}
/// Add reaction (like/upvote, dislike/downvote or emoji) to an event
///
/// <https://github.com/nostr-protocol/nips/blob/master/25.md>
#[inline]
pub fn reaction<T, S>(target: T, reaction: S) -> Self
where
T: Into<ReactionTarget>,
S: Into<String>,
{
Self::new(Kind::Reaction, reaction).tags(target.into().into_tags())
}
/// Create a new channel
///
/// <https://github.com/nostr-protocol/nips/blob/master/28.md>
#[inline]
pub fn channel(metadata: &Metadata) -> Self {
Self::new(Kind::ChannelCreation, metadata.as_json())
}
/// Channel metadata
///
/// <https://github.com/nostr-protocol/nips/blob/master/28.md>
#[inline]
pub fn channel_metadata(
channel_id: EventId,
relay_url: Option<RelayUrl>,
metadata: &Metadata,
) -> Self {
Self::new(Kind::ChannelMetadata, metadata.as_json()).tag(
Nip10Tag::Event {
id: channel_id,
relay_hint: relay_url,
marker: None,
public_key: None,
}
.to_tag(),
)
}
/// Channel message
///
/// <https://github.com/nostr-protocol/nips/blob/master/28.md>
#[inline]
pub fn channel_msg<S>(channel_id: EventId, relay_url: RelayUrl, content: S) -> Self
where
S: Into<String>,
{
Self::new(Kind::ChannelMessage, content).tag(
Nip10Tag::Event {
id: channel_id,
relay_hint: Some(relay_url),
marker: Some(Marker::Root),
public_key: None,
}
.to_tag(),
)
}
/// Hide message
///
/// The `message_id` must be the [`EventId`] of the kind `42`.
///
/// <https://github.com/nostr-protocol/nips/blob/master/28.md>
pub fn hide_channel_msg<S>(message_id: EventId, reason: Option<S>) -> Self
where
S: Into<String>,
{
let content: Value = json!({
"reason": reason.map(|s| s.into()).unwrap_or_default(),
});
Self::new(Kind::ChannelHideMessage, content.to_string()).tag(Tag::event(message_id))
}
/// Mute channel user
///
/// <https://github.com/nostr-protocol/nips/blob/master/28.md>
pub fn mute_channel_user<S>(public_key: PublicKey, reason: Option<S>) -> Self
where
S: Into<String>,
{
let content: Value = json!({
"reason": reason.map(|s| s.into()).unwrap_or_default(),
});
Self::new(Kind::ChannelMuteUser, content.to_string()).tag(Tag::public_key(public_key))
}
/// Authentication of clients to the relay
///
/// <https://github.com/nostr-protocol/nips/blob/master/42.md>
#[inline]
pub fn auth<S>(challenge: S, relay: RelayUrl) -> Self
where
S: Into<String>,
{
Self::new(Kind::Authentication, "").tags([
Nip42Tag::Challenge(challenge.into()).into(),
Nip42Tag::Relay(relay).into(),
])
}
/// Live Event
///
/// <https://github.com/nostr-protocol/nips/blob/master/53.md>
#[inline]
pub fn live_event(live_event: LiveEvent) -> Self {
let tags: Vec<Tag> = live_event.into();
Self::new(Kind::LiveEvent, "").tags(tags)
}
/// Live Event Message
///
/// <https://github.com/nostr-protocol/nips/blob/master/53.md>
pub fn live_event_msg<S>(
live_event_id: S,
live_event_host: PublicKey,
content: S,
relay_hint: Option<RelayUrl>,
) -> Self
where
S: Into<String>,
{
Self::new(Kind::LiveEventMessage, content).tag(
Nip01Tag::Coordinate {
coordinate: Coordinate::new(Kind::LiveEvent, live_event_host)
.identifier(live_event_id),
relay_hint,
}
.to_tag(),
)
}
/// Reporting
///
/// <https://github.com/nostr-protocol/nips/blob/master/56.md>
#[inline]
pub fn report<I, S>(tags: I, content: S) -> Self
where
I: IntoIterator<Item = Tag>,
S: Into<String>,
{
Self::new(Kind::Reporting, content).tags(tags)
}
/// Create **public** zap request event
///
/// **This event MUST NOT be broadcasted to relays**, instead must be sent to a recipient's LNURL pay callback url.
///
/// <https://github.com/nostr-protocol/nips/blob/master/57.md>
pub fn public_zap_request(data: ZapRequestData) -> Self {
let message: String = data.message.clone();
let tags: Vec<Tag> = data.into();
Self::new(Kind::ZapRequest, message).tags(tags)
}
/// Zap Receipt
///
/// <https://github.com/nostr-protocol/nips/blob/master/57.md>
pub fn zap_receipt<S1, S2>(bolt11: S1, preimage: Option<S2>, zap_request: &Event) -> Self
where
S1: Into<String>,
S2: Into<String>,
{
let mut tags: Vec<Tag> = vec![
Nip57Tag::Bolt11(bolt11.into()).to_tag(),
Nip57Tag::Description(zap_request.as_json()).to_tag(),
];
// add preimage tag if provided
if let Some(pre_image_tag) = preimage {
tags.push(Nip57Tag::Preimage(pre_image_tag.into()).to_tag())
}
// add e tag
if let Some(tag) = zap_request.tags.iter().find(|t| t.kind() == "e").cloned() {
tags.push(tag);
}
// add a tag
if let Some(tag) = zap_request.tags.iter().find(|t| t.kind() == "a").cloned() {
tags.push(tag);
}
// add p tag
if let Some(tag) = zap_request.tags.iter().find(|t| t.kind() == "p").cloned() {
tags.push(tag);
}
// add P tag
tags.push(Nip57Tag::Sender(zap_request.pubkey).to_tag());
Self::new(Kind::ZapReceipt, "").tags(tags)
}
/// Badge definition
///
/// <https://github.com/nostr-protocol/nips/blob/master/58.md>
///
/// # Example
/// ```rust,no_run
/// use nostr::prelude::*;
///
/// let badge_id = String::from("nostr-sdk-test-badge");
/// let name = Some(String::from("rust-nostr test badge"));
/// let description = Some(String::from("This is a test badge"));
/// let image_url = Some(Url::parse("https://nostr.build/someimage/1337").unwrap());
/// let image_size = Some(ImageDimensions::new(1024, 1024));
/// let thumbs = vec![(
/// Url::parse("https://nostr.build/somethumbnail/1337").unwrap(),
/// Some(ImageDimensions::new(256, 256)),
/// )];
///
/// let event_builder =
/// EventBuilder::define_badge(badge_id, name, description, image_url, image_size, thumbs);
/// ```
pub fn define_badge<S>(
badge_id: S,
name: Option<S>,
description: Option<S>,
image: Option<Url>,
image_dimensions: Option<ImageDimensions>,
thumbnails: Vec<(Url, Option<ImageDimensions>)>,
) -> Self
where
S: Into<String>,
{
let mut tags: Vec<Tag> = Vec::new();
// Set identifier tag
tags.push(Nip58Tag::Identifier(badge_id.into()).to_tag());
// Set name tag
if let Some(name) = name {
tags.push(Nip58Tag::Name(name.into()).to_tag());
}
// Set description tag
if let Some(description) = description {
tags.push(Nip58Tag::Description(description.into()).to_tag());
}
// Set image tag
if let Some(image) = image {
let image_tag = if let Some(dimensions) = image_dimensions {
Nip58Tag::Image(image, Some(dimensions)).to_tag()
} else {
Nip58Tag::Image(image, None).to_tag()
};
tags.push(image_tag);
}
// Set thumbnail tags
for (thumb, dimensions) in thumbnails.into_iter() {
let thumb_tag = if let Some(dimensions) = dimensions {
Nip58Tag::Thumb(thumb, Some(dimensions)).to_tag()
} else {
Nip58Tag::Thumb(thumb, None).to_tag()
};
tags.push(thumb_tag);
}
Self::new(Kind::BadgeDefinition, "").tags(tags)
}
/// Badge award
///
/// <https://github.com/nostr-protocol/nips/blob/master/58.md>
pub fn award_badge<I>(badge_definition: &Event, awarded_public_keys: I) -> Result<Self, Error>
where
I: IntoIterator<Item = PublicKey>,
{
let badge_id = badge_definition
.tags
.iter()
.find_map(|t| match t.try_into() {
Ok(Nip01Tag::Identifier(id)) => Some(id),
_ => None,
})
.ok_or(Error::NIP58(nip58::Error::IdentifierTagNotFound))?;
// At least 1 tag
let mut tags = Vec::with_capacity(1);
// Add identity tag
tags.push(
Nip01Tag::Coordinate {
coordinate: Coordinate::new(Kind::BadgeDefinition, badge_definition.pubkey)
.identifier(badge_id),
relay_hint: None,
}
.to_tag(),
);
// Add awarded public keys
tags.extend(awarded_public_keys.into_iter().map(Tag::public_key));
// Build event
Ok(Self::new(Kind::BadgeAward, "").tags(tags))
}
/// Profile badges
///
/// <https://github.com/nostr-protocol/nips/blob/master/58.md>
pub fn profile_badges(
badge_definitions: Vec<Event>,
badge_awards: Vec<Event>,
pubkey_awarded: &PublicKey,
) -> Result<Self, Error> {
if badge_definitions.len() != badge_awards.len() {
return Err(Error::NIP58(nip58::Error::InvalidLength));
}
let badge_awards: Vec<Event> = nip58::filter_for_kind(badge_awards, &Kind::BadgeAward);
if badge_awards.is_empty() {
return Err(Error::NIP58(nip58::Error::InvalidKind));
}
for award in badge_awards.iter() {
if !award.tags.iter().any(|t| match Nip01Tag::try_from(t) {
Ok(Nip01Tag::PublicKey { public_key, .. }) => public_key == *pubkey_awarded,
_ => false,
}) {
return Err(Error::NIP58(nip58::Error::BadgeAwardsLackAwardedPublicKey));
}
}
let badge_definitions: Vec<Event> =
nip58::filter_for_kind(badge_definitions, &Kind::BadgeDefinition);
if badge_definitions.is_empty() {
return Err(Error::NIP58(nip58::Error::InvalidKind));
}
let mut tags: Vec<Tag> = Vec::new();
let badge_definitions_identifiers = badge_definitions.iter().filter_map(|event| {
let id: String = event.tags.identifier()?;
Some((event, id))
});
let badge_awards_identifiers = badge_awards.iter().filter_map(|event| {
let (_, relay_url) =
nip58::extract_awarded_public_key(event.tags.as_slice(), *pubkey_awarded)?;
let (id, a_tag) = event
.tags
.iter()
.find_map(|t| match Nip01Tag::try_from(t) {
Ok(Nip01Tag::Coordinate { coordinate, .. }) => Some((coordinate.identifier, t)),
_ => None,
})?;
Some((event, id, a_tag, relay_url))
});
// This collection has been filtered for the needed tags
let users_badges = core::iter::zip(badge_definitions_identifiers, badge_awards_identifiers);
for (badge_definition, badge_award) in users_badges {
match (badge_definition, badge_award) {
((_, identifier), (_, badge_id, ..)) if badge_id != identifier => {
return Err(Error::NIP58(nip58::Error::MismatchedBadgeDefinitionOrAward));
}
((_, identifier), (badge_award_event, badge_id, a_tag, relay_url))
if badge_id == identifier =>
{
let badge_award_event_tag: Tag = Nip01Tag::Event {
id: badge_award_event.id,
relay_hint: relay_url.clone(),
public_key: None,
}
.into();
tags.extend_from_slice(&[a_tag.clone(), badge_award_event_tag]);
}
_ => {}
}
}
Ok(EventBuilder::new(Kind::ProfileBadges, "").tags(tags))
}
/// Data Vending Machine (DVM) - Job Request
///
/// <https://github.com/nostr-protocol/nips/blob/master/90.md>
pub fn job_request(kind: Kind) -> Result<Self, Error> {
if !kind.is_job_request() {
return Err(Error::WrongKind {
received: kind,
expected: WrongKindError::Range(NIP90_JOB_REQUEST_RANGE),
});
}
Ok(Self::new(kind, ""))
}
/// Data Vending Machine (DVM) - Job Result
///
/// <https://github.com/nostr-protocol/nips/blob/master/90.md>
pub fn job_result<S>(
job_request: Event,
payload: S,
millisats: u64,
bolt11: Option<String>,
) -> Result<Self, Error>
where
S: Into<String>,
{
let kind: Kind = job_request.kind + 1000;
// Check if Job Result kind
if !kind.is_job_result() {
return Err(Error::WrongKind {
received: kind,
expected: WrongKindError::Range(NIP90_JOB_RESULT_RANGE),
});
}
let mut tags: Vec<Tag> = job_request
.tags
.iter()
.filter_map(|t| {
if t.kind() == "i" {
Some(t.clone())
} else {
None
}
})
.collect();
tags.extend_from_slice(&[
Tag::event(job_request.id),
Tag::public_key(job_request.pubkey),
Nip90Tag::Request(job_request).to_tag(),
Nip90Tag::Amount { millisats, bolt11 }.to_tag(),
]);
Ok(Self::new(kind, payload).tags(tags))
}
/// Data Vending Machine (DVM) - Job Feedback
///
/// <https://github.com/nostr-protocol/nips/blob/master/90.md>
pub fn job_feedback(data: JobFeedbackData) -> Self {
let mut tags: Vec<Tag> = Vec::with_capacity(3);
tags.push(Tag::event(data.job_request_id));
tags.push(Tag::public_key(data.customer_public_key));
tags.push(
Nip90Tag::Status {
status: data.status,
extra_info: data.extra_info,
}
.to_tag(),
);
if let Some(millisats) = data.amount_msat {
tags.push(
Nip90Tag::Amount {
millisats,
bolt11: data.bolt11,
}
.to_tag(),
);
}
Self::new(Kind::JobFeedback, data.payload.unwrap_or_default()).tags(tags)
}
/// File metadata
///
/// <https://github.com/nostr-protocol/nips/blob/master/94.md>
#[inline]
pub fn file_metadata<S>(description: S, metadata: FileMetadata) -> Self
where
S: Into<String>,
{
let tags: Vec<Tag> = metadata.into();
Self::new(Kind::FileMetadata, description.into()).tags(tags)
}
/// HTTP Auth
///
/// <https://github.com/nostr-protocol/nips/blob/master/98.md>
#[inline]
#[cfg(feature = "nip98")]
pub fn http_auth(data: HttpData) -> Self {
let tags: Vec<Tag> = data.into();
Self::new(Kind::HttpAuth, "").tags(tags)
}
/// Set stall data
///
/// <https://github.com/nostr-protocol/nips/blob/master/15.md>
#[inline]
pub fn stall_data(data: StallData) -> Self {
let content: String = data.as_json();
let tags: Vec<Tag> = data.into();
Self::new(Kind::SetStall, content).tags(tags)
}
/// Set product data
///
/// <https://github.com/nostr-protocol/nips/blob/master/15.md>
#[inline]
pub fn product_data(data: ProductData) -> Self {
let content: String = data.as_json();
let tags: Vec<Tag> = data.into();
Self::new(Kind::SetProduct, content).tags(tags)
}
/// Private direct message relay list
///
/// <https://github.com/nostr-protocol/nips/blob/master/17.md>
pub fn nip17_relay_list<I>(urls: I) -> Self
where
I: IntoIterator<Item = RelayUrl>,
{
Self::new(Kind::InboxRelays, "").tags(urls.into_iter().map(Nip17Tag::Relay).map(Into::into))
}
/// Mute list
///
/// <https://github.com/nostr-protocol/nips/blob/master/51.md>
#[inline]
pub fn mute_list(list: MuteList) -> Self {
let tags: Vec<Tag> = list.into();
Self::new(Kind::MuteList, "").tags(tags)
}
/// Pinned notes
///
/// <https://github.com/nostr-protocol/nips/blob/master/51.md>
#[inline]
pub fn pinned_notes<I>(ids: I) -> Self
where
I: IntoIterator<Item = EventId>,
{
Self::new(Kind::PinList, "").tags(ids.into_iter().map(Tag::event))
}
/// Bookmarks
///
/// <https://github.com/nostr-protocol/nips/blob/master/51.md>
#[inline]
pub fn bookmarks(list: Bookmarks) -> Self {
let tags: Vec<Tag> = list.into();
Self::new(Kind::Bookmarks, "").tags(tags)
}
/// Blossom Server List
///
/// <https://github.com/nostr-protocol/nips/blob/master/B7.md>
#[inline]
pub fn blossom_server_list<I>(servers: I) -> Self
where
I: IntoIterator<Item = Url>,
{
Self::new(Kind::BlossomServerList, "")
.tags(servers.into_iter().map(|s| NipB7Tag::Server(s).to_tag()))
}
/// Communities
///
/// <https://github.com/nostr-protocol/nips/blob/master/51.md>
#[inline]
pub fn communities<I>(communities: I) -> Self
where
I: IntoIterator<Item = Coordinate>,
{
Self::new(Kind::Communities, "").tags(communities.into_iter().map(Tag::from))
}
/// Public chats
///
/// <https://github.com/nostr-protocol/nips/blob/master/51.md>
#[inline]
pub fn public_chats<I>(chat: I) -> Self
where
I: IntoIterator<Item = EventId>,
{
Self::new(Kind::PublicChats, "").tags(chat.into_iter().map(Tag::event))
}
/// Blocked relays
///
/// <https://github.com/nostr-protocol/nips/blob/master/51.md>
#[inline]
pub fn blocked_relays<I>(relay: I) -> Self
where
I: IntoIterator<Item = RelayUrl>,
{
Self::new(Kind::BlockedRelays, "")
.tags(relay.into_iter().map(|r| Nip51Tag::Relay(r).to_tag()))
}
/// Search relays
///
/// <https://github.com/nostr-protocol/nips/blob/master/51.md>
#[inline]
pub fn search_relays<I>(relay: I) -> Self
where
I: IntoIterator<Item = RelayUrl>,
{
Self::new(Kind::SearchRelays, "")
.tags(relay.into_iter().map(|r| Nip51Tag::Relay(r).to_tag()))
}
/// Interests
///
/// <https://github.com/nostr-protocol/nips/blob/master/51.md>
#[inline]
pub fn interests(list: Interests) -> Self {
let tags: Vec<Tag> = list.into();
Self::new(Kind::Interests, "").tags(tags)
}
/// Emojis
///
/// <https://github.com/nostr-protocol/nips/blob/master/51.md>
#[inline]
pub fn emojis(list: Emojis) -> Self {
let tags: Vec<Tag> = list.into();
Self::new(Kind::Emojis, "").tags(tags)
}
/// Follow set
///
/// <https://github.com/nostr-protocol/nips/blob/master/51.md>
pub fn follow_set<ID, I>(identifier: ID, public_keys: I) -> Self
where
ID: Into<String>,
I: IntoIterator<Item = PublicKey>,
{
let tags: Vec<Tag> = vec![Tag::identifier(identifier)];
Self::new(Kind::FollowSet, "").tags(
tags.into_iter()
.chain(public_keys.into_iter().map(Tag::public_key)),
)
}
/// Relay set
///
/// <https://github.com/nostr-protocol/nips/blob/master/51.md>
pub fn relay_set<ID, I>(identifier: ID, relays: I) -> Self
where
ID: Into<String>,
I: IntoIterator<Item = RelayUrl>,
{
let tags: Vec<Tag> = vec![Tag::identifier(identifier)];
Self::new(Kind::RelaySet, "").tags(
tags.into_iter()
.chain(relays.into_iter().map(|r| Nip51Tag::Relay(r).to_tag())),
)
}
/// Bookmark set
///
/// <https://github.com/nostr-protocol/nips/blob/master/51.md>
pub fn bookmarks_set<ID>(identifier: ID, list: Bookmarks) -> Self
where
ID: Into<String>,
{
let mut tags: Vec<Tag> = list.into();
tags.push(Tag::identifier(identifier));
Self::new(Kind::BookmarkSet, "").tags(tags)
}
/// Article Curation set
///
/// <https://github.com/nostr-protocol/nips/blob/master/51.md>
pub fn articles_curation_set<ID>(identifier: ID, list: ArticlesCuration) -> Self
where
ID: Into<String>,
{
let mut tags: Vec<Tag> = list.into();
tags.push(Tag::identifier(identifier));
Self::new(Kind::ArticlesCurationSet, "").tags(tags)
}
/// Videos Curation set
///
/// <https://github.com/nostr-protocol/nips/blob/master/51.md>
pub fn videos_curation_set<ID, I>(identifier: ID, video: I) -> Self
where
ID: Into<String>,
I: IntoIterator<Item = Coordinate>,
{
let tags: Vec<Tag> = vec![Tag::identifier(identifier)];
Self::new(Kind::VideosCurationSet, "").tags(
tags.into_iter()
.chain(video.into_iter().map(|c| Tag::coordinate(c, None))),
)
}
/// Interest set
///
/// <https://github.com/nostr-protocol/nips/blob/master/51.md>
pub fn interest_set<ID, I, S>(identifier: ID, hashtags: I) -> Self
where
ID: Into<String>,
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let tags: Vec<Tag> = vec![Tag::identifier(identifier)];
Self::new(Kind::InterestSet, "").tags(
tags.into_iter()
.chain(hashtags.into_iter().map(Tag::hashtag)),
)
}
/// Emoji set
///
/// <https://github.com/nostr-protocol/nips/blob/master/51.md>
pub fn emoji_set<ID, I>(identifier: ID, emojis: I) -> Self
where
ID: Into<String>,
I: IntoIterator<Item = (String, Url)>,
{
let tags: Vec<Tag> = vec![Tag::identifier(identifier)];
Self::new(Kind::EmojiSet, "").tags(tags.into_iter().chain(emojis.into_iter().map(
|(shortcode, image_url)| {
Nip30Tag::Emoji {
shortcode,
image_url,
emoji_set: None,
}
.to_tag()
},
)))
}
/// Label
///
/// <https://github.com/nostr-protocol/nips/blob/master/32.md>
pub fn label<S1, S2>(namespace: S1, label: S2) -> Self
where
S1: Into<String>,
S2: Into<String>,
{
let namespace: String = namespace.into();
let label: String = label.into();
Self::new(Kind::Label, "").tags([
Nip32Tag::LabelNamespace(namespace.clone()).to_tag(),
Nip32Tag::Label {
value: label,
namespace,
}
.to_tag(),
])
}
/// User Statuses
///
/// <https://github.com/nostr-protocol/nips/blob/master/38.md>
#[inline]
pub fn live_status<S>(status: LiveStatus, content: S) -> Self
where
S: Into<String>,
{
let tags: Vec<Tag> = status.into();
Self::new(Kind::UserStatus, content).tags(tags)
}
/// Code Snippets
///
/// <https://github.com/nostr-protocol/nips/blob/master/C0.md>
#[inline]
pub fn code_snippet(snippet: CodeSnippet) -> Self {
snippet.to_event_builder()
}
/// Git Repository Announcement
///
/// <https://github.com/nostr-protocol/nips/blob/master/34.md>
#[inline]
pub fn git_repository_announcement(
announcement: GitRepositoryAnnouncement,
) -> Result<Self, Error> {
announcement.to_event_builder()
}
/// Git Issue
///
/// <https://github.com/nostr-protocol/nips/blob/master/34.md>
#[inline]
pub fn git_issue(issue: GitIssue) -> Result<Self, Error> {
issue.to_event_builder()
}
/// Git Patch
///
/// <https://github.com/nostr-protocol/nips/blob/master/34.md>
#[inline]
pub fn git_patch(patch: GitPatch) -> Result<Self, Error> {
patch.to_event_builder()
}
/// Git Pull Request
///
/// <https://github.com/nostr-protocol/nips/blob/master/34.md>
#[inline]
pub fn git_pull_request(pull_request: GitPullRequest) -> Result<Self, Error> {
pull_request.to_event_builder()
}
/// Git Pull Request Update
///
/// <https://github.com/nostr-protocol/nips/blob/master/34.md>
#[inline]
pub fn git_pull_request_update(update: GitPullRequestUpdate) -> Result<Self, Error> {
update.to_event_builder()
}
/// Git User Grasp List
///
/// <https://github.com/nostr-protocol/nips/blob/master/34.md>
#[inline]
pub fn git_user_grasp_list(grasp_list: GitUserGraspList) -> Self {
grasp_list.to_event_builder()
}
/// Torrent metadata
///
/// <https://github.com/nostr-protocol/nips/blob/master/35.md>
#[inline]
pub fn torrent(metadata: Torrent) -> Self {
metadata.to_event_builder()
}
// TODO: add `torrent_comment`
/// Create a poll
///
/// <https://github.com/nostr-protocol/nips/blob/master/88.md>
#[inline]
pub fn poll(poll: Poll) -> Self {
poll.to_event_builder()
}
/// Create a poll response
///
/// <https://github.com/nostr-protocol/nips/blob/master/88.md>
#[inline]
pub fn poll_response(response: PollResponse) -> Self {
response.to_event_builder()
}
/// Chat message
///
/// <https://github.com/nostr-protocol/nips/blob/master/C7.md>
#[inline]
pub fn chat_message<S>(content: S) -> Self
where
S: Into<String>,
{
Self::new(Kind::ChatMessage, content)
}
/// Chat message reply
///
/// <https://github.com/nostr-protocol/nips/blob/master/C7.md>
pub fn chat_message_reply<S>(
content: S,
reply_to: &Event,
relay_url: Option<RelayUrl>,
) -> Result<Self, Error>
where
S: Into<String>,
{
let mut content = content.into();
if !has_nostr_event_uri(&content, &reply_to.id) {
let nevent = Nip19Event {
event_id: reply_to.id,
author: None,
kind: None,
relays: relay_url.clone().into_iter().collect(),
};
content = format!("{}\n{content}", nevent.to_nostr_uri()?);
}
Ok(Self::new(Kind::ChatMessage, content).tag(
Nip18Tag::Quote {
id: reply_to.id,
relay_hint: relay_url,
public_key: Some(reply_to.pubkey),
}
.to_tag(),
))
}
/// Thread
///
/// <https://github.com/nostr-protocol/nips/blob/master/7D.md>
#[inline]
pub fn thread<S>(content: S, title: Option<String>) -> Self
where
S: Into<String>,
{
let mut builder = Self::new(Kind::Thread, content);
if let Some(t) = title {
builder = builder.tag(Nip7DTag::Title(t).to_tag());
}
builder
}
/// Thread reply
///
/// <https://github.com/nostr-protocol/nips/blob/master/7D.md>
#[inline]
pub fn thread_reply<S>(content: S, reply_to: &Event, relay_url: Option<RelayUrl>) -> Self
where
S: Into<String>,
{
let tags = vec![
Nip22Tag::Event {
id: reply_to.id,
relay_hint: relay_url,
public_key: Some(reply_to.pubkey),
uppercase: true,
}
.to_tag(),
Nip22Tag::Kind {
kind: Kind::Thread,
uppercase: true,
}
.to_tag(),
];
Self::new(Kind::Comment, content).tags(tags)
}
/// Web Bookmark
///
/// <https://github.com/nostr-protocol/nips/blob/master/B0.md>
#[inline]
pub fn web_bookmark(web_bookmark: WebBookmark) -> Self {
web_bookmark.to_event_builder()
}
}
fn has_nostr_event_uri(content: &str, event_id: &EventId) -> bool {
const OPTS: NostrParserOptions = NostrParserOptions::disable_all().nostr_uris(true);
let parser = NostrParser::new().parse(content).opts(OPTS);
for token in parser.into_iter() {
if let Token::Nostr(nip21) = token {
if nip21.event_id().as_ref() == Some(event_id) {
return true;
}
}
}
false
}
impl FinalizeUnsignedEvent for EventBuilder {
#[inline]
fn finalize_unsigned(self, public_key: PublicKey) -> UnsignedEvent {
UnsignedEvent {
// Not compute event ID, as the user may want POW, so would be an unnecessary computation.
id: None,
pubkey: public_key,
created_at: self.created_at.unwrap_or_else(Timestamp::now),
kind: self.kind,
tags: self.tags,
content: self.content,
}
}
}
impl FinalizeUnsignedEventAsync for EventBuilder {
#[inline]
fn finalize_unsigned_async<'a>(self, public_key: PublicKey) -> BoxedFuture<'a, UnsignedEvent>
where
Self: 'a,
{
Box::pin(async move { self.finalize_unsigned(public_key) })
}
}
impl<S> FinalizeEvent<S> for EventBuilder
where
S: GetPublicKey + SignEvent + ?Sized,
{
type Error = SignerError;
fn finalize(self, signer: &S) -> Result<Event, Self::Error> {
let public_key: PublicKey = signer.get_public_key()?;
let unsigned: UnsignedEvent = self.finalize_unsigned(public_key);
signer.sign_event(unsigned)
}
}
impl<S> FinalizeEventAsync<S> for EventBuilder
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
type Error = SignerError;
fn finalize_async<'a>(self, signer: &'a S) -> BoxedFuture<'a, Result<Event, Self::Error>>
where
Self: 'a,
S: 'a,
{
Box::pin(async move {
let public_key: PublicKey = signer.get_public_key_async().await?;
let unsigned: UnsignedEvent = self.finalize_unsigned(public_key);
signer.sign_event_async(unsigned).await
})
}
}
#[cfg(test)]
mod tests {
#[cfg(all(feature = "std", feature = "os-rng"))]
use core::str::FromStr;
use super::*;
#[cfg(all(feature = "std", feature = "os-rng"))]
use crate::SecretKey;
#[test]
#[cfg(all(feature = "std", feature = "os-rng"))]
fn round_trip() {
let keys = Keys::new(
SecretKey::from_str("6b911fd37cdf5c81d4c0adb1ab7fa822ed253ab0ad9aa18d77257c88b29b718e")
.unwrap(),
);
let event = EventBuilder::text_note("hello").finalize(&keys).unwrap();
let serialized = event.as_json();
let deserialized = Event::from_json(serialized).unwrap();
assert_eq!(event, deserialized);
}
#[test]
fn test_zap_event_builder() {
let bolt11 = "lnbc10u1p3unwfusp5t9r3yymhpfqculx78u027lxspgxcr2n2987mx2j55nnfs95nxnzqpp5jmrh92pfld78spqs78v9euf2385t83uvpwk9ldrlvf6ch7tpascqhp5zvkrmemgth3tufcvflmzjzfvjt023nazlhljz2n9hattj4f8jq8qxqyjw5qcqpjrzjqtc4fc44feggv7065fqe5m4ytjarg3repr5j9el35xhmtfexc42yczarjuqqfzqqqqqqqqlgqqqqqqgq9q9qxpqysgq079nkq507a5tw7xgttmj4u990j7wfggtrasah5gd4ywfr2pjcn29383tphp4t48gquelz9z78p4cq7ml3nrrphw5w6eckhjwmhezhnqpy6gyf0";
let preimage = Some("5d006d2cf1e73c7148e7519a4c68adc81642ce0e25a432b2434c99f97344c15f");
let zap_request_json = String::from(
"{\"pubkey\":\"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245\",\"content\":\"\",\"id\":\"d9cc14d50fcb8c27539aacf776882942c1a11ea4472f8cdec1dea82fab66279d\",\"created_at\":1674164539,\"sig\":\"77127f636577e9029276be060332ea565deaf89ff215a494ccff16ae3f757065e2bc59b2e8c113dd407917a010b3abd36c8d7ad84c0e3ab7dab3a0b0caa9835d\",\"kind\":9734,\"tags\":[[\"e\",\"3624762a1274dd9636e0c552b53086d70bc88c165bc4dc0f9e836a1eaf86c3b8\"],[\"p\",\"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245\"],[\"relays\",\"wss://relay.damus.io\",\"wss://nostr-relay.wlvs.space\",\"wss://nostr.fmt.wiz.biz\",\"wss://relay.nostr.bg\",\"wss://nostr.oxtr.dev\",\"wss://nostr.v0l.io\",\"wss://brb.io\",\"wss://nostr.bitcoiner.social\",\"ws://monad.jb55.com:8080\",\"wss://relay.snort.social\"]]}",
);
let zap_request_event: Event = Event::from_json(zap_request_json).unwrap();
let event_builder = EventBuilder::zap_receipt(bolt11, preimage, &zap_request_event);
assert_eq!(6, event_builder.tags.len());
let has_preimage_tag = event_builder
.tags
.clone()
.iter()
.any(|t| t.kind() == "preimage");
assert!(has_preimage_tag);
}
#[test]
fn test_zap_event_builder_without_preimage() {
let bolt11 = "lnbc10u1p3unwfusp5t9r3yymhpfqculx78u027lxspgxcr2n2987mx2j55nnfs95nxnzqpp5jmrh92pfld78spqs78v9euf2385t83uvpwk9ldrlvf6ch7tpascqhp5zvkrmemgth3tufcvflmzjzfvjt023nazlhljz2n9hattj4f8jq8qxqyjw5qcqpjrzjqtc4fc44feggv7065fqe5m4ytjarg3repr5j9el35xhmtfexc42yczarjuqqfzqqqqqqqqlgqqqqqqgq9q9qxpqysgq079nkq507a5tw7xgttmj4u990j7wfggtrasah5gd4ywfr2pjcn29383tphp4t48gquelz9z78p4cq7ml3nrrphw5w6eckhjwmhezhnqpy6gyf0";
let preimage: Option<&str> = None;
let zap_request_json = String::from(
"{\"pubkey\":\"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245\",\"content\":\"\",\"id\":\"d9cc14d50fcb8c27539aacf776882942c1a11ea4472f8cdec1dea82fab66279d\",\"created_at\":1674164539,\"sig\":\"77127f636577e9029276be060332ea565deaf89ff215a494ccff16ae3f757065e2bc59b2e8c113dd407917a010b3abd36c8d7ad84c0e3ab7dab3a0b0caa9835d\",\"kind\":9734,\"tags\":[[\"e\",\"3624762a1274dd9636e0c552b53086d70bc88c165bc4dc0f9e836a1eaf86c3b8\"],[\"p\",\"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245\"],[\"relays\",\"wss://relay.damus.io\",\"wss://nostr-relay.wlvs.space\",\"wss://nostr.fmt.wiz.biz\",\"wss://relay.nostr.bg\",\"wss://nostr.oxtr.dev\",\"wss://nostr.v0l.io\",\"wss://brb.io\",\"wss://nostr.bitcoiner.social\",\"ws://monad.jb55.com:8080\",\"wss://relay.snort.social\"]]}",
);
let zap_request_event = Event::from_json(zap_request_json).unwrap();
let event_builder = EventBuilder::zap_receipt(bolt11, preimage, &zap_request_event);
assert_eq!(5, event_builder.tags.len());
let has_preimage_tag = event_builder
.tags
.clone()
.iter()
.any(|t| t.kind() == "preimage");
assert!(!has_preimage_tag);
}
#[test]
fn test_badge_definition_event_builder_badge_id_only() {
let badge_id = String::from("bravery");
let event_builder =
EventBuilder::define_badge(badge_id, None, None, None, None, Vec::new());
let has_id = event_builder.tags.clone().iter().any(|t| t.kind() == "d");
assert!(has_id);
assert_eq!(Kind::BadgeDefinition, event_builder.kind);
}
#[test]
fn test_badge_definition_event_builder_full() {
let badge_id = String::from("bravery");
let name = Some(String::from("Bravery"));
let description = Some(String::from("Brave pubkey"));
let image_url = Some(Url::parse("https://nostr.build/someimage/1337").unwrap());
let image_size = Some(ImageDimensions::new(1024, 1024));
let thumbs = vec![(
Url::parse("https://nostr.build/somethumbnail/1337").unwrap(),
Some(ImageDimensions::new(256, 256)),
)];
let event_builder =
EventBuilder::define_badge(badge_id, name, description, image_url, image_size, thumbs);
let has_id = event_builder.tags.clone().iter().any(|t| t.kind() == "d");
assert!(has_id);
assert_eq!(Kind::BadgeDefinition, event_builder.kind);
}
#[test]
#[cfg(all(feature = "std", feature = "os-rng"))]
fn test_badge_award_event_builder() {
let keys = Keys::generate();
let pub_key = keys.public_key();
// Set up badge definition
let badge_definition_event_json = format!(
r#"{{
"id": "4d16822726cefcb45768988c6451b6de5a20b504b8df85efe0808caf346e167c",
"pubkey": "{}",
"created_at": 1677921759,
"kind": 30009,
"tags": [
["d", "bravery"],
["name", "Bravery"],
["description", "A brave soul"]
],
"content": "",
"sig": "cf154350a615f0355d165b52c7ecccce563d9a935801181e9016d077f38d31a1dc992a757ef8d652a416885f33d836cf408c79f5d983d6f1f03c966ace946d59"
}}"#,
pub_key
);
let badge_definition_event: Event =
serde_json::from_str(&badge_definition_event_json).unwrap();
// Set up goal event
let example_event_json = format!(
r#"{{
"content": "",
"id": "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7",
"kind": 8,
"pubkey": "{}",
"sig": "fd0954de564cae9923c2d8ee9ab2bf35bc19757f8e328a978958a2fcc950eaba0754148a203adec29b7b64080d0cf5a32bebedd768ea6eb421a6b751bb4584a8",
"created_at": 1671739153,
"tags": [
["a", "30009:{}:bravery"],
["p", "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"],
["p", "232a4ba3df82ccc252a35abee7d87d1af8fc3cc749e4002c3691434da692b1df"]
]
}}"#,
pub_key, pub_key
);
let example_event: Event = serde_json::from_str(&example_event_json).unwrap();
// Create new event with the event builder
let awarded_pubkeys = vec![
PublicKey::from_str("32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245")
.unwrap(),
PublicKey::from_str("232a4ba3df82ccc252a35abee7d87d1af8fc3cc749e4002c3691434da692b1df")
.unwrap(),
];
let event_builder: Event =
EventBuilder::award_badge(&badge_definition_event, awarded_pubkeys)
.unwrap()
.finalize(&keys)
.unwrap();
assert_eq!(event_builder.kind, Kind::BadgeAward);
assert_eq!(event_builder.content, "");
assert_eq!(event_builder.tags, example_event.tags);
}
#[test]
#[cfg(all(feature = "std", feature = "os-rng"))]
fn test_profile_badges() {
// The pubkey used for profile badges event
let keys = Keys::generate();
let pub_key = keys.public_key();
// Create badge 1
let badge_one_keys = Keys::generate();
let badge_one_pubkey = badge_one_keys.public_key();
let awarded_pubkeys = vec![
pub_key,
PublicKey::from_str("232a4ba3df82ccc252a35abee7d87d1af8fc3cc749e4002c3691434da692b1df")
.unwrap(),
];
let bravery_badge_event =
EventBuilder::define_badge("bravery", None, None, None, None, Vec::new())
.finalize(&badge_one_keys)
.unwrap();
let bravery_badge_award =
EventBuilder::award_badge(&bravery_badge_event, awarded_pubkeys.clone())
.unwrap()
.finalize(&badge_one_keys)
.unwrap();
// Badge 2
let badge_two_keys = Keys::generate();
let badge_two_pubkey = badge_two_keys.public_key();
let honor_badge_event =
EventBuilder::define_badge("honor", None, None, None, None, Vec::new())
.finalize(&badge_two_keys)
.unwrap();
let honor_badge_award =
EventBuilder::award_badge(&honor_badge_event, awarded_pubkeys.clone())
.unwrap()
.finalize(&badge_two_keys)
.unwrap();
let example_event_json = format!(
r#"{{
"content":"",
"id": "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7",
"kind": 10008,
"pubkey": "{pub_key}",
"sig":"fd0954de564cae9923c2d8ee9ab2bf35bc19757f8e328a978958a2fcc950eaba0754148a203adec29b7b64080d0cf5a32bebedd768ea6eb421a6b751bb4584a8",
"created_at":1671739153,
"tags":[
["a", "30009:{badge_one_pubkey}:bravery"],
["e", "{}"],
["a", "30009:{badge_two_pubkey}:honor"],
["e", "{}"]
]
}}"#,
bravery_badge_award.id, honor_badge_award.id,
);
let example_event: Event = serde_json::from_str(&example_event_json).unwrap();
let badge_definitions = vec![bravery_badge_event, honor_badge_event];
let badge_awards = vec![bravery_badge_award, honor_badge_award];
let profile_badges =
EventBuilder::profile_badges(badge_definitions, badge_awards, &pub_key)
.unwrap()
.finalize(&keys)
.unwrap();
assert_eq!(profile_badges.kind, Kind::ProfileBadges);
assert_eq!(profile_badges.tags, example_event.tags);
}
#[test]
#[cfg(all(feature = "std", feature = "os-rng"))]
fn test_text_note_reply() {
let json: &str = r###"{"kind":1,"created_at":1732718325,"content":"## rust-nostr v0.37 is out! 🦀\n\n### Summary\n\nAdd support to NIP17 relay list in SDK (when `gossip` option is enabled), add NIP22 and NIP73 support, \nfix Swift Package, many performance improvements and bug fixes and more!\n\nFrom this release all the rust features are be disabled by default (except `std` feature in `nostr` crate).\n\nFull changelog: https://rust-nostr.org/changelog\n\n### Contributors\n\nThanks to all contributors!\n\n* nostr:npub1zuuajd7u3sx8xu92yav9jwxpr839cs0kc3q6t56vd5u9q033xmhsk6c2uc \n* nostr:npub1q0uulk2ga9dwkp8hsquzx38hc88uqggdntelgqrtkm29r3ass6fq8y9py9 \n* nostr:npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445 \n* nostr:npub1zwnx29tj2lnem8wvjcx7avm8l4unswlz6zatk0vxzeu62uqagcash7fhrf \n* nostr:npub1acxjpdrlk2vw320dxcy3prl87g5kh4c73wp0knullrmp7c4mc7nq88gj3j \n\n### Links\n\nhttps://rust-nostr.org\nhttps://rust-nostr.org/donate\n\n#rustnostr #nostr #rustlang #programming #rust #python #javascript #kotlin #swift #flutter","tags":[["t","rustnostr"],["t","nostr"],["t","rustlang"],["t","programming"],["t","rust"],["t","python"],["t","javascript"],["t","kotlin"],["t","swift"],["t","flutter"],["p","1739d937dc8c0c7370aa27585938c119e25c41f6c441a5d34c6d38503e3136ef","","mention"],["p","03f9cfd948e95aeb04f780382344f7c1cfc0210d9af3f4006bb6d451c7b08692","","mention"],["p","126103bfddc8df256b6e0abfd7f3797c80dcc4ea88f7c2f87dd4104220b4d65f","","mention"],["p","13a665157257e79d9dcc960deeb367fd79383be2d0babb3d861679a5701d463b","","mention"],["p","ee0d20b47fb298e8a9ed3609108fe7f2296bd71e8b82fb4f9ff8f61f62bbc7a6","","mention"]],"pubkey":"68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272","id":"8262a50cf7832351ae3f21c429e111bb31be0cf754ec437e015534bf5cc2eee8","sig":"7e81ff3dfb78ba59b09b48d5218331a3259c56f702a6b8e118938a219879d60e7062e90fc1b070a4c472988d1801ec55714388efc6a4a3876a8a957c5c7808b6"}"###;
let root_event = Event::from_json(json).unwrap();
assert_eq!(root_event.tags.public_keys().count(), 5);
// Build reply
let reply_keys = Keys::generate();
let reply = EventBuilder::text_note_reply("Test reply", &root_event, None, None)
.finalize(&reply_keys)
.unwrap();
assert_eq!(reply.tags.public_keys().count(), 1); // Root author
assert_eq!(reply.tags.public_keys().next().unwrap(), root_event.pubkey);
assert_eq!(reply.tags.event_ids().count(), 1); // Root event ID
assert_eq!(reply.tags.event_ids().next().unwrap(), root_event.id);
// Build reply of reply
let other_keys = Keys::generate();
let reply_of_reply =
EventBuilder::text_note_reply("Test reply of reply", &reply, Some(&root_event), None)
.finalize(&other_keys)
.unwrap();
assert_eq!(reply_of_reply.tags.public_keys().count(), 2); // Reply + root author
let mut pks = reply_of_reply.tags.public_keys();
assert_eq!(pks.next().unwrap(), reply.pubkey);
assert_eq!(pks.next().unwrap(), root_event.pubkey);
assert_eq!(reply_of_reply.tags.event_ids().count(), 2); // Reply + root event IDs
let mut ids = reply_of_reply.tags.event_ids();
assert_eq!(ids.next().unwrap(), reply.id);
assert_eq!(ids.next().unwrap(), root_event.id);
}
#[test]
#[cfg(all(feature = "std", feature = "os-rng"))]
fn replaceable_repost() {
let keys = Keys::generate();
let replaceable = EventBuilder::mute_list(MuteList::default())
.finalize(&keys)
.unwrap();
let repost = EventBuilder::repost(&replaceable, None)
.finalize(&keys)
.unwrap();
assert_eq!(repost.kind, Kind::GenericRepost);
assert_eq!(
repost
.tags
.iter()
.find(|t| t.kind() == "a")
.and_then(|t| Nip01Tag::try_from(t).ok())
.unwrap(),
Nip01Tag::Coordinate {
coordinate: Coordinate::new(replaceable.kind, replaceable.pubkey),
relay_hint: None,
}
);
}
#[test]
#[cfg(all(feature = "std", feature = "os-rng"))]
fn addressable_repost() {
let keys = Keys::generate();
let addressable = EventBuilder::follow_set("lorem", [])
.finalize(&keys)
.unwrap();
let repost = EventBuilder::repost(&addressable, None)
.finalize(&keys)
.unwrap();
assert_eq!(repost.kind, Kind::GenericRepost);
assert_eq!(
repost
.tags
.iter()
.find(|t| t.kind() == "a")
.and_then(|t| Nip01Tag::try_from(t).ok())
.unwrap(),
Nip01Tag::Coordinate {
coordinate: Coordinate::new(addressable.kind, addressable.pubkey)
.identifier("lorem"),
relay_hint: None,
}
);
}
#[test]
#[cfg(all(feature = "std", feature = "os-rng"))]
fn text_note_repost() {
let note_keys = Keys::generate();
let repost_keys = Keys::generate();
let relay_url = RelayUrl::parse("wss://relay.example.com").unwrap();
let note = EventBuilder::text_note("hello")
.finalize(¬e_keys)
.unwrap();
let repost = EventBuilder::repost(¬e, Some(relay_url.clone()))
.finalize(&repost_keys)
.unwrap();
assert_eq!(repost.kind, Kind::Repost);
assert_eq!(repost.content, note.as_json());
assert_eq!(
Nip18Tag::try_from(&repost.tags[0]).unwrap(),
Nip18Tag::Event {
id: note.id,
relay_hint: Some(relay_url),
}
);
assert_eq!(
Nip18Tag::try_from(&repost.tags[1]).unwrap(),
Nip18Tag::PublicKey {
public_key: note.pubkey,
relay_hint: None,
}
);
}
#[test]
#[cfg(all(feature = "std", feature = "os-rng"))]
fn protected_repost_has_empty_content() {
let keys = Keys::generate();
let protected = EventBuilder::text_note("secret")
.tag(Tag::protected())
.finalize(&keys)
.unwrap();
let repost = EventBuilder::repost(&protected, None)
.finalize(&keys)
.unwrap();
assert!(repost.content.is_empty());
}
}
#[cfg(bench)]
#[cfg(all(feature = "std", feature = "os-rng"))]
mod benches {
use test::{Bencher, black_box};
use super::*;
#[bench]
pub fn builder_to_event(bh: &mut Bencher) {
let keys = Keys::generate();
bh.iter(|| {
black_box(EventBuilder::text_note("hello").finalize(&keys)).unwrap();
});
}
}