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. Both v2 seeds (current) and legacy v1 seeds are still accepted in execute_pull for backward compatibility — see instructions/mandate_account.rs.

Core Accounts

AccountSeedsSizePurpose
ProtocolConfig["config"]220 bytesSingleton: admin, Arcium cluster, wrapped_usdc_mint, wrapping_vault, paused flag, transfer_hook_program_id
KeeperConfig["keeper-config"]237 bytesSingleton: admin, keeper mode (Centralized/TukTuk), keeper endpoint, keeper authority
Plan (VelaPlan)["plan", merchant, plan_id]~280 bytesBilling plan definition (amount, frequency seconds, mint, active flag)
Mandate (VelaMandate) v3["mandate", subscriber, merchant, mandate_index] (v2) or ["mandate", subscriber, plan] (legacy)268 bytesSubscription mandate — plan-independent in v2, supports inline plan changes via pending_new_plan
StreamMandate v2["stream", subscriber, merchant, mandate_index]225 bytesStreaming payment mandate (rate_per_second, cap, settlement state)
AgentMandate["agent-mandate", authority, agent]~540 bytes (base + services Vec)Agent spending mandate (daily_limit, lifetime_cap, per-service limits)
PullApproval["approval", mandate]66 bytesEphemeral pull authorization (approved_amount, valid_until) — one per mandate at a time
Credential mint (per-merchant)["merchant-credential", merchant]Token-2022 mintSoulbound subscription credential mint — persists across plans
Credential mint (per-plan)["credential", merchant, plan_id]Token-2022 mintPer-plan credential mint, minted to subscribers of that plan
TokenConfig["token_config", mint]213 bytesPer-mint billing configuration (decimals, billing_rail, oracle_reference, enabled)

Credentials are Token-2022 mints with the Non-Transferable extension, not custom PDA data accounts. Subscriber-facing metadata (plan tier, features) lives in the Metadata Pointer extension on the mint.

Seed Design Rationale

Plan-independence (v1.7 change): The Mandate PDA no longer includes the plan pubkey in its v2 seeds. Instead, it uses (subscriber, merchant, mandate_index). This enables:

  • Plan switching without closing and recreating the mandate
  • Inline upgrades (pending_new_plan field stores the target plan)
  • Per-merchant credential mints that persist across plan changes

Legacy v1 seeds (["mandate", subscriber, plan]) remain valid. Both layouts are validated in validate_loaded_mandate_address, and execute_pull signs with whichever layout a given mandate was created under.

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

PullApproval singleton: PullApproval uses only ["approval", mandate] — there is no epoch seed. Exactly one approval exists per mandate at a time (per-period caching, per decision D-05). Replay protection is enforced by closing the account after consumption in execute_pull and by the valid_until timestamp (set to mandate.next_payment_due in the Arcium callback).


Account Versioning (v1.7 Pattern)

Most long-lived accounts use a reserved-space pattern for additive upgrades. Sizes vary by account — some reserve the shared ACCOUNT_RESERVED_BYTES constant (64), others carry smaller version-tail buffers (_reserved_v2, _reserved_v3) earned back from prior migrations, and ephemeral accounts do not reserve at all.

rust
// Typical long-lived account shape
pub struct SomeAccount {
    // ... actual fields
    pub bump: u8,
    pub version: u8,                              // Account schema version
    pub _reserved: [u8; ACCOUNT_RESERVED_BYTES],  // 64 bytes on most long-lived accounts
}

Reserved Space by Account

AccountVersion fieldReserved bytesNotes
ProtocolConfig32 (PROTOCOL_CONFIG_RESERVED_BYTES)Smaller reserve — config is low-churn
KeeperConfig64 (ACCOUNT_RESERVED_BYTES)
VelaPlan32 (PLAN_RESERVED_BYTES)
VelaMandate7 (_reserved_v3)Shrank across v2→v3 as fields were added
StreamMandate23 (_reserved_v2)Shrank across v1→v2
AgentMandate64
TokenConfig64
UsagePlan64
PullApproval✗ none0Ephemeral — closed after every pull, no forward compat needed

Design Decisions

DecisionRationale
version: u8Compact, supports 255 schema versions
Varying reserve sizesEach account type picks a reserve budget that matches its expected change frequency
PullApproval opt-outEphemeral accounts don't need reserved space — they never outlive a single pull
Migration strategyAdditive only — new fields appended, old fields unchanged, version bumped

Migration Examples

MigrationVersion ChangeReserved Space Used
v1.7: Plan-independent mandatesMandate v1 → v2Consumed reserve for seed-layout shift
v1.7: Inline plan changesMandate v2 → v3Added pending_new_plan, pending_effective_at, pending_change_type, pending_nonce_short — dropped reserve to 7 bytes
v1.8: Streaming pending rate changeStreamMandate v1 → v2Added pending_new_rate_per_second, pending_new_authorized_max_rate, etc. — reserve dropped to 23 bytes

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_type, 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 (Arcium)

Keeper → request_validation(mandate)
→ Arcium MXE validates encrypted inputs off-chain (MPC)
→ validate_mandate_callback() writes PullApproval PDA
→ PullApproval = {mandate, valid_until, approved, approved_amount, created_at, bump}

There is no non-Arcium validation path. execute_pull requires a PullApproval PDA to exist and have approved == true; missing or unapproved approvals fail with ApprovalNotGranted or ArciumUnavailable.

valid_until is set to mandate.next_payment_due by the callback — approvals naturally expire at the boundary of the billing period they were issued for.

4. Pull Execution

Keeper → execute_pull()
→ Pre-CPI checks: mandate active, next_payment_due reached,
                  pulls_executed < max_pulls, PullApproval valid
→ Token-2022: transfer_checked(subscriber_wrapped → merchant_wrapped,
                               authority = mandate PDA, signed via invoke_signed)
→ vela-transfer-hook: execute()
  → Dispatch on owner discriminator (StreamMandate vs PullApproval)
  → For pulls: read PullApproval, validate amount ≤ approved_amount
               and valid_until not elapsed
  → Return Ok or fail the transfer closed
→ vela-protocol: advance state (pulls_executed++, next_payment_due += frequency),
                 close PullApproval PDA (refund lamports to keeper)

The transfer moves wrapped USDC (Token-2022), not native SPL USDC. Source and destination are the subscriber's and merchant's wrapped accounts; the mandate PDA is the signing authority. See Custody and Wrapping Model below.

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 Change (v1.7+)

Subscriber → request_plan_change(mandate, new_plan)
→ Mandate.pending_new_plan = new_plan
→ Mandate.pending_effective_at = timestamp
→ Mandate.pending_change_type = <upgrade | downgrade>
→ At next pull after pending_effective_at: inline change executes
→ Mandate.amount, frequency updated from new_plan
→ clear_pending() resets all pending_* fields

Plan changes are inline — the mandate isn't closed and recreated. The pending change is stored across four fields (pending_new_plan, pending_effective_at, pending_change_type, pending_nonce_short) and applied 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 wrapped USDC account)
  • Destination account (merchant's wrapped USDC account)
  • Amount being transferred
  • ExtraAccountMetaList PDA (registered at mint creation, provides additional accounts)

From the extra accounts, the hook receives a slot for the token account's owner account — which resolves to either a StreamMandate PDA or some other owner — plus slots for the PullApproval PDA, ProtocolConfig, TokenConfig, and a wrapping-vault reference used to detect wrap/unwrap operations.

Enforcement Logic

Dispatch is by owner account discriminator, not by a mandate-type enum. The hook reads the first 8 bytes of the owner account:

rust
fn handler_transfer_hook(ctx: Context<TransferHook>, amount: u64) -> Result<()> {
    // Detect wrap/unwrap (source or destination is the wrapping vault) → allow
    if is_wrap_or_unwrap(&ctx) { return Ok(()); }

    // Protocol pause check
    require!(!protocol_config.paused, VelaError::ProtocolPaused);

    // Discriminator-based dispatch on the token account's owner
    if owner_discriminator == StreamMandate::DISCRIMINATOR {
        // Streaming branch
        let stream = StreamMandate::try_deserialize(&owner_data)?;
        require!(stream.status == StreamStatus::Active, StreamNotActive);
        let elapsed = clock.unix_timestamp - stream.last_settled_ts;
        require!(elapsed >= stream.min_settle_interval as i64,
                 MinSettleIntervalViolation);
        let accrued = (elapsed as u64).checked_mul(stream.rate_per_second)?;
        require!(amount <= accrued, AmountExceedsStreamRate);
        if let Some(cap) = stream.max_streamed {
            require!(stream.total_streamed.checked_add(amount)? <= cap,
                     StreamCapExceeded);
        }
        return Ok(());
    }

    // Default branch: PullApproval path
    // Used by periodic pulls AND agent pulls — agent_pull.rs creates a
    // PullApproval the same way periodic validation does.
    let (expected, _) = Pubkey::find_program_address(
        &[PullApproval::SEED_PREFIX, owner.key().as_ref()],
        &vela_protocol::ID,
    );
    require_keys_eq!(ctx.accounts.pull_approval.key(), expected);

    let approval = PullApproval::try_deserialize(&approval_data)?;
    require!(approval.approved, ApprovalNotGranted);
    require!(clock.unix_timestamp <= approval.valid_until, ApprovalExpired);
    require!(amount <= approval.approved_amount, AmountExceedsPlanAmount);

    Ok(())
}

Why There Is No Dedicated "Agent" Hook Branch

Agent pulls reuse the PullApproval path. agent_pull.rs validates the agent-mandate-specific rules (per-service daily limits, lifetime cap, min pull amount, cooldown) inline in the main program, then creates a one-shot PullApproval that the hook validates identically to a periodic pull. This keeps the hook minimal and means new agent-mandate logic can ship without upgrading the hook.

Fail-Closed Design

The hook is designed to fail closed:

  • Any error in reading accounts → transfer fails
  • Missing PullApproval → transfer fails with ApprovalNotGranted
  • Expired approval → ApprovalExpired
  • Amount exceeds approval → AmountExceedsPlanAmount
  • Protocol paused → ProtocolPaused

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 mints (soulbound). They serve as proof of subscription. There is no separate Credential data account — the mint itself, plus a token account holding exactly 1 unit for the subscriber, is the credential.

PropertyValueWhy
StandardToken-2022 mintRequired for Non-Transferable extension
TransferabilityNon-TransferableCredentials can't be traded or transferred
MetadataMetadata Pointer extensionStores plan tier, features, start date
LifecycleMinted on subscribe, burned on cancelClean lifecycle management

Two Credential Scopes

Both scopes exist in the current code. Per-merchant is the v1.7 canonical pattern; per-plan mints remain for flows that need plan-specific credential metadata.

ScopeMint seedsCreated byUse
Per-merchant["merchant-credential", merchant]init_merchant_credentialSingle mint per merchant, persists across plan changes
Per-plan["credential", merchant, plan_id]create_planOne mint per plan; minted to subscribers of that plan

v1.7 Per-Merchant Pattern

In v1.6 and earlier, credentials were strictly per-plan. Plan switching required burn + remint cycles. In v1.7, the merchant-credential mint gives merchants a single long-lived credential pubkey that survives plan upgrades. The mandate's pending_new_plan drives the plan change without touching credential state.

Metadata Structure

Credential Mint 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": "2026-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]
  size: 213 bytes
  fields:
    - mint: Pubkey                               // The token mint
    - token_program: Pubkey                      // SPL Token or Token-2022 program id
    - billing_rail: BillingRail                  // TransferHook | TokenDelegate
    - decimals: u8                               // Must match on-chain decimals
    - enabled: bool                              // Billing enabled for this mint
    - oracle_reference: Pubkey                   // Price oracle reference (default until v1.8)
    - admin: Pubkey                              // Admin that registered the token
    - created_at: i64
    - bump: u8
    - version: u8
    - _reserved: [u8; 64]

BillingRail selects how charges route:

  • TransferHook — wrapped-token flow via the Token-2022 transfer hook CPI chain (current USDC path)
  • TokenDelegate — native SPL tokens via approve/transfer delegation (v1.8+)

Initialization

init_token_config asserts that the on-chain decimals of the mint match the registered decimals (TokenConfigDecimalsMismatch, error 6713). This prevents a class of bugs where amount calculations are wrong due to decimal mismatch.

Supported Mints (Planned)

MintSymbolDecimalsBilling railStatus
USDC (wrapped)wUSDC6TransferHookActive
PYUSDPYUSD6TokenDelegatePlanned
EURCEURC6TokenDelegatePlanned
Wrapped SOLwSOL9TokenDelegatePlanned

Error Code Strategy

Error discriminants are assigned explicitly in errors.rs. The layout is bimodal: the original "core" errors are packed densely starting at 0; later feature bands were added at 6500+ once banding became useful for filtering.

Actual Error Layout

RangeCategoryExamples
0–61Core protocol (dense, unbanded)PullTooEarly (0), MandateNotActive (1), ApprovalNotGranted (11), ApprovalExpired (12), ArciumUnavailable (17), TransferNotAuthorized (26), UnauthorizedAgent (30), DailyLimitExceeded (36), LifetimeCapExceeded (38), UnauthorizedKeeper (51), ProtocolPaused (60)
100Versioning: mandateMandateVersionUnsupported
200Versioning: planPlanVersionUnsupported
300Versioning: migrationMigrationPreconditionFailed
400Versioning: agent mandateAgentMandateVersionUnsupported
6500–6503Token registryTokenNotRegistered (6500), TokenAlreadyRegistered, TokenDisabled, InvalidBillingRail
6600–6601Hook upgradeMetaListAlreadyInitialized (6600), WrongAccountType (6601)
6700–6716Streaming + plan-change flowStreamNotActive (6700), MinSettleIntervalViolation (6703), AmountExceedsStreamRate (6704), StreamCapExceeded (6705), UnauthorizedStreamSigner (6710), TokenConfigDecimalsMismatch (6713), UnauthorizedUpgrade (6716)
6800–6803Proration mathMathDivByZero (6800), MathOverflow, MathUnderflow, InvalidElapsed (6803)

Unused: 62–99, 101–199, 201–299, 301–399, 401–6499, 6504–6599, 6602–6699, 6717–6799, 6804+ — reserved for future additions in their respective bands.

Error Design Principles

  1. Typed names, no generic failures — every error has a specific name that identifies the exact failure mode
  2. Original cluster stayed packed — early errors (0–61) kept their dense numbering rather than being renumbered into bands; new feature families get their own high-band on introduction
  3. Banded bands for feature families — token registry, hook upgrade, streaming, proration math each own a 100-wide band for forward growth
  4. Zero is a real error codePullTooEarly = 0 is an explicit design choice; Anchor's #[error_code] discriminants are non-negative

Cross-Program Error Handling

When the transfer hook returns an error, it propagates through Token-2022's CPI back to vela-protocol. Clients decode the Anchor error name or numeric code for display. Example:

Token-2022 transfer failed → custom program error: 0x1A2C (6700 = StreamNotActive)
→ client surfaces: "Stream is not active — cancelled or paused"

Custody and Wrapping Model

Billing transfers do not move native SPL USDC directly. Instead, the protocol operates on a Token-2022 wrapped USDC receipt backed 1:1 by SPL USDC in a single shared vault.

Components

ComponentRole
wrapping_vaultSPL USDC associated token account owned by the mint-authority PDA. Holds all SPL USDC backing outstanding wrapped supply 1:1. Address is stored in ProtocolConfig.wrapping_vault.
Wrapped USDC mintToken-2022 mint with Transfer Hook, Permanent Delegate, and Metadata Pointer extensions. Address stored in ProtocolConfig.wrapped_usdc_mint.
mint-authority PDASingleton PDA (seeds ["mint-authority"]) that owns the wrapping vault, mints/burns wrapped USDC, and acts as the Permanent Delegate on the wrapped mint.

Flow

wrap(amount, destination_authority)
  → SPL USDC: subscriber → wrapping_vault
  → wUSDC minted to a Token-2022 account whose owner is `destination_authority`
    (can be the subscriber's wallet OR a mandate PDA — caller's choice)

unwrap(amount)
  → wUSDC burned from caller's wrapped account (caller must be token authority)
  → SPL USDC: wrapping_vault → caller
  → No mandate-state gating — unwrap is unilateral for self-owned accounts

Why Wrap?

  • SPL USDC is a vanilla SPL Token and does not support transfer hooks. Wrapping gives the protocol a Token-2022 receipt that can carry the hook extension, enabling programmable billing constraints at the token layer.
  • A single shared vault is cheaper than per-mandate vaults (one rent-bearing account vs. N).
  • The Permanent Delegate gives the protocol a fallback override path for emergencies without requiring wUSDC account owners to pre-approve delegation.

Where Authority Lives

  • Subscriber-owned wUSDC account (token::authority = subscriber): subscriber can spend, transfer, and unwrap at will. Used for wallets that aren't yet bound to a mandate.
  • Mandate-owned wUSDC account (token::authority = mandate PDA): locked to billing flow. Used by agent mandates that require a pre-funded budget; the mandate PDA signs pulls via invoke_signed.
  • Periodic-pull model: the subscriber-owned wrapped account is the source; the mandate PDA is the transfer authority (not the token account owner), signing the CPI with its own seeds. The Permanent Delegate makes this valid without an approve call.

Internal knowledge base for the Vela Labs workspace.