Skip to content

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.

ProgramRoleWhy Separate
vela-protocolCreates plans, subscriptions, pulls, usage records, wrapped mint, config, Arcium callbacksMain business logic — all merchant/subscriber/keeper interactions
vela-transfer-hookEnforces PullApproval at Token-2022 transfer timeIsolated 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:

  1. Cannot assume main program state is loaded — the hook runs in a CPI from Token-2022, not from vela-protocol
  2. Must be minimal and fail-closed — any failure in the hook should prevent the transfer
  3. Security isolation — a bug in the main program shouldn't compromise transfer enforcement
  4. 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 result

The 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

AccountSeedsSizePurpose
ProtocolConfig["config"]~200 bytesGlobal protocol parameters, admin pubkey, transfer_hook_program_id, paused flag
KeeperConfig["keeper"]~150 bytesKeeper authority pubkey, scheduling parameters, pause controls
Plan["plan", merchant, plan_index]~300 bytesBilling plan definition (amount, frequency, billing_mode, mint)
Mandate (v2)["mandate", subscriber, merchant, mandate_index]~400 bytesSubscription mandate — plan-independent (v1.7), supports inline upgrades
StreamMandate["stream", subscriber, merchant, mandate_index]~350 bytesStreaming payment mandate (rate, cap, settlement state)
AgentMandate["agent-mandate", agent, authority]~300 bytesAgent spending mandate (daily_limit, total_cap, authorized_services)
PullApproval["approval", mandate, epoch]~100 bytesEphemeral pull authorization (approved_amount, valid_until)
Credential["credential", subscriber, merchant]~200 bytesPer-merchant subscription credential (soulbound NFT)
TokenConfig["token-config", mint]~150 bytesPer-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

DecisionRationale
version: u8Compact, 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 strategyAdditive only — new fields appended, old fields unchanged

Migration Examples

MigrationVersion ChangeReserved Space Used
v1.7: Plan-independent mandatesMandate v1 → v20 bytes (reused existing fields)
v1.7: Inline upgrade supportMandate v2 → v3~32 bytes (pending_upgrade field)
v1.8: Streaming paymentsStreamMandate 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:

  1. No additional CPI needed — reserved space is allocated at account creation
  2. No rent adjustment — space is already paid for
  3. Deterministic account size — all accounts of the same type have the same size
  4. 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 to

The 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 pull

The 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 fails

Without 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 closed

The 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 possible

Cancellation 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 updated

Plan 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.

PropertyValueWhy
StandardToken-2022Required for Non-Transferable extension
TransferabilityNon-TransferableCredentials can't be traded or transferred
MetadataMetadata Pointer extensionStores plan tier, features, start date
ScopePer-merchant (v1.7)Not per-plan — enables plan switching
LifecycleMinted on subscribe, burned on cancelClean lifecycle management

v1.7 Per-Merchant Pattern

In v1.6 and earlier, credentials were per-plan. This meant plan switching required:

  1. Burn old credential
  2. Create new mandate with new plan
  3. Mint new credential
  4. Update all references

In v1.7, credentials are per-merchant. Plan switching only requires:

  1. Set pending_upgrade on existing mandate
  2. Upgrade executes at next pull
  3. 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 preferences

Initialization

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)

MintSymbolDecimalsStatus
USDCUSDC6Active
PYUSDPYUSD6Planned
EURCEURC6Planned
Wrapped SOLwSOL9Planned

Error Code Strategy

Error Code Bands

RangeCategoryExamples
6000-6099Core protocolInvalidConfig, Unauthorized, Paused
6100-6199Mandate lifecycleMandateExpired, MandateCancelled, InvalidUpgrade
6200-6299Pull executionExceedsApproval, ApprovalExpired, InsufficientBalance
6300-6399CredentialCredentialNotFound, AlreadySubscribed, NotSubscribed
6400-6499KeeperInvalidKeeper, InvalidSchedule, KeeperPaused
6500-6599Plan managementPlanNotFound, PlanInactive, InvalidPlanParams
6600-6699Arcium callbacksInvalidCallback, ValidationFailed, EncryptionError
6700-6711StreamingExceedsStreamRate, ExceedsStreamCap, SettleTooEarly
6800-6899Agent mandatesExceedsDailyLimit, ExceedsTotalCap, UnauthorizedService
6900-6999ReservedUpgrade-specific, multi-token, future use

Error Design Principles

  1. Typed names, no generic failures — every error has a specific name that identifies the exact failure mode
  2. Grouped by module — error codes are banded by functional area
  3. Reserved bands — space reserved for future features without renumbering
  4. Stream-specific isolated — streaming errors get their own band to prevent collision with future periodic billing errors
  5. 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"

Internal knowledge base for the Vela Labs workspace.