Add zap utils

This commit is contained in:
Jon Staab
2024-03-25 16:52:49 -07:00
parent eb9c778a66
commit 0b5a4e8511
13 changed files with 307 additions and 19 deletions
-3
View File
@@ -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
+3 -1
View File
@@ -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
+112
View File
@@ -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))
+126
View File
@@ -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
}
+1
View File
@@ -6,3 +6,4 @@ export * from './Links'
export * from './Relays'
export * from './Router'
export * from './Tags'
export * from './Zaps'
+2 -4
View File
@@ -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"
},
+1 -1
View File
@@ -5,7 +5,7 @@
"outDir": "build",
"esModuleInterop": true,
"skipLibCheck": true,
"lib": ["es2019"]
"lib": ["esnext"]
},
"include": ["**/*.ts"]
}