Skip to content

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

OperationSourceWhy
Transaction buildingOn-chainMandate state, credentials, approvals must be current
Credential derivationOn-chainMust match on-chain state exactly
Pull validationOn-chain (via hook)Transfer hook reads on-chain PDAs
Merchant dashboard displayD1 (cached)Performance — avoid RPC calls for every page load
Analytics & reportingD1 (cached)Historical data, aggregations, charting
Search & filteringD1 (indexed)On-chain data isn't searchable efficiently
CSV exportD1 (cached)Bulk data retrieval from D1 is faster
Webhook event processingD1 (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 fields

Singleton 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 retries

Singleton 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 | UsageBased

Plans 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 effect

The 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 effect

Streaming 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 | Exhausted

Agent 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: u8

Ephemeral 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

DirectionMechanismFrequency
On-chain → D1Helius webhooksNear real-time (< 2s)
On-chain → D1Keeper sync (fallback)Every 5 minutes
D1 → On-chainNever (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:

  1. On-chain always wins for security-critical data (mandate status, amounts)
  2. D1 wins for display-only data (merchant name, plan description) — these don't exist on-chain
  3. Billing events are reconciled by tx_signature — duplicates are detected and deduplicated

Account Size Reference

AccountApproximate 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

VersionChangeMilestone
Mandate v1 → v2Plan-independent seedsv1.7
Mandate v2 → v3Inline upgrade support (pending_upgrade)v1.7
StreamMandate v1 → v2Pending rate changev1.8
Credential v1 → v2Per-merchant scope (seeds change)v1.7
Plan v1 → v2Mutable fields (name, description)v1.5

All migrations are additive — new fields are appended, existing fields are unchanged. The version field enables forward-compatible deserialization.

Internal knowledge base for the Vela Labs workspace.