Add zap utils
This commit is contained in:
@@ -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