Skip to main content

React SVM Example

React SPA example using the Solana wallet adapter.

Next.js SVM Example

Next.js example with server-side token management.
This guide covers the Solana-specific parts of a Sonar integration. The Sonar API calls (authentication, entities, pre-purchase checks, purchase permits) work the same way as in the EVM guides. Only the wallet adapter and contract interaction differ.
Follow the Frontend-only guide for a React SPA, or the Frontend with Backend guide for Next.js, to handle Sonar OAuth and API calls. Then use this guide for the Solana transaction layer.

Installation

npm install @echoxyz/sonar-core @echoxyz/sonar-react \
  @solana/wallet-adapter-react @solana/wallet-adapter-react-ui \
  @solana/web3.js @solana/spl-token \
  @coral-xyz/anchor uuid

Provider setup

Wrap your app with the Solana connection and wallet providers alongside SonarProvider:
import { SonarProvider } from "@echoxyz/sonar-react";
import { ConnectionProvider, WalletProvider } from "@solana/wallet-adapter-react";
import { WalletModalProvider } from "@solana/wallet-adapter-react-ui";
import "@solana/wallet-adapter-react-ui/styles.css";

const RPC_URL = "https://api.mainnet-beta.solana.com";

function App() {
  return (
    <SonarProvider config={sonarConfig}>
      <ConnectionProvider endpoint={RPC_URL}>
        <WalletProvider wallets={[]} autoConnect>
          <WalletModalProvider>
            {/* Your application */}
          </WalletModalProvider>
        </WalletProvider>
      </ConnectionProvider>
    </SonarProvider>
  );
}

Configuration

You’ll need three sale-specific values alongside the standard Sonar config:
VariableDescription
PROGRAM_IDThe Sonar settlement sale program: 3XxHCh947y1PAMK5y8oecBtaLU7TtTj9r4yMYwzFbJYs
PAYMENT_TOKEN_MINTThe SPL token mint address accepted as payment
SALE_UUIDYour sale’s UUID from the Echo founder dashboard

Committing funds to the sale

Submitting a bid on Solana requires a two-instruction transaction:
  1. An Ed25519 signature verification instruction (proving the purchase permit was signed by Sonar)
  2. The place_bid program instruction
Both instructions must be in the same transaction with the Ed25519 instruction immediately preceding place_bid. The program validates the permit signature via instruction introspection.

Deriving PDAs

The program uses Program Derived Addresses for all state. Derive these before building the transaction:
import { PublicKey } from "@solana/web3.js";
import { parse as uuidParse } from "uuid";

// IDs from the Sonar API may be 0x-prefixed or UUID-formatted
function parseIdBytes(id: string): Uint8Array {
  const s = id.replace(/^0x/i, "");
  return s.includes("-") ? uuidParse(s) : Buffer.from(s, "hex");
}

const programPublicKey = new PublicKey(PROGRAM_ID);

// Sale PDA — derived from the sale UUID
const [salePDA] = PublicKey.findProgramAddressSync(
  [Buffer.from("settlement_sale"), Buffer.from(parseIdBytes(saleUUID))],
  programPublicKey
);

// Entity state PDA — derived from the sale PDA and the sale-specific entity ID
const [entityStatePDA] = PublicKey.findProgramAddressSync(
  [Buffer.from("entity_state"), salePDA.toBuffer(), Buffer.from(parseIdBytes(saleSpecificEntityID))],
  programPublicKey
);

// Wallet binding PDA — derived from the sale PDA and the bidder's wallet
const [walletBindingPDA] = PublicKey.findProgramAddressSync(
  [Buffer.from("wallet_binding"), salePDA.toBuffer(), wallet.publicKey.toBuffer()],
  programPublicKey
);

Encoding the permit

The purchase permit must be Borsh-encoded using the program IDL to produce the message bytes for Ed25519 verification. Copy the IDL from the example app into your project.
import { BorshCoder, BN } from "@coral-xyz/anchor";
import { IDL } from "./idl/settlement_sale";

// permit is the PermitJSON field from Sonar's generatePurchasePermit response
const permitData = {
  saleSpecificEntityId: parseIdBytes(permit.SaleSpecificEntityID),
  saleUuid: parseIdBytes(permit.SaleUUID),
  wallet: Array.from(new PublicKey(permit.Wallet).toBytes()),
  expiresAt: new BN(permit.ExpiresAt),
  minAmount: new BN(permit.MinAmount),
  maxAmount: new BN(permit.MaxAmount),
  minPrice: new BN(permit.MinPrice),
  maxPrice: new BN(permit.MaxPrice),
  opensAt: new BN(permit.OpensAt),
  closesAt: new BN(permit.ClosesAt),
  payload: Buffer.from(permit.Payload.replace(/^0x/, ""), "hex"),
};

const coder = new BorshCoder(IDL);
const messageBytes = coder.types.encode("purchasePermitV3", permitData);

Building and submitting the transaction

import {
  Ed25519Program,
  PublicKey,
  SYSVAR_INSTRUCTIONS_PUBKEY,
  SystemProgram,
  Transaction,
} from "@solana/web3.js";
import { AnchorProvider, Program } from "@coral-xyz/anchor";
import { TOKEN_PROGRAM_ID, getAssociatedTokenAddressSync } from "@solana/spl-token";

const provider = new AnchorProvider(connection, wallet, { commitment: "confirmed" });
const program = new Program(IDL, provider);

// Fetch the sale account to get the permit signer public key and vault address
const saleAccount = await program.account.settlementSale.fetch(salePDA);

// Ed25519 verify instruction — must immediately precede place_bid
const ed25519Ix = Ed25519Program.createInstructionWithPublicKey({
  publicKey: saleAccount.permitSigner.toBytes(),
  message: messageBytes,
  signature: Buffer.from(purchasePermitResp.Signature.replace(/^0x/, ""), "hex"),
});

const bidderTokenAccount = getAssociatedTokenAddressSync(
  new PublicKey(PAYMENT_TOKEN_MINT),
  wallet.publicKey
);

const placeBidIx = await program.methods
  .placeBid(permitData, new BN(amount.toString()), new BN(0), false)
  .accounts({
    bidder:             wallet.publicKey,
    sale:               salePDA,
    entityState:        entityStatePDA,
    walletBinding:      walletBindingPDA,
    bidderTokenAccount,
    vault:              saleAccount.vault,
    paymentTokenMint:   new PublicKey(PAYMENT_TOKEN_MINT),
    tokenProgram:       TOKEN_PROGRAM_ID,
    systemProgram:      SystemProgram.programId,
    instructions:       SYSVAR_INSTRUCTIONS_PUBKEY,
  })
  .instruction();

const tx = new Transaction();
tx.add(ed25519Ix, placeBidIx);
tx.feePayer = wallet.publicKey;
const { blockhash } = await connection.getLatestBlockhash();
tx.recentBlockhash = blockhash;

const signed = await wallet.signTransaction(tx);
const sig = await connection.sendRawTransaction(signed.serialize());
await connection.confirmTransaction(sig, "confirmed");

Reading committed amounts

To show a participant their current committed amount, poll the entityState account:
const state = await program.account.entityState.fetchNullable(entityStatePDA);
const committedAmount = state ? BigInt(state.currentAmount.toString()) : BigInt(0);
A null result means the entity hasn’t placed a bid yet; treat the committed amount as zero.

Further reading

SettlementSale (SVM)

Program reference: PDAs, instructions, and account types

Frontend-only

React SPA auth and Sonar API patterns

Frontend with Backend

Next.js server-side auth patterns

Purchase Permits

How Sonar authorizes participation