Skip to content

Commit

Permalink
receiver fallback and lnurlp
Browse files Browse the repository at this point in the history
  • Loading branch information
riccardobl committed Oct 30, 2024
1 parent 1856520 commit e647d2e
Show file tree
Hide file tree
Showing 19 changed files with 936 additions and 390 deletions.
535 changes: 313 additions & 222 deletions api/paidAction/index.js

Large diffs are not rendered by default.

53 changes: 53 additions & 0 deletions api/paidAction/transfer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { satsToMsats } from '@/lib/format'
import { notifyDeposit } from '@/lib/webPush'
export const anonable = true
export const supportsPessimism = true
export const supportsOptimism = false

export async function getCost ({ sats }) {
return satsToMsats(sats)
}

export async function invoiceablePeer ({ targetUserId }, { models }) {
return targetUserId
}

export async function perform ({ invoiceId, sats, description, descriptionHash, comment, targetUserId }, { me, tx }) {
const invoice = await tx.invoice.update({
where: { id: invoiceId },
data: {
comment
}
})
await notifyDeposit(targetUserId, invoice)
return { sats, targetUserId }
}

export async function describe ({ sats, description, descriptionHash }, context) {
return `SN: ${description ?? ''}`
}

export async function describeHash ({ sats, description, descriptionHash }, context) {
return descriptionHash
}

export async function getSybilFeePercent ({ sats, description, descriptionHash }, context) {
return 10n
}

export async function onPaid ({ invoice }, { tx }) {
const isP2P = !!invoice.invoiceForward
if (isP2P) return
if (!invoice?.msatsReceived) throw new Error('Not paid?')
const targetUserId = invoice.actionArgs?.targetUserId
if (!targetUserId) throw new Error('No targetUserId')

await tx.user.update({
where: { id: targetUserId },
data: {
msats: {
increment: invoice.msatsReceived
}
}
})
}
13 changes: 8 additions & 5 deletions api/resolvers/item.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ import { uploadIdsFromText } from './upload'
import assertGofacYourself from './ofac'
import assertApiKeyNotPermitted from './apiKey'
import performPaidAction from '../paidAction'
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
import { verifyHmac } from './wallet'
import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error'
import { verifyHmac } from '@/lib/crypto'

function commentsOrderByClause (me, models, sort) {
if (sort === 'recent') {
Expand Down Expand Up @@ -1351,10 +1351,13 @@ export const updateItem = async (parent, { sub: subName, forward, hash, hmac, ..
const adminEdit = ADMIN_ITEMS.includes(old.id) && SN_ADMIN_IDS.includes(meId)
// anybody can edit with valid hash+hmac
let hmacEdit = false
if (old.invoice?.hash && hash && hmac) {
hmacEdit = old.invoice.hash === hash && verifyHmac(hash, hmac)
try {
if (old.invoice?.hash && hash && hmac) {
hmacEdit = old.invoice.hash === hash && verifyHmac(hash, hmac)
}
} catch (e) {
throw new GqlAuthorizationError(e.message)
}

// ownership permission check
if (!authorEdit && !adminEdit && !hmacEdit) {
throw new GqlInputError('item does not belong to you')
Expand Down
11 changes: 11 additions & 0 deletions api/resolvers/notifications.js
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,17 @@ export default {
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
)
queries.push(
`(SELECT "Invoice".id::text, "Invoice"."confirmedAt" AS "sortTime", FLOOR("msatsReceived" / 1000) as "earnedSats",
'InvoicePaid' AS type
FROM "Invoice"
WHERE "Invoice"."userId" = $1
AND "confirmedAt" IS NOT NULL
AND "actionType" = 'TRANSFER'
AND created_at < $2
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
)
}

if (meFull.noteWithdrawals) {
Expand Down
2 changes: 2 additions & 0 deletions api/resolvers/paidAction.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ function paidActionType (actionType) {
return 'SubPaidAction'
case 'DONATE':
return 'DonatePaidAction'
case 'TRANSFER':
return 'TransferPaidAction'
case 'POLL_VOTE':
return 'PollVotePaidAction'
default:
Expand Down
30 changes: 7 additions & 23 deletions api/resolvers/wallet.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
getInvoice as getInvoiceFromLnd, deletePayment, getPayment,
parsePaymentRequest
} from 'ln-service'
import crypto, { timingSafeEqual } from 'crypto'
import { createHmac, verifyHmac } from '@/lib/crypto'
import serialize from './serial'
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { SELECT, itemQueryWithMeta } from './item'
Expand All @@ -23,6 +23,7 @@ import { generateResolverName, generateTypeDefName } from '@/lib/wallet'
import { lnAddrOptions } from '@/lib/lnurl'
import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error'
import { getNodeSockets, getOurPubkey } from '../lnd'
import { addWalletLog } from '@/wallets/server'

function injectResolvers (resolvers) {
console.group('injected GraphQL resolvers:')
Expand Down Expand Up @@ -117,19 +118,6 @@ export async function getWithdrawl (parent, { id }, { me, models, lnd }) {
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,
Expand Down Expand Up @@ -465,7 +453,11 @@ const resolvers = {
createWithdrawl: createWithdrawal,
sendToLnAddr,
cancelInvoice: async (parent, { hash, hmac }, { models, lnd, boss }) => {
verifyHmac(hash, hmac)
try {
verifyHmac(hash, hmac)
} catch (err) {
throw new GqlAuthorizationError(err.message)
}
await finalizeHodlInvoice({ data: { hash }, lnd, models, boss })
return await models.invoice.findFirst({ where: { hash } })
},
Expand Down Expand Up @@ -618,14 +610,6 @@ const resolvers = {

export default injectResolvers(resolvers)

export const addWalletLog = async ({ wallet, level, message }, { models }) => {
try {
await models.walletLog.create({ data: { userId: wallet.userId, wallet: wallet.type, level, message } })
} catch (err) {
console.error('error creating wallet log:', err)
}
}

async function upsertWallet (
{ wallet, testCreateInvoice }, { settings, data, priorityOnly }, { me, models }) {
if (!me) {
Expand Down
6 changes: 6 additions & 0 deletions api/typeDefs/paidAction.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,10 @@ type DonatePaidAction implements PaidAction {
paymentMethod: PaymentMethod!
}
type TransferPaidAction implements PaidAction {
result: TransferResult
invoice: Invoice
paymentMethod: PaymentMethod!
}
`
4 changes: 4 additions & 0 deletions api/typeDefs/rewards.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ export default gql`
sats: Int!
}
type TransferResult {
sats: Int!
}
type Rewards {
total: Int!
time: Date!
Expand Down
3 changes: 2 additions & 1 deletion lib/apollo.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ function getClient (uri) {
'ItemActPaidAction',
'PollVotePaidAction',
'SubPaidAction',
'DonatePaidAction'
'DonatePaidAction',
'TransferPaidAction'
],
Notification: [
'Reply',
Expand Down
11 changes: 11 additions & 0 deletions lib/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
export const DEFAULT_SUBS = ['bitcoin', 'nostr', 'tech', 'meta', 'jobs']
export const DEFAULT_SUBS_NO_JOBS = DEFAULT_SUBS.filter(s => s !== 'jobs')

/**
* @typedef {import('@prisma/client').InvoiceActionState} InvoiceActionState
* @type {InvoiceActionState[]}
*/
export const PAID_ACTION_TERMINAL_STATES = ['FAILED', 'PAID', 'RETRYING']
export const NOFOLLOW_LIMIT = 1000
export const UNKNOWN_LINK_REL = 'noreferrer nofollow noopener'
Expand Down Expand Up @@ -182,3 +186,10 @@ 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 INVOICE_EXPIRE_SECS = 600
export const MAX_PENDING_INVOICES_PER_WALLET = 25
export const MAX_PENDING_PAID_ACTIONS_PER_USER = 100
// the fee for the zap sybil service
export const ZAP_SYBIL_FEE_PERCENT = 30n
export const MAX_FEE_ESTIMATE_PERMILE = 25n // the maximum fee relative to outgoing we'll allow for the fee estimate
15 changes: 14 additions & 1 deletion lib/crypto.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createHash } from 'node:crypto'
import crypto, { createHash } from 'node:crypto'
import { timingSafeEqual } from 'crypto'

export function hashEmail ({
email,
Expand All @@ -7,3 +8,15 @@ export function hashEmail ({
const saltedEmail = `${email.toLowerCase()}${salt}`
return createHash('sha256').update(saltedEmail).digest('hex')
}

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 Error('bad hmac')
}
return true
}
70 changes: 65 additions & 5 deletions lib/validate.js
Original file line number Diff line number Diff line change
Expand Up @@ -829,18 +829,78 @@ export const lud18PayerDataSchema = (k1) => object({
// returns true for every number in this range: [-Infinity, ..., 0, ..., Infinity]
export const isNumber = x => typeof x === 'number' && !Number.isNaN(x)

/**
*
* @param {any | bigint} x
* @param {number} min
* @param {number} max
* @returns {number}
*/
export const toNumber = (x, min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER) => {
if (typeof x === 'undefined') {
throw new Error('value is required')
}
const n = Number(x)
if (isNumber(n)) {
if (x < min || x > max) {
if (typeof x === 'bigint') {
if (x < BigInt(min) || x > BigInt(max)) {
throw new Error(`value ${x} must be between ${min} and ${max}`)
}
return n
return Number(x)
} else {
const n = Number(x)
if (isNumber(n)) {
if (x < min || x > max) {
throw new Error(`value ${x} must be between ${min} and ${max}`)
}
return n
}
}
throw new Error(`value ${x} is not a number`)
}

export const toPositiveNumber = (x) => toNumber(x, 0)
/**
* @param {number | bigint} x
* @returns {number}
*/
export const toPositiveNumber = (x) => {
return toNumber(x, 0)
}

/**
* @param {any} x
* @param {bigint | number} [min]
* @param {bigint | number} [max]
* @returns {bigint}
*/
export const toBigInt = (x, min, max) => {
if (typeof x === 'undefined') throw new Error('value is required')
min = min !== undefined ? BigInt(min) : undefined
max = max !== undefined ? BigInt(max) : undefined

const n = BigInt(x)
if (min !== undefined && n < min) {
throw new Error(`value ${x} must be at least ${min}`)
}

if (max !== undefined && n > max) {
throw new Error(`value ${x} must be at most ${max}`)
}

return n
}

/**
* @param {number|bigint} x
* @returns {bigint}
*/
export const toPositiveBigInt = (x) => {
return toBigInt(x, 0)
}

/**
* @param {number|bigint} x
* @returns {number|bigint}
*/
export const toPositive = (x) => {
if (typeof x === 'bigint') return toPositiveBigInt(x)
return toPositiveNumber(x)
}
36 changes: 13 additions & 23 deletions pages/api/lnurlp/[username]/pay.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
import models from '@/api/models'
import lnd from '@/api/lnd'
import { createInvoice } from 'ln-service'
import { lnurlPayDescriptionHashForUser, lnurlPayMetadataString, lnurlPayDescriptionHash } from '@/lib/lnurl'
import serialize from '@/api/resolvers/serial'
import { schnorr } from '@noble/curves/secp256k1'
import { createHash } from 'crypto'
import { datePivot } from '@/lib/time'
import { BALANCE_LIMIT_MSATS, INV_PENDING_LIMIT, LNURLP_COMMENT_MAX_LENGTH, USER_IDS_BALANCE_NO_LIMIT } from '@/lib/constants'
import { ssValidate, lud18PayerDataSchema } from '@/lib/validate'
import { LNURLP_COMMENT_MAX_LENGTH } from '@/lib/constants'
import { ssValidate, lud18PayerDataSchema, toPositiveNumber } from '@/lib/validate'
import assertGofacYourself from '@/api/resolvers/ofac'

import performPaidAction from '@/api/paidAction'
import { msatsToSats } from '@/lib/format'
export default async ({ query: { username, amount, nostr, comment, payerdata: payerData }, headers }, res) => {
const user = await models.user.findUnique({ where: { name: username } })
if (!user) {
Expand Down Expand Up @@ -71,27 +69,19 @@ export default async ({ query: { username, amount, nostr, comment, payerdata: pa
}

// generate invoice
const expiresAt = datePivot(new Date(), { minutes: 5 })
const invoice = await createInvoice({
const actionResponse = await performPaidAction('TRANSFER', {
sats: toPositiveNumber(msatsToSats(BigInt(amount))),
description,
description_hash: descriptionHash,
lnd,
mtokens: amount,
expires_at: expiresAt
})

await serialize(
models.$queryRaw`SELECT * FROM create_invoice(${invoice.id}, ${invoice.secret}::TEXT, ${invoice.request},
${expiresAt}::timestamp, ${Number(amount)}, ${user.id}::INTEGER, ${noteStr || description},
${comment || null}, ${parsedPayerData || null}::JSONB, ${INV_PENDING_LIMIT}::INTEGER,
${USER_IDS_BALANCE_NO_LIMIT.includes(Number(user.id)) ? 0 : BALANCE_LIMIT_MSATS})`,
{ models }
)
descriptionHash,
comment: comment || '',
targetUserId: user.id
}, { models, lnd, me: user, disableFeeCredit: true })

if (!actionResponse.invoice?.bolt11) throw new Error('could not generate invoice')
return res.status(200).json({
pr: invoice.request,
pr: actionResponse.invoice.bolt11,
routes: [],
verify: `${process.env.NEXT_PUBLIC_URL}/api/lnurlp/${username}/verify/${invoice.id}`
verify: `${process.env.NEXT_PUBLIC_URL}/api/lnurlp/${username}/verify/${actionResponse.invoice.hash}`
})
} catch (error) {
console.log(error)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "InvoiceActionType" ADD VALUE 'TRANSFER';
Loading

0 comments on commit e647d2e

Please sign in to comment.