Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add timeouts to all wallet API calls #1722

Merged
merged 9 commits into from
Dec 16, 2024
11 changes: 9 additions & 2 deletions api/resolvers/wallet.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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:')
Expand Down Expand Up @@ -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)
Comment on lines +70 to +75
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mhhh, if the wallet can use signals, then it's not clear if withTimeout or the signal will throw first 🤔

Preferably, we want the signal to throw first so we get FetchTimeoutError as the error which includes additional information.

I tested this with LNbits and setting the timeout to 10 and the signal always won but not sure if we can rely on that. I'll add 100ms to withTimeout to be sure but without affecting the timeout message itself.

Copy link
Member Author

@ekzyis ekzyis Dec 15, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done in eff81eb

: null
}, {
settings,
Expand Down
52 changes: 33 additions & 19 deletions lib/cln.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this assumes the signal was a timeout signal that used WALLET_CREATE_INVOICE_TIMEOUT_MS as the timeout which isn't clear from the immediate context but since we are smart and know more than just the immediate context and this is a workaround for node-fetch/node-fetch#1462, I think this is okay

}
throw err
}

assertResponseOk(res)
assertContentTypeJson(res)
Expand Down
3 changes: 3 additions & 0 deletions lib/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
14 changes: 8 additions & 6 deletions lib/fetch.js
Original file line number Diff line number Diff line change
@@ -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
}
Expand Down
12 changes: 9 additions & 3 deletions lib/lnurl.js
Original file line number Diff line number Diff line change
@@ -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'))
Expand All @@ -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'
Expand All @@ -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') {
Expand Down
18 changes: 17 additions & 1 deletion lib/time.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ export class TimeoutError extends Error {
constructor (timeout) {
super(`timeout after ${timeout / 1000}s`)
this.name = 'TimeoutError'
this.timeout = timeout
}
}

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

Expand All @@ -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
}
22 changes: 11 additions & 11 deletions wallets/blink/client.js
Original file line number Diff line number Diff line change
@@ -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')
}
Expand All @@ -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: `
Expand Down Expand Up @@ -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
Expand All @@ -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')
Expand All @@ -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({
Expand Down Expand Up @@ -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
Expand Down
16 changes: 9 additions & 7 deletions wallets/blink/common.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { fetchWithTimeout } from '@/lib/fetch'
import { assertContentTypeJson, assertResponseOk } from '@/lib/url'

export const galoyBlinkUrl = 'https://api.blink.sv/graphql'
Expand All @@ -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: `
Expand All @@ -21,7 +22,7 @@ export async function getWallet ({ apiKey, currency }) {
}
}
}`
})
}, { signal })

const wallets = out.data.me.defaultAccount.wallets
for (const wallet of wallets) {
Expand All @@ -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)
Expand All @@ -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: `
Expand All @@ -58,7 +60,7 @@ export async function getScopes ({ apiKey }) {
scopes
}
}`
})
}, { signal })
const scopes = out?.data?.authorization?.scopes
return scopes || []
}
15 changes: 7 additions & 8 deletions wallets/blink/server.js
Original file line number Diff line number Diff line change
@@ -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')
}
Expand All @@ -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)
Expand All @@ -52,7 +51,7 @@ export async function createInvoice (
walletId: wallet.id
}
}
})
}, { signal })

const res = out.data.lnInvoiceCreate
const errors = res.errors
Expand Down
Loading
Loading