Skip to content

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.

ModeOriginEnforcementSettlementUse Case
Periodic Pullv1.0PullApproval PDA (via Arcium)Keeper-triggered on scheduleMonthly subscriptions
Streamingv1.8StreamMandate branch in hookKeeper or merchant settlePer-second billing, rentals
Usage-Basedv1.1Usage counter + Arcium charge computationPullApproval path after usage reportMetered API calls
Agent Mandatev1.4Daily/lifetime + per-service limits in agent_pull.rs; PullApproval at the token layerAgent-initiated pullsAI agent spending, API budgets
One-time Commercev1.9Server-side Payment Intent plus strict RPC verificationCustomer wallet transferOrders 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

PropertyValueRationale
Authorization scopePer-period approved amount from ArciumHook caps the transfer at the approved amount
EnforcementTransfer hook validates PullApprovalNo trust in keeper — hook enforces at the token layer
FrequencyConfigurable (u64 seconds — min 3600)Keeper triggers on schedule
Failure modeFail closedIf anything is wrong, transfer fails
Replay protectionPullApproval is a singleton per mandate and is closed after successful pull; valid_until bounds unused approvals to the current billing periodClosed account cannot be reused; stale approvals naturally expire
PrivacyArcium MXE validates encrypted inputs via MPCAmount 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 slot

PullApprovals 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

PropertyValueRationale
Settlement modelPull-based, not continuous accrualNo on-chain clock dependency
Rate unitPer-second (rate_per_second: u64)Fine-grained, SDK derives human-friendly display
CapOptional lifetime maximum (max_streamed: Option<u64>)Prevents unlimited drainage
Minimum settle intervalStored as min_settle_interval: u32 seconds; must be ≥ 60 (D-09)Prevents spam settlements
Who can settleKeeper authority OR merchant (enforced in execute_stream)Settlement is NOT permissionless
Real-time UXSDK accruedNow() derives client-sideNo on-chain query needed for display
Failure modeFail closed on insufficient balance or cap breachNo 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. 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_* fields

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, 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

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,                                                │
       │    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

PropertyValueRationale
daily_limitPer-rolling-window aggregate spending capPrevents runaway agent spending
lifetime_capLifetime 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_resetAgents are constrained per counterparty, not just globally
min_pull_amount / min_pull_intervalDust + cooldown controlsPrevents per-tx and rate abuse
Daily resetRolling 24h window (AGENT_DAILY_RESET_WINDOW_SECONDS) via reset_daily_if_neededBased on daily_last_reset, not wall-clock days
Fund sourceMandate-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_pull validates 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 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.