From 930d1f41d7e9b9b4c29fc2f2767956e0680c3bd5 Mon Sep 17 00:00:00 2001 From: Face <69168154+face-hh@users.noreply.github.com> Date: Fri, 23 May 2025 21:45:41 +0300 Subject: [PATCH] feat: update database schema for precision + AMM behavior --- website/drizzle/0002_parched_silver_sable.sql | 17 + website/drizzle/meta/0002_snapshot.json | 709 ++++++++++++++++++ website/drizzle/meta/_journal.json | 7 + .../src/lib/components/self/TradeModal.svelte | 102 ++- website/src/lib/server/db/schema.ts | 32 +- .../api/coin/[coinSymbol]/trade/+server.ts | 56 +- website/src/routes/api/coin/create/+server.ts | 16 +- .../src/routes/coin/[coinSymbol]/+page.svelte | 62 +- 8 files changed, 893 insertions(+), 108 deletions(-) create mode 100644 website/drizzle/0002_parched_silver_sable.sql create mode 100644 website/drizzle/meta/0002_snapshot.json diff --git a/website/drizzle/0002_parched_silver_sable.sql b/website/drizzle/0002_parched_silver_sable.sql new file mode 100644 index 0000000..4a9a2f4 --- /dev/null +++ b/website/drizzle/0002_parched_silver_sable.sql @@ -0,0 +1,17 @@ +ALTER TABLE "coin" ALTER COLUMN "initial_supply" SET DATA TYPE numeric(30, 8);--> statement-breakpoint +ALTER TABLE "coin" ALTER COLUMN "circulating_supply" SET DATA TYPE numeric(30, 8);--> statement-breakpoint +ALTER TABLE "coin" ALTER COLUMN "current_price" SET DATA TYPE numeric(20, 8);--> statement-breakpoint +ALTER TABLE "coin" ALTER COLUMN "market_cap" SET DATA TYPE numeric(30, 2);--> statement-breakpoint +ALTER TABLE "coin" ALTER COLUMN "volume_24h" SET DATA TYPE numeric(30, 2);--> statement-breakpoint +ALTER TABLE "coin" ALTER COLUMN "volume_24h" SET DEFAULT '0.00';--> statement-breakpoint +ALTER TABLE "coin" ALTER COLUMN "change_24h" SET DATA TYPE numeric(10, 4);--> statement-breakpoint +ALTER TABLE "coin" ALTER COLUMN "pool_coin_amount" SET DATA TYPE numeric(30, 8);--> statement-breakpoint +ALTER TABLE "coin" ALTER COLUMN "pool_base_currency_amount" SET DATA TYPE numeric(30, 8);--> statement-breakpoint +ALTER TABLE "coin" ALTER COLUMN "pool_base_currency_amount" SET DEFAULT '0.00000000';--> statement-breakpoint +ALTER TABLE "price_history" ALTER COLUMN "price" SET DATA TYPE numeric(20, 8);--> statement-breakpoint +ALTER TABLE "transaction" ALTER COLUMN "quantity" SET DATA TYPE numeric(30, 8);--> statement-breakpoint +ALTER TABLE "transaction" ALTER COLUMN "price_per_coin" SET DATA TYPE numeric(20, 8);--> statement-breakpoint +ALTER TABLE "transaction" ALTER COLUMN "total_base_currency_amount" SET DATA TYPE numeric(30, 8);--> statement-breakpoint +ALTER TABLE "user" ALTER COLUMN "base_currency_balance" SET DATA TYPE numeric(20, 8);--> statement-breakpoint +ALTER TABLE "user" ALTER COLUMN "base_currency_balance" SET DEFAULT '10000.00000000';--> statement-breakpoint +ALTER TABLE "user_portfolio" ALTER COLUMN "quantity" SET DATA TYPE numeric(30, 8); \ No newline at end of file diff --git a/website/drizzle/meta/0002_snapshot.json b/website/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..8921514 --- /dev/null +++ b/website/drizzle/meta/0002_snapshot.json @@ -0,0 +1,709 @@ +{ + "id": "446a7a86-4e91-4461-829a-1059470307c4", + "prevId": "a272e8ea-cd8d-4f01-b826-5c4ea55499c4", + "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.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(10, 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": {}, + "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.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.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": true + }, + "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()" + } + }, + "indexes": {}, + "foreignKeys": { + "transaction_user_id_user_id_fk": { + "name": "transaction_user_id_user_id_fk", + "tableFrom": "transaction", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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" + } + }, + "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": "'10000.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 + } + }, + "indexes": {}, + "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.transaction_type": { + "name": "transaction_type", + "schema": "public", + "values": [ + "BUY", + "SELL" + ] + } + }, + "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 4833492..5ba9771 100644 --- a/website/drizzle/meta/_journal.json +++ b/website/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1747991689472, "tag": "0001_last_selene", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1748023983269, + "tag": "0002_parched_silver_sable", + "breakpoints": true } ] } \ No newline at end of file diff --git a/website/src/lib/components/self/TradeModal.svelte b/website/src/lib/components/self/TradeModal.svelte index b6ec4bb..68aa438 100644 --- a/website/src/lib/components/self/TradeModal.svelte +++ b/website/src/lib/components/self/TradeModal.svelte @@ -8,7 +8,7 @@ import { USER_DATA } from '$lib/stores/user-data'; import { toast } from 'svelte-sonner'; - let { + let { open = $bindable(false), type, coin, @@ -26,16 +26,45 @@ let loading = $state(false); let numericAmount = $derived(parseFloat(amount) || 0); - let estimatedCost = $derived(numericAmount * coin.currentPrice); + let currentPrice = $derived(coin.currentPrice || 0); + + let maxSellableAmount = $derived( + type === 'SELL' && coin + ? Math.min(userHolding, Math.floor(Number(coin.poolCoinAmount) * 0.995)) + : userHolding + ); + + let estimatedResult = $derived(calculateEstimate(numericAmount, type, currentPrice)); let hasValidAmount = $derived(numericAmount > 0); let userBalance = $derived($USER_DATA ? Number($USER_DATA.baseCurrencyBalance) : 0); let hasEnoughFunds = $derived( - type === 'BUY' - ? estimatedCost <= userBalance - : numericAmount <= userHolding + type === 'BUY' ? numericAmount <= userBalance : numericAmount <= userHolding ); let canTrade = $derived(hasValidAmount && hasEnoughFunds && !loading); + function calculateEstimate(amount: number, tradeType: 'BUY' | 'SELL', price: number) { + if (!amount || !price || !coin) return { result: 0 }; + + const poolCoin = Number(coin.poolCoinAmount); + const poolBase = Number(coin.poolBaseCurrencyAmount); + + if (poolCoin <= 0 || poolBase <= 0) return { result: 0 }; + + const k = poolCoin * poolBase; + + if (tradeType === 'BUY') { + // AMM formula: how many coins for spending 'amount' dollars + const newPoolBase = poolBase + amount; + const newPoolCoin = k / newPoolBase; + return { result: poolCoin - newPoolCoin }; + } else { + // AMM formula: how many dollars for selling 'amount' coins + const newPoolCoin = poolCoin + amount; + const newPoolBase = k / newPoolCoin; + return { result: poolBase - newPoolBase }; + } + } + function handleClose() { open = false; amount = ''; @@ -65,9 +94,10 @@ } toast.success(`${type === 'BUY' ? 'Bought' : 'Sold'} successfully!`, { - description: type === 'BUY' - ? `Purchased ${result.coinsBought.toFixed(2)} ${coin.symbol} for $${result.totalCost.toFixed(2)}` - : `Sold ${result.coinsSold.toFixed(2)} ${coin.symbol} for $${result.totalReceived.toFixed(2)}` + description: + type === 'BUY' + ? `Purchased ${result.coinsBought.toFixed(6)} ${coin.symbol} for $${result.totalCost.toFixed(6)}` + : `Sold ${result.coinsSold.toFixed(6)} ${coin.symbol} for $${result.totalReceived.toFixed(6)}` }); onSuccess?.(); @@ -83,10 +113,10 @@ function setMaxAmount() { if (type === 'SELL') { - amount = userHolding.toString(); + amount = maxSellableAmount.toString(); } else if ($USER_DATA) { - const maxCoins = Math.floor(userBalance / coin.currentPrice * 100) / 100; - amount = maxCoins.toString(); + // For BUY, max is user's balance + amount = userBalance.toString(); } } @@ -111,58 +141,66 @@
- Available: {userHolding.toFixed(2)} {coin.symbol}
+ Available: {userHolding.toFixed(6)}
+ {coin.symbol}
+ {#if maxSellableAmount < userHolding}
+
Max sellable: {maxSellableAmount.toFixed(0)} {coin.symbol} (pool limit)
+ {/if}
- Balance: ${userBalance.toFixed(2)} + Balance: ${userBalance.toFixed(6)}
{/if}+ AMM estimation - includes slippage from pool impact +
- You own: {userHolding.toFixed(2)} + You own: {formatSupply(userHolding)} {coin.symbol}
{/if} @@ -465,9 +475,9 @@