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}