Security Model
Security Architecture & Test Coverage
Section titled “Security Architecture & Test Coverage”Token Distribution Protocol on Solana
Program ID: 6VkmhxbTH9dnzAE7Scpxn6R3HeXYtY4oZffAFMAYvECk
What is TDP?
Section titled “What is TDP?”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
Architecture Overview
Section titled “Architecture Overview”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
Security Model — Core Design
Section titled “Security Model — Core Design”| Design Decision | Purpose |
|---|---|
| PDA seeds include recipient | Cryptographically 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 transfers | Only the program can move tokens via PDA signing |
| Token-2022 transfer-hook rejection | Prevents mints with transfer hooks that could block CPI |
Security Model — Guardrails
Section titled “Security Model — Guardrails”| Guardrail | Where |
|---|---|
Checked math (checked_mul, checked_div, etc.) | All vesting calculations |
| Custom error codes | Every invalid state transition |
| 60-second minimum duration | Anti-griefing — prevents account space bloat |
cancelled state set before CPI | Reentrancy protection during cancel operations |
| Cliff bounds validated against start/end | Prevents logical inconsistencies |
| Vesting count tracked per creator | Prevents stream address collision |
Account Model
Section titled “Account Model”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_countPDA seeds: ["stream", creator, recipient, mint, vesting_count]
Testing Strategy — 3 Layers
Section titled “Testing Strategy — 3 Layers”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
Layer 1 — Rust Unit Tests
Section titled “Layer 1 — Rust Unit Tests”Embedded program tests (#[cfg(test)]):
| File | Tests | Coverage |
|---|---|---|
errors.rs | 2 | Error discriminant values, error messages for all 15 variants |
events.rs | 4 | Serialization round-trips for 4 event types (StreamCreated, TokensClaimed, StreamCompleted, StreamCancelled) |
create_stream.rs | 5 | PDA derivation determinism, vesting_count uniqueness, duration boundary, balance checks |
Run with: cargo test
Layer 2 — Anchor Integration Tests
Section titled “Layer 2 — Anchor Integration Tests”Vitest + anchor-litesvm (SVM simulator, no validator needed):
| Test File | Tests | Coverage |
|---|---|---|
000.create-stream.test.ts | 9 | Happy path, cliff variant, 4 validation rejections, insufficient balance, event emission |
001.withdraw.test.ts | 15 | Partial/full vesting, cumulative tracking, cliff/before-start/cancelled rejections, ExceedsClaimable, event, T/P third-party rejection |
002.cancel.test.ts | 8 | Pre-start/partial/post-end splits, double-cancel, event, account closure |
003.milestone.test.ts | 18 | Full milestone lifecycle: create, trigger, withdraw, cancel + events |
005.security-audit.test.ts | 19 | Dedicated security audit suite — 7 attack categories |
Custom alpha-sort sequencer ensures deterministic test order.
Layer 3 — Web Unit Tests
Section titled “Layer 3 — Web Unit Tests”Vitest + jsdom for frontend utilities:
| Test File | Tests | Coverage |
|---|---|---|
format.test.ts | 18 | formatAddress (3), formatSol (5), formatDate (2), formatDuration (6), clamp (3) |
cn.test.ts | 4 | Merging, conditional classes, Tailwind conflict resolution, empty input |
Setup: @testing-library/jest-dom matchers + Buffer polyfill
Run with: pnpm test in apps/dapp/
Security Audit Test Suite — Deep Dive
Section titled “Security Audit Test Suite — Deep Dive”File: solana-tdp.005.security-audit.test.ts
7 attack vector categories — 19 tests total:
| # | Category | Tests | What It Verifies |
|---|---|---|---|
| 1 | Signer Authority | 3 | Non-sender rejected for create_milestone, non-creator for cancel_milestone, wrong sender for withdraw |
| 2 | PDA Uniqueness | 4 | Different senders → different PDAs, vesting_count chain prevents collision |
| 3 | Integer Overflow | 1 | Handles 10^16 amounts with vesting_count chain |
| 4 | Account Ownership | 4 | Vault authority = stream PDA, mint constraint on withdraw/cancel |
| 5 | State Transition Guards | 2 | cancelled set before CPI (reentrancy) |
| 6 | Wrong Account Attacks | 4 | Rejects wrong vault PDA, wrong sender_token account |
| 7 | Timestamp Boundaries | 1 | Rejects create_stream with past start_time |
Attack Vectors — Detail
Section titled “Attack Vectors — Detail”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 1Pre-commit Security Gates
Section titled “Pre-commit Security Gates”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)
CI/CD Security Pipeline
Section titled “CI/CD Security Pipeline”GitHub Actions — 13 jobs on push/PR to main:
| Job | Guard |
|---|---|
typecheck-web | TypeScript strict checks |
typecheck-api | Worker type safety |
typecheck-sdk | SDK type safety |
typecheck-anchor-ts | Test code type safety |
lint | oxlint . (304 rules) |
format | oxfmt --check . |
lint-rust | cargo fmt --check + cargo clippy |
build-web | Production Vite build |
test-api | API tests with vitest |
test-web | Web unit + Storybook browser tests |
anchor | cargo fmt + clippy + anchor build + vitest tests |
deploy-web | Cloudflare Pages (main only) |
deploy-api | Cloudflare Worker (main only) |
Known Gaps & Future Work
Section titled “Known Gaps & Future Work”| Gap | Impact | Suggested Remediation |
|---|---|---|
| No dependency vulnerability scanning | Supply-chain risk from compromised npm/crates | Add npm audit / cargo audit or Dependabot to CI |
| No formal verification | Mathematical correctness of vesting math unproven | SMT solver (Z3) on vesting formulas |
| No fuzzing | Edge cases in account deserialization | trident fuzzing harness for Anchor |
| No manual audit | No third-party review of the economic logic | Professional Solana security audit |
| No invariant testing | Cross-instruction invariants untested | Fuzz testing with state invariants |
Appendix — Full Test Coverage Map
Section titled “Appendix — Full Test Coverage Map”| Test File | Instructions/Utilities | Layer | Tests |
|---|---|---|---|
errors.rs | Error discriminant stability | Rust unit | 2 |
events.rs | Event serialization round-trips | Rust unit | 4 |
create_stream.rs | PDA derivation, vesting math | Rust unit | 5 |
000.create-stream.test.ts | create_stream | Integration | 9 |
001.withdraw.test.ts | withdraw | Integration | 15 |
002.cancel.test.ts | cancel | Integration | 8 |
003.milestone.test.ts | Milestone lifecycle (4 ixns) | Integration | 18 |
005.security-audit.test.ts | 7 attack categories (all ixns) | Integration | 19 |
format.test.ts | formatAddress/Sol/Date/Duration/clamp | Web unit | 18 |
cn.test.ts | cn() class merging | Web unit | 4 |
| Total | ~147 |
Solana TDP — Token Distribution Protocol
- Program:
6VkmhxbTH9dnzAE7Scpxn6R3HeXYtY4oZffAFMAYvECk - Repo:
github.com/simplyvest/simplyvest - Tests:
pnpm test(all workspaces) - Build:
pnpm build(all workspaces)