Skip to content

Commit

Permalink
Use HODL invoices (#432)
Browse files Browse the repository at this point in the history
* Use HODL invoices

* Fix expiry check comparing string with Date

* Fix unconfirmed user balance for HODL invoices

This is done by syncing the data from LND to the Invoice table.

If the columns is_held and msatsReceived are set, the frontend is told that we're ready to execute the action.

We then update the user balance in the same tx as the action.

We need to still keep checking the invoice for expiration though.

* Fix worker acting upon deleted invoices

* Prevent usage of invoice after expiration

* Use onComplete from <Countdown> to show expired status

* Remove unused lnd argument

* Fix item destructuring from query

* Fix balance added to every stacker

* Fix hmac required

* Fix invoices not used when logged in

* refactor: move invoiceable code into form

* renamed invoiceHash, invoiceHmac to hash, hmac since it's less verbose all over the place
* form now supports `invoiceable` in its props
* form then wraps `onSubmit` with `useInvoiceable` and passes optional invoice options

* Show expired if expired and canceled

* Also use useCallback for zapping

* Always expire modal invoices after 3m

* little styling thing

---------

Co-authored-by: ekzyis <[email protected]>
Co-authored-by: keyan <[email protected]>
Co-authored-by: Keyan <[email protected]>
  • Loading branch information
4 people authored Aug 31, 2023
1 parent c6dfd1e commit ac45fdc
Show file tree
Hide file tree
Showing 23 changed files with 339 additions and 313 deletions.
103 changes: 65 additions & 38 deletions api/resolvers/item.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { sendUserNotification } from '../webPush'
import { proxyImages } from './imgproxy'
import { defaultCommentSort } from '../../lib/item'
import { createHmac } from './wallet'
import { settleHodlInvoice } from 'ln-service'

export async function commentFilterClause (me, models) {
let clause = ` AND ("Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}`
Expand Down Expand Up @@ -52,15 +53,27 @@ async function checkInvoice (models, hash, hmac, fee) {
user: true
}
})

if (!invoice) {
throw new GraphQLError('invoice not found', { extensions: { code: 'BAD_INPUT' } })
}

const expired = new Date(invoice.expiresAt) <= new Date()
if (expired) {
throw new GraphQLError('invoice expired', { extensions: { code: 'BAD_INPUT' } })
}

if (invoice.cancelled) {
throw new GraphQLError('invoice was canceled', { extensions: { code: 'BAD_INPUT' } })
}

if (!invoice.msatsReceived) {
throw new GraphQLError('invoice was not paid', { extensions: { code: 'BAD_INPUT' } })
}
if (msatsToSats(invoice.msatsReceived) < fee) {
if (fee && msatsToSats(invoice.msatsReceived) < fee) {
throw new GraphQLError('invoice amount too low', { extensions: { code: 'BAD_INPUT' } })
}

return invoice
}

Expand Down Expand Up @@ -604,34 +617,34 @@ export default {

return await models.item.update({ where: { id: Number(id) }, data })
},
upsertLink: async (parent, { id, invoiceHash, invoiceHmac, ...item }, { me, models, lnd }) => {
upsertLink: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => {
await ssValidate(linkSchema, item, models)

if (id) {
return await updateItem(parent, { id, ...item }, { me, models })
} else {
return await createItem(parent, item, { me, models, lnd, invoiceHash, invoiceHmac })
return await createItem(parent, item, { me, models, lnd, hash, hmac })
}
},
upsertDiscussion: async (parent, { id, invoiceHash, invoiceHmac, ...item }, { me, models, lnd }) => {
upsertDiscussion: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => {
await ssValidate(discussionSchema, item, models)

if (id) {
return await updateItem(parent, { id, ...item }, { me, models })
} else {
return await createItem(parent, item, { me, models, lnd, invoiceHash, invoiceHmac })
return await createItem(parent, item, { me, models, lnd, hash, hmac })
}
},
upsertBounty: async (parent, { id, invoiceHash, invoiceHmac, ...item }, { me, models, lnd }) => {
upsertBounty: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => {
await ssValidate(bountySchema, item, models)

if (id) {
return await updateItem(parent, { id, ...item }, { me, models })
} else {
return await createItem(parent, item, { me, models, lnd, invoiceHash, invoiceHmac })
return await createItem(parent, item, { me, models, lnd, hash, hmac })
}
},
upsertPoll: async (parent, { id, invoiceHash, invoiceHmac, ...item }, { me, models, lnd }) => {
upsertPoll: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => {
const optionCount = id
? await models.pollOption.count({
where: {
Expand All @@ -646,10 +659,10 @@ export default {
return await updateItem(parent, { id, ...item }, { me, models })
} else {
item.pollCost = item.pollCost || POLL_COST
return await createItem(parent, item, { me, models, lnd, invoiceHash, invoiceHmac })
return await createItem(parent, item, { me, models, lnd, hash, hmac })
}
},
upsertJob: async (parent, { id, ...item }, { me, models }) => {
upsertJob: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => {
if (!me) {
throw new GraphQLError('you must be logged in to create job', { extensions: { code: 'FORBIDDEN' } })
}
Expand All @@ -665,16 +678,16 @@ export default {
if (id) {
return await updateItem(parent, { id, ...item }, { me, models })
} else {
return await createItem(parent, item, { me, models })
return await createItem(parent, item, { me, models, lnd, hash, hmac })
}
},
upsertComment: async (parent, { id, invoiceHash, invoiceHmac, ...item }, { me, models, lnd }) => {
upsertComment: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => {
await ssValidate(commentSchema, item)

if (id) {
return await updateItem(parent, { id, ...item }, { me, models })
} else {
const rItem = await createItem(parent, item, { me, models, lnd, invoiceHash, invoiceHmac })
const rItem = await createItem(parent, item, { me, models, lnd, hash, hmac })

const notify = async () => {
const user = await models.user.findUnique({ where: { id: me?.id || ANON_USER_ID } })
Expand Down Expand Up @@ -706,19 +719,19 @@ export default {

return id
},
act: async (parent, { id, sats, invoiceHash, invoiceHmac }, { me, models }) => {
act: async (parent, { id, sats, hash, hmac }, { me, models, lnd }) => {
// need to make sure we are logged in
if (!me && !invoiceHash) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
if (!me && !hash) {
throw new GraphQLError('you must be logged in or pay', { extensions: { code: 'FORBIDDEN' } })
}

await ssValidate(amountSchema, { amount: sats })

let user = me
let invoice
if (!me && invoiceHash) {
invoice = await checkInvoice(models, invoiceHash, invoiceHmac, sats)
user = invoice.user
if (hash) {
invoice = await checkInvoice(models, hash, hmac, sats)
if (!me) user = invoice.user
}

// disallow self tips except anons
Expand All @@ -738,14 +751,18 @@ export default {
throw new GraphQLError('cannot zap a post for which you are forwarded zaps', { extensions: { code: 'BAD_INPUT' } })
}

const calls = [
const trx = [
models.$queryRaw`SELECT item_act(${Number(id)}::INTEGER, ${user.id}::INTEGER, 'TIP', ${Number(sats)}::INTEGER)`
]
if (invoice) {
calls.push(models.invoice.delete({ where: { hash: invoice.hash } }))
trx.unshift(models.$queryRaw`UPDATE users SET msats = msats + ${invoice.msatsReceived} WHERE id = ${invoice.user.id}`)
trx.push(models.invoice.delete({ where: { hash: invoice.hash } }))
}

const [{ item_act: vote }] = await serialize(models, ...calls)
const query = await serialize(models, ...trx)
const { item_act: vote } = trx.length > 1 ? query[1][0] : query[0]

if (invoice?.isHeld) await settleHodlInvoice({ secret: invoice.preimage, lnd })

const notify = async () => {
try {
Expand Down Expand Up @@ -1098,24 +1115,27 @@ export const updateItem = async (parent, { sub: subName, forward, options, ...it
return item
}

export const createItem = async (parent, { forward, options, ...item }, { me, models, lnd, invoiceHash, invoiceHmac }) => {
let spamInterval = ITEM_SPAM_INTERVAL
const trx = []
export const createItem = async (parent, { forward, options, ...item }, { me, models, lnd, hash, hmac }) => {
const spamInterval = me ? ITEM_SPAM_INTERVAL : ANON_ITEM_SPAM_INTERVAL

// rename to match column name
item.subName = item.sub
delete item.sub

if (!me && !hash) {
throw new GraphQLError('you must be logged in or pay', { extensions: { code: 'FORBIDDEN' } })
}
let invoice
if (hash) {
// if we are logged in, we don't compare the invoice amount with the fee
// since it's not a fixed amount that we could use here.
// we rely on the query telling us if the balance is too low
const fee = !me ? (item.parentId ? ANON_COMMENT_FEE : ANON_POST_FEE) : undefined
invoice = await checkInvoice(models, hash, hmac, fee)
item.userId = invoice.user.id
}
if (me) {
item.userId = Number(me.id)
} else {
if (!invoiceHash) {
throw new GraphQLError('you must be logged in or pay', { extensions: { code: 'FORBIDDEN' } })
}
const invoice = await checkInvoice(models, invoiceHash, invoiceHmac, item.parentId ? ANON_COMMENT_FEE : ANON_POST_FEE)
item.userId = invoice.user.id
spamInterval = ANON_ITEM_SPAM_INTERVAL
trx.push(models.invoice.delete({ where: { hash: invoiceHash } }))
}

const fwdUsers = await getForwardUsers(models, forward)
Expand All @@ -1128,12 +1148,19 @@ export const createItem = async (parent, { forward, options, ...item }, { me, mo
item.url = await proxyImages(item.url)
}

const [result] = await serialize(
models,
const trx = [
models.$queryRawUnsafe(`${SELECT} FROM create_item($1::JSONB, $2::JSONB, $3::JSONB, '${spamInterval}'::INTERVAL) AS "Item"`,
JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options)),
...trx)
item = Array.isArray(result) ? result[0] : result
JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options))
]
if (invoice) {
trx.unshift(models.$queryRaw`UPDATE users SET msats = msats + ${invoice.msatsReceived} WHERE id = ${invoice.user.id}`)
trx.push(models.invoice.delete({ where: { hash: invoice.hash } }))
}

const query = await serialize(models, ...trx)
item = trx.length > 1 ? query[1][0] : query[0]

if (invoice?.isHeld) await settleHodlInvoice({ secret: invoice.preimage, lnd })

await createMentions(item, models)

Expand Down
28 changes: 24 additions & 4 deletions api/resolvers/wallet.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createInvoice, decodePaymentRequest, payViaPaymentRequest } from 'ln-service'
import { createHodlInvoice, createInvoice, decodePaymentRequest, payViaPaymentRequest, cancelHodlInvoice } from 'ln-service'
import { GraphQLError } from 'graphql'
import crypto from 'crypto'
import serialize from './serial'
Expand All @@ -11,7 +11,7 @@ import { amountSchema, lnAddrSchema, ssValidate, withdrawlSchema } from '../../l
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 }) {
export async function getInvoice (parent, { id }, { me, models, lnd }) {
const inv = await models.invoice.findUnique({
where: {
id: Number(id)
Expand All @@ -24,6 +24,7 @@ export async function getInvoice (parent, { id }, { me, models }) {
if (!inv) {
throw new GraphQLError('invoice not found', { extensions: { code: 'BAD_INPUT' } })
}

if (inv.user.id === ANON_USER_ID) {
return inv
}
Expand Down Expand Up @@ -223,7 +224,7 @@ export default {
},

Mutation: {
createInvoice: async (parent, { amount, expireSecs = 3600 }, { me, models, lnd }) => {
createInvoice: async (parent, { amount, hodlInvoice = false, expireSecs = 3600 }, { me, models, lnd }) => {
await ssValidate(amountSchema, { amount })

let expirePivot = { seconds: expireSecs }
Expand All @@ -242,7 +243,7 @@ export default {
const expiresAt = datePivot(new Date(), expirePivot)
const description = `Funding @${user.name} on stacker.news`
try {
const invoice = await createInvoice({
const invoice = await (hodlInvoice ? createHodlInvoice : createInvoice)({
description: user.hideInvoiceDesc ? undefined : description,
lnd,
tokens: amount,
Expand All @@ -254,6 +255,8 @@ export default {
${expiresAt}::timestamp, ${amount * 1000}, ${user.id}::INTEGER, ${description},
${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
Expand Down Expand Up @@ -312,6 +315,23 @@ export default {

// take pr and createWithdrawl
return await createWithdrawal(parent, { invoice: res2.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
}
},

Expand Down
14 changes: 7 additions & 7 deletions api/typeDefs/item.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,15 @@ export default gql`
bookmarkItem(id: ID): Item
subscribeItem(id: ID): Item
deleteItem(id: ID): Item
upsertLink(id: ID, sub: String, title: String!, url: String!, boost: Int, forward: [ItemForwardInput], invoiceHash: String, invoiceHmac: String): Item!
upsertDiscussion(id: ID, sub: String, title: String!, text: String, boost: Int, forward: [ItemForwardInput], invoiceHash: String, invoiceHmac: String): Item!
upsertBounty(id: ID, sub: String, title: String!, text: String, bounty: Int!, boost: Int, forward: [ItemForwardInput]): Item!
upsertLink(id: ID, sub: String, title: String!, url: String!, boost: Int, forward: [ItemForwardInput], hash: String, hmac: String): Item!
upsertDiscussion(id: ID, sub: String, title: String!, text: String, boost: Int, forward: [ItemForwardInput], hash: String, hmac: String): Item!
upsertBounty(id: ID, sub: String, title: String!, text: String, bounty: Int, hash: String, hmac: String, boost: Int, forward: [ItemForwardInput]): Item!
upsertJob(id: ID, sub: String!, title: String!, company: String!, location: String, remote: Boolean,
text: String!, url: String!, maxBid: Int!, status: String, logo: Int): Item!
upsertPoll(id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: [ItemForwardInput], invoiceHash: String, invoiceHmac: String): Item!
upsertComment(id:ID, text: String!, parentId: ID, invoiceHash: String, invoiceHmac: String): Item!
text: String!, url: String!, maxBid: Int!, status: String, logo: Int, hash: String, hmac: String): Item!
upsertPoll(id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: [ItemForwardInput], hash: String, hmac: String): Item!
upsertComment(id:ID, text: String!, parentId: ID, hash: String, hmac: String): Item!
dontLikeThis(id: ID!): Boolean!
act(id: ID!, sats: Int, invoiceHash: String, invoiceHmac: String): ItemActResult!
act(id: ID!, sats: Int, hash: String, hmac: String): ItemActResult!
pollVote(id: ID!): ID!
}
Expand Down
4 changes: 3 additions & 1 deletion api/typeDefs/wallet.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ export default gql`
}
extend type Mutation {
createInvoice(amount: Int!, expireSecs: Int): Invoice!
createInvoice(amount: Int!, expireSecs: Int, hodlInvoice: Boolean): Invoice!
createWithdrawl(invoice: String!, maxFee: Int!): Withdrawl!
sendToLnAddr(addr: String!, amount: Int!, maxFee: Int!): Withdrawl!
cancelInvoice(hash: String!, hmac: String!): Invoice!
}
type Invoice {
Expand All @@ -26,6 +27,7 @@ export default gql`
satsRequested: Int!
nostr: JSONObject
hmac: String
isHeld: Boolean
}
type Withdrawl {
Expand Down
Loading

0 comments on commit ac45fdc

Please sign in to comment.