// What the card app's API does: live counts, a wallet's blanks and cards, and the two // transactions (buy a blank, activate a card). Platform-neutral; functions/api/* wires it to // Cloudflare (secrets, D1 flags, rate limits). // // The server builds every transaction it signs. The browser only asks for "buy N in the open // wave" or "activate this blank as this tier", and the server derives everything else from // card.config.ts and the chain. So the co-signer never parses instructions it didn't write: // the only way to get its signature on a burn is to ask for an activation, and that transaction // always burns exactly the published amount (plan §5 and §7.2). import { address, appendTransactionMessageInstructions, compileTransaction, createKeyPairSignerFromBytes, createTransactionMessage, generateKeyPairSigner, getAddressEncoder, getBase58Decoder, getBase64EncodedWireTransaction, getCompiledTransactionMessageDecoder, getPublicKeyFromAddress, getSignatureFromTransaction, getTransactionDecoder, isAddress, partiallySignTransaction, pipe, setTransactionMessageFeePayer, setTransactionMessageLifetimeUsingBlockhash, verifySignature, type Address, type Blockhash, type Instruction, type KeyPairSigner, type Transaction, } from '@solana/kit' import { ACTIVATE_GROUP, BLANK, GLITCH_DECIMALS, TIER_MINT_LIMIT_ID, TIERS, WAVE_LABELS, burnBaseUnits, tierById, utcMillis, waveMintLimitId, type Cluster, type ClusterSettings, type TierId, type WaveLabel, } from '../card.config.ts' import type { CardAddresses } from './addresses.ts' import { ATA_PROGRAM, CANDY_GUARD_PROGRAM, CANDY_MACHINE_PROGRAM, COMPUTE_BUDGET_PROGRAM, MPL_CORE_PROGRAM, SYSTEM_PROGRAM, TOKEN_2022_PROGRAM, allocationAccounts, allocationPda, assetBurnAccounts, burnChecked, counterBlocked, decodeCandyMachine, decodeCoreAsset, decodeCounter, decodeToken2022Account, mintCounterPda, mintLimitAccounts, mintV1, setComputeUnitLimit, setComputeUnitPrice, solPaymentAccounts, thirdPartySignerAccounts, token2022Ata, type RawAccount, } from './chain.ts' import { RpcError, getAccounts, getLatestBlockhash, isTransient, type Rpc } from './rpc.ts' // ---------- Context and errors ---------- export interface Ctx { readonly cluster: Cluster readonly settings: ClusterSettings readonly addr: CardAddresses readonly rpc: Rpc readonly now: Date /** Flags set to '1' in D1 (see migrations/0001_card.sql). */ readonly flags: ReadonlySet /** Whether the RPC speaks Helius DAS; without it, asset lists use getProgramAccounts. */ readonly das: boolean } /** A refusal the app can show. `code` picks the screen state; `message` is safe to display. */ export class CardError extends Error { readonly status: number readonly code: string constructor(status: number, code: string, message: string) { super(message) this.name = 'CardError' this.status = status this.code = code } } const refuse = (status: number, code: string, message: string): never => { throw new CardError(status, code, message) } // Compute budgets: the local rehearsal (test/onchain.test.ts) measured up to ~120k for a buy // (the first one in a wave also creates the wallet's counter) and ~135k for an activation, varying // with the random keys' PDA bumps. The // real $GLITCH carries pump.fun's metadata extensions, which cost a little more to burn. export const BUY_COMPUTE_UNITS = 160_000 export const ACTIVATE_COMPUTE_UNITS = 180_000 const MIN_PRIORITY_MICROLAMPORTS = 10_000n const MAX_PRIORITY_MICROLAMPORTS = 2_000_000n /** SOL a wallet needs beyond the price per blank: asset rent, the first counter's rent, fees. */ export const BUY_FEE_ALLOWANCE = 5_000_000n /** * A fee payer must stay rent-exempt after paying (an empty system account needs 890,880 lamports), * or the transaction fails with InsufficientFundsForRent. Kept back from what a wallet can spend. */ export const WALLET_RESERVE = 890_880n /** SOL a wallet needs to activate: card rent and fees, with room for a tier's per-wallet counter (no tier has one since plan O14). */ export const ACTIVATE_FEE_ALLOWANCE = 6_000_000n /** Largest serialized Solana transaction: 1,232 bytes, which is 1,644 characters of base64. */ export const MAX_TX_BASE64 = 1644 // ---------- Settings helpers ---------- const timeOf = (iso: string | null): number | null => (iso ? utcMillis(iso) : null) /** Only valid times reach the app, so it never counts down to a time the server won't open at. */ const published = (iso: string | null): string | null => (timeOf(iso) === null ? null : iso) /** The wave on sale: the latest one whose opening time has passed. */ export function currentWave(settings: ClusterSettings, now: Date): WaveLabel | null { let open: WaveLabel | null = null for (const label of WAVE_LABELS) { const t = timeOf(settings.waves[label]) if (t !== null && t <= now.getTime()) open = label } return open } /** * A wave's sold count from its allocation counter. A wave without one can't sell (the guard refuses * with AllocationNotInitialized): setup makes all four with the machine, so that only happens when * someone squatted its address first (see setup.ts allocationState). It counts as sold out * everywhere (state, wallet view and buy agree) and the other waves still sell. */ const waveSold = (acc: RawAccount | null): number => (acc ? (decodeCounter(acc, 4) ?? BLANK.perWave) : BLANK.perWave) /** * The wave a sale comes from: the earliest opened wave that still has blanks left (so an earlier * wave's leftovers sell first) and, for a wallet, where that wallet is still under its five. A * wallet that bought five in wave 1 therefore buys from wave 2 once it opens, which keeps "five per * wallet in each wave" true. Null when nothing is open to it. `minted` is each wave's allocation * counter and `bought` the wallet's counter for each wave, in WAVE_LABELS order. */ export function sellingWave(settings: ClusterSettings, now: Date, minted: readonly number[], bought?: readonly number[]): WaveLabel | null { for (const [i, label] of WAVE_LABELS.entries()) { const t = timeOf(settings.waves[label]) if (t === null || t > now.getTime() || (minted[i] ?? 0) >= BLANK.perWave) continue if (bought && (bought[i] ?? 0) >= BLANK.perWalletPerWave) continue return label } return null } export function activationOpen(settings: ClusterSettings, now: Date): boolean { const t = timeOf(settings.activationOpensAt) return t !== null && t <= now.getTime() } const paused = (ctx: Ctx, scope: string) => ctx.flags.has('paused') || ctx.flags.has(`paused:${scope}`) /** Activations of any tier: the global pause, paused:activate (used while rotating the co-signer), or that tier's own flag. */ const activationPaused = (ctx: Ctx, tier: TierId) => paused(ctx, 'activate') || ctx.flags.has(`paused:${tier}`) /** * On mainnet nothing is sold or activated until the lock has run (plan §6.4): before it, the machine * authority could still raise a tier's cap. */ function requireLocked(ctx: Ctx) { if (ctx.cluster === 'mainnet' && !ctx.addr.locked) refuse(503, 'not-ready', "The card app isn't open yet.") } /** * Mainnet also checks the chain itself: a machine whose authority isn't retired could still have its * cap raised, and one whose mint authority isn't its guard could mint around every rule. */ function requireRetired(ctx: Ctx, cm: { authority: string; mintAuthority: string }, guard: string) { if (ctx.cluster === 'mainnet' && (cm.authority !== SYSTEM_PROGRAM || cm.mintAuthority !== guard)) refuse(503, 'not-ready', "The card app isn't open yet.") } const PROGRAMS = new Set([SYSTEM_PROGRAM, CANDY_GUARD_PROGRAM, CANDY_MACHINE_PROGRAM, MPL_CORE_PROGRAM, TOKEN_2022_PROGRAM, ATA_PROGRAM, COMPUTE_BUDGET_PROGRAM]) /** The co-signer must never be asked to pay for, or sign as, the buyer. */ function requireBuyer(ctx: Ctx, owner: Address) { if (owner === ctx.addr.cosigner || PROGRAMS.has(owner)) refuse(400, 'bad-request', "That address can't buy or activate cards.") } // ---------- State (public, cached briefly at the edge) ---------- export interface WaveState { label: WaveLabel opensAt: string | null limit: number minted: number } export interface TierState { id: TierId name: string supply: number minted: number perWallet: number | null /** Whole $GLITCH to burn, or null until the amounts are published. */ burn: string | null /** The paused: flag. */ paused: boolean } export interface CardState { ready: boolean cluster: Cluster paused: boolean glitchMint: string | null collections: { blank: string | null; card: string | null } blank: { priceLamports: number; supply: number; minted: number; perWalletPerWave: number; current: WaveLabel | null; paused: boolean; waves: WaveState[] } activation: { opensAt: string | null; open: boolean; paused: boolean } tiers: TierState[] } export async function getState(ctx: Ctx): Promise { const { addr, settings } = ctx const base: CardState = { ready: false, cluster: ctx.cluster, paused: ctx.flags.has('paused'), glitchMint: settings.glitchMint, collections: { blank: addr.blankCollection, card: addr.cardCollection }, blank: { priceLamports: BLANK.priceLamports, supply: BLANK.supply, minted: 0, perWalletPerWave: BLANK.perWalletPerWave, current: currentWave(settings, ctx.now), paused: ctx.flags.has('paused:buy'), waves: [] }, activation: { opensAt: published(settings.activationOpensAt), open: activationOpen(settings, ctx.now), paused: paused(ctx, 'activate') }, // Only amounts the activation would accept are published; a mistyped one keeps that tier closed. tiers: TIERS.map((t) => ({ id: t.id, name: t.name, supply: t.supply, minted: 0, perWallet: t.perWallet, burn: burnBaseUnits(settings.burn[t.id]) === null ? null : settings.burn[t.id], paused: ctx.flags.has(`paused:${t.id}`) })), } if (!addr.blank || TIERS.some((t) => !addr.tiers[t.id])) { base.blank.waves = WAVE_LABELS.map((label) => ({ label, opensAt: published(settings.waves[label]), limit: BLANK.perWave, minted: 0 })) return base } const blankMachine = address(addr.blank.machine) const blankGuard = address(addr.blank.guard) const allocations = await Promise.all(WAVE_LABELS.map((l) => allocationPda(waveMintLimitId(l), blankGuard, blankMachine))) const tierMachines = TIERS.map((t) => address(addr.tiers[t.id]!.machine)) const accounts = await getAccounts(ctx.rpc, [blankMachine, ...allocations, ...tierMachines]) const blank = decodeCandyMachine(accounts[0] ?? null) if (!blank) throw new Error('blank machine unreadable') base.blank.minted = blank.itemsRedeemed const waveMinted = WAVE_LABELS.map((_, i) => waveSold(accounts[1 + i] ?? null)) base.blank.current = sellingWave(settings, ctx.now, waveMinted) ?? currentWave(settings, ctx.now) base.blank.waves = WAVE_LABELS.map((label, i) => ({ label, opensAt: published(settings.waves[label]), limit: BLANK.perWave, minted: waveMinted[i]!, })) base.tiers = base.tiers.map((t, i) => { const m = decodeCandyMachine(accounts[1 + WAVE_LABELS.length + i] ?? null) if (!m) throw new Error(`${t.id} machine unreadable`) return { ...t, minted: m.itemsRedeemed } }) base.ready = true return base } // ---------- A wallet's blanks, cards and balances ---------- export interface OwnedAsset { id: string name: string /** Frozen assets (marketplace listings freeze them) can't be burned. Known only with DAS. */ frozen?: boolean } export interface WalletView { owner: string lamports: string /** $GLITCH in base units (6 decimals), as a string; "0" with no account. */ glitch: string blanks: OwnedAsset[] cards: Array /** The wave this wallet buys from next (see sellingWave), or null. */ buyWave: WaveLabel | null /** Blanks this wallet bought in that wave (counts toward the five-per-wave limit). */ boughtThisWave: number /** Cards activated, for the tiers with a per-wallet limit. */ activated: Partial> /** Waves and tiers this wallet can never mint from, because someone funded its counter first (see counterBlocked). */ blocked: Array /** When the server read this (its clock, not the device's): the app compares it with wave openings. */ readAt: string } export function parseOwner(raw: unknown): Address { if (typeof raw !== 'string' || !isAddress(raw)) return refuse(400, 'bad-request', 'That is not a Solana address.') return raw } const b58 = getBase58Decoder() const addressBytes = getAddressEncoder() const MAX_LISTED = 200 /** Core assets a wallet holds in one collection. */ async function listAssets(ctx: Ctx, owner: Address, collection: Address): Promise> { if (ctx.das) { // DAS takes named params (an object), per Helius's searchAssets reference. const out = await ctx.rpc.call<{ items: Array<{ id: string; burnt?: boolean; ownership?: { frozen?: boolean }; content?: { json_uri?: string; metadata?: { name?: string } } }> }>('searchAssets', { ownerAddress: owner, grouping: ['collection', collection], burnt: false, page: 1, limit: MAX_LISTED, }) return out.items .filter((a) => !a.burnt && typeof a.id === 'string') .map((a) => ({ id: a.id, name: a.content?.metadata?.name ?? '', uri: a.content?.json_uri ?? '', frozen: a.ownership?.frozen === true })) } // Without DAS: an AssetV1 (key 1) owned by `owner` whose update authority is Collection(collection). const ownerBytes = new Uint8Array(33) ownerBytes[0] = 1 ownerBytes.set(addressBytes.encode(owner), 1) const uaBytes = new Uint8Array(33) uaBytes[0] = 2 uaBytes.set(addressBytes.encode(collection), 1) const out = await ctx.rpc.call>('getProgramAccounts', [ MPL_CORE_PROGRAM, { encoding: 'base64', commitment: 'confirmed', filters: [{ memcmp: { offset: 0, bytes: b58.decode(ownerBytes) } }, { memcmp: { offset: 33, bytes: b58.decode(uaBytes) } }], }, ]) return out.slice(0, MAX_LISTED).flatMap((row) => { const asset = decodeCoreAsset({ owner: row.account.owner, lamports: BigInt(row.account.lamports), data: Uint8Array.from(atob(row.account.data[0]), (c) => c.charCodeAt(0)) }) return asset ? [{ id: row.pubkey, name: asset.name, uri: asset.uri, frozen: false }] : [] }) } export async function getWallet(ctx: Ctx, ownerRaw: unknown): Promise { const owner = parseOwner(ownerRaw) const { addr, settings } = ctx if (!addr.blank || !addr.blankCollection || !addr.cardCollection) return refuse(503, 'not-ready', 'The card app is not set up yet.') const blankMachine = address(addr.blank.machine) const blankGuard = address(addr.blank.guard) const limited = TIERS.filter((t) => t.perWallet !== null && addr.tiers[t.id]) const keys: Address[] = [owner] if (settings.glitchMint) keys.push(await token2022Ata(owner, address(settings.glitchMint))) // Every wave's allocation and this wallet's counter for it: the wave on sale depends on both. for (const l of WAVE_LABELS) keys.push(await allocationPda(waveMintLimitId(l), blankGuard, blankMachine)) for (const l of WAVE_LABELS) keys.push(await mintCounterPda(waveMintLimitId(l), owner, blankGuard, blankMachine)) for (const t of limited) { const m = addr.tiers[t.id]! keys.push(await mintCounterPda(TIER_MINT_LIMIT_ID, owner, address(m.guard), address(m.machine))) } const [accounts, blanks, cards] = await Promise.all([ getAccounts(ctx.rpc, keys), listAssets(ctx, owner, address(addr.blankCollection)), listAssets(ctx, owner, address(addr.cardCollection)), ]) let i = 0 const wallet = accounts[i++] ?? null const ata = settings.glitchMint ? decodeToken2022Account(accounts[i++] ?? null) : null const waveMinted = WAVE_LABELS.map(() => waveSold(accounts[i++] ?? null)) const blocked: Array = [] // A blocked wave counts as used up for this wallet, so the wave it buys from moves past it. const boughtPerWave = WAVE_LABELS.map((label) => { const acc = accounts[i++] ?? null if (!counterBlocked(acc)) return decodeCounter(acc, 2) ?? 0 blocked.push(label) return BLANK.perWalletPerWave }) // The wave this wallet would buy from; if it has its five in every open wave, report the general one. const wave = sellingWave(settings, ctx.now, waveMinted, boughtPerWave) ?? sellingWave(settings, ctx.now, waveMinted) const bought = wave ? boughtPerWave[WAVE_LABELS.indexOf(wave)]! : 0 const activated: Partial> = {} for (const t of limited) { const acc = accounts[i++] ?? null if (counterBlocked(acc)) blocked.push(t.id) activated[t.id] = counterBlocked(acc) ? t.perWallet! : (decodeCounter(acc, 2) ?? 0) } const tierOfUri = new Map() if (addr.uris) for (const t of TIERS) tierOfUri.set(addr.uris.tiers[t.id], t.id) return { owner, lamports: String(wallet?.lamports ?? 0n), glitch: String(ata && ata.mint === settings.glitchMint && ata.owner === owner ? ata.amount : 0n), blanks: blanks.map(({ id, name, frozen }) => ({ id, name, frozen })), cards: cards.map(({ id, name, uri }) => ({ id, name, tier: tierOfUri.get(uri) ?? null })), buyWave: wave, boughtThisWave: bought, activated, blocked, readAt: ctx.now.toISOString(), } } // ---------- Building transactions ---------- /** The 75th percentile of recent priority fees on these accounts, clamped; a fixed fallback if the RPC can't say. */ export async function priorityFee(ctx: Ctx, writable: Address[]): Promise { try { const fees = await ctx.rpc.call>('getRecentPrioritizationFees', [writable], { timeoutMs: 3000, retries: 0 }) const sorted = fees.map((f) => BigInt(f.prioritizationFee)).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) const p75 = sorted[Math.floor(sorted.length * 0.75)] ?? 0n return p75 < MIN_PRIORITY_MICROLAMPORTS ? MIN_PRIORITY_MICROLAMPORTS : p75 > MAX_PRIORITY_MICROLAMPORTS ? MAX_PRIORITY_MICROLAMPORTS : p75 } catch { return MIN_PRIORITY_MICROLAMPORTS * 5n } } async function compileAndSign(feePayer: Address, ixs: Instruction[], blockhash: string, lastValidBlockHeight: number, signers: CryptoKeyPair[]): Promise { const message = pipe( createTransactionMessage({ version: 0 }), (m) => setTransactionMessageFeePayer(feePayer, m), (m) => setTransactionMessageLifetimeUsingBlockhash({ blockhash: blockhash as Blockhash, lastValidBlockHeight: BigInt(lastValidBlockHeight) }, m), (m) => appendTransactionMessageInstructions(ixs, m), ) const signed = await partiallySignTransaction(signers, compileTransaction(message)) const wire = getBase64EncodedWireTransaction(signed) if (wire.length > MAX_TX_BASE64) throw new Error(`transaction too large (${wire.length} base64 chars)`) return wire } export interface BuiltTransactions { /** Base64 wire transactions, already signed by everyone except the wallet. */ txs: string[] /** The address of the asset each transaction creates, in the same order. */ assets: string[] lastValidBlockHeight: number } export function parseCount(raw: unknown): number { if (typeof raw !== 'number' || !Number.isInteger(raw) || raw < 1 || raw > BLANK.perWalletPerWave) { return refuse(400, 'bad-request', `Choose between 1 and ${BLANK.perWalletPerWave} blanks.`) } return raw } /** * Transactions that each mint one blank in the wave on sale to `owner`, who pays 0.1 SOL a blank. * Co-signed like activations: the blank guard requires the co-signer too, so the pause flags and * the app's limits hold on-chain and nobody can mint around the app. */ export async function buildBuy(ctx: Ctx, cosigner: KeyPairSigner, input: { owner: unknown; count: unknown }): Promise { const owner = parseOwner(input.owner) const want = parseCount(input.count) const { addr, settings } = ctx if (!addr.blank || !addr.blankCollection || !settings.payout || !addr.cosigner) return refuse(503, 'not-ready', 'Blank sales are not set up yet.') if (cosigner.address !== addr.cosigner) throw new Error('co-signer key does not match addresses file') // The whitepaper promises blanks only after $GLITCH launches: no coin address, no sale. if (!settings.glitchMint) return refuse(409, 'wave-closed', 'Blank sales open after $GLITCH launches.') requireLocked(ctx) requireBuyer(ctx, owner) if (paused(ctx, 'buy')) return refuse(503, 'paused', 'Blank sales are paused for a moment. Nothing was charged.') if (!currentWave(settings, ctx.now)) return refuse(409, 'wave-closed', 'Blanks are not on sale right now.') const machine = address(addr.blank.machine) const guard = address(addr.blank.guard) const allocations = await Promise.all(WAVE_LABELS.map((l) => allocationPda(waveMintLimitId(l), guard, machine))) const counters = await Promise.all(WAVE_LABELS.map((l) => mintCounterPda(waveMintLimitId(l), owner, guard, machine))) const accounts = await getAccounts(ctx.rpc, [machine, ...allocations, ...counters, owner]) const cm = decodeCandyMachine(accounts[0] ?? null) const minted = WAVE_LABELS.map((_, i) => waveSold(accounts[1 + i] ?? null)) const counterAccounts = WAVE_LABELS.map((_, i) => accounts[1 + WAVE_LABELS.length + i] ?? null) // Only waves that would otherwise sell to it count: a blocked wave that isn't open, or is sold // out, isn't why this wallet can't buy now. const blockedWaves = WAVE_LABELS.filter((label, i) => { const t = timeOf(settings.waves[label]) return counterBlocked(counterAccounts[i]!) && t !== null && t <= ctx.now.getTime() && (minted[i] ?? 0) < BLANK.perWave }) // A blocked wave is as good as used up for this wallet: it buys from the next one instead. const boughtPer = counterAccounts.map((acc) => (counterBlocked(acc) ? BLANK.perWalletPerWave : decodeCounter(acc, 2))) const wallet = accounts[1 + 2 * WAVE_LABELS.length] ?? null if (!cm || boughtPer.some((x) => x === null)) throw new Error('blank machine state unreadable') requireRetired(ctx, cm, guard) if (cm.itemsRedeemed >= cm.itemsAvailable) return refuse(409, 'sold-out', 'Every blank has been sold.') const wave = sellingWave(settings, ctx.now, minted as number[], boughtPer as number[]) if (!wave) { // Stock left somewhere open, but not for this wallet: it has its five in every open wave, or // someone blocked it there. const open = sellingWave(settings, ctx.now, minted as number[]) if (open && blockedWaves.length) return refuse(409, 'wallet-blocked', BLOCKED_BUY) if (open) return refuse(409, 'wallet-limit', 'This wallet has bought as many blanks as one wallet can right now.') return refuse(409, 'sold-out', 'The blanks on sale now are sold out.') } const w = WAVE_LABELS.indexOf(wave) const id = waveMintLimitId(wave) const allocation = allocations[w]! const bought = boughtPer[w]! const left = Math.min(cm.itemsAvailable - cm.itemsRedeemed, BLANK.perWave - minted[w]!) const allowed = BLANK.perWalletPerWave - bought if (allowed <= 0) return refuse(409, 'wallet-limit', 'This wallet has bought as many blanks as one wallet can right now.') const perBlank = BigInt(BLANK.priceLamports) + BUY_FEE_ALLOWANCE const spendable = (wallet?.lamports ?? 0n) - WALLET_RESERVE const affordable = spendable > 0n ? Number(spendable / perBlank) : 0 if (affordable < 1) return refuse(409, 'no-sol', `Each blank needs about ${solUi(perBlank + WALLET_RESERVE)} SOL in the wallet (0.1 SOL plus network fees, and the small balance every wallet must keep).`) const count = Math.min(want, left, allowed, affordable) const [fee, { blockhash, lastValidBlockHeight }] = await Promise.all([priorityFee(ctx, [machine, allocation]), getLatestBlockhash(ctx.rpc)]) const payout = address(settings.payout) const collection = address(addr.blankCollection) // GuardSet order: solPayment, (startDate: no accounts), thirdPartySigner, mintLimit, allocation. const remaining = [ ...solPaymentAccounts(payout), ...thirdPartySignerAccounts(cosigner.address), ...(await mintLimitAccounts(id, owner, guard, machine)), ...(await allocationAccounts(id, guard, machine)), ] const txs: string[] = [] const assets: string[] = [] for (let n = 0; n < count; n++) { const asset = await generateKeyPairSigner() const ixs = [setComputeUnitLimit(BUY_COMPUTE_UNITS), setComputeUnitPrice(fee), await mintV1({ guard, machine, collection, wallet: owner, asset: asset.address, group: wave, remaining })] txs.push(await compileAndSign(owner, ixs, blockhash, lastValidBlockHeight, [asset.keyPair, cosigner.keyPair])) assets.push(asset.address) } return { txs, assets, lastValidBlockHeight, wave, count } } export function parseTier(raw: unknown): TierId { const t = typeof raw === 'string' ? tierById(raw) : undefined return t ? t.id : refuse(400, 'bad-request', 'Pick a tier: Debit, Gold, Platinum, Black or Infinite.') } /** * One transaction that burns the blank and the tier's $GLITCH and mints the card, signed by the * new card's key and the co-signer. The wallet's signature is the last one it needs. */ export async function buildActivate(ctx: Ctx, cosigner: KeyPairSigner, input: { owner: unknown; blank: unknown; tier: unknown }): Promise { const owner = parseOwner(input.owner) if (typeof input.blank !== 'string' || !isAddress(input.blank)) return refuse(400, 'bad-request', 'That is not a blank card address.') const blank = input.blank const tier = tierById(parseTier(input.tier))! const { addr, settings } = ctx const machines = addr.tiers[tier.id] if (!machines || !addr.blankCollection || !addr.cardCollection || !addr.cosigner || !settings.glitchMint) return refuse(503, 'not-ready', 'Activation is not set up yet.') if (cosigner.address !== addr.cosigner) throw new Error('co-signer key does not match addresses file') requireLocked(ctx) requireBuyer(ctx, owner) const burnUi = settings.burn[tier.id] const amount = burnBaseUnits(burnUi) if (!activationOpen(settings, ctx.now) || amount === null) return refuse(409, 'activation-closed', 'Activation is not open yet.') if (activationPaused(ctx, tier.id)) return refuse(503, 'paused', `${tier.name} activations are paused for a moment. Nothing was burned.`) const machine = address(machines.machine) const guard = address(machines.guard) const mint = address(settings.glitchMint) const ata = await token2022Ata(owner, mint) const counter = tier.perWallet !== null ? await mintCounterPda(TIER_MINT_LIMIT_ID, owner, guard, machine) : null const accounts = await getAccounts(ctx.rpc, [machine, blank, ata, owner, ...(counter ? [counter] : [])]) const [m, b, t, w, c] = accounts as Array const cm = decodeCandyMachine(m ?? null) if (!cm) throw new Error(`${tier.id} machine unreadable`) requireRetired(ctx, cm, guard) const asset = decodeCoreAsset(b ?? null) if (!asset || asset.owner !== owner || asset.collection !== addr.blankCollection) return refuse(409, 'not-your-blank', "That blank isn't in this wallet any more. If it has another, press Activate again.") if (cm.itemsRedeemed >= cm.itemsAvailable) return refuse(409, 'tier-sold-out', `Every ${tier.name} card has been activated.`) if (counter) { if (counterBlocked(c ?? null)) return refuse(409, 'wallet-blocked', blockedTier(tier.name)) const used = decodeCounter(c ?? null, 2) if (used === null) throw new Error('mint counter unreadable') if (used >= tier.perWallet!) return refuse(409, 'tier-limit', `One ${tier.name} card per wallet, and this wallet has already activated one.`) } const tokens = decodeToken2022Account(t ?? null) if (!tokens || tokens.mint !== mint || tokens.owner !== owner || tokens.amount < amount) { return refuse(409, 'no-glitch', `${tier.name} needs ${Number(burnUi).toLocaleString('en-US', { maximumFractionDigits: GLITCH_DECIMALS })} $GLITCH in this wallet.`) } if (tokens.state !== 1) return refuse(409, 'no-glitch', "This wallet's $GLITCH account is frozen, so it can't burn.") if ((w?.lamports ?? 0n) < ACTIVATE_FEE_ALLOWANCE + WALLET_RESERVE) return refuse(409, 'no-sol', `Activating needs about ${solUi(ACTIVATE_FEE_ALLOWANCE + WALLET_RESERVE)} SOL in the wallet, for network fees and the small balance every wallet must keep.`) const card = await generateKeyPairSigner() const remaining = [ ...thirdPartySignerAccounts(cosigner.address), ...(counter ? await mintLimitAccounts(TIER_MINT_LIMIT_ID, owner, guard, machine) : []), ...assetBurnAccounts(blank, address(addr.blankCollection)), ] const [fee, { blockhash, lastValidBlockHeight }] = await Promise.all([priorityFee(ctx, [machine]), getLatestBlockhash(ctx.rpc)]) const ixs = [ setComputeUnitLimit(ACTIVATE_COMPUTE_UNITS), setComputeUnitPrice(fee), burnChecked(ata, mint, owner, amount, GLITCH_DECIMALS), await mintV1({ guard, machine, collection: address(addr.cardCollection), wallet: owner, asset: card.address, group: ACTIVATE_GROUP, remaining }), ] const tx = await compileAndSign(owner, ixs, blockhash, lastValidBlockHeight, [card.keyPair, cosigner.keyPair]) return { txs: [tx], assets: [card.address], lastValidBlockHeight } } // ---------- Sending and confirming ---------- const fromBase64 = (s: string) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0)) const MINT_V1_DISCRIMINATOR = [145, 98, 192, 118, 184, 147, 118, 104] /** Which of ours a transaction is: a blank purchase, or an activation of one tier. */ export type Shape = { kind: 'buy' } | { kind: 'activate'; tier: TierId } /** * Exactly the shape buildBuy and buildActivate produce, so the relay (and our Helius quota) carries * nothing else: two compute-budget instructions, then either one blank mint, or one Token-2022 * BurnChecked plus one tier mint. No lookup tables. */ function shapeOf( ctx: Ctx, msg: { staticAccounts: Address[]; instructions: Array<{ programAddressIndex: number; accountIndices?: number[]; data?: Uint8Array }>; addressTableLookups?: unknown[] }, ): Shape | null { if (msg.addressTableLookups && msg.addressTableLookups.length > 0) return null const program = (ix: { programAddressIndex: number }) => msg.staticAccounts[ix.programAddressIndex] const ixs = msg.instructions const budget = ixs.slice(0, 2) if (budget.length !== 2 || budget.some((ix) => program(ix) !== COMPUTE_BUDGET_PROGRAM || (ix.data?.[0] !== 2 && ix.data?.[0] !== 3))) return null const rest = ixs.slice(2) const mint = rest[rest.length - 1] if (!mint || program(mint) !== CANDY_GUARD_PROGRAM || !MINT_V1_DISCRIMINATOR.every((b, i) => mint.data?.[i] === b)) return null const machine = msg.staticAccounts[mint.accountIndices?.[2] ?? -1] if (machine === undefined) return null if (rest.length === 1) return machine === ctx.addr.blank?.machine ? { kind: 'buy' } : null const burn = rest[0]! const tier = TIERS.find((t) => ctx.addr.tiers[t.id]?.machine === machine) return rest.length === 2 && program(burn) === TOKEN_2022_PROGRAM && burn.data?.[0] === 15 && tier ? { kind: 'activate', tier: tier.id } : null } /** * The co-signer is one of the signers and every signature checks out against the message. So each * transaction relayed was built by our own (rate-limited) build endpoints, and a wallet that altered * it after we signed breaks ours. The network would refuse either, but only after a call to our RPC. */ async function signaturesValid(tx: Transaction, cosigner: string | null): Promise { if (!cosigner || !(cosigner in tx.signatures)) return false for (const [signer, sig] of Object.entries(tx.signatures)) { if (!sig || !(await verifySignature(await getPublicKeyFromAddress(signer as Address), sig, tx.messageBytes))) return false } return true } /** * The wallet's per-wallet counters a relayed mint can create: one per wave, or the tier's record. * A tier without a perWallet limit (none has one since plan O14) never creates its record, so that * address can't be the one taken. */ async function counterAddresses(ctx: Ctx, shape: Shape, owner: Address): Promise { if (shape.kind === 'buy') { const b = ctx.addr.blank! return Promise.all(WAVE_LABELS.map((l) => mintCounterPda(waveMintLimitId(l), owner, address(b.guard), address(b.machine)))) } const m = ctx.addr.tiers[shape.tier]! return [await mintCounterPda(TIER_MINT_LIMIT_ID, owner, address(m.guard), address(m.machine))] } const CHANGED = "Your wallet changed the transaction while signing, so it can't be sent. Nothing was charged. Try again, or use Phantom, Solflare or Backpack." /** * Checks a wallet-signed transaction is one of ours and complete, then sends it. Sending the same * signed bytes again is harmless (the network drops duplicates), so the app resends until it lands. * The pause flags apply here too, so a pause also stops transactions built before it. */ export async function relay(ctx: Ctx, raw: unknown): Promise<{ signature: string }> { if (typeof raw !== 'string' || raw.length > MAX_TX_BASE64 || !/^[A-Za-z0-9+/]+={0,2}$/.test(raw)) return refuse(400, 'bad-request', 'Expected a signed transaction.') if (!ctx.addr.blank) return refuse(503, 'not-ready', 'The card app is not set up yet.') requireLocked(ctx) let signature: string let shape: Shape | null let blockhash: string let wire: string let owner: Address try { const tx = getTransactionDecoder().decode(fromBase64(raw)) if (Object.values(tx.signatures).some((s) => !s)) return refuse(400, 'unsigned', 'The wallet did not sign the transaction.') const msg = getCompiledTransactionMessageDecoder().decode(tx.messageBytes) as unknown as { staticAccounts: Address[] instructions: Array<{ programAddressIndex: number; accountIndices?: number[]; data?: Uint8Array }> addressTableLookups?: unknown[] lifetimeToken: string } blockhash = msg.lifetimeToken // The fee payer, which our builds make the buyer. owner = msg.staticAccounts[0]! // Ours are fixed; the likeliest way to get here is a wallet that added instructions while signing. shape = shapeOf(ctx, msg) if (!shape || !(await signaturesValid(tx, ctx.addr.cosigner))) return refuse(400, 'changed', CHANGED) signature = getSignatureFromTransaction(tx) wire = getBase64EncodedWireTransaction(tx) } catch (err) { if (err instanceof CardError) throw err return refuse(400, 'bad-request', 'Expected a signed transaction.') } if (shape.kind === 'buy' && paused(ctx, 'buy')) return refuse(503, 'paused', 'Blank sales are paused for a moment. Nothing was charged.') if (shape.kind === 'activate' && activationPaused(ctx, shape.tier)) return refuse(503, 'paused', `${tierById(shape.tier)!.name} activations are paused for a moment. Nothing was burned.`) try { // Two tries of 10 s at most, so the browser (30 s timeout) always hears back. // Re-encoded from the decoded bytes: one transaction has one form, however it arrived. await ctx.rpc.call('sendTransaction', [wire, { encoding: 'base64', skipPreflight: false, preflightCommitment: 'confirmed', maxRetries: 0 }], { timeoutMs: 10_000, retries: 1 }) } catch (err) { // A resend of something that already landed: RPC nodes word it either way. if (err instanceof RpcError && /already been processed|AlreadyProcessed/i.test(`${err.message} ${err.logs.join(' ')}`)) return { signature } if (err instanceof RpcError && !isTransient(err)) { const failure = explainFailure(err, await counterAddresses(ctx, shape, owner), shape) // "Blockhash not found" can come from one node a slot or two behind the one that handed out // the blockhash. Only call it expired if the blockhash really is no longer valid; otherwise // it's "try again", and the app's resends land it. if (failure.code === 'expired' && (await blockhashStillValid(ctx, blockhash))) { return refuse(503, 'unavailable', "Solana is catching up. We'll keep sending it.") } throw failure } throw err } return { signature } } /** True if the blockhash is still valid, or if that can't be found out (then it's not called expired). */ async function blockhashStillValid(ctx: Ctx, blockhash: string): Promise { try { const { value } = await ctx.rpc.call<{ value: boolean }>('isBlockhashValid', [blockhash, { commitment: 'processed' }], { timeoutMs: 5_000, retries: 0 }) return value } catch { return true } } /** SOL as the app shows it: up to four decimals below 1. */ const solUi = (lamports: bigint) => (Number(lamports) / 1e9).toLocaleString('en-US', { maximumFractionDigits: 4 }) const BLOCKED_BUY = "Someone has blocked this wallet from buying more blanks (by sending SOL to its purchase record, which the minting program can't get past). Nothing was charged. Buy from another wallet." const blockedTier = (tier: string) => `${tier} can't be activated from this wallet: someone blocked its one-per-wallet record. Nothing was burned. Send the blank and the $GLITCH to another wallet and activate there.` // Anchor logs "Error Code: " for program errors; these are the ones a buyer can hit. const FAILURES: Array<[RegExp, number, string, string]> = [ [/Blockhash not found|BlockhashNotFound|block height exceeded/i, 409, 'expired', 'It took too long to reach the network, so it expired. Nothing was charged; try again.'], [/freeze_delegate\.rs:\d+:Reject/, 409, 'blank-frozen', "That blank is frozen, usually because it's listed for sale. Unlist it, or press Activate again to use another blank. Nothing was burned."], [/Error Code: CandyMachineEmpty/, 409, 'sold-out', 'Sold out a moment ago. Nothing was charged.'], [/Error Code: AllocationLimitReached/, 409, 'sold-out', 'Sold out a moment ago. Nothing was charged.'], [/Error Code: AllowedMintLimitReached/, 409, 'wallet-limit', 'This wallet has reached its limit. Nothing was charged.'], [/Error Code: MintNotLive/, 409, 'wave-closed', 'Blanks are not on sale yet. Nothing was charged.'], [/Error Code: AllocationNotInitialized/, 409, 'wave-closed', "These blanks can't sell right now. Nothing was charged. Try again."], [/Error Code: NotEnoughSOL|insufficient lamports|no record of a prior credit|InsufficientFundsForRent|insufficient funds for rent/i, 409, 'no-sol', 'Not enough SOL in the wallet. Nothing was charged.'], [/Error: insufficient funds/i, 409, 'no-glitch', 'Not enough $GLITCH in the wallet. Nothing was burned.'], [/Error Code: IncorrectOwner/, 409, 'not-your-blank', "That blank isn't in this wallet any more. Nothing was burned. If it has another, press Activate again."], // The counter was funded by someone else between the build and the send (see counterBlocked). [/Create Account: account .* already in use|already in use/i, 409, 'wallet-blocked', 'Someone has blocked this wallet from minting here. Nothing was charged. Use another wallet.'], ] /** The address a "Create Account: … already in use" log names, in either wording the runtime uses. */ const IN_USE = /Create Account: account (?:Address \{ address: )?([1-9A-HJ-NP-Za-km-z]{32,44})\b[^\n]*already in use/ /** * `counters` are the wallet's own per-wallet counter addresses for this mint. Only one of those, * taken first, blocks the wallet for good (see counterBlocked); anything else the mint creates (the * new blank or card) has a fresh address on every build, so another try works. */ export function explainFailure(err: RpcError, counters: readonly string[] = [], shape: Shape = { kind: 'buy' }): CardError { const text = `${err.message}\n${err.logs.join('\n')}` const taken = IN_USE.exec(text)?.[1] if (taken && !counters.includes(taken)) { const nothing = shape.kind === 'buy' ? 'Nothing was charged.' : 'Nothing was burned.' return new CardError(409, 'failed', `An address this ${shape.kind === 'buy' ? 'purchase' : 'activation'} needed was taken just before it landed. ${nothing} Try again.`) } if (taken && shape.kind === 'activate') return new CardError(409, 'wallet-blocked', blockedTier(tierById(shape.tier)!.name)) for (const [re, status, code, message] of FAILURES) { if (!re.test(text)) continue // A tier's machine empty: name the tier (the buyer picked it a moment ago). if (shape.kind === 'activate' && code === 'sold-out') return new CardError(status, code, `${tierById(shape.tier)!.name} sold out a moment ago. Nothing was burned.`) return new CardError(status, code, message) } return new CardError(409, 'failed', 'The network refused the transaction. Nothing was charged.') } export type TxState = 'pending' | 'confirmed' | 'failed' | 'expired' const SIGNATURE = /^[1-9A-HJ-NP-Za-km-z]{64,88}$/ /** Blocks past lastValidBlockHeight before "not found" means "expired": RPC nodes behind a load * balancer can be a few blocks apart. */ const EXPIRY_MARGIN = 30 type Status = { err: unknown; confirmationStatus?: string } | null // Confirmed (or finalized) either way, or still pending: an error seen only at "processed" can be on a // fork that is dropped, and calling it failed would stop the app resending it. const stateOf = (s: Status): TxState | null => !s ? null : s.confirmationStatus !== 'confirmed' && s.confirmationStatus !== 'finalized' ? 'pending' : s.err ? 'failed' : 'confirmed' export const blockHeight = (ctx: Ctx) => ctx.rpc.call('getBlockHeight', [{ commitment: 'confirmed' }]) /** * Up to 10 signatures (comma-separated) in one call, so a five-blank purchase is one poll. * `heightNow` lets the endpoint share one block-height read between many polls; `mayDeepSearch` * rations the full-history search (the costly call), which a buyer needs about once per purchase. * Without it the answer stays "pending" and the next poll tries again. */ export async function txStatuses( ctx: Ctx, rawSigs: unknown, rawHeight: unknown, heightNow: () => Promise = () => blockHeight(ctx), mayDeepSearch: () => Promise = async () => true, ): Promise<{ states: TxState[] }> { const sigs = typeof rawSigs === 'string' ? rawSigs.split(',') : [] if (sigs.length < 1 || sigs.length > 10 || sigs.some((s) => !SIGNATURE.test(s))) return refuse(400, 'bad-request', 'Expected up to 10 transaction signatures.') const height = typeof rawHeight === 'string' && /^\d{1,12}$/.test(rawHeight) ? Number(rawHeight) : null const recent = await ctx.rpc.call<{ value: Status[] }>('getSignatureStatuses', [sigs, { searchTransactionHistory: false }]) const states: Array = sigs.map((_, i) => stateOf(recent.value[i] ?? null)) const missing = sigs.filter((_, i) => states[i] === null) if (missing.length && height !== null) { const now = await heightNow() if (now > height + EXPIRY_MARGIN && (await mayDeepSearch())) { // Only "expired" if a full history search can't find it either. const deep = await ctx.rpc.call<{ value: Status[] }>('getSignatureStatuses', [missing, { searchTransactionHistory: true }]) const found = new Map(missing.map((sig, j) => [sig, stateOf(deep.value[j] ?? null) ?? 'expired'])) sigs.forEach((sig, i) => { if (states[i] === null) states[i] = found.get(sig) ?? null }) } } return { states: states.map((s) => s ?? 'pending') } } // ---------- The co-signer key ---------- /** The co-signer from its secret: the 64-byte keypair as a JSON array (Solana CLI format). */ export async function loadCosigner(secret: string | undefined): Promise { if (!secret) return null let bytes: unknown try { bytes = JSON.parse(secret) } catch { return null } if (!Array.isArray(bytes) || bytes.length !== 64 || !bytes.every((b) => Number.isInteger(b) && b >= 0 && b <= 255)) return null // A 64-byte array whose public half doesn't match its secret half is a broken secret: refuse it // as misconfiguration (fail closed), not as an outage. try { return await createKeyPairSignerFromBytes(Uint8Array.from(bytes as number[])) } catch { return null } }