Full lesson
CapRail
Cap table of a private company on Solana. Admission, vesting and ROFR are enforced by the token itself — a Token-2022 mint with a transfer hook — not by the interface.
What it does
From the project README:
Cap table of a private company on Solana, where the shareholder agreement is enforced by the token itself. The equity token is a Token-2022 mint with a transfer hook: every transfer — from the panel, from a script, from any wallet — runs the company's policy and investor registry on chain, and a transfer to a wallet that is not admitted is refused by the network, atomically, with the reason in the transaction logs. The cap table is read from the chain, not maintained by hand.
- Original
- capcaprail/CapRail
- SuperStack fork
- Blockchains/CapRail @
2ea5c96 - Stars
- 0
- Last push (original)
- 2026-10-27
- Languages
- TypeScript, Rust, Shell, CSS, HTML, JavaScript
- Detected stack
- Anchorweb3.js
- Categories
- Infrastructure & Tooling
- Licence
- Apache-2.0
- Homepage
- https://capcaprail.github.io/CapRail/
Architecture
240 source files. By extension: .ts ×169, .rs ×48, .tsx ×21, .js ×2.
| Top-level directory | Source files |
|---|---|
apps | 124 |
packages | 55 |
programs | 48 |
tools | 13 |
On-chain programs: programs/caprail, programs/caprail-hook
Dapp: devnet deploy pending
⏳ Devnet deploy pending — goes live when the devnet wallet is topped up. This fork's own dapp is not deployed yet, so there is no program address or transaction to show. It is queued in dapp-queue.txt; once devnet SOL is available it is deployed, exercised with real devnet transactions, and this section switches to the live dapp at /dapps/caprail/. Until then, the lesson and step-by-step guide below are complete, and you can run it locally with the commands below.
Key code, explained
Excerpts chosen automatically where the code uses the most Solana concepts, pinned to the fork's commit.
programs/caprail-hook/src/initialize.rs L13–36 @ 2ea5c96 · Apache-2.0 · original #[account(mut)] pub payer: Signer<'info>, pub mint: InterfaceAccount<'info, Mint>, // Хто має право оголосити список для мінта — той, хто володіє емісією. // У CapRail це `Company` PDA всередині `create_token`, до відкликання права. pub authority: Signer<'info>, // PDA цієї програми: саме тут його шукає Token-2022 // (`get_extra_account_metas_address(mint, hook_program)`). /// CHECK: акаунт стандарту інтерфейсу хука, не Anchor-тип #[account( init, payer = payer, space = ExtraAccountMetaList::size_of(EXTRA_ACCOUNT_COUNT)?, seeds = [EXTRA_ACCOUNT_METAS_SEED, mint.key().as_ref()], bump, )] pub extra_account_meta_list: UncheckedAccount<'info>, pub system_program: Program<'info, System>,} pub fn initialize_extra_account_meta_list_handler( ctx: Context<InitializeExtraAccountMetaList>,) -> Result<()> { require!(- Line 14
pub payer: Signer<'info>,: Requires this account to have signed the transaction. - Line 23
init,: Creates and pays for a new account. Space and payer must be specified. - Line 26
seeds = [EXTRA_ACCOUNT_METAS_SEED, mint.key().as_ref()],: Declares the PDA seeds. Anchor re-derives the address and rejects the account if it doesn't match. - Line 27
bump,: Uses the bump seed, the extra byte that pushes the derived address off the ed25519 curve. - Line 36
require!(: An explicit validation that aborts the instruction with an error.
programs/caprail/src/instructions/init_platform.rs L13–36 @ 2ea5c96 · Apache-2.0 · original// `authority` живе офлайн: це єдиний ключ продукту поза гаманцями, і панель цю// інструкцію не будує — її будує `tools/demo init-platform`.#[derive(Accounts)]pub struct InitPlatform<'info> { // Підписує і платить той самий офлайн-ключ; окремого платника немає, бо // немає й сервера, який міг би ним бути. #[account(mut)] pub authority: Signer<'info>, // `init`, а не `init_if_needed`: повторний виклик має впасти, а не // переписати комісію під уже виставленими пропозиціями. #[account( init, payer = authority, space = PlatformConfig::SPACE, seeds = [PlatformConfig::SEED], bump, )] pub platform: Account<'info, PlatformConfig>, // Стейблкоїн оплати. Інтерфейс, а не Token-2022: справжні стейблкоїни // здебільшого на класичній токен-програмі, а демонстраційний — на Token-2022; // `accept_offer` платить тією програмою, якій належить цей мінт. pub payment_mint: Box<InterfaceAccount<'info, Mint>>, // Рахунок комісії — саме токен-рахунок того ж мінта, а не гаманець-власник // (T034): `accept_offer` не виводить ATA і не тягне зайвий акаунт у кадр.- Line 20
pub authority: Signer<'info>,: Requires this account to have signed the transaction. - Line 21
// `init`, а не `init_if_needed`: повторний виклик має впасти, а не: Creates the account only if it doesn't exist. Watch for re-initialisation bugs. - Line 24
init,: Creates and pays for a new account. Space and payer must be specified. - Line 27
seeds = [PlatformConfig::SEED],: Declares the PDA seeds. Anchor re-derives the address and rejects the account if it doesn't match. - Line 28
bump,: Uses the bump seed, the extra byte that pushes the derived address off the ed25519 curve.
programs/caprail/src/instructions/set_investor_status.rs L37–60 @ 2ea5c96 · Apache-2.0 · original #[account(mut)] pub compliance_officer: Signer<'info>, // Статуси допуску ставить лише комплаєнс-офіцер; адміністратор не може // (FR-004), і це перевіряє зв'язок, а не інтерфейс. #[account(has_one = compliance_officer @ CaprailError::Unauthorized)] pub company: Account<'info, Company>, #[account(has_one = company @ CaprailError::Unauthorized)] pub token_config: Account<'info, TokenConfig>, // Перший статус створює запис, наступні — оновлюють. Хук акаунтів не // створює, тож без цієї інструкції гаманець просто «не допущений». #[account( init_if_needed, payer = compliance_officer, space = InvestorRecord::SPACE, seeds = [ InvestorRecord::SEED, token_config.mint.as_ref(), args.wallet.as_ref(), ], bump, )] pub investor_record: Account<'info, InvestorRecord>, pub system_program: Program<'info, System>,}- Line 38
pub compliance_officer: Signer<'info>,: Requires this account to have signed the transaction. - Line 41
#[account(has_one = compliance_officer @ CaprailError::Unauthorized)]: Checks that a stored pubkey field matches the passed account. - Line 48
init_if_needed,: Creates the account only if it doesn't exist. Watch for re-initialisation bugs. - Line 51
seeds = [: Declares the PDA seeds. Anchor re-derives the address and rejects the account if it doesn't match. - Line 56
bump,: Uses the bump seed, the extra byte that pushes the derived address off the ed25519 curve.
programs/caprail/src/instructions/accept_offer.rs L1–24 @ 2ea5c96 · Apache-2.0 · originaluse anchor_lang::prelude::*;use anchor_lang::solana_program::program::invoke_signed;use anchor_spl::associated_token::AssociatedToken;use anchor_spl::token_interface::{ spl_token_2022, transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked,};use spl_transfer_hook_interface::onchain::add_extra_accounts_for_execute_cpi; use crate::errors::CaprailError;use crate::events::OfferAccepted;use crate::hook::HOOK_PROGRAM_ID;use crate::state::{Offer, OfferStatus, PlatformConfig, TokenConfig}; // Угода на вторинному ринку (FR-012, FR-013): три перекази в одній інструкції —// токен продавець → покупець (делегатом `Offer` PDA, через хук), оплата// покупець → продавець за вирахуванням комісії, комісія → рахунок платформи.// Будь-яка відмова (хук, баланс, делегування) відкочує всю транзакцію, тож// половини угоди в мережі не буває (SC-006).#[derive(Accounts)]pub struct AcceptOffer<'info> { #[account(mut)] pub buyer: Signer<'info>, #[account( seeds = [PlatformConfig::SEED],- Line 2
use anchor_lang::solana_program::program::invoke_signed;: Makes a CPI signed by a PDA. The signer seeds prove the program controls that address. - Line 5
spl_token_2022, transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked,: A checked token transfer, which also verifies the mint and decimals. - Line 5
spl_token_2022, transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked,: Targets the Token-2022 program instead of the original Token Program. - Line 22
pub buyer: Signer<'info>,: Requires this account to have signed the transaction. - Line 24
seeds = [PlatformConfig::SEED],: Declares the PDA seeds. Anchor re-derives the address and rejects the account if it doesn't match.
How to run, build and test
These commands come from the repo's own manifests (package.json scripts, Anchor.toml, Cargo, go.mod, Makefile). Check the README for prerequisites and environment variables.
| Directory | Command | Runs |
|---|---|---|
. | npm run dev | pnpm -r --parallel dev |
. | npm run build | pnpm -r build |
. | npm run test | pnpm -r test |
. | npm run lint | biome check . |
packages/db | npm run build | tsc -p tsconfig.json |
packages/db | npm run test | vitest run --passWithNoTests |
packages/chain | npm run build | tsc -p tsconfig.json |
packages/chain | npm run test | vitest run --passWithNoTests |
packages/shared | npm run build | tsc -p tsconfig.json |
packages/shared | npm run test | vitest run --passWithNoTests |
packages/indexer | npm run build | tsc -p tsconfig.json |
packages/indexer | npm run test | vitest run --passWithNoTests |
apps/web | npm run dev | vite |
apps/web | npm run build | vite build |
apps/web | npm run test | vitest run --passWithNoTests |
apps/api | npm run dev | node --watch --env-file-if-exists=../../.env src/index.ts |
What you could build from it
- Fork the SuperStack copy and extend it within the Infrastructure & Tooling gaps listed on the idea page.
- Learn the pattern first: Building and sending transactions from a client
- Learn the pattern first: Anchor account constraints