diff --git a/api/paidAction/README.md b/api/paidAction/README.md index a2f9c6904..a32588076 100644 --- a/api/paidAction/README.md +++ b/api/paidAction/README.md @@ -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). diff --git a/api/paidAction/boost.js b/api/paidAction/boost.js index 1721a22cb..2030f8100 100644 --- a/api/paidAction/boost.js +++ b/api/paidAction/boost.js @@ -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 ] diff --git a/api/paidAction/buyCredits.js b/api/paidAction/buyCredits.js new file mode 100644 index 000000000..b0851817c --- /dev/null +++ b/api/paidAction/buyCredits.js @@ -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' +} diff --git a/api/paidAction/donate.js b/api/paidAction/donate.js index e8bcfbbb5..20f4e7e63 100644 --- a/api/paidAction/donate.js +++ b/api/paidAction/donate.js @@ -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 ] diff --git a/api/paidAction/downZap.js b/api/paidAction/downZap.js index 4266fbfa6..f10bc17c8 100644 --- a/api/paidAction/downZap.js +++ b/api/paidAction/downZap.js @@ -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 ] diff --git a/api/paidAction/index.js b/api/paidAction/index.js index caed362a3..ad067a3ca 100644 --- a/api/paidAction/index.js +++ b/api/paidAction/index.js @@ -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 diff --git a/api/paidAction/inviteGift.js b/api/paidAction/inviteGift.js index c96f5c92d..2c24ac401 100644 --- a/api/paidAction/inviteGift.js +++ b/api/paidAction/inviteGift.js @@ -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, diff --git a/api/paidAction/itemCreate.js b/api/paidAction/itemCreate.js index 7bc0518e2..7ed723a8d 100644 --- a/api/paidAction/itemCreate.js +++ b/api/paidAction/itemCreate.js @@ -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) } diff --git a/api/paidAction/itemUpdate.js b/api/paidAction/itemUpdate.js index dc80aefc5..3aad8e448 100644 --- a/api/paidAction/itemUpdate.js +++ b/api/paidAction/itemUpdate.js @@ -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 ] diff --git a/api/paidAction/lib/assert.js b/api/paidAction/lib/assert.js index 8fcc95bac..a4d599c52 100644 --- a/api/paidAction/lib/assert.js +++ b/api/paidAction/lib/assert.js @@ -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)) - }` - ) - } -} diff --git a/api/paidAction/pollVote.js b/api/paidAction/pollVote.js index c63ecef2e..d2eb41785 100644 --- a/api/paidAction/pollVote.js +++ b/api/paidAction/pollVote.js @@ -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 ] diff --git a/api/paidAction/receive.js b/api/paidAction/receive.js index 4bf28e18d..96769a7fe 100644 --- a/api/paidAction/receive.js +++ b/api/paidAction/receive.js @@ -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 } } diff --git a/api/paidAction/territoryBilling.js b/api/paidAction/territoryBilling.js index 3f5d6fede..526816f7c 100644 --- a/api/paidAction/territoryBilling.js +++ b/api/paidAction/territoryBilling.js @@ -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 ] diff --git a/api/paidAction/territoryCreate.js b/api/paidAction/territoryCreate.js index 3cb4bb8e5..ef2610d51 100644 --- a/api/paidAction/territoryCreate.js +++ b/api/paidAction/territoryCreate.js @@ -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 ] diff --git a/api/paidAction/territoryUnarchive.js b/api/paidAction/territoryUnarchive.js index 70f03931c..bb547be80 100644 --- a/api/paidAction/territoryUnarchive.js +++ b/api/paidAction/territoryUnarchive.js @@ -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 ] diff --git a/api/paidAction/territoryUpdate.js b/api/paidAction/territoryUpdate.js index 54bdc42bf..30040a804 100644 --- a/api/paidAction/territoryUpdate.js +++ b/api/paidAction/territoryUpdate.js @@ -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 ] diff --git a/api/paidAction/zap.js b/api/paidAction/zap.js index 51ac29b06..8705d7aa7 100644 --- a/api/paidAction/zap.js +++ b/api/paidAction/zap.js @@ -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` } diff --git a/api/resolvers/invite.js b/api/resolvers/invite.js index 226debe64..229050ff8 100644 --- a/api/resolvers/invite.js +++ b/api/resolvers/invite.js @@ -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 diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 2f0676a94..8f90aed5e 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -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') { diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js index a7af37bc5..26e8c4872 100644 --- a/api/resolvers/notifications.js +++ b/api/resolvers/notifications.js @@ -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})` diff --git a/api/resolvers/paidAction.js b/api/resolvers/paidAction.js index 3fc20c4e5..2b993c1f0 100644 --- a/api/resolvers/paidAction.js +++ b/api/resolvers/paidAction.js @@ -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') } diff --git a/api/resolvers/user.js b/api/resolvers/user.js index 3dd6863f0..34e2aa402 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -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) diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index 7e305eec9..c71749ac0 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -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) diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index fe87babd7..e44eca24a 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -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! diff --git a/api/typeDefs/paidAction.js b/api/typeDefs/paidAction.js index 56dd74323..45c66c397 100644 --- a/api/typeDefs/paidAction.js +++ b/api/typeDefs/paidAction.js @@ -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! +} ` diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index e61cb4b76..bfefe7e3d 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -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 { diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js index 01b12bff2..3006fc7a4 100644 --- a/api/typeDefs/wallet.js +++ b/api/typeDefs/wallet.js @@ -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 diff --git a/components/banners.js b/components/banners.js index 72387e7ca..c4edbf0f2 100644 --- a/components/banners.js +++ b/components/banners.js @@ -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 ( - - - Your wallet is over the current limit ({numWithUnits(msatsToSats(BALANCE_LIMIT_MSATS))}) - -

- Deposits to your wallet from outside of SN are blocked. -

-

- Please spend or withdraw sats to restore full wallet functionality. -

-
- ) -} - export function WalletSecurityBanner ({ isActive }) { return ( diff --git a/components/fee-button.js b/components/fee-button.js index 194dd1231..d490c4499 100644 --- a/components/fee-button.js +++ b/components/fee-button.js @@ -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, diff --git a/components/item-act.js b/components/item-act.js index 3254357b2..7733b4a12 100644 --- a/components/item-act.js +++ b/components/item-act.js @@ -179,9 +179,11 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a ) } -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 { diff --git a/components/item-info.js b/components/item-info.js index 63123860a..b98500cd8 100644 --- a/components/item-info.js +++ b/components/item-info.js @@ -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 ({
{!(item.position && (pinnable || !item.subName)) && !(!item.parentId && Number(item.user?.id) === USER_ID.ad) && <> - + {numWithUnits(item.sats)} \ @@ -229,11 +253,21 @@ function InfoDropdownItem ({ item }) {
cost
{item.cost}
sats
-
{item.sats}
+
{item.sats - item.credits}
+
CCs
+
{item.credits}
+
comment sats
+
{item.commentSats - item.commentCredits}
+
comment CCs
+
{item.commentCredits}
{me && ( <>
sats from me
-
{item.meSats}
+
{item.meSats - item.meCredits}
+
CCs from me
+
{item.meCredits}
+
downsats from me
+
{item.meDontLikeSats}
)}
zappers
diff --git a/components/nav/common.js b/components/nav/common.js index f6077c4c9..7f0fc32fd 100644 --- a/components/nav/common.js +++ b/components/nav/common.js @@ -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 ( @@ -140,21 +139,24 @@ export function NavNotifications ({ className }) { export function WalletSummary () { const { me } = useMe() - if (!me) return null - if (me.privates?.hideWalletBalance) { - return - } - return `${abbrNum(me.privates?.sats)}` + if (!me || me.privates?.sats === 0) return null + return ( + + {`${abbrNum(me.privates?.sats)}`} + + ) } export function NavWalletSummary ({ className }) { const { me } = useMe() - const walletLimitReached = me?.privates?.sats >= msatsToSats(BALANCE_LIMIT_MSATS) return ( - - + + @@ -194,8 +196,11 @@ export function MeDropdown ({ me, dropNavKey }) { bookmarks - - wallet + + wallets + + + credits satistics diff --git a/components/nav/mobile/offcanvas.js b/components/nav/mobile/offcanvas.js index 39a88b1e5..698dc96e0 100644 --- a/components/nav/mobile/offcanvas.js +++ b/components/nav/mobile/offcanvas.js @@ -59,8 +59,11 @@ export default function OffCanvas ({ me, dropNavKey }) { bookmarks - - wallet + + wallets + + + credits satistics diff --git a/components/notifications.js b/components/notifications.js index 2264c03c4..d159c74d1 100644 --- a/components/notifications.js +++ b/components/notifications.js @@ -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 }) { {i !== n.item.forwards.length - 1 && ' '}
) + 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 ( <> - 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{' '} } @@ -567,7 +589,7 @@ function ForwardedVotification ({ n }) { return ( <> - you were forwarded {numWithUnits(n.earnedSats, { abbreviate: false })} from + you were forwarded {numWithUnits(n.earnedSats, { abbreviate: false, unitSingular: 'CC', unitPlural: 'CCs' })} from diff --git a/components/wallet-card.js b/components/wallet-card.js index 1c5d1d0c3..0c0fb7ea5 100644 --- a/components/wallet-card.js +++ b/components/wallet-card.js @@ -44,7 +44,7 @@ export default function WalletCard ({ wallet, draggable, onDragStart, onDragEnte : {wallet.def.card.title}}
- + {isConfigured(wallet) ? <>configure diff --git a/components/wallet-logger.js b/components/wallet-logger.js index 2344b572d..a59cfb3e5 100644 --- a/components/wallet-logger.js +++ b/components/wallet-logger.js @@ -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 diff --git a/fragments/comments.js b/fragments/comments.js index 0813dc9cd..50cdfafdc 100644 --- a/fragments/comments.js +++ b/fragments/comments.js @@ -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 diff --git a/fragments/items.js b/fragments/items.js index 6e1a9f407..ada616cd9 100644 --- a/fragments/items.js +++ b/fragments/items.js @@ -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 diff --git a/fragments/paidAction.js b/fragments/paidAction.js index c47fa7005..17bc21e6d 100644 --- a/fragments/paidAction.js +++ b/fragments/paidAction.js @@ -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} diff --git a/fragments/users.js b/fragments/users.js index b96ec9327..c65cc2a06 100644 --- a/fragments/users.js +++ b/fragments/users.js @@ -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 } }` diff --git a/lib/constants.js b/lib/constants.js index 1fd63ea8a..3372d9942 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -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, diff --git a/lib/validate.js b/lib/validate.js index 86b03bded..12bce77a8 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -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) }) diff --git a/pages/credits.js b/pages/credits.js new file mode 100644 index 000000000..0351f9cb0 --- /dev/null +++ b/pages/credits.js @@ -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 ( + + + +

+
+ {me?.privates?.credits} +
+
cowboy credits
+ +

+ + +

+
+ {me?.privates?.sats - me?.privates?.credits} +
+
sats
+ +

+ +
+ + +

+
+ {me?.privates?.credits} +
+
cowboy credits
+ +

+
+ +

+
+ {me?.privates?.sats - me?.privates?.credits} +
+
sats
+ +

+
+
+
+ ) +} + +export function BuyCreditsButton () { + const showModal = useShowModal() + const strike = useLightning() + const [buyCredits] = usePaidMutation(BUY_CREDITS) + + return ( + <> + + + ) +} diff --git a/pages/invites/[id].js b/pages/invites/[id].js index d7ee861fb..c088d98b9 100644 --- a/pages/invites/[id].js +++ b/pages/invites/[id].js @@ -64,13 +64,13 @@ function InviteHeader ({ invite }) { if (invite.revoked) { Inner = () =>
this invite link expired
} else if ((invite.limit && invite.limit <= invite.invitees.length) || invite.poor) { - Inner = () =>
this invite link has no more sats
+ Inner = () =>
this invite link has no more cowboy credits
} else { Inner = () => (
- Get {invite.gift} free sats from{' '} + Get {invite.gift} cowboy credits from{' '} @{invite.user.name}{' '} - when you sign up today + when you sign up
) } diff --git a/pages/satistics/index.js b/pages/satistics/index.js index 55e4cd103..ef2331856 100644 --- a/pages/satistics/index.js +++ b/pages/satistics/index.js @@ -264,7 +264,7 @@ export default function Satistics ({ ssrData }) {
type
detail
-
sats
+
sats/credits
{facts.map(f => )}
diff --git a/pages/settings/index.js b/pages/settings/index.js index 13cccd107..4f1e912d9 100644 --- a/pages/settings/index.js +++ b/pages/settings/index.js @@ -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' />
wallet
+ sats} + /> + sats} + /> proxy deposits to attached wallets diff --git a/pages/settings/wallets/[wallet].js b/pages/wallets/[wallet].js similarity index 98% rename from pages/settings/wallets/[wallet].js rename to pages/wallets/[wallet].js index 98cc2bf52..ff5830a6d 100644 --- a/pages/settings/wallets/[wallet].js +++ b/pages/wallets/[wallet].js @@ -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?.() diff --git a/pages/settings/wallets/index.js b/pages/wallets/index.js similarity index 95% rename from pages/settings/wallets/index.js rename to pages/wallets/index.js index 95e9a6eb8..06ffa784b 100644 --- a/pages/settings/wallets/index.js +++ b/pages/wallets/index.js @@ -86,10 +86,10 @@ export default function Wallet ({ ssrData }) { return (
-

attach wallets

-
attach wallets to supplement your SN wallet
+

wallets

+
use real bitcoin
- + wallet logs
diff --git a/pages/wallet/logs.js b/pages/wallets/logs.js similarity index 100% rename from pages/wallet/logs.js rename to pages/wallets/logs.js diff --git a/pages/wallet/index.js b/pages/withdraw.js similarity index 67% rename from pages/wallet/index.js rename to pages/withdraw.js index 759e6e52c..1a39386e3 100644 --- a/pages/wallet/index.js +++ b/pages/withdraw.js @@ -1,198 +1,68 @@ -import { useRouter } from 'next/router' -import { Checkbox, Form, Input, InputUserSuggest, SubmitButton } from '@/components/form' +import { getGetServerSideProps } from '@/api/ssrApollo' +import { CenterLayout } from '@/components/layout' import Link from 'next/link' -import Button from 'react-bootstrap/Button' +import { useRouter } from 'next/router' +import { InputGroup, Nav } from 'react-bootstrap' +import styles from '@/components/user-header.module.css' 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 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 { 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 ( - - - - ) - } else if (router.query.type?.includes('withdraw')) { - return ( - - - - ) - } else { - return ( - - - - - - - ) - } -} - -function YouHaveSats () { - const { me } = useMe() - const limitReached = me?.privates?.sats >= msatsToSats(BALANCE_LIMIT_MSATS) +export default function Withdraw () { return ( -

- you have{' '} - {me && ( - me.privates?.hideWalletBalance - ? - : numWithUnits(me.privates?.sats, { abbreviate: false, format: true }) - )} - -

+ + + ) } -function WalletHistory () { - return ( -
-
- - wallet history - -
-
- ) -} - -export function WalletForm () { - return ( -
- - - - or - - - -
- - - -
-
- ) -} - -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 - } - - return ( - <> - - -
- {me && showAlert && - { - window.localStorage.setItem('hideLnAddrAlert', 'yep') - setShowAlert(false) - }} - > - You can also fund your account via lightning address with {`${me.name}@stacker.news`} - } -
{ - 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}`) - } - }} - > - sats} - /> - generate invoice -
-
- - - ) -} - -export function WithdrawalForm () { +function WithdrawForm () { const router = useRouter() + const { me } = useMe() return (
- +

+
+ {numWithUnits(me?.privates?.sats - me?.privates?.credits, { abbreviate: false, format: true, unitSingular: 'sats', unitPlural: 'sats' })} +
+

@@ -205,12 +75,12 @@ export function SelectedWithdrawalForm () { const router = useRouter() switch (router.query.type) { - case 'withdraw': - return - case 'lnurl-withdraw': - return - case 'lnaddr-withdraw': + case 'lnurl': + return + case 'lnaddr': return + default: + return } } @@ -271,7 +141,9 @@ export function InvWithdrawal () { required append={sats} /> - withdraw +
+ withdraw +
) @@ -343,7 +215,7 @@ function LnQRWith ({ k1, encodedUrl }) { return } -export function LnWithdrawal () { +export function LnurlWithdrawal () { // query for challenge const [createWith, { data, error }] = useMutation(gql` mutation createWith { @@ -507,7 +379,9 @@ export function LnAddrWithdrawal () { />
} - send +
+ send +
) diff --git a/prisma/migrations/20241203195142_fee_credits/migration.sql b/prisma/migrations/20241203195142_fee_credits/migration.sql new file mode 100644 index 000000000..b4a8fadbb --- /dev/null +++ b/prisma/migrations/20241203195142_fee_credits/migration.sql @@ -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 +$$; \ No newline at end of file diff --git a/prisma/migrations/20250102224852_hat_streak_credits/migration.sql b/prisma/migrations/20250102224852_hat_streak_credits/migration.sql new file mode 100644 index 000000000..0b3f667c1 --- /dev/null +++ b/prisma/migrations/20250102224852_hat_streak_credits/migration.sql @@ -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(); \ No newline at end of file diff --git a/prisma/migrations/20250103011357_fix_expireboost_keepuntil/migration.sql b/prisma/migrations/20250103011357_fix_expireboost_keepuntil/migration.sql deleted file mode 100644 index a67aac420..000000000 --- a/prisma/migrations/20250103011357_fix_expireboost_keepuntil/migration.sql +++ /dev/null @@ -1,4 +0,0 @@ --- fix existing boost jobs -UPDATE pgboss.job -SET keepuntil = startafter + interval '10 days' -WHERE name = 'expireBoost' AND state = 'created'; \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ea6490f27..dcdb3b938 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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) diff --git a/wallets/README.md b/wallets/README.md index 9ad23f8ab..41b0ab861 100644 --- a/wallets/README.md +++ b/wallets/README.md @@ -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` diff --git a/wallets/config.js b/wallets/config.js index cdc574240..25f1f2ba2 100644 --- a/wallets/config.js +++ b/wallets/config.js @@ -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) { diff --git a/worker/search.js b/worker/search.js index 2e7ed21a8..7e91c10f5 100644 --- a/worker/search.js +++ b/worker/search.js @@ -27,9 +27,11 @@ const ITEM_SEARCH_FIELDS = gql` remote upvotes sats + credits boost lastCommentAt commentSats + commentCredits path ncomments }`