Skip to content

Protocol Modularity

Overview

v1.7 was a dedicated infrastructure milestone that refactored the protocol for extensibility. It didn't ship user-facing features — it shipped the foundation that made v1.8 (streaming, upgrades, multi-token) possible without breaking changes.

v1.7 Rationale: Why This Milestone Existed

By the end of v1.5, the protocol had accumulated significant technical debt:

  1. Rigid account structures — Adding fields to Plan or Mandate accounts required account closure and recreation. Every schema change was a breaking change.
  2. Plan-coupled mandates — Mandate PDAs were derived from plan IDs. Updating a plan invalidated all active mandates.
  3. Per-plan credentials — Credentials were minted per plan tier. Plan switching required burning and re-minting.
  4. Single-mint ProtocolConfig — The protocol supported one token mint. Adding a new mint required a config change and redeployment.
  5. Static transfer hook — The transfer hook program ID was baked into the protocol. Updating the hook required redeploying the entire protocol.
  6. Distributed PDA derivation — PDA seeds were scattered across the SDK, dashboard, and test code. No single source of truth.

v1.7 addressed all of these through 5 phases and 29 plans, enabling v1.8 to ship streaming, upgrades, and multi-token as additive features.

Versioned Accounts with Reserved Space

The Problem

On Solana, account data layouts are fixed at creation time. Adding a field to an account requires:

  1. Closing all existing accounts.
  2. Re-creating them with the new layout.
  3. Migrating data from old to new accounts.

For a billing protocol with thousands of active mandates, this is unacceptable.

The Solution

Every upgradeable account now has:

  • A version: u8 field that identifies the account schema version.
  • 64 bytes of reserved space at the end of the account data.
rust
#[account]
pub struct Plan {
    pub merchant: Pubkey,
    pub amount: u64,
    pub period: PlanPeriod,
    pub mint: Pubkey,
    pub version: u8,        // Schema version
    // ... existing fields
    pub reserved: [u8; 64], // Future fields
}

#[account]
pub struct Mandate {
    pub plan: Pubkey,
    pub subscriber: Pubkey,
    pub merchant: Pubkey,
    pub period_start: i64,
    pub period_end: i64,
    pub pull_count: u32,
    pub version: u8,        // Schema version
    // ... existing fields
    pub pending_upgrade: Option<PendingUpgrade>,  // v1.8 uses reserved space
    pub reserved: [u8; 64], // Future fields
}

How It Works

  1. New fields consume reserved space: When v1.8 needed pending_upgrade on Mandate, it used bytes from the reserved space without changing the account discriminator.
  2. Version field enables conditional deserialization: The SDK reads the version field and deserializes accordingly. V1 accounts are still readable by V2 code.
  3. No account closure needed: Existing accounts work with new code. New fields default to zero/none for old accounts.

Version Compatibility

AccountV1 StructureV2 StructureV3 Structure
PlanBasic fields+ version + reserved(future)
MandateBasic fields+ version + reserved+ pending_upgrade (v1.8)
StreamMandate(new in v1.8)+ version + reserved(future)

Plan-Independent Mandate PDAs

The Problem

Original mandate PDA derivation:

rust
// Original (v1.0–v1.6): Mandate derived from plan ID
seeds = [b"mandate", plan.key().as_ref(), subscriber.key().as_ref()]

This meant:

  • Updating a plan (amount, period) required re-deriving all mandate PDAs.
  • Plan versioning was impossible because mandates were tied to specific plan instances.
  • Plan updates were effectively breaking changes for active subscribers.

The Solution

rust
// v1.7+: Mandate derived from merchant + subscriber (not plan)
seeds = [b"mandate", merchant.key().as_ref(), subscriber.key().as_ref()]

Mandates now reference the plan by address (not by PDA derivation). This means:

  • Plans can be updated without affecting existing mandates.
  • Multiple plans can exist for the same merchant-subscriber pair (upgrade paths).
  • The mandate stores a plan: Pubkey reference that can be updated independently.

Implications

Before (Plan-Coupled)After (Plan-Independent)
Plan update → mandates need re-derivationPlan update → mandates reference new plan
Can't have multiple plans per subscriberMultiple plans per subscriber for upgrades
Plan versioning is a breaking changePlan versioning is additive
PDA factory needs plan address for mandate derivationPDA factory only needs merchant + subscriber

Per-Merchant Credentials over Per-Plan

The Problem

Original credential design:

rust
// Original: Credential per plan tier
seeds = [b"credential", plan.key().as_ref(), subscriber.key().as_ref()]

This meant:

  • Plan switching required burning the old credential and minting a new one.
  • Credential metadata was tied to plan details.
  • Multiple credentials per subscriber for the same merchant (one per plan).

The Solution

rust
// v1.7+: Credential per merchant-subscriber pair
seeds = [b"credential", merchant.key().as_ref(), subscriber.key().as_ref()]

Credentials now track the subscriber↔merchant relationship, not the plan tier:

Before (Per-Plan)After (Per-Merchant)
Plan switch → burn + re-mint credentialPlan switch → update credential metadata
Multiple credentials per merchantOne credential per merchant
Metadata changes require re-mintingMetadata updates in-place
Credential is a "plan receipt"Credential is a "relationship proof"

Why This Matters for Upgrades (v1.8)

When a subscriber switches from Basic to Premium:

  1. The credential stays the same (same PDA, same token).
  2. The mandate's pending_upgrade field captures the transition.
  3. The credential's metadata pointer is updated with new plan details.
  4. No burn/re-mint cycle — the subscriber keeps their credential throughout.

TokenConfig PDA Registry

The Problem

Original ProtocolConfig had a single mint field:

rust
// Original: Single mint in ProtocolConfig
pub struct ProtocolConfig {
    pub mint: Pubkey,  // Only one token mint supported
    // ...
}

This meant:

  • Adding a new token mint required updating ProtocolConfig.
  • No per-mint configuration (decimals, oracle, enabled status).
  • Multi-token billing was architecturally impossible.

The Solution

rust
// v1.7+: Per-mint TokenConfig PDA
#[account]
pub struct TokenConfig {
    pub mint: Pubkey,
    pub decimals: u8,
    pub enabled: bool,
    pub oracle: Option<Pubkey>,  // Price oracle for USD display
    pub bump: u8,
}

TokenConfig Resolution Flow

Merchant creates plan with token mint


SDK looks up TokenConfig PDA for that mint


TokenConfig.enabled? → yes → proceed
TokenConfig.enabled? → no  → error: token not supported


SDK uses TokenConfig.decimals for amount formatting
SDK uses TokenConfig.oracle for USD display (client-side)

Admin Operations

  • init_token_config: Register a new token mint for billing.
  • update_token_config: Update decimals, oracle, or enabled status.
  • Admin kill-switch: Disable a token mint without removing it (soft disable).
  • Multi-token plan rendering: Dashboard widget and checkout render plans in the subscriber's preferred token.

Upgradeable Transfer Hook with Dynamic ExtraAccountMetaList

The Problem

The transfer hook program ID was baked into the protocol:

rust
// Original: Static transfer hook reference
declare_id!("TransferHook1111111111111111111111111111111");

Updating the transfer hook (e.g., adding streaming validation) required redeploying the entire protocol program.

The Solution

rust
// v1.7+: Dynamic transfer hook from ProtocolConfig
pub struct ProtocolConfig {
    pub transfer_hook_program_id: Pubkey,  // Can be updated
    // ...
}

Plus an upgradeable ExtraAccountMetaList with 8 reserved slots:

rust
// ExtraAccountMetaList with 8 slots for future accounts
// Slot 0: Mandate PDA
// Slot 1: Plan PDA
// Slot 2: PullApproval PDA
// Slot 3: ProtocolConfig PDA
// Slot 4: (reserved for StreamMandate — used in v1.8)
// Slot 5: (reserved)
// Slot 6: (reserved)
// Slot 7: (reserved)

How Hook Upgrades Work

  1. Deploy new transfer hook program.
  2. Admin calls update_config with new transfer_hook_program_id.
  3. Token-2022 mints registered with the ExtraAccountMetaList PDA continue to work — the meta list points to account patterns, not specific program logic.
  4. New hook program reads the same account patterns but can add validation branches (e.g., streaming vs periodic).

v1.8 Used This for Streaming

When v1.8 added streaming:

  1. Transfer hook gained a discriminator-first dispatch (periodic vs streaming).
  2. Slot 4 of ExtraAccountMetaList was activated for StreamMandate PDA.
  3. No protocol program redeployment needed — just hook program update.

Centralized SDK PDAFactory

The Problem

PDA derivation was scattered across the codebase:

typescript
// In vela-sdk
const [mandatePDA] = PublicKey.findProgramAddressSync(
  [Buffer.from("mandate"), plan.toBuffer(), subscriber.toBuffer()],
  programId
);

// In vela-dashboard (separate implementation)
const [mandatePDA] = PublicKey.findProgramAddressSync(
  [Buffer.from("mandate"), plan.toBuffer(), subscriber.toBuffer()],
  programId
);
// ↑ Same logic, separate code — drift risk

The Solution

typescript
// v1.7+: Centralized PDAFactory in @vela/sdk
export class PDAFactory {
  static mandate(params: { merchant: PublicKey; subscriber: PublicKey }): [PublicKey, number] {
    return PublicKey.findProgramAddressSync(
      [Buffer.from("mandate"), params.merchant.toBuffer(), params.subscriber.toBuffer()],
      PROGRAM_ID
    );
  }

  static plan(params: { merchant: PublicKey; planId: BN }): [PublicKey, number] { ... }
  static credential(params: { merchant: PublicKey; subscriber: PublicKey }): [PublicKey, number] { ... }
  static tokenConfig(params: { mint: PublicKey }): [PublicKey, number] { ... }
  static pullApproval(params: { mandate: PublicKey; nonce: BN }): [PublicKey, number] { ... }
  static streamMandate(params: { subscriber: PublicKey; merchant: PublicKey }): [PublicKey, number] { ... }
  static agentMandate(params: { subscriber: PublicKey; service: string }): [PublicKey, number] { ... }
}

Why This Matters

  • Single source of truth: PDA seeds are defined once, used everywhere.
  • Drift prevention: If seeds change, only PDAFactory needs updating.
  • Type safety: Typed parameters prevent seed-order mistakes.
  • Cross-repo consistency: Dashboard, checkout, portal, widget, and admin all use the same PDAFactory.

Dynamic Transfer-Hook Program ID from On-Chain Config

How It Works

The transfer hook program ID is stored in ProtocolConfig on-chain:

rust
// In vela-transfer-hook
let config = deserialize_protocol_config(accounts.protocol_config)?;
let expected_hook = config.transfer_hook_program_id;

if ctx.program_id != expected_hook {
    return Err(ErrorCode::InvalidTransferHook);
}

This means:

  • The hook program can be updated by admin without redeploying the protocol.
  • Multiple hook versions can coexist (old mints use old hook, new mints use new hook).
  • Hook upgrades are atomic (single update_config instruction).

Why Dynamic (Not Static)

Static HookDynamic Hook
Hook update = protocol redeploymentHook update = config update
All mints must migrate simultaneouslyMints can migrate incrementally
No rollback possibleRollback = config update
Breaking change for every consumerTransparent to consumers

Gap-Closure Phase Pattern

The Pattern

v1.7 introduced a phase pattern where verification reveals drift:

Phase 43: Implement feature X
Phase 44: Gap-closure phase — verify Phase 43, fix integration drift,
          backfill summaries, reconcile checkboxes, correct scope headers

Why This Pattern Emerged

  • Phase 42's verification revealed integration drift between protocol and SDK.
  • Rather than retro-amending Phase 42, a dedicated gap-closure phase (Phase 43) was created.
  • This pattern was repeated in Phase 44 and formalized as a standard practice.

When to Use Gap-Closure Phases

  • Verification reveals drift between implemented and documented state.
  • Cross-phase integration issues are discovered.
  • Artifact discipline was relaxed during execution (missing summaries, stale checkboxes).
  • Scope headers don't match actual implementation.

When NOT to Use Gap-Closure Phases

  • During normal execution (each plan should include its own verification).
  • As an excuse to skip verification during implementation.
  • For new feature work (gap-closure is corrective, not additive).

Summary of v1.7 Decisions

DecisionRationaleEnabled By
Versioned accounts with reserved spaceAdditive field migrations without account closurev1.8 pending_upgrade, future fields
Plan-independent mandate PDAsPlan updates don't invalidate active subscriptionsv1.8 plan upgrades
Per-merchant credentialsPlan switching without burn/re-mintv1.8 plan switching
TokenConfig PDA registryMulti-token billing without breaking changesv1.8 multi-token activation
Upgradeable transfer hook (8 slots)Hook evolves without protocol redeploymentv1.8 streaming validation branch
Centralized SDK PDAFactorySingle source of truth for PDA derivationAll repos use consistent derivation
Dynamic transfer-hook program IDHook updates via config, not redeploymentFuture hook enhancements
Gap-closure phase patternVerification drift gets dedicated corrective phaseProcess improvement

Internal knowledge base for the Vela Labs workspace.