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) - `models`: the current prisma client (for anything that doesn't need to be done atomically with the payment)
- `lnd`: the current lnd client - `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` ## `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). 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 = [ export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT, PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC 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 = [ export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT, PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
] ]

View File

@ -5,6 +5,7 @@ export const anonable = false
export const paymentMethods = [ export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT, PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC
] ]

View File

@ -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 BUY_CREDITS from './buyCredits'
import * as INVITE_GIFT from './inviteGift' import * as INVITE_GIFT from './inviteGift'
export const paidActions = { export const paidActions = {
@ -33,6 +34,7 @@ export const paidActions = {
TERRITORY_UNARCHIVE, TERRITORY_UNARCHIVE,
DONATE, DONATE,
RECEIVE, RECEIVE,
BUY_CREDITS,
INVITE_GIFT INVITE_GIFT
} }
@ -96,7 +98,8 @@ export default async function performPaidAction (actionType, args, incomingConte
// additional payment methods that logged in users can use // additional payment methods that logged in users can use
if (me) { 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 { try {
return await performNoInvoiceAction(actionType, args, contextWithPaymentMethod) return await performNoInvoiceAction(actionType, args, contextWithPaymentMethod)
} catch (e) { } catch (e) {
@ -141,6 +144,13 @@ async function performNoInvoiceAction (actionType, args, incomingContext) {
const context = { ...incomingContext, tx } const context = { ...incomingContext, tx }
if (paymentMethod === 'FEE_CREDIT') { 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({ await tx.user.update({
where: { where: {
id: me?.id ?? USER_ID.anon id: me?.id ?? USER_ID.anon

View File

@ -5,7 +5,8 @@ import { notifyInvite } from '@/lib/webPush'
export const anonable = false export const anonable = false
export const paymentMethods = [ 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 }) { export async function getCost ({ id }, { models, me }) {
@ -36,7 +37,7 @@ export async function perform ({ id, userId }, { me, cost, tx }) {
} }
}, },
data: { data: {
msats: { mcredits: {
increment: cost increment: cost
}, },
inviteId: id, inviteId: id,

View File

@ -8,6 +8,7 @@ export const anonable = true
export const paymentMethods = [ export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT, PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC, PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC,
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC 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, // 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 // cost must be greater than user's balance, and user has not disabled freebies
const freebie = (parentId || bio) && cost <= baseCost && !!me && 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) return freebie ? BigInt(0) : BigInt(cost)
} }

View File

@ -8,6 +8,7 @@ export const anonable = true
export const paymentMethods = [ export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT, PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC 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 { PAID_ACTION_TERMINAL_STATES, USER_ID } from '@/lib/constants'
import { msatsToSats, numWithUnits } from '@/lib/format'
import { datePivot } from '@/lib/time' import { datePivot } from '@/lib/time'
const MAX_PENDING_PAID_ACTIONS_PER_USER = 100 const MAX_PENDING_PAID_ACTIONS_PER_USER = 100
const MAX_PENDING_DIRECT_INVOICES_PER_USER_MINUTES = 10 const MAX_PENDING_DIRECT_INVOICES_PER_USER_MINUTES = 10
const MAX_PENDING_DIRECT_INVOICES_PER_USER = 100 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) { export async function assertBelowMaxPendingInvoices (context) {
const { models, me } = context const { models, me } = context
@ -56,47 +54,3 @@ export async function assertBelowMaxPendingDirectPayments (userId, context) {
throw new Error('Receiver has too many direct payments') 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 = [ export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT, PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC 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 { toPositiveBigInt, numWithUnits, msatsToSats, satsToMsats } from '@/lib/format'
import { notifyDeposit } from '@/lib/webPush' import { notifyDeposit } from '@/lib/webPush'
import { getInvoiceableWallets } from '@/wallets/server' import { getInvoiceableWallets } from '@/wallets/server'
import { assertBelowBalanceLimit } from './lib/assert'
export const anonable = false export const anonable = false
@ -19,13 +18,16 @@ export async function getCost ({ msats }) {
export async function getInvoiceablePeer (_, { me, models, cost, paymentMethod }) { 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.P2P && !me?.proxyReceive) return null
if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.DIRECT && !me?.directReceive) 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 }) const wallets = await getInvoiceableWallets(me.id, { models })
if (wallets.length === 0) { if (wallets.length === 0) {
return null return null
} }
if (cost < satsToMsats(me.receiveCreditsBelowSats)) {
return null
}
return me.id return me.id
} }
@ -39,7 +41,7 @@ export async function perform ({
lud18Data, lud18Data,
noteStr noteStr
}, { me, tx }) { }, { me, tx }) {
const invoice = await tx.invoice.update({ return await tx.invoice.update({
where: { id: invoiceId }, where: { id: invoiceId },
data: { data: {
comment, comment,
@ -48,11 +50,6 @@ export async function perform ({
}, },
include: { invoiceForward: true } 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 }) { export async function describe ({ description }, { me, cost, paymentMethod, sybilFeePercent }) {
@ -73,7 +70,7 @@ export async function onPaid ({ invoice }, { tx }) {
await tx.user.update({ await tx.user.update({
where: { id: invoice.userId }, where: { id: invoice.userId },
data: { data: {
msats: { mcredits: {
increment: invoice.msatsReceived increment: invoice.msatsReceived
} }
} }

View File

@ -6,6 +6,7 @@ export const anonable = false
export const paymentMethods = [ export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT, PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
] ]

View File

@ -6,6 +6,7 @@ export const anonable = false
export const paymentMethods = [ export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT, PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
] ]

View File

@ -6,6 +6,7 @@ export const anonable = false
export const paymentMethods = [ export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT, PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
] ]

View File

@ -7,6 +7,7 @@ export const anonable = false
export const paymentMethods = [ export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT, PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
] ]

View File

@ -6,8 +6,9 @@ import { getInvoiceableWallets } from '@/wallets/server'
export const anonable = true export const anonable = true
export const paymentMethods = [ export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.P2P, 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.OPTIMISTIC,
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
] ]
@ -16,16 +17,38 @@ export async function getCost ({ sats }) {
return satsToMsats(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({ const item = await models.item.findUnique({
where: { id: parseInt(id) }, 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 }) const wallets = await getInvoiceableWallets(item.userId, { models })
// request peer invoice if they have an attached wallet and have not forwarded the item // 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 () { export async function getSybilFeePercent () {
@ -90,32 +113,38 @@ export async function onPaid ({ invoice, actIds }, { tx }) {
const sats = msatsToSats(msats) const sats = msatsToSats(msats)
const itemAct = acts.find(act => act.act === 'TIP') const itemAct = acts.find(act => act.act === 'TIP')
// give user and all forwards the sats if (invoice?.invoiceForward) {
await tx.$executeRaw` // only the op got sats and we need to add it to their stackedMsats
WITH forwardees AS ( // because the sats were p2p
SELECT "userId", ((${itemAct.msats}::BIGINT * pct) / 100)::BIGINT AS msats await tx.user.update({
FROM "ItemForward" where: { id: itemAct.item.userId },
WHERE "itemId" = ${itemAct.itemId}::INTEGER data: { stackedMsats: { increment: itemAct.msats } }
), total_forwarded AS ( })
SELECT COALESCE(SUM(msats), 0) as msats } else {
FROM forwardees // splits only use mcredits
), recipients AS ( await tx.$executeRaw`
SELECT "userId", msats, msats AS "stackedMsats" FROM forwardees WITH forwardees AS (
UNION SELECT "userId", ((${itemAct.msats}::BIGINT * pct) / 100)::BIGINT AS mcredits
SELECT ${itemAct.item.userId}::INTEGER as "userId", FROM "ItemForward"
CASE WHEN ${!!invoice?.invoiceForward}::BOOLEAN WHERE "itemId" = ${itemAct.itemId}::INTEGER
THEN 0::BIGINT ), total_forwarded AS (
ELSE ${itemAct.msats}::BIGINT - (SELECT msats FROM total_forwarded)::BIGINT SELECT COALESCE(SUM(mcredits), 0) as mcredits
END as msats, FROM forwardees
${itemAct.msats}::BIGINT - (SELECT msats FROM total_forwarded)::BIGINT as "stackedMsats" ), recipients AS (
ORDER BY "userId" ASC -- order to prevent deadlocks SELECT "userId", mcredits FROM forwardees
) UNION
UPDATE users SELECT ${itemAct.item.userId}::INTEGER as "userId",
SET ${itemAct.msats}::BIGINT - (SELECT mcredits FROM total_forwarded)::BIGINT as mcredits
msats = users.msats + recipients.msats, ORDER BY "userId" ASC -- order to prevent deadlocks
"stackedMsats" = users."stackedMsats" + recipients."stackedMsats" )
FROM recipients UPDATE users
WHERE users.id = recipients."userId"` 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 // 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 // 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), "weightedVotes" = "weightedVotes" + (zapper.trust * zap.log_sats),
upvotes = upvotes + zap.first_vote, upvotes = upvotes + zap.first_vote,
msats = "Item".msats + ${msats}::BIGINT, msats = "Item".msats + ${msats}::BIGINT,
mcredits = "Item".mcredits + ${invoice?.invoiceForward ? 0n : msats}::BIGINT,
"lastZapAt" = now() "lastZapAt" = now()
FROM zap, zapper FROM zap, zapper
WHERE "Item".id = ${itemAct.itemId}::INTEGER 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 SELECT * FROM "Item" WHERE id = ${itemAct.itemId}::INTEGER
) )
UPDATE "Item" UPDATE "Item"
SET "commentMsats" = "Item"."commentMsats" + ${msats}::BIGINT SET "commentMsats" = "Item"."commentMsats" + ${msats}::BIGINT,
"commentMcredits" = "Item"."commentMcredits" + ${invoice?.invoiceForward ? 0n : msats}::BIGINT
FROM zapped FROM zapped
WHERE "Item".path @> zapped.path AND "Item".id <> zapped.id` WHERE "Item".path @> zapped.path AND "Item".id <> zapped.id`
} }

View File

@ -83,7 +83,7 @@ export default {
}, },
poor: async (invite, args, { me, models }) => { poor: async (invite, args, { me, models }) => {
const user = await models.user.findUnique({ where: { id: invite.userId } }) 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 }) => { description: (invite, args, { me }) => {
return invite.userId === me?.id ? invite.description : undefined 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(` return await models.$queryRawUnsafe(`
SELECT "Item".*, to_jsonb(users.*) || jsonb_build_object('meMute', "Mute"."mutedId" IS NOT NULL) as user, 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"."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", 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", "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) 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 "SubSubscription" ON "Sub"."name" = "SubSubscription"."subName" AND "SubSubscription"."userId" = ${me.id}
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT "itemId", 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 DISTINCT FROM 'FAILED' AND "InvoiceForward".id IS NOT NULL 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 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" sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM 'FAILED' AND act = 'DONT_LIKE_THIS') AS "meDontLikeMsats"
FROM "ItemAct" FROM "ItemAct"
LEFT JOIN "Invoice" ON "Invoice".id = "ItemAct"."invoiceId"
LEFT JOIN "InvoiceForward" ON "InvoiceForward"."invoiceId" = "Invoice"."id"
WHERE "ItemAct"."userId" = ${me.id} WHERE "ItemAct"."userId" = ${me.id}
AND "ItemAct"."itemId" = "Item".id AND "ItemAct"."itemId" = "Item".id
GROUP BY "ItemAct"."itemId" GROUP BY "ItemAct"."itemId"
@ -940,7 +945,7 @@ export default {
return await performPaidAction('POLL_VOTE', { id }, { me, models, lnd }) 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 }) assertApiKeyNotPermitted({ me })
await validateSchema(actSchema, { sats, act }) await validateSchema(actSchema, { sats, act })
await assertGofacYourself({ models, headers }) await assertGofacYourself({ models, headers })
@ -974,7 +979,7 @@ export default {
} }
if (act === 'TIP') { 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') { } else if (act === 'DONT_LIKE_THIS') {
return await performPaidAction('DOWN_ZAP', { id, sats }, { me, models, lnd }) return await performPaidAction('DOWN_ZAP', { id, sats }, { me, models, lnd })
} else if (act === 'BOOST') { } else if (act === 'BOOST') {
@ -1049,11 +1054,17 @@ export default {
}, },
Item: { Item: {
sats: async (item, args, { models }) => { 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 }) => { commentSats: async (item, args, { models }) => {
return msatsToSats(item.commentMsats) return msatsToSats(item.commentMsats)
}, },
commentCredits: async (item, args, { models }) => {
return msatsToSats(item.commentMcredits)
},
isJob: async (item, args, { models }) => { isJob: async (item, args, { models }) => {
return item.subName === 'jobs' return item.subName === 'jobs'
}, },
@ -1170,8 +1181,8 @@ export default {
}, },
meSats: async (item, args, { me, models }) => { meSats: async (item, args, { me, models }) => {
if (!me) return 0 if (!me) return 0
if (typeof item.meMsats !== 'undefined') { if (typeof item.meMsats !== 'undefined' && typeof item.meMcredits !== 'undefined') {
return msatsToSats(item.meMsats) return msatsToSats(BigInt(item.meMsats) + BigInt(item.meMcredits))
} }
const { _sum: { msats } } = await models.itemAct.aggregate({ const { _sum: { msats } } = await models.itemAct.aggregate({
@ -1197,6 +1208,38 @@ export default {
return (msats && msatsToSats(msats)) || 0 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 }) => { meDontLikeSats: async (item, args, { me, models }) => {
if (!me) return 0 if (!me) return 0
if (typeof item.meDontLikeMsats !== 'undefined') { if (typeof item.meDontLikeMsats !== 'undefined') {

View File

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

View File

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

View File

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

View File

@ -583,6 +583,9 @@ const resolvers = {
await models.walletLog.deleteMany({ where: { userId: me.id, wallet } }) await models.walletLog.deleteMany({ where: { userId: me.id, wallet } })
return true 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 }))?.withdrawl?.msatsPaid
return msats ? msatsToSats(msats) : null 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 }) => { nostr: async (invoice, args, { models }) => {
try { try {
return JSON.parse(invoice.desc) return JSON.parse(invoice.desc)

View File

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

View File

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

View File

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

View File

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

View File

@ -5,8 +5,6 @@ import { useMe } from '@/components/me'
import { useMutation } from '@apollo/client' import { useMutation } from '@apollo/client'
import { WELCOME_BANNER_MUTATION } from '@/fragments/users' import { WELCOME_BANNER_MUTATION } from '@/fragments/users'
import { useToast } from '@/components/toast' import { useToast } from '@/components/toast'
import { BALANCE_LIMIT_MSATS } from '@/lib/constants'
import { msatsToSats, numWithUnits } from '@/lib/format'
import Link from 'next/link' import Link from 'next/link'
export function WelcomeBanner ({ Banner }) { 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 }) { export function WalletSecurityBanner ({ isActive }) {
return ( return (
<Alert className={styles.banner} key='info' variant='warning'> <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 lines = { ...baseLineItems, ...lineItems, ...remoteLineItems }
const total = Object.values(lines).sort(sortHelper).reduce((acc, { modifier }) => modifier(acc), 0) 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 // 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 { return {
lines, lines,
merge: mergeLineItems, merge: mergeLineItems,

View File

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

View File

@ -30,6 +30,39 @@ import classNames from 'classnames'
import SubPopover from './sub-popover' import SubPopover from './sub-popover'
import useCanEdit from './use-can-edit' 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 ({ export default function ItemInfo ({
item, full, commentsText = 'comments', item, full, commentsText = 'comments',
commentTextSingular = 'comment', className, embellishUser, extraInfo, edit, toggleEdit, editText, commentTextSingular = 'comment', className, embellishUser, extraInfo, edit, toggleEdit, editText,
@ -62,16 +95,7 @@ export default function ItemInfo ({
<div className={className || `${styles.other}`}> <div className={className || `${styles.other}`}>
{!(item.position && (pinnable || !item.subName)) && !(!item.parentId && Number(item.user?.id) === USER_ID.ad) && {!(item.position && (pinnable || !item.subName)) && !(!item.parentId && Number(item.user?.id) === USER_ID.ad) &&
<> <>
<span title={`from ${numWithUnits(item.upvotes, { <span title={itemTitle(item)}>
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)`} `}
>
{numWithUnits(item.sats)} {numWithUnits(item.sats)}
</span> </span>
<span> \ </span> <span> \ </span>
@ -229,11 +253,21 @@ function InfoDropdownItem ({ item }) {
<div>cost</div> <div>cost</div>
<div>{item.cost}</div> <div>{item.cost}</div>
<div>sats</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 && ( {me && (
<> <>
<div>sats from me</div> <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> <div>zappers</div>

View File

@ -6,12 +6,11 @@ import BackArrow from '../../svgs/arrow-left-line.svg'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import Price from '../price' import Price from '../price'
import SubSelect from '../sub-select' 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 Head from 'next/head'
import NoteIcon from '../../svgs/notification-4-fill.svg' import NoteIcon from '../../svgs/notification-4-fill.svg'
import { useMe } from '../me' import { useMe } from '../me'
import HiddenWalletSummary from '../hidden-wallet-summary' import { abbrNum } from '../../lib/format'
import { abbrNum, msatsToSats } from '../../lib/format'
import { useServiceWorker } from '../serviceworker' import { useServiceWorker } from '../serviceworker'
import { signOut } from 'next-auth/react' import { signOut } from 'next-auth/react'
import Badges from '../badge' import Badges from '../badge'
@ -25,7 +24,7 @@ import { useHasNewNotes } from '../use-has-new-notes'
import { useWallets } from '@/wallets/index' import { useWallets } from '@/wallets/index'
import SwitchAccountList, { useAccounts } from '@/components/account' import SwitchAccountList, { useAccounts } from '@/components/account'
import { useShowModal } from '@/components/modal' import { useShowModal } from '@/components/modal'
import { numWithUnits } from '@/lib/format'
export function Brand ({ className }) { export function Brand ({ className }) {
return ( return (
<Link href='/' passHref legacyBehavior> <Link href='/' passHref legacyBehavior>
@ -140,21 +139,24 @@ export function NavNotifications ({ className }) {
export function WalletSummary () { export function WalletSummary () {
const { me } = useMe() const { me } = useMe()
if (!me) return null if (!me || me.privates?.sats === 0) return null
if (me.privates?.hideWalletBalance) { return (
return <HiddenWalletSummary abbreviate fixedWidth /> <span
} className='text-monospace'
return `${abbrNum(me.privates?.sats)}` title={`${numWithUnits(me.privates?.credits, { abbreviate: false, unitSingular: 'CC', unitPlural: 'CCs' })}`}
>
{`${abbrNum(me.privates?.sats)}`}
</span>
)
} }
export function NavWalletSummary ({ className }) { export function NavWalletSummary ({ className }) {
const { me } = useMe() const { me } = useMe()
const walletLimitReached = me?.privates?.sats >= msatsToSats(BALANCE_LIMIT_MSATS)
return ( return (
<Nav.Item className={className}> <Nav.Item className={className}>
<Link href='/wallet' passHref legacyBehavior> <Link href='/credits' passHref legacyBehavior>
<Nav.Link eventKey='wallet' className={`${walletLimitReached ? 'text-warning' : 'text-success'} text-monospace px-0 text-nowrap`}> <Nav.Link eventKey='credits' className='text-success text-monospace px-0 text-nowrap'>
<WalletSummary me={me} /> <WalletSummary me={me} />
</Nav.Link> </Nav.Link>
</Link> </Link>
@ -194,8 +196,11 @@ export function MeDropdown ({ me, dropNavKey }) {
<Link href={'/' + me.name + '/bookmarks'} passHref legacyBehavior> <Link href={'/' + me.name + '/bookmarks'} passHref legacyBehavior>
<Dropdown.Item active={me.name + '/bookmarks' === dropNavKey}>bookmarks</Dropdown.Item> <Dropdown.Item active={me.name + '/bookmarks' === dropNavKey}>bookmarks</Dropdown.Item>
</Link> </Link>
<Link href='/wallet' passHref legacyBehavior> <Link href='/wallets' passHref legacyBehavior>
<Dropdown.Item eventKey='wallet'>wallet</Dropdown.Item> <Dropdown.Item eventKey='wallets'>wallets</Dropdown.Item>
</Link>
<Link href='/credits' passHref legacyBehavior>
<Dropdown.Item eventKey='credits'>credits</Dropdown.Item>
</Link> </Link>
<Link href='/satistics?inc=invoice,withdrawal,stacked,spent' passHref legacyBehavior> <Link href='/satistics?inc=invoice,withdrawal,stacked,spent' passHref legacyBehavior>
<Dropdown.Item eventKey='satistics'>satistics</Dropdown.Item> <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> <Link href={'/' + me.name + '/bookmarks'} passHref legacyBehavior>
<Dropdown.Item active={me.name + '/bookmarks' === dropNavKey}>bookmarks</Dropdown.Item> <Dropdown.Item active={me.name + '/bookmarks' === dropNavKey}>bookmarks</Dropdown.Item>
</Link> </Link>
<Link href='/wallet' passHref legacyBehavior> <Link href='/wallets' passHref legacyBehavior>
<Dropdown.Item eventKey='wallet'>wallet</Dropdown.Item> <Dropdown.Item eventKey='wallets'>wallets</Dropdown.Item>
</Link>
<Link href='/credits' passHref legacyBehavior>
<Dropdown.Item eventKey='credits'>credits</Dropdown.Item>
</Link> </Link>
<Link href='/satistics?inc=invoice,withdrawal,stacked,spent' passHref legacyBehavior> <Link href='/satistics?inc=invoice,withdrawal,stacked,spent' passHref legacyBehavior>
<Dropdown.Item eventKey='satistics'>satistics</Dropdown.Item> <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 }) { function Votification ({ n }) {
let forwardedSats = 0 let forwardedSats = 0
let ForwardedUsers = null let ForwardedUsers = null
let stackedTextString
let forwardedTextString
if (n.item.forwards?.length) { if (n.item.forwards?.length) {
forwardedSats = Math.floor(n.earnedSats * n.item.forwards.map(fwd => fwd.pct).reduce((sum, cur) => sum + cur) / 100) 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) => ForwardedUsers = () => n.item.forwards.map((fwd, i) =>
@ -547,14 +565,18 @@ function Votification ({ n }) {
</Link> </Link>
{i !== n.item.forwards.length - 1 && ' '} {i !== n.item.forwards.length - 1 && ' '}
</span>) </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 ( return (
<> <>
<NoteHeader color='success'> <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 && {n.item.forwards?.length > 0 &&
<> <>
{' '}and forwarded {numWithUnits(forwardedSats, { abbreviate: false })} to{' '} {' '}and forwarded {forwardedTextString} to{' '}
<ForwardedUsers /> <ForwardedUsers />
</>} </>}
</NoteHeader> </NoteHeader>
@ -567,7 +589,7 @@ function ForwardedVotification ({ n }) {
return ( return (
<> <>
<NoteHeader color='success'> <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> </NoteHeader>
<NoteItem item={n.item} /> <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>} : <Card.Title className={styles.walletLogo}>{wallet.def.card.title}</Card.Title>}
</div> </div>
</Card.Body> </Card.Body>
<Link href={`/settings/wallets/${wallet.def.name}`}> <Link href={`/wallets/${wallet.def.name}`}>
<Card.Footer className={styles.attach}> <Card.Footer className={styles.attach}>
{isConfigured(wallet) {isConfigured(wallet)
? <>configure<Gear width={14} height={14} /></> ? <>configure<Gear width={14} height={14} /></>

View File

@ -281,7 +281,7 @@ export function useWalletLogs (wallet, initialPage = 1, logsPerPage = 10) {
useEffect(() => { useEffect(() => {
// only fetch new logs if we are on a page that uses logs // 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 if (!me || !needLogs) return
let timeout let timeout

View File

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

View File

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

View File

@ -11,6 +11,7 @@ export const PAID_ACTION = gql`
fragment PaidActionFields on PaidAction { fragment PaidActionFields on PaidAction {
invoice { invoice {
...InvoiceFields ...InvoiceFields
invoiceForward
} }
paymentMethod 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` export const ACT_MUTATION = gql`
${PAID_ACTION} ${PAID_ACTION}
${ITEM_ACT_PAID_ACTION_FIELDS} ${ITEM_ACT_PAID_ACTION_FIELDS}

View File

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

View File

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

View File

@ -2,11 +2,11 @@ import { string, ValidationError, number, object, array, boolean, date } from '.
import { import {
BOOST_MIN, MAX_POLL_CHOICE_LENGTH, MAX_TITLE_LENGTH, MAX_POLL_NUM_CHOICES, 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, 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' } from './constants'
import { SUPPORTED_CURRENCIES } from './currency' import { SUPPORTED_CURRENCIES } from './currency'
import { NOSTR_MAX_RELAY_NUM, NOSTR_PUBKEY_BECH32, NOSTR_PUBKEY_HEX } from './nostr' 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 { SUB } from '@/fragments/subs'
import { NAME_QUERY } from '@/fragments/users' import { NAME_QUERY } from '@/fragments/users'
import { datePivot } from './time' import { datePivot } from './time'
@ -185,7 +185,7 @@ export function advSchema (args) {
} }
export const autowithdrawSchemaMembers = object({ 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), 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) 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) { if (invite.revoked) {
Inner = () => <div className='text-danger'>this invite link expired</div> Inner = () => <div className='text-danger'>this invite link expired</div>
} else if ((invite.limit && invite.limit <= invite.invitees.length) || invite.poor) { } 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 { } else {
Inner = () => ( Inner = () => (
<div> <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>{' '} <Link href={`/${invite.user.name}`}>@{invite.user.name}</Link>{' '}
when you sign up today when you sign up
</div> </div>
) )
} }

View File

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

View File

@ -160,12 +160,15 @@ export default function Settings ({ ssrData }) {
hideIsContributor: settings?.hideIsContributor, hideIsContributor: settings?.hideIsContributor,
noReferralLinks: settings?.noReferralLinks, noReferralLinks: settings?.noReferralLinks,
proxyReceive: settings?.proxyReceive, proxyReceive: settings?.proxyReceive,
directReceive: settings?.directReceive directReceive: settings?.directReceive,
receiveCreditsBelowSats: settings?.receiveCreditsBelowSats,
sendCreditsBelowSats: settings?.sendCreditsBelowSats
}} }}
schema={settingsSchema} schema={settingsSchema}
onSubmit={async ({ onSubmit={async ({
tipDefault, tipRandom, tipRandomMin, tipRandomMax, withdrawMaxFeeDefault, tipDefault, tipRandom, tipRandomMin, tipRandomMax, withdrawMaxFeeDefault,
zapUndos, zapUndosEnabled, nostrPubkey, nostrRelays, satsFilter, zapUndos, zapUndosEnabled, nostrPubkey, nostrRelays, satsFilter,
receiveCreditsBelowSats, sendCreditsBelowSats,
...values ...values
}) => { }) => {
if (nostrPubkey.length === 0) { if (nostrPubkey.length === 0) {
@ -191,6 +194,8 @@ export default function Settings ({ ssrData }) {
withdrawMaxFeeDefault: Number(withdrawMaxFeeDefault), withdrawMaxFeeDefault: Number(withdrawMaxFeeDefault),
satsFilter: Number(satsFilter), satsFilter: Number(satsFilter),
zapUndos: zapUndosEnabled ? Number(zapUndos) : null, zapUndos: zapUndosEnabled ? Number(zapUndos) : null,
receiveCreditsBelowSats: Number(receiveCreditsBelowSats),
sendCreditsBelowSats: Number(sendCreditsBelowSats),
nostrPubkey, nostrPubkey,
nostrRelays: nostrRelaysFiltered, nostrRelays: nostrRelaysFiltered,
...values ...values
@ -335,6 +340,18 @@ export default function Settings ({ ssrData }) {
name='noteCowboyHat' name='noteCowboyHat'
/> />
<div className='form-label'>wallet</div> <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 <Checkbox
label={ label={
<div className='d-flex align-items-center'>proxy deposits to attached wallets <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) await save(values, values.enabled)
toaster.success('saved settings') toaster.success('saved settings')
router.push('/settings/wallets') router.push('/wallets')
} catch (err) { } catch (err) {
console.error(err) console.error(err)
toaster.danger(err.message || err.toString?.()) toaster.danger(err.message || err.toString?.())
@ -115,7 +115,7 @@ export default function WalletSettings () {
try { try {
await detach() await detach()
toaster.success('saved settings') toaster.success('saved settings')
router.push('/settings/wallets') router.push('/wallets')
} catch (err) { } catch (err) {
console.error(err) console.error(err)
const message = 'failed to detach: ' + err.message || err.toString?.() const message = 'failed to detach: ' + err.message || err.toString?.()

View File

@ -86,10 +86,10 @@ export default function Wallet ({ ssrData }) {
return ( return (
<Layout> <Layout>
<div className='py-5 w-100'> <div className='py-5 w-100'>
<h2 className='mb-2 text-center'>attach wallets</h2> <h2 className='mb-2 text-center'>wallets</h2>
<h6 className='text-muted text-center'>attach wallets to supplement your SN wallet</h6> <h6 className='text-muted text-center'>use real bitcoin</h6>
<div className='text-center'> <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 wallet logs
</Link> </Link>
</div> </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 { getGetServerSideProps } from '@/api/ssrApollo'
import { amountSchema, lnAddrSchema, withdrawlSchema } from '@/lib/validate' import { CenterLayout } from '@/components/layout'
import Nav from 'react-bootstrap/Nav' import Link from 'next/link'
import { BALANCE_LIMIT_MSATS, FAST_POLL_INTERVAL, SSR } from '@/lib/constants' import { useRouter } from 'next/router'
import { msatsToSats, numWithUnits } from '@/lib/format' import { InputGroup, Nav } from 'react-bootstrap'
import styles from '@/components/user-header.module.css' import styles from '@/components/user-header.module.css'
import HiddenWalletSummary from '@/components/hidden-wallet-summary' import { gql, useMutation, useQuery } from '@apollo/client'
import AccordianItem from '@/components/accordian-item' import { CREATE_WITHDRAWL, SEND_TO_LNADDR } from '@/fragments/wallet'
import { lnAddrOptions } from '@/lib/lnurl' import { requestProvider } from 'webln'
import useDebounceCallback from '@/components/use-debounce-callback' import { useEffect, useState } from 'react'
import { Scanner } from '@yudiel/react-qr-scanner' import { useMe } from '@/components/me'
import CameraIcon from '@/svgs/camera-line.svg' 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 { useShowModal } from '@/components/modal'
import { useField } from 'formik' import { useField } from 'formik'
import { useToast } from '@/components/toast' import { useToast } from '@/components/toast'
import { WalletLimitBanner } from '@/components/banners' import { Scanner } from '@yudiel/react-qr-scanner'
import Plug from '@/svgs/plug.svg'
import { decode } from 'bolt11' 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 const getServerSideProps = getGetServerSideProps({ authRequired: true })
export default function Wallet () { export default function Withdraw () {
const router = useRouter() return (
<CenterLayout>
if (router.query.type === 'fund') { <WithdrawForm />
return ( </CenterLayout>
<CenterLayout> )
<FundForm />
</CenterLayout>
)
} else if (router.query.type?.includes('withdraw')) {
return (
<CenterLayout>
<WithdrawalForm />
</CenterLayout>
)
} else {
return (
<CenterLayout>
<YouHaveSats />
<WalletLimitBanner />
<WalletForm />
<WalletHistory />
</CenterLayout>
)
}
} }
function YouHaveSats () { function WithdrawForm () {
const router = useRouter()
const { me } = useMe() 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 ( return (
<div className='w-100 d-flex flex-column align-items-center py-5'> <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 <Nav
className={styles.nav} className={styles.nav}
activeKey={router.query.type} activeKey={router.query.type ?? 'invoice'}
> >
<Nav.Item> <Nav.Item>
<Link href='/wallet?type=withdraw' passHref legacyBehavior> <Link href='/withdraw' passHref legacyBehavior>
<Nav.Link eventKey='withdraw'>invoice</Nav.Link> <Nav.Link eventKey='invoice'>invoice</Nav.Link>
</Link> </Link>
</Nav.Item> </Nav.Item>
<Nav.Item> <Nav.Item>
<Link href='/wallet?type=lnurl-withdraw' passHref legacyBehavior> <Link href='/withdraw?type=lnurl' passHref legacyBehavior>
<Nav.Link eventKey='lnurl-withdraw'>QR code</Nav.Link> <Nav.Link eventKey='lnurl'>QR code</Nav.Link>
</Link> </Link>
</Nav.Item> </Nav.Item>
<Nav.Item> <Nav.Item>
<Link href='/wallet?type=lnaddr-withdraw' passHref legacyBehavior> <Link href='/withdraw?type=lnaddr' passHref legacyBehavior>
<Nav.Link eventKey='lnaddr-withdraw'>lightning address</Nav.Link> <Nav.Link eventKey='lnaddr'>lightning address</Nav.Link>
</Link> </Link>
</Nav.Item> </Nav.Item>
</Nav> </Nav>
@ -205,12 +75,12 @@ export function SelectedWithdrawalForm () {
const router = useRouter() const router = useRouter()
switch (router.query.type) { switch (router.query.type) {
case 'withdraw': case 'lnurl':
return <InvWithdrawal /> return <LnurlWithdrawal />
case 'lnurl-withdraw': case 'lnaddr':
return <LnWithdrawal />
case 'lnaddr-withdraw':
return <LnAddrWithdrawal /> return <LnAddrWithdrawal />
default:
return <InvWithdrawal />
} }
} }
@ -271,7 +141,9 @@ export function InvWithdrawal () {
required required
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>} 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> </Form>
</> </>
) )
@ -343,7 +215,7 @@ function LnQRWith ({ k1, encodedUrl }) {
return <Qr value={encodedUrl} status='waiting for you' /> return <Qr value={encodedUrl} status='waiting for you' />
} }
export function LnWithdrawal () { export function LnurlWithdrawal () {
// query for challenge // query for challenge
const [createWith, { data, error }] = useMutation(gql` const [createWith, { data, error }] = useMutation(gql`
mutation createWith { mutation createWith {
@ -507,7 +379,9 @@ export function LnAddrWithdrawal () {
/> />
</div> </div>
</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> </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) trust Float @default(0)
lastSeenAt DateTime? lastSeenAt DateTime?
stackedMsats BigInt @default(0) stackedMsats BigInt @default(0)
stackedMcredits BigInt @default(0)
noteAllDescendants Boolean @default(true) noteAllDescendants Boolean @default(true)
noteDeposits Boolean @default(true) noteDeposits Boolean @default(true)
noteWithdrawals Boolean @default(true) noteWithdrawals Boolean @default(true)
@ -119,6 +120,9 @@ model User {
autoWithdrawMaxFeePercent Float? autoWithdrawMaxFeePercent Float?
autoWithdrawThreshold Int? autoWithdrawThreshold Int?
autoWithdrawMaxFeeTotal Int? autoWithdrawMaxFeeTotal Int?
mcredits BigInt @default(0)
receiveCreditsBelowSats Int @default(10)
sendCreditsBelowSats Int @default(10)
muters Mute[] @relation("muter") muters Mute[] @relation("muter")
muteds Mute[] @relation("muted") muteds Mute[] @relation("muted")
ArcOut Arc[] @relation("fromUser") ArcOut Arc[] @relation("fromUser")
@ -140,8 +144,8 @@ model User {
vaultKeyHash String @default("") vaultKeyHash String @default("")
walletsUpdatedAt DateTime? walletsUpdatedAt DateTime?
vaultEntries VaultEntry[] @relation("VaultEntries") vaultEntries VaultEntry[] @relation("VaultEntries")
proxyReceive Boolean @default(false) proxyReceive Boolean @default(true)
directReceive Boolean @default(false) directReceive Boolean @default(true)
DirectPaymentReceived DirectPayment[] @relation("DirectPaymentReceived") DirectPaymentReceived DirectPayment[] @relation("DirectPaymentReceived")
DirectPaymentSent DirectPayment[] @relation("DirectPaymentSent") DirectPaymentSent DirectPayment[] @relation("DirectPaymentSent")
@ -520,10 +524,12 @@ model Item {
pollCost Int? pollCost Int?
paidImgLink Boolean @default(false) paidImgLink Boolean @default(false)
commentMsats BigInt @default(0) commentMsats BigInt @default(0)
commentMcredits BigInt @default(0)
lastCommentAt DateTime? lastCommentAt DateTime?
lastZapAt DateTime? lastZapAt DateTime?
ncomments Int @default(0) ncomments Int @default(0)
msats BigInt @default(0) msats BigInt @default(0)
mcredits BigInt @default(0)
cost Int @default(0) cost Int @default(0)
weightedDownVotes Float @default(0) weightedDownVotes Float @default(0)
bio Boolean @default(false) bio Boolean @default(false)

View File

@ -1,6 +1,6 @@
# Wallets # 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. 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[]` - `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` - `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` - `validate: (config) => void`

View File

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

View File

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