Skip to content

Commit c40092d

Browse files
authored
Merge branch 'master' into anon_webln_qr_fallback
2 parents d672d38 + 4651b36 commit c40092d

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+599
-179
lines changed

api/paidAction/index.js

+38-28
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { datePivot } from '@/lib/time'
33
import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
44
import { createHmac } from '@/api/resolvers/wallet'
55
import { Prisma } from '@prisma/client'
6-
import { createWrappedInvoice, createInvoice as createUserInvoice } from '@/wallets/server'
6+
import { createWrappedInvoice, createUserInvoice } from '@/wallets/server'
77
import { assertBelowMaxPendingInvoices, assertBelowMaxPendingDirectPayments } from './lib/assert'
88

99
import * as ITEM_CREATE from './itemCreate'
@@ -264,42 +264,51 @@ async function performDirectAction (actionType, args, incomingContext) {
264264
throw new NonInvoiceablePeerError()
265265
}
266266

267-
let invoiceObject
268-
269267
try {
270268
await assertBelowMaxPendingDirectPayments(userId, incomingContext)
271269

272270
const description = actionDescription ?? await paidActions[actionType].describe(args, incomingContext)
273-
invoiceObject = await createUserInvoice(userId, {
271+
272+
for await (const { invoice, logger, wallet } of createUserInvoice(userId, {
274273
msats: cost,
275274
description,
276275
expiry: INVOICE_EXPIRE_SECS
277-
}, { models, lnd })
278-
} catch (e) {
279-
console.error('failed to create outside invoice', e)
280-
throw new NonInvoiceablePeerError()
281-
}
282-
283-
const { invoice, wallet } = invoiceObject
284-
const hash = parsePaymentRequest({ request: invoice }).id
276+
}, { models, lnd })) {
277+
let hash
278+
try {
279+
hash = parsePaymentRequest({ request: invoice }).id
280+
} catch (e) {
281+
console.error('failed to parse invoice', e)
282+
logger?.error('failed to parse invoice: ' + e.message, { bolt11: invoice })
283+
continue
284+
}
285285

286-
const payment = await models.directPayment.create({
287-
data: {
288-
comment,
289-
lud18Data,
290-
desc: noteStr,
291-
bolt11: invoice,
292-
msats: cost,
293-
hash,
294-
walletId: wallet.id,
295-
receiverId: userId
286+
try {
287+
return {
288+
invoice: await models.directPayment.create({
289+
data: {
290+
comment,
291+
lud18Data,
292+
desc: noteStr,
293+
bolt11: invoice,
294+
msats: cost,
295+
hash,
296+
walletId: wallet.id,
297+
receiverId: userId
298+
}
299+
}),
300+
paymentMethod: 'DIRECT'
301+
}
302+
} catch (e) {
303+
console.error('failed to create direct payment', e)
304+
logger?.error('failed to create direct payment: ' + e.message, { bolt11: invoice })
305+
}
296306
}
297-
})
298-
299-
return {
300-
invoice: payment,
301-
paymentMethod: 'DIRECT'
307+
} catch (e) {
308+
console.error('failed to create user invoice', e)
302309
}
310+
311+
throw new NonInvoiceablePeerError()
303312
}
304313

305314
export async function retryPaidAction (actionType, args, incomingContext) {
@@ -419,7 +428,7 @@ async function createSNInvoice (actionType, args, context) {
419428
}
420429

421430
async function createDbInvoice (actionType, args, context) {
422-
const { me, models, tx, cost, optimistic, actionId, invoiceArgs, predecessorId } = context
431+
const { me, models, tx, cost, optimistic, actionId, invoiceArgs, paymentAttempt, predecessorId } = context
423432
const { bolt11, wrappedBolt11, preimage, wallet, maxFee } = invoiceArgs
424433

425434
const db = tx ?? models
@@ -445,6 +454,7 @@ async function createDbInvoice (actionType, args, context) {
445454
actionArgs: args,
446455
expiresAt,
447456
actionId,
457+
paymentAttempt,
448458
predecessorId
449459
}
450460

api/resolvers/notifications.js

+34-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { pushSubscriptionSchema, validateSchema } from '@/lib/validate'
55
import { replyToSubscription } from '@/lib/webPush'
66
import { getSub } from './sub'
77
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
8+
import { WALLET_MAX_RETRIES, WALLET_RETRY_BEFORE_MS } from '@/lib/constants'
89

910
export default {
1011
Query: {
@@ -345,11 +346,25 @@ export default {
345346
)
346347

347348
queries.push(
348-
`(SELECT "Invoice".id::text, "Invoice"."updated_at" AS "sortTime", NULL as "earnedSats", 'Invoicification' AS type
349+
`(SELECT "Invoice".id::text,
350+
CASE
351+
WHEN
352+
"Invoice"."paymentAttempt" < ${WALLET_MAX_RETRIES}
353+
AND "Invoice"."userCancel" = false
354+
AND "Invoice"."cancelledAt" <= now() - interval '${`${WALLET_RETRY_BEFORE_MS} milliseconds`}'
355+
THEN "Invoice"."cancelledAt" + interval '${`${WALLET_RETRY_BEFORE_MS} milliseconds`}'
356+
ELSE "Invoice"."updated_at"
357+
END AS "sortTime", NULL as "earnedSats", 'Invoicification' AS type
349358
FROM "Invoice"
350359
WHERE "Invoice"."userId" = $1
351360
AND "Invoice"."updated_at" < $2
352361
AND "Invoice"."actionState" = 'FAILED'
362+
AND (
363+
-- this is the inverse of the filter for automated retries
364+
"Invoice"."paymentAttempt" >= ${WALLET_MAX_RETRIES}
365+
OR "Invoice"."userCancel" = true
366+
OR "Invoice"."cancelledAt" <= now() - interval '${`${WALLET_RETRY_BEFORE_MS} milliseconds`}'
367+
)
353368
AND (
354369
"Invoice"."actionType" = 'ITEM_CREATE' OR
355370
"Invoice"."actionType" = 'ZAP' OR
@@ -467,6 +482,24 @@ export default {
467482
return subAct.subName
468483
}
469484
},
485+
ReferralSource: {
486+
__resolveType: async (n, args, { models }) => n.type
487+
},
488+
Referral: {
489+
source: async (n, args, { models, me }) => {
490+
// retrieve the referee landing record
491+
const referral = await models.oneDayReferral.findFirst({ where: { refereeId: Number(n.id), landing: true } })
492+
if (!referral) return null // if no landing record, it will return a generic referral
493+
494+
switch (referral.type) {
495+
case 'POST':
496+
case 'COMMENT': return { ...await getItem(n, { id: referral.typeId }, { models, me }), type: 'Item' }
497+
case 'TERRITORY': return { ...await getSub(n, { name: referral.typeId }, { models, me }), type: 'Sub' }
498+
case 'PROFILE': return { ...await models.user.findUnique({ where: { id: Number(referral.typeId) }, select: { name: true } }), type: 'User' }
499+
default: return null
500+
}
501+
}
502+
},
470503
Streak: {
471504
days: async (n, args, { models }) => {
472505
const res = await models.$queryRaw`

api/resolvers/paidAction.js

+18-10
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { retryPaidAction } from '../paidAction'
2-
import { USER_ID } from '@/lib/constants'
2+
import { USER_ID, WALLET_MAX_RETRIES, WALLET_RETRY_TIMEOUT_MS } from '@/lib/constants'
33

44
function paidActionType (actionType) {
55
switch (actionType) {
@@ -50,24 +50,32 @@ export default {
5050
}
5151
},
5252
Mutation: {
53-
retryPaidAction: async (parent, { invoiceId }, { models, me, lnd }) => {
53+
retryPaidAction: async (parent, { invoiceId, newAttempt }, { models, me, lnd }) => {
5454
if (!me) {
5555
throw new Error('You must be logged in')
5656
}
5757

58-
const invoice = await models.invoice.findUnique({ where: { id: invoiceId, userId: me.id } })
58+
// make sure only one client at a time can retry by acquiring a lock that expires
59+
const [invoice] = await models.$queryRaw`
60+
UPDATE "Invoice"
61+
SET "retryPendingSince" = now()
62+
WHERE
63+
id = ${invoiceId} AND
64+
"userId" = ${me.id} AND
65+
"actionState" = 'FAILED' AND
66+
("retryPendingSince" IS NULL OR "retryPendingSince" < now() - ${`${WALLET_RETRY_TIMEOUT_MS} milliseconds`}::interval)
67+
RETURNING *`
5968
if (!invoice) {
60-
throw new Error('Invoice not found')
69+
throw new Error('Invoice not found or retry pending')
6170
}
6271

63-
if (invoice.actionState !== 'FAILED') {
64-
if (invoice.actionState === 'PAID') {
65-
throw new Error('Invoice is already paid')
66-
}
67-
throw new Error(`Invoice is not in failed state: ${invoice.actionState}`)
72+
// do we want to retry a payment from the beginning with all sender and receiver wallets?
73+
const paymentAttempt = newAttempt ? invoice.paymentAttempt + 1 : invoice.paymentAttempt
74+
if (paymentAttempt > WALLET_MAX_RETRIES) {
75+
throw new Error('Payment has been retried too many times')
6876
}
6977

70-
const result = await retryPaidAction(invoice.actionType, { invoice }, { models, me, lnd })
78+
const result = await retryPaidAction(invoice.actionType, { invoice }, { paymentAttempt, models, me, lnd })
7179

7280
return {
7381
...result,

api/resolvers/user.js

+38-3
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
44
import { msatsToSats } from '@/lib/format'
55
import { bioSchema, emailSchema, settingsSchema, validateSchema, userSchema } from '@/lib/validate'
66
import { getItem, updateItem, filterClause, createItem, whereClause, muteClause, activeOrMine } from './item'
7-
import { USER_ID, RESERVED_MAX_USER_ID, SN_NO_REWARDS_IDS, INVOICE_ACTION_NOTIFICATION_TYPES } from '@/lib/constants'
7+
import { USER_ID, RESERVED_MAX_USER_ID, SN_NO_REWARDS_IDS, INVOICE_ACTION_NOTIFICATION_TYPES, WALLET_MAX_RETRIES, WALLET_RETRY_BEFORE_MS } from '@/lib/constants'
88
import { viewGroup } from './growth'
9-
import { timeUnitForRange, whenRange } from '@/lib/time'
9+
import { datePivot, timeUnitForRange, whenRange } from '@/lib/time'
1010
import assertApiKeyNotPermitted from './apiKey'
1111
import { hashEmail } from '@/lib/crypto'
1212
import { isMuted } from '@/lib/user'
@@ -543,7 +543,17 @@ export default {
543543
actionType: {
544544
in: INVOICE_ACTION_NOTIFICATION_TYPES
545545
},
546-
actionState: 'FAILED'
546+
actionState: 'FAILED',
547+
OR: [
548+
{
549+
paymentAttempt: {
550+
gte: WALLET_MAX_RETRIES
551+
}
552+
},
553+
{
554+
userCancel: true
555+
}
556+
]
547557
}
548558
})
549559

@@ -552,6 +562,31 @@ export default {
552562
return true
553563
}
554564

565+
const invoiceActionFailed2 = await models.invoice.findFirst({
566+
where: {
567+
userId: me.id,
568+
updatedAt: {
569+
gt: datePivot(lastChecked, { milliseconds: -WALLET_RETRY_BEFORE_MS })
570+
},
571+
actionType: {
572+
in: INVOICE_ACTION_NOTIFICATION_TYPES
573+
},
574+
actionState: 'FAILED',
575+
paymentAttempt: {
576+
lt: WALLET_MAX_RETRIES
577+
},
578+
userCancel: false,
579+
cancelledAt: {
580+
lte: datePivot(new Date(), { milliseconds: -WALLET_RETRY_BEFORE_MS })
581+
}
582+
}
583+
})
584+
585+
if (invoiceActionFailed2) {
586+
foundNotes()
587+
return true
588+
}
589+
555590
// update checkedNotesAt to prevent rechecking same time period
556591
models.user.update({
557592
where: { id: me.id },

api/resolvers/wallet.js

+19-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ import { formatMsats, msatsToSats, msatsToSatsDecimal, satsToMsats } from '@/lib
99
import {
1010
USER_ID, INVOICE_RETENTION_DAYS,
1111
PAID_ACTION_PAYMENT_METHODS,
12-
WALLET_CREATE_INVOICE_TIMEOUT_MS
12+
WALLET_CREATE_INVOICE_TIMEOUT_MS,
13+
WALLET_RETRY_AFTER_MS,
14+
WALLET_RETRY_BEFORE_MS,
15+
WALLET_MAX_RETRIES
1316
} from '@/lib/constants'
1417
import { amountSchema, validateSchema, withdrawlSchema, lnAddrSchema } from '@/lib/validate'
1518
import assertGofacYourself from './ofac'
@@ -456,6 +459,21 @@ const resolvers = {
456459
cursor: nextCursor,
457460
entries: logs
458461
}
462+
},
463+
failedInvoices: async (parent, args, { me, models }) => {
464+
if (!me) {
465+
throw new GqlAuthenticationError()
466+
}
467+
return await models.$queryRaw`
468+
SELECT * FROM "Invoice"
469+
WHERE "userId" = ${me.id}
470+
AND "actionState" = 'FAILED'
471+
-- never retry if user has cancelled the invoice manually
472+
AND "userCancel" = false
473+
AND "cancelledAt" < now() - ${`${WALLET_RETRY_AFTER_MS} milliseconds`}::interval
474+
AND "cancelledAt" > now() - ${`${WALLET_RETRY_BEFORE_MS} milliseconds`}::interval
475+
AND "paymentAttempt" < ${WALLET_MAX_RETRIES}
476+
ORDER BY id DESC`
459477
}
460478
},
461479
Wallet: {

api/typeDefs/item.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ export default gql`
159159
remote: Boolean
160160
sub: Sub
161161
subName: String
162-
status: String
162+
status: String!
163163
uploadId: Int
164164
otsHash: String
165165
parentOtsHash: String

api/typeDefs/notifications.js

+3
Original file line numberDiff line numberDiff line change
@@ -124,9 +124,12 @@ export default gql`
124124
withdrawl: Withdrawl!
125125
}
126126
127+
union ReferralSource = Item | Sub | User
128+
127129
type Referral {
128130
id: ID!
129131
sortTime: Date!
132+
source: ReferralSource
130133
}
131134
132135
type SubStatus {

api/typeDefs/paidAction.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ extend type Query {
77
}
88
99
extend type Mutation {
10-
retryPaidAction(invoiceId: Int!): PaidAction!
10+
retryPaidAction(invoiceId: Int!, newAttempt: Boolean): PaidAction!
1111
}
1212
1313
enum PaymentMethod {

api/typeDefs/sub.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export default gql`
3131
}
3232
3333
type Sub {
34-
name: ID!
34+
name: String!
3535
createdAt: Date!
3636
userId: Int!
3737
user: User!

api/typeDefs/user.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export default gql`
4949
type User {
5050
id: ID!
5151
createdAt: Date!
52-
name: String
52+
name: String!
5353
nitems(when: String, from: String, to: String): Int!
5454
nposts(when: String, from: String, to: String): Int!
5555
nterritories(when: String, from: String, to: String): Int!

api/typeDefs/wallet.js

+1
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ const typeDefs = `
7272
wallet(id: ID!): Wallet
7373
walletByType(type: String!): Wallet
7474
walletLogs(type: String, from: String, to: String, cursor: String): WalletLog!
75+
failedInvoices: [Invoice!]!
7576
}
7677
7778
extend type Mutation {

awards.csv

+2
Original file line numberDiff line numberDiff line change
@@ -173,3 +173,5 @@ Soxasora,pr,#1839,#1790,easy,,,1,90k,[email protected],2025-01-27
173173
Soxasora,pr,#1820,#1819,easy,,,1,90k,[email protected],2025-01-27
174174
SatsAllDay,issue,#1820,#1819,easy,,,1,9k,[email protected],2025-01-27
175175
Soxasora,pr,#1814,#1736,easy,,,,100k,[email protected],2025-01-27
176+
jason-me,pr,#1857,,easy,,,,100k,[email protected],2025-02-08
177+
ed-kung,pr,#1901,#323,good-first-issue,,,,20k,[email protected],2025-02-14

components/bounty-form.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ export function BountyForm ({
7373
hint={
7474
editThreshold
7575
? (
76-
<div className='text-muted fw-bold'>
76+
<div className='text-muted fw-bold font-monospace'>
7777
<Countdown date={editThreshold} />
7878
</div>
7979
)

components/comment.js

+4
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,10 @@ export default function Comment ({
130130
// HACK wait for other comments to uncollapse if they're collapsed
131131
setTimeout(() => {
132132
ref.current.scrollIntoView({ behavior: 'instant', block: 'start' })
133+
// make sure we can outline a comment again if it was already outlined before
134+
ref.current.addEventListener('animationend', () => {
135+
ref.current.classList.remove('outline-it')
136+
}, { once: true })
133137
ref.current.classList.add('outline-it')
134138
}, 100)
135139
}

0 commit comments

Comments
 (0)