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