Solana KitHow to Build a Token-Gated App

Building the Frontend

Create the React UI for uploading and purchasing token-gated files

Building the Frontend

In this final section, you'll build the React frontend that ties everything together. The UI allows users to:

  • Connect their Solana wallet
  • Upload encrypted files with a price
  • Browse and purchase files
  • Decrypt and download purchased files

Project Structure

providers.tsx
header.tsx
wallet-button.tsx
file-upload.tsx
purchase-card.tsx
config.ts
shelbyClient.ts
encryption.ts
anchor.ts
utils.ts
layout.tsx
page.tsx
globals.css

Part A: Configuration

lib/config.ts

This file centralizes all configuration including program IDs, RPC endpoints, and ACE settings.

import type { Address } from "@solana/kit";

/**
 * Configuration for the token-gated access control dapp.
 *
 * Program IDs default to deployed testnet addresses.
 * Override via environment variables for custom deployments.
 */
export const config = {
  // Solana RPC endpoint (used for reading blob metadata)
  solanaRpcUrl:
    process.env.NEXT_PUBLIC_SOLANA_RPC_URL || "https://api.testnet.solana.com",

  // Solana programs
  programs: {
    /** Access control program - handles blob registration and purchases */
    accessControl: (process.env.NEXT_PUBLIC_ACCESS_CONTROL_PROGRAM_ID ||
      "Ej2KamzNByfcYEkkbx9TT5RCqbKgkmvQ5NCC7rPyyzxq") as Address,
    /** ACE hook program - verifies access for decryption */
    aceHook: (process.env.NEXT_PUBLIC_ACE_HOOK_PROGRAM_ID ||
      "3eQcE44r9fPmNVbfQtZrwZmRWsifjouHRaxmRKgshEND") as Address,
  },

  // ACE threshold decryption committee
  ace: {
    /** Worker endpoints for threshold IBE (filter out empty values) */
    workerEndpoints:
      [
        process.env.NEXT_PUBLIC_ACE_WORKER_0,
        process.env.NEXT_PUBLIC_ACE_WORKER_1,
      ].filter((url): url is string => !!url).length > 0
        ? [
            process.env.NEXT_PUBLIC_ACE_WORKER_0,
            process.env.NEXT_PUBLIC_ACE_WORKER_1,
          ].filter((url): url is string => !!url)
        : [
            "https://ace-worker-0-646682240579.europe-west1.run.app",
            "https://ace-worker-1-646682240579.europe-west1.run.app",
          ],
    /** Threshold for decryption (number of workers needed) */
    threshold: Number.parseInt(
      process.env.NEXT_PUBLIC_ACE_THRESHOLD || "2",
      10
    ),
    /** Solana chain name for ACE contract ID */
    solanaChainName: (process.env.NEXT_PUBLIC_ACE_CHAIN_NAME ||
      "testnet") as "mainnet-beta" | "testnet" | "devnet" | "localnet",
  },

  // Shelby storage
  shelby: {
    /** Seller account address to browse files from (Aptos/Shelby address) */
    sellerAccount: process.env.NEXT_PUBLIC_SELLER_ACCOUNT || "",
  },

  // Default pricing
  pricing: {
    /** Default price in SOL (string for input binding) */
    defaultPriceSol: "0.0005",
  },
} as const;

export const LAMPORTS_PER_SOL = 1_000_000_000n;
export const SYSTEM_PROGRAM_ADDRESS =
  "11111111111111111111111111111111" as Address;

/**
 * Green box encryption scheme for threshold IBE.
 * This is the protocol-level scheme ID expected by the on-chain program.
 */
export const GREEN_BOX_SCHEME = 2;

lib/shelbyClient.ts

Create a shared Shelby client instance.

"use client";

import { ShelbyClient } from "@shelby-protocol/sdk/browser";
import { Network } from "@shelby-protocol/solana-kit/react";

export const shelbyClient = new ShelbyClient({
  network: Network.SHELBYNET,
  apiKey: process.env.NEXT_PUBLIC_SHELBYNET_API_KEY || "",
});

Part B: Encryption Helpers

lib/encryption.ts

This file handles both AES-GCM encryption (for files) and ACE threshold encryption (for keys).

import { ace } from "@aptos-labs/ace-sdk";
import { config } from "./config";

// ============================================================================
// AES-GCM Encryption (for file content - the "redBox")
// ============================================================================

const IV_LENGTH = 12; // 96 bits for AES-GCM

/** Helper to convert Uint8Array to ArrayBuffer for Web Crypto API */
function toArrayBuffer(arr: Uint8Array): ArrayBuffer {
  return arr.buffer.slice(
    arr.byteOffset,
    arr.byteOffset + arr.byteLength
  ) as ArrayBuffer;
}

/**
 * Generate a random 256-bit key for AES-GCM encryption.
 */
export function generateRedKey(): Uint8Array {
  return crypto.getRandomValues(new Uint8Array(32));
}

/**
 * Encrypt file content with AES-GCM using the provided key.
 * Returns: IV (12 bytes) || ciphertext
 */
export async function encryptFile(
  plaintext: Uint8Array,
  redKey: Uint8Array
): Promise<Uint8Array> {
  const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));

  const cryptoKey = await crypto.subtle.importKey(
    "raw",
    toArrayBuffer(redKey),
    { name: "AES-GCM" },
    false,
    ["encrypt"]
  );

  const ciphertext = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv },
    cryptoKey,
    toArrayBuffer(plaintext)
  );

  // Prepend IV to ciphertext
  const result = new Uint8Array(iv.length + ciphertext.byteLength);
  result.set(iv, 0);
  result.set(new Uint8Array(ciphertext), iv.length);

  return result;
}

/**
 * Decrypt file content with AES-GCM.
 * Input format: IV (12 bytes) || ciphertext
 */
export async function decryptFile(
  redBox: Uint8Array,
  redKey: Uint8Array
): Promise<Uint8Array> {
  const iv = redBox.slice(0, IV_LENGTH);
  const ciphertext = redBox.slice(IV_LENGTH);

  const cryptoKey = await crypto.subtle.importKey(
    "raw",
    toArrayBuffer(redKey),
    { name: "AES-GCM" },
    false,
    ["decrypt"]
  );

  const plaintext = await crypto.subtle.decrypt(
    { name: "AES-GCM", iv },
    cryptoKey,
    toArrayBuffer(ciphertext)
  );

  return new Uint8Array(plaintext);
}

// ============================================================================
// ACE Threshold IBE (for key encryption - the "greenBox")
// ============================================================================

/**
 * Create an ACE committee instance.
 */
export function createAceCommittee(): ace.Committee {
  return new ace.Committee({
    workerEndpoints: [...config.ace.workerEndpoints] as string[],
    threshold: config.ace.threshold,
  });
}

/**
 * Create an ACE contract ID for Solana.
 */
export function createAceContractId(): ace.ContractID {
  return ace.ContractID.newSolana({
    knownChainName: config.ace.solanaChainName,
    programId: config.programs.aceHook,
  });
}

/**
 * Encrypt the redKey into a greenBox using threshold IBE.
 * This greenBox can only be decrypted by users who have purchased access.
 */
export async function encryptRedKey(
  redKey: Uint8Array,
  fullBlobNameBytes: Uint8Array
): Promise<Uint8Array> {
  const committee = createAceCommittee();
  const contractId = createAceContractId();

  // Fetch encryption key from committee
  const encryptionKeyResult = await ace.EncryptionKey.fetch({
    committee,
  });
  const encryptionKey = encryptionKeyResult.unwrapOrThrow(
    "Failed to fetch encryption key"
  );

  // Encrypt the redKey
  const encryptResult = ace.encrypt({
    encryptionKey,
    contractId,
    domain: fullBlobNameBytes,
    plaintext: redKey,
  }).unwrapOrThrow("Failed to encrypt redKey");

  return encryptResult.ciphertext.toBytes();
}

/**
 * Decrypt the greenBox to recover the redKey using a proof-of-permission transaction.
 * @param signedTransactionBytes - Serialized signed transaction bytes (from any Solana SDK)
 */
export async function decryptGreenBox(
  greenBoxBytes: Uint8Array,
  fullBlobNameBytes: Uint8Array,
  signedTransactionBytes: Uint8Array
): Promise<Uint8Array> {
  const committee = createAceCommittee();
  const contractId = createAceContractId();

  // Reconstruct the ciphertext from bytes
  const greenBox = ace.Ciphertext.fromBytes(greenBoxBytes).unwrapOrThrow(
    "Failed to parse greenBox ciphertext"
  );

  // Create proof of permission from the signed transaction bytes
  const pop = ace.ProofOfPermission.createSolana({
    txn: signedTransactionBytes as any,
  });

  // Fetch decryption key from committee
  const decryptionKeyResult = await ace.DecryptionKey.fetch({
    committee,
    contractId,
    domain: fullBlobNameBytes,
    proof: pop,
  });
  const decryptionKey = decryptionKeyResult.unwrapOrThrow(
    "Failed to fetch decryption key"
  );

  // Decrypt the greenBox
  const plaintext = ace.decrypt({
    decryptionKey,
    ciphertext: greenBox,
  }).unwrapOrThrow("Failed to decrypt greenBox");

  return plaintext;
}

Part C: Anchor Helpers

lib/anchor.ts

This file provides PDA derivation and instruction encoding using Anchor.

import { AnchorProvider, BN, Program } from "@coral-xyz/anchor";
import {
  type Address,
  getAddressEncoder,
  getProgramDerivedAddress,
} from "@solana/kit";
import { Connection, PublicKey } from "@solana/web3.js";
import accessControlIdl from "../../anchor/target/idl/access_control.json";
import aceHookIdl from "../../anchor/target/idl/ace_hook.json";
import type { AccessControl } from "../../anchor/target/types/access_control";
import type { AceHook } from "../../anchor/target/types/ace_hook";
import { config, GREEN_BOX_SCHEME } from "./config";

// ============================================================================
// Anchor Program Helpers
// ============================================================================

/**
 * Create an Anchor provider with a mock wallet for encoding instructions.
 * The wallet is only used for account resolution, not actual signing.
 */
function createEncodingProvider(signerPubkey?: PublicKey): AnchorProvider {
  const connection = new Connection(config.solanaRpcUrl, "confirmed");

  // Create a mock wallet that satisfies Anchor's requirements
  const mockWallet = {
    publicKey: signerPubkey ?? PublicKey.default,
    signTransaction: async () => {
      throw new Error("Not implemented");
    },
    signAllTransactions: async () => {
      throw new Error("Not implemented");
    },
  };

  return new AnchorProvider(connection, mockWallet as never, {
    commitment: "confirmed",
  });
}

/**
 * Get the AccessControl program instance for encoding/fetching
 */
function getAccessControlProgram(
  signerPubkey?: PublicKey
): Program<AccessControl> {
  return new Program<AccessControl>(
    accessControlIdl as AccessControl,
    createEncodingProvider(signerPubkey)
  );
}

/**
 * Get the AceHook program instance for encoding/fetching
 */
function getAceHookProgram(signerPubkey?: PublicKey): Program<AceHook> {
  return new Program<AceHook>(
    aceHookIdl as AceHook,
    createEncodingProvider(signerPubkey)
  );
}

// ============================================================================
// PDA Derivations
// ============================================================================

/**
 * Derive the blob metadata PDA for a given owner and blob name.
 * Seeds must be raw bytes (no length prefix) to match Anchor's PDA derivation.
 */
export async function deriveBlobMetadataPda(
  storageAccountAddressBytes: Uint8Array,
  blobName: string
): Promise<Address> {
  const [pda] = await getProgramDerivedAddress({
    programAddress: config.programs.accessControl,
    seeds: [
      new TextEncoder().encode("blob_metadata"),
      storageAccountAddressBytes,
      new TextEncoder().encode(blobName),
    ],
  });
  return pda;
}

/**
 * Derive the access receipt PDA for a given owner, blob name, and buyer.
 * Seeds must be raw bytes (no length prefix) to match Anchor's PDA derivation.
 */
export async function deriveAccessReceiptPda(
  storageAccountAddressBytes: Uint8Array,
  blobName: string,
  buyerAddress: Address
): Promise<Address> {
  const [pda] = await getProgramDerivedAddress({
    programAddress: config.programs.accessControl,
    seeds: [
      new TextEncoder().encode("access"),
      storageAccountAddressBytes,
      new TextEncoder().encode(blobName),
      getAddressEncoder().encode(buyerAddress),
    ],
  });
  return pda;
}

/**
 * Check if a user has already purchased access to a blob.
 * Returns true if the receipt account exists on-chain.
 */
export async function checkHasPurchased(
  storageAccountAddressBytes: Uint8Array,
  blobName: string,
  buyerAddress: Address
): Promise<boolean> {
  const receiptPda = await deriveAccessReceiptPda(
    storageAccountAddressBytes,
    blobName,
    buyerAddress
  );
  const connection = new Connection(config.solanaRpcUrl, "confirmed");
  const accountInfo = await connection.getAccountInfo(
    new PublicKey(receiptPda)
  );
  return accountInfo !== null;
}

// ============================================================================
// Instruction Data Encoders (using Anchor for type-safe encoding)
// ============================================================================

/**
 * Encode the register_blob instruction data using Anchor.
 * @param signerAddress - The signer's Solana address (used for account resolution)
 */
export async function encodeRegisterBlobData(
  storageAccountAddress: Uint8Array,
  blobName: string,
  greenBoxScheme: number,
  greenBoxBytes: Uint8Array,
  price: bigint,
  signerAddress: Address
): Promise<Uint8Array> {
  const signerPubkey = new PublicKey(signerAddress);
  const program = getAccessControlProgram(signerPubkey);

  // Build the instruction using Anchor's type-safe methods builder
  const ix = await program.methods
    .registerBlob(
      Array.from(storageAccountAddress) as number[],
      blobName,
      greenBoxScheme,
      Buffer.from(greenBoxBytes),
      new BN(price.toString())
    )
    .instruction();

  return new Uint8Array(ix.data);
}

/**
 * Encode the purchase instruction data using Anchor.
 * @param signerAddress - The buyer's Solana address (the signer)
 * @param ownerSolanaAddress - The seller's Solana address (receives SOL)
 */
export async function encodePurchaseData(
  storageAccountAddress: Uint8Array,
  blobName: string,
  signerAddress: Address,
  ownerSolanaAddress: Address
): Promise<Uint8Array> {
  const signerPubkey = new PublicKey(signerAddress);
  const ownerPubkey = new PublicKey(ownerSolanaAddress);
  const program = getAccessControlProgram(signerPubkey);

  // Build the instruction using Anchor's type-safe methods builder
  // We must provide the `owner` account explicitly since Anchor can't auto-resolve it
  const ix = await program.methods
    .purchase(Array.from(storageAccountAddress) as number[], blobName)
    .accounts({
      owner: ownerPubkey,
    })
    .instruction();

  return new Uint8Array(ix.data);
}

/**
 * Encode the assert_access instruction data using Anchor.
 * @param signerAddress - The user's Solana address (the signer)
 * @param blobMetadataPda - The blob metadata PDA
 * @param receiptPda - The access receipt PDA
 */
export async function encodeAssertAccessData(
  fullBlobNameBytes: Uint8Array,
  signerAddress: Address,
  blobMetadataPda: Address,
  receiptPda: Address
): Promise<Uint8Array> {
  const signerPubkey = new PublicKey(signerAddress);
  const program = getAceHookProgram(signerPubkey);

  // Build the instruction using Anchor's type-safe methods builder
  // We must provide accounts explicitly since Anchor can't auto-resolve them
  const ix = await program.methods
    .assertAccess(Buffer.from(fullBlobNameBytes))
    .accounts({
      blobMetadata: new PublicKey(blobMetadataPda),
      receipt: new PublicKey(receiptPda),
    })
    .instruction();

  return new Uint8Array(ix.data);
}

// ============================================================================
// On-chain fetch helpers
// ============================================================================

/**
 * Fetch and decode the blob_metadata account from Solana using Anchor.
 */
export async function fetchBlobMetadata(
  storageAccountAddressBytes: Uint8Array,
  blobName: string
): Promise<{
  owner: Address;
  greenBoxBytes: Uint8Array;
  price: bigint;
  seqnum: bigint;
}> {
  const program = getAccessControlProgram();

  // Derive PDA using @solana/kit (consistent with rest of codebase)
  const pda = await deriveBlobMetadataPda(storageAccountAddressBytes, blobName);

  // Fetch account using Anchor - auto-deserializes the data
  const metadata = await program.account.blobMetadata.fetch(new PublicKey(pda));

  if (metadata.greenBoxScheme !== GREEN_BOX_SCHEME) {
    throw new Error(`Unsupported green_box_scheme: ${metadata.greenBoxScheme}`);
  }

  return {
    owner: metadata.owner.toBase58() as Address,
    greenBoxBytes: Buffer.from(metadata.greenBoxBytes),
    price: BigInt(metadata.price.toString()),
    seqnum: BigInt(metadata.seqnum.toString()),
  };
}

lib/utils.ts

Utility functions for blob names and file downloads.

// ============================================================================
// Full Blob Name Construction
// ============================================================================

/**
 * Construct the full blob name bytes used for ACE encryption.
 * Format: "0x" (2 bytes) + owner_aptos_addr (32 bytes) + "/" (1 byte) + blob_name
 */
export function buildFullBlobNameBytes(
  ownerAptosAddrBytes: Uint8Array,
  blobName: string
): Uint8Array {
  const prefix = new TextEncoder().encode("0x");
  const separator = new TextEncoder().encode("/");
  const nameBytes = new TextEncoder().encode(blobName);

  const result = new Uint8Array(
    prefix.length +
      ownerAptosAddrBytes.length +
      separator.length +
      nameBytes.length
  );
  result.set(prefix, 0);
  result.set(ownerAptosAddrBytes, prefix.length);
  result.set(separator, prefix.length + ownerAptosAddrBytes.length);
  result.set(
    nameBytes,
    prefix.length + ownerAptosAddrBytes.length + separator.length
  );

  return result;
}

// ============================================================================
// File Download
// ============================================================================

/**
 * Trigger a file download in the browser.
 */
export function downloadFile(
  data: Uint8Array,
  filename: string,
  mimeType = "application/octet-stream"
): void {
  const blob = new Blob([data as BlobPart], { type: mimeType });
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = filename;
  a.click();
  URL.revokeObjectURL(url);
}

Part D: UI Components

components/providers.tsx

Set up the Solana client and React Query providers.

"use client";

import { autoDiscover, createClient } from "@solana/client";
import { SolanaProvider } from "@solana/react-hooks";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { PropsWithChildren } from "react";
import { config } from "../lib/config";

const isSolanaWallet = (wallet: { features: Record<string, unknown> }) => {
  return Object.keys(wallet.features).some((feature) =>
    feature.startsWith("solana:")
  );
};

const client = createClient({
  endpoint: config.solanaRpcUrl,
  walletConnectors: autoDiscover({ filter: isSolanaWallet }),
});

const queryClient = new QueryClient();

export function Providers({ children }: PropsWithChildren) {
  return (
    <QueryClientProvider client={queryClient}>
      <SolanaProvider client={client}>{children}</SolanaProvider>
    </QueryClientProvider>
  );
}

components/file-upload.tsx

The seller component for uploading encrypted files. This component uses the useStorageAccount hook to derive a Shelby storage account from the connected Solana wallet. Learn more about storage accounts.

"use client";

import { useUploadBlobs } from "@shelby-protocol/react";
import { useStorageAccount } from "@shelby-protocol/solana-kit/react";
import { useSendTransaction, useWalletConnection } from "@solana/react-hooks";
import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useRef, useState } from "react";
import type { ReactNode } from "react";
import {
  config,
  GREEN_BOX_SCHEME,
  LAMPORTS_PER_SOL,
  SYSTEM_PROGRAM_ADDRESS,
} from "../lib/config";
import { deriveBlobMetadataPda, encodeRegisterBlobData } from "../lib/anchor";
import { encryptFile, encryptRedKey, generateRedKey } from "../lib/encryption";
import { buildFullBlobNameBytes } from "../lib/utils";
import { shelbyClient } from "../lib/shelbyClient";

type UploadStep =
  | "idle"
  | "encrypting"
  | "uploading"
  | "registering"
  | "done"
  | "error";

export function FileUpload() {
  const { status, wallet } = useWalletConnection();
  const { send, isSending } = useSendTransaction();
  const fileInputRef = useRef<HTMLInputElement>(null);
  const queryClient = useQueryClient();

  const { storageAccountAddress, signAndSubmitTransaction } = useStorageAccount(
    {
      client: shelbyClient,
      solanaAddress: wallet?.account.address.toString(),
      signMessageFn: wallet?.signMessage,
    }
  );

  const { mutateAsync: uploadBlobs } = useUploadBlobs({
    client: shelbyClient,
  });

  const [selectedFile, setSelectedFile] = useState<File | null>(null);
  const [price, setPrice] = useState<string>(config.pricing.defaultPriceSol);
  const [step, setStep] = useState<UploadStep>("idle");
  const [statusMessage, setStatusMessage] = useState<ReactNode | null>(null);

  const handleUpload = useCallback(async () => {
    if (!selectedFile || !storageAccountAddress || !wallet) return;

    const walletAddress = wallet.account.address;

    try {
      // Step 1: Generate a random AES key and encrypt the file.
      setStep("encrypting");
      setStatusMessage("Generating encryption key and encrypting file...");

      const redKey = generateRedKey();
      const fileBytes = new Uint8Array(await selectedFile.arrayBuffer());
      const redBox = await encryptFile(fileBytes, redKey);

      // Step 2: Encrypt the AES key with threshold IBE so only buyers can decrypt.
      const storageAccountAddressBytes = storageAccountAddress.toUint8Array();
      const fullBlobNameBytes = buildFullBlobNameBytes(
        storageAccountAddressBytes,
        selectedFile.name
      );

      setStatusMessage("Encrypting access key with threshold cryptography...");
      const greenBoxBytes = await encryptRedKey(redKey, fullBlobNameBytes);

      // Step 3: Upload the encrypted file to Shelby storage.
      setStep("uploading");
      setStatusMessage("Uploading encrypted file to Shelby...");

      await uploadBlobs({
        signer: { account: storageAccountAddress, signAndSubmitTransaction },
        blobs: [
          {
            blobName: selectedFile.name,
            blobData: redBox,
          },
        ],
        expirationMicros: (1000 * 60 * 60 * 24 * 30 + Date.now()) * 1000,
      });

      // Step 4: Register the encrypted key and price on-chain.
      setStep("registering");
      setStatusMessage("Registering file on Solana...");

      const priceLamports = BigInt(
        Math.floor(Number.parseFloat(price) * Number(LAMPORTS_PER_SOL))
      );

      const blobMetadataPda = await deriveBlobMetadataPda(
        storageAccountAddressBytes,
        selectedFile.name
      );
      const instructionData = await encodeRegisterBlobData(
        storageAccountAddressBytes,
        selectedFile.name,
        GREEN_BOX_SCHEME,
        greenBoxBytes,
        priceLamports,
        walletAddress
      );

      const instruction = {
        programAddress: config.programs.accessControl,
        accounts: [
          { address: blobMetadataPda, role: 1 },
          { address: walletAddress, role: 3 },
          { address: SYSTEM_PROGRAM_ADDRESS, role: 0 },
        ],
        data: instructionData,
      };

      const result = await send({ instructions: [instruction] });

      setStep("done");
      const explorerUrl = `https://explorer.solana.com/tx/${result}?cluster=testnet`;
      setStatusMessage(
        <>
          Successfully uploaded and registered: {selectedFile.name}.
          <a
            className="underline underline-offset-2"
            href={explorerUrl}
            target="_blank"
            rel="noreferrer"
          >
            View transaction
          </a>
        </>
      );
      setSelectedFile(null);
      if (fileInputRef.current) {
        fileInputRef.current.value = "";
      }

      setTimeout(() => {
        queryClient.invalidateQueries();
      }, 1500);
    } catch (err) {
      setStep("error");
      const message = err instanceof Error ? err.message : "Unknown error";
      const cause =
        err instanceof Error && err.cause instanceof Error
          ? err.cause.message
          : undefined;
      setStatusMessage(
        cause ? `Error: ${message} — ${cause}` : `Error: ${message}`
      );
    }
  }, [
    selectedFile,
    storageAccountAddress,
    wallet,
    price,
    send,
    signAndSubmitTransaction,
    uploadBlobs,
    queryClient,
  ]);

  const handleFileChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      const file = e.target.files?.[0] ?? null;
      setSelectedFile(file);
      setStep("idle");
      setStatusMessage(null);
    },
    []
  );

  const handleSelectFile = useCallback(() => {
    fileInputRef.current?.click();
  }, []);

  const isProcessing = step !== "idle" && step !== "done" && step !== "error";

  if (status !== "connected") {
    return (
      <section className="w-full max-w-3xl space-y-4 rounded-2xl border border-border-low bg-card p-6 shadow-[0_20px_80px_-50px_rgba(0,0,0,0.35)]">
        <div className="space-y-1">
          <p className="text-lg font-semibold">Upload Token-Gated File</p>
          <p className="text-sm text-muted">
            Connect your wallet to upload encrypted files that can be purchased
            by others.
          </p>
        </div>
        <div className="rounded-lg bg-cream/50 p-4 text-center text-sm text-muted">
          Wallet not connected
        </div>
      </section>
    );
  }

  return (
    <section className="w-full max-w-3xl space-y-4 rounded-2xl border border-border-low bg-card p-6 shadow-[0_20px_80px_-50px_rgba(0,0,0,0.35)]">
      <div className="space-y-1">
        <p className="text-lg font-semibold">Upload Token-Gated File</p>
        <p className="text-sm text-muted">
          Upload an encrypted file to Shelby and register it on Solana. Others
          can purchase access to decrypt it.
        </p>
      </div>

      {/* File Input */}
      <div className="space-y-3">
        <input
          ref={fileInputRef}
          type="file"
          onChange={handleFileChange}
          disabled={isProcessing || isSending}
          className="hidden"
        />

        <div
          onClick={handleSelectFile}
          className="cursor-pointer rounded-xl border-2 border-dashed border-border-low bg-cream/30 p-8 text-center transition hover:border-foreground/30 hover:bg-cream/50"
        >
          {selectedFile ? (
            <div className="space-y-1">
              <p className="font-medium">{selectedFile.name}</p>
              <p className="text-sm text-muted">
                {(selectedFile.size / 1024).toFixed(1)} KB
              </p>
            </div>
          ) : (
            <div className="space-y-1">
              <p className="text-sm text-muted">
                Click to select a file to sell
              </p>
            </div>
          )}
        </div>

        {/* Price Input */}
        <div className="flex items-center gap-3">
          <label className="text-sm text-muted">Price (SOL):</label>
          <input
            type="number"
            min="0"
            step="0.0001"
            value={price}
            onChange={(e) => setPrice(e.target.value)}
            disabled={isProcessing || isSending}
            className="flex-1 rounded-lg border border-border-low bg-card px-4 py-2.5 text-sm outline-none transition placeholder:text-muted focus:border-foreground/30 disabled:cursor-not-allowed disabled:opacity-60"
          />
        </div>

        {/* Upload Button */}
        <button
          onClick={handleUpload}
          disabled={isProcessing || isSending || !selectedFile}
          className="w-full rounded-lg bg-foreground px-5 py-2.5 text-sm font-medium text-background transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40"
        >
          {isProcessing || isSending ? "Processing..." : "Upload & Register"}
        </button>
      </div>

      {/* Progress Steps */}
      {step !== "idle" && (
        <div className="space-y-2 rounded-lg border border-border-low bg-cream/30 p-4">
          <div className="flex items-center gap-2">
            <StepIndicator
              active={step === "encrypting"}
              done={
                step === "uploading" ||
                step === "registering" ||
                step === "done"
              }
            />
            <span className="text-sm">Encrypt file</span>
          </div>
          <div className="flex items-center gap-2">
            <StepIndicator
              active={step === "uploading"}
              done={step === "registering" || step === "done"}
            />
            <span className="text-sm">Upload to Shelby</span>
          </div>
          <div className="flex items-center gap-2">
            <StepIndicator
              active={step === "registering"}
              done={step === "done"}
            />
            <span className="text-sm">Register on Solana</span>
          </div>
        </div>
      )}

      {/* Status Message */}
      {statusMessage && (
        <div
          className={`rounded-lg border px-4 py-3 text-sm break-all ${
            step === "error"
              ? "border-red-300 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400"
              : step === "done"
              ? "border-green-300 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-900/20 dark:text-green-400"
              : "border-border-low bg-cream/50"
          }`}
        >
          {statusMessage}
        </div>
      )}

      {/* Storage Account Info */}
      <div className="border-t border-border-low pt-4 text-xs text-muted">
        <p>
          <span className="font-medium">Shelby Storage Account:</span>{" "}
          <span className="font-mono">{storageAccountAddress?.toString()}</span>
        </p>
      </div>
    </section>
  );
}

function StepIndicator({ active, done }: { active: boolean; done: boolean }) {
  if (done) {
    return (
      <span className="flex h-5 w-5 items-center justify-center rounded-full bg-green-500 text-white text-xs">

      </span>
    );
  }
  if (active) {
    return (
      <span className="flex h-5 w-5 items-center justify-center rounded-full bg-foreground text-background">
        <span className="h-2 w-2 animate-pulse rounded-full bg-background" />
      </span>
    );
  }
  return (
    <span className="flex h-5 w-5 items-center justify-center rounded-full border-2 border-border-low" />
  );
}

components/purchase-card.tsx

The buyer component for browsing, purchasing, and downloading files.

"use client";

import { useAccountBlobs } from "@shelby-protocol/react";
import { type BlobMetadata } from "@shelby-protocol/sdk/browser";
import {
  appendTransactionMessageInstruction,
  type Blockhash,
  compileTransaction,
  createSolanaRpc,
  createTransactionMessage,
  getTransactionEncoder,
  setTransactionMessageFeePayer,
  setTransactionMessageLifetimeUsingBlockhash,
} from "@solana/kit";
import { useSendTransaction, useWalletConnection } from "@solana/react-hooks";
import { useCallback, useEffect, useState } from "react";
import type { ReactNode } from "react";
import { config, SYSTEM_PROGRAM_ADDRESS } from "../lib/config";
import { shelbyClient } from "../lib/shelbyClient";
import {
  checkHasPurchased,
  deriveAccessReceiptPda,
  deriveBlobMetadataPda,
  encodeAssertAccessData,
  encodePurchaseData,
  fetchBlobMetadata,
} from "../lib/anchor";
import { decryptFile, decryptGreenBox } from "../lib/encryption";
import { buildFullBlobNameBytes, downloadFile } from "../lib/utils";

type PurchaseStep =
  | "idle"
  | "purchasing"
  | "decrypting"
  | "downloading"
  | "done"
  | "error";

export function PurchaseCard() {
  const { status, wallet } = useWalletConnection();
  const { send, isSending } = useSendTransaction();

  const sellerAccount = config.shelby.sellerAccount;
  const {
    data: blobs,
    isLoading: isBlobsLoading,
    error: blobsError,
  } = useAccountBlobs({
    client: shelbyClient,
    account: sellerAccount,
    enabled: !!sellerAccount,
  });

  const [selectedBlob, setSelectedBlob] = useState<BlobMetadata | null>(null);
  const [step, setStep] = useState<PurchaseStep>("idle");
  const [statusMessage, setStatusMessage] = useState<ReactNode | null>(null);
  const [purchasedFiles, setPurchasedFiles] = useState<Map<string, boolean>>(
    new Map()
  );
  const [isCheckingPurchases, setIsCheckingPurchases] = useState(false);

  useEffect(() => {
    if (!wallet || !blobs || blobs.length === 0) return;

    const buyerAddress = wallet.account.address;

    async function checkAllPurchases() {
      setIsCheckingPurchases(true);
      const results = new Map<string, boolean>();

      await Promise.all(
        blobs!.map(async (blob) => {
          try {
            const hasPurchased = await checkHasPurchased(
              blob.owner.bcsToBytes(),
              blob.blobNameSuffix,
              buyerAddress
            );
            results.set(blob.blobNameSuffix, hasPurchased);
          } catch (err) {
            console.error(
              `Error checking purchase status for ${blob.blobNameSuffix}:`,
              err
            );
            results.set(blob.blobNameSuffix, false);
          }
        })
      );

      setPurchasedFiles(results);
      setIsCheckingPurchases(false);
    }

    checkAllPurchases();
  }, [wallet, blobs]);

  const handlePurchase = useCallback(async () => {
    if (!wallet || !selectedBlob) return;

    const buyerAddress = wallet.account.address;
    const fileName = selectedBlob.blobNameSuffix;

    try {
      // Step 1: Build purchase instruction with PDAs and seller info.
      setStep("purchasing");
      setStatusMessage("Building purchase transaction...");

      const storageAccountAddressBytes = selectedBlob.owner.bcsToBytes();
      if (storageAccountAddressBytes.length !== 32) {
        throw new Error("Invalid storage account address: must be 32 bytes");
      }

      const blobMetadataPda = await deriveBlobMetadataPda(
        storageAccountAddressBytes,
        fileName
      );
      const receiptPda = await deriveAccessReceiptPda(
        storageAccountAddressBytes,
        fileName,
        buyerAddress
      );

      const { owner: ownerSolanaPubkey } = await fetchBlobMetadata(
        storageAccountAddressBytes,
        fileName
      );

      const instructionData = await encodePurchaseData(
        storageAccountAddressBytes,
        fileName,
        buyerAddress,
        ownerSolanaPubkey
      );

      const instruction = {
        programAddress: config.programs.accessControl,
        accounts: [
          { address: blobMetadataPda, role: 0 },
          { address: receiptPda, role: 1 },
          { address: buyerAddress, role: 3 },
          { address: ownerSolanaPubkey, role: 1 },
          { address: SYSTEM_PROGRAM_ADDRESS, role: 0 },
        ],
        data: instructionData,
      };

      // Step 2: Sign and submit the purchase transaction.
      setStatusMessage("Awaiting signature...");
      const signature = await send({ instructions: [instruction] });

      setStep("done");
      const explorerUrl = `https://explorer.solana.com/tx/${signature}?cluster=testnet`;
      setStatusMessage(
        <>
          Purchase successful! You can now decrypt the file.{" "}
          <a
            className="underline underline-offset-2"
            href={explorerUrl}
            target="_blank"
            rel="noreferrer"
          >
            View transaction
          </a>
        </>
      );
      setPurchasedFiles((prev) => new Map(prev).set(fileName, true));
    } catch (err) {
      console.error("Purchase failed:", err);
      const errorMessage = err instanceof Error ? err.message : "Unknown error";
      setStep("error");
      setStatusMessage(`Error: ${errorMessage}`);
    }
  }, [wallet, selectedBlob, send]);

  const handleDecrypt = useCallback(async () => {
    if (!wallet || !selectedBlob) return;

    const buyerAddress = wallet.account.address;
    const fileName = selectedBlob.blobNameSuffix;

    try {
      setStep("decrypting");
      setStatusMessage("Building proof of permission...");

      // Step 1: Build the assert_access instruction as proof of permission.
      const storageAccountAddressBytes = selectedBlob.owner.bcsToBytes();
      const fullBlobNameBytes = buildFullBlobNameBytes(
        storageAccountAddressBytes,
        fileName
      );

      const blobMetadataPda = await deriveBlobMetadataPda(
        storageAccountAddressBytes,
        fileName
      );
      const receiptPda = await deriveAccessReceiptPda(
        storageAccountAddressBytes,
        fileName,
        buyerAddress
      );

      const assertAccessData = await encodeAssertAccessData(
        fullBlobNameBytes,
        buyerAddress,
        blobMetadataPda,
        receiptPda
      );

      const instruction = {
        programAddress: config.programs.aceHook,
        accounts: [
          { address: blobMetadataPda, role: 0 },
          { address: receiptPda, role: 0 },
          { address: buyerAddress, role: 2 },
        ],
        data: assertAccessData,
      };

      // Step 2: Build and sign the transaction (not submitted, only used as proof).
      const rpc = createSolanaRpc(config.solanaRpcUrl);
      const { value: latestBlockhash } = await rpc.getLatestBlockhash().send();
      const { blockhash, lastValidBlockHeight } = latestBlockhash;

      let txMessage: any = createTransactionMessage({ version: 0 });
      txMessage = setTransactionMessageFeePayer(buyerAddress, txMessage);
      txMessage = setTransactionMessageLifetimeUsingBlockhash(
        {
          blockhash: blockhash as Blockhash,
          lastValidBlockHeight: BigInt(lastValidBlockHeight),
        },
        txMessage
      );
      txMessage = appendTransactionMessageInstruction(instruction, txMessage);

      const compiledTx = compileTransaction(txMessage);

      setStatusMessage("Signing proof of permission...");

      if (!wallet.signTransaction) {
        throw new Error(
          "Wallet does not support signing transactions. " +
            "Please use a wallet that supports the signTransaction feature."
        );
      }

      const signedTx = await wallet.signTransaction(compiledTx as any);

      const txEncoder = getTransactionEncoder();
      const serializedTx = new Uint8Array(txEncoder.encode(signedTx as any));

      // Step 3: Send proof to ACE workers and receive the decryption key.
      const { greenBoxBytes } = await fetchBlobMetadata(
        storageAccountAddressBytes,
        fileName
      );

      setStatusMessage("Fetching decryption key from ACE committee...");

      const redKey = await decryptGreenBox(
        greenBoxBytes,
        fullBlobNameBytes,
        serializedTx
      );

      // Step 4: Fetch the encrypted file from Shelby storage.
      setStep("downloading");
      setStatusMessage("Fetching encrypted file from Shelby...");

      const blob = await shelbyClient.rpc.getBlob({
        account: selectedBlob.owner.toString(),
        blobName: fileName,
      });

      const reader = blob.readable.getReader();
      const chunks: Uint8Array[] = [];
      let totalLength = 0;
      while (true) {
        const { done, value } = await reader.read();
        if (done) break;
        chunks.push(value);
        totalLength += value.length;
      }
      const redBox = new Uint8Array(totalLength);
      let offset = 0;
      for (const chunk of chunks) {
        redBox.set(chunk, offset);
        offset += chunk.length;
      }

      // Step 5: Decrypt and download the file.
      setStatusMessage("Decrypting file...");
      const plaintext = await decryptFile(redBox, redKey);

      downloadFile(plaintext, fileName);

      setStep("done");
      setStatusMessage("File decrypted and downloaded!");
    } catch (err) {
      console.error("Decryption failed:", err);
      setStep("error");
      setStatusMessage(
        `Error: ${err instanceof Error ? err.message : "Unknown error"}`
      );
    }
  }, [wallet, selectedBlob]);

  const isProcessing = step !== "idle" && step !== "done" && step !== "error";
  const hasPurchased = selectedBlob
    ? purchasedFiles.get(selectedBlob.blobNameSuffix) === true
    : false;

  const handleAction = useCallback(async () => {
    if (!selectedBlob) return;

    if (hasPurchased) {
      await handleDecrypt();
    } else {
      await handlePurchase();
    }
  }, [selectedBlob, hasPurchased, handleDecrypt, handlePurchase]);

  if (!sellerAccount) {
    return (
      <section className="w-full max-w-3xl space-y-4 rounded-2xl border border-border-low bg-card p-6 shadow-[0_20px_80px_-50px_rgba(0,0,0,0.35)]">
        <div className="space-y-1">
          <p className="text-lg font-semibold">Browse Files</p>
          <p className="text-sm text-muted">
            No seller account configured. Set{" "}
            <code className="font-mono text-xs bg-cream/50 px-1 py-0.5 rounded">
              NEXT_PUBLIC_SELLER_ACCOUNT
            </code>{" "}
            in your <code className="font-mono text-xs">.env</code> file.
          </p>
        </div>
      </section>
    );
  }

  const isConnected = status === "connected";

  return (
    <section className="w-full max-w-3xl space-y-4 rounded-2xl border border-border-low bg-card p-6 shadow-[0_20px_80px_-50px_rgba(0,0,0,0.35)]">
      <div className="space-y-1">
        <p className="text-lg font-semibold">Browse Files</p>
        <p className="text-sm text-muted">
          {isConnected
            ? "Select a file to purchase access and decrypt."
            : "Connect your wallet to purchase access to token-gated files."}
        </p>
      </div>

      {/* File Grid */}
      <div>
        {isBlobsLoading && (
          <div className="rounded-lg bg-cream/50 p-8 text-center text-sm text-muted">
            Loading files...
          </div>
        )}

        {isCheckingPurchases && !isBlobsLoading && (
          <div className="text-xs text-muted text-center animate-pulse mb-4">
            Checking purchase status...
          </div>
        )}

        {blobsError && (
          <div className="rounded-lg border border-red-300 bg-red-50 p-4 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400">
            Failed to load files: {blobsError.message}
          </div>
        )}

        {blobs && blobs.length === 0 && (
          <div className="rounded-lg bg-cream/50 p-8 text-center text-sm text-muted">
            No files found for this account.
          </div>
        )}

        {blobs && blobs.length > 0 && (
          <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
            {blobs.map((blob) => {
              const isSelected =
                selectedBlob?.blobNameSuffix === blob.blobNameSuffix;
              const isPurchased =
                isConnected && purchasedFiles.get(blob.blobNameSuffix) === true;
              return (
                <button
                  key={blob.blobNameSuffix}
                  onClick={() => setSelectedBlob(blob)}
                  disabled={isProcessing || isSending}
                  className={`group relative flex flex-col rounded-xl border bg-card p-4 text-left transition-all duration-200 cursor-pointer disabled:cursor-not-allowed disabled:opacity-60 ${
                    isSelected
                      ? "border-foreground/50 shadow-lg shadow-foreground/5"
                      : "border-border-low hover:-translate-y-1 hover:shadow-lg hover:shadow-foreground/5 hover:border-foreground/20"
                  }`}
                >
                  {/* File Name + Lock Icon */}
                  <div className="flex items-center justify-between gap-2 mb-2">
                    <p
                      className="text-sm font-medium truncate"
                      title={blob.blobNameSuffix}
                    >
                      {blob.blobNameSuffix}
                    </p>
                    {isPurchased ? (
                      <span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-green-500/20 text-green-500">
                        <svg
                          className="h-3 w-3"
                          fill="none"
                          viewBox="0 0 24 24"
                          stroke="currentColor"
                          strokeWidth={3}
                        >
                          <path
                            strokeLinecap="round"
                            strokeLinejoin="round"
                            d="M5 13l4 4L19 7"
                          />
                        </svg>
                      </span>
                    ) : (
                      <span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-foreground/10 text-muted">
                        <svg
                          className="h-3 w-3"
                          fill="none"
                          viewBox="0 0 24 24"
                          stroke="currentColor"
                          strokeWidth={2}
                        >
                          <path
                            strokeLinecap="round"
                            strokeLinejoin="round"
                            d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
                          />
                        </svg>
                      </span>
                    )}
                  </div>

                  {/* File Size */}
                  <div className="flex items-center justify-between">
                    <span className="text-xs text-muted">
                      {(blob.size / 1024).toFixed(1)} KB
                    </span>
                    {isPurchased && (
                      <span className="text-[10px] font-medium text-green-500">
                        Purchased
                      </span>
                    )}
                  </div>

                  {/* Selection indicator */}
                  {isSelected && (
                    <div className="absolute inset-0 rounded-xl ring-2 ring-foreground/30 pointer-events-none" />
                  )}
                </button>
              );
            })}
          </div>
        )}
      </div>

      {/* Action Button - only show when a file is selected */}
      {selectedBlob && (
        <button
          onClick={handleAction}
          disabled={
            !isConnected || isProcessing || isSending || isCheckingPurchases
          }
          className={`w-full rounded-lg px-5 py-3 text-sm font-medium transition ${
            !isConnected
              ? "bg-foreground/50 text-background"
              : hasPurchased
              ? "bg-green-600 text-white hover:bg-green-700"
              : "bg-foreground text-background hover:opacity-90"
          } disabled:cursor-not-allowed disabled:opacity-40`}
        >
          {!isConnected
            ? "Connect wallet to purchase"
            : isCheckingPurchases
            ? "Checking access..."
            : step === "purchasing"
            ? "Purchasing..."
            : step === "decrypting" || step === "downloading"
            ? "Decrypting..."
            : hasPurchased
            ? "Download File"
            : "Purchase & Download"}
        </button>
      )}

      {/* Status Message - only show when a file is selected */}
      {selectedBlob && statusMessage && (
        <div
          className={`rounded-lg border px-4 py-3 text-sm ${
            step === "error"
              ? "border-red-300 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400"
              : step === "done"
              ? "border-green-300 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-900/20 dark:text-green-400"
              : "border-border-low bg-cream/50"
          }`}
        >
          {statusMessage}
        </div>
      )}
    </section>
  );
}

Part E: Main Page

app/layout.tsx

import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { Providers } from "./components/providers";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "Token-Gated Files",
  description: "Buy and sell encrypted files on Solana",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

app/page.tsx

"use client";

import { FileUpload } from "./components/file-upload";
import { PurchaseCard } from "./components/purchase-card";

export default function Home() {
  return (
    <div className="min-h-screen bg-white">
      <header className="border-b">
        <div className="mx-auto max-w-5xl px-4 py-4">
          <h1 className="text-xl font-semibold">Token-Gated Files</h1>
        </div>
      </header>

      <main className="mx-auto max-w-5xl px-4 py-8 space-y-8">
        <section>
          <h2 className="text-2xl font-semibold mb-2">
            Upload and buy encrypted files
          </h2>
          <p className="text-gray-500 mb-6">
            Upload encrypted files to sell, or purchase access to existing files
            with SOL.
          </p>
        </section>

        <FileUpload />

        <section>
          <h3 className="text-lg font-semibold mb-4">Files for sale</h3>
          <PurchaseCard />
        </section>
      </main>
    </div>
  );
}

Run the Application

Start the development server:

npm run dev

Visit http://localhost:3000 and test the complete flow:

Step 1: Upload a File

  1. Connect your Solana wallet
  2. Note your Storage Account address displayed below the upload form
  3. Make sure to fund your Storage Account with ShelbyUSD.
  4. Upload a file with a price

Step 2: Configure Seller Account

After uploading, add the seller account to your .env file so the "Browse Files" section can display files for sale:

# Seller Account (your storage account address from the upload step)
NEXT_PUBLIC_SELLER_ACCOUNT=0x-your-storage-account-address

Your final .env file should look like:

# Shelby Storage
NEXT_PUBLIC_SHELBYNET_API_KEY=AG-your-api-key-here

# Solana RPC
NEXT_PUBLIC_SOLANA_RPC_URL=your-choice-of-solana-rpc

# Program IDs
NEXT_PUBLIC_ACCESS_CONTROL_PROGRAM_ID=6XyAbrfHK5sinJAj3nXEVG2ALzKTXQv89JLuYwXictGV
NEXT_PUBLIC_ACE_HOOK_PROGRAM_ID=8jDv41SQVKCaVtkbFS1ZaVDDCEtKkAc7QXV3Y1psGts9

# ACE Workers
NEXT_PUBLIC_ACE_WORKER_0=https://ace-worker-0-646682240579.europe-west1.run.app
NEXT_PUBLIC_ACE_WORKER_1=https://ace-worker-1-646682240579.europe-west1.run.app
NEXT_PUBLIC_ACE_THRESHOLD=2
NEXT_PUBLIC_ACE_CHAIN_NAME=testnet

# Seller Account
NEXT_PUBLIC_SELLER_ACCOUNT=0x-your-storage-account-address

Step 3: Test the Purchase Flow

  1. Restart the development server
  2. Your uploaded file should now appear in "Files for sale"
  3. (Optional) Connect a different wallet to test purchasing

Congratulations! 🎉

You've built a complete token-gated file marketplace on Solana! The system provides:

  • End-to-end encryption - Files are encrypted before upload
  • Threshold cryptography - No single party can decrypt without a valid receipt
  • SOL payments - Simple micropayments for file access
  • Decentralized storage - Files stored on Shelby's distributed network

Next Steps

  • Implement file listing by multiple sellers
  • Add support for updating file prices
  • Add support for deleting a file
  • Build an admin dashboard for sellers