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}