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

@ -1,11 +1,12 @@
/* /*
This file contains functions that deal with encoding and decoding nprofiles, This file contains functions that deal with encoding and decoding nprofiles,
but with he addition of bridge urls in the nprofile. but with he addition of bridge urls in the nprofile.
These functions are basically the same functions from nostr-tools package These functions are basically the same functions from nostr-tools package
but with some tweaks to allow for the bridge inclusion. but with some tweaks to allow for the bridge inclusion.
*/ */
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()
@ -14,7 +15,13 @@ export const utf8Encoder = new TextEncoder()
export type CustomProfilePointer = { export type CustomProfilePointer = {
pubkey: string pubkey: string
relays?: 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 encodeTLV = (tlv: TLV): Uint8Array => {
const entries: Uint8Array[] = [] const entries: Uint8Array[] = []
Object.entries(tlv) Object.entries(tlv)
/* /*
the original function does a reverse() here, the original function does a reverse() here,
but here it causes the nprofile string to be different, but here it causes the nprofile string to be different,
even though it would still decode to the correct original inputs even though it would still decode to the correct original inputs
*/ */
//.reverse() //.reverse()
.forEach(([t, vs]) => { .forEach(([t, vs]) => {
vs.forEach(v => { vs.forEach(v => {
const entry = new Uint8Array(v.length + 2) const entry = new Uint8Array(v.length + 2)
@ -41,16 +48,31 @@ const encodeTLV = (tlv: TLV): Uint8Array => {
entries.push(entry) entries.push(entry)
}) })
}) })
return concatBytes(...entries); return concatBytes(...entries);
} }
export const encodeNprofile = (profile: CustomProfilePointer): string => { export const encodeNprofile = (profile: CustomProfilePointer): string => {
const data = encodeTLV({ const data = encodeTLV({
0: [hexToBytes(profile.pubkey)], 0: [hexToBytes(profile.pubkey)],
1: (profile.relays || []).map(url => utf8Encoder.encode(url)), 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); return bech32.encode("nprofile", words, 5000);
} }
@ -70,19 +92,19 @@ const parseTLV = (data: Uint8Array): TLV => {
} }
export const decodeNprofile = (nprofile: string): CustomProfilePointer => { export const decodeNprofile = (nprofile: string): CustomProfilePointer => {
const { prefix, words } = bech32.decode(nprofile, 5000) const { prefix, words } = bech32.decode(nprofile, 5000)
if (prefix !== "nprofile") { if (prefix !== "nprofile") {
throw new Error ("Expected nprofile prefix"); throw new Error("Expected nprofile prefix");
} }
const data = new Uint8Array(bech32.fromWords(words)) const data = new Uint8Array(bech32.fromWords(words))
const tlv = parseTLV(data); const tlv = parseTLV(data);
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nprofile') 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') if (tlv[0][0].length !== 32) throw new Error('TLV 0 should be 32 bytes')
return { return {
pubkey: bytesToHex(tlv[0][0]), pubkey: bytesToHex(tlv[0][0]),
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [], relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
bridge: tlv[2] ? tlv[2].map(d => utf8Decoder.decode(d)): [] bridge: tlv[2] ? tlv[2].map(d => utf8Decoder.decode(d)) : []
} }
} }

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)