diff --git a/scripts/scripts/seed.ts b/scripts/scripts/seed.ts new file mode 100644 index 0000000..4f439d1 --- /dev/null +++ b/scripts/scripts/seed.ts @@ -0,0 +1,237 @@ +/** + * TradeFlow – Database Seed Script + * Run: npm run seed + * + * Generates: + * - 20 mock users with realistic Stellar G-addresses + * - 3 distinct liquidity pools + * - 5,000+ historical swap records spread across a 30-day timeline + * + * Idempotent: clears all existing data before seeding. + * All inserts are wrapped in a single transaction for performance. + */ + +import { PrismaClient, Prisma } from "@prisma/client"; +import { faker } from "@faker-js/faker"; + +const prisma = new PrismaClient(); + +// ─── Config ────────────────────────────────────────────────────────────────── +const SEED_CONFIG = { + users: 20, + pools: 3, + swaps: 5000, + daysBack: 30, +} as const; + +// ─── Stellar address generator ──────────────────────────────────────────────── +// Real Stellar G-addresses are base32-encoded 32-byte ed25519 public keys. +// We generate deterministic, realistic-looking addresses using faker. +const BASE32_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +function randomStellarAddress(): string { + // G + 55 base32 chars = 56 chars total (matches real Stellar address length) + let addr = "G"; + for (let i = 0; i < 55; i++) { + addr += BASE32_CHARS[Math.floor(Math.random() * BASE32_CHARS.length)]; + } + return addr; +} + +// ─── Pool definitions ───────────────────────────────────────────────────────── +const POOL_DEFINITIONS = [ + { + name: "XLM/USDC", + tokenA: "XLM", + tokenB: "USDC", + basePrice: 0.11, // XLM price in USDC + priceVolatility: 0.05, // ±5% daily drift + liquidityUsd: 2_500_000, + }, + { + name: "XLM/BTC", + tokenA: "XLM", + tokenB: "BTC", + basePrice: 0.0000018, + priceVolatility: 0.08, + liquidityUsd: 800_000, + }, + { + name: "USDC/EURC", + tokenA: "USDC", + tokenB: "EURC", + basePrice: 0.92, + priceVolatility: 0.01, // Stablecoin pair — low volatility + liquidityUsd: 5_000_000, + }, +]; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** Returns a random Date between `daysAgo` days ago and now */ +function randomDateInWindow(daysAgo: number): Date { + const now = Date.now(); + const windowMs = daysAgo * 24 * 60 * 60 * 1000; + return new Date(now - Math.random() * windowMs); +} + +/** + * Simulate a realistic price walk using geometric Brownian motion. + * Returns a price multiplier relative to the pool's base price. + */ +function priceWalk(basePrice: number, volatility: number, daysElapsed: number): number { + // Simple GBM approximation: price * e^(vol * sqrt(dt) * Z) + const dt = daysElapsed / 365; + const Z = faker.number.float({ min: -2, max: 2 }); + const drift = Math.exp(volatility * Math.sqrt(dt) * Z); + return Math.max(basePrice * drift, basePrice * 0.5); // floor at 50% of base +} + +/** + * Generate a mathematically valid swap: + * - amountIn: random between 10–50,000 units of tokenA + * - amountOut: amountIn * exchangeRate * (1 - fee) with ±0.5% slippage + * - fee: 0.3% (Uniswap V2 standard) + */ +function generateSwapAmounts( + exchangeRate: number +): { amountIn: number; amountOut: number; fee: number; priceImpact: number } { + const FEE_RATE = 0.003; + const amountIn = faker.number.float({ min: 10, max: 50_000, fractionDigits: 6 }); + const slippage = faker.number.float({ min: -0.005, max: 0.005 }); // ±0.5% + const amountOut = amountIn * exchangeRate * (1 - FEE_RATE) * (1 + slippage); + const fee = amountIn * FEE_RATE; + const priceImpact = Math.abs(slippage) + (amountIn / 1_000_000) * 0.01; // size-dependent impact + return { + amountIn: parseFloat(amountIn.toFixed(6)), + amountOut: parseFloat(Math.max(amountOut, 0.000001).toFixed(6)), + fee: parseFloat(fee.toFixed(6)), + priceImpact: parseFloat(priceImpact.toFixed(6)), + }; +} + +// ─── Main ───────────────────────────────────────────────────────────────────── +async function main() { + console.log("🌱 TradeFlow seed starting...\n"); + + // ── 1. Idempotent clear ──────────────────────────────────────────────────── + console.log("🗑️ Clearing existing data..."); + await prisma.$transaction([ + prisma.swap.deleteMany(), + prisma.pool.deleteMany(), + prisma.user.deleteMany(), + ]); + console.log(" ✓ Tables cleared\n"); + + // ── 2. Seed users ────────────────────────────────────────────────────────── + console.log(`👤 Creating ${SEED_CONFIG.users} users...`); + const userData: Prisma.UserCreateManyInput[] = Array.from( + { length: SEED_CONFIG.users }, + () => ({ + walletAddress: randomStellarAddress(), + username: faker.internet.username(), + email: faker.internet.email(), + createdAt: randomDateInWindow(60), // users created up to 60 days ago + }) + ); + + await prisma.user.createMany({ data: userData }); + const users = await prisma.user.findMany(); + console.log(` ✓ ${users.length} users created\n`); + + // ── 3. Seed pools ────────────────────────────────────────────────────────── + console.log(`🏊 Creating ${POOL_DEFINITIONS.length} liquidity pools...`); + const poolData: Prisma.PoolCreateManyInput[] = POOL_DEFINITIONS.map((def) => ({ + name: def.name, + tokenA: def.tokenA, + tokenB: def.tokenB, + liquidityUsd: def.liquidityUsd, + basePrice: def.basePrice, + createdAt: randomDateInWindow(60), + })); + + await prisma.pool.createMany({ data: poolData }); + const pools = await prisma.pool.findMany(); + console.log(` ✓ Pools: ${pools.map((p) => p.name).join(", ")}\n`); + + // ── 4. Seed swaps (single transaction) ───────────────────────────────────── + console.log(`🔄 Generating ${SEED_CONFIG.swaps} swap records...`); + + const swapData: Prisma.SwapCreateManyInput[] = Array.from( + { length: SEED_CONFIG.swaps }, + (_, i) => { + const pool = pools[i % pools.length]; // distribute evenly across pools + const user = users[Math.floor(Math.random() * users.length)]; + const poolDef = POOL_DEFINITIONS.find((d) => d.name === pool.name)!; + + const swapDate = randomDateInWindow(SEED_CONFIG.daysBack); + const daysElapsed = + (Date.now() - swapDate.getTime()) / (1000 * 60 * 60 * 24); + const currentPrice = priceWalk(poolDef.basePrice, poolDef.priceVolatility, daysElapsed); + + // Randomly decide direction: tokenA→tokenB or tokenB→tokenA + const direction = Math.random() > 0.5 ? "A_TO_B" : "B_TO_A"; + const effectiveRate = direction === "A_TO_B" ? currentPrice : 1 / currentPrice; + + const { amountIn, amountOut, fee, priceImpact } = generateSwapAmounts(effectiveRate); + + return { + poolId: pool.id, + userId: user.id, + tokenIn: direction === "A_TO_B" ? pool.tokenA : pool.tokenB, + tokenOut: direction === "A_TO_B" ? pool.tokenB : pool.tokenA, + amountIn, + amountOut, + fee, + priceImpact, + executionPrice: effectiveRate, + status: faker.helpers.weightedArrayElement([ + { weight: 92, value: "COMPLETED" }, + { weight: 5, value: "FAILED" }, + { weight: 3, value: "PENDING" }, + ]), + txHash: `0x${faker.string.hexadecimal({ length: 64, casing: "lower" }).replace("0x", "")}`, + createdAt: swapDate, + }; + } + ); + + // Batch into chunks of 500 for the single transaction + const CHUNK_SIZE = 500; + const chunks: Prisma.SwapCreateManyInput[][] = []; + for (let i = 0; i < swapData.length; i += CHUNK_SIZE) { + chunks.push(swapData.slice(i, i + CHUNK_SIZE)); + } + + await prisma.$transaction( + chunks.map((chunk) => prisma.swap.createMany({ data: chunk })) + ); + + const swapCount = await prisma.swap.count(); + console.log(` ✓ ${swapCount} swaps inserted\n`); + + // ── 5. Summary ───────────────────────────────────────────────────────────── + console.log("✅ Seed complete!\n"); + console.log("📊 Summary:"); + console.log(` Users : ${await prisma.user.count()}`); + console.log(` Pools : ${await prisma.pool.count()}`); + console.log(` Swaps : ${await prisma.swap.count()}`); + + const byPool = await prisma.swap.groupBy({ + by: ["poolId"], + _count: { id: true }, + }); + for (const entry of byPool) { + const pool = pools.find((p) => p.id === entry.poolId); + console.log(` └─ ${pool?.name ?? entry.poolId}: ${entry._count.id} swaps`); + } +} + +main() + .catch((e) => { + console.error("❌ Seed failed:", e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + });