From 0b5a4e8511bde67c8d5689f6b0f6bbbd040f8aa1 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Mon, 25 Mar 2024 16:52:49 -0700 Subject: [PATCH] Add zap utils --- package-lock.json | 11 +++ packages/lib/Tools.ts | 42 +++++++++++ packages/lib/package.json | 9 +-- packages/lib/tsconfig.json | 2 +- packages/network/package.json | 6 +- packages/network/tsconfig.json | 2 +- packages/util/Address.ts | 3 - packages/util/Events.ts | 4 +- packages/util/Filters.ts | 112 +++++++++++++++++++++++++++++ packages/util/Zaps.ts | 126 +++++++++++++++++++++++++++++++++ packages/util/index.ts | 1 + packages/util/package.json | 6 +- packages/util/tsconfig.json | 2 +- 13 files changed, 307 insertions(+), 19 deletions(-) create mode 100644 packages/util/Zaps.ts diff --git a/package-lock.json b/package-lock.json index d845d09..88d6afe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3322,6 +3322,9 @@ "name": "@coracle.social/lib", "version": "0.0.1", "license": "MIT", + "dependencies": { + "@scure/base": "^1.1.6" + }, "devDependencies": { "@types/events": "^3.0.3", "gts": "^5.0.1", @@ -3329,6 +3332,14 @@ "typescript": "~5.1.6" } }, + "packages/lib/node_modules/@scure/base": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.6.tgz", + "integrity": "sha512-ok9AWwhcgYuGG3Zfhyqg+zwl+Wn5uE+dwC0NV/2qQkx4dABbb/bx96vWu8NSj+BNjjSjno+JRYRjle1jV08k3g==", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "packages/network": { "name": "@coracle.social/network", "version": "0.0.2", diff --git a/packages/lib/Tools.ts b/packages/lib/Tools.ts index 0c70627..fcff182 100644 --- a/packages/lib/Tools.ts +++ b/packages/lib/Tools.ts @@ -1,3 +1,5 @@ +import {bech32, utf8} from "@scure/base" + export const now = () => Math.round(Date.now() / 1000) export const nth = (i: number) => (xs: T[]) => xs[i] @@ -6,6 +8,8 @@ export const first = (xs: T[]) => xs[0] export const last = (xs: T[]) => xs[xs.length - 1] +export const prop = (k: string) => (x: Record) => x[k] + export const identity = (x: T) => x export const between = (low: number, high: number, n: number) => n > low && n < high @@ -16,12 +20,50 @@ export const uniq = (xs: T[]) => Array.from(new Set(xs)) export const shuffle = (xs: T[]): T[] => xs.sort(() => Math.random() > 0.5 ? 1 : -1) +export const randomId = (): string => Math.random().toString().slice(2) + export const isIterable = (x: any) => Symbol.iterator in Object(x) export const toIterable = (x: any) => isIterable(x) ? x : [x] export const stripProtocol = (url: string) => url.replace(/.*:\/\//, "") +export const groupBy = (f: (x: T) => string, xs: T[]) => { + const r: Record = {} + + for (const x of xs) { + const k = f(x) + + if (!r[k]) { + r[k] = [] + } + + r[k].push(x) + } + + return r +} + +export const pushToKey = (m: Record | Map, k: string, v: T) => { + if (m instanceof Map) { + const a = m.get(k) || [] + + a.push(v) + m.set(k, a) + } else { + m[k] = m[k] || [] + m[k].push(v) + } + + return m +} + +export const hexToBech32 = (prefix: string, url: string) => + bech32.encode(prefix, bech32.toWords(utf8.decode(url)), false) + +export const bech32ToHex = (b32: string) => + utf8.encode(bech32.fromWords(bech32.decode(b32, false).words)) + // https://github.com/microsoft/TypeScript/issues/4628#issuecomment-1147905253 export type OmitStatics = T extends {new(...args: infer A): infer R} ? diff --git a/packages/lib/package.json b/packages/lib/package.json index d2fbc75..66abdd2 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -20,10 +20,8 @@ } }, "scripts": { - "pub": "npm run lint && npm run rebuild && npm publish", - "rebuild": "npm run clean && npm run build", - "build": "tsc-multi", - "clean": "gts clean", + "pub": "npm run lint && npm run build && npm publish", + "build": "gts clean && tsc-multi", "lint": "gts lint", "fix": "gts fix" }, @@ -32,5 +30,8 @@ "gts": "^5.0.1", "tsc-multi": "^1.1.0", "typescript": "~5.1.6" + }, + "dependencies": { + "@scure/base": "^1.1.6" } } diff --git a/packages/lib/tsconfig.json b/packages/lib/tsconfig.json index b375b08..15d351a 100644 --- a/packages/lib/tsconfig.json +++ b/packages/lib/tsconfig.json @@ -5,7 +5,7 @@ "outDir": "build", "esModuleInterop": true, "skipLibCheck": true, - "lib": ["es2019", "dom", "dom.iterable"] + "lib": ["esnext", "dom", "dom.iterable"] }, "include": ["**/*.ts"] } diff --git a/packages/network/package.json b/packages/network/package.json index a7a260b..0e703f5 100644 --- a/packages/network/package.json +++ b/packages/network/package.json @@ -20,10 +20,8 @@ } }, "scripts": { - "pub": "npm run lint && npm run rebuild && npm publish", - "rebuild": "npm run clean && npm run build", - "build": "tsc-multi", - "clean": "gts clean", + "pub": "npm run lint && npm run build && npm publish", + "build": "gts clean && tsc-multi", "lint": "gts lint", "fix": "gts fix" }, diff --git a/packages/network/tsconfig.json b/packages/network/tsconfig.json index c10e8d2..13631ba 100644 --- a/packages/network/tsconfig.json +++ b/packages/network/tsconfig.json @@ -5,7 +5,7 @@ "outDir": "build", "esModuleInterop": true, "skipLibCheck": true, - "lib": ["es2019", "dom"] + "lib": ["esnext", "dom"] }, "include": ["**/*.ts"] } diff --git a/packages/util/Address.ts b/packages/util/Address.ts index a56d31d..401d89e 100644 --- a/packages/util/Address.ts +++ b/packages/util/Address.ts @@ -49,9 +49,6 @@ export const getIdentifier = (e: UnsignedEvent) => e.tags.find(t => t[0] === "d" export const addressFromEvent = (e: UnsignedEvent, relays: string[] = []) => ({kind: e.kind, pubkey: e.pubkey, identifier: getIdentifier(e), relays}) -export const addressToFilter = (a: Address) => - ({kinds: [a.kind], authors: [a.pubkey], "#d": [a.identifier]}) - // Utils export const isGroupAddress = (a: Address) => a.kind === GROUP_DEFINITION diff --git a/packages/util/Events.ts b/packages/util/Events.ts index efe9e5e..6d41438 100644 --- a/packages/util/Events.ts +++ b/packages/util/Events.ts @@ -5,7 +5,9 @@ import {Tags} from './Tags' import {addressFromEvent, encodeAddress} from './Address' import {isEphemeralKind, isReplaceableKind, isPlainReplaceableKind, isParameterizedReplaceableKind} from './Kinds' -export type Rumor = Pick +export type Rumor = Pick & { + wrap?: Event +} export type CreateEventOpts = { content?: string diff --git a/packages/util/Filters.ts b/packages/util/Filters.ts index 6d097b7..d720bcb 100644 --- a/packages/util/Filters.ts +++ b/packages/util/Filters.ts @@ -1,5 +1,9 @@ import type {Event} from 'nostr-tools' import {matchFilter as nostrToolsMatchFilter} from 'nostr-tools' +import {prop, groupBy, randomId, uniq} from '@coracle.social/lib' +import type {Rumor} from './Events' +import {decodeAddress, addressFromEvent, encodeAddress} from './Address' +import {isReplaceableKind} from './Kinds' export type Filter = { ids?: string[] @@ -42,3 +46,111 @@ export const matchFilters = (filters: Filter[], event: Event) => { return false } + +export const calculateFilterGroup = ({since, until, limit, search, ...filter}: Filter) => { + const group = Object.keys(filter) + + if (since) group.push(`since:${since}`) + if (until) group.push(`until:${until}`) + if (limit) group.push(`limit:${randomId()}`) + if (search) group.push(`search:${search}`) + + return group.sort().join("-") +} + +export const combineFilters = (filters: Filter[]) => { + const result = [] + + for (const group of Object.values(groupBy(calculateFilterGroup, filters))) { + const newFilter: Record = {} + + for (const k of Object.keys(group[0])) { + if (["since", "until", "limit", "search"].includes(k)) { + newFilter[k] = (group[0] as Record)[k] + } else { + newFilter[k] = uniq(group.flatMap(prop(k))) + } + } + + result.push(newFilter as Filter) + } + + return result +} + +export const getIdFilters = (idsOrAddresses: string[]) => { + const ids = [] + const aFilters = [] + + for (const idOrAddress of idsOrAddresses) { + if (idOrAddress.includes(":")) { + const {kind, pubkey, identifier} = decodeAddress(idOrAddress) + + if (identifier) { + aFilters.push({kinds: [kind], authors: [pubkey], "#d": [identifier]}) + } else { + aFilters.push({kinds: [kind], authors: [pubkey]}) + } + } else { + ids.push(idOrAddress) + } + } + + const filters = combineFilters(aFilters) + + if (ids.length > 0) { + filters.push({ids}) + } + + return filters +} + +export const getReplyFilters = (events: Rumor[], filter: Filter) => { + const a = [] + const e = [] + + for (const event of events) { + e.push(event.id) + + if (isReplaceableKind(event.kind)) { + a.push(encodeAddress(addressFromEvent(event))) + } + + if (event.wrap) { + e.push(event.wrap.id) + } + } + + const filters = [] + + if (a.length > 0) { + filters.push({...filter, "#a": a}) + } + + if (e.length > 0) { + filters.push({...filter, "#e": e}) + } + + return filters +} + +export const getFilterGenerality = (filter: Filter) => { + if (filter.ids || filter["#e"] || filter["#a"]) { + return 0 + } + + const hasTags = Object.keys(filter).find((k: string) => k.startsWith("#")) + + if (filter.authors && hasTags) { + return 0.2 + } + + if (filter.authors) { + return Math.min(1, filter.authors.length / 100) + } + + return 1 +} + +export const guessFilterDelta = (filters: Filter[], max = 60 * 60 * 24) => + Math.round(max * Math.max(0.005, 1 - filters.map(getFilterGenerality).reduce((a, b) => a + b) / filters.length)) diff --git a/packages/util/Zaps.ts b/packages/util/Zaps.ts new file mode 100644 index 0000000..81b7175 --- /dev/null +++ b/packages/util/Zaps.ts @@ -0,0 +1,126 @@ +import type {Event} from 'nostr-tools' +import {hexToBech32} from '@coracle.social/lib' +import {Tags} from "./Tags" + +const DIVISORS = { + m: BigInt(1e3), + u: BigInt(1e6), + n: BigInt(1e9), + p: BigInt(1e12), +} + +const MAX_MILLISATS = BigInt("2100000000000000000") + +const MILLISATS_PER_BTC = BigInt(1e11) + +export const hrpToMillisat = (hrpString: string) => { + let divisor, value + if (hrpString.slice(-1).match(/^[munp]$/)) { + divisor = hrpString.slice(-1) + value = hrpString.slice(0, -1) + } else if (hrpString.slice(-1).match(/^[^munp0-9]$/)) { + throw new Error("Not a valid multiplier for the amount") + } else { + value = hrpString + } + + if (!value.match(/^\d+$/)) throw new Error("Not a valid human readable amount") + + const valueBN = BigInt(value) + + const millisatoshisBN = divisor + ? (valueBN * MILLISATS_PER_BTC) / (DIVISORS as any)[divisor] + : valueBN * MILLISATS_PER_BTC + + if ( + (divisor === "p" && !(valueBN % BigInt(10) === BigInt(0))) || + millisatoshisBN > MAX_MILLISATS + ) { + throw new Error("Amount is outside of valid range") + } + + return millisatoshisBN +} + +export const getInvoiceAmount = (bolt11: string) => { + const hrp = bolt11.match(/lnbc(\d+\w)/) + const bn = hrpToMillisat(hrp![1]) + return Number(bn) +} + +export const getLnUrl = (address: string) => { + if (address.startsWith("lnurl1")) { + return address + } + + // If it's a regular url, just encode it + if (address.includes("://")) { + return hexToBech32("lnurl", address) + } + + // Try to parse it as a lud16 address + if (address.includes("@")) { + const [name, domain] = address.split("@") + + if (domain && name) { + return hexToBech32("lnurl", `https://${domain}/.well-known/lnurlp/${name}`) + } + } + + return null +} + +export type Zapper = { + lnurl: string + pubkey: string, + callback: string + minSendable: number + maxSendable: number + nostrPubkey: string + allowsNostr: boolean +} + +export type Zap = { + request: Event + response: Event, + invoiceAmount: number +} + +export const zapFromEvent = (response: Event, zapper: Zapper) => { + const responseMeta = Tags.fromEvent(response).asObject() + + let zap: Zap + try { + zap = { + response, + invoiceAmount: getInvoiceAmount(responseMeta.bolt11), + request: JSON.parse(responseMeta.description), + } + } catch (e) { + return null + } + + // Don't count zaps that the user sent himself + if (zap.request.pubkey === zapper.pubkey) { + return null + } + + const {amount, lnurl} = Tags.fromEvent(zap.request).asObject() + + // Verify that the zapper actually sent the requested amount (if it was supplied) + if (amount && parseInt(amount) !== zap.invoiceAmount) { + return null + } + + // If the sending client provided an lnurl tag, verify that too + if (lnurl && lnurl !== zapper.lnurl) { + return null + } + + // Verify that the request actually came from the recipient's zapper + if (zap.response.pubkey !== zapper.nostrPubkey) { + return null + } + + return zap +} diff --git a/packages/util/index.ts b/packages/util/index.ts index 0a5f99d..a83c323 100644 --- a/packages/util/index.ts +++ b/packages/util/index.ts @@ -6,3 +6,4 @@ export * from './Links' export * from './Relays' export * from './Router' export * from './Tags' +export * from './Zaps' diff --git a/packages/util/package.json b/packages/util/package.json index 11b6473..3957d08 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -20,10 +20,8 @@ } }, "scripts": { - "pub": "npm run lint && npm run rebuild && npm publish", - "rebuild": "npm run clean && npm run build", - "build": "tsc-multi", - "clean": "gts clean", + "pub": "npm run lint && npm run build && npm publish", + "build": "gts clean && tsc-multi", "lint": "gts lint", "fix": "gts fix" }, diff --git a/packages/util/tsconfig.json b/packages/util/tsconfig.json index 526a20b..a6bbc7e 100644 --- a/packages/util/tsconfig.json +++ b/packages/util/tsconfig.json @@ -5,7 +5,7 @@ "outDir": "build", "esModuleInterop": true, "skipLibCheck": true, - "lib": ["es2019"] + "lib": ["esnext"] }, "include": ["**/*.ts"] }