From bf067871a6b2800cebf4aff69c2846621b4590ed Mon Sep 17 00:00:00 2001 From: Oz Date: Sun, 7 Jun 2026 20:24:50 -0400 Subject: [PATCH] feat: add VTC Token Creator (VRC-20 + PumpFun bonding curve) - bondingCurve.ts: Exponential bonding curve with buy/sell/graduate mechanics - feeDistributor.ts: Fee split 40/40/20 (creator/treasury/buyback) - tokenCreator.ts: VRC-20 deploy + vtc-launch inscription generators - index.ts: REST API endpoints for create, trade, price, graduate Co-Authored-By: Oz --- src/creator/bondingCurve.ts | 434 ++++++++++++++++++++++ src/creator/feeDistributor.ts | 156 ++++++++ src/creator/index.ts | 652 ++++++++++++++++++++++++++++++++++ src/creator/tokenCreator.ts | 440 +++++++++++++++++++++++ 4 files changed, 1682 insertions(+) create mode 100644 src/creator/bondingCurve.ts create mode 100644 src/creator/feeDistributor.ts create mode 100644 src/creator/index.ts create mode 100644 src/creator/tokenCreator.ts diff --git a/src/creator/bondingCurve.ts b/src/creator/bondingCurve.ts new file mode 100644 index 0000000..5298289 --- /dev/null +++ b/src/creator/bondingCurve.ts @@ -0,0 +1,434 @@ +/** + * @module bondingCurve + * @description Exponential bonding curve mechanics for PumpFun-style VRC-20 token launches on Vertcoin. + * + * The curve follows: price = startPrice * e^(growthRate * tokensSold) + * + * Starting price is derived from: graduationVtc / (curveSupply * ln(curveSupply)) + * Growth rate ensures the integral of the price function from 0 to curveSupply equals graduationVtc. + * + * @example + * ```ts + * import { BondingCurve } from "./bondingCurve"; + * + * const curve = new BondingCurve(1_000_000_000, 700_000_000, 300_000_000, 85); + * const quote = curve.buy(1); // Buy with 1 VTC + * console.log(quote.tokensOut, quote.newPrice); + * + * if (curve.isGraduated()) { + * const grad = curve.graduate(); + * console.log(`LP: ${grad.lpVtc} VTC + ${grad.lpTokens} tokens`); + * } + * ``` + */ + +/** Fee distribution percentages per trade (basis: 100%) */ +export const FEE_SPLIT = { + /** 40% of trading fee goes to token creator */ + CREATOR_PCT: 0.4, + /** 40% of trading fee goes to protocol treasury */ + TREASURY_PCT: 0.4, + /** 20% of trading fee is used for buyback & burn */ + BUYBACK_PCT: 0.2, +} as const; + +/** Graduation constants */ +const GRADUATION = { + /** Percentage of graduation VTC allocated to LP */ + LP_VTC_PCT: 0.90, + /** Percentage allocated as creator bonus */ + CREATOR_BONUS_PCT: 0.05, + /** Percentage allocated as treasury fee */ + TREASURY_FEE_PCT: 0.05, +} as const; + +/** + * Result of a buy operation on the bonding curve. + */ +export interface BuyResult { + /** Number of tokens received */ + tokensOut: number; + /** Price per token after the buy */ + newPrice: number; + /** Price impact as a decimal (0.05 = 5%) */ + priceImpact: number; + /** Total VTC raised on the curve so far */ + totalRaised: number; +} + +/** + * Result of a sell operation on the bonding curve. + */ +export interface SellResult { + /** VTC returned to seller */ + vtcOut: number; + /** Price per token after the sell */ + newPrice: number; +} + +/** + * Result of graduating the bonding curve to a DEX AMM pool. + */ +export interface GraduationResult { + /** VTC allocated to the liquidity pool */ + lpVtc: number; + /** Tokens allocated to the liquidity pool (from LP reserve) */ + lpTokens: number; + /** VTC bonus sent to the token creator */ + creatorBonus: number; + /** VTC fee sent to the protocol treasury */ + treasuryFee: number; +} + +/** + * Exponential bonding curve for PumpFun-style token launches on Vertcoin. + * + * The price function is: `price(x) = startPrice * e^(growthRate * x)` + * where x = number of tokens already sold. + * + * The integral (cost to buy from 0 to curveSupply) equals graduationTarget, + * which determines the growth rate. + */ +export class BondingCurve { + /** Total token supply (curve + LP reserve) */ + public readonly totalSupply: number; + /** Tokens available for trading on the bonding curve (typically 70%) */ + public readonly curveSupply: number; + /** Tokens reserved for DEX liquidity at graduation (typically 30%) */ + public readonly lpReserve: number; + /** VTC amount that triggers graduation to AMM */ + public readonly graduationTarget: number; + + /** Starting price derived from graduation economics */ + public readonly startPrice: number; + /** Exponential growth rate of the price curve */ + public readonly growthRate: number; + + /** Current number of tokens sold on the curve */ + private _tokensSold: number = 0; + /** Total VTC raised from buys (less sells) */ + private _totalRaised: number = 0; + /** Whether the curve has graduated to AMM */ + private _graduated: boolean = false; + + /** + * Creates a new bonding curve instance. + * + * @param totalSupply - Total token supply (e.g. 1_000_000_000) + * @param curveSupply - Tokens on the bonding curve (e.g. 700_000_000) + * @param lpReserve - Tokens reserved for LP (e.g. 300_000_000) + * @param graduationTarget - VTC raised to trigger graduation (e.g. 85) + * @throws {Error} If curveSupply + lpReserve > totalSupply + * @throws {Error} If any value is <= 0 + * + * @example + * ```ts + * // 1B supply, 70% curve, 30% LP, graduates at 85 VTC + * const curve = new BondingCurve(1_000_000_000, 700_000_000, 300_000_000, 85); + * ``` + */ + constructor( + totalSupply: number, + curveSupply: number, + lpReserve: number, + graduationTarget: number + ) { + if (totalSupply <= 0 || curveSupply <= 0 || lpReserve <= 0 || graduationTarget <= 0) { + throw new Error("All bonding curve parameters must be positive"); + } + if (curveSupply + lpReserve > totalSupply) { + throw new Error( + `curveSupply (${curveSupply}) + lpReserve (${lpReserve}) exceeds totalSupply (${totalSupply})` + ); + } + + this.totalSupply = totalSupply; + this.curveSupply = curveSupply; + this.lpReserve = lpReserve; + this.graduationTarget = graduationTarget; + + // Starting price: graduationVtc / (curveSupply * ln(curveSupply)) + this.startPrice = graduationTarget / (curveSupply * Math.log(curveSupply)); + + // Growth rate: derived so that integral from 0 to curveSupply of + // startPrice * e^(growthRate * x) dx = graduationTarget + // + // Integral = (startPrice / growthRate) * (e^(growthRate * curveSupply) - 1) = graduationTarget + // => e^(growthRate * curveSupply) = (graduationTarget * growthRate / startPrice) + 1 + // + // We solve numerically with Newton's method. + this.growthRate = this._solveGrowthRate(); + } + + /** + * Returns the current spot price in VTC per token. + * + * @param tokensSold - Optional override; defaults to internal state + * @returns Current price in VTC + * + * @example + * ```ts + * const price = curve.getPrice(); // current price + * const priceAt500k = curve.getPrice(500_000); // hypothetical price + * ``` + */ + getPrice(tokensSold?: number): number { + const sold = tokensSold ?? this._tokensSold; + return this.startPrice * Math.exp(this.growthRate * sold); + } + + /** + * Simulates buying tokens with a given VTC amount. + * + * Uses the integral of the exponential curve to compute how many tokens + * can be purchased for the given VTC, accounting for the non-linear price. + * + * @param vtcAmount - Amount of VTC to spend (before fees) + * @returns Buy result with tokens received, new price, impact, and total raised + * @throws {Error} If curve is graduated + * @throws {Error} If vtcAmount <= 0 + * + * @example + * ```ts + * const result = curve.buy(5); // spend 5 VTC + * console.log(`Got ${result.tokensOut} tokens at ${result.newPrice} VTC each`); + * console.log(`Price impact: ${(result.priceImpact * 100).toFixed(2)}%`); + * ``` + */ + buy(vtcAmount: number): BuyResult { + if (this._graduated) { + throw new Error("Curve has graduated — trade on the AMM pool instead"); + } + if (vtcAmount <= 0) { + throw new Error("vtcAmount must be positive"); + } + + const priceBefore = this.getPrice(); + + // Integral of startPrice * e^(growthRate * x) from tokensSold to tokensSold + tokensOut = vtcAmount + // => (startPrice / growthRate) * (e^(growthRate * (tokensSold + tokensOut)) - e^(growthRate * tokensSold)) = vtcAmount + // => e^(growthRate * (tokensSold + tokensOut)) = (vtcAmount * growthRate / startPrice) + e^(growthRate * tokensSold) + // => tokensSold + tokensOut = ln((vtcAmount * growthRate / startPrice) + e^(growthRate * tokensSold)) / growthRate + + const expCurrent = Math.exp(this.growthRate * this._tokensSold); + const expNew = (vtcAmount * this.growthRate) / this.startPrice + expCurrent; + + if (expNew <= 0) { + throw new Error("Invalid curve state: negative exponential"); + } + + const newTokensSold = Math.log(expNew) / this.growthRate; + let tokensOut = newTokensSold - this._tokensSold; + + // Cap at remaining curve supply + const remaining = this.curveSupply - this._tokensSold; + if (tokensOut > remaining) { + tokensOut = remaining; + } + + // Update state + this._tokensSold += tokensOut; + this._totalRaised += vtcAmount; + + const priceAfter = this.getPrice(); + const priceImpact = priceBefore > 0 ? (priceAfter - priceBefore) / priceBefore : 0; + + // Auto-graduate if target reached + if (this._totalRaised >= this.graduationTarget) { + this._graduated = true; + } + + return { + tokensOut, + newPrice: priceAfter, + priceImpact, + totalRaised: this._totalRaised, + }; + } + + /** + * Simulates selling tokens back to the bonding curve. + * + * Uses the inverse integral to compute VTC returned for a given token amount. + * + * @param tokenAmount - Number of tokens to sell + * @returns Sell result with VTC out and new price + * @throws {Error} If curve is graduated + * @throws {Error} If tokenAmount > tokensSold or <= 0 + * + * @example + * ```ts + * const result = curve.sell(100_000); + * console.log(`Received ${result.vtcOut} VTC`); + * ``` + */ + sell(tokenAmount: number): SellResult { + if (this._graduated) { + throw new Error("Curve has graduated — trade on the AMM pool instead"); + } + if (tokenAmount <= 0) { + throw new Error("tokenAmount must be positive"); + } + if (tokenAmount > this._tokensSold) { + throw new Error( + `Cannot sell ${tokenAmount} tokens — only ${this._tokensSold} have been sold` + ); + } + + // VTC out = integral from (tokensSold - tokenAmount) to tokensSold + const upperExp = Math.exp(this.growthRate * this._tokensSold); + const lowerExp = Math.exp(this.growthRate * (this._tokensSold - tokenAmount)); + const vtcOut = (this.startPrice / this.growthRate) * (upperExp - lowerExp); + + // Update state + this._tokensSold -= tokenAmount; + this._totalRaised = Math.max(0, this._totalRaised - vtcOut); + + return { + vtcOut, + newPrice: this.getPrice(), + }; + } + + /** + * Checks whether the bonding curve has reached its graduation target. + * + * @returns true if total VTC raised >= graduationTarget + */ + isGraduated(): boolean { + return this._graduated; + } + + /** + * Executes graduation: allocates funds and tokens for the AMM liquidity pool. + * + * Graduation splits the raised VTC: + * - 90% → LP paired with the LP reserve tokens + * - 5% → creator bonus + * - 5% → protocol treasury fee + * + * LP tokens are burned for permanent liquidity. + * + * @returns Graduation allocation details + * @throws {Error} If curve has not reached graduation target + * + * @example + * ```ts + * if (curve.isGraduated()) { + * const grad = curve.graduate(); + * console.log(`LP: ${grad.lpVtc} VTC + ${grad.lpTokens} tokens`); + * console.log(`Creator bonus: ${grad.creatorBonus} VTC`); + * console.log(`Treasury fee: ${grad.treasuryFee} VTC`); + * } + * ``` + */ + graduate(): GraduationResult { + if (!this._graduated) { + throw new Error( + `Cannot graduate — raised ${this._totalRaised} VTC of ${this.graduationTarget} target` + ); + } + + const raised = this._totalRaised; + + return { + lpVtc: raised * GRADUATION.LP_VTC_PCT, + lpTokens: this.lpReserve, + creatorBonus: raised * GRADUATION.CREATOR_BONUS_PCT, + treasuryFee: raised * GRADUATION.TREASURY_FEE_PCT, + }; + } + + // ── Accessors ────────────────────────────────────────────────────────── + + /** Number of tokens currently sold on the curve */ + get tokensSold(): number { + return this._tokensSold; + } + + /** Total VTC raised from curve trading */ + get totalRaised(): number { + return this._totalRaised; + } + + /** Tokens remaining on the curve */ + get tokensRemaining(): number { + return this.curveSupply - this._tokensSold; + } + + /** Progress toward graduation as a decimal (0–1) */ + get graduationProgress(): number { + return Math.min(1, this._totalRaised / this.graduationTarget); + } + + /** + * Returns a snapshot of the full curve state for API responses. + */ + toJSON() { + return { + totalSupply: this.totalSupply, + curveSupply: this.curveSupply, + lpReserve: this.lpReserve, + graduationTarget: this.graduationTarget, + startPrice: this.startPrice, + growthRate: this.growthRate, + tokensSold: this._tokensSold, + tokensRemaining: this.tokensRemaining, + totalRaised: this._totalRaised, + currentPrice: this.getPrice(), + graduated: this._graduated, + graduationProgress: this.graduationProgress, + }; + } + + // ── Private helpers ──────────────────────────────────────────────────── + + /** + * Numerically solves for growthRate using Newton's method. + * + * We need growthRate such that: + * (startPrice / growthRate) * (e^(growthRate * curveSupply) - 1) = graduationTarget + * + * Rearranged: f(k) = (startPrice / k) * (e^(k * N) - 1) - G = 0 + * where k = growthRate, N = curveSupply, G = graduationTarget + */ + private _solveGrowthRate(): number { + const N = this.curveSupply; + const G = this.graduationTarget; + const P0 = this.startPrice; + + // f(k) = (P0/k) * (e^(kN) - 1) - G + // f'(k) = P0 * ((N*k*e^(kN) - e^(kN) + 1) / k^2) + const f = (k: number) => (P0 / k) * (Math.exp(k * N) - 1) - G; + const fPrime = (k: number) => { + const ekN = Math.exp(k * N); + return P0 * ((N * k * ekN - ekN + 1) / (k * k)); + }; + + // Initial guess: small positive value + let k = 1e-9; + const MAX_ITER = 100; + const TOLERANCE = 1e-15; + + for (let i = 0; i < MAX_ITER; i++) { + const fVal = f(k); + const fPrimeVal = fPrime(k); + if (Math.abs(fPrimeVal) < 1e-30) break; + + const kNext = k - fVal / fPrimeVal; + + // Growth rate must stay positive + if (kNext <= 0) { + k = k / 2; + continue; + } + + if (Math.abs(kNext - k) < TOLERANCE) { + return kNext; + } + k = kNext; + } + + return k; + } +} diff --git a/src/creator/feeDistributor.ts b/src/creator/feeDistributor.ts new file mode 100644 index 0000000..de808ad --- /dev/null +++ b/src/creator/feeDistributor.ts @@ -0,0 +1,156 @@ +/** + * @module feeDistributor + * @description Fee handling for VRC-20 bonding curve trades on Vertcoin. + * + * Per-trade fee distribution: + * - 40% → token creator + * - 40% → protocol treasury + * - 20% → buyback & burn (tokens are purchased and permanently destroyed) + * + * @example + * ```ts + * import { calculateFees, executeBuyback } from "./feeDistributor"; + * + * // 1 VTC trade at 100 bps (1%) fee + * const fees = calculateFees(1, 100); + * console.log(fees); // { totalFee: 0.01, creatorFee: 0.004, treasuryFee: 0.004, buybackFee: 0.002 } + * + * // Execute buyback when enough VTC has accumulated + * const burned = executeBuyback(0.5, 0.0000001); + * console.log(`Burned ${burned.tokensBurned} tokens`); + * ``` + */ + +import { FEE_SPLIT } from "./bondingCurve"; + +/** + * Breakdown of fees collected from a single trade. + */ +export interface FeeBreakdown { + /** Total fee in VTC (tradeAmount * feeBps / 10_000) */ + totalFee: number; + /** VTC allocated to token creator (40%) */ + creatorFee: number; + /** VTC allocated to protocol treasury (40%) */ + treasuryFee: number; + /** VTC allocated to buyback & burn (20%) */ + buybackFee: number; +} + +/** + * Result of a buyback operation. + */ +export interface BuybackResult { + /** VTC spent on buyback */ + vtcSpent: number; + /** Tokens purchased and burned */ + tokensBurned: number; + /** Effective price paid per token */ + effectivePrice: number; +} + +/** + * Calculates the fee split for a trade on the bonding curve. + * + * Fees are deducted from the trade amount before curve execution. + * The fee is split 40/40/20 between creator, treasury, and buyback. + * + * @param tradeAmount - Gross VTC amount of the trade + * @param feeBps - Fee in basis points (100 = 1%) + * @returns Detailed fee breakdown + * @throws {Error} If tradeAmount <= 0 + * @throws {Error} If feeBps < 0 or > 10_000 + * + * @example + * ```ts + * // 10 VTC trade with 1% fee + * const fees = calculateFees(10, 100); + * // fees.totalFee = 0.1 + * // fees.creatorFee = 0.04 + * // fees.treasuryFee = 0.04 + * // fees.buybackFee = 0.02 + * ``` + */ +export function calculateFees(tradeAmount: number, feeBps: number): FeeBreakdown { + if (tradeAmount <= 0) { + throw new Error("tradeAmount must be positive"); + } + if (feeBps < 0 || feeBps > 10_000) { + throw new Error(`feeBps must be between 0 and 10000, got ${feeBps}`); + } + + const totalFee = tradeAmount * (feeBps / 10_000); + + return { + totalFee, + creatorFee: totalFee * FEE_SPLIT.CREATOR_PCT, + treasuryFee: totalFee * FEE_SPLIT.TREASURY_PCT, + buybackFee: totalFee * FEE_SPLIT.BUYBACK_PCT, + }; +} + +/** + * Executes a buyback using accumulated buyback VTC balance. + * + * Tokens purchased during buyback are permanently burned (sent to a + * provably unspendable address). The effective amount of tokens burned + * is computed as: buybackBalance / currentPrice. + * + * In production, this would construct an actual transaction via vertcoind RPC + * to purchase tokens on the bonding curve and then burn them. This function + * computes the expected outcome for the given parameters. + * + * @param buybackBalance - Accumulated VTC available for buyback + * @param currentPrice - Current token price in VTC from the bonding curve + * @returns Buyback result with tokens burned and effective price + * @throws {Error} If buybackBalance <= 0 + * @throws {Error} If currentPrice <= 0 + * + * @example + * ```ts + * // 0.5 VTC accumulated, current price 0.0000001 VTC/token + * const result = executeBuyback(0.5, 0.0000001); + * console.log(`Burned ${result.tokensBurned} tokens for ${result.vtcSpent} VTC`); + * ``` + */ +export function executeBuyback( + buybackBalance: number, + currentPrice: number +): BuybackResult { + if (buybackBalance <= 0) { + throw new Error("buybackBalance must be positive"); + } + if (currentPrice <= 0) { + throw new Error("currentPrice must be positive"); + } + + // Simple market buy: tokens = VTC / price + // In practice, this would go through the bonding curve's buy() with slippage + const tokensBurned = buybackBalance / currentPrice; + + return { + vtcSpent: buybackBalance, + tokensBurned, + effectivePrice: currentPrice, + }; +} + +/** + * Computes the net amount after fee deduction. + * + * @param grossAmount - Original trade amount in VTC + * @param feeBps - Fee in basis points + * @returns Net amount available for curve execution + * + * @example + * ```ts + * const net = netAfterFees(10, 100); // 9.9 VTC + * ``` + */ +export function netAfterFees(grossAmount: number, feeBps: number): number { + if (grossAmount <= 0) return 0; + if (feeBps < 0 || feeBps > 10_000) { + throw new Error(`feeBps must be between 0 and 10000, got ${feeBps}`); + } + return grossAmount * (1 - feeBps / 10_000); +} diff --git a/src/creator/index.ts b/src/creator/index.ts new file mode 100644 index 0000000..eb988f5 --- /dev/null +++ b/src/creator/index.ts @@ -0,0 +1,652 @@ +/** + * @module creator + * @description REST API endpoints for VRC-20 token creation and PumpFun-style + * bonding curve trading on Vertcoin. + * + * Endpoints: + * - POST /api/token/create — Deploy a new VRC-20 token + * - POST /api/token/create-bonding — Create a bonding curve token + * - GET /api/token/:tick/info — Token info + curve state + * - POST /api/token/:tick/buy — Buy tokens on the bonding curve + * - POST /api/token/:tick/sell — Sell tokens on the bonding curve + * - GET /api/token/:tick/price — Current price + chart data + * - POST /api/token/:tick/graduate — Trigger graduation to AMM + * + * @example + * ```ts + * import express from "express"; + * import tokenRoutes from "./creator"; + * + * const app = express(); + * app.use(express.json()); + * app.use("/api/token", tokenRoutes); + * app.listen(6000); + * ``` + */ + +import routerx from "express-promise-router"; +import rateLimit from "express-rate-limit"; +import type { Request, Response } from "express"; +import { + createVRC20, + createBondingCurveToken, + generateInscribeCommand, + validateTokenParams, +} from "./tokenCreator"; +import { BondingCurve } from "./bondingCurve"; +import { calculateFees, executeBuyback, netAfterFees } from "./feeDistributor"; + +// ── In-memory store ────────────────────────────────────────────────────── +// In production, replace with MongoDB models (like pumpActionModel.ts pattern) + +interface TokenRecord { + tick: string; + name: string; + type: "vrc-20" | "bonding-curve"; + inscription: object; + inscribeCommand: string; + curve: BondingCurve | null; + feeBps: number; + creatorAddress: string; + createdAt: Date; + /** Price history: { timestamp, price, tokensSold, totalRaised } */ + priceHistory: PricePoint[]; + /** Accumulated fees */ + accumulatedFees: { + creator: number; + treasury: number; + buyback: number; + }; +} + +interface PricePoint { + timestamp: number; + price: number; + tokensSold: number; + totalRaised: number; +} + +/** In-memory token registry keyed by uppercase tick */ +const tokenStore = new Map(); + +// ── Rate limiters ──────────────────────────────────────────────────────── + +/** Rate limit for token creation: 2 per minute */ +const createLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 2, + standardHeaders: true, + legacyHeaders: false, + message: { error: "Too many token creation requests. Try again in 1 minute." }, +}); + +/** Rate limit for buy/sell: 1 per 10 seconds (matches existing pump routes) */ +const tradeLimiter = rateLimit({ + windowMs: 10 * 1000, + max: 1, + standardHeaders: true, + legacyHeaders: false, + message: { error: "Too many trade requests. Try again in 10 seconds." }, +}); + +// ── Router ─────────────────────────────────────────────────────────────── + +const router = routerx(); + +router.use((req, res, next) => { + console.log(`Token Creator request: ${req.method} ${req.originalUrl}`); + next(); +}); + +// ── POST /create — Deploy a standard VRC-20 token ──────────────────────── + +/** + * @route POST /api/token/create + * @description Deploy a new VRC-20 token inscription on Vertcoin. + * + * @body {string} name - Human-readable token name + * @body {string} symbol - Token ticker (1–32 chars, alphanumeric) + * @body {number} maxSupply - Maximum token supply + * @body {number} limitPerMint - Max tokens per mint + * @body {number} [decimals=8] - Decimal places (0–18) + * @body {number} [feeRate=10] - Inscription fee rate (vertoshis/vbyte) + * @body {string} creatorAddress - Creator's VTC address + * + * @returns {object} 200 - { tick, inscription, inscribeCommand } + * @returns {object} 400 - { error, details } + * @returns {object} 409 - { error } if tick already exists + * + * @example + * ```sh + * curl -X POST http://localhost:6000/api/token/create \ + * -H 'Content-Type: application/json' \ + * -d '{ + * "name": "Vertcoin Gold", + * "symbol": "VTCG", + * "maxSupply": 21000000, + * "limitPerMint": 1000, + * "decimals": 8, + * "feeRate": 10, + * "creatorAddress": "vtc1qexample..." + * }' + * ``` + */ +router.post("/create", createLimiter, async (req: Request, res: Response) => { + try { + const { + name, + symbol, + maxSupply, + limitPerMint, + decimals = 8, + feeRate = 10, + creatorAddress, + } = req.body; + + // Validate required fields + if (!creatorAddress) { + return res.status(400).json({ error: "creatorAddress is required" }); + } + + // Check for duplicates + const tick = (symbol || "").toUpperCase(); + if (tokenStore.has(tick)) { + return res.status(409).json({ error: `Token "${tick}" already exists` }); + } + + // Validate params (returns errors array, doesn't throw) + const errors = validateTokenParams({ + name, + symbol, + maxSupply, + limitPerMint, + decimals, + }); + + if (errors.length > 0) { + return res.status(400).json({ error: "Validation failed", details: errors }); + } + + // Create inscription + const inscription = createVRC20(name, symbol, maxSupply, limitPerMint, decimals); + const inscribeCommand = generateInscribeCommand(inscription, feeRate); + + // Store token + const record: TokenRecord = { + tick, + name, + type: "vrc-20", + inscription, + inscribeCommand, + curve: null, + feeBps: 0, + creatorAddress, + createdAt: new Date(), + priceHistory: [], + accumulatedFees: { creator: 0, treasury: 0, buyback: 0 }, + }; + tokenStore.set(tick, record); + + return res.json({ + tick, + inscription, + inscribeCommand, + message: `VRC-20 token "${tick}" deploy inscription created. Run the inscribe command to broadcast.`, + }); + } catch (err: any) { + console.error("POST /create error:", err); + return res.status(500).json({ error: err.message || "Internal server error" }); + } +}); + +// ── POST /create-bonding — Create a bonding curve token ────────────────── + +/** + * @route POST /api/token/create-bonding + * @description Create a PumpFun-style bonding curve token on Vertcoin. + * + * @body {string} name - Human-readable token name + * @body {string} symbol - Token ticker + * @body {number} supply - Total token supply + * @body {number} curveSupply - Tokens on the bonding curve (e.g. 70% of supply) + * @body {number} lpReserve - Tokens reserved for LP (e.g. 30% of supply) + * @body {number} graduationVtc - VTC target to trigger graduation + * @body {number} feeBps - Trading fee in basis points (e.g. 100 = 1%) + * @body {number} [feeRate=10] - Inscription fee rate (vertoshis/vbyte) + * @body {string} creatorAddress - Creator's VTC address + * + * @returns {object} 200 - { tick, inscription, inscribeCommand, curveState } + * + * @example + * ```sh + * curl -X POST http://localhost:6000/api/token/create-bonding \ + * -H 'Content-Type: application/json' \ + * -d '{ + * "name": "My Pump Token", + * "symbol": "MYVTC", + * "supply": 1000000000, + * "curveSupply": 700000000, + * "lpReserve": 300000000, + * "graduationVtc": 85, + * "feeBps": 100, + * "feeRate": 10, + * "creatorAddress": "vtc1qexample..." + * }' + * ``` + */ +router.post("/create-bonding", createLimiter, async (req: Request, res: Response) => { + try { + const { + name, + symbol, + supply, + curveSupply, + lpReserve, + graduationVtc, + feeBps, + feeRate = 10, + creatorAddress, + } = req.body; + + if (!creatorAddress) { + return res.status(400).json({ error: "creatorAddress is required" }); + } + + const tick = (symbol || "").toUpperCase(); + if (tokenStore.has(tick)) { + return res.status(409).json({ error: `Token "${tick}" already exists` }); + } + + const errors = validateTokenParams({ + name, + symbol, + supply, + curveSupply, + lpReserve, + graduationVtc, + feeBps, + }); + + if (errors.length > 0) { + return res.status(400).json({ error: "Validation failed", details: errors }); + } + + const inscription = createBondingCurveToken( + name, symbol, supply, curveSupply, lpReserve, graduationVtc, feeBps + ); + const inscribeCommand = generateInscribeCommand(inscription, feeRate); + + // Initialize bonding curve engine + const curve = new BondingCurve(supply, curveSupply, lpReserve, graduationVtc); + + const now = Date.now(); + const record: TokenRecord = { + tick, + name, + type: "bonding-curve", + inscription, + inscribeCommand, + curve, + feeBps, + creatorAddress, + createdAt: new Date(), + priceHistory: [ + { timestamp: now, price: curve.getPrice(), tokensSold: 0, totalRaised: 0 }, + ], + accumulatedFees: { creator: 0, treasury: 0, buyback: 0 }, + }; + tokenStore.set(tick, record); + + return res.json({ + tick, + inscription, + inscribeCommand, + curveState: curve.toJSON(), + message: `Bonding curve token "${tick}" created. Inscribe to activate on-chain.`, + }); + } catch (err: any) { + console.error("POST /create-bonding error:", err); + return res.status(500).json({ error: err.message || "Internal server error" }); + } +}); + +// ── GET /:tick/info — Token info + curve state ─────────────────────────── + +/** + * @route GET /api/token/:tick/info + * @description Get token information and bonding curve state. + * + * @param {string} tick - Token ticker (URL param) + * @returns {object} 200 - Token info with curve state if bonding-curve type + * @returns {object} 404 - { error } if token not found + * + * @example + * ```sh + * curl http://localhost:6000/api/token/MYVTC/info + * ``` + */ +router.get("/:tick/info", async (req: Request, res: Response) => { + const tick = req.params.tick.toUpperCase(); + const token = tokenStore.get(tick); + + if (!token) { + return res.status(404).json({ error: `Token "${tick}" not found` }); + } + + return res.json({ + tick: token.tick, + name: token.name, + type: token.type, + creatorAddress: token.creatorAddress, + createdAt: token.createdAt.toISOString(), + feeBps: token.feeBps, + curveState: token.curve ? token.curve.toJSON() : null, + accumulatedFees: token.accumulatedFees, + }); +}); + +// ── POST /:tick/buy — Buy tokens on the bonding curve ──────────────────── + +/** + * @route POST /api/token/:tick/buy + * @description Buy tokens on the bonding curve using VTC. + * + * Fees are deducted first (40% creator / 40% treasury / 20% buyback), + * then the net amount is used to purchase tokens on the curve. + * + * @param {string} tick - Token ticker (URL param) + * @body {number} vtcAmount - Gross VTC to spend + * @body {string} buyerAddress - Buyer's VTC address + * + * @returns {object} 200 - { tokensOut, newPrice, priceImpact, totalRaised, fees } + * @returns {object} 400 - { error } if curve graduated or invalid amount + * + * @example + * ```sh + * curl -X POST http://localhost:6000/api/token/MYVTC/buy \ + * -H 'Content-Type: application/json' \ + * -d '{ "vtcAmount": 5, "buyerAddress": "vtc1q..." }' + * ``` + */ +router.post("/:tick/buy", tradeLimiter, async (req: Request, res: Response) => { + try { + const tick = req.params.tick.toUpperCase(); + const token = tokenStore.get(tick); + + if (!token) { + return res.status(404).json({ error: `Token "${tick}" not found` }); + } + if (token.type !== "bonding-curve" || !token.curve) { + return res.status(400).json({ error: `"${tick}" is not a bonding curve token` }); + } + if (token.curve.isGraduated()) { + return res.status(400).json({ + error: `"${tick}" has graduated — trade on the AMM pool instead`, + }); + } + + const { vtcAmount, buyerAddress } = req.body; + + if (!vtcAmount || vtcAmount <= 0) { + return res.status(400).json({ error: "vtcAmount must be positive" }); + } + if (!buyerAddress) { + return res.status(400).json({ error: "buyerAddress is required" }); + } + + // Calculate and deduct fees + const fees = calculateFees(vtcAmount, token.feeBps); + const netVtc = netAfterFees(vtcAmount, token.feeBps); + + // Execute buy on the curve + const result = token.curve.buy(netVtc); + + // Accumulate fees + token.accumulatedFees.creator += fees.creatorFee; + token.accumulatedFees.treasury += fees.treasuryFee; + token.accumulatedFees.buyback += fees.buybackFee; + + // Record price point + token.priceHistory.push({ + timestamp: Date.now(), + price: result.newPrice, + tokensSold: token.curve.tokensSold, + totalRaised: result.totalRaised, + }); + + return res.json({ + tick, + tokensOut: result.tokensOut, + newPrice: result.newPrice, + priceImpact: result.priceImpact, + totalRaised: result.totalRaised, + graduated: token.curve.isGraduated(), + fees: { + totalFee: fees.totalFee, + creatorFee: fees.creatorFee, + treasuryFee: fees.treasuryFee, + buybackFee: fees.buybackFee, + }, + }); + } catch (err: any) { + console.error(`POST /${req.params.tick}/buy error:`, err); + return res.status(500).json({ error: err.message || "Internal server error" }); + } +}); + +// ── POST /:tick/sell — Sell tokens on the bonding curve ────────────────── + +/** + * @route POST /api/token/:tick/sell + * @description Sell tokens back to the bonding curve for VTC. + * + * Fees are deducted from the VTC output. + * + * @param {string} tick - Token ticker (URL param) + * @body {number} tokenAmount - Tokens to sell + * @body {string} sellerAddress - Seller's VTC address + * + * @returns {object} 200 - { vtcOut, netVtcOut, newPrice, fees } + * + * @example + * ```sh + * curl -X POST http://localhost:6000/api/token/MYVTC/sell \ + * -H 'Content-Type: application/json' \ + * -d '{ "tokenAmount": 1000000, "sellerAddress": "vtc1q..." }' + * ``` + */ +router.post("/:tick/sell", tradeLimiter, async (req: Request, res: Response) => { + try { + const tick = req.params.tick.toUpperCase(); + const token = tokenStore.get(tick); + + if (!token) { + return res.status(404).json({ error: `Token "${tick}" not found` }); + } + if (token.type !== "bonding-curve" || !token.curve) { + return res.status(400).json({ error: `"${tick}" is not a bonding curve token` }); + } + if (token.curve.isGraduated()) { + return res.status(400).json({ + error: `"${tick}" has graduated — trade on the AMM pool instead`, + }); + } + + const { tokenAmount, sellerAddress } = req.body; + + if (!tokenAmount || tokenAmount <= 0) { + return res.status(400).json({ error: "tokenAmount must be positive" }); + } + if (!sellerAddress) { + return res.status(400).json({ error: "sellerAddress is required" }); + } + + // Execute sell on the curve + const result = token.curve.sell(tokenAmount); + + // Deduct fees from VTC output + const fees = calculateFees(result.vtcOut, token.feeBps); + const netVtcOut = netAfterFees(result.vtcOut, token.feeBps); + + // Accumulate fees + token.accumulatedFees.creator += fees.creatorFee; + token.accumulatedFees.treasury += fees.treasuryFee; + token.accumulatedFees.buyback += fees.buybackFee; + + // Record price point + token.priceHistory.push({ + timestamp: Date.now(), + price: result.newPrice, + tokensSold: token.curve.tokensSold, + totalRaised: token.curve.totalRaised, + }); + + return res.json({ + tick, + vtcOut: result.vtcOut, + netVtcOut, + newPrice: result.newPrice, + fees: { + totalFee: fees.totalFee, + creatorFee: fees.creatorFee, + treasuryFee: fees.treasuryFee, + buybackFee: fees.buybackFee, + }, + }); + } catch (err: any) { + console.error(`POST /${req.params.tick}/sell error:`, err); + return res.status(500).json({ error: err.message || "Internal server error" }); + } +}); + +// ── GET /:tick/price — Current price + chart data ──────────────────────── + +/** + * @route GET /api/token/:tick/price + * @description Get current token price and price history for charting. + * + * @param {string} tick - Token ticker (URL param) + * @query {number} [limit=100] - Max price history points to return + * + * @returns {object} 200 - { currentPrice, priceHistory, marketCap, ... } + * + * @example + * ```sh + * curl http://localhost:6000/api/token/MYVTC/price?limit=50 + * ``` + */ +router.get("/:tick/price", async (req: Request, res: Response) => { + const tick = req.params.tick.toUpperCase(); + const token = tokenStore.get(tick); + + if (!token) { + return res.status(404).json({ error: `Token "${tick}" not found` }); + } + + if (!token.curve) { + return res.json({ + tick, + type: "vrc-20", + message: "Standard VRC-20 tokens do not have a bonding curve price", + }); + } + + const limit = Math.min( + Math.max(1, parseInt(String(req.query.limit)) || 100), + 1000 + ); + + const currentPrice = token.curve.getPrice(); + const history = token.priceHistory.slice(-limit); + + // Market cap = current price * tokens sold (only sold tokens are circulating) + const marketCap = currentPrice * token.curve.tokensSold; + + return res.json({ + tick, + currentPrice, + marketCap, + tokensSold: token.curve.tokensSold, + tokensRemaining: token.curve.tokensRemaining, + totalRaised: token.curve.totalRaised, + graduationProgress: token.curve.graduationProgress, + graduated: token.curve.isGraduated(), + priceHistory: history, + }); +}); + +// ── POST /:tick/graduate — Trigger graduation to AMM ───────────────────── + +/** + * @route POST /api/token/:tick/graduate + * @description Trigger graduation of a bonding curve token to the VTC DEX AMM. + * + * Only callable once the graduation target has been reached. Allocates: + * - 90% of raised VTC → liquidity pool (paired with LP reserve tokens) + * - 5% → creator bonus + * - 5% → protocol treasury + * + * LP tokens are burned for permanent liquidity. + * + * Also executes any pending buyback with accumulated buyback fees. + * + * @param {string} tick - Token ticker (URL param) + * + * @returns {object} 200 - Graduation result + buyback result + * @returns {object} 400 - { error } if not yet eligible + * + * @example + * ```sh + * curl -X POST http://localhost:6000/api/token/MYVTC/graduate + * ``` + */ +router.post("/:tick/graduate", async (req: Request, res: Response) => { + try { + const tick = req.params.tick.toUpperCase(); + const token = tokenStore.get(tick); + + if (!token) { + return res.status(404).json({ error: `Token "${tick}" not found` }); + } + if (token.type !== "bonding-curve" || !token.curve) { + return res.status(400).json({ error: `"${tick}" is not a bonding curve token` }); + } + if (!token.curve.isGraduated()) { + return res.status(400).json({ + error: `"${tick}" has not reached graduation target`, + totalRaised: token.curve.totalRaised, + graduationTarget: token.curve.graduationTarget, + progress: `${(token.curve.graduationProgress * 100).toFixed(2)}%`, + }); + } + + // Execute graduation + const graduation = token.curve.graduate(); + + // Execute pending buyback + let buybackResult = null; + if (token.accumulatedFees.buyback > 0) { + const currentPrice = token.curve.getPrice(); + buybackResult = executeBuyback(token.accumulatedFees.buyback, currentPrice); + token.accumulatedFees.buyback = 0; + } + + return res.json({ + tick, + message: `"${tick}" has graduated to AMM! LP tokens burned for permanent liquidity.`, + graduation: { + lpVtc: graduation.lpVtc, + lpTokens: graduation.lpTokens, + creatorBonus: graduation.creatorBonus, + treasuryFee: graduation.treasuryFee, + }, + buyback: buybackResult, + accumulatedFees: token.accumulatedFees, + }); + } catch (err: any) { + console.error(`POST /${req.params.tick}/graduate error:`, err); + return res.status(500).json({ error: err.message || "Internal server error" }); + } +}); + +export default router; diff --git a/src/creator/tokenCreator.ts b/src/creator/tokenCreator.ts new file mode 100644 index 0000000..75e87f5 --- /dev/null +++ b/src/creator/tokenCreator.ts @@ -0,0 +1,440 @@ +/** + * @module tokenCreator + * @description Core token creation logic for VRC-20 deployment and PumpFun-style + * bonding curve launches on Vertcoin. + * + * Generates the JSON inscription payloads that get inscribed on-chain via + * ord-vertcoin, and provides validation for all token parameters. + * + * @example + * ```ts + * import { + * createVRC20, + * createBondingCurveToken, + * generateInscribeCommand, + * validateTokenParams, + * } from "./tokenCreator"; + * + * // Standard VRC-20 deploy + * const deploy = createVRC20("Vertcoin Gold", "VTCG", 21_000_000, 1_000, 8); + * const cmd = generateInscribeCommand(deploy, 10); + * console.log(cmd); + * + * // PumpFun-style bonding curve token + * const pump = createBondingCurveToken("MyToken", "MYVTC", 1e9, 7e8, 3e8, 85, 100); + * const pumpCmd = generateInscribeCommand(pump, 10); + * ``` + */ + +// ── Constants ──────────────────────────────────────────────────────────── + +/** ord-vertcoin default server URL (port 3080 per VTC convention) */ +const ORD_SERVER = process.env.ORD_SERVER || "http://127.0.0.1:3080"; + +/** Maximum tick length for VRC-20 tokens */ +const MAX_TICK_LENGTH = 32; + +/** Minimum tick length */ +const MIN_TICK_LENGTH = 1; + +/** Maximum decimals allowed */ +const MAX_DECIMALS = 18; + +/** Reserved tick names that cannot be used */ +const RESERVED_TICKS = new Set(["VTC", "BTC", "ETH", "SOL", "USDT", "USDC"]); + +// ── Types ──────────────────────────────────────────────────────────────── + +/** + * VRC-20 deploy inscription payload. + */ +export interface VRC20DeployInscription { + /** Protocol identifier */ + p: "vrc-20"; + /** Operation type */ + op: "deploy"; + /** Token ticker symbol */ + tick: string; + /** Maximum supply (string for arbitrary precision) */ + max: string; + /** Limit per mint (string for arbitrary precision) */ + lim: string; + /** Decimal places */ + dec: string; +} + +/** + * Bonding curve launch inscription payload. + */ +export interface BondingCurveInscription { + /** Protocol identifier */ + p: "vtc-launch"; + /** Operation type */ + op: "create"; + /** Token ticker symbol */ + tick: string; + /** Total token supply */ + supply: string; + /** Tokens available on the bonding curve */ + curve_supply: string; + /** Tokens reserved for DEX LP at graduation */ + lp_reserve: string; + /** VTC amount to trigger graduation */ + graduation_vtc: string; + /** Trading fee in basis points */ + fee_bps: number; +} + +/** + * Parameters for token validation. + */ +export interface TokenParams { + name: string; + symbol: string; + maxSupply?: number; + limitPerMint?: number; + decimals?: number; + supply?: number; + curveSupply?: number; + lpReserve?: number; + graduationVtc?: number; + feeBps?: number; +} + +/** + * A single validation error. + */ +export interface ValidationError { + field: string; + message: string; +} + +// ── Token Creation Functions ───────────────────────────────────────────── + +/** + * Creates a standard VRC-20 deploy inscription JSON payload. + * + * This generates the JSON that will be inscribed on the Vertcoin blockchain + * to deploy a new VRC-20 token. The inscription is written using ord-vertcoin. + * + * @param name - Human-readable token name (for metadata only, not inscribed) + * @param symbol - Token ticker (1–32 chars, alphanumeric + underscore) + * @param maxSupply - Maximum token supply + * @param limitPerMint - Maximum tokens per mint operation + * @param decimals - Decimal places (0–18, default 8) + * @returns VRC-20 deploy inscription object + * @throws {Error} If parameters are invalid + * + * @example + * ```ts + * const inscription = createVRC20("Vertcoin Gold", "VTCG", 21_000_000, 1_000, 8); + * // { + * // p: "vrc-20", + * // op: "deploy", + * // tick: "VTCG", + * // max: "21000000", + * // lim: "1000", + * // dec: "8" + * // } + * ``` + */ +export function createVRC20( + name: string, + symbol: string, + maxSupply: number, + limitPerMint: number, + decimals: number = 8 +): VRC20DeployInscription { + const errors = validateTokenParams({ + name, + symbol, + maxSupply, + limitPerMint, + decimals, + }); + + if (errors.length > 0) { + const msgs = errors.map((e) => `${e.field}: ${e.message}`).join("; "); + throw new Error(`Invalid VRC-20 parameters: ${msgs}`); + } + + return { + p: "vrc-20", + op: "deploy", + tick: symbol.toUpperCase(), + max: String(maxSupply), + lim: String(limitPerMint), + dec: String(decimals), + }; +} + +/** + * Creates a PumpFun-style bonding curve token inscription JSON payload. + * + * This generates the `vtc-launch` protocol inscription for deploying a token + * with an automatic bonding curve and graduation to AMM liquidity. + * + * @param name - Human-readable token name (metadata only) + * @param symbol - Token ticker (1–32 chars, alphanumeric + underscore) + * @param supply - Total token supply + * @param curveSupply - Tokens on the bonding curve (typically 70% of supply) + * @param lpReserve - Tokens reserved for DEX LP (typically 30% of supply) + * @param graduationVtc - VTC raised to trigger graduation to AMM + * @param feeBps - Trading fee in basis points (e.g. 100 = 1%) + * @returns Bonding curve inscription object + * @throws {Error} If parameters are invalid + * + * @example + * ```ts + * const inscription = createBondingCurveToken( + * "My Pump Token", + * "MYVTC", + * 1_000_000_000, // 1B supply + * 700_000_000, // 70% on curve + * 300_000_000, // 30% LP reserve + * 85, // graduate at 85 VTC + * 100 // 1% fee + * ); + * ``` + */ +export function createBondingCurveToken( + name: string, + symbol: string, + supply: number, + curveSupply: number, + lpReserve: number, + graduationVtc: number, + feeBps: number +): BondingCurveInscription { + const errors = validateTokenParams({ + name, + symbol, + supply, + curveSupply, + lpReserve, + graduationVtc, + feeBps, + }); + + if (errors.length > 0) { + const msgs = errors.map((e) => `${e.field}: ${e.message}`).join("; "); + throw new Error(`Invalid bonding curve parameters: ${msgs}`); + } + + return { + p: "vtc-launch", + op: "create", + tick: symbol.toUpperCase(), + supply: String(supply), + curve_supply: String(curveSupply), + lp_reserve: String(lpReserve), + graduation_vtc: String(graduationVtc), + fee_bps: feeBps, + }; +} + +/** + * Generates the ord-vertcoin CLI command string to inscribe a token. + * + * The command writes the inscription JSON to a temporary file and invokes + * `ord --data-dir ~/.vertcoin wallet inscribe` with the specified fee rate. + * + * @param inscriptionJson - The inscription payload (VRC-20 or bonding curve) + * @param feeRate - Fee rate in vertoshis per vbyte + * @returns Shell command string ready for execution + * @throws {Error} If feeRate <= 0 + * + * @example + * ```ts + * const deploy = createVRC20("VTCG", "VTCG", 21000000, 1000, 8); + * const cmd = generateInscribeCommand(deploy, 10); + * // ord --data-dir ~/.vertcoin wallet inscribe \ + * // --fee-rate 10 \ + * // --file /tmp/vtc-inscription-VTCG.json + * ``` + */ +export function generateInscribeCommand( + inscriptionJson: VRC20DeployInscription | BondingCurveInscription, + feeRate: number +): string { + if (feeRate <= 0) { + throw new Error("feeRate must be positive"); + } + + const tick = inscriptionJson.tick; + const jsonStr = JSON.stringify(inscriptionJson, null, 2); + const tmpFile = `/tmp/vtc-inscription-${tick}.json`; + + // Two-part command: write JSON to temp file, then inscribe + const writeCmd = `cat << 'EOF' > ${tmpFile}\n${jsonStr}\nEOF`; + + const inscribeCmd = [ + "ord", + "--data-dir ~/.vertcoin", + "wallet inscribe", + `--fee-rate ${feeRate}`, + "--content-type application/json", + `--file ${tmpFile}`, + ].join(" \\\n "); + + return `${writeCmd}\n\n${inscribeCmd}`; +} + +/** + * Validates token creation parameters and returns all errors found. + * + * Checks both VRC-20 and bonding curve fields based on which are provided. + * Returns an empty array if all parameters are valid. + * + * @param params - Token parameters to validate + * @returns Array of validation errors (empty if valid) + * + * @example + * ```ts + * const errors = validateTokenParams({ + * name: "", + * symbol: "VTCG", + * maxSupply: 21_000_000, + * limitPerMint: 1_000, + * decimals: 8, + * }); + * + * if (errors.length > 0) { + * errors.forEach((e) => console.error(`${e.field}: ${e.message}`)); + * } + * ``` + */ +export function validateTokenParams(params: TokenParams): ValidationError[] { + const errors: ValidationError[] = []; + + // ── Name validation ──────────────────────────────────────────────── + if (!params.name || params.name.trim().length === 0) { + errors.push({ field: "name", message: "Token name is required" }); + } else if (params.name.length > 64) { + errors.push({ field: "name", message: "Token name must be 64 characters or fewer" }); + } + + // ── Symbol (tick) validation ─────────────────────────────────────── + if (!params.symbol || params.symbol.trim().length === 0) { + errors.push({ field: "symbol", message: "Token symbol is required" }); + } else { + const sym = params.symbol.toUpperCase(); + if (sym.length < MIN_TICK_LENGTH || sym.length > MAX_TICK_LENGTH) { + errors.push({ + field: "symbol", + message: `Symbol must be ${MIN_TICK_LENGTH}–${MAX_TICK_LENGTH} characters`, + }); + } + if (!/^[A-Z0-9_]+$/.test(sym)) { + errors.push({ + field: "symbol", + message: "Symbol may only contain letters, digits, and underscores", + }); + } + if (RESERVED_TICKS.has(sym)) { + errors.push({ + field: "symbol", + message: `"${sym}" is a reserved ticker and cannot be used`, + }); + } + } + + // ── VRC-20 specific fields ───────────────────────────────────────── + if (params.maxSupply !== undefined) { + if (!Number.isFinite(params.maxSupply) || params.maxSupply <= 0) { + errors.push({ field: "maxSupply", message: "Must be a positive finite number" }); + } + if (params.maxSupply > 1e18) { + errors.push({ field: "maxSupply", message: "Exceeds maximum allowed supply (1e18)" }); + } + } + + if (params.limitPerMint !== undefined) { + if (!Number.isFinite(params.limitPerMint) || params.limitPerMint <= 0) { + errors.push({ field: "limitPerMint", message: "Must be a positive finite number" }); + } + if ( + params.maxSupply !== undefined && + params.limitPerMint > params.maxSupply + ) { + errors.push({ + field: "limitPerMint", + message: "Limit per mint cannot exceed max supply", + }); + } + } + + if (params.decimals !== undefined) { + if (!Number.isInteger(params.decimals) || params.decimals < 0) { + errors.push({ field: "decimals", message: "Must be a non-negative integer" }); + } + if (params.decimals > MAX_DECIMALS) { + errors.push({ + field: "decimals", + message: `Decimals must be ${MAX_DECIMALS} or fewer`, + }); + } + } + + // ── Bonding curve specific fields ────────────────────────────────── + if (params.supply !== undefined) { + if (!Number.isFinite(params.supply) || params.supply <= 0) { + errors.push({ field: "supply", message: "Must be a positive finite number" }); + } + } + + if (params.curveSupply !== undefined) { + if (!Number.isFinite(params.curveSupply) || params.curveSupply <= 0) { + errors.push({ field: "curveSupply", message: "Must be a positive finite number" }); + } + if ( + params.supply !== undefined && + params.curveSupply > params.supply + ) { + errors.push({ + field: "curveSupply", + message: "Curve supply cannot exceed total supply", + }); + } + } + + if (params.lpReserve !== undefined) { + if (!Number.isFinite(params.lpReserve) || params.lpReserve <= 0) { + errors.push({ field: "lpReserve", message: "Must be a positive finite number" }); + } + } + + // Verify curveSupply + lpReserve <= supply + if ( + params.supply !== undefined && + params.curveSupply !== undefined && + params.lpReserve !== undefined + ) { + if (params.curveSupply + params.lpReserve > params.supply) { + errors.push({ + field: "supply", + message: `curveSupply (${params.curveSupply}) + lpReserve (${params.lpReserve}) exceeds total supply (${params.supply})`, + }); + } + } + + if (params.graduationVtc !== undefined) { + if (!Number.isFinite(params.graduationVtc) || params.graduationVtc <= 0) { + errors.push({ field: "graduationVtc", message: "Must be a positive finite number" }); + } + } + + if (params.feeBps !== undefined) { + if (!Number.isInteger(params.feeBps) || params.feeBps < 0) { + errors.push({ field: "feeBps", message: "Must be a non-negative integer" }); + } + if (params.feeBps > 1_000) { + errors.push({ + field: "feeBps", + message: "Fee cannot exceed 1000 bps (10%)", + }); + } + } + + return errors; +}