Skip to content

Billing Flows

Detailed walkthrough of all billing modes supported by Vela Protocol: periodic pulls, streaming payments, usage-based billing, and agent mandates.


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.

ModeOriginEnforcementSettlementUse Case
Periodic Pullv1.0PullApproval PDAKeeper-triggered on scheduleMonthly subscriptions
Streamingv1.8StreamMandate + hook streaming branchPull-based settlePer-second billing, rentals
Usage-Basedv1.1Usage counter in mandateArcium-computed chargesMetered API calls
Agent Mandatev1.4Daily limit + total capAgent-initiated pullsAI agent spending, API budgets

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.

Phase 0 Flow (Without Arcium)

┌─────────────┐     ┌─────────────┐     ┌──────────────┐     ┌──────────────┐
│  Subscriber  │     │   Merchant   │     │    Keeper     │     │  Transfer    │
│              │     │              │     │   (Automated) │     │    Hook      │
└──────┬───────┘     └──────┬───────┘     └──────┬───────┘     └──────┬───────┘
       │                     │                    │                    │
       │  subscribe(merchant, plan)              │                    │
       │─────────────────────┐                   │                    │
       │                     │ Mandate PDA       │                    │
       │                     │ created           │                    │
       │                     │ Credential NFT    │                    │
       │                     │ minted            │                    │
       │◄────────────────────┘                   │                    │
       │  Subscription Active                    │                    │
       │                     │                    │                    │
       │                     │  execute_pull(mandate, epoch)          │
       │                     │◄───────────────────┤                    │
       │                     │                    │                    │
       │                     │  transfer_checked()│                    │
       │                     │────────────────────────────────────────►│
       │                     │                    │                    │
       │                     │                    │  Validate locally: │
       │                     │                    │  - mandate active? │
       │                     │                    │  - balance > amount│
       │                     │                    │  - amount <= limit │
       │                     │                    │                    │
       │                     │  Transfer succeeds or fails closed     │
       │◄────────────────────────────────────────────────────────────┤

Phase 1+ Flow (With Arcium)

┌─────────────┐     ┌─────────────┐     ┌──────────────┐     ┌──────────────┐
│  Subscriber  │     │   Keeper     │     │    Arcium     │     │  Transfer    │
│              │     │              │     │  (Encrypted)  │     │    Hook      │
└──────┬───────┘     └──────┬───────┘     └──────┬───────┘     └──────┬───────┘
       │                     │                    │                    │
       │  subscribe()        │                    │                    │
       │─────────────────────┐                   │                    │
       │  (same as Phase 0)  │                   │                    │
       │                     │                    │                    │
       │                     │  request_validation(mandate, epoch)    │
       │                     │───────────────────►│                    │
       │                     │                    │                    │
       │                     │                    │  Validate encrypted│
       │                     │                    │  inputs:           │
       │                     │                    │  - mandate amount  │
       │                     │                    │  - balance check   │
       │                     │                    │  - frequency check │
       │                     │                    │                    │
       │                     │  callback()        │                    │
       │                     │◄───────────────────┤                    │
       │                     │  PullApproval PDA  │                    │
       │                     │  written           │                    │
       │                     │                    │                    │
       │                     │  execute_pull()    │                    │
       │                     │────────────────────────────────────────►│
       │                     │                    │                    │
       │                     │                    │  Read PullApproval │
       │                     │                    │  amount ≤ approved │
       │                     │                    │  approval valid?   │
       │                     │                    │  mandate active?   │
       │                     │                    │                    │
       │                     │  Transfer succeeds or fails closed     │

Key Properties

PropertyValueRationale
Authorization scopeFixed amount per periodMerchant can't pull more than agreed
EnforcementTransfer hook validates PullApprovalNo trust in keeper — hook enforces
FrequencyConfigurable (daily, weekly, monthly)Keeper triggers on schedule
Failure modeFail closedIf anything is wrong, transfer fails
Replay protectionEpoch-scoped approvalsApproval for epoch N invalid in N+1
Privacy (Phase 1)Arcium validates encrypted inputsAmount and balance hidden from observers

PullApproval Lifecycle

1. Keeper requests validation → Arcium processes
2. callback() writes PullApproval PDA
   - seeds: ["approval", mandate, epoch]
   - fields: {approved_amount, valid_until}
3. execute_pull() → transfer_checked() → hook reads PullApproval
4. Hook validates amount ≤ approved_amount AND valid_until > now
5. Transfer succeeds → PullApproval consumed (cannot be replayed)
   OR Transfer fails → PullApproval expires at valid_until

PullApprovals are ephemeral. They're created for a specific epoch, used once (or expire), and never reused. The epoch seed ensures uniqueness across billing periods.


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   │     │   Settler     │     │  Transfer    │
│              │     │              │     │ (Any party)   │     │    Hook      │
└──────┬───────┘     └──────┬───────┘     └──────┬───────┘     └──────┬───────┘
       │                     │                    │                    │
       │  authorize_stream(merchant, rate, cap)   │                    │
       │─────────────────────┐                   │                    │
       │                     │ StreamMandate PDA │                    │
       │                     │ created           │                    │
       │                     │ rate_per_second   │                    │
       │                     │ max_streamed (cap)│                    │
       │                     │ last_settled_ts=now│                   │
       │                     │ total_streamed=0   │                   │
       │                     │                    │                    │
       │                     │  settle()          │                    │
       │                     │◄───────────────────┤                    │
       │                     │                    │                    │
       │                     │  Calculate amount: │                    │
       │                     │  elapsed = now -   │                    │
       │                     │    last_settled_ts │                    │
       │                     │  amount = elapsed  │                    │
       │                     │    × rate_per_second│                   │
       │                     │  amount = min(     │                    │
       │                     │    amount,         │                    │
       │                     │    max_streamed -  │                    │
       │                     │    total_streamed) │                    │
       │                     │                    │                    │
       │                     │  settle_stream()   │                    │
       │                     │────────────────────────────────────────►│
       │                     │                    │                    │
       │                     │                    │  Hook streaming    │
       │                     │                    │  branch validates: │
       │                     │                    │  - rate × elapsed  │
       │                     │                    │  - cap not exceeded│
       │                     │                    │  - min_interval    │
       │                     │                    │                    │
       │                     │  Transfer succeeds │                    │
       │                     │  total_streamed += │                    │
       │                     │    amount          │                    │
       │                     │  last_settled_ts = │                    │
       │                     │    now             │                    │

Key Properties

PropertyValueRationale
Settlement modelPull-based, not continuous accrualNo on-chain clock dependency
Rate unitPer-secondFine-grained, SDK derives human-friendly display
CapOptional lifetime maximumPrevents unlimited drainage
Minimum settle interval60 seconds (configurable)Prevents spam settlements
Real-time UXSDK accruedNow() derives client-sideNo on-chain query needed for display
Failure modeFail closed on insufficient balanceNo silent overdraft

Settle-Then-Mutate Invariant

Every settlement follows the settle-then-mutate pattern:

  1. Calculate amount from rate × elapsed
  2. Transfer tokens via transfer_checked + hook
  3. Update total_streamed, last_settled_ts only 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:

Merchant → request_rate_change(stream_mandate, new_rate)
→ StreamMandate.pending_rate_change = Some({new_rate, effective_at})
→ At next settlement after effective_at:
  - Old rate applied up to effective_at
  - New rate applied from effective_at
  - pending_rate_change cleared

This 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, and Arcium computes the charge on encrypted data.

Flow

┌─────────────┐     ┌─────────────┐     ┌──────────────┐     ┌──────────────┐
│  Subscriber  │     │   Merchant   │     │    Arcium     │     │  Keeper      │
│              │     │              │     │  (Encrypted)  │     │              │
└──────┬───────┘     └──────┬───────┘     └──────┬───────┘     └──────┬───────┘
       │                     │                    │                    │
       │  subscribe(metered  │                    │                    │
       │  plan)              │                    │                    │
       │─────────────────────┐                   │                    │
       │                     │ Mandate with       │                    │
       │                     │ billing_mode=      │                    │
       │                     │ UsageBased         │                    │
       │                     │                    │                    │
       │                     │  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

PropertyValueRationale
PrivacyUsage encrypted via ArciumMerchant can't see subscriber's total usage
EnforcementUsage counter gates transfersCan't pull more than usage warrants
MeteringPer-unit billing with rate tableSupports tiered pricing
ReportingMerchant submits encrypted usageSubscriber 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,       │                    │                    │
       │    total_cap,         │                    │                    │
       │    authorized_services)│                   │                    │
       │─────────────────────┐  │                    │                    │
       │                     │  AgentMandate PDA │                    │
       │                     │  created          │                    │
       │                     │                    │                    │
       │                     │  agent_pull(       │                    │
       │                     │    mandate,        │                    │
       │                     │    amount,         │                    │
       │                     │    service_id)     │                    │
       │                     │────────────────────────────────────────►│
       │                     │                    │                    │
       │                     │                    │  Hook validates:   │
       │                     │                    │  - daily_spent +   │
       │                     │                    │    amount ≤ limit  │
       │                     │                    │  - total_spent +   │
       │                     │                    │    amount ≤ cap    │
       │                     │                    │  - service in      │
       │                     │                    │    authorized list │
       │                     │                    │                    │
       │                     │  Transfer succeeds │                    │
       │                     │  daily_spent +=    │                    │
       │                     │    amount          │                    │
       │                     │  total_spent +=    │                    │
       │                     │    amount          │                    │

Key Properties

PropertyValueRationale
Daily limitPer-24h spending capPrevents runaway agent spending
Total capLifetime spending capHard ceiling on delegation
Authorized servicesWhitelist of service IDsAgent can only spend on approved services
Daily resetAfter 24h periodBased on mandate creation timestamp
CU profiling gateRequired at mandate creationPrevents pathological compute patterns

Daily Limit Reset Logic

current_period_start = mandate.created_at + (periods_elapsed × 24h)
periods_elapsed = floor((now - mandate.created_at) / 86400)

if now > current_period_start + 86400:
    mandate.daily_spent = 0  // Reset for new period

Ephemeral PullApproval Reuse

Agent mandates reuse the same PullApproval infrastructure as periodic pulls:

  • agent_pull() creates an ephemeral PullApproval PDA
  • The transfer hook validates the approval identically to periodic pulls
  • The approval is consumed by the transfer (one-time use)
  • 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.

CU Profiling Gate

Before an agent mandate can be created, the system profiles the expected compute unit consumption:

  1. Simulate the agent's expected transaction pattern
  2. Measure CU consumption per transaction
  3. Verify the pattern won't exceed Solana's compute budget
  4. Reject mandate creation if the pattern could create CU exhaustion

This prevents a class of attacks where an agent's transaction pattern could consume excessive on-chain compute resources.


Cross-Mode Compatibility

All four billing modes share the same enforcement infrastructure:

Shared ComponentUsed By
Transfer hook (vela-transfer-hook)All modes
PullApproval PDA patternPeriodic pulls, Agent mandates
Mandate lifecycle (create → active → cancel)All modes
Credential NFT (soulbound)Periodic, Streaming, Usage
TokenConfig registryAll modes (multi-token support)
Keeper infrastructurePeriodic, 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:

  1. Define the new mandate type (e.g., TieredMandate) in vela-protocol
  2. Define the settlement instruction in vela-protocol
  3. Add the validation branch in vela-transfer-hook's execute() function
  4. 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.

Internal knowledge base for the Vela Labs workspace.