diff --git a/.fdignore b/.fdignore index 691ae4c..603fd55 100644 --- a/.fdignore +++ b/.fdignore @@ -1,5 +1,5 @@ node_modules -docs +#docs docs/reference docs/.vitepress/cache build diff --git a/docs/util/address.md b/docs/util/address.md index 703ca12..78e8c01 100644 --- a/docs/util/address.md +++ b/docs/util/address.md @@ -1,110 +1,106 @@ # Nostr Address -The Address module provides utilities for working with Nostr Addresses (NIP-19 naddr format) and handles the conversion between different address formats. +The Address module provides utilities for working with Nostr Addresses (NIP-19 naddr format) and handles the conversion between different address formats. Addresses are used to reference addressable events (kinds 10000-39999) in a format that includes kind, pubkey, and identifier. -## Address Class +## API ```typescript -class Address { +// Address class for handling addressable events +export declare class Address { constructor( - readonly kind: number, // Event kind - readonly pubkey: string, // Author's public key - readonly identifier: string, // Unique identifier (d-tag) - readonly relays?: string[], // Optional relay hints - ) + kind: number, + pubkey: string, + identifier: string, + relays?: string[] + ); + + // Check if string is a valid address format + static isAddress(address: string): boolean; + + // Parse address from string format "kind:pubkey:identifier" + static from(address: string, relays?: string[]): Address; + + // Parse address from naddr (NIP-19 format) + static fromNaddr(naddr: string): Address; + + // Create address from addressable event + static fromEvent(event: AddressableEvent, relays?: string[]): Address; + + // Convert to string format "kind:pubkey:identifier" + toString(): string; + + // Convert to naddr (NIP-19 format) + toNaddr(): string; } -``` -## Creating Addresses - -### From Components -```typescript -const address = new Address( - 30023, // kind (e.g., long-form article) - 'ab82...123', // pubkey - 'my-article-title', // identifier - ['wss://relay.example.com'] // relays -) -``` - -### From String Format -```typescript -// Parse "kind:pubkey:identifier" format -const address = Address.from('30023:ab82...123:my-article-title') - -// With optional relays -const address = Address.from( - '30023:ab82...123:my-article-title', - ['wss://relay.example.com'] -) -``` - -### From Naddr -```typescript -// Parse naddr format -const address = Address.fromNaddr('naddr1...') -``` - -### From Event -```typescript -const address = Address.fromEvent(event, relays) -``` - -## Converting Addresses - -### To String -```typescript -const address = new Address(kind, pubkey, identifier) -address.toString() // => "kind:pubkey:identifier" -``` - -### To Naddr -```typescript -const address = new Address(kind, pubkey, identifier, relays) -address.toNaddr() // => "naddr1..." -``` - -## Utility Functions - -### Check Address Format -```typescript -// Check if string is valid address format -Address.isAddress('30023:abc...123:title') // => true -Address.isAddress('not-an-address') // => false -``` - -### Get Address from Event -```typescript -import { getAddress } from '@welshman/util' - -// Extract address from event -const address = getAddress(event) +// Utility function to get address string from event +export declare const getAddress: (e: AddressableEvent) => string; ``` ## Examples -### Working with Long-form Content +### Creating and parsing addresses + ```typescript -// Create address for article -const articleAddress = new Address( - 30023, // Long-form content kind - authorPubkey, - 'my-article-slug', +import { Address } from '@welshman/util'; + +// Create address from components +const address = new Address( + 30023, + '27067f0efd1b9ffc6d71672a1b69a4e5ac3b8ce3cc8428b06849448e38d69389', + 'my-article', ['wss://relay.example.com'] -) +); -// Convert to string format for storage -const addressString = articleAddress.toString() +// Parse from string format +const parsed = Address.from('30023:27067f0efd1b9ffc6d71672a1b69a4e5ac3b8ce3cc8428b06849448e38d69389:my-article'); +console.log(parsed.kind); // 30023 +console.log(parsed.identifier); // 'my-article' -// Convert to naddr for sharing -const shareableAddress = articleAddress.toNaddr() +// Check if string is valid address +const isValid = Address.isAddress('30023:27067f0efd1b9ffc6d71672a1b69a4e5ac3b8ce3cc8428b06849448e38d69389:my-article'); // true +const isInvalid = Address.isAddress('invalid-format'); // false ``` -### Handling Replaceable Events +### Converting between formats + ```typescript -// Create address from replaceable event -const address = Address.fromEvent(event) +import { Address } from '@welshman/util'; -// Store latest version using address as key -storage.set(address.toString(), event) +const address = new Address(30023, '27067f0efd1b9ffc6d71672a1b69a4e5ac3b8ce3cc8428b06849448e38d69389', 'my-article'); + +// Convert to string format +const addressString = address.toString(); +console.log(addressString); // '30023:27067f0efd1b9ffc6d71672a1b69a4e5ac3b8ce3cc8428b06849448e38d69389:my-article' + +// Convert to naddr format (NIP-19) +const naddr = address.toNaddr(); +console.log(naddr); // 'naddr1...' + +// Parse back from naddr +const fromNaddr = Address.fromNaddr(naddr); +console.log(fromNaddr.kind); // 30023 +``` + +### Working with events + +```typescript +import { Address, getAddress } from '@welshman/util'; + +const event = { + kind: 30023, + pubkey: '27067f0efd1b9ffc6d71672a1b69a4e5ac3b8ce3cc8428b06849448e38d69389', + tags: [ + ['d', 'my-article'], + ['title', 'My Article Title'] + ] +}; + +// Create address from event +const address = Address.fromEvent(event, ['wss://relay.example.com']); +console.log(address.identifier); // 'my-article' + +// Get address string directly +const addressString = getAddress(event); +console.log(addressString); // '30023:27067f0efd1b9ffc6d71672a1b69a4e5ac3b8ce3cc8428b06849448e38d69389:my-article' ``` diff --git a/docs/util/encryptable.md b/docs/util/encryptable.md index 535b468..eaaead1 100644 --- a/docs/util/encryptable.md +++ b/docs/util/encryptable.md @@ -1,162 +1,91 @@ # Encryptable -The Encryptable module provides a system for handling encrypted Nostr events, particularly useful for private content like muted lists, bookmarks, or other encrypted user data. +The Encryptable module provides utilities for handling encrypted Nostr events, allowing you to merge plaintext updates into events and encrypt them before publishing. -## Core Types - -### Encrypt Function -```typescript -type Encrypt = (x: string) => Promise -``` - -### Encryptable Updates -```typescript -type EncryptableUpdates = { - content?: string - tags?: string[][] -} -``` - -### Decrypted Event -```typescript -type DecryptedEvent = TrustedEvent & { - plaintext: EncryptableUpdates -} -``` - -## Encryptable Class +## API ```typescript -class Encryptable { +// Encryption function type +export type Encrypt = (x: string) => Promise; + +// Partial event content for updates +export type EncryptableUpdates = Partial; + +// Event with attached plaintext data +export type DecryptedEvent = TrustedEvent & { + plaintext: EncryptableUpdates; +}; + +// Creates a DecryptedEvent by attaching plaintext to an event +export declare const asDecryptedEvent: ( + event: TrustedEvent, + plaintext?: EncryptableUpdates +) => DecryptedEvent; + +// Encryptable class for handling encrypted events +export declare class Encryptable { constructor( - readonly event: Partial, // Base event template - readonly updates: EncryptableUpdates // Plaintext updates - ) + event: Partial, + updates: EncryptableUpdates + ); + + // Encrypts updates and merges them into the event + reconcile(encrypt: Encrypt): Promise; } ``` -## Usage - -### Basic Encryption -```typescript -// Create encryptable event -const encryptable = new Encryptable( - { kind: 10000 }, // Base event - { content: "secret content" } // Plaintext updates -) - -// Encrypt and get final event -const event = await encryptable.reconcile(encryptFn) -``` - -### Private Lists -```typescript -// Create private mute list -const muteList = new Encryptable( - { - kind: 10000, // Mute list kind - tags: [] // Public tags - }, - { - content: JSON.stringify(['pubkey1', 'pubkey2']), // Private content - tags: [['p', 'pubkey1'], ['p', 'pubkey2']] // Private tags - } -) - -// Encrypt for publishing -const encrypted = await muteList.reconcile(async (content) => { - return await nip04.encrypt(pubkey, content) -}) -``` - -### Updating Encrypted Content -```typescript -// Create encryptable from existing event -const existing = { - kind: 10000, - content: encryptedContent, - tags: publicTags -} - -// Add new encrypted content -const updated = new Encryptable( - existing, - { - content: JSON.stringify(newContent), - tags: newPrivateTags - } -) - -const final = await updated.reconcile(encrypt) -``` - -## Helper Functions - -### Create Decrypted Event -```typescript -import { asDecryptedEvent } from '@welshman/util' - -// Add plaintext content to event -const decrypted = asDecryptedEvent( - event, - { - content: decryptedContent, - tags: decryptedTags - } -) -``` - ## Examples -### Private Bookmarks +### Basic Usage + ```typescript -// Create private bookmark list -const bookmarks = new Encryptable( +import { Encryptable } from '@welshman/util'; + +// Create encryptable with plaintext updates +const encryptable = new Encryptable( + { kind: 10000 }, // Base event template + { content: "secret mute list data" } // Plaintext content to encrypt +); + +// Encrypt and get final event +const encryptFn = async (text: string) => { + // Your encryption logic here + return await encrypt(text); +}; + +const event = await encryptable.reconcile(encryptFn); +// event.content is now encrypted +``` + +### Encrypting Tags + +```typescript +import { Encryptable } from '@welshman/util'; + +// Encrypt both content and tag values +const encryptable = new Encryptable( + { kind: 10000, tags: [] }, { - kind: 10003, - tags: [['d', 'bookmarks']] // Public identifier - }, - { - content: JSON.stringify([ - { id: 'note1', title: 'Secret Note' } - ]) + content: JSON.stringify(['pubkey1', 'pubkey2']), + tags: [['p', 'sensitive-pubkey'], ['e', 'sensitive-event-id']] } -) +); -// Encrypt for publishing -const event = await bookmarks.reconcile(async (content) => { - return await myEncryptionFunction(content) -}) +// The reconcile method encrypts tag values at index 1 +const event = await encryptable.reconcile(encryptFn); +// event.tags[0] = ['p', 'encrypted-pubkey'] +// event.tags[1] = ['e', 'encrypted-event-id'] ``` -### Encrypted Group Membership +### Working with Decrypted Events + ```typescript -// Create private group member list -const members = new Encryptable( - { - kind: 30000, - tags: [['d', 'group-members']] - }, - { - tags: members.map(m => ['p', m.pubkey, m.role]) - } -) +import { asDecryptedEvent } from '@welshman/util'; -const encrypted = await members.reconcile(encrypt) -``` - -### Updating Private Content -```typescript -function updatePrivateList(event: DecryptedEvent, newItems: string[]) { - return new Encryptable( - event, - { - content: JSON.stringify(newItems) - } - ) -} - -// Usage -const updated = updatePrivateList(existingEvent, newItems) -const final = await updated.reconcile(encrypt) +// Add plaintext data to an event for reference +const event = { kind: 10000, content: "encrypted...", tags: [] }; +const plaintext = { content: "original content", tags: [['p', 'pubkey']] }; + +const decryptedEvent = asDecryptedEvent(event, plaintext); +console.log(decryptedEvent.plaintext.content); // "original content" ``` diff --git a/docs/util/events.md b/docs/util/events.md index 157fde9..39f213d 100644 --- a/docs/util/events.md +++ b/docs/util/events.md @@ -1,185 +1,205 @@ # Nostr Events -The Events module provides comprehensive type definitions and utilities for working with Nostr events, including helper functions for event creation, validation, and manipulation. +The Events module provides type definitions and utilities for working with Nostr events, including creation, validation, and manipulation functions. -## Event Types Hierarchy +## API + +### Event Types ```typescript -// Base event with content and tags -interface EventContent { - tags: string[][] - content: string -} +// Base event content structure +export type EventContent = { + tags: string[][]; + content: string; +}; -// Base event with kind -interface EventTemplate extends EventContent { - kind: number -} +// Event template with kind +export type EventTemplate = EventContent & { + kind: number; +}; // Event with timestamp -interface StampedEvent extends EventTemplate { - created_at: number -} +export type StampedEvent = EventTemplate & { + created_at: number; +}; // Event with author -interface OwnedEvent extends StampedEvent { - pubkey: string -} +export type OwnedEvent = StampedEvent & { + pubkey: string; +}; // Event with ID -interface HashedEvent extends OwnedEvent { - id: string -} +export type HashedEvent = OwnedEvent & { + id: string; +}; -// Event with signature -interface SignedEvent extends HashedEvent { - sig: string - [verifiedSymbol]?: boolean -} +// Signed event +export type SignedEvent = HashedEvent & { + sig: string; +}; -// Event with wrapped content -interface UnwrappedEvent extends HashedEvent { - wrap: SignedEvent -} +// Wrapped event (NIP-59) +export type UnwrappedEvent = HashedEvent & { + wrap: SignedEvent; +}; // Event that can be either signed or wrapped -type TrustedEvent = HashedEvent & { - sig?: string - wrap?: SignedEvent - [verifiedSymbol]?: boolean -} +export type TrustedEvent = HashedEvent & { + sig?: string; + wrap?: SignedEvent; +}; ``` -## Event Creation - -### Create Basic Event -```typescript -import { createEvent } from '@welshman/util' - -const event = createEvent( - 1, // kind - { - content: "Hello Nostr!", - tags: [["t", "nostr"]], - created_at: now() // Optional, defaults to current time - } -) -``` - -## Type Guards +### Event Creation ```typescript -// Check event types -isEventTemplate(event): boolean -isStampedEvent(event): boolean -isOwnedEvent(event): boolean -isHashedEvent(event): boolean -isSignedEvent(event): boolean -isUnwrappedEvent(event): boolean -isTrustedEvent(event): boolean +// Options for creating events +export type MakeEventOpts = { + content?: string; + tags?: string[][]; + created_at?: number; +}; + +// Creates a stamped event template +export declare const makeEvent: (kind: number, opts?: MakeEventOpts) => StampedEvent; ``` -## Event Type Conversion +### Type Guards ```typescript -// Convert to specific event types -asEventTemplate(event): EventTemplate -asStampedEvent(event): StampedEvent -asOwnedEvent(event): OwnedEvent -asHashedEvent(event): HashedEvent -asSignedEvent(event): SignedEvent -asUnwrappedEvent(event): UnwrappedEvent -asTrustedEvent(event): TrustedEvent +export declare const isEventTemplate: (e: EventTemplate) => e is EventTemplate; +export declare const isStampedEvent: (e: StampedEvent) => e is StampedEvent; +export declare const isOwnedEvent: (e: OwnedEvent) => e is OwnedEvent; +export declare const isHashedEvent: (e: HashedEvent) => e is HashedEvent; +export declare const isSignedEvent: (e: TrustedEvent) => e is SignedEvent; +export declare const isUnwrappedEvent: (e: TrustedEvent) => e is UnwrappedEvent; +export declare const isTrustedEvent: (e: TrustedEvent) => e is TrustedEvent; ``` -## Event Utilities +### Event Utilities -### Event Validation ```typescript -// Check if event has valid signature -hasValidSignature(event: SignedEvent): boolean +// Event validation and signatures +export declare const verifyEvent: (event: TrustedEvent) => boolean; -// Get event identifier (d tag) -getIdentifier(event: EventTemplate): string | undefined +// Event properties +export declare const getIdentifier: (e: EventTemplate) => string | undefined; +export declare const getIdOrAddress: (e: HashedEvent) => string; +export declare const getIdAndAddress: (e: HashedEvent) => string[]; + +// Event type checking +export declare const isEphemeral: (e: EventTemplate) => boolean; +export declare const isReplaceable: (e: EventTemplate) => boolean; +export declare const isPlainReplaceable: (e: EventTemplate) => boolean; +export declare const isParameterizedReplaceable: (e: EventTemplate) => boolean; + +// Thread and reply handling +// Note: getAncestors handles comments (kind 1111) differently from regular notes +export declare const getAncestors: (event: EventTemplate) => { roots: string[]; replies: string[] }; +export declare const getParentIdsAndAddrs: (event: EventTemplate) => string[]; +export declare const getParentIdOrAddr: (event: EventTemplate) => string | undefined; +export declare const getParentId: (event: EventTemplate) => string | undefined; +export declare const getParentAddr: (event: EventTemplate) => string | undefined; +export declare const isChildOf: (child: EventTemplate, parent: HashedEvent) => boolean; ``` -### Event References -```typescript -// Get event ID or address -getIdOrAddress(event: HashedEvent): string +## Threading Protocols -// Get both ID and address (if replaceable) -getIdAndAddress(event: HashedEvent): string[] -``` +The `getAncestors` function handles two different threading protocols: -### Event Type Checking -```typescript -// Check event properties -isEphemeral(event: EventTemplate): boolean -isReplaceable(event: EventTemplate): boolean -isPlainReplaceable(event: EventTemplate): boolean -isParameterizedReplaceable(event: EventTemplate): boolean -``` +### Regular Notes (NIP-10) +For regular notes and most event kinds, threading follows [NIP-10](https://github.com/nostr-protocol/nips/blob/master/10.md): +- Uses `e` and `a` tags with optional markers (`root`, `reply`, `mention`) +- Positional rules apply when markers are absent: + - First `e`/`a` tag = root + - Last `e`/`a` tag = reply target + - Middle tags = mentions -### Thread & Reply Handling -```typescript -// Get thread information -getAncestors(event: EventTemplate): { roots: string[], replies: string[] } +### Comments (NIP-22) +For comments (kind 1111), threading follows [NIP-22](https://github.com/nostr-protocol/nips/blob/master/22.md): +- Uses uppercase tags (`E`, `A`, `P`, `K`) for root references +- Uses lowercase tags (`e`, `a`, `p`, `k`) for reply references +- No positional rules - explicit tag types determine relationship -// Get parent references -getParentIdsAndAddrs(event: EventTemplate): string[] -getParentIdOrAddr(event: EventTemplate): string | undefined -getParentId(event: EventTemplate): string | undefined -getParentAddr(event: EventTemplate): string | undefined - -// Check reply relationship -isChildOf(child: EventTemplate, parent: HashedEvent): boolean -``` +All `getParent*` functions and `isChildOf` include this logic, automatically handling both protocols based on event kind. ## Examples -### Creating and Processing Events +### Creating Events ```typescript -// Create new event -const event = createEvent(1, { - content: "Hello world!", - tags: [["t", "greeting"]] -}) +import { makeEvent, NOTE, LONG_FORM } from '@welshman/util'; -// Process based on type -if (isSignedEvent(event)) { - // Handle signed event - if (hasValidSignature(event)) { - processValidEvent(event) - } -} else if (isUnwrappedEvent(event)) { - // Handle wrapped event - processWrappedEvent(event) -} +// Create a basic note +const note = makeEvent(NOTE, { + content: "Hello Nostr!", + tags: [["t", "nostr"]] +}); + +// Create a long-form article with custom timestamp +const article = makeEvent(LONG_FORM, { + content: "# My Article\n\nThis is my article content...", + tags: [["d", "my-article"], ["title", "My Article"]], + created_at: 1234567890 +}); +``` + +### Event Properties + +```typescript +import { getIdentifier, getIdOrAddress, LONG_FORM } from '@welshman/util'; + +const article = makeEvent(LONG_FORM, { + content: "Article content...", + tags: [["d", "my-unique-id"]] +}); + +// Get the identifier (d tag value) +const identifier = getIdentifier(article); // "my-unique-id" + +// For a hashed event, get ID or address +const reference = getIdOrAddress(hashedArticle); +// Returns address for replaceable events, ID for others ``` ### Working with Threads ```typescript -// Get thread context -const ancestors = getAncestors(event) -const rootId = ancestors.roots[0] -const replyTo = ancestors.replies[0] +import { getAncestors, isChildOf, NOTE, COMMENT } from '@welshman/util'; -// Check threading -if (isChildOf(event, parentEvent)) { - // Handle reply +// Regular note reply (NIP-10) +const noteReply = makeEvent(NOTE, { + content: "This is a reply to a note", + tags: [ + ["e", "root-event-id", "", "root"], + ["e", "parent-event-id", "", "reply"] + ] +}); + +// Comment reply (NIP-22) +const commentReply = makeEvent(COMMENT, { + content: "This is a reply comment", + tags: [ + ["E", "root-event-id"], // uppercase = root reference + ["e", "parent-event-id"] // lowercase = reply reference + ] +}); + +// Both work the same way +const noteAncestors = getAncestors(noteReply); +const commentAncestors = getAncestors(commentReply); + +console.log('Note roots:', noteAncestors.roots); // ["root-event-id"] +console.log('Note replies:', noteAncestors.replies); // ["parent-event-id"] + +console.log('Comment roots:', commentAncestors.roots); // ["root-event-id"] +console.log('Comment replies:', commentAncestors.replies); // ["parent-event-id"] + +// Parent checking works for both protocols +if (isChildOf(noteReply, parentEvent)) { + console.log('Note is a reply'); +} +if (isChildOf(commentReply, parentEvent)) { + console.log('Comment is a reply'); } ``` - -### Type Conversion - -```typescript -// Convert to needed type -const template = asEventTemplate(event) -const stamped = asStampedEvent(event) -const owned = asOwnedEvent(event) -const hashed = asHashedEvent(event) -const signed = asSignedEvent(event) -``` diff --git a/docs/util/filters.md b/docs/util/filters.md index 5f1d550..ad20a31 100644 --- a/docs/util/filters.md +++ b/docs/util/filters.md @@ -1,165 +1,188 @@ # Filters -The Filters module provides utilities for creating, manipulating, and matching Nostr event filters. -It includes support for filter operations, optimization, and time-based filtering. +The Filters module provides utilities for creating, manipulating, and matching Nostr event filters. It includes support for filter operations, optimization, and time-based filtering. -## Core Types +## API + +### Filter Matching ```typescript -interface Filter { - ids?: string[] // Match specific event IDs - kinds?: number[] // Match event kinds - authors?: string[] // Match author pubkeys - since?: number // Match events since timestamp - until?: number // Match events until timestamp - limit?: number // Limit number of results - search?: string // Text search - [key: `#${string}`]: string[] // Tag filters -} +// Check if an event matches a filter +export declare const matchFilter: (filter: Filter, event: E) => boolean; + +// Check if an event matches any filter in array +export declare const matchFilters: (filters: Filter[], event: E) => boolean; ``` -## Filter Operations +### Filter Operations -### Match Events ```typescript -// Match single filter -matchFilter(filter: Filter, event: HashedEvent): boolean +// Get a compact string representation of a filter +export declare const getFilterId: (filter: Filter) => string; -// Match multiple filters -matchFilters(filters: Filter[], event: HashedEvent): boolean +// Combine multiple filters into minimal filter set +export declare const unionFilters: (filters: Filter[]) => Filter[]; + +// Create intersection of filter groups +export declare const intersectFilters: (groups: Filter[][]) => Filter[]; + +// Trim large filter arrays to avoid relay limits +export declare const trimFilter: (filter: Filter) => Filter; +export declare const trimFilters: (filters: Filter[]) => Filter[]; ``` -### Combine Filters -```typescript -// Combine filters with OR operation -unionFilters(filters: Filter[]): Filter[] +### Specialized Filter Creation -// Combine filters with AND operation -intersectFilters(groups: Filter[][]): Filter[] +```typescript +// Create filters for finding events by ID or address +export declare const getIdFilters: (idsOrAddresses: string[]) => Filter[]; + +// Create filters for finding replies to events +export declare const getReplyFilters: (events: TrustedEvent[], filter?: Filter) => Filter[]; + +// Add repost filters (kinds 6, 16) to existing filters +export declare const addRepostFilters: (filters: Filter[]) => Filter[]; ``` -### Filter Utilities -```typescript -// Get unique filter ID -getFilterId(filter: Filter): string - -// Calculate filter group -calculateFilterGroup(filter: Filter): string - -// Get filters for event IDs or addresses -getIdFilters(idsOrAddresses: string[]): Filter[] - -// Get filters for reply events -getReplyFilters(events: TrustedEvent[], filter?: Filter): Filter[] - -// Add repost filters -addRepostFilters(filters: Filter[]): Filter[] -``` - -## Time Constants +### Filter Analysis ```typescript -// Unix epoch for Nostr (2021-01-01) -export const EPOCH = 1609459200 +// Calculate filter generality (0 = specific, 1 = very general) +export declare const getFilterGenerality: (filter: Filter) => number; -// One day in seconds -export const DAY = 86400 +// Estimate time delta for filter results +export declare const guessFilterDelta: (filters: Filter[], max?: number) => number; + +// Get expected result count for ID-based filters +export declare const getFilterResultCardinality: (filter: Filter) => number | undefined; ``` ## Examples -### Basic Filtering +### Basic Filter Matching ```typescript -// Create basic filter -const filter: Filter = { - kinds: [1], // Text notes - authors: ['pubkey1', 'pubkey2'], - since: now() - 24 * 60 * 60, // Last 24 hours - limit: 100 -} +import { matchFilter, matchFilters, NOTE, LONG_FORM } from '@welshman/util'; -// Match event against filter -if (matchFilter(filter, event)) { - processEvent(event) -} +const event = { + id: 'abc123...', + kind: 1, + pubkey: 'def456...', + created_at: 1234567890, + content: 'Hello Nostr!', + tags: [['t', 'nostr']] +}; + +// Single filter matching +const filter = { kinds: [NOTE], authors: ['def456...'] }; +const matches = matchFilter(filter, event); // true + +// Multiple filter matching +const filters = [ + { kinds: [NOTE] }, + { kinds: [LONG_FORM], authors: ['def456...'] } +]; +const matchesAny = matchFilters(filters, event); // true (matches first filter) ``` -### Combining Filters +### Creating Filters for IDs and Addresses ```typescript -// Union of filters (OR) -const combinedFilters = unionFilters([ - { kinds: [1], authors: ['pub1'] }, - { kinds: [1], authors: ['pub2'] } -]) +import { getIdFilters } from '@welshman/util'; -// Intersection of filters (AND) -const intersectedFilters = intersectFilters([ - [{ kinds: [1] }], - [{ authors: ['pub1'] }] -]) +// Mix of event IDs and addresses +const references = [ + 'abc123...', // event ID + '30023:def456...:my-article', // address + 'ghi789...', // another event ID +]; + +const filters = getIdFilters(references); +// Returns: [ +// { ids: ['abc123...', 'ghi789...'] }, +// { kinds: [30023], authors: ['def456...'], '#d': ['my-article'] } +// ] ``` -### Time-based Filtering +### Finding Replies ```typescript -// Filter events from specific time range -const timeFilter: Filter = { - since: now() - 7 * DAY, // Last week - until: now(), - limit: 100 -} +import { getReplyFilters } from '@welshman/util'; -// Guess appropriate time window -const delta = guessFilterDelta([timeFilter]) +const originalEvents = [ + { id: 'abc123...', kind: 1, /* ... */ }, + { id: 'def456...', kind: 30023, /* ... */ } +]; + +// Find all replies to these events +const replyFilters = getReplyFilters(originalEvents); +// Returns filters with #e and #a tags pointing to the original events + +// Add additional constraints +const recentReplies = getReplyFilters(originalEvents, { + since: Math.floor(Date.now() / 1000) - 3600 // last hour +}); ``` -### Tag Filtering +### Filter Operations ```typescript -// Filter by tags -const tagFilter: Filter = { - '#t': ['nostr', 'bitcoin'], // Match hashtags - '#p': ['pubkey1'], // Match mentions - limit: 50 -} +import { unionFilters, intersectFilters, trimFilters } from '@welshman/util'; + +// Combine overlapping filters +const filters = [ + { kinds: [1], authors: ['abc...'] }, + { kinds: [1], authors: ['def...'] }, + { kinds: [6], authors: ['abc...'] } +]; + +const combined = unionFilters(filters); +// Results in more efficient filter set + +// Intersect filter groups +const group1 = [{ kinds: [1, 6] }]; +const group2 = [{ authors: ['abc...', 'def...'] }]; +const intersection = intersectFilters([group1, group2]); +// Returns: [{ kinds: [1, 6], authors: ['abc...', 'def...'] }] + +// Trim oversized filters +const largeFilters = [{ authors: new Array(2000).fill('pubkey') }]; +const trimmed = trimFilters(largeFilters); +// Limits arrays to 1000 items max ``` -## Filter Optimization +### Adding Repost Support -### Trim Filters ```typescript -// Trim large filters to reasonable size -const trimmedFilter = trimFilter(filter) -const trimmedFilters = trimFilters(filters) +import { addRepostFilters, NOTE, LONG_FORM } from '@welshman/util'; + +const baseFilters = [ + { kinds: [NOTE] }, + { kinds: [LONG_FORM], authors: ['abc...'] } +]; + +const withReposts = addRepostFilters(baseFilters); +// Automatically adds: +// - kind 6 filters for note reposts +// - kind 16 filters with #k tags for other reposts ``` ### Filter Analysis + ```typescript -// Get filter generality score -const score = getFilterGenerality(filter) +import { getFilterGenerality, guessFilterDelta, getFilterResultCardinality } from '@welshman/util'; -// Get expected result count -const count = getFilterResultCardinality(filter) -``` - -## Advanced Usage - -### Reply Chain Filters -```typescript -// Get filters for replies -const replyFilters = getReplyFilters(events, { - kinds: [1], - limit: 100 -}) -``` - -### Repost Handling -```typescript -// Add filters for reposts -const withReposts = addRepostFilters([ - { kinds: [1] } // Original filter -]) -// Results in filters for kinds 1, 6, and 16 +const specificFilter = { ids: ['abc123...'] }; +const generalFilter = { kinds: [1] }; + +console.log(getFilterGenerality(specificFilter)); // 0 (very specific) +console.log(getFilterGenerality(generalFilter)); // 1 (very general) + +// Estimate appropriate time window +const filters = [{ authors: ['abc...', 'def...'] }]; +const deltaSeconds = guessFilterDelta(filters); // ~21600 (6 hours) + +// Check expected result count +const idFilter = { ids: ['abc...', 'def...', 'ghi...'] }; +const resultCount = getFilterResultCardinality(idFilter); // 3 ``` diff --git a/docs/util/list.md b/docs/util/list.md index 3ac38d6..db84ad5 100644 --- a/docs/util/list.md +++ b/docs/util/list.md @@ -109,6 +109,13 @@ const updated = addToListPrivately( ['p', 'pubkey2'] ) +// Add new items publicly +const addItems = addToListPublicly( + list, + ['p', 'pubkey3'], + ['p', 'pubkey4'] +) + // Encrypt and publish const encrypted = await updated.reconcile(encrypt) ``` @@ -121,19 +128,6 @@ const list = readList(decryptedEvent) // Remove item const removeItem = removeFromList(list, 'pubkey1') -// Add new items publicly -const addItems = addToListPublicly( - list, - ['p', 'pubkey3'], - ['p', 'pubkey4'] -) -``` - -### Working with Tags -```typescript -// Get all list tags -const tags = getListTags(list) - // Remove by predicate const noMentions = removeFromListByPredicate( list, @@ -141,35 +135,8 @@ const noMentions = removeFromListByPredicate( ) ``` -## Common List Types - -### Mute List +### Working with Tags ```typescript -const muteList = makeList({ - kind: 10000, - publicTags: [['d', 'mutes']], - privateTags: [] // Keep muted users private -}) -``` - -### Bookmark List -```typescript -const bookmarks = makeList({ - kind: 10003, - privateTags: [ - ['e', 'id1'], - ['e', 'id2'] - ] -}) -``` - -### Relay List -```typescript -const relays = makeList({ - kind: 10002, - publicTags: [[ - ['r', 'wss://relay1.com'], - ['r', 'wss://relay2.com', 'write'] - ] -}) +// Get all list tags +const tags = getListTags(list) ``` diff --git a/docs/util/profile.md b/docs/util/profile.md index dc02fac..bfdeb64 100644 --- a/docs/util/profile.md +++ b/docs/util/profile.md @@ -73,7 +73,7 @@ const profile = makeProfile({ }) // Create profile event -const event = createProfile(profile) +const profileEvent = createProfile(profile) ``` ### Reading Profile @@ -107,7 +107,7 @@ if (profileHasName(profile)) { ### Updating Profile ```typescript // Edit existing profile -const updated = editProfile({ +const profileEvent = editProfile({ ...existingProfile, name: "New Name", about: "Updated bio" diff --git a/docs/util/relay.md b/docs/util/relay.md index 717b19d..fcd4d63 100644 --- a/docs/util/relay.md +++ b/docs/util/relay.md @@ -1,147 +1,158 @@ # Relay -The `Relay` module provides utilities for working with Nostr relays, including a local in-memory relay implementation that integrates with [Repository](/util/repository) for event storage. -The Relay class extends EventEmitter to provide event-based communication. +The `Relay` module provides utilities for working with Nostr relays, including URL normalization, validation, and relay profile handling. -## Core Components +## API + +### Types and Enums -### Relay Class ```typescript -class Relay extends Emitter { - constructor(readonly repository: Repository) - - // Emit events: 'EVENT', 'EOSE', 'OK' - emit(type: string, ...args: any[]): boolean - - // Handle relay messages - send(type: string, ...message: any[]): void +// Relay operation modes +export enum RelayMode { + Read = "read", + Write = "write", + Inbox = "inbox" } + +// Relay information from NIP-11 +export type RelayProfile = { + url: string; + icon?: string; + banner?: string; + name?: string; + pubkey?: string; + contact?: string; + software?: string; + version?: string; + negentropy?: number; + description?: string; + supported_nips?: number[]; + limitation?: { + min_pow_difficulty?: number; + payment_required?: boolean; + auth_required?: boolean; + }; +}; +``` + +### URL Validation + +```typescript +// Check if URL is a valid relay URL +export declare const isRelayUrl: (url: string) => boolean; + +// Check if URL is an onion (Tor) address +export declare const isOnionUrl: (url: string) => boolean; + +// Check if URL is a local address +export declare const isLocalUrl: (url: string) => boolean; + +// Check if URL contains an IP address +export declare const isIPAddress: (url: string) => boolean; + +// Check if URL is safe to share publicly +export declare const isShareableRelayUrl: (url: string) => boolean; +``` + +### URL Normalization + +```typescript +// Normalize relay URL to standard format +export declare const normalizeRelayUrl: (url: string) => string; + +// Format URL for display (remove protocol, trailing slash) +export declare const displayRelayUrl: (url: string) => string; ``` ### Relay Profile -```typescript -interface RelayProfile { - url: string // Relay URL - name?: string // Display name - description?: string // Description - pubkey?: string // Operator's pubkey - contact?: string // Contact information - software?: string // Software name - version?: string // Software version - supported_nips?: number[] // Supported NIPs - limitation?: { - min_pow_difficulty?: number - payment_required?: boolean - auth_required?: boolean - } -} -``` - -### Finding Relay Information ```typescript -// Fetch relay information document -async function getRelayProfile(url: string): Promise { - try { - const normalized = normalizeRelayUrl(url) - // Convert ws/wss to http/https - const httpUrl = normalized.replace(/^ws(s)?:\/\//, 'http$1://') - - // Fetch relay information document - const response = await fetch(`${httpUrl}`) - const info = await response.json() - - return { - url: normalized, - name: info.name, - description: info.description, - pubkey: info.pubkey, - contact: info.contact, - software: info.software, - version: info.version, - supported_nips: info.supported_nips, - limitation: info.limitation - } - } catch (error) { - console.error(`Failed to fetch relay info for ${url}:`, error) - return null - } -} +// Get display name for relay profile +export declare const displayRelayProfile: (profile?: RelayProfile, fallback?: string) => string; ``` -## URL Utilities +## Examples ### URL Validation + ```typescript -// Check if URL is valid relay URL -isRelayUrl(url: string): boolean +import { + isRelayUrl, + isOnionUrl, + isLocalUrl, + isShareableRelayUrl +} from '@welshman/util'; -// Check if URL is .onion address -isOnionUrl(url: string): boolean +// Valid relay URLs +console.log(isRelayUrl('wss://relay.damus.io')); // true +console.log(isRelayUrl('relay.damus.io')); // true (auto-adds wss://) +console.log(isRelayUrl('ws://localhost:8080')); // true -// Check if URL is local -isLocalUrl(url: string): boolean +// Invalid URLs +console.log(isRelayUrl('https://example.com')); // false (not websocket) +console.log(isRelayUrl('invalid-url')); // false -// Check if URL is IP address -isIPAddress(url: string): boolean +// Special URL types +console.log(isOnionUrl('wss://7rqsrjfmyb3n2k72.onion')); // true +console.log(isLocalUrl('ws://localhost:8080')); // true +console.log(isLocalUrl('wss://relay.local')); // true -// Check if URL can be shared -isShareableRelayUrl(url: string): boolean +// Safe to share publicly +console.log(isShareableRelayUrl('wss://relay.damus.io')); // true +console.log(isShareableRelayUrl('ws://localhost:8080')); // false (local) ``` -### URL Formatting +### URL Normalization + ```typescript -// Normalize relay URL -normalizeRelayUrl(url: string): string +import { normalizeRelayUrl, displayRelayUrl } from '@welshman/util'; -// Format URL for display -displayRelayUrl(url: string): string +// Normalize various URL formats +console.log(normalizeRelayUrl('relay.damus.io')); +// 'wss://relay.damus.io/' -// Format relay profile for display -displayRelayProfile(profile?: RelayProfile, fallback = ""): string +console.log(normalizeRelayUrl('ws://RELAY.EXAMPLE.COM/path')); +// 'ws://relay.example.com/path' + +console.log(normalizeRelayUrl('wss://relay.damus.io/?ref=123')); +// 'wss://relay.damus.io/' (strips query params) + +// Format for display +console.log(displayRelayUrl('wss://relay.damus.io/')); +// 'relay.damus.io' + +console.log(displayRelayUrl('ws://localhost:8080/')); +// 'localhost:8080' ``` +### Working with Relay Profiles -## Usage Examples - -### URL Processing ```typescript -// Validate relay URL -if (isRelayUrl(url)) { - // Normalize for consistency - const normalized = normalizeRelayUrl(url) +import { displayRelayProfile, RelayProfile } from '@welshman/util'; - // Check if shareable - if (isShareableRelayUrl(normalized)) { - // Format for display - const display = displayRelayUrl(normalized) - showRelay(display) +const relayProfile: RelayProfile = { + url: 'wss://relay.damus.io', + name: 'Damus Relay', + description: 'A high-performance Nostr relay', + software: 'strfry', + version: '1.0.0', + supported_nips: [1, 2, 4, 9, 11, 12, 15, 16, 20, 22], + limitation: { + payment_required: false, + auth_required: false, + min_pow_difficulty: 0 } -} -``` - -### Relay usage with Repository - -```typescript -// Create storage and relay interface -const repository = new Repository() -const relay = new Relay(repository) - -// Subscribe to events -relay.send("REQ", "sub_id", { - kinds: [1], - limit: 100 -}) - -// Listen for events -relay.on("EVENT", (subId, event) => { - console.log(`Received event for ${subId}:`, event) -}) - -// Publish event -// Will be stored in repository and sent to matching subscribers -relay.send("EVENT", signedEvent) - -// Close subscription -relay.send("CLOSE", "sub_id") +}; + +// Get display name +const displayName = displayRelayProfile(relayProfile); +console.log(displayName); // 'Damus Relay' + +// With fallback for unnamed relays +const anonymousRelay: RelayProfile = { + url: 'wss://anonymous.relay.com' +}; + +const name = displayRelayProfile(anonymousRelay, 'Unknown Relay'); +console.log(name); // 'Unknown Relay' ``` diff --git a/docs/util/tags.md b/docs/util/tags.md index 2971d55..3dd3657 100644 --- a/docs/util/tags.md +++ b/docs/util/tags.md @@ -103,9 +103,8 @@ uniqTags(tags: string[][]): string[][] tagsFromIMeta(imeta: string[]): string[][] ``` -## Usage Examples +## Example -### Basic Tag Handling ```typescript // Get specific tag types const pubkeys = getPubkeyTagValues(event.tags) @@ -118,32 +117,3 @@ const refs = getTags(['p', 'e'], event.tags) // Get single tag const topic = getTagValue('t', event.tags) ``` - -### Thread Processing -```typescript -// Get thread context -const {roots, replies} = getReplyTags(event.tags) - -// Process thread structure -function processThread(tags: string[][]) { - const thread = getReplyTags(tags) - - return { - rootEvents: thread.roots.map(t => t[1]), - replyTo: thread.replies.map(t => t[1]), - mentions: thread.mentions.map(t => t[1]) - } -} -``` - -### Tag Collection -```typescript -// Collect all references -function collectReferences(tags: string[][]) { - return { - events: getEventTagValues(tags), - profiles: getPubkeyTagValues(tags), - addresses: getAddressTagValues(tags) - } -} -``` diff --git a/docs/util/zaps.md b/docs/util/zaps.md index 44aabbd..365912e 100644 --- a/docs/util/zaps.md +++ b/docs/util/zaps.md @@ -1,192 +1,219 @@ # Zaps -The Zaps module provides utilities for working with Lightning Network payments (zaps) in Nostr, including LNURL handling, invoice amount parsing, and zap validation. +The Zaps module provides utilities for working with Lightning Network payments (zaps) in Nostr, following [NIP-57](https://github.com/nostr-protocol/nips/blob/master/57.md). It includes LNURL handling, invoice amount parsing, and zap validation. -## Zapper Interface -The Zapper interface represents a Lightning Network payment provider that can process zaps: +## Protocol Overview + +Zaps enable Lightning Network payments to be associated with Nostr events through a standardized flow: + +1. **Zap Request** (kind 9734): Client creates a request specifying the amount and target +2. **Lightning Invoice**: LNURL service generates an invoice with the request embedded +3. **Zap Receipt** (kind 9735): Zapper publishes proof of payment to Nostr + +## API + +### Types ```typescript -interface Zapper { - // LNURL for payment processing - lnurl: string +// Zapper service information +export type Zapper = { + lnurl: string; + pubkey?: string; + callback?: string; + minSendable?: number; + maxSendable?: number; + nostrPubkey?: string; + allowsNostr?: boolean; +}; - // User's pubkey on the payment service - pubkey?: string - - // LNURL callback endpoint - callback?: string - - // Minimum payment amount in millisatoshis - minSendable?: number - - // Maximum payment amount in millisatoshis - maxSendable?: number - - // Pubkey used to sign zap receipts - nostrPubkey?: string - - // Whether provider supports Nostr zaps - allowsNostr?: boolean -} +// Complete zap with request and receipt +export type Zap = { + request: TrustedEvent; // kind 9734 (zap request) + response: TrustedEvent; // kind 9735 (zap receipt) + invoiceAmount: number; // amount in millisatoshis +}; ``` -### Finding Nostr Zappers - -#### Getting Lightning Info - -First, check the user's profile for Lightning addresses: +### Lightning Network Utilities ```typescript -function getLightningInfo(profile: Profile) { - // Check for Lightning Address (NIP-57) - if (profile.lud16) { - return { - type: 'lud16', - address: profile.lud16 - } - } +// Convert human-readable amount to millisatoshis +export declare const hrpToMillisat: (hrpString: string) => bigint; - // Check for LNURL - if (profile.lud06) { - return { - type: 'lud06', - url: profile.lud06 - } - } +// Extract amount from BOLT11 lightning invoice +export declare const getInvoiceAmount: (bolt11: string) => number; - return null -} +// Convert lightning address or URL to LNURL +export declare const getLnUrl: (address: string) => string | null; ``` -#### Fetching LNURL Metadata - -Once you have the Lightning address or LNURL, fetch the metadata: +### Zap Validation ```typescript -async function fetchZapper(address: string): Promise { - // Convert Lightning address to LNURL if needed - const lnurl = getLnUrl(address) - if (!lnurl) return null - - try { - // Decode and fetch LNURL metadata - const url = new URL(bech32.decode(lnurl).data) - const response = await fetch(url.toString()) - const metadata = await response.json() - - // Extract zapper details - return { - lnurl, - callback: metadata.callback, - minSendable: metadata.minSendable, - maxSendable: metadata.maxSendable, - nostrPubkey: metadata.nostrPubkey, - allowsNostr: Boolean(metadata.allowsNostr), - } - } catch (error) { - console.error('Failed to fetch zapper:', error) - return null - } -} +// Create validated Zap from zap receipt event +export declare const zapFromEvent: (response: TrustedEvent, zapper?: Zapper) => Zap | null; ``` +## Examples + +### Converting Lightning Addresses + ```typescript -// Example Alby zapper configuration -const albyZapper: Zapper = { - lnurl: "lnurl1...", - pubkey: "alby_user_pubkey", - nostrPubkey: "alby_signing_key", +import { getLnUrl } from '@welshman/util'; + +// Lightning address (LUD-16) +const lnurl1 = getLnUrl('satoshi@getalby.com'); +console.log(lnurl1); // 'lnurl1...' (encoded URL) + +// Regular URL +const lnurl2 = getLnUrl('https://getalby.com/.well-known/lnurlp/satoshi'); +console.log(lnurl2); // 'lnurl1...' (encoded URL) + +// Already encoded LNURL +const lnurl3 = getLnUrl('lnurl1dp68gurn8ghj7mr0vdskc6r0wd6z7mrww4excttsv9un7um9wdekjmmw84jxywf5x43rvv35xgmr2enrxanr2cfcvsmnwe3jxcukvde48qukgdec89snwde3vfjxvepjxpjnjvtpxd3kvdnxx5crxwpjvyunsephsz36jf'); +console.log(lnurl3); // 'lnurl1...' (same as input) + +// Invalid address +const invalid = getLnUrl('not-a-valid-address'); +console.log(invalid); // null +``` + +### Parsing Invoice Amounts + +```typescript +import { getInvoiceAmount, hrpToMillisat } from '@welshman/util'; + +// Extract amount from BOLT11 invoice +const invoice = 'lnbc1500n1...'; // 1500 nanosats = 1.5 sats +const amount = getInvoiceAmount(invoice); +console.log(amount); // 1500 (millisatoshis) + +// Convert human-readable amounts +console.log(hrpToMillisat('1000')); // 100000000000n (1000 BTC in millisats) +console.log(hrpToMillisat('1000m')); // 100000000n (1000 mBTC = 1 BTC in millisats) +console.log(hrpToMillisat('1000u')); // 100000n (1000 µBTC = 1 mBTC in millisats) +console.log(hrpToMillisat('1000n')); // 100n (1000 nBTC = 1000 sats in millisats) +console.log(hrpToMillisat('1000p')); // 0.1n (1000 pBTC = 1 msat, but must be divisible by 10) +``` + +### Validating Zaps + +```typescript +import { zapFromEvent, ZAP_RESPONSE } from '@welshman/util'; + +// Zapper service configuration +const zapper: Zapper = { + lnurl: 'lnurl1dp68gurn8ghj7mr0vdskc6r0wd6z7mrww4excttsv9un7um9wdekjmmw84jxywf5x43rvv35xgmr2enrxanr2cfcvsmnwe3jxcukvde48qukgdec89snwde3vfjxvepjxpjnjvtpxd3kvdnxx5crxwpjvyunsephsz36jf', + nostrPubkey: 'zapper-pubkey-hex', allowsNostr: true, - minSendable: 1000, // 1 sat minimum - maxSendable: 100000000 // 100k sats maximum + minSendable: 1000, + maxSendable: 10000000 +}; + +// Zap receipt event (kind 9735) +const zapReceipt = { + kind: ZAP_RESPONSE, + pubkey: 'zapper-pubkey-hex', + tags: [ + ['bolt11', 'lnbc1500n1...'], + ['description', '{"kind":9734,"pubkey":"sender-pubkey","tags":[["p","recipient-pubkey"],["amount","1500"],["relays","wss://relay.com"]],"content":"Great post!","created_at":1234567890}'], + ['p', 'recipient-pubkey'] + ], + // ... other event fields +}; + +// Validate the zap +const validZap = zapFromEvent(zapReceipt, zapper); + +if (validZap) { + console.log('Amount:', validZap.invoiceAmount); // 1500 millisats + console.log('Request:', validZap.request.content); // "Great post!" + console.log('Recipient:', validZap.request.tags.find(t => t[0] === 'p')?.[1]); +} else { + console.log('Invalid zap - failed validation'); +} +``` + +### Complete Zap Flow Example + +```typescript +import { getLnUrl, zapFromEvent, makeEvent, ZAP_REQUEST } from '@welshman/util'; + +// Step 1: Get LNURL from lightning address +const lightningAddress = 'satoshi@getalby.com'; +const lnurl = getLnUrl(lightningAddress); + +if (!lnurl) { + throw new Error('Invalid lightning address'); } -// Example LNbits zapper -const lnbitsZapper: Zapper = { - lnurl: "lnurl1...", - callback: "https://lnbits.com/callback", - nostrPubkey: "lnbits_signing_key", +// Step 2: Create zap request (kind 9734) +const zapRequest = makeEvent(ZAP_REQUEST, { + content: 'Amazing content!', + tags: [ + ['p', 'recipient-pubkey-hex'], // recipient + ['amount', '5000'], // 5000 millisats = 5 sats + ['lnurl', lnurl], + ['relays', 'wss://relay.damus.io', 'wss://relay.snort.social'] + ] +}); + +// Step 3: Send to LNURL service (implementation specific) +// The service will generate an invoice with the zap request in description + +// Step 4: Pay the invoice (using Lightning wallet) + +// Step 5: Validate received zap receipt +const zapperInfo = { + lnurl, + nostrPubkey: 'zapper-service-pubkey', allowsNostr: true +}; + +// When zap receipt arrives (kind 9735) +function handleZapReceipt(zapReceipt: TrustedEvent) { + const validatedZap = zapFromEvent(zapReceipt, zapperInfo); + + if (validatedZap) { + console.log(`Received ${validatedZap.invoiceAmount} msat zap!`); + console.log(`Message: ${validatedZap.request.content}`); + return validatedZap; + } else { + console.log('Invalid zap receipt'); + return null; + } } ``` -### Zap Structure -```typescript -interface Zap { - request: TrustedEvent // Zap request event kind 9734 - response: TrustedEvent // Zap receipt/response event kind 9735 sent by the zapper - invoiceAmount: number // Amount in millisats -} -``` +### Zap Validation Rules -## Core Functions - -### Lightning Address Handling -```typescript -// Convert address to LNURL -function getLnUrl(address: string): string | null - -// Examples: -getLnUrl("user@domain.com") // => lnurl1... -getLnUrl("https://domain.com/.well-known/lnurlp/user") // => lnurl1... -getLnUrl("lnurl1...") // => returns unchanged -``` - -### Invoice Processing -```typescript -// Parse amount from BOLT11 invoice -function getInvoiceAmount(bolt11: string): number - -// Convert human readable amount to millisats -function hrpToMillisat(hrpString: string): bigint -``` - -### Zap Validation - -The `zapFromEvent` function validates a zap receipt event, against an expected zapper. - -It returns a `Zap` object if the zap is valid, or `null` if not. +The `zapFromEvent` function validates several aspects of a zap according to NIP-57: ```typescript -function zapFromEvent( - response: TrustedEvent, - zapper: Zapper | undefined -): Zap | null -``` - -## Usage Examples - -### Processing Lightning Addresses -```typescript -// Get LNURL from various formats -const lnurl1 = getLnUrl("user@getalby.com") -const lnurl2 = getLnUrl("https://getalby.com/.well-known/lnurlp/user") -const lnurl3 = getLnUrl("lnurl1...") - -// Check if conversion was successful -if (lnurl1) { - // Process LNURL - processLnurl(lnurl1) -} -``` - -### Invoice Amount Handling -```typescript -// Get invoice amount in millisats -const amount = getInvoiceAmount(bolt11Invoice) - -// Convert string amount to millisats -const millisats = hrpToMillisat("1000") // 1000 sats -const millisats = hrpToMillisat("1m") // 1 million sats -``` - -### Zap Validation -```typescript -// Validate zap event -const zap = zapFromEvent(zapResponse, albyZapper) - -if (zap) { - // Process valid zap - processZap(zap) -} +import { zapFromEvent } from '@welshman/util'; + +// Validation checks performed: +// 1. Invoice amount matches requested amount (if specified) +// 2. Zap request is properly embedded in invoice description +// 3. Zapper pubkey matches the expected zapper service +// 4. LNURL matches the expected service (if provided in request) +// 5. Self-zaps are filtered out (sender != zapper) + +const zapReceipt = { + // ... zap receipt event +}; + +const zapper = { + nostrPubkey: 'expected-zapper-pubkey', + lnurl: 'expected-lnurl' +}; + +const validZap = zapFromEvent(zapReceipt, zapper); + +// Returns null if any validation fails: +// - Malformed bolt11 invoice +// - Amount mismatch +// - Wrong zapper pubkey +// - LNURL mismatch +// - Self-zap detection ```