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_
- __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

View file

@ -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`)

View file

@ -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 {

View file

@ -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)) : []
}
}

View file

@ -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<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 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 })
}
}

View file

@ -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<Types.Product> {
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 })
}
}

View file

@ -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)