ca11ac9fb8
* wip backend optimism * another inch * make action state transitions only happen once * another inch * almost ready for testing * use interactive txs * another inch * ready for basic testing * lint fix * inches * wip item update * get item update to work * donate and downzap * inchy inch * fix territory paid actions * wip usePaidMutation * usePaidMutation error handling * PENDING_HELD and HELD transitions, gql paidAction return types * mostly working pessimism * make sure invoice field is present in optimisticResponse * inches * show optimistic values to current me * first pass at notifications and payment status reporting * fix migration to have withdrawal hash * reverse optimism on payment failure * Revert "Optimistic updates via pending sats in item context (#1229)" This reverts commit 93713b33df9bc3701dc5a692b86a04ff64e8cfb1. * add onCompleted to usePaidMutation * onPaid and onPayError for new comments * use 'IS DISTINCT FROM' for NULL invoiceActionState columns * make usePaidMutation easier to read * enhance invoice qr * prevent actions on unpaid items * allow navigation to action's invoice * retry create item * start edit window after item is paid for * fix ux of retries from notifications * refine retries * fix optimistic downzaps * remember item updates can't be retried * store reference to action item in invoice * remove invoice modal layout shift * fix destructuring * fix zap undos * make sure ItemAct is paid in aggregate queries * dont toast on long press zap undo * fix delete and remindme bots * optimistic poll votes with retries * fix retry notifications and invoice item context * fix pessimisitic typo * item mentions and mention notifications * dont show payment retry on item popover * make bios work * refactor paidAction transitions * remove stray console.log * restore docker compose nwc settings * add new todos * persist qr modal on post submission + unify item form submission * fix post edit threshold * make bounty payments work * make job posting work * remove more store procedure usage ... document serialization concerns * dont use dynamic imports for paid action modules * inline comment denormalization * create item starts with median votes * fix potential of serialization anomalies in zaps * dont trigger notification indicator on successful paid action invoices * ignore invoiceId on territory actions and add optimistic concurrency control * begin docs for paid actions * better error toasts and fix apollo cache warnings * small documentation enhancements * improve paid action docs * optimistic concurrency control for territory updates * use satsToMsats and msatsToSats helpers * explictly type raw query template parameters * improve consistency of nested relation names * complete paid action docs * useEffect for canEdit on payment * make sure invoiceId is provided when required * don't return null when expecting array * remove buy credits * move verifyPayment to paidAction * fix comments invoicePaidAt time zone * close nwc connections once * grouped logs for paid actions * stop invoiceWaitUntilPaid if not attempting to pay * allow actionState to transition directly from HELD to PAID * make paid mutation wait until pessimistic are fully paid * change button text when form submits/pays * pulsing form submit button * ignore me in notification indicator for territory subscription * filter unpaid items from more queries * fix donation stike timing * fix pending poll vote * fix recent item notifcation padding * no default form submitting button text * don't show paying on submit button on free edits * fix territory autorenew with fee credits * reorg readme * allow jobs to be editted forever * fix image uploads * more filter fixes for aggregate views * finalize paid action invoice expirations * remove unnecessary async * keep clientside cache normal/consistent * add more detail to paid action doc * improve paid action table * remove actionType guard * fix top territories * typo api/paidAction/README.md Co-authored-by: ekzyis <ek@stacker.news> * typo components/use-paid-mutation.js Co-authored-by: ekzyis <ek@stacker.news> * Apply suggestions from code review Co-authored-by: ekzyis <ek@stacker.news> * encorporate ek feeback * more ek suggestions * fix 'cost to post' hover on items * Apply suggestions from code review Co-authored-by: ekzyis <ek@stacker.news> --------- Co-authored-by: ekzyis <ek@stacker.news>
288 lines
9.1 KiB
JavaScript
288 lines
9.1 KiB
JavaScript
// https://github.com/getAlby/js-sdk/blob/master/src/webln/NostrWeblnProvider.ts
|
|
|
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
|
import { Relay, finalizeEvent, nip04 } from 'nostr-tools'
|
|
import { parseNwcUrl } from '@/lib/url'
|
|
import { useWalletLogger } from '../logger'
|
|
import { Status, migrateLocalStorage } from '.'
|
|
import { bolt11Tags } from '@/lib/bolt11'
|
|
import { JIT_INVOICE_TIMEOUT_MS, Wallet } from '@/lib/constants'
|
|
import { useMe } from '../me'
|
|
import { InvoiceExpiredError } from '../payment'
|
|
|
|
const NWCContext = createContext()
|
|
|
|
export function NWCProvider ({ children }) {
|
|
const me = useMe()
|
|
const [nwcUrl, setNwcUrl] = useState('')
|
|
const [walletPubkey, setWalletPubkey] = useState()
|
|
const [relayUrl, setRelayUrl] = useState()
|
|
const [secret, setSecret] = useState()
|
|
const [status, setStatus] = useState()
|
|
const { logger } = useWalletLogger(Wallet.NWC)
|
|
|
|
let storageKey = 'webln:provider:nwc'
|
|
if (me) {
|
|
storageKey = `${storageKey}:${me.id}`
|
|
}
|
|
|
|
const getInfo = useCallback(async (relayUrl, walletPubkey) => {
|
|
logger.info(`requesting info event from ${relayUrl}`)
|
|
|
|
let relay
|
|
try {
|
|
relay = await Relay.connect(relayUrl)
|
|
logger.ok(`connected to ${relayUrl}`)
|
|
} catch (err) {
|
|
const msg = `failed to connect to ${relayUrl}`
|
|
logger.error(msg)
|
|
throw new Error(msg)
|
|
}
|
|
|
|
try {
|
|
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))
|
|
}, timeout)
|
|
|
|
let found = false
|
|
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))
|
|
}
|
|
}
|
|
})
|
|
})
|
|
} finally {
|
|
relay?.close()?.catch()
|
|
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 () => {
|
|
let configStr = window.localStorage.getItem(storageKey)
|
|
setStatus(Status.Initialized)
|
|
if (!configStr) {
|
|
if (me) {
|
|
// backwards compatibility: try old storageKey
|
|
const oldStorageKey = storageKey.split(':').slice(0, -1).join(':')
|
|
configStr = migrateLocalStorage(oldStorageKey, storageKey)
|
|
}
|
|
if (!configStr) {
|
|
logger.info('no existing config found')
|
|
return
|
|
}
|
|
}
|
|
|
|
const config = JSON.parse(configStr)
|
|
|
|
const { nwcUrl } = config
|
|
setNwcUrl(nwcUrl)
|
|
|
|
const params = parseNwcUrl(nwcUrl)
|
|
setRelayUrl(params.relayUrl)
|
|
setWalletPubkey(params.walletPubkey)
|
|
setSecret(params.secret)
|
|
|
|
logger.info(
|
|
'loaded wallet config: ' +
|
|
'secret=****** ' +
|
|
`pubkey=${params.walletPubkey.slice(0, 6)}..${params.walletPubkey.slice(-6)} ` +
|
|
`relay=${params.relayUrl}`)
|
|
|
|
try {
|
|
await validateParams(params)
|
|
setStatus(Status.Enabled)
|
|
logger.ok('wallet enabled')
|
|
} catch (err) {
|
|
logger.error('invalid config:', err)
|
|
setStatus(Status.Error)
|
|
logger.info('wallet disabled')
|
|
throw err
|
|
}
|
|
}, [me, validateParams, logger])
|
|
|
|
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) {
|
|
setStatus(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))
|
|
|
|
logger.info(
|
|
'saved wallet config: ' +
|
|
'secret=****** ' +
|
|
`pubkey=${params.walletPubkey.slice(0, 6)}..${params.walletPubkey.slice(-6)} ` +
|
|
`relay=${params.relayUrl}`)
|
|
|
|
try {
|
|
await validateParams(params)
|
|
setStatus(Status.Enabled)
|
|
logger.ok('wallet enabled')
|
|
} catch (err) {
|
|
logger.error('invalid config:', err)
|
|
setStatus(Status.Error)
|
|
logger.info('wallet disabled')
|
|
throw err
|
|
}
|
|
}, [validateParams, logger])
|
|
|
|
const clearConfig = useCallback(() => {
|
|
window.localStorage.removeItem(storageKey)
|
|
setNwcUrl('')
|
|
setRelayUrl(undefined)
|
|
setWalletPubkey(undefined)
|
|
setSecret(undefined)
|
|
setStatus(undefined)
|
|
}, [])
|
|
|
|
const sendPayment = useCallback(async (bolt11) => {
|
|
const hash = bolt11Tags(bolt11).payment_hash
|
|
logger.info('sending payment:', `payment_hash=${hash}`)
|
|
|
|
let relay
|
|
try {
|
|
relay = await Relay.connect(relayUrl)
|
|
logger.ok(`connected to ${relayUrl}`)
|
|
} catch (err) {
|
|
const msg = `failed to connect to ${relayUrl}`
|
|
logger.error(msg)
|
|
throw new Error(msg)
|
|
}
|
|
|
|
try {
|
|
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 = JIT_INVOICE_TIMEOUT_MS
|
|
const timer = setTimeout(() => {
|
|
const msg = 'timeout waiting for payment'
|
|
logger.error(msg)
|
|
reject(new InvoiceExpiredError(hash))
|
|
}, 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),
|
|
tags: [['p', walletPubkey]],
|
|
content
|
|
}, secret)
|
|
await relay.publish(request)
|
|
|
|
const filter = {
|
|
kinds: [23195],
|
|
authors: [walletPubkey],
|
|
'#e': [request.id]
|
|
}
|
|
relay.subscribe([filter], {
|
|
async onevent (response) {
|
|
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)
|
|
}
|
|
},
|
|
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))
|
|
}
|
|
}
|
|
})
|
|
})().catch(reject)
|
|
})
|
|
const preimage = ret.preimage
|
|
logger.ok('payment successful:', `payment_hash=${hash}`, `preimage=${preimage}`)
|
|
return ret
|
|
} catch (err) {
|
|
logger.error('payment failed:', `payment_hash=${hash}`, err.message || err.toString?.())
|
|
throw err
|
|
} finally {
|
|
relay?.close()?.catch()
|
|
if (relay) logger.info(`closed connection to ${relayUrl}`)
|
|
}
|
|
}, [walletPubkey, relayUrl, secret, logger])
|
|
|
|
useEffect(() => {
|
|
loadConfig().catch(err => logger.error(err.message || err.toString?.()))
|
|
}, [])
|
|
|
|
const value = useMemo(
|
|
() => ({ name: 'NWC', nwcUrl, relayUrl, walletPubkey, secret, status, saveConfig, clearConfig, getInfo, sendPayment }),
|
|
[nwcUrl, relayUrl, walletPubkey, secret, status, saveConfig, clearConfig, getInfo, sendPayment])
|
|
return (
|
|
<NWCContext.Provider value={value}>
|
|
{children}
|
|
</NWCContext.Provider>
|
|
)
|
|
}
|
|
|
|
export function useNWC () {
|
|
return useContext(NWCContext)
|
|
}
|