Add timeouts to all wallet API calls (#1722)

* Add timeout to all wallet API calls

* Pass timeout signal to wallet API

* Fix timeout error message not shown on timeout

* Fix cross-fetch throws generic error message on abort

* Fix wrong method in error message

* Always use FetchTimeoutError

* Catch NDK timeout error to replace with custom timeout error

* Also use 15s for NWC connect timeout

* Add timeout delay
This commit is contained in:
ekzyis 2024-12-16 21:05:31 +01:00 committed by GitHub
parent 819d382494
commit 62a922247d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 222 additions and 121 deletions

View File

@ -8,7 +8,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'
@ -24,6 +25,7 @@ import validateWallet from '@/wallets/validate'
import { canReceive } from '@/wallets/common'
import performPaidAction from '../paidAction'
import performPayingAction from '../payingAction'
import { timeoutSignal, withTimeout } from '@/lib/time'
function injectResolvers (resolvers) {
console.group('injected GraphQL resolvers:')
@ -65,7 +67,12 @@ function injectResolvers (resolvers) {
wallet,
testCreateInvoice:
walletDef.testCreateInvoice && validateLightning && canReceive({ def: walletDef, config: data })
? (data) => walletDef.testCreateInvoice(data, { logger })
? (data) => withTimeout(
walletDef.testCreateInvoice(data, {
logger,
signal: timeoutSignal(WALLET_CREATE_INVOICE_TIMEOUT_MS)
}),
WALLET_CREATE_INVOICE_TIMEOUT_MS)
: null
}, {
settings,

View File

@ -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)

View File

@ -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

View File

@ -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
}

View File

@ -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') {

View File

@ -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
}

View File

@ -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

View File

@ -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 || []
}

View File

@ -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

View File

@ -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
}

View File

@ -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)
}

View File

@ -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)

View File

@ -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) {

View File

@ -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) {

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -15,8 +15,8 @@ import { walletLogger } from '@/api/resolvers/wallet'
import walletDefs from '@/wallets/server'
import { parsePaymentRequest } from 'ln-service'
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'
@ -201,6 +201,9 @@ async function walletCreateInvoice ({ wallet, def }, {
expiry
},
wallet.wallet,
{ logger }
), 10_000)
{
logger,
signal: timeoutSignal(WALLET_CREATE_INVOICE_TIMEOUT_MS)
}
), WALLET_CREATE_INVOICE_TIMEOUT_MS)
}