packages/core/src/launch.ts
The signed launch record. Shown whole, as it was in the repository when this site was built. Line numbers link: add #L12 to the address.
packages/core/src/launch.ts124 lines
1// The Solary launch record: plain text the deployer's wallet signs (ed25519 `signMessage`) to bind a pump.fun mint to2// an NFT collection and a fee split. The browser and the server build identical bytes from the same fields; the last3// line is canonical JSON so the record stays machine-checkable when a wallet shows it.4import { FEE_RULES, bpsToPct, feeSplit, holdersBpsError } from './fees';5import { isSolAddress } from './pump';6 7export const LAUNCH_DOMAIN = { name: 'Solary', version: '1' } as const;8 9/** Wallet Standard chain ids. */10export const SOL_CHAIN_IDS = ['solana:mainnet', 'solana:devnet'] as const;11export type SolChainId = (typeof SOL_CHAIN_IDS)[number];12 13export const NAME_MAX = 32;14export const TICKER_MAX = 10;15export const DESCRIPTION_MAX = 500;16export const IMAGE_MAX_BYTES = 2 * 1024 * 1024;17export const IMAGE_MIME = ['image/png', 'image/gif', 'image/jpeg', 'image/webp'] as const;18export const RESERVED_TICKERS = ['SOLARY', 'SOL', 'USDC', 'USDT'] as const;19export const TICKER_RE = /^[A-Z0-9]{1,10}$/;20 21export function normalizeTicker(input: string): string {22 return input.trim().replace(/^\$/, '').toUpperCase();23}24 25export function tickerError(symbol: string): string | null {26 if (!symbol) return 'ticker required';27 if (!TICKER_RE.test(symbol)) return `ticker: A–Z and 0–9, up to ${TICKER_MAX}`;28 if ((RESERVED_TICKERS as readonly string[]).includes(symbol)) return `$${symbol} is reserved`;29 return null;30}31 32export function nameError(name: string): string | null {33 const n = name.trim();34 if (!n) return 'name required';35 if ([...n].length > NAME_MAX) return `name: up to ${NAME_MAX} characters`;36 return null;37}38 39export interface LaunchRecord {40 name: string;41 /** uppercase ticker, as launched */42 symbol: string;43 /** base58 address of the NFT collection whose holders earn */44 collection: string;45 /** holders' share of the coin's creator fees, bps */46 holdersBps: number;47 /** base58 wallet that signs the record and pays for the launch */48 deployer: string;49 /** 0x… server nonce (api/launch/nonce) */50 nonce: string;51 chainId: SolChainId;52 /** base58 mint address of the coin */53 mint: string;54}55 56export function launchRecordJson(r: LaunchRecord): string {57 const split = feeSplit(r.holdersBps);58 return JSON.stringify({59 domain: LAUNCH_DOMAIN,60 name: r.name,61 symbol: r.symbol,62 collection: r.collection,63 holdersBps: r.holdersBps,64 creatorBps: split.creatorBps,65 buybackBps: split.buybackBps,66 platformBps: split.platformBps,67 deployer: r.deployer,68 nonce: r.nonce,69 chainId: r.chainId,70 mint: r.mint,71 });72}73 74/** The exact text the wallet signs (UTF-8). `collectionName` is display only and not part of the JSON. */75export function launchRecordText(r: LaunchRecord, collectionName?: string): string {76 const s = feeSplit(r.holdersBps);77 const parts = [`${bpsToPct(s.holdersBps)} to NFT holders by rarity`];78 if (s.creatorBps > 0) parts.push(`${bpsToPct(s.creatorBps)} to the creator`);79 parts.push(`${bpsToPct(s.buybackBps)} buys and burns $SOLARY`, `${bpsToPct(s.platformBps)} to Solary`);80 return [81 'Solary launch record (pump.fun)',82 `Coin: ${r.name} ($${r.symbol})`,83 `Mint: ${r.mint}`,84 `Collection: ${collectionName ? `${collectionName} ` : ''}${r.collection}`,85 `Creator fees: ${parts.join(', ')}`,86 `Deployer: ${r.deployer}`,87 '',88 launchRecordJson(r),89 ].join('\n');90}91 92export function launchRecordBytes(r: LaunchRecord, collectionName?: string): Uint8Array {93 return new TextEncoder().encode(launchRecordText(r, collectionName));94}95 96/** Field checks shared by the browser (before signing) and the server (before verifying). Empty = valid. */97export function launchRecordErrors(r: LaunchRecord): string[] {98 const errs: string[] = [];99 if (!isSolAddress(r.mint)) errs.push('mint: not a Solana address');100 if (!isSolAddress(r.deployer)) errs.push('deployer: not a Solana address');101 if (!isSolAddress(r.collection)) errs.push('collection: not a Solana address');102 if (!(SOL_CHAIN_IDS as readonly string[]).includes(r.chainId)) errs.push('chainId: expected solana:mainnet or solana:devnet');103 const n = nameError(r.name);104 if (n) errs.push(n);105 if (r.name !== r.name.trim()) errs.push('name: no leading or trailing spaces');106 if (normalizeTicker(r.symbol) !== r.symbol) errs.push('symbol must be uppercase A–Z0–9');107 const t = tickerError(r.symbol);108 if (t) errs.push(t);109 const h = holdersBpsError(r.holdersBps);110 if (h) errs.push(h);111 if (!/^0x[0-9a-fA-F]{64}$/.test(r.nonce)) errs.push('nonce: expected 32 bytes of hex');112 return errs;113}114 115/** The public line appended to the coin's description. */116export function holdersLine(collectionName: string, holdersBps: number = FEE_RULES.holdersDefaultBps): string {117 return `${bpsToPct(holdersBps)} of this coin's creator fees go to ${collectionName} holders via Solary`;118}119 120export function fullDescription(description: string, collectionName: string, holdersBps: number): string {121 const d = description.trim();122 const line = holdersLine(collectionName, holdersBps);123 return d ? `${d}\n\n${line}` : line;124}