A Frontend for the Dutch Auction - Dropping the Legacy Solana Stack

A Frontend for the Dutch Auction - Dropping the Legacy Solana Stack

The piggy bank frontend used the standard Solana web stack: @solana/web3.js v1, @solana/wallet-adapter-react, and the Anchor TypeScript client. It worked, but every piece of that stack is being replaced.

This post builds a frontend for the dutch auction program using the modern Solana stack — no @solana/web3.js, no wallet adapter, no Anchor TS client. Same program, completely different client-side toolchain.

Why Replace It

The legacy Solana frontend stack has real problems:

The modern stack fixes all of these:

The Stack

Layer Piggy Bank (Legacy) Dutch Auction (Modern)
SDK @solana/web3.js v1 @solana/kit v6
Wallet @solana/wallet-adapter-react @wallet-standard/react + @solana/react
Program client @coral-xyz/anchor TS Codama-generated Kit client
State management React Query Plain React hooks
Buffer polyfill Required Not needed

Generating a Kit-Native Client with Codama

Instead of importing Anchor’s TypeScript client at runtime, Codama reads the IDL at build time and generates typed instruction builders and account decoders that use Kit types directly.

The generation script is 7 lines:

import { createFromRoot } from 'codama';
import { rootNodeFromAnchor } from '@codama/nodes-from-anchor';
import { renderVisitor as renderJavaScriptVisitor } from '@codama/renderers-js';
import { readFileSync } from 'node:fs';

const anchorIdl = JSON.parse(
  readFileSync('target/idl/dutch_auction.json', 'utf-8')
);

const codama = createFromRoot(rootNodeFromAnchor(anchorIdl));
codama.accept(renderJavaScriptVisitor('app/generated'));

This produces a generated/ directory with typed functions:

Every function accepts and returns Kit types (Address, TransactionSigner, IInstruction). No PublicKey, no BN, no Anchor provider. The generated code is checked into git, so the app has zero build-time dependency on Codama.

Wallet Connection Without wallet-adapter

The Piggy Bank frontend needed five packages for wallet connection: @solana/wallet-adapter-base, @solana/wallet-adapter-react, @solana/wallet-adapter-react-ui, @solana/wallet-adapter-wallets, plus CSS imports and a dynamic() import to avoid SSR issues.

The Dutch Auction uses two packages: @wallet-standard/react and @solana/react.

The provider is minimal:

import { SelectedWalletAccountContextProvider } from '@solana/react';
import type { UiWallet } from '@wallet-standard/react';

const STORAGE_KEY = 'dutch-auction-wallet';

export function Providers({ children }) {
  return (
    <SelectedWalletAccountContextProvider
      filterWallets={(wallet: UiWallet) => wallet.accounts.length > 0}
      stateSync={{
        getSelectedWallet: () => localStorage.getItem(STORAGE_KEY),
        storeSelectedWallet: (key) => localStorage.setItem(STORAGE_KEY, key),
        deleteSelectedWallet: () => localStorage.removeItem(STORAGE_KEY),
      }}
    >
      {children}
    </SelectedWalletAccountContextProvider>
  );
}

No ConnectionProvider, no WalletProvider, no WalletModalProvider. The SelectedWalletAccountContextProvider from @solana/react handles wallet discovery, connection, and account selection through the Wallet Standard protocol. Wallets like Phantom and Solflare that implement Wallet Standard are discovered automatically — no need to list them.

The stateSync prop persists the selected wallet across page reloads using localStorage. In the legacy stack, wallet-adapter handled this internally but gave you no control over the storage mechanism.

Custom Wallet Button

Without wallet-adapter-react-ui, the wallet button is a regular React component using hooks from @wallet-standard/react:

import { useWallets, useConnect, useDisconnect } from '@wallet-standard/react';
import { useSelectedWalletAccount } from '@solana/react';

function ConnectOption({ wallet, onConnect }) {
  const [isConnecting, connect] = useConnect(wallet);

  return (
    <button
      disabled={isConnecting}
      onClick={async () => {
        const accounts = await connect();
        if (accounts[0]) onConnect(accounts[0]);
      }}
    >
      <img src={wallet.icon} alt="" />
      {wallet.name}
    </button>
  );
}

export default function WalletButton() {
  const wallets = useWallets();
  const [selectedAccount, setSelectedAccount] = useSelectedWalletAccount();

  if (selectedAccount) {
    return <ConnectedView account={selectedAccount} />;
  }

  return (
    <Dropdown>
      {wallets.map(wallet =>
        <ConnectOption key={wallet.name} wallet={wallet} />
      )}
    </Dropdown>
  );
}

useWallets() returns all Wallet Standard wallets discovered on the page. useConnect(wallet) returns a connect function and a loading state. No modal library, no CSS overrides. The UI is entirely custom, styled to match the rest of the app.

The biggest win: no dynamic(() => import(...), { ssr: false }) hack. The Wallet Standard hooks handle SSR gracefully because they don’t depend on browser globals during initial render.

Building Transactions with Kit

The legacy stack builds transactions with Anchor’s .methods builder:

// Legacy (Piggy Bank style)
const tx = await program.methods
  .lock(amountLamports, expTimestamp)
  .accounts({ payer: wallet.publicKey, dst: wallet.publicKey, lock: lockKeypair.publicKey })
  .signers([lockKeypair])
  .rpc();

Kit uses a functional pipe pattern with the Codama-generated instruction:

// Modern (Dutch Auction)
import { pipe, createTransactionMessage,
  setTransactionMessageFeePayerSigner,
  setTransactionMessageLifetimeUsingBlockhash,
  appendTransactionMessageInstruction,
  signAndSendTransactionMessageWithSigners } from '@solana/kit';
import { getInitInstructionAsync } from '@/generated';

const ix = await getInitInstructionAsync({
  seller: signer,
  sellMint: address(params.sellMint),
  buyMint: address(params.buyMint),
  sellerSellAta: address(params.sellerSellAta),
  sellAmount: params.sellAmount,
  startPrice: params.startPrice,
  endPrice: params.endPrice,
  startTime: params.startTime,
  endTime: params.endTime,
});

const { value: latestBlockhash } = await rpc.getLatestBlockhash().send();

const message = pipe(
  createTransactionMessage({ version: 0 }),
  (m) => setTransactionMessageFeePayerSigner(signer, m),
  (m) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, m),
  (m) => appendTransactionMessageInstruction(ix, m),
);

const signatureBytes = await signAndSendTransactionMessageWithSigners(message);

More explicit, but every step is visible. The transaction signer comes from @solana/react’s useWalletAccountTransactionSendingSigner hook, which bridges the Wallet Standard account to Kit’s TransactionSigner type:

import { useWalletAccountTransactionSendingSigner } from '@solana/react';

const signer = useWalletAccountTransactionSendingSigner(account, 'solana:devnet');

This replaces the entire useAnchorProvider()new Program().methods.x().rpc() pipeline from the Piggy Bank.

The Decimals Problem

The first working version hardcoded decimals = 9 everywhere — SOL has 9 decimals, so it seemed like a safe default. It wasn’t.

When testing with real SPL tokens on devnet, creating an auction immediately failed with TransferChecked: insufficient funds. The sell token had 6 decimals, not 9. The UI was multiplying the user’s input by 10^9 when it should have been 10^6, producing an amount 1000x larger than the wallet’s balance.

The fix: fetch the actual decimals from the on-chain mint account before building the transaction:

const sellMintInfo = await rpc
  .getAccountInfo(address(sellMint), { encoding: 'base64' })
  .send();

// SPL Mint layout: decimals is a u8 at byte offset 44
const sellBytes = Uint8Array.from(atob(sellMintInfo.value.data), c => c.charCodeAt(0));
const sellDecimals = sellBytes[44];

// Convert display amount to raw
const rawAmount = BigInt(Math.floor(parseFloat(sellAmount) * 10 ** sellDecimals));

Byte offset 44 in the SPL Mint layout is where the decimals field lives. This is a pattern that comes up constantly in Solana frontends — any time you display or convert token amounts, you need the mint’s decimals. The legacy getMint() helper from @solana/spl-token abstracts this, but with Kit you read the raw bytes. More work, but no hidden dependency.

The auction card also needed fixing. The sell amount, current price, start price, and end price all use different decimal bases — sell amount uses the sell token’s decimals, prices use the buy token’s decimals. The useAuctions hook now batch-fetches all unique mint decimals in a single getMultipleAccounts call:

const mintSet = new Set<string>();
for (const a of decoded) {
  mintSet.add(a.data.sellMint);
  mintSet.add(a.data.buyMint);
}

const mintResults = await rpc
  .getMultipleAccounts([...mintSet].map(m => m as Address), { encoding: 'base64' })
  .send();

for (let i = 0; i < mintAddrs.length; i++) {
  const mintBytes = Uint8Array.from(atob(mintResults.value[i].data), c => c.charCodeAt(0));
  mintDecimals.set(mintAddrs[i], mintBytes[44]);
}

One RPC call for all mints, regardless of how many auctions exist.

Live Price Ticker

A Dutch auction’s price changes every second. The frontend replicates the on-chain formula client-side:

export function calculateCurrentPrice(auction: Auction): CurrentPriceInfo {
  const now = BigInt(Math.floor(Date.now() / 1000));

  if (now < auction.startTime) {
    return { currentPrice: auction.startPrice, status: 'pending', ... };
  }
  if (now >= auction.endTime) {
    return { currentPrice: auction.endPrice, status: 'ended', ... };
  }

  const elapsed = now - auction.startTime;
  const duration = auction.endTime - auction.startTime;
  const priceRange = auction.startPrice - auction.endPrice;
  const priceDecrease = (priceRange * elapsed) / duration;

  return {
    currentPrice: auction.startPrice - priceDecrease,
    timeRemaining: Number(auction.endTime - now),
    percentComplete: Number((elapsed * 100n) / duration),
    status: 'active',
  };
}

The useCurrentPrice hook wraps this in a setInterval that ticks every second. The price displayed in the UI always matches what the on-chain program would compute — both use the same linear interpolation formula. The only risk is clock skew between the user’s browser and the Solana cluster, which is typically under a second.

Note the use of BigInt arithmetic throughout. Kit uses native bigint for all amounts and timestamps, replacing Anchor’s BN library. No import needed, no conversion functions.

Account Discovery via getProgramAccounts

Like the piggy bank, the auction list uses getProgramAccounts to find all auctions on-chain:

const result = await rpc
  .getProgramAccounts(DUTCH_AUCTION_PROGRAM_ADDRESS, {
    encoding: 'base64',
    filters: [
      { dataSize: BigInt(getAuctionSize()) },
    ],
  })
  .send();

const decoder = getAuctionDecoder();
for (const item of result) {
  const bytes = Uint8Array.from(atob(item.account.data), c => c.charCodeAt(0));
  const data = decoder.decode(bytes);
  decoded.push({ address: item.pubkey, data });
}

getAuctionSize() and getAuctionDecoder() come from the Codama-generated client. The filter ensures we only get accounts matching the Auction struct’s exact size. The decoder handles the 8-byte Anchor discriminator plus all fields.

In the legacy stack, this would be program.account.auction.all() — one line. The Kit version is more verbose but doesn’t require an Anchor Program instance, an AnchorProvider, or a Connection. Just an RPC client and a decoder.

What Changed, What Didn’t

The UI is virtually identical to the Piggy Bank — same dark theme, same monospace terminal aesthetic, same Tailwind v4 setup. The user experience is the same: connect wallet, fill form, sign transaction.

What changed is everything underneath:

No Buffer polyfill. The Piggy Bank needed globalThis.Buffer = Buffer because @solana/web3.js v1 uses Node.js Buffer. Kit uses Uint8Array natively. One less browser compatibility hack.

No dynamic() import. The wallet adapter’s modal component crashed during SSR, requiring Next.js dynamic() with ssr: false. The Wallet Standard hooks don’t have this problem — they check typeof window internally.

No React Query. The Piggy Bank used React Query for cache management and mutation state. The Dutch Auction uses plain useState + useCallback hooks. React Query is a good library, but the Solana hooks from @solana/react handle the connection lifecycle, and simple polling with setInterval is enough for auction data that changes every second anyway.

No Anchor runtime. The Anchor TypeScript client (@coral-xyz/anchor) bundles its own IDL parser, account decoder, and instruction builder. The Codama-generated client is just functions — no class instances, no provider initialization, no IDL parsing at runtime.

Typed instruction builders. Codama generates getInitInstructionAsync() with full TypeScript types for every parameter. The Anchor client has types too, but they’re derived from the IDL at runtime via generics. Codama’s types are generated at build time — your editor knows the exact shape before you run anything.

What I Learned

The modern stack works. Framework-kit (@solana/react) is newer and less documented than wallet-adapter, but the core workflow — discover wallets, connect, sign transactions — works reliably. The API surface is smaller and more composable.

Codama is the right abstraction layer. Generating a typed client from the IDL separates the program interface from the client implementation. When the program changes, you regenerate. The generated code doesn’t depend on Anchor at runtime, so the frontend can use Kit types end-to-end.

Decimals are never safe to assume. Hardcoding decimals = 9 broke immediately with real tokens. Any frontend that displays or converts token amounts must fetch the mint’s decimals from on-chain. This isn’t a legacy vs. modern stack issue — it’s a Solana fundamental that both stacks handle the same way.

The pipe pattern takes getting used to. Kit’s pipe(createTransactionMessage, setFeePayerSigner, setLifetime, appendInstruction) is more explicit than Anchor’s fluent builder, but it makes every step visible. There’s no magic — you see exactly what goes into the transaction message and in what order.

The migration path is clear. If you have a working Anchor program, moving the frontend from legacy to modern is: (1) generate a Codama client, (2) replace wallet-adapter with Wallet Standard hooks, (3) replace web3.js types with Kit types. The program doesn’t change at all.

The code is on GitHub: anchor-dutch-auction

Jose

Written by Jose

Full-stack developer with 20 years of Rails experience, currently building on Solana.

Follow me on X
Back to blog