Skip to content

Security Model

Token Distribution Protocol on Solana

Program ID: 6VkmhxbTH9dnzAE7Scpxn6R3HeXYtY4oZffAFMAYvECk


A protocol for creating, managing, and claiming token vesting schedules on Solana — replacing spreadsheets and manual multisig transfers with an on-chain Anchor program.

graph LR
    A[Creator] -->|create_stream| B(Anchor Program)
    B --> C[PDA Vault]
    D[Recipient] -->|withdraw| B
    B -->|CPI| E[SPL Token]
    F[Web dApp] -->|reads| C
    G[TS SDK] -->|helpers| F

monorepo/
├── apps/
│ ├── solana-tdp-anchor/ ← Anchor program (Rust)
│ ├── dapp/ ← React frontend (Vite)
│ └── api/ ← Cloudflare Worker
├── packages/
│ └── solana-tdp-sdk/ ← TypeScript SDK
└── docs/ ← Architecture, deployment
  • Anchor 0.32.1 — on-chain vesting logic (7 instructions)
  • React + TanStack Router — frontend dApp
  • TypeScript SDK — PDA helpers, event parsing, vesting math

Design DecisionPurpose
PDA seeds include recipientCryptographically commits beneficiary to the PDA address — extra safety beyond Anchor’s has_one
Custom PDA vault (not ATA)Fully closable on completion/cancellation — returns rent SOL
invoke_signed for all CPI transfersOnly the program can move tokens via PDA signing
Token-2022 transfer-hook rejectionPrevents mints with transfer hooks that could block CPI

GuardrailWhere
Checked math (checked_mul, checked_div, etc.)All vesting calculations
Custom error codesEvery invalid state transition
60-second minimum durationAnti-griefing — prevents account space bloat
cancelled state set before CPIReentrancy protection during cancel operations
Cliff bounds validated against start/endPrevents logical inconsistencies
Vesting count tracked per creatorPrevents stream address collision

Three account types, all Program-Derived Addresses:

StreamAccount (187 bytes)
├── creator, recipient, mint, vault
├── amount, amount_withdrawn
├── start_time, end_time, cliff_time
├── vesting_count, cancelled, bump, vault_bump
MilestoneStreamAccount (196 bytes)
├── creator, recipient, mint, vault
├── amount, amount_withdrawn
├── milestone_authority, milestone_reached
├── vesting_count, cancelled, bump, vault_bump
CreatorConfig (48 bytes)
├── creator, vesting_count

PDA seeds: ["stream", creator, recipient, mint, vesting_count]


graph TD
    subgraph "Layer 1: Rust Unit Tests"
        A[cargo test] --> A1[Error disciminants]
        A --> A2[Event serialization]
        A --> A3[PDA derivation]
    end
    subgraph "Layer 2: Anchor Integration Tests"
        B[Vitest + litesvm] --> B1[Create/Withdraw/Cancel]
        B --> B2[Milestone lifecycle]
        B --> B3[Security audit vectors]
    end
    subgraph "Layer 3: Web Unit Tests"
        C[Vitest + jsdom] --> C1[Format utilities]
        C --> C2[cn class merging]
    end

Embedded program tests (#[cfg(test)]):

FileTestsCoverage
errors.rs2Error discriminant values, error messages for all 15 variants
events.rs4Serialization round-trips for 4 event types (StreamCreated, TokensClaimed, StreamCompleted, StreamCancelled)
create_stream.rs5PDA derivation determinism, vesting_count uniqueness, duration boundary, balance checks

Run with: cargo test


Vitest + anchor-litesvm (SVM simulator, no validator needed):

Test FileTestsCoverage
000.create-stream.test.ts9Happy path, cliff variant, 4 validation rejections, insufficient balance, event emission
001.withdraw.test.ts15Partial/full vesting, cumulative tracking, cliff/before-start/cancelled rejections, ExceedsClaimable, event, T/P third-party rejection
002.cancel.test.ts8Pre-start/partial/post-end splits, double-cancel, event, account closure
003.milestone.test.ts18Full milestone lifecycle: create, trigger, withdraw, cancel + events
005.security-audit.test.ts19Dedicated security audit suite — 7 attack categories

Custom alpha-sort sequencer ensures deterministic test order.


Vitest + jsdom for frontend utilities:

Test FileTestsCoverage
format.test.ts18formatAddress (3), formatSol (5), formatDate (2), formatDuration (6), clamp (3)
cn.test.ts4Merging, conditional classes, Tailwind conflict resolution, empty input

Setup: @testing-library/jest-dom matchers + Buffer polyfill

Run with: pnpm test in apps/dapp/


File: solana-tdp.005.security-audit.test.ts

7 attack vector categories — 19 tests total:

#CategoryTestsWhat It Verifies
1Signer Authority3Non-sender rejected for create_milestone, non-creator for cancel_milestone, wrong sender for withdraw
2PDA Uniqueness4Different senders → different PDAs, vesting_count chain prevents collision
3Integer Overflow1Handles 10^16 amounts with vesting_count chain
4Account Ownership4Vault authority = stream PDA, mint constraint on withdraw/cancel
5State Transition Guards2cancelled set before CPI (reentrancy)
6Wrong Account Attacks4Rejects wrong vault PDA, wrong sender_token account
7Timestamp Boundaries1Rejects create_stream with past start_time

Signer Authority:
✗ CreateMilestoneStream with non-sender → Unauthorized
✗ CancelMilestone with non-creator → Unauthorized
✗ Withdraw with wrong sender account → constraint raw 1
PDA Uniqueness:
✗ Same stream PDA for different senders → address not unique
✗ Identical vault PDAs for different streams → address not unique
✓ vesting_count increments → unique stream PDA
Integer Overflow:
✓ 10,000,000,000,000,000 lamports
✓ vesting_count chains across streams
Account Ownership:
✗ Vault owned by wrong PDA → TokenOwnerOff
✗ Withdraw with different mint vault → constraint raw 1
✗ Cancel with different mint vault → constraint raw 1

Every commit runs automatically:

┌─ nano-staged ──────────────────────────┐
│ *.{js,ts,tsx} → oxlint --fix + oxfmt │
│ *.{css,md,json} → oxfmt │
└─────────────────────────────────────────┘
┌─ TypeScript typecheck ─────────────────┐
│ web: tsgo --noEmit │
│ api: typecheck │
│ anchor: tsgo --noEmit │
└─────────────────────────────────────────┘
┌─ Rust checks ──────────────────────────┐
│ cargo fmt --check │
│ cargo clippy --all-targets -D warnings│
└─────────────────────────────────────────┘

Oxlint rules: no-explicit-any (error), no-non-null-assertion (error), import/no-cycle (error), no-console (warn)


GitHub Actions — 13 jobs on push/PR to main:

JobGuard
typecheck-webTypeScript strict checks
typecheck-apiWorker type safety
typecheck-sdkSDK type safety
typecheck-anchor-tsTest code type safety
lintoxlint . (304 rules)
formatoxfmt --check .
lint-rustcargo fmt --check + cargo clippy
build-webProduction Vite build
test-apiAPI tests with vitest
test-webWeb unit + Storybook browser tests
anchorcargo fmt + clippy + anchor build + vitest tests
deploy-webCloudflare Pages (main only)
deploy-apiCloudflare Worker (main only)

GapImpactSuggested Remediation
No dependency vulnerability scanningSupply-chain risk from compromised npm/cratesAdd npm audit / cargo audit or Dependabot to CI
No formal verificationMathematical correctness of vesting math unprovenSMT solver (Z3) on vesting formulas
No fuzzingEdge cases in account deserializationtrident fuzzing harness for Anchor
No manual auditNo third-party review of the economic logicProfessional Solana security audit
No invariant testingCross-instruction invariants untestedFuzz testing with state invariants

Test FileInstructions/UtilitiesLayerTests
errors.rsError discriminant stabilityRust unit2
events.rsEvent serialization round-tripsRust unit4
create_stream.rsPDA derivation, vesting mathRust unit5
000.create-stream.test.tscreate_streamIntegration9
001.withdraw.test.tswithdrawIntegration15
002.cancel.test.tscancelIntegration8
003.milestone.test.tsMilestone lifecycle (4 ixns)Integration18
005.security-audit.test.ts7 attack categories (all ixns)Integration19
format.test.tsformatAddress/Sol/Date/Duration/clampWeb unit18
cn.test.tscn() class mergingWeb unit4
Total~147

Solana TDP — Token Distribution Protocol

  • Program: 6VkmhxbTH9dnzAE7Scpxn6R3HeXYtY4oZffAFMAYvECk
  • Repo: github.com/simplyvest/simplyvest
  • Tests: pnpm test (all workspaces)
  • Build: pnpm build (all workspaces)