a46f81f1e1
* Use same naming scheme between ln containers and env vars * Add router_lnd container * Only open channels to router_lnd * Use 1sat base fee and 0ppm fee rate * Add script to test routing * Also fund router_lnd wallet * Receiver fallbacks * Rename to predecessorId * Remove useless wallet table join * Missing renaming to predecessor * Fix payment stuck on sender error We want to await the invoice poll promise so we can check for receiver errors, but in case of sender errors, the promise will never settle. * Don't log failed forwards as sender errors * fix check for receiver error --------- Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com> Co-authored-by: k00b <k00b@stacker.news>
1021 lines
31 KiB
JavaScript
1021 lines
31 KiB
JavaScript
import {
|
|
getInvoice as getInvoiceFromLnd, deletePayment, getPayment,
|
|
parsePaymentRequest
|
|
} from 'ln-service'
|
|
import crypto, { timingSafeEqual } from 'crypto'
|
|
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
|
|
import { SELECT, itemQueryWithMeta } from './item'
|
|
import { formatMsats, msatsToSats, msatsToSatsDecimal, satsToMsats } from '@/lib/format'
|
|
import {
|
|
USER_ID, INVOICE_RETENTION_DAYS,
|
|
PAID_ACTION_PAYMENT_METHODS
|
|
} from '@/lib/constants'
|
|
import { amountSchema, validateSchema, withdrawlSchema, lnAddrSchema } from '@/lib/validate'
|
|
import assertGofacYourself from './ofac'
|
|
import assertApiKeyNotPermitted from './apiKey'
|
|
import { bolt11Tags } from '@/lib/bolt11'
|
|
import { finalizeHodlInvoice } from '@/worker/wallet'
|
|
import walletDefs from '@/wallets/server'
|
|
import { generateResolverName, generateTypeDefName } from '@/wallets/graphql'
|
|
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 performPaidAction from '../paidAction'
|
|
import performPayingAction from '../payingAction'
|
|
|
|
function injectResolvers (resolvers) {
|
|
console.group('injected GraphQL resolvers:')
|
|
for (const walletDef of walletDefs) {
|
|
const resolverName = generateResolverName(walletDef.walletField)
|
|
console.log(resolverName)
|
|
resolvers.Mutation[resolverName] = async (parent, { settings, validateLightning, vaultEntries, ...data }, { me, models }) => {
|
|
console.log('resolving', resolverName, { settings, validateLightning, vaultEntries, ...data })
|
|
|
|
let existingVaultEntries
|
|
if (typeof vaultEntries === 'undefined' && data.id) {
|
|
// this mutation was sent from an unsynced client
|
|
// to pass validation, we need to add the existing vault entries for validation
|
|
// in case the client is removing the receiving config
|
|
existingVaultEntries = await models.vaultEntry.findMany({
|
|
where: {
|
|
walletId: Number(data.id)
|
|
}
|
|
})
|
|
}
|
|
|
|
const validData = await validateWallet(walletDef,
|
|
{ ...data, ...settings, vaultEntries: vaultEntries ?? existingVaultEntries },
|
|
{ serverSide: true })
|
|
if (validData) {
|
|
data && Object.keys(validData).filter(key => key in data).forEach(key => { data[key] = validData[key] })
|
|
settings && Object.keys(validData).filter(key => key in settings).forEach(key => { settings[key] = validData[key] })
|
|
}
|
|
|
|
// wallet in shape of db row
|
|
const wallet = {
|
|
field: walletDef.walletField,
|
|
type: walletDef.walletType,
|
|
userId: me?.id
|
|
}
|
|
const logger = walletLogger({ wallet, models })
|
|
|
|
return await upsertWallet({
|
|
wallet,
|
|
testCreateInvoice:
|
|
walletDef.testCreateInvoice && validateLightning && canReceive({ def: walletDef, config: data })
|
|
? (data) => walletDef.testCreateInvoice(data, { logger, me, models })
|
|
: null
|
|
}, {
|
|
settings,
|
|
data,
|
|
vaultEntries
|
|
}, { logger, me, models })
|
|
}
|
|
}
|
|
console.groupEnd()
|
|
|
|
return resolvers
|
|
}
|
|
|
|
export async function getInvoice (parent, { id }, { me, models, lnd }) {
|
|
const inv = await models.invoice.findUnique({
|
|
where: {
|
|
id: Number(id)
|
|
}
|
|
})
|
|
|
|
if (!inv) {
|
|
throw new GqlInputError('invoice not found')
|
|
}
|
|
|
|
if (inv.userId === USER_ID.anon) {
|
|
return inv
|
|
}
|
|
if (!me) {
|
|
throw new GqlAuthenticationError()
|
|
}
|
|
if (inv.userId !== me.id) {
|
|
throw new GqlInputError('not ur invoice')
|
|
}
|
|
|
|
return inv
|
|
}
|
|
|
|
export async function getWithdrawl (parent, { id }, { me, models, lnd }) {
|
|
if (!me) {
|
|
throw new GqlAuthenticationError()
|
|
}
|
|
|
|
const wdrwl = await models.withdrawl.findUnique({
|
|
where: {
|
|
id: Number(id)
|
|
}
|
|
})
|
|
|
|
if (!wdrwl) {
|
|
throw new GqlInputError('withdrawal not found')
|
|
}
|
|
|
|
if (wdrwl.userId !== me.id) {
|
|
throw new GqlInputError('not ur withdrawal')
|
|
}
|
|
|
|
return wdrwl
|
|
}
|
|
|
|
export function createHmac (hash) {
|
|
const key = Buffer.from(process.env.INVOICE_HMAC_KEY, 'hex')
|
|
return crypto.createHmac('sha256', key).update(Buffer.from(hash, 'hex')).digest('hex')
|
|
}
|
|
|
|
export function verifyHmac (hash, hmac) {
|
|
const hmac2 = createHmac(hash)
|
|
if (!timingSafeEqual(Buffer.from(hmac), Buffer.from(hmac2))) {
|
|
throw new GqlAuthorizationError('bad hmac')
|
|
}
|
|
return true
|
|
}
|
|
|
|
const resolvers = {
|
|
Query: {
|
|
invoice: getInvoice,
|
|
wallet: async (parent, { id }, { me, models }) => {
|
|
if (!me) {
|
|
throw new GqlAuthenticationError()
|
|
}
|
|
|
|
return await models.wallet.findUnique({
|
|
where: {
|
|
userId: me.id,
|
|
id: Number(id)
|
|
},
|
|
include: {
|
|
vaultEntries: true
|
|
}
|
|
})
|
|
},
|
|
walletByType: async (parent, { type }, { me, models }) => {
|
|
if (!me) {
|
|
throw new GqlAuthenticationError()
|
|
}
|
|
|
|
const wallet = await models.wallet.findFirst({
|
|
where: {
|
|
userId: me.id,
|
|
type
|
|
},
|
|
include: {
|
|
vaultEntries: true
|
|
}
|
|
})
|
|
return wallet
|
|
},
|
|
wallets: async (parent, args, { me, models }) => {
|
|
if (!me) {
|
|
throw new GqlAuthenticationError()
|
|
}
|
|
|
|
return await models.wallet.findMany({
|
|
include: {
|
|
vaultEntries: true
|
|
},
|
|
where: {
|
|
userId: me.id
|
|
},
|
|
orderBy: {
|
|
priority: 'asc'
|
|
}
|
|
})
|
|
},
|
|
withdrawl: getWithdrawl,
|
|
direct: async (parent, { id }, { me, models }) => {
|
|
if (!me) {
|
|
throw new GqlAuthenticationError()
|
|
}
|
|
|
|
return await models.directPayment.findUnique({
|
|
where: {
|
|
id: Number(id),
|
|
receiverId: me.id
|
|
}
|
|
})
|
|
},
|
|
numBolt11s: async (parent, args, { me, models, lnd }) => {
|
|
if (!me) {
|
|
throw new GqlAuthenticationError()
|
|
}
|
|
|
|
return await models.withdrawl.count({
|
|
where: {
|
|
userId: me.id,
|
|
hash: { not: null }
|
|
}
|
|
})
|
|
},
|
|
connectAddress: async (parent, args, { lnd }) => {
|
|
return process.env.LND_CONNECT_ADDRESS
|
|
},
|
|
walletHistory: async (parent, { cursor, inc }, { me, models, lnd }) => {
|
|
const decodedCursor = decodeCursor(cursor)
|
|
if (!me) {
|
|
throw new GqlAuthenticationError()
|
|
}
|
|
|
|
const include = new Set(inc?.split(','))
|
|
const queries = []
|
|
|
|
if (include.has('invoice')) {
|
|
queries.push(
|
|
`(SELECT
|
|
id, created_at as "createdAt", COALESCE("msatsReceived", "msatsRequested") as msats,
|
|
'invoice' as type,
|
|
jsonb_build_object(
|
|
'bolt11', bolt11,
|
|
'status', CASE WHEN "confirmedAt" IS NOT NULL THEN 'CONFIRMED'
|
|
WHEN cancelled THEN 'CANCELLED'
|
|
WHEN "expiresAt" <= $2 AND NOT "isHeld" THEN 'EXPIRED'
|
|
ELSE 'PENDING' END,
|
|
'description', "desc",
|
|
'invoiceComment', comment,
|
|
'invoicePayerData', "lud18Data") as other
|
|
FROM "Invoice"
|
|
WHERE "userId" = $1
|
|
AND created_at <= $2)`
|
|
)
|
|
}
|
|
|
|
if (include.has('withdrawal')) {
|
|
queries.push(
|
|
`(SELECT
|
|
"Withdrawl".id, "Withdrawl".created_at as "createdAt",
|
|
COALESCE("msatsPaid", "msatsPaying") as msats,
|
|
CASE WHEN bool_and("InvoiceForward".id IS NULL) THEN 'withdrawal' ELSE 'p2p' END as type,
|
|
jsonb_build_object(
|
|
'bolt11', "Withdrawl".bolt11,
|
|
'autoWithdraw', "autoWithdraw",
|
|
'status', COALESCE(status::text, 'PENDING'),
|
|
'msatsFee', COALESCE("msatsFeePaid", "msatsFeePaying")) as other
|
|
FROM "Withdrawl"
|
|
LEFT JOIN "InvoiceForward" ON "Withdrawl".id = "InvoiceForward"."withdrawlId"
|
|
WHERE "Withdrawl"."userId" = $1
|
|
AND "Withdrawl".created_at <= $2
|
|
GROUP BY "Withdrawl".id)`
|
|
)
|
|
queries.push(
|
|
`(SELECT id, created_at as "createdAt", msats, 'direct' as type,
|
|
jsonb_build_object(
|
|
'bolt11', bolt11,
|
|
'description', "desc",
|
|
'invoiceComment', comment,
|
|
'invoicePayerData', "lud18Data") as other
|
|
FROM "DirectPayment"
|
|
WHERE "DirectPayment"."receiverId" = $1
|
|
AND "DirectPayment".created_at <= $2)`
|
|
)
|
|
}
|
|
|
|
if (include.has('stacked')) {
|
|
// query1 - get all sats stacked as OP or as a forward
|
|
queries.push(
|
|
`(SELECT
|
|
"Item".id,
|
|
MAX("ItemAct".created_at) AS "createdAt",
|
|
FLOOR(
|
|
SUM("ItemAct".msats)
|
|
* (CASE WHEN "Item"."userId" = $1 THEN
|
|
COALESCE(1 - ((SELECT SUM(pct) FROM "ItemForward" WHERE "itemId" = "Item".id) / 100.0), 1)
|
|
ELSE
|
|
(SELECT pct FROM "ItemForward" WHERE "itemId" = "Item".id AND "userId" = $1) / 100.0
|
|
END)
|
|
) AS msats,
|
|
'stacked' AS type, NULL::JSONB AS other
|
|
FROM "ItemAct"
|
|
JOIN "Item" ON "ItemAct"."itemId" = "Item".id
|
|
-- only join to with item forward for items where we aren't the OP
|
|
LEFT JOIN "ItemForward" ON "ItemForward"."itemId" = "Item".id AND "Item"."userId" <> $1
|
|
WHERE "ItemAct".act = 'TIP'
|
|
AND ("Item"."userId" = $1 OR "ItemForward"."userId" = $1)
|
|
AND "ItemAct".created_at <= $2
|
|
AND ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID')
|
|
GROUP BY "Item".id)`
|
|
)
|
|
queries.push(
|
|
`(SELECT
|
|
min("Earn".id) as id, created_at as "createdAt",
|
|
sum(msats) as msats, 'earn' as type, NULL::JSONB AS other
|
|
FROM "Earn"
|
|
WHERE "Earn"."userId" = $1 AND "Earn".created_at <= $2
|
|
GROUP BY "userId", created_at)`
|
|
)
|
|
queries.push(
|
|
`(SELECT id, created_at as "createdAt", msats, 'referral' as type, NULL::JSONB AS other
|
|
FROM "ReferralAct"
|
|
WHERE "ReferralAct"."referrerId" = $1 AND "ReferralAct".created_at <= $2)`
|
|
)
|
|
queries.push(
|
|
`(SELECT id, created_at as "createdAt", msats, 'revenue' as type,
|
|
jsonb_build_object('subName', "SubAct"."subName") as other
|
|
FROM "SubAct"
|
|
WHERE "userId" = $1 AND type = 'REVENUE'
|
|
AND created_at <= $2)`
|
|
)
|
|
}
|
|
|
|
if (include.has('spent')) {
|
|
queries.push(
|
|
`(SELECT "Item".id, MAX("ItemAct".created_at) as "createdAt", sum("ItemAct".msats) as msats,
|
|
'spent' as type, NULL::JSONB AS other
|
|
FROM "ItemAct"
|
|
JOIN "Item" on "ItemAct"."itemId" = "Item".id
|
|
WHERE "ItemAct"."userId" = $1
|
|
AND "ItemAct".created_at <= $2
|
|
AND ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID')
|
|
GROUP BY "Item".id)`
|
|
)
|
|
queries.push(
|
|
`(SELECT id, created_at as "createdAt", sats * 1000 as msats,'donation' as type, NULL::JSONB AS other
|
|
FROM "Donation"
|
|
WHERE "userId" = $1
|
|
AND created_at <= $2)`
|
|
)
|
|
queries.push(
|
|
`(SELECT id, created_at as "createdAt", msats, 'billing' as type,
|
|
jsonb_build_object('subName', "SubAct"."subName") as other
|
|
FROM "SubAct"
|
|
WHERE "userId" = $1 AND type = 'BILLING'
|
|
AND created_at <= $2)`
|
|
)
|
|
}
|
|
|
|
if (queries.length === 0) {
|
|
return {
|
|
cursor: null,
|
|
facts: []
|
|
}
|
|
}
|
|
|
|
let history = await models.$queryRawUnsafe(`
|
|
${queries.join(' UNION ALL ')}
|
|
ORDER BY "createdAt" DESC
|
|
OFFSET $3
|
|
LIMIT ${LIMIT}`,
|
|
me.id, decodedCursor.time, decodedCursor.offset)
|
|
|
|
history = history.map(f => {
|
|
f = { ...f, ...f.other }
|
|
|
|
if (f.bolt11) {
|
|
f.description = bolt11Tags(f.bolt11).description
|
|
}
|
|
|
|
switch (f.type) {
|
|
case 'withdrawal':
|
|
f.msats = (-1 * Number(f.msats)) - Number(f.msatsFee)
|
|
break
|
|
case 'p2p':
|
|
f.msats = -1 * Number(f.msats)
|
|
break
|
|
case 'spent':
|
|
case 'donation':
|
|
case 'billing':
|
|
f.msats *= -1
|
|
break
|
|
default:
|
|
break
|
|
}
|
|
|
|
return f
|
|
})
|
|
|
|
return {
|
|
cursor: history.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
|
|
facts: history
|
|
}
|
|
},
|
|
walletLogs: async (parent, { type, from, to, cursor }, { me, models }) => {
|
|
if (!me) {
|
|
throw new GqlAuthenticationError()
|
|
}
|
|
|
|
// we cursoring with the wallet logs on the client
|
|
// if we have from, don't use cursor
|
|
// regardless, store the state of the cursor for the next call
|
|
|
|
const decodedCursor = cursor ? decodeCursor(cursor) : { offset: 0, time: to ?? new Date() }
|
|
|
|
let logs = []
|
|
let nextCursor
|
|
if (from) {
|
|
logs = await models.walletLog.findMany({
|
|
where: {
|
|
userId: me.id,
|
|
wallet: type ?? undefined,
|
|
createdAt: {
|
|
gt: from ? new Date(Number(from)) : undefined,
|
|
lte: to ? new Date(Number(to)) : undefined
|
|
}
|
|
},
|
|
orderBy: [
|
|
{ createdAt: 'desc' },
|
|
{ id: 'desc' }
|
|
]
|
|
})
|
|
nextCursor = nextCursorEncoded(decodedCursor, logs.length)
|
|
} else {
|
|
logs = await models.walletLog.findMany({
|
|
where: {
|
|
userId: me.id,
|
|
wallet: type ?? undefined,
|
|
createdAt: {
|
|
lte: decodedCursor.time
|
|
}
|
|
},
|
|
orderBy: [
|
|
{ createdAt: 'desc' },
|
|
{ id: 'desc' }
|
|
],
|
|
take: LIMIT,
|
|
skip: decodedCursor.offset
|
|
})
|
|
nextCursor = logs.length === LIMIT ? nextCursorEncoded(decodedCursor, logs.length) : null
|
|
}
|
|
|
|
return {
|
|
cursor: nextCursor,
|
|
entries: logs
|
|
}
|
|
}
|
|
},
|
|
Wallet: {
|
|
wallet: async (wallet) => {
|
|
return {
|
|
...wallet.wallet,
|
|
__resolveType: generateTypeDefName(wallet.type)
|
|
}
|
|
}
|
|
},
|
|
WalletDetails: {
|
|
__resolveType: wallet => wallet.__resolveType
|
|
},
|
|
InvoiceOrDirect: {
|
|
__resolveType: invoiceOrDirect => invoiceOrDirect.__resolveType
|
|
},
|
|
Mutation: {
|
|
createInvoice: async (parent, { amount }, { me, models, lnd, headers }) => {
|
|
await validateSchema(amountSchema, { amount })
|
|
await assertGofacYourself({ models, headers })
|
|
|
|
const { invoice, paymentMethod } = await performPaidAction('RECEIVE', {
|
|
msats: satsToMsats(amount)
|
|
}, { models, lnd, me })
|
|
|
|
return {
|
|
...invoice,
|
|
__resolveType:
|
|
paymentMethod === PAID_ACTION_PAYMENT_METHODS.DIRECT ? 'Direct' : 'Invoice'
|
|
}
|
|
},
|
|
createWithdrawl: createWithdrawal,
|
|
sendToLnAddr,
|
|
cancelInvoice: async (parent, { hash, hmac }, { models, lnd, boss }) => {
|
|
verifyHmac(hash, hmac)
|
|
await finalizeHodlInvoice({ data: { hash }, lnd, models, boss })
|
|
return await models.invoice.findFirst({ where: { hash } })
|
|
},
|
|
dropBolt11: async (parent, { hash }, { me, models, lnd }) => {
|
|
if (!me) {
|
|
throw new GqlAuthenticationError()
|
|
}
|
|
|
|
const retention = `${INVOICE_RETENTION_DAYS} days`
|
|
|
|
const [invoice] = await models.$queryRaw`
|
|
WITH to_be_updated AS (
|
|
SELECT id, hash, bolt11
|
|
FROM "Withdrawl"
|
|
WHERE "userId" = ${me.id}
|
|
AND hash = ${hash}
|
|
AND now() > created_at + ${retention}::INTERVAL
|
|
AND hash IS NOT NULL
|
|
AND status IS NOT NULL
|
|
), updated_rows AS (
|
|
UPDATE "Withdrawl"
|
|
SET hash = NULL, bolt11 = NULL, preimage = NULL
|
|
FROM to_be_updated
|
|
WHERE "Withdrawl".id = to_be_updated.id)
|
|
SELECT * FROM to_be_updated;`
|
|
|
|
if (invoice) {
|
|
try {
|
|
await deletePayment({ id: invoice.hash, lnd })
|
|
} catch (error) {
|
|
console.error(error)
|
|
await models.withdrawl.update({
|
|
where: { id: invoice.id },
|
|
data: { hash: invoice.hash, bolt11: invoice.bolt11, preimage: invoice.preimage }
|
|
})
|
|
throw new GqlInputError('failed to drop bolt11 from lnd')
|
|
}
|
|
}
|
|
|
|
await models.$queryRaw`
|
|
UPDATE "DirectPayment"
|
|
SET hash = NULL, bolt11 = NULL, preimage = NULL
|
|
WHERE "receiverId" = ${me.id}
|
|
AND hash = ${hash}
|
|
AND now() > created_at + ${retention}::INTERVAL
|
|
AND hash IS NOT NULL`
|
|
|
|
return true
|
|
},
|
|
setWalletPriority: async (parent, { id, priority }, { me, models }) => {
|
|
if (!me) {
|
|
throw new GqlAuthenticationError()
|
|
}
|
|
|
|
await models.wallet.update({ where: { userId: me.id, id: Number(id) }, data: { priority } })
|
|
|
|
return true
|
|
},
|
|
removeWallet: async (parent, { id }, { me, models }) => {
|
|
if (!me) {
|
|
throw new GqlAuthenticationError()
|
|
}
|
|
|
|
const wallet = await models.wallet.findUnique({ where: { userId: me.id, id: Number(id) } })
|
|
if (!wallet) {
|
|
throw new GqlInputError('wallet not found')
|
|
}
|
|
|
|
const logger = walletLogger({ wallet, models })
|
|
await models.wallet.delete({ where: { userId: me.id, id: Number(id) } })
|
|
logger.info('wallet detached')
|
|
|
|
return true
|
|
},
|
|
deleteWalletLogs: async (parent, { wallet }, { me, models }) => {
|
|
if (!me) {
|
|
throw new GqlAuthenticationError()
|
|
}
|
|
|
|
await models.walletLog.deleteMany({ where: { userId: me.id, wallet } })
|
|
|
|
return true
|
|
}
|
|
},
|
|
|
|
Withdrawl: {
|
|
satsPaying: w => msatsToSats(w.msatsPaying),
|
|
satsPaid: w => msatsToSats(w.msatsPaid),
|
|
satsFeePaying: w => w.invoiceForward ? 0 : msatsToSats(w.msatsFeePaying),
|
|
satsFeePaid: w => w.invoiceForward ? 0 : msatsToSats(w.msatsFeePaid),
|
|
// we never want to fetch the sensitive data full monty in nested resolvers
|
|
forwardedActionType: async (withdrawl, args, { models }) => {
|
|
return (await models.invoiceForward.findUnique({
|
|
where: { withdrawlId: Number(withdrawl.id) },
|
|
include: {
|
|
invoice: true
|
|
}
|
|
}))?.invoice?.actionType
|
|
},
|
|
preimage: async (withdrawl, args, { lnd }) => {
|
|
try {
|
|
if (withdrawl.status === 'CONFIRMED' && withdrawl.hash) {
|
|
return withdrawl.preimage ?? (await getPayment({ id: withdrawl.hash, lnd })).payment.secret
|
|
}
|
|
} catch (err) {
|
|
console.error('error fetching payment from LND', err)
|
|
}
|
|
}
|
|
},
|
|
Direct: {
|
|
nostr: async (direct, args, { models }) => {
|
|
try {
|
|
return JSON.parse(direct.desc)
|
|
} catch (err) {
|
|
}
|
|
|
|
return null
|
|
},
|
|
sats: direct => msatsToSats(direct.msats)
|
|
},
|
|
|
|
Invoice: {
|
|
satsReceived: i => msatsToSats(i.msatsReceived),
|
|
satsRequested: i => msatsToSats(i.msatsRequested),
|
|
// we never want to fetch the sensitive data full monty in nested resolvers
|
|
forwardStatus: async (invoice, args, { models }) => {
|
|
const forward = await models.invoiceForward.findUnique({
|
|
where: { invoiceId: Number(invoice.id) },
|
|
include: {
|
|
withdrawl: true
|
|
}
|
|
})
|
|
return forward?.withdrawl?.status
|
|
},
|
|
forwardedSats: async (invoice, args, { models }) => {
|
|
const msats = (await models.invoiceForward.findUnique({
|
|
where: { invoiceId: Number(invoice.id) },
|
|
include: {
|
|
withdrawl: true
|
|
}
|
|
}))?.withdrawl?.msatsPaid
|
|
return msats ? msatsToSats(msats) : null
|
|
},
|
|
nostr: async (invoice, args, { models }) => {
|
|
try {
|
|
return JSON.parse(invoice.desc)
|
|
} catch (err) {
|
|
}
|
|
|
|
return null
|
|
},
|
|
confirmedPreimage: async (invoice, args, { lnd }) => {
|
|
try {
|
|
if (invoice.confirmedAt) {
|
|
return invoice.preimage ?? (await getInvoiceFromLnd({ id: invoice.hash, lnd })).secret
|
|
}
|
|
} catch (err) {
|
|
console.error('error fetching invoice from LND', err)
|
|
}
|
|
|
|
return null
|
|
},
|
|
item: async (invoice, args, { models, me }) => {
|
|
if (!invoice.actionId) return null
|
|
switch (invoice.actionType) {
|
|
case 'ITEM_CREATE':
|
|
case 'ZAP':
|
|
case 'DOWN_ZAP':
|
|
case 'POLL_VOTE':
|
|
case 'BOOST':
|
|
return (await itemQueryWithMeta({
|
|
me,
|
|
models,
|
|
query: `
|
|
${SELECT}
|
|
FROM "Item"
|
|
WHERE id = $1`
|
|
}, Number(invoice.actionId)))?.[0]
|
|
default:
|
|
return null
|
|
}
|
|
},
|
|
itemAct: async (invoice, args, { models, me }) => {
|
|
const action2act = {
|
|
ZAP: 'TIP',
|
|
DOWN_ZAP: 'DONT_LIKE_THIS',
|
|
POLL_VOTE: 'POLL',
|
|
BOOST: 'BOOST'
|
|
}
|
|
switch (invoice.actionType) {
|
|
case 'ZAP':
|
|
case 'DOWN_ZAP':
|
|
case 'POLL_VOTE':
|
|
case 'BOOST':
|
|
return (await models.$queryRaw`
|
|
SELECT id, act, "invoiceId", "invoiceActionState", msats
|
|
FROM "ItemAct"
|
|
WHERE "ItemAct"."invoiceId" = ${Number(invoice.id)}::INTEGER
|
|
AND "ItemAct"."userId" = ${me?.id}::INTEGER
|
|
AND act = ${action2act[invoice.actionType]}::"ItemActType"`
|
|
)?.[0]
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
},
|
|
|
|
Fact: {
|
|
item: async (fact, args, { models }) => {
|
|
if (fact.type !== 'spent' && fact.type !== 'stacked') {
|
|
return null
|
|
}
|
|
const [item] = await models.$queryRawUnsafe(`
|
|
${SELECT}
|
|
FROM "Item"
|
|
WHERE id = $1`, Number(fact.id))
|
|
|
|
return item
|
|
},
|
|
sats: fact => msatsToSatsDecimal(fact.msats)
|
|
}
|
|
}
|
|
|
|
export default injectResolvers(resolvers)
|
|
|
|
export const walletLogger = ({ wallet, models }) => {
|
|
// no-op logger if wallet is not provided
|
|
if (!wallet) {
|
|
return {
|
|
ok: () => {},
|
|
info: () => {},
|
|
error: () => {},
|
|
warn: () => {}
|
|
}
|
|
}
|
|
|
|
// server implementation of wallet logger interface on client
|
|
const log = (level) => async (message, context = {}) => {
|
|
try {
|
|
if (context?.bolt11) {
|
|
// automatically populate context from bolt11 to avoid duplicating this code
|
|
const decoded = await parsePaymentRequest({ request: context.bolt11 })
|
|
context = {
|
|
...context,
|
|
amount: formatMsats(decoded.mtokens),
|
|
payment_hash: decoded.id,
|
|
created_at: decoded.created_at,
|
|
expires_at: decoded.expires_at,
|
|
description: decoded.description,
|
|
// payments should affect wallet status
|
|
status: true
|
|
}
|
|
}
|
|
context.recv = true
|
|
|
|
await models.walletLog.create({
|
|
data: {
|
|
userId: wallet.userId,
|
|
wallet: wallet.type,
|
|
level,
|
|
message,
|
|
context
|
|
}
|
|
})
|
|
} catch (err) {
|
|
console.error('error creating wallet log:', err)
|
|
}
|
|
}
|
|
|
|
return {
|
|
ok: (message, context) => log('SUCCESS')(message, context),
|
|
info: (message, context) => log('INFO')(message, context),
|
|
error: (message, context) => log('ERROR')(message, context),
|
|
warn: (message, context) => log('WARN')(message, context)
|
|
}
|
|
}
|
|
|
|
async function upsertWallet (
|
|
{ wallet, testCreateInvoice }, { settings, data, vaultEntries }, { logger, me, models }) {
|
|
if (!me) {
|
|
throw new GqlAuthenticationError()
|
|
}
|
|
assertApiKeyNotPermitted({ me })
|
|
|
|
if (testCreateInvoice) {
|
|
try {
|
|
await testCreateInvoice(data)
|
|
} catch (err) {
|
|
const message = 'failed to create test invoice: ' + (err.message || err.toString?.())
|
|
logger.error(message)
|
|
throw new GqlInputError(message)
|
|
}
|
|
}
|
|
|
|
const { id, enabled, priority, ...walletData } = data
|
|
|
|
const txs = []
|
|
|
|
if (id) {
|
|
const oldVaultEntries = await models.vaultEntry.findMany({ where: { userId: me.id, walletId: Number(id) } })
|
|
|
|
// createMany is the set difference of the new - old
|
|
// deleteMany is the set difference of the old - new
|
|
// updateMany is the intersection of the old and new
|
|
const difference = (a = [], b = [], key = 'key') => a.filter(x => !b.find(y => y[key] === x[key]))
|
|
const intersectionMerge = (a = [], b = [], key = 'key') => a.filter(x => b.find(y => y[key] === x[key]))
|
|
.map(x => ({ [key]: x[key], ...b.find(y => y[key] === x[key]) }))
|
|
|
|
txs.push(
|
|
models.wallet.update({
|
|
where: { id: Number(id), userId: me.id },
|
|
data: {
|
|
enabled,
|
|
priority,
|
|
// client only wallets has no walletData
|
|
...(Object.keys(walletData).length > 0
|
|
? {
|
|
[wallet.field]: {
|
|
update: {
|
|
where: { walletId: Number(id) },
|
|
data: walletData
|
|
}
|
|
}
|
|
}
|
|
: {}),
|
|
...(vaultEntries
|
|
? {
|
|
vaultEntries: {
|
|
deleteMany: difference(oldVaultEntries, vaultEntries, 'key').map(({ key }) => ({
|
|
userId: me.id, key
|
|
})),
|
|
create: difference(vaultEntries, oldVaultEntries, 'key').map(({ key, iv, value }) => ({
|
|
key, iv, value, userId: me.id
|
|
})),
|
|
update: intersectionMerge(oldVaultEntries, vaultEntries, 'key').map(({ key, iv, value }) => ({
|
|
where: { userId_key: { userId: me.id, key } },
|
|
data: { value, iv }
|
|
}))
|
|
}
|
|
}
|
|
: {})
|
|
|
|
},
|
|
include: {
|
|
vaultEntries: true
|
|
}
|
|
})
|
|
)
|
|
} else {
|
|
txs.push(
|
|
models.wallet.create({
|
|
include: {
|
|
vaultEntries: true
|
|
},
|
|
data: {
|
|
enabled,
|
|
priority,
|
|
userId: me.id,
|
|
type: wallet.type,
|
|
// client only wallets has no walletData
|
|
...(Object.keys(walletData).length > 0 ? { [wallet.field]: { create: walletData } } : {}),
|
|
...(vaultEntries
|
|
? {
|
|
vaultEntries: {
|
|
createMany: {
|
|
data: vaultEntries?.map(({ key, iv, value }) => ({ key, iv, value, userId: me.id }))
|
|
}
|
|
}
|
|
}
|
|
: {})
|
|
}
|
|
})
|
|
)
|
|
}
|
|
|
|
if (settings) {
|
|
txs.push(
|
|
models.user.update({
|
|
where: { id: me.id },
|
|
data: settings
|
|
})
|
|
)
|
|
}
|
|
|
|
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'
|
|
}
|
|
})
|
|
)
|
|
|
|
const [upsertedWallet] = await models.$transaction(txs)
|
|
return upsertedWallet
|
|
}
|
|
|
|
export async function createWithdrawal (parent, { invoice, maxFee }, { me, models, lnd, headers, wallet, logger }) {
|
|
assertApiKeyNotPermitted({ me })
|
|
await validateSchema(withdrawlSchema, { invoice, maxFee })
|
|
await assertGofacYourself({ models, headers })
|
|
|
|
// remove 'lightning:' prefix if present
|
|
invoice = invoice.replace(/^lightning:/, '')
|
|
|
|
// decode invoice to get amount
|
|
let decoded, sockets
|
|
try {
|
|
decoded = await parsePaymentRequest({ request: invoice })
|
|
} catch (error) {
|
|
console.log(error)
|
|
throw new GqlInputError('could not decode invoice')
|
|
}
|
|
|
|
try {
|
|
sockets = await getNodeSockets({ lnd, public_key: decoded.destination })
|
|
} catch (error) {
|
|
// likely not found if it's an unannounced channel, e.g. phoenix
|
|
console.log(error)
|
|
}
|
|
|
|
if (sockets) {
|
|
for (const { socket } of sockets) {
|
|
const ip = socket.split(':')[0]
|
|
await assertGofacYourself({ models, headers, ip })
|
|
}
|
|
}
|
|
|
|
if (!decoded.mtokens || BigInt(decoded.mtokens) <= 0) {
|
|
throw new GqlInputError('invoice must specify an amount')
|
|
}
|
|
|
|
if (decoded.mtokens > Number.MAX_SAFE_INTEGER) {
|
|
throw new GqlInputError('invoice amount is too large')
|
|
}
|
|
|
|
// check if there's an invoice with same hash that has an invoiceForward
|
|
// we can't allow this because it creates two outgoing payments from our node
|
|
// with the same hash
|
|
const selfPayment = await models.invoice.findUnique({
|
|
where: { hash: decoded.id },
|
|
include: { invoiceForward: true }
|
|
})
|
|
if (selfPayment?.invoiceForward) {
|
|
throw new GqlInputError('SN cannot pay an invoice that SN is proxying')
|
|
}
|
|
|
|
return await performPayingAction({ bolt11: invoice, maxFee, walletId: wallet?.id }, { me, models, lnd })
|
|
}
|
|
|
|
export async function sendToLnAddr (parent, { addr, amount, maxFee, comment, ...payer },
|
|
{ me, models, lnd, headers }) {
|
|
if (!me) {
|
|
throw new GqlAuthenticationError()
|
|
}
|
|
assertApiKeyNotPermitted({ me })
|
|
|
|
const res = await fetchLnAddrInvoice({ addr, amount, maxFee, comment, ...payer },
|
|
{
|
|
me,
|
|
models,
|
|
lnd
|
|
})
|
|
|
|
// take pr and createWithdrawl
|
|
return await createWithdrawal(parent, { invoice: res.pr, maxFee }, { me, models, lnd, headers })
|
|
}
|
|
|
|
export async function fetchLnAddrInvoice (
|
|
{ addr, amount, maxFee, comment, ...payer },
|
|
{
|
|
me, models, lnd, autoWithdraw = false
|
|
}) {
|
|
const options = await lnAddrOptions(addr)
|
|
await validateSchema(lnAddrSchema, { addr, amount, maxFee, comment, ...payer }, options)
|
|
|
|
if (payer) {
|
|
payer = {
|
|
...payer,
|
|
identifier: payer.identifier ? `${me.name}@stacker.news` : undefined
|
|
}
|
|
payer = Object.fromEntries(
|
|
Object.entries(payer).filter(([, value]) => !!value)
|
|
)
|
|
}
|
|
|
|
const milliamount = 1000 * amount
|
|
const callback = new URL(options.callback)
|
|
callback.searchParams.append('amount', milliamount)
|
|
|
|
if (comment?.length) {
|
|
callback.searchParams.append('comment', comment)
|
|
}
|
|
|
|
let stringifiedPayerData = ''
|
|
if (payer && Object.entries(payer).length) {
|
|
stringifiedPayerData = JSON.stringify(payer)
|
|
callback.searchParams.append('payerdata', stringifiedPayerData)
|
|
}
|
|
|
|
// call callback with amount and conditionally comment
|
|
const res = await (await fetch(callback.toString())).json()
|
|
if (res.status === 'ERROR') {
|
|
throw new Error(res.reason)
|
|
}
|
|
|
|
// decode invoice
|
|
try {
|
|
const decoded = await parsePaymentRequest({ request: res.pr })
|
|
const ourPubkey = await getOurPubkey({ lnd })
|
|
if (autoWithdraw && decoded.destination === ourPubkey && process.env.NODE_ENV === 'production') {
|
|
// unset lnaddr so we don't trigger another withdrawal with same destination
|
|
await models.wallet.deleteMany({
|
|
where: { userId: me.id, type: 'LIGHTNING_ADDRESS' }
|
|
})
|
|
throw new Error('automated withdrawals to other stackers are not allowed')
|
|
}
|
|
if (!decoded.mtokens || BigInt(decoded.mtokens) !== BigInt(milliamount)) {
|
|
throw new Error('invoice has incorrect amount')
|
|
}
|
|
} catch (e) {
|
|
console.log(e)
|
|
throw e
|
|
}
|
|
|
|
return res
|
|
}
|