From e0b99f3adb4ebb69d46e86ddda5910c694df9fd4 Mon Sep 17 00:00:00 2001 From: k00b Date: Tue, 3 Dec 2024 17:10:48 -0600 Subject: [PATCH 01/30] wip adding cowboy credits --- api/paidAction/README.md | 25 +++--- api/paidAction/boost.js | 1 + api/paidAction/buyFeeCredits.js | 28 ++++++ api/paidAction/donate.js | 1 + api/paidAction/downZap.js | 1 + api/paidAction/index.js | 14 ++- api/paidAction/itemCreate.js | 1 + api/paidAction/itemUpdate.js | 1 + api/paidAction/pollVote.js | 1 + api/paidAction/receive.js | 7 +- api/paidAction/territoryBilling.js | 1 + api/paidAction/territoryCreate.js | 1 + api/paidAction/territoryUnarchive.js | 1 + api/paidAction/territoryUpdate.js | 1 + api/paidAction/zap.js | 86 ++++++++++++------- api/resolvers/user.js | 3 +- api/typeDefs/item.js | 1 + api/typeDefs/user.js | 5 ++ fragments/users.js | 2 + lib/constants.js | 3 +- pages/settings/index.js | 18 +++- .../20241203195142_fee_credits/migration.sql | 10 +++ .../migration.sql | 14 +++ prisma/schema.prisma | 6 ++ 24 files changed, 181 insertions(+), 51 deletions(-) create mode 100644 api/paidAction/buyFeeCredits.js create mode 100644 prisma/migrations/20241203195142_fee_credits/migration.sql create mode 100644 prisma/migrations/20241203212140_more_fee_credits/migration.sql diff --git a/api/paidAction/README.md b/api/paidAction/README.md index 13661b56f..dfafd7581 100644 --- a/api/paidAction/README.md +++ b/api/paidAction/README.md @@ -92,18 +92,19 @@ stateDiagram-v2 ### Table of existing paid actions and their supported flows -| action | fee credits | optimistic | pessimistic | anonable | qr payable | p2p wrapped | side effects | -| ----------------- | ----------- | ---------- | ----------- | -------- | ---------- | ----------- | ------------ | -| zaps | x | x | x | x | x | x | x | -| posts | x | x | x | x | x | | x | -| comments | x | x | x | x | x | | x | -| downzaps | x | x | | | x | | x | -| poll votes | x | x | | | x | | | -| territory actions | x | | x | | x | | | -| donations | x | | x | x | x | | | -| update posts | x | | x | | x | | x | -| update comments | x | | x | | x | | x | -| receive | | x | | x | x | x | x | +| action | fee credits | optimistic | pessimistic | anonable | qr payable | p2p wrapped | side effects | reward sats | p2p direct | +| ----------------- | ----------- | ---------- | ----------- | -------- | ---------- | ----------- | ------------ | ----------- | ---------- | +| zaps | x | x | x | x | x | x | x | | | +| posts | x | x | x | x | x | | x | x | | +| comments | x | x | x | x | x | | x | x | | +| downzaps | x | x | | | x | | x | x | | +| poll votes | x | x | | | x | | | x | | +| territory actions | x | | x | | x | | | x | | +| donations | x | | x | x | x | | | x | | +| update posts | x | | x | | x | | x | x | | +| update comments | x | | x | | x | | x | x | | +| receive | | x | | | x | x | x | | x | +| buy fee credits | | | x | | | | | x | | ## Not-custodial zaps (ie p2p wrapped payments) Zaps, and possibly other future actions, can be performed peer to peer and non-custodially. This means that the payment is made directly from the client to the recipient, without the server taking custody of the funds. Currently, in order to trigger this behavior, the recipient must have a receiving wallet attached and the sender must have insufficient funds in their custodial wallet to perform the requested zap. diff --git a/api/paidAction/boost.js b/api/paidAction/boost.js index af96b4c83..05d800174 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/buyFeeCredits.js b/api/paidAction/buyFeeCredits.js new file mode 100644 index 000000000..28b11ac36 --- /dev/null +++ b/api/paidAction/buyFeeCredits.js @@ -0,0 +1,28 @@ +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 }) { + return await tx.user.update({ + where: { id: me.id }, + data: { + mcredits: { + increment: cost + } + } + }) +} + +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 834bac974..c5932651f 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_FEE_CREDITS from './buyFeeCredits' export const paidActions = { ITEM_CREATE, @@ -31,7 +32,8 @@ export const paidActions = { TERRITORY_BILLING, TERRITORY_UNARCHIVE, DONATE, - RECEIVE + RECEIVE, + BUY_FEE_CREDITS } export default async function performPaidAction (actionType, args, incomingContext) { @@ -94,7 +96,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) { @@ -138,6 +141,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/itemCreate.js b/api/paidAction/itemCreate.js index 4b2b8bb9e..8f3a8c2d4 100644 --- a/api/paidAction/itemCreate.js +++ b/api/paidAction/itemCreate.js @@ -7,6 +7,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 ] diff --git a/api/paidAction/itemUpdate.js b/api/paidAction/itemUpdate.js index b27968ce2..f35f2c7a9 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/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..24c5c56f6 100644 --- a/api/paidAction/receive.js +++ b/api/paidAction/receive.js @@ -19,13 +19,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 } @@ -73,7 +76,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..29f692cd6 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,30 @@ export async function getCost ({ sats }) { return satsToMsats(sats) } -export async function getInvoiceablePeer ({ id }, { models }) { +export async function getInvoiceablePeer ({ id, sats }, { models, me }) { + if (sats < me?.sendCreditsBelowSats) { + return null + } + const item = await models.item.findUnique({ where: { id: parseInt(id) }, - include: { itemForwards: true } + include: { + itemForwards: true, + user: true + } }) 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 +105,37 @@ 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, + "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 @@ -134,7 +154,8 @@ export async function onPaid ({ invoice, actIds }, { tx }) { SET "weightedVotes" = "weightedVotes" + (zapper.trust * zap.log_sats), upvotes = upvotes + zap.first_vote, - msats = "Item".msats + ${msats}::BIGINT, + msats = "Item".msats + ${invoice?.invoiceForward ? msats : 0n}::BIGINT, + mcredits = "Item".mcredits + ${invoice?.invoiceForward ? 0n : msats}::BIGINT, "lastZapAt" = now() FROM zap, zapper WHERE "Item".id = ${itemAct.itemId}::INTEGER @@ -165,7 +186,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" + ${invoice?.invoiceForward ? msats : 0n}::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/user.js b/api/resolvers/user.js index e836b22be..2a1caf066 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -1104,7 +1104,8 @@ export default { if (!when || when === 'forever') { // forever - return (user.stackedMsats && msatsToSats(user.stackedMsats)) || 0 + return ((user.stackedMsats && msatsToSats(user.stackedMsats)) || 0) + + ((user.stackedMcredits && msatsToSats(user.stackedMcredits)) || 0) } const range = whenRange(when, from, to) diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index fe87babd7..f5753cd79 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -131,6 +131,7 @@ export default gql` lastCommentAt: Date upvotes: Int! meSats: Int! + meCredits: Int! meDontLikeSats: Int! meBookmark: Boolean! meSubscription: Boolean! diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index 8b100170f..c8d8728b0 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -109,6 +109,8 @@ export default gql` withdrawMaxFeeDefault: Int! proxyReceive: Boolean directReceive: Boolean + receiveCreditsBelowSats: Int! + sendCreditsBelowSats: Int! } type AuthMethods { @@ -125,6 +127,7 @@ export default gql` extremely sensitive """ sats: Int! + credits: Int! authMethods: AuthMethods! lnAddr: String @@ -189,6 +192,8 @@ export default gql` walletsUpdatedAt: Date proxyReceive: Boolean directReceive: Boolean + receiveCreditsBelowSats: Int! + sendCreditsBelowSats: Int! } type UserOptional { diff --git a/fragments/users.js b/fragments/users.js index da2078618..a6eff073c 100644 --- a/fragments/users.js +++ b/fragments/users.js @@ -116,6 +116,8 @@ export const SETTINGS_FIELDS = gql` apiKeyEnabled proxyReceive directReceive + receiveCreditsBelowSats + sendCreditsBelowSats } }` diff --git a/lib/constants.js b/lib/constants.js index 978343ea6..229fd1064 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 diff --git a/pages/settings/index.js b/pages/settings/index.js index d5941ea18..97f9011ef 100644 --- a/pages/settings/index.js +++ b/pages/settings/index.js @@ -110,6 +110,8 @@ export default function Settings ({ ssrData }) { // if we switched to anon, me is null before the page is reloaded if ((!data && !ssrData) || !me) return + console.log(settings) + return (
@@ -160,7 +162,9 @@ 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 ({ @@ -335,6 +339,18 @@ export default function Settings ({ ssrData }) { name='noteCowboyHat' />
wallet
+ sats} + /> + sats} + /> proxy deposits to attached wallets diff --git a/prisma/migrations/20241203195142_fee_credits/migration.sql b/prisma/migrations/20241203195142_fee_credits/migration.sql new file mode 100644 index 000000000..5458e17e4 --- /dev/null +++ b/prisma/migrations/20241203195142_fee_credits/migration.sql @@ -0,0 +1,10 @@ +-- AlterTable +ALTER TABLE "Item" ADD COLUMN "mcredits" BIGINT NOT NULL DEFAULT 0; + +-- AlterTable +ALTER TABLE "ItemUserAgg" ADD COLUMN "zapCredits" BIGINT NOT NULL DEFAULT 0; + +-- AlterTable +ALTER TABLE "users" ADD COLUMN "mcredits" BIGINT NOT NULL DEFAULT 0, +ADD COLUMN "receiveCreditsBelowSats" INTEGER NOT NULL DEFAULT 10, +ADD COLUMN "sendCreditsBelowSats" INTEGER NOT NULL DEFAULT 10; diff --git a/prisma/migrations/20241203212140_more_fee_credits/migration.sql b/prisma/migrations/20241203212140_more_fee_credits/migration.sql new file mode 100644 index 000000000..875314eab --- /dev/null +++ b/prisma/migrations/20241203212140_more_fee_credits/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - You are about to drop the column `zapCredits` on the `ItemUserAgg` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Item" ADD COLUMN "commentMcredits" BIGINT NOT NULL DEFAULT 0; + +-- AlterTable +ALTER TABLE "ItemUserAgg" DROP COLUMN "zapCredits"; + +-- AlterTable +ALTER TABLE "users" ADD COLUMN "stackedMcredits" BIGINT NOT NULL DEFAULT 0; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 94eac2fb4..34afe8752 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") @@ -519,10 +523,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) From 0bd6e9c44bb886ca46a4a23568ad60bd757730b1 Mon Sep 17 00:00:00 2001 From: k00b Date: Tue, 3 Dec 2024 18:38:40 -0600 Subject: [PATCH 02/30] invite gift paid action --- api/paidAction/README.md | 3 +- api/paidAction/index.js | 7 +- api/paidAction/inviteGift.js | 60 +++++++++++++++ api/resolvers/serial.js | 76 ------------------- pages/invites/[id].js | 14 ++-- pages/invites/index.js | 1 + .../migration.sql | 11 +++ prisma/schema.prisma | 19 ++--- worker/territory.js | 11 ++- 9 files changed, 100 insertions(+), 102 deletions(-) create mode 100644 api/paidAction/inviteGift.js delete mode 100644 api/resolvers/serial.js create mode 100644 prisma/migrations/20241203235457_invite_denormalized_count/migration.sql diff --git a/api/paidAction/README.md b/api/paidAction/README.md index dfafd7581..a2f9c6904 100644 --- a/api/paidAction/README.md +++ b/api/paidAction/README.md @@ -104,7 +104,8 @@ stateDiagram-v2 | update posts | x | | x | | x | | x | x | | | update comments | x | | x | | x | | x | x | | | receive | | x | | | x | x | x | | x | -| buy fee credits | | | x | | | | | x | | +| buy fee credits | | | x | | x | | | x | | +| invite gift | x | | | | | | x | x | | ## Not-custodial zaps (ie p2p wrapped payments) Zaps, and possibly other future actions, can be performed peer to peer and non-custodially. This means that the payment is made directly from the client to the recipient, without the server taking custody of the funds. Currently, in order to trigger this behavior, the recipient must have a receiving wallet attached and the sender must have insufficient funds in their custodial wallet to perform the requested zap. diff --git a/api/paidAction/index.js b/api/paidAction/index.js index c5932651f..218167f4f 100644 --- a/api/paidAction/index.js +++ b/api/paidAction/index.js @@ -19,6 +19,7 @@ import * as DONATE from './donate' import * as BOOST from './boost' import * as RECEIVE from './receive' import * as BUY_FEE_CREDITS from './buyFeeCredits' +import * as INVITE_GIFT from './inviteGift' export const paidActions = { ITEM_CREATE, @@ -33,7 +34,8 @@ export const paidActions = { TERRITORY_UNARCHIVE, DONATE, RECEIVE, - BUY_FEE_CREDITS + BUY_FEE_CREDITS, + INVITE_GIFT } export default async function performPaidAction (actionType, args, incomingContext) { @@ -103,7 +105,8 @@ export default async function performPaidAction (actionType, args, incomingConte } catch (e) { // if we fail with fee credits or reward sats, but not because of insufficient funds, bail console.error(`${paymentMethod} action failed`, e) - if (!e.message.includes('\\"users\\" violates check constraint \\"msats_positive\\"')) { + if (!e.message.includes('\\"users\\" violates check constraint \\"msats_positive\\"') && + !e.message.includes('\\"users\\" violates check constraint \\"mcredits_positive\\"')) { throw e } } diff --git a/api/paidAction/inviteGift.js b/api/paidAction/inviteGift.js new file mode 100644 index 000000000..15b9f2c13 --- /dev/null +++ b/api/paidAction/inviteGift.js @@ -0,0 +1,60 @@ +import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' +import { satsToMsats } from '@/lib/format' +import { notifyInvite } from '@/lib/webPush' + +export const anonable = false + +export const paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT, + PAID_ACTION_PAYMENT_METHODS.REWARD_SATS +] + +export async function getCost ({ id }, { models, me }) { + const invite = await models.invite.findUnique({ where: { id, userId: me.id, revoked: false } }) + if (!invite) { + throw new Error('invite not found') + } + return satsToMsats(invite.gift) +} + +export async function perform ({ id, userId }, { me, cost, tx }) { + const invite = await tx.invite.findUnique({ + where: { id, userId: me.id, revoked: false } + }) + + if (invite.giftedCount >= invite.limit) { + throw new Error('invite limit reached') + } + + // check that user was created in last hour + // check that user did not already redeem an invite + await tx.user.update({ + where: { + id: userId, + inviteId: { is: null }, + createdAt: { + gt: new Date(Date.now() - 1000 * 60 * 60) + } + }, + data: { + mcredits: { + increment: cost + }, + inviteId: id, + referrerId: me.id + } + }) + + return await tx.invite.update({ + where: { id, userId: me.id, giftedCount: { lt: invite.limit }, revoked: false }, + data: { + giftedCount: { + increment: 1 + } + } + }) +} + +export async function nonCriticalSideEffects (_, { me }) { + notifyInvite(me.id) +} diff --git a/api/resolvers/serial.js b/api/resolvers/serial.js deleted file mode 100644 index 633358405..000000000 --- a/api/resolvers/serial.js +++ /dev/null @@ -1,76 +0,0 @@ -import retry from 'async-retry' -import Prisma from '@prisma/client' -import { msatsToSats, numWithUnits } from '@/lib/format' -import { BALANCE_LIMIT_MSATS } from '@/lib/constants' -import { GqlInputError } from '@/lib/error' - -export default async function serialize (trx, { models, lnd }) { - // wrap first argument in array if not array already - const isArray = Array.isArray(trx) - if (!isArray) trx = [trx] - - // conditional queries can be added inline using && syntax - // we filter any falsy value out here - trx = trx.filter(q => !!q) - - const results = await retry(async bail => { - try { - const [, ...results] = await models.$transaction( - [models.$executeRaw`SELECT ASSERT_SERIALIZED()`, ...trx], - { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }) - return results - } catch (error) { - console.log(error) - // two cases where we get insufficient funds: - // 1. plpgsql function raises - // 2. constraint violation via a prisma call - // XXX prisma does not provide a way to distinguish these cases so we - // have to check the error message - if (error.message.includes('SN_INSUFFICIENT_FUNDS') || - error.message.includes('\\"users\\" violates check constraint \\"msats_positive\\"')) { - bail(new GqlInputError('insufficient funds')) - } - if (error.message.includes('SN_NOT_SERIALIZABLE')) { - bail(new Error('wallet balance transaction is not serializable')) - } - if (error.message.includes('SN_CONFIRMED_WITHDRAWL_EXISTS')) { - bail(new Error('withdrawal invoice already confirmed (to withdraw again create a new invoice)')) - } - if (error.message.includes('SN_PENDING_WITHDRAWL_EXISTS')) { - bail(new Error('withdrawal invoice exists and is pending')) - } - if (error.message.includes('SN_INELIGIBLE')) { - bail(new Error('user ineligible for gift')) - } - if (error.message.includes('SN_UNSUPPORTED')) { - bail(new Error('unsupported action')) - } - if (error.message.includes('SN_DUPLICATE')) { - bail(new Error('duplicate not allowed')) - } - if (error.message.includes('SN_REVOKED_OR_EXHAUSTED')) { - bail(new Error('faucet has been revoked or is exhausted')) - } - if (error.message.includes('SN_INV_PENDING_LIMIT')) { - bail(new Error('too many pending invoices')) - } - if (error.message.includes('SN_INV_EXCEED_BALANCE')) { - bail(new Error(`pending invoices and withdrawals must not cause balance to exceed ${numWithUnits(msatsToSats(BALANCE_LIMIT_MSATS))}`)) - } - if (error.message.includes('40001') || error.code === 'P2034') { - throw new Error('wallet balance serialization failure - try again') - } - if (error.message.includes('23514') || ['P2002', 'P2003', 'P2004'].includes(error.code)) { - bail(new Error('constraint failure')) - } - bail(error) - } - }, { - minTimeout: 10, - maxTimeout: 100, - retries: 10 - }) - - // if first argument was not an array, unwrap the result - return isArray ? results : results[0] -} diff --git a/pages/invites/[id].js b/pages/invites/[id].js index c33076cf0..ff29fabe5 100644 --- a/pages/invites/[id].js +++ b/pages/invites/[id].js @@ -2,14 +2,13 @@ import Login from '@/components/login' import { getProviders } from 'next-auth/react' import { getServerSession } from 'next-auth/next' import models from '@/api/models' -import serialize from '@/api/resolvers/serial' import { gql } from '@apollo/client' import { INVITE_FIELDS } from '@/fragments/invites' import getSSRApolloClient from '@/api/ssrApollo' import Link from 'next/link' import { CenterLayout } from '@/components/layout' import { getAuthOptions } from '@/pages/api/auth/[...nextauth]' -import { notifyInvite } from '@/lib/webPush' +import performPaidAction from '@/api/paidAction' export async function getServerSideProps ({ req, res, query: { id, error = null } }) { const session = await getServerSession(req, res, getAuthOptions(req)) @@ -36,12 +35,11 @@ export async function getServerSideProps ({ req, res, query: { id, error = null try { // attempt to send gift // catch any errors and just ignore them for now - await serialize( - models.$queryRawUnsafe('SELECT invite_drain($1::INTEGER, $2::TEXT)', session.user.id, id), - { models } - ) - const invite = await models.invite.findUnique({ where: { id } }) - notifyInvite(invite.userId) + await performPaidAction({ + action: 'INVITE_GIFT', + id, + userId: session.user.id + }, { models, me: { id: data.invite.user.id } }) } catch (e) { console.log(e) } diff --git a/pages/invites/index.js b/pages/invites/index.js index a7ee743d4..7692c9d2e 100644 --- a/pages/invites/index.js +++ b/pages/invites/index.js @@ -81,6 +81,7 @@ function InviteForm () { {`${process.env.NEXT_PUBLIC_URL}/invites/`}} label={<>invite code optional} + hint='leave blank for a random code that is hard to guess' name='id' autoComplete='off' /> diff --git a/prisma/migrations/20241203235457_invite_denormalized_count/migration.sql b/prisma/migrations/20241203235457_invite_denormalized_count/migration.sql new file mode 100644 index 000000000..730e092bb --- /dev/null +++ b/prisma/migrations/20241203235457_invite_denormalized_count/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +ALTER TABLE "Invite" ADD COLUMN "giftedCount" INTEGER NOT NULL DEFAULT 0; + +-- denormalize giftedCount +UPDATE "Invite" +SET "giftedCount" = (SELECT COUNT(*) FROM "users" WHERE "users"."inviteId" = "Invite".id) +WHERE "Invite"."id" = "Invite".id; + +-- add mcredits check +ALTER TABLE users ADD CONSTRAINT "mcredits_positive" CHECK ("mcredits" >= 0) NOT VALID; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 34afe8752..908e21831 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -471,15 +471,16 @@ model LnWith { } model Invite { - id String @id @default(cuid()) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - userId Int - gift Int? - limit Int? - revoked Boolean @default(false) - user User @relation("Invites", fields: [userId], references: [id], onDelete: Cascade) - invitees User[] + id String @id @default(cuid()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + userId Int + gift Int? + limit Int? + giftedCount Int @default(0) + revoked Boolean @default(false) + user User @relation("Invites", fields: [userId], references: [id], onDelete: Cascade) + invitees User[] description String? diff --git a/worker/territory.js b/worker/territory.js index e687b6125..3aa386f96 100644 --- a/worker/territory.js +++ b/worker/territory.js @@ -1,6 +1,5 @@ import lnd from '@/api/lnd' import performPaidAction from '@/api/paidAction' -import serialize from '@/api/resolvers/serial' import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' import { nextBillingWithGrace } from '@/lib/territory' import { datePivot } from '@/lib/time' @@ -53,8 +52,10 @@ export async function territoryBilling ({ data: { subName }, boss, models }) { } export async function territoryRevenue ({ models }) { - await serialize( - models.$executeRaw` + // this is safe nonserializable because it only acts on old data that won't + // be affected by concurrent updates ... and the update takes a lock on the + // users table + await models.$executeRaw` WITH revenue AS ( SELECT coalesce(sum(msats), 0) as revenue, "subName", "userId" FROM ( @@ -88,7 +89,5 @@ export async function territoryRevenue ({ models }) { SET msats = users.msats + "SubActResultTotal".total_msats, "stackedMsats" = users."stackedMsats" + "SubActResultTotal".total_msats FROM "SubActResultTotal" - WHERE users.id = "SubActResultTotal"."userId"`, - { models } - ) + WHERE users.id = "SubActResultTotal"."userId"` } From 1ab9c15a66f7ebaa084a1d472f0a9a1a2e7b0cc8 Mon Sep 17 00:00:00 2001 From: k00b Date: Tue, 3 Dec 2024 18:42:11 -0600 Subject: [PATCH 03/30] remove balance limit --- api/paidAction/lib/assert.js | 48 +----------------------------------- api/paidAction/receive.js | 8 +----- 2 files changed, 2 insertions(+), 54 deletions(-) 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/receive.js b/api/paidAction/receive.js index 24c5c56f6..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 @@ -42,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, @@ -51,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 }) { From bae8c2e1df308e8495dcc2d547c76244b4782986 Mon Sep 17 00:00:00 2001 From: k00b Date: Tue, 3 Dec 2024 19:45:34 -0600 Subject: [PATCH 04/30] remove p2p zap withdrawal notifications --- api/resolvers/notifications.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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})` From 1928a5e6d75a70f5e314ece5a68776fb26cf280b Mon Sep 17 00:00:00 2001 From: k00b Date: Wed, 4 Dec 2024 10:09:10 -0600 Subject: [PATCH 05/30] credits typedefs --- api/typeDefs/item.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index f5753cd79..94ed0ac9c 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -127,7 +127,9 @@ export default gql` bountyPaidTo: [Int] noteId: String sats: Int! + credits: Int! commentSats: Int! + commentCredits: Int! lastCommentAt: Date upvotes: Int! meSats: Int! From 89504f0a333877355501ac307674f7e89c92e7a7 Mon Sep 17 00:00:00 2001 From: k00b Date: Wed, 4 Dec 2024 12:19:09 -0600 Subject: [PATCH 06/30] squash migrations --- .../20241203195142_fee_credits/migration.sql | 11 +++++++---- .../20241203212140_more_fee_credits/migration.sql | 14 -------------- 2 files changed, 7 insertions(+), 18 deletions(-) delete mode 100644 prisma/migrations/20241203212140_more_fee_credits/migration.sql diff --git a/prisma/migrations/20241203195142_fee_credits/migration.sql b/prisma/migrations/20241203195142_fee_credits/migration.sql index 5458e17e4..2b921dcc2 100644 --- a/prisma/migrations/20241203195142_fee_credits/migration.sql +++ b/prisma/migrations/20241203195142_fee_credits/migration.sql @@ -1,10 +1,13 @@ -- AlterTable -ALTER TABLE "Item" ADD COLUMN "mcredits" BIGINT NOT NULL DEFAULT 0; - --- AlterTable -ALTER TABLE "ItemUserAgg" ADD COLUMN "zapCredits" BIGINT NOT NULL DEFAULT 0; +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; + +-- 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; diff --git a/prisma/migrations/20241203212140_more_fee_credits/migration.sql b/prisma/migrations/20241203212140_more_fee_credits/migration.sql deleted file mode 100644 index 875314eab..000000000 --- a/prisma/migrations/20241203212140_more_fee_credits/migration.sql +++ /dev/null @@ -1,14 +0,0 @@ -/* - Warnings: - - - You are about to drop the column `zapCredits` on the `ItemUserAgg` table. All the data in the column will be lost. - -*/ --- AlterTable -ALTER TABLE "Item" ADD COLUMN "commentMcredits" BIGINT NOT NULL DEFAULT 0; - --- AlterTable -ALTER TABLE "ItemUserAgg" DROP COLUMN "zapCredits"; - --- AlterTable -ALTER TABLE "users" ADD COLUMN "stackedMcredits" BIGINT NOT NULL DEFAULT 0; From 9406995995aee39e6edf58a52b0cdb74ae83f0e2 Mon Sep 17 00:00:00 2001 From: k00b Date: Wed, 4 Dec 2024 12:42:24 -0600 Subject: [PATCH 07/30] remove wallet limit stuff --- components/banners.js | 23 ----------------------- components/nav/common.js | 7 +++---- lib/constants.js | 1 - lib/validate.js | 6 +++--- pages/wallet/index.js | 10 +++------- 5 files changed, 9 insertions(+), 38 deletions(-) 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/nav/common.js b/components/nav/common.js index 47057b7e0..fd8d9001f 100644 --- a/components/nav/common.js +++ b/components/nav/common.js @@ -6,12 +6,12 @@ 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' @@ -149,12 +149,11 @@ export function WalletSummary () { export function NavWalletSummary ({ className }) { const { me } = useMe() - const walletLimitReached = me?.privates?.sats >= msatsToSats(BALANCE_LIMIT_MSATS) return ( - + diff --git a/lib/constants.js b/lib/constants.js index 229fd1064..4aba8895e 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -50,7 +50,6 @@ export const MAX_POLL_CHOICE_LENGTH = 40 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/wallet/index.js b/pages/wallet/index.js index 759e6e52c..1b06ad225 100644 --- a/pages/wallet/index.js +++ b/pages/wallet/index.js @@ -15,8 +15,8 @@ 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 { FAST_POLL_INTERVAL, SSR } from '@/lib/constants' +import { 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' @@ -27,7 +27,6 @@ import CameraIcon from '@/svgs/camera-line.svg' 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 { decode } from 'bolt11' @@ -52,7 +51,6 @@ export default function Wallet () { return ( - @@ -62,9 +60,8 @@ export default function Wallet () { function YouHaveSats () { const { me } = useMe() - const limitReached = me?.privates?.sats >= msatsToSats(BALANCE_LIMIT_MSATS) return ( -

+

you have{' '} {me && ( me.privates?.hideWalletBalance @@ -130,7 +127,6 @@ export function FundForm () { return ( <> -
{me && showAlert && Date: Wed, 4 Dec 2024 18:22:54 -0600 Subject: [PATCH 08/30] CCs in item detail --- api/resolvers/item.js | 56 ++++++++++++++++++++++++++++++++++++----- api/resolvers/wallet.js | 3 +++ api/typeDefs/wallet.js | 1 + components/item-act.js | 22 +++++++++++++++- components/item-info.js | 52 +++++++++++++++++++++++++++++--------- fragments/comments.js | 3 +++ fragments/items.js | 3 +++ fragments/paidAction.js | 1 + worker/search.js | 2 ++ 9 files changed, 124 insertions(+), 19 deletions(-) diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 3092bdab9..8d0cf7a87 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -149,6 +149,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) @@ -166,10 +167,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" @@ -1048,10 +1053,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.mcredits) + 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) + return msatsToSats(item.commentMsats + item.commentMcredits) + }, + commentCredits: async (item, args, { models }) => { + return msatsToSats(item.commentMcredits) }, isJob: async (item, args, { models }) => { return item.subName === 'jobs' @@ -1169,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(item.meMsats + item.meMcredits) } const { _sum: { msats } } = await models.itemAct.aggregate({ @@ -1196,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/wallet.js b/api/resolvers/wallet.js index b0f9f7798..a626a9b8b 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -615,6 +615,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/wallet.js b/api/typeDefs/wallet.js index 97f4f69e8..d7e49301a 100644 --- a/api/typeDefs/wallet.js +++ b/api/typeDefs/wallet.js @@ -126,6 +126,7 @@ const typeDefs = ` actionState: String actionType: String actionError: String + invoiceForward: Boolean item: Item itemAct: ItemAct forwardedSats: Int diff --git a/components/item-act.js b/components/item-act.js index 372895676..4559d9a86 100644 --- a/components/item-act.js +++ b/components/item-act.js @@ -182,21 +182,35 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a function modifyActCache (cache, { result, invoice }) { if (!result) return const { id, sats, path, act } = result + const p2p = invoice?.invoiceForward + cache.modify({ id: `Item:${id}`, fields: { sats (existingSats = 0) { - if (act === 'TIP') { + if (act === 'TIP' && p2p) { return existingSats + sats } return existingSats }, + credits (existingCredits = 0) { + if (act === 'TIP' && !p2p) { + return existingCredits + sats + } + return existingCredits + }, meSats: (existingSats = 0) => { if (act === 'TIP') { return existingSats + sats } return existingSats }, + meCredits: (existingCredits = 0) => { + if (act === 'TIP' && !p2p) { + return existingCredits + sats + } + return existingCredits + }, meDontLikeSats: (existingSats = 0) => { if (act === 'DONT_LIKE_THIS') { return existingSats + sats @@ -219,6 +233,12 @@ function modifyActCache (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 } diff --git a/components/item-info.js b/components/item-info.js index b0dbafaf5..b1996cb60 100644 --- a/components/item-info.js +++ b/components/item-info.js @@ -30,6 +30,33 @@ import classNames from 'classnames' import SubPopover from './sub-popover' import useCanEdit from './use-can-edit' +function itemTitle (item) { + let title = 'from ' + title += numWithUnits(item.upvotes, { + abbreviate: false, + unitSingular: 'stacker', + unitPlural: 'stackers' + }) + 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 +89,7 @@ export default function ItemInfo ({
{!(item.position && (pinnable || !item.subName)) && !(!item.parentId && Number(item.user?.id) === USER_ID.ad) && <> - + {numWithUnits(item.sats)} \ @@ -229,11 +247,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/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..ff3a8092f 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 }` 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 }` From e821dc9ae6d69b095ff3682606a8a6c4d84c42f0 Mon Sep 17 00:00:00 2001 From: k00b Date: Wed, 4 Dec 2024 21:39:45 -0600 Subject: [PATCH 09/30] comments with meCredits --- components/item-act.js | 13 +++-- .../migration.sql | 56 +++++++++++++++++++ 2 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 prisma/migrations/20241205002336_cowboy_credit_comments/migration.sql diff --git a/components/item-act.js b/components/item-act.js index 4559d9a86..100b81994 100644 --- a/components/item-act.js +++ b/components/item-act.js @@ -179,7 +179,7 @@ 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, path, act } = result const p2p = invoice?.invoiceForward @@ -188,7 +188,7 @@ function modifyActCache (cache, { result, invoice }) { id: `Item:${id}`, fields: { sats (existingSats = 0) { - if (act === 'TIP' && p2p) { + if (act === 'TIP') { return existingSats + sats } return existingSats @@ -200,13 +200,13 @@ function modifyActCache (cache, { result, invoice }) { return existingCredits }, meSats: (existingSats = 0) => { - if (act === 'TIP') { + if (act === 'TIP' && me) { return existingSats + sats } return existingSats }, meCredits: (existingCredits = 0) => { - if (act === 'TIP' && !p2p) { + if (act === 'TIP' && !p2p && me) { return existingCredits + sats } return existingCredits @@ -249,6 +249,7 @@ function modifyActCache (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] @@ -259,7 +260,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 }) => { @@ -267,7 +268,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 }) => { diff --git a/prisma/migrations/20241205002336_cowboy_credit_comments/migration.sql b/prisma/migrations/20241205002336_cowboy_credit_comments/migration.sql new file mode 100644 index 000000000..f66f298d2 --- /dev/null +++ b/prisma/migrations/20241205002336_cowboy_credit_comments/migration.sql @@ -0,0 +1,56 @@ +-- 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 From b307ab0377cd1b98f76c5048e1e95f2c80dfb399 Mon Sep 17 00:00:00 2001 From: k00b Date: Thu, 5 Dec 2024 17:17:14 -0600 Subject: [PATCH 10/30] begin including CCs in item stats/notifications --- api/paidAction/README.md | 6 ++++++ api/paidAction/zap.js | 5 +++-- api/resolvers/item.js | 5 ++--- api/resolvers/user.js | 8 +++++++- components/item-info.js | 14 ++++++++++---- components/nav/common.js | 11 +++++++++-- components/notifications.js | 32 +++++++++++++++++++++++++++++--- fragments/users.js | 1 + pages/wallet/index.js | 27 +++++++++++++++++---------- 9 files changed, 84 insertions(+), 25 deletions(-) diff --git a/api/paidAction/README.md b/api/paidAction/README.md index a2f9c6904..1cbc22521 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.sats - 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/zap.js b/api/paidAction/zap.js index 29f692cd6..eae31b67c 100644 --- a/api/paidAction/zap.js +++ b/api/paidAction/zap.js @@ -132,6 +132,7 @@ export async function onPaid ({ invoice, actIds }, { tx }) { 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"` @@ -154,7 +155,7 @@ export async function onPaid ({ invoice, actIds }, { tx }) { SET "weightedVotes" = "weightedVotes" + (zapper.trust * zap.log_sats), upvotes = upvotes + zap.first_vote, - msats = "Item".msats + ${invoice?.invoiceForward ? msats : 0n}::BIGINT, + msats = "Item".msats + ${msats}::BIGINT, mcredits = "Item".mcredits + ${invoice?.invoiceForward ? 0n : msats}::BIGINT, "lastZapAt" = now() FROM zap, zapper @@ -186,7 +187,7 @@ export async function onPaid ({ invoice, actIds }, { tx }) { SELECT * FROM "Item" WHERE id = ${itemAct.itemId}::INTEGER ) UPDATE "Item" - SET "commentMsats" = "Item"."commentMsats" + ${invoice?.invoiceForward ? msats : 0n}::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/item.js b/api/resolvers/item.js index 8d0cf7a87..f239ea739 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -1053,14 +1053,13 @@ export default { }, Item: { sats: async (item, args, { models }) => { - return msatsToSats(BigInt(item.msats) + BigInt(item.mePendingMsats || 0) + - BigInt(item.mcredits) + BigInt(item.mePendingMcredits || 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 + item.commentMcredits) + return msatsToSats(item.commentMsats) }, commentCredits: async (item, args, { models }) => { return msatsToSats(item.commentMcredits) diff --git a/api/resolvers/user.js b/api/resolvers/user.js index 2a1caf066..c86275f7a 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -1022,7 +1022,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 }) => { diff --git a/components/item-info.js b/components/item-info.js index 3451614af..b98500cd8 100644 --- a/components/item-info.js +++ b/components/item-info.js @@ -31,14 +31,20 @@ import SubPopover from './sub-popover' import useCanEdit from './use-can-edit' function itemTitle (item) { - let title = 'from ' + let title = '' title += numWithUnits(item.upvotes, { abbreviate: false, - unitSingular: 'stacker', - unitPlural: 'stackers' + 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` + 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) { diff --git a/components/nav/common.js b/components/nav/common.js index fd8d9001f..7fa564bb2 100644 --- a/components/nav/common.js +++ b/components/nav/common.js @@ -25,7 +25,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 ( @@ -144,7 +144,14 @@ export function WalletSummary () { if (me.privates?.hideWalletBalance) { return } - return `${abbrNum(me.privates?.sats)}` + return ( + + {`${abbrNum(me.privates?.sats)}`} + + ) } export function NavWalletSummary ({ className }) { diff --git a/components/notifications.js b/components/notifications.js index 491643278..f5378aa7c 100644 --- a/components/notifications.js +++ b/components/notifications.js @@ -525,9 +525,31 @@ function Referral ({ n }) { ) } +function stackedText (item) { + let text = '' + console.log(item.sats, item.credits) + if (item.sats) { + text += `${numWithUnits(item.sats, { abbreviate: false })}` + + if (item.credits) { + text += ' (' + } + } + if (item.credits) { + text += `${numWithUnits(item.credits, { abbreviate: false, unitSingular: 'CC', unitPlural: 'CCs' })}` + if (item.sats) { + text += ')' + } + } + + 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) => @@ -537,14 +559,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{' '} } @@ -557,7 +583,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/fragments/users.js b/fragments/users.js index a6eff073c..9194d52d4 100644 --- a/fragments/users.js +++ b/fragments/users.js @@ -39,6 +39,7 @@ ${STREAK_FIELDS} nostrCrossposting nsfwMode sats + credits tipDefault tipRandom tipRandomMin diff --git a/pages/wallet/index.js b/pages/wallet/index.js index 1b06ad225..e872f40fa 100644 --- a/pages/wallet/index.js +++ b/pages/wallet/index.js @@ -18,7 +18,6 @@ import Nav from 'react-bootstrap/Nav' import { FAST_POLL_INTERVAL, SSR } from '@/lib/constants' import { 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' @@ -60,16 +59,24 @@ export default function Wallet () { function YouHaveSats () { const { me } = useMe() + + if (!me) return null + return ( -

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

+ <> +

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

+

+ you have{' '} + + {numWithUnits(me.privates?.credits, { abbreviate: false, format: true, unitSingular: 'cowboy credit', unitPlural: 'cowboy credits' })} + +

+ ) } From f83552a0bd8df777c7fcd144737861e413b884af Mon Sep 17 00:00:00 2001 From: k00b Date: Fri, 6 Dec 2024 12:05:49 -0600 Subject: [PATCH 11/30] buy credits ui/mutation --- .../{buyFeeCredits.js => buyCredits.js} | 6 +- api/paidAction/index.js | 4 +- api/resolvers/wallet.js | 3 + api/typeDefs/paidAction.js | 6 + api/typeDefs/wallet.js | 5 + components/nav/common.js | 14 +- components/nav/mobile/offcanvas.js | 4 +- components/wallet-card.js | 2 +- components/wallet-logger.js | 2 +- fragments/paidAction.js | 11 + pages/credits.js | 108 +++++++++ pages/{settings => }/wallets/[wallet].js | 0 pages/{settings => }/wallets/index.js | 6 +- pages/{wallet => wallets}/logs.js | 0 pages/{wallet/index.js => withdraw.js} | 227 ++++-------------- 15 files changed, 201 insertions(+), 197 deletions(-) rename api/paidAction/{buyFeeCredits.js => buyCredits.js} (91%) create mode 100644 pages/credits.js rename pages/{settings => }/wallets/[wallet].js (100%) rename pages/{settings => }/wallets/index.js (95%) rename pages/{wallet => wallets}/logs.js (100%) rename pages/{wallet/index.js => withdraw.js} (69%) diff --git a/api/paidAction/buyFeeCredits.js b/api/paidAction/buyCredits.js similarity index 91% rename from api/paidAction/buyFeeCredits.js rename to api/paidAction/buyCredits.js index 28b11ac36..b0851817c 100644 --- a/api/paidAction/buyFeeCredits.js +++ b/api/paidAction/buyCredits.js @@ -13,7 +13,7 @@ export async function getCost ({ credits }) { } export async function perform ({ credits }, { me, cost, tx }) { - return await tx.user.update({ + await tx.user.update({ where: { id: me.id }, data: { mcredits: { @@ -21,6 +21,10 @@ export async function perform ({ credits }, { me, cost, tx }) { } } }) + + return { + credits + } } export async function describe () { diff --git a/api/paidAction/index.js b/api/paidAction/index.js index 4cf1bd0f3..a9d7e302b 100644 --- a/api/paidAction/index.js +++ b/api/paidAction/index.js @@ -18,7 +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_FEE_CREDITS from './buyFeeCredits' +import * as BUY_CREDITS from './buyCredits' import * as INVITE_GIFT from './inviteGift' export const paidActions = { @@ -34,7 +34,7 @@ export const paidActions = { TERRITORY_UNARCHIVE, DONATE, RECEIVE, - BUY_FEE_CREDITS, + BUY_CREDITS, INVITE_GIFT } diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index a626a9b8b..58ed39592 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -563,6 +563,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 }) } }, 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/wallet.js b/api/typeDefs/wallet.js index d7e49301a..0022639ec 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 { diff --git a/components/nav/common.js b/components/nav/common.js index 7fa564bb2..bff01d81f 100644 --- a/components/nav/common.js +++ b/components/nav/common.js @@ -10,7 +10,6 @@ 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 } from '../../lib/format' import { useServiceWorker } from '../serviceworker' import { signOut } from 'next-auth/react' @@ -140,10 +139,7 @@ export function NavNotifications ({ className }) { export function WalletSummary () { const { me } = useMe() - if (!me) return null - if (me.privates?.hideWalletBalance) { - return - } + if (!me || me.privates?.sats === 0) return null return ( - - + + @@ -200,8 +196,8 @@ export function MeDropdown ({ me, dropNavKey }) { bookmarks - - wallet + + wallets satistics diff --git a/components/nav/mobile/offcanvas.js b/components/nav/mobile/offcanvas.js index 39a88b1e5..d7a714065 100644 --- a/components/nav/mobile/offcanvas.js +++ b/components/nav/mobile/offcanvas.js @@ -59,8 +59,8 @@ export default function OffCanvas ({ me, dropNavKey }) { bookmarks - - wallet + + wallets satistics 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 a7ef8f206..ed87ed372 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/paidAction.js b/fragments/paidAction.js index ff3a8092f..17bc21e6d 100644 --- a/fragments/paidAction.js +++ b/fragments/paidAction.js @@ -118,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/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/settings/wallets/[wallet].js b/pages/wallets/[wallet].js similarity index 100% rename from pages/settings/wallets/[wallet].js rename to pages/wallets/[wallet].js 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 69% rename from pages/wallet/index.js rename to pages/withdraw.js index e872f40fa..1a39386e3 100644 --- a/pages/wallet/index.js +++ b/pages/withdraw.js @@ -1,201 +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 { FAST_POLL_INTERVAL, SSR } from '@/lib/constants' -import { numWithUnits } from '@/lib/format' -import styles from '@/components/user-header.module.css' -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 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() - - if (!me) return null - - return ( - <> -

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

-

- you have{' '} - - {numWithUnits(me.privates?.credits, { abbreviate: false, format: true, unitSingular: 'cowboy credit', unitPlural: 'cowboy credits' })} - -

- - ) -} - -function WalletHistory () { - return ( -
-
- - wallet history - -
-
- ) -} - -export function WalletForm () { +export default function Withdraw () { 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' })} +
+

@@ -208,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 } } @@ -274,7 +141,9 @@ export function InvWithdrawal () { required append={sats} /> - withdraw +
+ withdraw +
) @@ -346,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 { @@ -510,7 +379,9 @@ export function LnAddrWithdrawal () { />
} - send +
+ send +
) From f4e5e700d0b4b33023af6be9eb8ddfa42c3859c5 Mon Sep 17 00:00:00 2001 From: k00b Date: Sat, 7 Dec 2024 19:09:16 -0600 Subject: [PATCH 12/30] fix old /settings/wallets paths --- pages/wallets/[wallet].js | 4 ++-- wallets/README.md | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pages/wallets/[wallet].js b/pages/wallets/[wallet].js index 98cc2bf52..ff5830a6d 100644 --- a/pages/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/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` From 88b0392986368dd791e77d932814a51ddc113b02 Mon Sep 17 00:00:00 2001 From: k00b Date: Sat, 7 Dec 2024 19:10:49 -0600 Subject: [PATCH 13/30] bios don't get sats --- api/paidAction/zap.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/api/paidAction/zap.js b/api/paidAction/zap.js index eae31b67c..314ba5853 100644 --- a/api/paidAction/zap.js +++ b/api/paidAction/zap.js @@ -30,6 +30,11 @@ export async function getInvoiceablePeer ({ id, sats }, { models, me }) { } }) + // 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 From 26de1391438c540e893b714d229f1b3efae09251 Mon Sep 17 00:00:00 2001 From: k00b Date: Sat, 7 Dec 2024 19:33:18 -0600 Subject: [PATCH 14/30] fix settings --- pages/settings/index.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pages/settings/index.js b/pages/settings/index.js index 97f9011ef..91105e3e9 100644 --- a/pages/settings/index.js +++ b/pages/settings/index.js @@ -170,6 +170,7 @@ export default function Settings ({ ssrData }) { onSubmit={async ({ tipDefault, tipRandom, tipRandomMin, tipRandomMax, withdrawMaxFeeDefault, zapUndos, zapUndosEnabled, nostrPubkey, nostrRelays, satsFilter, + receiveCreditsBelowSats, sendCreditsBelowSats, ...values }) => { if (nostrPubkey.length === 0) { @@ -195,6 +196,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 From 6d22316337b6a8e365cb210c69744f41643bb31e Mon Sep 17 00:00:00 2001 From: k00b Date: Sat, 7 Dec 2024 19:33:46 -0600 Subject: [PATCH 15/30] make invites work with credits --- api/resolvers/invite.js | 2 +- pages/invites/[id].js | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) 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/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
) } From c076b230188e2625b81352c709db37925284e863 Mon Sep 17 00:00:00 2001 From: k00b Date: Tue, 10 Dec 2024 18:19:56 -0600 Subject: [PATCH 16/30] restore migration from master --- .../20241203235457_invite_denormalized_count/migration.sql | 1 + 1 file changed, 1 insertion(+) diff --git a/prisma/migrations/20241203235457_invite_denormalized_count/migration.sql b/prisma/migrations/20241203235457_invite_denormalized_count/migration.sql index 8dafc7571..522696754 100644 --- a/prisma/migrations/20241203235457_invite_denormalized_count/migration.sql +++ b/prisma/migrations/20241203235457_invite_denormalized_count/migration.sql @@ -5,3 +5,4 @@ ALTER TABLE "Invite" ADD COLUMN "giftedCount" INTEGER NOT NULL DEFAULT 0; UPDATE "Invite" SET "giftedCount" = (SELECT COUNT(*) FROM "users" WHERE "users"."inviteId" = "Invite".id) WHERE "Invite"."id" = "Invite".id; + From 0c92d8f688f655270327dc3fc44ef3d753e36345 Mon Sep 17 00:00:00 2001 From: k00b Date: Tue, 10 Dec 2024 19:28:54 -0600 Subject: [PATCH 17/30] inform backend of send wallets on zap --- api/paidAction/zap.js | 7 +++++-- api/resolvers/item.js | 4 ++-- api/typeDefs/item.js | 2 +- components/item-act.js | 2 +- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/api/paidAction/zap.js b/api/paidAction/zap.js index 314ba5853..568f46c2c 100644 --- a/api/paidAction/zap.js +++ b/api/paidAction/zap.js @@ -17,8 +17,11 @@ export async function getCost ({ sats }) { return satsToMsats(sats) } -export async function getInvoiceablePeer ({ id, sats }, { models, me }) { - if (sats < me?.sendCreditsBelowSats) { +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 } diff --git a/api/resolvers/item.js b/api/resolvers/item.js index f239ea739..85f909cb9 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -944,7 +944,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 }) @@ -978,7 +978,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') { diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index 94ed0ac9c..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! } diff --git a/components/item-act.js b/components/item-act.js index 100b81994..b25fbf52b 100644 --- a/components/item-act.js +++ b/components/item-act.js @@ -292,7 +292,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 { From f0fab58a168c2ec3ff73630dc4fb8a55a356df82 Mon Sep 17 00:00:00 2001 From: k00b Date: Sat, 14 Dec 2024 17:37:39 -0600 Subject: [PATCH 18/30] satistics header --- pages/satistics/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 => )}
From dcdd33af34e197095ecb2966821f2614cf438382 Mon Sep 17 00:00:00 2001 From: k00b Date: Sat, 14 Dec 2024 18:40:31 -0600 Subject: [PATCH 19/30] default receive options to true and squash migrations --- .../20241203195142_fee_credits/migration.sql | 65 +++++++++++++++++++ .../migration.sql | 56 ---------------- prisma/schema.prisma | 4 +- 3 files changed, 67 insertions(+), 58 deletions(-) delete mode 100644 prisma/migrations/20241205002336_cowboy_credit_comments/migration.sql diff --git a/prisma/migrations/20241203195142_fee_credits/migration.sql b/prisma/migrations/20241203195142_fee_credits/migration.sql index 2b921dcc2..b4a8fadbb 100644 --- a/prisma/migrations/20241203195142_fee_credits/migration.sql +++ b/prisma/migrations/20241203195142_fee_credits/migration.sql @@ -8,6 +8,71 @@ 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/20241205002336_cowboy_credit_comments/migration.sql b/prisma/migrations/20241205002336_cowboy_credit_comments/migration.sql deleted file mode 100644 index f66f298d2..000000000 --- a/prisma/migrations/20241205002336_cowboy_credit_comments/migration.sql +++ /dev/null @@ -1,56 +0,0 @@ --- 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/schema.prisma b/prisma/schema.prisma index a783cb4f7..d65f3d8c4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -144,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") From 5502c27a4c2f08925c36dfead7e15bdcb938be9d Mon Sep 17 00:00:00 2001 From: k00b Date: Sat, 14 Dec 2024 18:56:20 -0600 Subject: [PATCH 20/30] fix paidAction query --- api/resolvers/paidAction.js | 2 ++ 1 file changed, 2 insertions(+) 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') } From c99df3d6ac3a9405136a5d5974876261e8047910 Mon Sep 17 00:00:00 2001 From: k00b Date: Sat, 14 Dec 2024 19:07:25 -0600 Subject: [PATCH 21/30] add nav for credits --- components/nav/common.js | 3 +++ components/nav/mobile/offcanvas.js | 3 +++ 2 files changed, 6 insertions(+) diff --git a/components/nav/common.js b/components/nav/common.js index bff01d81f..6d484af53 100644 --- a/components/nav/common.js +++ b/components/nav/common.js @@ -199,6 +199,9 @@ export function MeDropdown ({ me, dropNavKey }) { wallets + + credits + satistics diff --git a/components/nav/mobile/offcanvas.js b/components/nav/mobile/offcanvas.js index d7a714065..698dc96e0 100644 --- a/components/nav/mobile/offcanvas.js +++ b/components/nav/mobile/offcanvas.js @@ -62,6 +62,9 @@ export default function OffCanvas ({ me, dropNavKey }) { wallets + + credits + satistics From 78f11822eb7ec6a6e3b471f5cb3768f8e7d5f5e7 Mon Sep 17 00:00:00 2001 From: k00b Date: Tue, 17 Dec 2024 10:06:53 -0600 Subject: [PATCH 22/30] fix forever stacked count --- api/resolvers/user.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/api/resolvers/user.js b/api/resolvers/user.js index c86275f7a..dd41c2660 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -1110,8 +1110,7 @@ export default { if (!when || when === 'forever') { // forever - return ((user.stackedMsats && msatsToSats(user.stackedMsats)) || 0) + - ((user.stackedMcredits && msatsToSats(user.stackedMcredits)) || 0) + return ((user.stackedMsats && msatsToSats(user.stackedMsats)) || 0) } const range = whenRange(when, from, to) From 37021eb1c9d548f5222eb78b9ac309cf8b9c4f2f Mon Sep 17 00:00:00 2001 From: k00b Date: Sat, 21 Dec 2024 19:10:01 -0600 Subject: [PATCH 23/30] ek suggested fixes --- api/paidAction/zap.js | 2 +- components/notifications.js | 14 +++++--------- pages/settings/index.js | 2 -- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/api/paidAction/zap.js b/api/paidAction/zap.js index 568f46c2c..8705d7aa7 100644 --- a/api/paidAction/zap.js +++ b/api/paidAction/zap.js @@ -21,7 +21,7 @@ export async function getInvoiceablePeer ({ id, sats, hasSendWallet }, { models, // 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))) { + (me && !hasSendWallet && (me.mcredits >= cost || me.msats >= cost))) { return null } diff --git a/components/notifications.js b/components/notifications.js index faae6f80b..c94827843 100644 --- a/components/notifications.js +++ b/components/notifications.js @@ -537,19 +537,15 @@ function Referral ({ n }) { function stackedText (item) { let text = '' - console.log(item.sats, item.credits) - if (item.sats) { - text += `${numWithUnits(item.sats, { abbreviate: false })}` + if (item.sats - item.credits > 0) { + text += `${numWithUnits(item.sats - item.credits, { abbreviate: false })}` - if (item.credits) { - text += ' (' + if (item.credits > 0) { + text += ' and ' } } - if (item.credits) { + if (item.credits > 0) { text += `${numWithUnits(item.credits, { abbreviate: false, unitSingular: 'CC', unitPlural: 'CCs' })}` - if (item.sats) { - text += ')' - } } return text diff --git a/pages/settings/index.js b/pages/settings/index.js index 91105e3e9..b6bbdc9ae 100644 --- a/pages/settings/index.js +++ b/pages/settings/index.js @@ -110,8 +110,6 @@ export default function Settings ({ ssrData }) { // if we switched to anon, me is null before the page is reloaded if ((!data && !ssrData) || !me) return - console.log(settings) - return (
From a0480088591db9d048f62aa6d9db2f9050863551 Mon Sep 17 00:00:00 2001 From: k00b Date: Fri, 27 Dec 2024 19:58:19 -0600 Subject: [PATCH 24/30] fix lint --- components/item-act.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/components/item-act.js b/components/item-act.js index 6a6165ff5..7733b4a12 100644 --- a/components/item-act.js +++ b/components/item-act.js @@ -233,6 +233,8 @@ function modifyActCache (cache, { result, invoice }, me) { 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 => { From 23a4cea635f1adc3fe03b0b8a66df115db659bdf Mon Sep 17 00:00:00 2001 From: k00b Date: Thu, 2 Jan 2025 16:07:05 -0600 Subject: [PATCH 25/30] fix freebies wrt CCs --- api/paidAction/itemCreate.js | 2 +- components/fee-button.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/paidAction/itemCreate.js b/api/paidAction/itemCreate.js index a03b8b7c9..e2f3fc5bc 100644 --- a/api/paidAction/itemCreate.js +++ b/api/paidAction/itemCreate.js @@ -30,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/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, From 5a3cdb05f7230a11fdd8f82a528ab1140471af9f Mon Sep 17 00:00:00 2001 From: k00b Date: Thu, 2 Jan 2025 16:39:56 -0600 Subject: [PATCH 26/30] add back disable freebies --- wallets/config.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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) { From 4de74b4ca3b3a0eb8b211e72686f606b939d0449 Mon Sep 17 00:00:00 2001 From: k00b Date: Thu, 2 Jan 2025 16:54:02 -0600 Subject: [PATCH 27/30] trigger cowboy hat job on CC depletion --- .../20250102224852_hat_streak_credits/migration.sql | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 prisma/migrations/20250102224852_hat_streak_credits/migration.sql 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 From 264f72120679e99db58f4702dea3c73a4182edc5 Mon Sep 17 00:00:00 2001 From: k00b Date: Thu, 2 Jan 2025 17:07:45 -0600 Subject: [PATCH 28/30] fix meMsats+meMcredits --- api/resolvers/item.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 88069c107..8f90aed5e 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -1182,7 +1182,7 @@ export default { meSats: async (item, args, { me, models }) => { if (!me) return 0 if (typeof item.meMsats !== 'undefined' && typeof item.meMcredits !== 'undefined') { - return msatsToSats(item.meMsats + item.meMcredits) + return msatsToSats(BigInt(item.meMsats) + BigInt(item.meMcredits)) } const { _sum: { msats } } = await models.itemAct.aggregate({ From 721e4d7ae68fd166382fa7ddd6b45650ddfaa51b Mon Sep 17 00:00:00 2001 From: Keyan <34140557+huumn@users.noreply.github.com> Date: Fri, 3 Jan 2025 08:53:06 -0600 Subject: [PATCH 29/30] Update api/paidAction/README.md Co-authored-by: ekzyis --- api/paidAction/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/paidAction/README.md b/api/paidAction/README.md index 1cbc22521..a32588076 100644 --- a/api/paidAction/README.md +++ b/api/paidAction/README.md @@ -196,7 +196,7 @@ All functions have the following signature: `function(args: Object, context: Obj ## 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.sats - item.mcredits`. +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. From d62afeb197e1973fcef63fa7f69ec8915363cfe5 Mon Sep 17 00:00:00 2001 From: k00b Date: Fri, 3 Jan 2025 10:01:57 -0600 Subject: [PATCH 30/30] remove expireBoost migration that doesn't work --- .../20250103011357_fix_expireboost_keepuntil/migration.sql | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 prisma/migrations/20250103011357_fix_expireboost_keepuntil/migration.sql 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