// The on-chain side of the card: program addresses, the PDAs the guards check, the four // instructions the server puts in a transaction, and decoders for the accounts it reads. // Platform-neutral (Pages Functions and Node tests). // // Layouts come from the program sources, not the JS clients: mpl-core-candy-machine @ ea3620b // (programs/candy-guard: mint_v1.rs, state/candy_guard.rs, guards/*.rs; programs/candy-machine-core: // state/candy_machine.rs) and mpl-core (state/asset.rs). test/chain.test.ts checks every builder // against the Umi client byte for byte. import { AccountRole, address, getAddressDecoder, getAddressEncoder, getProgramDerivedAddress, type AccountMeta, type Address, type Instruction, type ReadonlyUint8Array, } from '@solana/kit' export const CANDY_GUARD_PROGRAM = address('CMAGAKJ67e9hRZgfC5SFTbZH8MgEmtqazKXjmkaJjWTJ') export const CANDY_MACHINE_PROGRAM = address('CMACYFENjoBMHzapRXyo1JZkVS6EtaDDzkjMrmQLvr4J') export const MPL_CORE_PROGRAM = address('CoREENxT6tW1HoK8ypY1SxRMZTcVPm7R94rH4PZNhX7d') export const TOKEN_2022_PROGRAM = address('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb') export const ATA_PROGRAM = address('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL') export const SYSTEM_PROGRAM = address('11111111111111111111111111111111') export const COMPUTE_BUDGET_PROGRAM = address('ComputeBudget111111111111111111111111111111') const SYSVAR_INSTRUCTIONS = address('Sysvar1nstructions1111111111111111111111111') const SYSVAR_SLOT_HASHES = address('SysvarS1otHashes111111111111111111111111111') const enc = getAddressEncoder() const dec = getAddressDecoder() const utf8 = new TextEncoder() // ---------- PDAs ---------- const pda = async (program: Address, seeds: Array) => (await getProgramDerivedAddress({ programAddress: program, seeds }))[0] /** Signs the machine's CPI into mpl-core; it is the collection's update delegate. */ export const candyMachineAuthorityPda = (machine: Address) => pda(CANDY_MACHINE_PROGRAM, ['candy_machine', enc.encode(machine)]) /** The mintLimit guard's per-wallet counter (u16). */ export const mintCounterPda = (id: number, minter: Address, guard: Address, machine: Address) => pda(CANDY_GUARD_PROGRAM, ['mint_limit', Uint8Array.of(id), enc.encode(minter), enc.encode(guard), enc.encode(machine)]) /** The allocation guard's per-group counter (u32). */ export const allocationPda = (id: number, guard: Address, machine: Address) => pda(CANDY_GUARD_PROGRAM, ['allocation', Uint8Array.of(id), enc.encode(guard), enc.encode(machine)]) /** A wallet's associated account for a Token-2022 mint. */ export const token2022Ata = (owner: Address, mint: Address) => pda(ATA_PROGRAM, [enc.encode(owner), enc.encode(TOKEN_2022_PROGRAM), enc.encode(mint)]) // ---------- Instructions ---------- const ro = (a: Address): AccountMeta => ({ address: a, role: AccountRole.READONLY }) const rw = (a: Address): AccountMeta => ({ address: a, role: AccountRole.WRITABLE }) function u32le(n: number): Uint8Array { const b = new Uint8Array(4) new DataView(b.buffer).setUint32(0, n, true) return b } function u64le(n: bigint): Uint8Array { // setBigUint64 wraps silently; an out-of-range amount must fail, not become a different one. if (n < 0n || n >= 1n << 64n) throw new Error(`u64 out of range: ${n}`) const b = new Uint8Array(8) new DataView(b.buffer).setBigUint64(0, n, true) return b } const concat = (...parts: Uint8Array[]) => { const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)) let at = 0 for (const p of parts) { out.set(p, at) at += p.length } return out } export function setComputeUnitLimit(units: number): Instruction { return { programAddress: COMPUTE_BUDGET_PROGRAM, data: concat(Uint8Array.of(2), u32le(units)) } } export function setComputeUnitPrice(microLamports: bigint): Instruction { return { programAddress: COMPUTE_BUDGET_PROGRAM, data: concat(Uint8Array.of(3), u64le(microLamports)) } } /** * Token-2022 BurnChecked (instruction 15): burns `amount` base units from `account`, signed by its * owner. The decimals check makes the program refuse if `mint` isn't the 6-decimal coin we think. */ export function burnChecked(account: Address, mint: Address, owner: Address, amount: bigint, decimals: number): Instruction { return { programAddress: TOKEN_2022_PROGRAM, accounts: [rw(account), rw(mint), { address: owner, role: AccountRole.READONLY_SIGNER }], data: concat(Uint8Array.of(15), u64le(amount), Uint8Array.of(decimals)), } } /** Anchor's discriminator for candy_guard::mint_v1. */ const MINT_V1 = Uint8Array.of(145, 98, 192, 118, 184, 147, 118, 104) export interface MintV1Input { guard: Address machine: Address collection: Address /** Pays fees and rent, owns the new asset, and is the one the guards check. */ wallet: Address /** The new asset's address. It signs, because the account is created here. */ asset: Address /** Group label, at most 6 characters. */ group: string /** The guards' accounts, in the order the program reads them (GuardSet field order). */ remaining: AccountMeta[] } /** * candy_guard::mint_v1. Accounts follow the MintV1 struct; the optional `owner` slot is left * empty, which Anchor encodes as the program's own address, so the asset goes to the minter. * Data: discriminator, mint_args (empty Vec: none of our guards read it), label (Some(group)). */ export async function mintV1(input: MintV1Input): Promise { const label = utf8.encode(input.group) if (label.length === 0 || label.length > 6) throw new Error('Group labels are 1-6 bytes.') return { programAddress: CANDY_GUARD_PROGRAM, accounts: [ ro(input.guard), ro(CANDY_MACHINE_PROGRAM), rw(input.machine), rw(await candyMachineAuthorityPda(input.machine)), { address: input.wallet, role: AccountRole.WRITABLE_SIGNER }, // payer { address: input.wallet, role: AccountRole.WRITABLE_SIGNER }, // minter ro(CANDY_GUARD_PROGRAM), // owner: None { address: input.asset, role: AccountRole.WRITABLE_SIGNER }, rw(input.collection), ro(MPL_CORE_PROGRAM), ro(SYSTEM_PROGRAM), ro(SYSVAR_INSTRUCTIONS), ro(SYSVAR_SLOT_HASHES), ...input.remaining, ], data: concat(MINT_V1, u32le(0), Uint8Array.of(1), u32le(label.length), label), } } // Guard accounts, one helper per guard so the order in the callers reads like the GuardSet. export const solPaymentAccounts = (destination: Address): AccountMeta[] => [rw(destination)] // Read-only is enough: the guard only checks is_signer. (Umi marks it writable, which would make // every activation in a tier queue on one write lock for no reason.) export const thirdPartySignerAccounts = (signer: Address): AccountMeta[] => [{ address: signer, role: AccountRole.READONLY_SIGNER }] export const mintLimitAccounts = async (id: number, wallet: Address, guard: Address, machine: Address): Promise => [rw(await mintCounterPda(id, wallet, guard, machine))] export const allocationAccounts = async (id: number, guard: Address, machine: Address): Promise => [rw(await allocationPda(id, guard, machine))] export const assetBurnAccounts = (asset: Address, collection: Address): AccountMeta[] => [rw(asset), rw(collection)] // ---------- Account decoders ---------- export interface RawAccount { owner: string lamports: bigint data: Uint8Array } const CANDY_MACHINE_DISCRIMINATOR = Uint8Array.of(51, 173, 177, 113, 25, 241, 109, 189) const sameBytes = (a: Uint8Array, b: Uint8Array) => a.length === b.length && a.every((v, i) => v === b[i]) const view = (d: Uint8Array) => new DataView(d.buffer, d.byteOffset, d.byteLength) export interface CandyMachineState { authority: Address mintAuthority: Address collection: Address itemsRedeemed: number itemsAvailable: number } export function decodeCandyMachine(acc: RawAccount | null): CandyMachineState | null { if (!acc || acc.owner !== CANDY_MACHINE_PROGRAM || acc.data.length < 120) return null if (!sameBytes(acc.data.subarray(0, 8), CANDY_MACHINE_DISCRIMINATOR)) return null const v = view(acc.data) return { authority: dec.decode(acc.data.subarray(8, 40)), mintAuthority: dec.decode(acc.data.subarray(40, 72)), collection: dec.decode(acc.data.subarray(72, 104)), itemsRedeemed: Number(v.getBigUint64(104, true)), itemsAvailable: Number(v.getBigUint64(112, true)), } } /** * A wallet's mint counter that someone else funded first. The guard creates the counter on the * wallet's first mint with a plain create_account, which fails on an address that already holds * SOL; anyone can send SOL there (the address is public), and nothing can clear it. So that wallet * can never mint from that wave or tier. All it can be is an empty system account: nobody can put * data at a program address without that program. */ export function counterBlocked(acc: RawAccount | null): boolean { return !!acc && acc.owner === SYSTEM_PROGRAM && acc.data.length === 0 } /** Allocation tracker (u32) or mint counter (u16); an account that doesn't exist yet counts 0. */ export function decodeCounter(acc: RawAccount | null, bytes: 2 | 4): number | null { if (!acc) return 0 if (acc.owner !== CANDY_GUARD_PROGRAM || acc.data.length < bytes) return null const v = view(acc.data) return bytes === 2 ? v.getUint16(0, true) : v.getUint32(0, true) } export interface CoreAsset { owner: Address /** Set when the asset's update authority is a collection. */ collection: Address | null name: string uri: string } const utf8dec = new TextDecoder('utf-8', { fatal: true, ignoreBOM: false }) /** An mpl-core AssetV1 (Key 1): owner, update authority, name, uri. Null for anything else. */ export function decodeCoreAsset(acc: RawAccount | null): CoreAsset | null { if (!acc || acc.owner !== MPL_CORE_PROGRAM || acc.data.length < 34 || acc.data[0] !== 1) return null try { const d = acc.data const v = view(d) const owner = dec.decode(d.subarray(1, 33)) const tag = d[33] let at = 34 let collection: Address | null = null if (tag === 1 || tag === 2) { if (tag === 2) collection = dec.decode(d.subarray(34, 66)) at = 66 } else if (tag !== 0) return null const nameLen = v.getUint32(at, true) const name = utf8dec.decode(d.subarray(at + 4, at + 4 + nameLen)) at += 4 + nameLen const uriLen = v.getUint32(at, true) if (at + 4 + uriLen > d.length) return null const uri = utf8dec.decode(d.subarray(at + 4, at + 4 + uriLen)) return { owner, collection, name, uri } } catch { return null } } export interface TokenAccount { mint: Address owner: Address amount: bigint /** 1 = initialized; 2 = frozen, which can't burn. */ state: number } /** A Token-2022 account (the base layout, before any extensions). */ export function decodeToken2022Account(acc: RawAccount | null): TokenAccount | null { if (!acc || acc.owner !== TOKEN_2022_PROGRAM || acc.data.length < 165) return null const v = view(acc.data) return { mint: dec.decode(acc.data.subarray(0, 32)), owner: dec.decode(acc.data.subarray(32, 64)), amount: v.getBigUint64(64, true), state: acc.data[108]!, } }