flotilla/.agents/skills/welshman-util/SKILL.md
2026-09-14 14:24:36 -07:00

33 KiB
Raw Blame History

name description
welshman-util Use this skill when working with @welshman/util: nostr event types, kinds, tags, filters, addresses, keys, NIPs (13/42/86/98), relay urls, lightning, wallets, slash commands, or any core nostr data structure. Also covers relay selection — the RelaySelection routing DSL, Resolver, RelayScenario and fallback policies all live here (there is no @welshman/router package). (Profiles, lists, handlers, and rooms now live in @welshman/domain as Reader/Writer classes.)

welshman/util — Core Nostr Utilities

@welshman/util is the foundational layer of the welshman nostr stack, providing types, constants, and helpers for every nostr primitive: events, kinds, tags, filters, addresses, zaps, relays, and Lightning wallet integration. Higher level welshman packages (@welshman/net, @welshman/app, @welshman/store, etc.) depend on the types and utilities defined here.

Installation

npm install @welshman/util
# or
pnpm add @welshman/util
# or
yarn add @welshman/util

Key Exports

Event Types

Type Description
EventContent { tags, content } — base content structure
EventTemplate EventContent + kind
StampedEvent EventTemplate + created_at
OwnedEvent StampedEvent + pubkey
HashedEvent OwnedEvent + id
SignedEvent HashedEvent + sig
TrustedEvent HashedEvent + optional sig — most common in-app type

Event Utilities

Export Description
verifiedSymbol Symbol (re-exported from nostr-tools) used as a key on events; set event[verifiedSymbol] = true to skip signature re-validation
makeEvent(kind, opts?) Create a StampedEvent with optional content, tags, created_at
verifyEvent(event) Verify event signature; returns false for unsigned events (no sig field) even if verifiedSymbol is set, because isSignedEvent is checked first; returns true immediately for signed events where event[verifiedSymbol] is already set
getIdentifier(event) Get d tag value
getIdOrAddress(event) Returns address string for replaceable events, id otherwise
getIdAndAddress(event) Returns array with both id and address (if applicable)
deduplicateEvents(events) Deduplicate by id or address
compareEventsAsc(a, b) / compareEventsDesc(a, b) Canonical event order: created_at, then id to break a same-second tie
sortEventsAsc(events) / sortEventsDesc(events) Sort an iterable of events by that order
isEphemeral(event) True for ephemeral kinds (20000–29999)
isReplaceable(event) True for plain or parameterized replaceable
isPlainReplaceable(event) True for kinds 10000–19999 and metadata/contacts
isParameterizedReplaceable(event) True for kinds 30000–39999
sortEventsAsc(events) / sortEventsDesc(events) Sort by created_at
asEventTemplate, asStampedEvent, asOwnedEvent, asHashedEvent, asSignedEvent Narrow an event down to the fields of a given level

NIP-10 and NIP-22 threading lives on the Note and Comment classes in @welshman/domain, not here. The free helpers getAncestors, getParentIdOrAddr, isChildOf, getReplyTags and getCommentTags do not exist in this package.

Type Guards

isEventTemplate, isStampedEvent, isOwnedEvent, isHashedEvent, isSignedEvent

Keys & event construction

Export Description
makeSecret() Cryptographically secure random hex private key
getPubkey(secret) Derive the hex pubkey from a hex secret
stamp(event, created_at?) EventTemplate → StampedEvent
own(event, pubkey) StampedEvent → OwnedEvent
hash(event) / getHash(event) OwnedEvent → HashedEvent / the id alone
sign(event, secret) / getSig(event, secret) HashedEvent → SignedEvent / the sig alone
prep(event, pubkey, created_at?) Stamp + own + hash in one step — an unsigned rumor

Proof of work (NIP-13)

Export Description
makePow(event, difficulty) Mine a nonce tag until the id has difficulty leading zero bits; returns a ProofOfWork
getPow(event) Leading zero bits on an event's id
estimateWork(difficulty) / benchmarkDifficulty Rough cost estimate for a difficulty target

Event Kinds (constants)

All constants are exported by name from @welshman/util.

Core / NIP-01

PROFILE = 0            NOTE = 1              FOLLOWS = 3
DELETE = 5             REPOST = 6            REACTION = 7
BADGE_AWARD = 8        MESSAGE = 9           THREAD = 11
SEAL = 13              DIRECT_MESSAGE = 14   DIRECT_MESSAGE_FILE = 15
GENERIC_REPOST = 16    PICTURE_NOTE = 20     VANISH = 62
COMMENT = 1111         GENERIC_REPOST = 16

Channels (NIP-28)

CHANNEL_CREATE = 40    CHANNEL_UPDATE = 41   CHANNEL_MESSAGE = 42
CHANNEL_HIDE_MESSAGE = 43                    CHANNEL_MUTE_USER = 44

Wrapped / encrypted (NIP-59)

WRAP = 1059            WRAP_NIP04 = 1060
WRAPPED_KINDS = [DIRECT_MESSAGE, DIRECT_MESSAGE_FILE]   // convenience array

Media / files

FILE_METADATA = 1063   PICTURE_NOTE = 20     AUDIO = 31337

Polls

POLL = 1068            POLL_RESPONSE = 1018

Marketplace / auction

BID = 1021             BID_CONFIRMATION = 1022
STALL = 30017          PRODUCT = 30018       MARKET_UI = 30019
PRODUCT_SOLD_AS_AUCTION = 30020
CLASSIFIED = 30402     DRAFT_CLASSIFIED = 30403

Git (NIP-34)

GIT_PATCH = 1617       GIT_ISSUE = 1621      GIT_REPLY = 1622
GIT_STATUS_OPEN = 1630 GIT_STATUS_COMPLETE = 1631
GIT_STATUS_CLOSED = 1632                     GIT_STATUS_DRAFT = 1633
GIT_REPOSITORY = 30403

Social / community

REMIX = 1808           REPORT = 1984         LABEL = 1985
REVIEW = 1986          HIGHLIGHT = 9802      APPROVAL = 4550
NOSTROCKET_PROBLEM = 1971
COMMUNITY = 34550
BADGE_DEFINITION = 30009   BADGES = 30008
LIVE_EVENT = 30311     LIVE_CHAT_MESSAGE = 1311

Rooms (NIP-29)

ROOM_CREATE = 9007     ROOM_DELETE = 9008    ROOM = 35834
ROOM_JOIN = 9021       ROOM_LEAVE = 9022     ROOM_META = 39000
ROOM_ADMINS = 39001    ROOM_MEMBERS = 39002  ROOM_EDIT_META = 9002
ROOM_ADD_MEMBER = 9000 ROOM_REMOVE_MEMBER = 9001
ROOM_ADD_PERM = 9003   ROOM_REMOVE_PERM = 9004
ROOM_DELETE_EVENT = 9005                     ROOM_EDIT_STATUS = 9006
ROOM_CREATE_PERMISSION = 19004
ROOM_UPDATE_PINS = 9010                      ROOM_PINS = 39005
RELAY_MEMBERS = 13534  RELAY_ADD_MEMBER = 8000   RELAY_REMOVE_MEMBER = 8001
RELAY_JOIN = 28934     RELAY_INVITE = 28935      RELAY_LEAVE = 28936
RELAY_ROLE = 33534

Pinboards

PINBOARD = 30067       PIN = 39067

Slash commands

COMMAND = 31992

Replaceable lists (kinds 10000–10099)

MUTES = 10000          PINS = 10001          RELAYS = 10002
BOOKMARKS = 10003      COMMUNITIES = 10004   CHANNELS = 10005
BLOCKED_RELAYS = 10006 SEARCH_RELAYS = 10007 ROOMS = 10009
FEEDS = 10014          TOPICS = 10015        EMOJIS = 10030
MESSAGING_RELAYS = 10050                     BLOSSOM_SERVERS = 10063
FILE_SERVERS = 10096

Parameterized replaceable lists (kinds 30000–30102)

NAMED_PEOPLE = 30000   NAMED_RELAYS = 30002  NAMED_BOOKMARKS = 30003
NAMED_CURATIONS = 30004                      NAMED_TOPICS = 30015
NAMED_WIKI_AUTHORS = 30101                   NAMED_WIKI_RELAYS = 30102
NAMED_EMOJIS = 30030   NAMED_ARTIFACTS = 30063
NAMED_COMMUNITIES = 30064

Long-form / wiki / publishing (NIP-23)

LONG_FORM = 30023      LONG_FORM_DRAFT = 30024
WIKI = 30818           APP_DATA = 30078
FEED = 31890

Calendar (NIP-52)

CALENDAR = 31924       EVENT_DATE = 31922    EVENT_TIME = 31923
EVENT_RSVP = 31925

Handlers (NIP-89)

HANDLER_INFORMATION = 31990   HANDLER_RECOMMENDATION = 31989

Status / alerts

STATUS = 30315
ALERT_EMAIL = 32830    ALERT_STATUS = 32831  ALERT_WEB = 32832
ALERT_ANDROID = 32833  ALERT_IOS = 32834

Zaps / wallet / Lightning

ZAP_GOAL = 9041        ZAP_REQUEST = 9734    ZAP_RECEIPT = 9735
WALLET_INFO = 13194    WALLET_REQUEST = 23194 WALLET_RESPONSE = 23195
LIGHTNING_PUB_RPC = 21000
OTS = 1040

Auth

CLIENT_AUTH = 22242    BLOSSOM_AUTH = 24242  HTTP_AUTH = 27235
NOSTR_CONNECT = 24133

Follow packs

FOLLOW_PACK = 39089

Promenade protocol

PROMENADE_REGISTER_ACCOUNT = 16430   PROMENADE_SHARD_SHARE = 26428
PROMENADE_SHARD_ACK = 26429          PROMENADE_CONFIG = 26430
PROMENADE_COMMIT = 26431             PROMENADE_REQUEST = 26432
PROMENADE_RESULT = 26433

Deprecated

DEPRECATED_RELAY_RECOMMENDATION = 2
DEPRECATED_DIRECT_MESSAGE = 4
DEPRECATED_NAMED_GENERIC = 30001

DVM — Data Vending Machines (NIP-90, kinds 5000–7000)

Requests (5xxx) and their paired responses (6xxx):

DVM_REQUEST_TEXT_EXTRACTION = 5000     DVM_RESPONSE_TEXT_EXTRACTION = 6000
DVM_REQUEST_TEXT_SUMMARY = 5001        DVM_RESPONSE_TEXT_SUMMARY = 6001
DVM_REQUEST_TEXT_TRANSLATION = 5002    DVM_RESPONSE_TEXT_TRANSLATION = 6002
DVM_REQUEST_TEXT_GENERATION = 5050     DVM_RESPONSE_TEXT_GENERATION = 6050
DVM_REQUEST_IMAGE_GENERATION = 5100    DVM_RESPONSE_IMAGE_GENERATION = 6100
DVM_REQUEST_VIDEO_CONVERSION = 5200    DVM_RESPONSE_VIDEO_CONVERSION = 6200
DVM_REQUEST_VIDEO_TRANSLATION = 5201   DVM_RESPONSE_VIDEO_TRANSLATION = 6201
DVM_REQUEST_IMAGE_TO_VIDEO_CONVERSION = 5202
DVM_RESPONSE_IMAGE_TO_VIDEO_CONVERSION = 6202
DVM_REQUEST_TEXT_TO_SPEECH = 5250      DVM_RESPONSE_TEXT_TO_SPEECH = 6250
DVM_REQUEST_DISCOVER_CONTENT = 5300    DVM_RESPONSE_DISCOVER_CONTENT = 6300
DVM_REQUEST_DISCOVER_PEOPLE = 5301     DVM_RESPONSE_DISCOVER_PEOPLE = 6301
DVM_REQUEST_SEARCH_CONTENT = 5302      DVM_RESPONSE_SEARCH_CONTENT = 6302
DVM_REQUEST_SEARCH_PEOPLE = 5303       DVM_RESPONSE_SEARCH_PEOPLE = 6303
DVM_REQUEST_COUNT = 5400               DVM_RESPONSE_COUNT = 6400
DVM_REQUEST_MALWARE_SCAN = 5500        DVM_RESPONSE_MALWARE_SCAN = 6500
DVM_REQUEST_OTS = 5900                 DVM_RESPONSE_OTS = 6900
DVM_REQUEST_OP_RETURN = 5901           DVM_RESPONSE_OP_RETURN = 6901
DVM_REQUEST_PUBLISH_SCHEDULE = 5905    DVM_RESPONSE_PUBLISH_SCHEDULE = 6905
DVM_FEEDBACK = 7000

Use isDVMKind(kind) to test if a kind falls in the DVM range (5000–7000).

Kind classifiers

isRegularKind(kind)                // 1000–9999 and select low kinds
isPlainReplaceableKind(kind)       // 0, 3, and 10000–19999
isEphemeralKind(kind)              // 20000–29999
isParameterizedReplaceableKind(kind) // 30000–39999
isReplaceableKind(kind)            // plain OR parameterized replaceable
isDVMKind(kind)                    // 5000–7000

Tags

Export Description
tagSpec(keys, matchValue?, normalizeValue?) Build a TagSpec — keys is a string or string[]; optional value filter/normalizer
hexTags(keys) Spec matching 32-byte hex values (isHex32) — e/p tags
addressTags(keys) Spec matching replaceable addresses (Address.isAddress) — a tags
relayTags(keys) Spec matching relay urls (isRelayUrl) — r/relay tags
topicTags(keys) Spec that strips a leading # from values — t tags
kindTags(keys) Spec whose values parse to number — k tags
matchTags(spec, tags) All tags matching the spec — spec first, then the tags array
matchTag(spec, tags) First tag matching the spec, or undefined
tagValues(spec, tags) Values (index 1, normalized) of all matching tags; undefined dropped
tagValue(spec, tags) Value of the first matching tag, or undefined
tagMatcher(spec) / tagValueExtractor(spec) The raw (tag)=>boolean / (tag)=>T for a spec
tagValueMatcher(spec, value) Predicate for one value, compared after normalization — matches however the tag spelled it

The old getTagValue/getPubkeyTagValues/getEventTags/… accessors were removed — use the spec selectors above (e.g. tagValues(hexTags("p"), tags), tagValue(tagSpec("title"), tags)).

Filters

Export Description
matchFilter(filter, event) Test if event matches a single filter
matchFilters(filters, event) Test if event matches any filter
getIdFilters(idsOrAddresses) Build filters from mixed ids and addresses
getReplyFilters(events, filter?) Build filters to find replies
addRepostFilters(filters) Add kind 6/16 repost filters
unionFilters(filters) Merge overlapping filters
intersectFilters(groups) Intersect arrays of filter groups
trimFilter(filter) / trimFilters(filters) Limit array fields to 1000 items
getFilterId(filter) Compact string key for a filter
getFilterGenerality(filter) 0 = specific, 1 = general
guessFilterDelta(filters, max?) Estimate appropriate time window in seconds
getFilterResultCardinality(filter) Expected result count for id-based filters

Address

Export Description
Address class Handles kind:pubkey:identifier and NIP-19 naddr format
Address.isAddress(s) Validate address string format
Address.from(s, relays?) Parse from kind:pubkey:identifier string
Address.fromNaddr(naddr) Parse from NIP-19 naddr
Address.fromEvent(event, relays?) Create from addressable event
address.toString() Serialize to kind:pubkey:identifier
address.toNaddr() Serialize to NIP-19 naddr
getAddress(event) Convenience: get address string from event

Relay

Export Description
LOCAL_RELAY_URL "local://welshman.relay/" — the conventional url for the in-memory repository
isRelayUrl(url) Validate relay URL
isShareableRelayUrl(url) True if valid relay URL and not a local address
isOnionUrl(url) Tor address check
isLocalUrl(url) Local address check
isIPAddress(url) IP address check
normalizeRelayUrl(url) Normalize to standard wss:// format (passes LOCAL_RELAY_URL through unchanged)
displayRelayUrl(url) Strip protocol and trailing slash

RelayMode, RelayProfile and displayRelayProfile are gone. Read/write intent is expressed by the NIP-65 RelayList reader/writer in @welshman/domain (readUrls()/writeUrls(), addReadUrl/addWriteUrl), and NIP-11 relay info by the Relay type in @welshman/domain plus app.use(Relays).

Relay Selection (routing DSL)

RelaySelection.ts holds the relay-routing DSL. A relay selection names a source, such as "the author's outbox" or "the relays this event was seen on", rather than a list of urls. Turning one into urls needs relay lists, a tracker and a repository, so producing and resolving selections are separate steps: this package defines and scores them, and @welshman/app's Router plugin supplies the one ResolveRoute implementation that dereferences them (see the welshman-app skill). @welshman/domain readers/writers/queries emit selections, and @welshman/feeds asks for the capability as its FeedRouter interface.

Route + selection types

Type Description
EventRef { id?, pubkey?, kind?, identifier?, relays? } — all optional and additive. A known pubkey routes directly without finding the event; id (or kind+pubkey+identifier) lets the resolver look it up; relays are hints for that lookup and a last-resort fallback
RelayRoute Discriminated union: userInbox / userOutbox / userMessaging, pubkeyInbox / pubkeyOutbox / pubkeyMessaging ({pubkey}), eventInbox / eventOutbox / seen ({ref}), relay ({url}), index, search
RelaySelection { route: RelayRoute; weight: number }

DSL constructors (each defaults weight = 1)

Export Returns Description
inbox(pubkey, weight?) RelaySelection that pubkey's read relays
outbox(pubkey, weight?) RelaySelection that pubkey's write relays
messaging(pubkey, weight?) RelaySelection that pubkey's NIP-17 messaging relays
userInbox(weight?) / userOutbox(weight?) / userMessaging(weight?) RelaySelection the current user's relays
eventInbox(ref, weight?) / eventOutbox(ref, weight?) RelaySelection the referenced event's author's relays
seen(ref, weight?) RelaySelection relays the event was found on (tracker + ref hints)
relay(url, weight?) RelaySelection a literal relay url (formerly relayHint)
relays(urls, weight?) RelaySelection[] one selection per url (formerly relayHints)
inboxes(pubkeys, weight?) RelaySelection[] uniq(pubkeys).map(inbox) — inbox per referenced pubkey
indexers(weight?) RelaySelection profile/relay-list index relays
searchRelays(weight?) RelaySelection full-text search relays

Note relays and inboxes return arrays — spread them with ... into a route list; every other constructor returns a single RelaySelection.

Resolved selections + fallback policies

Export Description
Selection { weight: number; relays: string[] } — a concrete, resolved weighted relay set
makeSelection(relays, weight?) Build a Selection, filtering isRelayUrl and normalizing each url
FallbackPolicy (count: number, limit: number) => number — how many defaults to add
addNoFallbacks Never add fallback relays (the default)
addMinimalFallbacks Add one fallback only if nothing else was found
addMaximalFallbacks Top up to the limit with fallbacks

RelayScenario — scores and picks concrete relays from weighted Selections:

new RelayScenario(selections: Selection[], options?: RelayScenarioOptions)
// options: { policy?, limit?, allowLocal?, allowOnion?, allowInsecure?,
//            getRelayQuality?, getDefaultRelays? }

scenario.limit(n)          // chainable (returns a cloned scenario)
scenario.policy(fn)        // chainable
scenario.allowLocal(bool) / allowOnion(bool) / allowInsecure(bool)   // chainable
scenario.getUrls()         // string[]
scenario.getUrl()          // first of getUrls()

getUrls() drops onion, local and plain-ws:// urls unless explicitly allowed, sums each url's weight across selections, scores as quality * (1 + log(weight)) times a random factor, takes the best limit (default 3), then adds shuffled getDefaultRelays() urls per the fallback policy. The log keeps a relay that many selections name from dominating, and the random factor lets lower-ranked relays get picked occasionally.

Resolver — bundles a single route-resolution function with the scenario options to apply to everything it produces:

type ResolveRoute = (route: RelayRoute) => MaybeAsync<string[]>

new Resolver(routeResolver: ResolveRoute, options?: RelayScenarioOptions)

await resolver.scenario(selections)  // Promise<RelayScenario> — resolves each route, builds a scenario
await resolver.relays(selections)    // Promise<string[]>  — scenario(...).getUrls()
await resolver.relay(selections)     // Promise<string | undefined> — scenario(...).getUrl()

In an app, @welshman/app's Router owns the Resolver (app.use(Router).resolver), built with getRelayQuality/getDefaultRelays from app config, and injects it into every domain kind's context. Domain readers/writers then call def.context.resolver.scenario(...) / .relay(...).

import {outbox, inboxes, relay, Resolver} from '@welshman/util'

// Declarative selections: author's write relays (weight 1) + each mentioned
// pubkey's read relays (weight 0.5) + an explicit relay hint.
const selections = [
  outbox(authorPubkey),
  ...inboxes(mentionedPubkeys, 0.5),
  relay('wss://relay.example.com'),
]

// A Resolver dereferences routes -> urls given some route resolver.
const resolver = new Resolver(resolveRoute, {limit: 5, getRelayQuality})
const urls = await resolver.relays(selections)   // string[]

Routing gotchas

  • Resolution is async, because resolving an outbox may have to load a NIP-65 list first.
  • A scenario that resolves to nothing yields an empty array. Add .policy(addMinimalFallbacks) where an empty result would break the caller.
  • Weights express preference, not selection. Naming the same url in ten selections does not make it ten times more likely. To force a relay, use forceRoutes (domain writers) or setRoutes (domain queries).
  • Two calls with identical selections can return different urls. In tests, assert on membership rather than exact url lists, or inject a deterministic getRelayQuality.
  • A relay whose getRelayQuality is 0 is dropped entirely, because the scenario filters on the score and -0 is falsy. A scenario can come back empty even though its selections resolved to urls.

Slash commands

Command.ts models NIP-89-style slash commands: CommandArg/CommandArgType/COMMAND_ARG_TYPES (pubkey, event, address, relay, number, bool, enum, word, text) with validateCommandArgs; CommandScope/CommandScopeTarget with parseCommandScope, renderCommandScope, matchesCommandScopes, commandScopeRelays, commandScopesToFilter; and the invocation grammar — parseCommandInvocation, renderCommandInvocation, bindCommandArgs, parseCommandArgs, getActiveCommandArgIndex. Kind COMMAND is 31992. @welshman/editor's CommandExtension/CommandSuggestion render and autocomplete these in the composer.

Lightning (NIP-57 support)

Export Description
getLnUrl(address) Convert a lud16 address, HTTPS URL, or existing lnurl1… to an LNURL; undefined if invalid
getInvoiceAmount(bolt11) Extract the millisatoshi amount from a BOLT11 invoice
hrpToMillisat(hrpString) Convert a human-readable BTC amount to millisats (bigint)
toMsats(sats) / fromMsats(msats) Unit conversion

The Zapper and Zap types moved to @welshman/domain (other/Zapper.ts), alongside the ZapRequest/ZapReceipt/ZapGoal kinds. Receipt validation is app.use(Zappers).validateZapReceipt(...).

NIP-05 handles

Export Description
Handle { nip05, pubkey?, nip46?, relays? }
queryProfile(nip05) Resolve a NIP-05 identifier via /.well-known/nostr.json; undefined on failure
displayNip05(nip05) / displayHandle(handle) Drop a leading _@ for display

Pubkey

Pubkey wraps a hex pubkey plus relay hints. Pubkey.from(entity, relays?) accepts hex, npub… or nprofile…; instances expose toString(), toNpub(), toNprofile().

Wallet

Export Description
WalletType Enum: WebLN, NWC
Wallet Union: `WebLNWallet
isWebLNWallet(wallet) Type guard
isNWCWallet(wallet) Type guard

NIP-42 (Relay Auth)

makeRelayAuth(url: string, challenge: string): StampedEvent
// Creates kind 22242 auth event; sign before sending

NIP-98 (HTTP Auth)

makeHttpAuth(url: string, method?: string, body?: string): Promise<StampedEvent>
makeHttpAuthHeader(event: SignedEvent): string  // Returns "Nostr <base64>"

NIP-86 (Relay Management)

sendManagementRequest(url: string, request: ManagementRequest, authEvent: SignedEvent): Promise<ManagementResponse>
// ManagementResponse = { result?: any; error?: string }

Requests are built by make* factories rather than an enum: makeBanPubkey, makeAllowPubkey, makeBanEvent, makeAllowEvent, makeCreateRole/makeEditRole/makeDeleteRole, makeAssignRole/makeUnassignRole, makeAssignMethod/makeUnassignMethod, makeCreateClaim/makeDeleteClaim/makeListClaims, makeChangeRelayName/Description/Icon, makeAllowKind/makeDisallowKind, makeBlockIp/makeUnblockIp, makeSignEvent, makeSupportedMethods, and the matching makeList* readers.

ManagementApi is a client class that pairs a relay url with a ManagementSign function so you don't have to build the NIP-98 auth event per call. app.use(RelayManagement).forUrl(url) returns one bound to the app's user.

fromNostrURI(s: string): string  // strips "nostr:" or "nostr://" prefix
toNostrURI(s: string): string    // ensures "nostr:" prefix

Blossom (Media Servers)

makeBlossomAuthEvent(opts: BlossomAuthEventOpts): StampedEvent
uploadBlob(server, blob, opts?): Promise<Response>
getBlob(server, sha256, opts?): Promise<Response>
deleteBlob(server, sha256, opts?): Promise<Response>
listBlobs(server, pubkey, opts?): Promise<Response>
checkBlobExists(server, sha256, opts?): Promise<{exists, size?}>
buildBlobUrl(server, sha256, extension?): string
encryptFile(file: Blob): Promise<EncryptedFile>
decryptFile(encryptedFile: EncryptedFile): Promise<Uint8Array>

Common Patterns

Creating and inspecting events

import { makeEvent, NOTE, PROFILE, RELAYS, LONG_FORM, getIdentifier, getIdOrAddress } from '@welshman/util'

// Text note (kind 1)
const note = makeEvent(NOTE, {
  content: 'Hello Nostr!',
  tags: [['t', 'nostr']],
})

// Profile update (kind 0)
const profile = makeEvent(PROFILE, {
  content: JSON.stringify({ name: 'Alice', about: 'Nostr dev' }),
  tags: [],
})

// Relay list (kind 10002)
const relayList = makeEvent(RELAYS, {
  content: '',
  tags: [
    ['r', 'wss://relay.example.com', 'read'],
    ['r', 'wss://relay2.example.com', 'write'],
  ],
})

Pre-verifying persisted events with verifiedSymbol

When loading events from a local store (IndexedDB, localStorage, etc.) at startup, you can skip expensive signature re-validation by marking them as already verified:

import { verifiedSymbol } from '@welshman/util'
import type { TrustedEvent } from '@welshman/util'

// Load from storage
const storedEvents: TrustedEvent[] = await db.getAll('events')

// Mark as pre-verified — verifyEvent() will return true immediately (without
// re-running the cryptographic check) for events that have a sig field
for (const event of storedEvents) {
  event[verifiedSymbol] = true
}

app.repository.load(storedEvents)

Only do this for events you persisted yourself after they were validated. Never set verifiedSymbol on events received directly from untrusted external sources.

Working with tags

import {
  tagSpec,
  hexTags,
  topicTags,
  relayTags,
  tagValue,
  tagValues,
} from '@welshman/util'

// A selector takes a spec FIRST, then the tags array
const title  = tagValue(tagSpec('title'), event.tags)     // string | undefined
const urls   = tagValues(tagSpec('r'), event.tags)        // string[]

// Multiple keys at once
const ids    = tagValues(tagSpec(['e', 'a']), event.tags) // string[]

const mentions = tagValues(hexTags('p'), event.tags)      // string[]
const topics   = tagValues(topicTags('t'), event.tags)    // string[] ("#x" -> "x")
const relays   = tagValues(relayTags(['r', 'relay']), event.tags)

Matching and building filters

import { matchFilters, getIdFilters, getReplyFilters, addRepostFilters, NOTE } from '@welshman/util'
import { ago, HOUR } from '@welshman/lib'

// Does this event match our subscription?
const active = matchFilters([{ kinds: [NOTE], authors: [myPubkey] }], event)

// Fetch a set of events by id or address
const filters = getIdFilters([
  'abc123',                             // event id
  '30023:deadbeef:my-slug',             // address
])

// Find all replies to a set of events
const replyFilters = getReplyFilters(events, { since: ago(HOUR) })

// Automatically include repost kinds
const withReposts = addRepostFilters([{ kinds: [NOTE] }])

Addresses for replaceable events

import { Address, getAddress } from '@welshman/util'

// From an addressable event
const addr = Address.fromEvent(event, ['wss://relay.example.com'])
console.log(addr.toString())  // '30023:deadbeef:my-slug'
console.log(addr.toNaddr())   // 'naddr1...'

// Round-trip from naddr
const parsed = Address.fromNaddr('naddr1...')

// Quick string form
const addressStr = getAddress(event)  // '30023:deadbeef:my-slug'

Zap flow

import { getLnUrl, makeEvent, ZAP_REQUEST } from '@welshman/util'
import { Zappers } from '@welshman/app'

// Step 1: resolve LNURL
const lnurl = getLnUrl('satoshi@getalby.com')
if (!lnurl) throw new Error('Invalid lightning address')

// Step 2: build zap request (kind 9734)
const zapRequest = makeEvent(ZAP_REQUEST, {
  content: 'Great post!',
  tags: [
    ['p', recipientPubkey],
    ['e', targetEventId],
    ['amount', '5000'],           // millisats
    ['lnurl', lnurl],
    ['relays', 'wss://relay.damus.io'],
  ],
})

// Step 3: sign, send to LNURL callback, pay invoice...

// Step 4: validate receipt (kind 9735)
const zap = await app.use(Zappers).validateZapReceipt(zapReceipt, zappedEvent)
if (zap) {
  console.log(`Received ${zap.invoiceAmount} msat`, zap.request.content)
}

NIP-42 relay authentication

import { makeRelayAuth } from '@welshman/util'

// Inside relay AUTH handler
const authEvent = makeRelayAuth('wss://relay.example.com', challengeFromRelay)
const signed = await signer.sign(authEvent)
// send signed AUTH message to relay

NIP-98 HTTP authentication

import { makeHttpAuth, makeHttpAuthHeader } from '@welshman/util'

const body = JSON.stringify({ data: 'example' })
const authEvent = await makeHttpAuth('https://api.example.com/upload', 'POST', body)
const signed = await signer.signEvent(authEvent)

await fetch('https://api.example.com/upload', {
  method: 'POST',
  body,
  headers: {
    Authorization: makeHttpAuthHeader(signed),
    'Content-Type': 'application/json',
  },
})

Integration Notes

  • @welshman/net — uses TrustedEvent, Filter, SignedEvent from this package as the wire types for relay connections and subscriptions.
  • @welshman/store — provides Svelte stores over repositories built on TrustedEvent; relies on isReplaceable, getAddress, etc. for deduplication.
  • @welshman/app — high-level application layer; composes net/store/domain and uses the lightning helpers from this package (profile/list/handler/room helpers now live in @welshman/domain).
  • @welshman/app's Router plugin — dereferences the RelaySelection DSL defined here, and injects its Resolver into every @welshman/domain kind.
  • @welshman/signer — produces SignedEvent objects that satisfy types defined here; signers also provide the nip44 encrypt/decrypt functions used by @welshman/domain list writers to encrypt private (NIP-44) tags.

Gotchas & Tips

  • TrustedEvent vs SignedEvent: Most in-app code should accept TrustedEvent (has id, may have sig). Only require SignedEvent when you need to ensure the event has a signature.

  • Replaceable event identity: Use getIdOrAddress rather than event.id when referencing events that may be addressable — the address string is stable across updates, the id is not.

  • app.use(Zappers).validateZapReceipt returns undefined on any validation failure including amount mismatch, wrong zapper pubkey, malformed invoice, or self-zap. Always check the result. For a reactive list of a parent's valid zaps use validZapReceipts(receipts, parent), which re-validates as each recipient's zapper loads.

  • getLnUrl handles three input forms: bare lightning address (user@domain), full HTTPS URL, or already-encoded lnurl1.... Returns undefined for anything else.

  • normalizeTopic is not exported. Topics.ts isn't re-exported from the index; use topicTags("t") to get normalized topic values off an event's tags.

  • normalizeRelayUrl vs displayRelayUrl: Use normalizeRelayUrl before storing or comparing relay URLs. Use displayRelayUrl only for human-readable display (strips protocol/trailing slash).

  • Address.isAddress checks the kind:pubkey:identifier format only, not naddr. To validate an naddr string, use Address.fromNaddr inside a try/catch.

  • Tag selector argument order: the spec comes first, the tags array second — tagValue(tagSpec('title'), event.tags), tagValues(hexTags('p'), event.tags). Mixing up the order produces no TypeScript error but silently returns undefined or [].

  • verifiedSymbol is a Symbol key: you must import verifiedSymbol from @welshman/util and use it as a computed property key — event[verifiedSymbol] = true. You cannot use a string key. The symbol is re-exported from nostr-tools/pure, so it is the same identity as the one used internally by verifyEvent.


  • welshman-app (welshman-app skill) — the Router plugin that dereferences the routing DSL above, plus RelayStats.getQuality for the scoring input.
  • @welshman/domain (welshman-domain skill) — Profiles, lists, handlers, rooms, and event routing moved out of @welshman/util and now live here. The old free functions (readProfile/makeProfile, readList/makeList, PublishedProfile/PublishedList, Encryptable, the handler/room helpers, …) were replaced by configurable KindFactory bundles: Kind.configure(context).reader(event) returns an async Reader that decodes the event, and .writer(reader?) builds/edits one. Private (NIP-44) list tags are handled inside the list Reader/Writer, so Encryptable/DecryptedEvent no longer exist.