Appearance
Protocol Design (Deep Dive)
Technical architecture of the Vela Protocol on-chain programs, covering the dual-program split, PDA schema, mandate lifecycle, and enforcement boundaries.
Dual-Program Split
Vela Protocol uses two separate on-chain programs that work together but maintain strict isolation boundaries.
| Program | Role | Why Separate |
|---|---|---|
vela-protocol | Creates plans, subscriptions, pulls, usage records, wrapped mint, config, Arcium callbacks | Main business logic — all merchant/subscriber/keeper interactions |
vela-transfer-hook | Enforces PullApproval at Token-2022 transfer time | Isolated from main program for security — runs during transfer_checked CPI |
Why the Split Exists
The transfer-hook path runs inside the Token-2022 program's CPI. When transfer_checked is invoked, Token-2022 calls into the registered transfer hook program. This execution context has critical constraints:
- Cannot assume main program state is loaded — the hook runs in a CPI from Token-2022, not from vela-protocol
- Must be minimal and fail-closed — any failure in the hook should prevent the transfer
- Security isolation — a bug in the main program shouldn't compromise transfer enforcement
- Independent upgrade path — hook logic changes don't require redeploying the entire protocol
CPI Flow During a Pull Payment
vela-protocol: execute_pull()
→ Token-2022: transfer_checked()
→ vela-transfer-hook: execute()
→ Read PullApproval PDA
→ Validate amount ≤ approved_amount
→ Check mandate is active
→ Return Ok or Error
← Token-2022: transfer proceeds or fails
← vela-protocol: record resultThe hook doesn't call back into vela-protocol. It reads the PullApproval PDA directly and makes a local decision. This is intentional — the hook must be self-contained.
PDA Schema (v2 Seeds, Post-v1.7)
The PDA schema was refactored in v1.7 to support plan-independent mandates, versioned accounts, and streaming payments.
Core Accounts
| Account | Seeds | Size | Purpose |
|---|---|---|---|
ProtocolConfig | ["config"] | ~200 bytes | Global protocol parameters, admin pubkey, transfer_hook_program_id, paused flag |
KeeperConfig | ["keeper"] | ~150 bytes | Keeper authority pubkey, scheduling parameters, pause controls |
Plan | ["plan", merchant, plan_index] | ~300 bytes | Billing plan definition (amount, frequency, billing_mode, mint) |
Mandate (v2) | ["mandate", subscriber, merchant, mandate_index] | ~400 bytes | Subscription mandate — plan-independent (v1.7), supports inline upgrades |
StreamMandate | ["stream", subscriber, merchant, mandate_index] | ~350 bytes | Streaming payment mandate (rate, cap, settlement state) |
AgentMandate | ["agent-mandate", agent, authority] | ~300 bytes | Agent spending mandate (daily_limit, total_cap, authorized_services) |
PullApproval | ["approval", mandate, epoch] | ~100 bytes | Ephemeral pull authorization (approved_amount, valid_until) |
Credential | ["credential", subscriber, merchant] | ~200 bytes | Per-merchant subscription credential (soulbound NFT) |
TokenConfig | ["token-config", mint] | ~150 bytes | Per-mint billing configuration (decimals, enabled, oracle) |
Seed Design Rationale
Plan-independence (v1.7 change): The Mandate PDA no longer includes the plan pubkey in its seeds. Instead, it uses (subscriber, merchant, mandate_index). This enables:
- Plan switching without closing and recreating the mandate
- Inline upgrades (pending_upgrade field stores the target plan)
- Per-merchant credentials that persist across plan changes
Mandate index: Using a monotonic index rather than a hash allows:
- O(1) derivation of the next mandate for a subscriber/merchant pair
- Sequential scanning of all mandates for a subscriber
- No collision risk
Epoch in PullApproval: The epoch seed ensures PullApprovals are unique per billing period. An approval for epoch N cannot be replayed in epoch N+1.
Account Versioning (v1.7 Pattern)
Every account in the protocol now includes:
rust
pub struct SomeAccount {
pub version: u8, // Account schema version
pub _reserved: [u8; 64], // Reserved space for future fields
// ... actual fields
}Design Decisions
| Decision | Rationale |
|---|---|
version: u8 | Compact, supports 255 schema versions. First version is always 1. |
_reserved: [u8; 64] | 64 bytes of reserved space. Enough for 8-16 additional fields. |
| Cost per account | ~64 bytes × rent rate ≈ negligible |
| Migration strategy | Additive only — new fields appended, old fields unchanged |
Migration Examples
| Migration | Version Change | Reserved Space Used |
|---|---|---|
| v1.7: Plan-independent mandates | Mandate v1 → v2 | 0 bytes (reused existing fields) |
| v1.7: Inline upgrade support | Mandate v2 → v3 | ~32 bytes (pending_upgrade field) |
| v1.8: Streaming payments | StreamMandate v1 → v2 | ~16 bytes (pending_rate_change) |
| Future: Multi-token billing | — | ~32 bytes (mint override) |
Why Not realloc?
Solana supports realloc for account data, but Vela uses reserved space instead because:
- No additional CPI needed — reserved space is allocated at account creation
- No rent adjustment — space is already paid for
- Deterministic account size — all accounts of the same type have the same size
- Simpler program logic — no need to handle realloc failures
Mandate Lifecycle
1. Plan Creation
Merchant → create_plan(merchant, amount, frequency, billing_mode, mint)
→ Plan PDA created with seeds ["plan", merchant, plan_index]
→ Plan is active and can be subscribed toThe plan defines the billing parameters but doesn't create any subscriber state. Plans are templates.
2. Subscription
Subscriber → subscribe(merchant, plan)
→ Mandate PDA created with seeds ["mandate", subscriber, merchant, mandate_index]
→ Credential NFT minted (non-transferable Token-2022)
→ Mandate is active, awaiting first pullThe mandate binds the subscriber to the plan's billing parameters. The credential NFT proves subscription.
3. Pull Validation (Phase 0 — Local)
Keeper → execute_pull(mandate, amount)
→ Transfer hook checks mandate locally:
- Is mandate active? (valid_until > now)
- Is amount ≤ mandate amount?
- Is subscriber balance sufficient? (token check)
→ Transfer executes or failsWithout Arcium, the transfer hook makes local decisions based on mandate state.
3. Pull Validation (Phase 1+ — Arcium)
Keeper → request_validation(mandate, epoch)
→ Arcium validates encrypted inputs
→ callback() writes PullApproval PDA
→ PullApproval = {mandate, epoch, approved_amount, valid_until}With Arcium, the keeper requests validation from the encrypted compute engine. Arcium writes a PullApproval PDA that authorizes a specific pull.
4. Pull Execution
Keeper → execute_pull(mandate)
→ Token-2022: transfer_checked(subscriber → merchant)
→ vela-transfer-hook: execute()
→ Read PullApproval PDA
→ Validate amount ≤ approved_amount
→ Check mandate is active
→ Transfer succeeds or fails closedThe actual transfer goes through Token-2022's standard flow, with the hook enforcing the approval.
5. Cancellation
Subscriber or Merchant → cancel(mandate)
→ Mandate status set to Cancelled
→ Credential NFT burned
→ No further pulls possibleCancellation is immediate. Any pending PullApprovals become invalid because the mandate is no longer active.
6. Plan Upgrade (v1.7+)
Subscriber → request_upgrade(mandate, new_plan)
→ Mandate.pending_upgrade = Some(new_plan)
→ At next pull: inline upgrade executes
→ Mandate.amount, frequency updated from new_plan
→ pending_upgrade cleared
→ Credential metadata updatedPlan upgrades are inline — the mandate isn't closed and recreated. The pending_upgrade field stores the target plan, and the upgrade executes atomically during the next pull cycle.
Transfer Hook Enforcement Boundary
The transfer hook (vela-transfer-hook) is the security linchpin of the protocol. It enforces that every token transfer from subscriber to merchant has a valid mandate and approval.
Hook Execution Context
The hook runs inside Token-2022's transfer_checked CPI. It receives:
- Source account (subscriber's token account)
- Destination account (merchant's token account)
- Amount being transferred
- ExtraAccountMetaList PDA (registered at mint creation, provides additional accounts)
From the extra accounts, the hook receives:
- The Mandate PDA or StreamMandate PDA
- The PullApproval PDA (for periodic pulls) or settlement parameters (for streaming)
Enforcement Logic
rust
fn execute(ctx: Context<Execute>) -> Result<()> {
// 1. Determine mandate type from account structure
let mandate = match determine_mandate_type(&ctx.accounts) {
MandateType::Periodic => read_mandate(&ctx.accounts.mandate)?,
MandateType::Streaming => read_stream_mandate(&ctx.accounts.stream_mandate)?,
MandateType::Agent => read_agent_mandate(&ctx.accounts.agent_mandate)?,
};
// 2. Validate mandate is active
if mandate.is_expired(Clock::get()?.unix_timestamp) {
return Err(ErrorCode::MandateExpired);
}
// 3. Validate amount against approval/limit
match mandate.mandate_type {
Periodic => {
let approval = read_pull_approval(&ctx.accounts.pull_approval)?;
if ctx.accounts.amount > approval.approved_amount {
return Err(ErrorCode::ExceedsApproval);
}
if Clock::get()?.unix_timestamp > approval.valid_until {
return Err(ErrorCode::ApprovalExpired);
}
}
Streaming => {
let stream = &ctx.accounts.stream_mandate;
let elapsed = Clock::get()?.unix_timestamp - stream.last_settled_ts;
let expected = elapsed * stream.rate_per_second;
if ctx.accounts.amount > expected {
return Err(ErrorCode::ExceedsStreamRate);
}
// Check cap
if stream.total_streamed + ctx.accounts.amount > stream.max_streamed {
return Err(ErrorCode::ExceedsStreamCap);
}
}
Agent => {
let agent = &ctx.accounts.agent_mandate;
// Check daily limit
if agent.daily_spent + ctx.accounts.amount > agent.daily_limit {
return Err(ErrorCode::ExceedsDailyLimit);
}
// Check total cap
if agent.total_spent + ctx.accounts.amount > agent.total_cap {
return Err(ErrorCode::ExceedsTotalCap);
}
// Check authorized services
if !agent.authorized_services.contains(&ctx.accounts.service_id) {
return Err(ErrorCode::UnauthorizedService);
}
}
}
Ok(()) // Transfer proceeds
}Fail-Closed Design
The hook is designed to fail closed:
- Any error in reading accounts → transfer fails
- Missing PullApproval → transfer fails
- Expired approval → transfer fails
- Amount exceeds approval → transfer fails
- Mandate not active → transfer fails
There is no "default allow" path. If the hook can't make a determination, the transfer is rejected.
Credential System
Design
Credentials are Non-Transferable Token-2022 tokens (soulbound). They serve as proof of subscription.
| Property | Value | Why |
|---|---|---|
| Standard | Token-2022 | Required for Non-Transferable extension |
| Transferability | Non-Transferable | Credentials can't be traded or transferred |
| Metadata | Metadata Pointer extension | Stores plan tier, features, start date |
| Scope | Per-merchant (v1.7) | Not per-plan — enables plan switching |
| Lifecycle | Minted on subscribe, burned on cancel | Clean lifecycle management |
v1.7 Per-Merchant Pattern
In v1.6 and earlier, credentials were per-plan. This meant plan switching required:
- Burn old credential
- Create new mandate with new plan
- Mint new credential
- Update all references
In v1.7, credentials are per-merchant. Plan switching only requires:
- Set
pending_upgradeon existing mandate - Upgrade executes at next pull
- Credential metadata updated in-place
The credential PDA seeds ["credential", subscriber, merchant] don't include the plan, so the credential persists across plan changes.
Metadata Structure
Credential Metadata:
- name: "VelaPay {merchant_name} Subscription"
- symbol: "VELA"
- uri: ipfs://... (dynamic metadata JSON)
Dynamic metadata JSON:
{
"plan_tier": "pro",
"plan_amount": "9.99",
"plan_frequency": "monthly",
"start_date": "2025-01-15",
"features": ["api_access", "priority_support"]
}TokenConfig Registry (v1.7)
Purpose
The TokenConfig registry enables multi-token billing. Each supported mint has a TokenConfig PDA that stores billing-related configuration.
Schema
TokenConfig:
seeds: ["token-config", mint]
fields:
- mint: Pubkey // The SPL token mint
- decimals: u8 // Must match on-chain decimals
- enabled: bool // Billing enabled for this mint
- oracle: Pubkey // Price oracle address (for conversion)
- min_pull_amount: u64 // Minimum pull amount (dust prevention)
- _reserved: [u8; 64] // Future: fee configuration, settlement preferencesInitialization
init_token_config asserts that the on-chain decimals of the mint match the registered decimals. This prevents a class of bugs where amount calculations are wrong due to decimal mismatch.
Supported Mints (Planned)
| Mint | Symbol | Decimals | Status |
|---|---|---|---|
| USDC | USDC | 6 | Active |
| PYUSD | PYUSD | 6 | Planned |
| EURC | EURC | 6 | Planned |
| Wrapped SOL | wSOL | 9 | Planned |
Error Code Strategy
Error Code Bands
| Range | Category | Examples |
|---|---|---|
| 6000-6099 | Core protocol | InvalidConfig, Unauthorized, Paused |
| 6100-6199 | Mandate lifecycle | MandateExpired, MandateCancelled, InvalidUpgrade |
| 6200-6299 | Pull execution | ExceedsApproval, ApprovalExpired, InsufficientBalance |
| 6300-6399 | Credential | CredentialNotFound, AlreadySubscribed, NotSubscribed |
| 6400-6499 | Keeper | InvalidKeeper, InvalidSchedule, KeeperPaused |
| 6500-6599 | Plan management | PlanNotFound, PlanInactive, InvalidPlanParams |
| 6600-6699 | Arcium callbacks | InvalidCallback, ValidationFailed, EncryptionError |
| 6700-6711 | Streaming | ExceedsStreamRate, ExceedsStreamCap, SettleTooEarly |
| 6800-6899 | Agent mandates | ExceedsDailyLimit, ExceedsTotalCap, UnauthorizedService |
| 6900-6999 | Reserved | Upgrade-specific, multi-token, future use |
Error Design Principles
- Typed names, no generic failures — every error has a specific name that identifies the exact failure mode
- Grouped by module — error codes are banded by functional area
- Reserved bands — space reserved for future features without renumbering
- Stream-specific isolated — streaming errors get their own band to prevent collision with future periodic billing errors
- No error 0 — all errors are non-zero; success is indicated by the Ok variant
Cross-Program Error Handling
When the transfer hook returns an error, it propagates through Token-2022's CPI back to vela-protocol. The protocol catches these errors and translates them into user-facing messages:
Token-2022 transfer_failed: custom program error: 0x1770 (6000 + pull execution offset)
→ vela-protocol translates to: "Pull payment failed: approval expired"