cowboy credits (aka nov-5 (aka jan-3)) (#1678)

* 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 <ek@stacker.news>

* remove expireBoost migration that doesn't work

---------

Co-authored-by: ekzyis <ek@stacker.news>
This commit is contained in:
Keyan 2025-01-03 10:33:07 -06:00 committed by GitHub
parent 47debbcb06
commit 146b60278c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
57 changed files with 661 additions and 369 deletions

View File

@ -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).

View File

@ -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
]

View File

@ -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'
}

View File

@ -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
]

View File

@ -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
]

View File

@ -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 = {
@ -33,6 +34,7 @@ export const paidActions = {
TERRITORY_UNARCHIVE,
DONATE,
RECEIVE,
BUY_CREDITS,
INVITE_GIFT
}
@ -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) {
@ -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

View File

@ -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 }) {
@ -36,7 +37,7 @@ export async function perform ({ id, userId }, { me, cost, tx }) {
}
},
data: {
msats: {
mcredits: {
increment: cost
},
inviteId: id,

View File

@ -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
]
@ -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)
}

View File

@ -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
]

View File

@ -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
@ -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))
}`
)
}
}

View File

@ -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
]

View File

@ -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
@ -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
}
@ -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,
@ -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 }) {
@ -73,7 +70,7 @@ export async function onPaid ({ invoice }, { tx }) {
await tx.user.update({
where: { id: invoice.userId },
data: {
msats: {
mcredits: {
increment: invoice.msatsReceived
}
}

View File

@ -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
]

View File

@ -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
]

View File

@ -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
]

View File

@ -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
]

View File

@ -6,8 +6,9 @@ import { getInvoiceableWallets } from '@/wallets/server'
export const anonable = true
export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.P2P,
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC,
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
]
@ -16,16 +17,38 @@ export async function getCost ({ sats }) {
return satsToMsats(sats)
}
export async function getInvoiceablePeer ({ id }, { models }) {
export async function getInvoiceablePeer ({ id, sats, hasSendWallet }, { models, me, cost }) {
// if the zap is dust, or if me doesn't have a send wallet but has enough sats/credits to pay for it
// then we don't invoice the peer
if (sats < me?.sendCreditsBelowSats ||
(me && !hasSendWallet && (me.mcredits >= cost || me.msats >= cost))) {
return null
}
const item = await models.item.findUnique({
where: { id: parseInt(id) },
include: { itemForwards: true }
include: {
itemForwards: true,
user: true
}
})
// bios don't get sats
if (item.bio) {
return null
}
const wallets = await getInvoiceableWallets(item.userId, { models })
// request peer invoice if they have an attached wallet and have not forwarded the item
return wallets.length > 0 && item.itemForwards.length === 0 ? item.userId : null
// and the receiver doesn't want to receive credits
if (wallets.length > 0 &&
item.itemForwards.length === 0 &&
sats >= item.user.receiveCreditsBelowSats) {
return item.userId
}
return null
}
export async function getSybilFeePercent () {
@ -90,32 +113,38 @@ export async function onPaid ({ invoice, actIds }, { tx }) {
const sats = msatsToSats(msats)
const itemAct = acts.find(act => act.act === 'TIP')
// give user and all forwards the sats
await tx.$executeRaw`
WITH forwardees AS (
SELECT "userId", ((${itemAct.msats}::BIGINT * pct) / 100)::BIGINT AS msats
FROM "ItemForward"
WHERE "itemId" = ${itemAct.itemId}::INTEGER
), total_forwarded AS (
SELECT COALESCE(SUM(msats), 0) as msats
FROM forwardees
), recipients AS (
SELECT "userId", msats, msats AS "stackedMsats" FROM forwardees
UNION
SELECT ${itemAct.item.userId}::INTEGER as "userId",
CASE WHEN ${!!invoice?.invoiceForward}::BOOLEAN
THEN 0::BIGINT
ELSE ${itemAct.msats}::BIGINT - (SELECT msats FROM total_forwarded)::BIGINT
END as msats,
${itemAct.msats}::BIGINT - (SELECT msats FROM total_forwarded)::BIGINT as "stackedMsats"
ORDER BY "userId" ASC -- order to prevent deadlocks
)
UPDATE users
SET
msats = users.msats + recipients.msats,
"stackedMsats" = users."stackedMsats" + recipients."stackedMsats"
FROM recipients
WHERE users.id = recipients."userId"`
if (invoice?.invoiceForward) {
// only the op got sats and we need to add it to their stackedMsats
// because the sats were p2p
await tx.user.update({
where: { id: itemAct.item.userId },
data: { stackedMsats: { increment: itemAct.msats } }
})
} else {
// splits only use mcredits
await tx.$executeRaw`
WITH forwardees AS (
SELECT "userId", ((${itemAct.msats}::BIGINT * pct) / 100)::BIGINT AS mcredits
FROM "ItemForward"
WHERE "itemId" = ${itemAct.itemId}::INTEGER
), total_forwarded AS (
SELECT COALESCE(SUM(mcredits), 0) as mcredits
FROM forwardees
), recipients AS (
SELECT "userId", mcredits FROM forwardees
UNION
SELECT ${itemAct.item.userId}::INTEGER as "userId",
${itemAct.msats}::BIGINT - (SELECT mcredits FROM total_forwarded)::BIGINT as mcredits
ORDER BY "userId" ASC -- order to prevent deadlocks
)
UPDATE users
SET
mcredits = users.mcredits + recipients.mcredits,
"stackedMsats" = users."stackedMsats" + recipients.mcredits,
"stackedMcredits" = users."stackedMcredits" + recipients.mcredits
FROM recipients
WHERE users.id = recipients."userId"`
}
// perform denomormalized aggregates: weighted votes, upvotes, msats, lastZapAt
// NOTE: for the rows that might be updated by a concurrent zap, we use UPDATE for implicit locking
@ -135,6 +164,7 @@ export async function onPaid ({ invoice, actIds }, { tx }) {
"weightedVotes" = "weightedVotes" + (zapper.trust * zap.log_sats),
upvotes = upvotes + zap.first_vote,
msats = "Item".msats + ${msats}::BIGINT,
mcredits = "Item".mcredits + ${invoice?.invoiceForward ? 0n : msats}::BIGINT,
"lastZapAt" = now()
FROM zap, zapper
WHERE "Item".id = ${itemAct.itemId}::INTEGER
@ -165,7 +195,8 @@ export async function onPaid ({ invoice, actIds }, { tx }) {
SELECT * FROM "Item" WHERE id = ${itemAct.itemId}::INTEGER
)
UPDATE "Item"
SET "commentMsats" = "Item"."commentMsats" + ${msats}::BIGINT
SET "commentMsats" = "Item"."commentMsats" + ${msats}::BIGINT,
"commentMcredits" = "Item"."commentMcredits" + ${invoice?.invoiceForward ? 0n : msats}::BIGINT
FROM zapped
WHERE "Item".path @> zapped.path AND "Item".id <> zapped.id`
}

View File

@ -83,7 +83,7 @@ export default {
},
poor: async (invite, args, { me, models }) => {
const user = await models.user.findUnique({ where: { id: invite.userId } })
return msatsToSats(user.msats) < invite.gift
return msatsToSats(user.msats) < invite.gift && msatsToSats(user.mcredits) < invite.gift
},
description: (invite, args, { me }) => {
return invite.userId === me?.id ? invite.description : undefined

View File

@ -150,6 +150,7 @@ export async function itemQueryWithMeta ({ me, models, query, orderBy = '' }, ..
return await models.$queryRawUnsafe(`
SELECT "Item".*, to_jsonb(users.*) || jsonb_build_object('meMute', "Mute"."mutedId" IS NOT NULL) as user,
COALESCE("ItemAct"."meMsats", 0) as "meMsats", COALESCE("ItemAct"."mePendingMsats", 0) as "mePendingMsats",
COALESCE("ItemAct"."meMcredits", 0) as "meMcredits", COALESCE("ItemAct"."mePendingMcredits", 0) as "mePendingMcredits",
COALESCE("ItemAct"."meDontLikeMsats", 0) as "meDontLikeMsats", b."itemId" IS NOT NULL AS "meBookmark",
"ThreadSubscription"."itemId" IS NOT NULL AS "meSubscription", "ItemForward"."itemId" IS NOT NULL AS "meForward",
to_jsonb("Sub".*) || jsonb_build_object('meMuteSub', "MuteSub"."userId" IS NOT NULL)
@ -167,10 +168,14 @@ export async function itemQueryWithMeta ({ me, models, query, orderBy = '' }, ..
LEFT JOIN "SubSubscription" ON "Sub"."name" = "SubSubscription"."subName" AND "SubSubscription"."userId" = ${me.id}
LEFT JOIN LATERAL (
SELECT "itemId",
sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM 'FAILED' AND (act = 'FEE' OR act = 'TIP')) AS "meMsats",
sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS NOT DISTINCT FROM 'PENDING' AND (act = 'FEE' OR act = 'TIP') AND "Item"."userId" <> ${me.id}) AS "mePendingMsats",
sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM 'FAILED' AND "InvoiceForward".id IS NOT NULL AND (act = 'FEE' OR act = 'TIP')) AS "meMsats",
sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM 'FAILED' AND "InvoiceForward".id IS NULL AND (act = 'FEE' OR act = 'TIP')) AS "meMcredits",
sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS NOT DISTINCT FROM 'PENDING' AND "InvoiceForward".id IS NOT NULL AND (act = 'FEE' OR act = 'TIP')) AS "mePendingMsats",
sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS NOT DISTINCT FROM 'PENDING' AND "InvoiceForward".id IS NULL AND (act = 'FEE' OR act = 'TIP')) AS "mePendingMcredits",
sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM 'FAILED' AND act = 'DONT_LIKE_THIS') AS "meDontLikeMsats"
FROM "ItemAct"
LEFT JOIN "Invoice" ON "Invoice".id = "ItemAct"."invoiceId"
LEFT JOIN "InvoiceForward" ON "InvoiceForward"."invoiceId" = "Invoice"."id"
WHERE "ItemAct"."userId" = ${me.id}
AND "ItemAct"."itemId" = "Item".id
GROUP BY "ItemAct"."itemId"
@ -940,7 +945,7 @@ export default {
return await performPaidAction('POLL_VOTE', { id }, { me, models, lnd })
},
act: async (parent, { id, sats, act = 'TIP' }, { me, models, lnd, headers }) => {
act: async (parent, { id, sats, act = 'TIP', hasSendWallet }, { me, models, lnd, headers }) => {
assertApiKeyNotPermitted({ me })
await validateSchema(actSchema, { sats, act })
await assertGofacYourself({ models, headers })
@ -974,7 +979,7 @@ export default {
}
if (act === 'TIP') {
return await performPaidAction('ZAP', { id, sats }, { me, models, lnd })
return await performPaidAction('ZAP', { id, sats, hasSendWallet }, { me, models, lnd })
} else if (act === 'DONT_LIKE_THIS') {
return await performPaidAction('DOWN_ZAP', { id, sats }, { me, models, lnd })
} else if (act === 'BOOST') {
@ -1049,11 +1054,17 @@ export default {
},
Item: {
sats: async (item, args, { models }) => {
return msatsToSats(BigInt(item.msats) + BigInt(item.mePendingMsats || 0))
return msatsToSats(BigInt(item.msats) + BigInt(item.mePendingMsats || 0) + BigInt(item.mePendingMcredits || 0))
},
credits: async (item, args, { models }) => {
return msatsToSats(BigInt(item.mcredits) + BigInt(item.mePendingMcredits || 0))
},
commentSats: async (item, args, { models }) => {
return msatsToSats(item.commentMsats)
},
commentCredits: async (item, args, { models }) => {
return msatsToSats(item.commentMcredits)
},
isJob: async (item, args, { models }) => {
return item.subName === 'jobs'
},
@ -1170,8 +1181,8 @@ export default {
},
meSats: async (item, args, { me, models }) => {
if (!me) return 0
if (typeof item.meMsats !== 'undefined') {
return msatsToSats(item.meMsats)
if (typeof item.meMsats !== 'undefined' && typeof item.meMcredits !== 'undefined') {
return msatsToSats(BigInt(item.meMsats) + BigInt(item.meMcredits))
}
const { _sum: { msats } } = await models.itemAct.aggregate({
@ -1197,6 +1208,38 @@ export default {
return (msats && msatsToSats(msats)) || 0
},
meCredits: async (item, args, { me, models }) => {
if (!me) return 0
if (typeof item.meMcredits !== 'undefined') {
return msatsToSats(item.meMcredits)
}
const { _sum: { msats } } = await models.itemAct.aggregate({
_sum: {
msats: true
},
where: {
itemId: Number(item.id),
userId: me.id,
invoiceActionState: {
not: 'FAILED'
},
invoice: {
invoiceForward: { is: null }
},
OR: [
{
act: 'TIP'
},
{
act: 'FEE'
}
]
}
})
return (msats && msatsToSats(msats)) || 0
},
meDontLikeSats: async (item, args, { me, models }) => {
if (!me) return 0
if (typeof item.meDontLikeMsats !== 'undefined') {

View File

@ -247,7 +247,7 @@ export default {
WHERE "Withdrawl"."userId" = $1
AND "Withdrawl".status = 'CONFIRMED'
AND "Withdrawl".created_at < $2
AND ("InvoiceForward"."id" IS NULL OR "Invoice"."actionType" = 'ZAP')
AND "InvoiceForward"."id" IS NULL
GROUP BY "Withdrawl".id
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`

View File

@ -21,6 +21,8 @@ function paidActionType (actionType) {
return 'PollVotePaidAction'
case 'RECEIVE':
return 'ReceivePaidAction'
case 'BUY_CREDITS':
return 'BuyCreditsPaidAction'
default:
throw new Error('Unknown action type')
}

View File

@ -1024,7 +1024,13 @@ export default {
if (!me || me.id !== user.id) {
return 0
}
return msatsToSats(user.msats)
return msatsToSats(user.msats + user.mcredits)
},
credits: async (user, args, { models, me }) => {
if (!me || me.id !== user.id) {
return 0
}
return msatsToSats(user.mcredits)
},
authMethods,
hasInvites: async (user, args, { models }) => {
@ -1106,7 +1112,7 @@ export default {
if (!when || when === 'forever') {
// forever
return (user.stackedMsats && msatsToSats(user.stackedMsats)) || 0
return ((user.stackedMsats && msatsToSats(user.stackedMsats)) || 0)
}
const range = whenRange(when, from, to)

View File

@ -583,6 +583,9 @@ const resolvers = {
await models.walletLog.deleteMany({ where: { userId: me.id, wallet } })
return true
},
buyCredits: async (parent, { credits }, { me, models, lnd }) => {
return await performPaidAction('BUY_CREDITS', { credits }, { models, me, lnd })
}
},
@ -644,6 +647,9 @@ const resolvers = {
}))?.withdrawl?.msatsPaid
return msats ? msatsToSats(msats) : null
},
invoiceForward: async (invoice, args, { models }) => {
return !!invoice.invoiceForward || !!(await models.invoiceForward.findUnique({ where: { invoiceId: Number(invoice.id) } }))
},
nostr: async (invoice, args, { models }) => {
try {
return JSON.parse(invoice.desc)

View File

@ -60,7 +60,7 @@ export default gql`
hash: String, hmac: String): ItemPaidAction!
updateNoteId(id: ID!, noteId: String!): Item!
upsertComment(id: ID, text: String!, parentId: ID, boost: Int, hash: String, hmac: String): ItemPaidAction!
act(id: ID!, sats: Int, act: String, idempotent: Boolean): ItemActPaidAction!
act(id: ID!, sats: Int, act: String, hasSendWallet: Boolean): ItemActPaidAction!
pollVote(id: ID!): PollVotePaidAction!
toggleOutlaw(id: ID!): Item!
}
@ -127,10 +127,13 @@ export default gql`
bountyPaidTo: [Int]
noteId: String
sats: Int!
credits: Int!
commentSats: Int!
commentCredits: Int!
lastCommentAt: Date
upvotes: Int!
meSats: Int!
meCredits: Int!
meDontLikeSats: Int!
meBookmark: Boolean!
meSubscription: Boolean!

View File

@ -11,6 +11,7 @@ extend type Mutation {
}
enum PaymentMethod {
REWARD_SATS
FEE_CREDIT
ZERO_COST
OPTIMISTIC
@ -52,4 +53,9 @@ type DonatePaidAction implements PaidAction {
paymentMethod: PaymentMethod!
}
type BuyCreditsPaidAction implements PaidAction {
result: BuyCreditsResult
invoice: Invoice
paymentMethod: PaymentMethod!
}
`

View File

@ -114,6 +114,8 @@ export default gql`
withdrawMaxFeeDefault: Int!
proxyReceive: Boolean
directReceive: Boolean
receiveCreditsBelowSats: Int!
sendCreditsBelowSats: Int!
}
type AuthMethods {
@ -130,6 +132,7 @@ export default gql`
extremely sensitive
"""
sats: Int!
credits: Int!
authMethods: AuthMethods!
lnAddr: String
@ -194,6 +197,8 @@ export default gql`
walletsUpdatedAt: Date
proxyReceive: Boolean
directReceive: Boolean
receiveCreditsBelowSats: Int!
sendCreditsBelowSats: Int!
}
type UserOptional {

View File

@ -83,6 +83,11 @@ const typeDefs = `
removeWallet(id: ID!): Boolean
deleteWalletLogs(wallet: String): Boolean
setWalletPriority(id: ID!, priority: Int!): Boolean
buyCredits(credits: Int!): BuyCreditsPaidAction!
}
type BuyCreditsResult {
credits: Int!
}
interface InvoiceOrDirect {
@ -126,6 +131,7 @@ const typeDefs = `
actionState: String
actionType: String
actionError: String
invoiceForward: Boolean
item: Item
itemAct: ItemAct
forwardedSats: Int

View File

@ -5,8 +5,6 @@ import { useMe } from '@/components/me'
import { useMutation } from '@apollo/client'
import { WELCOME_BANNER_MUTATION } from '@/fragments/users'
import { useToast } from '@/components/toast'
import { BALANCE_LIMIT_MSATS } from '@/lib/constants'
import { msatsToSats, numWithUnits } from '@/lib/format'
import Link from 'next/link'
export function WelcomeBanner ({ Banner }) {
@ -102,27 +100,6 @@ export function MadnessBanner ({ handleClose }) {
)
}
export function WalletLimitBanner () {
const { me } = useMe()
const limitReached = me?.privates?.sats >= msatsToSats(BALANCE_LIMIT_MSATS)
if (!me || !limitReached) return
return (
<Alert className={styles.banner} key='info' variant='warning'>
<Alert.Heading>
Your wallet is over the current limit ({numWithUnits(msatsToSats(BALANCE_LIMIT_MSATS))})
</Alert.Heading>
<p className='mb-1'>
Deposits to your wallet from <strong>outside</strong> of SN are blocked.
</p>
<p>
Please spend or withdraw sats to restore full wallet functionality.
</p>
</Alert>
)
}
export function WalletSecurityBanner ({ isActive }) {
return (
<Alert className={styles.banner} key='info' variant='warning'>

View File

@ -114,7 +114,7 @@ export function FeeButtonProvider ({ baseLineItems = DEFAULT_BASE_LINE_ITEMS, us
const lines = { ...baseLineItems, ...lineItems, ...remoteLineItems }
const total = Object.values(lines).sort(sortHelper).reduce((acc, { modifier }) => modifier(acc), 0)
// freebies: there's only a base cost and we don't have enough sats
const free = total === lines.baseCost?.modifier(0) && lines.baseCost?.allowFreebies && me?.privates?.sats < total && !me?.privates?.disableFreebies
const free = total === lines.baseCost?.modifier(0) && lines.baseCost?.allowFreebies && me?.privates?.sats < total && me?.privates?.credits < total && !me?.privates?.disableFreebies
return {
lines,
merge: mergeLineItems,

View File

@ -179,9 +179,11 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a
</Form>)
}
function modifyActCache (cache, { result, invoice }) {
function modifyActCache (cache, { result, invoice }, me) {
if (!result) return
const { id, sats, act } = result
const p2p = invoice?.invoiceForward
cache.modify({
id: `Item:${id}`,
fields: {
@ -191,12 +193,24 @@ function modifyActCache (cache, { result, invoice }) {
}
return existingSats
},
credits (existingCredits = 0) {
if (act === 'TIP' && !p2p) {
return existingCredits + sats
}
return existingCredits
},
meSats: (existingSats = 0) => {
if (act === 'TIP') {
if (act === 'TIP' && me) {
return existingSats + sats
}
return existingSats
},
meCredits: (existingCredits = 0) => {
if (act === 'TIP' && !p2p && me) {
return existingCredits + sats
}
return existingCredits
},
meDontLikeSats: (existingSats = 0) => {
if (act === 'DONT_LIKE_THIS') {
return existingSats + sats
@ -219,6 +233,8 @@ function modifyActCache (cache, { result, invoice }) {
function updateAncestors (cache, { result, invoice }) {
if (!result) return
const { id, sats, act, path } = result
const p2p = invoice?.invoiceForward
if (act === 'TIP') {
// update all ancestors
path.split('.').forEach(aId => {
@ -226,6 +242,12 @@ function updateAncestors (cache, { result, invoice }) {
cache.modify({
id: `Item:${aId}`,
fields: {
commentCredits (existingCommentCredits = 0) {
if (p2p) {
return existingCommentCredits
}
return existingCommentCredits + sats
},
commentSats (existingCommentSats = 0) {
return existingCommentSats + sats
}
@ -237,6 +259,7 @@ function updateAncestors (cache, { result, invoice }) {
}
export function useAct ({ query = ACT_MUTATION, ...options } = {}) {
const { me } = useMe()
// because the mutation name we use varies,
// we need to extract the result/invoice from the response
const getPaidActionResult = data => Object.values(data)[0]
@ -253,7 +276,7 @@ export function useAct ({ query = ACT_MUTATION, ...options } = {}) {
update: (cache, { data }) => {
const response = getPaidActionResult(data)
if (!response) return
modifyActCache(cache, response)
modifyActCache(cache, response, me)
options?.update?.(cache, { data })
},
onPayError: (e, cache, { data }) => {
@ -261,7 +284,7 @@ export function useAct ({ query = ACT_MUTATION, ...options } = {}) {
if (!response || !response.result) return
const { result: { sats } } = response
const negate = { ...response, result: { ...response.result, sats: -1 * sats } }
modifyActCache(cache, negate)
modifyActCache(cache, negate, me)
options?.onPayError?.(e, cache, { data })
},
onPaid: (cache, { data }) => {
@ -286,7 +309,7 @@ export function useZap () {
// add current sats to next tip since idempotent zaps use desired total zap not difference
const sats = nextTip(meSats, { ...me?.privates })
const variables = { id: item.id, sats, act: 'TIP' }
const variables = { id: item.id, sats, act: 'TIP', hasSendWallet: wallets.length > 0 }
const optimisticResponse = { act: { __typename: 'ItemActPaidAction', result: { path: item.path, ...variables } } }
try {

View File

@ -30,6 +30,39 @@ import classNames from 'classnames'
import SubPopover from './sub-popover'
import useCanEdit from './use-can-edit'
function itemTitle (item) {
let title = ''
title += numWithUnits(item.upvotes, {
abbreviate: false,
unitSingular: 'zapper',
unitPlural: 'zappers'
})
if (item.sats) {
title += ` \\ ${numWithUnits(item.sats - item.credits, { abbreviate: false })}`
}
if (item.credits) {
title += ` \\ ${numWithUnits(item.credits, { abbreviate: false, unitSingular: 'CC', unitPlural: 'CCs' })}`
}
if (item.mine) {
title += ` (${numWithUnits(item.meSats, { abbreviate: false })} to post)`
} else if (item.meSats || item.meDontLikeSats || item.meAnonSats) {
const satSources = []
if (item.meAnonSats || (item.meSats || 0) - (item.meCredits || 0) > 0) {
satSources.push(`${numWithUnits((item.meSats || 0) + (item.meAnonSats || 0) - (item.meCredits || 0), { abbreviate: false })}`)
}
if (item.meCredits) {
satSources.push(`${numWithUnits(item.meCredits, { abbreviate: false, unitSingular: 'CC', unitPlural: 'CCs' })}`)
}
if (item.meDontLikeSats) {
satSources.push(`${numWithUnits(item.meDontLikeSats, { abbreviate: false, unitSingular: 'downsat', unitPlural: 'downsats' })}`)
}
if (satSources.length) {
title += ` (${satSources.join(' & ')} from me)`
}
}
return title
}
export default function ItemInfo ({
item, full, commentsText = 'comments',
commentTextSingular = 'comment', className, embellishUser, extraInfo, edit, toggleEdit, editText,
@ -62,16 +95,7 @@ export default function ItemInfo ({
<div className={className || `${styles.other}`}>
{!(item.position && (pinnable || !item.subName)) && !(!item.parentId && Number(item.user?.id) === USER_ID.ad) &&
<>
<span title={`from ${numWithUnits(item.upvotes, {
abbreviate: false,
unitSingular: 'stacker',
unitPlural: 'stackers'
})} ${item.mine
? `\\ ${numWithUnits(item.meSats, { abbreviate: false })} to post`
: `(${numWithUnits(meSats, { abbreviate: false })}${item.meDontLikeSats
? ` & ${numWithUnits(item.meDontLikeSats, { abbreviate: false, unitSingular: 'downsat', unitPlural: 'downsats' })}`
: ''} from me)`} `}
>
<span title={itemTitle(item)}>
{numWithUnits(item.sats)}
</span>
<span> \ </span>
@ -229,11 +253,21 @@ function InfoDropdownItem ({ item }) {
<div>cost</div>
<div>{item.cost}</div>
<div>sats</div>
<div>{item.sats}</div>
<div>{item.sats - item.credits}</div>
<div>CCs</div>
<div>{item.credits}</div>
<div>comment sats</div>
<div>{item.commentSats - item.commentCredits}</div>
<div>comment CCs</div>
<div>{item.commentCredits}</div>
{me && (
<>
<div>sats from me</div>
<div>{item.meSats}</div>
<div>{item.meSats - item.meCredits}</div>
<div>CCs from me</div>
<div>{item.meCredits}</div>
<div>downsats from me</div>
<div>{item.meDontLikeSats}</div>
</>
)}
<div>zappers</div>

View File

@ -6,12 +6,11 @@ import BackArrow from '../../svgs/arrow-left-line.svg'
import { useCallback, useEffect, useState } from 'react'
import Price from '../price'
import SubSelect from '../sub-select'
import { USER_ID, BALANCE_LIMIT_MSATS } from '../../lib/constants'
import { USER_ID } from '../../lib/constants'
import Head from 'next/head'
import NoteIcon from '../../svgs/notification-4-fill.svg'
import { useMe } from '../me'
import HiddenWalletSummary from '../hidden-wallet-summary'
import { abbrNum, msatsToSats } from '../../lib/format'
import { abbrNum } from '../../lib/format'
import { useServiceWorker } from '../serviceworker'
import { signOut } from 'next-auth/react'
import Badges from '../badge'
@ -25,7 +24,7 @@ import { useHasNewNotes } from '../use-has-new-notes'
import { useWallets } from '@/wallets/index'
import SwitchAccountList, { useAccounts } from '@/components/account'
import { useShowModal } from '@/components/modal'
import { numWithUnits } from '@/lib/format'
export function Brand ({ className }) {
return (
<Link href='/' passHref legacyBehavior>
@ -140,21 +139,24 @@ export function NavNotifications ({ className }) {
export function WalletSummary () {
const { me } = useMe()
if (!me) return null
if (me.privates?.hideWalletBalance) {
return <HiddenWalletSummary abbreviate fixedWidth />
}
return `${abbrNum(me.privates?.sats)}`
if (!me || me.privates?.sats === 0) return null
return (
<span
className='text-monospace'
title={`${numWithUnits(me.privates?.credits, { abbreviate: false, unitSingular: 'CC', unitPlural: 'CCs' })}`}
>
{`${abbrNum(me.privates?.sats)}`}
</span>
)
}
export function NavWalletSummary ({ className }) {
const { me } = useMe()
const walletLimitReached = me?.privates?.sats >= msatsToSats(BALANCE_LIMIT_MSATS)
return (
<Nav.Item className={className}>
<Link href='/wallet' passHref legacyBehavior>
<Nav.Link eventKey='wallet' className={`${walletLimitReached ? 'text-warning' : 'text-success'} text-monospace px-0 text-nowrap`}>
<Link href='/credits' passHref legacyBehavior>
<Nav.Link eventKey='credits' className='text-success text-monospace px-0 text-nowrap'>
<WalletSummary me={me} />
</Nav.Link>
</Link>
@ -194,8 +196,11 @@ export function MeDropdown ({ me, dropNavKey }) {
<Link href={'/' + me.name + '/bookmarks'} passHref legacyBehavior>
<Dropdown.Item active={me.name + '/bookmarks' === dropNavKey}>bookmarks</Dropdown.Item>
</Link>
<Link href='/wallet' passHref legacyBehavior>
<Dropdown.Item eventKey='wallet'>wallet</Dropdown.Item>
<Link href='/wallets' passHref legacyBehavior>
<Dropdown.Item eventKey='wallets'>wallets</Dropdown.Item>
</Link>
<Link href='/credits' passHref legacyBehavior>
<Dropdown.Item eventKey='credits'>credits</Dropdown.Item>
</Link>
<Link href='/satistics?inc=invoice,withdrawal,stacked,spent' passHref legacyBehavior>
<Dropdown.Item eventKey='satistics'>satistics</Dropdown.Item>

View File

@ -59,8 +59,11 @@ export default function OffCanvas ({ me, dropNavKey }) {
<Link href={'/' + me.name + '/bookmarks'} passHref legacyBehavior>
<Dropdown.Item active={me.name + '/bookmarks' === dropNavKey}>bookmarks</Dropdown.Item>
</Link>
<Link href='/wallet' passHref legacyBehavior>
<Dropdown.Item eventKey='wallet'>wallet</Dropdown.Item>
<Link href='/wallets' passHref legacyBehavior>
<Dropdown.Item eventKey='wallets'>wallets</Dropdown.Item>
</Link>
<Link href='/credits' passHref legacyBehavior>
<Dropdown.Item eventKey='credits'>credits</Dropdown.Item>
</Link>
<Link href='/satistics?inc=invoice,withdrawal,stacked,spent' passHref legacyBehavior>
<Dropdown.Item eventKey='satistics'>satistics</Dropdown.Item>

View File

@ -535,9 +535,27 @@ function Referral ({ n }) {
)
}
function stackedText (item) {
let text = ''
if (item.sats - item.credits > 0) {
text += `${numWithUnits(item.sats - item.credits, { abbreviate: false })}`
if (item.credits > 0) {
text += ' and '
}
}
if (item.credits > 0) {
text += `${numWithUnits(item.credits, { abbreviate: false, unitSingular: 'CC', unitPlural: 'CCs' })}`
}
return text
}
function Votification ({ n }) {
let forwardedSats = 0
let ForwardedUsers = null
let stackedTextString
let forwardedTextString
if (n.item.forwards?.length) {
forwardedSats = Math.floor(n.earnedSats * n.item.forwards.map(fwd => fwd.pct).reduce((sum, cur) => sum + cur) / 100)
ForwardedUsers = () => n.item.forwards.map((fwd, i) =>
@ -547,14 +565,18 @@ function Votification ({ n }) {
</Link>
{i !== n.item.forwards.length - 1 && ' '}
</span>)
stackedTextString = numWithUnits(n.earnedSats, { abbreviate: false, unitSingular: 'CC', unitPlural: 'CCs' })
forwardedTextString = numWithUnits(forwardedSats, { abbreviate: false, unitSingular: 'CC', unitPlural: 'CCs' })
} else {
stackedTextString = stackedText(n.item)
}
return (
<>
<NoteHeader color='success'>
your {n.item.title ? 'post' : 'reply'} stacked {numWithUnits(n.earnedSats, { abbreviate: false })}
your {n.item.title ? 'post' : 'reply'} stacked {stackedTextString}
{n.item.forwards?.length > 0 &&
<>
{' '}and forwarded {numWithUnits(forwardedSats, { abbreviate: false })} to{' '}
{' '}and forwarded {forwardedTextString} to{' '}
<ForwardedUsers />
</>}
</NoteHeader>
@ -567,7 +589,7 @@ function ForwardedVotification ({ n }) {
return (
<>
<NoteHeader color='success'>
you were forwarded {numWithUnits(n.earnedSats, { abbreviate: false })} from
you were forwarded {numWithUnits(n.earnedSats, { abbreviate: false, unitSingular: 'CC', unitPlural: 'CCs' })} from
</NoteHeader>
<NoteItem item={n.item} />
</>

View File

@ -44,7 +44,7 @@ export default function WalletCard ({ wallet, draggable, onDragStart, onDragEnte
: <Card.Title className={styles.walletLogo}>{wallet.def.card.title}</Card.Title>}
</div>
</Card.Body>
<Link href={`/settings/wallets/${wallet.def.name}`}>
<Link href={`/wallets/${wallet.def.name}`}>
<Card.Footer className={styles.attach}>
{isConfigured(wallet)
? <>configure<Gear width={14} height={14} /></>

View File

@ -281,7 +281,7 @@ export function useWalletLogs (wallet, initialPage = 1, logsPerPage = 10) {
useEffect(() => {
// only fetch new logs if we are on a page that uses logs
const needLogs = router.asPath.startsWith('/settings/wallets') || router.asPath.startsWith('/wallet/logs')
const needLogs = router.asPath.startsWith('/wallets')
if (!me || !needLogs) return
let timeout

View File

@ -27,11 +27,13 @@ export const COMMENT_FIELDS = gql`
...StreakFields
}
sats
credits
meAnonSats @client
upvotes
freedFreebie
boost
meSats
meCredits
meDontLikeSats
meBookmark
meSubscription
@ -39,6 +41,7 @@ export const COMMENT_FIELDS = gql`
freebie
path
commentSats
commentCredits
mine
otsHash
ncomments

View File

@ -38,6 +38,7 @@ export const ITEM_FIELDS = gql`
otsHash
position
sats
credits
meAnonSats @client
boost
bounty
@ -46,6 +47,7 @@ export const ITEM_FIELDS = gql`
path
upvotes
meSats
meCredits
meDontLikeSats
meBookmark
meSubscription
@ -55,6 +57,7 @@ export const ITEM_FIELDS = gql`
bio
ncomments
commentSats
commentCredits
lastCommentAt
isJob
status

View File

@ -11,6 +11,7 @@ export const PAID_ACTION = gql`
fragment PaidActionFields on PaidAction {
invoice {
...InvoiceFields
invoiceForward
}
paymentMethod
}`
@ -117,6 +118,17 @@ export const DONATE = gql`
}
}`
export const BUY_CREDITS = gql`
${PAID_ACTION}
mutation buyCredits($credits: Int!) {
buyCredits(credits: $credits) {
result {
credits
}
...PaidActionFields
}
}`
export const ACT_MUTATION = gql`
${PAID_ACTION}
${ITEM_ACT_PAID_ACTION_FIELDS}

View File

@ -39,6 +39,7 @@ ${STREAK_FIELDS}
nostrCrossposting
nsfwMode
sats
credits
tipDefault
tipRandom
tipRandomMin
@ -116,6 +117,8 @@ export const SETTINGS_FIELDS = gql`
apiKeyEnabled
proxyReceive
directReceive
receiveCreditsBelowSats
sendCreditsBelowSats
}
}`

View File

@ -8,7 +8,8 @@ export const PAID_ACTION_PAYMENT_METHODS = {
PESSIMISTIC: 'PESSIMISTIC',
OPTIMISTIC: 'OPTIMISTIC',
DIRECT: 'DIRECT',
P2P: 'P2P'
P2P: 'P2P',
REWARD_SATS: 'REWARD_SATS'
}
export const PAID_ACTION_TERMINAL_STATES = ['FAILED', 'PAID', 'RETRYING']
export const NOFOLLOW_LIMIT = 1000
@ -50,7 +51,6 @@ export const ITEM_EDIT_SECONDS = 600
export const ITEM_SPAM_INTERVAL = '10m'
export const ANON_ITEM_SPAM_INTERVAL = '0'
export const INV_PENDING_LIMIT = 100
export const BALANCE_LIMIT_MSATS = 100000000 // 100k sat
export const USER_ID = {
k00b: 616,
ek: 6030,

View File

@ -2,11 +2,11 @@ import { string, ValidationError, number, object, array, boolean, date } from '.
import {
BOOST_MIN, MAX_POLL_CHOICE_LENGTH, MAX_TITLE_LENGTH, MAX_POLL_NUM_CHOICES,
MIN_POLL_NUM_CHOICES, MAX_FORWARDS, BOOST_MULT, MAX_TERRITORY_DESC_LENGTH, POST_TYPES,
TERRITORY_BILLING_TYPES, MAX_COMMENT_TEXT_LENGTH, MAX_POST_TEXT_LENGTH, MIN_TITLE_LENGTH, BOUNTY_MIN, BOUNTY_MAX, BALANCE_LIMIT_MSATS
TERRITORY_BILLING_TYPES, MAX_COMMENT_TEXT_LENGTH, MAX_POST_TEXT_LENGTH, MIN_TITLE_LENGTH, BOUNTY_MIN, BOUNTY_MAX
} from './constants'
import { SUPPORTED_CURRENCIES } from './currency'
import { NOSTR_MAX_RELAY_NUM, NOSTR_PUBKEY_BECH32, NOSTR_PUBKEY_HEX } from './nostr'
import { msatsToSats, numWithUnits, abbrNum } from './format'
import { numWithUnits } from './format'
import { SUB } from '@/fragments/subs'
import { NAME_QUERY } from '@/fragments/users'
import { datePivot } from './time'
@ -185,7 +185,7 @@ export function advSchema (args) {
}
export const autowithdrawSchemaMembers = object({
autoWithdrawThreshold: intValidator.required('required').min(0, 'must be at least 0').max(msatsToSats(BALANCE_LIMIT_MSATS), `must be at most ${abbrNum(msatsToSats(BALANCE_LIMIT_MSATS))}`).transform(Number),
autoWithdrawThreshold: intValidator.required('required').min(0, 'must be at least 0').max(10000, 'must be at most 10000').transform(Number),
autoWithdrawMaxFeePercent: floatValidator.required('required').min(0, 'must be at least 0').max(50, 'must not exceed 50').transform(Number),
autoWithdrawMaxFeeTotal: intValidator.required('required').min(0, 'must be at least 0').max(1_000, 'must not exceed 1000').transform(Number)
})

108
pages/credits.js Normal file
View File

@ -0,0 +1,108 @@
import { getGetServerSideProps } from '@/api/ssrApollo'
import { Form, Input, SubmitButton } from '@/components/form'
import { CenterLayout } from '@/components/layout'
import { useLightning } from '@/components/lightning'
import { useMe } from '@/components/me'
import { useShowModal } from '@/components/modal'
import { usePaidMutation } from '@/components/use-paid-mutation'
import { BUY_CREDITS } from '@/fragments/paidAction'
import { amountSchema } from '@/lib/validate'
import { Button, Col, InputGroup, Row } from 'react-bootstrap'
export const getServerSideProps = getGetServerSideProps({ authRequired: true })
export default function Credits () {
const { me } = useMe()
return (
<CenterLayout footer footerLinks>
<Row className='w-100 d-none d-sm-flex justify-content-center'>
<Col>
<h2 className='text-end me-1 me-md-3'>
<div className='text-monospace'>
{me?.privates?.credits}
</div>
<div className='text-muted'>cowboy credits</div>
<BuyCreditsButton />
</h2>
</Col>
<Col>
<h2 className='text-start ms-1 ms-md-3'>
<div className='text-monospace'>
{me?.privates?.sats - me?.privates?.credits}
</div>
<div className='text-muted'>sats</div>
<Button variant='success mt-3' href='/withdraw'>withdraw sats</Button>
</h2>
</Col>
</Row>
<Row className='w-100 d-flex d-sm-none justify-content-center my-5'>
<Row className='mb-5'>
<h2 className='text-start'>
<div className='text-monospace'>
{me?.privates?.credits}
</div>
<div className='text-muted'>cowboy credits</div>
<BuyCreditsButton />
</h2>
</Row>
<Row>
<h2 className='text-end'>
<div className='text-monospace'>
{me?.privates?.sats - me?.privates?.credits}
</div>
<div className='text-muted'>sats</div>
<Button variant='success mt-3' href='/withdraw'>withdraw sats</Button>
</h2>
</Row>
</Row>
</CenterLayout>
)
}
export function BuyCreditsButton () {
const showModal = useShowModal()
const strike = useLightning()
const [buyCredits] = usePaidMutation(BUY_CREDITS)
return (
<>
<Button
onClick={() => showModal(onClose => (
<Form
initial={{
amount: 10000
}}
schema={amountSchema}
onSubmit={async ({ amount }) => {
const { error } = await buyCredits({
variables: {
credits: Number(amount)
},
onCompleted: () => {
strike()
}
})
onClose()
if (error) throw error
}}
>
<Input
label='amount'
name='amount'
type='number'
required
autoFocus
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/>
<div className='d-flex'>
<SubmitButton variant='secondary' className='ms-auto mt-1 px-4'>buy</SubmitButton>
</div>
</Form>
))}
className='mt-3'
variant='secondary'
>buy credits
</Button>
</>
)
}

View File

@ -64,13 +64,13 @@ function InviteHeader ({ invite }) {
if (invite.revoked) {
Inner = () => <div className='text-danger'>this invite link expired</div>
} else if ((invite.limit && invite.limit <= invite.invitees.length) || invite.poor) {
Inner = () => <div className='text-danger'>this invite link has no more sats</div>
Inner = () => <div className='text-danger'>this invite link has no more cowboy credits</div>
} else {
Inner = () => (
<div>
Get <span className='text-success'>{invite.gift} free sats</span> from{' '}
Get <span className='text-success'>{invite.gift} cowboy credits</span> from{' '}
<Link href={`/${invite.user.name}`}>@{invite.user.name}</Link>{' '}
when you sign up today
when you sign up
</div>
)
}

View File

@ -264,7 +264,7 @@ export default function Satistics ({ ssrData }) {
<div className={styles.rows}>
<div className={[styles.type, styles.head].join(' ')}>type</div>
<div className={[styles.detail, styles.head].join(' ')}>detail</div>
<div className={[styles.sats, styles.head].join(' ')}>sats</div>
<div className={[styles.sats, styles.head].join(' ')}>sats/credits</div>
{facts.map(f => <Fact key={f.type + f.id} fact={f} />)}
</div>
</div>

View File

@ -160,12 +160,15 @@ export default function Settings ({ ssrData }) {
hideIsContributor: settings?.hideIsContributor,
noReferralLinks: settings?.noReferralLinks,
proxyReceive: settings?.proxyReceive,
directReceive: settings?.directReceive
directReceive: settings?.directReceive,
receiveCreditsBelowSats: settings?.receiveCreditsBelowSats,
sendCreditsBelowSats: settings?.sendCreditsBelowSats
}}
schema={settingsSchema}
onSubmit={async ({
tipDefault, tipRandom, tipRandomMin, tipRandomMax, withdrawMaxFeeDefault,
zapUndos, zapUndosEnabled, nostrPubkey, nostrRelays, satsFilter,
receiveCreditsBelowSats, sendCreditsBelowSats,
...values
}) => {
if (nostrPubkey.length === 0) {
@ -191,6 +194,8 @@ export default function Settings ({ ssrData }) {
withdrawMaxFeeDefault: Number(withdrawMaxFeeDefault),
satsFilter: Number(satsFilter),
zapUndos: zapUndosEnabled ? Number(zapUndos) : null,
receiveCreditsBelowSats: Number(receiveCreditsBelowSats),
sendCreditsBelowSats: Number(sendCreditsBelowSats),
nostrPubkey,
nostrRelays: nostrRelaysFiltered,
...values
@ -335,6 +340,18 @@ export default function Settings ({ ssrData }) {
name='noteCowboyHat'
/>
<div className='form-label'>wallet</div>
<Input
label='receive credits for zaps and deposits below'
name='receiveCreditsBelowSats'
required
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/>
<Input
label='send credits for zaps below'
name='sendCreditsBelowSats'
required
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/>
<Checkbox
label={
<div className='d-flex align-items-center'>proxy deposits to attached wallets

View File

@ -92,7 +92,7 @@ export default function WalletSettings () {
await save(values, values.enabled)
toaster.success('saved settings')
router.push('/settings/wallets')
router.push('/wallets')
} catch (err) {
console.error(err)
toaster.danger(err.message || err.toString?.())
@ -115,7 +115,7 @@ export default function WalletSettings () {
try {
await detach()
toaster.success('saved settings')
router.push('/settings/wallets')
router.push('/wallets')
} catch (err) {
console.error(err)
const message = 'failed to detach: ' + err.message || err.toString?.()

View File

@ -86,10 +86,10 @@ export default function Wallet ({ ssrData }) {
return (
<Layout>
<div className='py-5 w-100'>
<h2 className='mb-2 text-center'>attach wallets</h2>
<h6 className='text-muted text-center'>attach wallets to supplement your SN wallet</h6>
<h2 className='mb-2 text-center'>wallets</h2>
<h6 className='text-muted text-center'>use real bitcoin</h6>
<div className='text-center'>
<Link href='/wallet/logs' className='text-muted fw-bold text-underline'>
<Link href='/wallets/logs' className='text-muted fw-bold text-underline'>
wallet logs
</Link>
</div>

View File

@ -1,198 +1,68 @@
import { useRouter } from 'next/router'
import { Checkbox, Form, Input, InputUserSuggest, SubmitButton } from '@/components/form'
import Link from 'next/link'
import Button from 'react-bootstrap/Button'
import { gql, useMutation, useQuery } from '@apollo/client'
import Qr, { QrSkeleton } from '@/components/qr'
import { CenterLayout } from '@/components/layout'
import InputGroup from 'react-bootstrap/InputGroup'
import { WithdrawlSkeleton } from '@/pages/withdrawals/[id]'
import { useMe } from '@/components/me'
import { useEffect, useState } from 'react'
import { requestProvider } from 'webln'
import Alert from 'react-bootstrap/Alert'
import { CREATE_WITHDRAWL, SEND_TO_LNADDR } from '@/fragments/wallet'
import { getGetServerSideProps } from '@/api/ssrApollo'
import { amountSchema, lnAddrSchema, withdrawlSchema } from '@/lib/validate'
import Nav from 'react-bootstrap/Nav'
import { BALANCE_LIMIT_MSATS, FAST_POLL_INTERVAL, SSR } from '@/lib/constants'
import { msatsToSats, numWithUnits } from '@/lib/format'
import { CenterLayout } from '@/components/layout'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { InputGroup, Nav } from 'react-bootstrap'
import styles from '@/components/user-header.module.css'
import HiddenWalletSummary from '@/components/hidden-wallet-summary'
import AccordianItem from '@/components/accordian-item'
import { lnAddrOptions } from '@/lib/lnurl'
import useDebounceCallback from '@/components/use-debounce-callback'
import { Scanner } from '@yudiel/react-qr-scanner'
import CameraIcon from '@/svgs/camera-line.svg'
import { gql, useMutation, useQuery } from '@apollo/client'
import { CREATE_WITHDRAWL, SEND_TO_LNADDR } from '@/fragments/wallet'
import { requestProvider } from 'webln'
import { useEffect, useState } from 'react'
import { useMe } from '@/components/me'
import { WithdrawlSkeleton } from './withdrawals/[id]'
import { Checkbox, Form, Input, InputUserSuggest, SubmitButton } from '@/components/form'
import { lnAddrSchema, withdrawlSchema } from '@/lib/validate'
import { useShowModal } from '@/components/modal'
import { useField } from 'formik'
import { useToast } from '@/components/toast'
import { WalletLimitBanner } from '@/components/banners'
import Plug from '@/svgs/plug.svg'
import { Scanner } from '@yudiel/react-qr-scanner'
import { decode } from 'bolt11'
import CameraIcon from '@/svgs/camera-line.svg'
import { FAST_POLL_INTERVAL, SSR } from '@/lib/constants'
import Qr, { QrSkeleton } from '@/components/qr'
import useDebounceCallback from '@/components/use-debounce-callback'
import { lnAddrOptions } from '@/lib/lnurl'
import AccordianItem from '@/components/accordian-item'
import { numWithUnits } from '@/lib/format'
export const getServerSideProps = getGetServerSideProps({ authRequired: true })
export default function Wallet () {
const router = useRouter()
if (router.query.type === 'fund') {
return (
<CenterLayout>
<FundForm />
</CenterLayout>
)
} else if (router.query.type?.includes('withdraw')) {
return (
<CenterLayout>
<WithdrawalForm />
</CenterLayout>
)
} else {
return (
<CenterLayout>
<YouHaveSats />
<WalletLimitBanner />
<WalletForm />
<WalletHistory />
</CenterLayout>
)
}
export default function Withdraw () {
return (
<CenterLayout>
<WithdrawForm />
</CenterLayout>
)
}
function YouHaveSats () {
function WithdrawForm () {
const router = useRouter()
const { me } = useMe()
const limitReached = me?.privates?.sats >= msatsToSats(BALANCE_LIMIT_MSATS)
return (
<h2 className={`${me ? 'visible' : 'invisible'} ${limitReached ? 'text-warning' : 'text-success'}`}>
you have{' '}
<span className='text-monospace'>{me && (
me.privates?.hideWalletBalance
? <HiddenWalletSummary />
: numWithUnits(me.privates?.sats, { abbreviate: false, format: true })
)}
</span>
</h2>
)
}
function WalletHistory () {
return (
<div className='d-flex flex-column text-center'>
<div>
<Link href='/satistics?inc=invoice,withdrawal' className='text-muted fw-bold text-underline'>
wallet history
</Link>
</div>
</div>
)
}
export function WalletForm () {
return (
<div className='align-items-center text-center pt-5 pb-4'>
<Link href='/wallet?type=fund'>
<Button variant='success'>fund</Button>
</Link>
<span className='mx-3 fw-bold text-muted'>or</span>
<Link href='/wallet?type=withdraw'>
<Button variant='success'>withdraw</Button>
</Link>
<div className='mt-5'>
<Link href='/settings/wallets'>
<Button variant='info'>attach wallets <Plug className='fill-white ms-1' width={16} height={16} /></Button>
</Link>
</div>
</div>
)
}
export function FundForm () {
const { me } = useMe()
const [showAlert, setShowAlert] = useState(true)
const router = useRouter()
const [createInvoice, { called, error }] = useMutation(gql`
mutation createInvoice($amount: Int!) {
createInvoice(amount: $amount) {
__typename
id
}
}`)
useEffect(() => {
setShowAlert(!window.localStorage.getItem('hideLnAddrAlert'))
}, [])
if (called && !error) {
return <QrSkeleton description status='generating' bolt11Info />
}
return (
<>
<YouHaveSats />
<WalletLimitBanner />
<div className='w-100 py-5'>
{me && showAlert &&
<Alert
variant='success' dismissible onClose={() => {
window.localStorage.setItem('hideLnAddrAlert', 'yep')
setShowAlert(false)
}}
>
You can also fund your account via lightning address with <strong>{`${me.name}@stacker.news`}</strong>
</Alert>}
<Form
initial={{
amount: 1000
}}
schema={amountSchema}
onSubmit={async ({ amount }) => {
const { data } = await createInvoice({ variables: { amount: Number(amount) } })
if (data.createInvoice.__typename === 'Direct') {
router.push(`/directs/${data.createInvoice.id}`)
} else {
router.push(`/invoices/${data.createInvoice.id}`)
}
}}
>
<Input
label='amount'
name='amount'
required
autoFocus
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/>
<SubmitButton variant='success' className='mt-2'>generate invoice</SubmitButton>
</Form>
</div>
<WalletHistory />
</>
)
}
export function WithdrawalForm () {
const router = useRouter()
return (
<div className='w-100 d-flex flex-column align-items-center py-5'>
<YouHaveSats />
<h2 className='text-start ms-1 ms-md-3'>
<div className='text-monospace'>
{numWithUnits(me?.privates?.sats - me?.privates?.credits, { abbreviate: false, format: true, unitSingular: 'sats', unitPlural: 'sats' })}
</div>
</h2>
<Nav
className={styles.nav}
activeKey={router.query.type}
activeKey={router.query.type ?? 'invoice'}
>
<Nav.Item>
<Link href='/wallet?type=withdraw' passHref legacyBehavior>
<Nav.Link eventKey='withdraw'>invoice</Nav.Link>
<Link href='/withdraw' passHref legacyBehavior>
<Nav.Link eventKey='invoice'>invoice</Nav.Link>
</Link>
</Nav.Item>
<Nav.Item>
<Link href='/wallet?type=lnurl-withdraw' passHref legacyBehavior>
<Nav.Link eventKey='lnurl-withdraw'>QR code</Nav.Link>
<Link href='/withdraw?type=lnurl' passHref legacyBehavior>
<Nav.Link eventKey='lnurl'>QR code</Nav.Link>
</Link>
</Nav.Item>
<Nav.Item>
<Link href='/wallet?type=lnaddr-withdraw' passHref legacyBehavior>
<Nav.Link eventKey='lnaddr-withdraw'>lightning address</Nav.Link>
<Link href='/withdraw?type=lnaddr' passHref legacyBehavior>
<Nav.Link eventKey='lnaddr'>lightning address</Nav.Link>
</Link>
</Nav.Item>
</Nav>
@ -205,12 +75,12 @@ export function SelectedWithdrawalForm () {
const router = useRouter()
switch (router.query.type) {
case 'withdraw':
return <InvWithdrawal />
case 'lnurl-withdraw':
return <LnWithdrawal />
case 'lnaddr-withdraw':
case 'lnurl':
return <LnurlWithdrawal />
case 'lnaddr':
return <LnAddrWithdrawal />
default:
return <InvWithdrawal />
}
}
@ -271,7 +141,9 @@ export function InvWithdrawal () {
required
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/>
<SubmitButton variant='success' className='mt-2'>withdraw</SubmitButton>
<div className='d-flex justify-content-end mt-4'>
<SubmitButton variant='success'>withdraw</SubmitButton>
</div>
</Form>
</>
)
@ -343,7 +215,7 @@ function LnQRWith ({ k1, encodedUrl }) {
return <Qr value={encodedUrl} status='waiting for you' />
}
export function LnWithdrawal () {
export function LnurlWithdrawal () {
// query for challenge
const [createWith, { data, error }] = useMutation(gql`
mutation createWith {
@ -507,7 +379,9 @@ export function LnAddrWithdrawal () {
/>
</div>
</div>}
<SubmitButton variant='success' className='mt-2'>send</SubmitButton>
<div className='d-flex justify-content-end mt-4'>
<SubmitButton variant='success'>send</SubmitButton>
</div>
</Form>
</>
)

View File

@ -0,0 +1,78 @@
-- AlterTable
ALTER TABLE "Item" ADD COLUMN "mcredits" BIGINT NOT NULL DEFAULT 0,
ADD COLUMN "commentMcredits" BIGINT NOT NULL DEFAULT 0;
-- AlterTable
ALTER TABLE "users" ADD COLUMN "mcredits" BIGINT NOT NULL DEFAULT 0,
ADD COLUMN "stackedMcredits" BIGINT NOT NULL DEFAULT 0,
ADD COLUMN "receiveCreditsBelowSats" INTEGER NOT NULL DEFAULT 10,
ADD COLUMN "sendCreditsBelowSats" INTEGER NOT NULL DEFAULT 10;
-- default to true now
ALTER TABLE "users" ALTER COLUMN "proxyReceive" SET DEFAULT true,
ALTER COLUMN "directReceive" SET DEFAULT true;
-- if they don't have either set, set to true
UPDATE "users" SET "proxyReceive" = true, "directReceive" = true
WHERE NOT "proxyReceive" AND NOT "directReceive";
-- add mcredits check
ALTER TABLE users ADD CONSTRAINT "mcredits_positive" CHECK ("mcredits" >= 0) NOT VALID;
ALTER TABLE users ADD CONSTRAINT "stackedMcredits_positive" CHECK ("stackedMcredits" >= 0) NOT VALID;
-- add cowboy credits
CREATE OR REPLACE FUNCTION item_comments_zaprank_with_me(_item_id int, _global_seed int, _me_id int, _level int, _where text, _order_by text)
RETURNS jsonb
LANGUAGE plpgsql VOLATILE PARALLEL SAFE AS
$$
DECLARE
result jsonb;
BEGIN
IF _level < 1 THEN
RETURN '[]'::jsonb;
END IF;
EXECUTE 'CREATE TEMP TABLE IF NOT EXISTS t_item ON COMMIT DROP AS'
|| ' SELECT "Item".*, "Item".created_at at time zone ''UTC'' AS "createdAt", "Item".updated_at at time zone ''UTC'' AS "updatedAt", '
|| ' "Item"."invoicePaidAt" at time zone ''UTC'' AS "invoicePaidAtUTC", to_jsonb(users.*) || jsonb_build_object(''meMute'', "Mute"."mutedId" IS NOT NULL) AS user, '
|| ' COALESCE("ItemAct"."meMsats", 0) AS "meMsats", COALESCE("ItemAct"."mePendingMsats", 0) as "mePendingMsats", COALESCE("ItemAct"."meDontLikeMsats", 0) AS "meDontLikeMsats", '
|| ' COALESCE("ItemAct"."meMcredits", 0) AS "meMcredits", COALESCE("ItemAct"."mePendingMcredits", 0) as "mePendingMcredits", '
|| ' "Bookmark"."itemId" IS NOT NULL AS "meBookmark", "ThreadSubscription"."itemId" IS NOT NULL AS "meSubscription", '
|| ' GREATEST(g.tf_hot_score, l.tf_hot_score) AS personal_hot_score, GREATEST(g.tf_top_score, l.tf_top_score) AS personal_top_score '
|| ' FROM "Item" '
|| ' JOIN users ON users.id = "Item"."userId" '
|| ' LEFT JOIN "Mute" ON "Mute"."muterId" = $5 AND "Mute"."mutedId" = "Item"."userId"'
|| ' LEFT JOIN "Bookmark" ON "Bookmark"."userId" = $5 AND "Bookmark"."itemId" = "Item".id '
|| ' LEFT JOIN "ThreadSubscription" ON "ThreadSubscription"."userId" = $5 AND "ThreadSubscription"."itemId" = "Item".id '
|| ' LEFT JOIN LATERAL ( '
|| ' SELECT "itemId", '
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM ''FAILED'' AND "InvoiceForward".id IS NOT NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "meMsats", '
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM ''FAILED'' AND "InvoiceForward".id IS NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "meMcredits", '
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS NOT DISTINCT FROM ''PENDING'' AND "InvoiceForward".id IS NOT NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "mePendingMsats", '
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS NOT DISTINCT FROM ''PENDING'' AND "InvoiceForward".id IS NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "mePendingMcredits", '
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM ''FAILED'' AND act = ''DONT_LIKE_THIS'') AS "meDontLikeMsats" '
|| ' FROM "ItemAct" '
|| ' LEFT JOIN "Invoice" ON "Invoice".id = "ItemAct"."invoiceId" '
|| ' LEFT JOIN "InvoiceForward" ON "InvoiceForward"."invoiceId" = "Invoice"."id" '
|| ' WHERE "ItemAct"."userId" = $5 '
|| ' AND "ItemAct"."itemId" = "Item".id '
|| ' GROUP BY "ItemAct"."itemId" '
|| ' ) "ItemAct" ON true '
|| ' LEFT JOIN zap_rank_personal_view g ON g."viewerId" = $6 AND g.id = "Item".id '
|| ' LEFT JOIN zap_rank_personal_view l ON l."viewerId" = $5 AND l.id = g.id '
|| ' WHERE "Item".path <@ (SELECT path FROM "Item" WHERE id = $1) ' || _where || ' '
USING _item_id, _level, _where, _order_by, _me_id, _global_seed;
EXECUTE ''
|| 'SELECT COALESCE(jsonb_agg(sub), ''[]''::jsonb) AS comments '
|| 'FROM ( '
|| ' SELECT "Item".*, item_comments_zaprank_with_me("Item".id, $6, $5, $2 - 1, $3, $4) AS comments '
|| ' FROM t_item "Item" '
|| ' WHERE "Item"."parentId" = $1 '
|| _order_by
|| ' ) sub'
INTO result USING _item_id, _level, _where, _order_by, _me_id, _global_seed;
RETURN result;
END
$$;

View File

@ -0,0 +1,6 @@
DROP TRIGGER IF EXISTS user_streak ON users;
CREATE TRIGGER user_streak
AFTER UPDATE ON users
FOR EACH ROW
WHEN (NEW.msats < OLD.msats OR NEW.mcredits < OLD.mcredits)
EXECUTE PROCEDURE user_streak_check();

View File

@ -1,4 +0,0 @@
-- fix existing boost jobs
UPDATE pgboss.job
SET keepuntil = startafter + interval '10 days'
WHERE name = 'expireBoost' AND state = 'created';

View File

@ -39,6 +39,7 @@ model User {
trust Float @default(0)
lastSeenAt DateTime?
stackedMsats BigInt @default(0)
stackedMcredits BigInt @default(0)
noteAllDescendants Boolean @default(true)
noteDeposits Boolean @default(true)
noteWithdrawals Boolean @default(true)
@ -119,6 +120,9 @@ model User {
autoWithdrawMaxFeePercent Float?
autoWithdrawThreshold Int?
autoWithdrawMaxFeeTotal Int?
mcredits BigInt @default(0)
receiveCreditsBelowSats Int @default(10)
sendCreditsBelowSats Int @default(10)
muters Mute[] @relation("muter")
muteds Mute[] @relation("muted")
ArcOut Arc[] @relation("fromUser")
@ -140,8 +144,8 @@ model User {
vaultKeyHash String @default("")
walletsUpdatedAt DateTime?
vaultEntries VaultEntry[] @relation("VaultEntries")
proxyReceive Boolean @default(false)
directReceive Boolean @default(false)
proxyReceive Boolean @default(true)
directReceive Boolean @default(true)
DirectPaymentReceived DirectPayment[] @relation("DirectPaymentReceived")
DirectPaymentSent DirectPayment[] @relation("DirectPaymentSent")
@ -520,10 +524,12 @@ model Item {
pollCost Int?
paidImgLink Boolean @default(false)
commentMsats BigInt @default(0)
commentMcredits BigInt @default(0)
lastCommentAt DateTime?
lastZapAt DateTime?
ncomments Int @default(0)
msats BigInt @default(0)
mcredits BigInt @default(0)
cost Int @default(0)
weightedDownVotes Float @default(0)
bio Boolean @default(false)

View File

@ -1,6 +1,6 @@
# Wallets
Every wallet that you can see at [/settings/wallets](https://stacker.news/settings/wallets) is implemented as a plugin in this directory.
Every wallet that you can see at [/wallets](https://stacker.news/wallets) is implemented as a plugin in this directory.
This README explains how you can add another wallet for use with Stacker News.
@ -59,11 +59,11 @@ This is an optional value. Set this to true if your wallet needs to be configure
- `fields: WalletField[]`
Wallet fields define what this wallet requires for configuration and thus are used to construct the forms like the one you can see at [/settings/wallets/lnbits](https://stacker.news/settings/walletslnbits).
Wallet fields define what this wallet requires for configuration and thus are used to construct the forms like the one you can see at [/wallets/lnbits](https://stacker.news/walletslnbits).
- `card: WalletCard`
Wallet cards are the components you can see at [/settings/wallets](https://stacker.news/settings/wallets). This property customizes this card for this wallet.
Wallet cards are the components you can see at [/wallets](https://stacker.news/wallets). This property customizes this card for this wallet.
- `validate: (config) => void`

View File

@ -2,7 +2,7 @@ import { useMe } from '@/components/me'
import useVault from '@/components/vault/use-vault'
import { useCallback } from 'react'
import { canReceive, canSend, getStorageKey, saveWalletLocally, siftConfig, upsertWalletVariables } from './common'
import { useMutation } from '@apollo/client'
import { gql, useMutation } from '@apollo/client'
import { generateMutation } from './graphql'
import { REMOVE_WALLET } from '@/fragments/wallet'
import { useWalletLogger } from '@/wallets/logger'
@ -18,6 +18,7 @@ export function useWalletConfigurator (wallet) {
const logger = useWalletLogger(wallet)
const [upsertWallet] = useMutation(generateMutation(wallet?.def))
const [removeWallet] = useMutation(REMOVE_WALLET)
const [disableFreebies] = useMutation(gql`mutation { disableFreebies }`)
const _saveToServer = useCallback(async (serverConfig, clientConfig, validateLightning) => {
const variables = await upsertWalletVariables(
@ -116,6 +117,7 @@ export function useWalletConfigurator (wallet) {
}
if (newCanSend) {
disableFreebies().catch(console.error)
if (oldCanSend) {
logger.ok('details for sending updated')
} else {
@ -130,7 +132,7 @@ export function useWalletConfigurator (wallet) {
logger.info('details for sending deleted')
}
}, [isActive, wallet.def, wallet.config, _saveToServer, _saveToLocal, _validate,
_detachFromLocal, _detachFromServer])
_detachFromLocal, _detachFromServer, disableFreebies])
const detach = useCallback(async () => {
if (isActive) {

View File

@ -27,9 +27,11 @@ const ITEM_SEARCH_FIELDS = gql`
remote
upvotes
sats
credits
boost
lastCommentAt
commentSats
commentCredits
path
ncomments
}`