Appearance
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.
| Mode | Origin | Enforcement | Settlement | Use Case |
|---|---|---|---|---|
| Periodic Pull | v1.0 | PullApproval PDA | Keeper-triggered on schedule | Monthly subscriptions |
| Streaming | v1.8 | StreamMandate + hook streaming branch | Pull-based settle | Per-second billing, rentals |
| Usage-Based | v1.1 | Usage counter in mandate | Arcium-computed charges | Metered API calls |
| Agent Mandate | v1.4 | Daily limit + total cap | Agent-initiated pulls | AI 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
| Property | Value | Rationale |
|---|---|---|
| Authorization scope | Fixed amount per period | Merchant can't pull more than agreed |
| Enforcement | Transfer hook validates PullApproval | No trust in keeper — hook enforces |
| Frequency | Configurable (daily, weekly, monthly) | Keeper triggers on schedule |
| Failure mode | Fail closed | If anything is wrong, transfer fails |
| Replay protection | Epoch-scoped approvals | Approval for epoch N invalid in N+1 |
| Privacy (Phase 1) | Arcium validates encrypted inputs | Amount 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_untilPullApprovals 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
| Property | Value | Rationale |
|---|---|---|
| Settlement model | Pull-based, not continuous accrual | No on-chain clock dependency |
| Rate unit | Per-second | Fine-grained, SDK derives human-friendly display |
| Cap | Optional lifetime maximum | Prevents unlimited drainage |
| Minimum settle interval | 60 seconds (configurable) | Prevents spam settlements |
| Real-time UX | SDK accruedNow() derives client-side | No on-chain query needed for display |
| Failure mode | Fail closed on insufficient balance | 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:
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 clearedThis 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
| 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, │ │ │
│ 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
| Property | Value | Rationale |
|---|---|---|
| Daily limit | Per-24h spending cap | Prevents runaway agent spending |
| Total cap | Lifetime spending cap | Hard ceiling on delegation |
| Authorized services | Whitelist of service IDs | Agent can only spend on approved services |
| Daily reset | After 24h period | Based on mandate creation timestamp |
| CU profiling gate | Required at mandate creation | Prevents 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 periodEphemeral 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:
- Simulate the agent's expected transaction pattern
- Measure CU consumption per transaction
- Verify the pattern won't exceed Solana's compute budget
- 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 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.