diff --git a/proto/autogenerated/client.md b/proto/autogenerated/client.md index 0ff4d1c4..6884a4cb 100644 --- a/proto/autogenerated/client.md +++ b/proto/autogenerated/client.md @@ -897,6 +897,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 @@ -964,6 +965,7 @@ The nostr server will send back a message response, and inside the body there wi - __max_withdrawable__: _number_ - __network_max_fee_bps__: _number_ - __network_max_fee_fixed__: _number_ + - __noffer__: _string_ - __service_fee_bps__: _number_ - __userId__: _string_ - __user_identifier__: _string_ diff --git a/proto/autogenerated/ts/types.ts b/proto/autogenerated/ts/types.ts index 329d8c22..d3c2c131 100644 --- a/proto/autogenerated/ts/types.ts +++ b/proto/autogenerated/ts/types.ts @@ -1977,6 +1977,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 +1985,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 +1998,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`) @@ -2351,6 +2356,7 @@ export type UserInfo = { max_withdrawable: number network_max_fee_bps: number network_max_fee_fixed: number + noffer: string service_fee_bps: number userId: string user_identifier: string @@ -2362,6 +2368,7 @@ export type UserInfoOptions = OptionsBaseMessage & { max_withdrawable_CustomCheck?: (v: number) => boolean network_max_fee_bps_CustomCheck?: (v: number) => boolean network_max_fee_fixed_CustomCheck?: (v: number) => boolean + noffer_CustomCheck?: (v: string) => boolean service_fee_bps_CustomCheck?: (v: number) => boolean userId_CustomCheck?: (v: string) => boolean user_identifier_CustomCheck?: (v: string) => boolean @@ -2382,6 +2389,9 @@ export const UserInfoValidate = (o?: UserInfo, opts: UserInfoOptions = {}, path: if (typeof o.network_max_fee_fixed !== 'number') return new Error(`${path}.network_max_fee_fixed: is not a number`) if (opts.network_max_fee_fixed_CustomCheck && !opts.network_max_fee_fixed_CustomCheck(o.network_max_fee_fixed)) return new Error(`${path}.network_max_fee_fixed: 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.service_fee_bps !== 'number') return new Error(`${path}.service_fee_bps: is not a number`) if (opts.service_fee_bps_CustomCheck && !opts.service_fee_bps_CustomCheck(o.service_fee_bps)) return new Error(`${path}.service_fee_bps: custom check failed`) diff --git a/proto/service/structs.proto b/proto/service/structs.proto index 10eb1afd..bedb45bf 100644 --- a/proto/service/structs.proto +++ b/proto/service/structs.proto @@ -353,6 +353,7 @@ message UserInfo{ int64 service_fee_bps = 5; int64 network_max_fee_bps = 6; int64 network_max_fee_fixed = 7; + string noffer = 8; } message GetUserOperationsRequest{ @@ -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..baf2f8e5 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,24 +15,36 @@ 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, + relay: string, + offer: string + priceType: PriceType, + price?: number +} +export enum PriceType { + fixed = 0, + variable = 1, + spontaneous = 2, +} 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,19 +54,39 @@ 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 relay = offer.relay + if (!relay) { + const settings = LoadNosrtSettingsFromEnv() + relay = settings.relays[0] + } + const o: TLV = { + 0: [hexToBytes(offer.pubkey)], + 1: [utf8Encoder.encode(relay)], + 2: [utf8Encoder.encode(offer.offer)], + 3: [new Uint8Array([Number(offer.priceType)])], + } + if (offer.price) { + o[4] = [new Uint8Array(new BigUint64Array([BigInt(offer.price)]).buffer)] + } + const data = encodeTLV(o); + const words = bech32.toWords(data) + return bech32.encode("noffer", words, 5000); +} + const parseTLV = (data: Uint8Array): TLV => { const result: TLV = {} let rest = data @@ -69,20 +102,42 @@ const parseTLV = (data: Uint8Array): TLV => { return result } -export const decodeNprofile = (nprofile: string): CustomProfilePointer => { - const { prefix, words } = bech32.decode(nprofile, 5000) - if (prefix !== "nprofile") { - throw new Error ("Expected nprofile prefix"); - } +export const decodeNoffer = (noffer: string): OfferPointer => { + const { prefix, words } = bech32.decode(noffer, 5000) + if (prefix !== "noffer") { + 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 noffer') + if (tlv[0][0].length !== 32) throw new Error('TLV 0 should be 32 bytes') + if (!tlv[1]?.[0]) throw new Error('missing TLV 1 for noffer') + if (!tlv[2]?.[0]) throw new Error('missing TLV 2 for noffer') + if (!tlv[3]?.[0]) throw new Error('missing TLV 3 for noffer') + return { + pubkey: bytesToHex(tlv[0][0]), + relay: utf8Decoder.decode(tlv[1][0]), + offer: utf8Decoder.decode(tlv[2][0]), + priceType: tlv[3][0][0], + price: tlv[4] ? Number(new BigUint64Array(tlv[4][0])[0]) : undefined + } +} - 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 +export const decodeNprofile = (nprofile: string): CustomProfilePointer => { + 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') + + 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)) : [] + } +} 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..90579908 100644 --- a/src/nostrMiddleware.ts +++ b/src/nostrMiddleware.ts @@ -1,9 +1,12 @@ -import Main from "./services/main/index.js" +import Main, { NofferData } 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,11 @@ 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 NofferData + mainHandler.handleNip69Noffer(offerReq, event) + return + } if (!j.rpcName) { onClientEvent(j as { requestId: string }, event.pub) return @@ -59,3 +67,5 @@ export default (serverMethods: Types.ServerMethods, mainHandler: Main, nostrSett }) return { Stop: () => nostr.Stop, Send: (...args) => nostr.Send(...args) } } + + diff --git a/src/services/main/appUserManager.ts b/src/services/main/appUserManager.ts index 6da520ae..1909be9d 100644 --- a/src/services/main/appUserManager.ts +++ b/src/services/main/appUserManager.ts @@ -4,6 +4,7 @@ import * as Types from '../../../proto/autogenerated/ts/types.js' import { MainSettings } from './settings.js' import ApplicationManager from './applicationManager.js' +import { encodeNoffer, PriceType } from '../../custom-nip19.js' export default class { storage: Storage settings: MainSettings @@ -59,7 +60,8 @@ export default class { user_identifier: appUser.identifier, network_max_fee_bps: this.settings.lndSettings.feeRateBps, 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: appUser.identifier, priceType: PriceType.spontaneous, relay: "" }) } } diff --git a/src/services/main/applicationManager.ts b/src/services/main/applicationManager.ts index e2ebad2a..c134a3df 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, PriceType } from '../../custom-nip19.js' const TOKEN_EXPIRY_TIME = 2 * 60 * 1000 // 2 minutes, in milliseconds @@ -157,7 +158,8 @@ export default class { user_identifier: u.identifier, network_max_fee_bps: this.settings.lndSettings.feeRateBps, 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: u.identifier, priceType: PriceType.spontaneous, relay: "" }) }, max_withdrawable: this.paymentManager.GetMaxPayableInvoice(u.user.balance_sats, true) @@ -196,8 +198,9 @@ export default class { user_identifier: user.identifier, network_max_fee_bps: this.settings.lndSettings.feeRateBps, 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, priceType: PriceType.spontaneous, relay: "" }) + }, } } diff --git a/src/services/main/index.ts b/src/services/main/index.ts index 2a8de39e..aba85c05 100644 --- a/src/services/main/index.ts +++ b/src/services/main/index.ts @@ -12,7 +12,7 @@ import AppUserManager from "./appUserManager.js" import { Application } from '../storage/entity/Application.js' import { UserReceivingInvoice } from '../storage/entity/UserReceivingInvoice.js' import { UnsignedEvent } from '../nostr/tools/event.js' -import { NostrSend } from '../nostr/handler.js' +import { NostrEvent, NostrSend } from '../nostr/handler.js' import MetricsManager from '../metrics/index.js' import { LoggedEvent } from '../storage/eventsLog.js' import { LiquidityProvider } from "./liquidityProvider.js" @@ -21,6 +21,7 @@ import { Utils } from "../helpers/utilsWrapper.js" import { RugPullTracker } from "./rugPullTracker.js" import { AdminManager } from "./adminManager.js" import { Unlocker } from "./unlocker.js" +import { defaultInvoiceExpiry } from "../storage/paymentStorage.js" type UserOperationsSub = { id: string @@ -30,6 +31,7 @@ type UserOperationsSub = { newOutgoingTx: (operation: Types.UserOperation) => void } const appTag = "Lightning.Pub" +export type NofferData = { offer: string, amount?: number } export default class { storage: Storage lnd: LND @@ -46,9 +48,9 @@ export default class { liquidityProvider: LiquidityProvider utils: Utils rugPullTracker: RugPullTracker - unlocker:Unlocker + unlocker: Unlocker nostrSend: NostrSend = () => { getLogger({})("nostr send not initialized yet") } - constructor(settings: MainSettings, storage: Storage, adminManager: AdminManager, utils: Utils,unlocker:Unlocker) { + constructor(settings: MainSettings, storage: Storage, adminManager: AdminManager, utils: Utils, unlocker: Unlocker) { this.settings = settings this.storage = storage this.utils = utils @@ -272,4 +274,67 @@ export default class { log({ unsigned: event }) this.nostrSend({ type: 'app', appId: invoice.linkedApplication.app_id }, { type: 'event', event }) } + + async getNofferInvoice(offerReq: NofferData, appId: string): Promise<{ success: true, invoice: string } | { success: false, code: number, max: number }> { + try { + const { remote } = await this.lnd.ChannelBalance() + const { offer, amount } = offerReq + const split = offer.split(':') + if (split.length === 1) { + if (!amount || isNaN(amount) || amount < 10 || amount > remote) { + return { success: false, code: 5, max: remote } + } + const res = await this.applicationManager.AddAppUserInvoice(appId, { + http_callback_url: "", payer_identifier: split[0], receiver_identifier: split[0], + invoice_req: { amountSats: amount, memo: "free offer" } + }) + return { success: true, invoice: res.invoice } + } else if (split[0] === 'p') { + const product = await this.productManager.NewProductInvoice(split[1]) + return { success: true, invoice: product.invoice } + } else { + return { success: false, code: 1, max: remote } + } + } catch (e: any) { + getLogger({ component: "noffer" })(ERROR, e.message || e) + return { success: false, code: 1, max: 0 } + } + } + + async handleNip69Noffer(offerReq: NofferData, event: NostrEvent) { + const offerInvoice = await this.getNofferInvoice(offerReq, event.appId) + if (!offerInvoice.success) { + const code = offerInvoice.code + const e = newNofferResponse(JSON.stringify({ code, error: codeToMessage(code), range: { min: 10, max: offerInvoice.max } }), event) + this.nostrSend({ type: 'app', appId: event.appId }, { type: 'event', event: e, encrypt: { toPub: event.pub } }) + return + } + const e = newNofferResponse(JSON.stringify({ bolt11: offerInvoice.invoice }), event) + this.nostrSend({ type: 'app', appId: event.appId }, { type: 'event', event: e, encrypt: { toPub: event.pub } }) + return + } } + +const codeToMessage = (code: number) => { + switch (code) { + case 1: return 'Invalid Offer' + case 2: return 'Temporary Failure' + case 3: return 'Expired Offer' + case 4: return 'Unsupported Feature' + case 5: return 'Invalid Amount' + default: throw new Error("unknown error code" + code) + } +} + +const newNofferResponse = (content: string, event: NostrEvent): UnsignedEvent => { + return { + content, + created_at: Math.floor(Date.now() / 1000), + kind: 21001, + pubkey: "", + tags: [ + ['p', event.pub], + ['e', event.id], + ], + } +} \ No newline at end of file diff --git a/src/services/main/productManager.ts b/src/services/main/productManager.ts index 38bacfc4..1b8320a7 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, PriceType } 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, priceType: PriceType.fixed, price: newProduct.price_sats, relay: "" }) } } diff --git a/src/services/nostr/handler.ts b/src/services/nostr/handler.ts index e3fcdf59..e38dda2b 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,13 @@ 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) + } + if (!toSign.pubkey) { + toSign.pubkey = keys.publicKey + } } const signed = finishEvent(toSign, keys.privateKey)