ekzyis b3d485e8c4
Refactor default payment method setting (#803)
* Refactor setting of default providers

* fixed warning about component update while rendering another component
* individual providers no longer need to know if they are the default or not
* default setting is now handled by WebLNContext -- the same context that returns the provider. this makes a lot more sense and is a lot easier to read
* default payment checkbox is now also disabled if there is only one enabled provider or if it is the default provider

* Fix order lost on page reload

On page reload, the providers were synced in the order they were loaded.

This means that the default payment provider setting was lost.

Fixed this by syncing order to local storage and on page reload, only syncing providers when they were initialized (else the order would have been lost again).
2024-02-09 09:42:26 -06:00

250 lines
6.9 KiB
JavaScript

// https://github.com/getAlby/js-sdk/blob/master/src/webln/NostrWeblnProvider.ts
import { createContext, useCallback, useContext, useEffect, useState } from 'react'
import { Relay, finalizeEvent, nip04 } from 'nostr-tools'
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 [relay, setRelay] = useState()
const name = 'NWC'
const storageKey = 'webln:provider:nwc'
const loadConfig = useCallback(async () => {
const configStr = window.localStorage.getItem(storageKey)
if (!configStr) {
setEnabled(undefined)
return
}
const config = JSON.parse(configStr)
const { nwcUrl } = config
setNwcUrl(nwcUrl)
const params = parseWalletConnectUrl(nwcUrl)
setRelayUrl(params.relayUrl)
setWalletPubkey(params.walletPubkey)
setSecret(params.secret)
try {
const supported = await validateParams(params)
setEnabled(supported.includes('pay_invoice'))
} 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 = parseWalletConnectUrl(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 {
const supported = await validateParams(params)
setEnabled(supported.includes('pay_invoice'))
} 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)
}, [])
useEffect(() => {
let relay
(async function () {
if (relayUrl) {
relay = await Relay.connect(relayUrl)
setRelay(relay)
}
})().catch((err) => {
console.error(err)
setRelay(null)
})
return () => {
relay?.close()
setRelay(null)
}
}, [relayUrl])
const sendPayment = useCallback((bolt11) => {
return new Promise(function (resolve, reject) {
(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)
})
}, [relay, walletPubkey, secret])
const getInfo = useCallback(() => getInfoWithRelay(relay, walletPubkey), [relay, 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()
resolve(supported)
sub.close()
},
onclose (reason) {
clearTimeout(timer)
reject(new Error(reason))
},
oneose () {
clearTimeout(timer)
reject(new Error('info event not found'))
}
})
})
}
function parseWalletConnectUrl (walletConnectUrl) {
walletConnectUrl = walletConnectUrl
.replace('nostrwalletconnect://', 'http://')
.replace('nostr+walletconnect://', 'http://') // makes it possible to parse with URL in the different environments (browser/node/...)
const url = new URL(walletConnectUrl)
const params = {}
params.walletPubkey = url.host
const secret = url.searchParams.get('secret')
const relayUrl = url.searchParams.get('relay')
if (secret) {
params.secret = secret
}
if (relayUrl) {
params.relayUrl = relayUrl
}
return params
}