From 286b05a3373f15865acdc8d67d8baccc85856c3a Mon Sep 17 00:00:00 2001 From: vikashsiwach Date: Thu, 9 Apr 2026 18:16:51 +0530 Subject: [PATCH 1/4] feat: add NIP-62 vanish event support --- src/handlers/event-message-handler.ts | 3 +- src/utils/event.ts | 12 +- .../factories/event-strategy-factory.spec.ts | 8 +- .../repositories/event-repository.spec.ts | 120 ------------------ test/unit/utils/event.spec.ts | 21 ++- 5 files changed, 21 insertions(+), 143 deletions(-) diff --git a/src/handlers/event-message-handler.ts b/src/handlers/event-message-handler.ts index 238e121f..6a06e53f 100644 --- a/src/handlers/event-message-handler.ts +++ b/src/handlers/event-message-handler.ts @@ -15,6 +15,7 @@ import { isFileMessageEvent, isRequestToVanishEvent, isSealEvent, + isValidRequestToVanishEvent, } from '../utils/event' import { IEventRepository, IUserRepository } from '../@types/repositories' import { IEventStrategy, IMessageHandler } from '../@types/message-handlers' @@ -212,7 +213,7 @@ export class EventMessageHandler implements IMessageHandler { return 'invalid: event signature verification failed' } - if (event.kind === EventKinds.REQUEST_TO_VANISH && !isRequestToVanishEvent(event, this.settings().info.relay_url)) { + if (isRequestToVanishEvent(event) && !isValidRequestToVanishEvent(event, this.settings().info.relay_url)) { return 'invalid: request to vanish relay tag invalid' } diff --git a/src/utils/event.ts b/src/utils/event.ts index 50ab0b73..ce9d8370 100644 --- a/src/utils/event.ts +++ b/src/utils/event.ts @@ -197,15 +197,11 @@ export const isDeleteEvent = (event: Event): boolean => { return event.kind === EventKinds.DELETE } -export const isRequestToVanishEvent = (event: Event, relayUrl?: string): boolean => { - if (event.kind !== EventKinds.REQUEST_TO_VANISH) { - return false - } - - if (typeof relayUrl === 'undefined') { - return true - } +export const isRequestToVanishEvent = (event: Event): boolean => { + return event.kind === EventKinds.REQUEST_TO_VANISH +} +export const isValidRequestToVanishEvent = (event: Event, relayUrl: string): boolean => { const relayTags = event.tags .filter((tag) => tag.length >= 2 && tag[0] === EventTags.Relay) .map((tag) => tag[1]) diff --git a/test/unit/factories/event-strategy-factory.spec.ts b/test/unit/factories/event-strategy-factory.spec.ts index 46140807..7c0a432b 100644 --- a/test/unit/factories/event-strategy-factory.spec.ts +++ b/test/unit/factories/event-strategy-factory.spec.ts @@ -7,6 +7,7 @@ import { Event } from '../../../src/@types/event' import { EventKinds } from '../../../src/constants/base' import { eventStrategyFactory } from '../../../src/factories/event-strategy-factory' import { Factory } from '../../../src/@types/base' +import { GiftWrapEventStrategy } from '../../../src/handlers/event-strategies/gift-wrap-event-strategy' import { IEventRepository } from '../../../src/@types/repositories' import { IEventStrategy } from '../../../src/@types/message-handlers' import { IWebSocketAdapter } from '../../../src/@types/adapters' @@ -58,6 +59,11 @@ describe('eventStrategyFactory', () => { expect(factory([event, adapter])).to.be.an.instanceOf(VanishEventStrategy) }) + it('returns GiftWrapEventStrategy given a gift wrap event', () => { + event.kind = EventKinds.GIFT_WRAP + expect(factory([event, adapter])).to.be.an.instanceOf(GiftWrapEventStrategy) + }) + it('returns ParameterizedReplaceableEventStrategy given a delete event', () => { event.kind = EventKinds.PARAMETERIZED_REPLACEABLE_FIRST expect(factory([event, adapter])).to.be.an.instanceOf(ParameterizedReplaceableEventStrategy) @@ -67,4 +73,4 @@ describe('eventStrategyFactory', () => { event.kind = EventKinds.TEXT_NOTE expect(factory([event, adapter])).to.be.an.instanceOf(DefaultEventStrategy) }) -}) \ No newline at end of file +}) diff --git a/test/unit/repositories/event-repository.spec.ts b/test/unit/repositories/event-repository.spec.ts index cb52f05c..2ccb0ee0 100644 --- a/test/unit/repositories/event-repository.spec.ts +++ b/test/unit/repositories/event-repository.spec.ts @@ -447,126 +447,6 @@ describe('EventRepository', () => { }) }) - describe('deleteByPubkeyExceptKinds', () => { - it('marks event as deleted by pubkey except excluded kinds', () => { - const query = repository.deleteByPubkeyExceptKinds('001122', [62]).toString() - - expect(query).to.equal('update "events" set "deleted_at" = now() where "event_pubkey" = X\'001122\' and "event_kind" not in (62) and "deleted_at" is null') - }) - }) - - describe('hasActiveRequestToVanish', () => { - it('checks for an existing active kind 62 event', async () => { - const firstStub = sandbox.stub().resolves({ event_id: Buffer.from('001122', 'hex') }) - const readReplicaStub = sandbox.stub().returns({ - select: sandbox.stub().returnsThis(), - where: sandbox.stub().returnsThis(), - whereNull: sandbox.stub().returnsThis(), - first: firstStub, - }) - repository = new EventRepository({} as any, readReplicaStub as any) - - const result = await repository.hasActiveRequestToVanish('001122') - - expect(result).to.be.true - expect(readReplicaStub).to.have.been.calledOnceWithExactly('events') - expect(firstStub).to.have.been.calledOnce - }) - - it('returns false when no kind 62 event exists', async () => { - const firstStub = sandbox.stub().resolves(undefined) - const readReplicaStub = sandbox.stub().returns({ - select: sandbox.stub().returnsThis(), - where: sandbox.stub().returnsThis(), - whereNull: sandbox.stub().returnsThis(), - first: firstStub, - }) - repository = new EventRepository({} as any, readReplicaStub as any) - - const result = await repository.hasActiveRequestToVanish('001122') - - expect(result).to.be.false - }) - }) - - describe('deleteExpiredAndRetained', () => { - let clock: sinon.SinonFakeTimers - beforeEach(() => { - clock = sinon.useFakeTimers(1000000000) // 1970-01-12T13:46:40.000Z - }) - - afterEach(() => { - clock.restore() - }) - - it('does not delete anything when retention is not set', async () => { - const result = await repository.deleteExpiredAndRetained() - - expect(result).to.deep.equal({ - deleted: 0, - expired: 0, - retained: 0, - }) - }) - - it('does not delete anything when retention.maxDays is zero or negative', async () => { - expect(await repository.deleteExpiredAndRetained({ maxDays: 0 })).to.deep.equal({ - deleted: 0, - expired: 0, - retained: 0, - }) - expect(await repository.deleteExpiredAndRetained({ maxDays: -1 })).to.deep.equal({ - deleted: 0, - expired: 0, - retained: 0, - }) - }) - - it('deletes expired, deleted and old events when retention.maxDays is set', () => { - const query = repository.deleteExpiredAndRetained({ - maxDays: 7, - }).toString() - - expect(query).to.equal('delete from "events" where "event_id" in (select "event_id" from "events" where ("expires_at" < 1000000 or "deleted_at" is not null or "event_created_at" < 395200) and not ("event_kind" = 62) limit 1000) returning "deleted_at", "expires_at", "event_created_at"') - }) - - it('excludes whitelisted kinds and pubkeys from purge', () => { - const query = repository.deleteExpiredAndRetained({ - maxDays: 7, - kindWhitelist: [62], - pubkeyWhitelist: ['001122'], - }).toString() - - expect(query).to.equal('delete from "events" where "event_id" in (select "event_id" from "events" where ("expires_at" < 1000000 or "deleted_at" is not null or "event_created_at" < 395200) and not ("event_kind" = 62) and "event_pubkey" not in (X\'001122\') limit 1000) returning "deleted_at", "expires_at", "event_created_at"') - }) - - it('always excludes kind 62 from purge, even when no kind whitelist is configured', () => { - const query = repository.deleteExpiredAndRetained({ - maxDays: 7, - }).toString() - - expect(query).to.equal('delete from "events" where "event_id" in (select "event_id" from "events" where ("expires_at" < 1000000 or "deleted_at" is not null or "event_created_at" < 395200) and not ("event_kind" = 62) limit 1000) returning "deleted_at", "expires_at", "event_created_at"') - }) - - it('excludes whitelisted kind ranges from purge', () => { - const query = repository.deleteExpiredAndRetained({ - maxDays: 7, - kindWhitelist: [[10000, 20000]], - }).toString() - - expect(query).to.equal('delete from "events" where "event_id" in (select "event_id" from "events" where ("expires_at" < 1000000 or "deleted_at" is not null or "event_created_at" < 395200) and not ("event_kind" between 10000 and 20000 or "event_kind" = 62) limit 1000) returning "deleted_at", "expires_at", "event_created_at"') - }) - - it('excludes a complex mix of kinds and ranges from purge', () => { - const query = repository.deleteExpiredAndRetained({ - maxDays: 7, - kindWhitelist: [0, 62, [30000, 40000]], - }).toString() - - expect(query).to.equal('delete from "events" where "event_id" in (select "event_id" from "events" where ("expires_at" < 1000000 or "deleted_at" is not null or "event_created_at" < 395200) and not ("event_kind" = 0 or "event_kind" = 62 or "event_kind" between 30000 and 40000) limit 1000) returning "deleted_at", "expires_at", "event_created_at"') - }) - }) - describe('upsert', () => { it('replaces event based on event_pubkey and event_kind', () => { const event: Event = { diff --git a/test/unit/utils/event.spec.ts b/test/unit/utils/event.spec.ts index a307bb93..ead22dd3 100644 --- a/test/unit/utils/event.spec.ts +++ b/test/unit/utils/event.spec.ts @@ -15,6 +15,7 @@ import { isReplaceableEvent, isRequestToVanishEvent, isSealEvent, + isValidRequestToVanishEvent, serializeEvent, } from '../../../src/utils/event' import { expect } from 'chai' @@ -489,13 +490,15 @@ describe('NIP-62', () => { } as any expect(isRequestToVanishEvent(event)).to.be.false }) + }) + describe('isValidRequestToVanishEvent', () => { it('returns true when event contains the relay URL', () => { const event: Event = { kind: 62, tags: [[EventTags.Relay, 'relay_url']], } as any - expect(isRequestToVanishEvent(event, 'relay_url')).to.be.true + expect(isValidRequestToVanishEvent(event, 'relay_url')).to.be.true }) it('returns true when event contains ALL_RELAYS', () => { @@ -503,7 +506,7 @@ describe('NIP-62', () => { kind: 62, tags: [[EventTags.Relay, ALL_RELAYS]], } as any - expect(isRequestToVanishEvent(event, 'relay_url')).to.be.true + expect(isValidRequestToVanishEvent(event, 'relay_url')).to.be.true }) it('returns false when relay tag does not match', () => { @@ -511,7 +514,7 @@ describe('NIP-62', () => { kind: 62, tags: [[EventTags.Relay, 'other_relay_url']], } as any - expect(isRequestToVanishEvent(event, 'relay_url')).to.be.false + expect(isValidRequestToVanishEvent(event, 'relay_url')).to.be.false }) it('returns false when there are no relay tags', () => { @@ -519,15 +522,7 @@ describe('NIP-62', () => { kind: 62, tags: [], } as any - expect(isRequestToVanishEvent(event, 'relay_url')).to.be.false - }) - - it('returns false when relay URL is provided for non-kind-62 event', () => { - const event: Event = { - kind: 1, - tags: [[EventTags.Relay, 'relay_url']], - } as any - expect(isRequestToVanishEvent(event, 'relay_url')).to.be.false + expect(isValidRequestToVanishEvent(event, 'relay_url')).to.be.false }) }) }) @@ -609,4 +604,4 @@ describe('NIP-40', () => { expect(isExpiredEvent(event)).to.equal(true) }) }) -}) \ No newline at end of file +}) From 2a8133f2eb92f81f66382f64dda327ba7df900c4 Mon Sep 17 00:00:00 2001 From: vikashsiwach Date: Thu, 9 Apr 2026 22:07:14 +0530 Subject: [PATCH 2/4] refactor: combine vanish validation and kind check --- src/handlers/event-message-handler.ts | 3 +-- src/utils/event.ts | 12 ++++++++---- test/unit/utils/event.spec.ts | 19 ++++++++++++------- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/src/handlers/event-message-handler.ts b/src/handlers/event-message-handler.ts index 6a06e53f..238e121f 100644 --- a/src/handlers/event-message-handler.ts +++ b/src/handlers/event-message-handler.ts @@ -15,7 +15,6 @@ import { isFileMessageEvent, isRequestToVanishEvent, isSealEvent, - isValidRequestToVanishEvent, } from '../utils/event' import { IEventRepository, IUserRepository } from '../@types/repositories' import { IEventStrategy, IMessageHandler } from '../@types/message-handlers' @@ -213,7 +212,7 @@ export class EventMessageHandler implements IMessageHandler { return 'invalid: event signature verification failed' } - if (isRequestToVanishEvent(event) && !isValidRequestToVanishEvent(event, this.settings().info.relay_url)) { + if (event.kind === EventKinds.REQUEST_TO_VANISH && !isRequestToVanishEvent(event, this.settings().info.relay_url)) { return 'invalid: request to vanish relay tag invalid' } diff --git a/src/utils/event.ts b/src/utils/event.ts index ce9d8370..50ab0b73 100644 --- a/src/utils/event.ts +++ b/src/utils/event.ts @@ -197,11 +197,15 @@ export const isDeleteEvent = (event: Event): boolean => { return event.kind === EventKinds.DELETE } -export const isRequestToVanishEvent = (event: Event): boolean => { - return event.kind === EventKinds.REQUEST_TO_VANISH -} +export const isRequestToVanishEvent = (event: Event, relayUrl?: string): boolean => { + if (event.kind !== EventKinds.REQUEST_TO_VANISH) { + return false + } + + if (typeof relayUrl === 'undefined') { + return true + } -export const isValidRequestToVanishEvent = (event: Event, relayUrl: string): boolean => { const relayTags = event.tags .filter((tag) => tag.length >= 2 && tag[0] === EventTags.Relay) .map((tag) => tag[1]) diff --git a/test/unit/utils/event.spec.ts b/test/unit/utils/event.spec.ts index ead22dd3..19f2d893 100644 --- a/test/unit/utils/event.spec.ts +++ b/test/unit/utils/event.spec.ts @@ -15,7 +15,6 @@ import { isReplaceableEvent, isRequestToVanishEvent, isSealEvent, - isValidRequestToVanishEvent, serializeEvent, } from '../../../src/utils/event' import { expect } from 'chai' @@ -490,15 +489,13 @@ describe('NIP-62', () => { } as any expect(isRequestToVanishEvent(event)).to.be.false }) - }) - describe('isValidRequestToVanishEvent', () => { it('returns true when event contains the relay URL', () => { const event: Event = { kind: 62, tags: [[EventTags.Relay, 'relay_url']], } as any - expect(isValidRequestToVanishEvent(event, 'relay_url')).to.be.true + expect(isRequestToVanishEvent(event, 'relay_url')).to.be.true }) it('returns true when event contains ALL_RELAYS', () => { @@ -506,7 +503,7 @@ describe('NIP-62', () => { kind: 62, tags: [[EventTags.Relay, ALL_RELAYS]], } as any - expect(isValidRequestToVanishEvent(event, 'relay_url')).to.be.true + expect(isRequestToVanishEvent(event, 'relay_url')).to.be.true }) it('returns false when relay tag does not match', () => { @@ -514,7 +511,7 @@ describe('NIP-62', () => { kind: 62, tags: [[EventTags.Relay, 'other_relay_url']], } as any - expect(isValidRequestToVanishEvent(event, 'relay_url')).to.be.false + expect(isRequestToVanishEvent(event, 'relay_url')).to.be.false }) it('returns false when there are no relay tags', () => { @@ -522,7 +519,15 @@ describe('NIP-62', () => { kind: 62, tags: [], } as any - expect(isValidRequestToVanishEvent(event, 'relay_url')).to.be.false + expect(isRequestToVanishEvent(event, 'relay_url')).to.be.false + }) + + it('returns false when relay URL is provided for non-kind-62 event', () => { + const event: Event = { + kind: 1, + tags: [[EventTags.Relay, 'relay_url']], + } as any + expect(isRequestToVanishEvent(event, 'relay_url')).to.be.false }) }) }) From 58aed82b857b0066c80a48a7eec59ef783ba3781 Mon Sep 17 00:00:00 2001 From: vikashsiwach Date: Fri, 10 Apr 2026 22:48:25 +0530 Subject: [PATCH 3/4] feat: add is_vanished column to users table --- ...0_230000_add_is_vanished_to_users_table.js | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 migrations/20260410_230000_add_is_vanished_to_users_table.js diff --git a/migrations/20260410_230000_add_is_vanished_to_users_table.js b/migrations/20260410_230000_add_is_vanished_to_users_table.js new file mode 100644 index 00000000..f5f73a53 --- /dev/null +++ b/migrations/20260410_230000_add_is_vanished_to_users_table.js @@ -0,0 +1,30 @@ +exports.up = async function (knex) { + await knex.schema.alterTable('users', (table) => { + table.boolean('is_vanished').notNullable().defaultTo(false) + }) + + await knex.raw(` + UPDATE users u + SET is_vanished = true + FROM events e + WHERE u.pubkey = e.event_pubkey + AND e.event_kind = 62 + AND e.deleted_at IS NULL + `) + + await knex.raw(` + INSERT INTO users (pubkey, is_admitted, balance, is_vanished, created_at, updated_at) + SELECT DISTINCT e.event_pubkey, false, 0, true, NOW(), NOW() + FROM events e + LEFT JOIN users u ON u.pubkey = e.event_pubkey + WHERE e.event_kind = 62 + AND e.deleted_at IS NULL + AND u.pubkey IS NULL + `) +} + +exports.down = function (knex) { + return knex.schema.alterTable('users', (table) => { + table.dropColumn('is_vanished') + }) +} From 5436848136ec1c13dda6a86bc2c4dfdee55a0a55 Mon Sep 17 00:00:00 2001 From: vikashsiwach Date: Sun, 12 Apr 2026 03:06:47 +0530 Subject: [PATCH 4/4] feat: add is_vanished optimization with lazy hydration --- ...0_230000_add_is_vanished_to_users_table.js | 20 ++---- src/@types/repositories.ts | 2 + src/@types/user.ts | 2 + .../get-admission-check-controller-factory.ts | 7 +- .../post-invoice-controller-factory.ts | 7 +- src/factories/event-strategy-factory.ts | 5 +- src/factories/message-handler-factory.ts | 2 +- src/factories/payments-service-factory.ts | 4 +- .../static-mirroring.worker-factory.ts | 2 +- src/factories/worker-factory.ts | 2 +- src/handlers/event-message-handler.ts | 4 +- .../event-strategies/vanish-event-strategy.ts | 5 +- src/repositories/user-repository.ts | 72 +++++++++++++++++-- src/utils/transform.ts | 1 + .../factories/event-strategy-factory.spec.ts | 6 +- .../handlers/event-message-handler.spec.ts | 30 +++++--- .../vanish-event-strategy.spec.ts | 16 +++-- 17 files changed, 137 insertions(+), 50 deletions(-) diff --git a/migrations/20260410_230000_add_is_vanished_to_users_table.js b/migrations/20260410_230000_add_is_vanished_to_users_table.js index f5f73a53..2deb8252 100644 --- a/migrations/20260410_230000_add_is_vanished_to_users_table.js +++ b/migrations/20260410_230000_add_is_vanished_to_users_table.js @@ -6,20 +6,12 @@ exports.up = async function (knex) { await knex.raw(` UPDATE users u SET is_vanished = true - FROM events e - WHERE u.pubkey = e.event_pubkey - AND e.event_kind = 62 - AND e.deleted_at IS NULL - `) - - await knex.raw(` - INSERT INTO users (pubkey, is_admitted, balance, is_vanished, created_at, updated_at) - SELECT DISTINCT e.event_pubkey, false, 0, true, NOW(), NOW() - FROM events e - LEFT JOIN users u ON u.pubkey = e.event_pubkey - WHERE e.event_kind = 62 - AND e.deleted_at IS NULL - AND u.pubkey IS NULL + WHERE EXISTS ( + SELECT 1 FROM events e + WHERE e.event_pubkey = u.pubkey + AND e.event_kind = 62 + AND e.deleted_at IS NULL + ) `) } diff --git a/src/@types/repositories.ts b/src/@types/repositories.ts index 12e956f8..961f14db 100644 --- a/src/@types/repositories.ts +++ b/src/@types/repositories.ts @@ -62,4 +62,6 @@ export interface IUserRepository { findByPubkey(pubkey: Pubkey, client?: DatabaseClient): Promise upsert(user: Partial, client?: DatabaseClient): Promise getBalanceByPubkey(pubkey: Pubkey, client?: DatabaseClient): Promise + isVanished(pubkey: Pubkey, client?: DatabaseClient): Promise + setVanished(pubkey: Pubkey, vanished: boolean, client?: DatabaseClient): Promise } diff --git a/src/@types/user.ts b/src/@types/user.ts index 83a5237c..983a3e4a 100644 --- a/src/@types/user.ts +++ b/src/@types/user.ts @@ -3,6 +3,7 @@ import { Pubkey } from './base' export interface User { pubkey: Pubkey isAdmitted: boolean + isVanished: boolean balance: bigint tosAcceptedAt?: Date | null createdAt: Date @@ -12,6 +13,7 @@ export interface User { export interface DBUser { pubkey: Buffer is_admitted: boolean + is_vanished: boolean balance: bigint created_at: Date updated_at: Date diff --git a/src/factories/controllers/get-admission-check-controller-factory.ts b/src/factories/controllers/get-admission-check-controller-factory.ts index c7d2d47e..c1bdd265 100644 --- a/src/factories/controllers/get-admission-check-controller-factory.ts +++ b/src/factories/controllers/get-admission-check-controller-factory.ts @@ -1,12 +1,15 @@ +import { getMasterDbClient, getReadReplicaDbClient } from '../../database/client' import { createSettings } from '../settings-factory' -import { getMasterDbClient } from '../../database/client' +import { EventRepository } from '../../repositories/event-repository' import { GetSubmissionCheckController } from '../../controllers/admission/get-admission-check-controller' import { slidingWindowRateLimiterFactory } from '../rate-limiter-factory' import { UserRepository } from '../../repositories/user-repository' export const createGetAdmissionCheckController = () => { const dbClient = getMasterDbClient() - const userRepository = new UserRepository(dbClient) + const readReplicaDbClient = getReadReplicaDbClient() + const eventRepository = new EventRepository(dbClient, readReplicaDbClient) + const userRepository = new UserRepository(dbClient, eventRepository) return new GetSubmissionCheckController( userRepository, diff --git a/src/factories/controllers/post-invoice-controller-factory.ts b/src/factories/controllers/post-invoice-controller-factory.ts index 50331572..1d5b6593 100644 --- a/src/factories/controllers/post-invoice-controller-factory.ts +++ b/src/factories/controllers/post-invoice-controller-factory.ts @@ -1,6 +1,7 @@ +import { getMasterDbClient, getReadReplicaDbClient } from '../../database/client' import { createPaymentsService } from '../payments-service-factory' import { createSettings } from '../settings-factory' -import { getMasterDbClient } from '../../database/client' +import { EventRepository } from '../../repositories/event-repository' import { IController } from '../../@types/controllers' import { PostInvoiceController } from '../../controllers/invoices/post-invoice-controller' import { slidingWindowRateLimiterFactory } from '../rate-limiter-factory' @@ -8,7 +9,9 @@ import { UserRepository } from '../../repositories/user-repository' export const createPostInvoiceController = (): IController => { const dbClient = getMasterDbClient() - const userRepository = new UserRepository(dbClient) + const readReplicaDbClient = getReadReplicaDbClient() + const eventRepository = new EventRepository(dbClient, readReplicaDbClient) + const userRepository = new UserRepository(dbClient, eventRepository) const paymentsService = createPaymentsService() return new PostInvoiceController( diff --git a/src/factories/event-strategy-factory.ts b/src/factories/event-strategy-factory.ts index 45180fe4..438beca7 100644 --- a/src/factories/event-strategy-factory.ts +++ b/src/factories/event-strategy-factory.ts @@ -5,7 +5,7 @@ import { EphemeralEventStrategy } from '../handlers/event-strategies/ephemeral-e import { Event } from '../@types/event' import { Factory } from '../@types/base' import { GiftWrapEventStrategy } from '../handlers/event-strategies/gift-wrap-event-strategy' -import { IEventRepository } from '../@types/repositories' +import { IEventRepository, IUserRepository } from '../@types/repositories' import { IEventStrategy } from '../@types/message-handlers' import { IWebSocketAdapter } from '../@types/adapters' import { ParameterizedReplaceableEventStrategy } from '../handlers/event-strategies/parameterized-replaceable-event-strategy' @@ -14,10 +14,11 @@ import { VanishEventStrategy } from '../handlers/event-strategies/vanish-event-s export const eventStrategyFactory = ( eventRepository: IEventRepository, + userRepository: IUserRepository, ): Factory>, [Event, IWebSocketAdapter]> => ([event, adapter]: [Event, IWebSocketAdapter]) => { if (isRequestToVanishEvent(event)) { - return new VanishEventStrategy(adapter, eventRepository) + return new VanishEventStrategy(adapter, eventRepository, userRepository) } else if (isGiftWrapEvent(event)) { return new GiftWrapEventStrategy(adapter, eventRepository) } else if (isReplaceableEvent(event)) { diff --git a/src/factories/message-handler-factory.ts b/src/factories/message-handler-factory.ts index 34c37493..a5c1c9fe 100644 --- a/src/factories/message-handler-factory.ts +++ b/src/factories/message-handler-factory.ts @@ -17,7 +17,7 @@ export const messageHandlerFactory = ( { return new EventMessageHandler( adapter, - eventStrategyFactory(eventRepository), + eventStrategyFactory(eventRepository, userRepository), eventRepository, userRepository, createSettings, diff --git a/src/factories/payments-service-factory.ts b/src/factories/payments-service-factory.ts index abd97ddd..1a762e44 100644 --- a/src/factories/payments-service-factory.ts +++ b/src/factories/payments-service-factory.ts @@ -10,9 +10,9 @@ export const createPaymentsService = () => { const dbClient = getMasterDbClient() const rrDbClient = getReadReplicaDbClient() const invoiceRepository = new InvoiceRepository(dbClient) - const userRepository = new UserRepository(dbClient) - const paymentsProcessor = createPaymentsProcessor() const eventRepository = new EventRepository(dbClient, rrDbClient) + const userRepository = new UserRepository(dbClient, eventRepository) + const paymentsProcessor = createPaymentsProcessor() return new PaymentsService( dbClient, diff --git a/src/factories/static-mirroring.worker-factory.ts b/src/factories/static-mirroring.worker-factory.ts index 234430e4..67f7028e 100644 --- a/src/factories/static-mirroring.worker-factory.ts +++ b/src/factories/static-mirroring.worker-factory.ts @@ -8,7 +8,7 @@ export const staticMirroringWorkerFactory = () => { const dbClient = getMasterDbClient() const readReplicaDbClient = getReadReplicaDbClient() const eventRepository = new EventRepository(dbClient, readReplicaDbClient) - const userRepository = new UserRepository(dbClient) + const userRepository = new UserRepository(dbClient, eventRepository) return new StaticMirroringWorker( eventRepository, diff --git a/src/factories/worker-factory.ts b/src/factories/worker-factory.ts index 123e59d2..ec358798 100644 --- a/src/factories/worker-factory.ts +++ b/src/factories/worker-factory.ts @@ -16,7 +16,7 @@ export const workerFactory = (): AppWorker => { const dbClient = getMasterDbClient() const readReplicaDbClient = getReadReplicaDbClient() const eventRepository = new EventRepository(dbClient, readReplicaDbClient) - const userRepository = new UserRepository(dbClient) + const userRepository = new UserRepository(dbClient, eventRepository) const settings = createSettings() diff --git a/src/handlers/event-message-handler.ts b/src/handlers/event-message-handler.ts index 238e121f..2c3564bd 100644 --- a/src/handlers/event-message-handler.ts +++ b/src/handlers/event-message-handler.ts @@ -234,8 +234,8 @@ export class EventMessageHandler implements IMessageHandler { return } - const existingVanishRequest = await this.eventRepository.hasActiveRequestToVanish(event.pubkey) - if (existingVanishRequest) { + const isVanished = await this.userRepository.isVanished(event.pubkey) + if (isVanished) { return 'blocked: request to vanish active for pubkey' } } diff --git a/src/handlers/event-strategies/vanish-event-strategy.ts b/src/handlers/event-strategies/vanish-event-strategy.ts index 32dbb494..ed8ca73a 100644 --- a/src/handlers/event-strategies/vanish-event-strategy.ts +++ b/src/handlers/event-strategies/vanish-event-strategy.ts @@ -1,8 +1,8 @@ +import { IEventRepository, IUserRepository } from '../../@types/repositories' import { createCommandResult } from '../../utils/messages' import { createLogger } from '../../factories/logger-factory' import { Event } from '../../@types/event' import { EventKinds } from '../../constants/base' -import { IEventRepository } from '../../@types/repositories' import { IEventStrategy } from '../../@types/message-handlers' import { IWebSocketAdapter } from '../../@types/adapters' import { WebSocketAdapterEvent } from '../../constants/adapter' @@ -13,6 +13,7 @@ export class VanishEventStrategy implements IEventStrategy> public constructor( private readonly webSocket: IWebSocketAdapter, private readonly eventRepository: IEventRepository, + private readonly userRepository: IUserRepository, ) {} public async execute(event: Event): Promise { @@ -25,6 +26,8 @@ export class VanishEventStrategy implements IEventStrategy> const count = await this.eventRepository.create(event) + await this.userRepository.setVanished(event.pubkey, true) + this.webSocket.emit( WebSocketAdapterEvent.Message, createCommandResult(event.id, true, count ? '' : 'duplicate:') diff --git a/src/repositories/user-repository.ts b/src/repositories/user-repository.ts index fedf40b4..6fa5c76d 100644 --- a/src/repositories/user-repository.ts +++ b/src/repositories/user-repository.ts @@ -1,15 +1,18 @@ -import { always, applySpec, omit, pipe, prop } from 'ramda' - +import { always, applySpec, defaultTo, omit, pipe, prop } from 'ramda' import { DatabaseClient, Pubkey } from '../@types/base' import { DBUser, User } from '../@types/user' import { fromDBUser, toBuffer } from '../utils/transform' +import { IEventRepository, IUserRepository } from '../@types/repositories' import { createLogger } from '../factories/logger-factory' -import { IUserRepository } from '../@types/repositories' + const debug = createLogger('user-repository') export class UserRepository implements IUserRepository { - public constructor(private readonly dbClient: DatabaseClient) { } + public constructor( + private readonly dbClient: DatabaseClient, + private readonly eventRepository: IEventRepository, + ) { } public async findByPubkey( pubkey: Pubkey, @@ -28,7 +31,7 @@ export class UserRepository implements IUserRepository { } public async upsert( - user: User, + user: Partial, client: DatabaseClient = this.dbClient, ): Promise { debug('upsert: %o', user) @@ -37,7 +40,8 @@ export class UserRepository implements IUserRepository { const row = applySpec({ pubkey: pipe(prop('pubkey'), toBuffer), - is_admitted: prop('isAdmitted'), + is_admitted: pipe(prop('isAdmitted'), defaultTo(false)), + is_vanished: pipe(prop('isVanished'), defaultTo(false)), tos_accepted_at: prop('tosAcceptedAt'), updated_at: always(date), created_at: always(date), @@ -61,6 +65,62 @@ export class UserRepository implements IUserRepository { } as Promise } + /** + * Returns vanish state from users.is_vanished, or lazily hydrates a user row from events once + * when no users row exists (single upsert; no duplicate inserts). + */ + public async isVanished( + pubkey: Pubkey, + client: DatabaseClient = this.dbClient + ): Promise { + const existing = await this.findByPubkey(pubkey, client) + if (existing) { + return existing.isVanished + } + + const vanishedFromEvents = await this.eventRepository.hasActiveRequestToVanish(pubkey) + await this.upsertVanishState(pubkey, vanishedFromEvents, client) + return vanishedFromEvents + } + + public setVanished( + pubkey: Pubkey, + vanished: boolean, + client: DatabaseClient = this.dbClient + ): Promise { + return this.upsertVanishState(pubkey, vanished, client) + } + + private upsertVanishState( + pubkey: Pubkey, + isVanished: boolean, + client: DatabaseClient, + ): Promise { + debug('upsert vanish state for %s: %o', pubkey, isVanished) + const date = new Date() + + const query = client('users') + .insert({ + pubkey: toBuffer(pubkey), + is_admitted: false, + balance: 0n, + is_vanished: isVanished, + created_at: date, + updated_at: date, + }) + .onConflict('pubkey') + .merge({ + is_vanished: isVanished, + updated_at: date, + }) + + return { + then: (onfulfilled: (value: number) => T1 | PromiseLike, onrejected: (reason: any) => T2 | PromiseLike) => query.then(prop('rowCount') as () => number).then(onfulfilled, onrejected), + catch: (onrejected: (reason: any) => T | PromiseLike) => query.catch(onrejected), + toString: (): string => query.toString(), + } as Promise + } + public async getBalanceByPubkey( pubkey: Pubkey, client: DatabaseClient = this.dbClient diff --git a/src/utils/transform.ts b/src/utils/transform.ts index 33aa9244..9c9d01b2 100644 --- a/src/utils/transform.ts +++ b/src/utils/transform.ts @@ -39,6 +39,7 @@ export const fromDBInvoice = applySpec({ export const fromDBUser = applySpec({ pubkey: pipe(prop('pubkey') as () => Buffer, fromBuffer), isAdmitted: prop('is_admitted'), + isVanished: prop('is_vanished'), balance: prop('balance'), createdAt: prop('created_at'), updatedAt: prop('updated_at'), diff --git a/test/unit/factories/event-strategy-factory.spec.ts b/test/unit/factories/event-strategy-factory.spec.ts index 7c0a432b..a180cb7f 100644 --- a/test/unit/factories/event-strategy-factory.spec.ts +++ b/test/unit/factories/event-strategy-factory.spec.ts @@ -1,5 +1,6 @@ import { expect } from 'chai' +import { IEventRepository, IUserRepository } from '../../../src/@types/repositories' import { DefaultEventStrategy } from '../../../src/handlers/event-strategies/default-event-strategy' import { DeleteEventStrategy } from '../../../src/handlers/event-strategies/delete-event-strategy' import { EphemeralEventStrategy } from '../../../src/handlers/event-strategies/ephemeral-event-strategy' @@ -8,7 +9,6 @@ import { EventKinds } from '../../../src/constants/base' import { eventStrategyFactory } from '../../../src/factories/event-strategy-factory' import { Factory } from '../../../src/@types/base' import { GiftWrapEventStrategy } from '../../../src/handlers/event-strategies/gift-wrap-event-strategy' -import { IEventRepository } from '../../../src/@types/repositories' import { IEventStrategy } from '../../../src/@types/message-handlers' import { IWebSocketAdapter } from '../../../src/@types/adapters' import { ParameterizedReplaceableEventStrategy } from '../../../src/handlers/event-strategies/parameterized-replaceable-event-strategy' @@ -17,16 +17,18 @@ import { VanishEventStrategy } from '../../../src/handlers/event-strategies/vani describe('eventStrategyFactory', () => { let eventRepository: IEventRepository + let userRepository: IUserRepository let event: Event let adapter: IWebSocketAdapter let factory: Factory>, [Event, IWebSocketAdapter]> beforeEach(() => { eventRepository = {} as any + userRepository = {} as any event = {} as any adapter = {} as any - factory = eventStrategyFactory(eventRepository) + factory = eventStrategyFactory(eventRepository, userRepository) }) it('returns ReplaceableEvent given a set_metadata event', () => { diff --git a/test/unit/handlers/event-message-handler.spec.ts b/test/unit/handlers/event-message-handler.spec.ts index 30995f80..059b4b55 100644 --- a/test/unit/handlers/event-message-handler.spec.ts +++ b/test/unit/handlers/event-message-handler.spec.ts @@ -50,6 +50,9 @@ describe('EventMessageHandler', () => { sig: 'f'.repeat(128), tags: [], } + userRepository = { + isVanished: async () => false, + } as any }) afterEach(() => { @@ -71,7 +74,10 @@ describe('EventMessageHandler', () => { canAcceptEventStub = sandbox.stub(EventMessageHandler.prototype, 'canAcceptEvent' as any) isEventValidStub = sandbox.stub(EventMessageHandler.prototype, 'isEventValid' as any) isUserAdmitted = sandbox.stub(EventMessageHandler.prototype, 'isUserAdmitted' as any) - eventRepository = { hasActiveRequestToVanish: sandbox.stub().resolves(false) } + eventRepository = {} as any + userRepository = { + isVanished: sandbox.stub().resolves(false), + } as any strategyExecuteStub = sandbox.stub() strategyFactoryStub = sandbox.stub().returns({ execute: strategyExecuteStub, @@ -126,11 +132,11 @@ describe('EventMessageHandler', () => { it('rejects event if request to vanish is active for pubkey', async () => { canAcceptEventStub.returns(undefined) isEventValidStub.resolves(undefined) - eventRepository.hasActiveRequestToVanish.resolves(true) + ;(userRepository.isVanished as any).resolves(true) await handler.handleMessage(message) - expect(eventRepository.hasActiveRequestToVanish).to.have.been.calledOnceWithExactly(event.pubkey) + expect(userRepository.isVanished as any).to.have.been.calledOnceWithExactly(event.pubkey) expect(onMessageSpy).to.have.been.calledOnceWithExactly( [MessageType.OK, event.id, false, 'blocked: request to vanish active for pubkey'], ) @@ -260,7 +266,7 @@ describe('EventMessageHandler', () => { handler = new EventMessageHandler( {} as any, () => null, - { hasActiveRequestToVanish: async () => false } as any, + {} as any, userRepository, () => settings, () => ({ hit: async () => false }) @@ -783,10 +789,13 @@ describe('EventMessageHandler', () => { webSocket = { getClientAddress: getClientAddressStub, } as any + userRepository = { + isVanished: async () => false, + } as any handler = new EventMessageHandler( webSocket, () => null, - { hasActiveRequestToVanish: async () => false } as any, + {} as any, userRepository, () => settings, () => ({ hit: rateLimiterHitStub }) @@ -1050,11 +1059,12 @@ describe('EventMessageHandler', () => { } as any userRepository = { findByPubkey: userRepositoryFindByPubkeyStub, + isVanished: async () => false, } as any handler = new EventMessageHandler( webSocket, () => null, - { hasActiveRequestToVanish: async () => false } as any, + {} as any, userRepository, () => settings, () => ({ hit: async () => false }) @@ -1135,27 +1145,27 @@ describe('EventMessageHandler', () => { }) it('fulfills with reason if user is not admitted', async () => { - userRepositoryFindByPubkeyStub.resolves({ isAdmitted: false }) + userRepositoryFindByPubkeyStub.resolves({ isAdmitted: false, isVanished: false }) return expect((handler as any).isUserAdmitted(event)).to.eventually.equal('blocked: pubkey not admitted') }) it('fulfills with reason if user is not admitted', async () => { - userRepositoryFindByPubkeyStub.resolves({ isAdmitted: false }) + userRepositoryFindByPubkeyStub.resolves({ isAdmitted: false, isVanished: false }) return expect((handler as any).isUserAdmitted(event)).to.eventually.equal('blocked: pubkey not admitted') }) it('fulfills with reason if user does not meet minimum balance', async () => { settings.limits.event.pubkey.minBalance = 1000n - userRepositoryFindByPubkeyStub.resolves({ isAdmitted: true, balance: 999n }) + userRepositoryFindByPubkeyStub.resolves({ isAdmitted: true, isVanished: false, balance: 999n }) return expect((handler as any).isUserAdmitted(event)).to.eventually.equal('blocked: insufficient balance') }) it('fulfills with undefined if user is admitted', async () => { settings.limits.event.pubkey.minBalance = 0n - userRepositoryFindByPubkeyStub.resolves({ isAdmitted: true }) + userRepositoryFindByPubkeyStub.resolves({ isAdmitted: true, isVanished: false }) return expect((handler as any).isUserAdmitted(event)).to.eventually.be.undefined }) diff --git a/test/unit/handlers/event-strategies/vanish-event-strategy.spec.ts b/test/unit/handlers/event-strategies/vanish-event-strategy.spec.ts index 2b6aefe2..845372cd 100644 --- a/test/unit/handlers/event-strategies/vanish-event-strategy.spec.ts +++ b/test/unit/handlers/event-strategies/vanish-event-strategy.spec.ts @@ -1,20 +1,24 @@ -import chai from 'chai' -import chaiAsPromised from 'chai-as-promised' import { Event } from '../../../../src/@types/event' import { EventKinds } from '../../../../src/constants/base' import { IWebSocketAdapter } from '../../../../src/@types/adapters' import { MessageType } from '../../../../src/@types/messages' -import Sinon from 'sinon' import { VanishEventStrategy } from '../../../../src/handlers/event-strategies/vanish-event-strategy' import { WebSocketAdapterEvent } from '../../../../src/constants/adapter' +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import Sinon from 'sinon' +import sinonChai from 'sinon-chai' + chai.use(chaiAsPromised) +chai.use(sinonChai) const { expect } = chai describe('VanishEventStrategy', () => { let webSocket: IWebSocketAdapter let eventRepository: any + let userRepository: any let webSocketEmitStub: Sinon.SinonStub let strategy: VanishEventStrategy let sandbox: Sinon.SinonSandbox @@ -31,11 +35,14 @@ describe('VanishEventStrategy', () => { deleteByPubkeyExceptKinds: sandbox.stub().resolves(1), create: sandbox.stub().resolves(1), } + userRepository = { + setVanished: sandbox.stub().resolves(1), + } webSocketEmitStub = sandbox.stub() webSocket = { emit: webSocketEmitStub, } as any - strategy = new VanishEventStrategy(webSocket, eventRepository) + strategy = new VanishEventStrategy(webSocket, eventRepository, userRepository) }) afterEach(() => { @@ -50,6 +57,7 @@ describe('VanishEventStrategy', () => { [EventKinds.REQUEST_TO_VANISH], ) expect(eventRepository.create).to.have.been.calledOnceWithExactly(event) + expect(userRepository.setVanished).to.have.been.calledOnceWithExactly(event.pubkey, true) expect(webSocketEmitStub).to.have.been.calledOnceWithExactly( WebSocketAdapterEvent.Message, [MessageType.OK, event.id, true, ''],