Skip to content

React mint panel

A working panel that handles every case the read model can return. Roughly 90 lines, no GraveMint-specific state management, and nothing recomputed client-side.

Check it before you ship it

If you have the MCP server installed, ask your agent to "review this file for Solana Deads integration mistakes" — it checks for the specific things this page is careful about.

Install

bash
npm install @solanadeads/gravemint

The client

Create it once, outside your component tree.

ts
// lib/gravemint.ts
import { GraveMintClient } from '@solanadeads/gravemint';

export const gm = new GraveMintClient({
  apiKey: import.meta.env.VITE_GRAVEMINT_KEY,   // a gm_pub_ key — origin-bound
});

Only a gm_pub_ key belongs in browser code

Anything with a public build prefix (VITE_, NEXT_PUBLIC_, EXPO_PUBLIC_) is inlined into the client bundle by definition. A gm_live_ key there is a full partner credential published to every visitor, and nothing bounds it to an origin.

The panel

tsx
import { useEffect, useState } from 'react';
import { useWallet } from '@solana/wallet-adapter-react';
import { VersionedTransaction } from '@solana/web3.js';
import { gm } from './lib/gravemint';

function priceLabel(p) {
  switch (p?.kind) {
    case 'amount': return p.isFree ? 'Free' : `${p.amount} ${p.currency}`;
    case 'range':  return `${p.min} – ${p.max} ${p.currency}`;
    case 'hidden': return 'Price revealed when you qualify';
    default:       return '—';
  }
}

export function MintPanel({ identifier }: { identifier: string }) {
  const { publicKey, signTransaction } = useWallet();
  const [drop, setDrop] = useState(null);
  const [verdict, setVerdict] = useState(null);
  const [status, setStatus] = useState('');

  useEffect(() => { gm.v1.collection(identifier).then(setDrop); }, [identifier]);

  const phase = drop?.phases?.active;
  const wallet = publicKey?.toBase58();

  // Eligibility is a SERVER verdict — never inferred from holdings you fetched yourself.
  useEffect(() => {
    if (!drop || !phase || !wallet) return;
    gm.v1.eligibility(drop.collection.id, phase.id, wallet).then(setVerdict);
  }, [drop, phase, wallet]);

  if (!drop) return <p>Loading…</p>;

  // Always handle this branch: it is what keeps a pinned client honest when a drop
  // uses a mechanic this surface does not model.
  if (!drop.capabilities.supportedBySurface) {
    return <a href={drop.capabilities.mintUrl}>Mint on GraveMint</a>;
  }

  if (!phase) return <p>No active phase.</p>;

  const canMint =
    verdict?.meetsRequirements && verdict.phase.hasStarted && !verdict.phase.hasEnded;

  async function mint() {
    try {
      setStatus('Preparing…');
      const prep = await gm.mint.prepare({
        collectionId: drop.collection.id,
        phaseId: phase.id,
        walletAddress: wallet,
        quantity: 1,
      });

      setStatus('Approve in your wallet…');
      const tx = VersionedTransaction.deserialize(
        Buffer.from(prep.transactions[0], 'base64'),
      );
      const signed = await signTransaction(tx);

      // We broadcast. Never send a transaction hash.
      setStatus('Minting…');
      await gm.mint.execute({
        sessionId: prep.sessions[0],
        signedTransaction: Buffer.from(signed.serialize()).toString('base64'),
      });
      setStatus('Minted.');
    } catch (err) {
      // A batch keeps its codes inside results[]; a single mint promotes them.
      const code = err.code ?? err.results?.find((r) => r.errorCode)?.errorCode;
      setStatus(code === 'TX_MODIFIED' ? 'Something altered the transaction. Start over.'
        : code === 'SESSION_EXPIRED' ? 'That took too long — try again.'
        : err.message);
    }
  }

  return (
    <div>
      <h2>{drop.collection.name}</h2>
      <p>{drop.stats.minted} / {drop.stats.totalSupply} minted</p>
      <p>{priceLabel(phase.priceDisplay)}</p>
      {!wallet && <p>Connect a wallet to continue.</p>}
      {wallet && !canMint && <p>{verdict?.reason ?? 'Not eligible for this phase.'}</p>}
      <button onClick={mint} disabled={!canMint}>Mint</button>
      <p>{status}</p>
    </div>
  );
}

What this deliberately does not do

  • No price arithmetic. priceLabel reads the union and nothing else.
  • No client-side eligibility. The verdict is the server's.
  • No broadcast. It signs and hands the bytes back.
  • No Date.now() countdown. If you add one, anchor it to drop.serverTime.

Each of those is a rule with a failure mode behind it — see the read model and Wallets and signing.

Next

SDK pages are generated from the published npm tarballs and cannot drift.