Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

invite paid action #1681

Merged
merged 1 commit into from
Dec 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 14 additions & 12 deletions api/paidAction/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,18 +92,20 @@ stateDiagram-v2

### Table of existing paid actions and their supported flows

| action | fee credits | optimistic | pessimistic | anonable | qr payable | p2p wrapped | side effects |
| ----------------- | ----------- | ---------- | ----------- | -------- | ---------- | ----------- | ------------ |
| zaps | x | x | x | x | x | x | x |
| posts | x | x | x | x | x | | x |
| comments | x | x | x | x | x | | x |
| downzaps | x | x | | | x | | x |
| poll votes | x | x | | | x | | |
| territory actions | x | | x | | x | | |
| donations | x | | x | x | x | | |
| update posts | x | | x | | x | | x |
| update comments | x | | x | | x | | x |
| receive | | x | | x | x | x | x |
| action | fee credits | optimistic | pessimistic | anonable | qr payable | p2p wrapped | side effects | reward sats | p2p direct |
| ----------------- | ----------- | ---------- | ----------- | -------- | ---------- | ----------- | ------------ | ----------- | ---------- |
| zaps | x | x | x | x | x | x | x | | |
| posts | x | x | x | x | x | | x | x | |
| comments | x | x | x | x | x | | x | x | |
| downzaps | x | x | | | x | | x | x | |
| poll votes | x | x | | | x | | | x | |
| territory actions | x | | x | | x | | | x | |
| donations | x | | x | x | x | | | x | |
| update posts | x | | x | | x | | x | x | |
| update comments | x | | x | | x | | x | x | |
| receive | | x | | | x | x | x | | x |
| buy fee credits | | | x | | x | | | x | |
| invite gift | x | | | | | | x | x | |

## Not-custodial zaps (ie p2p wrapped payments)
Zaps, and possibly other future actions, can be performed peer to peer and non-custodially. This means that the payment is made directly from the client to the recipient, without the server taking custody of the funds. Currently, in order to trigger this behavior, the recipient must have a receiving wallet attached and the sender must have insufficient funds in their custodial wallet to perform the requested zap.
Expand Down
11 changes: 7 additions & 4 deletions api/paidAction/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import * as TERRITORY_UNARCHIVE from './territoryUnarchive'
import * as DONATE from './donate'
import * as BOOST from './boost'
import * as RECEIVE from './receive'
import * as INVITE_GIFT from './inviteGift'

export const paidActions = {
ITEM_CREATE,
Expand All @@ -31,7 +32,8 @@ export const paidActions = {
TERRITORY_BILLING,
TERRITORY_UNARCHIVE,
DONATE,
RECEIVE
RECEIVE,
INVITE_GIFT
}

export default async function performPaidAction (actionType, args, incomingContext) {
Expand All @@ -52,7 +54,7 @@ export default async function performPaidAction (actionType, args, incomingConte
// treat context as immutable
const contextWithMe = {
...incomingContext,
me: me ? await models.user.findUnique({ where: { id: me.id } }) : undefined
me: me ? await models.user.findUnique({ where: { id: parseInt(me.id) } }) : undefined
}
const context = {
...contextWithMe,
Expand Down Expand Up @@ -100,7 +102,8 @@ export default async function performPaidAction (actionType, args, incomingConte
} catch (e) {
// if we fail with fee credits or reward sats, but not because of insufficient funds, bail
console.error(`${paymentMethod} action failed`, e)
if (!e.message.includes('\\"users\\" violates check constraint \\"msats_positive\\"')) {
if (!e.message.includes('\\"users\\" violates check constraint \\"msats_positive\\"') &&
!e.message.includes('\\"users\\" violates check constraint \\"mcredits_positive\\"')) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsequential spillover from cherrypick

throw e
}
}
Expand Down Expand Up @@ -312,7 +315,7 @@ export async function retryPaidAction (actionType, args, incomingContext) {
const retryContext = {
...incomingContext,
optimistic: actionOptimistic,
me: await models.user.findUnique({ where: { id: me.id } }),
me: await models.user.findUnique({ where: { id: parseInt(me.id) } }),
cost: BigInt(msatsRequested),
actionId
}
Expand Down
59 changes: 59 additions & 0 deletions api/paidAction/inviteGift.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants'
import { satsToMsats } from '@/lib/format'
import { notifyInvite } from '@/lib/webPush'

export const anonable = false

export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT
]

export async function getCost ({ id }, { models, me }) {
const invite = await models.invite.findUnique({ where: { id, userId: me.id, revoked: false } })
if (!invite) {
throw new Error('invite not found')
}
return satsToMsats(invite.gift)
}

export async function perform ({ id, userId }, { me, cost, tx }) {
const invite = await tx.invite.findUnique({
where: { id, userId: me.id, revoked: false }
})

if (invite.giftedCount >= invite.limit) {
throw new Error('invite limit reached')
}

// check that user was created in last hour
// check that user did not already redeem an invite
await tx.user.update({
where: {
id: userId,
inviteId: null,
createdAt: {
gt: new Date(Date.now() - 1000 * 60 * 60)
}
},
data: {
msats: {
increment: cost
},
inviteId: id,
referrerId: me.id
}
})

return await tx.invite.update({
where: { id, userId: me.id, giftedCount: { lt: invite.limit }, revoked: false },
data: {
giftedCount: {
increment: 1
}
}
})
}

export async function nonCriticalSideEffects (_, { me }) {
notifyInvite(me.id)
}
76 changes: 0 additions & 76 deletions api/resolvers/serial.js

This file was deleted.

2 changes: 1 addition & 1 deletion lib/validate.js
Original file line number Diff line number Diff line change
Expand Up @@ -480,7 +480,7 @@ export const inviteSchema = object({
gift: intValidator.positive('must be greater than 0').required('required'),
limit: intValidator.positive('must be positive'),
description: string().trim().max(40, 'must be at most 40 characters'),
id: string().matches(/^[\w-_]+$/, 'only letters, numbers, underscores, and hyphens').min(4, 'must be at least 4 characters').max(32, 'must be at most 32 characters')
id: string().matches(/^[\w-_]+$/, 'only letters, numbers, underscores, and hyphens').min(8, 'must be at least 8 characters').max(32, 'must be at most 32 characters')
})

export const pushSubscriptionSchema = object({
Expand Down
13 changes: 5 additions & 8 deletions pages/invites/[id].js
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,13 @@ import Login from '@/components/login'
import { getProviders } from 'next-auth/react'
import { getServerSession } from 'next-auth/next'
import models from '@/api/models'
import serialize from '@/api/resolvers/serial'
import { gql } from '@apollo/client'
import { INVITE_FIELDS } from '@/fragments/invites'
import getSSRApolloClient from '@/api/ssrApollo'
import Link from 'next/link'
import { CenterLayout } from '@/components/layout'
import { getAuthOptions } from '@/pages/api/auth/[...nextauth]'
import { notifyInvite } from '@/lib/webPush'
import performPaidAction from '@/api/paidAction'

export async function getServerSideProps ({ req, res, query: { id, error = null } }) {
const session = await getServerSession(req, res, getAuthOptions(req))
Expand All @@ -36,12 +35,10 @@ export async function getServerSideProps ({ req, res, query: { id, error = null
try {
// attempt to send gift
// catch any errors and just ignore them for now
await serialize(
models.$queryRawUnsafe('SELECT invite_drain($1::INTEGER, $2::TEXT)', session.user.id, id),
{ models }
)
const invite = await models.invite.findUnique({ where: { id } })
notifyInvite(invite.userId)
await performPaidAction('INVITE_GIFT', {
id,
userId: session.user.id
}, { models, me: { id: data.invite.user.id } })
} catch (e) {
console.log(e)
}
Expand Down
1 change: 1 addition & 0 deletions pages/invites/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ function InviteForm () {
<Input
prepend={<InputGroup.Text className='text-muted'>{`${process.env.NEXT_PUBLIC_URL}/invites/`}</InputGroup.Text>}
label={<>invite code <small className='text-muted ms-2'>optional</small></>}
hint='leave blank for a random code that is hard to guess'
name='id'
autoComplete='off'
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "Invite" ADD COLUMN "giftedCount" INTEGER NOT NULL DEFAULT 0;

-- denormalize giftedCount
UPDATE "Invite"
SET "giftedCount" = (SELECT COUNT(*) FROM "users" WHERE "users"."inviteId" = "Invite".id)
WHERE "Invite"."id" = "Invite".id;

19 changes: 10 additions & 9 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -467,15 +467,16 @@ model LnWith {
}

model Invite {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
userId Int
gift Int?
limit Int?
revoked Boolean @default(false)
user User @relation("Invites", fields: [userId], references: [id], onDelete: Cascade)
invitees User[]
id String @id @default(cuid())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
userId Int
gift Int?
limit Int?
giftedCount Int @default(0)
revoked Boolean @default(false)
user User @relation("Invites", fields: [userId], references: [id], onDelete: Cascade)
invitees User[]

description String?

Expand Down
11 changes: 5 additions & 6 deletions worker/territory.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import lnd from '@/api/lnd'
import performPaidAction from '@/api/paidAction'
import serialize from '@/api/resolvers/serial'
import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants'
import { nextBillingWithGrace } from '@/lib/territory'
import { datePivot } from '@/lib/time'
Expand Down Expand Up @@ -53,8 +52,10 @@ export async function territoryBilling ({ data: { subName }, boss, models }) {
}

export async function territoryRevenue ({ models }) {
await serialize(
models.$executeRaw`
// this is safe nonserializable because it only acts on old data that won't
// be affected by concurrent updates ... and the update takes a lock on the
// users table
await models.$executeRaw`
WITH revenue AS (
SELECT coalesce(sum(msats), 0) as revenue, "subName", "userId"
FROM (
Expand Down Expand Up @@ -88,7 +89,5 @@ export async function territoryRevenue ({ models }) {
SET msats = users.msats + "SubActResultTotal".total_msats,
"stackedMsats" = users."stackedMsats" + "SubActResultTotal".total_msats
FROM "SubActResultTotal"
WHERE users.id = "SubActResultTotal"."userId"`,
{ models }
)
WHERE users.id = "SubActResultTotal"."userId"`
}
Loading