Skip to content

Commit

Permalink
Merge branch 'master' into improve-values
Browse files Browse the repository at this point in the history
  • Loading branch information
huumn authored Dec 18, 2024
2 parents afe4b26 + 6098d39 commit 42621df
Show file tree
Hide file tree
Showing 21 changed files with 279 additions and 151 deletions.
59 changes: 36 additions & 23 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 @@ -21,9 +22,10 @@ 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 { timeoutSignal, withTimeout } from '@/lib/time'

function injectResolvers (resolvers) {
console.group('injected GraphQL resolvers:')
Expand Down Expand Up @@ -63,9 +65,15 @@ function injectResolvers (resolvers) {

return await upsertWallet({
wallet,
walletDef,
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,
Expand Down Expand Up @@ -551,7 +559,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
},
Expand Down Expand Up @@ -759,7 +770,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()
}
Expand Down Expand Up @@ -865,24 +876,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
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)
}
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
Loading

0 comments on commit 42621df

Please sign in to comment.