diff --git a/apps/scoutgamecron/cron.yml b/apps/scoutgamecron/cron.yml index 21d6a95951..4484e18390 100644 --- a/apps/scoutgamecron/cron.yml +++ b/apps/scoutgamecron/cron.yml @@ -34,3 +34,9 @@ cron: url: '/update-builder-card-activity' # Every hour schedule: '0 * * * *' + + + - name: 'resolve-balance-issues' + url: '/resolve-balance-issues' + # Every day at midnight + schedule: '0 0 * * *' diff --git a/apps/scoutgamecron/src/scripts/detectAndResolveBalanceIssues.ts b/apps/scoutgamecron/src/scripts/detectAndResolveBalanceIssues.ts new file mode 100644 index 0000000000..24bd5db413 --- /dev/null +++ b/apps/scoutgamecron/src/scripts/detectAndResolveBalanceIssues.ts @@ -0,0 +1,77 @@ +import { log } from "@charmverse/core/log"; +import { prisma } from "@charmverse/core/prisma-client"; +import { currentSeason, getCurrentWeek } from "@packages/scoutgame/dates"; +import { getPointStatsFromHistory } from "@packages/scoutgame/points/getPointStatsFromHistory"; + + + +async function detectBalanceIssues() { + const scouts = await prisma.scout.findMany({ + orderBy: { + farcasterId: 'asc' + }, + select: { + id: true, + farcasterId: true, + farcasterName: true, + currentBalance: true + } + }); + const totalScouts = scouts.length; + + log.info(`Checking ${totalScouts} scouts for balance issues...`); + + const scoutsWithBalanceIssues = []; + + for (let i = 0; i < totalScouts; i++) { + log.info(`Checking scout ${i + 1} of ${totalScouts}: fid=${scouts[i].farcasterId}, name=${scouts[i].farcasterName}`); + const scout = scouts[i]; + + const balances = await getPointStatsFromHistory({ + userIdOrUsername: scout.id + }); + + if (balances.balance !== scout.currentBalance) { + + log.error(`Scout (id: ${scout.id}) (fid:${scout.farcasterId}) has a balance discrepancy: ${balances.balance} (computed) vs ${scout.currentBalance} (current)`) + scoutsWithBalanceIssues.push({ + farcasterId: scout.farcasterId, + scoutId: scout.id, + expectedBalance: balances.balance, + currentBalance: scout.currentBalance, + pointDetails: balances + }); + } + } + + return scoutsWithBalanceIssues; +} + +async function resolveBalanceIssues() { + const balanceIssues = await detectBalanceIssues(); + + for (let i = 0; i < balanceIssues.length; i++) { + const balanceToResolve = balanceIssues[i]; + + await prisma.builderEvent.create({ + data: { + season: currentSeason, + type: 'misc_event', + week: getCurrentWeek(), + builder: { + connect: { + id: balanceToResolve.scoutId + } + }, + pointsReceipts: { + create: { + value: balanceToResolve.expectedBalance - balanceToResolve.currentBalance, + recipientId: balanceToResolve.scoutId + } + } + } + }) + } +} + +// detectBalanceIssues().then(console.log) \ No newline at end of file diff --git a/apps/scoutgamecron/src/scripts/manualNftResync.ts b/apps/scoutgamecron/src/scripts/manualNftResync.ts new file mode 100644 index 0000000000..d87edad1b7 --- /dev/null +++ b/apps/scoutgamecron/src/scripts/manualNftResync.ts @@ -0,0 +1,17 @@ +import { syncUserNFTsFromOnchainData } from "@packages/scoutgame/builderNfts/syncUserNFTsFromOnchainData"; + + + +const username = '' + +export async function manualNftResync() { + if (!username) { + throw new Error('Please provide a username'); + }; + + await syncUserNFTsFromOnchainData({username}) + console.log('manualNftResync'); +} + + +// manualNftResync().then(console.log) \ No newline at end of file diff --git a/apps/scoutgamecron/src/tasks/resolveBalanceIssues/__tests__/resolveBalanceIssues.spec.ts b/apps/scoutgamecron/src/tasks/resolveBalanceIssues/__tests__/resolveBalanceIssues.spec.ts new file mode 100644 index 0000000000..ce347b9d87 --- /dev/null +++ b/apps/scoutgamecron/src/tasks/resolveBalanceIssues/__tests__/resolveBalanceIssues.spec.ts @@ -0,0 +1,45 @@ +import { prisma } from '@charmverse/core/prisma-client'; +import { mockScout } from '@packages/scoutgame/testing/database'; + +import { resolveBalanceIssues } from '../resolveBalanceIssues'; + +describe('resolveBalanceIssues', () => { + it('should resolve balance issues by creating an event to adjust the balance, so that expected balance is in line with current balance', async () => { + const balance = 100; + + const scout = await mockScout({ + currentBalance: balance + }); + + const initialReceiptsCount = await prisma.pointsReceipt.count({ + where: { + recipientId: scout.id + } + }); + + expect(initialReceiptsCount).toBe(0); + + await resolveBalanceIssues(); + + const receipts = await prisma.pointsReceipt.findMany({ + where: { + recipientId: scout.id + } + }); + + expect(receipts.length).toBe(1); + + const receipt = receipts[0]; + + expect(receipt.value).toBe(100); + expect(receipt.recipientId).toBe(scout.id); + + const updatedScout = await prisma.scout.findUniqueOrThrow({ + where: { + id: scout.id + } + }); + + expect(updatedScout.currentBalance).toBe(balance); + }); +}); diff --git a/apps/scoutgamecron/src/tasks/resolveBalanceIssues/resolveBalanceIssues.ts b/apps/scoutgamecron/src/tasks/resolveBalanceIssues/resolveBalanceIssues.ts new file mode 100644 index 0000000000..054652f1d7 --- /dev/null +++ b/apps/scoutgamecron/src/tasks/resolveBalanceIssues/resolveBalanceIssues.ts @@ -0,0 +1,30 @@ +import { prisma } from '@charmverse/core/prisma-client'; +import { currentSeason, getCurrentWeek } from '@packages/scoutgame/dates'; +import { detectBalanceIssues } from '@packages/scoutgame/points/detectBalanceIssues'; + +export async function resolveBalanceIssues() { + const balanceIssues = await detectBalanceIssues(); + + for (let i = 0; i < balanceIssues.length; i++) { + const balanceToResolve = balanceIssues[i]; + + await prisma.builderEvent.create({ + data: { + season: currentSeason, + type: 'misc_event', + week: getCurrentWeek(), + builder: { + connect: { + id: balanceToResolve.scoutId + } + }, + pointsReceipts: { + create: { + value: balanceToResolve.currentBalance - balanceToResolve.expectedBalance, + recipientId: balanceToResolve.scoutId + } + } + } + }); + } +} diff --git a/apps/scoutgamecron/src/tasks/resolveMissingPurchases/index.ts b/apps/scoutgamecron/src/tasks/resolveMissingPurchases/index.ts index d5ad309803..22edbe27bc 100644 --- a/apps/scoutgamecron/src/tasks/resolveMissingPurchases/index.ts +++ b/apps/scoutgamecron/src/tasks/resolveMissingPurchases/index.ts @@ -5,6 +5,7 @@ import { resolveMissingPurchases } from './resolveMissingPurchases'; export async function resolveMissingPurchasesTask(ctx: Koa.Context) { log.info('Resyncing builder NFT sales'); - await resolveMissingPurchases({ minutesAgoToNow: 5 }); + // This task is run every 5 minutes. Adding some padding so we don't miss any transactions + await resolveMissingPurchases({ minutesAgoToNow: 7 }); log.info(`Syncing complete`); } diff --git a/apps/scoutgamecron/src/worker.ts b/apps/scoutgamecron/src/worker.ts index 0e8fb9eec0..27760c0ada 100644 --- a/apps/scoutgamecron/src/worker.ts +++ b/apps/scoutgamecron/src/worker.ts @@ -9,6 +9,7 @@ import { processAllBuilderActivity } from './tasks/processBuilderActivity'; import { processGemsPayout } from './tasks/processGemsPayout'; import { processNftMints } from './tasks/processNftMints'; import { sendNotifications } from './tasks/pushNotifications/sendNotifications'; +import { resolveBalanceIssues } from './tasks/resolveBalanceIssues/resolveBalanceIssues'; import { resolveMissingPurchasesTask } from './tasks/resolveMissingPurchases'; import { updateAllBuilderCardActivities } from './tasks/updateBuilderCardActivity'; import { updateMixpanelUserProfilesTask } from './tasks/updateMixpanelProfilesTask'; @@ -63,6 +64,8 @@ addTask('/update-builder-card-activity', updateAllBuilderCardActivities); addTask('/resync-nft-purchases', resolveMissingPurchasesTask); +addTask('/resolve-balance-issues', resolveBalanceIssues); + // Standard health check used by Beanstalk router.get('/api/health', middleware.healthCheck); diff --git a/package.json b/package.json index 0ab57cfc9e..34ccb111c0 100644 --- a/package.json +++ b/package.json @@ -58,9 +58,9 @@ "start:remote": "concurrently --names \"next,sockets\" -c \"green,yellow\" \"npm run server:remote\" \"npm run sockets:remote\"", "start:staging": "concurrently --names \"next,sockets\" -c \"green,yellow\" \"REACT_APP_WEBSOCKETS_HOST=http://127.0.0.1:3336 npm run server:staging\" \"npm run sockets:staging\"", "prepare": "husky install", - "db-tool": "npx prisma studio", - "db-tool:dev": "dotenv -e .env.local -- npx prisma studio", - "db-tool:test": "dotenv -e .env.test.local -- npx prisma studio", + "db-tool": "npx prisma studio --schema=node_modules/@charmverse/core/src/prisma/schema.prisma", + "db-tool:dev": "dotenv -e .env.local -- npx prisma studio --schema=node_modules/@charmverse/core/src/prisma/schema.prisma", + "db-tool:test": "dotenv -e .env.test.local -- npx prisma studio --schema=node_modules/@charmverse/core/src/prisma/schema.prisma", "serverless:build": "dotenv -e .env.local -- npx serverless package", "serverless:start": "dotenv -e .env.test.local -- serverless offline start --httpPort=3020", "typecheck": "NODE_OPTIONS=\"--max_old_space_size=4096\" node_modules/typescript/bin/tsc --project tsconfig.json --noEmit", diff --git a/packages/scoutgame/src/builderNfts/syncUserNFTsFromOnchainData.ts b/packages/scoutgame/src/builderNfts/syncUserNFTsFromOnchainData.ts index d38f507f46..51468b6a0d 100644 --- a/packages/scoutgame/src/builderNfts/syncUserNFTsFromOnchainData.ts +++ b/packages/scoutgame/src/builderNfts/syncUserNFTsFromOnchainData.ts @@ -35,37 +35,51 @@ export async function syncUserNFTsFromOnchainData({ const txRequiringReconciliation = userPurchases.filter((p) => !p.nftPurchase); for (let i = 0; i < txRequiringReconciliation.length; i++) { - log.info(`Processing missing tx ${i + 1} / ${txRequiringReconciliation.length}`); + const txToReconcile = txRequiringReconciliation[i]; - const tx = txRequiringReconciliation[i]; + log.error(`Processing missing txToReconcile ${i + 1} / ${txRequiringReconciliation.length}`, { + sourceTransaction: txToReconcile.pendingTransaction?.sourceChainTxHash, + sourceChain: txToReconcile.pendingTransaction?.sourceChainId, + optimismTxHash: txToReconcile.txHash, + tokenId: txToReconcile.tokenId, + scoutId: txToReconcile.scoutId, + tokensToPurchase: txToReconcile.amount + }); const expectedPrice = - tx.pendingTransaction?.targetAmountReceived ?? + txToReconcile.pendingTransaction?.targetAmountReceived ?? (await getTokenPurchasePrice({ args: { - amount: BigInt(tx.amount), - tokenId: BigInt(tx.tokenId) + amount: BigInt(txToReconcile.amount), + tokenId: BigInt(txToReconcile.tokenId) }, - blockNumber: BigInt(tx.blockNumber) - BigInt(1) + blockNumber: BigInt(txToReconcile.blockNumber) - BigInt(1) })); + if (!txToReconcile.pendingTransaction) { + log.error('No pending transaction found for txToReconcile', { + scoutId: txToReconcile.scoutId, + tokenId: txToReconcile.tokenId, + tokensToPurchase: txToReconcile.amount + }); + } const pendingTx = - tx.pendingTransaction ?? + txToReconcile.pendingTransaction ?? (await savePendingTransaction({ user: { scoutId: scout.id, - walletAddress: tx.transferEvent.to + walletAddress: txToReconcile.transferEvent.to }, transactionInfo: { destinationChainId: 10, sourceChainId: 10, - sourceChainTxHash: tx.txHash + sourceChainTxHash: txToReconcile.txHash }, purchaseInfo: { quotedPriceCurrency: optimismUsdcContractAddress, builderContractAddress: realOptimismMainnetBuildersContract, - tokenId: parseInt(tx.tokenId), + tokenId: parseInt(txToReconcile.tokenId), quotedPrice: Number(expectedPrice.toString()), - tokenAmount: Number(tx.amount) + tokenAmount: Number(txToReconcile.amount) } })); diff --git a/packages/scoutgame/src/points/detectBalanceIssues.ts b/packages/scoutgame/src/points/detectBalanceIssues.ts new file mode 100644 index 0000000000..6c70419efb --- /dev/null +++ b/packages/scoutgame/src/points/detectBalanceIssues.ts @@ -0,0 +1,48 @@ +import { log } from '@charmverse/core/log'; +import { prisma } from '@charmverse/core/prisma-client'; +import { getPointStatsFromHistory } from '@packages/scoutgame/points/getPointStatsFromHistory'; + +export async function detectBalanceIssues() { + const scouts = await prisma.scout.findMany({ + orderBy: { + farcasterId: 'asc' + }, + select: { + id: true, + farcasterId: true, + farcasterName: true, + currentBalance: true + } + }); + const totalScouts = scouts.length; + + log.info(`Checking ${totalScouts} scouts for balance issues...`); + + const scoutsWithBalanceIssues = []; + + for (let i = 0; i < totalScouts; i++) { + log.info( + `Checking scout ${i + 1} of ${totalScouts}: fid=${scouts[i].farcasterId}, name=${scouts[i].farcasterName}` + ); + const scout = scouts[i]; + + const balances = await getPointStatsFromHistory({ + userIdOrUsername: scout.id + }); + + if (balances.balance !== scout.currentBalance) { + log.error( + `Scout (id: ${scout.id}) (fid:${scout.farcasterId}) has a balance discrepancy: ${balances.balance} (computed) vs ${scout.currentBalance} (current)` + ); + scoutsWithBalanceIssues.push({ + farcasterId: scout.farcasterId, + scoutId: scout.id, + expectedBalance: balances.balance, + currentBalance: scout.currentBalance, + pointDetails: balances + }); + } + } + + return scoutsWithBalanceIssues; +} diff --git a/packages/scoutgame/src/testing/database.ts b/packages/scoutgame/src/testing/database.ts index e1d35e4b10..238dd7f25f 100644 --- a/packages/scoutgame/src/testing/database.ts +++ b/packages/scoutgame/src/testing/database.ts @@ -60,7 +60,8 @@ export async function mockScout({ onboardedAt = new Date(), builderId, season, - email + email, + currentBalance }: { username?: string; agreedToTermsAt?: Date | null; @@ -69,6 +70,7 @@ export async function mockScout({ builderId?: string; // automatically "scout" a builder season?: string; email?: string; + currentBalance?: number; } = {}) { const scout = await prisma.scout.create({ data: { @@ -76,7 +78,8 @@ export async function mockScout({ agreedToTermsAt, onboardedAt, displayName, - email + email, + currentBalance } }); if (builderId) {