invite paid action (#1681)
This commit is contained in:
parent
909853521d
commit
713227b255
|
@ -92,18 +92,20 @@ stateDiagram-v2
|
||||||
|
|
||||||
### Table of existing paid actions and their supported flows
|
### Table of existing paid actions and their supported flows
|
||||||
|
|
||||||
| action | fee credits | optimistic | pessimistic | anonable | qr payable | p2p wrapped | side effects |
|
| action | fee credits | optimistic | pessimistic | anonable | qr payable | p2p wrapped | side effects | reward sats | p2p direct |
|
||||||
| ----------------- | ----------- | ---------- | ----------- | -------- | ---------- | ----------- | ------------ |
|
| ----------------- | ----------- | ---------- | ----------- | -------- | ---------- | ----------- | ------------ | ----------- | ---------- |
|
||||||
| zaps | x | x | x | x | x | x | x |
|
| zaps | x | x | x | x | x | x | x | | |
|
||||||
| posts | x | x | x | x | x | | x |
|
| posts | x | x | x | x | x | | x | x | |
|
||||||
| comments | x | x | x | x | x | | x |
|
| comments | x | x | x | x | x | | x | x | |
|
||||||
| downzaps | x | x | | | x | | x |
|
| downzaps | x | x | | | x | | x | x | |
|
||||||
| poll votes | x | x | | | x | | |
|
| poll votes | x | x | | | x | | | x | |
|
||||||
| territory actions | x | | x | | x | | |
|
| territory actions | x | | x | | x | | | x | |
|
||||||
| donations | x | | x | x | x | | |
|
| donations | x | | x | x | x | | | x | |
|
||||||
| update posts | x | | x | | x | | x |
|
| update posts | x | | x | | x | | x | x | |
|
||||||
| update comments | x | | x | | x | | x |
|
| update comments | x | | x | | x | | x | x | |
|
||||||
| receive | | 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)
|
## 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.
|
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.
|
||||||
|
|
|
@ -18,6 +18,7 @@ import * as TERRITORY_UNARCHIVE from './territoryUnarchive'
|
||||||
import * as DONATE from './donate'
|
import * as DONATE from './donate'
|
||||||
import * as BOOST from './boost'
|
import * as BOOST from './boost'
|
||||||
import * as RECEIVE from './receive'
|
import * as RECEIVE from './receive'
|
||||||
|
import * as INVITE_GIFT from './inviteGift'
|
||||||
|
|
||||||
export const paidActions = {
|
export const paidActions = {
|
||||||
ITEM_CREATE,
|
ITEM_CREATE,
|
||||||
|
@ -31,7 +32,8 @@ export const paidActions = {
|
||||||
TERRITORY_BILLING,
|
TERRITORY_BILLING,
|
||||||
TERRITORY_UNARCHIVE,
|
TERRITORY_UNARCHIVE,
|
||||||
DONATE,
|
DONATE,
|
||||||
RECEIVE
|
RECEIVE,
|
||||||
|
INVITE_GIFT
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function performPaidAction (actionType, args, incomingContext) {
|
export default async function performPaidAction (actionType, args, incomingContext) {
|
||||||
|
@ -52,7 +54,7 @@ export default async function performPaidAction (actionType, args, incomingConte
|
||||||
// treat context as immutable
|
// treat context as immutable
|
||||||
const contextWithMe = {
|
const contextWithMe = {
|
||||||
...incomingContext,
|
...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 = {
|
const context = {
|
||||||
...contextWithMe,
|
...contextWithMe,
|
||||||
|
@ -100,7 +102,8 @@ export default async function performPaidAction (actionType, args, incomingConte
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// if we fail with fee credits or reward sats, but not because of insufficient funds, bail
|
// if we fail with fee credits or reward sats, but not because of insufficient funds, bail
|
||||||
console.error(`${paymentMethod} action failed`, e)
|
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\\"')) {
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -312,7 +315,7 @@ export async function retryPaidAction (actionType, args, incomingContext) {
|
||||||
const retryContext = {
|
const retryContext = {
|
||||||
...incomingContext,
|
...incomingContext,
|
||||||
optimistic: actionOptimistic,
|
optimistic: actionOptimistic,
|
||||||
me: await models.user.findUnique({ where: { id: me.id } }),
|
me: await models.user.findUnique({ where: { id: parseInt(me.id) } }),
|
||||||
cost: BigInt(msatsRequested),
|
cost: BigInt(msatsRequested),
|
||||||
actionId
|
actionId
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
|
@ -1,76 +0,0 @@
|
||||||
import retry from 'async-retry'
|
|
||||||
import Prisma from '@prisma/client'
|
|
||||||
import { msatsToSats, numWithUnits } from '@/lib/format'
|
|
||||||
import { BALANCE_LIMIT_MSATS } from '@/lib/constants'
|
|
||||||
import { GqlInputError } from '@/lib/error'
|
|
||||||
|
|
||||||
export default async function serialize (trx, { models, lnd }) {
|
|
||||||
// wrap first argument in array if not array already
|
|
||||||
const isArray = Array.isArray(trx)
|
|
||||||
if (!isArray) trx = [trx]
|
|
||||||
|
|
||||||
// conditional queries can be added inline using && syntax
|
|
||||||
// we filter any falsy value out here
|
|
||||||
trx = trx.filter(q => !!q)
|
|
||||||
|
|
||||||
const results = await retry(async bail => {
|
|
||||||
try {
|
|
||||||
const [, ...results] = await models.$transaction(
|
|
||||||
[models.$executeRaw`SELECT ASSERT_SERIALIZED()`, ...trx],
|
|
||||||
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable })
|
|
||||||
return results
|
|
||||||
} catch (error) {
|
|
||||||
console.log(error)
|
|
||||||
// two cases where we get insufficient funds:
|
|
||||||
// 1. plpgsql function raises
|
|
||||||
// 2. constraint violation via a prisma call
|
|
||||||
// XXX prisma does not provide a way to distinguish these cases so we
|
|
||||||
// have to check the error message
|
|
||||||
if (error.message.includes('SN_INSUFFICIENT_FUNDS') ||
|
|
||||||
error.message.includes('\\"users\\" violates check constraint \\"msats_positive\\"')) {
|
|
||||||
bail(new GqlInputError('insufficient funds'))
|
|
||||||
}
|
|
||||||
if (error.message.includes('SN_NOT_SERIALIZABLE')) {
|
|
||||||
bail(new Error('wallet balance transaction is not serializable'))
|
|
||||||
}
|
|
||||||
if (error.message.includes('SN_CONFIRMED_WITHDRAWL_EXISTS')) {
|
|
||||||
bail(new Error('withdrawal invoice already confirmed (to withdraw again create a new invoice)'))
|
|
||||||
}
|
|
||||||
if (error.message.includes('SN_PENDING_WITHDRAWL_EXISTS')) {
|
|
||||||
bail(new Error('withdrawal invoice exists and is pending'))
|
|
||||||
}
|
|
||||||
if (error.message.includes('SN_INELIGIBLE')) {
|
|
||||||
bail(new Error('user ineligible for gift'))
|
|
||||||
}
|
|
||||||
if (error.message.includes('SN_UNSUPPORTED')) {
|
|
||||||
bail(new Error('unsupported action'))
|
|
||||||
}
|
|
||||||
if (error.message.includes('SN_DUPLICATE')) {
|
|
||||||
bail(new Error('duplicate not allowed'))
|
|
||||||
}
|
|
||||||
if (error.message.includes('SN_REVOKED_OR_EXHAUSTED')) {
|
|
||||||
bail(new Error('faucet has been revoked or is exhausted'))
|
|
||||||
}
|
|
||||||
if (error.message.includes('SN_INV_PENDING_LIMIT')) {
|
|
||||||
bail(new Error('too many pending invoices'))
|
|
||||||
}
|
|
||||||
if (error.message.includes('SN_INV_EXCEED_BALANCE')) {
|
|
||||||
bail(new Error(`pending invoices and withdrawals must not cause balance to exceed ${numWithUnits(msatsToSats(BALANCE_LIMIT_MSATS))}`))
|
|
||||||
}
|
|
||||||
if (error.message.includes('40001') || error.code === 'P2034') {
|
|
||||||
throw new Error('wallet balance serialization failure - try again')
|
|
||||||
}
|
|
||||||
if (error.message.includes('23514') || ['P2002', 'P2003', 'P2004'].includes(error.code)) {
|
|
||||||
bail(new Error('constraint failure'))
|
|
||||||
}
|
|
||||||
bail(error)
|
|
||||||
}
|
|
||||||
}, {
|
|
||||||
minTimeout: 10,
|
|
||||||
maxTimeout: 100,
|
|
||||||
retries: 10
|
|
||||||
})
|
|
||||||
|
|
||||||
// if first argument was not an array, unwrap the result
|
|
||||||
return isArray ? results : results[0]
|
|
||||||
}
|
|
|
@ -480,7 +480,7 @@ export const inviteSchema = object({
|
||||||
gift: intValidator.positive('must be greater than 0').required('required'),
|
gift: intValidator.positive('must be greater than 0').required('required'),
|
||||||
limit: intValidator.positive('must be positive'),
|
limit: intValidator.positive('must be positive'),
|
||||||
description: string().trim().max(40, 'must be at most 40 characters'),
|
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({
|
export const pushSubscriptionSchema = object({
|
||||||
|
|
|
@ -2,14 +2,13 @@ import Login from '@/components/login'
|
||||||
import { getProviders } from 'next-auth/react'
|
import { getProviders } from 'next-auth/react'
|
||||||
import { getServerSession } from 'next-auth/next'
|
import { getServerSession } from 'next-auth/next'
|
||||||
import models from '@/api/models'
|
import models from '@/api/models'
|
||||||
import serialize from '@/api/resolvers/serial'
|
|
||||||
import { gql } from '@apollo/client'
|
import { gql } from '@apollo/client'
|
||||||
import { INVITE_FIELDS } from '@/fragments/invites'
|
import { INVITE_FIELDS } from '@/fragments/invites'
|
||||||
import getSSRApolloClient from '@/api/ssrApollo'
|
import getSSRApolloClient from '@/api/ssrApollo'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { CenterLayout } from '@/components/layout'
|
import { CenterLayout } from '@/components/layout'
|
||||||
import { getAuthOptions } from '@/pages/api/auth/[...nextauth]'
|
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 } }) {
|
export async function getServerSideProps ({ req, res, query: { id, error = null } }) {
|
||||||
const session = await getServerSession(req, res, getAuthOptions(req))
|
const session = await getServerSession(req, res, getAuthOptions(req))
|
||||||
|
@ -36,12 +35,10 @@ export async function getServerSideProps ({ req, res, query: { id, error = null
|
||||||
try {
|
try {
|
||||||
// attempt to send gift
|
// attempt to send gift
|
||||||
// catch any errors and just ignore them for now
|
// catch any errors and just ignore them for now
|
||||||
await serialize(
|
await performPaidAction('INVITE_GIFT', {
|
||||||
models.$queryRawUnsafe('SELECT invite_drain($1::INTEGER, $2::TEXT)', session.user.id, id),
|
id,
|
||||||
{ models }
|
userId: session.user.id
|
||||||
)
|
}, { models, me: { id: data.invite.user.id } })
|
||||||
const invite = await models.invite.findUnique({ where: { id } })
|
|
||||||
notifyInvite(invite.userId)
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e)
|
console.log(e)
|
||||||
}
|
}
|
||||||
|
|
|
@ -81,6 +81,7 @@ function InviteForm () {
|
||||||
<Input
|
<Input
|
||||||
prepend={<InputGroup.Text className='text-muted'>{`${process.env.NEXT_PUBLIC_URL}/invites/`}</InputGroup.Text>}
|
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></>}
|
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'
|
name='id'
|
||||||
autoComplete='off'
|
autoComplete='off'
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -467,15 +467,16 @@ model LnWith {
|
||||||
}
|
}
|
||||||
|
|
||||||
model Invite {
|
model Invite {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||||
userId Int
|
userId Int
|
||||||
gift Int?
|
gift Int?
|
||||||
limit Int?
|
limit Int?
|
||||||
revoked Boolean @default(false)
|
giftedCount Int @default(0)
|
||||||
user User @relation("Invites", fields: [userId], references: [id], onDelete: Cascade)
|
revoked Boolean @default(false)
|
||||||
invitees User[]
|
user User @relation("Invites", fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
invitees User[]
|
||||||
|
|
||||||
description String?
|
description String?
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import lnd from '@/api/lnd'
|
import lnd from '@/api/lnd'
|
||||||
import performPaidAction from '@/api/paidAction'
|
import performPaidAction from '@/api/paidAction'
|
||||||
import serialize from '@/api/resolvers/serial'
|
|
||||||
import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants'
|
import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants'
|
||||||
import { nextBillingWithGrace } from '@/lib/territory'
|
import { nextBillingWithGrace } from '@/lib/territory'
|
||||||
import { datePivot } from '@/lib/time'
|
import { datePivot } from '@/lib/time'
|
||||||
|
@ -53,8 +52,10 @@ export async function territoryBilling ({ data: { subName }, boss, models }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function territoryRevenue ({ models }) {
|
export async function territoryRevenue ({ models }) {
|
||||||
await serialize(
|
// this is safe nonserializable because it only acts on old data that won't
|
||||||
models.$executeRaw`
|
// be affected by concurrent updates ... and the update takes a lock on the
|
||||||
|
// users table
|
||||||
|
await models.$executeRaw`
|
||||||
WITH revenue AS (
|
WITH revenue AS (
|
||||||
SELECT coalesce(sum(msats), 0) as revenue, "subName", "userId"
|
SELECT coalesce(sum(msats), 0) as revenue, "subName", "userId"
|
||||||
FROM (
|
FROM (
|
||||||
|
@ -88,7 +89,5 @@ export async function territoryRevenue ({ models }) {
|
||||||
SET msats = users.msats + "SubActResultTotal".total_msats,
|
SET msats = users.msats + "SubActResultTotal".total_msats,
|
||||||
"stackedMsats" = users."stackedMsats" + "SubActResultTotal".total_msats
|
"stackedMsats" = users."stackedMsats" + "SubActResultTotal".total_msats
|
||||||
FROM "SubActResultTotal"
|
FROM "SubActResultTotal"
|
||||||
WHERE users.id = "SubActResultTotal"."userId"`,
|
WHERE users.id = "SubActResultTotal"."userId"`
|
||||||
{ models }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue