stacker.news/components/webln/nwc.js

232 lines
6.6 KiB
JavaScript

// https://github.com/getAlby/js-sdk/blob/master/src/webln/NostrWeblnProvider.ts
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
import { Relay, finalizeEvent, nip04 } from 'nostr-tools'
import { parseNwcUrl } from '../../lib/url'
const NWCContext = createContext()
export function NWCProvider ({ children }) {
const [nwcUrl, setNwcUrl] = useState('')
const [walletPubkey, setWalletPubkey] = useState()
const [relayUrl, setRelayUrl] = useState()
const [secret, setSecret] = useState()
const [enabled, setEnabled] = useState()
const [initialized, setInitialized] = useState(false)
const relayRef = useRef()
const name = 'NWC'
const storageKey = 'webln:provider:nwc'
const updateRelay = async (relayUrl) => {
try {
relayRef.current?.close()
if (relayUrl) relayRef.current = await Relay.connect(relayUrl)
} catch (err) {
console.error(err)
}
}
const loadConfig = useCallback(async () => {
const configStr = window.localStorage.getItem(storageKey)
if (!configStr) {
setEnabled(undefined)
setInitialized(true)
return
}
const config = JSON.parse(configStr)
const { nwcUrl } = config
setNwcUrl(nwcUrl)
const params = parseNwcUrl(nwcUrl)
setRelayUrl(params.relayUrl)
setWalletPubkey(params.walletPubkey)
setSecret(params.secret)
try {
await validateParams(params)
setEnabled(true)
await updateRelay(params.relayUrl)
} catch (err) {
console.error('invalid NWC config:', err)
setEnabled(false)
throw err
} finally {
setInitialized(true)
}
}, [])
const saveConfig = useCallback(async (config) => {
// immediately store config so it's not lost even if config is invalid
const { nwcUrl } = config
setNwcUrl(nwcUrl)
if (!nwcUrl) {
setEnabled(undefined)
return
}
const params = parseNwcUrl(nwcUrl)
setRelayUrl(params.relayUrl)
setWalletPubkey(params.walletPubkey)
setSecret(params.secret)
// XXX Even though NWC allows to configure budget,
// this is definitely not ideal from a security perspective.
window.localStorage.setItem(storageKey, JSON.stringify(config))
try {
await validateParams(params)
setEnabled(true)
await updateRelay(params.relayUrl)
} catch (err) {
console.error('invalid NWC config:', err)
setEnabled(false)
throw err
}
}, [])
const clearConfig = useCallback(() => {
window.localStorage.removeItem(storageKey)
setNwcUrl('')
setRelayUrl(undefined)
setWalletPubkey(undefined)
setSecret(undefined)
setEnabled(undefined)
}, [])
const sendPayment = useCallback((bolt11) => {
return new Promise(function (resolve, reject) {
const relay = relayRef.current
if (!relay) {
return reject(new Error('not connected to relay'))
}
(async function () {
// XXX set this to mock NWC relays
const MOCK_NWC_RELAY = false
// timeout since NWC is async (user needs to confirm payment in wallet)
// timeout is same as invoice expiry
const timeout = MOCK_NWC_RELAY ? 3000 : 180_000
let timer
const resetTimer = () => {
clearTimeout(timer)
timer = setTimeout(() => {
sub?.close()
if (MOCK_NWC_RELAY) {
const heads = Math.random() < 0.5
if (heads) {
return resolve({ preimage: null })
}
return reject(new Error('mock error'))
}
return reject(new Error('timeout'))
}, timeout)
}
if (MOCK_NWC_RELAY) return resetTimer()
const payload = {
method: 'pay_invoice',
params: { invoice: bolt11 }
}
const content = await nip04.encrypt(secret, walletPubkey, JSON.stringify(payload))
const request = finalizeEvent({
kind: 23194,
created_at: Math.floor(Date.now() / 1000),
tags: [['p', walletPubkey]],
content
}, secret)
await relay.publish(request)
resetTimer()
const filter = {
kinds: [23195],
authors: [walletPubkey],
'#e': [request.id]
}
const sub = relay.subscribe([filter], {
async onevent (response) {
resetTimer()
try {
const content = JSON.parse(await nip04.decrypt(secret, walletPubkey, response.content))
if (content.error) return reject(new Error(content.error.message))
if (content.result) return resolve({ preimage: content.result.preimage })
} catch (err) {
return reject(err)
} finally {
clearTimeout(timer)
sub.close()
}
},
onclose (reason) {
clearTimeout(timer)
reject(new Error(reason))
}
})
})().catch(reject)
})
}, [walletPubkey, secret])
const getInfo = useCallback(() => getInfoWithRelay(relayRef?.current, walletPubkey), [relayRef?.current, walletPubkey])
useEffect(() => {
loadConfig().catch(console.error)
}, [])
const value = { name, nwcUrl, relayUrl, walletPubkey, secret, initialized, enabled, saveConfig, clearConfig, getInfo, sendPayment }
return (
<NWCContext.Provider value={value}>
{children}
</NWCContext.Provider>
)
}
export function useNWC () {
return useContext(NWCContext)
}
async function validateParams ({ relayUrl, walletPubkey, secret }) {
let infoRelay
try {
// validate connection by fetching info event
infoRelay = await Relay.connect(relayUrl)
return await getInfoWithRelay(infoRelay, walletPubkey)
} finally {
infoRelay?.close()
}
}
async function getInfoWithRelay (relay, walletPubkey) {
return await new Promise((resolve, reject) => {
const timeout = 5000
const timer = setTimeout(() => {
reject(new Error('timeout waiting for response'))
sub?.close()
}, timeout)
const sub = relay.subscribe([
{
kinds: [13194],
authors: [walletPubkey]
}
], {
onevent (event) {
clearTimeout(timer)
const supported = event.content.split(/[\s,]+/) // handle both spaces and commas
supported.includes('pay_invoice') ? resolve() : reject(new Error('wallet does not support pay_invoice'))
sub.close()
},
onclose (reason) {
clearTimeout(timer)
reject(new Error(reason))
},
oneose () {
clearTimeout(timer)
reject(new Error('info event not found'))
}
})
})
}