Appearance
Data Model
Complete data model for Vela Protocol — on-chain accounts, off-chain database schema, and the boundary between them.
Architecture Principle: On-Chain as Source of Truth
The fundamental data model principle is:
On-chain state is authoritative. D1 is a UI cache for display, search, and analytics.
This principle was validated across multiple milestones (v1.0-v1.7) and formalized in v1.2 Phase 23. Every shortcut that cached on-chain data in D1 for critical paths eventually required remediation.
Decision Matrix
| Operation | Source | Why |
|---|---|---|
| Transaction building | On-chain | Mandate state, credentials, approvals must be current |
| Credential derivation | On-chain | Must match on-chain state exactly |
| Pull validation | On-chain (via hook) | Transfer hook reads on-chain PDAs |
| Merchant dashboard display | D1 (cached) | Performance — avoid RPC calls for every page load |
| Analytics & reporting | D1 (cached) | Historical data, aggregations, charting |
| Search & filtering | D1 (indexed) | On-chain data isn't searchable efficiently |
| CSV export | D1 (cached) | Bulk data retrieval from D1 is faster |
| Webhook event processing | D1 (event log) | Event deduplication and retry tracking |
On-Chain Accounts
Core Protocol
ProtocolConfig
Account: ProtocolConfig
Seeds: ["config"]
Version: 1
Fields:
admin: Pubkey // Protocol administrator
transfer_hook_program_id: Pubkey // vela-transfer-hook program ID
wrapped_usdc_mint: Pubkey // Wrapped USDC mint for settlements
paused: bool // Emergency pause flag
version: u8 // Account schema version
_reserved: [u8; 64] // Reserved for future fieldsSingleton account. Created once during protocol initialization. The admin can update config parameters. The transfer_hook_program_id is resolved dynamically (v1.7) rather than hardcoded.
KeeperConfig
Account: KeeperConfig
Seeds: ["keeper"]
Version: 1
Fields:
keeper_authority: Pubkey // Authorized keeper public key
scheduling_params: SchedulingParams
paused: bool // Keeper pause flag
version: u8
_reserved: [u8; 64]
SchedulingParams:
min_interval: i64 // Minimum seconds between pulls
max_retries: u8 // Max retries for failed pulls
retry_delay: i64 // Seconds between retriesSingleton account. The keeper authority is the only entity that can trigger pulls (excluding self-service pulls).
Plan
Account: Plan
Seeds: ["plan", merchant, plan_index]
Version: 2
Fields:
merchant: Pubkey // Plan creator/owner
plan_index: u64 // Sequential plan number for merchant
amount: u64 // Billing amount (in token units)
frequency: PlanFrequency // Billing frequency
billing_mode: BillingMode // Pull, UsageBased, etc.
mint: Pubkey // Payment token mint
active: bool // Plan active/inactive
metadata_uri: String // IPFS URI for plan metadata
version: u8
_reserved: [u8; 64]
PlanFrequency:
Daily | Weekly | Monthly | Quarterly | Yearly | Custom(seconds)
BillingMode:
PeriodicPull | UsageBasedPlans are templates. Multiple subscribers can subscribe to the same plan. Plans can be deactivated (no new subscriptions) without affecting existing mandates.
Mandate (v2 — Plan-Independent)
Account: Mandate
Seeds: ["mandate", subscriber, merchant, mandate_index]
Version: 3
Fields:
subscriber: Pubkey // Submitter wallet
merchant: Pubkey // Merchant wallet
mandate_index: u64 // Sequential mandate number
amount: u64 // Current billing amount
frequency: PlanFrequency // Current billing frequency
pulls_executed: u64 // Total pulls completed
valid_until: i64 // Expiration timestamp (0 = never)
status: MandateStatus // Active, Cancelled, Paused
pending_upgrade: Option<UpgradeDetails> // v3: inline upgrade
created_at: i64 // Creation timestamp
last_pull_at: Option<i64> // Last successful pull timestamp
version: u8
_reserved: [u8; 64]
MandateStatus:
Active | Cancelled | Paused | Expired
UpgradeDetails:
new_plan: Pubkey // Target plan for upgrade
new_amount: u64 // New billing amount
new_frequency: PlanFrequency // New billing frequency
effective_at: i64 // When upgrade takes effectThe v1.7 refactor made mandates plan-independent. The seeds no longer include the plan pubkey, enabling plan switching without closing the mandate. The pending_upgrade field (v3) supports atomic inline upgrades at the next pull cycle.
StreamMandate
Account: StreamMandate
Seeds: ["stream", subscriber, merchant, mandate_index]
Version: 2
Fields:
subscriber: Pubkey
merchant: Pubkey
mandate_index: u64
rate_per_second: u64 // Tokens per second
last_settled_ts: i64 // Last settlement timestamp
total_streamed: u64 // Total tokens streamed so far
max_streamed: u64 // Lifetime cap (0 = unlimited)
min_settle_interval: u64 // Minimum seconds between settlements
status: StreamStatus
pending_rate_change: Option<RateChange> // v2: pending rate change
created_at: i64
version: u8
_reserved: [u8; 64]
StreamStatus:
Active | Paused | Cancelled | Exhausted
RateChange:
new_rate: u64 // New rate per second
effective_at: i64 // When new rate takes effectStreaming mandates track a per-second rate and total streamed amount. The pending_rate_change field (v2) supports rate transitions without interrupting the stream.
AgentMandate
Account: AgentMandate
Seeds: ["agent-mandate", agent, authority]
Version: 1
Fields:
agent: Pubkey // Agent wallet (AI bot, service)
authority: Pubkey // Delegating authority (human)
daily_limit: u64 // Max spend per 24h period
daily_spent: u64 // Spent in current 24h period
daily_period_start: i64 // Start of current period
total_cap: u64 // Lifetime spending cap
total_spent: u64 // Total spent so far
authorized_services: Vec<Pubkey> // Whitelist of allowed services
status: AgentMandateStatus
created_at: i64
version: u8
_reserved: [u8; 64]
AgentMandateStatus:
Active | Revoked | ExhaustedAgent mandates delegate bounded spending authority. The daily limit resets every 24 hours based on created_at. The total cap is a hard lifetime ceiling.
PullApproval
Account: PullApproval
Seeds: ["approval", mandate, epoch]
Version: 1
Fields:
mandate: Pubkey // Mandate being approved
epoch: u64 // Billing epoch
approved_amount: u64 // Maximum approved amount
valid_until: i64 // Approval expiration timestamp
created_at: i64
version: u8Ephemeral account. Created by Arcium callback (Phase 1+) or keeper (Phase 0). Consumed by execute_pull(). The epoch seed ensures uniqueness per billing period.
Credential
Account: Credential
Seeds: ["credential", subscriber, merchant]
Version: 2
Fields:
subscriber: Pubkey
merchant: Pubkey
mint: Pubkey // Credential NFT mint
plan_tier: String // Current plan tier name
minted_at: i64
version: u8
_reserved: [u8; 64]Per-merchant (v1.7), not per-plan. The credential persists across plan upgrades. The credential NFT is non-transferable (Token-2022 Non-Transferable extension).
TokenConfig
Account: TokenConfig
Seeds: ["token-config", mint]
Version: 1
Fields:
mint: Pubkey // SPL token mint address
decimals: u8 // Must match on-chain decimals
enabled: bool // Billing enabled for this mint
oracle: Pubkey // Price oracle address
min_pull_amount: u64 // Minimum pull amount (dust prevention)
version: u8
_reserved: [u8; 64]Per-mint configuration. init_token_config asserts on-chain decimals match registered decimals.
Off-Chain Database (D1)
Dashboard Schema
The D1 database serves the merchant dashboard. It mirrors on-chain state for display purposes and stores dashboard-specific data that doesn't exist on-chain.
Merchants Table
sql
CREATE TABLE merchants (
id TEXT PRIMARY KEY, -- UUID
wallet TEXT NOT NULL UNIQUE, -- Merchant wallet address
org_id TEXT NOT NULL, -- Organization ID (Better Auth)
email TEXT NOT NULL, -- Merchant email
status TEXT NOT NULL DEFAULT 'active', -- active, suspended, closed
created_at INTEGER NOT NULL, -- Unix timestamp
updated_at INTEGER NOT NULL -- Unix timestamp
);
CREATE INDEX idx_merchants_wallet ON merchants(wallet);
CREATE INDEX idx_merchants_org ON merchants(org_id);Plans Table
sql
CREATE TABLE plans (
id TEXT PRIMARY KEY, -- UUID
merchant_id TEXT NOT NULL, -- FK to merchants
plan_pubkey TEXT NOT NULL, -- On-chain Plan PDA address
name TEXT NOT NULL, -- Display name
description TEXT, -- Plan description
amount INTEGER NOT NULL, -- Billing amount (lamports/units)
frequency TEXT NOT NULL, -- daily, weekly, monthly, etc.
mint TEXT NOT NULL, -- Payment token mint
billing_mode TEXT NOT NULL, -- periodic_pull, usage_based
active INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
FOREIGN KEY (merchant_id) REFERENCES merchants(id)
);
CREATE INDEX idx_plans_merchant ON plans(merchant_id);Subscribers Table
sql
CREATE TABLE subscribers (
id TEXT PRIMARY KEY, -- UUID
mandate_pubkey TEXT NOT NULL, -- On-chain Mandate PDA address
wallet TEXT NOT NULL, -- Subscriber wallet
merchant_id TEXT NOT NULL,
plan_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active', -- active, cancelled, expired
subscribed_at INTEGER NOT NULL,
cancelled_at INTEGER,
FOREIGN KEY (merchant_id) REFERENCES merchants(id),
FOREIGN KEY (plan_id) REFERENCES plans(id)
);
CREATE INDEX idx_subscribers_mandate ON subscribers(mandate_pubkey);
CREATE INDEX idx_subscribers_merchant ON subscribers(merchant_id);
CREATE INDEX idx_subscribers_wallet ON subscribers(wallet);Billing Events Table
sql
CREATE TABLE billing_events (
id TEXT PRIMARY KEY, -- UUID
mandate_pubkey TEXT NOT NULL, -- Mandate involved
merchant_id TEXT NOT NULL,
amount INTEGER NOT NULL, -- Amount transferred
tx_signature TEXT NOT NULL, -- Solana transaction signature
event_type TEXT NOT NULL, -- pull, stream_settle, usage_charge, agent_pull
status TEXT NOT NULL DEFAULT 'completed', -- completed, failed, pending
block_height INTEGER, -- Solana block height
slot INTEGER, -- Solana slot
created_at INTEGER NOT NULL,
FOREIGN KEY (merchant_id) REFERENCES merchants(id)
);
CREATE INDEX idx_events_mandate ON billing_events(mandate_pubkey);
CREATE INDEX idx_events_merchant ON billing_events(merchant_id);
CREATE INDEX idx_events_type ON billing_events(event_type);
CREATE INDEX idx_events_created ON billing_events(created_at);Audit Log Table
sql
CREATE TABLE audit_log (
id TEXT PRIMARY KEY,
admin_wallet TEXT NOT NULL,
action TEXT NOT NULL, -- pause_protocol, update_config, etc.
target TEXT, -- Target entity
details TEXT, -- JSON details
tx_signature TEXT,
created_at INTEGER NOT NULL
);
CREATE INDEX idx_audit_admin ON audit_log(admin_wallet);
CREATE INDEX idx_audit_created ON audit_log(created_at);Protocol Snapshots Table
sql
CREATE TABLE protocol_snapshots (
id TEXT PRIMARY KEY,
tvl INTEGER NOT NULL, -- Total value locked (in lamports)
mandate_count INTEGER NOT NULL,
merchant_count INTEGER NOT NULL,
active_streams INTEGER NOT NULL,
timestamp INTEGER NOT NULL
);
CREATE INDEX idx_snapshots_ts ON protocol_snapshots(timestamp);Cost Snapshots Table
sql
CREATE TABLE cost_snapshots (
id TEXT PRIMARY KEY,
sol_cost REAL NOT NULL, -- SOL cost in period
keeper_operations INTEGER NOT NULL,
compute_units_used INTEGER NOT NULL,
timestamp INTEGER NOT NULL
);
CREATE INDEX idx_cost_ts ON cost_snapshots(timestamp);Webhook Endpoints Table
sql
CREATE TABLE webhook_endpoints (
id TEXT PRIMARY KEY,
merchant_id TEXT NOT NULL,
url TEXT NOT NULL,
secret TEXT NOT NULL, -- Webhook signing secret
events TEXT NOT NULL, -- JSON array of subscribed event types
status TEXT NOT NULL DEFAULT 'active', -- active, disabled, failed
last_delivery_at INTEGER,
last_failure_at INTEGER,
failure_count INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
FOREIGN KEY (merchant_id) REFERENCES merchants(id)
);
CREATE INDEX idx_webhooks_merchant ON webhook_endpoints(merchant_id);Checkout Sessions Table
sql
CREATE TABLE checkout_sessions (
id TEXT PRIMARY KEY,
merchant_id TEXT NOT NULL,
plan_id TEXT NOT NULL,
subscriber_wallet TEXT, -- Set when subscriber connects
status TEXT NOT NULL DEFAULT 'pending', -- pending, completed, expired, failed
mandate_pubkey TEXT, -- Set after subscription
tx_signature TEXT, -- Set after completion
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
completed_at INTEGER,
FOREIGN KEY (merchant_id) REFERENCES merchants(id),
FOREIGN KEY (plan_id) REFERENCES plans(id)
);
CREATE INDEX idx_checkout_merchant ON checkout_sessions(merchant_id);
CREATE INDEX idx_checkout_status ON checkout_sessions(status);On-Chain vs Off-Chain Boundary
Data Flow Diagram
┌─────────────────────────────────────────────────────────────────┐
│ ON-CHAIN (Authoritative) │
│ │
│ ProtocolConfig ─── KeeperConfig ─── Plan (template) │
│ │ │ │ │
│ │ Keeper triggers Subscriber selects │
│ │ pull on schedule plan & subscribes │
│ ▼ ▼ ▼ │
│ Mandate ◄──── PullApproval ──── Arcium callback │
│ StreamMandate │
│ AgentMandate ────── Transfer Hook enforcement │
│ Credential (NFT) │
│ TokenConfig │
└──────────────────────────┬──────────────────────────────────────┘
│
Webhook events
(Helius indexing)
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ OFF-CHAIN (D1 Cache) │
│ │
│ merchants ─── plans ─── subscribers ─── billing_events │
│ │ │ │ │ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ Dashboard UI ◄── Search ◄── Filters ◄── Analytics ◄── Export │
│ │
│ audit_log ─── protocol_snapshots ─── cost_snapshots │
│ webhook_endpoints ─── checkout_sessions │
└─────────────────────────────────────────────────────────────────┘Sync Strategy
| Direction | Mechanism | Frequency |
|---|---|---|
| On-chain → D1 | Helius webhooks | Near real-time (< 2s) |
| On-chain → D1 | Keeper sync (fallback) | Every 5 minutes |
| D1 → On-chain | Never (one-way) | N/A |
The sync is strictly one-way: on-chain events flow to D1 via webhooks. D1 never writes back to on-chain state.
Consistency Model
D1 is eventually consistent with on-chain state. The expected lag is:
- Webhook path: < 2 seconds (Helius Enhanced webhooks)
- Keeper fallback: < 5 minutes (periodic sync)
For operations that require exact state, the dashboard reads directly from chain via RPC. D1 is used for display, search, and analytics where slight staleness is acceptable.
Conflict Resolution
When on-chain and D1 data disagree:
- On-chain always wins for security-critical data (mandate status, amounts)
- D1 wins for display-only data (merchant name, plan description) — these don't exist on-chain
- Billing events are reconciled by tx_signature — duplicates are detected and deduplicated
Account Size Reference
| Account | Approximate Size (bytes) | Rent (SOL) |
|---|---|---|
| ProtocolConfig | ~200 | ~0.002 |
| KeeperConfig | ~150 | ~0.0015 |
| Plan | ~300 | ~0.003 |
| Mandate (v3) | ~400 | ~0.004 |
| StreamMandate (v2) | ~350 | ~0.0035 |
| AgentMandate | ~300 | ~0.003 |
| PullApproval | ~100 | ~0.001 |
| Credential | ~200 | ~0.002 |
| TokenConfig | ~150 | ~0.0015 |
Cost per Subscription
Creating a new subscription (subscribe + mandate + credential) requires:
- Mandate PDA: ~0.004 SOL
- Credential NFT mint: ~0.002 SOL
- Metadata account: ~0.001 SOL
- Total: ~0.007 SOL per subscription (at current rent rates)
Migration History
| Version | Change | Milestone |
|---|---|---|
| Mandate v1 → v2 | Plan-independent seeds | v1.7 |
| Mandate v2 → v3 | Inline upgrade support (pending_upgrade) | v1.7 |
| StreamMandate v1 → v2 | Pending rate change | v1.8 |
| Credential v1 → v2 | Per-merchant scope (seeds change) | v1.7 |
| Plan v1 → v2 | Mutable fields (name, description) | v1.5 |
All migrations are additive — new fields are appended, existing fields are unchanged. The version field enables forward-compatible deserialization.