Merge pull request #727 from shocknet/noffer

nip69 server
This commit is contained in:
Justin (shocknet) 2024-09-07 18:15:55 -04:00 committed by GitHub
commit 9b278cff31
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 206 additions and 46 deletions

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

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, 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: "" })
},
}
}

View file

@ -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],
],
}
}

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, 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<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, priceType: PriceType.fixed, price: newProduct.price_sats, relay: "" })
}
}

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