Appearance
Billing Flows
Detailed walkthrough of Vela Protocol billing modes and the separate Commerce OS one-time payment rail.
Overview
Vela Protocol supports four billing modes, each with distinct on-chain semantics, enforcement mechanisms, and lifecycle management. All modes share the same foundational security model: the transfer hook enforces that every token movement from subscriber to merchant has a valid authorization.
All billing transfers operate on wrapped USDC (Token-2022), not native SPL USDC. See Custody and Wrapping Model in protocol-design.md for details on how subscribers wrap SPL USDC into a Token-2022 receipt that carries the transfer hook.
Commerce OS is a separate product rail. It accepts public SPL or Token-2022 one-time transfers on devnet and does not create or exercise a mandate. Vela Protocol itself remains Token-2022-only.
| Mode | Origin | Enforcement | Settlement | Use Case |
|---|---|---|---|---|
| Periodic Pull | v1.0 | PullApproval PDA (via Arcium) | Keeper-triggered on schedule | Monthly subscriptions |
| Streaming | v1.8 | StreamMandate branch in hook | Keeper or merchant settle | Per-second billing, rentals |
| Usage-Based | v1.1 | Usage counter + Arcium charge computation | PullApproval path after usage report | Metered API calls |
| Agent Mandate | v1.4 | Daily/lifetime + per-service limits in agent_pull.rs; PullApproval at the token layer | Agent-initiated pulls | AI agent spending, API budgets |
| One-time Commerce | v1.9 | Server-side Payment Intent plus strict RPC verification | Customer wallet transfer | Orders and standalone invoices |
One-time Commerce OS (v1.9)
The merchant creates a direct or invoice-backed Payment Intent and sends the hosted /pay/:id URL. Checkout builds a Solana Pay request with a unique public-key reference. The customer broadcasts a transfer, and the dashboard verifies the confirmed transaction's reference, mint, program, gross amount, recipient, settlement owner, and success before deriving the payer.
Confirmation batches the payment, invoice, customer wallet, receipt, audit record, and webhook outbox. Refunds follow the reverse path: VelaPay builds an unsigned transaction, the merchant settlement wallet signs it, and the dashboard verifies it before producing the refund receipt and credit note.
See Commerce OS Architecture for state machines and security invariants.
1. Periodic Pull Billing (v1.0)
Periodic pull is the foundational billing mode. The subscriber authorizes a mandate that allows the merchant (via keeper) to pull a fixed amount on a recurring schedule. Arcium encrypted compute is the active production validation layer — there is no non-Arcium code path.
Flow
┌─────────────┐ ┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ Subscriber │ │ Keeper │ │ Arcium │ │ Transfer │
│ │ │ │ │ MXE │ │ Hook │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │ │
│ subscribe(merchant, plan) │ │
│─────────────────────┐ │ │
│ │ Mandate PDA │ │
│ │ created │ │
│ │ Credential mint │ │
│ │ token issued │ │
│◄────────────────────┘ │ │
│ │ │ │
│ │ request_validation(mandate) │
│ │───────────────────►│ │
│ │ │ │
│ │ │ MPC validates │
│ │ │ encrypted inputs: │
│ │ │ - mandate amount │
│ │ │ - balance check │
│ │ │ - frequency check │
│ │ │ │
│ │ validate_mandate_callback() │
│ │◄───────────────────┤ │
│ │ PullApproval PDA │ │
│ │ written │ │
│ │ {approved_amount, │ │
│ │ valid_until, │ │
│ │ approved=true} │ │
│ │ │ │
│ │ execute_pull() │ │
│ │ transfer_checked(wUSDC, sub → merch, │
│ │ authority = mandate PDA) │
│ │────────────────────────────────────────►│
│ │ │ │
│ │ │ Read PullApproval │
│ │ │ amount ≤ approved │
│ │ │ valid_until > now │
│ │ │ │
│ │ Transfer succeeds or fails closed │
│ │ PullApproval PDA closed │
│ │ (lamports → keeper) │Key Properties
| Property | Value | Rationale |
|---|---|---|
| Authorization scope | Per-period approved amount from Arcium | Hook caps the transfer at the approved amount |
| Enforcement | Transfer hook validates PullApproval | No trust in keeper — hook enforces at the token layer |
| Frequency | Configurable (u64 seconds — min 3600) | Keeper triggers on schedule |
| Failure mode | Fail closed | If anything is wrong, transfer fails |
| Replay protection | PullApproval is a singleton per mandate and is closed after successful pull; valid_until bounds unused approvals to the current billing period | Closed account cannot be reused; stale approvals naturally expire |
| Privacy | Arcium MXE validates encrypted inputs via MPC | Amount and balance hidden from validators, RPC, and chain observers |
PullApproval Lifecycle
1. Keeper calls request_validation(mandate) → Arcium MXE queues computation
2. validate_mandate_callback() writes PullApproval PDA
- seeds: ["approval", mandate] (no epoch — singleton per mandate)
- fields: {mandate, valid_until = mandate.next_payment_due,
approved = true, approved_amount, created_at, bump}
3. execute_pull() → transfer_checked() → hook reads PullApproval
4. Hook validates approved == true AND valid_until > now AND amount ≤ approved_amount
5. Transfer succeeds → execute_pull closes PullApproval
(drains lamports to keeper, account no longer exists)
OR Transfer fails → PullApproval remains until valid_until elapses;
the next request_validation re-uses the same PDA slotPullApprovals are ephemeral and singleton per mandate. Replay protection comes from account closure after a successful pull plus the valid_until window — not from an epoch seed.
2. Streaming Payments (v1.8)
Streaming enables per-second billing. Unlike periodic pulls which settle at fixed intervals, streaming allows settlement at any time based on elapsed time × rate.
Flow
┌─────────────┐ ┌─────────────┐ ┌──────────────────┐ ┌──────────────┐
│ Subscriber │ │ Merchant │ │ Keeper OR Merch │ │ Transfer │
│ │ │ │ │ (execute_stream)│ │ Hook │
└──────┬───────┘ └──────┬───────┘ └──────┬───────────┘ └──────┬───────┘
│ │ │ │
│ authorize_stream(merchant, rate, cap) │ │
│─────────────────────┐ │ │
│ │ StreamMandate PDA │ │
│ │ created │ │
│ │ rate_per_second │ │
│ │ max_streamed (cap)│ │
│ │ last_settled_ts=now│ │
│ │ total_streamed=0 │ │
│ │ │ │
│ │ execute_stream() │ │
│ │◄───────────────────┤ │
│ │ (caller must be │ │
│ │ keeper OR merchant; │
│ │ else UnauthorizedStreamSigner) │
│ │ │ │
│ │ Calculate amount: │ │
│ │ elapsed = now - │ │
│ │ last_settled_ts │ │
│ │ accrued = elapsed │ │
│ │ × rate_per_second │
│ │ amount = min( │ │
│ │ accrued, │ │
│ │ max_streamed - │ │
│ │ total_streamed) │ │
│ │ │ │
│ │ transfer_checked(wUSDC, sub → merch) │
│ │────────────────────────────────────────────►│
│ │ │ │
│ │ │ Hook: owner is │
│ │ │ StreamMandate │
│ │ │ (discriminator check)│
│ │ │ → streaming branch: │
│ │ │ - elapsed ≥ min_ │
│ │ │ settle_interval │
│ │ │ - amount ≤ accrued │
│ │ │ - cap not exceeded │
│ │ │ │
│ │ Transfer succeeds │ │
│ │ total_streamed += amount │
│ │ last_settled_ts = now │Key Properties
| Property | Value | Rationale |
|---|---|---|
| Settlement model | Pull-based, not continuous accrual | No on-chain clock dependency |
| Rate unit | Per-second (rate_per_second: u64) | Fine-grained, SDK derives human-friendly display |
| Cap | Optional lifetime maximum (max_streamed: Option<u64>) | Prevents unlimited drainage |
| Minimum settle interval | Stored as min_settle_interval: u32 seconds; must be ≥ 60 (D-09) | Prevents spam settlements |
| Who can settle | Keeper authority OR merchant (enforced in execute_stream) | Settlement is NOT permissionless |
| Real-time UX | SDK accruedNow() derives client-side | No on-chain query needed for display |
| Failure mode | Fail closed on insufficient balance or cap breach | No silent overdraft |
Settle-Then-Mutate Invariant
Every settlement follows the settle-then-mutate pattern:
- Calculate amount from rate × elapsed
- Transfer tokens via
transfer_checked+ hook - Update
total_streamed,last_settled_tsonly after transfer succeeds
This invariant ensures:
- State is only updated after confirmed transfer
- Failed transfers don't corrupt stream state
- Re-running settlement after a failed attempt produces the same result
Pending Rate Change (v1.8)
StreamMandate v2 supports pending rate changes. The change is persisted across four fields (rather than a single Option<RateChange>) to fit the existing reserved-space layout:
Merchant → request_stream_rate_change(stream_mandate, new_rate)
→ StreamMandate.pending_new_rate_per_second = new_rate
→ StreamMandate.pending_new_authorized_max_rate = new_max
→ StreamMandate.pending_effective_at = timestamp
→ StreamMandate.pending_change_type = nonzero
→ StreamMandate.pending_nonce_short = <8-byte nonce>
At next settlement after pending_effective_at:
- Old rate applied up to pending_effective_at
- New rate applied from pending_effective_at
- clear_pending_rate_change() resets all pending_* fieldsThis allows rate changes without interrupting the stream or creating settlement gaps.
3. Usage-Based Billing (v1.1)
Usage-based billing charges subscribers based on actual consumption. The merchant reports usage, Arcium computes the charge on encrypted data, and the standard PullApproval path executes the resulting transfer. Usage is a billing type on the existing mandate (billing_type: BillingType::Usage) rather than a separate mandate account.
Flow
┌─────────────┐ ┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ Subscriber │ │ Merchant │ │ Arcium │ │ Keeper │
│ │ │ │ │ (Encrypted) │ │ │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │ │
│ subscribe(metered │ │ │
│ plan) │ │ │
│─────────────────────┐ │ │
│ │ Mandate with │ │
│ │ billing_type= │ │
│ │ Usage │ │
│ │ │ │
│ │ submit_usage( │ │
│ │ mandate, │ │
│ │ encrypted_usage)│ │
│ │───────────────────►│ │
│ │ │ │
│ │ │ Compute charge │
│ │ │ from encrypted │
│ │ │ usage + plan rate │
│ │ │ │
│ │ callback() │ │
│ │◄───────────────────┤ │
│ │ Usage counter │ │
│ │ updated in │ │
│ │ mandate PDA │ │
│ │ │ │
│ │ │ execute_pull() │
│ │ │───────────────────►│
│ │ │ │
│ │ │ Hook validates │
│ │ │ against usage │
│ │ │ counter │Key Properties
| Property | Value | Rationale |
|---|---|---|
| Privacy | Usage encrypted via Arcium | Merchant can't see subscriber's total usage |
| Enforcement | Usage counter gates transfers | Can't pull more than usage warrants |
| Metering | Per-unit billing with rate table | Supports tiered pricing |
| Reporting | Merchant submits encrypted usage | Subscriber can't be overcharged |
Usage Counter Enforcement
The mandate PDA includes a usage counter that tracks total billed usage. The transfer hook gates transfers against this counter:
- Every pull must reference a valid usage record
- The hook validates that the pull amount corresponds to the reported usage × plan rate
- Usage is encrypted — only Arcium can compute the actual charge
- The subscriber can verify the charge is within expected bounds
4. Agent Mandates (v1.4)
Agent mandates allow an authority to delegate spending authority to an AI agent or automated service with bounded constraints.
Flow
┌─────────────┐ ┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ Authority │ │ Agent │ │ Service │ │ Transfer │
│ (Human) │ │ (AI/Bot) │ │ (Target) │ │ Hook │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │ │
│ create_agent_mandate( │
│ agent, │
│ daily_limit, │
│ lifetime_cap, │
│ services: Vec<ServiceLimit>, │
│ min_pull_amount, │
│ min_pull_interval) │
│─────────────────────┐ │
│ │ AgentMandate PDA │
│ │ created │
│ │ Mandate-owned wUSDC ATA funded │
│ │ │
│ │ agent_pull(mandate, amount, service) │
│ │─────────────────────┐ │
│ │ agent_pull.rs validates: │
│ │ - mandate Active │
│ │ - signer == agent │
│ │ - amount ≥ min_pull_amount │
│ │ - now - last_pull_at ≥ min_pull_interval│
│ │ - reset_daily_if_needed() │
│ │ - daily_spent + amt ≤ daily_limit │
│ │ - total_spent + amt ≤ lifetime_cap │
│ │ - service is in services; per-service │
│ │ daily_limit also checked │
│ │ Creates PullApproval for this mandate │
│ │ │
│ │ transfer_checked(wUSDC, │
│ │ mandate_wrapped_account → service) │
│ │────────────────────────────────────────►│
│ │ │ │
│ │ │ Hook: owner is │
│ │ │ AgentMandate → │
│ │ │ falls through to │
│ │ │ PullApproval path│
│ │ │ (same as periodic)│
│ │ │ │
│ │ Transfer succeeds │
│ │ daily_spent, total_spent, │
│ │ per-service daily_spent updated │
│ │ last_pull_at = now │Key Properties
| Property | Value | Rationale |
|---|---|---|
daily_limit | Per-rolling-window aggregate spending cap | Prevents runaway agent spending |
lifetime_cap | Lifetime spending cap (total_spent tracked against it) | Hard ceiling on delegation |
services: Vec<ServiceLimit> | Per-service entries, each with its own daily_limit, daily_spent, last_reset | Agents are constrained per counterparty, not just globally |
min_pull_amount / min_pull_interval | Dust + cooldown controls | Prevents per-tx and rate abuse |
| Daily reset | Rolling 24h window (AGENT_DAILY_RESET_WINDOW_SECONDS) via reset_daily_if_needed | Based on daily_last_reset, not wall-clock days |
| Fund source | Mandate-owned wUSDC ATA (token::authority = agent_mandate) | Vault-per-mandate: pre-funded budget, not direct from authority's wallet |
Daily Reset Logic
if now.saturating_sub(mandate.daily_last_reset) >= AGENT_DAILY_RESET_WINDOW_SECONDS:
mandate.daily_spent = 0
mandate.daily_last_reset = now
// Same rolling-window rule applies per-service via reset_service_daily_if_needed.Ephemeral PullApproval Reuse
Agent pulls reuse the same PullApproval infrastructure as periodic pulls:
agent_pullvalidates the agent-specific rules inline (limits, cooldown, services), then creates a one-shot PullApproval for the mandate- The transfer hook validates the approval identically to periodic pulls — no dedicated agent branch exists in the hook
- The approval is consumed by the transfer (account closed afterwards)
- Zero transfer hook changes required — the hook doesn't distinguish between periodic and agent approvals
This design means adding agent mandates didn't require modifying the transfer hook — only the main protocol needed the new instruction.
Cross-Mode Compatibility
All four billing modes share the same enforcement infrastructure:
| Shared Component | Used By |
|---|---|
| Transfer hook (vela-transfer-hook) | All modes |
| PullApproval PDA pattern | Periodic pulls, Agent mandates |
| Mandate lifecycle (create → active → cancel) | All modes |
| Credential NFT (soulbound) | Periodic, Streaming, Usage |
| TokenConfig registry | All modes (multi-token support) |
| Keeper infrastructure | Periodic, Usage |
The transfer hook doesn't need to know the billing mode — it validates the authorization (PullApproval or stream parameters) and enforces the constraints. This separation of concerns means new billing modes can be added to the main protocol without modifying the hook.
Adding a New Billing Mode
To add a new billing mode:
- Define the new mandate type (e.g.,
TieredMandate) in vela-protocol - Define the settlement instruction in vela-protocol
- Add the validation branch in vela-transfer-hook's
execute()function - Register the new mandate PDA seeds in the ExtraAccountMetaList
The hook modification is minimal — just a new match arm in the validation logic. The core enforcement infrastructure (PullApproval, fail-closed, amount checking) is reused.