/** * Launch a coin on cell from a script, through the public API. * * node --import tsx launch-api.mts [x link] * * The keypair is the launcher: it pays, it signs, and it is written into the * coin as its creator, exactly as a wallet is on the site. This sends a real * mainnet launch and costs the launch fee plus the chain's rent and fees. * Set CELL_API to point at another deployment. * * The three calls: * 1. POST /api/v1/launch with JSON. The pad answers with a launch id, the * coin's address and a base64 transaction the launcher has not signed yet. * 2. Sign it with the launcher's key and nothing else, then POST the bytes to * /api/v1/launch/{id}/signed. The pad checks they are the exact message it * issued, adds the mint's signature and sends it. * 3. Poll GET /api/v1/launch/{id} until the status is live, expired or failed, * at most once a second; the route limits each caller to that. */ import { readFile } from 'node:fs/promises'; import { extname } from 'node:path'; import { Keypair, VersionedTransaction } from '@solana/web3.js'; const API = (process.env.SPLICE_API ?? 'http://localhost:3000').replace(/\/$/, ''); const IMAGE_TYPES: Record = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', }; const [keypairPath, imagePath, name, symbol, twitter] = process.argv.slice(2); if (!keypairPath || !imagePath || !name || !symbol) { throw new Error('usage: launch-api.mts [x link]'); } const imageContentType = IMAGE_TYPES[extname(imagePath).toLowerCase()]; if (!imageContentType) throw new Error('the picture must be a png, jpg or webp'); const launcher = Keypair.fromSecretKey( Uint8Array.from(JSON.parse(await readFile(keypairPath, 'utf8')) as number[]), ); /** Every refusal is `{ error, field? }`; this turns one into a thrown message. */ async function call(path: string, init?: RequestInit): Promise { const response = await fetch(`${API}${path}`, init); const raw = await response.text(); let body: T & { error?: string; field?: string }; try { body = JSON.parse(raw) as typeof body; } catch { throw new Error(`${path} ${response.status}: ${raw.slice(0, 200)}`); } if (!response.ok) { throw new Error(`${path} ${response.status}: ${body.error ?? 'refused'}${body.field ? ` (${body.field})` : ''}`); } return body; } // 1. Ask for the transaction. const created = await call<{ id: string; mint: string; transaction: string; feeLamports: string }>( '/api/v1/launch', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name, symbol, payer: launcher.publicKey.toBase58(), ...(twitter ? { twitter } : {}), imageBase64: (await readFile(imagePath)).toString('base64'), imageContentType, }), }, ); console.log(`mint ${created.mint}, about ${Number(created.feeLamports) / 1e9} SOL all in`); // 2. Sign with the launcher only. The mint's signature slot stays empty for the pad. const transaction = VersionedTransaction.deserialize(Buffer.from(created.transaction, 'base64')); transaction.sign([launcher]); await call(`/api/v1/launch/${created.id}/signed`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ transaction: Buffer.from(transaction.serialize()).toString('base64') }), }); // 3. Wait for it to land. A launch that has not settled in three minutes has // outlived its blockhash; check its status again later rather than poll forever. for (let poll = 0; ; poll += 1) { if (poll === 90) throw new Error(`launch ${created.id} has not settled; check GET /api/v1/launch/${created.id}`); const status = await call<{ status: string; signature?: string; error?: string }>( `/api/v1/launch/${created.id}`, ); if (status.status === 'live') { console.log(`live: ${API}/coin/${created.mint} (tx ${status.signature})`); break; } if (status.status === 'expired' || status.status === 'failed') { throw new Error(`launch ${status.status}${status.error ? `: ${status.error}` : ''}`); } await new Promise(resolve => setTimeout(resolve, 2_000)); }