Update util docs
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
node_modules
|
||||
docs
|
||||
#docs
|
||||
docs/reference
|
||||
docs/.vitepress/cache
|
||||
build
|
||||
|
||||
+85
-89
@@ -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'
|
||||
```
|
||||
|
||||
+71
-142
@@ -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<string>
|
||||
```
|
||||
|
||||
### Encryptable Updates
|
||||
```typescript
|
||||
type EncryptableUpdates = {
|
||||
content?: string
|
||||
tags?: string[][]
|
||||
}
|
||||
```
|
||||
|
||||
### Decrypted Event
|
||||
```typescript
|
||||
type DecryptedEvent = TrustedEvent & {
|
||||
plaintext: EncryptableUpdates
|
||||
}
|
||||
```
|
||||
|
||||
## Encryptable Class
|
||||
## API
|
||||
|
||||
```typescript
|
||||
class Encryptable<T extends EventTemplate> {
|
||||
// Encryption function type
|
||||
export type Encrypt = (x: string) => Promise<string>;
|
||||
|
||||
// Partial event content for updates
|
||||
export type EncryptableUpdates = Partial<EventContent>;
|
||||
|
||||
// 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<T extends EventTemplate> {
|
||||
constructor(
|
||||
readonly event: Partial<T>, // Base event template
|
||||
readonly updates: EncryptableUpdates // Plaintext updates
|
||||
)
|
||||
event: Partial<T>,
|
||||
updates: EncryptableUpdates
|
||||
);
|
||||
|
||||
// Encrypts updates and merges them into the event
|
||||
reconcile(encrypt: Encrypt): Promise<T>;
|
||||
}
|
||||
```
|
||||
|
||||
## 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"
|
||||
```
|
||||
|
||||
+156
-136
@@ -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)
|
||||
```
|
||||
|
||||
+139
-116
@@ -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: <E extends HashedEvent>(filter: Filter, event: E) => boolean;
|
||||
|
||||
// Check if an event matches any filter in array
|
||||
export declare const matchFilters: <E extends HashedEvent>(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
|
||||
```
|
||||
|
||||
+10
-43
@@ -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)
|
||||
```
|
||||
|
||||
@@ -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"
|
||||
|
||||
+126
-115
@@ -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<E extends HashedEvent = TrustedEvent> extends Emitter {
|
||||
constructor(readonly repository: Repository<E>)
|
||||
|
||||
// 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<RelayProfile | null> {
|
||||
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'
|
||||
```
|
||||
|
||||
+1
-31
@@ -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)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
+189
-162
@@ -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<Zapper | null> {
|
||||
// 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
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user