Skip to content

Commit

Permalink
cowboy credits (aka nov-5 (aka jan-3)) (#1678)
Browse files Browse the repository at this point in the history
* wip adding cowboy credits

* invite gift paid action

* remove balance limit

* remove p2p zap withdrawal notifications

* credits typedefs

* squash migrations

* remove wallet limit stuff

* CCs in item detail

* comments with meCredits

* begin including CCs in item stats/notifications

* buy credits ui/mutation

* fix old /settings/wallets paths

* bios don't get sats

* fix settings

* make invites work with credits

* restore migration from master

* inform backend of send wallets on zap

* satistics header

* default receive options to true and squash migrations

* fix paidAction query

* add nav for credits

* fix forever stacked count

* ek suggested fixes

* fix lint

* fix freebies wrt CCs

* add back disable freebies

* trigger cowboy hat job on CC depletion

* fix meMsats+meMcredits

* Update api/paidAction/README.md

Co-authored-by: ekzyis <[email protected]>

* remove expireBoost migration that doesn't work

---------

Co-authored-by: ekzyis <[email protected]>
  • Loading branch information
huumn and ekzyis authored Jan 3, 2025
1 parent 47debbc commit 146b602
Show file tree
Hide file tree
Showing 57 changed files with 658 additions and 366 deletions.
6 changes: 6 additions & 0 deletions api/paidAction/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,12 @@ All functions have the following signature: `function(args: Object, context: Obj
- `models`: the current prisma client (for anything that doesn't need to be done atomically with the payment)
- `lnd`: the current lnd client

## Recording Cowboy Credits

To avoid adding sats and credits together everywhere to show an aggregate sat value, in most cases we denormalize a `sats` field that carries the "sats value", the combined sats + credits of something, and a `credits` field that carries only the earned `credits`. For example, the `Item` table has an `msats` field that carries the sum of the `mcredits` and `msats` earned and a `mcredits` field that carries the value of the `mcredits` earned. So, the sats value an item earned is `item.msats` BUT the real sats earned is `item.msats - item.mcredits`.

The ONLY exception to this are for the `users` table where we store a stacker's rewards sats and credits balances separately.

## `IMPORTANT: transaction isolation`

We use a `read committed` isolation level for actions. This means paid actions need to be mindful of concurrency issues. Specifically, reading data from the database and then writing it back in `read committed` is a common source of consistency bugs (aka serialization anamolies).
Expand Down
1 change: 1 addition & 0 deletions api/paidAction/boost.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const anonable = false

export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC
]

Expand Down
32 changes: 32 additions & 0 deletions api/paidAction/buyCredits.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants'
import { satsToMsats } from '@/lib/format'

export const anonable = false

export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
]

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

export async function perform ({ credits }, { me, cost, tx }) {
await tx.user.update({
where: { id: me.id },
data: {
mcredits: {
increment: cost
}
}
})

return {
credits
}
}

export async function describe () {
return 'SN: buy fee credits'
}
1 change: 1 addition & 0 deletions api/paidAction/donate.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const anonable = true

export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
]

Expand Down
1 change: 1 addition & 0 deletions api/paidAction/downZap.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const anonable = false

export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC
]

Expand Down
12 changes: 11 additions & 1 deletion 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 BUY_CREDITS from './buyCredits'
import * as INVITE_GIFT from './inviteGift'

export const paidActions = {
Expand All @@ -33,6 +34,7 @@ export const paidActions = {
TERRITORY_UNARCHIVE,
DONATE,
RECEIVE,
BUY_CREDITS,
INVITE_GIFT
}

Expand Down Expand Up @@ -96,7 +98,8 @@ export default async function performPaidAction (actionType, args, incomingConte

// additional payment methods that logged in users can use
if (me) {
if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT) {
if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT ||
paymentMethod === PAID_ACTION_PAYMENT_METHODS.REWARD_SATS) {
try {
return await performNoInvoiceAction(actionType, args, contextWithPaymentMethod)
} catch (e) {
Expand Down Expand Up @@ -141,6 +144,13 @@ async function performNoInvoiceAction (actionType, args, incomingContext) {
const context = { ...incomingContext, tx }

if (paymentMethod === 'FEE_CREDIT') {
await tx.user.update({
where: {
id: me?.id ?? USER_ID.anon
},
data: { mcredits: { decrement: cost } }
})
} else if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.REWARD_SATS) {
await tx.user.update({
where: {
id: me?.id ?? USER_ID.anon
Expand Down
5 changes: 3 additions & 2 deletions api/paidAction/inviteGift.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { notifyInvite } from '@/lib/webPush'
export const anonable = false

export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS
]

export async function getCost ({ id }, { models, me }) {
Expand Down Expand Up @@ -36,7 +37,7 @@ export async function perform ({ id, userId }, { me, cost, tx }) {
}
},
data: {
msats: {
mcredits: {
increment: cost
},
inviteId: id,
Expand Down
3 changes: 2 additions & 1 deletion api/paidAction/itemCreate.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const anonable = true

export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC,
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
]
Expand All @@ -29,7 +30,7 @@ export async function getCost ({ subName, parentId, uploadIds, boost = 0, bio },
// sub allows freebies (or is a bio or a comment), cost is less than baseCost, not anon,
// cost must be greater than user's balance, and user has not disabled freebies
const freebie = (parentId || bio) && cost <= baseCost && !!me &&
cost > me?.msats && !me?.disableFreebies
me?.msats < cost && !me?.disableFreebies && me?.mcredits < cost

return freebie ? BigInt(0) : BigInt(cost)
}
Expand Down
1 change: 1 addition & 0 deletions api/paidAction/itemUpdate.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const anonable = true

export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
]

Expand Down
48 changes: 1 addition & 47 deletions api/paidAction/lib/assert.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import { BALANCE_LIMIT_MSATS, PAID_ACTION_TERMINAL_STATES, USER_ID, SN_ADMIN_IDS } from '@/lib/constants'
import { msatsToSats, numWithUnits } from '@/lib/format'
import { PAID_ACTION_TERMINAL_STATES, USER_ID } from '@/lib/constants'
import { datePivot } from '@/lib/time'

const MAX_PENDING_PAID_ACTIONS_PER_USER = 100
const MAX_PENDING_DIRECT_INVOICES_PER_USER_MINUTES = 10
const MAX_PENDING_DIRECT_INVOICES_PER_USER = 100
const USER_IDS_BALANCE_NO_LIMIT = [...SN_ADMIN_IDS, USER_ID.anon, USER_ID.ad]

export async function assertBelowMaxPendingInvoices (context) {
const { models, me } = context
Expand Down Expand Up @@ -56,47 +54,3 @@ export async function assertBelowMaxPendingDirectPayments (userId, context) {
throw new Error('Receiver has too many direct payments')
}
}

export async function assertBelowBalanceLimit (context) {
const { me, tx } = context
if (!me || USER_IDS_BALANCE_NO_LIMIT.includes(me.id)) return

// we need to prevent this invoice (and any other pending invoices and withdrawls)
// from causing the user's balance to exceed the balance limit
const pendingInvoices = await tx.invoice.aggregate({
where: {
userId: me.id,
// p2p invoices are never in state PENDING
actionState: 'PENDING',
actionType: 'RECEIVE'
},
_sum: {
msatsRequested: true
}
})

// Get pending withdrawals total
const pendingWithdrawals = await tx.withdrawl.aggregate({
where: {
userId: me.id,
status: null
},
_sum: {
msatsPaying: true,
msatsFeePaying: true
}
})

// Calculate total pending amount
const pendingMsats = (pendingInvoices._sum.msatsRequested ?? 0n) +
((pendingWithdrawals._sum.msatsPaying ?? 0n) + (pendingWithdrawals._sum.msatsFeePaying ?? 0n))

// Check balance limit
if (pendingMsats + me.msats > BALANCE_LIMIT_MSATS) {
throw new Error(
`pending invoices and withdrawals must not cause balance to exceed ${
numWithUnits(msatsToSats(BALANCE_LIMIT_MSATS))
}`
)
}
}
1 change: 1 addition & 0 deletions api/paidAction/pollVote.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const anonable = false

export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC
]

Expand Down
15 changes: 6 additions & 9 deletions api/paidAction/receive.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants'
import { toPositiveBigInt, numWithUnits, msatsToSats, satsToMsats } from '@/lib/format'
import { notifyDeposit } from '@/lib/webPush'
import { getInvoiceableWallets } from '@/wallets/server'
import { assertBelowBalanceLimit } from './lib/assert'

export const anonable = false

Expand All @@ -19,13 +18,16 @@ export async function getCost ({ msats }) {
export async function getInvoiceablePeer (_, { me, models, cost, paymentMethod }) {
if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.P2P && !me?.proxyReceive) return null
if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.DIRECT && !me?.directReceive) return null
if ((cost + me.msats) <= satsToMsats(me.autoWithdrawThreshold)) return null

const wallets = await getInvoiceableWallets(me.id, { models })
if (wallets.length === 0) {
return null
}

if (cost < satsToMsats(me.receiveCreditsBelowSats)) {
return null
}

return me.id
}

Expand All @@ -39,7 +41,7 @@ export async function perform ({
lud18Data,
noteStr
}, { me, tx }) {
const invoice = await tx.invoice.update({
return await tx.invoice.update({
where: { id: invoiceId },
data: {
comment,
Expand All @@ -48,11 +50,6 @@ export async function perform ({
},
include: { invoiceForward: true }
})

if (!invoice.invoiceForward) {
// if the invoice is not p2p, assert that the user's balance limit is not exceeded
await assertBelowBalanceLimit({ me, tx })
}
}

export async function describe ({ description }, { me, cost, paymentMethod, sybilFeePercent }) {
Expand All @@ -73,7 +70,7 @@ export async function onPaid ({ invoice }, { tx }) {
await tx.user.update({
where: { id: invoice.userId },
data: {
msats: {
mcredits: {
increment: invoice.msatsReceived
}
}
Expand Down
1 change: 1 addition & 0 deletions api/paidAction/territoryBilling.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const anonable = false

export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
]

Expand Down
1 change: 1 addition & 0 deletions api/paidAction/territoryCreate.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const anonable = false

export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
]

Expand Down
1 change: 1 addition & 0 deletions api/paidAction/territoryUnarchive.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const anonable = false

export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
]

Expand Down
1 change: 1 addition & 0 deletions api/paidAction/territoryUpdate.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const anonable = false

export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
]

Expand Down
Loading

0 comments on commit 146b602

Please sign in to comment.