apps/web/lib/solana/live.ts

Builds and sends the launch transaction. Shown whole, as it was in the repository when this site was built. Line numbers link: add #L12 to the address.

apps/web/lib/solana/live.ts271 lines
1// Live pump.fun launch. The deployer's wallet pays and signs; the coin's creator fees are split through pump.fun fee2// sharing, set and locked in the same launch:3//   create_v2(creator = deployer) → create_fee_sharing_config → update_fee_shares(sharingShareholders(...)) → [ATA + first buy]4// Shareholders: [{ Solary vault, 10 000 − creator bps }, { deployer, creator bps }?] (core/fees.ts). After5// update_fee_shares the split is locked (pump's one-time policy), the curve's creator is the per-mint sharing config,6// and every creator fee (bonding curve, then PumpSwap) accrues in that coin's own vault, which the keeper pays out with7// the permissionless distribute_creator_fees.8// With NEXT_PUBLIC_SOL_LUT (a lookup table of launchLookupAddresses()) it is one transaction; without it, two signed9// together: [create + fee-sharing config] then [locked split + first buy].10import '../launch/polyfill';11import {12  ComputeBudgetProgram,13  Connection,14  Keypair,15  PublicKey,16  TransactionMessage,17  VersionedTransaction,18  type AddressLookupTableAccount,19  type TransactionInstruction,20} from '@solana/web3.js';21import { feeSplit, sharingShareholders } from '@solary/core/fees';22import { RPC_PROXY, SOLARY_VAULT } from '@/lib/config';23import { LAUNCH_OVERHEAD_LAMPORTS, SOLARY_VAULT_OK, SOL_LUT, SOL_PRIORITY_MICRO_LAMPORTS } from './config';24import { buyExactSolInIxs, createFeeSharingConfigIx, createV2Ix, pumpPda, updateFeeSharesIx } from './pump';25import { SolanaLaunchError, type SolanaLaunchAdapter, type SolanaLaunchInput, type SolanaLaunchResult, type SolanaLaunchStep, type SolanaWalletLike } from './types';26 27export const MAX_TX_BYTES = 1232;28 29function rpcEndpoint(): string {30  if (RPC_PROXY.startsWith('http')) return RPC_PROXY;31  return (typeof window === 'undefined' ? 'http://localhost:3100' : window.location.origin) + RPC_PROXY;32}33 34const sizeOf = (tx: VersionedTransaction) => {35  try {36    return tx.serialize().length;37  } catch {38    return Infinity; // web3.js throws when the message itself is over the limit39  }40};41 42/** The locked split's shareholders for this deployer, as web3.js keys. */43export function launchShareholders(holdersBps: number, deployer: PublicKey): Array<{ address: PublicKey; bps: number }> {44  return sharingShareholders(feeSplit(holdersBps), SOLARY_VAULT, deployer.toBase58()).map((s) => ({ address: new PublicKey(s.address), bps: s.bps }));45}46 47export class LiveSolanaLaunchAdapter implements SolanaLaunchAdapter {48  readonly mode = 'live' as const;49  readonly vault = SOLARY_VAULT;50  private readonly mints = new Map<string, Keypair>();51  private lut: AddressLookupTableAccount | null | undefined;52 53  constructor(private readonly connection: Connection = new Connection(rpcEndpoint(), 'confirmed')) {}54 55  newMint(): string {56    const kp = Keypair.generate();57    const a = kp.publicKey.toBase58();58    this.mints.set(a, kp);59    return a;60  }61 62  private async lookupTable(): Promise<AddressLookupTableAccount | null> {63    if (this.lut !== undefined) return this.lut;64    if (!SOL_LUT) return (this.lut = null);65    try {66      this.lut = (await this.connection.getAddressLookupTable(new PublicKey(SOL_LUT))).value;67    } catch {68      this.lut = null;69    }70    return this.lut;71  }72 73  /** The launch as one or two unsigned transactions. Exported shape for tests and the devnet/mainnet dry run. */74  async build(input: SolanaLaunchInput, deployer: PublicKey): Promise<{ txs: VersionedTransaction[]; oneTransaction: boolean; blockhash: string; lastValidBlockHeight: number }> {75    if (!SOLARY_VAULT_OK) throw new SolanaLaunchError('config', 'the Solary vault address is not set on this site yet');76    const mint = new PublicKey(input.mint);77    const cfg = pumpPda.sharingConfig(mint);78    const price = ComputeBudgetProgram.setComputeUnitPrice({ microLamports: SOL_PRIORITY_MICRO_LAMPORTS });79    const create = createV2Ix({ mint, user: deployer, creator: deployer, name: input.name, symbol: input.symbol, uri: input.metadataUri });80    const config = createFeeSharingConfigIx({ mint, creator: deployer });81    const shares = updateFeeSharesIx({ mint, authority: deployer, currentShareholders: [deployer], shareholders: launchShareholders(input.holdersBps, deployer) });82    const buy = input.initialBuyLamports > 0n ? buyExactSolInIxs({ mint, user: deployer, creator: cfg, lamports: input.initialBuyLamports }) : [];83    const { blockhash, lastValidBlockHeight } = await this.connection.getLatestBlockhash('confirmed');84    const compile = (ixs: TransactionInstruction[], alts: AddressLookupTableAccount[] = []) =>85      new VersionedTransaction(new TransactionMessage({ payerKey: deployer, recentBlockhash: blockhash, instructions: ixs }).compileToV0Message(alts));86 87    const lut = await this.lookupTable();88    if (lut) {89      const one = compile([ComputeBudgetProgram.setComputeUnitLimit({ units: buy.length ? 640_000 : 480_000 }), price, create, config, shares, ...buy], [lut]);90      if (sizeOf(one) <= MAX_TX_BYTES) return { txs: [one], oneTransaction: true, blockhash, lastValidBlockHeight };91    }92    // create + config measured at 180–220k CU on mainnet (varies with PDA bumps): keep a wide margin93    const first = compile([ComputeBudgetProgram.setComputeUnitLimit({ units: 320_000 }), price, create, config]);94    const second = compile([ComputeBudgetProgram.setComputeUnitLimit({ units: buy.length ? 320_000 : 160_000 }), price, shares, ...buy]);95    if (sizeOf(first) > MAX_TX_BYTES || sizeOf(second) > MAX_TX_BYTES) {96      throw new SolanaLaunchError('too-large', 'the launch transaction is too large: shorten the name or the ticker');97    }98    return { txs: [first, second], oneTransaction: false, blockhash, lastValidBlockHeight };99  }100 101  /**102   * Rehearsal: everything a launch does up to and including the wallet's approval (and the mint's signature), then103   * the signed transactions are thrown away. Nothing is sent, no SOL moves. Used by the founder's private104   * /launch?rehearse=<key> link to see the wallet's real approval screen before launches open.105   */106  async rehearse(input: SolanaLaunchInput, wallet: SolanaWalletLike, onStep?: (s: SolanaLaunchStep) => void): Promise<{ oneTransaction: boolean; bytes: number[] }> {107    const { signed, built } = await this.signLaunch(input, wallet, onStep);108    this.mints.delete(input.mint);109    return { oneTransaction: built.oneTransaction, bytes: signed.map((t) => t.serialize().length) };110  }111 112  async launch(input: SolanaLaunchInput, wallet: SolanaWalletLike, onStep?: (s: SolanaLaunchStep) => void): Promise<SolanaLaunchResult> {113    const { signed, built } = await this.signLaunch(input, wallet, onStep);114    return this.sendLaunch(input, signed, built, onStep);115  }116 117  /** Build, simulate, have the wallet approve and the mint sign: the launch, ready to send. */118  private async signLaunch(input: SolanaLaunchInput, wallet: SolanaWalletLike, onStep?: (s: SolanaLaunchStep) => void) {119    const mintKp = this.mints.get(input.mint);120    if (!mintKp) throw new SolanaLaunchError('config', 'unknown mint: call newMint() first');121    const deployer = new PublicKey(wallet.publicKey);122 123    onStep?.('building');124    const balance = BigInt(await this.connection.getBalance(deployer, 'confirmed'));125    const need = input.initialBuyLamports + LAUNCH_OVERHEAD_LAMPORTS;126    if (balance < need) throw new SolanaLaunchError('insufficient-sol', `This wallet holds ${fmtSol(balance)}. The launch needs about ${fmtSol(need)} (your dev buy plus rent and fees).`);127    const built = await this.build(input, deployer);128 129    onStep?.('simulating');130    // the second transaction of a split launch depends on the first, so only the first can be simulated up front131    const sim = await this.connection.simulateTransaction(built.txs[0]!, { sigVerify: false, replaceRecentBlockhash: true, commitment: 'confirmed' });132    if (sim.value.err) {133      const logs = sim.value.logs ?? [];134      const funds = logs.some((l) => /insufficient lamports|insufficient funds/i.test(l));135      throw new SolanaLaunchError(136        funds ? 'insufficient-sol' : 'simulation',137        funds ? 'Not enough SOL in this wallet for the launch.' : `pump.fun would reject this launch right now (${JSON.stringify(sim.value.err)}). Nothing was sent.`,138        logs,139      );140    }141 142    onStep?.('signing');143    // The wallet signs first, on transactions nobody else has signed: Phantom blocks ("This dApp could be malicious")144    // a transaction that arrives already partly signed, and may add its own guard instructions before signing. The145    // mint keypair (made in this browser by newMint) signs whatever the wallet returns, after checking it still pays146    // from the deployer and still names the mint as a signer.147    if (!wallet.signAllTransactions && !wallet.signTransaction) throw new SolanaLaunchError('wallet', 'This wallet cannot sign transactions. Try Phantom, Solflare or Backpack.');148    const signAll = async () => {149      if (wallet.signAllTransactions) return wallet.signAllTransactions(built.txs);150      const out: VersionedTransaction[] = [];151      for (const t of built.txs) out.push(await wallet.signTransaction!(t));152      return out;153    };154    let signed: VersionedTransaction[];155    try {156      signed = await signAll();157    } catch (e) {158      // after "Proceed anyway" Phantom rejects the first request with 4100 (not authorized): ask once more159      if (walletErrorCode(e) !== 4100) throw new SolanaLaunchError('rejected', 'The wallet did not sign the launch. Nothing was sent.');160      try {161        signed = await signAll();162      } catch {163        throw new SolanaLaunchError('rejected', 'The wallet did not authorize the launch. Disconnect and reconnect your wallet, then try again. Nothing was sent.');164      }165    }166    for (const [i, tx] of signed.entries()) {167      const keys = tx.message.staticAccountKeys;168      const signers = keys.slice(0, tx.message.header.numRequiredSignatures).map((k) => k.toBase58());169      if (keys[0]?.toBase58() !== deployer.toBase58() || (i === 0 && !signers.includes(input.mint)))170        throw new SolanaLaunchError('wallet', 'The wallet returned a different transaction than the one built. Nothing was sent.');171    }172    signed[0]!.sign([mintKp]);173    return { signed, built };174  }175 176  private async sendLaunch(177    input: SolanaLaunchInput,178    signed: VersionedTransaction[],179    built: { oneTransaction: boolean; lastValidBlockHeight: number },180    onStep?: (s: SolanaLaunchStep) => void,181  ): Promise<SolanaLaunchResult> {182    const signatures: string[] = [];183    for (const [i, tx] of signed.entries()) {184      onStep?.(i === 0 ? 'sending' : 'finishing');185      let sig: string;186      try {187        sig = await this.connection.sendRawTransaction(tx.serialize(), { skipPreflight: i > 0, preflightCommitment: 'confirmed', maxRetries: 3 });188      } catch (e) {189        if (i > 0) throw new SolanaLaunchError('partial', 'The coin was created, but its fee split was not set yet. Finish the launch to lock it.', [], { mint: input.mint, signature: signatures[0] });190        throw new SolanaLaunchError('send', `Sending failed: ${(e as Error).message.split('\n')[0]}`);191      }192      signatures.push(sig);193      onStep?.('confirming');194      const conf = await pollConfirm(this.connection, sig, built.lastValidBlockHeight, tx.serialize());195      if (conf.value.err) {196        if (i > 0) throw new SolanaLaunchError('partial', 'The coin was created, but setting its fee split did not land. Finish the launch to lock it.', [], { mint: input.mint, signature: signatures[0] });197        const expired = conf.value.err === 'expired';198        throw new SolanaLaunchError(199          expired ? 'expired' : 'failed',200          expired ? 'The launch took too long to land and expired. Nothing was created; try again.' : 'The launch transaction failed on-chain. Nothing was created.',201        );202      }203    }204    this.mints.delete(input.mint);205    return { mint: input.mint, signature: signatures[0]!, signatures, oneTransaction: built.oneTransaction };206  }207 208  /** Finish a two-transaction launch whose second transaction did not land: set the locked split now. */209  async finish(mint: string, holdersBps: number, wallet: SolanaWalletLike): Promise<string> {210    if (!SOLARY_VAULT_OK) throw new SolanaLaunchError('config', 'the Solary vault address is not set on this site yet');211    const deployer = new PublicKey(wallet.publicKey);212    const m = new PublicKey(mint);213    const { blockhash, lastValidBlockHeight } = await this.connection.getLatestBlockhash('confirmed');214    const ixs = [215      ComputeBudgetProgram.setComputeUnitLimit({ units: 160_000 }),216      ComputeBudgetProgram.setComputeUnitPrice({ microLamports: SOL_PRIORITY_MICRO_LAMPORTS }),217      updateFeeSharesIx({ mint: m, authority: deployer, currentShareholders: [deployer], shareholders: launchShareholders(holdersBps, deployer) }),218    ];219    const tx = new VersionedTransaction(new TransactionMessage({ payerKey: deployer, recentBlockhash: blockhash, instructions: ixs }).compileToV0Message());220    let signed: VersionedTransaction;221    try {222      if (wallet.signTransaction) signed = await wallet.signTransaction(tx);223      else if (wallet.signAllTransactions) signed = (await wallet.signAllTransactions([tx]))[0]!;224      else throw new SolanaLaunchError('wallet', 'This wallet cannot sign transactions.');225    } catch (e) {226      if (e instanceof SolanaLaunchError) throw e;227      throw new SolanaLaunchError('rejected', 'The wallet did not sign. The split is still not set.');228    }229    const sig = await this.connection.sendRawTransaction(signed.serialize(), { preflightCommitment: 'confirmed' });230    const conf = await pollConfirm(this.connection, sig, lastValidBlockHeight, signed.serialize());231    if (conf.value.err) throw new SolanaLaunchError('partial', 'Setting the fee split did not land. Try again.', [], { mint });232    return sig;233  }234}235 236/**237 * Confirm by polling getSignatureStatuses over HTTP. The site talks to Solana through the /api/rpc proxy, which has238 * no WebSocket, so web3.js confirmTransaction (signature subscription) could miss a landed transaction and report it239 * expired. Re-broadcasts the signed bytes every few seconds until it lands or the blockhash expires.240 */241async function pollConfirm(connection: Connection, sig: string, lastValidBlockHeight: number, raw: Uint8Array): Promise<{ value: { err: unknown } }> {242  const started = Date.now();243  for (let i = 0; ; i++) {244    const { value } = await connection.getSignatureStatuses([sig], { searchTransactionHistory: false }).catch(() => ({ value: [null] }));245    const st = value[0];246    if (st && (st.confirmationStatus === 'confirmed' || st.confirmationStatus === 'finalized')) return { value: { err: st.err ?? null } };247    if (st?.err) return { value: { err: st.err } };248    if (i % 3 === 2) await connection.sendRawTransaction(raw, { skipPreflight: true, maxRetries: 0 }).catch(() => undefined);249    if (i % 2 === 1) {250      const height = await connection.getBlockHeight('confirmed').catch(() => 0);251      if (height > lastValidBlockHeight || Date.now() - started > 150_000) {252        const last = (await connection.getSignatureStatuses([sig], { searchTransactionHistory: true }).catch(() => ({ value: [null] }))).value[0];253        if (last && !last.err && (last.confirmationStatus === 'confirmed' || last.confirmationStatus === 'finalized')) return { value: { err: null } };254        return { value: { err: last?.err ?? 'expired' } };255      }256    }257    await new Promise((r) => setTimeout(r, 1500));258  }259}260 261/** A wallet's EIP-1193-style error code, through wallet-adapter's wrapper errors (`e.error`). */262function walletErrorCode(e: unknown): number | null {263  for (let x = e as { code?: unknown; error?: unknown } | undefined, i = 0; x && i < 3; x = x.error as typeof x, i++) {264    if (typeof x.code === 'number') return x.code;265  }266  return null;267}268 269function fmtSol(lamports: bigint): string {270  return `${(Number(lamports) / 1e9).toFixed(3)} SOL`;271}