Solana KitHow to Build a Token-Gated App

Writing the Programs

Build the Anchor programs for access control and decryption verification

Writing the Programs

In this section, you'll create two Anchor programs:

  1. access_control - Manages blob registration and purchases
  2. ace_hook - Verifies access for threshold decryption

Already Deployed Programs

The programs are already deployed on Solana testnet:

ProgramAddress
access_control6XyAbrfHK5sinJAj3nXEVG2ALzKTXQv89JLuYwXictGV
ace_hook8jDv41SQVKCaVtkbFS1ZaVDDCEtKkAc7QXV3Y1psGts9

Feel free to use these deployed programs instead of building and deploying your own. Simply configure the program IDs in your .env file and skip to the frontend section.

Program Architecture

Our system needs two programs working together:

┌─────────────────────────────────────────────────────────────────┐
│                      access_control                              │
│  • register_blob() - Store greenBox + price                     │
│  • purchase() - Transfer SOL, create receipt                    │
│  • BlobMetadata PDA - Stores encrypted key info                 │
│  • Receipt PDA - Proves purchase                                │
└─────────────────────────────────────────────────────────────────┘

                              │ reads

┌─────────────────────────────────────────────────────────────────┐
│                        ace_hook                                  │
│  • assert_access() - Verify receipt for decryption              │
│  • Called by buyers as proof-of-permission                      │
│  • Verified by ACE workers                                      │
└─────────────────────────────────────────────────────────────────┘

Part A: The access_control Program

This program handles the core marketplace functionality: registering encrypted files and processing purchases.

Create Program Files

Cargo.toml
lib.rs
mod.rs
register_blob.rs
purchase.rs

Cargo.toml

Create anchor/programs/access_control/Cargo.toml:

[package]
name = "access_control"
version = "0.1.0"
description = "Token-gated access control for encrypted files on Shelby"
edition = "2021"

[lib]
crate-type = ["cdylib", "lib"]
name = "access_control"

[features]
default = []
cpi = ["no-entrypoint"]
no-entrypoint = []
no-idl = []
no-log-ix-name = []
idl-build = ["anchor-lang/idl-build"]

[dependencies]
anchor-lang = "0.31.1"
sha3 = "0.10"

lib.rs - Main Program

Create anchor/programs/access_control/src/lib.rs:

use anchor_lang::prelude::*;

declare_id!("YOUR_PROGRAM_ID_HERE");

pub mod instructions;
pub use instructions::*;

/// Metadata for an encrypted blob registered on-chain.
/// Stores the encrypted key (greenBox) and price for access.
#[account]
pub struct BlobMetadata {
    /// The Solana owner who registered this blob
    pub owner: Pubkey,
    /// Encryption scheme used for the greenBox (2 = threshold IBE)
    pub green_box_scheme: u8,
    /// The encrypted cipher key (greenBox) that can be decrypted via ACE
    pub green_box_bytes: Vec<u8>,
    /// Sequence number for tracking updates
    pub seqnum: u64,
    /// Price in lamports to purchase access
    pub price: u64,
}

/// Receipt proving a buyer has purchased access to a blob.
#[account]
pub struct Receipt {
    /// Sequence number at time of purchase (must match blob's seqnum)
    pub seqnum: u64,
}

#[program]
pub mod access_control {
    use super::*;

    /// Register a new encrypted blob with its greenBox and price.
    /// Called by the file owner after uploading encrypted content to Shelby.
    pub fn register_blob(
        ctx: Context<RegisterBlob>,
        storage_account_address: [u8; 32],
        blob_name: String,
        green_box_scheme: u8,
        green_box_bytes: Vec<u8>,
        price: u64,
    ) -> Result<()> {
        msg!("register_blob: blob_name={}", blob_name);
        msg!("register_blob: green_box_scheme={}", green_box_scheme);
        msg!("register_blob: green_box_bytes_len={}", green_box_bytes.len());
        msg!("register_blob: price={}", price);
        instructions::register_blob::handler(
            ctx,
            storage_account_address,
            blob_name,
            green_box_scheme,
            green_box_bytes,
            price,
        )
    }

    /// Purchase access to a blob by paying the owner.
    /// Creates a receipt PDA that proves the buyer has paid.
    pub fn purchase(
        ctx: Context<Purchase>,
        storage_account_address: [u8; 32],
        blob_name: String
    ) -> Result<()> {
        instructions::purchase::handler(ctx, storage_account_address, blob_name)
    }
}

instructions/mod.rs

Create anchor/programs/access_control/src/instructions/mod.rs:

pub mod register_blob;
pub mod purchase;

pub use register_blob::*;
pub use purchase::*;

instructions/register_blob.rs

Create anchor/programs/access_control/src/instructions/register_blob.rs:

use anchor_lang::prelude::*;
use crate::BlobMetadata;

#[derive(Accounts)]
#[instruction(storage_account_address: [u8; 32], blob_name: String)]
pub struct RegisterBlob<'info> {
    #[account(
        init,
        payer = owner,
        // discriminator + owner + scheme + greenBox + seqnum + price
        // NOTE: Solana CPI limits account creation to 10KB (10240 bytes)
        // greenBox is typically ~300 bytes, allocate 1KB to be safe
        space = 8  // discriminator
              + 32 // owner
              + 1  // scheme
              + 4 + 1024 // green_box_bytes vec len + data (1KB)
              + 8  // seqnum
              + 8, // price
        seeds = [b"blob_metadata", storage_account_address.as_ref(), blob_name.as_bytes()],
        bump
    )]
    pub blob_metadata: Account<'info, BlobMetadata>,

    #[account(mut)]
    pub owner: Signer<'info>,

    pub system_program: Program<'info, System>,
}

pub fn handler(
    ctx: Context<RegisterBlob>,
    _storage_account_address: [u8; 32],
    _blob_name: String,
    green_box_scheme: u8,
    green_box_bytes: Vec<u8>,
    price: u64,
) -> Result<()> {
    let blob_metadata = &mut ctx.accounts.blob_metadata;
    blob_metadata.owner = ctx.accounts.owner.key();
    blob_metadata.green_box_scheme = green_box_scheme;
    blob_metadata.green_box_bytes = green_box_bytes;
    blob_metadata.price = price;
    blob_metadata.seqnum += 1;
    Ok(())
}

instructions/purchase.rs

Create anchor/programs/access_control/src/instructions/purchase.rs:

use anchor_lang::prelude::*;
use crate::{BlobMetadata, Receipt};

#[derive(Accounts)]
#[instruction(storage_account_address: [u8; 32], blob_name: String)]
pub struct Purchase<'info> {
    #[account(
        seeds = [b"blob_metadata", storage_account_address.as_ref(), blob_name.as_bytes()],
        bump
    )]
    pub blob_metadata: Account<'info, BlobMetadata>,

    #[account(
        init,
        payer = buyer,
        space = 8 + 8, // discriminator + seqnum
        seeds = [b"access", storage_account_address.as_ref(), blob_name.as_bytes(), buyer.key().as_ref()],
        bump
    )]
    pub receipt: Account<'info, Receipt>,

    #[account(mut)]
    pub buyer: Signer<'info>,

    /// CHECK: We verify this matches blob_metadata.owner
    #[account(mut)]
    pub owner: AccountInfo<'info>,

    pub system_program: Program<'info, System>,
}

pub fn handler(
    ctx: Context<Purchase>,
    _storage_account_address: [u8; 32],
    _blob_name: String
) -> Result<()> {
    let blob_metadata = &ctx.accounts.blob_metadata;
    msg!("purchase: price={}", blob_metadata.price);
    msg!("purchase: seqnum={}", blob_metadata.seqnum);

    // Verify the owner account matches the blob's registered owner
    require!(
        ctx.accounts.owner.key() == blob_metadata.owner,
        PurchaseError::InvalidOwner
    );

    // Transfer SOL from buyer to owner
    anchor_lang::solana_program::program::invoke(
        &anchor_lang::solana_program::system_instruction::transfer(
            ctx.accounts.buyer.key,
            &ctx.accounts.owner.key(),
            blob_metadata.price,
        ),
        &[
            ctx.accounts.buyer.to_account_info(),
            ctx.accounts.owner.to_account_info(),
            ctx.accounts.system_program.to_account_info(),
        ],
    )?;

    // Record the purchase
    let receipt = &mut ctx.accounts.receipt;
    receipt.seqnum = blob_metadata.seqnum;

    Ok(())
}

#[error_code]
pub enum PurchaseError {
    #[msg("Owner account does not match the blob's registered owner")]
    InvalidOwner,
}

Part B: The ace_hook Program

This program verifies that a user has purchased access to a blob. It's called by buyers to create a "proof of permission" that ACE workers verify before releasing decryption keys.

Why a Separate Program?

The ACE (Access Control Encryption) system needs to verify that a user has legitimate access before releasing decryption key shares. Workers do this by:

  1. Receiving a signed transaction from the buyer
  2. Simulating the transaction on-chain
  3. Verifying it calls the correct assert_access function

Having a separate program makes verification clean and auditable—workers can check the exact program ID and instruction being called.

What are ACE Workers?

ACE workers are a distributed network of nodes that collectively manage encryption keys. No single worker holds the complete decryption key—instead, the key is split across multiple workers using threshold cryptography.

When a buyer wants to decrypt a file:

  1. They sign a transaction calling assert_access to prove they have a valid receipt
  2. They send this signed transaction to the workers as a "proof of permission"
  3. Each worker verifies the transaction on-chain and returns their key share
  4. The client combines the shares to reconstruct the decryption key

Deployed Workers (Testnet):

WorkerEndpoint
Worker 0https://ace-worker-0-646682240579.europe-west1.run.app
Worker 1https://ace-worker-1-646682240579.europe-west1.run.app

The threshold is set to 2, meaning both workers must agree to release their key shares.

Create Program Files

Cargo.toml
lib.rs

Cargo.toml

Create anchor/programs/ace_hook/Cargo.toml:

[package]
name = "ace_hook"
version = "0.1.0"
description = "ACE hook - allows users to prove access by signing a transaction for decryption key providers"
edition = "2021"

[lib]
crate-type = ["cdylib", "lib"]
name = "ace_hook"

[features]
default = []
cpi = ["no-entrypoint"]
no-entrypoint = []
no-idl = []
no-log-ix-name = []
idl-build = ["anchor-lang/idl-build"]

[dependencies]
anchor-lang = "0.31.1"
access_control = { path = "../access_control", features = ["no-entrypoint"] }

lib.rs - Hook Program

Create anchor/programs/ace_hook/src/lib.rs:

#![allow(unexpected_cfgs)]

use anchor_lang::prelude::*;
use access_control::{BlobMetadata, Receipt, ID as ACCESS_CONTROL_PROGRAM_ID};

// This is an ACE-specific access control program.
// Apps need to define such a callable so that consumers can prove they are allowed
// to access a given data by signing a transaction to call this callable and
// handing the signed transaction to decryption key providers.

declare_id!("YOUR_ACE_HOOK_PROGRAM_ID_HERE");

#[program]
pub mod ace_hook {
    use super::*;

    /// Assert that the caller has access to the specified blob.
    /// This function is called by consumers who sign a transaction proving their access.
    /// The signed transaction can then be presented to decryption key providers as proof of permission.
    pub fn assert_access(
        ctx: Context<AssertAccess>,
        full_blob_name_bytes: Vec<u8>
    ) -> Result<()> {
        // Debug: log what we're checking
        msg!("blob_metadata.owner = {}", ctx.accounts.blob_metadata.owner);
        msg!("receipt.owner = {}", ctx.accounts.receipt.owner);
        msg!("expected = {}", ACCESS_CONTROL_PROGRAM_ID);

        // Verify blob_metadata account is owned by access_control program
        if *ctx.accounts.blob_metadata.owner != ACCESS_CONTROL_PROGRAM_ID {
            msg!("FAIL: blob_metadata owner");
            return Err(ErrorCode::InvalidAccountOwner.into());
        }

        // Verify receipt account is owned by access_control program
        if *ctx.accounts.receipt.owner != ACCESS_CONTROL_PROGRAM_ID {
            msg!("FAIL: receipt owner");
            return Err(ErrorCode::InvalidAccountOwner.into());
        }

        // Parse full_blob_name_bytes:
        // [0:2] "0x" prefix
        // [2:34] owner_aptos_addr (32 bytes)
        // [34] "/" separator
        // [35:] blob_name
        if full_blob_name_bytes.len() < 35
            || &full_blob_name_bytes[0..2] != b"0x"
            || full_blob_name_bytes[34] != b'/'
        {
            return Err(ErrorCode::InvalidBlobName.into());
        }

        let owner_aptos_addr: [u8; 32] = full_blob_name_bytes[2..34]
            .try_into()
            .map_err(|_| ErrorCode::InvalidBlobName)?;
        let blob_name = &full_blob_name_bytes[35..];

        // Derive expected PDA for blob_metadata (using access_control program's ID)
        let (expected_blob_metadata_pda, _bump) = Pubkey::find_program_address(
            &[
                b"blob_metadata",
                owner_aptos_addr.as_ref(),
                blob_name,
            ],
            &ACCESS_CONTROL_PROGRAM_ID,
        );
        if ctx.accounts.blob_metadata.key() != expected_blob_metadata_pda {
            return Err(ErrorCode::InvalidAccountOwner.into());
        }

        // Derive expected PDA for receipt (using access_control program's ID)
        let (expected_receipt_pda, _bump) = Pubkey::find_program_address(
            &[
                b"access",
                owner_aptos_addr.as_ref(),
                blob_name,
                ctx.accounts.user.key().as_ref(),
            ],
            &ACCESS_CONTROL_PROGRAM_ID,
        );
        if ctx.accounts.receipt.key() != expected_receipt_pda {
            return Err(ErrorCode::InvalidAccountOwner.into());
        }

        // Deserialize accounts owned by access_control program
        let blob_metadata_data = &ctx.accounts.blob_metadata.try_borrow_data()?;
        let mut blob_metadata_slice = &blob_metadata_data[8..]; // Skip 8-byte discriminator
        let blob_metadata = BlobMetadata::deserialize(&mut blob_metadata_slice)?;

        let receipt_data = &ctx.accounts.receipt.try_borrow_data()?;
        let mut receipt_slice = &receipt_data[8..]; // Skip 8-byte discriminator
        let receipt = Receipt::deserialize(&mut receipt_slice)?;

        require!(
            blob_metadata.seqnum == receipt.seqnum,
            ErrorCode::AccessDenied
        );

        Ok(())
    }
}

#[derive(Accounts)]
pub struct AssertAccess<'info> {
    /// CHECK: Account owned by access_control program, we verify ownership and deserialize manually
    pub blob_metadata: AccountInfo<'info>,

    /// CHECK: Account owned by access_control program, we verify ownership and deserialize manually
    pub receipt: AccountInfo<'info>,

    pub user: Signer<'info>,
}

#[error_code]
pub enum ErrorCode {
    #[msg("Access denied")]
    AccessDenied,
    #[msg("Invalid blob name format")]
    InvalidBlobName,
    #[msg("Invalid account owner")]
    InvalidAccountOwner,
}

Part C: Build and Deploy

Prerequisites: Fund Your Solana Wallet

Before deploying to testnet, your local Solana wallet must be funded with SOL to pay for deployment fees.

# Check your current wallet address
solana address

# Request an airdrop (testnet only, may take a few tries)
solana airdrop 2 --url testnet

# Verify your balance
solana balance --url testnet

Testnet airdrops are rate-limited. If the airdrop fails, wait a few minutes and try again, or use the Solana Faucet.

Build the Programs

cd anchor
anchor build

This generates:

  • Compiled programs in target/deploy/
  • IDL files in target/idl/
  • TypeScript types in target/types/

Get Program IDs

After building, get the generated program IDs:

solana address -k target/deploy/access_control-keypair.json
solana address -k target/deploy/ace_hook-keypair.json

Update Program IDs

Update the declare_id! macros in your source files with the actual addresses:

  1. anchor/programs/access_control/src/lib.rs:

    declare_id!("YOUR_ACCESS_CONTROL_ADDRESS");
  2. anchor/programs/ace_hook/src/lib.rs:

    declare_id!("YOUR_ACE_HOOK_ADDRESS");
  3. anchor/Anchor.toml:

    [programs.testnet]
    access_control = "YOUR_ACCESS_CONTROL_ADDRESS"
    ace_hook = "YOUR_ACE_HOOK_ADDRESS"

Rebuild and Deploy

# Rebuild with updated IDs
anchor build

# Deploy to testnet
anchor deploy --provider.cluster testnet

Update Environment

After deployment, add the following to your .env file:

# Program IDs (replace with your deployed addresses)
NEXT_PUBLIC_ACCESS_CONTROL_PROGRAM_ID=your-access-control-address
NEXT_PUBLIC_ACE_HOOK_PROGRAM_ID=your-ace-hook-address

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

# ACE Workers (testnet)
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

Your complete .env file should now look like:

# Shelby Storage (from setup step)
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=your-access-control-address
NEXT_PUBLIC_ACE_HOOK_PROGRAM_ID=your-ace-hook-address

# 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

Using Pre-deployed Programs?

If you skipped deployment, use these addresses:

ProgramAddress
access_control6XyAbrfHK5sinJAj3nXEVG2ALzKTXQv89JLuYwXictGV
ace_hook8jDv41SQVKCaVtkbFS1ZaVDDCEtKkAc7QXV3Y1psGts9

Next Steps

With the programs deployed, let's build the frontend