nostr-1.3.0.0: lib/Nostr/Event.hs
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE OverloadedStrings #-}
{-|
Module : Nostr.Event
Description : NIP-01 compliant Event types and utilities
Copyright : (c) Emre YILMAZ, 2026
License : MIT
Maintainer : z@emre.xyz
This module defines the core Event types according to NIP-01 specification.
-}
module Nostr.Event
( -- * Core Types
EventId(..)
, PubKey(..)
, Signature(..)
, Timestamp
, Kind
, Tag
, Event(..)
-- * Event Kinds
, kindSetMetadata
, kindTextNote
, kindRecommendRelay
, kindContactList
, kindEncryptedDirectMessage
, kindDeletion
, kindAuth
-- * Constructors and Validators
, mkEventId
, mkPubKey
, mkSignature
, validateHex64
, isValidHex
-- * Event Creation
, createUnsignedEvent
, validateEvent
-- * Serialization
, serializeForSigning
) where
import Data.Aeson (FromJSON(..), ToJSON(..), Value, object, toJSON, withObject, (.:), (.=))
import Data.Text (Text)
import qualified Data.Text as T
import Data.Word (Word64)
import Data.Char (isHexDigit)
import GHC.Generics (Generic)
-- | Unix timestamp in seconds
type Timestamp = Word64
-- | Event kind as defined in NIP-01
-- Kinds determine how the event should be interpreted
type Kind = Int
-- | Standard event kinds
kindSetMetadata :: Kind
kindSetMetadata = 0
kindTextNote :: Kind
kindTextNote = 1
kindRecommendRelay :: Kind
kindRecommendRelay = 2
kindContactList :: Kind
kindContactList = 3
kindEncryptedDirectMessage :: Kind
kindEncryptedDirectMessage = 4
kindDeletion :: Kind
kindDeletion = 5
kindAuth :: Kind
kindAuth = 22242
-- | Tag is a JSON array of strings
-- Examples: ["e", "event_id"], ["p", "pubkey"]
type Tag = [Text]
-- | Event ID - 64-character lowercase hex string (32 bytes)
-- Computed as SHA-256 hash of the serialized event data
newtype EventId = EventId { unEventId :: Text }
deriving (Show, Eq, Ord, Generic)
-- | Public key - 64-character lowercase hex string (32 bytes)
-- secp256k1 public key of the event creator
newtype PubKey = PubKey { unPubKey :: Text }
deriving (Show, Eq, Ord, Generic)
-- | Schnorr signature - 128-character lowercase hex string (64 bytes)
-- Signature over the event ID
newtype Signature = Signature { unSignature :: Text }
deriving (Show, Eq, Ord, Generic)
-- | Check if a character is a valid hex digit (0-9, a-f)
isValidHex :: Char -> Bool
isValidHex c = isHexDigit c && (c >= '0' && c <= '9' || c >= 'a' && c <= 'f')
-- | Validate that a Text is exactly 64 lowercase hex characters
validateHex64 :: Text -> Bool
validateHex64 t = T.length t == 64 && T.all isValidHex t
-- | Validate that a Text is exactly 128 lowercase hex characters (for signatures)
validateHex128 :: Text -> Bool
validateHex128 t = T.length t == 128 && T.all isValidHex t
-- | Smart constructor for EventId with validation
mkEventId :: Text -> Either Text EventId
mkEventId t
| validateHex64 t = Right (EventId t)
| otherwise = Left $ "Invalid EventId: must be 64 lowercase hex characters, got: " <> t
-- | Smart constructor for PubKey with validation
mkPubKey :: Text -> Either Text PubKey
mkPubKey t
| validateHex64 t = Right (PubKey t)
| otherwise = Left $ "Invalid PubKey: must be 64 lowercase hex characters, got: " <> t
-- | Smart constructor for Signature with validation
mkSignature :: Text -> Either Text Signature
mkSignature t
| validateHex128 t = Right (Signature t)
| otherwise = Left $ "Invalid Signature: must be 128 lowercase hex characters, got: " <> t
-- | Nostr Event as defined in NIP-01
-- All events must have these fields in this exact structure
data Event = Event
{ eventId :: EventId -- ^ 32-byte SHA-256 hash, lowercase hex
, eventPubkey :: PubKey -- ^ 32-byte secp256k1 public key, lowercase hex
, eventCreatedAt :: Timestamp -- ^ Unix timestamp in seconds
, eventKind :: Kind -- ^ Event kind (0-65535)
, eventTags :: [Tag] -- ^ Array of tags (each tag is an array of strings)
, eventContent :: Text -- ^ Arbitrary string content
, eventSig :: Signature -- ^ 64-byte Schnorr signature, lowercase hex
} deriving (Show, Eq, Generic)
-- | JSON serialization for EventId
instance ToJSON EventId where
toJSON (EventId t) = toJSON t
instance FromJSON EventId where
parseJSON v = do
t <- parseJSON v
case mkEventId t of
Right eid -> return eid
Left err -> fail (T.unpack err)
-- | JSON serialization for PubKey
instance ToJSON PubKey where
toJSON (PubKey t) = toJSON t
instance FromJSON PubKey where
parseJSON v = do
t <- parseJSON v
case mkPubKey t of
Right pk -> return pk
Left err -> fail (T.unpack err)
-- | JSON serialization for Signature
instance ToJSON Signature where
toJSON (Signature t) = toJSON t
instance FromJSON Signature where
parseJSON v = do
t <- parseJSON v
case mkSignature t of
Right sig -> return sig
Left err -> fail (T.unpack err)
-- | JSON serialization for Event (NIP-01 format)
instance ToJSON Event where
toJSON event = object
[ "id" .= eventId event
, "pubkey" .= eventPubkey event
, "created_at" .= eventCreatedAt event
, "kind" .= eventKind event
, "tags" .= eventTags event
, "content" .= eventContent event
, "sig" .= eventSig event
]
instance FromJSON Event where
parseJSON = withObject "Event" $ \v -> Event
<$> v .: "id"
<*> v .: "pubkey"
<*> v .: "created_at"
<*> v .: "kind"
<*> v .: "tags"
<*> v .: "content"
<*> v .: "sig"
-- | Create an unsigned event (with dummy id and signature)
-- This should be signed using the Crypto module
createUnsignedEvent
:: PubKey -- ^ Public key of the creator
-> Timestamp -- ^ Creation timestamp
-> Kind -- ^ Event kind
-> [Tag] -- ^ Tags
-> Text -- ^ Content
-> Event
createUnsignedEvent pubkey created kind tags content = Event
{ eventId = EventId (T.replicate 64 "0") -- Placeholder, to be computed
, eventPubkey = pubkey
, eventCreatedAt = created
, eventKind = kind
, eventTags = tags
, eventContent = content
, eventSig = Signature (T.replicate 128 "0") -- Placeholder, to be computed
}
-- | Validate that an event has well-formed fields
validateEvent :: Event -> Either Text ()
validateEvent event = do
-- EventId validation (already validated by newtype)
when (eventId event == EventId (T.replicate 64 "0")) $
Left "Event has placeholder ID (not computed yet)"
-- Signature validation
when (eventSig event == Signature (T.replicate 128 "0")) $
Left "Event has placeholder signature (not signed yet)"
-- Kind validation (should be in valid range)
when (eventKind event < 0 || eventKind event > 65535) $
Left "Event kind must be between 0 and 65535"
-- Timestamp validation (basic sanity check)
when (eventCreatedAt event == 0) $
Left "Event timestamp cannot be zero"
return ()
where
when :: Bool -> Either Text () -> Either Text ()
when True action = action
when False _ = Right ()
-- | Serialize event for signing (NIP-01 format)
-- Returns the JSON array: [0, <pubkey>, <created_at>, <kind>, <tags>, <content>]
-- This is used by the Crypto module to compute the event ID
serializeForSigning :: Event -> Value
serializeForSigning event = toJSON
[ toJSON (0 :: Int)
, toJSON (unPubKey (eventPubkey event))
, toJSON (eventCreatedAt event)
, toJSON (eventKind event)
, toJSON (eventTags event)
, toJSON (eventContent event)
]