Remove useRef for NWC relay (#1014)

* Remove useRef for NWC relay

* connect to relay for every payment for more reliable payments
* remove getInfoWithRelay method (no longer needed since we no longer use useRef)
* fix 'WebSocket is already in CLOSING or CLOSED state.' errors
* improve logging

* Log connection failures

* Fix no error thrown on validation error
This commit is contained in:
ekzyis 2024-04-04 15:29:39 +02:00 committed by GitHub
parent 9f06692e3e
commit ebcdc21728
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1 changed files with 102 additions and 105 deletions

View File

@ -1,6 +1,6 @@
// https://github.com/getAlby/js-sdk/blob/master/src/webln/NostrWeblnProvider.ts
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
import { createContext, useCallback, useContext, useEffect, useState } from 'react'
import { Relay, finalizeEvent, nip04 } from 'nostr-tools'
import { parseNwcUrl } from '@/lib/url'
import { useWalletLogger } from '../logger'
@ -17,25 +17,83 @@ export function NWCProvider ({ children }) {
const [initialized, setInitialized] = useState(false)
const logger = useWalletLogger('nwc')
const relayRef = useRef()
const name = 'NWC'
const storageKey = 'webln:provider:nwc'
const updateRelay = async (relayUrl) => {
const getInfo = useCallback(async (relayUrl, walletPubkey) => {
logger.info(`requesting info event from ${relayUrl}`)
let relay, sub
try {
if (relayRef.current) {
relayRef.current.close()
logger.info('disconnected from', relayRef.current.url)
}
if (relayUrl) {
relayRef.current = await Relay.connect(relayUrl)
logger.ok(`connected to ${relayUrl}`)
}
} catch (err) {
console.error(err)
relay = await Relay.connect(relayUrl).catch(() => {
// NOTE: passed error is undefined for some reason
const msg = `failed to connect to ${relayUrl}`
logger.error(msg)
throw new Error(msg)
})
logger.ok(`connected to ${relayUrl}`)
return await new Promise((resolve, reject) => {
const timeout = 5000
const timer = setTimeout(() => {
const msg = 'timeout waiting for info event'
logger.error(msg)
reject(new Error(msg))
sub?.close()
}, timeout)
let found = false
sub = relay.subscribe([
{
kinds: [13194],
authors: [walletPubkey]
}
], {
onevent (event) {
clearTimeout(timer)
found = true
logger.ok(`received info event from ${relayUrl}`)
resolve(event)
},
onclose (reason) {
clearTimeout(timer)
if (!['closed by caller', 'relay connection closed by us'].includes(reason)) {
// only log if not closed by us (caller)
const msg = 'connection closed: ' + (reason || 'unknown reason')
logger.error(msg)
reject(new Error(msg))
}
},
oneose () {
clearTimeout(timer)
if (!found) {
const msg = 'EOSE received without info event'
logger.error(msg)
reject(new Error(msg))
}
sub?.close()
}
})
})
} finally {
// For some reason, websocket is already in CLOSING or CLOSED state.
// relay?.close()
if (relay) logger.info(`closed connection to ${relayUrl}`)
}
}
}, [logger])
const validateParams = useCallback(async ({ relayUrl, walletPubkey }) => {
// validate connection by fetching info event
// function needs to throw an error for formik validation to fail
const event = await getInfo(relayUrl, walletPubkey)
const supported = event.content.split(/[\s,]+/) // handle both spaces and commas
logger.info('supported methods:', supported)
if (!supported.includes('pay_invoice')) {
const msg = 'wallet does not support pay_invoice'
logger.error(msg)
throw new Error(msg)
}
logger.ok('wallet supports pay_invoice')
}, [logger])
const loadConfig = useCallback(async () => {
const configStr = window.localStorage.getItem(storageKey)
@ -63,14 +121,10 @@ export function NWCProvider ({ children }) {
`relay=${params.relayUrl}`)
try {
logger.info(`requesting info event from ${params.relayUrl}`)
await validateParams({ ...params, logger })
logger.ok('info event received')
await updateRelay(params.relayUrl)
await validateParams(params)
setEnabled(true)
logger.ok('wallet enabled')
} catch (err) {
logger.error('invalid config:', err)
setEnabled(false)
logger.info('wallet disabled')
throw err
@ -104,19 +158,15 @@ export function NWCProvider ({ children }) {
`relay=${params.relayUrl}`)
try {
logger.info(`requesting info event from ${params.relayUrl}`)
await validateParams({ ...params, logger })
logger.ok('info event received')
await updateRelay(params.relayUrl)
await validateParams(params)
setEnabled(true)
logger.ok('wallet enabled')
} catch (err) {
logger.error('invalid config:', err)
setEnabled(false)
logger.info('wallet disabled')
throw err
}
}, [logger])
}, [validateParams, logger])
const clearConfig = useCallback(() => {
window.localStorage.removeItem(storageKey)
@ -131,41 +181,34 @@ export function NWCProvider ({ children }) {
const inv = lnpr.decode(bolt11)
const hash = inv.tagsObject.payment_hash
logger.info('sending payment:', `payment_hash=${hash}`)
try {
const ret = await 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
let relay, sub
try {
relay = await Relay.connect(relayUrl).catch(() => {
// NOTE: passed error is undefined for some reason
const msg = `failed to connect to ${relayUrl}`
logger.error(msg)
throw new Error(msg)
})
logger.ok(`connected to ${relayUrl}`)
const ret = await new Promise(function (resolve, reject) {
(async function () {
// 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 timeout = 180_000
const timer = setTimeout(() => {
const msg = 'timeout waiting for info event'
logger.error(msg)
reject(new Error(msg))
sub?.close()
}, timeout)
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),
@ -173,30 +216,27 @@ export function NWCProvider ({ children }) {
content
}, secret)
await relay.publish(request)
resetTimer()
const filter = {
kinds: [23195],
authors: [walletPubkey],
'#e': [request.id]
}
const sub = relay.subscribe([filter], {
sub = relay.subscribe([filter], {
async onevent (response) {
resetTimer()
clearTimeout(timer)
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))
sub?.close()
}
})
})().catch(reject)
@ -207,11 +247,13 @@ export function NWCProvider ({ children }) {
} catch (err) {
logger.error('payment failed:', `payment_hash=${hash}`, err.message || err.toString?.())
throw err
} finally {
// For some reason, websocket is already in CLOSING or CLOSED state.
// relay?.close()
if (relay) logger.info(`closed connection to ${relayUrl}`)
}
}, [walletPubkey, secret, logger])
const getInfo = useCallback(() => getInfoWithRelay(relayRef?.current, walletPubkey), [relayRef?.current, walletPubkey])
useEffect(() => {
loadConfig().catch(err => logger.error(err.message || err.toString?.()))
}, [])
@ -227,48 +269,3 @@ export function NWCProvider ({ children }) {
export function useNWC () {
return useContext(NWCContext)
}
async function validateParams ({ relayUrl, walletPubkey, secret, logger }) {
let infoRelay
try {
// validate connection by fetching info event
infoRelay = await Relay.connect(relayUrl)
logger.ok(`connected to ${relayUrl}`)
await getInfoWithRelay(infoRelay, walletPubkey, logger)
} finally {
infoRelay?.close()
logger.info(`closed connection to ${relayUrl}`)
}
}
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 || 'connection closed: reason unknown'))
},
oneose () {
clearTimeout(timer)
reject(new Error('info event not found'))
}
})
})
}