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
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 devVisit http://localhost:3000 and test the complete flow:
Step 1: Upload a File
- Connect your Solana wallet
- Note your Storage Account address displayed below the upload form
- Make sure to fund your Storage Account with ShelbyUSD.
- 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-addressYour 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-addressStep 3: Test the Purchase Flow
- Restart the development server
- Your uploaded file should now appear in "Files for sale"
- (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