Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions supabase/migrations/20260708220000_vanity_mints.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
-- Vanity mint support for the OrbitX / OG Scan token launcher.
--
-- * vanity_mint_pool — pre-ground keypairs whose address ends with a suffix
-- (needed for long suffixes like "orbit" that can't be ground live).
-- * claim_vanity_mint — atomically hands out ONE unused keypair for a suffix
-- (FOR UPDATE SKIP LOCKED, so concurrent launches never collide).
-- * token_launches — record of confirmed launches.
--
-- SECURITY: secret_key is sensitive. These tables have RLS enabled with NO
-- public policies, so only the service-role key (server-side) can touch them.

/* ─── Pre-ground vanity keypair pool ─────────────────────────────────── */
create table if not exists public.vanity_mint_pool (
id uuid primary key default gen_random_uuid(),
address text not null unique, -- base58 mint pubkey (ends with suffix)
suffix text not null, -- normalized (lowercase) suffix
secret_key jsonb not null, -- 64-byte secret as JSON array
claimed_at timestamptz, -- null = available
created_at timestamptz not null default now()
);

-- Fast lookup of the next available key for a suffix.
create index if not exists vanity_mint_pool_available_idx
on public.vanity_mint_pool (suffix)
where claimed_at is null;

alter table public.vanity_mint_pool enable row level security;
-- No policies → unreachable by anon/authenticated; service role bypasses RLS.

/* ─── Atomic claim RPC ───────────────────────────────────────────────── */
create or replace function public.claim_vanity_mint(p_suffix text)
returns table (address text, secret_key jsonb)
language plpgsql
security definer
set search_path = public
as $$
declare
v_id uuid;
begin
select id into v_id
from public.vanity_mint_pool
where suffix = lower(p_suffix) and claimed_at is null
order by created_at
for update skip locked
limit 1;

if v_id is null then
return; -- pool empty for this suffix
end if;

update public.vanity_mint_pool
set claimed_at = now()
where id = v_id;

return query
select vmp.address, vmp.secret_key
from public.vanity_mint_pool vmp
where vmp.id = v_id;
end;
$$;

revoke all on function public.claim_vanity_mint(text) from public, anon, authenticated;

/* ─── Confirmed launch records ───────────────────────────────────────── */
create table if not exists public.token_launches (
id uuid primary key default gen_random_uuid(),
mint_address text not null unique,
tx_signature text not null,
name text,
symbol text,
launcher_wallet text,
metadata_uri text,
created_at timestamptz not null default now()
);

alter table public.token_launches enable row level security;

-- Launches are public info (they're on-chain); allow read, restrict writes to service role.
drop policy if exists token_launches_read on public.token_launches;
create policy token_launches_read on public.token_launches
for select using (true);
116 changes: 116 additions & 0 deletions web/api/_lib/vanity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* Server-side Solana vanity mint generation.
*
* A "vanity" mint is a Keypair whose base58 public key ends with a chosen
* suffix (e.g. "orb" → address ends in ...orb). Addresses are found by brute
* force: generate a keypair, check the suffix, repeat. Cost grows ~58x per
* extra character, so short suffixes ("orb") are grindable live inside a
* serverless request while long ones ("orbit") must be pre-ground into a pool.
*
* SECURITY: secret keys generated here MUST stay server-side. They are used to
* partial-sign the Pump create transaction and are never returned to the client.
*/

import { Keypair } from "@solana/web3.js";

/** Base58 alphabet used by Solana/Bitcoin (excludes 0 O I l). */
export const BASE58_ALPHABET =
"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";

export interface GrindOptions {
suffix: string;
caseInsensitive?: boolean;
/** Stop grinding after this many ms (default 8000). */
timeBudgetMs?: number;
/** Hard cap on attempts regardless of time (default Infinity). */
maxAttempts?: number;
}

export interface GrindResult {
keypair: Keypair;
address: string;
attempts: number;
elapsedMs: number;
}

/**
* Validate a suffix against the base58 alphabet. Returns the list of chars
* that can never appear in a base58 address (so the caller can fail fast
* instead of grinding forever).
*/
export function invalidSuffixChars(suffix: string, caseInsensitive: boolean): string[] {
const bad: string[] = [];
for (const ch of suffix) {
const variants = caseInsensitive
? Array.from(new Set([ch.toLowerCase(), ch.toUpperCase()]))
: [ch];
if (!variants.some((v) => BASE58_ALPHABET.includes(v))) bad.push(ch);
}
return bad;
}

/**
* Estimate the average number of keypairs needed to find one match, so we can
* decide up front whether a live grind is realistic or the pool is required.
*/
export function estimateAvgAttempts(suffix: string, caseInsensitive: boolean): number {
let favorable = 1;
for (const ch of suffix) {
const variants = caseInsensitive
? Array.from(new Set([ch.toLowerCase(), ch.toUpperCase()]))
: [ch];
const count = variants.filter((v) => BASE58_ALPHABET.includes(v)).length;
favorable *= Math.max(count, 1);
}
return Math.round(Math.pow(58, suffix.length) / favorable);
}

export function matchesSuffix(address: string, suffix: string, caseInsensitive: boolean): boolean {
if (caseInsensitive) return address.toLowerCase().endsWith(suffix.toLowerCase());
return address.endsWith(suffix);
}

/**
* Grind a vanity mint keypair live. Time-boxed so it can never exceed the
* serverless function budget. Returns null if not found within the budget.
*/
export function grindVanityMint(opts: GrindOptions): GrindResult | null {
const { suffix, caseInsensitive = true, timeBudgetMs = 8000, maxAttempts = Infinity } = opts;

const bad = invalidSuffixChars(suffix, caseInsensitive);
if (bad.length > 0) {
throw new Error(
`Suffix "${suffix}" contains characters that never appear in base58 addresses: ${bad.join(", ")} (base58 excludes 0, O, I, l).`,
);
}

const deadline = Date.now() + timeBudgetMs;
let attempts = 0;

while (Date.now() < deadline && attempts < maxAttempts) {
// Check in batches so Date.now() isn't called on every iteration.
for (let i = 0; i < 2000; i++) {
const kp = Keypair.generate();
const address = kp.publicKey.toBase58();
attempts++;
if (matchesSuffix(address, suffix, caseInsensitive)) {
return { keypair: kp, address, attempts, elapsedMs: timeBudgetMs - (deadline - Date.now()) };
}
}
}
return null;
}

/** Serialize a Keypair's 64-byte secret to a JSON array string (pool storage). */
export function secretToJson(kp: Keypair): string {
return JSON.stringify(Array.from(kp.secretKey));
}

/** Rebuild a Keypair from a JSON array secret produced by secretToJson. */
export function keypairFromJson(json: string): Keypair {
const arr = JSON.parse(json);
if (!Array.isArray(arr) || arr.length !== 64) {
throw new Error("Invalid secret key: expected a 64-byte JSON array.");
}
return Keypair.fromSecretKey(Uint8Array.from(arr));
}
116 changes: 116 additions & 0 deletions web/api/_lib/vanityMint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* Vanity mint SOURCE resolver.
*
* Strategy (in order):
* 1. If a pre-ground pool exists for the configured suffix, atomically claim
* one keypair from it (required for long suffixes like "orbit" that can't
* be ground live).
* 2. Otherwise grind live within the serverless time budget (fine for short
* suffixes like "orb").
*
* Config via env:
* VANITY_SUFFIX default "orb"
* VANITY_CASE_INSENSITIVE default "true"
* VANITY_LIVE_BUDGET_MS default 8000
* VANITY_USE_POOL "true" to try the pool first (default "false")
*/

import { Keypair } from "@solana/web3.js";
import {
grindVanityMint,
keypairFromJson,
estimateAvgAttempts,
invalidSuffixChars,
} from "./vanity";

export interface VanityConfig {
suffix: string;
caseInsensitive: boolean;
liveBudgetMs: number;
usePool: boolean;
}

export function loadVanityConfig(): VanityConfig {
return {
suffix: (process.env.VANITY_SUFFIX || "orb").trim(),
caseInsensitive: (process.env.VANITY_CASE_INSENSITIVE || "true") !== "false",
liveBudgetMs: Number(process.env.VANITY_LIVE_BUDGET_MS || 8000),
usePool: (process.env.VANITY_USE_POOL || "false") === "true",
};
}

const SUPABASE_URL = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL || "";
const SERVICE_ROLE =
process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY || "";

/**
* Atomically claim one unused keypair from the pool for this suffix via the
* `claim_vanity_mint` RPC (uses FOR UPDATE SKIP LOCKED, so concurrent launches
* never hand out the same key). Returns null if the pool is empty or unconfigured.
*/
async function claimFromPool(suffix: string): Promise<Keypair | null> {
if (!SUPABASE_URL || !SERVICE_ROLE) return null;
const res = await fetch(`${SUPABASE_URL}/rest/v1/rpc/claim_vanity_mint`, {
method: "POST",
headers: {
"Content-Type": "application/json",
apikey: SERVICE_ROLE,
Authorization: `Bearer ${SERVICE_ROLE}`,
},
body: JSON.stringify({ p_suffix: suffix }),
});
if (!res.ok) {
console.error("claim_vanity_mint failed:", res.status, await res.text().catch(() => ""));
return null;
}
const data = await res.json();
const secret = Array.isArray(data) ? data[0]?.secret_key : data?.secret_key;
if (!secret) return null; // pool empty
return keypairFromJson(typeof secret === "string" ? secret : JSON.stringify(secret));
}

export interface ResolvedMint {
keypair: Keypair;
address: string;
source: "pool" | "live";
}

/**
* Resolve a vanity mint keypair for the current launch.
* Throws a clear, user-facing error when neither source can produce one.
*/
export async function resolveVanityMint(cfg: VanityConfig): Promise<ResolvedMint> {
const bad = invalidSuffixChars(cfg.suffix, cfg.caseInsensitive);
if (bad.length > 0) {
throw new Error(
`Configured VANITY_SUFFIX "${cfg.suffix}" can't exist in a base58 address (bad chars: ${bad.join(", ")}). base58 excludes 0, O, I, l.`,
);
}

if (cfg.usePool) {
const claimed = await claimFromPool(cfg.suffix);
if (claimed) {
return { keypair: claimed, address: claimed.publicKey.toBase58(), source: "pool" };
}
// Pool empty: only safe to fall back to live grind if the suffix is cheap.
const avg = estimateAvgAttempts(cfg.suffix, cfg.caseInsensitive);
if (avg > 2_000_000) {
throw new Error(
`Vanity pool for "...${cfg.suffix}" is empty and this suffix is too expensive to grind live (~${avg.toLocaleString()} keys avg). Run the offline grinder to refill the pool.`,
);
}
}

const ground = grindVanityMint({
suffix: cfg.suffix,
caseInsensitive: cfg.caseInsensitive,
timeBudgetMs: cfg.liveBudgetMs,
});
if (!ground) {
const avg = estimateAvgAttempts(cfg.suffix, cfg.caseInsensitive);
throw new Error(
`Couldn't find a "...${cfg.suffix}" address within ${cfg.liveBudgetMs}ms (needs ~${avg.toLocaleString()} keys avg). Increase VANITY_LIVE_BUDGET_MS, raise the function timeout, or use a pre-ground pool.`,
);
}
return { keypair: ground.keypair, address: ground.address, source: "live" };
}
Loading