scripts/go-live.ts
Makes $SOLARY the official coin and sets its split. Shown whole, as it was in the repository when this site was built. Line numbers link: add #L12 to the address.
scripts/go-live.ts221 lines
1// The $SOLARY go-live step: read the official coin from the chain and make it the site's official token.2// tsx scripts/go-live.ts <mint> read-only check: what the chain says about the coin (writes nothing)3// tsx scripts/go-live.ts <mint> --apply also upsert it into the database as the official coin (run on the server)4// Reads: the Token-2022 (or SPL) mint and its metadata, the metadata JSON (image, description, links), the pump.fun5// bonding curve (creator, graduated) and, when the creator is a fee-sharing config, its shareholders: if the Solary6// vault is one of them, the keeper claims $SOLARY's creator fees and splits the vault's part 50/50 buyback/platform.7// Otherwise the coin is shown and bought back, and its creator fees stay with whoever launched it.8// The logo is copied into UPLOADS_DIR (served at /media/official/...) so the site never depends on an IPFS gateway.9import './lib/env';10import { mkdir, writeFile } from 'node:fs/promises';11import path from 'node:path';12import { PublicKey } from '@solana/web3.js';13import { sql } from 'drizzle-orm';14import { closeDb, coins, getDb, keeperEvents } from '@solary/db';15import {16 PUMP_IDS,17 PUMP_SEEDS,18 decodeBondingCurve,19 decodeSharingConfig,20 decodeToken2022Metadata,21 isSolAddress,22 sharingConfigLocked,23} from '@solary/core/pump';24import { rpcUrl } from './lib/env';25 26const argv = process.argv.slice(2);27const mint = argv.find((a) => !a.startsWith('--')) ?? '';28const apply = argv.includes('--apply');29const vault = process.env.NEXT_PUBLIC_SOLARY_VAULT ?? '';30const siteUrl = (process.env.NEXT_PUBLIC_SITE_URL ?? 'https://solary.lol').replace(/\/+$/, '');31 32function fail(msg: string): never {33 console.error(`go-live: ${msg}`);34 process.exit(1);35}36 37if (!isSolAddress(mint)) fail(`"${mint}" is not a Solana address. Usage: tsx scripts/go-live.ts <mint> [--apply]`);38 39async function rpc<T>(method: string, params: unknown[]): Promise<T> {40 for (let attempt = 0; ; attempt++) {41 const r = await fetch(rpcUrl(), {42 method: 'POST',43 headers: { 'content-type': 'application/json' },44 body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),45 signal: AbortSignal.timeout(20_000),46 });47 if (r.status === 429 && attempt < 4) {48 await new Promise((res) => setTimeout(res, 1500 * (attempt + 1)));49 continue;50 }51 const j = (await r.json()) as { result?: T; error?: { message: string } };52 if (j.error) throw new Error(`${method}: ${j.error.message}`);53 return j.result as T;54 }55}56 57type Acct = { owner: string; lamports: number; data: [string, string] } | null;58const bytes = (a: NonNullable<Acct>) => new Uint8Array(Buffer.from(a.data[0], 'base64'));59const pda = (seeds: Array<string | PublicKey>, program: string) =>60 PublicKey.findProgramAddressSync(61 seeds.map((s) => (typeof s === 'string' ? Buffer.from(s) : s.toBuffer())),62 new PublicKey(program),63 )[0].toBase58();64 65const GATEWAYS = ['https://ipfs.io/ipfs/', 'https://w3s.link/ipfs/', 'https://dweb.link/ipfs/', 'https://gateway.pinata.cloud/ipfs/'];66function urlCandidates(u: string): string[] {67 const out = [u.replace(/^ipfs:\/\//, 'https://ipfs.io/ipfs/')];68 const m = u.match(/\/ipfs\/(.+)$/) ?? u.match(/^ipfs:\/\/(.+)$/);69 if (m) for (const g of GATEWAYS) out.push(g + m[1]);70 return [...new Set(out)];71}72async function fetchFirst(u: string, accept: string): Promise<Response | null> {73 for (const c of urlCandidates(u)) {74 try {75 const r = await fetch(c, { signal: AbortSignal.timeout(15_000), headers: { accept } });76 if (r.ok) return r;77 } catch {78 // next gateway79 }80 }81 return null;82}83 84const mintPk = new PublicKey(mint);85const curve = pda([PUMP_SEEDS.bondingCurve, mintPk], PUMP_IDS.pump);86const cfg = pda([PUMP_SEEDS.sharingConfig, mintPk], PUMP_IDS.pumpFees);87const res = await rpc<{ value: Acct[] }>('getMultipleAccounts', [[mint, curve, cfg], { encoding: 'base64', commitment: 'confirmed' }]);88const [mintA, curveA, cfgA] = res.value;89if (!mintA) fail('the mint account does not exist on mainnet (wrong address, or not landed yet)');90if (mintA.owner !== PUMP_IDS.token2022 && mintA.owner !== PUMP_IDS.token) fail(`not a token mint (owner ${mintA.owner})`);91 92const md = decodeToken2022Metadata(bytes(mintA));93if (!md) fail('no on-chain metadata on the mint (pump.fun coins carry Token-2022 metadata)');94const name = md.name.replace(/\0/g, '').trim().slice(0, 64);95const symbol = md.symbol.replace(/\0/g, '').trim().replace(/^\$/, '').toUpperCase().slice(0, 16);96 97let description = '';98let image: string | null = null;99const socials: { twitter?: string; telegram?: string; website?: string } = {};100if (md.uri) {101 const r = await fetchFirst(md.uri, 'application/json');102 if (r) {103 try {104 const j = (await r.json()) as Record<string, unknown>;105 if (typeof j.description === 'string') description = j.description.slice(0, 2000);106 if (typeof j.image === 'string') image = j.image;107 for (const k of ['twitter', 'telegram', 'website'] as const) if (typeof j[k] === 'string' && /^https?:\/\//.test(j[k] as string)) socials[k] = (j[k] as string).slice(0, 200);108 } catch {109 // metadata JSON unreadable: keep name and ticker110 }111 }112}113 114let deployer = '';115let graduated = false;116let curvePct = 0;117let vaultBps = 0;118let locked = false;119let shareholders: Array<{ address: string; bps: number }> = [];120if (curveA) {121 const bc = decodeBondingCurve(bytes(curveA));122 graduated = bc.complete;123 deployer = bc.creator;124 // pump.fun curves start with 793.1M real tokens for sale; progress = sold / initial125 const initialReal = 793_100_000_000_000n;126 curvePct = graduated ? 100 : Math.max(0, Math.min(100, Number(((initialReal - bc.realTokenReserves) * 10_000n) / initialReal) / 100));127 if (cfgA && bc.creator === cfg) {128 const sc = decodeSharingConfig(bytes(cfgA));129 shareholders = sc.shareholders;130 locked = sharingConfigLocked(sc);131 deployer = sc.admin;132 vaultBps = sc.shareholders.filter((s) => s.address === vault).reduce((a, s) => a + s.bps, 0);133 }134} else console.warn('go-live: no pump.fun bonding curve for this mint: shown as the official token, nothing to claim');135 136const supply = await rpc<{ value: { amount: string; decimals: number } }>('getTokenSupply', [mint]).catch(() => null);137 138// the split the keeper applies to what reaches the vault: none to holders, the vault's part half buyback, half platform139const creatorBps = 10_000 - vaultBps;140const buybackBps = Math.floor(vaultBps / 2);141const platformBps = vaultBps - buybackBps;142 143console.log(`$${symbol} · ${name}`);144console.log(` mint ${mint} (${mintA.owner === PUMP_IDS.token2022 ? 'Token-2022' : 'SPL Token'}${supply ? `, supply ${(Number(supply.value.amount) / 10 ** supply.value.decimals).toLocaleString('en-US')}` : ''})`);145console.log(` launched by ${deployer || 'unknown'}`);146console.log(` state ${curveA ? (graduated ? 'graduated to PumpSwap' : `on the bonding curve, ${curvePct.toFixed(1)}% sold`) : 'not a pump.fun curve'}`);147console.log(` image ${image ?? 'none found'}`);148if (shareholders.length) {149 console.log(` fee sharing ${locked ? 'locked' : 'EDITABLE'}: ${shareholders.map((s) => `${s.address === vault ? 'Solary vault' : s.address.slice(0, 6) + '…'} ${s.bps / 100}%`).join(', ')}`);150}151console.log(152 vaultBps > 0153 ? ` fees the vault gets ${vaultBps / 100}% of $${symbol}'s creator fees: the keeper claims them, ${buybackBps / 100}% buys back and burns $${symbol}, ${platformBps / 100}% goes to the treasury`154 : ` fees $${symbol}'s creator fees go to its launcher, not the vault (set the vault ${vault ? vault.slice(0, 6) + '…' : ''} as a fee-sharing recipient on pump.fun to fund buybacks from them)`,155);156 157if (!apply) {158 console.log('\nread-only check done (add --apply on the server to make it the official coin)');159 process.exit(0);160}161 162// ---------------------------------------------------------------- apply (server)163if (!process.env.DATABASE_URL) fail('DATABASE_URL is not set');164if (process.env.NEXT_PUBLIC_SOLARY_MINT && process.env.NEXT_PUBLIC_SOLARY_MINT !== mint) fail(`NEXT_PUBLIC_SOLARY_MINT is ${process.env.NEXT_PUBLIC_SOLARY_MINT}, not ${mint}: fix the env first`);165 166// keep the logo on our own disk so the site never waits on an IPFS gateway167let imageUri = image;168const uploads = process.env.UPLOADS_DIR;169if (image && uploads) {170 const r = await fetchFirst(image, 'image/*');171 const type = r?.headers.get('content-type') ?? '';172 const ext = type.includes('png') ? 'png' : type.includes('gif') ? 'gif' : type.includes('webp') ? 'webp' : type.includes('jpeg') || type.includes('jpg') ? 'jpg' : null;173 if (r && ext) {174 const buf = Buffer.from(await r.arrayBuffer());175 if (buf.length > 0 && buf.length < 15 * 1024 * 1024) {176 await mkdir(path.join(uploads, 'official'), { recursive: true });177 await writeFile(path.join(uploads, 'official', `${mint}.${ext}`), buf);178 imageUri = `${siteUrl}/media/official/${mint}.${ext}`;179 console.log(` logo copied to ${imageUri}`);180 }181 } else console.warn(' could not download the logo; using its original URL');182}183 184const db = getDb();185await db.transaction(async (t) => {186 await t.execute(sql`UPDATE coins SET official = false WHERE official AND mint <> ${mint}`);187 const values = {188 mint,189 name,190 symbol,191 description: description || 'The Solary token. Part of every Solary coin’s fees buys it back and burns it.',192 imageUri,193 metadataUri: md.uri || null,194 socials,195 collection: null,196 deployer: deployer || mint,197 holdersBps: 0,198 creatorBps,199 buybackBps,200 platformBps,201 official: true,202 status: 'active' as const,203 graduated,204 curvePct,205 };206 await t207 .insert(coins)208 .values(values)209 .onConflictDoUpdate({210 target: coins.mint,211 set: { name, symbol, description: values.description, imageUri, metadataUri: values.metadataUri, socials, collection: null, holdersBps: 0, creatorBps, buybackBps, platformBps, official: true, status: 'active', graduated, curvePct },212 });213 await t.insert(keeperEvents).values({214 coin: mint,215 action: 'official_launched',216 message: `$${symbol} is live: ${vaultBps > 0 ? `its creator fees buy back $${symbol} and fund Solary, and ` : ''}part of every Solary coin's fees buys it back and burns it`,217 level: 'info',218 });219});220console.log(`\n$${symbol} is now the official coin in the database`);221await closeDb();