diff --git a/proto/autogenerated/client.md b/proto/autogenerated/client.md index 0ff4d1c4..4d8d30c0 100644 --- a/proto/autogenerated/client.md +++ b/proto/autogenerated/client.md @@ -652,6 +652,7 @@ The nostr server will send back a message response, and inside the body there wi - __identifier__: _string_ - __info__: _[UserInfo](#UserInfo)_ - __max_withdrawable__: _number_ + - __noffer__: _string_ ### Application - __balance__: _number_ @@ -897,6 +898,7 @@ The nostr server will send back a message response, and inside the body there wi ### Product - __id__: _string_ - __name__: _string_ + - __noffer__: _string_ - __price_sats__: _number_ ### RelaysMigration diff --git a/proto/autogenerated/ts/types.ts b/proto/autogenerated/ts/types.ts index 329d8c22..87a44181 100644 --- a/proto/autogenerated/ts/types.ts +++ b/proto/autogenerated/ts/types.ts @@ -510,6 +510,7 @@ export type AppUser = { identifier: string info: UserInfo max_withdrawable: number + noffer: string } export const AppUserOptionalFields: [] = [] export type AppUserOptions = OptionsBaseMessage & { @@ -517,6 +518,7 @@ export type AppUserOptions = OptionsBaseMessage & { identifier_CustomCheck?: (v: string) => boolean info_Options?: UserInfoOptions max_withdrawable_CustomCheck?: (v: number) => boolean + noffer_CustomCheck?: (v: string) => boolean } 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') @@ -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 (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 } @@ -1977,6 +1982,7 @@ export const PaymentStateValidate = (o?: PaymentState, opts: PaymentStateOptions export type Product = { id: string name: string + noffer: string price_sats: number } export const ProductOptionalFields: [] = [] @@ -1984,6 +1990,7 @@ export type ProductOptions = OptionsBaseMessage & { checkOptionalsAreSet?: [] id_CustomCheck?: (v: string) => boolean name_CustomCheck?: (v: string) => boolean + noffer_CustomCheck?: (v: string) => boolean price_sats_CustomCheck?: (v: number) => boolean } 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 (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 (opts.price_sats_CustomCheck && !opts.price_sats_CustomCheck(o.price_sats)) return new Error(`${path}.price_sats: custom check failed`) diff --git a/proto/service/structs.proto b/proto/service/structs.proto index 10eb1afd..60dbcc37 100644 --- a/proto/service/structs.proto +++ b/proto/service/structs.proto @@ -192,6 +192,7 @@ message AppUser { string identifier = 1; UserInfo info = 2; int64 max_withdrawable = 3; + string noffer = 4; } message AddAppInvoiceRequest { @@ -409,6 +410,7 @@ message Product { string id = 1; string name = 2; int64 price_sats = 3; + string noffer = 4; } message GetProductBuyLinkResponse { diff --git a/src/custom-nip19.ts b/src/custom-nip19.ts index d9c5a9b0..31eca823 100644 --- a/src/custom-nip19.ts +++ b/src/custom-nip19.ts @@ -1,11 +1,12 @@ /* - This file contains functions that deal with encoding and decoding nprofiles, - but with he addition of bridge urls in the nprofile. - These functions are basically the same functions from nostr-tools package - but with some tweaks to allow for the bridge inclusion. + This file contains functions that deal with encoding and decoding nprofiles, + but with he addition of bridge urls in the nprofile. + These functions are basically the same functions from nostr-tools package + but with some tweaks to allow for the bridge inclusion. */ import { bytesToHex, concatBytes, hexToBytes } from '@noble/hashes/utils'; import { bech32 } from 'bech32'; +import { LoadNosrtSettingsFromEnv } from './services/nostr/index.js'; export const utf8Decoder = new TextDecoder('utf-8') export const utf8Encoder = new TextEncoder() @@ -14,7 +15,13 @@ export const utf8Encoder = new TextEncoder() export type CustomProfilePointer = { pubkey: string relays?: string[] - bridge?: string[] // one bridge + bridge?: string[] // one bridge +} + +export type OfferPointer = { + pubkey: string, + relays?: string[], + offer: string } @@ -23,15 +30,15 @@ type TLV = { [t: number]: Uint8Array[] } const encodeTLV = (tlv: TLV): Uint8Array => { - const entries: Uint8Array[] = [] + const entries: Uint8Array[] = [] Object.entries(tlv) - /* - the original function does a reverse() here, - but here it causes the nprofile string to be different, - even though it would still decode to the correct original inputs - */ - //.reverse() + /* + the original function does a reverse() here, + but here it causes the nprofile string to be different, + even though it would still decode to the correct original inputs + */ + //.reverse() .forEach(([t, vs]) => { vs.forEach(v => { const entry = new Uint8Array(v.length + 2) @@ -41,16 +48,31 @@ const encodeTLV = (tlv: TLV): Uint8Array => { entries.push(entry) }) }) - return concatBytes(...entries); + return concatBytes(...entries); } export const encodeNprofile = (profile: CustomProfilePointer): string => { - const data = encodeTLV({ + const data = encodeTLV({ 0: [hexToBytes(profile.pubkey)], 1: (profile.relays || []).map(url => utf8Encoder.encode(url)), - 2: (profile.bridge || []).map(url => utf8Encoder.encode(url)) + 2: (profile.bridge || []).map(url => utf8Encoder.encode(url)) }); - const words = bech32.toWords(data) + const words = bech32.toWords(data) + 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); } @@ -70,19 +92,19 @@ const parseTLV = (data: Uint8Array): TLV => { } export const decodeNprofile = (nprofile: string): CustomProfilePointer => { - const { prefix, words } = bech32.decode(nprofile, 5000) - if (prefix !== "nprofile") { - throw new Error ("Expected nprofile prefix"); - } + const { prefix, words } = bech32.decode(nprofile, 5000) + if (prefix !== "nprofile") { + throw new Error("Expected nprofile prefix"); + } const data = new Uint8Array(bech32.fromWords(words)) - const tlv = parseTLV(data); - if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nprofile') - if (tlv[0][0].length !== 32) throw new Error('TLV 0 should be 32 bytes') + const tlv = parseTLV(data); + if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nprofile') + if (tlv[0][0].length !== 32) throw new Error('TLV 0 should be 32 bytes') - return { - pubkey: bytesToHex(tlv[0][0]), - relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [], - bridge: tlv[2] ? tlv[2].map(d => utf8Decoder.decode(d)): [] - } + return { + pubkey: bytesToHex(tlv[0][0]), + relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [], + bridge: tlv[2] ? tlv[2].map(d => utf8Decoder.decode(d)) : [] + } } \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 34602ad8..397fe054 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,7 +17,7 @@ const start = async () => { log("manual process ended") return } - + const { apps, mainHandler, liquidityProviderInfo, wizard, adminManager } = keepOn const serverMethods = GetServerMethods(mainHandler) const nostrSettings = LoadNosrtSettingsFromEnv() diff --git a/src/nostrMiddleware.ts b/src/nostrMiddleware.ts index 5bb1b1ec..8ffe86c5 100644 --- a/src/nostrMiddleware.ts +++ b/src/nostrMiddleware.ts @@ -1,9 +1,12 @@ import Main from "./services/main/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 NewNostrTransport, { NostrRequest } from '../proto/autogenerated/ts/nostr_transport.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 } => { const log = getLogger({}) @@ -45,6 +48,12 @@ export default (serverMethods: Types.ServerMethods, mainHandler: Main, nostrSett log(ERROR, "invalid json event received", event.content) 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) { onClientEvent(j as { requestId: string }, event.pub) return @@ -59,3 +68,41 @@ export default (serverMethods: Types.ServerMethods, mainHandler: Main, nostrSett }) 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 => { + 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], + ], + } +} diff --git a/src/services/main/applicationManager.ts b/src/services/main/applicationManager.ts index 49f1d280..d44307e6 100644 --- a/src/services/main/applicationManager.ts +++ b/src/services/main/applicationManager.ts @@ -8,6 +8,7 @@ import { ApplicationUser } from '../storage/entity/ApplicationUser.js' import { PubLogger, getLogger } from '../helpers/logger.js' import crypto from 'crypto' import { Application } from '../storage/entity/Application.js' +import { encodeNoffer } from '../../custom-nip19.js' const TOKEN_EXPIRY_TIME = 2 * 60 * 1000 // 2 minutes, in milliseconds @@ -160,6 +161,7 @@ export default class { 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) } } @@ -197,7 +199,7 @@ export default class { network_max_fee_bps: this.settings.lndSettings.feeRateBps, network_max_fee_fixed: this.settings.lndSettings.feeFixedLimit, service_fee_bps: this.settings.outgoingAppUserInvoiceFeeBps - } + }, noffer: encodeNoffer({ pubkey: app.nostr_public_key!, offer: user.identifier }) } } diff --git a/src/services/main/productManager.ts b/src/services/main/productManager.ts index 38bacfc4..3fd4dd4c 100644 --- a/src/services/main/productManager.ts +++ b/src/services/main/productManager.ts @@ -5,6 +5,7 @@ import * as Types from '../../../proto/autogenerated/ts/types.js' import { MainSettings } from './settings.js' import PaymentManager from './paymentManager.js' import { defaultInvoiceExpiry } from '../storage/paymentStorage.js' +import { encodeNoffer } from '../../custom-nip19.js' export default class { storage: Storage @@ -20,10 +21,12 @@ export default class { async AddProduct(userId: string, req: Types.AddProductRequest): Promise { const user = await this.storage.userStorage.GetUser(userId) const newProduct = await this.storage.productStorage.AddProduct(req.name, req.price_sats, user) + const offer = `p:${newProduct.product_id}` return { id: newProduct.product_id, name: newProduct.name, price_sats: newProduct.price_sats, + noffer: encodeNoffer({ pubkey: user.user_id, offer: offer }) } } diff --git a/src/services/nostr/handler.ts b/src/services/nostr/handler.ts index e3fcdf59..9830cec4 100644 --- a/src/services/nostr/handler.ts +++ b/src/services/nostr/handler.ts @@ -6,7 +6,7 @@ import { encodeNprofile } from '../../custom-nip19.js' const handledEvents: string[] = [] // TODO: - big memory leak here, add TTL type AppInfo = { appId: 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 NostrSend = (initiator: SendInitiator, data: SendData, relays?: string[] | undefined) => void @@ -22,6 +22,7 @@ export type NostrEvent = { appId: string startAtNano: string startAtMs: number + kind: number } type SettingsRequest = { @@ -89,7 +90,7 @@ const sendToNostr: NostrSend = (initiator, data, relays) => { subProcessHandler.Send(initiator, data, relays) } send({ type: 'ready' }) - +const supportedKinds = [21000, 21001] export default class Handler { pool = new SimplePool() settings: NostrSettings @@ -132,7 +133,7 @@ export default class Handler { const sub = relay.sub([ { since: Math.ceil(Date.now() / 1000), - kinds: [21000], + kinds: supportedKinds, '#p': Object.keys(this.apps), } ]) @@ -140,7 +141,7 @@ export default class Handler { log("up to date with nostr events") }) sub.on('event', async (e) => { - if (e.kind !== 21000 || !e.pubkey) { + if (!supportedKinds.includes(e.kind) || !e.pubkey) { return } 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 if (handledEvents.includes(eventId)) { this.log("event already handled") @@ -166,7 +167,7 @@ export default class Handler { const startAtNano = process.hrtime.bigint().toString() const decoded = decodePayload(e.content) 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[]) { @@ -184,6 +185,10 @@ export default class Handler { } } else { 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)