nip69 server

This commit is contained in:
boufni95 2024-08-31 15:04:55 +00:00
parent 8e18e67995
commit 747bce3cf1
9 changed files with 130 additions and 37 deletions

View file

@ -652,6 +652,7 @@ The nostr server will send back a message response, and inside the body there wi
- __identifier__: _string_ - __identifier__: _string_
- __info__: _[UserInfo](#UserInfo)_ - __info__: _[UserInfo](#UserInfo)_
- __max_withdrawable__: _number_ - __max_withdrawable__: _number_
- __noffer__: _string_
### Application ### Application
- __balance__: _number_ - __balance__: _number_
@ -897,6 +898,7 @@ The nostr server will send back a message response, and inside the body there wi
### Product ### Product
- __id__: _string_ - __id__: _string_
- __name__: _string_ - __name__: _string_
- __noffer__: _string_
- __price_sats__: _number_ - __price_sats__: _number_
### RelaysMigration ### RelaysMigration

View file

@ -510,6 +510,7 @@ export type AppUser = {
identifier: string identifier: string
info: UserInfo info: UserInfo
max_withdrawable: number max_withdrawable: number
noffer: string
} }
export const AppUserOptionalFields: [] = [] export const AppUserOptionalFields: [] = []
export type AppUserOptions = OptionsBaseMessage & { export type AppUserOptions = OptionsBaseMessage & {
@ -517,6 +518,7 @@ export type AppUserOptions = OptionsBaseMessage & {
identifier_CustomCheck?: (v: string) => boolean identifier_CustomCheck?: (v: string) => boolean
info_Options?: UserInfoOptions info_Options?: UserInfoOptions
max_withdrawable_CustomCheck?: (v: number) => boolean max_withdrawable_CustomCheck?: (v: number) => boolean
noffer_CustomCheck?: (v: string) => boolean
} }
export const AppUserValidate = (o?: AppUser, opts: AppUserOptions = {}, path: string = 'AppUser::root.'): Error | null => { export const AppUserValidate = (o?: AppUser, opts: AppUserOptions = {}, path: string = 'AppUser::root.'): Error | null => {
if (opts.checkOptionalsAreSet && opts.allOptionalsAreSet) return new Error(path + ': only one of checkOptionalsAreSet or allOptionalNonDefault can be set for each message') if (opts.checkOptionalsAreSet && opts.allOptionalsAreSet) return new Error(path + ': only one of checkOptionalsAreSet or allOptionalNonDefault can be set for each message')
@ -532,6 +534,9 @@ export const AppUserValidate = (o?: AppUser, opts: AppUserOptions = {}, path: st
if (typeof o.max_withdrawable !== 'number') return new Error(`${path}.max_withdrawable: is not a number`) if (typeof o.max_withdrawable !== 'number') return new Error(`${path}.max_withdrawable: is not a number`)
if (opts.max_withdrawable_CustomCheck && !opts.max_withdrawable_CustomCheck(o.max_withdrawable)) return new Error(`${path}.max_withdrawable: custom check failed`) if (opts.max_withdrawable_CustomCheck && !opts.max_withdrawable_CustomCheck(o.max_withdrawable)) return new Error(`${path}.max_withdrawable: custom check failed`)
if (typeof o.noffer !== 'string') return new Error(`${path}.noffer: is not a string`)
if (opts.noffer_CustomCheck && !opts.noffer_CustomCheck(o.noffer)) return new Error(`${path}.noffer: custom check failed`)
return null return null
} }
@ -1977,6 +1982,7 @@ export const PaymentStateValidate = (o?: PaymentState, opts: PaymentStateOptions
export type Product = { export type Product = {
id: string id: string
name: string name: string
noffer: string
price_sats: number price_sats: number
} }
export const ProductOptionalFields: [] = [] export const ProductOptionalFields: [] = []
@ -1984,6 +1990,7 @@ export type ProductOptions = OptionsBaseMessage & {
checkOptionalsAreSet?: [] checkOptionalsAreSet?: []
id_CustomCheck?: (v: string) => boolean id_CustomCheck?: (v: string) => boolean
name_CustomCheck?: (v: string) => boolean name_CustomCheck?: (v: string) => boolean
noffer_CustomCheck?: (v: string) => boolean
price_sats_CustomCheck?: (v: number) => boolean price_sats_CustomCheck?: (v: number) => boolean
} }
export const ProductValidate = (o?: Product, opts: ProductOptions = {}, path: string = 'Product::root.'): Error | null => { export const ProductValidate = (o?: Product, opts: ProductOptions = {}, path: string = 'Product::root.'): Error | null => {
@ -1996,6 +2003,9 @@ export const ProductValidate = (o?: Product, opts: ProductOptions = {}, path: st
if (typeof o.name !== 'string') return new Error(`${path}.name: is not a string`) if (typeof o.name !== 'string') return new Error(`${path}.name: is not a string`)
if (opts.name_CustomCheck && !opts.name_CustomCheck(o.name)) return new Error(`${path}.name: custom check failed`) if (opts.name_CustomCheck && !opts.name_CustomCheck(o.name)) return new Error(`${path}.name: custom check failed`)
if (typeof o.noffer !== 'string') return new Error(`${path}.noffer: is not a string`)
if (opts.noffer_CustomCheck && !opts.noffer_CustomCheck(o.noffer)) return new Error(`${path}.noffer: custom check failed`)
if (typeof o.price_sats !== 'number') return new Error(`${path}.price_sats: is not a number`) if (typeof o.price_sats !== 'number') return new Error(`${path}.price_sats: is not a number`)
if (opts.price_sats_CustomCheck && !opts.price_sats_CustomCheck(o.price_sats)) return new Error(`${path}.price_sats: custom check failed`) if (opts.price_sats_CustomCheck && !opts.price_sats_CustomCheck(o.price_sats)) return new Error(`${path}.price_sats: custom check failed`)

View file

@ -192,6 +192,7 @@ message AppUser {
string identifier = 1; string identifier = 1;
UserInfo info = 2; UserInfo info = 2;
int64 max_withdrawable = 3; int64 max_withdrawable = 3;
string noffer = 4;
} }
message AddAppInvoiceRequest { message AddAppInvoiceRequest {
@ -409,6 +410,7 @@ message Product {
string id = 1; string id = 1;
string name = 2; string name = 2;
int64 price_sats = 3; int64 price_sats = 3;
string noffer = 4;
} }
message GetProductBuyLinkResponse { message GetProductBuyLinkResponse {

View file

@ -6,6 +6,7 @@
*/ */
import { bytesToHex, concatBytes, hexToBytes } from '@noble/hashes/utils'; import { bytesToHex, concatBytes, hexToBytes } from '@noble/hashes/utils';
import { bech32 } from 'bech32'; import { bech32 } from 'bech32';
import { LoadNosrtSettingsFromEnv } from './services/nostr/index.js';
export const utf8Decoder = new TextDecoder('utf-8') export const utf8Decoder = new TextDecoder('utf-8')
export const utf8Encoder = new TextEncoder() export const utf8Encoder = new TextEncoder()
@ -17,6 +18,12 @@ export type CustomProfilePointer = {
bridge?: string[] // one bridge bridge?: string[] // one bridge
} }
export type OfferPointer = {
pubkey: string,
relays?: string[],
offer: string
}
type TLV = { [t: number]: Uint8Array[] } type TLV = { [t: number]: Uint8Array[] }
@ -54,6 +61,21 @@ export const encodeNprofile = (profile: CustomProfilePointer): string => {
return bech32.encode("nprofile", words, 5000); return bech32.encode("nprofile", words, 5000);
} }
export const encodeNoffer = (offer: OfferPointer): string => {
let relays = offer.relays
if (!relays) {
const settings = LoadNosrtSettingsFromEnv()
relays = settings.relays
}
const data = encodeTLV({
0: [hexToBytes(offer.pubkey)],
1: (relays).map(url => utf8Encoder.encode(url)),
2: [hexToBytes(offer.offer)]
});
const words = bech32.toWords(data)
return bech32.encode("nprofile", words, 5000);
}
const parseTLV = (data: Uint8Array): TLV => { const parseTLV = (data: Uint8Array): TLV => {
const result: TLV = {} const result: TLV = {}
let rest = data let rest = data

View file

@ -1,9 +1,12 @@
import Main from "./services/main/index.js" import Main from "./services/main/index.js"
import Nostr from "./services/nostr/index.js" import Nostr from "./services/nostr/index.js"
import { NostrSend, NostrSettings } from "./services/nostr/handler.js" import { NostrEvent, NostrSend, NostrSettings } from "./services/nostr/handler.js"
import * as Types from '../proto/autogenerated/ts/types.js' import * as Types from '../proto/autogenerated/ts/types.js'
import NewNostrTransport, { NostrRequest } from '../proto/autogenerated/ts/nostr_transport.js'; import NewNostrTransport, { NostrRequest } from '../proto/autogenerated/ts/nostr_transport.js';
import { ERROR, getLogger } from "./services/helpers/logger.js"; import { ERROR, getLogger } from "./services/helpers/logger.js";
import { UnsignedEvent } from "./services/nostr/tools/event.js";
import { defaultInvoiceExpiry } from "./services/storage/paymentStorage.js";
import { Application } from "./services/storage/entity/Application.js";
export default (serverMethods: Types.ServerMethods, mainHandler: Main, nostrSettings: NostrSettings, onClientEvent: (e: { requestId: string }, fromPub: string) => void): { Stop: () => void, Send: NostrSend } => { export default (serverMethods: Types.ServerMethods, mainHandler: Main, nostrSettings: NostrSettings, onClientEvent: (e: { requestId: string }, fromPub: string) => void): { Stop: () => void, Send: NostrSend } => {
const log = getLogger({}) const log = getLogger({})
@ -45,6 +48,12 @@ export default (serverMethods: Types.ServerMethods, mainHandler: Main, nostrSett
log(ERROR, "invalid json event received", event.content) log(ERROR, "invalid json event received", event.content)
return return
} }
if (event.kind === 21001) {
const offerReq = j as { offer: string }
handleNofferEvent(mainHandler, offerReq, event)
.then(e => nostr.Send({ type: 'app', appId: event.appId }, { type: 'event', event: e, encrypt: { toPub: event.pub } }))
return
}
if (!j.rpcName) { if (!j.rpcName) {
onClientEvent(j as { requestId: string }, event.pub) onClientEvent(j as { requestId: string }, event.pub)
return return
@ -59,3 +68,41 @@ export default (serverMethods: Types.ServerMethods, mainHandler: Main, nostrSett
}) })
return { Stop: () => nostr.Stop, Send: (...args) => nostr.Send(...args) } return { Stop: () => nostr.Stop, Send: (...args) => nostr.Send(...args) }
} }
// TODO: move this to paymentManager
const handleNofferEvent = async (mainHandler: Main, offerReq: { offer: string }, event: NostrEvent): Promise<UnsignedEvent> => {
const app = await mainHandler.storage.applicationStorage.GetApplication(event.appId)
try {
const offer = offerReq.offer
let invoice: string
const split = offer.split(':')
if (split.length === 1) {
const user = await mainHandler.storage.applicationStorage.GetApplicationUser(app, split[0])
//TODO: add prop def for amount
const userInvoice = await mainHandler.paymentManager.NewInvoice(user.user.user_id, { amountSats: 1000, memo: "free offer" }, { expiry: defaultInvoiceExpiry, linkedApplication: app })
invoice = userInvoice.invoice
} else if (split[0] === 'p') {
const product = await mainHandler.productManager.NewProductInvoice(split[1])
invoice = product.invoice
} else {
return newNofferResponse(JSON.stringify({ code: 1, message: 'Invalid Offer' }), app, event)
}
return newNofferResponse(JSON.stringify({ bolt11: invoice }), app, event)
} catch (e: any) {
getLogger({ component: "noffer" })(ERROR, e.message || e)
return newNofferResponse(JSON.stringify({ code: 1, message: 'Invalid Offer' }), app, event)
}
}
const newNofferResponse = (content: string, app: Application, event: NostrEvent): UnsignedEvent => {
return {
content,
created_at: Math.floor(Date.now() / 1000),
kind: 21001,
pubkey: app.nostr_public_key!,
tags: [
['p', event.pub],
['e', event.id],
],
}
}

View file

@ -8,6 +8,7 @@ import { ApplicationUser } from '../storage/entity/ApplicationUser.js'
import { PubLogger, getLogger } from '../helpers/logger.js' import { PubLogger, getLogger } from '../helpers/logger.js'
import crypto from 'crypto' import crypto from 'crypto'
import { Application } from '../storage/entity/Application.js' import { Application } from '../storage/entity/Application.js'
import { encodeNoffer } from '../../custom-nip19.js'
const TOKEN_EXPIRY_TIME = 2 * 60 * 1000 // 2 minutes, in milliseconds const TOKEN_EXPIRY_TIME = 2 * 60 * 1000 // 2 minutes, in milliseconds
@ -160,6 +161,7 @@ export default class {
service_fee_bps: this.settings.outgoingAppUserInvoiceFeeBps service_fee_bps: this.settings.outgoingAppUserInvoiceFeeBps
}, },
noffer: encodeNoffer({ pubkey: app.nostr_public_key!, offer: u.identifier }),
max_withdrawable: this.paymentManager.GetMaxPayableInvoice(u.user.balance_sats, true) max_withdrawable: this.paymentManager.GetMaxPayableInvoice(u.user.balance_sats, true)
} }
} }
@ -197,7 +199,7 @@ export default class {
network_max_fee_bps: this.settings.lndSettings.feeRateBps, network_max_fee_bps: this.settings.lndSettings.feeRateBps,
network_max_fee_fixed: this.settings.lndSettings.feeFixedLimit, network_max_fee_fixed: this.settings.lndSettings.feeFixedLimit,
service_fee_bps: this.settings.outgoingAppUserInvoiceFeeBps service_fee_bps: this.settings.outgoingAppUserInvoiceFeeBps
} }, noffer: encodeNoffer({ pubkey: app.nostr_public_key!, offer: user.identifier })
} }
} }

View file

@ -5,6 +5,7 @@ import * as Types from '../../../proto/autogenerated/ts/types.js'
import { MainSettings } from './settings.js' import { MainSettings } from './settings.js'
import PaymentManager from './paymentManager.js' import PaymentManager from './paymentManager.js'
import { defaultInvoiceExpiry } from '../storage/paymentStorage.js' import { defaultInvoiceExpiry } from '../storage/paymentStorage.js'
import { encodeNoffer } from '../../custom-nip19.js'
export default class { export default class {
storage: Storage storage: Storage
@ -20,10 +21,12 @@ export default class {
async AddProduct(userId: string, req: Types.AddProductRequest): Promise<Types.Product> { async AddProduct(userId: string, req: Types.AddProductRequest): Promise<Types.Product> {
const user = await this.storage.userStorage.GetUser(userId) const user = await this.storage.userStorage.GetUser(userId)
const newProduct = await this.storage.productStorage.AddProduct(req.name, req.price_sats, user) const newProduct = await this.storage.productStorage.AddProduct(req.name, req.price_sats, user)
const offer = `p:${newProduct.product_id}`
return { return {
id: newProduct.product_id, id: newProduct.product_id,
name: newProduct.name, name: newProduct.name,
price_sats: newProduct.price_sats, price_sats: newProduct.price_sats,
noffer: encodeNoffer({ pubkey: user.user_id, offer: offer })
} }
} }

View file

@ -6,7 +6,7 @@ import { encodeNprofile } from '../../custom-nip19.js'
const handledEvents: string[] = [] // TODO: - big memory leak here, add TTL const handledEvents: string[] = [] // TODO: - big memory leak here, add TTL
type AppInfo = { appId: string, publicKey: string, privateKey: string, name: string } type AppInfo = { appId: string, publicKey: string, privateKey: string, name: string }
type ClientInfo = { clientId: string, publicKey: string, privateKey: string, name: string } type ClientInfo = { clientId: string, publicKey: string, privateKey: string, name: string }
export type SendData = { type: "content", content: string, pub: string } | { type: "event", event: UnsignedEvent } export type SendData = { type: "content", content: string, pub: string } | { type: "event", event: UnsignedEvent, encrypt?: { toPub: string } }
export type SendInitiator = { type: 'app', appId: string } | { type: 'client', clientId: string } export type SendInitiator = { type: 'app', appId: string } | { type: 'client', clientId: string }
export type NostrSend = (initiator: SendInitiator, data: SendData, relays?: string[] | undefined) => void export type NostrSend = (initiator: SendInitiator, data: SendData, relays?: string[] | undefined) => void
@ -22,6 +22,7 @@ export type NostrEvent = {
appId: string appId: string
startAtNano: string startAtNano: string
startAtMs: number startAtMs: number
kind: number
} }
type SettingsRequest = { type SettingsRequest = {
@ -89,7 +90,7 @@ const sendToNostr: NostrSend = (initiator, data, relays) => {
subProcessHandler.Send(initiator, data, relays) subProcessHandler.Send(initiator, data, relays)
} }
send({ type: 'ready' }) send({ type: 'ready' })
const supportedKinds = [21000, 21001]
export default class Handler { export default class Handler {
pool = new SimplePool() pool = new SimplePool()
settings: NostrSettings settings: NostrSettings
@ -132,7 +133,7 @@ export default class Handler {
const sub = relay.sub([ const sub = relay.sub([
{ {
since: Math.ceil(Date.now() / 1000), since: Math.ceil(Date.now() / 1000),
kinds: [21000], kinds: supportedKinds,
'#p': Object.keys(this.apps), '#p': Object.keys(this.apps),
} }
]) ])
@ -140,7 +141,7 @@ export default class Handler {
log("up to date with nostr events") log("up to date with nostr events")
}) })
sub.on('event', async (e) => { sub.on('event', async (e) => {
if (e.kind !== 21000 || !e.pubkey) { if (!supportedKinds.includes(e.kind) || !e.pubkey) {
return return
} }
const pubTags = e.tags.find(tags => tags && tags.length > 1 && tags[0] === 'p') const pubTags = e.tags.find(tags => tags && tags.length > 1 && tags[0] === 'p')
@ -155,7 +156,7 @@ export default class Handler {
}) })
} }
async processEvent(e: Event<21000>, app: AppInfo) { async processEvent(e: Event, app: AppInfo) {
const eventId = e.id const eventId = e.id
if (handledEvents.includes(eventId)) { if (handledEvents.includes(eventId)) {
this.log("event already handled") this.log("event already handled")
@ -166,7 +167,7 @@ export default class Handler {
const startAtNano = process.hrtime.bigint().toString() const startAtNano = process.hrtime.bigint().toString()
const decoded = decodePayload(e.content) const decoded = decodePayload(e.content)
const content = await decryptData(decoded, getSharedSecret(app.privateKey, e.pubkey)) const content = await decryptData(decoded, getSharedSecret(app.privateKey, e.pubkey))
this.eventCallback({ id: eventId, content, pub: e.pubkey, appId: app.appId, startAtNano, startAtMs }) this.eventCallback({ id: eventId, content, pub: e.pubkey, appId: app.appId, startAtNano, startAtMs, kind: e.kind })
} }
async Send(initiator: SendInitiator, data: SendData, relays?: string[]) { async Send(initiator: SendInitiator, data: SendData, relays?: string[]) {
@ -184,6 +185,10 @@ export default class Handler {
} }
} else { } else {
toSign = data.event toSign = data.event
if (data.encrypt) {
const content = await encryptData(data.event.content, getSharedSecret(keys.privateKey, data.encrypt.toPub))
toSign.content = encodePayload(content)
}
} }
const signed = finishEvent(toSign, keys.privateKey) const signed = finishEvent(toSign, keys.privateKey)