Add zap utils
This commit is contained in:
Generated
+11
@@ -3322,6 +3322,9 @@
|
|||||||
"name": "@coracle.social/lib",
|
"name": "@coracle.social/lib",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@scure/base": "^1.1.6"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/events": "^3.0.3",
|
"@types/events": "^3.0.3",
|
||||||
"gts": "^5.0.1",
|
"gts": "^5.0.1",
|
||||||
@@ -3329,6 +3332,14 @@
|
|||||||
"typescript": "~5.1.6"
|
"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": {
|
"packages/network": {
|
||||||
"name": "@coracle.social/network",
|
"name": "@coracle.social/network",
|
||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import {bech32, utf8} from "@scure/base"
|
||||||
|
|
||||||
export const now = () => Math.round(Date.now() / 1000)
|
export const now = () => Math.round(Date.now() / 1000)
|
||||||
|
|
||||||
export const nth = (i: number) => <T>(xs: T[]) => xs[i]
|
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 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 identity = <T>(x: T) => x
|
||||||
|
|
||||||
export const between = (low: number, high: number, n: number) => n > low && n < high
|
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 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 isIterable = (x: any) => Symbol.iterator in Object(x)
|
||||||
|
|
||||||
export const toIterable = (x: any) => isIterable(x) ? x : [x]
|
export const toIterable = (x: any) => isIterable(x) ? x : [x]
|
||||||
|
|
||||||
export const stripProtocol = (url: string) => url.replace(/.*:\/\//, "")
|
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
|
// https://github.com/microsoft/TypeScript/issues/4628#issuecomment-1147905253
|
||||||
export type OmitStatics<T, S extends string> =
|
export type OmitStatics<T, S extends string> =
|
||||||
T extends {new(...args: infer A): infer R} ?
|
T extends {new(...args: infer A): infer R} ?
|
||||||
|
|||||||
@@ -20,10 +20,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"pub": "npm run lint && npm run rebuild && npm publish",
|
"pub": "npm run lint && npm run build && npm publish",
|
||||||
"rebuild": "npm run clean && npm run build",
|
"build": "gts clean && tsc-multi",
|
||||||
"build": "tsc-multi",
|
|
||||||
"clean": "gts clean",
|
|
||||||
"lint": "gts lint",
|
"lint": "gts lint",
|
||||||
"fix": "gts fix"
|
"fix": "gts fix"
|
||||||
},
|
},
|
||||||
@@ -32,5 +30,8 @@
|
|||||||
"gts": "^5.0.1",
|
"gts": "^5.0.1",
|
||||||
"tsc-multi": "^1.1.0",
|
"tsc-multi": "^1.1.0",
|
||||||
"typescript": "~5.1.6"
|
"typescript": "~5.1.6"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@scure/base": "^1.1.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"outDir": "build",
|
"outDir": "build",
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"lib": ["es2019", "dom", "dom.iterable"]
|
"lib": ["esnext", "dom", "dom.iterable"]
|
||||||
},
|
},
|
||||||
"include": ["**/*.ts"]
|
"include": ["**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,10 +20,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"pub": "npm run lint && npm run rebuild && npm publish",
|
"pub": "npm run lint && npm run build && npm publish",
|
||||||
"rebuild": "npm run clean && npm run build",
|
"build": "gts clean && tsc-multi",
|
||||||
"build": "tsc-multi",
|
|
||||||
"clean": "gts clean",
|
|
||||||
"lint": "gts lint",
|
"lint": "gts lint",
|
||||||
"fix": "gts fix"
|
"fix": "gts fix"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"outDir": "build",
|
"outDir": "build",
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"lib": ["es2019", "dom"]
|
"lib": ["esnext", "dom"]
|
||||||
},
|
},
|
||||||
"include": ["**/*.ts"]
|
"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[] = []) =>
|
export const addressFromEvent = (e: UnsignedEvent, relays: string[] = []) =>
|
||||||
({kind: e.kind, pubkey: e.pubkey, identifier: getIdentifier(e), relays})
|
({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
|
// Utils
|
||||||
|
|
||||||
export const isGroupAddress = (a: Address) => a.kind === GROUP_DEFINITION
|
export const isGroupAddress = (a: Address) => a.kind === GROUP_DEFINITION
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import {Tags} from './Tags'
|
|||||||
import {addressFromEvent, encodeAddress} from './Address'
|
import {addressFromEvent, encodeAddress} from './Address'
|
||||||
import {isEphemeralKind, isReplaceableKind, isPlainReplaceableKind, isParameterizedReplaceableKind} from './Kinds'
|
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 = {
|
export type CreateEventOpts = {
|
||||||
content?: string
|
content?: string
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import type {Event} from 'nostr-tools'
|
import type {Event} from 'nostr-tools'
|
||||||
import {matchFilter as nostrToolsMatchFilter} 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 = {
|
export type Filter = {
|
||||||
ids?: string[]
|
ids?: string[]
|
||||||
@@ -42,3 +46,111 @@ export const matchFilters = (filters: Filter[], event: Event) => {
|
|||||||
|
|
||||||
return false
|
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 './Relays'
|
||||||
export * from './Router'
|
export * from './Router'
|
||||||
export * from './Tags'
|
export * from './Tags'
|
||||||
|
export * from './Zaps'
|
||||||
|
|||||||
@@ -20,10 +20,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"pub": "npm run lint && npm run rebuild && npm publish",
|
"pub": "npm run lint && npm run build && npm publish",
|
||||||
"rebuild": "npm run clean && npm run build",
|
"build": "gts clean && tsc-multi",
|
||||||
"build": "tsc-multi",
|
|
||||||
"clean": "gts clean",
|
|
||||||
"lint": "gts lint",
|
"lint": "gts lint",
|
||||||
"fix": "gts fix"
|
"fix": "gts fix"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"outDir": "build",
|
"outDir": "build",
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"lib": ["es2019"]
|
"lib": ["esnext"]
|
||||||
},
|
},
|
||||||
"include": ["**/*.ts"]
|
"include": ["**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user