SatsAllDay 362f95add9
LUD-18 Wallet implementation (#531)
* LUD-18 Wallet implementation

Query the lightning address provider client-side to learn of capabilities

Conditionally render LUD-12 and LUD-18 fields based on what the remote
server says is supported

Allow identifier, name, and email to be sent from the SN side during the withdrawal flow. Auth seems too complicated for our use case, and idk about pubkey?

* Clear inputs if the new ln addr provier doesn't support those fields

* various ux improvements

* dynamic client-side validation for required payer data

* don't re-init form state on error

* correct min and max amount values

* only send applicable data to graphql api based on payerdata schema

* input type for numeric values (amount, max fee)

* update step for amount and max fee

* Fix identifier optional and field blur

* reuse more code

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
2023-10-03 18:22:56 -05:00

420 lines
14 KiB
JavaScript

import { createHodlInvoice, createInvoice, decodePaymentRequest, payViaPaymentRequest, cancelHodlInvoice } from 'ln-service'
import { GraphQLError } from 'graphql'
import crypto from 'crypto'
import serialize from './serial'
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
import lnpr from 'bolt11'
import { SELECT } from './item'
import { lnAddrOptions, lnurlPayDescriptionHash } from '../../lib/lnurl'
import { msatsToSats, msatsToSatsDecimal } from '../../lib/format'
import { amountSchema, lnAddrSchema, ssValidate, withdrawlSchema } from '../../lib/validate'
import { ANON_BALANCE_LIMIT_MSATS, ANON_INV_PENDING_LIMIT, ANON_USER_ID, BALANCE_LIMIT_MSATS, INV_PENDING_LIMIT } from '../../lib/constants'
import { datePivot } from '../../lib/time'
export async function getInvoice (parent, { id }, { me, models, lnd }) {
const inv = await models.invoice.findUnique({
where: {
id: Number(id)
},
include: {
user: true
}
})
if (!inv) {
throw new GraphQLError('invoice not found', { extensions: { code: 'BAD_INPUT' } })
}
if (inv.user.id === ANON_USER_ID) {
return inv
}
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
}
if (inv.user.id !== me.id) {
throw new GraphQLError('not ur invoice', { extensions: { code: 'FORBIDDEN' } })
}
try {
inv.nostr = JSON.parse(inv.desc)
} catch (err) {
}
return inv
}
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 default {
Query: {
invoice: getInvoice,
withdrawl: async (parent, { id }, { me, models, lnd }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
}
const wdrwl = await models.withdrawl.findUnique({
where: {
id: Number(id)
},
include: {
user: true
}
})
if (wdrwl.user.id !== me.id) {
throw new GraphQLError('not ur withdrawal', { extensions: { code: 'FORBIDDEN' } })
}
return wdrwl
},
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 GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
}
const include = new Set(inc?.split(','))
const queries = []
if (include.has('invoice')) {
queries.push(
`(SELECT ('invoice' || id) as id, id as "factId", bolt11, created_at as "createdAt",
COALESCE("msatsReceived", "msatsRequested") as msats, NULL as "msatsFee",
CASE WHEN "confirmedAt" IS NOT NULL THEN 'CONFIRMED'
WHEN "expiresAt" <= $2 THEN 'EXPIRED'
WHEN cancelled THEN 'CANCELLED'
ELSE 'PENDING' END as status,
"desc" as description,
comment as "invoiceComment",
"lud18Data" as "invoicePayerData",
'invoice' as type
FROM "Invoice"
WHERE "userId" = $1
AND created_at <= $2)`)
}
if (include.has('withdrawal')) {
queries.push(
`(SELECT ('withdrawal' || id) as id, id as "factId", bolt11, created_at as "createdAt",
CASE WHEN status = 'CONFIRMED' THEN "msatsPaid"
ELSE "msatsPaying" END as msats,
CASE WHEN status = 'CONFIRMED' THEN "msatsFeePaid"
ELSE "msatsFeePaying" END as "msatsFee",
COALESCE(status::text, 'PENDING') as status,
NULL as description,
NULL as "invoiceComment",
NULL as "invoicePayerData",
'withdrawal' as type
FROM "Withdrawl"
WHERE "userId" = $1
AND created_at <= $2)`)
}
if (include.has('stacked')) {
// query1 - get all sats stacked as OP or as a forward
queries.push(
`(SELECT
('stacked' || "Item".id) AS id,
"Item".id AS "factId",
NULL AS bolt11,
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",
0 AS "msatsFee",
NULL AS status,
NULL as description,
NULL as "invoiceComment",
NULL as "invoicePayerData",
'stacked' AS type
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
GROUP BY "Item".id)`
)
queries.push(
`(SELECT ('earn' || min("Earn".id)) as id, min("Earn".id) as "factId", NULL as bolt11,
created_at as "createdAt", sum(msats),
0 as "msatsFee", NULL as status, NULL as description, NULL as "invoiceComment", NULL as "invoicePayerData", 'earn' as type
FROM "Earn"
WHERE "Earn"."userId" = $1 AND "Earn".created_at <= $2
GROUP BY "userId", created_at)`)
queries.push(
`(SELECT ('referral' || "ReferralAct".id) as id, "ReferralAct".id as "factId", NULL as bolt11,
created_at as "createdAt", msats,
0 as "msatsFee", NULL as status, NULL as description, NULL as "invoiceComment", NULL as "invoicePayerData", 'referral' as type
FROM "ReferralAct"
WHERE "ReferralAct"."referrerId" = $1 AND "ReferralAct".created_at <= $2)`)
}
if (include.has('spent')) {
queries.push(
`(SELECT ('spent' || "Item".id) as id, "Item".id as "factId", NULL as bolt11,
MAX("ItemAct".created_at) as "createdAt", sum("ItemAct".msats) as msats,
0 as "msatsFee", NULL as status, NULL as description, NULL as "invoiceComment", NULL as "invoicePayerData", 'spent' as type
FROM "ItemAct"
JOIN "Item" on "ItemAct"."itemId" = "Item".id
WHERE "ItemAct"."userId" = $1
AND "ItemAct".created_at <= $2
GROUP BY "Item".id)`)
queries.push(
`(SELECT ('donation' || "Donation".id) as id, "Donation".id as "factId", NULL as bolt11,
created_at as "createdAt", sats * 1000 as msats,
0 as "msatsFee", NULL as status, NULL as description, NULL as "invoiceComment", NULL as "invoicePayerData", 'donation' as type
FROM "Donation"
WHERE "userId" = $1
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 => {
if (f.bolt11) {
const inv = lnpr.decode(f.bolt11)
if (inv) {
const { tags } = inv
for (const tag of tags) {
if (tag.tagName === 'description') {
// prioritize description from bolt11 over description from our DB
f.description = tag.data
break
}
}
}
}
switch (f.type) {
case 'withdrawal':
f.msats = (-1 * Number(f.msats)) - Number(f.msatsFee)
break
case 'spent':
f.msats *= -1
break
case 'donation':
f.msats *= -1
break
default:
break
}
return f
})
return {
cursor: history.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
facts: history
}
}
},
Mutation: {
createInvoice: async (parent, { amount, hodlInvoice = false, expireSecs = 3600 }, { me, models, lnd }) => {
await ssValidate(amountSchema, { amount })
let expirePivot = { seconds: expireSecs }
let invLimit = INV_PENDING_LIMIT
let balanceLimit = BALANCE_LIMIT_MSATS
let id = me?.id
if (!me) {
expirePivot = { seconds: Math.min(expireSecs, 180) }
invLimit = ANON_INV_PENDING_LIMIT
balanceLimit = ANON_BALANCE_LIMIT_MSATS
id = ANON_USER_ID
}
const user = await models.user.findUnique({ where: { id } })
const expiresAt = datePivot(new Date(), expirePivot)
const description = `Funding @${user.name} on stacker.news`
try {
const invoice = await (hodlInvoice ? createHodlInvoice : createInvoice)({
description: user.hideInvoiceDesc ? undefined : description,
lnd,
tokens: amount,
expires_at: expiresAt
})
const [inv] = await serialize(models,
models.$queryRaw`SELECT * FROM create_invoice(${invoice.id}, ${invoice.request},
${expiresAt}::timestamp, ${amount * 1000}, ${user.id}::INTEGER, ${description}, NULL, NULL,
${invLimit}::INTEGER, ${balanceLimit})`)
if (hodlInvoice) await models.invoice.update({ where: { hash: invoice.id }, data: { preimage: invoice.secret } })
// the HMAC is only returned during invoice creation
// this makes sure that only the person who created this invoice
// has access to the HMAC
const hmac = createHmac(inv.hash)
return { ...inv, hmac }
} catch (error) {
console.log(error)
throw error
}
},
createWithdrawl: createWithdrawal,
sendToLnAddr: async (parent, { addr, amount, maxFee, comment, ...payer }, { me, models, lnd }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
}
const options = await lnAddrOptions(addr)
await ssValidate(lnAddrSchema, { addr, amount, maxFee, comment, ...payer }, options)
if (payer) {
payer = {
...payer,
identifier: payer.identifier ? me.name : undefined
}
}
const milliamount = 1000 * amount
const callback = new URL(options.callback)
callback.searchParams.append('amount', milliamount)
if (comment?.length) {
callback.searchParams.append('comment', comment)
}
let encodedPayerData = ''
if (payer) {
encodedPayerData = encodeURIComponent(JSON.stringify(payer))
callback.searchParams.append('payerdata', encodedPayerData)
}
// 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 decodePaymentRequest({ lnd, request: res.pr })
if (decoded.description_hash !== lnurlPayDescriptionHash(`${options.metadata}${encodedPayerData}`)) {
throw new Error('description hash does not match')
}
if (!decoded.mtokens || BigInt(decoded.mtokens) !== BigInt(milliamount)) {
throw new Error('invoice has incorrect amount')
}
} catch (e) {
console.log(e)
throw e
}
// take pr and createWithdrawl
return await createWithdrawal(parent, { invoice: res.pr, maxFee }, { me, models, lnd })
},
cancelInvoice: async (parent, { hash, hmac }, { models, lnd }) => {
const hmac2 = createHmac(hash)
if (hmac !== hmac2) {
throw new GraphQLError('bad hmac', { extensions: { code: 'FORBIDDEN' } })
}
await cancelHodlInvoice({ id: hash, lnd })
const inv = await serialize(models,
models.invoice.update({
where: {
hash
},
data: {
cancelled: true
}
}))
return inv
}
},
Withdrawl: {
satsPaying: w => msatsToSats(w.msatsPaying),
satsPaid: w => msatsToSats(w.msatsPaid),
satsFeePaying: w => msatsToSats(w.msatsFeePaying),
satsFeePaid: w => msatsToSats(w.msatsFeePaid)
},
Invoice: {
satsReceived: i => msatsToSats(i.msatsReceived),
satsRequested: i => msatsToSats(i.msatsRequested)
},
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.factId))
return item
},
sats: fact => msatsToSatsDecimal(fact.msats),
satsFee: fact => msatsToSatsDecimal(fact.msatsFee)
}
}
async function createWithdrawal (parent, { invoice, maxFee }, { me, models, lnd }) {
await ssValidate(withdrawlSchema, { invoice, maxFee })
// remove 'lightning:' prefix if present
invoice = invoice.replace(/^lightning:/, '')
// decode invoice to get amount
let decoded
try {
decoded = await decodePaymentRequest({ lnd, request: invoice })
} catch (error) {
console.log(error)
throw new GraphQLError('could not decode invoice', { extensions: { code: 'BAD_INPUT' } })
}
if (!decoded.mtokens || BigInt(decoded.mtokens) <= 0) {
throw new GraphQLError('your invoice must specify an amount', { extensions: { code: 'BAD_INPUT' } })
}
const msatsFee = Number(maxFee) * 1000
const user = await models.user.findUnique({ where: { id: me.id } })
// create withdrawl transactionally (id, bolt11, amount, fee)
const [withdrawl] = await serialize(models,
models.$queryRaw`SELECT * FROM create_withdrawl(${decoded.id}, ${invoice},
${Number(decoded.mtokens)}, ${msatsFee}, ${user.name})`)
payViaPaymentRequest({
lnd,
request: invoice,
// can't use max_fee_mtokens https://github.com/alexbosworth/ln-service/issues/141
max_fee: Number(maxFee),
pathfinding_timeout: 30000
})
return withdrawl
}