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. Both v2 seeds (current) and legacy v1 seeds are still accepted in execute_pull for backward compatibility — see instructions/mandate_account.rs.
Core Accounts
| Account | Seeds | Size | Purpose |
|---|---|---|---|
ProtocolConfig | ["config"] | 220 bytes | Singleton: admin, Arcium cluster, wrapped_usdc_mint, wrapping_vault, paused flag, transfer_hook_program_id |
KeeperConfig | ["keeper-config"] | 237 bytes | Singleton: admin, keeper mode (Centralized/TukTuk), keeper endpoint, keeper authority |
Plan (VelaPlan) | ["plan", merchant, plan_id] | ~280 bytes | Billing plan definition (amount, frequency seconds, mint, active flag) |
Mandate (VelaMandate) v3 | ["mandate", subscriber, merchant, mandate_index] (v2) or ["mandate", subscriber, plan] (legacy) | 268 bytes | Subscription mandate — plan-independent in v2, supports inline plan changes via pending_new_plan |
StreamMandate v2 | ["stream", subscriber, merchant, mandate_index] | 225 bytes | Streaming 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 bytes | Ephemeral pull authorization (approved_amount, valid_until) — one per mandate at a time |
Credential mint (per-merchant) | ["merchant-credential", merchant] | Token-2022 mint | Soulbound subscription credential mint — persists across plans |
Credential mint (per-plan) | ["credential", merchant, plan_id] | Token-2022 mint | Per-plan credential mint, minted to subscribers of that plan |
TokenConfig | ["token_config", mint] | 213 bytes | Per-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_planfield 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
| Account | Version field | Reserved bytes | Notes |
|---|---|---|---|
ProtocolConfig | ✓ | 32 (PROTOCOL_CONFIG_RESERVED_BYTES) | Smaller reserve — config is low-churn |
KeeperConfig | ✓ | 64 (ACCOUNT_RESERVED_BYTES) | |
VelaPlan | ✓ | 32 (PLAN_RESERVED_BYTES) | |
VelaMandate | ✓ | 7 (_reserved_v3) | Shrank across v2→v3 as fields were added |
StreamMandate | ✓ | 23 (_reserved_v2) | Shrank across v1→v2 |
AgentMandate | ✓ | 64 | |
TokenConfig | ✓ | 64 | |
UsagePlan | ✓ | 64 | |
PullApproval | ✗ none | 0 | Ephemeral — closed after every pull, no forward compat needed |
Design Decisions
| Decision | Rationale |
|---|---|
version: u8 | Compact, supports 255 schema versions |
| Varying reserve sizes | Each account type picks a reserve budget that matches its expected change frequency |
PullApproval opt-out | Ephemeral accounts don't need reserved space — they never outlive a single pull |
| Migration strategy | Additive only — new fields appended, old fields unchanged, version bumped |
Migration Examples
| Migration | Version Change | Reserved Space Used |
|---|---|---|
| v1.7: Plan-independent mandates | Mandate v1 → v2 | Consumed reserve for seed-layout shift |
| v1.7: Inline plan changes | Mandate v2 → v3 | Added pending_new_plan, pending_effective_at, pending_change_type, pending_nonce_short — dropped reserve to 7 bytes |
| v1.8: Streaming pending rate change | StreamMandate v1 → v2 | Added 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:
- 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_type, 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 (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 possibleCancellation 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_* fieldsPlan 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.
| Property | Value | Why |
|---|---|---|
| Standard | Token-2022 mint | 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 |
| Lifecycle | Minted on subscribe, burned on cancel | Clean 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.
| Scope | Mint seeds | Created by | Use |
|---|---|---|---|
| Per-merchant | ["merchant-credential", merchant] | init_merchant_credential | Single mint per merchant, persists across plan changes |
| Per-plan | ["credential", merchant, plan_id] | create_plan | One 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)
| Mint | Symbol | Decimals | Billing rail | Status |
|---|---|---|---|---|
| USDC (wrapped) | wUSDC | 6 | TransferHook | Active |
| PYUSD | PYUSD | 6 | TokenDelegate | Planned |
| EURC | EURC | 6 | TokenDelegate | Planned |
| Wrapped SOL | wSOL | 9 | TokenDelegate | Planned |
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
| Range | Category | Examples |
|---|---|---|
| 0–61 | Core 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) |
| 100 | Versioning: mandate | MandateVersionUnsupported |
| 200 | Versioning: plan | PlanVersionUnsupported |
| 300 | Versioning: migration | MigrationPreconditionFailed |
| 400 | Versioning: agent mandate | AgentMandateVersionUnsupported |
| 6500–6503 | Token registry | TokenNotRegistered (6500), TokenAlreadyRegistered, TokenDisabled, InvalidBillingRail |
| 6600–6601 | Hook upgrade | MetaListAlreadyInitialized (6600), WrongAccountType (6601) |
| 6700–6716 | Streaming + plan-change flow | StreamNotActive (6700), MinSettleIntervalViolation (6703), AmountExceedsStreamRate (6704), StreamCapExceeded (6705), UnauthorizedStreamSigner (6710), TokenConfigDecimalsMismatch (6713), UnauthorizedUpgrade (6716) |
| 6800–6803 | Proration math | MathDivByZero (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
- Typed names, no generic failures — every error has a specific name that identifies the exact failure mode
- 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
- Banded bands for feature families — token registry, hook upgrade, streaming, proration math each own a 100-wide band for forward growth
- Zero is a real error code —
PullTooEarly = 0is 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
| Component | Role |
|---|---|
wrapping_vault | SPL 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 mint | Token-2022 mint with Transfer Hook, Permanent Delegate, and Metadata Pointer extensions. Address stored in ProtocolConfig.wrapped_usdc_mint. |
mint-authority PDA | Singleton 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 accountsWhy 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 viainvoke_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
approvecall.