Appearance
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:
- Rigid account structures — Adding fields to Plan or Mandate accounts required account closure and recreation. Every schema change was a breaking change.
- Plan-coupled mandates — Mandate PDAs were derived from plan IDs. Updating a plan invalidated all active mandates.
- Per-plan credentials — Credentials were minted per plan tier. Plan switching required burning and re-minting.
- Single-mint ProtocolConfig — The protocol supported one token mint. Adding a new mint required a config change and redeployment.
- Static transfer hook — The transfer hook program ID was baked into the protocol. Updating the hook required redeploying the entire protocol.
- 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:
- Closing all existing accounts.
- Re-creating them with the new layout.
- 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: u8field 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
- New fields consume reserved space: When v1.8 needed
pending_upgradeon Mandate, it used bytes from the reserved space without changing the account discriminator. - Version field enables conditional deserialization: The SDK reads the
versionfield and deserializes accordingly. V1 accounts are still readable by V2 code. - No account closure needed: Existing accounts work with new code. New fields default to zero/none for old accounts.
Version Compatibility
| Account | V1 Structure | V2 Structure | V3 Structure |
|---|---|---|---|
| Plan | Basic fields | + version + reserved | (future) |
| Mandate | Basic 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: Pubkeyreference that can be updated independently.
Implications
| Before (Plan-Coupled) | After (Plan-Independent) |
|---|---|
| Plan update → mandates need re-derivation | Plan update → mandates reference new plan |
| Can't have multiple plans per subscriber | Multiple plans per subscriber for upgrades |
| Plan versioning is a breaking change | Plan versioning is additive |
| PDA factory needs plan address for mandate derivation | PDA 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 credential | Plan switch → update credential metadata |
| Multiple credentials per merchant | One credential per merchant |
| Metadata changes require re-minting | Metadata 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:
- The credential stays the same (same PDA, same token).
- The mandate's
pending_upgradefield captures the transition. - The credential's metadata pointer is updated with new plan details.
- 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
- Deploy new transfer hook program.
- Admin calls
update_configwith newtransfer_hook_program_id. - Token-2022 mints registered with the
ExtraAccountMetaListPDA continue to work — the meta list points to account patterns, not specific program logic. - 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:
- Transfer hook gained a discriminator-first dispatch (periodic vs streaming).
- Slot 4 of ExtraAccountMetaList was activated for
StreamMandatePDA. - 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 riskThe 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
PDAFactoryneeds 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_configinstruction).
Why Dynamic (Not Static)
| Static Hook | Dynamic Hook |
|---|---|
| Hook update = protocol redeployment | Hook update = config update |
| All mints must migrate simultaneously | Mints can migrate incrementally |
| No rollback possible | Rollback = config update |
| Breaking change for every consumer | Transparent 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 headersWhy 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
| Decision | Rationale | Enabled By |
|---|---|---|
| Versioned accounts with reserved space | Additive field migrations without account closure | v1.8 pending_upgrade, future fields |
| Plan-independent mandate PDAs | Plan updates don't invalidate active subscriptions | v1.8 plan upgrades |
| Per-merchant credentials | Plan switching without burn/re-mint | v1.8 plan switching |
| TokenConfig PDA registry | Multi-token billing without breaking changes | v1.8 multi-token activation |
| Upgradeable transfer hook (8 slots) | Hook evolves without protocol redeployment | v1.8 streaming validation branch |
| Centralized SDK PDAFactory | Single source of truth for PDA derivation | All repos use consistent derivation |
| Dynamic transfer-hook program ID | Hook updates via config, not redeployment | Future hook enhancements |
| Gap-closure phase pattern | Verification drift gets dedicated corrective phase | Process improvement |