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.TESTNET,
  apiKey: process.env.NEXT_PUBLIC_TESTNET_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 { PublicKey } from "@solana/web3.js";
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,
    wallet,
  });

  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 APT and 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_TESTNET_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