Skip to content

Commit 2bbe798

Browse files
feat: NIP-40 (#148)
* feat: add method for checking if event is expired * fix: tag length check * feat: add method for expiration check * feat: refactor event expiration * fix: remove stale comment * fix: remove unused method * fix: upsert/insert tests * fix: failing tests * feat: add tests for event expiration * feat: update test * feat: add nip 40 to supportedNips * chore: add expires_at column to events table * chore: use uint for expires_at --------- Co-authored-by: Ricardo Arturo Cabral Mejía <[email protected]>
1 parent 1475d65 commit 2bbe798

File tree

11 files changed

+179
-13
lines changed

11 files changed

+179
-13
lines changed
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
exports.up = function (knex) {
2+
return knex.schema.alterTable('events', function (table) {
3+
table.integer('expires_at').unsigned().nullable().index()
4+
})
5+
}
6+
7+
exports.down = function (knex) {
8+
return knex.schema.alterTable('events', function (table) {
9+
table.dropColumn('expires_at')
10+
})
11+
}

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
22,
1616
26,
1717
28,
18-
33
18+
33,
19+
40
1920
],
2021
"main": "src/index.ts",
2122
"scripts": {

src/@types/event.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ContextMetadata, EventId, Pubkey, Tag } from './base'
2-
import { ContextMetadataKey, EventDeduplicationMetadataKey, EventDelegatorMetadataKey, EventKinds } from '../constants/base'
2+
import { ContextMetadataKey, EventDeduplicationMetadataKey, EventDelegatorMetadataKey, EventExpirationTimeMetadataKey, EventKinds } from '../constants/base'
33

44
export interface BaseEvent {
55
id: EventId
@@ -25,6 +25,10 @@ export interface DelegatedEvent extends Event {
2525
[EventDelegatorMetadataKey]?: Pubkey
2626
}
2727

28+
export interface ExpiringEvent extends Event {
29+
[EventExpirationTimeMetadataKey]?: number
30+
}
31+
2832
export interface ParameterizedReplaceableEvent extends Event {
2933
[EventDeduplicationMetadataKey]: string[]
3034
}
@@ -42,6 +46,7 @@ export interface DBEvent {
4246
event_deduplication?: string | null
4347
first_seen: Date
4448
deleted_at?: Date
49+
expires_at?: number
4550
}
4651

4752
export interface CanonicalEvent {

src/constants/base.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export enum EventTags {
3636
// Multicast = 'm',
3737
Delegation = 'delegation',
3838
Deduplication = 'd',
39+
Expiration = 'expiration',
3940
}
4041

4142
export enum PaymentsProcessors {
@@ -45,3 +46,4 @@ export enum PaymentsProcessors {
4546
export const EventDelegatorMetadataKey = Symbol('Delegator')
4647
export const EventDeduplicationMetadataKey = Symbol('Deduplication')
4748
export const ContextMetadataKey = Symbol('Context')
49+
export const EventExpirationTimeMetadataKey = Symbol('Expiration')

src/factories/event-strategy-factory.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import { IWebSocketAdapter } from '../@types/adapters'
1010
import { ParameterizedReplaceableEventStrategy } from '../handlers/event-strategies/parameterized-replaceable-event-strategy'
1111
import { ReplaceableEventStrategy } from '../handlers/event-strategies/replaceable-event-strategy'
1212

13-
1413
export const eventStrategyFactory = (
1514
eventRepository: IEventRepository,
1615
): Factory<IEventStrategy<Event, Promise<void>>, [Event, IWebSocketAdapter]> =>
@@ -23,7 +22,7 @@ export const eventStrategyFactory = (
2322
return new DeleteEventStrategy(adapter, eventRepository)
2423
} else if (isParameterizedReplaceableEvent(event)) {
2524
return new ParameterizedReplaceableEventStrategy(adapter, eventRepository)
26-
}
25+
}
2726

2827
return new DefaultEventStrategy(adapter, eventRepository)
2928
}

src/handlers/event-message-handler.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1+
import { Event, ExpiringEvent } from '../@types/event'
12
import { EventRateLimit, FeeSchedule, Settings } from '../@types/settings'
2-
import { getEventProofOfWork, getPubkeyProofOfWork, isEventIdValid, isEventKindOrRangeMatch, isEventSignatureValid } from '../utils/event'
3+
import { getEventExpiration, getEventProofOfWork, getPubkeyProofOfWork, isEventIdValid, isEventKindOrRangeMatch, isEventSignatureValid, isExpiredEvent } from '../utils/event'
34
import { IEventStrategy, IMessageHandler } from '../@types/message-handlers'
45
import { ContextMetadataKey } from '../constants/base'
56
import { createCommandResult } from '../utils/messages'
67
import { createLogger } from '../factories/logger-factory'
7-
import { Event } from '../@types/event'
8+
import { EventExpirationTimeMetadataKey } from '../constants/base'
89
import { Factory } from '../@types/base'
910
import { IncomingEventMessage } from '../@types/messages'
1011
import { IRateLimiter } from '../@types/utils'
@@ -24,7 +25,7 @@ export class EventMessageHandler implements IMessageHandler {
2425
) {}
2526

2627
public async handleMessage(message: IncomingEventMessage): Promise<void> {
27-
const [, event] = message
28+
let [, event] = message
2829

2930
event[ContextMetadataKey] = message[ContextMetadataKey]
3031

@@ -35,6 +36,14 @@ export class EventMessageHandler implements IMessageHandler {
3536
return
3637
}
3738

39+
if (isExpiredEvent(event)) {
40+
debug('event %s rejected: expired')
41+
this.webSocket.emit(WebSocketAdapterEvent.Message, createCommandResult(event.id, false, 'event is expired'))
42+
return
43+
}
44+
45+
event = this.addExpirationMetadata(event)
46+
3847
if (await this.isRateLimited(event)) {
3948
debug('event %s rejected: rate-limited')
4049
this.webSocket.emit(WebSocketAdapterEvent.Message, createCommandResult(event.id, false, 'rate-limited: slow down'))
@@ -261,4 +270,17 @@ export class EventMessageHandler implements IMessageHandler {
261270
return 'blocked: insufficient balance'
262271
}
263272
}
273+
274+
protected addExpirationMetadata(event: Event): Event | ExpiringEvent {
275+
const eventExpiration: number = getEventExpiration(event)
276+
if (eventExpiration) {
277+
const expiringEvent: ExpiringEvent = {
278+
...event,
279+
[EventExpirationTimeMetadataKey]: eventExpiration,
280+
}
281+
return expiringEvent
282+
} else {
283+
return event
284+
}
285+
}
264286
}

src/repositories/event-repository.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import {
2929
toPairs,
3030
} from 'ramda'
3131

32-
import { ContextMetadataKey, EventDeduplicationMetadataKey, EventDelegatorMetadataKey } from '../constants/base'
32+
import { ContextMetadataKey, EventDeduplicationMetadataKey, EventDelegatorMetadataKey, EventExpirationTimeMetadataKey } from '../constants/base'
3333
import { DatabaseClient, EventId } from '../@types/base'
3434
import { DBEvent, Event } from '../@types/event'
3535
import { IEventRepository, IQueryResult } from '../@types/repositories'
@@ -182,6 +182,12 @@ export class EventRepository implements IEventRepository {
182182
always(null),
183183
),
184184
remote_address: path([ContextMetadataKey as any, 'remoteAddress', 'address']),
185+
expires_at: ifElse(
186+
propSatisfies(is(Number), EventExpirationTimeMetadataKey),
187+
pipe(prop(EventExpirationTimeMetadataKey as any), toBuffer),
188+
always(null),
189+
),
190+
185191
})(event)
186192

187193
return this.masterDbClient('events')
@@ -214,6 +220,11 @@ export class EventRepository implements IEventRepository {
214220
pipe(prop(EventDeduplicationMetadataKey as any), toJSON),
215221
),
216222
remote_address: path([ContextMetadataKey as any, 'remoteAddress', 'address']),
223+
expires_at: ifElse(
224+
propSatisfies(is(Number), EventExpirationTimeMetadataKey),
225+
pipe(prop(EventExpirationTimeMetadataKey as any), toBuffer),
226+
always(null),
227+
),
217228
})(event)
218229

219230
const query = this.masterDbClient('events')
@@ -250,6 +261,7 @@ export class EventRepository implements IEventRepository {
250261
event_signature: pipe(always(''), toBuffer),
251262
event_delegator: always(null),
252263
event_deduplication: pipe(always([pubkey, 5]), toJSON),
264+
expires_at: always(null),
253265
deleted_at: always(date.toISOString()),
254266
})
255267
)

src/utils/event.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,29 @@ export const isDeleteEvent = (event: Event): boolean => {
269269
return event.kind === EventKinds.DELETE
270270
}
271271

272+
export const isExpiredEvent = (event: Event): boolean => {
273+
if (!event.tags.length) return false
274+
275+
const expirationTime = getEventExpiration(event)
276+
277+
if (!expirationTime) return false
278+
279+
const date = new Date()
280+
const isExpired = expirationTime <= Math.floor(date.getTime() / 1000)
281+
282+
return isExpired
283+
}
284+
285+
export const getEventExpiration = (event: Event): number | undefined => {
286+
const [, rawExpirationTime] = event.tags.find((tag) => tag.length >= 2 && tag[0] === EventTags.Expiration) ?? []
287+
if (!rawExpirationTime) return
288+
289+
const expirationTime = Number(rawExpirationTime)
290+
if ((Number.isSafeInteger(expirationTime) && Math.log10(expirationTime))) {
291+
return expirationTime
292+
}
293+
}
294+
272295
export const getEventProofOfWork = (eventId: EventId): number => {
273296
return getLeadingZeroBits(Buffer.from(eventId, 'hex'))
274297
}

test/unit/handlers/event-message-handler.spec.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ describe('EventMessageHandler', () => {
111111
})
112112

113113
it('rejects event if invalid', async () => {
114-
isEventValidStub.returns('reason')
114+
isEventValidStub.resolves('reason')
115115

116116
await handler.handleMessage(message)
117117

@@ -128,6 +128,28 @@ describe('EventMessageHandler', () => {
128128
expect(isUserAdmitted).to.have.been.calledWithExactly(event)
129129
expect(strategyFactoryStub).not.to.have.been.called
130130
})
131+
132+
it('rejects event if it is expired', async () => {
133+
isEventValidStub.resolves(undefined)
134+
135+
const expiredEvent = {
136+
...event,
137+
tags: [
138+
['expiration', '1600000'],
139+
],
140+
}
141+
142+
const expiredEventMessage: any = [MessageType.EVENT, expiredEvent]
143+
144+
await handler.handleMessage(expiredEventMessage)
145+
146+
expect(isEventValidStub).to.have.been.calledOnceWithExactly(expiredEvent)
147+
148+
expect(onMessageSpy).to.have.been.calledOnceWithExactly(
149+
[MessageType.OK, event.id, false, 'event is expired'],
150+
)
151+
expect(strategyExecuteStub).not.to.have.been.called
152+
})
131153

132154
it('does not call strategy if none given', async () => {
133155
isEventValidStub.returns(undefined)

test/unit/repositories/event-repository.spec.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -435,7 +435,7 @@ describe('EventRepository', () => {
435435

436436
const query = (repository as any).insert(event).toString()
437437

438-
expect(query).to.equal('insert into "events" ("event_content", "event_created_at", "event_delegator", "event_id", "event_kind", "event_pubkey", "event_signature", "event_tags", "remote_address") values (\'I\'\'ve set up mirroring between relays: https://i.imgur.com/HxCDipB.png\', 1648351380, NULL, X\'6b3cdd0302ded8068ad3f0269c74423ca4fee460f800f3d90103b63f14400407\', 1, X\'22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793\', X\'b37adfed0e6398546d623536f9ddc92b95b7dc71927e1123266332659253ecd0ffa91ddf2c0a82a8426c5b363139d28534d6cac893b8a810149557a3f6d36768\', \'[["p","8355095016fddbe31fcf1453b26f613553e9758cf2263e190eac8fd96a3d3de9","wss://nostr-pub.wellorder.net"],["e","7377fa81fc6c7ae7f7f4ef8938d4a603f7bf98183b35ab128235cc92d4bebf96","wss://nostr-relay.untethr.me"]]\', \'::1\') on conflict do nothing')
438+
expect(query).to.equal('insert into "events" ("event_content", "event_created_at", "event_delegator", "event_id", "event_kind", "event_pubkey", "event_signature", "event_tags", "expires_at", "remote_address") values (\'I\'\'ve set up mirroring between relays: https://i.imgur.com/HxCDipB.png\', 1648351380, NULL, X\'6b3cdd0302ded8068ad3f0269c74423ca4fee460f800f3d90103b63f14400407\', 1, X\'22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793\', X\'b37adfed0e6398546d623536f9ddc92b95b7dc71927e1123266332659253ecd0ffa91ddf2c0a82a8426c5b363139d28534d6cac893b8a810149557a3f6d36768\', \'[["p","8355095016fddbe31fcf1453b26f613553e9758cf2263e190eac8fd96a3d3de9","wss://nostr-pub.wellorder.net"],["e","7377fa81fc6c7ae7f7f4ef8938d4a603f7bf98183b35ab128235cc92d4bebf96","wss://nostr-relay.untethr.me"]]\', NULL, \'::1\') on conflict do nothing')
439439
})
440440
})
441441

@@ -453,7 +453,7 @@ describe('EventRepository', () => {
453453
it('insert stubs by pubkey & event ids', () => {
454454
const query = repository.insertStubs('001122', ['aabbcc', 'ddeeff']).toString()
455455

456-
expect(query).to.equal('insert into "events" ("deleted_at", "event_content", "event_created_at", "event_deduplication", "event_delegator", "event_id", "event_kind", "event_pubkey", "event_signature", "event_tags") values (\'1970-01-20T08:57:15.425Z\', \'\', 1673835, \'["001122",5]\', NULL, X\'aabbcc\', 5, X\'001122\', X\'\', \'[]\'), (\'1970-01-20T08:57:15.425Z\', \'\', 1673835, \'["001122",5]\', NULL, X\'ddeeff\', 5, X\'001122\', X\'\', \'[]\') on conflict do nothing')
456+
expect(query).to.equal('insert into "events" ("deleted_at", "event_content", "event_created_at", "event_deduplication", "event_delegator", "event_id", "event_kind", "event_pubkey", "event_signature", "event_tags", "expires_at") values (\'1970-01-20T08:57:15.425Z\', \'\', 1673835, \'["001122",5]\', NULL, X\'aabbcc\', 5, X\'001122\', X\'\', \'[]\', NULL), (\'1970-01-20T08:57:15.425Z\', \'\', 1673835, \'["001122",5]\', NULL, X\'ddeeff\', 5, X\'001122\', X\'\', \'[]\', NULL) on conflict do nothing')
457457
})
458458
})
459459

@@ -480,7 +480,7 @@ describe('EventRepository', () => {
480480

481481
const query = repository.upsert(event).toString()
482482

483-
expect(query).to.equal('insert into "events" ("event_content", "event_created_at", "event_deduplication", "event_delegator", "event_id", "event_kind", "event_pubkey", "event_signature", "event_tags", "remote_address") values (\'{"name":"[email protected]","about":"","picture":"https://feat-2311-nostr.minds.io/icon/1002952989368913934/medium/1564498626/1564498626/1653379539"}\', 1564498626, \'["55b702c167c85eb1c2d5ab35d68bedd1a35b94c01147364d2395c2f66f35a503",0]\', NULL, X\'e527fe8b0f64a38c6877f943a9e8841074056ba72aceb31a4c85e6d10b27095a\', 0, X\'55b702c167c85eb1c2d5ab35d68bedd1a35b94c01147364d2395c2f66f35a503\', X\'d1de98733de2b412549aa64454722d9b66ab3c68e9e0d0f9c5d42e7bd54c30a06174364b683d2c8dbb386ff47f31e6cb7e2f3c3498d8819ee80421216c8309a9\', \'[]\', \'::1\') on conflict (event_pubkey, event_kind, event_deduplication) WHERE (event_kind = 0 OR event_kind = 3 OR event_kind = 41 OR (event_kind >= 10000 AND event_kind < 20000)) OR (event_kind >= 30000 AND event_kind < 40000) do update set "event_id" = X\'e527fe8b0f64a38c6877f943a9e8841074056ba72aceb31a4c85e6d10b27095a\',"event_created_at" = 1564498626,"event_tags" = \'[]\',"event_content" = \'{"name":"[email protected]","about":"","picture":"https://feat-2311-nostr.minds.io/icon/1002952989368913934/medium/1564498626/1564498626/1653379539"}\',"event_signature" = X\'d1de98733de2b412549aa64454722d9b66ab3c68e9e0d0f9c5d42e7bd54c30a06174364b683d2c8dbb386ff47f31e6cb7e2f3c3498d8819ee80421216c8309a9\',"event_delegator" = NULL,"remote_address" = \'::1\' where "events"."event_created_at" < 1564498626')
483+
expect(query).to.equal('insert into "events" ("event_content", "event_created_at", "event_deduplication", "event_delegator", "event_id", "event_kind", "event_pubkey", "event_signature", "event_tags", "expires_at", "remote_address") values (\'{"name":"[email protected]","about":"","picture":"https://feat-2311-nostr.minds.io/icon/1002952989368913934/medium/1564498626/1564498626/1653379539"}\', 1564498626, \'["55b702c167c85eb1c2d5ab35d68bedd1a35b94c01147364d2395c2f66f35a503",0]\', NULL, X\'e527fe8b0f64a38c6877f943a9e8841074056ba72aceb31a4c85e6d10b27095a\', 0, X\'55b702c167c85eb1c2d5ab35d68bedd1a35b94c01147364d2395c2f66f35a503\', X\'d1de98733de2b412549aa64454722d9b66ab3c68e9e0d0f9c5d42e7bd54c30a06174364b683d2c8dbb386ff47f31e6cb7e2f3c3498d8819ee80421216c8309a9\', \'[]\', NULL, \'::1\') on conflict (event_pubkey, event_kind, event_deduplication) WHERE (event_kind = 0 OR event_kind = 3 OR event_kind = 41 OR (event_kind >= 10000 AND event_kind < 20000)) OR (event_kind >= 30000 AND event_kind < 40000) do update set "event_id" = X\'e527fe8b0f64a38c6877f943a9e8841074056ba72aceb31a4c85e6d10b27095a\',"event_created_at" = 1564498626,"event_tags" = \'[]\',"event_content" = \'{"name":"[email protected]","about":"","picture":"https://feat-2311-nostr.minds.io/icon/1002952989368913934/medium/1564498626/1564498626/1653379539"}\',"event_signature" = X\'d1de98733de2b412549aa64454722d9b66ab3c68e9e0d0f9c5d42e7bd54c30a06174364b683d2c8dbb386ff47f31e6cb7e2f3c3498d8819ee80421216c8309a9\',"event_delegator" = NULL,"remote_address" = \'::1\',"expires_at" = NULL where "events"."event_created_at" < 1564498626')
484484
})
485485

486486
it('replaces event based on event_pubkey, event_kind and event_deduplication', () => {
@@ -498,7 +498,7 @@ describe('EventRepository', () => {
498498

499499
const query = repository.upsert(event).toString()
500500

501-
expect(query).to.equal('insert into "events" ("event_content", "event_created_at", "event_deduplication", "event_delegator", "event_id", "event_kind", "event_pubkey", "event_signature", "event_tags", "remote_address") values (\'{"name":"[email protected]","about":"","picture":"https://feat-2311-nostr.minds.io/icon/1002952989368913934/medium/1564498626/1564498626/1653379539"}\', 1564498626, \'["deduplication"]\', NULL, X\'e527fe8b0f64a38c6877f943a9e8841074056ba72aceb31a4c85e6d10b27095a\', 0, X\'55b702c167c85eb1c2d5ab35d68bedd1a35b94c01147364d2395c2f66f35a503\', X\'d1de98733de2b412549aa64454722d9b66ab3c68e9e0d0f9c5d42e7bd54c30a06174364b683d2c8dbb386ff47f31e6cb7e2f3c3498d8819ee80421216c8309a9\', \'[]\', \'::1\') on conflict (event_pubkey, event_kind, event_deduplication) WHERE (event_kind = 0 OR event_kind = 3 OR event_kind = 41 OR (event_kind >= 10000 AND event_kind < 20000)) OR (event_kind >= 30000 AND event_kind < 40000) do update set "event_id" = X\'e527fe8b0f64a38c6877f943a9e8841074056ba72aceb31a4c85e6d10b27095a\',"event_created_at" = 1564498626,"event_tags" = \'[]\',"event_content" = \'{"name":"[email protected]","about":"","picture":"https://feat-2311-nostr.minds.io/icon/1002952989368913934/medium/1564498626/1564498626/1653379539"}\',"event_signature" = X\'d1de98733de2b412549aa64454722d9b66ab3c68e9e0d0f9c5d42e7bd54c30a06174364b683d2c8dbb386ff47f31e6cb7e2f3c3498d8819ee80421216c8309a9\',"event_delegator" = NULL,"remote_address" = \'::1\' where "events"."event_created_at" < 1564498626')
501+
expect(query).to.equal('insert into "events" ("event_content", "event_created_at", "event_deduplication", "event_delegator", "event_id", "event_kind", "event_pubkey", "event_signature", "event_tags", "expires_at", "remote_address") values (\'{"name":"[email protected]","about":"","picture":"https://feat-2311-nostr.minds.io/icon/1002952989368913934/medium/1564498626/1564498626/1653379539"}\', 1564498626, \'["deduplication"]\', NULL, X\'e527fe8b0f64a38c6877f943a9e8841074056ba72aceb31a4c85e6d10b27095a\', 0, X\'55b702c167c85eb1c2d5ab35d68bedd1a35b94c01147364d2395c2f66f35a503\', X\'d1de98733de2b412549aa64454722d9b66ab3c68e9e0d0f9c5d42e7bd54c30a06174364b683d2c8dbb386ff47f31e6cb7e2f3c3498d8819ee80421216c8309a9\', \'[]\', NULL, \'::1\') on conflict (event_pubkey, event_kind, event_deduplication) WHERE (event_kind = 0 OR event_kind = 3 OR event_kind = 41 OR (event_kind >= 10000 AND event_kind < 20000)) OR (event_kind >= 30000 AND event_kind < 40000) do update set "event_id" = X\'e527fe8b0f64a38c6877f943a9e8841074056ba72aceb31a4c85e6d10b27095a\',"event_created_at" = 1564498626,"event_tags" = \'[]\',"event_content" = \'{"name":"[email protected]","about":"","picture":"https://feat-2311-nostr.minds.io/icon/1002952989368913934/medium/1564498626/1564498626/1653379539"}\',"event_signature" = X\'d1de98733de2b412549aa64454722d9b66ab3c68e9e0d0f9c5d42e7bd54c30a06174364b683d2c8dbb386ff47f31e6cb7e2f3c3498d8819ee80421216c8309a9\',"event_delegator" = NULL,"remote_address" = \'::1\',"expires_at" = NULL where "events"."event_created_at" < 1564498626')
502502
})
503503
})
504504
})

0 commit comments

Comments
 (0)