From 11197d138244770543daa46684839538758798ea Mon Sep 17 00:00:00 2001 From: MD1125 <166238654+MD1125@users.noreply.github.com> Date: Thu, 12 Jun 2025 22:35:54 +0200 Subject: [PATCH 01/18] Minegame.exe --- .../lib/components/self/games/Mines.svelte | 614 ++++++++++++++++++ website/src/lib/server/games/mines.ts | 98 +++ .../api/gambling/mines/cashout/+server.ts | 74 +++ .../api/gambling/mines/reveal/+server.ts | 81 +++ .../api/gambling/mines/start/+server.ts | 100 +++ website/src/routes/gambling/+page.svelte | 17 +- 6 files changed, 979 insertions(+), 5 deletions(-) create mode 100644 website/src/lib/components/self/games/Mines.svelte create mode 100644 website/src/lib/server/games/mines.ts create mode 100644 website/src/routes/api/gambling/mines/cashout/+server.ts create mode 100644 website/src/routes/api/gambling/mines/reveal/+server.ts create mode 100644 website/src/routes/api/gambling/mines/start/+server.ts diff --git a/website/src/lib/components/self/games/Mines.svelte b/website/src/lib/components/self/games/Mines.svelte new file mode 100644 index 0000000..affd016 --- /dev/null +++ b/website/src/lib/components/self/games/Mines.svelte @@ -0,0 +1,614 @@ + + + + + Mines + + Navigate through the minefield and cash out before hitting a mine! + + + Info + + + + Mines Game Information + +
+
+

Winning Probabilities

+

+ The probability of winning increases with each safe tile you reveal. Here's how it works: +

+
+
+

For each tile you reveal:

+
    +
  • More mines = Higher risk = Higher potential payout
  • +
  • Fewer mines = Lower risk = Lower potential payout
  • +
  • Each safe tile increases your multiplier
  • +
+
+
+

Example:

+

With 3 mines on the board:

+
    +
  • First tile: 88% chance of being safe
  • +
  • Second tile: 87% chance of being safe
  • +
  • Third tile: 86% chance of being safe
  • +
+
+
+
+ +
+

Game Rules & Information

+
+

+ If you leave the page while playing: +

+
    +
  • If you haven't revealed any tiles, your game session will be ended after 5 minutes of inactivity
  • +
  • If you have revealed tiles, the auto cashout will process your gains within 15 seconds
  • +
+
+
+
+
+
+
+
+ +
+ +
+ +
+

Balance

+

{formatValue(balance)}

+
+ + +
= 7}> + {#each Array(TOTAL_TILES) as _, index} + + + {/each} +
+
+ + +
+ +
+ +
+ +
+
+ + +
+ + +

+ Max bet: {MAX_BET_AMOUNT.toLocaleString()} +

+
+ + +
+
+ + + + +
+
+ + +
+ {#if !isPlaying} + + {:else} + + {#if hasRevealedTile} +
+
+
+ Auto Cashout in {Math.ceil(AUTO_CASHOUT_TIME - autoCashoutTimer)}s +
+
+
= 7} + style="width: {autoCashoutProgress}%" + >
+
+
+
+ {/if} + + + {#if hasRevealedTile} +
+
+ Current Profit: + + +{formatValue(betAmount * (currentMultiplier - 1))} + +
+
+ Next Tile: + + +{formatValue(betAmount * (calculateRawMultiplier(revealedTiles.length + 1, mineCount) - 1))} + +
+
+ Current Multiplier: + {currentMultiplier.toFixed(2)}x +
+
+ {/if} + {/if} +
+
+
+
+
+ + diff --git a/website/src/lib/server/games/mines.ts b/website/src/lib/server/games/mines.ts new file mode 100644 index 0000000..2c95f24 --- /dev/null +++ b/website/src/lib/server/games/mines.ts @@ -0,0 +1,98 @@ +import { db } from '$lib/server/db'; +import { user } from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; + + +interface MinesSession { + sessionToken: string; + betAmount: number; + mineCount: number; + minePositions: number[]; + revealedTiles: number[]; + startTime: number; + currentMultiplier: number; + status: 'active' | 'won' | 'lost'; + lastActivity: number; + userId: number; +} + + +export const activeGames = new Map(); + +// Clean up old games every minute. +setInterval(async () => { + const now = Date.now(); + for (const [token, game] of activeGames.entries()) { + // Delete games older than 5 minutes that are still there for some reason. + if (now - game.lastActivity > 5 * 60 * 1000) { + // If no tiles were revealed, refund the bet + if (game.revealedTiles.length === 0) { + try { + console.log(`Processing refund for inactive Mines game ${token} (User: ${game.userId}, Bet: ${game.betAmount})`); + + const [userData] = await db + .select({ baseCurrencyBalance: user.baseCurrencyBalance }) + .from(user) + .where(eq(user.id, game.userId)) + .for('update') + .limit(1); + + const currentBalance = Number(userData.baseCurrencyBalance); + const newBalance = Math.round((currentBalance + game.betAmount) * 100000000) / 100000000; + + await db + .update(user) + .set({ + baseCurrencyBalance: newBalance.toFixed(8), + updatedAt: new Date() + }) + .where(eq(user.id, game.userId)); + + console.log(`Successfully refunded ${game.betAmount} to user ${game.userId}. New balance: ${newBalance}`); + } catch (error) { + console.error(`Failed to refund inactive game ${token}:`, error); + } + } else { + console.log(`Cleaning up inactive game ${token} (User: ${game.userId}) - No refund needed as tiles were revealed`); + } + activeGames.delete(token); + } + } +}, 60000); + +// Rig the game... +const getMaxPayout = (bet: number, picks: number): number => { + const absoluteCap = 5_000_000; // never pay above this. Yeah, its rigged. Live with that :) + const baseCap = 1.4; // 1.4x min multiplier, increase to, well, increase payouts + const growthRate = 0.45; // cap curve sensitivity + + // Cap increases with number of successful reveals + const effectiveMultiplierCap = baseCap + Math.pow(picks, growthRate); + const payoutCap = bet * effectiveMultiplierCap; + + return Math.min(payoutCap, absoluteCap); +}; + + +export function calculateMultiplier(picks: number, mines: number, betAmount: number): number { + const TOTAL_TILES = 25; + const HOUSE_EDGE = 0.05; + + let probability = 1; + for (let i = 0; i < picks; i++) { + probability *= (TOTAL_TILES - mines - i) / (TOTAL_TILES - i); + } + + if (probability <= 0) return 1.0; + + const fairMultiplier = (1 / probability) * (1 - HOUSE_EDGE); + const rawPayout = fairMultiplier * betAmount; + + const maxPayout = getMaxPayout(betAmount, picks); + const cappedPayout = Math.min(rawPayout, maxPayout); + const effectiveMultiplier = cappedPayout / betAmount; + + return Math.max(1.0, effectiveMultiplier); +} + + diff --git a/website/src/routes/api/gambling/mines/cashout/+server.ts b/website/src/routes/api/gambling/mines/cashout/+server.ts new file mode 100644 index 0000000..a91bb68 --- /dev/null +++ b/website/src/routes/api/gambling/mines/cashout/+server.ts @@ -0,0 +1,74 @@ +import { auth } from '$lib/auth'; +import { error, json } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { user } from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; +import { activeGames } from '$lib/server/games/mines'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async ({ request }) => { + const session = await auth.api.getSession({ + headers: request.headers + }); + + if (!session?.user) { + throw error(401, 'Not authenticated'); + } + + try { + const { sessionToken } = await request.json(); + const game = activeGames.get(sessionToken); + const userId = Number(session.user.id); + + if (!game) { + return json({ error: 'Invalid session' }, { status: 400 }); + } + + const result = await db.transaction(async (tx) => { + const [userData] = await tx + .select({ baseCurrencyBalance: user.baseCurrencyBalance }) + .from(user) + .where(eq(user.id, userId)) + .for('update') + .limit(1); + + const currentBalance = Number(userData.baseCurrencyBalance); + let payout: number; + let newBalance: number; + + // If no tiles revealed, treat as abort and return full bet. This could be changed later to keep the initial bet on the Server + if (game.revealedTiles.length === 0) { + payout = game.betAmount; + newBalance = Math.round((currentBalance + payout) * 100000000) / 100000000; + } else { + // Calculate payout + payout = game.betAmount * game.currentMultiplier; + const roundedPayout = Math.round(payout * 100000000) / 100000000; + newBalance = Math.round((currentBalance + roundedPayout) * 100000000) / 100000000; + } + + await tx + .update(user) + .set({ + baseCurrencyBalance: newBalance.toFixed(8), + updatedAt: new Date() + }) + .where(eq(user.id, userId)); + + activeGames.delete(sessionToken); + + return { + newBalance, + payout, + amountWagered: game.betAmount, + isAbort: game.revealedTiles.length === 0 + }; + }); + + return json(result); + } catch (e) { + console.error('Mines cashout error:', e); + const errorMessage = e instanceof Error ? e.message : 'Internal server error'; + return json({ error: errorMessage }, { status: 400 }); + } +}; \ No newline at end of file diff --git a/website/src/routes/api/gambling/mines/reveal/+server.ts b/website/src/routes/api/gambling/mines/reveal/+server.ts new file mode 100644 index 0000000..75f95c7 --- /dev/null +++ b/website/src/routes/api/gambling/mines/reveal/+server.ts @@ -0,0 +1,81 @@ +import { auth } from '$lib/auth'; +import { error, json } from '@sveltejs/kit'; +import { activeGames, calculateMultiplier } from '$lib/server/games/mines'; +import type { RequestHandler } from './$types'; +import { db } from '$lib/server/db'; +import { user } from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; + +export const POST: RequestHandler = async ({ request }) => { + const session = await auth.api.getSession({ + headers: request.headers + }); + + if (!session?.user) { + throw error(401, 'Not authenticated'); + } + + try { + const { sessionToken, tileIndex } = await request.json(); + const game = activeGames.get(sessionToken); + + if (!game) { + return json({ error: 'Invalid session' }, { status: 400 }); + } + + if (game.revealedTiles.includes(tileIndex)) { + return json({ error: 'Tile already revealed' }, { status: 400 }); + } + + // Update last activity time + game.lastActivity = Date.now(); + + // Check if hit mine + + if (game.minePositions.includes(tileIndex)) { + game.status = 'lost'; + const minePositions = game.minePositions; + + // Fetch user balance to return after loss + const userId = Number(session.user.id); + const [userData] = await db + .select({ baseCurrencyBalance: user.baseCurrencyBalance }) + .from(user) + .where(eq(user.id, userId)) + .limit(1); + + activeGames.delete(sessionToken); + + return json({ + hitMine: true, + minePositions, + newBalance: Number(userData.baseCurrencyBalance), + status: 'lost' + }); + } + + + // Safe tile + game.revealedTiles.push(tileIndex); + game.currentMultiplier = calculateMultiplier( + game.revealedTiles.length, + game.mineCount, + game.betAmount + ); + + // Check if all safe tiles are revealed. Crazy when you get this :) + if (game.revealedTiles.length === 25 - game.mineCount) { + game.status = 'won'; + } + + return json({ + hitMine: false, + currentMultiplier: game.currentMultiplier, + status: game.status + }); + } catch (e) { + console.error('Mines reveal error:', e); + const errorMessage = e instanceof Error ? e.message : 'Internal server error'; + return json({ error: errorMessage }, { status: 400 }); + } +}; \ No newline at end of file diff --git a/website/src/routes/api/gambling/mines/start/+server.ts b/website/src/routes/api/gambling/mines/start/+server.ts new file mode 100644 index 0000000..34e25d9 --- /dev/null +++ b/website/src/routes/api/gambling/mines/start/+server.ts @@ -0,0 +1,100 @@ +import { auth } from '$lib/auth'; +import { error, json } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { user } from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; +import { activeGames } from '$lib/server/games/mines'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async ({ request }) => { + const session = await auth.api.getSession({ + headers: request.headers + }); + + if (!session?.user) { + throw error(401, 'Not authenticated'); + } + + try { + const { betAmount, mineCount } = await request.json(); + const userId = Number(session.user.id); + + if (!betAmount || !mineCount || mineCount < 3 || mineCount > 24) { + return json({ error: 'Invalid bet amount or mine count' }, { status: 400 }); + } + + if (betAmount > 1000000) { + return json({ error: 'Bet amount too large' }, { status: 400 }); + } + + const result = await db.transaction(async (tx) => { + const [userData] = await tx + .select({ baseCurrencyBalance: user.baseCurrencyBalance }) + .from(user) + .where(eq(user.id, userId)) + .for('update') + .limit(1); + + const currentBalance = Number(userData.baseCurrencyBalance); + const roundedAmount = Math.round(betAmount * 100000000) / 100000000; + const roundedBalance = Math.round(currentBalance * 100000000) / 100000000; + + if (roundedAmount > roundedBalance) { + throw new Error(`Insufficient funds. You need *${roundedAmount.toFixed(2)} but only have *${roundedBalance.toFixed(2)}`); + } + + // Generate mine positions + const positions = new Set(); + while (positions.size < mineCount) { + positions.add(Math.floor(Math.random() * 25)); + } + const safePositions = []; + for (let i = 0; i < 25; i++) { + if (!positions.has(i)) safePositions.push(i); + } + console.log(positions) + console.log('Safe positions:', safePositions); + + + // transaction token for authentication + const randomBytes = new Uint8Array(8); + crypto.getRandomValues(randomBytes); + const sessionToken = Array.from(randomBytes) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); + + const now = Date.now(); + + // Create session + activeGames.set(sessionToken, { + sessionToken, + betAmount: roundedAmount, + mineCount, + minePositions: Array.from(positions), + revealedTiles: [], + startTime: now, + lastActivity: now, + currentMultiplier: 1, + status: 'active', + userId + }); + + // Hold bet amount on server to prevent the user from like sending it to another account and farming money without a risk + await tx + .update(user) + .set({ + baseCurrencyBalance: (roundedBalance - roundedAmount).toFixed(8), + updatedAt: new Date() + }) + .where(eq(user.id, userId)); + + return { sessionToken }; + }); + + return json(result); + } catch (e) { + console.error('Mines start error:', e); + const errorMessage = e instanceof Error ? e.message : 'Internal server error'; + return json({ error: errorMessage }, { status: 400 }); + } +}; \ No newline at end of file diff --git a/website/src/routes/gambling/+page.svelte b/website/src/routes/gambling/+page.svelte index 4df7bde..27b7d0c 100644 --- a/website/src/routes/gambling/+page.svelte +++ b/website/src/routes/gambling/+page.svelte @@ -1,6 +1,7 @@ diff --git a/website/src/lib/components/self/games/Dice.svelte b/website/src/lib/components/self/games/Dice.svelte new file mode 100644 index 0000000..bf9d5cf --- /dev/null +++ b/website/src/lib/components/self/games/Dice.svelte @@ -0,0 +1,480 @@ + + + + + Dice + Choose a number and roll the dice to win 3x your bet! + + +
+
+
+

Balance

+

{formatValue(balance)}

+
+ +
+
+
+ {#each Array(6) as _, i} +
+
+ {#each Array(getDotsForFace(i + 1)) as _} +
+ {/each} +
+
+ {/each} +
+
+
+ +
+ {#if lastResult && !isRolling} +
+ {#if lastResult.won} +

WIN

+

+ Won {formatValue(lastResult.payout)} on {lastResult.result} +

+ {:else} +

LOSS

+

+ Lost {formatValue(lastResult.amountWagered)} on {lastResult.result} +

+ {/if} +
+ {/if} +
+
+ +
+
+
Choose Number
+
+ {#each Array(6) as _, i} + + {/each} +
+
+ +
+ + +

+ Max bet: {MAX_BET_AMOUNT.toLocaleString()} +

+
+ +
+
+ + + + +
+
+ + +
+
+
+
+ + diff --git a/website/src/lib/components/self/games/Mines.svelte b/website/src/lib/components/self/games/Mines.svelte index affd016..ab685da 100644 --- a/website/src/lib/components/self/games/Mines.svelte +++ b/website/src/lib/components/self/games/Mines.svelte @@ -131,6 +131,7 @@ } if (autoCashoutTimer >= AUTO_CASHOUT_TIME) { isAutoCashout = true; + clearInterval(autoCashoutInterval); cashOut(); } }, 100); @@ -227,6 +228,12 @@ hasRevealedTile = false; isAutoCashout = false; resetAutoCashoutTimer(); + + // Prevents the Tiles getting revealed when you Abort your bet. + if (!result.isAbort) { + revealedTiles = [...Array(TOTAL_TILES).keys()]; + minePositions = result.minePositions; + } } catch (error) { console.error('Cashout error:', error); toast.error('Failed to cash out', { @@ -276,8 +283,21 @@ } } - onMount(() => { + // Dynmaically fetch the correct balance. + onMount(async () => { volumeSettings.load(); + + try { + const response = await fetch('/api/portfolio/summary'); + if (!response.ok) { + throw new Error('Failed to fetch portfolio summary'); + } + const data = await response.json(); + balance = data.baseCurrencyBalance; + onBalanceUpdate?.(data.baseCurrencyBalance); + } catch (error) { + console.error('Failed to fetch balance:', error); + } }); onDestroy(() => { @@ -359,7 +379,7 @@ +
{mineCount}
+ +

Mines

+ + +
@@ -611,4 +638,4 @@ height: 32px; object-fit: contain; } - + \ No newline at end of file diff --git a/website/src/lib/components/self/games/Slots.svelte b/website/src/lib/components/self/games/Slots.svelte index 6ed0c6c..d935979 100644 --- a/website/src/lib/components/self/games/Slots.svelte +++ b/website/src/lib/components/self/games/Slots.svelte @@ -211,8 +211,21 @@ } }); - onMount(() => { + // Dynmaically fetch the correct balance. + onMount(async () => { volumeSettings.load(); + + try { + const response = await fetch('/api/portfolio/summary'); + if (!response.ok) { + throw new Error('Failed to fetch portfolio summary'); + } + const data = await response.json(); + balance = data.baseCurrencyBalance; + onBalanceUpdate?.(data.baseCurrencyBalance); + } catch (error) { + console.error('Failed to fetch balance:', error); + } }); diff --git a/website/src/lib/server/games/mines.ts b/website/src/lib/server/games/mines.ts index 2c95f24..766e10d 100644 --- a/website/src/lib/server/games/mines.ts +++ b/website/src/lib/server/games/mines.ts @@ -19,17 +19,13 @@ interface MinesSession { export const activeGames = new Map(); -// Clean up old games every minute. +// Clean up old games every minute. (5 Minute system) setInterval(async () => { const now = Date.now(); for (const [token, game] of activeGames.entries()) { - // Delete games older than 5 minutes that are still there for some reason. if (now - game.lastActivity > 5 * 60 * 1000) { - // If no tiles were revealed, refund the bet if (game.revealedTiles.length === 0) { - try { - console.log(`Processing refund for inactive Mines game ${token} (User: ${game.userId}, Bet: ${game.betAmount})`); - + try { const [userData] = await db .select({ baseCurrencyBalance: user.baseCurrencyBalance }) .from(user) @@ -48,29 +44,74 @@ setInterval(async () => { }) .where(eq(user.id, game.userId)); - console.log(`Successfully refunded ${game.betAmount} to user ${game.userId}. New balance: ${newBalance}`); } catch (error) { console.error(`Failed to refund inactive game ${token}:`, error); } - } else { - console.log(`Cleaning up inactive game ${token} (User: ${game.userId}) - No refund needed as tiles were revealed`); } activeGames.delete(token); } } }, 60000); +setInterval(async () => { + const now = Date.now(); + for (const [token, game] of activeGames.entries()) { + if (game.status === 'active' && game.revealedTiles.length > 0 && now - game.lastActivity > 20000) { + try { + const [userData] = await db + .select({ baseCurrencyBalance: user.baseCurrencyBalance }) + .from(user) + .where(eq(user.id, game.userId)) + .for('update') + .limit(1); + + const currentBalance = Number(userData.baseCurrencyBalance); + const payout = game.betAmount * game.currentMultiplier; + const roundedPayout = Math.round(payout * 100000000) / 100000000; + const newBalance = Math.round((currentBalance + roundedPayout) * 100000000) / 100000000; + + await db + .update(user) + .set({ + baseCurrencyBalance: newBalance.toFixed(8), + updatedAt: new Date() + }) + .where(eq(user.id, game.userId)); + + activeGames.delete(token); + } catch (error) { + console.error(`Failed to auto cashout game ${token}:`, error); + } + } + } +}, 15000); + // Rig the game... -const getMaxPayout = (bet: number, picks: number): number => { - const absoluteCap = 5_000_000; // never pay above this. Yeah, its rigged. Live with that :) - const baseCap = 1.4; // 1.4x min multiplier, increase to, well, increase payouts - const growthRate = 0.45; // cap curve sensitivity - - // Cap increases with number of successful reveals - const effectiveMultiplierCap = baseCap + Math.pow(picks, growthRate); - const payoutCap = bet * effectiveMultiplierCap; - - return Math.min(payoutCap, absoluteCap); +const getMaxPayout = (bet: number, picks: number, mines: number): number => { + const MAX_PAYOUT = 2_000_000; // Maximum payout cap of 2 million to not make linker too rich + const HIGH_BET_THRESHOLD = 50_000; + + const mineFactor = 1 + (mines / 25); + const baseMultiplier = (1.4 + Math.pow(picks, 0.45)) * mineFactor; + + // For high bets, we stop linker from getting richer ¯\_(ツ)_/¯ + if (bet > HIGH_BET_THRESHOLD) { + const betRatio = Math.pow(Math.min(1, (bet - HIGH_BET_THRESHOLD) / (MAX_PAYOUT - HIGH_BET_THRESHOLD)), 1); + + // Direct cap on multiplier for high bets + const maxAllowedMultiplier = 1.05 + (picks * 0.1); + const highBetMultiplier = Math.min(baseMultiplier, maxAllowedMultiplier) * (1 - (bet / MAX_PAYOUT) * 0.9); + const betSizeFactor = Math.max(0.1, 1 - (bet / MAX_PAYOUT) * 0.9); + const minMultiplier = (1.1 + (picks * 0.15 * betSizeFactor)) * mineFactor; + + const reducedMultiplier = highBetMultiplier - ((highBetMultiplier - minMultiplier) * betRatio); + const payout = Math.min(bet * reducedMultiplier, MAX_PAYOUT); + + return payout; + } + + const payout = Math.min(bet * baseMultiplier, MAX_PAYOUT); + return payout; }; @@ -78,6 +119,7 @@ export function calculateMultiplier(picks: number, mines: number, betAmount: num const TOTAL_TILES = 25; const HOUSE_EDGE = 0.05; + // Calculate probability of winning based on picks and mines let probability = 1; for (let i = 0; i < picks; i++) { probability *= (TOTAL_TILES - mines - i) / (TOTAL_TILES - i); @@ -85,14 +127,15 @@ export function calculateMultiplier(picks: number, mines: number, betAmount: num if (probability <= 0) return 1.0; + // Calculate fair multiplier based on probability and house edge const fairMultiplier = (1 / probability) * (1 - HOUSE_EDGE); + const rawPayout = fairMultiplier * betAmount; - - const maxPayout = getMaxPayout(betAmount, picks); + const maxPayout = getMaxPayout(betAmount, picks, mines); const cappedPayout = Math.min(rawPayout, maxPayout); const effectiveMultiplier = cappedPayout / betAmount; - return Math.max(1.0, effectiveMultiplier); + return Math.max(1.0, Number(effectiveMultiplier.toFixed(2))); } diff --git a/website/src/routes/+page.svelte b/website/src/routes/+page.svelte index e51493d..0fee172 100644 --- a/website/src/routes/+page.svelte +++ b/website/src/routes/+page.svelte @@ -95,11 +95,7 @@ class="text-primary underline hover:cursor-pointer" onclick={() => (shouldSignIn = !shouldSignIn)}>sign in - or{' '} - to play. + to play. {/if}

diff --git a/website/src/routes/api/gambling/dice/+server.ts b/website/src/routes/api/gambling/dice/+server.ts new file mode 100644 index 0000000..d023cf6 --- /dev/null +++ b/website/src/routes/api/gambling/dice/+server.ts @@ -0,0 +1,87 @@ +import { auth } from '$lib/auth'; +import { error, json } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { user } from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; +import { randomBytes } from 'crypto'; +import type { RequestHandler } from './$types'; + +interface DiceRequest { + selectedNumber: number; + amount: number; +} + +export const POST: RequestHandler = async ({ request }) => { + const session = await auth.api.getSession({ + headers: request.headers + }); + + if (!session?.user) { + throw error(401, 'Not authenticated'); + } + + try { + const { selectedNumber, amount }: DiceRequest = await request.json(); + + if (!selectedNumber || selectedNumber < 1 || selectedNumber > 6 || !Number.isInteger(selectedNumber)) { + return json({ error: 'Invalid number selection' }, { status: 400 }); + } + + if (!amount || amount <= 0 || !Number.isFinite(amount)) { + return json({ error: 'Invalid bet amount' }, { status: 400 }); + } + + if (amount > 1000000) { + return json({ error: 'Bet amount too large' }, { status: 400 }); + } + + const userId = Number(session.user.id); + + const result = await db.transaction(async (tx) => { + const [userData] = await tx + .select({ baseCurrencyBalance: user.baseCurrencyBalance }) + .from(user) + .where(eq(user.id, userId)) + .for('update') + .limit(1); + + const currentBalance = Number(userData.baseCurrencyBalance); + + const roundedAmount = Math.round(amount * 100000000) / 100000000; + const roundedBalance = Math.round(currentBalance * 100000000) / 100000000; + + if (roundedAmount > roundedBalance) { + throw new Error(`Insufficient funds. You need *${roundedAmount.toFixed(2)} but only have *${roundedBalance.toFixed(2)}`); + } + + const gameResult = Math.floor(randomBytes(1)[0] / 42.67) + 1; // This gives us a number between 1-6 + const won = gameResult === selectedNumber; + + const multiplier = 3; + const payout = won ? roundedAmount * multiplier : 0; + const newBalance = roundedBalance - roundedAmount + payout; + + await tx + .update(user) + .set({ + baseCurrencyBalance: newBalance.toFixed(8), + updatedAt: new Date() + }) + .where(eq(user.id, userId)); + + return { + won, + result: gameResult, + newBalance, + payout, + amountWagered: roundedAmount + }; + }); + + return json(result); + } catch (e) { + console.error('Dice API error:', e); + const errorMessage = e instanceof Error ? e.message : 'Internal server error'; + return json({ error: errorMessage }, { status: 400 }); + } +}; diff --git a/website/src/routes/api/gambling/mines/cashout/+server.ts b/website/src/routes/api/gambling/mines/cashout/+server.ts index a91bb68..773e9ad 100644 --- a/website/src/routes/api/gambling/mines/cashout/+server.ts +++ b/website/src/routes/api/gambling/mines/cashout/+server.ts @@ -47,6 +47,7 @@ export const POST: RequestHandler = async ({ request }) => { newBalance = Math.round((currentBalance + roundedPayout) * 100000000) / 100000000; } + await tx .update(user) .set({ @@ -55,13 +56,15 @@ export const POST: RequestHandler = async ({ request }) => { }) .where(eq(user.id, userId)); + activeGames.delete(sessionToken); return { newBalance, payout, amountWagered: game.betAmount, - isAbort: game.revealedTiles.length === 0 + isAbort: game.revealedTiles.length === 0, + minePositions: game.minePositions }; }); diff --git a/website/src/routes/api/gambling/mines/reveal/+server.ts b/website/src/routes/api/gambling/mines/reveal/+server.ts index 75f95c7..6a711ec 100644 --- a/website/src/routes/api/gambling/mines/reveal/+server.ts +++ b/website/src/routes/api/gambling/mines/reveal/+server.ts @@ -27,35 +27,46 @@ export const POST: RequestHandler = async ({ request }) => { return json({ error: 'Tile already revealed' }, { status: 400 }); } - // Update last activity time game.lastActivity = Date.now(); - // Check if hit mine if (game.minePositions.includes(tileIndex)) { game.status = 'lost'; const minePositions = game.minePositions; - // Fetch user balance to return after loss const userId = Number(session.user.id); const [userData] = await db .select({ baseCurrencyBalance: user.baseCurrencyBalance }) .from(user) .where(eq(user.id, userId)) + .for('update') .limit(1); + + const currentBalance = Number(userData.baseCurrencyBalance); + + + await db + .update(user) + .set({ + baseCurrencyBalance: currentBalance.toFixed(8), + updatedAt: new Date() + }) + .where(eq(user.id, userId)); activeGames.delete(sessionToken); + return json({ hitMine: true, minePositions, - newBalance: Number(userData.baseCurrencyBalance), - status: 'lost' + newBalance: currentBalance, + status: 'lost', + amountWagered: game.betAmount }); } - // Safe tile + // Safe tile (Yipeee) game.revealedTiles.push(tileIndex); game.currentMultiplier = calculateMultiplier( game.revealedTiles.length, @@ -63,9 +74,38 @@ export const POST: RequestHandler = async ({ request }) => { game.betAmount ); - // Check if all safe tiles are revealed. Crazy when you get this :) if (game.revealedTiles.length === 25 - game.mineCount) { game.status = 'won'; + const userId = Number(session.user.id); + const [userData] = await db + .select({ baseCurrencyBalance: user.baseCurrencyBalance }) + .from(user) + .where(eq(user.id, userId)) + .for('update') + .limit(1); + + const currentBalance = Number(userData.baseCurrencyBalance); + const payout = game.betAmount * game.currentMultiplier; + const roundedPayout = Math.round(payout * 100000000) / 100000000; + const newBalance = Math.round((currentBalance + roundedPayout) * 100000000) / 100000000; + + await db + .update(user) + .set({ + baseCurrencyBalance: newBalance.toFixed(8), + updatedAt: new Date() + }) + .where(eq(user.id, userId)); + + activeGames.delete(sessionToken); + + return json({ + hitMine: false, + currentMultiplier: game.currentMultiplier, + status: 'won', + newBalance, + payout + }); } return json({ diff --git a/website/src/routes/api/gambling/mines/start/+server.ts b/website/src/routes/api/gambling/mines/start/+server.ts index 34e25d9..cff9ab4 100644 --- a/website/src/routes/api/gambling/mines/start/+server.ts +++ b/website/src/routes/api/gambling/mines/start/+server.ts @@ -43,6 +43,7 @@ export const POST: RequestHandler = async ({ request }) => { throw new Error(`Insufficient funds. You need *${roundedAmount.toFixed(2)} but only have *${roundedBalance.toFixed(2)}`); } + // Generate mine positions const positions = new Set(); while (positions.size < mineCount) { @@ -52,11 +53,8 @@ export const POST: RequestHandler = async ({ request }) => { for (let i = 0; i < 25; i++) { if (!positions.has(i)) safePositions.push(i); } - console.log(positions) - console.log('Safe positions:', safePositions); - - // transaction token for authentication + // transaction token for authentication stuff const randomBytes = new Uint8Array(8); crypto.getRandomValues(randomBytes); const sessionToken = Array.from(randomBytes) @@ -64,6 +62,7 @@ export const POST: RequestHandler = async ({ request }) => { .join(''); const now = Date.now(); + const newBalance = roundedBalance - roundedAmount; // Create session activeGames.set(sessionToken, { @@ -79,16 +78,20 @@ export const POST: RequestHandler = async ({ request }) => { userId }); - // Hold bet amount on server to prevent the user from like sending it to another account and farming money without a risk + // Update user balance await tx .update(user) .set({ - baseCurrencyBalance: (roundedBalance - roundedAmount).toFixed(8), + baseCurrencyBalance: newBalance.toFixed(8), updatedAt: new Date() }) .where(eq(user.id, userId)); - return { sessionToken }; + + return { + sessionToken, + newBalance + }; }); return json(result); diff --git a/website/src/routes/gambling/+page.svelte b/website/src/routes/gambling/+page.svelte index 27b7d0c..996aeb4 100644 --- a/website/src/routes/gambling/+page.svelte +++ b/website/src/routes/gambling/+page.svelte @@ -9,6 +9,7 @@ import SignInConfirmDialog from '$lib/components/self/SignInConfirmDialog.svelte'; import { Button } from '$lib/components/ui/button'; import SEO from '$lib/components/self/SEO.svelte'; + import Dice from '$lib/components/self/games/Dice.svelte' let shouldSignIn = $state(false); let balance = $state(0); @@ -78,6 +79,12 @@ > Mines +
@@ -87,6 +94,8 @@ {:else if activeGame === 'mines'} + {:else if activeGame === 'dice'} + {/if} {/if} diff --git a/website/static/sound/dice.mp3 b/website/static/sound/dice.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..e73d79d6d3925eb2562926cd4644a3892a4883a5 GIT binary patch literal 44544 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 Date: Mon, 23 Jun 2025 20:21:56 +0300 Subject: [PATCH 04/18] invalidate cache to help with CF cache --- website/src/hooks.server.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/website/src/hooks.server.ts b/website/src/hooks.server.ts index abd3d72..26a51b1 100644 --- a/website/src/hooks.server.ts +++ b/website/src/hooks.server.ts @@ -172,6 +172,13 @@ export const handle: Handle = async ({ event, resolve }) => { event.locals.userSession = userData; + if (event.url.pathname.startsWith('/api/')) { + const response = await svelteKitHandler({ event, resolve, auth }); + response.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, private'); + + return response; + } + return svelteKitHandler({ event, resolve, auth }); }; From 38942dab9a55d9d161075d9205c94ce8081e0d0d Mon Sep 17 00:00:00 2001 From: Face <69168154+face-hh@users.noreply.github.com> Date: Mon, 23 Jun 2025 22:37:39 +0300 Subject: [PATCH 05/18] fix: indexes and multiple instances --- docker-compose.yml | 80 +- website/drizzle/0003_complete_runaways.sql | 19 + website/drizzle/meta/0003_snapshot.json | 2027 ++++++++++++++++++++ website/drizzle/meta/_journal.json | 7 + website/src/lib/server/db/schema.ts | 28 + 5 files changed, 2129 insertions(+), 32 deletions(-) create mode 100644 website/drizzle/0003_complete_runaways.sql create mode 100644 website/drizzle/meta/0003_snapshot.json diff --git a/docker-compose.yml b/docker-compose.yml index a6601c2..bfb0282 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,54 +1,70 @@ -version: '3.8' - services: - app: + # Multiple app instances + app-1: build: context: . target: production-main dockerfile: Dockerfile ports: - - "3002:3000" + - "3003:3000" # Expose to host for nginx env_file: - website/.env + environment: + - INSTANCE_NAME=app-1 depends_on: - websocket - - redis - - postgres restart: unless-stopped + networks: + - shared_backend + app-2: + build: + context: . + target: production-main + dockerfile: Dockerfile + ports: + - "3004:3000" # Different port for second instance + env_file: + - website/.env + environment: + - INSTANCE_NAME=app-2 + depends_on: + - websocket + restart: unless-stopped + networks: + - shared_backend + + app-3: + build: + context: . + target: production-main + dockerfile: Dockerfile + ports: + - "3005:3000" # Different port for third instance + env_file: + - website/.env + environment: + - INSTANCE_NAME=app-3 + depends_on: + - websocket + restart: unless-stopped + networks: + - shared_backend + + # WebSocket service (single instance is usually sufficient) websocket: build: context: . target: production-websocket dockerfile: Dockerfile ports: - - "8081:8080" + - "8082:8080" env_file: - website/.env - depends_on: - - redis restart: unless-stopped + networks: + - shared_backend - redis: - image: redis:8-alpine - volumes: - - rugplay_redisdata:/data - command: "redis-server --save 60 1" - restart: unless-stopped - - postgres: - image: pgvector/pgvector:pg16 - container_name: rugplay-postgres - environment: - POSTGRES_USER: ${POSTGRES_USER} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - POSTGRES_DB: ${POSTGRES_DB:-rugplay} - ports: - - "5432:5432" - volumes: - - rugplay_pgdata:/var/lib/postgresql/data - restart: unless-stopped - -volumes: - rugplay_pgdata: - rugplay_redisdata: +networks: + shared_backend: + external: true \ No newline at end of file diff --git a/website/drizzle/0003_complete_runaways.sql b/website/drizzle/0003_complete_runaways.sql new file mode 100644 index 0000000..a7b2e52 --- /dev/null +++ b/website/drizzle/0003_complete_runaways.sql @@ -0,0 +1,19 @@ +CREATE INDEX IF NOT EXISTS "coin_symbol_idx" ON "coin" USING btree ("symbol");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "coin_creator_id_idx" ON "coin" USING btree ("creator_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "coin_is_listed_idx" ON "coin" USING btree ("is_listed");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "coin_market_cap_idx" ON "coin" USING btree ("market_cap");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "coin_current_price_idx" ON "coin" USING btree ("current_price");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "coin_change24h_idx" ON "coin" USING btree ("change_24h");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "coin_volume24h_idx" ON "coin" USING btree ("volume_24h");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "coin_created_at_idx" ON "coin" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "transaction_user_id_idx" ON "transaction" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "transaction_coin_id_idx" ON "transaction" USING btree ("coin_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "transaction_type_idx" ON "transaction" USING btree ("type");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "transaction_timestamp_idx" ON "transaction" USING btree ("timestamp");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "transaction_user_coin_idx" ON "transaction" USING btree ("user_id","coin_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "transaction_coin_type_idx" ON "transaction" USING btree ("coin_id","type");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "user_username_idx" ON "user" USING btree ("username");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "user_is_banned_idx" ON "user" USING btree ("is_banned");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "user_is_admin_idx" ON "user" USING btree ("is_admin");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "user_created_at_idx" ON "user" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "user_updated_at_idx" ON "user" USING btree ("updated_at"); \ No newline at end of file diff --git a/website/drizzle/meta/0003_snapshot.json b/website/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..7abf363 --- /dev/null +++ b/website/drizzle/meta/0003_snapshot.json @@ -0,0 +1,2027 @@ +{ + "id": "496c23a1-1fd7-4116-b72d-5b56db3e7059", + "prevId": "223d9abc-f0d3-4c71-9cda-8a069fc13205", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.account_deletion_request": { + "name": "account_deletion_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "scheduled_deletion_at": { + "name": "scheduled_deletion_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_processed": { + "name": "is_processed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "account_deletion_request_user_id_idx": { + "name": "account_deletion_request_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_deletion_request_scheduled_deletion_idx": { + "name": "account_deletion_request_scheduled_deletion_idx", + "columns": [ + { + "expression": "scheduled_deletion_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_deletion_request_open_idx": { + "name": "account_deletion_request_open_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "is_processed = false", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_deletion_request_user_id_user_id_fk": { + "name": "account_deletion_request_user_id_user_id_fk", + "tableFrom": "account_deletion_request", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "account_deletion_request_user_id_unique": { + "name": "account_deletion_request_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + } + }, + "public.coin": { + "name": "coin", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "symbol": { + "name": "symbol", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "initial_supply": { + "name": "initial_supply", + "type": "numeric(30, 8)", + "primaryKey": false, + "notNull": true + }, + "circulating_supply": { + "name": "circulating_supply", + "type": "numeric(30, 8)", + "primaryKey": false, + "notNull": true + }, + "current_price": { + "name": "current_price", + "type": "numeric(20, 8)", + "primaryKey": false, + "notNull": true + }, + "market_cap": { + "name": "market_cap", + "type": "numeric(30, 2)", + "primaryKey": false, + "notNull": true + }, + "volume_24h": { + "name": "volume_24h", + "type": "numeric(30, 2)", + "primaryKey": false, + "notNull": false, + "default": "'0.00'" + }, + "change_24h": { + "name": "change_24h", + "type": "numeric(30, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.0000'" + }, + "pool_coin_amount": { + "name": "pool_coin_amount", + "type": "numeric(30, 8)", + "primaryKey": false, + "notNull": true, + "default": "'0.00000000'" + }, + "pool_base_currency_amount": { + "name": "pool_base_currency_amount", + "type": "numeric(30, 8)", + "primaryKey": false, + "notNull": true, + "default": "'0.00000000'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_listed": { + "name": "is_listed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "coin_symbol_idx": { + "name": "coin_symbol_idx", + "columns": [ + { + "expression": "symbol", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "coin_creator_id_idx": { + "name": "coin_creator_id_idx", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "coin_is_listed_idx": { + "name": "coin_is_listed_idx", + "columns": [ + { + "expression": "is_listed", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "coin_market_cap_idx": { + "name": "coin_market_cap_idx", + "columns": [ + { + "expression": "market_cap", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "coin_current_price_idx": { + "name": "coin_current_price_idx", + "columns": [ + { + "expression": "current_price", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "coin_change24h_idx": { + "name": "coin_change24h_idx", + "columns": [ + { + "expression": "change_24h", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "coin_volume24h_idx": { + "name": "coin_volume24h_idx", + "columns": [ + { + "expression": "volume_24h", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "coin_created_at_idx": { + "name": "coin_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "coin_creator_id_user_id_fk": { + "name": "coin_creator_id_user_id_fk", + "tableFrom": "coin", + "tableTo": "user", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "coin_symbol_unique": { + "name": "coin_symbol_unique", + "nullsNotDistinct": false, + "columns": [ + "symbol" + ] + } + } + }, + "public.comment": { + "name": "comment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "coin_id": { + "name": "coin_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "likes_count": { + "name": "likes_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_deleted": { + "name": "is_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "comment_user_id_idx": { + "name": "comment_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "comment_coin_id_idx": { + "name": "comment_coin_id_idx", + "columns": [ + { + "expression": "coin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "comment_user_id_user_id_fk": { + "name": "comment_user_id_user_id_fk", + "tableFrom": "comment", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "comment_coin_id_coin_id_fk": { + "name": "comment_coin_id_coin_id_fk", + "tableFrom": "comment", + "tableTo": "coin", + "columnsFrom": [ + "coin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.comment_like": { + "name": "comment_like", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "comment_id": { + "name": "comment_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "comment_like_user_id_user_id_fk": { + "name": "comment_like_user_id_user_id_fk", + "tableFrom": "comment_like", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comment_like_comment_id_comment_id_fk": { + "name": "comment_like_comment_id_comment_id_fk", + "tableFrom": "comment_like", + "tableTo": "comment", + "columnsFrom": [ + "comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "comment_like_user_id_comment_id_pk": { + "name": "comment_like_user_id_comment_id_pk", + "columns": [ + "user_id", + "comment_id" + ] + } + }, + "uniqueConstraints": {} + }, + "public.notification": { + "name": "notification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_read": { + "name": "is_read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_user_id_idx": { + "name": "notification_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_type_idx": { + "name": "notification_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_is_read_idx": { + "name": "notification_is_read_idx", + "columns": [ + { + "expression": "is_read", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_created_at_idx": { + "name": "notification_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_user_id_user_id_fk": { + "name": "notification_user_id_user_id_fk", + "tableFrom": "notification", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.prediction_bet": { + "name": "prediction_bet", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "question_id": { + "name": "question_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "side": { + "name": "side", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(20, 8)", + "primaryKey": false, + "notNull": true + }, + "actual_winnings": { + "name": "actual_winnings", + "type": "numeric(20, 8)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "settled_at": { + "name": "settled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "prediction_bet_user_id_idx": { + "name": "prediction_bet_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "prediction_bet_question_id_idx": { + "name": "prediction_bet_question_id_idx", + "columns": [ + { + "expression": "question_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "prediction_bet_user_question_idx": { + "name": "prediction_bet_user_question_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "question_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "prediction_bet_created_at_idx": { + "name": "prediction_bet_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "prediction_bet_user_id_user_id_fk": { + "name": "prediction_bet_user_id_user_id_fk", + "tableFrom": "prediction_bet", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "prediction_bet_question_id_prediction_question_id_fk": { + "name": "prediction_bet_question_id_prediction_question_id_fk", + "tableFrom": "prediction_bet", + "tableTo": "prediction_question", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.prediction_question": { + "name": "prediction_question", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "creator_id": { + "name": "creator_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "question": { + "name": "question", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "prediction_market_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ACTIVE'" + }, + "resolution_date": { + "name": "resolution_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ai_resolution": { + "name": "ai_resolution", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "total_yes_amount": { + "name": "total_yes_amount", + "type": "numeric(20, 8)", + "primaryKey": false, + "notNull": true, + "default": "'0.00000000'" + }, + "total_no_amount": { + "name": "total_no_amount", + "type": "numeric(20, 8)", + "primaryKey": false, + "notNull": true, + "default": "'0.00000000'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "requires_web_search": { + "name": "requires_web_search", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "validation_reason": { + "name": "validation_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "prediction_question_creator_id_idx": { + "name": "prediction_question_creator_id_idx", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "prediction_question_status_idx": { + "name": "prediction_question_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "prediction_question_resolution_date_idx": { + "name": "prediction_question_resolution_date_idx", + "columns": [ + { + "expression": "resolution_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "prediction_question_status_resolution_idx": { + "name": "prediction_question_status_resolution_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resolution_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "prediction_question_creator_id_user_id_fk": { + "name": "prediction_question_creator_id_user_id_fk", + "tableFrom": "prediction_question", + "tableTo": "user", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.price_history": { + "name": "price_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "coin_id": { + "name": "coin_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "numeric(20, 8)", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "price_history_coin_id_coin_id_fk": { + "name": "price_history_coin_id_coin_id_fk", + "tableFrom": "price_history", + "tableTo": "coin", + "columnsFrom": [ + "coin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.promo_code": { + "name": "promo_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reward_amount": { + "name": "reward_amount", + "type": "numeric(20, 8)", + "primaryKey": false, + "notNull": true + }, + "max_uses": { + "name": "max_uses", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "promo_code_created_by_user_id_fk": { + "name": "promo_code_created_by_user_id_fk", + "tableFrom": "promo_code", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "promo_code_code_unique": { + "name": "promo_code_code_unique", + "nullsNotDistinct": false, + "columns": [ + "code" + ] + } + } + }, + "public.promo_code_redemption": { + "name": "promo_code_redemption", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "promo_code_id": { + "name": "promo_code_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reward_amount": { + "name": "reward_amount", + "type": "numeric(20, 8)", + "primaryKey": false, + "notNull": true + }, + "redeemed_at": { + "name": "redeemed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "promo_code_redemption_user_id_user_id_fk": { + "name": "promo_code_redemption_user_id_user_id_fk", + "tableFrom": "promo_code_redemption", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "promo_code_redemption_promo_code_id_promo_code_id_fk": { + "name": "promo_code_redemption_promo_code_id_promo_code_id_fk", + "tableFrom": "promo_code_redemption", + "tableTo": "promo_code", + "columnsFrom": [ + "promo_code_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "promo_code_redemption_user_id_promo_code_id_unique": { + "name": "promo_code_redemption_user_id_promo_code_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "promo_code_id" + ] + } + } + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + } + }, + "public.transaction": { + "name": "transaction", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "coin_id": { + "name": "coin_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "transaction_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(30, 8)", + "primaryKey": false, + "notNull": true + }, + "price_per_coin": { + "name": "price_per_coin", + "type": "numeric(20, 8)", + "primaryKey": false, + "notNull": true + }, + "total_base_currency_amount": { + "name": "total_base_currency_amount", + "type": "numeric(30, 8)", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "recipient_user_id": { + "name": "recipient_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sender_user_id": { + "name": "sender_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "transaction_user_id_idx": { + "name": "transaction_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "transaction_coin_id_idx": { + "name": "transaction_coin_id_idx", + "columns": [ + { + "expression": "coin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "transaction_type_idx": { + "name": "transaction_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "transaction_timestamp_idx": { + "name": "transaction_timestamp_idx", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "transaction_user_coin_idx": { + "name": "transaction_user_coin_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "transaction_coin_type_idx": { + "name": "transaction_coin_type_idx", + "columns": [ + { + "expression": "coin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "transaction_user_id_user_id_fk": { + "name": "transaction_user_id_user_id_fk", + "tableFrom": "transaction", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "transaction_coin_id_coin_id_fk": { + "name": "transaction_coin_id_coin_id_fk", + "tableFrom": "transaction", + "tableTo": "coin", + "columnsFrom": [ + "coin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transaction_recipient_user_id_user_id_fk": { + "name": "transaction_recipient_user_id_user_id_fk", + "tableFrom": "transaction", + "tableTo": "user", + "columnsFrom": [ + "recipient_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "transaction_sender_user_id_user_id_fk": { + "name": "transaction_sender_user_id_user_id_fk", + "tableFrom": "transaction", + "tableTo": "user", + "columnsFrom": [ + "sender_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_banned": { + "name": "is_banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_currency_balance": { + "name": "base_currency_balance", + "type": "numeric(20, 8)", + "primaryKey": false, + "notNull": true, + "default": "'100.00000000'" + }, + "bio": { + "name": "bio", + "type": "varchar(160)", + "primaryKey": false, + "notNull": false, + "default": "'Hello am 48 year old man from somalia. Sorry for my bed england. I selled my wife for internet connection for play “conter stirk”'" + }, + "username": { + "name": "username", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "volume_master": { + "name": "volume_master", + "type": "numeric(3, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0.70'" + }, + "volume_muted": { + "name": "volume_muted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_reward_claim": { + "name": "last_reward_claim", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "total_rewards_claimed": { + "name": "total_rewards_claimed", + "type": "numeric(20, 8)", + "primaryKey": false, + "notNull": true, + "default": "'0.00000000'" + }, + "login_streak": { + "name": "login_streak", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "prestige_level": { + "name": "prestige_level", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + } + }, + "indexes": { + "user_username_idx": { + "name": "user_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_is_banned_idx": { + "name": "user_is_banned_idx", + "columns": [ + { + "expression": "is_banned", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_is_admin_idx": { + "name": "user_is_admin_idx", + "columns": [ + { + "expression": "is_admin", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_created_at_idx": { + "name": "user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_updated_at_idx": { + "name": "user_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_username_unique": { + "name": "user_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + } + }, + "public.user_portfolio": { + "name": "user_portfolio", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "coin_id": { + "name": "coin_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(30, 8)", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_portfolio_user_id_user_id_fk": { + "name": "user_portfolio_user_id_user_id_fk", + "tableFrom": "user_portfolio", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_portfolio_coin_id_coin_id_fk": { + "name": "user_portfolio_coin_id_coin_id_fk", + "tableFrom": "user_portfolio", + "tableTo": "coin", + "columnsFrom": [ + "coin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_portfolio_user_id_coin_id_pk": { + "name": "user_portfolio_user_id_coin_id_pk", + "columns": [ + "user_id", + "coin_id" + ] + } + }, + "uniqueConstraints": {} + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": { + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "HOPIUM", + "SYSTEM", + "TRANSFER", + "RUG_PULL" + ] + }, + "public.prediction_market_status": { + "name": "prediction_market_status", + "schema": "public", + "values": [ + "ACTIVE", + "RESOLVED", + "CANCELLED" + ] + }, + "public.transaction_type": { + "name": "transaction_type", + "schema": "public", + "values": [ + "BUY", + "SELL", + "TRANSFER_IN", + "TRANSFER_OUT" + ] + } + }, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/website/drizzle/meta/_journal.json b/website/drizzle/meta/_journal.json index aede010..6f60140 100644 --- a/website/drizzle/meta/_journal.json +++ b/website/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1749916220202, "tag": "0002_small_micromacro", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1750707307426, + "tag": "0003_complete_runaways", + "breakpoints": true } ] } \ No newline at end of file diff --git a/website/src/lib/server/db/schema.ts b/website/src/lib/server/db/schema.ts index b0b0015..e6b6420 100644 --- a/website/src/lib/server/db/schema.ts +++ b/website/src/lib/server/db/schema.ts @@ -33,6 +33,14 @@ export const user = pgTable("user", { }).notNull().default("0.00000000"), loginStreak: integer("login_streak").notNull().default(0), prestigeLevel: integer("prestige_level").default(0), +}, (table) => { + return { + usernameIdx: index("user_username_idx").on(table.username), + isBannedIdx: index("user_is_banned_idx").on(table.isBanned), + isAdminIdx: index("user_is_admin_idx").on(table.isAdmin), + createdAtIdx: index("user_created_at_idx").on(table.createdAt), + updatedAtIdx: index("user_updated_at_idx").on(table.updatedAt), + }; }); export const session = pgTable("session", { @@ -88,6 +96,17 @@ export const coin = pgTable("coin", { createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), isListed: boolean("is_listed").default(true).notNull(), +}, (table) => { + return { + symbolIdx: index("coin_symbol_idx").on(table.symbol), + creatorIdIdx: index("coin_creator_id_idx").on(table.creatorId), + isListedIdx: index("coin_is_listed_idx").on(table.isListed), + marketCapIdx: index("coin_market_cap_idx").on(table.marketCap), + currentPriceIdx: index("coin_current_price_idx").on(table.currentPrice), + change24hIdx: index("coin_change24h_idx").on(table.change24h), + volume24hIdx: index("coin_volume24h_idx").on(table.volume24h), + createdAtIdx: index("coin_created_at_idx").on(table.createdAt), + }; }); export const userPortfolio = pgTable("user_portfolio", { @@ -114,6 +133,15 @@ export const transaction = pgTable("transaction", { timestamp: timestamp("timestamp", { withTimezone: true }).notNull().defaultNow(), recipientUserId: integer('recipient_user_id').references(() => user.id, { onDelete: 'set null' }), senderUserId: integer('sender_user_id').references(() => user.id, { onDelete: 'set null' }), +}, (table) => { + return { + userIdIdx: index("transaction_user_id_idx").on(table.userId), + coinIdIdx: index("transaction_coin_id_idx").on(table.coinId), + typeIdx: index("transaction_type_idx").on(table.type), + timestampIdx: index("transaction_timestamp_idx").on(table.timestamp), + userCoinIdx: index("transaction_user_coin_idx").on(table.userId, table.coinId), + coinTypeIdx: index("transaction_coin_type_idx").on(table.coinId, table.type), + }; }); export const priceHistory = pgTable("price_history", { From 9159e476f10a2eac2df16e415673f663beef4193 Mon Sep 17 00:00:00 2001 From: Face <69168154+face-hh@users.noreply.github.com> Date: Tue, 24 Jun 2025 12:30:23 +0300 Subject: [PATCH 06/18] fix UI w/ wrong tiles + increment input --- .../lib/components/self/games/Mines.svelte | 121 ++++++++---------- 1 file changed, 53 insertions(+), 68 deletions(-) diff --git a/website/src/lib/components/self/games/Mines.svelte b/website/src/lib/components/self/games/Mines.svelte index affd016..0eec998 100644 --- a/website/src/lib/components/self/games/Mines.svelte +++ b/website/src/lib/components/self/games/Mines.svelte @@ -28,6 +28,7 @@ import { volumeSettings } from '$lib/stores/volume-settings'; import { onMount, onDestroy } from 'svelte'; import { ModeWatcher } from 'mode-watcher'; + import { Info } from 'lucide-svelte'; interface MinesResult { won: boolean; @@ -162,7 +163,7 @@ if (result.hitMine) { playSound('lose'); - revealedTiles = [...Array(TOTAL_TILES).keys()]; + revealedTiles = [...revealedTiles, index]; minePositions = result.minePositions; isPlaying = false; resetAutoCashoutTimer(); @@ -227,6 +228,7 @@ hasRevealedTile = false; isAutoCashout = false; resetAutoCashoutTimer(); + minePositions = []; } catch (error) { console.error('Cashout error:', error); toast.error('Failed to cash out', { @@ -268,6 +270,7 @@ clickedSafeTiles = []; currentMultiplier = 1; sessionToken = result.sessionToken; + minePositions = []; } catch (error) { console.error('Start game error:', error); toast.error('Failed to start game', { @@ -290,56 +293,7 @@ Mines Navigate through the minefield and cash out before hitting a mine! - - - Info - - - - Mines Game Information - -
-
-

Winning Probabilities

-

- The probability of winning increases with each safe tile you reveal. Here's how it works: -

-
-
-

For each tile you reveal:

-
    -
  • More mines = Higher risk = Higher potential payout
  • -
  • Fewer mines = Lower risk = Lower potential payout
  • -
  • Each safe tile increases your multiplier
  • -
-
-
-

Example:

-

With 3 mines on the board:

-
    -
  • First tile: 88% chance of being safe
  • -
  • Second tile: 87% chance of being safe
  • -
  • Third tile: 86% chance of being safe
  • -
-
-
-
- -
-

Game Rules & Information

-
-

- If you leave the page while playing: -

-
    -
  • If you haven't revealed any tiles, your game session will be ended after 5 minutes of inactivity
  • -
  • If you have revealed tiles, the auto cashout will process your gains within 15 seconds
  • -
-
-
-
-
-
+
@@ -390,23 +344,55 @@
-
- { + const target = e.target as HTMLInputElement | null; + const val = Math.max( + MIN_MINES, + Math.min(24, parseInt(target?.value ?? '') || MIN_MINES) + ); + mineCount = val; + }} disabled={isPlaying} - > - - {mineCount} Mines - - - {#each Array(22) as _, i} - {i + MIN_MINES} Mines - {/each} - - + class="w-12 text-center [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + /> +
+

+ You will get + + {(calculateRawMultiplier( + isPlaying ? revealedTiles.length + 1 : 1, + mineCount + )).toFixed(2)}x + + per tile, probability of winning: + + {calculateProbability( + isPlaying ? 1 : 1, + mineCount + )}% + +

@@ -451,8 +437,7 @@ size="sm" variant="outline" onclick={() => setBetAmount(Math.floor(Math.min(balance, MAX_BET_AMOUNT)))} - disabled={isPlaying}>Max + disabled={isPlaying}>Max From df10f0c7baf9fcb15649843304052cb2989503bc Mon Sep 17 00:00:00 2001 From: Face <69168154+face-hh@users.noreply.github.com> Date: Tue, 24 Jun 2025 12:34:58 +0300 Subject: [PATCH 07/18] use existing function for fetching portfolio --- .../src/lib/components/self/games/Coinflip.svelte | 13 ++++++------- website/src/lib/components/self/games/Dice.svelte | 13 ++++++------- website/src/lib/components/self/games/Mines.svelte | 13 ++++++------- website/src/lib/components/self/games/Slots.svelte | 13 ++++++------- 4 files changed, 24 insertions(+), 28 deletions(-) diff --git a/website/src/lib/components/self/games/Coinflip.svelte b/website/src/lib/components/self/games/Coinflip.svelte index 1b77200..e4aac9a 100644 --- a/website/src/lib/components/self/games/Coinflip.svelte +++ b/website/src/lib/components/self/games/Coinflip.svelte @@ -164,6 +164,7 @@ import { formatValue, playSound, showConfetti } from '$lib/utils'; import { volumeSettings } from '$lib/stores/volume-settings'; import { onMount } from 'svelte'; + import { fetchPortfolioSummary } from '$lib/stores/portfolio-data'; interface CoinflipResult { won: boolean; @@ -316,15 +317,13 @@ onMount(async () => { volumeSettings.load(); - + try { - const response = await fetch('/api/portfolio/summary'); - if (!response.ok) { - throw new Error('Failed to fetch portfolio summary'); + const data = await fetchPortfolioSummary(); + if (data) { + balance = data.baseCurrencyBalance; + onBalanceUpdate?.(data.baseCurrencyBalance); } - const data = await response.json(); - balance = data.baseCurrencyBalance; - onBalanceUpdate?.(data.baseCurrencyBalance); } catch (error) { console.error('Failed to fetch balance:', error); } diff --git a/website/src/lib/components/self/games/Dice.svelte b/website/src/lib/components/self/games/Dice.svelte index bf9d5cf..1ae5687 100644 --- a/website/src/lib/components/self/games/Dice.svelte +++ b/website/src/lib/components/self/games/Dice.svelte @@ -13,6 +13,7 @@ import { formatValue, playSound, showConfetti, showSchoolPrideCannons } from '$lib/utils'; import { volumeSettings } from '$lib/stores/volume-settings'; import { onMount } from 'svelte'; + import { fetchPortfolioSummary } from '$lib/stores/portfolio-data'; interface DiceResult { won: boolean; @@ -226,15 +227,13 @@ // Dynmaically fetch the correct balance. onMount(async () => { volumeSettings.load(); - + try { - const response = await fetch('/api/portfolio/summary'); - if (!response.ok) { - throw new Error('Failed to fetch portfolio summary'); + const data = await fetchPortfolioSummary(); + if (data) { + balance = data.baseCurrencyBalance; + onBalanceUpdate?.(data.baseCurrencyBalance); } - const data = await response.json(); - balance = data.baseCurrencyBalance; - onBalanceUpdate?.(data.baseCurrencyBalance); } catch (error) { console.error('Failed to fetch balance:', error); } diff --git a/website/src/lib/components/self/games/Mines.svelte b/website/src/lib/components/self/games/Mines.svelte index d607bf4..5e3b9dc 100644 --- a/website/src/lib/components/self/games/Mines.svelte +++ b/website/src/lib/components/self/games/Mines.svelte @@ -29,6 +29,7 @@ import { onMount, onDestroy } from 'svelte'; import { ModeWatcher } from 'mode-watcher'; import { Info } from 'lucide-svelte'; + import { fetchPortfolioSummary } from '$lib/stores/portfolio-data'; interface MinesResult { won: boolean; @@ -283,15 +284,13 @@ // Dynmaically fetch the correct balance. onMount(async () => { volumeSettings.load(); - + try { - const response = await fetch('/api/portfolio/summary'); - if (!response.ok) { - throw new Error('Failed to fetch portfolio summary'); + const data = await fetchPortfolioSummary(); + if (data) { + balance = data.baseCurrencyBalance; + onBalanceUpdate?.(data.baseCurrencyBalance); } - const data = await response.json(); - balance = data.baseCurrencyBalance; - onBalanceUpdate?.(data.baseCurrencyBalance); } catch (error) { console.error('Failed to fetch balance:', error); } diff --git a/website/src/lib/components/self/games/Slots.svelte b/website/src/lib/components/self/games/Slots.svelte index d935979..240339e 100644 --- a/website/src/lib/components/self/games/Slots.svelte +++ b/website/src/lib/components/self/games/Slots.svelte @@ -13,6 +13,7 @@ import { formatValue, playSound, showConfetti, showSchoolPrideCannons } from '$lib/utils'; import { volumeSettings } from '$lib/stores/volume-settings'; import { onMount } from 'svelte'; + import { fetchPortfolioSummary } from '$lib/stores/portfolio-data'; interface SlotsResult { won: boolean; @@ -214,15 +215,13 @@ // Dynmaically fetch the correct balance. onMount(async () => { volumeSettings.load(); - + try { - const response = await fetch('/api/portfolio/summary'); - if (!response.ok) { - throw new Error('Failed to fetch portfolio summary'); + const data = await fetchPortfolioSummary(); + if (data) { + balance = data.baseCurrencyBalance; + onBalanceUpdate?.(data.baseCurrencyBalance); } - const data = await response.json(); - balance = data.baseCurrencyBalance; - onBalanceUpdate?.(data.baseCurrencyBalance); } catch (error) { console.error('Failed to fetch balance:', error); } From 5533669745af8a4b253404b5c16a59c985e42a86 Mon Sep 17 00:00:00 2001 From: Face <69168154+face-hh@users.noreply.github.com> Date: Tue, 24 Jun 2025 12:40:01 +0300 Subject: [PATCH 08/18] clean dice component --- .../src/lib/components/self/games/Dice.svelte | 118 +++++------------- 1 file changed, 29 insertions(+), 89 deletions(-) diff --git a/website/src/lib/components/self/games/Dice.svelte b/website/src/lib/components/self/games/Dice.svelte index 1ae5687..ea4430c 100644 --- a/website/src/lib/components/self/games/Dice.svelte +++ b/website/src/lib/components/self/games/Dice.svelte @@ -44,10 +44,6 @@ 6: { x: 0, y: 180, z: 0 } }; - function getRandInt(from: number, to: number): number { - return Math.round(Math.random() * (to - from)) + from; - } - function getExtraSpin(spinFactor = 4) { const extraSpinsX = spinFactor * 360; const extraSpinsY = spinFactor * 360; @@ -108,9 +104,9 @@ let betAmountDisplay = $state('10'); let selectedNumber = $state(1); let isRolling = $state(false); - let diceRotation = $state({ x: 0, y: 0 }); let lastResult = $state(null); let activeSoundTimeouts = $state([]); + let diceElement: HTMLElement | null = null; let canBet = $derived( betAmount > 0 && betAmount <= balance && betAmount <= MAX_BET_AMOUNT && !isRolling @@ -154,8 +150,8 @@ activeSoundTimeouts.forEach(clearTimeout); activeSoundTimeouts = []; - const spinFactor = 20; // Increase / Decrease to make the Spin faster or slower - const animationDuration = 1500; // Duration of the Animation, keep it like thatif you haven't added your own sound in website\static\sound\dice.mp3 + const spinFactor = 20; + const animationDuration = 1500; try { const response = await fetch('/api/gambling/dice', { @@ -177,7 +173,6 @@ const resultData: DiceResult = await response.json(); playSound('dice'); - const diceElement = document.querySelector('.dice') as HTMLElement; if (diceElement) { diceElement.style.transition = 'none'; diceElement.style.transform = getDiceTransform(selectedNumber, false); @@ -212,19 +207,6 @@ } } - function getDotsForFace(face: number): number { // Could be made bigger, has no Point though Ig. - switch (face) { - case 1: return 1; - case 2: return 2; - case 3: return 3; - case 4: return 4; - case 5: return 5; - case 6: return 6; - default: return 0; - } - } - - // Dynmaically fetch the correct balance. onMount(async () => { volumeSettings.load(); @@ -255,11 +237,11 @@
-
+
{#each Array(6) as _, i}
- {#each Array(getDotsForFace(i + 1)) as _} + {#each Array(i + 1) as _}
{/each}
@@ -379,12 +361,12 @@ height: 100px; background: #fff; border: 2px solid #363131; - box-sizing: border-box; border-radius: 8px; display: flex; align-items: center; justify-content: center; backface-visibility: hidden; + box-sizing: border-box; } .face:nth-child(1) { transform: translateZ(50px); } @@ -401,8 +383,8 @@ grid-template-columns: repeat(3, 1fr); grid-template-rows: repeat(3, 1fr); padding: 15%; - box-sizing: border-box; gap: 10%; + box-sizing: border-box; } .dot { @@ -412,68 +394,26 @@ height: 100%; } - .face:nth-child(1) .dot-container { - grid-template-areas: - ". . ." - ". dot ." - ". . ."; - } - .face:nth-child(1) .dot { - grid-area: dot; - } - - .face:nth-child(2) .dot-container { - grid-template-areas: - "dot1 . ." - ". . ." - ". . dot2"; - } - .face:nth-child(2) .dot:nth-child(1) { grid-area: dot1; } - .face:nth-child(2) .dot:nth-child(2) { grid-area: dot2; } - - .face:nth-child(3) .dot-container { - grid-template-areas: - "dot1 . ." - ". dot2 ." - ". . dot3"; - } - .face:nth-child(3) .dot:nth-child(1) { grid-area: dot1; } - .face:nth-child(3) .dot:nth-child(2) { grid-area: dot2; } - .face:nth-child(3) .dot:nth-child(3) { grid-area: dot3; } - - .face:nth-child(4) .dot-container { - grid-template-areas: - "dot1 . dot2" - ". . ." - "dot3 . dot4"; - } - .face:nth-child(4) .dot:nth-child(1) { grid-area: dot1; } - .face:nth-child(4) .dot:nth-child(2) { grid-area: dot2; } - .face:nth-child(4) .dot:nth-child(3) { grid-area: dot3; } - .face:nth-child(4) .dot:nth-child(4) { grid-area: dot4; } - - .face:nth-child(5) .dot-container { - grid-template-areas: - "dot1 . dot2" - ". dot3 ." - "dot4 . dot5"; - } - .face:nth-child(5) .dot:nth-child(1) { grid-area: dot1; } - .face:nth-child(5) .dot:nth-child(2) { grid-area: dot2; } - .face:nth-child(5) .dot:nth-child(3) { grid-area: dot3; } - .face:nth-child(5) .dot:nth-child(4) { grid-area: dot4; } - .face:nth-child(5) .dot:nth-child(5) { grid-area: dot5; } - - .face:nth-child(6) .dot-container { - grid-template-areas: - "dot1 . dot2" - "dot3 . dot4" - "dot5 . dot6"; - } - .face:nth-child(6) .dot:nth-child(1) { grid-area: dot1; } - .face:nth-child(6) .dot:nth-child(2) { grid-area: dot2; } - .face:nth-child(6) .dot:nth-child(3) { grid-area: dot3; } - .face:nth-child(6) .dot:nth-child(4) { grid-area: dot4; } - .face:nth-child(6) .dot:nth-child(5) { grid-area: dot5; } - .face:nth-child(6) .dot:nth-child(6) { grid-area: dot6; } + /* Dot positions for each face */ + .face:nth-child(1) .dot { grid-area: 2 / 2; } + .face:nth-child(2) .dot:nth-child(1) { grid-area: 1 / 1; } + .face:nth-child(2) .dot:nth-child(2) { grid-area: 3 / 3; } + .face:nth-child(3) .dot:nth-child(1) { grid-area: 1 / 1; } + .face:nth-child(3) .dot:nth-child(2) { grid-area: 2 / 2; } + .face:nth-child(3) .dot:nth-child(3) { grid-area: 3 / 3; } + .face:nth-child(4) .dot:nth-child(1) { grid-area: 1 / 1; } + .face:nth-child(4) .dot:nth-child(2) { grid-area: 1 / 3; } + .face:nth-child(4) .dot:nth-child(3) { grid-area: 3 / 1; } + .face:nth-child(4) .dot:nth-child(4) { grid-area: 3 / 3; } + .face:nth-child(5) .dot:nth-child(1) { grid-area: 1 / 1; } + .face:nth-child(5) .dot:nth-child(2) { grid-area: 1 / 3; } + .face:nth-child(5) .dot:nth-child(3) { grid-area: 2 / 2; } + .face:nth-child(5) .dot:nth-child(4) { grid-area: 3 / 1; } + .face:nth-child(5) .dot:nth-child(5) { grid-area: 3 / 3; } + .face:nth-child(6) .dot:nth-child(1) { grid-area: 1 / 1; } + .face:nth-child(6) .dot:nth-child(2) { grid-area: 1 / 3; } + .face:nth-child(6) .dot:nth-child(3) { grid-area: 2 / 1; } + .face:nth-child(6) .dot:nth-child(4) { grid-area: 2 / 3; } + .face:nth-child(6) .dot:nth-child(5) { grid-area: 3 / 1; } + .face:nth-child(6) .dot:nth-child(6) { grid-area: 3 / 3; } From ad1739f7f43337182724ed79fd42c016f6646312 Mon Sep 17 00:00:00 2001 From: Face <69168154+face-hh@users.noreply.github.com> Date: Tue, 24 Jun 2025 12:46:38 +0300 Subject: [PATCH 09/18] clean mines component --- .../lib/components/self/games/Mines.svelte | 194 ++++++------------ 1 file changed, 58 insertions(+), 136 deletions(-) diff --git a/website/src/lib/components/self/games/Mines.svelte b/website/src/lib/components/self/games/Mines.svelte index 5e3b9dc..41245e5 100644 --- a/website/src/lib/components/self/games/Mines.svelte +++ b/website/src/lib/components/self/games/Mines.svelte @@ -1,7 +1,7 @@ @@ -306,7 +238,6 @@ Mines Navigate through the minefield and cash out before hitting a mine! - @@ -325,20 +256,21 @@
- - +
-
- +
+ aria-label="Decrease mines">- { + oninput={(e) => { const target = e.target as HTMLInputElement | null; const val = Math.max( MIN_MINES, @@ -385,30 +315,24 @@ + aria-label="Increase mines">+

You will get - - {(calculateRawMultiplier( - isPlaying ? revealedTiles.length + 1 : 1, - mineCount - )).toFixed(2)}x + + {calculateRawMultiplier(isPlaying ? revealedTiles.length + 1 : 1, mineCount).toFixed( + 2 + )}x per tile, probability of winning: - - {calculateProbability( - isPlaying ? 1 : 1, - mineCount - )}% + + {calculateProbability(isPlaying ? 1 : 1, mineCount)}%

- -
- -
+ disabled={isPlaying}>Max
- -
{#if !isPlaying} {:else} - {#if hasRevealedTile}
-
-
+
+
Auto Cashout in {Math.ceil(AUTO_CASHOUT_TIME - autoCashoutTimer)}s
-
+
= 7} style="width: {autoCashoutProgress}%" >
-
+
{/if}
{/if} + + {#if !(turnstileVerified || optimisticTurnstileVerified)} +
+ ) => { + turnstileToken = e.detail.token; + turnstileError = ''; + }} + on:error={(e: CustomEvent<{ code: string }>) => { + turnstileToken = ''; + turnstileError = e.detail.code || 'Captcha error'; + }} + on:expired={() => { + turnstileToken = ''; + turnstileError = 'Captcha expired'; + }} + execution="render" + appearance="always" + /> + {#if turnstileError} +

{turnstileError}

+ {/if} +
+ {/if}
diff --git a/website/src/lib/components/self/TradeModal.svelte b/website/src/lib/components/self/TradeModal.svelte index 9c323dd..8e4de0a 100644 --- a/website/src/lib/components/self/TradeModal.svelte +++ b/website/src/lib/components/self/TradeModal.svelte @@ -7,6 +7,9 @@ import { TrendingUp, TrendingDown, Loader2 } from 'lucide-svelte'; import { PORTFOLIO_SUMMARY } from '$lib/stores/portfolio-data'; import { toast } from 'svelte-sonner'; + import { Turnstile } from 'svelte-turnstile'; + import { PUBLIC_TURNSTILE_SITE_KEY } from '$env/static/public'; + import { page } from '$app/stores'; let { open = $bindable(false), @@ -24,6 +27,8 @@ let amount = $state(''); let loading = $state(false); + let turnstileToken = $state(''); + let turnstileError = $state(''); let numericAmount = $derived(parseFloat(amount) || 0); let currentPrice = $derived(coin.currentPrice || 0); @@ -39,7 +44,14 @@ let hasEnoughFunds = $derived( type === 'BUY' ? numericAmount <= userBalance : numericAmount <= userHolding ); - let canTrade = $derived(hasValidAmount && hasEnoughFunds && !loading); + const turnstileVerified = $derived(!!$page.data?.turnstileVerified); + let optimisticTurnstileVerified = $state(false); + + let showCaptcha = $derived(!(turnstileVerified || optimisticTurnstileVerified)); + + let canTrade = $derived( + hasValidAmount && hasEnoughFunds && !loading && (!showCaptcha || !!turnstileToken) + ); function calculateEstimate(amount: number, tradeType: 'BUY' | 'SELL', price: number) { if (!amount || !price || !coin) return { result: 0 }; @@ -70,6 +82,8 @@ loading = false; } + let turnstileReset = $state<(() => void) | undefined>(undefined); + async function handleTrade() { if (!canTrade) return; @@ -82,7 +96,8 @@ }, body: JSON.stringify({ type, - amount: numericAmount + amount: numericAmount, + turnstileToken }) }); @@ -101,6 +116,9 @@ onSuccess?.(); handleClose(); + + turnstileToken = ''; + optimisticTurnstileVerified = true; } catch (e) { toast.error('Trade failed', { description: (e as Error).message @@ -194,6 +212,33 @@ {type === 'BUY' ? 'Insufficient funds' : 'Insufficient coins'} {/if} + +
+ ) => { + turnstileToken = e.detail.token; + turnstileError = ''; + }} + on:error={(e: CustomEvent<{ code: string }>) => { + turnstileToken = ''; + turnstileError = e.detail.code || 'Captcha error'; + }} + on:expired={() => { + turnstileToken = ''; + turnstileError = 'Captcha expired'; + }} + execution="render" + appearance="always" + /> + {#if turnstileError} +

{turnstileError}

+ {/if} +
+ {/if}
diff --git a/website/src/lib/server/redis.ts b/website/src/lib/server/redis.ts index ba6ace6..09533ef 100644 --- a/website/src/lib/server/redis.ts +++ b/website/src/lib/server/redis.ts @@ -15,3 +15,14 @@ if (!building) { } export { client as redis }; + +const TURNSTILE_PREFIX = 'turnstile:verified:'; +const TURNSTILE_TTL = 5 * 60; // 5 minutes + +export async function setTurnstileVerifiedRedis(userId: string) { + await client.set(`${TURNSTILE_PREFIX}${userId}`, '1', { EX: TURNSTILE_TTL }); +} + +export async function isTurnstileVerifiedRedis(userId: string): Promise { + return !!(await client.get(`${TURNSTILE_PREFIX}${userId}`)); +} diff --git a/website/src/lib/server/turnstile.ts b/website/src/lib/server/turnstile.ts new file mode 100644 index 0000000..65acd8c --- /dev/null +++ b/website/src/lib/server/turnstile.ts @@ -0,0 +1,20 @@ +import { env } from '$env/dynamic/private'; + +const TURNSTILE_SECRET = env.TURNSTILE_SECRET_KEY; + +export async function verifyTurnstile(token: string, request: Request): Promise { + if (!TURNSTILE_SECRET) return false; + const ip = request.headers.get('x-forwarded-for') || request.headers.get('cf-connecting-ip') || undefined; + const body = new URLSearchParams({ + secret: TURNSTILE_SECRET, + response: token, + ...(ip ? { remoteip: ip } : {}) + }); + const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { + method: 'POST', + body, + headers: { 'content-type': 'application/x-www-form-urlencoded' } + }); + const data = await res.json(); + return !!data.success; +} diff --git a/website/src/routes/+layout.server.ts b/website/src/routes/+layout.server.ts index 4f62bd0..b9fcbf3 100644 --- a/website/src/routes/+layout.server.ts +++ b/website/src/routes/+layout.server.ts @@ -12,5 +12,6 @@ export const load: LayoutServerLoad = async (event) => { return { userSession: event.locals.userSession, url: event.url.pathname, + turnstileVerified: event.locals.turnstileVerified ?? false }; }; \ No newline at end of file diff --git a/website/src/routes/api/coin/[coinSymbol]/trade/+server.ts b/website/src/routes/api/coin/[coinSymbol]/trade/+server.ts index 821e93e..d5001f0 100644 --- a/website/src/routes/api/coin/[coinSymbol]/trade/+server.ts +++ b/website/src/routes/api/coin/[coinSymbol]/trade/+server.ts @@ -5,6 +5,8 @@ import { coin, userPortfolio, user, transaction, priceHistory } from '$lib/serve import { eq, and, gte } from 'drizzle-orm'; import { redis } from '$lib/server/redis'; import { createNotification } from '$lib/server/notification'; +import { verifyTurnstile } from '$lib/server/turnstile'; +import { setTurnstileVerifiedRedis, isTurnstileVerifiedRedis } from '$lib/server/redis'; async function calculate24hMetrics(coinId: number, currentPrice: number) { const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); @@ -53,7 +55,16 @@ export async function POST({ params, request }) { } const { coinSymbol } = params; - const { type, amount } = await request.json(); + const { type, amount, turnstileToken } = await request.json(); + + const alreadyVerified = await isTurnstileVerifiedRedis(session.user.id); + + if (!alreadyVerified) { + if (!turnstileToken || !(await verifyTurnstile(turnstileToken, request))) { + throw error(400, 'Captcha verification failed'); + } + await setTurnstileVerifiedRedis(session.user.id); + } if (!['BUY', 'SELL'].includes(type)) { throw error(400, 'Invalid transaction type'); diff --git a/website/src/routes/api/transfer/+server.ts b/website/src/routes/api/transfer/+server.ts index 726d345..f200e16 100644 --- a/website/src/routes/api/transfer/+server.ts +++ b/website/src/routes/api/transfer/+server.ts @@ -5,6 +5,8 @@ import { user, userPortfolio, coin, transaction } from '$lib/server/db/schema'; import { eq, and } from 'drizzle-orm'; import { createNotification } from '$lib/server/notification'; import { formatValue } from '$lib/utils'; +import { verifyTurnstile } from '$lib/server/turnstile'; +import { setTurnstileVerifiedRedis, isTurnstileVerifiedRedis } from '$lib/server/redis'; import type { RequestHandler } from './$types'; interface TransferRequest { @@ -22,7 +24,16 @@ export const POST: RequestHandler = async ({ request }) => { if (!session?.user) { throw error(401, 'Not authenticated'); } try { - const { recipientUsername, type, amount, coinSymbol }: TransferRequest = await request.json(); + const { recipientUsername, type, amount, coinSymbol, turnstileToken }: TransferRequest & { turnstileToken?: string } = await request.json(); + + const alreadyVerified = await isTurnstileVerifiedRedis(session.user.id); + + if (!alreadyVerified) { + if (!turnstileToken || !(await verifyTurnstile(turnstileToken, request))) { + throw error(400, 'Captcha verification failed'); + } + await setTurnstileVerifiedRedis(session.user.id); + } if (!recipientUsername || !type || !amount || typeof amount !== 'number' || !Number.isFinite(amount) || amount <= 0) { throw error(400, 'Invalid transfer parameters'); From 519236913ed6cf89fe310a95b289bc6ea30cc393 Mon Sep 17 00:00:00 2001 From: Face <69168154+face-hh@users.noreply.github.com> Date: Tue, 24 Jun 2025 19:43:10 +0300 Subject: [PATCH 17/18] Update TradeModal.svelte --- .../src/lib/components/self/TradeModal.svelte | 51 ++++++++++--------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/website/src/lib/components/self/TradeModal.svelte b/website/src/lib/components/self/TradeModal.svelte index 8e4de0a..1d8ce80 100644 --- a/website/src/lib/components/self/TradeModal.svelte +++ b/website/src/lib/components/self/TradeModal.svelte @@ -213,31 +213,32 @@ {/if} -
- ) => { - turnstileToken = e.detail.token; - turnstileError = ''; - }} - on:error={(e: CustomEvent<{ code: string }>) => { - turnstileToken = ''; - turnstileError = e.detail.code || 'Captcha error'; - }} - on:expired={() => { - turnstileToken = ''; - turnstileError = 'Captcha expired'; - }} - execution="render" - appearance="always" - /> - {#if turnstileError} -

{turnstileError}

- {/if} -
+ {#if showCaptcha} +
+ ) => { + turnstileToken = e.detail.token; + turnstileError = ''; + }} + on:error={(e: CustomEvent<{ code: string }>) => { + turnstileToken = ''; + turnstileError = e.detail.code || 'Captcha error'; + }} + on:expired={() => { + turnstileToken = ''; + turnstileError = 'Captcha expired'; + }} + execution="render" + appearance="always" + /> + {#if turnstileError} +

{turnstileError}

+ {/if} +
{/if}
From 96cb799cc7ced0549964406a25052384986ccf8d Mon Sep 17 00:00:00 2001 From: Face <69168154+face-hh@users.noreply.github.com> Date: Tue, 24 Jun 2025 20:49:27 +0300 Subject: [PATCH 18/18] revert captcha --- website/.env.example | 7 +-- website/package-lock.json | 19 +------ website/package.json | 3 +- website/src/app.d.ts | 4 +- website/src/hooks.server.ts | 9 +--- .../lib/components/self/SendMoneyModal.svelte | 46 +--------------- .../src/lib/components/self/TradeModal.svelte | 52 ++----------------- website/src/lib/server/redis.ts | 11 ---- website/src/lib/server/turnstile.ts | 20 ------- website/src/routes/+layout.server.ts | 1 - .../api/coin/[coinSymbol]/trade/+server.ts | 13 +---- website/src/routes/api/transfer/+server.ts | 13 +---- 12 files changed, 12 insertions(+), 186 deletions(-) delete mode 100644 website/src/lib/server/turnstile.ts diff --git a/website/.env.example b/website/.env.example index f04ec89..50db989 100644 --- a/website/.env.example +++ b/website/.env.example @@ -26,9 +26,4 @@ PUBLIC_B2_ENDPOINT=https://s3.us-west-002.backblazeb2.com PUBLIC_B2_REGION=us-west-002 # OpenAI (for AI features) -OPENROUTER_API_KEY=your_openrouter_api_key - -# Turnstile (for CAPTCHA) -# The default ones are for testing purposes only, and will accept any request. -PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA -TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA \ No newline at end of file +OPENROUTER_API_KEY=your_openrouter_api_key \ No newline at end of file diff --git a/website/package-lock.json b/website/package-lock.json index f855751..c5b45f5 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -28,8 +28,7 @@ "sharp": "^0.34.2", "svelte-apexcharts": "^1.0.2", "svelte-confetti": "^2.3.1", - "svelte-lightweight-charts": "^2.2.0", - "svelte-turnstile": "^0.11.0" + "svelte-lightweight-charts": "^2.2.0" }, "devDependencies": { "@internationalized/date": "^3.8.1", @@ -5417,17 +5416,6 @@ "svelte": "^5.30.2" } }, - "node_modules/svelte-turnstile": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/svelte-turnstile/-/svelte-turnstile-0.11.0.tgz", - "integrity": "sha512-2LFklx9JVsR3fJ7e3fGG1HEAWWEqRq1WfNaVrKgZJ+pzfY2NColiH+wH0kK2yX3DrcGLiJ9vBeTyiLFWotKpLA==", - "dependencies": { - "turnstile-types": "^1.2.3" - }, - "peerDependencies": { - "svelte": "^3.58.0 || ^4.0.0 || ^5.0.0" - } - }, "node_modules/svg.draggable.js": { "version": "2.2.2", "license": "MIT", @@ -5590,11 +5578,6 @@ "version": "2.8.1", "license": "0BSD" }, - "node_modules/turnstile-types": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/turnstile-types/-/turnstile-types-1.2.3.tgz", - "integrity": "sha512-EDjhDB9TDwda2JRbhzO/kButPio3JgrC3gXMVAMotxldybTCJQVMvPNJ89rcAiN9vIrCb2i1E+VNBCqB8wue0A==" - }, "node_modules/tw-animate-css": { "version": "1.3.0", "dev": true, diff --git a/website/package.json b/website/package.json index ea17787..efacfac 100644 --- a/website/package.json +++ b/website/package.json @@ -61,8 +61,7 @@ "sharp": "^0.34.2", "svelte-apexcharts": "^1.0.2", "svelte-confetti": "^2.3.1", - "svelte-lightweight-charts": "^2.2.0", - "svelte-turnstile": "^0.11.0" + "svelte-lightweight-charts": "^2.2.0" }, "optionalDependencies": { "@rollup/rollup-linux-x64-gnu": "*" diff --git a/website/src/app.d.ts b/website/src/app.d.ts index 584973d..d9fc4c3 100644 --- a/website/src/app.d.ts +++ b/website/src/app.d.ts @@ -4,13 +4,11 @@ declare global { namespace App { interface Locals { userSession: User; - turnstileVerified?: boolean; } interface PageData { userSession: User; - turnstileVerified?: boolean; } } } -export { }; +export {}; diff --git a/website/src/hooks.server.ts b/website/src/hooks.server.ts index f988aa8..8a37675 100644 --- a/website/src/hooks.server.ts +++ b/website/src/hooks.server.ts @@ -8,7 +8,6 @@ import { db } from '$lib/server/db'; import { user } from '$lib/server/db/schema'; import { eq } from 'drizzle-orm'; import { minesCleanupInactiveGames, minesAutoCashout } from '$lib/server/games/mines'; -import { isTurnstileVerifiedRedis } from '$lib/server/redis'; async function initializeScheduler() { if (building) return; @@ -114,7 +113,7 @@ export const handle: Handle = async ({ event, resolve }) => { const userId = session.user.id; const cacheKey = `user:${userId}`; const now = Date.now(); - + const cached = sessionCache.get(cacheKey); if (cached && (now - cached.timestamp) < cached.ttl) { userData = cached.userData; @@ -180,12 +179,6 @@ export const handle: Handle = async ({ event, resolve }) => { event.locals.userSession = userData; - if (session?.user?.id) { - event.locals.turnstileVerified = await isTurnstileVerifiedRedis(session.user.id); - } else { - event.locals.turnstileVerified = false; - } - if (event.url.pathname.startsWith('/api/')) { const response = await svelteKitHandler({ event, resolve, auth }); response.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, private'); diff --git a/website/src/lib/components/self/SendMoneyModal.svelte b/website/src/lib/components/self/SendMoneyModal.svelte index ca238b1..6559edf 100644 --- a/website/src/lib/components/self/SendMoneyModal.svelte +++ b/website/src/lib/components/self/SendMoneyModal.svelte @@ -8,9 +8,6 @@ import { Send, DollarSign, Coins, Loader2 } from 'lucide-svelte'; import { PORTFOLIO_DATA } from '$lib/stores/portfolio-data'; import { toast } from 'svelte-sonner'; - import { Turnstile } from 'svelte-turnstile'; - import { PUBLIC_TURNSTILE_SITE_KEY } from '$env/static/public'; - import { page } from '$app/stores'; let { open = $bindable(false), @@ -27,9 +24,6 @@ let amount = $state(''); let selectedCoinSymbol = $state(''); let loading = $state(false); - let turnstileToken = $state(''); - let turnstileError = $state(''); - let turnstileReset = $state<(() => void) | undefined>(undefined); let numericAmount = $derived(parseFloat(amount) || 0); let hasValidAmount = $derived(numericAmount > 0); @@ -63,9 +57,6 @@ let isWithinCoinValueLimit = $derived(transferType === 'COIN' ? estimatedValue >= 10 : true); - const turnstileVerified = $derived(!!$page.data?.turnstileVerified); - let optimisticTurnstileVerified = $state(false); - let canSend = $derived( hasValidAmount && hasValidRecipient && @@ -73,8 +64,7 @@ isWithinCashLimit && isWithinCoinValueLimit && !loading && - (transferType === 'CASH' || selectedCoinSymbol.length > 0) && - (turnstileVerified || optimisticTurnstileVerified || !!turnstileToken) + (transferType === 'CASH' || selectedCoinSymbol.length > 0) ); function handleClose() { @@ -124,8 +114,7 @@ recipientUsername: recipientUsername.trim(), type: transferType, amount: numericAmount, - coinSymbol: transferType === 'COIN' ? selectedCoinSymbol : undefined, - turnstileToken + coinSymbol: transferType === 'COIN' ? selectedCoinSymbol : undefined }) }); @@ -152,9 +141,6 @@ onSuccess?.(); handleClose(); - - turnstileToken = ''; - optimisticTurnstileVerified = true; } catch (e) { toast.error('Transfer failed', { description: (e as Error).message @@ -339,34 +325,6 @@
{/if} - - {#if !(turnstileVerified || optimisticTurnstileVerified)} -
- ) => { - turnstileToken = e.detail.token; - turnstileError = ''; - }} - on:error={(e: CustomEvent<{ code: string }>) => { - turnstileToken = ''; - turnstileError = e.detail.code || 'Captcha error'; - }} - on:expired={() => { - turnstileToken = ''; - turnstileError = 'Captcha expired'; - }} - execution="render" - appearance="always" - /> - {#if turnstileError} -

{turnstileError}

- {/if} -
- {/if}
diff --git a/website/src/lib/components/self/TradeModal.svelte b/website/src/lib/components/self/TradeModal.svelte index 1d8ce80..f554103 100644 --- a/website/src/lib/components/self/TradeModal.svelte +++ b/website/src/lib/components/self/TradeModal.svelte @@ -7,9 +7,6 @@ import { TrendingUp, TrendingDown, Loader2 } from 'lucide-svelte'; import { PORTFOLIO_SUMMARY } from '$lib/stores/portfolio-data'; import { toast } from 'svelte-sonner'; - import { Turnstile } from 'svelte-turnstile'; - import { PUBLIC_TURNSTILE_SITE_KEY } from '$env/static/public'; - import { page } from '$app/stores'; let { open = $bindable(false), @@ -27,8 +24,6 @@ let amount = $state(''); let loading = $state(false); - let turnstileToken = $state(''); - let turnstileError = $state(''); let numericAmount = $derived(parseFloat(amount) || 0); let currentPrice = $derived(coin.currentPrice || 0); @@ -44,14 +39,7 @@ let hasEnoughFunds = $derived( type === 'BUY' ? numericAmount <= userBalance : numericAmount <= userHolding ); - const turnstileVerified = $derived(!!$page.data?.turnstileVerified); - let optimisticTurnstileVerified = $state(false); - - let showCaptcha = $derived(!(turnstileVerified || optimisticTurnstileVerified)); - - let canTrade = $derived( - hasValidAmount && hasEnoughFunds && !loading && (!showCaptcha || !!turnstileToken) - ); + let canTrade = $derived(hasValidAmount && hasEnoughFunds && !loading); function calculateEstimate(amount: number, tradeType: 'BUY' | 'SELL', price: number) { if (!amount || !price || !coin) return { result: 0 }; @@ -82,8 +70,6 @@ loading = false; } - let turnstileReset = $state<(() => void) | undefined>(undefined); - async function handleTrade() { if (!canTrade) return; @@ -96,8 +82,7 @@ }, body: JSON.stringify({ type, - amount: numericAmount, - turnstileToken + amount: numericAmount }) }); @@ -116,9 +101,6 @@ onSuccess?.(); handleClose(); - - turnstileToken = ''; - optimisticTurnstileVerified = true; } catch (e) { toast.error('Trade failed', { description: (e as Error).message @@ -212,34 +194,6 @@ {type === 'BUY' ? 'Insufficient funds' : 'Insufficient coins'} {/if} - - {#if showCaptcha} -
- ) => { - turnstileToken = e.detail.token; - turnstileError = ''; - }} - on:error={(e: CustomEvent<{ code: string }>) => { - turnstileToken = ''; - turnstileError = e.detail.code || 'Captcha error'; - }} - on:expired={() => { - turnstileToken = ''; - turnstileError = 'Captcha expired'; - }} - execution="render" - appearance="always" - /> - {#if turnstileError} -

{turnstileError}

- {/if} -
- {/if} @@ -258,4 +212,4 @@ - + \ No newline at end of file diff --git a/website/src/lib/server/redis.ts b/website/src/lib/server/redis.ts index 09533ef..ba6ace6 100644 --- a/website/src/lib/server/redis.ts +++ b/website/src/lib/server/redis.ts @@ -15,14 +15,3 @@ if (!building) { } export { client as redis }; - -const TURNSTILE_PREFIX = 'turnstile:verified:'; -const TURNSTILE_TTL = 5 * 60; // 5 minutes - -export async function setTurnstileVerifiedRedis(userId: string) { - await client.set(`${TURNSTILE_PREFIX}${userId}`, '1', { EX: TURNSTILE_TTL }); -} - -export async function isTurnstileVerifiedRedis(userId: string): Promise { - return !!(await client.get(`${TURNSTILE_PREFIX}${userId}`)); -} diff --git a/website/src/lib/server/turnstile.ts b/website/src/lib/server/turnstile.ts deleted file mode 100644 index 65acd8c..0000000 --- a/website/src/lib/server/turnstile.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { env } from '$env/dynamic/private'; - -const TURNSTILE_SECRET = env.TURNSTILE_SECRET_KEY; - -export async function verifyTurnstile(token: string, request: Request): Promise { - if (!TURNSTILE_SECRET) return false; - const ip = request.headers.get('x-forwarded-for') || request.headers.get('cf-connecting-ip') || undefined; - const body = new URLSearchParams({ - secret: TURNSTILE_SECRET, - response: token, - ...(ip ? { remoteip: ip } : {}) - }); - const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { - method: 'POST', - body, - headers: { 'content-type': 'application/x-www-form-urlencoded' } - }); - const data = await res.json(); - return !!data.success; -} diff --git a/website/src/routes/+layout.server.ts b/website/src/routes/+layout.server.ts index b9fcbf3..4f62bd0 100644 --- a/website/src/routes/+layout.server.ts +++ b/website/src/routes/+layout.server.ts @@ -12,6 +12,5 @@ export const load: LayoutServerLoad = async (event) => { return { userSession: event.locals.userSession, url: event.url.pathname, - turnstileVerified: event.locals.turnstileVerified ?? false }; }; \ No newline at end of file diff --git a/website/src/routes/api/coin/[coinSymbol]/trade/+server.ts b/website/src/routes/api/coin/[coinSymbol]/trade/+server.ts index d5001f0..821e93e 100644 --- a/website/src/routes/api/coin/[coinSymbol]/trade/+server.ts +++ b/website/src/routes/api/coin/[coinSymbol]/trade/+server.ts @@ -5,8 +5,6 @@ import { coin, userPortfolio, user, transaction, priceHistory } from '$lib/serve import { eq, and, gte } from 'drizzle-orm'; import { redis } from '$lib/server/redis'; import { createNotification } from '$lib/server/notification'; -import { verifyTurnstile } from '$lib/server/turnstile'; -import { setTurnstileVerifiedRedis, isTurnstileVerifiedRedis } from '$lib/server/redis'; async function calculate24hMetrics(coinId: number, currentPrice: number) { const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); @@ -55,16 +53,7 @@ export async function POST({ params, request }) { } const { coinSymbol } = params; - const { type, amount, turnstileToken } = await request.json(); - - const alreadyVerified = await isTurnstileVerifiedRedis(session.user.id); - - if (!alreadyVerified) { - if (!turnstileToken || !(await verifyTurnstile(turnstileToken, request))) { - throw error(400, 'Captcha verification failed'); - } - await setTurnstileVerifiedRedis(session.user.id); - } + const { type, amount } = await request.json(); if (!['BUY', 'SELL'].includes(type)) { throw error(400, 'Invalid transaction type'); diff --git a/website/src/routes/api/transfer/+server.ts b/website/src/routes/api/transfer/+server.ts index f200e16..726d345 100644 --- a/website/src/routes/api/transfer/+server.ts +++ b/website/src/routes/api/transfer/+server.ts @@ -5,8 +5,6 @@ import { user, userPortfolio, coin, transaction } from '$lib/server/db/schema'; import { eq, and } from 'drizzle-orm'; import { createNotification } from '$lib/server/notification'; import { formatValue } from '$lib/utils'; -import { verifyTurnstile } from '$lib/server/turnstile'; -import { setTurnstileVerifiedRedis, isTurnstileVerifiedRedis } from '$lib/server/redis'; import type { RequestHandler } from './$types'; interface TransferRequest { @@ -24,16 +22,7 @@ export const POST: RequestHandler = async ({ request }) => { if (!session?.user) { throw error(401, 'Not authenticated'); } try { - const { recipientUsername, type, amount, coinSymbol, turnstileToken }: TransferRequest & { turnstileToken?: string } = await request.json(); - - const alreadyVerified = await isTurnstileVerifiedRedis(session.user.id); - - if (!alreadyVerified) { - if (!turnstileToken || !(await verifyTurnstile(turnstileToken, request))) { - throw error(400, 'Captcha verification failed'); - } - await setTurnstileVerifiedRedis(session.user.id); - } + const { recipientUsername, type, amount, coinSymbol }: TransferRequest = await request.json(); if (!recipientUsername || !type || !amount || typeof amount !== 'number' || !Number.isFinite(amount) || amount <= 0) { throw error(400, 'Invalid transfer parameters');