lightning.service.ts raw

   1  import { SMESH_PUBKEY } from '@/constants'
   2  import { getZapInfoFromEvent } from '@/lib/event-metadata'
   3  import { TProfile } from '@/types'
   4  import { init, launchPaymentModal } from '@getalby/bitcoin-connect-react'
   5  import { Invoice } from '@getalby/lightning-tools'
   6  import { bech32 } from '@scure/base'
   7  import { WebLNProvider } from '@webbtc/webln-types'
   8  import dayjs from 'dayjs'
   9  import { Filter, kinds, NostrEvent } from 'nostr-tools'
  10  import { SubCloser } from 'nostr-tools/abstract-pool'
  11  import { makeZapRequest } from 'nostr-tools/nip57'
  12  import { utf8Decoder } from 'nostr-tools/utils'
  13  import client from './client.service'
  14  
  15  export type TRecentSupporter = { pubkey: string; amount: number; comment?: string }
  16  
  17  const OFFICIAL_PUBKEYS = [SMESH_PUBKEY]
  18  
  19  class LightningService {
  20    static instance: LightningService
  21    provider: WebLNProvider | null = null
  22    private recentSupportersCache: TRecentSupporter[] | null = null
  23    private initialized = false
  24  
  25    constructor() {
  26      if (!LightningService.instance) {
  27        LightningService.instance = this
  28      }
  29      return LightningService.instance
  30    }
  31  
  32    /**
  33     * Initialize bitcoin-connect. Call this AFTER setting up onConnected/onDisconnected listeners
  34     * to avoid race conditions with auto-reconnect.
  35     */
  36    initBitcoinConnect() {
  37      if (this.initialized) return
  38      this.initialized = true
  39      init({
  40        appName: 'Smesh',
  41        showBalance: false
  42      })
  43    }
  44  
  45    async zap(
  46      sender: string,
  47      recipientOrEvent: string | NostrEvent,
  48      sats: number,
  49      comment: string,
  50      closeOuterModel?: () => void
  51    ): Promise<{ preimage: string; invoice: string } | null> {
  52      if (!client.signer) {
  53        throw new Error('You need to be logged in to zap')
  54      }
  55      const { recipient, event } =
  56        typeof recipientOrEvent === 'string'
  57          ? { recipient: recipientOrEvent }
  58          : { recipient: recipientOrEvent.pubkey, event: recipientOrEvent }
  59  
  60      const [profile, receiptRelayList, senderRelayList] = await Promise.all([
  61        client.fetchProfile(recipient),
  62        client.fetchRelayList(recipient),
  63        sender
  64          ? client.fetchRelayList(sender)
  65          : Promise.resolve({ read: client.currentRelays, write: client.currentRelays })
  66      ])
  67      if (!profile) {
  68        throw new Error('Recipient not found')
  69      }
  70      const zapEndpoint = await this.getZapEndpoint(profile)
  71      if (!zapEndpoint) {
  72        throw new Error("Recipient's lightning address is invalid")
  73      }
  74      const { callback, lnurl } = zapEndpoint
  75      const amount = sats * 1000
  76      const zapRequestDraft = makeZapRequest({
  77        ...(event ? { event } : { pubkey: recipient }),
  78        amount,
  79        relays: receiptRelayList.read
  80          .slice(0, 4)
  81          .concat(senderRelayList.write.slice(0, 3))
  82          .concat(client.currentRelays),
  83        comment
  84      })
  85      const zapRequest = await client.signer.signEvent(zapRequestDraft)
  86  
  87      const zapRequestUrl = new URL(callback)
  88      zapRequestUrl.searchParams.append('amount', amount.toString())
  89      zapRequestUrl.searchParams.append('nostr', JSON.stringify(zapRequest))
  90      zapRequestUrl.searchParams.append('lnurl', lnurl)
  91  
  92      const zapRequestRes = await fetch(zapRequestUrl.toString())
  93      const zapRequestResBody = await zapRequestRes.json()
  94      if (zapRequestResBody.error) {
  95        throw new Error(zapRequestResBody.message)
  96      }
  97      const { pr, verify, reason } = zapRequestResBody
  98      if (!pr) {
  99        throw new Error(reason ?? 'Failed to create invoice')
 100      }
 101  
 102      if (this.provider) {
 103        const { preimage } = await this.provider.sendPayment(pr)
 104        closeOuterModel?.()
 105        return { preimage, invoice: pr }
 106      }
 107  
 108      return new Promise((resolve) => {
 109        closeOuterModel?.()
 110        let checkPaymentInterval: ReturnType<typeof setInterval> | undefined
 111        let subCloser: SubCloser | undefined
 112        const { setPaid } = launchPaymentModal({
 113          invoice: pr,
 114          onPaid: (response) => {
 115            clearInterval(checkPaymentInterval)
 116            subCloser?.close()
 117            resolve({ preimage: response.preimage, invoice: pr })
 118          },
 119          onCancelled: () => {
 120            clearInterval(checkPaymentInterval)
 121            subCloser?.close()
 122            resolve(null)
 123          }
 124        })
 125  
 126        if (verify) {
 127          checkPaymentInterval = setInterval(async () => {
 128            const invoice = new Invoice({ pr, verify })
 129            const paid = await invoice.verifyPayment()
 130  
 131            if (paid && invoice.preimage) {
 132              setPaid({
 133                preimage: invoice.preimage
 134              })
 135            }
 136          }, 1000)
 137        } else {
 138          const filter: Filter = {
 139            kinds: [kinds.Zap],
 140            '#p': [recipient],
 141            since: dayjs().subtract(1, 'minute').unix()
 142          }
 143          if (event) {
 144            filter['#e'] = [event.id]
 145          }
 146          subCloser = client.subscribe(
 147            senderRelayList.write.concat(client.currentRelays).slice(0, 4),
 148            filter,
 149            {
 150              onevent: (evt) => {
 151                const info = getZapInfoFromEvent(evt)
 152                if (!info) return
 153  
 154                if (info.invoice === pr) {
 155                  setPaid({ preimage: info.preimage ?? '' })
 156                }
 157              }
 158            }
 159          )
 160        }
 161      })
 162    }
 163  
 164    async payInvoice(
 165      invoice: string,
 166      closeOuterModel?: () => void
 167    ): Promise<{ preimage: string; invoice: string } | null> {
 168      if (this.provider) {
 169        const { preimage } = await this.provider.sendPayment(invoice)
 170        closeOuterModel?.()
 171        return { preimage, invoice: invoice }
 172      }
 173  
 174      return new Promise((resolve) => {
 175        closeOuterModel?.()
 176        launchPaymentModal({
 177          invoice: invoice,
 178          onPaid: (response) => {
 179            resolve({ preimage: response.preimage, invoice: invoice })
 180          },
 181          onCancelled: () => {
 182            resolve(null)
 183          }
 184        })
 185      })
 186    }
 187  
 188    async fetchRecentSupporters() {
 189      if (this.recentSupportersCache) {
 190        return this.recentSupportersCache
 191      }
 192      const relayList = await client.fetchRelayList(SMESH_PUBKEY)
 193      const events = await client.fetchEvents(relayList.read.slice(0, 4), {
 194        kinds: [kinds.Zap],
 195        '#p': OFFICIAL_PUBKEYS,
 196        since: dayjs().subtract(1, 'month').unix()
 197      })
 198      events.sort((a, b) => b.created_at - a.created_at)
 199      const map = new Map<string, { pubkey: string; amount: number; comment?: string }>()
 200      events.forEach((event) => {
 201        const info = getZapInfoFromEvent(event)
 202        if (!info || !info.senderPubkey || OFFICIAL_PUBKEYS.includes(info.senderPubkey)) return
 203  
 204        const { amount, comment, senderPubkey } = info
 205        const item = map.get(senderPubkey)
 206        if (!item) {
 207          map.set(senderPubkey, { pubkey: senderPubkey, amount, comment })
 208        } else {
 209          item.amount += amount
 210          if (!item.comment && comment) item.comment = comment
 211        }
 212      })
 213      this.recentSupportersCache = Array.from(map.values())
 214        .filter((item) => item.amount >= 1000)
 215        .sort((a, b) => b.amount - a.amount)
 216      return this.recentSupportersCache
 217    }
 218  
 219    private async getZapEndpoint(profile: TProfile): Promise<null | {
 220      callback: string
 221      lnurl: string
 222    }> {
 223      try {
 224        let lnurl: string = ''
 225  
 226        // Some clients have incorrectly filled in the positions for lud06 and lud16
 227        if (!profile.lightningAddress) {
 228          console.warn('Profile has no lightning address', profile)
 229          return null
 230        }
 231  
 232        if (profile.lightningAddress.includes('@')) {
 233          const [name, domain] = profile.lightningAddress.split('@')
 234          lnurl = new URL(`/.well-known/lnurlp/${name}`, `https://${domain}`).toString()
 235        } else {
 236          const { words } = bech32.decode(profile.lightningAddress as any, 1000)
 237          const data = bech32.fromWords(words)
 238          lnurl = utf8Decoder.decode(data)
 239        }
 240  
 241        const res = await fetch(lnurl)
 242        const body = await res.json()
 243  
 244        console.log('Zap endpoint:', body)
 245        if (body.allowsNostr !== false && body.callback) {
 246          return {
 247            callback: body.callback,
 248            lnurl
 249          }
 250        }
 251      } catch (err) {
 252        console.error(err)
 253      }
 254  
 255      return null
 256    }
 257  }
 258  
 259  const instance = new LightningService()
 260  export default instance
 261