Update util docs
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
node_modules
|
node_modules
|
||||||
docs
|
#docs
|
||||||
docs/reference
|
docs/reference
|
||||||
docs/.vitepress/cache
|
docs/.vitepress/cache
|
||||||
build
|
build
|
||||||
|
|||||||
+85
-89
@@ -1,110 +1,106 @@
|
|||||||
# Nostr Address
|
# 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
|
```typescript
|
||||||
class Address {
|
// Address class for handling addressable events
|
||||||
|
export declare class Address {
|
||||||
constructor(
|
constructor(
|
||||||
readonly kind: number, // Event kind
|
kind: number,
|
||||||
readonly pubkey: string, // Author's public key
|
pubkey: string,
|
||||||
readonly identifier: string, // Unique identifier (d-tag)
|
identifier: string,
|
||||||
readonly relays?: string[], // Optional relay hints
|
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
|
// Utility function to get address string from event
|
||||||
|
export declare const getAddress: (e: AddressableEvent) => string;
|
||||||
### 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)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
### Working with Long-form Content
|
### Creating and parsing addresses
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Create address for article
|
import { Address } from '@welshman/util';
|
||||||
const articleAddress = new Address(
|
|
||||||
30023, // Long-form content kind
|
// Create address from components
|
||||||
authorPubkey,
|
const address = new Address(
|
||||||
'my-article-slug',
|
30023,
|
||||||
|
'27067f0efd1b9ffc6d71672a1b69a4e5ac3b8ce3cc8428b06849448e38d69389',
|
||||||
|
'my-article',
|
||||||
['wss://relay.example.com']
|
['wss://relay.example.com']
|
||||||
)
|
);
|
||||||
|
|
||||||
// Convert to string format for storage
|
// Parse from string format
|
||||||
const addressString = articleAddress.toString()
|
const parsed = Address.from('30023:27067f0efd1b9ffc6d71672a1b69a4e5ac3b8ce3cc8428b06849448e38d69389:my-article');
|
||||||
|
console.log(parsed.kind); // 30023
|
||||||
|
console.log(parsed.identifier); // 'my-article'
|
||||||
|
|
||||||
// Convert to naddr for sharing
|
// Check if string is valid address
|
||||||
const shareableAddress = articleAddress.toNaddr()
|
const isValid = Address.isAddress('30023:27067f0efd1b9ffc6d71672a1b69a4e5ac3b8ce3cc8428b06849448e38d69389:my-article'); // true
|
||||||
|
const isInvalid = Address.isAddress('invalid-format'); // false
|
||||||
```
|
```
|
||||||
|
|
||||||
### Handling Replaceable Events
|
### Converting between formats
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Create address from replaceable event
|
import { Address } from '@welshman/util';
|
||||||
const address = Address.fromEvent(event)
|
|
||||||
|
|
||||||
// Store latest version using address as key
|
const address = new Address(30023, '27067f0efd1b9ffc6d71672a1b69a4e5ac3b8ce3cc8428b06849448e38d69389', 'my-article');
|
||||||
storage.set(address.toString(), event)
|
|
||||||
|
// 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
|
# 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
|
## API
|
||||||
|
|
||||||
### 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
|
|
||||||
|
|
||||||
```typescript
|
```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(
|
constructor(
|
||||||
readonly event: Partial<T>, // Base event template
|
event: Partial<T>,
|
||||||
readonly updates: EncryptableUpdates // Plaintext updates
|
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
|
## Examples
|
||||||
|
|
||||||
### Private Bookmarks
|
### Basic Usage
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Create private bookmark list
|
import { Encryptable } from '@welshman/util';
|
||||||
const bookmarks = new Encryptable(
|
|
||||||
|
// 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,
|
content: JSON.stringify(['pubkey1', 'pubkey2']),
|
||||||
tags: [['d', 'bookmarks']] // Public identifier
|
tags: [['p', 'sensitive-pubkey'], ['e', 'sensitive-event-id']]
|
||||||
},
|
|
||||||
{
|
|
||||||
content: JSON.stringify([
|
|
||||||
{ id: 'note1', title: 'Secret Note' }
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
|
|
||||||
// Encrypt for publishing
|
// The reconcile method encrypts tag values at index 1
|
||||||
const event = await bookmarks.reconcile(async (content) => {
|
const event = await encryptable.reconcile(encryptFn);
|
||||||
return await myEncryptionFunction(content)
|
// event.tags[0] = ['p', 'encrypted-pubkey']
|
||||||
})
|
// event.tags[1] = ['e', 'encrypted-event-id']
|
||||||
```
|
```
|
||||||
|
|
||||||
### Encrypted Group Membership
|
### Working with Decrypted Events
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Create private group member list
|
import { asDecryptedEvent } from '@welshman/util';
|
||||||
const members = new Encryptable(
|
|
||||||
{
|
|
||||||
kind: 30000,
|
|
||||||
tags: [['d', 'group-members']]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tags: members.map(m => ['p', m.pubkey, m.role])
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const encrypted = await members.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']] };
|
||||||
### Updating Private Content
|
|
||||||
```typescript
|
const decryptedEvent = asDecryptedEvent(event, plaintext);
|
||||||
function updatePrivateList(event: DecryptedEvent, newItems: string[]) {
|
console.log(decryptedEvent.plaintext.content); // "original content"
|
||||||
return new Encryptable(
|
|
||||||
event,
|
|
||||||
{
|
|
||||||
content: JSON.stringify(newItems)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage
|
|
||||||
const updated = updatePrivateList(existingEvent, newItems)
|
|
||||||
const final = await updated.reconcile(encrypt)
|
|
||||||
```
|
```
|
||||||
|
|||||||
+156
-136
@@ -1,185 +1,205 @@
|
|||||||
# Nostr Events
|
# 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
|
```typescript
|
||||||
// Base event with content and tags
|
// Base event content structure
|
||||||
interface EventContent {
|
export type EventContent = {
|
||||||
tags: string[][]
|
tags: string[][];
|
||||||
content: string
|
content: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
// Base event with kind
|
// Event template with kind
|
||||||
interface EventTemplate extends EventContent {
|
export type EventTemplate = EventContent & {
|
||||||
kind: number
|
kind: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
// Event with timestamp
|
// Event with timestamp
|
||||||
interface StampedEvent extends EventTemplate {
|
export type StampedEvent = EventTemplate & {
|
||||||
created_at: number
|
created_at: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
// Event with author
|
// Event with author
|
||||||
interface OwnedEvent extends StampedEvent {
|
export type OwnedEvent = StampedEvent & {
|
||||||
pubkey: string
|
pubkey: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
// Event with ID
|
// Event with ID
|
||||||
interface HashedEvent extends OwnedEvent {
|
export type HashedEvent = OwnedEvent & {
|
||||||
id: string
|
id: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
// Event with signature
|
// Signed event
|
||||||
interface SignedEvent extends HashedEvent {
|
export type SignedEvent = HashedEvent & {
|
||||||
sig: string
|
sig: string;
|
||||||
[verifiedSymbol]?: boolean
|
};
|
||||||
}
|
|
||||||
|
|
||||||
// Event with wrapped content
|
// Wrapped event (NIP-59)
|
||||||
interface UnwrappedEvent extends HashedEvent {
|
export type UnwrappedEvent = HashedEvent & {
|
||||||
wrap: SignedEvent
|
wrap: SignedEvent;
|
||||||
}
|
};
|
||||||
|
|
||||||
// Event that can be either signed or wrapped
|
// Event that can be either signed or wrapped
|
||||||
type TrustedEvent = HashedEvent & {
|
export type TrustedEvent = HashedEvent & {
|
||||||
sig?: string
|
sig?: string;
|
||||||
wrap?: SignedEvent
|
wrap?: SignedEvent;
|
||||||
[verifiedSymbol]?: boolean
|
};
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Event Creation
|
### 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
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Check event types
|
// Options for creating events
|
||||||
isEventTemplate(event): boolean
|
export type MakeEventOpts = {
|
||||||
isStampedEvent(event): boolean
|
content?: string;
|
||||||
isOwnedEvent(event): boolean
|
tags?: string[][];
|
||||||
isHashedEvent(event): boolean
|
created_at?: number;
|
||||||
isSignedEvent(event): boolean
|
};
|
||||||
isUnwrappedEvent(event): boolean
|
|
||||||
isTrustedEvent(event): boolean
|
// Creates a stamped event template
|
||||||
|
export declare const makeEvent: (kind: number, opts?: MakeEventOpts) => StampedEvent;
|
||||||
```
|
```
|
||||||
|
|
||||||
## Event Type Conversion
|
### Type Guards
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Convert to specific event types
|
export declare const isEventTemplate: (e: EventTemplate) => e is EventTemplate;
|
||||||
asEventTemplate(event): EventTemplate
|
export declare const isStampedEvent: (e: StampedEvent) => e is StampedEvent;
|
||||||
asStampedEvent(event): StampedEvent
|
export declare const isOwnedEvent: (e: OwnedEvent) => e is OwnedEvent;
|
||||||
asOwnedEvent(event): OwnedEvent
|
export declare const isHashedEvent: (e: HashedEvent) => e is HashedEvent;
|
||||||
asHashedEvent(event): HashedEvent
|
export declare const isSignedEvent: (e: TrustedEvent) => e is SignedEvent;
|
||||||
asSignedEvent(event): SignedEvent
|
export declare const isUnwrappedEvent: (e: TrustedEvent) => e is UnwrappedEvent;
|
||||||
asUnwrappedEvent(event): UnwrappedEvent
|
export declare const isTrustedEvent: (e: TrustedEvent) => e is TrustedEvent;
|
||||||
asTrustedEvent(event): TrustedEvent
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Event Utilities
|
### Event Utilities
|
||||||
|
|
||||||
### Event Validation
|
|
||||||
```typescript
|
```typescript
|
||||||
// Check if event has valid signature
|
// Event validation and signatures
|
||||||
hasValidSignature(event: SignedEvent): boolean
|
export declare const verifyEvent: (event: TrustedEvent) => boolean;
|
||||||
|
|
||||||
// Get event identifier (d tag)
|
// Event properties
|
||||||
getIdentifier(event: EventTemplate): string | undefined
|
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
|
## Threading Protocols
|
||||||
```typescript
|
|
||||||
// Get event ID or address
|
|
||||||
getIdOrAddress(event: HashedEvent): string
|
|
||||||
|
|
||||||
// Get both ID and address (if replaceable)
|
The `getAncestors` function handles two different threading protocols:
|
||||||
getIdAndAddress(event: HashedEvent): string[]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Event Type Checking
|
### Regular Notes (NIP-10)
|
||||||
```typescript
|
For regular notes and most event kinds, threading follows [NIP-10](https://github.com/nostr-protocol/nips/blob/master/10.md):
|
||||||
// Check event properties
|
- Uses `e` and `a` tags with optional markers (`root`, `reply`, `mention`)
|
||||||
isEphemeral(event: EventTemplate): boolean
|
- Positional rules apply when markers are absent:
|
||||||
isReplaceable(event: EventTemplate): boolean
|
- First `e`/`a` tag = root
|
||||||
isPlainReplaceable(event: EventTemplate): boolean
|
- Last `e`/`a` tag = reply target
|
||||||
isParameterizedReplaceable(event: EventTemplate): boolean
|
- Middle tags = mentions
|
||||||
```
|
|
||||||
|
|
||||||
### Thread & Reply Handling
|
### Comments (NIP-22)
|
||||||
```typescript
|
For comments (kind 1111), threading follows [NIP-22](https://github.com/nostr-protocol/nips/blob/master/22.md):
|
||||||
// Get thread information
|
- Uses uppercase tags (`E`, `A`, `P`, `K`) for root references
|
||||||
getAncestors(event: EventTemplate): { roots: string[], replies: string[] }
|
- Uses lowercase tags (`e`, `a`, `p`, `k`) for reply references
|
||||||
|
- No positional rules - explicit tag types determine relationship
|
||||||
|
|
||||||
// Get parent references
|
All `getParent*` functions and `isChildOf` include this logic, automatically handling both protocols based on event kind.
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
### Creating and Processing Events
|
### Creating Events
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Create new event
|
import { makeEvent, NOTE, LONG_FORM } from '@welshman/util';
|
||||||
const event = createEvent(1, {
|
|
||||||
content: "Hello world!",
|
|
||||||
tags: [["t", "greeting"]]
|
|
||||||
})
|
|
||||||
|
|
||||||
// Process based on type
|
// Create a basic note
|
||||||
if (isSignedEvent(event)) {
|
const note = makeEvent(NOTE, {
|
||||||
// Handle signed event
|
content: "Hello Nostr!",
|
||||||
if (hasValidSignature(event)) {
|
tags: [["t", "nostr"]]
|
||||||
processValidEvent(event)
|
});
|
||||||
}
|
|
||||||
} else if (isUnwrappedEvent(event)) {
|
// Create a long-form article with custom timestamp
|
||||||
// Handle wrapped event
|
const article = makeEvent(LONG_FORM, {
|
||||||
processWrappedEvent(event)
|
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
|
### Working with Threads
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Get thread context
|
import { getAncestors, isChildOf, NOTE, COMMENT } from '@welshman/util';
|
||||||
const ancestors = getAncestors(event)
|
|
||||||
const rootId = ancestors.roots[0]
|
|
||||||
const replyTo = ancestors.replies[0]
|
|
||||||
|
|
||||||
// Check threading
|
// Regular note reply (NIP-10)
|
||||||
if (isChildOf(event, parentEvent)) {
|
const noteReply = makeEvent(NOTE, {
|
||||||
// Handle reply
|
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
|
# Filters
|
||||||
|
|
||||||
The Filters module provides utilities for creating, manipulating, and matching Nostr event 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.
|
||||||
It includes support for filter operations, optimization, and time-based filtering.
|
|
||||||
|
|
||||||
## Core Types
|
## API
|
||||||
|
|
||||||
|
### Filter Matching
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface Filter {
|
// Check if an event matches a filter
|
||||||
ids?: string[] // Match specific event IDs
|
export declare const matchFilter: <E extends HashedEvent>(filter: Filter, event: E) => boolean;
|
||||||
kinds?: number[] // Match event kinds
|
|
||||||
authors?: string[] // Match author pubkeys
|
// Check if an event matches any filter in array
|
||||||
since?: number // Match events since timestamp
|
export declare const matchFilters: <E extends HashedEvent>(filters: Filter[], event: E) => boolean;
|
||||||
until?: number // Match events until timestamp
|
|
||||||
limit?: number // Limit number of results
|
|
||||||
search?: string // Text search
|
|
||||||
[key: `#${string}`]: string[] // Tag filters
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Filter Operations
|
### Filter Operations
|
||||||
|
|
||||||
### Match Events
|
|
||||||
```typescript
|
```typescript
|
||||||
// Match single filter
|
// Get a compact string representation of a filter
|
||||||
matchFilter(filter: Filter, event: HashedEvent): boolean
|
export declare const getFilterId: (filter: Filter) => string;
|
||||||
|
|
||||||
// Match multiple filters
|
// Combine multiple filters into minimal filter set
|
||||||
matchFilters(filters: Filter[], event: HashedEvent): boolean
|
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
|
### Specialized Filter Creation
|
||||||
```typescript
|
|
||||||
// Combine filters with OR operation
|
|
||||||
unionFilters(filters: Filter[]): Filter[]
|
|
||||||
|
|
||||||
// Combine filters with AND operation
|
```typescript
|
||||||
intersectFilters(groups: Filter[][]): Filter[]
|
// 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
|
### Filter Analysis
|
||||||
```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
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Unix epoch for Nostr (2021-01-01)
|
// Calculate filter generality (0 = specific, 1 = very general)
|
||||||
export const EPOCH = 1609459200
|
export declare const getFilterGenerality: (filter: Filter) => number;
|
||||||
|
|
||||||
// One day in seconds
|
// Estimate time delta for filter results
|
||||||
export const DAY = 86400
|
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
|
## Examples
|
||||||
|
|
||||||
### Basic Filtering
|
### Basic Filter Matching
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Create basic filter
|
import { matchFilter, matchFilters, NOTE, LONG_FORM } from '@welshman/util';
|
||||||
const filter: Filter = {
|
|
||||||
kinds: [1], // Text notes
|
|
||||||
authors: ['pubkey1', 'pubkey2'],
|
|
||||||
since: now() - 24 * 60 * 60, // Last 24 hours
|
|
||||||
limit: 100
|
|
||||||
}
|
|
||||||
|
|
||||||
// Match event against filter
|
const event = {
|
||||||
if (matchFilter(filter, event)) {
|
id: 'abc123...',
|
||||||
processEvent(event)
|
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
|
```typescript
|
||||||
// Union of filters (OR)
|
import { getIdFilters } from '@welshman/util';
|
||||||
const combinedFilters = unionFilters([
|
|
||||||
{ kinds: [1], authors: ['pub1'] },
|
|
||||||
{ kinds: [1], authors: ['pub2'] }
|
|
||||||
])
|
|
||||||
|
|
||||||
// Intersection of filters (AND)
|
// Mix of event IDs and addresses
|
||||||
const intersectedFilters = intersectFilters([
|
const references = [
|
||||||
[{ kinds: [1] }],
|
'abc123...', // event ID
|
||||||
[{ authors: ['pub1'] }]
|
'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
|
```typescript
|
||||||
// Filter events from specific time range
|
import { getReplyFilters } from '@welshman/util';
|
||||||
const timeFilter: Filter = {
|
|
||||||
since: now() - 7 * DAY, // Last week
|
|
||||||
until: now(),
|
|
||||||
limit: 100
|
|
||||||
}
|
|
||||||
|
|
||||||
// Guess appropriate time window
|
const originalEvents = [
|
||||||
const delta = guessFilterDelta([timeFilter])
|
{ 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
|
```typescript
|
||||||
// Filter by tags
|
import { unionFilters, intersectFilters, trimFilters } from '@welshman/util';
|
||||||
const tagFilter: Filter = {
|
|
||||||
'#t': ['nostr', 'bitcoin'], // Match hashtags
|
// Combine overlapping filters
|
||||||
'#p': ['pubkey1'], // Match mentions
|
const filters = [
|
||||||
limit: 50
|
{ 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
|
```typescript
|
||||||
// Trim large filters to reasonable size
|
import { addRepostFilters, NOTE, LONG_FORM } from '@welshman/util';
|
||||||
const trimmedFilter = trimFilter(filter)
|
|
||||||
const trimmedFilters = trimFilters(filters)
|
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
|
### Filter Analysis
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Get filter generality score
|
import { getFilterGenerality, guessFilterDelta, getFilterResultCardinality } from '@welshman/util';
|
||||||
const score = getFilterGenerality(filter)
|
|
||||||
|
|
||||||
// Get expected result count
|
const specificFilter = { ids: ['abc123...'] };
|
||||||
const count = getFilterResultCardinality(filter)
|
const generalFilter = { kinds: [1] };
|
||||||
```
|
|
||||||
|
console.log(getFilterGenerality(specificFilter)); // 0 (very specific)
|
||||||
## Advanced Usage
|
console.log(getFilterGenerality(generalFilter)); // 1 (very general)
|
||||||
|
|
||||||
### Reply Chain Filters
|
// Estimate appropriate time window
|
||||||
```typescript
|
const filters = [{ authors: ['abc...', 'def...'] }];
|
||||||
// Get filters for replies
|
const deltaSeconds = guessFilterDelta(filters); // ~21600 (6 hours)
|
||||||
const replyFilters = getReplyFilters(events, {
|
|
||||||
kinds: [1],
|
// Check expected result count
|
||||||
limit: 100
|
const idFilter = { ids: ['abc...', 'def...', 'ghi...'] };
|
||||||
})
|
const resultCount = getFilterResultCardinality(idFilter); // 3
|
||||||
```
|
|
||||||
|
|
||||||
### Repost Handling
|
|
||||||
```typescript
|
|
||||||
// Add filters for reposts
|
|
||||||
const withReposts = addRepostFilters([
|
|
||||||
{ kinds: [1] } // Original filter
|
|
||||||
])
|
|
||||||
// Results in filters for kinds 1, 6, and 16
|
|
||||||
```
|
```
|
||||||
|
|||||||
+10
-43
@@ -109,6 +109,13 @@ const updated = addToListPrivately(
|
|||||||
['p', 'pubkey2']
|
['p', 'pubkey2']
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Add new items publicly
|
||||||
|
const addItems = addToListPublicly(
|
||||||
|
list,
|
||||||
|
['p', 'pubkey3'],
|
||||||
|
['p', 'pubkey4']
|
||||||
|
)
|
||||||
|
|
||||||
// Encrypt and publish
|
// Encrypt and publish
|
||||||
const encrypted = await updated.reconcile(encrypt)
|
const encrypted = await updated.reconcile(encrypt)
|
||||||
```
|
```
|
||||||
@@ -121,19 +128,6 @@ const list = readList(decryptedEvent)
|
|||||||
// Remove item
|
// Remove item
|
||||||
const removeItem = removeFromList(list, 'pubkey1')
|
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
|
// Remove by predicate
|
||||||
const noMentions = removeFromListByPredicate(
|
const noMentions = removeFromListByPredicate(
|
||||||
list,
|
list,
|
||||||
@@ -141,35 +135,8 @@ const noMentions = removeFromListByPredicate(
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Common List Types
|
### Working with Tags
|
||||||
|
|
||||||
### Mute List
|
|
||||||
```typescript
|
```typescript
|
||||||
const muteList = makeList({
|
// Get all list tags
|
||||||
kind: 10000,
|
const tags = getListTags(list)
|
||||||
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']
|
|
||||||
]
|
|
||||||
})
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ const profile = makeProfile({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Create profile event
|
// Create profile event
|
||||||
const event = createProfile(profile)
|
const profileEvent = createProfile(profile)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Reading Profile
|
### Reading Profile
|
||||||
@@ -107,7 +107,7 @@ if (profileHasName(profile)) {
|
|||||||
### Updating Profile
|
### Updating Profile
|
||||||
```typescript
|
```typescript
|
||||||
// Edit existing profile
|
// Edit existing profile
|
||||||
const updated = editProfile({
|
const profileEvent = editProfile({
|
||||||
...existingProfile,
|
...existingProfile,
|
||||||
name: "New Name",
|
name: "New Name",
|
||||||
about: "Updated bio"
|
about: "Updated bio"
|
||||||
|
|||||||
+126
-115
@@ -1,147 +1,158 @@
|
|||||||
# Relay
|
# 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` module provides utilities for working with Nostr relays, including URL normalization, validation, and relay profile handling.
|
||||||
The Relay class extends EventEmitter to provide event-based communication.
|
|
||||||
|
|
||||||
## Core Components
|
## API
|
||||||
|
|
||||||
|
### Types and Enums
|
||||||
|
|
||||||
### Relay Class
|
|
||||||
```typescript
|
```typescript
|
||||||
class Relay<E extends HashedEvent = TrustedEvent> extends Emitter {
|
// Relay operation modes
|
||||||
constructor(readonly repository: Repository<E>)
|
export enum RelayMode {
|
||||||
|
Read = "read",
|
||||||
// Emit events: 'EVENT', 'EOSE', 'OK'
|
Write = "write",
|
||||||
emit(type: string, ...args: any[]): boolean
|
Inbox = "inbox"
|
||||||
|
|
||||||
// Handle relay messages
|
|
||||||
send(type: string, ...message: any[]): void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
### 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
|
```typescript
|
||||||
// Fetch relay information document
|
// Get display name for relay profile
|
||||||
async function getRelayProfile(url: string): Promise<RelayProfile | null> {
|
export declare const displayRelayProfile: (profile?: RelayProfile, fallback?: string) => string;
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## URL Utilities
|
## Examples
|
||||||
|
|
||||||
### URL Validation
|
### URL Validation
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Check if URL is valid relay URL
|
import {
|
||||||
isRelayUrl(url: string): boolean
|
isRelayUrl,
|
||||||
|
isOnionUrl,
|
||||||
|
isLocalUrl,
|
||||||
|
isShareableRelayUrl
|
||||||
|
} from '@welshman/util';
|
||||||
|
|
||||||
// Check if URL is .onion address
|
// Valid relay URLs
|
||||||
isOnionUrl(url: string): boolean
|
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
|
// Invalid URLs
|
||||||
isLocalUrl(url: string): boolean
|
console.log(isRelayUrl('https://example.com')); // false (not websocket)
|
||||||
|
console.log(isRelayUrl('invalid-url')); // false
|
||||||
|
|
||||||
// Check if URL is IP address
|
// Special URL types
|
||||||
isIPAddress(url: string): boolean
|
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
|
// Safe to share publicly
|
||||||
isShareableRelayUrl(url: string): boolean
|
console.log(isShareableRelayUrl('wss://relay.damus.io')); // true
|
||||||
|
console.log(isShareableRelayUrl('ws://localhost:8080')); // false (local)
|
||||||
```
|
```
|
||||||
|
|
||||||
### URL Formatting
|
### URL Normalization
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Normalize relay URL
|
import { normalizeRelayUrl, displayRelayUrl } from '@welshman/util';
|
||||||
normalizeRelayUrl(url: string): string
|
|
||||||
|
|
||||||
// Format URL for display
|
// Normalize various URL formats
|
||||||
displayRelayUrl(url: string): string
|
console.log(normalizeRelayUrl('relay.damus.io'));
|
||||||
|
// 'wss://relay.damus.io/'
|
||||||
|
|
||||||
// Format relay profile for display
|
console.log(normalizeRelayUrl('ws://RELAY.EXAMPLE.COM/path'));
|
||||||
displayRelayProfile(profile?: RelayProfile, fallback = ""): string
|
// '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
|
```typescript
|
||||||
// Validate relay URL
|
import { displayRelayProfile, RelayProfile } from '@welshman/util';
|
||||||
if (isRelayUrl(url)) {
|
|
||||||
// Normalize for consistency
|
|
||||||
const normalized = normalizeRelayUrl(url)
|
|
||||||
|
|
||||||
// Check if shareable
|
const relayProfile: RelayProfile = {
|
||||||
if (isShareableRelayUrl(normalized)) {
|
url: 'wss://relay.damus.io',
|
||||||
// Format for display
|
name: 'Damus Relay',
|
||||||
const display = displayRelayUrl(normalized)
|
description: 'A high-performance Nostr relay',
|
||||||
showRelay(display)
|
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
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
```
|
|
||||||
|
// Get display name
|
||||||
### Relay usage with Repository
|
const displayName = displayRelayProfile(relayProfile);
|
||||||
|
console.log(displayName); // 'Damus Relay'
|
||||||
```typescript
|
|
||||||
// Create storage and relay interface
|
// With fallback for unnamed relays
|
||||||
const repository = new Repository()
|
const anonymousRelay: RelayProfile = {
|
||||||
const relay = new Relay(repository)
|
url: 'wss://anonymous.relay.com'
|
||||||
|
};
|
||||||
// Subscribe to events
|
|
||||||
relay.send("REQ", "sub_id", {
|
const name = displayRelayProfile(anonymousRelay, 'Unknown Relay');
|
||||||
kinds: [1],
|
console.log(name); // 'Unknown Relay'
|
||||||
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")
|
|
||||||
```
|
```
|
||||||
|
|||||||
+1
-31
@@ -103,9 +103,8 @@ uniqTags(tags: string[][]): string[][]
|
|||||||
tagsFromIMeta(imeta: string[]): string[][]
|
tagsFromIMeta(imeta: string[]): string[][]
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage Examples
|
## Example
|
||||||
|
|
||||||
### Basic Tag Handling
|
|
||||||
```typescript
|
```typescript
|
||||||
// Get specific tag types
|
// Get specific tag types
|
||||||
const pubkeys = getPubkeyTagValues(event.tags)
|
const pubkeys = getPubkeyTagValues(event.tags)
|
||||||
@@ -118,32 +117,3 @@ const refs = getTags(['p', 'e'], event.tags)
|
|||||||
// Get single tag
|
// Get single tag
|
||||||
const topic = getTagValue('t', event.tags)
|
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
|
# 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
|
## Protocol Overview
|
||||||
The Zapper interface represents a Lightning Network payment provider that can process zaps:
|
|
||||||
|
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
|
```typescript
|
||||||
interface Zapper {
|
// Zapper service information
|
||||||
// LNURL for payment processing
|
export type Zapper = {
|
||||||
lnurl: string
|
lnurl: string;
|
||||||
|
pubkey?: string;
|
||||||
|
callback?: string;
|
||||||
|
minSendable?: number;
|
||||||
|
maxSendable?: number;
|
||||||
|
nostrPubkey?: string;
|
||||||
|
allowsNostr?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
// User's pubkey on the payment service
|
// Complete zap with request and receipt
|
||||||
pubkey?: string
|
export type Zap = {
|
||||||
|
request: TrustedEvent; // kind 9734 (zap request)
|
||||||
// LNURL callback endpoint
|
response: TrustedEvent; // kind 9735 (zap receipt)
|
||||||
callback?: string
|
invoiceAmount: number; // amount in millisatoshis
|
||||||
|
};
|
||||||
// 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
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Finding Nostr Zappers
|
### Lightning Network Utilities
|
||||||
|
|
||||||
#### Getting Lightning Info
|
|
||||||
|
|
||||||
First, check the user's profile for Lightning addresses:
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
function getLightningInfo(profile: Profile) {
|
// Convert human-readable amount to millisatoshis
|
||||||
// Check for Lightning Address (NIP-57)
|
export declare const hrpToMillisat: (hrpString: string) => bigint;
|
||||||
if (profile.lud16) {
|
|
||||||
return {
|
|
||||||
type: 'lud16',
|
|
||||||
address: profile.lud16
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for LNURL
|
// Extract amount from BOLT11 lightning invoice
|
||||||
if (profile.lud06) {
|
export declare const getInvoiceAmount: (bolt11: string) => number;
|
||||||
return {
|
|
||||||
type: 'lud06',
|
|
||||||
url: profile.lud06
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
// Convert lightning address or URL to LNURL
|
||||||
}
|
export declare const getLnUrl: (address: string) => string | null;
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Fetching LNURL Metadata
|
### Zap Validation
|
||||||
|
|
||||||
Once you have the Lightning address or LNURL, fetch the metadata:
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
async function fetchZapper(address: string): Promise<Zapper | null> {
|
// Create validated Zap from zap receipt event
|
||||||
// Convert Lightning address to LNURL if needed
|
export declare const zapFromEvent: (response: TrustedEvent, zapper?: Zapper) => Zap | null;
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Converting Lightning Addresses
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Example Alby zapper configuration
|
import { getLnUrl } from '@welshman/util';
|
||||||
const albyZapper: Zapper = {
|
|
||||||
lnurl: "lnurl1...",
|
// Lightning address (LUD-16)
|
||||||
pubkey: "alby_user_pubkey",
|
const lnurl1 = getLnUrl('satoshi@getalby.com');
|
||||||
nostrPubkey: "alby_signing_key",
|
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,
|
allowsNostr: true,
|
||||||
minSendable: 1000, // 1 sat minimum
|
minSendable: 1000,
|
||||||
maxSendable: 100000000 // 100k sats maximum
|
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
|
// Step 2: Create zap request (kind 9734)
|
||||||
const lnbitsZapper: Zapper = {
|
const zapRequest = makeEvent(ZAP_REQUEST, {
|
||||||
lnurl: "lnurl1...",
|
content: 'Amazing content!',
|
||||||
callback: "https://lnbits.com/callback",
|
tags: [
|
||||||
nostrPubkey: "lnbits_signing_key",
|
['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
|
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
|
### Zap Validation Rules
|
||||||
```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
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Core Functions
|
The `zapFromEvent` function validates several aspects of a zap according to NIP-57:
|
||||||
|
|
||||||
### 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.
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
function zapFromEvent(
|
import { zapFromEvent } from '@welshman/util';
|
||||||
response: TrustedEvent,
|
|
||||||
zapper: Zapper | undefined
|
// Validation checks performed:
|
||||||
): Zap | null
|
// 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
|
||||||
## Usage Examples
|
// 4. LNURL matches the expected service (if provided in request)
|
||||||
|
// 5. Self-zaps are filtered out (sender != zapper)
|
||||||
### Processing Lightning Addresses
|
|
||||||
```typescript
|
const zapReceipt = {
|
||||||
// Get LNURL from various formats
|
// ... zap receipt event
|
||||||
const lnurl1 = getLnUrl("user@getalby.com")
|
};
|
||||||
const lnurl2 = getLnUrl("https://getalby.com/.well-known/lnurlp/user")
|
|
||||||
const lnurl3 = getLnUrl("lnurl1...")
|
const zapper = {
|
||||||
|
nostrPubkey: 'expected-zapper-pubkey',
|
||||||
// Check if conversion was successful
|
lnurl: 'expected-lnurl'
|
||||||
if (lnurl1) {
|
};
|
||||||
// Process LNURL
|
|
||||||
processLnurl(lnurl1)
|
const validZap = zapFromEvent(zapReceipt, zapper);
|
||||||
}
|
|
||||||
```
|
// Returns null if any validation fails:
|
||||||
|
// - Malformed bolt11 invoice
|
||||||
### Invoice Amount Handling
|
// - Amount mismatch
|
||||||
```typescript
|
// - Wrong zapper pubkey
|
||||||
// Get invoice amount in millisats
|
// - LNURL mismatch
|
||||||
const amount = getInvoiceAmount(bolt11Invoice)
|
// - Self-zap detection
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user