Add zap utils
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import {bech32, utf8} from "@scure/base"
|
||||
|
||||
export const now = () => Math.round(Date.now() / 1000)
|
||||
|
||||
export const nth = (i: number) => <T>(xs: T[]) => xs[i]
|
||||
@@ -6,6 +8,8 @@ export const first = <T>(xs: T[]) => xs[0]
|
||||
|
||||
export const last = <T>(xs: T[]) => xs[xs.length - 1]
|
||||
|
||||
export const prop = (k: string) => <T>(x: Record<string, T>) => x[k]
|
||||
|
||||
export const identity = <T>(x: T) => x
|
||||
|
||||
export const between = (low: number, high: number, n: number) => n > low && n < high
|
||||
@@ -16,12 +20,50 @@ export const uniq = <T>(xs: T[]) => Array.from(new Set(xs))
|
||||
|
||||
export const shuffle = <T>(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 = <T>(f: (x: T) => string, xs: T[]) => {
|
||||
const r: Record<string, T[]> = {}
|
||||
|
||||
for (const x of xs) {
|
||||
const k = f(x)
|
||||
|
||||
if (!r[k]) {
|
||||
r[k] = []
|
||||
}
|
||||
|
||||
r[k].push(x)
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
export const pushToKey = <T>(m: Record<string, T[]> | Map<string, T[]>, 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, S extends string> =
|
||||
T extends {new(...args: infer A): infer R} ?
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"outDir": "build",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": ["es2019", "dom", "dom.iterable"]
|
||||
"lib": ["esnext", "dom", "dom.iterable"]
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"outDir": "build",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": ["es2019", "dom"]
|
||||
"lib": ["esnext", "dom"]
|
||||
},
|
||||
"include": ["**/*.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
|
||||
|
||||
@@ -5,7 +5,9 @@ import {Tags} from './Tags'
|
||||
import {addressFromEvent, encodeAddress} from './Address'
|
||||
import {isEphemeralKind, isReplaceableKind, isPlainReplaceableKind, isParameterizedReplaceableKind} from './Kinds'
|
||||
|
||||
export type Rumor = Pick<Event, 'kind' | 'tags' | 'content' | 'created_at' | 'pubkey' | 'id'>
|
||||
export type Rumor = Pick<Event, 'kind' | 'tags' | 'content' | 'created_at' | 'pubkey' | 'id'> & {
|
||||
wrap?: Event
|
||||
}
|
||||
|
||||
export type CreateEventOpts = {
|
||||
content?: string
|
||||
|
||||
@@ -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<string, any> = {}
|
||||
|
||||
for (const k of Object.keys(group[0])) {
|
||||
if (["since", "until", "limit", "search"].includes(k)) {
|
||||
newFilter[k] = (group[0] as Record<string, any>)[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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -6,3 +6,4 @@ export * from './Links'
|
||||
export * from './Relays'
|
||||
export * from './Router'
|
||||
export * from './Tags'
|
||||
export * from './Zaps'
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"outDir": "build",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": ["es2019"]
|
||||
"lib": ["esnext"]
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user