mirror of
https://github.com/Cameri/nostream.git
synced 2025-12-18 16:41:08 +01:00
fix: issues with invoices (#271)
* fix: issues with invoices * chore: add invoice event tag * chore: add sub limits * chore: cleanup invoices page * chore: use mergeDeepLeft when updating invoice * chore: ignore whitelisted pubkey for adminssion fee * chore: use secp256k1 bytesToHex * fix: insecure derivation from secret * fix: tests * chore: consistent returns * test: fix intg tests * fix: intg tests * chore: set SECRET for intg tests
This commit is contained in:
committed by
GitHub
parent
f23740073f
commit
e1561e78fd
@@ -82,14 +82,14 @@ limits:
|
||||
- 10
|
||||
- - 40
|
||||
- 49
|
||||
maxLength: 65536
|
||||
maxLength: 102400
|
||||
- description: 96 KB for event kind ranges 11-39 and 50-max
|
||||
kinds:
|
||||
- - 11
|
||||
- 39
|
||||
- - 50
|
||||
- 9007199254740991
|
||||
maxLength: 98304
|
||||
maxLength: 102400
|
||||
rateLimits:
|
||||
- description: 6 events/min for event kinds 0, 3, 40 and 41
|
||||
kinds:
|
||||
@@ -143,6 +143,10 @@ limits:
|
||||
subscription:
|
||||
maxSubscriptions: 10
|
||||
maxFilters: 10
|
||||
maxFilterValues: 2500
|
||||
maxSubscriptionIdLength: 256
|
||||
maxLimit: 5000
|
||||
minPrefixLength: 4
|
||||
message:
|
||||
rateLimits:
|
||||
- description: 240 raw messages/min
|
||||
|
||||
@@ -107,8 +107,11 @@
|
||||
var timeout
|
||||
var paid = false
|
||||
var fallbackTimeout
|
||||
var now = Math.floor(Date.now()/1000)
|
||||
|
||||
console.log('invoice id', reference)
|
||||
console.log('pubkey', pubkey)
|
||||
console.log('bolt11', invoice)
|
||||
|
||||
function getBackoffTime() {
|
||||
return 5000 + Math.floor(Math.random() * 5000)
|
||||
@@ -149,7 +152,8 @@
|
||||
var socket = new WebSocket(relayUrl)
|
||||
socket.onopen = () => {
|
||||
console.log('connected')
|
||||
socket.send(JSON.stringify(['REQ', 'payment', { kinds: [4], authors: [relayPubkey], '#c': [reference], limit: 1 }]))
|
||||
var subscription = ['REQ', 'payment', { kinds: [402], '#p': [pubkey], since: now - 60 }]
|
||||
socket.send(JSON.stringify(subscription))
|
||||
}
|
||||
|
||||
socket.onmessage = (raw) => {
|
||||
@@ -162,16 +166,22 @@
|
||||
|
||||
switch (message[0]) {
|
||||
case 'EVENT': {
|
||||
// TODO: validate event
|
||||
const event = message[2]
|
||||
// TODO: validate signature
|
||||
if (event.pubkey === relayPubkey) {
|
||||
paid = true
|
||||
if (
|
||||
event.pubkey === relayPubkey
|
||||
&& event.kind === 402
|
||||
) {
|
||||
const pubkeyTag = event.tags.find((t) => t[0] === 'p' && t[1] === pubkey)
|
||||
const invoiceTag = event.tags.find((t) => t[0] === 'bolt11' && t[1] === invoice)
|
||||
|
||||
if (expiresAt) clearTimeout(timeout)
|
||||
if (pubkeyTag && invoiceTag) {
|
||||
paid = true
|
||||
|
||||
hide('pending')
|
||||
show('paid')
|
||||
if (expiresAt) clearTimeout(timeout)
|
||||
|
||||
hide('pending')
|
||||
show('paid')
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import { Knex } from 'knex'
|
||||
import { SocketAddress } from 'net'
|
||||
|
||||
import { EventTags } from '../constants/base'
|
||||
|
||||
export type EventId = string
|
||||
export type Pubkey = string
|
||||
export type TagName = string
|
||||
export type TagName = EventTags | string
|
||||
export type Signature = string
|
||||
export type Tag = TagBase & string[]
|
||||
|
||||
export type Secret = string
|
||||
|
||||
export interface TagBase {
|
||||
0: TagName
|
||||
[index: number]: string
|
||||
type ExtraTagValues = {
|
||||
[index in Range<2, 100>]?: string
|
||||
}
|
||||
|
||||
export interface TagBase extends ExtraTagValues {
|
||||
0: TagName;
|
||||
1: string
|
||||
}
|
||||
|
||||
type Enumerate<
|
||||
|
||||
@@ -13,7 +13,6 @@ export interface IPaymentsService {
|
||||
confirmInvoice(
|
||||
invoice: Pick<Invoice, 'id' | 'amountPaid' | 'confirmedAt'>,
|
||||
): Promise<void>
|
||||
sendNewInvoiceNotification(invoice: Invoice): Promise<void>
|
||||
sendInvoiceUpdateNotification(invoice: Invoice): Promise<void>
|
||||
getPendingInvoices(): Promise<Invoice[]>
|
||||
}
|
||||
|
||||
@@ -82,6 +82,10 @@ export interface EventLimits {
|
||||
export interface ClientSubscriptionLimits {
|
||||
maxSubscriptions?: number
|
||||
maxFilters?: number
|
||||
maxFilterValues?: number
|
||||
maxLimit?: number
|
||||
minPrefixLength?: number
|
||||
maxSubscriptionIdLength?: number
|
||||
}
|
||||
|
||||
export interface ClientLimits {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { mergeDeepLeft, path, pipe } from 'ramda'
|
||||
import { IRunnable } from '../@types/base'
|
||||
import { path } from 'ramda'
|
||||
|
||||
import { createLogger } from '../factories/logger-factory'
|
||||
import { delayMs } from '../utils/misc'
|
||||
@@ -47,21 +47,27 @@ export class MaintenanceWorker implements IRunnable {
|
||||
for (const invoice of invoices) {
|
||||
debug('invoice %s: %o', invoice.id, invoice)
|
||||
try {
|
||||
debug('getting invoice %s from payment processor', invoice.id)
|
||||
debug('getting invoice %s from payment processor: %o', invoice.id, invoice)
|
||||
const updatedInvoice = await this.paymentsService.getInvoiceFromPaymentsProcessor(invoice)
|
||||
await delay()
|
||||
debug('updating invoice status %s: %o', invoice.id, invoice)
|
||||
debug('updating invoice status %s: %o', updatedInvoice.id, updatedInvoice)
|
||||
await this.paymentsService.updateInvoiceStatus(updatedInvoice)
|
||||
|
||||
if (
|
||||
invoice.status !== updatedInvoice.status
|
||||
&& updatedInvoice.status == InvoiceStatus.COMPLETED
|
||||
&& invoice.confirmedAt
|
||||
&& updatedInvoice.confirmedAt
|
||||
) {
|
||||
debug('confirming invoice %s & notifying %s', invoice.id, invoice.pubkey)
|
||||
|
||||
const update = pipe(
|
||||
mergeDeepLeft(updatedInvoice),
|
||||
mergeDeepLeft({ amountPaid: invoice.amountRequested }),
|
||||
)(invoice)
|
||||
|
||||
await Promise.all([
|
||||
this.paymentsService.confirmInvoice(invoice),
|
||||
this.paymentsService.sendInvoiceUpdateNotification(invoice),
|
||||
this.paymentsService.confirmInvoice(update),
|
||||
this.paymentsService.sendInvoiceUpdateNotification(update),
|
||||
])
|
||||
|
||||
await delay()
|
||||
|
||||
@@ -27,7 +27,6 @@ export enum EventKinds {
|
||||
PARAMETERIZED_REPLACEABLE_FIRST = 30000,
|
||||
PARAMETERIZED_REPLACEABLE_LAST = 39999,
|
||||
USER_APPLICATION_FIRST = 40000,
|
||||
USER_APPLICATION_LAST = Number.MAX_SAFE_INTEGER,
|
||||
}
|
||||
|
||||
export enum EventTags {
|
||||
@@ -37,6 +36,7 @@ export enum EventTags {
|
||||
Delegation = 'delegation',
|
||||
Deduplication = 'd',
|
||||
Expiration = 'expiration',
|
||||
Invoice = 'bolt11',
|
||||
}
|
||||
|
||||
export enum PaymentsProcessors {
|
||||
|
||||
@@ -136,7 +136,12 @@ export class PostInvoiceController implements IController {
|
||||
}
|
||||
|
||||
let invoice: Invoice
|
||||
const amount = admissionFee.reduce((sum, fee) => sum + BigInt(fee.amount), 0n)
|
||||
const amount = admissionFee.reduce((sum, fee) => {
|
||||
return fee.enabled && !fee.whitelists?.pubkeys?.includes(pubkey)
|
||||
? BigInt(fee.amount) + sum
|
||||
: sum
|
||||
}, 0n)
|
||||
|
||||
try {
|
||||
const description = `${relayName} Admission Fee for ${toBech32('npub')(pubkey)}`
|
||||
|
||||
@@ -145,8 +150,6 @@ export class PostInvoiceController implements IController {
|
||||
amount,
|
||||
description,
|
||||
)
|
||||
|
||||
await this.paymentsService.sendNewInvoiceNotification(invoice)
|
||||
} catch (error) {
|
||||
console.error('Unable to create invoice. Reason:', error)
|
||||
response
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Event, ExpiringEvent } from '../@types/event'
|
||||
import { EventRateLimit, FeeSchedule, Settings } from '../@types/settings'
|
||||
import { getEventExpiration, getEventProofOfWork, getPubkeyProofOfWork, isEventIdValid, isEventKindOrRangeMatch, isEventSignatureValid, isExpiredEvent } from '../utils/event'
|
||||
import { getEventExpiration, getEventProofOfWork, getPubkeyProofOfWork, getPublicKey, getRelayPrivateKey, isEventIdValid, isEventKindOrRangeMatch, isEventSignatureValid, isExpiredEvent } from '../utils/event'
|
||||
import { IEventStrategy, IMessageHandler } from '../@types/message-handlers'
|
||||
import { ContextMetadataKey } from '../constants/base'
|
||||
import { createCommandResult } from '../utils/messages'
|
||||
@@ -79,7 +79,15 @@ export class EventMessageHandler implements IMessageHandler {
|
||||
}
|
||||
}
|
||||
|
||||
protected getRelayPublicKey(): string {
|
||||
const relayPrivkey = getRelayPrivateKey(this.settings().info.relay_url)
|
||||
return getPublicKey(relayPrivkey)
|
||||
}
|
||||
|
||||
protected canAcceptEvent(event: Event): string | undefined {
|
||||
if (this.getRelayPublicKey() === event.pubkey) {
|
||||
return
|
||||
}
|
||||
const now = Math.floor(Date.now()/1000)
|
||||
|
||||
const limits = this.settings().limits?.event ?? {}
|
||||
@@ -185,6 +193,10 @@ export class EventMessageHandler implements IMessageHandler {
|
||||
}
|
||||
|
||||
protected async isRateLimited(event: Event): Promise<boolean> {
|
||||
if (this.getRelayPublicKey() === event.pubkey) {
|
||||
return false
|
||||
}
|
||||
|
||||
const { whitelists, rateLimits } = this.settings().limits?.event ?? {}
|
||||
if (!rateLimits || !rateLimits.length) {
|
||||
return false
|
||||
@@ -249,6 +261,10 @@ export class EventMessageHandler implements IMessageHandler {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.getRelayPublicKey() === event.pubkey) {
|
||||
return
|
||||
}
|
||||
|
||||
const isApplicableFee = (feeSchedule: FeeSchedule) =>
|
||||
feeSchedule.enabled
|
||||
&& !feeSchedule.whitelists?.pubkeys?.some((prefix) => event.pubkey.startsWith(prefix))
|
||||
|
||||
@@ -17,6 +17,8 @@ export const rootRequestHandler = (request: Request, response: Response, next: N
|
||||
paymentsUrl.protocol = paymentsUrl.protocol === 'wss:' ? 'https:' : 'http:'
|
||||
paymentsUrl.pathname = '/invoices'
|
||||
|
||||
const content = settings.limits?.event?.content
|
||||
|
||||
const relayInformationDocument = {
|
||||
name,
|
||||
description,
|
||||
@@ -29,12 +31,14 @@ export const rootRequestHandler = (request: Request, response: Response, next: N
|
||||
limitation: {
|
||||
max_message_length: settings.network.maxPayloadSize,
|
||||
max_subscriptions: settings.limits?.client?.subscription?.maxSubscriptions,
|
||||
max_filters: settings.limits?.client?.subscription?.maxFilters,
|
||||
max_limit: 5000,
|
||||
max_subid_length: 256,
|
||||
min_prefix: 4,
|
||||
max_filters: settings.limits?.client?.subscription?.maxFilterValues,
|
||||
max_limit: settings.limits?.client?.subscription?.maxLimit,
|
||||
max_subid_length: settings.limits?.client?.subscription?.maxSubscriptionIdLength,
|
||||
min_prefix: settings.limits?.client?.subscription?.minPrefixLength,
|
||||
max_event_tags: 2500,
|
||||
max_content_length: 102400,
|
||||
max_content_length: Array.isArray(content)
|
||||
? content[0].maxLength // best guess since we have per-kind limits
|
||||
: content?.maxLength,
|
||||
min_pow_difficulty: settings.limits?.event?.eventId?.minLeadingZeroBits,
|
||||
auth_required: false,
|
||||
payment_required: settings.payments?.enabled,
|
||||
|
||||
@@ -87,23 +87,33 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable {
|
||||
private canSubscribe(subscriptionId: SubscriptionId, filters: SubscriptionFilter[]): string | undefined {
|
||||
const subscriptions = this.webSocket.getSubscriptions()
|
||||
const existingSubscription = subscriptions.get(subscriptionId)
|
||||
const subscriptionLimits = this.settings().limits?.client?.subscription
|
||||
|
||||
if (existingSubscription?.length && equals(filters, existingSubscription)) {
|
||||
return `Duplicate subscription ${subscriptionId}: Ignorning`
|
||||
}
|
||||
|
||||
const maxSubscriptions = this.settings().limits?.client?.subscription?.maxSubscriptions ?? 0
|
||||
const maxSubscriptions = subscriptionLimits?.maxSubscriptions ?? 0
|
||||
if (maxSubscriptions > 0
|
||||
&& !existingSubscription?.length && subscriptions.size + 1 > maxSubscriptions
|
||||
) {
|
||||
return `Too many subscriptions: Number of subscriptions must be less than or equal to ${maxSubscriptions}`
|
||||
}
|
||||
|
||||
const maxFilters = this.settings().limits?.client?.subscription?.maxFilters ?? 0
|
||||
const maxFilters = subscriptionLimits?.maxFilters ?? 0
|
||||
if (maxFilters > 0) {
|
||||
if (filters.length > maxFilters) {
|
||||
return `Too many filters: Number of filters per susbscription must be less then or equal to ${maxFilters}`
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
typeof subscriptionLimits.maxSubscriptionIdLength === 'number'
|
||||
&& subscriptionId.length > subscriptionLimits.maxSubscriptionIdLength
|
||||
) {
|
||||
return `Subscription ID too long: Subscription ID must be less or equal to ${subscriptionLimits.maxSubscriptionIdLength}`
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,8 @@ export class LnurlPaymentsProcesor implements IPaymentsProcessor {
|
||||
|
||||
return {
|
||||
id: invoice.id,
|
||||
status: response.data.settled ? InvoiceStatus['COMPLETED'] : InvoiceStatus['PENDING'],
|
||||
confirmedAt: response.data.settled ? new Date() : undefined,
|
||||
status: response.data.settled ? InvoiceStatus.COMPLETED : InvoiceStatus.PENDING,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Unable to get invoice ${invoice.id}. Reason:`, error)
|
||||
|
||||
@@ -10,7 +10,7 @@ export const kindSchema = Schema.number().min(0).multiple(1).label('kind')
|
||||
|
||||
export const signatureSchema = Schema.string().case('lower').hex().length(128).label('sig')
|
||||
|
||||
export const subscriptionSchema = Schema.string().min(1).max(255).label('subscriptionId')
|
||||
export const subscriptionSchema = Schema.string().min(1).label('subscriptionId')
|
||||
|
||||
const seconds = (value: any, helpers: any) => (Number.isSafeInteger(value) && Math.log10(value) < 10) ? value : helpers.error('any.invalid')
|
||||
|
||||
@@ -20,5 +20,4 @@ export const createdAtSchema = Schema.number().min(0).multiple(1).custom(seconds
|
||||
export const tagSchema = Schema.array()
|
||||
.ordered(Schema.string().max(255).required().label('identifier'))
|
||||
.items(Schema.string().allow('').max(1024).label('value'))
|
||||
.max(10)
|
||||
.label('tag')
|
||||
|
||||
@@ -31,10 +31,9 @@ export const eventSchema = Schema.object({
|
||||
pubkey: pubkeySchema.required(),
|
||||
created_at: createdAtSchema.required(),
|
||||
kind: kindSchema.required(),
|
||||
tags: Schema.array().items(tagSchema).max(2500).required(),
|
||||
tags: Schema.array().items(tagSchema).required(),
|
||||
content: Schema.string()
|
||||
.allow('')
|
||||
.max(100 * 1024) // 100 kB
|
||||
.required(),
|
||||
sig: signatureSchema.required(),
|
||||
}).unknown(false)
|
||||
|
||||
@@ -3,10 +3,10 @@ import Schema from 'joi'
|
||||
import { createdAtSchema, kindSchema, prefixSchema } from './base-schema'
|
||||
|
||||
export const filterSchema = Schema.object({
|
||||
ids: Schema.array().items(prefixSchema.label('prefixOrId')).max(1000),
|
||||
authors: Schema.array().items(prefixSchema.label('prefixOrAuthor')).max(1000),
|
||||
kinds: Schema.array().items(kindSchema).max(20),
|
||||
ids: Schema.array().items(prefixSchema.label('prefixOrId')),
|
||||
authors: Schema.array().items(prefixSchema.label('prefixOrAuthor')),
|
||||
kinds: Schema.array().items(kindSchema),
|
||||
since: createdAtSchema,
|
||||
until: createdAtSchema,
|
||||
limit: Schema.number().min(0).multiple(1).max(5000),
|
||||
}).pattern(/^#[a-z]$/, Schema.array().items(Schema.string().max(1024)).max(256))
|
||||
limit: Schema.number().min(0).multiple(1),
|
||||
}).pattern(/^#[a-z]$/, Schema.array().items(Schema.string().max(1024)))
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { andThen, pipe } from 'ramda'
|
||||
import { broadcastEvent, encryptKind4Event, getPublicKey, getRelayPrivateKey, identifyEvent, signEvent } from '../utils/event'
|
||||
import { andThen, otherwise, pipe } from 'ramda'
|
||||
import { broadcastEvent, getPublicKey, getRelayPrivateKey, identifyEvent, signEvent } from '../utils/event'
|
||||
import { DatabaseClient, Pubkey } from '../@types/base'
|
||||
import { FeeSchedule, Settings } from '../@types/settings'
|
||||
import { IEventRepository, IInvoiceRepository, IUserRepository } from '../@types/repositories'
|
||||
import { Invoice, InvoiceStatus, InvoiceUnit } from '../@types/invoice'
|
||||
|
||||
import { Event, ExpiringEvent, UnidentifiedEvent } from '../@types/event'
|
||||
import { EventExpirationTimeMetadataKey, EventKinds, EventTags } from '../constants/base'
|
||||
import { createLogger } from '../factories/logger-factory'
|
||||
import { EventKinds } from '../constants/base'
|
||||
import { IPaymentsProcessor } from '../@types/clients'
|
||||
import { IPaymentsService } from '../@types/services'
|
||||
import { toBech32 } from '../utils/transform'
|
||||
import { Transaction } from '../database/transaction'
|
||||
import { UnidentifiedEvent } from '../@types/event'
|
||||
|
||||
const debug = createLogger('payments-service')
|
||||
|
||||
@@ -54,7 +53,7 @@ export class PaymentsService implements IPaymentsService {
|
||||
amount: bigint,
|
||||
description: string,
|
||||
): Promise<Invoice> {
|
||||
debug('create invoice for %s for %s: %d', pubkey, amount.toString(), description)
|
||||
debug('create invoice for %s for %s: %s', pubkey, amount.toString(), description)
|
||||
const transaction = new Transaction(this.dbClient)
|
||||
|
||||
try {
|
||||
@@ -220,68 +219,6 @@ export class PaymentsService implements IPaymentsService {
|
||||
}
|
||||
}
|
||||
|
||||
public async sendNewInvoiceNotification(invoice: Invoice): Promise<void> {
|
||||
debug('invoice created notification %s: %o', invoice.id, invoice)
|
||||
const currentSettings = this.settings()
|
||||
|
||||
const {
|
||||
info: {
|
||||
relay_url: relayUrl,
|
||||
name: relayName,
|
||||
},
|
||||
} = currentSettings
|
||||
|
||||
const relayPrivkey = getRelayPrivateKey(relayUrl)
|
||||
const relayPubkey = getPublicKey(relayPrivkey)
|
||||
|
||||
let unit: string = invoice.unit
|
||||
let amount: bigint = invoice.amountRequested
|
||||
if (invoice.unit === InvoiceUnit.MSATS) {
|
||||
amount /= 1000n
|
||||
unit = 'sats'
|
||||
}
|
||||
|
||||
const url = new URL(relayUrl)
|
||||
|
||||
const terms = new URL(relayUrl)
|
||||
terms.protocol = ['https', 'wss'].includes(url.protocol)
|
||||
? 'https'
|
||||
: 'http'
|
||||
terms.pathname += 'terms'
|
||||
|
||||
const unsignedInvoiceEvent: UnidentifiedEvent = {
|
||||
pubkey: relayPubkey,
|
||||
kind: EventKinds.ENCRYPTED_DIRECT_MESSAGE,
|
||||
created_at: Math.floor(invoice.createdAt.getTime() / 1000),
|
||||
content: `From: ${toBech32('npub')(relayPubkey)}@${url.hostname} (${relayName})
|
||||
To: ${toBech32('npub')(invoice.pubkey)}@${url.hostname}
|
||||
🧾 Admission Fee Invoice
|
||||
|
||||
Amount: ${amount.toString()} ${unit}
|
||||
|
||||
⚠️ By paying this invoice, you confirm that you have read and agree to the Terms of Service:
|
||||
${terms.toString()}
|
||||
${invoice.expiresAt ? `
|
||||
⏳ Expires at ${invoice.expiresAt.toISOString()}` : ''}
|
||||
|
||||
${invoice.bolt11}`,
|
||||
tags: [
|
||||
['p', invoice.pubkey],
|
||||
['bolt11', invoice.bolt11],
|
||||
],
|
||||
}
|
||||
|
||||
const persistEvent = this.eventRepository.create.bind(this.eventRepository)
|
||||
|
||||
await pipe(
|
||||
identifyEvent,
|
||||
andThen(encryptKind4Event(relayPrivkey, invoice.pubkey)),
|
||||
andThen(signEvent(relayPrivkey)),
|
||||
andThen(broadcastEvent),
|
||||
andThen(persistEvent),
|
||||
)(unsignedInvoiceEvent)
|
||||
}
|
||||
|
||||
public async sendInvoiceUpdateNotification(invoice: Invoice): Promise<void> {
|
||||
debug('invoice updated notification %s: %o', invoice.id, invoice)
|
||||
const currentSettings = this.settings()
|
||||
@@ -289,7 +226,6 @@ ${invoice.bolt11}`,
|
||||
const {
|
||||
info: {
|
||||
relay_url: relayUrl,
|
||||
name: relayName,
|
||||
},
|
||||
} = currentSettings
|
||||
|
||||
@@ -309,31 +245,36 @@ ${invoice.bolt11}`,
|
||||
unit = InvoiceUnit.SATS
|
||||
}
|
||||
|
||||
const url = new URL(relayUrl)
|
||||
const now = new Date()
|
||||
const expiration = new Date(now.getFullYear(), now.getMonth() + 1, now.getDate())
|
||||
|
||||
const unsignedInvoiceEvent: UnidentifiedEvent = {
|
||||
const unsignedInvoiceEvent: UnidentifiedEvent & Pick<ExpiringEvent, typeof EventExpirationTimeMetadataKey> = {
|
||||
pubkey: relayPubkey,
|
||||
kind: EventKinds.ENCRYPTED_DIRECT_MESSAGE,
|
||||
created_at: Math.floor(invoice.createdAt.getTime() / 1000),
|
||||
content: `🧾 Admission Fee Invoice Paid for ${relayPubkey}@${url.hostname} (${relayName})
|
||||
|
||||
Amount received: ${amount.toString()} ${unit}
|
||||
|
||||
Thanks!`,
|
||||
kind: EventKinds.INVOICE_UPDATE,
|
||||
created_at: Math.floor(now.getTime() / 1000),
|
||||
content: `Invoice paid: ${amount.toString()} ${unit}`,
|
||||
tags: [
|
||||
['p', invoice.pubkey],
|
||||
['c', invoice.id],
|
||||
[EventTags.Pubkey, invoice.pubkey],
|
||||
[EventTags.Invoice, invoice.bolt11],
|
||||
[EventTags.Expiration, Math.floor(expiration.getTime() / 1000).toString()],
|
||||
],
|
||||
[EventExpirationTimeMetadataKey]: expiration.getTime() / 1000,
|
||||
}
|
||||
|
||||
const persistEvent = this.eventRepository.create.bind(this.eventRepository)
|
||||
const persistEvent = async (event: Event) => {
|
||||
await this.eventRepository.create(event)
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
const logError = (error: Error) => console.error('Unable to send notification', error)
|
||||
|
||||
await pipe(
|
||||
identifyEvent,
|
||||
andThen(encryptKind4Event(relayPrivkey, invoice.pubkey)),
|
||||
andThen(signEvent(relayPrivkey)),
|
||||
andThen(broadcastEvent),
|
||||
andThen(persistEvent),
|
||||
andThen(broadcastEvent),
|
||||
otherwise(logError),
|
||||
)(unsignedInvoiceEvent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,15 +181,33 @@ export const identifyEvent = async (event: UnidentifiedEvent): Promise<UnsignedE
|
||||
return { ...event, id }
|
||||
}
|
||||
|
||||
export function getRelayPrivateKey(relayUrl: string): string {
|
||||
if (process.env.RELAY_PRIVATE_KEY) {
|
||||
return process.env.RELAY_PRIVATE_KEY
|
||||
let privateKeyCache: string | undefined
|
||||
export function getRelayPrivateKey(secret?: string): string {
|
||||
if (privateKeyCache) {
|
||||
return privateKeyCache
|
||||
}
|
||||
|
||||
return deriveFromSecret(relayUrl).toString('hex')
|
||||
if (process.env.RELAY_PRIVATE_KEY) {
|
||||
privateKeyCache = process.env.RELAY_PRIVATE_KEY
|
||||
|
||||
return privateKeyCache
|
||||
}
|
||||
|
||||
privateKeyCache = deriveFromSecret(secret).toString('hex')
|
||||
|
||||
return privateKeyCache
|
||||
}
|
||||
|
||||
export const getPublicKey = (privkey: string | Buffer) => Buffer.from(secp256k1.getPublicKey(privkey, true)).subarray(1).toString('hex')
|
||||
const publicKeyCache: Record<string, string> = {}
|
||||
export const getPublicKey = (privkey: string) => {
|
||||
if (privkey in publicKeyCache) {
|
||||
return publicKeyCache[privkey]
|
||||
}
|
||||
|
||||
publicKeyCache[privkey] = secp256k1.utils.bytesToHex(secp256k1.getPublicKey(privkey, true).subarray(1))
|
||||
|
||||
return publicKeyCache[privkey]
|
||||
}
|
||||
|
||||
export const signEvent = (privkey: string | Buffer | undefined) => async (event: UnsignedEvent): Promise<Event> => {
|
||||
const sig = await secp256k1.schnorr.sign(event.id, privkey as any)
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { createHmac } from 'crypto'
|
||||
|
||||
export function deriveFromSecret(purpose: string | Buffer): Buffer {
|
||||
return hmacSha256(process.env.SECRET as string, purpose)
|
||||
if (!process.env.SECRET) {
|
||||
throw new Error('SECRET environment variable not set')
|
||||
}
|
||||
|
||||
return hmacSha256(process.env.SECRET, purpose)
|
||||
}
|
||||
|
||||
export function hmacSha256(secret: string | Buffer, data: string | Buffer): Buffer {
|
||||
return createHmac('sha256', secret)
|
||||
.update(data)
|
||||
.digest()
|
||||
return createHmac('sha256', secret)
|
||||
.update(data)
|
||||
.digest()
|
||||
}
|
||||
|
||||
@@ -17,8 +17,6 @@ export class SlidingWindowRateLimiter implements IRateLimiter {
|
||||
const timestamp = Date.now()
|
||||
const { period } = options
|
||||
|
||||
debug('add %d hits on %s bucket', step, key)
|
||||
|
||||
const [,, entries] = await Promise.all([
|
||||
this.cache.removeRangeByScoreFromSortedSet(key, 0, timestamp - period),
|
||||
this.cache.addToSortedSet(key, { [`${timestamp}:${step}`]: timestamp.toString() }),
|
||||
|
||||
@@ -54,7 +54,7 @@ export async function createEvent(input: Partial<Event>, privkey: any): Promise<
|
||||
}
|
||||
|
||||
export function createIdentity(name: string) {
|
||||
const hmac = createHmac('sha256', process.env.SECRET ?? Math.random().toString())
|
||||
const hmac = createHmac('sha256', Math.random().toString())
|
||||
hmac.update(name)
|
||||
const privkey = hmac.digest().toString('hex')
|
||||
const pubkey = Buffer.from(secp256k1.getPublicKey(privkey, true)).toString('hex').substring(2)
|
||||
|
||||
@@ -72,7 +72,6 @@ Feature: NIP-01
|
||||
And Alice subscribes to text_note events from Bob and set_metadata events from Charlie
|
||||
Then Alice receives 2 events from Bob and Charlie
|
||||
|
||||
@test
|
||||
Scenario: Alice is interested in Bob's events from back in November
|
||||
Given someone called Alice
|
||||
And someone called Bob
|
||||
|
||||
@@ -12,9 +12,10 @@ Feature: NIP-16 Event treatment
|
||||
|
||||
Scenario: Charlie sends an ephemeral event
|
||||
Given someone called Charlie
|
||||
And Charlie subscribes to author Charlie
|
||||
Given someone called Alice
|
||||
And Alice subscribes to author Charlie
|
||||
When Charlie sends a ephemeral_event_0 event with content "now you see me"
|
||||
Then Charlie receives a ephemeral_event_0 event from Charlie with content "now you see me"
|
||||
Then Charlie unsubscribes from author Charlie
|
||||
When Charlie subscribes to author Charlie
|
||||
Then Charlie receives 0 ephemeral_event_0 events and EOSE
|
||||
Then Alice receives a ephemeral_event_0 event from Charlie with content "now you see me"
|
||||
Then Alice unsubscribes from author Charlie
|
||||
When Alice subscribes to author Charlie
|
||||
Then Alice receives 0 ephemeral_event_0 events and EOSE
|
||||
|
||||
@@ -35,6 +35,7 @@ export const streams = new WeakMap<WebSocket, Observable<unknown>>()
|
||||
|
||||
BeforeAll({ timeout: 1000 }, async function () {
|
||||
process.env.RELAY_PORT = '18808'
|
||||
process.env.SECRET = Math.random().toString().repeat(6)
|
||||
cacheClient = getCacheClient()
|
||||
dbClient = getMasterDbClient()
|
||||
rrDbClient = getReadReplicaDbClient()
|
||||
@@ -43,11 +44,12 @@ BeforeAll({ timeout: 1000 }, async function () {
|
||||
const settings = SettingsStatic.createSettings()
|
||||
|
||||
SettingsStatic._settings = pipe(
|
||||
assocPath( ['limits', 'event', 'createdAt', 'maxPositiveDelta'], 0),
|
||||
assocPath( ['limits', 'message', 'rateLimits'], []),
|
||||
assocPath( ['limits', 'event', 'rateLimits'], []),
|
||||
assocPath( ['limits', 'invoice', 'rateLimits'], []),
|
||||
assocPath( ['limits', 'connection', 'rateLimits'], []),
|
||||
assocPath(['payments', 'enabled'], false),
|
||||
assocPath(['limits', 'event', 'createdAt', 'maxPositiveDelta'], 0),
|
||||
assocPath(['limits', 'message', 'rateLimits'], []),
|
||||
assocPath(['limits', 'event', 'rateLimits'], []),
|
||||
assocPath(['limits', 'invoice', 'rateLimits'], []),
|
||||
assocPath(['limits', 'connection', 'rateLimits'], []),
|
||||
)(settings) as any
|
||||
|
||||
worker = workerFactory()
|
||||
@@ -80,11 +82,10 @@ After(async function () {
|
||||
const dbClient = getMasterDbClient()
|
||||
|
||||
await dbClient('events')
|
||||
.where({
|
||||
event_pubkey: Object
|
||||
.whereIn('event_pubkey', Object
|
||||
.values(this.parameters.identities as Record<string, { pubkey: string }>)
|
||||
.map(({ pubkey }) => Buffer.from(pubkey, 'hex')),
|
||||
}).del()
|
||||
).delete()
|
||||
this.parameters.identities = {}
|
||||
})
|
||||
|
||||
@@ -94,14 +95,14 @@ Given(/someone called (\w+)/, async function(name: string) {
|
||||
this.parameters.clients[name] = connection
|
||||
this.parameters.subscriptions[name] = []
|
||||
this.parameters.events[name] = []
|
||||
const subject = new Subject()
|
||||
connection.once('close', subject.next.bind(subject))
|
||||
const close = new Subject()
|
||||
connection.once('close', close.next.bind(close))
|
||||
|
||||
const project = (raw: MessageEvent) => JSON.parse(raw.data.toString('utf8'))
|
||||
const projection = (raw: MessageEvent) => JSON.parse(raw.data.toString('utf8'))
|
||||
|
||||
const replaySubject = new ReplaySubject(2, 1000)
|
||||
|
||||
fromEvent(connection, 'message').pipe(map(project) as any,takeUntil(subject)).subscribe(replaySubject)
|
||||
fromEvent(connection, 'message').pipe(map(projection) as any,takeUntil(close)).subscribe(replaySubject)
|
||||
|
||||
streams.set(
|
||||
connection,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { IncomingEventMessage, MessageType } from '../../../src/@types/messages'
|
||||
import { DelegatedEventMessageHandler } from '../../../src/handlers/delegated-event-message-handler'
|
||||
import { Event } from '../../../src/@types/event'
|
||||
import { EventMessageHandler } from '../../../src/handlers/event-message-handler'
|
||||
import { EventTags } from '../../../src/constants/base'
|
||||
import { IUserRepository } from '../../../src/@types/repositories'
|
||||
import { WebSocketAdapterEvent } from '../../../src/constants/adapter'
|
||||
|
||||
@@ -38,7 +39,7 @@ describe('DelegatedEventMessageHandler', () => {
|
||||
pubkey: 'f'.repeat(64),
|
||||
sig: 'f'.repeat(128),
|
||||
tags: [
|
||||
['delegation', 'delegator', 'rune', 'signature'],
|
||||
[EventTags.Delegation, 'delegator', 'rune', 'signature'],
|
||||
],
|
||||
}
|
||||
})
|
||||
@@ -192,7 +193,7 @@ describe('DelegatedEventMessageHandler', () => {
|
||||
'kind': 1,
|
||||
'tags': [
|
||||
[
|
||||
'delegation',
|
||||
EventTags.Delegation,
|
||||
'86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e',
|
||||
'kind=1&created_at>1640995200',
|
||||
'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1',
|
||||
|
||||
@@ -26,11 +26,17 @@ describe('EventMessageHandler', () => {
|
||||
let event: Event
|
||||
let message: IncomingEventMessage
|
||||
let sandbox: Sinon.SinonSandbox
|
||||
let origEnv: NodeJS.ProcessEnv
|
||||
|
||||
let originalConsoleWarn: (message?: any, ...optionalParams: any[]) => void | undefined = undefined
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = Sinon.createSandbox()
|
||||
origEnv = { ...process.env }
|
||||
process.env = {
|
||||
// deepcode ignore HardcodedNonCryptoSecret/test: <please specify a reason of ignoring this>
|
||||
SECRET: 'changeme',
|
||||
}
|
||||
originalConsoleWarn = console.warn
|
||||
console.warn = () => undefined
|
||||
event = {
|
||||
@@ -45,6 +51,7 @@ describe('EventMessageHandler', () => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.env = origEnv
|
||||
console.warn = originalConsoleWarn
|
||||
sandbox.restore()
|
||||
})
|
||||
@@ -75,7 +82,9 @@ describe('EventMessageHandler', () => {
|
||||
webSocket as any,
|
||||
strategyFactoryStub,
|
||||
userRepository,
|
||||
() => ({}) as any,
|
||||
() => ({
|
||||
info: { relay_url: 'relay_url' },
|
||||
}) as any,
|
||||
() => ({ hit: async () => false })
|
||||
)
|
||||
})
|
||||
@@ -128,7 +137,7 @@ describe('EventMessageHandler', () => {
|
||||
expect(isUserAdmitted).to.have.been.calledWithExactly(event)
|
||||
expect(strategyFactoryStub).not.to.have.been.called
|
||||
})
|
||||
|
||||
|
||||
it('rejects event if it is expired', async () => {
|
||||
isEventValidStub.resolves(undefined)
|
||||
|
||||
@@ -223,6 +232,9 @@ describe('EventMessageHandler', () => {
|
||||
},
|
||||
}
|
||||
settings = {
|
||||
info: {
|
||||
relay_url: 'relay_url',
|
||||
},
|
||||
limits: {
|
||||
event: eventLimits,
|
||||
},
|
||||
@@ -690,6 +702,9 @@ describe('EventMessageHandler', () => {
|
||||
rateLimits: [],
|
||||
}
|
||||
settings = {
|
||||
info: {
|
||||
relay_url: 'relay_url',
|
||||
},
|
||||
limits: {
|
||||
event: eventLimits,
|
||||
},
|
||||
|
||||
@@ -11,7 +11,7 @@ chai.use(sinonChai)
|
||||
|
||||
const { expect } = chai
|
||||
|
||||
import { ContextMetadataKey, EventDeduplicationMetadataKey } from '../../../src/constants/base'
|
||||
import { ContextMetadataKey, EventDeduplicationMetadataKey, EventTags } from '../../../src/constants/base'
|
||||
import { DatabaseClient } from '../../../src/@types/base'
|
||||
import { EventRepository } from '../../../src/repositories/event-repository'
|
||||
|
||||
@@ -383,12 +383,12 @@ describe('EventRepository', () => {
|
||||
kind: 1,
|
||||
tags: [
|
||||
[
|
||||
'p',
|
||||
EventTags.Pubkey,
|
||||
'8355095016fddbe31fcf1453b26f613553e9758cf2263e190eac8fd96a3d3de9',
|
||||
'wss://nostr-pub.wellorder.net',
|
||||
],
|
||||
[
|
||||
'e',
|
||||
EventTags.Event,
|
||||
'7377fa81fc6c7ae7f7f4ef8938d4a603f7bf98183b35ab128235cc92d4bebf96',
|
||||
'wss://nostr-relay.untethr.me',
|
||||
],
|
||||
@@ -417,12 +417,12 @@ describe('EventRepository', () => {
|
||||
kind: 1,
|
||||
tags: [
|
||||
[
|
||||
'p',
|
||||
EventTags.Pubkey,
|
||||
'8355095016fddbe31fcf1453b26f613553e9758cf2263e190eac8fd96a3d3de9',
|
||||
'wss://nostr-pub.wellorder.net',
|
||||
],
|
||||
[
|
||||
'e',
|
||||
EventTags.Event,
|
||||
'7377fa81fc6c7ae7f7f4ef8938d4a603f7bf98183b35ab128235cc92d4bebf96',
|
||||
'wss://nostr-relay.untethr.me',
|
||||
],
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { assocPath, omit, range } from 'ramda'
|
||||
import { assocPath, omit } from 'ramda'
|
||||
import { expect } from 'chai'
|
||||
|
||||
import { Event } from '../../../src/@types/event'
|
||||
import { eventSchema } from '../../../src/schemas/event-schema'
|
||||
import { EventTags } from '../../../src/constants/base'
|
||||
import { validateSchema } from '../../../src/utils/validation'
|
||||
|
||||
describe('NIP-01', () => {
|
||||
@@ -16,31 +17,31 @@ describe('NIP-01', () => {
|
||||
'kind': 7,
|
||||
'tags': [
|
||||
[
|
||||
'e',
|
||||
EventTags.Event,
|
||||
'c58e83bb744e4c29642db7a5c3bd1519516ad5c51f6ba5f90c451d03c1961210',
|
||||
'',
|
||||
'root',
|
||||
],
|
||||
[
|
||||
'e',
|
||||
EventTags.Event,
|
||||
'd0d78967b734628cec7bdfa2321c71c1f1c48e211b4b54333c3b0e94e7e99166',
|
||||
'',
|
||||
'reply',
|
||||
],
|
||||
[
|
||||
'p',
|
||||
EventTags.Pubkey,
|
||||
'edfa27d49d2af37ee331e1225bb6ed1912c6d999281b36d8018ad99bc3573c29',
|
||||
],
|
||||
[
|
||||
'p',
|
||||
EventTags.Pubkey,
|
||||
'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245',
|
||||
],
|
||||
[
|
||||
'e',
|
||||
EventTags.Event,
|
||||
'6fed2aae1e4f7d8b535774e4f7061c10e2ff20df1ef047da09462c7937925cd5',
|
||||
],
|
||||
[
|
||||
'p',
|
||||
EventTags.Pubkey,
|
||||
'2ef93f01cd2493e04235a6b87b10d3c4a74e2a7eb7c3caf168268f6af73314b5',
|
||||
],
|
||||
],
|
||||
@@ -115,7 +116,6 @@ describe('NIP-01', () => {
|
||||
],
|
||||
tag: [
|
||||
{ message: 'must be an array', transform: assocPath(['tags', 0], null) },
|
||||
{ message: 'must contain less than or equal to 10 items', transform: assocPath(['tags', 0], range(0, 11).map(() => 'x')) },
|
||||
],
|
||||
identifier: [
|
||||
{ message: 'must be a string', transform: assocPath(['tags', 0, 0], null) },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { assocPath, range } from 'ramda'
|
||||
import { assocPath } from 'ramda'
|
||||
import { expect } from 'chai'
|
||||
|
||||
import { filterSchema } from '../../../src/schemas/filter-schema'
|
||||
@@ -32,7 +32,6 @@ describe('NIP-01', () => {
|
||||
const cases = {
|
||||
ids: [
|
||||
{ message: 'must be an array', transform: assocPath(['ids'], null) },
|
||||
{ message: 'must contain less than or equal to 1000 items', transform: assocPath(['ids'], range(0, 1001).map(() => 'ffff')) },
|
||||
],
|
||||
prefixOrId: [
|
||||
{ message: 'length must be less than or equal to 64 characters long', transform: assocPath(['ids', 0], 'f'.repeat(65)) },
|
||||
@@ -41,7 +40,6 @@ describe('NIP-01', () => {
|
||||
],
|
||||
authors: [
|
||||
{ message: 'must be an array', transform: assocPath(['authors'], null) },
|
||||
{ message: 'must contain less than or equal to 1000 items', transform: assocPath(['authors'], range(0, 1001).map(() => 'ffff')) },
|
||||
],
|
||||
prefixOrAuthor: [
|
||||
{ message: 'length must be less than or equal to 64 characters long', transform: assocPath(['authors', 0], 'f'.repeat(65)) },
|
||||
@@ -50,7 +48,6 @@ describe('NIP-01', () => {
|
||||
],
|
||||
kinds: [
|
||||
{ message: 'must be an array', transform: assocPath(['kinds'], null) },
|
||||
{ message: 'must contain less than or equal to 20 items', transform: assocPath(['kinds'], range(0, 21).map(() => 1)) },
|
||||
],
|
||||
kind: [
|
||||
{ message: 'must be greater than or equal to 0', transform: assocPath(['kinds', 0], -1) },
|
||||
@@ -73,11 +70,9 @@ describe('NIP-01', () => {
|
||||
{ message: 'must be a number', transform: assocPath(['limit'], null) },
|
||||
{ message: 'must be greater than or equal to 0', transform: assocPath(['limit'], -1) },
|
||||
{ message: 'must be a multiple of 1', transform: assocPath(['limit'], Math.PI) },
|
||||
{ message: 'must be less than or equal to 5000', transform: assocPath(['limit'], 5001) },
|
||||
],
|
||||
'#e': [
|
||||
{ message: 'must be an array', transform: assocPath(['#e'], null) },
|
||||
{ message: 'must contain less than or equal to 256 items', transform: assocPath(['#e'], range(0, 1024 + 1).map(() => 'f')) },
|
||||
],
|
||||
'#e[0]': [
|
||||
{ message: 'length must be less than or equal to 1024 characters long', transform: assocPath(['#e', 0], 'f'.repeat(1024 + 1)) },
|
||||
@@ -85,7 +80,6 @@ describe('NIP-01', () => {
|
||||
],
|
||||
'#p': [
|
||||
{ message: 'must be an array', transform: assocPath(['#p'], null) },
|
||||
{ message: 'must contain less than or equal to 256 items', transform: assocPath(['#p'], range(0, 1024 + 1).map(() => 'f')) },
|
||||
],
|
||||
'#p[0]': [
|
||||
{ message: 'length must be less than or equal to 1024 characters long', transform: assocPath(['#p', 0], 'f'.repeat(1024 + 1)) },
|
||||
@@ -93,7 +87,6 @@ describe('NIP-01', () => {
|
||||
],
|
||||
'#r': [
|
||||
{ message: 'must be an array', transform: assocPath(['#r'], null) },
|
||||
{ message: 'must contain less than or equal to 256 items', transform: assocPath(['#r'], range(0, 1024 + 1).map(() => 'f')) },
|
||||
],
|
||||
'#r[0]': [
|
||||
{ message: 'length must be less than or equal to 1024 characters long', transform: assocPath(['#r', 0], 'f'.repeat(1024 + 1)) },
|
||||
|
||||
Reference in New Issue
Block a user