diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index a9b0cc106..852400508 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -7,7 +7,8 @@ import { SELECT, itemQueryWithMeta } from './item' import { formatMsats, msatsToSats, msatsToSatsDecimal, satsToMsats } from '@/lib/format' import { USER_ID, INVOICE_RETENTION_DAYS, - PAID_ACTION_PAYMENT_METHODS + PAID_ACTION_PAYMENT_METHODS, + WALLET_CREATE_INVOICE_TIMEOUT_MS } from '@/lib/constants' import { amountSchema, validateSchema, withdrawlSchema, lnAddrSchema } from '@/lib/validate' import assertGofacYourself from './ofac' @@ -21,13 +22,14 @@ import { lnAddrOptions } from '@/lib/lnurl' import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error' import { getNodeSockets, getOurPubkey } from '../lnd' import validateWallet from '@/wallets/validate' -import { canReceive } from '@/wallets/common' +import { canReceive, getWalletByType } from '@/wallets/common' import performPaidAction from '../paidAction' import performPayingAction from '../payingAction' import { parseInvoice } from '@/lib/boltInvoices' import lnd from '@/api/lnd' import { isBolt12Offer } from '@/lib/bolt12' import { fetchBolt12InvoiceFromOffer } from '@/lib/lndk' +import { timeoutSignal, withTimeout } from '@/lib/time' function injectResolvers (resolvers) { console.group('injected GraphQL resolvers:') @@ -67,9 +69,16 @@ function injectResolvers (resolvers) { return await upsertWallet({ wallet, + walletDef, testCreateInvoice: walletDef.testCreateInvoice && validateLightning && canReceive({ def: walletDef, config: data }) - ? (data) => walletDef.testCreateInvoice(data, { logger, lnd }) + ? (data) => withTimeout( + walletDef.testCreateInvoice(data, { + logger, + lnd, + signal: timeoutSignal(WALLET_CREATE_INVOICE_TIMEOUT_MS) + }), + WALLET_CREATE_INVOICE_TIMEOUT_MS) : null }, { settings, @@ -556,7 +565,10 @@ const resolvers = { const logger = walletLogger({ wallet, models }) await models.wallet.delete({ where: { userId: me.id, id: Number(id) } }) - logger.info('wallet detached') + + if (canReceive({ def: getWalletByType(wallet.type), config: wallet.wallet })) { + logger.info('details for receiving deleted') + } return true }, @@ -764,7 +776,7 @@ export const walletLogger = ({ wallet, models }) => { } async function upsertWallet ( - { wallet, testCreateInvoice }, { settings, data, vaultEntries }, { logger, me, models }) { + { wallet, walletDef, testCreateInvoice }, { settings, data, vaultEntries }, { logger, me, models }) { if (!me) { throw new GqlAuthenticationError() } @@ -870,24 +882,26 @@ async function upsertWallet ( ) } - txs.push( - models.walletLog.createMany({ - data: { - userId: me.id, - wallet: wallet.type, - level: 'SUCCESS', - message: id ? 'wallet details updated' : 'wallet attached' - } - }), - models.walletLog.create({ - data: { - userId: me.id, - wallet: wallet.type, - level: enabled ? 'SUCCESS' : 'INFO', - message: enabled ? 'wallet enabled' : 'wallet disabled' - } - }) - ) + if (canReceive({ def: walletDef, config: walletData })) { + txs.push( + models.walletLog.createMany({ + data: { + userId: me.id, + wallet: wallet.type, + level: 'SUCCESS', + message: id ? 'details for receiving updated' : 'details for receiving saved' + } + }), + models.walletLog.create({ + data: { + userId: me.id, + wallet: wallet.type, + level: enabled ? 'SUCCESS' : 'INFO', + message: enabled ? 'receiving enabled' : 'receiving disabled' + } + }) + ) + } const [upsertedWallet] = await models.$transaction(txs) return upsertedWallet diff --git a/components/wallet-logger.js b/components/wallet-logger.js index 7ae454c64..2344b572d 100644 --- a/components/wallet-logger.js +++ b/components/wallet-logger.js @@ -133,7 +133,7 @@ export function useWalletLogManager (setLogs) { `, { onCompleted: (_, { variables: { wallet: walletType } }) => { - setLogs?.(logs => logs.filter(l => walletType ? l.wallet !== getWalletByType(walletType).name : false)) + setLogs?.(logs => logs.filter(l => walletType ? l.wallet !== walletTag(getWalletByType(walletType)) : false)) } } ) diff --git a/lib/cln.js b/lib/cln.js index 489fb2489..7630aef06 100644 --- a/lib/cln.js +++ b/lib/cln.js @@ -2,30 +2,44 @@ import fetch from 'cross-fetch' import crypto from 'crypto' import { getAgent } from '@/lib/proxy' import { assertContentTypeJson, assertResponseOk } from './url' +import { FetchTimeoutError } from './fetch' +import { WALLET_CREATE_INVOICE_TIMEOUT_MS } from './constants' -export const createInvoice = async ({ msats, description, expiry }, { socket, rune, cert }) => { +export const createInvoice = async ({ msats, description, expiry }, { socket, rune, cert }, { signal }) => { const agent = getAgent({ hostname: socket, cert }) const url = `${agent.protocol}//${socket}/v1/invoice` - const res = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Rune: rune, - // can be any node id, only required for CLN v23.08 and below - // see https://docs.corelightning.org/docs/rest#server - nodeId: '02cb2e2d5a6c5b17fa67b1a883e2973c82e328fb9bd08b2b156a9e23820c87a490' - }, - agent, - body: JSON.stringify({ - // CLN requires a unique label for every invoice - // see https://docs.corelightning.org/reference/lightning-invoice - label: crypto.randomBytes(16).toString('hex'), - description, - amount_msat: msats, - expiry + + let res + try { + res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Rune: rune, + // can be any node id, only required for CLN v23.08 and below + // see https://docs.corelightning.org/docs/rest#server + nodeId: '02cb2e2d5a6c5b17fa67b1a883e2973c82e328fb9bd08b2b156a9e23820c87a490' + }, + agent, + body: JSON.stringify({ + // CLN requires a unique label for every invoice + // see https://docs.corelightning.org/reference/lightning-invoice + label: crypto.randomBytes(16).toString('hex'), + description, + amount_msat: msats, + expiry + }), + signal }) - }) + } catch (err) { + if (err.name === 'AbortError') { + // XXX node-fetch doesn't throw our custom TimeoutError but throws a generic error so we have to handle that manually. + // see https://github.com/node-fetch/node-fetch/issues/1462 + throw new FetchTimeoutError('POST', url, WALLET_CREATE_INVOICE_TIMEOUT_MS) + } + throw err + } assertResponseOk(res) assertContentTypeJson(res) diff --git a/lib/constants.js b/lib/constants.js index 066e6e6b9..5cbbf6531 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -191,3 +191,6 @@ export const LONG_POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_LONG_POLL_INTER export const EXTRA_LONG_POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL) export const ZAP_UNDO_DELAY_MS = 5_000 + +export const WALLET_SEND_PAYMENT_TIMEOUT_MS = 15_000 +export const WALLET_CREATE_INVOICE_TIMEOUT_MS = 15_000 diff --git a/lib/fetch.js b/lib/fetch.js index 918e86238..91f815c20 100644 --- a/lib/fetch.js +++ b/lib/fetch.js @@ -1,23 +1,25 @@ -import { TimeoutError } from '@/lib/time' +import { TimeoutError, timeoutSignal } from '@/lib/time' -class FetchTimeoutError extends TimeoutError { +export class FetchTimeoutError extends TimeoutError { constructor (method, url, timeout) { super(timeout) this.name = 'FetchTimeoutError' - this.message = `${method} ${url}: timeout after ${timeout / 1000}s` + this.message = timeout + ? `${method} ${url}: timeout after ${timeout / 1000}s` + : `${method} ${url}: timeout` } } -export async function fetchWithTimeout (resource, { timeout = 1000, ...options } = {}) { +export async function fetchWithTimeout (resource, { signal, timeout = 1000, ...options } = {}) { try { return await fetch(resource, { ...options, - signal: AbortSignal.timeout(timeout) + signal: signal ?? timeoutSignal(timeout) }) } catch (err) { if (err.name === 'TimeoutError') { // use custom error message - throw new FetchTimeoutError('GET', resource, timeout) + throw new FetchTimeoutError(options.method ?? 'GET', resource, err.timeout) } throw err } diff --git a/lib/lnurl.js b/lib/lnurl.js index 38b172dc8..930568d64 100644 --- a/lib/lnurl.js +++ b/lib/lnurl.js @@ -1,6 +1,8 @@ import { createHash } from 'crypto' import { bech32 } from 'bech32' import { lnAddrSchema } from './validate' +import { FetchTimeoutError } from '@/lib/fetch' +import { WALLET_CREATE_INVOICE_TIMEOUT_MS } from './constants' export function encodeLNUrl (url) { const words = bech32.toWords(Buffer.from(url.toString(), 'utf8')) @@ -25,7 +27,7 @@ export function lnurlPayDescriptionHash (data) { return createHash('sha256').update(data).digest('hex') } -export async function lnAddrOptions (addr) { +export async function lnAddrOptions (addr, { signal } = {}) { await lnAddrSchema().fields.addr.validate(addr) const [name, domain] = addr.split('@') let protocol = 'https' @@ -35,12 +37,16 @@ export async function lnAddrOptions (addr) { } const unexpectedErrorMessage = `An unexpected error occurred fetching the Lightning Address metadata for ${addr}. Check the address and try again.` let res + const url = `${protocol}://${domain}/.well-known/lnurlp/${name}` try { - const req = await fetch(`${protocol}://${domain}/.well-known/lnurlp/${name}`) + const req = await fetch(url, { signal }) res = await req.json() } catch (err) { - // If `fetch` fails, or if `req.json` fails, catch it here and surface a reasonable error console.log('Error fetching lnurlp', err) + if (err.name === 'TimeoutError') { + throw new FetchTimeoutError('GET', url, WALLET_CREATE_INVOICE_TIMEOUT_MS) + } + // If `fetch` fails, or if `req.json` fails, catch it here and surface a reasonable error throw new Error(unexpectedErrorMessage) } if (res.status === 'ERROR') { diff --git a/lib/time.js b/lib/time.js index 2ccd03900..cce8f7566 100644 --- a/lib/time.js +++ b/lib/time.js @@ -132,6 +132,7 @@ export class TimeoutError extends Error { constructor (timeout) { super(`timeout after ${timeout / 1000}s`) this.name = 'TimeoutError' + this.timeout = timeout } } @@ -140,7 +141,9 @@ function timeoutPromise (timeout) { // if no timeout is specified, never settle if (!timeout) return - setTimeout(() => reject(new TimeoutError(timeout)), timeout) + // delay timeout by 100ms so any parallel promise with same timeout will throw first + const delay = 100 + setTimeout(() => reject(new TimeoutError(timeout)), timeout + delay) }) } @@ -151,3 +154,16 @@ export async function withTimeout (promise, timeout) { export async function callWithTimeout (fn, timeout) { return await Promise.race([fn(), timeoutPromise(timeout)]) } + +// AbortSignal.timeout with our custom timeout error message +export function timeoutSignal (timeout) { + const controller = new AbortController() + + if (timeout) { + setTimeout(() => { + controller.abort(new TimeoutError(timeout)) + }, timeout) + } + + return controller.signal +} diff --git a/wallets/blink/client.js b/wallets/blink/client.js index a27df06f5..c5a487b83 100644 --- a/wallets/blink/client.js +++ b/wallets/blink/client.js @@ -1,10 +1,10 @@ import { getScopes, SCOPE_READ, SCOPE_WRITE, getWallet, request } from '@/wallets/blink/common' export * from '@/wallets/blink' -export async function testSendPayment ({ apiKey, currency }, { logger }) { +export async function testSendPayment ({ apiKey, currency }, { logger, signal }) { logger.info('trying to fetch ' + currency + ' wallet') - const scopes = await getScopes({ apiKey }) + const scopes = await getScopes({ apiKey }, { signal }) if (!scopes.includes(SCOPE_READ)) { throw new Error('missing READ scope') } @@ -13,17 +13,17 @@ export async function testSendPayment ({ apiKey, currency }, { logger }) { } currency = currency ? currency.toUpperCase() : 'BTC' - await getWallet({ apiKey, currency }) + await getWallet({ apiKey, currency }, { signal }) logger.ok(currency + ' wallet found') } -export async function sendPayment (bolt11, { apiKey, currency }) { - const wallet = await getWallet({ apiKey, currency }) - return await payInvoice(bolt11, { apiKey, wallet }) +export async function sendPayment (bolt11, { apiKey, currency }, { signal }) { + const wallet = await getWallet({ apiKey, currency }, { signal }) + return await payInvoice(bolt11, { apiKey, wallet }, { signal }) } -async function payInvoice (bolt11, { apiKey, wallet }) { +async function payInvoice (bolt11, { apiKey, wallet }, { signal }) { const out = await request({ apiKey, query: ` @@ -53,7 +53,7 @@ async function payInvoice (bolt11, { apiKey, wallet }) { walletId: wallet.id } } - }) + }, { signal }) const status = out.data.lnInvoicePaymentSend.status const errors = out.data.lnInvoicePaymentSend.errors @@ -79,7 +79,7 @@ async function payInvoice (bolt11, { apiKey, wallet }) { // at some point it should either be settled or fail on the backend, so the loop will exit await new Promise(resolve => setTimeout(resolve, 100)) - const txInfo = await getTxInfo(bolt11, { apiKey, wallet }) + const txInfo = await getTxInfo(bolt11, { apiKey, wallet }, { signal }) // settled if (txInfo.status === 'SUCCESS') { if (!txInfo.preImage) throw new Error('no preimage') @@ -98,7 +98,7 @@ async function payInvoice (bolt11, { apiKey, wallet }) { throw new Error('unexpected error') } -async function getTxInfo (bolt11, { apiKey, wallet }) { +async function getTxInfo (bolt11, { apiKey, wallet }, { signal }) { let out try { out = await request({ @@ -128,7 +128,7 @@ async function getTxInfo (bolt11, { apiKey, wallet }) { paymentRequest: bolt11, walletId: wallet.Id } - }) + }, { signal }) } catch (e) { // something went wrong during the query, // maybe the connection was lost, so we just return diff --git a/wallets/blink/common.js b/wallets/blink/common.js index bcc35a0ef..d0e46c3d3 100644 --- a/wallets/blink/common.js +++ b/wallets/blink/common.js @@ -1,3 +1,4 @@ +import { fetchWithTimeout } from '@/lib/fetch' import { assertContentTypeJson, assertResponseOk } from '@/lib/url' export const galoyBlinkUrl = 'https://api.blink.sv/graphql' @@ -7,7 +8,7 @@ export const SCOPE_READ = 'READ' export const SCOPE_WRITE = 'WRITE' export const SCOPE_RECEIVE = 'RECEIVE' -export async function getWallet ({ apiKey, currency }) { +export async function getWallet ({ apiKey, currency }, { signal }) { const out = await request({ apiKey, query: ` @@ -21,7 +22,7 @@ export async function getWallet ({ apiKey, currency }) { } } }` - }) + }, { signal }) const wallets = out.data.me.defaultAccount.wallets for (const wallet of wallets) { @@ -33,14 +34,15 @@ export async function getWallet ({ apiKey, currency }) { throw new Error(`wallet ${currency} not found`) } -export async function request ({ apiKey, query, variables = {} }) { - const res = await fetch(galoyBlinkUrl, { +export async function request ({ apiKey, query, variables = {} }, { signal }) { + const res = await fetchWithTimeout(galoyBlinkUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-KEY': apiKey }, - body: JSON.stringify({ query, variables }) + body: JSON.stringify({ query, variables }), + signal }) assertResponseOk(res) @@ -49,7 +51,7 @@ export async function request ({ apiKey, query, variables = {} }) { return res.json() } -export async function getScopes ({ apiKey }) { +export async function getScopes ({ apiKey }, { signal }) { const out = await request({ apiKey, query: ` @@ -58,7 +60,7 @@ export async function getScopes ({ apiKey }) { scopes } }` - }) + }, { signal }) const scopes = out?.data?.authorization?.scopes return scopes || [] } diff --git a/wallets/blink/server.js b/wallets/blink/server.js index 31d3a2708..0d1f2748f 100644 --- a/wallets/blink/server.js +++ b/wallets/blink/server.js @@ -1,10 +1,9 @@ -import { withTimeout } from '@/lib/time' import { getScopes, SCOPE_READ, SCOPE_RECEIVE, SCOPE_WRITE, getWallet, request } from '@/wallets/blink/common' import { msatsToSats } from '@/lib/format' export * from '@/wallets/blink' -export async function testCreateInvoice ({ apiKeyRecv, currencyRecv }) { - const scopes = await getScopes({ apiKey: apiKeyRecv }) +export async function testCreateInvoice ({ apiKeyRecv, currencyRecv }, { signal }) { + const scopes = await getScopes({ apiKey: apiKeyRecv }, { signal }) if (!scopes.includes(SCOPE_READ)) { throw new Error('missing READ scope') } @@ -15,17 +14,17 @@ export async function testCreateInvoice ({ apiKeyRecv, currencyRecv }) { throw new Error('missing RECEIVE scope') } - const timeout = 15_000 currencyRecv = currencyRecv ? currencyRecv.toUpperCase() : 'BTC' - return await withTimeout(createInvoice({ msats: 1000, expiry: 1 }, { apiKeyRecv, currencyRecv }), timeout) + return await createInvoice({ msats: 1000, expiry: 1 }, { apiKeyRecv, currencyRecv }, { signal }) } export async function createInvoice ( { msats, description, expiry }, - { apiKeyRecv: apiKey, currencyRecv: currency }) { + { apiKeyRecv: apiKey, currencyRecv: currency }, + { signal }) { currency = currency ? currency.toUpperCase() : 'BTC' - const wallet = await getWallet({ apiKey, currency }) + const wallet = await getWallet({ apiKey, currency }, { signal }) if (currency !== 'BTC') { throw new Error('unsupported currency ' + currency) @@ -52,7 +51,7 @@ export async function createInvoice ( walletId: wallet.id } } - }) + }, { signal }) const res = out.data.lnInvoiceCreate const errors = res.errors diff --git a/wallets/cln/server.js b/wallets/cln/server.js index a41c8fd71..2916e944b 100644 --- a/wallets/cln/server.js +++ b/wallets/cln/server.js @@ -2,14 +2,14 @@ import { createInvoice as clnCreateInvoice } from '@/lib/cln' export * from '@/wallets/cln' -export const testCreateInvoice = async ({ socket, rune, cert }) => { - return await createInvoice({ msats: 1000, expiry: 1, description: '' }, { socket, rune, cert }) +export const testCreateInvoice = async ({ socket, rune, cert }, { signal }) => { + return await createInvoice({ msats: 1000, expiry: 1, description: '' }, { socket, rune, cert }, { signal }) } export const createInvoice = async ( { msats, description, expiry }, - { socket, rune, cert } -) => { + { socket, rune, cert }, + { signal }) => { const inv = await clnCreateInvoice( { msats, @@ -20,7 +20,8 @@ export const createInvoice = async ( socket, rune, cert - }) + }, + { signal }) return inv.bolt11 } diff --git a/wallets/config.js b/wallets/config.js index 49e47c905..cdc574240 100644 --- a/wallets/config.js +++ b/wallets/config.js @@ -8,6 +8,8 @@ import { REMOVE_WALLET } from '@/fragments/wallet' import { useWalletLogger } from '@/wallets/logger' import { useWallets } from '.' import validateWallet from './validate' +import { WALLET_SEND_PAYMENT_TIMEOUT_MS } from '@/lib/constants' +import { timeoutSignal, withTimeout } from '@/lib/time' export function useWalletConfigurator (wallet) { const { me } = useMe() @@ -43,7 +45,13 @@ export function useWalletConfigurator (wallet) { clientConfig = Object.assign(clientConfig, transformedConfig) } if (wallet.def.testSendPayment && validateLightning) { - transformedConfig = await wallet.def.testSendPayment(clientConfig, { logger }) + transformedConfig = await withTimeout( + wallet.def.testSendPayment(clientConfig, { + logger, + signal: timeoutSignal(WALLET_SEND_PAYMENT_TIMEOUT_MS) + }), + WALLET_SEND_PAYMENT_TIMEOUT_MS + ) if (transformedConfig) { clientConfig = Object.assign(clientConfig, transformedConfig) } @@ -76,33 +84,52 @@ export function useWalletConfigurator (wallet) { }, [me?.id, wallet.def.name, reloadLocalWallets]) const save = useCallback(async (newConfig, validateLightning = true) => { - const { clientConfig, serverConfig } = await _validate(newConfig, validateLightning) + const { clientWithShared: oldClientConfig } = siftConfig(wallet.def.fields, wallet.config) + const { clientConfig: newClientConfig, serverConfig: newServerConfig } = await _validate(newConfig, validateLightning) + + const oldCanSend = canSend({ def: wallet.def, config: oldClientConfig }) + const newCanSend = canSend({ def: wallet.def, config: newClientConfig }) // if vault is active, encrypt and send to server regardless of wallet type if (isActive) { - await _saveToServer(serverConfig, clientConfig, validateLightning) + await _saveToServer(newServerConfig, newClientConfig, validateLightning) await _detachFromLocal() } else { - if (canSend({ def: wallet.def, config: clientConfig })) { - await _saveToLocal(clientConfig) + if (newCanSend) { + await _saveToLocal(newClientConfig) } else { // if it previously had a client config, remove it await _detachFromLocal() } - if (canReceive({ def: wallet.def, config: serverConfig })) { - await _saveToServer(serverConfig, clientConfig, validateLightning) + if (canReceive({ def: wallet.def, config: newServerConfig })) { + await _saveToServer(newServerConfig, newClientConfig, validateLightning) } else if (wallet.config.id) { // we previously had a server config if (wallet.vaultEntries.length > 0) { // we previously had a server config with vault entries, save it - await _saveToServer(serverConfig, clientConfig, validateLightning) + await _saveToServer(newServerConfig, newClientConfig, validateLightning) } else { // we previously had a server config without vault entries, remove it await _detachFromServer() } } } - }, [isActive, wallet.def, _saveToServer, _saveToLocal, _validate, + + if (newCanSend) { + if (oldCanSend) { + logger.ok('details for sending updated') + } else { + logger.ok('details for sending saved') + } + if (newConfig.enabled) { + logger.ok('sending enabled') + } else { + logger.info('sending disabled') + } + } else if (oldCanSend) { + logger.info('details for sending deleted') + } + }, [isActive, wallet.def, wallet.config, _saveToServer, _saveToLocal, _validate, _detachFromLocal, _detachFromServer]) const detach = useCallback(async () => { @@ -117,7 +144,9 @@ export function useWalletConfigurator (wallet) { // if vault is not active and has a client config, delete from local storage await _detachFromLocal() } - }, [isActive, _detachFromServer, _detachFromLocal]) + + logger.info('details for sending deleted') + }, [logger, isActive, _detachFromServer, _detachFromLocal]) return { save, detach } } diff --git a/wallets/lightning-address/server.js b/wallets/lightning-address/server.js index f7e51356a..cb9edf84d 100644 --- a/wallets/lightning-address/server.js +++ b/wallets/lightning-address/server.js @@ -1,18 +1,20 @@ +import { fetchWithTimeout } from '@/lib/fetch' import { msatsSatsFloor } from '@/lib/format' import { lnAddrOptions } from '@/lib/lnurl' import { assertContentTypeJson, assertResponseOk } from '@/lib/url' export * from '@/wallets/lightning-address' -export const testCreateInvoice = async ({ address }) => { - return await createInvoice({ msats: 1000 }, { address }) +export const testCreateInvoice = async ({ address }, { signal }) => { + return await createInvoice({ msats: 1000 }, { address }, { signal }) } export const createInvoice = async ( { msats, description }, - { address } + { address }, + { signal } ) => { - const { callback, commentAllowed } = await lnAddrOptions(address) + const { callback, commentAllowed } = await lnAddrOptions(address, { signal }) const callbackUrl = new URL(callback) // most lnurl providers suck nards so we have to floor to nearest sat @@ -25,7 +27,7 @@ export const createInvoice = async ( } // call callback with amount and conditionally comment - const res = await fetch(callbackUrl.toString()) + const res = await fetchWithTimeout(callbackUrl.toString(), { signal }) assertResponseOk(res) assertContentTypeJson(res) diff --git a/wallets/lnbits/client.js b/wallets/lnbits/client.js index 61abe48d9..a850a0e63 100644 --- a/wallets/lnbits/client.js +++ b/wallets/lnbits/client.js @@ -1,22 +1,23 @@ +import { fetchWithTimeout } from '@/lib/fetch' import { assertContentTypeJson } from '@/lib/url' export * from '@/wallets/lnbits' -export async function testSendPayment ({ url, adminKey, invoiceKey }, { logger }) { +export async function testSendPayment ({ url, adminKey, invoiceKey }, { signal, logger }) { logger.info('trying to fetch wallet') url = url.replace(/\/+$/, '') - await getWallet({ url, adminKey, invoiceKey }) + await getWallet({ url, adminKey, invoiceKey }, { signal }) logger.ok('wallet found') } -export async function sendPayment (bolt11, { url, adminKey }) { +export async function sendPayment (bolt11, { url, adminKey }, { signal }) { url = url.replace(/\/+$/, '') - const response = await postPayment(bolt11, { url, adminKey }) + const response = await postPayment(bolt11, { url, adminKey }, { signal }) - const checkResponse = await getPayment(response.payment_hash, { url, adminKey }) + const checkResponse = await getPayment(response.payment_hash, { url, adminKey }, { signal }) if (!checkResponse.preimage) { throw new Error('No preimage') } @@ -24,7 +25,7 @@ export async function sendPayment (bolt11, { url, adminKey }) { return checkResponse.preimage } -async function getWallet ({ url, adminKey, invoiceKey }) { +async function getWallet ({ url, adminKey, invoiceKey }, { signal }) { const path = '/api/v1/wallet' const headers = new Headers() @@ -32,7 +33,7 @@ async function getWallet ({ url, adminKey, invoiceKey }) { headers.append('Content-Type', 'application/json') headers.append('X-Api-Key', adminKey || invoiceKey) - const res = await fetch(url + path, { method: 'GET', headers }) + const res = await fetchWithTimeout(url + path, { method: 'GET', headers, signal }) assertContentTypeJson(res) if (!res.ok) { @@ -44,7 +45,7 @@ async function getWallet ({ url, adminKey, invoiceKey }) { return wallet } -async function postPayment (bolt11, { url, adminKey }) { +async function postPayment (bolt11, { url, adminKey }, { signal }) { const path = '/api/v1/payments' const headers = new Headers() @@ -54,7 +55,7 @@ async function postPayment (bolt11, { url, adminKey }) { const body = JSON.stringify({ bolt11, out: true }) - const res = await fetch(url + path, { method: 'POST', headers, body }) + const res = await fetchWithTimeout(url + path, { method: 'POST', headers, body, signal }) assertContentTypeJson(res) if (!res.ok) { @@ -66,7 +67,7 @@ async function postPayment (bolt11, { url, adminKey }) { return payment } -async function getPayment (paymentHash, { url, adminKey }) { +async function getPayment (paymentHash, { url, adminKey }, { signal }) { const path = `/api/v1/payments/${paymentHash}` const headers = new Headers() @@ -74,7 +75,7 @@ async function getPayment (paymentHash, { url, adminKey }) { headers.append('Content-Type', 'application/json') headers.append('X-Api-Key', adminKey) - const res = await fetch(url + path, { method: 'GET', headers }) + const res = await fetchWithTimeout(url + path, { method: 'GET', headers, signal }) assertContentTypeJson(res) if (!res.ok) { diff --git a/wallets/lnbits/server.js b/wallets/lnbits/server.js index 72099055b..5bc728265 100644 --- a/wallets/lnbits/server.js +++ b/wallets/lnbits/server.js @@ -1,3 +1,5 @@ +import { WALLET_CREATE_INVOICE_TIMEOUT_MS } from '@/lib/constants' +import { FetchTimeoutError } from '@/lib/fetch' import { msatsToSats } from '@/lib/format' import { getAgent } from '@/lib/proxy' import { assertContentTypeJson } from '@/lib/url' @@ -5,13 +7,14 @@ import fetch from 'cross-fetch' export * from '@/wallets/lnbits' -export async function testCreateInvoice ({ url, invoiceKey }) { - return await createInvoice({ msats: 1000, expiry: 1 }, { url, invoiceKey }) +export async function testCreateInvoice ({ url, invoiceKey }, { signal }) { + return await createInvoice({ msats: 1000, expiry: 1 }, { url, invoiceKey }, { signal }) } export async function createInvoice ( { msats, description, descriptionHash, expiry }, - { url, invoiceKey }) { + { url, invoiceKey }, + { signal }) { const path = '/api/v1/payments' const headers = new Headers() @@ -38,12 +41,23 @@ export async function createInvoice ( hostname = 'lnbits:5000' } - const res = await fetch(`${agent.protocol}//${hostname}${path}`, { - method: 'POST', - headers, - agent, - body - }) + let res + try { + res = await fetch(`${agent.protocol}//${hostname}${path}`, { + method: 'POST', + headers, + agent, + body, + signal + }) + } catch (err) { + if (err.name === 'AbortError') { + // XXX node-fetch doesn't throw our custom TimeoutError but throws a generic error so we have to handle that manually. + // see https://github.com/node-fetch/node-fetch/issues/1462 + throw new FetchTimeoutError('POST', url, WALLET_CREATE_INVOICE_TIMEOUT_MS) + } + throw err + } assertContentTypeJson(res) if (!res.ok) { diff --git a/wallets/nwc/client.js b/wallets/nwc/client.js index ea34f2c12..74691edc2 100644 --- a/wallets/nwc/client.js +++ b/wallets/nwc/client.js @@ -1,17 +1,16 @@ import { getNwc, supportedMethods, nwcTryRun } from '@/wallets/nwc' export * from '@/wallets/nwc' -export async function testSendPayment ({ nwcUrl }) { - const timeout = 15_000 - - const supported = await supportedMethods(nwcUrl, { timeout }) +export async function testSendPayment ({ nwcUrl }, { signal }) { + const supported = await supportedMethods(nwcUrl, { signal }) if (!supported.includes('pay_invoice')) { throw new Error('pay_invoice not supported') } } -export async function sendPayment (bolt11, { nwcUrl }) { - const nwc = await getNwc(nwcUrl) +export async function sendPayment (bolt11, { nwcUrl }, { signal }) { + const nwc = await getNwc(nwcUrl, { signal }) + // TODO: support AbortSignal const result = await nwcTryRun(() => nwc.payInvoice(bolt11)) return result.preimage } diff --git a/wallets/nwc/index.js b/wallets/nwc/index.js index 7360cba51..7ec942410 100644 --- a/wallets/nwc/index.js +++ b/wallets/nwc/index.js @@ -2,6 +2,9 @@ import Nostr from '@/lib/nostr' import { string } from '@/lib/yup' import { parseNwcUrl } from '@/lib/url' import { NDKNwc } from '@nostr-dev-kit/ndk' +import { TimeoutError } from '@/lib/time' + +const NWC_CONNECT_TIMEOUT_MS = 15_000 export const name = 'nwc' export const walletType = 'NWC' @@ -33,7 +36,7 @@ export const card = { subtitle: 'use Nostr Wallet Connect for payments' } -export async function getNwc (nwcUrl, { timeout = 5e4 } = {}) { +export async function getNwc (nwcUrl, { signal }) { const ndk = Nostr.ndk const { walletPubkey, secret, relayUrls } = parseNwcUrl(nwcUrl) const nwc = new NDKNwc({ @@ -42,7 +45,17 @@ export async function getNwc (nwcUrl, { timeout = 5e4 } = {}) { relayUrls, secret }) - await nwc.blockUntilReady(timeout) + + // TODO: support AbortSignal + try { + await nwc.blockUntilReady(NWC_CONNECT_TIMEOUT_MS) + } catch (err) { + if (err.message === 'Timeout') { + throw new TimeoutError(NWC_CONNECT_TIMEOUT_MS) + } + throw err + } + return nwc } @@ -63,8 +76,9 @@ export async function nwcTryRun (fun) { } } -export async function supportedMethods (nwcUrl, { timeout } = {}) { - const nwc = await getNwc(nwcUrl, { timeout }) +export async function supportedMethods (nwcUrl, { signal }) { + const nwc = await getNwc(nwcUrl, { signal }) + // TODO: support AbortSignal const result = await nwcTryRun(() => nwc.getInfo()) return result.methods } diff --git a/wallets/nwc/server.js b/wallets/nwc/server.js index bcb0201c4..b9cc8fd56 100644 --- a/wallets/nwc/server.js +++ b/wallets/nwc/server.js @@ -1,11 +1,8 @@ -import { withTimeout } from '@/lib/time' import { getNwc, supportedMethods, nwcTryRun } from '@/wallets/nwc' export * from '@/wallets/nwc' -export async function testCreateInvoice ({ nwcUrlRecv }) { - const timeout = 15_000 - - const supported = await supportedMethods(nwcUrlRecv, { timeout }) +export async function testCreateInvoice ({ nwcUrlRecv }, { signal }) { + const supported = await supportedMethods(nwcUrlRecv, { signal }) const supports = (method) => supported.includes(method) @@ -20,11 +17,12 @@ export async function testCreateInvoice ({ nwcUrlRecv }) { } } - return await withTimeout(createInvoice({ msats: 1000, expiry: 1 }, { nwcUrlRecv }), timeout) + return await createInvoice({ msats: 1000, expiry: 1 }, { nwcUrlRecv }, { signal }) } -export async function createInvoice ({ msats, description, expiry }, { nwcUrlRecv }) { - const nwc = await getNwc(nwcUrlRecv) +export async function createInvoice ({ msats, description, expiry }, { nwcUrlRecv }, { signal }) { + const nwc = await getNwc(nwcUrlRecv, { signal }) + // TODO: support AbortSignal const result = await nwcTryRun(() => nwc.sendReq('make_invoice', { amount: msats, description, expiry })) return result.invoice } diff --git a/wallets/payment.js b/wallets/payment.js index 484da3c07..043d57f89 100644 --- a/wallets/payment.js +++ b/wallets/payment.js @@ -2,13 +2,14 @@ import { useCallback } from 'react' import { useSendWallets } from '@/wallets' import { formatSats } from '@/lib/format' import useInvoice from '@/components/use-invoice' -import { FAST_POLL_INTERVAL } from '@/lib/constants' +import { FAST_POLL_INTERVAL, WALLET_SEND_PAYMENT_TIMEOUT_MS } from '@/lib/constants' import { WalletsNotAvailableError, WalletSenderError, WalletAggregateError, WalletPaymentAggregateError, WalletNotEnabledError, WalletSendNotConfiguredError, WalletPaymentError, WalletError, WalletReceiverError } from '@/wallets/errors' import { canSend } from './common' import { useWalletLoggerFactory } from './logger' +import { timeoutSignal, withTimeout } from '@/lib/time' export function useWalletPayment () { const wallets = useSendWallets() @@ -152,7 +153,12 @@ function useSendPayment () { logger.info(`↗ sending payment: ${formatSats(satsRequested)}`, { bolt11 }) try { - const preimage = await wallet.def.sendPayment(bolt11, wallet.config, { logger }) + const preimage = await withTimeout( + wallet.def.sendPayment(bolt11, wallet.config, { + logger, + signal: timeoutSignal(WALLET_SEND_PAYMENT_TIMEOUT_MS) + }), + WALLET_SEND_PAYMENT_TIMEOUT_MS) logger.ok(`↗ payment sent: ${formatSats(satsRequested)}`, { bolt11, preimage }) } catch (err) { // we don't log the error here since we want to handle receiver errors separately diff --git a/wallets/phoenixd/client.js b/wallets/phoenixd/client.js index 703ef8dfe..f923aaf4a 100644 --- a/wallets/phoenixd/client.js +++ b/wallets/phoenixd/client.js @@ -1,8 +1,9 @@ +import { fetchWithTimeout } from '@/lib/fetch' import { assertContentTypeJson, assertResponseOk } from '@/lib/url' export * from '@/wallets/phoenixd' -export async function testSendPayment (config, { logger }) { +export async function testSendPayment (config, { logger, signal }) { // TODO: // Not sure which endpoint to call to test primary password // see https://phoenix.acinq.co/server/api @@ -10,7 +11,7 @@ export async function testSendPayment (config, { logger }) { } -export async function sendPayment (bolt11, { url, primaryPassword }) { +export async function sendPayment (bolt11, { url, primaryPassword }, { signal }) { // https://phoenix.acinq.co/server/api#pay-bolt11-invoice const path = '/payinvoice' @@ -21,10 +22,11 @@ export async function sendPayment (bolt11, { url, primaryPassword }) { const body = new URLSearchParams() body.append('invoice', bolt11) - const res = await fetch(url + path, { + const res = await fetchWithTimeout(url + path, { method: 'POST', headers, - body + body, + signal }) assertResponseOk(res) diff --git a/wallets/phoenixd/server.js b/wallets/phoenixd/server.js index 67f324d22..aceefd075 100644 --- a/wallets/phoenixd/server.js +++ b/wallets/phoenixd/server.js @@ -1,17 +1,20 @@ +import { fetchWithTimeout } from '@/lib/fetch' import { msatsToSats } from '@/lib/format' import { assertContentTypeJson, assertResponseOk } from '@/lib/url' export * from '@/wallets/phoenixd' -export async function testCreateInvoice ({ url, secondaryPassword }) { +export async function testCreateInvoice ({ url, secondaryPassword }, { signal }) { return await createInvoice( { msats: 1000, description: 'SN test invoice', expiry: 1 }, - { url, secondaryPassword }) + { url, secondaryPassword }, + { signal }) } export async function createInvoice ( { msats, description, descriptionHash, expiry }, - { url, secondaryPassword } + { url, secondaryPassword }, + { signal } ) { // https://phoenix.acinq.co/server/api#create-bolt11-invoice const path = '/createinvoice' @@ -24,10 +27,11 @@ export async function createInvoice ( body.append('description', description) body.append('amountSat', msatsToSats(msats)) - const res = await fetch(url + path, { + const res = await fetchWithTimeout(url + path, { method: 'POST', headers, - body + body, + signal }) assertResponseOk(res) diff --git a/wallets/server.js b/wallets/server.js index 905cf64d2..8db3dff7b 100644 --- a/wallets/server.js +++ b/wallets/server.js @@ -17,8 +17,8 @@ import walletDefs from '@/wallets/server' import { parseInvoice } from '@/lib/boltInvoices' import { isBolt12Offer, isBolt12Invoice } from '@/lib/bolt12' import { toPositiveBigInt, toPositiveNumber, formatMsats, formatSats, msatsToSats } from '@/lib/format' -import { PAID_ACTION_TERMINAL_STATES } from '@/lib/constants' -import { withTimeout } from '@/lib/time' +import { PAID_ACTION_TERMINAL_STATES, WALLET_CREATE_INVOICE_TIMEOUT_MS } from '@/lib/constants' +import { timeoutSignal, withTimeout } from '@/lib/time' import { canReceive } from './common' import wrapInvoice from './wrap' @@ -219,6 +219,10 @@ async function walletCreateInvoice ({ wallet, def }, { expiry }, wallet.wallet, - { logger, lnd } - ), 10_000) + { + logger, + lnd, + signal: timeoutSignal(WALLET_CREATE_INVOICE_TIMEOUT_MS) + } + ), WALLET_CREATE_INVOICE_TIMEOUT_MS) }