Re-work publishing of wrapped events

This commit is contained in:
Jon Staab
2025-10-17 11:33:22 -07:00
parent 543dbda64f
commit ca38cbe20b
11 changed files with 141 additions and 116 deletions
+9 -14
View File
@@ -28,7 +28,7 @@ import {
PINS, PINS,
} from "@welshman/util" } from "@welshman/util"
import type {RoomMeta, Profile} from "@welshman/util" import type {RoomMeta, Profile} from "@welshman/util"
import {Nip59, stamp} from "@welshman/signer" import {Nip59, stamp, hash, own} from "@welshman/signer"
import {Router, addMaximalFallbacks} from "@welshman/router" import {Router, addMaximalFallbacks} from "@welshman/router"
import { import {
userRelaySelections, userRelaySelections,
@@ -205,24 +205,19 @@ export const pin = async (tag: string[]) => {
// NIP 59 // NIP 59
export type SendWrappedOptions = Omit<ThunkOptions, "event" | "relays"> & { export type SendWrappedOptions = Omit<ThunkOptions, "event" | "relays"> & {
template: EventTemplate event: EventTemplate
pubkeys: string[] recipients: string[]
} }
export const sendWrapped = async ({template, pubkeys, ...options}: SendWrappedOptions) => { export const sendWrapped = async ({event, recipients, ...options}: SendWrappedOptions) =>
const nip59 = Nip59.fromSigner(signer.get()!) new MergedThunk(
uniq(recipients)
return new MergedThunk( .map(recipient => {
await Promise.all(
uniq(pubkeys).map(async recipient => {
const event = await nip59.wrap(recipient, stamp(template))
const relays = Router.get().PubkeyInbox(recipient).getUrls() const relays = Router.get().PubkeyInbox(recipient).getUrls()
return publishThunk({event, relays, ...options}) return publishThunk({event, relays, recipient, ...options})
}), })
),
) )
}
// NIP 86 // NIP 86
+79 -67
View File
@@ -1,5 +1,6 @@
import type {Subscriber} from "svelte/store" import type {Subscriber} from "svelte/store"
import {writable, get} from "svelte/store" import {writable, get} from "svelte/store"
import type {Override} from '@welshman/lib'
import { import {
append, append,
reject, reject,
@@ -13,7 +14,6 @@ import {
nth, nth,
without, without,
} from "@welshman/lib" } from "@welshman/lib"
import {stamp, own, hash} from "@welshman/signer"
import { import {
TrustedEvent, TrustedEvent,
HashedEvent, HashedEvent,
@@ -26,6 +26,7 @@ import {
isHashedEvent, isHashedEvent,
isUnwrappedEvent, isUnwrappedEvent,
isSignedEvent, isSignedEvent,
WRAPPED_KINDS,
} from "@welshman/util" } from "@welshman/util"
import { import {
publish, publish,
@@ -34,42 +35,46 @@ import {
PublishOptions, PublishOptions,
PublishResultsByRelay, PublishResultsByRelay,
} from "@welshman/net" } from "@welshman/net"
import {ISigner, Nip59, prep} from '@welshman/signer'
import {repository, tracker} from "./core.js" import {repository, tracker} from "./core.js"
import {pubkey, getSession, getSigner} from "./session.js" import {pubkey, signer} from "./session.js"
export type ThunkEvent = EventTemplate | StampedEvent | OwnedEvent | TrustedEvent export type ThunkOptions = Override<PublishOptions, {
event: EventTemplate
export const prepEvent = (event: ThunkEvent) => { recipient?: string
if (!isStampedEvent(event as StampedEvent)) {
event = stamp(event)
}
if (!isOwnedEvent(event as OwnedEvent)) {
event = own(event as StampedEvent, get(pubkey)!)
}
if (!isHashedEvent(event as HashedEvent)) {
event = hash(event as OwnedEvent)
}
return event as TrustedEvent
}
export type ThunkOptions = Omit<PublishOptions, "event"> & {
event: ThunkEvent
delay?: number delay?: number
} }>
export class Thunk { export class Thunk {
_subs: Subscriber<Thunk>[] = [] _subs: Subscriber<Thunk>[] = []
event: TrustedEvent pubkey: string
signer: ISigner
event: HashedEvent
results: PublishResultsByRelay = {} results: PublishResultsByRelay = {}
complete = defer<void>() complete = defer<void>()
controller = new AbortController() controller = new AbortController()
constructor(readonly options: ThunkOptions) { constructor(readonly options: ThunkOptions) {
this.event = prepEvent(options.event) if (!options.recipient && WRAPPED_KINDS.includes(options.event.kind)) {
throw new Error(`Attempted to publish a kind ${options.event.kind} without wrapping it`)
}
const $pubkey = pubkey.get()
if (!$pubkey) {
throw new Error(`Attempted to publish an event without an active pubkey`)
}
const $signer = signer.get()
if (!$signer) {
throw new Error(`Attempted to publish an event without an active signer`)
}
this.pubkey = $pubkey
this.signer = $signer
this.event = prep(options.event, this.pubkey)
for (const relay of options.relays) { for (const relay of options.relays) {
this.results[relay] = { this.results[relay] = {
@@ -126,41 +131,10 @@ export class Thunk {
this._notify() this._notify()
} }
async publish() { async _publish(event: SignedEvent) {
let event = this.event // Copy the signature over since we may have deferred signing
ifLet(repository.getEvent(event.id), savedEvent => {
// Handle abort immediately if possible savedEvent.sig = event.sig
if (this.controller.signal.aborted) return
// If we were given a wrapped event, make sure to publish the wrapper, not the rumor
if (isUnwrappedEvent(event)) {
event = event.wrap
}
// If the event was already signed, leave it alone. Otherwise, sign it now. This is to
// decrease apparent latency in the UI that results from waiting for remote signers
if (!isSignedEvent(event)) {
const signer = getSigner(getSession(event.pubkey))
if (!signer) {
return this._fail(`No signer found for ${event.pubkey}`)
}
try {
event = await signer.sign(event, {
signal: AbortSignal.timeout(15_000),
})
} catch (e: any) {
return this._fail(String(e || "Failed to sign event"))
}
}
// We're guaranteed to have a signed event at this point
const signedEvent = event as SignedEvent
// Copy the signature over since we had deferred signing
ifLet(repository.getEvent(signedEvent.id), savedEvent => {
savedEvent.sig = signedEvent.sig
}) })
// Wait if the thunk is to be delayed // Wait if the thunk is to be delayed
@@ -176,9 +150,9 @@ export class Thunk {
// Send it off // Send it off
await publish({ await publish({
...this.options, ...this.options,
event: signedEvent, event,
onSuccess: (result: PublishResult) => { onSuccess: (result: PublishResult) => {
tracker.track(signedEvent.id, result.relay) tracker.track(event.id, result.relay)
this.options.onSuccess?.(result) this.options.onSuccess?.(result)
this.results[result.relay] = result this.results[result.relay] = result
this._notify() this._notify()
@@ -197,9 +171,51 @@ export class Thunk {
}, },
}) })
// Notify the caller that we're done
this.complete.resolve() this.complete.resolve()
} }
async publish() {
// Handle abort immediately if possible
if (this.controller.signal.aborted) return
// If we were given an event with wraps, reject it (this used to be allowed)
if (isUnwrappedEvent(this.event)) {
throw new Error("Attempted to publish an unwrapped event")
}
// If we're sending it privately, wrap the event using nip 59
if (this.options.recipient) {
const nip59 = Nip59.fromSigner(this.signer)
const event = await nip59.wrap(this.options.recipient, this.event)
return this._publish(event)
}
// If the event has been signed, we're good to go
if (isSignedEvent(this.event)) {
return this._publish(this.event)
}
// Allow for lazily signing events in order to decrease apparent latency in the UI
// that results from waiting for remote signers
try {
return this._publish(
await this.signer.sign(this.event, {
signal: AbortSignal.timeout(15_000),
})
)
} catch (e: any) {
return this._fail(String(e || "Failed to sign event"))
}
}
enqueue() {
thunkQueue.push(this)
repository.publish(this.event)
thunks.update($thunks => append(this, $thunks))
}
subscribe(subscriber: Subscriber<Thunk>) { subscribe(subscriber: Subscriber<Thunk>) {
this._subs.push(subscriber) this._subs.push(subscriber)
@@ -365,11 +381,7 @@ export function* flattenThunks(thunks: AbstractThunk[]): Iterable<Thunk> {
export const publishThunk = (options: ThunkOptions) => { export const publishThunk = (options: ThunkOptions) => {
const thunk = new Thunk(options) const thunk = new Thunk(options)
thunkQueue.push(thunk) thunk.enqueue()
repository.publish(thunk.event)
thunks.update($thunks => append(thunk, $thunks))
return thunk return thunk
} }
+3 -3
View File
@@ -290,11 +290,11 @@ describe("Repository", () => {
}) })
it("should handle wrapped events", () => { it("should handle wrapped events", () => {
const event: TrustedEvent = createEvent(1, {wrap: createEvent(1)}) const event: TrustedEvent = createEvent(1, {wraps: [createEvent(1)]})
repo.publish(event) repo.publish(event)
expect(repo.eventsByWrap.get(event.wrap!.id)).toEqual(event) expect(repo.eventsByWrap.get(event.wraps!.[0]!.id)).toEqual(event)
}) })
}) })
@@ -315,7 +315,7 @@ describe("Repository", () => {
it("should remove wrapped events", () => { it("should remove wrapped events", () => {
const wrapped = createEvent(1) const wrapped = createEvent(1)
const event = createEvent(1, {wrap: wrapped}) const event = createEvent(1, {wraps: [wrapped]})
repo.publish(event) repo.publish(event)
repo.removeEvent(event.id) repo.removeEvent(event.id)
+5 -5
View File
@@ -34,7 +34,7 @@ export type RepositoryUpdate = {
removed: Set<string> removed: Set<string>
} }
export class Repository<E extends HashedEvent = TrustedEvent> extends Emitter { export class Repository<E extends TrustedEvent = TrustedEvent> extends Emitter {
eventsById = new Map<string, E>() eventsById = new Map<string, E>()
eventsByWrap = new Map<string, E>() eventsByWrap = new Map<string, E>()
eventsByAddress = new Map<string, E>() eventsByAddress = new Map<string, E>()
@@ -133,8 +133,8 @@ export class Repository<E extends HashedEvent = TrustedEvent> extends Emitter {
if (event) { if (event) {
this.eventsById.delete(event.id) this.eventsById.delete(event.id)
if (isUnwrappedEvent(event)) { for (const wrap of event.wraps || []) {
this.eventsByWrap.delete(event.wrap.id) this.eventsByWrap.delete(wrap.id)
} }
this.eventsByAddress.delete(getAddress(event)) this.eventsByAddress.delete(getAddress(event))
@@ -235,8 +235,8 @@ export class Repository<E extends HashedEvent = TrustedEvent> extends Emitter {
} }
// Save wrapper index // Save wrapper index
if (isUnwrappedEvent(event)) { for (const wrap of event.wraps || []) {
this.eventsByWrap.set(event.wrap.id, event) this.eventsByWrap.set(wrap.id, event)
} }
// Update our timestamp and author indexes // Update our timestamp and author indexes
+8 -9
View File
@@ -1,15 +1,12 @@
import {UnwrappedEvent, SignedEvent, HashedEvent, StampedEvent, WRAP, SEAL} from "@welshman/util" import {isHashedEvent, SignedEvent, HashedEvent, StampedEvent, WRAP, SEAL} from "@welshman/util"
import {own, hash, decrypt, ISigner} from "./util.js" import {prep, hash, decrypt, ISigner} from "./util.js"
import {Nip01Signer} from "./signers/nip01.js" import {Nip01Signer} from "./signers/nip01.js"
export const seen = new Map<string, UnwrappedEvent | Error>() export const seen = new Map<string, HashedEvent | Error>()
export const now = (drift = 0) => export const now = (drift = 0) =>
Math.round(Date.now() / 1000 - Math.random() * Math.pow(10, drift)) Math.round(Date.now() / 1000 - Math.random() * Math.pow(10, drift))
export const getRumor = async (signer: ISigner, template: StampedEvent) =>
hash(own(template, await signer.getPubkey()))
export const getSeal = async (signer: ISigner, pubkey: string, rumor: HashedEvent) => export const getSeal = async (signer: ISigner, pubkey: string, rumor: HashedEvent) =>
signer.sign( signer.sign(
hash({ hash({
@@ -44,11 +41,12 @@ export const wrap = async (
template: StampedEvent, template: StampedEvent,
tags: string[][] = [], tags: string[][] = [],
) => { ) => {
const rumor = await getRumor(signer, template) const author = await signer.getPubkey()
const rumor = await prep(template, author)
const seal = await getSeal(signer, pubkey, rumor) const seal = await getSeal(signer, pubkey, rumor)
const wrap = await getWrap(wrapper, pubkey, seal, tags) const wrap = await getWrap(wrapper, pubkey, seal, tags)
return Object.assign(rumor, {wrap}) as UnwrappedEvent return wrap
} }
export const unwrap = async (signer: ISigner, wrap: SignedEvent) => { export const unwrap = async (signer: ISigner, wrap: SignedEvent) => {
@@ -68,10 +66,11 @@ export const unwrap = async (signer: ISigner, wrap: SignedEvent) => {
const rumor = JSON.parse(await decrypt(signer, seal.pubkey, seal.content)) const rumor = JSON.parse(await decrypt(signer, seal.pubkey, seal.content))
if (seal.pubkey !== rumor.pubkey) throw new Error("Seal pubkey does not match rumor pubkey") if (seal.pubkey !== rumor.pubkey) throw new Error("Seal pubkey does not match rumor pubkey")
if (!isHashedEvent(rumor)) throw new Error("Unwrapped object was not a hashed event")
seen.set(wrap.id, rumor) seen.set(wrap.id, rumor)
return Object.assign(rumor, {wrap}) as UnwrappedEvent return rumor
} catch (error) { } catch (error) {
seen.set(wrap.id, error as Error) seen.set(wrap.id, error as Error)
+17 -1
View File
@@ -4,7 +4,7 @@ import * as nt04 from "nostr-tools/nip04"
import * as nt44 from "nostr-tools/nip44" import * as nt44 from "nostr-tools/nip44"
import {generateSecretKey, getPublicKey, getEventHash} from "nostr-tools/pure" import {generateSecretKey, getPublicKey, getEventHash} from "nostr-tools/pure"
import {Emitter, cached, now} from "@welshman/lib" import {Emitter, cached, now} from "@welshman/lib"
import {SignedEvent, HashedEvent, EventTemplate, StampedEvent, OwnedEvent} from "@welshman/util" import {SignedEvent, HashedEvent, EventTemplate, StampedEvent, OwnedEvent, isStampedEvent, isOwnedEvent, isHashedEvent} from "@welshman/util"
export const makeSecret = () => bytesToHex(generateSecretKey()) export const makeSecret = () => bytesToHex(generateSecretKey())
@@ -21,6 +21,22 @@ export const own = (event: StampedEvent, pubkey: string) => ({...event, pubkey})
export const hash = (event: OwnedEvent) => ({...event, id: getHash(event)}) export const hash = (event: OwnedEvent) => ({...event, id: getHash(event)})
export const prep = (event: EventTemplate, pubkey: string, created_at = now()) => {
if (!isStampedEvent(event as StampedEvent)) {
event = stamp(event, created_at)
}
if (!isOwnedEvent(event as OwnedEvent)) {
event = own(event as StampedEvent, pubkey)
}
if (!isHashedEvent(event as HashedEvent)) {
event = hash(event as OwnedEvent)
}
return event as HashedEvent
}
export const sign = (event: HashedEvent, secret: string) => ({...event, sig: getSig(event, secret)}) export const sign = (event: HashedEvent, secret: string) => ({...event, sig: getSig(event, secret)})
export const nip04 = { export const nip04 = {
+8 -8
View File
@@ -105,7 +105,7 @@ describe("Events", () => {
it("should validate TrustedEvent", () => { it("should validate TrustedEvent", () => {
const unwrapped = { const unwrapped = {
...createHashedEvent(), ...createHashedEvent(),
wrap: createSignedEvent(), wraps: [createSignedEvent()],
} }
expect(Events.isTrustedEvent(createHashedEvent())).toBe(false) expect(Events.isTrustedEvent(createHashedEvent())).toBe(false)
expect(Events.isTrustedEvent(createSignedEvent())).toBe(true) expect(Events.isTrustedEvent(createSignedEvent())).toBe(true)
@@ -115,7 +115,7 @@ describe("Events", () => {
it("should validate UnwrappedEvent", () => { it("should validate UnwrappedEvent", () => {
const unwrapped = { const unwrapped = {
...createHashedEvent(), ...createHashedEvent(),
wrap: createSignedEvent(), wraps: [createSignedEvent()],
} }
expect(Events.isUnwrappedEvent(unwrapped)).toBe(true) expect(Events.isUnwrappedEvent(unwrapped)).toBe(true)
expect(Events.isUnwrappedEvent(createHashedEvent())).toBe(false) expect(Events.isUnwrappedEvent(createHashedEvent())).toBe(false)
@@ -152,10 +152,10 @@ describe("Events", () => {
const trustedEvent = { const trustedEvent = {
...createHashedEvent(), ...createHashedEvent(),
sig: sig, sig: sig,
wrap: createSignedEvent(), wraps: [createSignedEvent()],
} }
const result = Events.asSignedEvent(trustedEvent) const result = Events.asSignedEvent(trustedEvent)
expect(result).not.toHaveProperty("wrap") expect(result).not.toHaveProperty("wraps")
expect(result).toHaveProperty("sig") expect(result).toHaveProperty("sig")
}) })
@@ -163,10 +163,10 @@ describe("Events", () => {
const trustedEvent = { const trustedEvent = {
...createHashedEvent(), ...createHashedEvent(),
sig: sig, sig: sig,
wrap: createSignedEvent(), wraps: [createSignedEvent()],
} }
const result = Events.asUnwrappedEvent(trustedEvent) const result = Events.asUnwrappedEvent(trustedEvent)
expect(result).toHaveProperty("wrap") expect(result).toHaveProperty("wraps")
expect(result).not.toHaveProperty("sig") expect(result).not.toHaveProperty("sig")
}) })
@@ -174,11 +174,11 @@ describe("Events", () => {
const trustedEvent = { const trustedEvent = {
...createHashedEvent(), ...createHashedEvent(),
sig: sig, sig: sig,
wrap: createSignedEvent(), wraps: [createSignedEvent()],
} }
const result = Events.asTrustedEvent(trustedEvent) const result = Events.asTrustedEvent(trustedEvent)
expect(result).toHaveProperty("sig") expect(result).toHaveProperty("sig")
expect(result).toHaveProperty("wrap") expect(result).toHaveProperty("wraps")
}) })
}) })
+1 -1
View File
@@ -185,7 +185,7 @@ describe("Filters", () => {
it("should handle wrapped events", () => { it("should handle wrapped events", () => {
const event = createEvent({ const event = createEvent({
wrap: createEvent(), wraps: [createEvent()],
}) })
const result = getReplyFilters([event]) const result = getReplyFilters([event])
expect((result[0] as any)["#e"]).toHaveLength(2) expect((result[0] as any)["#e"]).toHaveLength(2)
+5 -5
View File
@@ -41,12 +41,12 @@ export type SignedEvent = HashedEvent & {
} }
export type UnwrappedEvent = HashedEvent & { export type UnwrappedEvent = HashedEvent & {
wrap: SignedEvent wraps: SignedEvent[]
} }
export type TrustedEvent = HashedEvent & { export type TrustedEvent = HashedEvent & {
sig?: string sig?: string
wrap?: SignedEvent wraps?: SignedEvent[]
[verifiedSymbol]?: boolean [verifiedSymbol]?: boolean
} }
@@ -111,7 +111,7 @@ export const isSignedEvent = (e: TrustedEvent): e is SignedEvent =>
Boolean(isHashedEvent(e) && typeof e.sig === "string" && e.sig.length > 0) Boolean(isHashedEvent(e) && typeof e.sig === "string" && e.sig.length > 0)
export const isUnwrappedEvent = (e: TrustedEvent): e is UnwrappedEvent => export const isUnwrappedEvent = (e: TrustedEvent): e is UnwrappedEvent =>
Boolean(isHashedEvent(e) && e.wrap && isSignedEvent(e.wrap)) Boolean(isHashedEvent(e) && e.wraps?.every(isSignedEvent))
export const isTrustedEvent = (e: TrustedEvent): e is TrustedEvent => export const isTrustedEvent = (e: TrustedEvent): e is TrustedEvent =>
isSignedEvent(e) || isUnwrappedEvent(e) isSignedEvent(e) || isUnwrappedEvent(e)
@@ -134,10 +134,10 @@ export const asSignedEvent = (e: SignedEvent): SignedEvent =>
pick(["kind", "tags", "content", "created_at", "pubkey", "id", "sig"], e) pick(["kind", "tags", "content", "created_at", "pubkey", "id", "sig"], e)
export const asUnwrappedEvent = (e: UnwrappedEvent): UnwrappedEvent => export const asUnwrappedEvent = (e: UnwrappedEvent): UnwrappedEvent =>
pick(["kind", "tags", "content", "created_at", "pubkey", "id", "wrap"], e) pick(["kind", "tags", "content", "created_at", "pubkey", "id", "wraps"], e)
export const asTrustedEvent = (e: TrustedEvent): TrustedEvent => export const asTrustedEvent = (e: TrustedEvent): TrustedEvent =>
pick(["kind", "tags", "content", "created_at", "pubkey", "id", "sig", "wrap"], e) pick(["kind", "tags", "content", "created_at", "pubkey", "id", "sig", "wraps"], e)
// Utilities for working with events // Utilities for working with events
+1 -3
View File
@@ -179,9 +179,7 @@ export const getReplyFilters = (events: TrustedEvent[], filter: Filter = {}) =>
a.push(getAddress(event)) a.push(getAddress(event))
} }
if (event.wrap) { event.wraps?.forEach(wrap => e.push(wrap.id))
e.push(event.wrap.id)
}
} }
const filters = [] const filters = []
+5
View File
@@ -197,3 +197,8 @@ export const FOLLOW_PACK = 39089
export const DEPRECATED_RELAY_RECOMMENDATION = 2 export const DEPRECATED_RELAY_RECOMMENDATION = 2
export const DEPRECATED_DIRECT_MESSAGE = 4 export const DEPRECATED_DIRECT_MESSAGE = 4
export const DEPRECATED_NAMED_GENERIC = 30001 export const DEPRECATED_NAMED_GENERIC = 30001
export const WRAPPED_KINDS = [
DIRECT_MESSAGE,
DIRECT_MESSAGE_FILE,
]