| name | welshman-util |
| description | Use this skill when working with @welshman/util: nostr event types, kinds, tags, filters, addresses, NIPs (42/86/98), profiles, relays, zaps, wallets, or any core nostr data structures. |
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, profiles, lists, 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
pnpm add @welshman/util
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 |
DecryptedEvent | TrustedEvent + plaintext (for encrypted lists/events) |
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 |
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 |
getAncestors(event) | Returns { roots, replies, mentions } for NIP-10 events (mentions may be empty [] but is always present); NIP-22/COMMENT path returns { roots, replies } without mentions |
getParentIdOrAddr(event) | Immediate parent id or address |
isChildOf(child, parent) | Check if child replies to parent |
Type Guards
isEventTemplate, isStampedEvent, isOwnedEvent, isHashedEvent, isSignedEvent
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
RELAY_MEMBERS = 13534 RELAY_ADD_MEMBER = 8000 RELAY_REMOVE_MEMBER = 8001
RELAY_JOIN = 28934 RELAY_INVITE = 28935 RELAY_LEAVE = 28936
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_RESPONSE = 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)
isPlainReplaceableKind(kind)
isEphemeralKind(kind)
isParameterizedReplaceableKind(kind)
isReplaceableKind(kind)
isDVMKind(kind)
Tags
| Export | Description |
|---|
getTags(types, tags) | Get all tags matching one or more type strings |
getTag(types, tags) | Get first matching tag |
getTagValues(types, tags) | Get value (index 1) of all matching tags — types first, then the tags array |
getTagValue(types, tags) | Get value of first matching tag — types first, then the tags array |
getEventTags(tags) | e tags |
getEventTagValues(tags) | Values of e tags |
getAddressTags(tags) | a tags |
getAddressTagValues(tags) | Values of a tags |
getPubkeyTags(tags) | p tags |
getPubkeyTagValues(tags) | Values of p tags |
getTopicTags(tags) / getTopicTagValues(tags) | t (hashtag) tags |
getRelayTags(tags) / getRelayTagValues(tags) | r and relay tags |
getKindTags(tags) / getKindTagValues(tags) | k tags (returns number[]) |
getGroupTags(tags) / getGroupTagValues(tags) | group tags |
getReplyTags(tags) | { roots, replies, mentions } — NIP-10 threading |
getCommentTags(tags) | { roots, replies } — NIP-22 uppercase/lowercase tags |
uniqTags(tags) | Remove duplicate tags |
tagsFromIMeta(imeta) | Parse imeta tag into array of tag arrays |
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 |
Profile
| Export | Description |
|---|
makeProfile(partial) | Create a profile object |
readProfile(event) | Parse PublishedProfile from kind 0 event |
createProfile(profile) | Create kind 0 EventTemplate |
editProfile(published) | Update existing profile event |
displayProfile(profile?, fallback?) | Get best display name string |
displayPubkey(pubkey) | Shorten pubkey to npub1abc...xyz |
profileHasName(profile?) | Check if profile has a name field |
Profile fields: name, display_name, about, picture, banner, website, nip05, lud06, lud16, lnurl
Lists (kind 10000+)
| Export | Description |
|---|
makeList(params) | Create a new list |
readList(event) | Parse PublishedList from DecryptedEvent |
getListTags(list) | Combined public + private tags |
addToListPublicly(list, ...tags) | Returns Encryptable with tag added publicly |
addToListPrivately(list, ...tags) | Returns Encryptable with tag added privately |
removeFromList(list, value) | Returns Encryptable with tag removed |
removeFromListByPredicate(list, pred) | Returns Encryptable with matching tags removed |
updateList(list, { publicTags?, privateTags? }) | Bulk update tags |
Encryptable
| Export | Description |
|---|
Encryptable<T> | Wraps a partial event with plaintext updates; call .reconcile(encrypt) to produce encrypted event |
asDecryptedEvent(event, plaintext?) | Attach plaintext data to a TrustedEvent |
Relay
| Export | Description |
|---|
RelayMode | Enum: Read, Write, Search, Blocked, Messaging |
RelayProfile | NIP-11 relay info type |
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 |
displayRelayUrl(url) | Strip protocol and trailing slash |
displayRelayProfile(profile?, fallback?) | Get display name for relay |
Zaps (NIP-57)
| Export | Description |
|---|
getLnUrl(address) | Convert lightning address or URL to LNURL; returns undefined if invalid |
getInvoiceAmount(bolt11) | Extract millisatoshi amount from BOLT11 invoice |
hrpToMillisat(hrpString) | Convert human-readable BTC amount to millisats (bigint) |
zapFromEvent(response, zapper) | Validate zap receipt and return Zap or undefined |
Zapper type | { lnurl, pubkey?, callback?, minSendable?, maxSendable?, nostrPubkey?, allowsNostr? } |
Zap type | { request: TrustedEvent, response: TrustedEvent, invoiceAmount: number } |
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
NIP-98 (HTTP Auth)
makeHttpAuth(url: string, method?: string, body?: string): Promise<StampedEvent>
makeHttpAuthHeader(event: SignedEvent): string
NIP-86 (Relay Management)
sendManagementRequest(url: string, request: ManagementRequest, authEvent: SignedEvent): Promise<ManagementResponse>
Handlers (NIP-89)
readHandlers(event: TrustedEvent): Handler[]
getHandlerKey(handler: Handler): string
getHandlerAddress(event: TrustedEvent): string | undefined
displayHandler(handler?: Handler, fallback?: string): string
Links
fromNostrURI(s: string): string
toNostrURI(s: string): string
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'
const note = makeEvent(NOTE, {
content: 'Hello Nostr!',
tags: [['t', 'nostr']],
})
const profile = makeEvent(PROFILE, {
content: JSON.stringify({ name: 'Alice', about: 'Nostr dev' }),
tags: [],
})
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'
const storedEvents: TrustedEvent[] = await db.getAll('events')
for (const event of storedEvents) {
event[verifiedSymbol] = true
}
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 {
getTagValue,
getTagValues,
getPubkeyTagValues,
getTopicTagValues,
getRelayTagValues,
getReplyTags,
uniqTags,
} from '@welshman/util'
const title = getTagValue('title', event.tags)
const urls = getTagValues('r', event.tags)
const ids = getTagValues(['e', 'a'], event.tags)
const mentions = getPubkeyTagValues(event.tags)
const topics = getTopicTagValues(event.tags)
const relays = getRelayTagValues(event.tags)
const { roots, replies, mentions: threadMentions } = getReplyTags(event.tags)
Matching and building filters
import { matchFilters, getIdFilters, getReplyFilters, addRepostFilters, NOTE } from '@welshman/util'
import { ago, HOUR } from '@welshman/lib'
const active = matchFilters([{ kinds: [NOTE], authors: [myPubkey] }], event)
const filters = getIdFilters([
'abc123',
'30023:deadbeef:my-slug',
])
const replyFilters = getReplyFilters(events, { since: ago(HOUR) })
const withReposts = addRepostFilters([{ kinds: [NOTE] }])
Addresses for replaceable events
import { Address, getAddress } from '@welshman/util'
const addr = Address.fromEvent(event, ['wss://relay.example.com'])
console.log(addr.toString())
console.log(addr.toNaddr())
const parsed = Address.fromNaddr('naddr1...')
const addressStr = getAddress(event)
Profiles
import { readProfile, displayProfile, displayPubkey, editProfile } from '@welshman/util'
const profile = readProfile(kind0Event)
console.log(displayProfile(profile, 'Anonymous'))
console.log(displayPubkey(pubkey))
const updatedEvent = editProfile({ ...profile, name: 'New Name', about: 'Updated bio' })
Zap flow
import { getLnUrl, makeEvent, ZAP_REQUEST, zapFromEvent } from '@welshman/util'
const lnurl = getLnUrl('satoshi@getalby.com')
if (!lnurl) throw new Error('Invalid lightning address')
const zapRequest = makeEvent(ZAP_REQUEST, {
content: 'Great post!',
tags: [
['p', recipientPubkey],
['e', targetEventId],
['amount', '5000'],
['lnurl', lnurl],
['relays', 'wss://relay.damus.io'],
],
})
const zap = zapFromEvent(zapReceipt, { nostrPubkey: zapperPubkey, allowsNostr: true, lnurl })
if (zap) {
console.log(`Received ${zap.invoiceAmount} msat`, zap.request.content)
}
NIP-42 relay authentication
import { makeRelayAuth } from '@welshman/util'
const authEvent = makeRelayAuth('wss://relay.example.com', challengeFromRelay)
const signed = await signer.sign(authEvent)
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; wraps net/store/router and uses profile, list, zap, and handler helpers from this package.
@welshman/router — uses RelayMode and relay URL helpers when computing relay selections.
@welshman/signer — produces SignedEvent objects that satisfy types defined here; the Encrypt function type used by Encryptable is typically provided by a signer.
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.
-
getAncestors handles two protocols: Kind 1111 (comment/NIP-22) uses uppercase E/A for roots and lowercase for replies, returning { roots, replies }. All other kinds use NIP-10 positional rules, returning { roots, replies, mentions } where mentions is always present but may be an empty array. You do not need to branch on this; getAncestors, getParentIdOrAddr, and isChildOf handle it automatically.
-
List mutations return Encryptable: addToListPrivately, removeFromList, etc. do not return an event directly. Call .reconcile(encryptFn) on the result to get the final EventTemplate ready to sign.
-
zapFromEvent returns undefined on any validation failure including amount mismatch, wrong zapper pubkey, malformed invoice, or self-zap. Always check the result.
-
getLnUrl handles three input forms: bare lightning address (user@domain), full HTTPS URL, or already-encoded lnurl1.... Returns undefined for anything else.
-
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.
-
getTagValue / getTagValues argument order: the type(s) come first, the tags array comes second — getTagValue('title', event.tags). This is the opposite of the specialized helpers like getEventTags(tags) which take only the tags array. 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.