Appearance
Data Model
Commerce OS Aggregate
The v1.9 commerce aggregate is merchant-scoped and centered on payment_intents, not raw Solana transactions.
| Record | Responsibility |
|---|---|
payment_intents | One-time amount, token snapshot, cluster, reference, settlement, verification, customer, invoice, receipt, and refund state |
invoices | Standalone or subscription commercial record; subscription, plan, and mandate links are nullable |
payment_receipts | Idempotent evidence for confirmed payments and refunds |
refund_intents | Reserved amount, token/cluster snapshot, signing lifecycle, signature, retry metadata, refund receipt, and credit note |
customers / customer_wallets | Merchant-owned identity and wallet aggregation |
merchant_domain_challenges | Expiring DNS TXT proof state |
commerce_event_outbox | Events committed with business state for asynchronous fan-out |
processed_event_ids | Event-ID idempotency for consumers and replay |
org_audit_log | Immutable user, API-key, and system actions |
Database checks constrain payment and refund statuses. Foreign keys and merchant ownership checks prevent cross-merchant links. Partial unique indexes reject duplicate active invoice intents and reused transaction signatures. Refund reservation triggers reject concurrent over-refunds.
Payment confirmation is one ordered D1 batch across the intent, invoice, customer wallet, receipt, audit record, and outbox. Refund confirmation applies the same rule to refund, payment, invoice, documents, audit, and outbox records.
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"]
Size: 220 bytes
Version: 1
Fields:
admin: Pubkey // Upgrade authority
cluster_pubkey: Pubkey // Arcium cluster PDA to use
cluster_type: ClusterType // Cerberus (devnet) | Manticore (mainnet)
cluster_offset: u64 // Arcium cluster offset (devnet: 456)
wrapped_usdc_mint: Pubkey // Token-2022 wrapped USDC mint
wrapping_vault: Pubkey // SPL USDC vault ATA owned by mint-authority PDA
paused: bool // Emergency pause flag
paused_at: i64 // Unix timestamp when paused (0 = not paused)
transfer_hook_program_id: Pubkey // Dynamic transfer hook program ID
bump: u8
version: u8
_reserved: [u8; 32] // PROTOCOL_CONFIG_RESERVED_BYTESSingleton 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. cluster_pubkey / cluster_type / cluster_offset pin the Arcium MXE cluster the protocol trusts.
KeeperConfig
Account: KeeperConfig
Seeds: ["keeper-config"]
Size: 237 bytes
Version: 1
Fields:
admin: Pubkey // Authority to update config
mode: KeeperMode // Centralized | TukTuk
keeper_endpoint: [u8; 128] // URL (centralized) or task queue pubkey (TukTuk)
endpoint_len: u8 // Actual length of keeper_endpoint bytes
keeper_authority: Pubkey // Pubkey authorized to execute pulls
bump: u8
version: u8
_reserved: [u8; 64] // ACCOUNT_RESERVED_BYTESSingleton account. The keeper authority is the only entity that can trigger execute_pull and (alongside the merchant) execute_stream.
Plan (VelaPlan)
Account: VelaPlan
Seeds: ["plan", merchant, plan_id]
Version: tracked via account
Fields:
merchant: Pubkey // Plan creator/owner
plan_id: u64 // Sequential plan number for merchant
amount: u64 // Billing amount (in token units)
frequency: u64 // Billing frequency in SECONDS (not an enum)
mint: Pubkey // Payment token mint
active: bool // Plan active/inactive
metadata_uri: String // IPFS URI for plan metadata
// (additional fields + bump + version + _reserved: [u8; 32] PLAN_RESERVED_BYTES)
BillingType (on Mandate, not Plan):
Flat | UsagePlans are templates. Multiple subscribers can subscribe to the same plan. Plans can be deactivated (no new subscriptions) without affecting existing mandates.
Frequency is stored as u64 seconds, not an enum. A 30-day monthly plan is 2_592_000. Minimum enforced frequency is 3600 seconds (FrequencyTooLow, error 5).
Mandate (VelaMandate, v3 — Plan-Independent v2 seeds)
Account: VelaMandate
Seeds (v2): ["mandate", subscriber, merchant, mandate_index]
Seeds (legacy v1, still accepted): ["mandate", subscriber, plan]
Size: 268 bytes
Version: 3
Fields (in on-chain order):
subscriber: Pubkey
plan: Pubkey // Current plan (can be swapped via pending_new_plan)
merchant: Pubkey
amount: u64 // Current billing amount
frequency: u64 // Current billing frequency (seconds)
start_date: i64
expiry: i64 // Expiration timestamp (0 = never)
max_pulls: u64
pulls_executed: u64
next_payment_due: i64
last_pull_at: i64
last_billing_recorded_pull: u64 // For usage-plan billing reconciliation
validation_request_nonce: u64 // Anti-replay for Arcium validation requests
billing_request_nonce: u64 // Anti-replay for Arcium billing callbacks
status: MandateStatus
bump: u8
billing_type: BillingType // Flat (0) | Usage (1)
mandate_index: u64 // v2 seed component
version: u8
credit_balance: u64
pending_new_plan: Pubkey // Target plan for pending change
pending_effective_at: i64
pending_change_type: u8 // 0 = no pending change
pending_nonce_short: [u8; 8]
_reserved_v3: [u8; 7]
MandateStatus:
Active | Cancelled | Expired // No Paused variantThe v1.7 refactor made mandates plan-independent at the seed level (v2 seeds drop the plan pubkey). Both v2 and legacy v1 seeds validate in validate_loaded_mandate_address. The v3 upgrade added the four pending_* fields for inline plan changes — the _reserved_v3 trailing buffer is what remains of the reserved pool after that expansion.
StreamMandate
Account: StreamMandate
Seeds: ["stream", subscriber, merchant, mandate_index]
Size: 225 bytes
Version: 2
Fields (in on-chain order):
version: u8
subscriber: Pubkey
merchant: Pubkey
mint: Pubkey
rate_per_second: u64
authorized_max_rate: u64 // Upper bound on rate (e.g. after rate change)
last_settled_ts: i64
total_streamed: u64
max_streamed: Option<u64> // Lifetime cap (None = unlimited)
paused_at: Option<i64>
min_settle_interval: u32 // Seconds; must be ≥ 60 (D-09)
status: StreamStatus
mandate_index: u64
bump: u8
pending_new_rate_per_second: u64
pending_new_authorized_max_rate: u64
pending_effective_at: i64
pending_change_type: u8 // 0 = no pending change
pending_nonce_short: [u8; 8]
_reserved_v2: [u8; 23]
StreamStatus:
Active | Paused | Cancelled // No Exhausted variantStreaming mandates track a per-second rate and total streamed amount. The pending_* fields (v2) support rate transitions without interrupting the stream. Settlement can only be triggered by the keeper authority or the merchant — not permissionlessly (UnauthorizedStreamSigner, error 6710).
AgentMandate
Account: AgentMandate
Seeds: ["agent-mandate", authority, agent] // authority FIRST
Size: base 190 bytes + (4 + MAX_SERVICES * 56) bytes for services Vec
Version: tracked via account
Fields (in on-chain order):
authority: Pubkey // Delegating authority (human)
agent: Pubkey // Agent wallet (AI bot, service)
daily_limit: u64 // Aggregate daily cap across all services
daily_spent: u64
daily_last_reset: i64 // Rolling window anchor (NOT a calendar day)
lifetime_cap: u64 // Hard lifetime ceiling
total_spent: u64
min_pull_amount: u64 // Minimum per-pull amount (dust control)
min_pull_interval: i64 // Cooldown between pulls (seconds)
last_pull_at: i64
status: AgentMandateStatus
services: Vec<ServiceLimit> // Per-service entries with their own daily limits
bump: u8
version: u8
_reserved: [u8; 64] // ACCOUNT_RESERVED_BYTES
ServiceLimit:
service: Pubkey // Destination service pubkey
daily_limit: u64
daily_spent: u64
last_reset: i64
AgentMandateStatus:
Active | Paused | Revoked // No Exhausted variantAgent mandates delegate bounded spending authority. The daily_limit is an aggregate across all services; each ServiceLimit entry carries its own per-service daily_limit. Both use a rolling 24h window anchored on daily_last_reset (and last_reset per service), not wall-clock days. The lifetime_cap is a hard ceiling.
Funds are held in a mandate-owned wrapped USDC ATA (token::authority = agent_mandate), separate from the authority's wallet. This is a vault-per-agent-mandate pattern.
PullApproval
Account: PullApproval
Seeds: ["approval", mandate] // Singleton per mandate — no epoch
Size: 66 bytes
Version: none (ephemeral, no version or _reserved)
Fields:
mandate: Pubkey // Mandate being approved
valid_until: i64 // Set to mandate.next_payment_due by the callback
approved: bool // True iff Arcium validated successfully
approved_amount: u64 // Maximum transfer amount the hook should allow
created_at: i64 // Timestamp of callback
bump: u8Ephemeral account written by validate_mandate_callback (Arcium) and closed by execute_pull after a successful transfer (lamports refund to the keeper payer). One approval exists per mandate at any given time. Replay protection is enforced by account closure plus the valid_until bound — there is no epoch seed.
Credential Mints
Credentials are Token-2022 mints (soulbound via the Non-Transferable extension), not custom PDA data accounts. There is no Credential data account with plan_tier / minted_at fields — credential metadata lives on the mint's Metadata Pointer extension.
Two flavours of credential mint coexist:
| Scope | Mint seeds | Created by | Purpose |
|---|---|---|---|
| Per-merchant | ["merchant-credential", merchant] | init_merchant_credential | One mint per merchant, survives plan changes |
| Per-plan | ["credential", merchant, plan_id] | create_plan | One mint per plan; minted 1 unit to each subscriber of that plan |
The merchant-credential mint is the v1.7 canonical pattern and is what the mandate's pending_new_plan flow plays nicely with (because the credential pubkey doesn't change when the plan does).
TokenConfig
Account: TokenConfig
Seeds: ["token_config", mint] // Underscore, not hyphen
Size: 213 bytes
Version: 1
Fields:
mint: Pubkey // Token mint address
token_program: Pubkey // SPL Token or Token-2022 program
billing_rail: BillingRail // TransferHook | TokenDelegate
decimals: u8 // Must match on-chain decimals
enabled: bool
oracle_reference: Pubkey // Price oracle reference (default until v1.8)
admin: Pubkey // Admin who registered
created_at: i64
bump: u8
version: u8
_reserved: [u8; 64] // ACCOUNT_RESERVED_BYTES
BillingRail:
TransferHook // Wrapped tokens via Token-2022 transfer hook CPI chain
TokenDelegate // Native SPL tokens via approve/transfer delegation (v1.8+)Per-mint configuration. init_token_config asserts on-chain decimals match registered decimals (TokenConfigDecimalsMismatch, error 6713).
Wrapping Vault and Wrapped Mint
Not a single #[account] type, but two protocol-owned artifacts that the entire billing flow depends on:
| Artifact | What it is | Pubkey location |
|---|---|---|
wrapping_vault | SPL USDC associated token account owned by the mint-authority PDA. Holds the SPL USDC backing all wrapped USDC 1:1. | ProtocolConfig.wrapping_vault |
| Wrapped USDC mint | Token-2022 mint with Transfer Hook + Permanent Delegate + Metadata Pointer extensions. The hook enforces billing constraints on every transfer. | ProtocolConfig.wrapped_usdc_mint |
mint-authority PDA | Seeds ["mint-authority"]. Owns the wrapping vault, mints/burns wrapped USDC, and is the Permanent Delegate on the wrapped mint. | Derived at runtime |
Flow:
wrap(amount, destination_authority)— moves SPL USDC intowrapping_vault, mints wrapped USDC to a Token-2022 account whose owner isdestination_authority(subscriber wallet OR a mandate PDA, caller's choice)unwrap(amount)— burns wrapped USDC from caller's account, releases SPL USDC fromwrapping_vault. Caller must be the wrapped account's token authority. No mandate-state gating — unwrap is unilateral for self-owned accounts.
All billing transfers (periodic pull, stream settle, agent pull) move wrapped USDC between wrapped accounts — never native SPL USDC.
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_type TEXT NOT NULL, -- 'flat' | 'usage' (mirrors on-chain BillingType)
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 | Size (bytes, exact) | Notes |
|---|---|---|
| ProtocolConfig | 220 | Includes 32-byte _reserved |
| KeeperConfig | 237 | Includes 64-byte _reserved + 128-byte endpoint buffer |
| VelaPlan | ~280 | Exact value depends on metadata_uri length |
| VelaMandate (v3) | 268 | Includes 7-byte _reserved_v3 |
| StreamMandate (v2) | 225 | Includes 23-byte _reserved_v2 |
| AgentMandate | 190 base + 4 + (MAX_SERVICES × 56) | ServiceLimit entries are 56 bytes each |
| PullApproval | 66 | No version field, no reserved space |
| TokenConfig | 213 | Includes 64-byte _reserved |
| Credential mint (per merchant or per plan) | Token-2022 mint with extensions | Size determined by Token-2022 (not a Vela data account) |
Cost per Subscription
Creating a new subscription (subscribe + mandate + credential token account) requires at minimum:
- Mandate PDA rent (268 bytes)
- Credential token account rent (minted from the existing merchant/plan credential mint)
- Optional fresh credential mint rent if the merchant hasn't initialised one yet
Actual SOL cost is a function of current rent rates — rely on Rent::minimum_balance(size) at runtime rather than a hardcoded figure.
Migration History
| Version | Change | Milestone |
|---|---|---|
| Mandate v1 → v2 | Plan-independent v2 seeds (legacy v1 seeds still accepted) | v1.7 |
| Mandate v2 → v3 | Inline plan change fields (pending_new_plan, pending_effective_at, pending_change_type, pending_nonce_short); reserve shrank to 7 bytes | v1.7 |
| StreamMandate v1 → v2 | Pending rate-change fields (pending_new_rate_per_second, pending_new_authorized_max_rate, etc.); reserve shrank to 23 bytes | v1.8 |
| Credentials | Switched from strictly per-plan mints to per-merchant mints (both scopes coexist) | v1.7 |
| Plan v1 → v2 | Mutable metadata fields | v1.5 |
All migrations are additive — new fields are appended, existing fields are unchanged. The version field enables forward-compatible deserialization.